diff --git a/.codespellignore b/.codespellignore new file mode 100644 index 0000000000..ab63c0cd29 --- /dev/null +++ b/.codespellignore @@ -0,0 +1,6 @@ +Wen +REGIST +PullRequest +cancelled +FOF +NoO diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000..e8f1b7eb8b --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,11 @@ +* @sunnypilot/dev-internal +/.github/ @devtekve @sunnyhaibin +/release/ci/ @devtekve @sunnyhaibin +/tinygrad_repo @devtekve @Discountchubbs +/tinygrad/ @devtekve @Discountchubbs +/selfdrive/controls/lib/longitudinal_planner.py @devtekve @Discountchubbs +/selfdrive/controls/lib/longitudinal_mpc_lib/long_mpc.py @devtekve @Discountchubbs +/selfdrive/modeld/ @devtekve @Discountchubbs +/sunnypilot/model* @devtekve @Discountchubbs +/sunnypilot/sunnylink/ @devtekve +/system/athena/ @devtekve \ No newline at end of file diff --git a/.github/labeler.yaml b/.github/labeler.yaml index 63d41d5b73..711f4597bd 100644 --- a/.github/labeler.yaml +++ b/.github/labeler.yaml @@ -1,7 +1,11 @@ -CI / testing: +ci: - changed-files: - any-glob-to-all-files: "{.github/**,**/test_*,**/test/**,Jenkinsfile}" +chore: + - changed-files: + - any-glob-to-all-files: "{.github/**}" + car: - changed-files: - any-glob-to-all-files: '{selfdrive/car/**,opendbc_repo}' @@ -24,4 +28,4 @@ multilanguage: autonomy: - changed-files: - - any-glob-to-all-files: "{selfdrive/modeld/models/**,selfdrive/test/process_replay/model_replay_ref_commit}" + - any-glob-to-all-files: "{selfdrive/modeld/models/**,selfdrive/test/process_replay/model_replay_ref_commit,sunnypilot/modeld*/models/**}" diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000000..a868ebb11c --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,43 @@ +exclude-labels: + - 'no-changelog' +categories: + - title: '🚀 Features' + labels: + - 'feature' + - 'enhancement' + - title: '🐛 Bug Fixes' + collapse-after: 5 + labels: + - 'fix' + - 'bugfix' + - 'bug' + - title: '🧰 Maintenance' + collapse-after: 5 + label: 'chore' +change-template: '- $TITLE @$AUTHOR (#$NUMBER)' +change-title-escapes: '\<*_&' +replacers: + - search: '/[Ss][Uu][Nn][Nn][Yy][Pp][Ii][Ll][Oo][Tt]/g' + replace: 'sunnypilot' + - search: '/\b[Ss][Pp]\b/g' + replace: 'SP' +version-resolver: + major: + labels: + - 'major' + minor: + labels: + - 'minor' + patch: + labels: + - 'patch' + default: patch +name-template: 'v$RESOLVED_VERSION 🚀' +tag-template: 'v$RESOLVED_VERSION' +version-template: "0.$MAJOR.$MINOR.$PATCH" # The day OP becomes v1, we need to bump this +tag-prefix: "v0." # The day OP becomes v1, we need to bump this +prerelease-identifier: "staging" +template: | + ## Changes + + $CHANGES diff --git a/.github/workflows/auto_pr_review.yaml b/.github/workflows/auto_pr_review.yaml index 99c3a258c6..edb058d7d1 100644 --- a/.github/workflows/auto_pr_review.yaml +++ b/.github/workflows/auto_pr_review.yaml @@ -1,7 +1,7 @@ name: "PR review" on: pull_request_target: - types: [opened, reopened, synchronize, edited] + types: [ opened, reopened, synchronize, edited ] jobs: labeler: @@ -9,6 +9,7 @@ jobs: permissions: contents: read pull-requests: write + issues: write runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -24,12 +25,67 @@ jobs: # Check PR target branch - name: check branch uses: Vankka/pr-target-branch-action@def32ec9d93514138d6ac0132ee62e120a72aed5 - if: github.repository == 'commaai/openpilot' + if: github.repository == 'sunnypilot/sunnypilot' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: target: /^(?!master$).*/ - exclude: /commaai:.*/ + exclude: /sunnypilot:.*/ change-to: ${{ github.base_ref }} already-exists-action: close_this already-exists-comment: "Your PR should be made against the `master` branch" + + update-pr-labels: + name: Update fork's PR Labels + runs-on: ubuntu-latest + if: (github.event.pull_request.head.repo.fork && (contains(github.event_name, 'pull_request') && github.event.action == 'synchronize')) + env: + PR_LABEL: 'dev' + TRUST_FORK_PR_LABEL: 'trust-fork-pr' + steps: + - name: Check if PR has dev label + id: check-labels + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const prNumber = context.payload.pull_request.number; + const { data: labels } = await github.rest.issues.listLabelsOnIssue({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber + }); + + const hasDevC3Label = labels.some(label => label.name === process.env.PR_LABEL); + const hasTrustLabel = labels.some(label => label.name === process.env.TRUST_FORK_PR_LABEL); + + console.log(`PR #${prNumber} has ${process.env.PR_LABEL} label: ${hasDevC3Label}`); + console.log(`PR #${prNumber} has ${process.env.TRUST_FORK_PR_LABEL} label: ${hasTrustLabel}`); + + core.setOutput('has-dev', hasDevC3Label ? 'true' : 'false'); + core.setOutput('has-trust', hasTrustLabel ? 'true' : 'false'); + + - name: Remove trust-fork-pr label if present + if: steps.check-labels.outputs.has-dev == 'true' && steps.check-labels.outputs.has-trust == 'true' + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const prNumber = context.payload.pull_request.number; + + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + name: process.env.TRUST_FORK_PR_LABEL + }); + + console.log(`Removed '${process.env.TRUST_FORK_PR_LABEL}' label from PR #${prNumber} as it received new commits`); + + // Add a comment to the PR + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: `The \`${process.env.TRUST_FORK_PR_LABEL}\` label has been automatically removed because new commits were pushed to this PR. This PR will need to be re-reviewed before the label can be applied again.` + }); diff --git a/.github/workflows/badges.yaml b/.github/workflows/badges.yaml index 9b99c4f1fe..d170a96368 100644 --- a/.github/workflows/badges.yaml +++ b/.github/workflows/badges.yaml @@ -11,7 +11,7 @@ jobs: badges: name: create badges runs-on: ubuntu-latest - if: github.repository == 'commaai/openpilot' + if: github.repository == 'sunnypilot/sunnypilot' permissions: contents: write steps: @@ -27,7 +27,7 @@ jobs: git checkout --orphan badges git rm -rf --cached . - git config user.email "badge-researcher@comma.ai" + git config user.email "badge-researcher@sunnypilot.ai" git config user.name "Badge Researcher" git add translation_badge.svg diff --git a/.github/workflows/build-all-tinygrad-models.yaml b/.github/workflows/build-all-tinygrad-models.yaml new file mode 100644 index 0000000000..412676e5fd --- /dev/null +++ b/.github/workflows/build-all-tinygrad-models.yaml @@ -0,0 +1,304 @@ +name: Build and push all tinygrad models + +on: + workflow_dispatch: + inputs: + set_min_version: + description: 'Minimum selector version required for the models (see helpers.py or readme.md)' + required: true + type: string + +jobs: + setup: + runs-on: ubuntu-latest + outputs: + json_version: ${{ steps.get-json.outputs.json_version }} + recompiled_dir: ${{ steps.create-recompiled-dir.outputs.recompiled_dir }} + json_file: ${{ steps.get-json.outputs.json_file }} + model_matrix: ${{ steps.set-matrix.outputs.model_matrix }} + tinygrad_ref: ${{ steps.get-tinygrad-ref.outputs.tinygrad_ref }} + steps: + - name: Checkout sunnypilot repo + uses: actions/checkout@v4 + with: + repository: sunnypilot/sunnypilot + path: sunnypilot + submodules: recursive + + - name: Get tinygrad_repo ref + id: get-tinygrad-ref + run: | + cd sunnypilot + export PYTHONPATH=$(pwd) + ref=$(python3 sunnypilot/models/tinygrad_ref.py) + echo "tinygrad_ref=$ref" >> $GITHUB_OUTPUT + echo "tinygrad_ref is $ref" + + - name: Checkout docs repo (sunnypilot-models, gh-pages) + uses: actions/checkout@v4 + with: + repository: sunnypilot/sunnypilot-models + ref: gh-pages + path: docs + ssh-key: ${{ secrets.CI_SUNNYPILOT_DOCS_PRIVATE_KEY }} + + - name: Get next JSON version to use (from GitHub docs repo) + id: get-json + run: | + cd docs/docs + latest=$(ls driving_models_v*.json | sed -E 's/.*_v([0-9]+)\.json/\1/' | sort -n | tail -1) + next=$((latest+1)) + json_file="driving_models_v${next}.json" + cp "driving_models_v${latest}.json" "$json_file" + echo "json_file=docs/docs/$json_file" >> $GITHUB_OUTPUT + echo "json_version=$((next+0))" >> $GITHUB_OUTPUT + echo "SRC_JSON_FILE=docs/docs/driving_models_v${latest}.json" >> $GITHUB_ENV + + - name: Extract tinygrad models + id: set-matrix + working-directory: docs/docs + run: | + jq -c '[.bundles[] | select(.runner=="tinygrad") | {ref, display_name: (.display_name | gsub(" \\([^)]*\\)"; "")), is_20hz}]' "$(basename "${SRC_JSON_FILE}")" > matrix.json + echo "model_matrix=$(cat matrix.json)" >> $GITHUB_OUTPUT + + - name: Set up SSH + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.GITLAB_SSH_PRIVATE_KEY }} + - run: | + mkdir -p ~/.ssh + ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts + + - name: Clone GitLab docs repo and create new recompiled dir + id: create-recompiled-dir + env: + GIT_SSH_COMMAND: 'ssh -o UserKnownHostsFile=~/.ssh/known_hosts' + run: | + git clone --depth 1 --filter=tree:0 --sparse git@gitlab.com:sunnypilot/public/${{ vars.MODELS_GITLAB }} gitlab_docs + cd gitlab_docs + git checkout main + git sparse-checkout set --no-cone models/ + cd models + latest_dir=$(ls -d recompiled* 2>/dev/null | sed -E 's/recompiled([0-9]+)/\1/' | sort -n | tail -1) + if [[ -z "$latest_dir" ]]; then + next_dir=1 + else + next_dir=$((latest_dir+1)) + fi + recompiled_dir="${next_dir}" + mkdir -p "recompiled${recompiled_dir}" + touch "recompiled${recompiled_dir}/.gitkeep" + cd ../.. + echo "recompiled_dir=$recompiled_dir" >> $GITHUB_OUTPUT + + - name: Push empty recompiled dir to GitLab + run: | + cd gitlab_docs + git add models/recompiled${{ steps.create-recompiled-dir.outputs.recompiled_dir }} + git config --global user.name "GitHub Action" + git config --global user.email "action@github.com" + git commit -m "Add recompiled${{ steps.create-recompiled-dir.outputs.recompiled_dir }} for build-all" || echo "No changes to commit" + git push origin main + + - name: Push new JSON to GitHub docs repo + run: | + cd docs + git pull origin gh-pages + git add docs/"$(basename ${{ steps.get-json.outputs.json_file }})" + git config --global user.name "GitHub Action" + git config --global user.email "action@github.com" + git commit -m "Add new ${{ steps.get-json.outputs.json_file }} for build-all" || echo "No changes to commit" + git push origin gh-pages + + get_and_build: + needs: [setup] + strategy: + matrix: + model: ${{ fromJson(needs.setup.outputs.model_matrix) }} + fail-fast: false + uses: ./.github/workflows/build-single-tinygrad-model.yaml + with: + upstream_branch: ${{ matrix.model.ref }} + custom_name: ${{ matrix.model.display_name }} + recompiled_dir: ${{ needs.setup.outputs.recompiled_dir }} + json_version: ${{ needs.setup.outputs.json_version }} + secrets: inherit + + retry_failed_models: + needs: [setup, get_and_build] + runs-on: ubuntu-latest + if: ${{ needs.setup.result != 'failure' && !cancelled() }} + outputs: + retry_matrix: ${{ steps.set-retry-matrix.outputs.retry_matrix }} + steps: + - uses: actions/download-artifact@v4 + with: + pattern: model-* + path: output + + - id: set-retry-matrix + run: | + echo '${{ needs.setup.outputs.model_matrix }}' > matrix.json + built=(); while IFS= read -r line; do built+=("$line"); done < <( + find output -maxdepth 1 -name 'model-*' -printf "%f\n" | sed -E 's/^model-//' | sed -E 's/-[0-9]+$//' | sed -E 's/ \([^)]*\)//' | awk '{gsub(/^ +| +$/, ""); print}' + ) + jq -c --argjson built "$(printf '%s\n' "${built[@]}" | jq -R . | jq -s .)" \ + 'map(select(.display_name as $n | ($built | index($n | gsub("^ +| +$"; "")) | not)))' matrix.json > retry_matrix.json + echo "retry_matrix=$(cat retry_matrix.json)" >> $GITHUB_OUTPUT + + retry_get_and_build: + needs: [setup, get_and_build, retry_failed_models] + if: ${{ needs.get_and_build.result == 'failure' || (needs.retry_failed_models.outputs.retry_matrix != '[]' && needs.retry_failed_models.outputs.retry_matrix != '') }} + strategy: + matrix: + model: ${{ fromJson(needs.retry_failed_models.outputs.retry_matrix) }} + fail-fast: false + uses: ./.github/workflows/build-single-tinygrad-model.yaml + with: + upstream_branch: ${{ matrix.model.ref }} + custom_name: ${{ matrix.model.display_name }} + recompiled_dir: ${{ needs.setup.outputs.recompiled_dir }} + json_version: ${{ needs.setup.outputs.json_version }} + artifact_suffix: -retry + secrets: inherit + + publish_models: + name: Publish models sequentially + needs: [setup, get_and_build, retry_failed_models, retry_get_and_build] + if: ${{ !cancelled() && (needs.get_and_build.result != 'failure' || needs.retry_get_and_build.result == 'success' || (needs.retry_failed_models.outputs.retry_matrix != '[]' && needs.retry_failed_models.outputs.retry_matrix != '')) }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + max-parallel: 1 + matrix: + model: ${{ fromJson(needs.setup.outputs.model_matrix) }} + env: + RECOMPILED_DIR: recompiled${{ needs.setup.outputs.recompiled_dir }} + JSON_FILE: ${{ needs.setup.outputs.json_file }} + ARTIFACT_NAME_INPUT: ${{ matrix.model.display_name }} + steps: + - name: Set up SSH + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.GITLAB_SSH_PRIVATE_KEY }} + + - name: Add GitLab.com SSH key to known_hosts + run: | + mkdir -p ~/.ssh + ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts + + - name: Clone GitLab docs repo + env: + GIT_SSH_COMMAND: 'ssh -o UserKnownHostsFile=~/.ssh/known_hosts' + run: | + echo "Cloning GitLab" + git clone --depth 1 --filter=tree:0 --sparse git@gitlab.com:sunnypilot/public/${{ vars.MODELS_GITLAB }} gitlab_docs + cd gitlab_docs + echo "checkout models/${RECOMPILED_DIR}" + git sparse-checkout set --no-cone models/${RECOMPILED_DIR} + git checkout main + cd .. + + - name: Checkout docs repo + uses: actions/checkout@v4 + with: + repository: sunnypilot/sunnypilot-models + ref: gh-pages + path: docs + ssh-key: ${{ secrets.CI_SUNNYPILOT_DOCS_PRIVATE_KEY }} + + - name: Validate recompiled dir and JSON version + run: | + if [ ! -d "gitlab_docs/models/$RECOMPILED_DIR" ]; then + echo "Recompiled dir $RECOMPILED_DIR does not exist in GitLab repo" + exit 1 + fi + if [ ! -f "$JSON_FILE" ]; then + echo "JSON file $JSON_FILE does not exist!" + exit 1 + fi + + - name: Download artifact name file + uses: actions/download-artifact@v4 + with: + name: artifact-name-${{ env.ARTIFACT_NAME_INPUT }} + path: artifact_name + + - name: Read artifact name + id: read-artifact-name + run: | + ARTIFACT_NAME=$(cat artifact_name/artifact_name.txt) + echo "artifact_name=$ARTIFACT_NAME" >> $GITHUB_OUTPUT + + - name: Download model artifact + uses: actions/download-artifact@v4 + with: + name: ${{ steps.read-artifact-name.outputs.artifact_name }} + path: output + + - name: Remove onnx files bc not needed for recompiled dir since they already exist from single build + run: | + find output -type f -name '*.onnx' -delete + find output -type f -name 'big_*.pkl' -delete + find output -type f -name 'dmonitoring_model_tinygrad.pkl' -delete + + - name: Copy model artifacts to gitlab + env: + ARTIFACT_NAME: ${{ steps.read-artifact-name.outputs.artifact_name }} + run: | + ARTIFACT_DIR="gitlab_docs/models/${RECOMPILED_DIR}/${ARTIFACT_NAME}" + mkdir -p "$ARTIFACT_DIR" + for path in output/*; do + if [ "$(basename "$path")" = "artifact_name.txt" ]; then + continue + fi + name="$(basename "$path")" + if [ -d "$path" ]; then + mkdir -p "$ARTIFACT_DIR/$name" + cp -r "$path"/* "$ARTIFACT_DIR/$name/" + echo "Copied dir $name -> $ARTIFACT_DIR/$name" + else + cp "$path" "$ARTIFACT_DIR/" + echo "Copied file $name -> $ARTIFACT_DIR/" + fi + done + + - name: Push recompiled dir to GitLab + env: + GITLAB_SSH_PRIVATE_KEY: ${{ secrets.GITLAB_SSH_PRIVATE_KEY }} + run: | + cd gitlab_docs + git checkout main + git pull origin main + for d in models/"$RECOMPILED_DIR"/*/; do + git sparse-checkout add "$d" + done + git add models/"$RECOMPILED_DIR" + git config --global user.name "GitHub Action" + git config --global user.email "action@github.com" + git commit -m "Update $RECOMPILED_DIR with model from build-all-tinygrad-models" || echo "No changes to commit" + git push origin main + - run: | + cd docs + git pull origin gh-pages + + - name: update json + run: | + ARGS="" + [ -n "${{ inputs.set_min_version }}" ] && ARGS="$ARGS --set-min-version \"${{ inputs.set_min_version }}\"" + ARGS="$ARGS --sort-by-date" + ARGS="$ARGS --tinygrad-ref \"${{ needs.setup.outputs.tinygrad_ref }}\"" + eval python3 docs/json_parser.py \ + --json-path "$JSON_FILE" \ + --recompiled-dir "gitlab_docs/models/$RECOMPILED_DIR" \ + $ARGS + + - name: Push updated json to GitHub + run: | + cd docs + git config --global user.name "GitHub Action" + git config --global user.email "action@github.com" + git checkout gh-pages + git add docs/"$(basename $JSON_FILE)" + git commit -m "Update $(basename $JSON_FILE) after recompiling model" || echo "No changes to commit" + git push origin gh-pages diff --git a/.github/workflows/build-single-tinygrad-model.yaml b/.github/workflows/build-single-tinygrad-model.yaml new file mode 100644 index 0000000000..e7e3b67b51 --- /dev/null +++ b/.github/workflows/build-single-tinygrad-model.yaml @@ -0,0 +1,228 @@ +name: Build Single Tinygrad Model and Push + +on: + workflow_call: + inputs: + upstream_branch: + description: 'Upstream commit to build from' + required: true + type: string + custom_name: + description: 'Custom name for the model (no date, only name)' + required: false + type: string + recompiled_dir: + description: 'Existing recompiled directory number (e.g. 3 for recompiled3)' + required: true + type: string + json_version: + description: 'driving_models version number to update (e.g. 5 for driving_models_v5.json)' + required: true + type: string + artifact_suffix: + description: 'Suffix for artifact name' + required: false + type: string + default: '' + bypass_push: + description: 'Bypass pushing to GitLab for build-all' + required: false + default: true + type: boolean + workflow_dispatch: + inputs: + upstream_branch: + description: 'Upstream commit to build from' + required: true + type: string + custom_name: + description: 'Custom name for the model (no date, only name)' + required: false + type: string + recompiled_dir: + description: 'Existing recompiled directory number (e.g. 3 for recompiled3)' + required: true + type: string + json_version: + description: 'driving_models version number to update (e.g. 5 for driving_models_v5.json)' + required: true + type: string + model_folder: + description: 'Model folder' + type: choice + default: 'None' + options: + - None + - Simple Plan Models + - Space Lab Models + - TR Models + - DTR Models + - Custom Merge Models + - FOF series models + - Other + custom_model_folder: + description: 'Custom model folder name (if "Other" selected)' + required: false + type: string + generation: + description: 'Model generation' + required: false + type: string + version: + description: 'Minimum selector version' + required: false + type: string +env: + RECOMPILED_DIR: recompiled${{ inputs.recompiled_dir }} + JSON_FILE: docs/docs/driving_models_v${{ inputs.json_version }}.json + +jobs: + build_model: + uses: ./.github/workflows/sunnypilot-build-model.yaml + with: + upstream_branch: ${{ inputs.upstream_branch }} + custom_name: ${{ inputs.custom_name || inputs.upstream_branch }} + is_20hz: true + artifact_suffix: ${{ inputs.artifact_suffix }} + secrets: inherit + + publish_model: + if: ${{ !inputs.bypass_push && !cancelled() }} + concurrency: + group: gitlab-push-${{ inputs.recompiled_dir }} + cancel-in-progress: false + needs: build_model + runs-on: ubuntu-latest + steps: + - name: Set up SSH + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.GITLAB_SSH_PRIVATE_KEY }} + + - name: Add GitLab.com SSH key to known_hosts + run: | + mkdir -p ~/.ssh + ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts + + - name: Clone GitLab docs repo + env: + GIT_SSH_COMMAND: 'ssh -o UserKnownHostsFile=~/.ssh/known_hosts' + run: | + echo "Cloning GitLab" + git clone --depth 1 --filter=tree:0 --sparse git@gitlab.com:sunnypilot/public/${{ vars.MODELS_GITLAB }} gitlab_docs + cd gitlab_docs + echo "checkout models/${RECOMPILED_DIR}" + git sparse-checkout set --no-cone models/${RECOMPILED_DIR} + git checkout main + cd .. + + - name: Checkout docs repo + uses: actions/checkout@v4 + with: + repository: sunnypilot/sunnypilot-models + ref: gh-pages + path: docs + ssh-key: ${{ secrets.CI_SUNNYPILOT_DOCS_PRIVATE_KEY }} + + - name: Validate recompiled dir and JSON version + run: | + if [ ! -d "gitlab_docs/models/$RECOMPILED_DIR" ]; then + echo "Recompiled dir $RECOMPILED_DIR does not exist in GitLab repo" + exit 1 + fi + if [ ! -f "$JSON_FILE" ]; then + echo "JSON file $JSON_FILE does not exist!" + exit 1 + fi + + - name: Download artifact name file + uses: actions/download-artifact@v4 + with: + name: artifact-name-${{ inputs.custom_name || inputs.upstream_branch }} + path: artifact_name + + - name: Read artifact name + id: read-artifact-name + run: | + ARTIFACT_NAME=$(cat artifact_name/artifact_name.txt) + echo "artifact_name=$ARTIFACT_NAME" >> $GITHUB_OUTPUT + + - name: Download and extract model artifact + uses: actions/download-artifact@v4 + with: + name: ${{ steps.read-artifact-name.outputs.artifact_name }} + path: output + + - name: Remove unwanted files + run: | + find output -type f -name 'dmonitoring_model_tinygrad.pkl' -delete + find output -type f -name 'dmonitoring_model.onnx' -delete + + - name: Copy model artifact(s) to GitLab recompiled dir + env: + ARTIFACT_NAME: ${{ steps.read-artifact-name.outputs.artifact_name }} + run: | + ARTIFACT_DIR="gitlab_docs/models/${RECOMPILED_DIR}/${ARTIFACT_NAME}" + mkdir -p "$ARTIFACT_DIR" + for path in output/*; do + if [ "$(basename "$path")" = "artifact_name.txt" ]; then + continue + fi + name="$(basename "$path")" + if [ -d "$path" ]; then + mkdir -p "$ARTIFACT_DIR/$name" + cp -r "$path"/* "$ARTIFACT_DIR/$name/" + echo "Copied dir $name -> $ARTIFACT_DIR/$name" + else + cp "$path" "$ARTIFACT_DIR/" + echo "Copied file $name -> $ARTIFACT_DIR/" + fi + done + + - name: Push recompiled dir to GitLab + env: + GITLAB_SSH_PRIVATE_KEY: ${{ secrets.GITLAB_SSH_PRIVATE_KEY }} + run: | + cd gitlab_docs + git checkout main + git pull origin main + for d in models/"$RECOMPILED_DIR"/*/; do + git sparse-checkout add "$d" + done + git add models/"$RECOMPILED_DIR" + git config --global user.name "GitHub Action" + git config --global user.email "action@github.com" + git commit -m "Create/Update $RECOMPILED_DIR with new/updated model from build-single-tinygrad-model" || echo "No changes to commit" + git push origin main + + - run: | + cd docs + git pull origin gh-pages + + - name: Run json_parser.py to update JSON + run: | + FOLDER="${{ inputs.model_folder }}" + if [ "$FOLDER" = "Other" ]; then + FOLDER="${{ inputs.custom_model_folder }}" + fi + ARGS="" + if [ "$FOLDER" != "None" ] && [ -n "$FOLDER" ]; then + ARGS="$ARGS --model-folder \"$FOLDER\"" + fi + [ -n "${{ inputs.generation }}" ] && ARGS="$ARGS --generation \"${{ inputs.generation }}\"" + [ -n "${{ inputs.version }}" ] && ARGS="$ARGS --version \"${{ inputs.version }}\"" + eval python3 docs/json_parser.py \ + --json-path "$JSON_FILE" \ + --recompiled-dir "gitlab_docs/models/$RECOMPILED_DIR" \ + --sort-by-date \ + $ARGS + + - name: Push updated JSON to GitHub docs repo + run: | + cd docs + git config --global user.name "GitHub Action" + git config --global user.email "action@github.com" + git checkout gh-pages + git add docs/"$(basename $JSON_FILE)" + git commit -m "Update $(basename $JSON_FILE) after recompiling model" || echo "No changes to commit" + git push origin gh-pages diff --git a/.github/workflows/cereal_validation.yaml b/.github/workflows/cereal_validation.yaml new file mode 100644 index 0000000000..3a864ebefb --- /dev/null +++ b/.github/workflows/cereal_validation.yaml @@ -0,0 +1,78 @@ +name: cereal validation + +on: + push: + branches: + - master + pull_request: + paths: + - 'cereal/**' + workflow_dispatch: + workflow_call: + inputs: + run_number: + default: '1' + required: true + type: string + +concurrency: + group: cereal-validation-ci-run-${{ inputs.run_number }}-${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && github.run_id || github.head_ref || github.ref }}-${{ github.workflow }}-${{ github.event_name }} + cancel-in-progress: true + +env: + CI: 1 + +jobs: + generate_cereal_artifact: + name: Generate cereal validation artifacts + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + with: + submodules: true + - run: ./tools/op.sh setup + - name: Build openpilot + run: scons -j$(nproc) cereal + - name: Generate the log file + run: | + export PYTHONPATH=${{ github.workspace }} + python3 cereal/messaging/tests/validate_sp_cereal_upstream.py -g -f schema_instances.bin + - name: 'Prepare artifact' + run: | + mkdir -p "cereal/messaging/tests/cereal_validations" + cp cereal/messaging/tests/validate_sp_cereal_upstream.py "cereal/messaging/tests/cereal_validations/validate_sp_cereal_upstream.py" + cp schema_instances.bin "cereal/messaging/tests/cereal_validations/schema_instances.bin" + - name: 'Upload Artifact' + uses: actions/upload-artifact@v4 + with: + name: cereal_validations + path: cereal/messaging/tests/cereal_validations + + validate_cereal_with_upstream: + name: Validate cereal with Upstream + runs-on: ubuntu-24.04 + needs: generate_cereal_artifact + steps: + - name: Checkout sunnypilot + uses: actions/checkout@v6 + - name: Checkout upstream openpilot + uses: actions/checkout@v6 + with: + repository: 'commaai/openpilot' + path: openpilot + submodules: true + ref: "refs/heads/master" + - run: ./tools/op.sh setup + - name: Build openpilot + working-directory: openpilot + run: scons -j$(nproc) cereal + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: cereal_validations + path: openpilot/cereal/messaging/tests/cereal_validations + - name: 'Run the validation' + run: | + export PYTHONPATH=${{ github.workspace }}/openpilot + chmod +x openpilot/cereal/messaging/tests/cereal_validations/validate_sp_cereal_upstream.py + python3 openpilot/cereal/messaging/tests/cereal_validations/validate_sp_cereal_upstream.py -r -f openpilot/cereal/messaging/tests/cereal_validations/schema_instances.bin diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 23a89de1c1..27d36f9a4e 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -35,13 +35,13 @@ jobs: # Push to docs.comma.ai - uses: actions/checkout@v6 - if: github.ref == 'refs/heads/master' && github.repository == 'commaai/openpilot' + if: github.ref == 'refs/heads/master' && github.repository == 'sunnypilot/sunnypilot' with: path: openpilot-docs ssh-key: ${{ secrets.OPENPILOT_DOCS_KEY }} - repository: commaai/openpilot-docs + repository: sunnypilot/sunnypilot-docs - name: Push - if: github.ref == 'refs/heads/master' && github.repository == 'commaai/openpilot' + if: github.ref == 'refs/heads/master' && github.repository == 'sunnypilot/sunnypilot' run: | set -x diff --git a/.github/workflows/lfs-maintenance.yaml b/.github/workflows/lfs-maintenance.yaml new file mode 100644 index 0000000000..8780abfbb3 --- /dev/null +++ b/.github/workflows/lfs-maintenance.yaml @@ -0,0 +1,72 @@ +name: Sync comma's LFS + +env: + LFS_URL: 'https://gitlab.com/sunnypilot/public/sunnypilot-new-lfs.git/info/lfs' + LFS_PUSH_URL: 'ssh://git@gitlab.com/sunnypilot/public/sunnypilot-new-lfs.git' + +on: + schedule: + - cron: '0 0 * * *' # Runs at 00:00 UTC every day + push: + branches: + - 'master' + pull_request: + branches: + - 'master' + workflow_dispatch: # enables manual triggering + inputs: + upstream_branch: + default: 'master' + type: string + +jobs: + sync: + runs-on: ubuntu-latest + # Skip if PR is in draft mode + if: (github.event_name != 'pull_request' || (github.event_name == 'pull_request' && github.event.pull_request.draft == false)) && !github.event.pull_request.head.repo.fork + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + with: + repository: 'commaai/openpilot' + ref: ${{ inputs.upstream_branch }} + + - name: LFS Fetch + run: | + git lfs fetch + + - name: Set up Git + run: | + git config --global user.name 'GitHub Action' + git config --global user.email 'action@github.com' + + - name: Set up SSH + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} + + - name: Add GitLab public keys + run: | + ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts + + - name: Ensure branch + run: | + if git symbolic-ref -q HEAD >/dev/null; then + echo "Already on a branch, proceeding with push" + else + echo "Detached HEAD state detected, creating temporary branch" + git checkout -b temp_branch + fi + + - name: Update LFS Config + run: | + echo '[lfs]' > .lfsconfig + echo ' url = ${{ env.LFS_URL }}' >> .lfsconfig + echo ' pushurl = ${{ env.LFS_PUSH_URL }}' >> .lfsconfig + echo ' locksverify = false' >> .lfsconfig + + - name: Push LFS + id: sync-and-commit + run: | + git lfs ls-files -l + git lfs push --all origin \ No newline at end of file diff --git a/.github/workflows/post-to-discourse/action.yml b/.github/workflows/post-to-discourse/action.yml new file mode 100644 index 0000000000..55232ce0e1 --- /dev/null +++ b/.github/workflows/post-to-discourse/action.yml @@ -0,0 +1,105 @@ +name: 'Post to Discourse' +description: 'Posts a message to a Discourse topic (existing or new)' + +inputs: + discourse-url: + description: 'Discourse instance URL (e.g., https://discourse.example.com)' + required: true + api-key: + description: 'Discourse API key' + required: true + api-username: + description: 'Discourse API username' + required: true + topic-id: + description: 'Discourse topic ID to post to (use this OR category-id + title)' + required: false + category-id: + description: 'Category ID for new topic (required if topic-id not provided)' + required: false + title: + description: 'Title for new topic (required if topic-id not provided)' + required: false + message: + description: 'Message content (markdown supported)' + required: true + +outputs: + post-number: + description: 'The post number in the topic' + value: ${{ steps.post.outputs.post_number }} + post-url: + description: 'Direct URL to the post' + value: ${{ steps.post.outputs.post_url }} + topic-id: + description: 'The topic ID (useful when creating a new topic)' + value: ${{ steps.post.outputs.topic_id }} + +runs: + using: "composite" + steps: + - name: Post to Discourse + id: post + shell: bash + run: | + # Validate inputs + if [ -z "${{ inputs.topic-id }}" ] && ([ -z "${{ inputs.category-id }}" ] || [ -z "${{ inputs.title }}" ]); then + echo "❌ Error: Must provide either topic-id OR both category-id and title" + exit 1 + fi + + if [ -n "${{ inputs.topic-id }}" ] && ([ -n "${{ inputs.category-id }}" ] || [ -n "${{ inputs.title }}" ]); then + echo "⚠️ Warning: Both topic-id and category-id/title provided. Will post to existing topic." + fi + + # Determine if creating new topic or posting to existing + if [ -n "${{ inputs.topic-id }}" ]; then + echo "📝 Posting to existing topic ID: ${{ inputs.topic-id }}" + + # Create JSON payload for posting to existing topic + PAYLOAD=$(jq -n \ + --arg content '${{ inputs.message }}' \ + --arg topic_id "${{ inputs.topic-id }}" \ + '{topic_id: $topic_id, raw: $content}') + else + echo "✨ Creating new topic: ${{ inputs.title }}" + + # Create JSON payload for new topic + PAYLOAD=$(jq -n \ + --arg content '${{ inputs.message }}' \ + --arg title "${{ inputs.title }}" \ + --arg category "${{ inputs.category-id }}" \ + '{title: $title, category: ($category | tonumber), raw: $content}') + fi + + # Post to Discourse + RESPONSE=$(curl -s -w "\n%{http_code}" \ + -X POST "${{ inputs.discourse-url }}/posts.json" \ + -H "Content-Type: application/json" \ + -H "Api-Key: ${{ inputs.api-key }}" \ + -H "Api-Username: ${{ inputs.api-username }}" \ + -d "$PAYLOAD") + + HTTP_CODE=$(echo "$RESPONSE" | tail -n1) + BODY=$(echo "$RESPONSE" | sed '$d') + + if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then + echo "✅ Successfully posted to Discourse!" + + POST_NUMBER=$(echo "$BODY" | jq -r '.post_number // "unknown"') + TOPIC_ID=$(echo "$BODY" | jq -r '.topic_id // "${{ inputs.topic-id }}"') + POST_URL="${{ inputs.discourse-url }}/t/${TOPIC_ID}/${POST_NUMBER}" + + echo "post_number=${POST_NUMBER}" >> $GITHUB_OUTPUT + echo "post_url=${POST_URL}" >> $GITHUB_OUTPUT + echo "topic_id=${TOPIC_ID}" >> $GITHUB_OUTPUT + + echo "Topic ID: ${TOPIC_ID}" + echo "Post number: ${POST_NUMBER}" + echo "URL: ${POST_URL}" + else + echo "❌ Failed to post to Discourse" + echo "HTTP Code: ${HTTP_CODE}" + echo "Response: ${BODY}" + exit 1 + fi \ No newline at end of file diff --git a/.github/workflows/prebuilt.yaml b/.github/workflows/prebuilt.yaml index ecf1e8503a..aeb0f11d84 100644 --- a/.github/workflows/prebuilt.yaml +++ b/.github/workflows/prebuilt.yaml @@ -6,13 +6,13 @@ on: env: DOCKER_LOGIN: docker login ghcr.io -u ${{ github.actor }} -p ${{ secrets.GITHUB_TOKEN }} - BUILD: selfdrive/test/docker_build.sh + BUILD: release/ci/docker_build_sp.sh jobs: build_prebuilt: name: build prebuilt runs-on: ubuntu-latest - if: github.repository == 'commaai/openpilot' + if: github.repository == 'sunnypilot/sunnypilot' env: PUSH_IMAGE: true permissions: diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml new file mode 100644 index 0000000000..c072e98e24 --- /dev/null +++ b/.github/workflows/release-drafter.yml @@ -0,0 +1,28 @@ +name: Release Drafter + +on: + push: + branches: + - master + tags: + - 'v*' + pull_request_target: + types: [opened, reopened, synchronize] + workflow_dispatch: + +permissions: + contents: read + +jobs: + update_release_draft: + permissions: + contents: write + pull-requests: write + runs-on: ubuntu-latest + steps: + - uses: release-drafter/release-drafter@v6 + with: + config-name: release-drafter.yml + prerelease: ${{ !startsWith(github.ref, 'refs/tags/v') }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index a90f064b82..6ae5336557 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -5,10 +5,10 @@ on: workflow_dispatch: jobs: - build_masterci: - name: build master-ci + build___nightly: + name: build __nightly runs-on: ubuntu-latest - if: github.repository == 'commaai/openpilot' + if: github.repository == 'sunnypilot/sunnypilot' permissions: checks: read contents: write @@ -19,7 +19,7 @@ jobs: with: ref: master wait-interval: 30 - running-workflow-name: 'build master-ci' + running-workflow-name: 'build __nightly' repo-token: ${{ secrets.GITHUB_TOKEN }} check-regexp: ^((?!.*(build prebuilt|create badges).*).)*$ - uses: actions/checkout@v4 @@ -27,5 +27,5 @@ jobs: submodules: true fetch-depth: 0 - run: ./tools/op.sh setup - - name: Push master-ci + - name: Push __nightly run: BRANCH=__nightly release/build_stripped.sh diff --git a/.github/workflows/repo-maintenance.yaml b/.github/workflows/repo-maintenance.yaml index f829415f4e..0db4933649 100644 --- a/.github/workflows/repo-maintenance.yaml +++ b/.github/workflows/repo-maintenance.yaml @@ -12,7 +12,7 @@ jobs: package_updates: name: package_updates runs-on: ubuntu-latest - if: github.repository == 'commaai/openpilot' + if: github.repository == 'sunnypilot/sunnypilot' steps: - uses: actions/checkout@v6 with: @@ -42,6 +42,10 @@ jobs: echo 'EOF' >> $GITHUB_OUTPUT - name: bump submodules run: | + git config submodule.msgq.update none + git config submodule.rednose_repo.update none + git config submodule.teleoprtc_repo.update none + git config submodule.tinygrad.update none git submodule update --remote git add . - name: update car docs @@ -51,8 +55,8 @@ jobs: - name: Create Pull Request uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 with: - author: Vehicle Researcher - token: ${{ secrets.ACTIONS_CREATE_PR_PAT }} + author: github-actions[bot] + token: ${{ github.repository == 'commaai/openpilot' && secrets.ACTIONS_CREATE_PR_PAT || secrets.GITHUB_TOKEN }} commit-message: Update Python packages title: '[bot] Update Python packages' branch: auto-package-updates diff --git a/.github/workflows/stale.yaml b/.github/workflows/stale.yaml index cb7c0ac076..dab61bf3b4 100644 --- a/.github/workflows/stale.yaml +++ b/.github/workflows/stale.yaml @@ -21,7 +21,7 @@ jobs: stale-pr-message: 'This PR has had no activity for ${{ env.DAYS_BEFORE_PR_STALE }} days. It will be automatically closed in ${{ env.DAYS_BEFORE_PR_CLOSE }} days if there is no activity.' close-pr-message: 'This PR has been automatically closed due to inactivity. Feel free to re-open once activity resumes.' stale-pr-label: stale - delete-branch: ${{ github.event.pull_request.head.repo.full_name == 'commaai/openpilot' }} # only delete branches on the main repo + delete-branch: ${{ github.event.pull_request.head.repo.full_name == 'sunnypilot/sunnypilot' }} # only delete branches on the main repo exempt-pr-labels: "ignore stale,needs testing" # if wip or it needs testing from the community, don't mark as stale days-before-pr-stale: ${{ env.DAYS_BEFORE_PR_STALE }} days-before-pr-close: ${{ env.DAYS_BEFORE_PR_CLOSE }} diff --git a/.github/workflows/sunnypilot-build-model.yaml b/.github/workflows/sunnypilot-build-model.yaml new file mode 100644 index 0000000000..ff09489b92 --- /dev/null +++ b/.github/workflows/sunnypilot-build-model.yaml @@ -0,0 +1,229 @@ +name: Build Model from Upstream + +env: + BUILD_DIR: "/data/openpilot" + OUTPUT_DIR: ${{ github.workspace }}/output + SCONS_CACHE_DIR: ${{ github.workspace }}/release/ci/scons_cache + UPSTREAM_REPO: "commaai/openpilot" + TINYGRAD_PATH: ${{ github.workspace }}/tinygrad_repo + MODELS_DIR: ${{ github.workspace }}/selfdrive/modeld/models + +on: + workflow_call: + inputs: + upstream_branch: + description: 'Upstream branch to build from' + required: true + default: 'master' + type: string + custom_name: + description: 'Custom name for the model (no date, only name)' + required: false + type: string + is_20hz: + description: 'Is this a 20Hz model' + required: false + type: boolean + default: true + artifact_suffix: + description: 'Suffix for artifact name' + required: false + type: string + default: '' + workflow_dispatch: + inputs: + upstream_branch: + description: 'Upstream branch to build from' + required: true + default: 'master' + type: string + custom_name: + description: 'Custom name for the model (no date, only name)' + required: false + type: string + is_20hz: + description: 'Is this a 20Hz model' + required: false + type: boolean + default: true + + +run-name: Build model [${{ inputs.custom_name || inputs.upstream_branch }}] from ref [${{ inputs.upstream_branch }}] + +jobs: + get_model: + runs-on: ubuntu-latest + env: + REF: ${{ inputs.upstream_branch }} + outputs: + model_date: ${{ steps.commit-date.outputs.model_date }} + steps: + # Note: To allow dynamic models from both openpilot and sunnypilot (merges/mashups), we try commaai as default, + # and fallback to sunnypilot if the ref checkout fails. + - name: Checkout commaai/openpilot + id: checkout_upstream + continue-on-error: true + uses: actions/checkout@v4 + with: + repository: commaai/openpilot + ref: ${{ inputs.upstream_branch }} + submodules: recursive + path: openpilot + + - name: Fallback to sunnypilot/sunnypilot + if: steps.checkout_upstream.outcome == 'failure' + uses: actions/checkout@v4 + with: + repository: sunnypilot/sunnypilot + ref: ${{ inputs.upstream_branch }} + submodules: recursive + path: openpilot + - name: Get commit date + id: commit-date + run: | + cd ${{ github.workspace }}/openpilot + commit_date=$(git log -1 --format=%cd --date=format:'%B %d, %Y') + echo "model_date=${commit_date}" >> $GITHUB_OUTPUT + cat $GITHUB_OUTPUT + - run: | + cd ${{ github.workspace }}/openpilot + git lfs pull + - name: 'Upload Artifact' + uses: actions/upload-artifact@v4 + with: + name: models-${{ env.REF }}${{ inputs.artifact_suffix }} + path: ${{ github.workspace }}/openpilot/selfdrive/modeld/models/*.onnx + + build_model: + runs-on: [self-hosted, tici] + needs: get_model + env: + MODEL_NAME: ${{ inputs.custom_name || inputs.upstream_branch }} (${{ needs.get_model.outputs.model_date }}) + REF: ${{ inputs.upstream_branch }} + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - run: git lfs pull + - name: Cache SCons + uses: actions/cache@v4 + with: + path: ${{env.SCONS_CACHE_DIR}} + key: scons-${{ runner.os }}-${{ runner.arch }}-${{ github.head_ref || github.ref_name }}-model-${{ github.sha }} + # Note: GitHub Actions enforces cache isolation between different build sources (PR builds, workflow dispatches, etc.) + # for security. Only caches from the default branch are shared across all builds. This is by design and cannot be overridden. + restore-keys: | + scons-${{ runner.os }}-${{ runner.arch }}-${{ github.head_ref || github.ref_name }}-model + scons-${{ runner.os }}-${{ runner.arch }}-${{ github.head_ref || github.ref_name }} + scons-${{ runner.os }}-${{ runner.arch }}-${{ env.MASTER_NEW_BRANCH }}-model + scons-${{ runner.os }}-${{ runner.arch }}-${{ env.MASTER_BRANCH }}-model + scons-${{ runner.os }}-${{ runner.arch }}-${{ env.MASTER_NEW_BRANCH }} + scons-${{ runner.os }}-${{ runner.arch }}-${{ env.MASTER_BRANCH }} + scons-${{ runner.os }}-${{ runner.arch }} + + - name: Set environment variables + id: set-env + run: | + # Set up common environment + source /etc/profile; + export UV_PROJECT_ENVIRONMENT=${HOME}/venv + export VIRTUAL_ENV=$UV_PROJECT_ENVIRONMENT + printenv >> $GITHUB_ENV + if [[ "${{ runner.debug }}" == "1" ]]; then + cat $GITHUB_OUTPUT + fi + + - name: Setup build environment + run: | + mkdir -p "${BUILD_DIR}/" + sudo find $BUILD_DIR/ -mindepth 1 -delete + echo "Starting build stage..." + echo "BUILD_DIR: ${BUILD_DIR}" + echo "CI_DIR: ${CI_DIR}" + echo "VERSION: ${{ steps.set-env.outputs.version }}" + echo "UV_PROJECT_ENVIRONMENT: ${UV_PROJECT_ENVIRONMENT}" + echo "VIRTUAL_ENV: ${VIRTUAL_ENV}" + echo "-------" + if [[ "${{ runner.debug }}" == "1" ]]; then + printenv + fi + PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --disable + rm -rf ${{ env.MODELS_DIR }}/*.onnx + + - name: Download model artifacts + uses: actions/download-artifact@v4 + with: + name: models-${{ env.REF }}${{ inputs.artifact_suffix }} + path: ${{ github.workspace }}/selfdrive/modeld/models + - run: | + rm -f ${{ github.workspace }}/selfdrive/modeld/models/{dmonitoring_model,big_driving_policy,big_driving_vision}.onnx + + - name: Build Model + run: | + source /etc/profile + export UV_PROJECT_ENVIRONMENT=${HOME}/venv + export VIRTUAL_ENV=$UV_PROJECT_ENVIRONMENT + export PYTHONPATH="${PYTHONPATH}:${{ env.TINYGRAD_PATH }}" + + # Loop through all .onnx files + find "${{ env.MODELS_DIR }}" -maxdepth 1 -name '*.onnx' | while IFS= read -r onnx_file; do + base_name=$(basename "$onnx_file" .onnx) + output_file="${{ env.MODELS_DIR }}/${base_name}_tinygrad.pkl" + + echo "Compiling: $onnx_file -> $output_file" + QCOM=1 python3 "${{ env.TINYGRAD_PATH }}/examples/openpilot/compile3.py" "$onnx_file" "$output_file" + DEV=QCOM FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 python3 "${{ env.MODELS_DIR }}/../get_model_metadata.py" "$onnx_file" || true + done + + - name: Validate Model Outputs + run: | + source /etc/profile + export UV_PROJECT_ENVIRONMENT=${HOME}/venv + export VIRTUAL_ENV=$UV_PROJECT_ENVIRONMENT + python3 "${{ github.workspace }}/release/ci/model_generator.py" \ + --validate-only \ + --model-dir "${{ env.MODELS_DIR }}" + + - name: Prepare Output + run: | + sudo rm -rf ${{ env.OUTPUT_DIR }} + mkdir -p ${{ env.OUTPUT_DIR }} + + # Copy the model files + rsync -avm \ + --include='*.dlc' \ + --include='*.pkl' \ + --include='*.onnx' \ + --exclude='*' \ + --delete-excluded \ + --chown=comma:comma \ + ${{ env.MODELS_DIR }}/ ${{ env.OUTPUT_DIR }}/ + + python3 "${{ github.workspace }}/release/ci/model_generator.py" \ + --model-dir "${{ env.MODELS_DIR }}" \ + --output-dir "${{ env.OUTPUT_DIR }}" \ + --custom-name "${{ env.MODEL_NAME }}" \ + --upstream-branch "${{ inputs.upstream_branch }}" \ + ${{ inputs.is_20hz && '--is-20hz' || '' }} + + - name: Write artifact name to file + run: echo "model-${{ env.MODEL_NAME }}${{ inputs.artifact_suffix }}-${{ github.run_number }}" > ${{ env.OUTPUT_DIR }}/artifact_name.txt + + - name: Upload Build Artifacts + id: upload-artifact + uses: actions/upload-artifact@v4 + with: + name: model-${{ env.MODEL_NAME }}${{ inputs.artifact_suffix }}-${{ github.run_number }} + path: ${{ env.OUTPUT_DIR }} + + - name: Upload artifact name file + uses: actions/upload-artifact@v4 + with: + name: artifact-name-${{ inputs.custom_name || inputs.upstream_branch }} + path: ${{ env.OUTPUT_DIR }}/artifact_name.txt + + - name: Re-enable powersave + if: always() + run: | + PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --enable diff --git a/.github/workflows/sunnypilot-build-prebuilt.yaml b/.github/workflows/sunnypilot-build-prebuilt.yaml new file mode 100644 index 0000000000..79cb5e3d61 --- /dev/null +++ b/.github/workflows/sunnypilot-build-prebuilt.yaml @@ -0,0 +1,369 @@ +name: sunnypilot prebuilt action + +env: + BUILD_DIR: "/data/openpilot" + OUTPUT_DIR: ${{ github.workspace }}/output + CI_DIR: ${{ github.workspace }}/release/ci + SCONS_CACHE_DIR: ${{ github.workspace }}/release/ci/scons_cache + PUBLIC_REPO_URL: "https://github.com/sunnypilot/sunnypilot" + + # Branch configurations + STAGING_SOURCE_BRANCH: 'master' + + # Runtime configuration + SOURCE_BRANCH: "${{ github.head_ref || github.ref_name }}" + +on: + push: + branches: [ master, master-dev ] + tags: [ 'release/*' ] + pull_request_target: + types: [ labeled ] + workflow_dispatch: + inputs: + wait_for_tests: + description: 'Wait for tests to finish' + required: false + type: boolean + default: false + +jobs: + prepare_strategy: + runs-on: ubuntu-24.04 + if: (!contains(github.event_name, 'pull_request') || (github.event.action == 'labeled' && github.event.label.name == 'prebuilt')) + outputs: + environment: ${{ steps.strategy.outputs.environment }} + new_branch: ${{ steps.strategy.outputs.new_branch }} + extra_version_identifier: ${{ steps.strategy.outputs.extra_version_identifier }} + version: ${{ steps.strategy.outputs.version }} + cancel_publish_in_progress: ${{ steps.strategy.outputs.cancel_publish_in_progress }} + publish_concurrency_group: ${{ steps.strategy.outputs.publish_concurrency_group }} + is_stable_branch: ${{ steps.strategy.outputs.is_stable_branch }} + build: ${{ steps.strategy.outputs.build }} + steps: + - uses: actions/checkout@v4 + - name: Extract deploy strategy + id: strategy + run: | + echo '::group::Strategy Extraction' + BRANCH="${{ github.head_ref || github.ref_name }}" + echo "Current branch: $BRANCH" + + STRATEGY_JSON='${{ vars.DEPLOY_STRATEGY }}' + CONFIG=$(echo "$STRATEGY_JSON" | jq -r --arg branch "$BRANCH" ' + .configs[] | select(.branch == $branch) + ') + + BUILD="$(date '+%Y.%m.%d')-${{ github.run_number }}" + if [[ -z "$CONFIG" || "$CONFIG" == "null" ]]; then + echo "No exact strategy match found. Falling back to feature/fork logic." + IS_FORK="${{ github.event.pull_request.head.repo.fork && 'true' || 'false' }}" + FORK_SUFFIX=$( [[ "$IS_FORK" == "true" ]] && echo "-fork" || echo "" ) + NEW_BRANCH="${BRANCH}${FORK_SUFFIX}-prebuilt" + + echo "new_branch=$NEW_BRANCH" >> $GITHUB_OUTPUT + echo "version=$BUILD" >> $GITHUB_OUTPUT + echo "cancel_publish_in_progress=true" >> $GITHUB_OUTPUT + echo "publish_concurrency_group=publish-${BRANCH}" >> $GITHUB_OUTPUT + echo "environment=feature-branch" >> $GITHUB_OUTPUT + echo "extra_version_identifier=feature-branch" >> $GITHUB_OUTPUT + else + echo "Matched config: $CONFIG" + environment=$(echo "$CONFIG" | jq -r '.environment') + echo "environment=$environment" >> $GITHUB_OUTPUT + echo "new_branch=$(echo "$CONFIG" | jq -r '.target_branch')" >> $GITHUB_OUTPUT + cancel="$(echo "$CONFIG" | jq -r '.cancel_publish_in_progress')"; + echo "cancel_publish_in_progress=$( [ "$cancel" = "null" ] && echo "true" || echo $cancel)" >> $GITHUB_OUTPUT + echo "publish_concurrency_group=publish-${BRANCH}$( [ "$cancel" = "null" ] || [ "$cancel" = "true" ] || echo "${{ github.sha }}" )" >> $GITHUB_OUTPUT + + is_stable_branch="$(echo "$CONFIG" | jq -r '.stable_branch // false')"; + echo "is_stable_branch=$is_stable_branch" >> $GITHUB_OUTPUT + + stable_version=$(cat sunnypilot/common/version.h | grep SUNNYPILOT_VERSION | sed -e 's/[^0-9|.]//g'); + echo "version=$([ "$is_stable_branch" = "true" ] && echo "$stable_version" || echo "$BUILD")" >> $GITHUB_OUTPUT + echo "extra_version_identifier=${environment}" >> $GITHUB_OUTPUT + fi + echo "build=$BUILD" >> $GITHUB_OUTPUT + cat $GITHUB_OUTPUT + + validate_tests: + runs-on: ubuntu-24.04 + needs: [ prepare_strategy ] + if: ${{ + ((github.event_name == 'workflow_dispatch' && inputs.wait_for_tests) || + (github.event_name == 'push' && needs.prepare_strategy.outputs.is_stable_branch == 'true') || + contains(github.event_name, 'pull_request') && (github.event.action == 'labeled' && github.event.label.name == 'prebuilt')) + }} + steps: + - uses: actions/checkout@v4 + - name: Wait for Tests + uses: ./.github/workflows/wait-for-action # Path to where you place the action + with: + workflow: tests.yaml # The workflow file to monitor + github-token: ${{ secrets.GITHUB_TOKEN }} + should-wait-for-start: ${{ github.event_name == 'push' && 'true' || 'false' }} + + build: + needs: [ validate_tests, prepare_strategy ] + concurrency: + group: build-${{ github.head_ref || github.ref_name }} + cancel-in-progress: false + runs-on: [self-hosted, tici] + outputs: + new_branch: ${{ needs.prepare_strategy.outputs.new_branch }} + version: ${{ needs.prepare_strategy.outputs.version }} + extra_version_identifier: ${{ needs.prepare_strategy.outputs.extra_version_identifier }} + commit_sha: ${{ github.sha }} + if: ${{ + (always() && !cancelled() && !failure()) && + needs.prepare_strategy.result == 'success' && + (needs.validate_tests.result == 'success' || needs.validate_tests.result == 'skipped') && + (!contains(github.event_name, 'pull_request') || + (github.event.action == 'labeled' && github.event.label.name == 'prebuilt')) + }} + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + ref: ${{ env.SOURCE_BRANCH }} + repository: ${{ github.event.pull_request.head.repo.fork && github.event.pull_request.head.repo.full_name || github.repository }} + - run: git lfs pull + + - name: Cache SCons + uses: actions/cache@v4 + with: + path: ${{env.SCONS_CACHE_DIR}} + key: scons-${{ runner.os }}-${{ runner.arch }}-${{ env.SOURCE_BRANCH }}-${{ github.sha }} + # Note: GitHub Actions enforces cache isolation between different build sources (PR builds, workflow dispatches, etc.) + # for security. Only caches from the default branch are shared across all builds. This is by design and cannot be overridden. + restore-keys: | + scons-${{ runner.os }}-${{ runner.arch }}-${{ env.SOURCE_BRANCH }} + scons-${{ runner.os }}-${{ runner.arch }}-${{ env.STAGING_SOURCE_BRANCH }} + scons-${{ runner.os }}-${{ runner.arch }} + + - name: Set environment variables + id: set-env + run: | + echo "new_branch=${{ needs.prepare_strategy.outputs.new_branch }}" >> $GITHUB_OUTPUT + echo "version=${{ needs.prepare_strategy.outputs.version }}" >> $GITHUB_OUTPUT + echo "extra_version_identifier=${{ needs.prepare_strategy.outputs.extra_version_identifier }}" >> $GITHUB_OUTPUT + echo "commit_sha=${{ github.sha }}" >> $GITHUB_OUTPUT + + # Set up common environment + source /etc/profile; + export UV_PROJECT_ENVIRONMENT=${HOME}/venv + export VIRTUAL_ENV=$UV_PROJECT_ENVIRONMENT + printenv >> $GITHUB_ENV + if [[ "${{ runner.debug }}" == "1" ]]; then + cat $GITHUB_OUTPUT + fi + + - name: Setup build environment + run: | + mkdir -p "${BUILD_DIR}/" + sudo find $BUILD_DIR/ -mindepth 1 -delete + echo "Starting build stage..." + echo "BUILD_DIR: ${BUILD_DIR}" + echo "CI_DIR: ${CI_DIR}" + echo "VERSION: ${{ steps.set-env.outputs.version }}" + echo "UV_PROJECT_ENVIRONMENT: ${UV_PROJECT_ENVIRONMENT}" + echo "VIRTUAL_ENV: ${VIRTUAL_ENV}" + echo "-------" + if [[ "${{ runner.debug }}" == "1" ]]; then + printenv + fi + PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --disable + + - name: Build Main Project + run: | + export PYTHONPATH="$BUILD_DIR" + ./release/release_files.py | sort | uniq | rsync -rRl${RUNNER_DEBUG:+v} --files-from=- . $BUILD_DIR/ + cd $BUILD_DIR + sed -i '/from .board.jungle import PandaJungle, PandaJungleDFU/s/^/#/' panda/__init__.py + echo "Building sunnypilot's modeld_v2..." + scons -j$(nproc) cache_dir=${{env.SCONS_CACHE_DIR}} --minimal sunnypilot/modeld_v2 + echo "Building sunnypilot's locationd..." + scons -j2 cache_dir=${{env.SCONS_CACHE_DIR}} --minimal sunnypilot/selfdrive/locationd + echo "Building openpilot's locationd..." + scons -j1 cache_dir=${{env.SCONS_CACHE_DIR}} --minimal selfdrive/locationd + echo "Building rest of sunnypilot" + scons -j$(nproc) cache_dir=${{env.SCONS_CACHE_DIR}} --minimal + touch ${BUILD_DIR}/prebuilt + if [[ "${{ runner.debug }}" == "1" ]]; then + ls -la ${BUILD_DIR} + fi + + - name: Prepare Output + run: | + sudo rm -rf ${OUTPUT_DIR} + mkdir -p ${OUTPUT_DIR} + rsync -am${RUNNER_DEBUG:+v} \ + --exclude='.sconsign.dblite' \ + --exclude='*.a' \ + --exclude='*.o' \ + --exclude='*.os' \ + --exclude='*.pyc' \ + --exclude='moc_*' \ + --exclude='__pycache__' \ + --exclude='Jenkinsfile' \ + --exclude='**/release/' \ + --exclude='**/.github/' \ + --exclude='**/selfdrive/ui/replay/' \ + --exclude='**/__pycache__/' \ + --exclude='${{env.SCONS_CACHE_DIR}}' \ + --exclude='**/.git/' \ + --exclude='**/SConstruct' \ + --exclude='**/SConscript' \ + --exclude='**/.venv/' \ + --exclude='selfdrive/modeld/models/driving_vision.onnx' \ + --exclude='selfdrive/modeld/models/driving_policy.onnx' \ + --exclude='third_party/*x86*' \ + --exclude='third_party/*Darwin*' \ + --delete-excluded \ + --chown=comma:comma \ + ${BUILD_DIR}/ ${OUTPUT_DIR}/ + + - name: 'Tar.gz files' + run: | + tar czf prebuilt.tar.gz -C ${{ env.OUTPUT_DIR }} . + ls -la prebuilt.tar.gz + + - name: 'Upload Artifact' + uses: actions/upload-artifact@v4 + with: + name: prebuilt + path: prebuilt.tar.gz + + - name: Re-enable powersave + if: always() + run: | + PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --enable + + + publish: + concurrency: + # We do a bit of a hack here to avoid canceling the publishing job if a new commit comes in while we're publishing by adding the sha to the group name. + # This means that if multiple commits come in while we're publishing, they will be queued up and publish one after the other. + # Otherwise, if a job is waiting to be published due to environment wait time, it would be canceled by a new commit and restart the wait time. + group: ${{ needs.prepare_strategy.outputs.publish_concurrency_group }} + cancel-in-progress: ${{ needs.prepare_strategy.outputs.cancel_publish_in_progress == 'true' }} + if: ${{ (always() && !cancelled() && !failure()) && needs.build.result == 'success' && needs.prepare_strategy.result == 'success' && (!contains(github.event_name, 'pull_request') || (github.event.action == 'labeled' && github.event.label.name == 'prebuilt')) }} + needs: [ build, prepare_strategy ] + runs-on: ubuntu-24.04 + environment: ${{ needs.prepare_strategy.outputs.environment }} + steps: + - uses: actions/checkout@v4 + + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: prebuilt + + - name: Untar prebuilt + run: | + mkdir -p ${{ env.OUTPUT_DIR }} + tar xzf prebuilt.tar.gz -C ${{ env.OUTPUT_DIR }} + + - name: Configure Git + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + + - name: Publish to Public Repository + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + echo '${{ toJSON(needs.build.outputs) }}' + ls -la ${{ env.OUTPUT_DIR }} + + ${{ env.CI_DIR }}/publish.sh \ + "${{ github.workspace }}" \ + "${{ env.OUTPUT_DIR }}" \ + "${{ needs.build.outputs.new_branch }}" \ + "${{ needs.build.outputs.version }}" \ + "https://x-access-token:${{github.token}}@github.com/sunnypilot/sunnypilot.git" \ + "${{ needs.build.outputs.extra_version_identifier }}" + + echo "" + echo "---- ℹ️ To update the list of branches that auto deploy prebuilts -----" + echo "" + echo "1. Go to: ${{ github.server_url }}/${{ github.repository }}/settings/variables/actions/AUTO_DEPLOY_PREBUILT_BRANCHES" + echo "2. Current value: ${{ vars.AUTO_DEPLOY_PREBUILT_BRANCHES }}" + echo "3. Update as needed (JSON array with no spaces)" + + - name: Tag ${{ needs.prepare_strategy.outputs.environment }} + if: ${{ needs.prepare_strategy.outputs.is_stable_branch == 'true' && (github.event_name != 'push' || !startsWith(github.ref, 'refs/tags/')) }} + run: | + TAG="${{ needs.prepare_strategy.outputs.environment }}/${{ needs.prepare_strategy.outputs.version }}/${{ needs.prepare_strategy.outputs.build }}" + git tag -f -a ${TAG} -m "${{ needs.prepare_strategy.outputs.environment }} @ ${{ needs.prepare_strategy.outputs.version }} of build ${{ needs.build.outputs.build }}." + git push -f origin ${TAG} + + notify: + needs: + - prepare_strategy + - build + - publish + runs-on: ubuntu-24.04 + if: ${{ (always() && !cancelled() && !failure()) + && needs.publish.result == 'success' + && (!contains(github.event_name, 'pull_request') || (github.event.action == 'labeled' && github.event.label.name == 'prebuilt')) + && (fromJSON(vars.DEV_FEEDBACK_NOTIFICATION_BRANCHES_V2)[github.head_ref || github.ref_name] != null) }} + steps: + - uses: actions/checkout@v4 + + - name: Prepare notification message + id: message + run: | + TEMPLATE='${{ vars.DISCOURSE_GENERAL_UPDATE_NOTICE }}' + export VERSION="${{ needs.prepare_strategy.outputs.version }}" + export branch_name="${{ env.SOURCE_BRANCH }}" + export new_branch="${{ needs.prepare_strategy.outputs.new_branch }}" + export commit_sha="${{ github.sha }}" + export commit_short_sha="${{ github.sha }}" + export commit_short_sha="${commit_short_sha:0:7}" + export extra_version_identifier="${{ needs.prepare_strategy.outputs.extra_version_identifier || github.run_number }}" + export PUBLIC_REPO_URL="${{ env.PUBLIC_REPO_URL }}" + + MESSAGE=$(cat << 'EOF' | envsubst + ${{ vars.DISCOURSE_GENERAL_UPDATE_NOTICE }} + EOF + ) + + { + echo 'content<> $GITHUB_OUTPUT + shell: bash + + - name: Post to Discourse + uses: ./.github/workflows/post-to-discourse + with: + discourse-url: ${{ vars.DISCOURSE_URL }} + api-key: ${{ secrets.DISCOURSE_API_KEY }} + api-username: "system" + topic-id: ${{ fromJSON(vars.DEV_FEEDBACK_NOTIFICATION_BRANCHES_V2)[github.head_ref || github.ref_name].topic_id }} + message: ${{ steps.message.outputs.content }} + + manage-pr-labels: + name: Remove prebuilt label + runs-on: ubuntu-latest + if: (always() && contains(github.event_name, 'pull_request') && (github.event.action == 'labeled' && github.event.label.name == 'prebuilt')) + env: + LABEL: prebuilt + steps: + - name: Remove trust-fork-pr label if present + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const prNumber = context.payload.pull_request.number; + + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + name: process.env.LABEL + }); + + console.log(`Removed '${process.env.LABEL}' label from PR #${prNumber}`); diff --git a/.github/workflows/sunnypilot-master-dev-prep.yaml b/.github/workflows/sunnypilot-master-dev-prep.yaml new file mode 100644 index 0000000000..1cb574764b --- /dev/null +++ b/.github/workflows/sunnypilot-master-dev-prep.yaml @@ -0,0 +1,243 @@ +name: Build dev + +env: + DEFAULT_SOURCE_BRANCH: "master" + DEFAULT_TARGET_BRANCH: "master-dev" + LFS_URL: 'https://gitlab.com/sunnypilot/public/sunnypilot-new-lfs.git/info/lfs' + LFS_PUSH_URL: 'ssh://git@gitlab.com/sunnypilot/public/sunnypilot-new-lfs.git' + +on: + push: + branches: + - master + pull_request_target: + types: [ labeled ] + branches: + - 'master' + workflow_dispatch: + inputs: + source_branch: + description: 'Source branch to reset from' + required: true + default: 'master' + type: string + target_branch: + description: 'Target branch to reset and squash into' + required: true + default: 'master-dev' + type: string + cancel_in_progress: + description: 'Cancel any in-progress runs of this workflow' + required: false + default: true + type: boolean + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: ${{ inputs.cancel_in_progress || github.event_name == 'push' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }} + +jobs: + reset-and-squash: + runs-on: ubuntu-latest + if: ( + (github.event_name == 'workflow_dispatch') + || (github.event_name == 'push' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch)) + || (contains(github.event_name, 'pull_request') && ((github.event.action == 'labeled' && (github.event.label.name == vars.PREBUILT_PR_LABEL || github.event.label.name == 'trust-fork-pr') && contains(github.event.pull_request.labels.*.name, vars.PREBUILT_PR_LABEL)))) + ) + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Fetch all history for all branches + token: ${{ secrets.GITHUB_TOKEN }} + persist-credentials: false + + - name: Wait for Tests + uses: ./.github/workflows/wait-for-action # Path to where you place the action + if: ( + (github.event_name == 'push' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch)) + || (contains(github.event_name, 'pull_request') && ((github.event.action == 'labeled' && (github.event.label.name == vars.PREBUILT_PR_LABEL || github.event.label.name == 'trust-fork-pr') && contains(github.event.pull_request.labels.*.name, vars.PREBUILT_PR_LABEL)))) + ) + with: + workflow: tests.yaml # The workflow file to monitor + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure Git + run: | + git config --global user.name 'github-actions[bot]' + git config --global user.email 'github-actions[bot]@users.noreply.github.com' + + - name: Set up SSH + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} + + - name: Add GitLab public keys + run: | + ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install PyGithub + + - name: Check branches exist + run: | + # Check if source branch exists + if ! git ls-remote --heads origin ${{ inputs.source_branch || env.DEFAULT_SOURCE_BRANCH }} | grep -q "${{ inputs.source_branch || env.DEFAULT_SOURCE_BRANCH }}"; then + echo "Source branch ${{ inputs.source_branch || env.DEFAULT_SOURCE_BRANCH }} does not exist!" + exit 1 + fi + + # Make sure we have the latest source branch + git fetch origin ${{ inputs.source_branch || env.DEFAULT_SOURCE_BRANCH }} + + # Check if target branch exists + if ! git ls-remote --heads origin ${{ inputs.target_branch || env.DEFAULT_TARGET_BRANCH }} | grep -q "${{ inputs.target_branch || env.DEFAULT_TARGET_BRANCH }}"; then + echo "Target branch ${{ inputs.target_branch || env.DEFAULT_TARGET_BRANCH }} does not exist, creating it from ${{ inputs.source_branch || env.DEFAULT_SOURCE_BRANCH }}" + git checkout -b ${{ inputs.target_branch || env.DEFAULT_TARGET_BRANCH }} origin/${{ inputs.source_branch || env.DEFAULT_SOURCE_BRANCH }} + git push origin ${{ inputs.target_branch || env.DEFAULT_TARGET_BRANCH }} + else + # Fetch target branch if it exists + git fetch origin ${{ inputs.target_branch || env.DEFAULT_TARGET_BRANCH }} + fi + + - name: Reset target branch + run: | + echo "Resetting ${{ inputs.target_branch || env.DEFAULT_TARGET_BRANCH }} to match ${{ inputs.source_branch || env.DEFAULT_SOURCE_BRANCH }}" + # Delete if exists and recreate pointing to source + git branch -D ${{ inputs.target_branch || env.DEFAULT_TARGET_BRANCH }} || true + git branch ${{ inputs.target_branch || env.DEFAULT_TARGET_BRANCH }} origin/${{ inputs.source_branch || env.DEFAULT_SOURCE_BRANCH }} + + - name: Get PRs to squash + id: get-prs + run: | + # Use GitHub API to get PRs with specific label, ordered by creation date + PR_LIST=$(gh api graphql -f query=' + query($search_query:String!) { + search(query: $search_query, type:ISSUE, first:40) { + nodes { + ... on PullRequest { + number + headRefName + title + createdAt + labels(last:10) { + nodes { + name + } + } + headRepository { + name + nameWithOwner + url + isFork + } + commits(last: 1) { + nodes { + commit { + statusCheckRollup { + state + } + } + } + } + } + } + } + }' -F search_query="repo:${{ github.repository }} is:pr is:open label:${{ vars.PREBUILT_PR_LABEL }},${{ vars.PREBUILT_PR_LABEL }}-c3 draft:false sort:created-asc") + + PR_LIST=${PR_LIST//\'/} + echo "PR_LIST=${PR_LIST}" >> $GITHUB_OUTPUT + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Process PRs + run: | + cp ${{ github.workspace }}/release/ci/squash_and_merge.py /tmp/squash_and_merge.py && \ + chmod +x /tmp/squash_and_merge.py && \ + python3 ${{ github.workspace }}/release/ci/squash_and_merge_prs.py \ + --pr-data '${{ steps.get-prs.outputs.PR_LIST }}' \ + --target-branch ${{ inputs.target_branch || env.DEFAULT_TARGET_BRANCH }} \ + --squash-script-path '/tmp/squash_and_merge.py' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Update LFS Config + run: | + echo '[lfs]' > .lfsconfig + echo ' url = ${{ env.LFS_URL }}' >> .lfsconfig + echo ' pushurl = ${{ env.LFS_PUSH_URL }}' >> .lfsconfig + echo ' locksverify = false' >> .lfsconfig + + - name: Restore workflows from source + run: | + TARGET_BRANCH="${{ inputs.target_branch || env.DEFAULT_TARGET_BRANCH }}" + SOURCE_BRANCH="${{ inputs.source_branch || env.DEFAULT_SOURCE_BRANCH }}" + + # Ensure we are on the target branch + git checkout $TARGET_BRANCH + + echo "Restoring .github/workflows from $SOURCE_BRANCH" + git checkout origin/$SOURCE_BRANCH -- .github/workflows + + if ! git diff --cached --quiet; then + echo "Workflows differ. Committing restoration." + git commit -m "chore: restore .github/workflows from $SOURCE_BRANCH" + else + echo "Workflows match $SOURCE_BRANCH." + fi + + - uses: actions/create-github-app-token@v2 + id: ci-token + with: + app-id: ${{ secrets.CI_GITHUB_ACTIONS_TOKEN_APP_ID }} + private-key: ${{ secrets.CI_GITHUB_ACTIONS_TOKEN_APP_PRIVATE_KEY }} + + - name: Push changes if there are diffs + id: push-changes + run: | + TARGET_BRANCH="${{ inputs.target_branch || env.DEFAULT_TARGET_BRANCH }}" + + # Use the App Token to set the remote URL with authentication + git remote set-url origin "https://x-access-token:${{ steps.ci-token.outputs.token }}@github.com/${{ github.repository }}.git" + + # Fetch the latest from remote + git fetch origin $TARGET_BRANCH + + # Check for diffs between local and remote + if git diff $TARGET_BRANCH origin/$TARGET_BRANCH --quiet; then + echo "No changes to push - local and remote branches are identical" + echo "has_changes=false" >> $GITHUB_OUTPUT + exit 0 + fi + + # Push with the authenticated origin + if ! git push origin $TARGET_BRANCH --force; then + echo "Failed to push changes to $TARGET_BRANCH" + exit 1 + fi + + echo "Branch $TARGET_BRANCH has been reset and updated with squashed PRs" + echo "has_changes=true" >> $GITHUB_OUTPUT + + - name: Trigger and wait for selfdrive tests + if: steps.push-changes.outputs.has_changes == 'true' + run: | + echo "Triggering selfdrive tests..." + gh workflow run tests.yaml --ref "${{ inputs.target_branch || env.DEFAULT_TARGET_BRANCH }}" + + echo "Sleeping for 120s to give plenty of time for the action to start and then we wait" + sleep 120 + + echo "Getting latest run ID..." + RUN_ID=$(gh run list --workflow=tests.yaml --branch="${{ inputs.target_branch || env.DEFAULT_TARGET_BRANCH }}" --limit=1 --json databaseId --jq '.[0].databaseId') + + echo "Watching run ID: $RUN_ID" + gh run watch "$RUN_ID" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test-discourse.yaml.yml b/.github/workflows/test-discourse.yaml.yml new file mode 100644 index 0000000000..fadaec4eaa --- /dev/null +++ b/.github/workflows/test-discourse.yaml.yml @@ -0,0 +1,78 @@ +name: Debug Discourse Posting + +on: + push: + +jobs: + test-discourse-post: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Post test message to Discourse + uses: ./.github/workflows/post-to-discourse + with: + discourse-url: ${{ vars.DISCOURSE_URL }} + api-key: ${{ secrets.DISCOURSE_API_KEY }} + api-username: ${{ secrets.DISCOURSE_API_USERNAME }} + topic-id: ${{ vars.DISCOURSE_UPDATES_TOPIC_ID }} + message: | + ## 🧪 Test Post from GitHub Actions + + **This is a test post to verify Discourse integration** + + - **Workflow**: ${{ github.workflow }} + - **Run Number**: #${{ github.run_number }} + - **Branch**: `${{ github.ref_name }}` + - **Commit**: ${{ github.sha }} + - **Actor**: @${{ github.actor }} + - **Timestamp**: ${{ github.event.head_commit.timestamp }} + + --- + + ### Fake Build Info (for testing) + - **Version**: 0.9.8-test + - **Build**: #42 + - **Branch**: release-test + + [View workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) + + *This is an automated test message. Drive safe! 🚗💨* + + + - name: Create topic on Discourse + uses: ./.github/workflows/post-to-discourse + with: + discourse-url: ${{ vars.DISCOURSE_URL }} + api-key: ${{ secrets.DISCOURSE_API_KEY }} + api-username: ${{ secrets.DISCOURSE_API_USERNAME }} + #topic-id: ${{ vars.DISCOURSE_UPDATES_TOPIC_ID }} + category-id: 4 + title: "This is a test of a new topic instead of a reply" + message: | + ## 🧪 Test Post from GitHub Actions + + **This is a test post to verify Discourse integration** + + - **Workflow**: ${{ github.workflow }} + - **Run Number**: #${{ github.run_number }} + - **Branch**: `${{ github.ref_name }}` + - **Commit**: ${{ github.sha }} + - **Actor**: @${{ github.actor }} + - **Timestamp**: ${{ github.event.head_commit.timestamp }} + + --- + + ### Fake Build Info (for testing) + - **Version**: 0.9.8-test + - **Build**: #42 + - **Branch**: release-test + + [View workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) + + *This is an automated test message. Drive safe! 🚗💨* + - name: Display results + if: always() + run: | + echo "::notice::Discourse post test completed" + echo "Check your Discourse topic to verify the post appeared correctly" \ No newline at end of file diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 91a8e5c324..834771d634 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -57,9 +57,25 @@ jobs: working-directory: ${{ env.STRIPPED_DIR }} run: release/check-dirty.sh - name: Check submodules - if: github.repository == 'commaai/openpilot' + if: github.repository == 'sunnypilot/sunnypilot' timeout-minutes: 3 - run: release/check-submodules.sh + run: | + if [ "${{ github.ref }}" != "refs/heads/master" ]; then + git fetch origin master:refs/remotes/origin/master + + SUBMODULE_PATHS=$(git diff origin/master HEAD --name-only | grep -E '^[^/]+$' | while read path; do + if git ls-files --stage "$path" | grep -q "^160000"; then + echo "$path" + fi + done | tr '\n' ' ') + + if [ -n "$SUBMODULE_PATHS" ]; then + echo "Changed submodule paths: $SUBMODULE_PATHS" + export SUBMODULE_PATHS="$SUBMODULE_PATHS" + export CHECK_PR_REFS=true + fi + fi + release/check-submodules.sh build_mac: name: build macOS @@ -109,7 +125,7 @@ jobs: - name: Build openpilot run: scons -j$(nproc) - name: Run unit tests - timeout-minutes: ${{ contains(runner.name, 'nsc') && 2 || 20 }} + 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 @@ -118,6 +134,7 @@ jobs: process_replay: name: process replay + if: false # disable process_replay for forks runs-on: ${{ (github.repository == 'commaai/openpilot') && ((github.event_name != 'pull_request') || diff --git a/.github/workflows/ui_preview.yaml b/.github/workflows/ui_preview.yaml index 72ced49852..a02726c3af 100644 --- a/.github/workflows/ui_preview.yaml +++ b/.github/workflows/ui_preview.yaml @@ -25,7 +25,7 @@ env: jobs: preview: - if: github.repository == 'commaai/openpilot' + if: github.repository == 'sunnypilot/sunnypilot' name: preview runs-on: ubuntu-latest timeout-minutes: 20 @@ -64,7 +64,7 @@ jobs: - name: Getting mici master ui uses: actions/checkout@v6 with: - repository: commaai/ci-artifacts + repository: sunnypilot/ci-artifacts ssh-key: ${{ secrets.CI_ARTIFACTS_DEPLOY_KEY }} path: ${{ github.workspace }}/master_mici ref: openpilot_master_ui_mici_raylib @@ -72,7 +72,7 @@ jobs: - name: Getting big master ui uses: actions/checkout@v6 with: - repository: commaai/ci-artifacts + repository: sunnypilot/ci-artifacts ssh-key: ${{ secrets.CI_ARTIFACTS_DEPLOY_KEY }} path: ${{ github.workspace }}/master_big ref: openpilot_master_ui_big_raylib @@ -104,7 +104,7 @@ jobs: id: find_diff run: | export PYTHONPATH=${{ github.workspace }} - baseurl="https://github.com/commaai/ci-artifacts/raw/refs/heads/${{ env.BRANCH_NAME }}" + baseurl="https://github.com/sunnypilot/ci-artifacts/raw/refs/heads/${{ env.BRANCH_NAME }}" COMMENT="" for variant in $VARIANTS; do @@ -123,7 +123,7 @@ jobs: cp "${{ github.workspace }}/selfdrive/ui/tests/diff/report/${diff_name}.html" "${{ github.workspace }}/pr_ui/" cp "${{ github.workspace }}/selfdrive/ui/tests/diff/report/${diff_name}.mp4" "${{ github.workspace }}/pr_ui/" - REPORT_URL="https://commaai.github.io/ci-artifacts/${diff_name}_pr_${{ github.event.number }}.html" + REPORT_URL="https://sunnypilot.github.io/ci-artifacts/${diff_name}_pr_${{ github.event.number }}.html" if [ $diff_exit_code -eq 0 ]; then COMMENT+="**${name}**: Videos are identical! [View Diff Report]($REPORT_URL)"$'\n' else diff --git a/.github/workflows/wait-for-action/action.yaml b/.github/workflows/wait-for-action/action.yaml new file mode 100644 index 0000000000..01bc614618 --- /dev/null +++ b/.github/workflows/wait-for-action/action.yaml @@ -0,0 +1,52 @@ +name: 'Wait for Tests' +description: 'Action to wait for workflow tests to start and complete' +inputs: + workflow: + description: 'The workflow file name to monitor' + required: true + default: 'tests.yaml' + branch: + description: 'The branch to monitor (defaults to current branch)' + required: false + default: '' + github-token: + description: 'GitHub token for API access' + required: true + wait-time: + description: 'Initial sleep time in seconds before monitoring starts' + required: false + default: '30' + should-wait-for-start: + description: 'Whether to wait for tests to start' + required: false + default: false + +runs: + using: 'composite' + steps: + - name: Wait for tests to start + if: inputs.should-wait-for-start == 'true' + shell: bash + run: | + echo "Sleeping for ${{ inputs.wait-time }} seconds to give some time for the action to start and then we'll wait" + sleep ${{ inputs.wait-time }} + + - name: Wait for tests to finish + shell: bash + run: | + BRANCH="${{ inputs.branch || github.head_ref || github.ref_name }}" + + echo "Looking for workflow runs of ${{ inputs.workflow }} on branch $BRANCH" + RUN_ID=$(gh run list --workflow=${{ inputs.workflow }} --branch="$BRANCH" --limit=1 --json databaseId --jq '.[0].databaseId') + echo "Watching run ID: $RUN_ID" + gh run watch "$RUN_ID" + + CONCLUSION=$(gh run view "$RUN_ID" --json conclusion --jq '.conclusion') + echo "Run concluded with: $CONCLUSION" + + if [[ "$CONCLUSION" != "success" ]]; then + echo "❌ Workflow run failed with conclusion: $CONCLUSION" + exit 1 + fi + env: + GITHUB_TOKEN: ${{ inputs.github-token }} diff --git a/.gitignore b/.gitignore index 1f58a371e0..738a150b7e 100644 --- a/.gitignore +++ b/.gitignore @@ -86,3 +86,10 @@ build/ .context/ PLAN.md TASK.md +CLAUDE.md +SKILL.md + +### JetBrains ### +!.idea/customTargets.xml +!.idea/tools/* +!.run/* diff --git a/.gitmodules b/.gitmodules index ad6530de9a..5c5d72a7dc 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,18 +1,21 @@ [submodule "panda"] path = panda - url = ../../commaai/panda.git + url = https://github.com/sunnyhaibin/panda.git [submodule "opendbc"] path = opendbc_repo - url = ../../commaai/opendbc.git + url = https://github.com/sunnypilot/opendbc.git [submodule "msgq"] path = msgq_repo - url = ../../commaai/msgq.git + url = https://github.com/commaai/msgq.git [submodule "rednose_repo"] path = rednose_repo - url = ../../commaai/rednose.git + url = https://github.com/commaai/rednose.git [submodule "teleoprtc_repo"] path = teleoprtc_repo - url = ../../commaai/teleoprtc + url = https://github.com/commaai/teleoprtc [submodule "tinygrad"] path = tinygrad_repo - url = https://github.com/tinygrad/tinygrad.git + url = https://github.com/sunnypilot/tinygrad.git +[submodule "sunnypilot/neural_network_data"] + path = sunnypilot/neural_network_data + url = https://github.com/sunnypilot/neural-network-data.git diff --git a/.idea/customTargets.xml b/.idea/customTargets.xml new file mode 100644 index 0000000000..772000ec3a --- /dev/null +++ b/.idea/customTargets.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/tools/External Tools.xml b/.idea/tools/External Tools.xml new file mode 100644 index 0000000000..d5d03136ea --- /dev/null +++ b/.idea/tools/External Tools.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.lfsconfig b/.lfsconfig index 42dfa2d944..4375f2ec74 100644 --- a/.lfsconfig +++ b/.lfsconfig @@ -1,4 +1,4 @@ [lfs] - url = https://gitlab.com/commaai/openpilot-lfs.git/info/lfs - pushurl = ssh://git@gitlab.com/commaai/openpilot-lfs.git + url = https://gitlab.com/sunnypilot/public/sunnypilot-new-lfs.git/info/lfs + pushurl = ssh://git@gitlab.com/sunnypilot/public/sunnypilot-new-lfs.git locksverify = false diff --git a/.lfsconfig-comma b/.lfsconfig-comma new file mode 100644 index 0000000000..42dfa2d944 --- /dev/null +++ b/.lfsconfig-comma @@ -0,0 +1,4 @@ +[lfs] + url = https://gitlab.com/commaai/openpilot-lfs.git/info/lfs + pushurl = ssh://git@gitlab.com/commaai/openpilot-lfs.git + locksverify = false diff --git a/.run/Build Debug.run.xml b/.run/Build Debug.run.xml new file mode 100644 index 0000000000..beddbc15d5 --- /dev/null +++ b/.run/Build Debug.run.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.run/Build Release.run.xml b/.run/Build Release.run.xml new file mode 100644 index 0000000000..0d5fe21933 --- /dev/null +++ b/.run/Build Release.run.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.run/Build_BIG_UI.run.xml b/.run/Build_BIG_UI.run.xml new file mode 100644 index 0000000000..b58b1bf1b7 --- /dev/null +++ b/.run/Build_BIG_UI.run.xml @@ -0,0 +1,26 @@ + + + + + \ No newline at end of file diff --git a/.run/Build_SMALL_UI.run.xml b/.run/Build_SMALL_UI.run.xml new file mode 100644 index 0000000000..6c231c83f8 --- /dev/null +++ b/.run/Build_SMALL_UI.run.xml @@ -0,0 +1,23 @@ + + + + + \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000..8ad026b12b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,1203 @@ +sunnypilot Version 2026.001.000 (2026-03-xx) +======================== +* What's Changed (sunnypilot/sunnypilot) + * Complete rewrite of the user interface from Qt C++ to Raylib Python + * comma four support + * ui: sunnypilot toggle style by @nayan8teen + * ui: fix scroll panel mouse wheel behavior by @nayan8teen + * ui: sunnypilot panels by @nayan8teen + * sunnylink: centralize key pair handling in sunnylink registration by @devtekve + * ui: reimplement sunnypilot branding with Raylib by @sunnyhaibin + * ui: Platform Selector by @Discountchubbs + * ui: vehicle brand settings by @Discountchubbs + * ui: sunnylink client-side implementation by @nayan8teen + * ui: `NetworkUISP` by @Discountchubbs + * ui: add sunnypilot font by @nayan8teen + * ui: sunnypilot sponsor tier color mapping by @sunnyhaibin + * ui: sunnylink panel by @nayan8teen + * ui: Models panel by @Discountchubbs + * ui: software panel by @Discountchubbs + * modeld_v2: support planplus outputs by @Discountchubbs + * ui: OSM panel by @Discountchubbs + * ui: Developer panel extension by @Discountchubbs + * sunnylink: Vehicle Selector support by @sunnyhaibin + * [TIZI/TICI] ui: Developer Metrics by @rav4kumar + * [comma 4] ui: sunnylink panel by @nayan8teen + * ui: lateral-only and longitudinal-only UI statuses support by @royjr + * sunnylink: elliptic curve keys support and improve key path handling by @nayan8teen + * sunnylink: block remote modification of SSH key parameters by @zikeji + * [TIZI/TICI] ui: rainbow path by @rav4kumar + * [TIZI/TICI] ui: chevron metrics by @rav4kumar + * ui: include MADS enabled state to `engaged` check by @sunnyhaibin + * Toyota: Enforce Factory Longitudinal Control by @sunnyhaibin + * ui: fix malformed dongle ID display on the PC if dongleID is not set by @dzid26 + * SL: Re enable and validate ingestion of swaglogs by @devtekve + * modeld_v2: planplus model tuning by @Discountchubbs + * ui: fix Always Offroad button visibility by @nayan8teen + * Reimplement sunnypilot Terms of Service & sunnylink Consent Screens by @sunnyhaibin + * [TIZI/TICI] ui: update dmoji position and Developer UI adjustments by @rav4kumar + * modeld: configurable camera offset by @Discountchubbs + * [TIZI/TICI] ui: sunnylink status on sidebar by @Copilot + * ui: Global Brightness Override by @nayan8teen + * ui: Customizable Interactive Timeout by @sunnyhaibin + * sunnylink: add units to param metadata by @nayan8teen + * ui: Customizable Onroad Brightness by @sunnyhaibin + * [TIZI/TICI] ui: Steering panel by @nayan8teen + * [TIZI/TICI] ui: Rocket Fuel by @rav4kumar + * [TIZI/TICI] ui: MICI style turn signals by @rav4kumar + * [TIZI/TICI] ui: MICI style blindspot indicators by @sunnyhaibin + * [MICI] ui: display blindspot indicators when available by @rav4kumar + * [TIZI/TICI] ui: Road Name by @rav4kumar + * [TIZI/TICI] ui: Blue "Exit Always Offroad" button by @dzid26 + * [TIZI/TICI] ui: Speed Limit by @rav4kumar + * Reapply "latcontrol_torque: lower kp and lower friction threshold (commaai/openpilot#36619)" by @sunnyhaibin + * [TIZI/TICI] ui: steering arc by @royjr + * [TIZI/TICI] ui: Smart Cruise Control elements by @sunnyhaibin + * [TIZI/TICI] ui: Green Light and Lead Departure elements by @sunnyhaibin + * [TIZI/TICI] ui: standstill timer by @sunnyhaibin + * [MICI] ui: driving models selector by @Discountchubbs + * [TIZI/TICI] ui: Hide vEgo and True vEgo by @sunnyhaibin + * [TIZI/TICI] ui: Visuals panel by @nayan8teen + * Device: Retain QuickBoot state after op switch by @nayan8teen + * [TIZI/TICI] ui: Trips panel by @sunnyhaibin + * [TIZI/TICI] ui: dynamic ICBM status by @sunnyhaibin + * [TIZI/TICI] ui: Cruise panel by @sunnyhaibin + * ui: better wake mode support by @nayan8teen + * Pause Lateral Control with Blinker: Post-Blinker Delay by @CHaucke89 + * SCC-V: Use p97 for predicted lateral accel by @yasu-oh + * Controls: Support for Torque Lateral Control v0 Tune by @sunnyhaibin +* What's Changed (sunnypilot/opendbc) + * Honda: DBC for Accord 9th Generation by @mvl-boston + * FCA: update tire stiffness values for `RAM_HD` by @dparring + * Honda: Nidec hybrid baseline brake support by @mvl-boston + * Subaru Global Gen2: bump steering limits and update tuning by @sunnyhaibin + * Toyota: Enforce Stock Longitudinal Control by @rav4kumar + * Nissan: use MADS enabled status for LKAS HUD logic by @downquark7 + * Reapply "Lateral: lower friction threshold (#2915)" (#378) by @sunnyhaibin + * HKG: add KIA_FORTE_2019_NON_SCC fingerprint by @royjr + * Nissan: Parse cruise control buttons by @downquark7 + * Rivian: Add stalk down ACC behavior to match stock Rivian by @lukasloetkolben + * Tesla: remove `TESLA_MODEL_X` from `dashcamOnly` by @ssysm + * Hyundai Longitudinal: refactor tuning by @Discountchubbs + * Tesla: add fingerprint for Model 3 Performance HW4 by @sunnyhaibin + * Toyota: do not disable radar when smartDSU or CAN Filter detected by @sunnyhaibin + * Honda: add missing `GasInterceptor` messages to Taiwan Odyssey DBC by @mvl-boston + * GM: remove `CHEVROLET_EQUINOX_NON_ACC_3RD_GEN` from `dashcamOnly` by @sunnyhaibin + * GM: remove `CHEVROLET_BOLT_NON_ACC_2ND_GEN` from `dashcamOnly` by @sunnyhaibin +* New Contributors (sunnypilot/sunnypilot) + * @TheSecurityDev made their first contribution in "ui: fix sidebar scroll in UI screenshots" + * @zikeji made their first contribution in "sunnylink: block remote modification of SSH key parameters" + * @Candy0707 made their first contribution in "[TIZI/TICI] ui: Fix misaligned turn signals and blindspot indicators with sidebar" + * @CHaucke89 made their first contribution in "Pause Lateral Control with Blinker: Post-Blinker Delay" + * @yasu-oh made their first contribution in "SCC-V: Use p97 for predicted lateral accel" +* New Contributors (sunnypilot/opendbc) + * @AmyJeanes made their first contribution in "Tesla: Fix stock LKAS being blocked when MADS is enabled" + * @mvl-boston made their first contribution in "Honda: Update Clarity brake to renamed DBC message name" + * @dzid26 made their first contribution in "Tesla: Parse speed limit from CAN" + * @firestar5683 made their first contribution in "GM: Non-ACC platforms with steering only support" + * @downquark7 made their first contribution in "Nissan: use MADS enabled status for LKAS HUD logic" + * @royjr made their first contribution in "HKG: add KIA_FORTE_2019_NON_SCC fingerprint" + * @ssysm made their first contribution in "Tesla: remove `TESLA_MODEL_X` from `dashcamOnly`" +* Full Changelog: https://github.com/sunnypilot/sunnypilot/compare/v2025.002.000...v2026.001.000 + +sunnypilot Version 2025.002.000 (2025-11-06) +======================== +* What's Changed (sunnypilot/sunnypilot) + * models: bump model json to v8 by @Discountchubbs + * Bug: Model UI Crash Fix by @nayan8teen + * controlsd: add `CP_SP` to `get_pid_accel_limits` by @THERoenPR + * sunnylink: update uploader button logic to support novice tier and above by @devtekve + * Tesla: Coop Steering by @AmyJeanes + * ui: update discord references and add forum widget by @devtekve + * ui: Fix spacing in sunnylink panel by @devtekve + * docs: Update README installation branches and discord links by @mpurnell1 in + * stats: sunnylink integration by @devtekve + * bug: Fix initial registration for sunnylink by @devtekve +* What's Changed (sunnypilot/opendbc) + * Honda: add brake hold messages for Clarity by @mvl-boston + * interface: add `CP_SP` to `get_pid_accel_limits` method signature by @roenthomas + * Honda: use fixed accel min/max constants for Gas Interceptor by @roenthomas + * Tesla: Coop Steering by @AmyJeanes +* New Contributors (sunnypilot/sunnypilot) + * @THERoenPR made their first contribution in "controlsd: add `CP_SP` to `get_pid_accel_limits`" + * @AmyJeanes made their first contribution in "Tesla: Coop Steering" + * @mpurnell1 made their first contribution in "docs: Update README installation branches and discord links" +* Full Changelog: https://github.com/sunnypilot/sunnypilot/compare/v2025.001.000...v2025.002.000 + +sunnypilot Version 2025.001.000 (2025-10-25) +======================== +* 🛠️ Major rewrite + * Most features are intended to be identical to previous versions with slight improvements + * Fully adopts upstream commaai’s openpilot, opendbc (car interface and safety), and panda test suites to ensure consistent safety compliance and reliability across all systems + * Added regression testing to verify expected behavior and maintain stability across core modules + * Aligns with comma.ai’s safety policy: preserving driver monitoring, actuation checks, and safety test suite coverage + * Some features have not yet been reimplemented in this rewrite and are temporarily disabled in this release. They may return in future releases once fully ported and validated. See the end of the changelog to get a list of what's not going to be present. +* 🌟 Major Features & Systems + * Modular Assistive Driving System (MADS) + * Complete driving assistance framework + * Driving Model Manager + * Custom driving model selection with support for about 86 models (as of writing), from Night Strike (October 2023) up to The Cool People’s Models (October 2025) + * Neural Network Lateral Control (NNLC) (Formerly NNFF) + * Advanced torque-based lateral control + * Dynamic Experimental Control (DEC) + * Intelligent longitudinal control adaptation + * Speed Limit Assist (SLA) + * Comprehensive speed limit integration featuring @pfeiferj's `mapd` for offline map limits downloads, a Speed Limit Resolver for sourcing data (from car, map, combined, etc), on-screen UI for Speed Limit Information/Warning, and Speed Limit Assist (SLA) to adjust cruise speed automatically. + * Currently disabled for Tesla with sunnypilot Longitudinal Control in release and Rivian with sunnypilot Longitudinal Control in all branches + * May return in future releases + * Intelligent Cruise Button Management (ICBM) + * System designed to manage the vehicle’s speed by sending cruise control button commands to the car’s ECU. + * Smart Cruise Control Map & Vision (SCC-M / SCC-V) + * When using any form of long control (sunnypilot longitudinal control or ICBM) it will control the speed at which you enter and perform a turn by leveraging map data (SCC-M) and/or by leveraging what the model sees about the curve ahead (SCC-V) + * Vehicle Selector + * If your vehicle isn’t fingerprinted automatically, you can still use the vehicle selector to get it working + * sunnylink Integration + * Cloud connectivity and settings backup/restore + * PENDING: The infrastructure is ready for remote setting management, including remote driving model switching. An announcement will be made when this is ready to use in current and future releases. + * External Storage Support + * Expanded storage options + * mapd Integration (thanks to @pfeiferj) + * Allow downloading OpenStreetMap databases for your area, which could be useful for Speed Limit Assist (SLA) +* User Interface Enhancements + * Complete UI Redesign from Default openpilot Experience + * A total overhaul of the sunnypilot offroad user interface for a modern and intuitive experience. + * New Settings Panels + * Reorganized settings into dedicated panels: Steering, Longitudinal, Vehicle, Models, Visuals, Display, and Trips. + * Advanced Controls Toggle + * Out of the box experience has a slightly reduced set of settings for a lower barrier of entry, once you are ready, you can get a few extra settings by toggling on the Advanced Controls. + * Models Panel + * A dedicated panel for model management, featuring a download manager, model folders, a favorites system, fuzzy search, and a cache refresh button. + * Visuals & Display + * Extensive customization options including brightness controls, custom interactivity timeouts, green light indicator, lead vehicle indicator, on-screen turn signals, blind spot indicators, lead chevron info, standstill timer, road name display, and a Tesla-like 🌈 rainbow road path. + * Screen Off while driving + * Options to turn the screen off while driving and customize wake-up behavior for alerts. + * Branch & Platform Selectors + * Improved software management with a searchable branch selector and a platform selector that displays the current fingerprint. + * Developer UI + * An enhanced developer UI with better alert positioning and an integrated error log viewer. + * Convenience Features + * Added an “Exit Offroad” button, “Always Offroad” mode, Quiet Mode, and customizable max time offroad settings. + * OpenStreetMap Database Downloader + * The OpenStreetMap database downloader now includes a search feature for easily finding areas. +* Model and AI Improvements + * Modular Model Backend + * Major refactor of `modeld` to support modular runners (SNPE, thneed, tinygrad) and dynamic model inputs. + * Enhanced Model Outputs + * Models now provide additional outputs like “turn desires” for improved control. + * Live Parameter Adjustments + * Support for live delay adjustments and software delay controls directly from the UI. + * Model Management + * Added model caching, automatic refresh capabilities, and shape inference from inputs for better compatibility. +* Control Systems + * Pause Lateral on Blinker + * Option to temporarily pause lateral control when the turn signal is active. + * Custom ACC Setpoint Increments + * Configure custom increments for adjusting the ACC set speed for applicable vehicle platforms. + * Steering on Brake Press + * Customizable steering behavior when the brake pedal is pressed. + * Enforce Torque Lateral Control + * New customized settings for fine-tuning torque-based steering. + * Automatic Lane Change + * Support for automatic lane changes, including a mode to disable it. +* Technical Infrastructure + * Custom Cereal Implementation + * Migrated sunnypilot-specific events, car parameters, and car controls to a dedicated cereal for better compatibility and performance. + * Car Interface Abstractions + * Refactored car interfaces to support brand-specific settings and easier integration. + * Param Store Caching + * Implemented a cache for the parameter store to reduce startup times, with support for live parameter updates. + * Enhanced Error Handling + * Improved exception management and Sentry logging for better stability and debugging. + * Docker & CI/CD + * Full Docker image support, a dedicated GitHub runner service, and comprehensive improvements to the entire CI/CD pipeline for automated testing, building, and releasing. +* Bug Fixes and Stability + * Registration Requirement Removed + * No longer necessary to register the device to go onroad. + * Panda Firmware Checks + * Improved firmware checks to gracefully handle deprecated Panda devices. + * Numerous Fixes + * Addressed a wide range of bugs across the system for a more stable and reliable experience. +* Developer Experience + * CLion IDE integration and external tools + * Comprehensive testing and build automation + * Model building and publishing automation + * UI preview generation and testing + * Release drafting and version management + * Code quality and maintenance workflows +* Translations and Localization + * Korean translation updates + * Automated translation management system +* ❌ Removed + * Navigate on openpilot (NoO) + * Navigate on openpilot (NoO) has been removed as upstream is prioritizing improving the driving model’s capabilities and simplifying the training stack. + * The feature may return in a future upstream release by comma.ai once model improvements from upstream make it more reliable. + * Visuals: Rocket Fuel + * Visuals: Displaying Braking Status + * Vehicle: Toyota - Enforce Stock Longitudinal Control + * Subaru: Increase Steering Torque + * Longitudinal: Acceleration Personality + * UI: Display CPU Temperature on Sidebar + * Lateral: Block Lane Change with Road Edge Detection + * UI: Display DM Camera in Reverse Gear + * UI: Auto-hide Selected UI Elements + * Visuals: Display End-to-End Longitudinal Status + * Toyota: Stop and Go Hack (alpha) + * Visuals: Onroad Settings + * Honda: Serial Steering Support + * Volkswagen: Non-ACC Platforms Support + * Longitudinal: Dynamic Personality + * Honda Nidec: Allow Stock Longitudinal Control + * Lateral Planner: Dynamic Lane Profile + * Lateral Planner: Laneful Mode + * Lateral: Custom Camera and Path Offsets + * Toyota: Door Controls +* New Contributors (sunnypilot/sunnypilot) + * @royjr made their first contribution in "NNLC: bump max similarity for higher accuracy (#704)" + * @nayan8teen made their first contribution in "UI: Update AbstractControlSP_SELECTOR and OptionControlSP (#800)" + * @wtogami made their first contribution in "TOYOTA_RAV4_PRIME NNLC tuning gen 1 (#850)" + * @dparring made their first contribution in "FCA: Ram 1500 improvements (#797)" + * @Kirito3481 made their first contribution in "Update ko-kr translation (#1167)" + * @michael-was-taken made their first contribution in "Reorder README tables: show -new branches first (#1191)" + * @dzid26 made their first contribution in "params: Fix loading delay on startup (#1297)" + * @HazZelnutz made their first contribution in "Visuals: Turn signals on screen when blinker is used (#1291)" + * @sirmuskrat made their first contribution in "ui: openpilot Longitudinal Control → sunnypilot Longitudinal Control (#1422)" +* New Contributors (sunnypilot/opendbc) + * @chrispypatt made their first contribution in "Toyota: SecOC Longitudinal Control (sunnypilot/opendbc#93)" + * @Discountchubbs made their first contribution in "Hyundai: EPS FW For 2022 KIA_NIRO_EV SCC (sunnypilot/opendbc#118)" + * @lukasloetkolben made their first contribution in "Tesla: enableBsm is always true (sunnypilot/opendbc#163)" + * @roenthomas made their first contribution in "Honda: int flag for modified EPS configs (sunnypilot/opendbc#254)" + * @AmyJeanes made their first contribution in "Tesla: Fix stock LKAS being blocked when MADS is enabled (sunnypilot/opendbc#286)" + * @mvl-boston made their first contribution in "Honda: Update Clarity brake to renamed DBC message name (sunnypilot/opendbc#282)" + * @dzid26 made their first contribution in "Tesla: Parse speed limit from CAN (sunnypilot/opendbc#308)" + * @firestar5683 made their first contribution in "GM: Non-ACC platforms with steering only support (sunnypilot/opendbc#229)" +************************ +* Synced with commaai's openpilot (v0.10.1) + * master commit c9dbf97649a27117be6d5955a49e2d4253337288 (September 12, 2025) +* New driving model + * World Model: removed global localization inputs + * World Model: 2x the number of parameters + * World Model: trained on 4x the number of segments + * Driving Vision Model: trained on 4x the number of segments +* Honda City 2023 support thanks to vanillagorillaa and drFritz! +* Honda N-Box 2018 support thanks to miettal! +* Honda Odyssey 2021-25 support thanks to csouers and MVL! + +sunnypilot - 0.9.7.1 (2024-06-13) +======================== +* New driving model + * Inputs the past curvature for smoother and more accurate lateral control + * Simplified neural network architecture in the model's last layers + * Minor fixes to desire augmentation and weight decay +* New driver monitoring model + * Improved end-to-end bit for phone detection +* Adjust driving personality with the follow distance button +* Support for hybrid variants of supported Ford models +* Fingerprinting without the OBD-II port on all cars +* Improved fuzzy fingerprinting for Ford and Volkswagen +************************ +* UPDATED: Synced with commaai's openpilot + * master commit f8cb04e (June 10, 2024) +* NEW❗: sunnylink (Alpha early access) + * NEW❗: Config/Settings Backup + * Remotely back up and restore sunnypilot settings easily + * Device registration with sunnylink ensures a secure, integrated experience across services + * AES encryption derived from the device's RSA private key is used for utmost security + * Settings are encrypted on-device, transmitted securely via HTTPS, and stored encrypted on sunnylink + * Prevents loss of settings after device resets, offering peace of mind through end-to-end encryption + * Early alpha access to all current and previous GitHub Sponsors and Patreon supporters + * GitHub account pairing from device settings scanning QR code + * Pairing your account will allow you to access features via our API (still WIP but accessible if you dig a little on our code 😉) + * Allow inheritance of your sponsorship status, allowing you to get extra features and early access whenever applicable +* NEW❗: iOS Siri Shortcuts Navigation support thanks to twilsonco and mike86437! + * iOS and macOS Shortcuts to quickly set navigation destinations from your iOS device + * comma Prime support + * Personal Mapbox/Amap/Google Maps token support + * Instructions on how to set up your iOS Siri Shortcuts: https://routinehub.co/shortcut/17677/ +* NEW❗: Forced Offroad mode + * Force sunnypilot in the offroad state even when the car is on + * When Forced Offroad mode is on, allows changing offroad-only settings even when the car is turned on + * To engage/disengage Force Offroad, go to Settings -> Device panel +* UPDATED: Auto Lane Change Timer -> Auto Lane Change by Blinker + * NEW❗: New "Off" option to disable lane change by blinker +* UPDATED: Pause Lateral Below Speed with Blinker + * NEW❗: Customizable Pause Lateral Speed + * Pause lateral actuation with blinker when traveling below the desired speed selected. Default is 20 MPH or 32 km/h. +* UPDATED: Hyundai CAN Longitudinal + * Auto-enable radar tracks on platforms with applicable Mando radar +* UPDATED: Hyundai CAN-FD Camera-based SCC + * NEW❗: Parse lead info for camera-based SCC platforms with longitudinal support + * Improve lead tracking when using openpilot longitudinal +* RE-ENABLED: Map-based Turn Speed Control (M-TSC) for supported platforms + * openpilot Longitudinal Control available cars + * Custom Stock Longitudinal Control available cars +* UPDATED: Continued support for comma Pedal + * In response to the official deprecation of support for comma Pedal in the upstream, sunnypilot will continue maintaining software support for comma Pedal +* UPDATED: Driving Model Selector v4 + * NEW❗: Driving Model additions + * North Dakota (April 29, 2024) - NDv2 + * WD40 (April 09, 2024) - WD40 + * Duck Amigo (March 18, 2024) - DA + * Recertified Herbalist (March 01, 2024) - CHLR + * Legacy Driving Models with Navigate on openpilot (NoO) support + * Includes Duck Amigo and all preceding models +* UPDATED: Bumping mapd by [@pfeiferj](https://github.com/pfeiferj) to version [v1.9.0](https://github.com/pfeiferj/mapd/releases/tag/v1.9.0) thanks to pfeiferj! +* UPDATED: Reset Mapbox Access Token -> Reset Access Tokens for Map Services + * Reset self-service access tokens for Mapbox, Amap, and Google Maps +* UPDATED: Upstream native support for Gap Adjust Cruise +* UPDATED: Neural Network Lateral Control (NNLC) + * Due to upstream changes with platform simplifications, most platforms will match and fallback to combined platform model + * This will be updated when the new mapping of platforms are restructured (thanks @twilsonco 😉) +* UI Updates + * Display Metrics Below Chevron + * NEW❗: Metrics is now being displayed below the chevron instead of above + * NEW❗: Display both Distance and Speed simultaneously + * NEW❗: View sunnylink connectivity status on the left sidebar! + +sunnypilot - 0.9.6.2 (2024-05-29) +======================== +* REMOVED: Screen Recorder + * Screen Recorder is removed due to unnecessary resource usage + * An improved version will be available in the near future. Stay tuned! + +sunnypilot - 0.9.6.1 (2024-02-27) +======================== +* New driving model + * Vision model trained on more data + * Improved driving performance + * Directly outputs curvature for lateral control +* New driver monitoring model + * Trained on larger dataset +* AGNOS 9 +* comma body streaming and controls over WebRTC +* Improved fuzzy fingerprinting for many makes and models +* Alpha longitudinal support for new Toyota models +* Chevrolet Equinox 2019-22 support thanks to JasonJShuler and nworb-cire! +* Dodge Durango 2020-21 support +* Hyundai Staria 2023 support thanks to sunnyhaibin! +* Kia Niro Plug-in Hybrid 2022 support thanks to sunnyhaibin! +* Lexus LC 2024 support thanks to nelsonjchen! +* Toyota RAV4 2023-24 support +* Toyota RAV4 Hybrid 2023-24 support +************************ +* UPDATED: Synced with commaai's openpilot + * master commit db57a21 (February 22, 2024) + * v0.9.6 release (February 27, 2024) +* UPDATED: Dynamic Experimental Control (DEC) + * Synced with dragonpilot-community/dragonpilot:beta3 commit f4ee52f +* NEW❗: Default Driving Model: Certified Herbalist v2 (February 13, 2024) +* UPDATED: Driving Model Selector v3 + * NEW❗: Driving Model additions + * Certified Herbalist v2 (February 13, 2024) - CHv2 + * Certified Herbalist (February 5, 2024) - CH + * Los Angeles v2 (January 24, 2024) - LAv2 + * Los Angeles (January 22, 2024) - LAv1 + * NEW❗: Model Caching thanks to DevTekVE! + * Model caching allows the selection of previously downloaded Driving Model + * Users can now access cached versions of selected models, eliminating redundant downloads for previously fetched models + * Legacy Driving Models support + * New Delhi (December 21, 2023) - ND + * Blue Diamond v2 (December 11, 2023) - BDv2 + * Blue Diamond (November 18, 2023) - BDv1 + * Farmville (November 7, 2023) - FV + * Night Strike (October 3, 2023) - NS + * Certain features are deprecated with newer Driving Models + * Dynamic Lane Profile (DLP) + * Custom Offsets +* UPDATED: Dynamic Lane Profile (DLP) + * Continued support for Legacy Driving Models (e.g., ND, BDv2, BDv1, FV, NS) + * Deprecated support for newer Driving Models (e.g., CHv2, CH, LAv2, LAv1) +* UPDATED: Custom Offsets + * Continued support for Legacy Driving Models (e.g., ND, BDv2, BDv1, FV, NS) + * Deprecated support for newer Driving Models (e.g., CHv2, CH, LAv2, LAv1) +* UPDATED: Hyundai/Kia/Genesis - ESCC Radar Interceptor + * Message parsing improvements with the latest firmware update: https://github.com/sunnypilot/panda/tree/test-escc-smdps +* UI Updates + * NEW❗: Visuals: Display Feature Status toggle + * Display the statuses of certain features on the driving screen + * NEW❗: Visuals: Enable Onroad Settings toggle + * Display the Onroad Settings button on the driving screen to adjust feature options on the driving screen, without navigating into the settings menu + * REMOVED: "Device ambient" temperature option on the sidebar +* FIXED: New comma 3X support +* FIXED: New comma eSIM support +* Bug fixes and performance improvements + +sunnypilot - 0.9.5.3 (2023-12-24) +======================== +* UPDATED: Dynamic Experimental Control (DEC) + * Synced with dragonpilot-community/dragonpilot:lp-dp-beta2 commit 578d38b +* UPDATED: Driving Model Selector v2 + * Driving models sort in descending order based on availability date + * Experimental/unmerged driving models are only available in "dev-c3" branch + * To select and use experimental driving models, navigate to "Software" panel, select the "dev-c3" branch, and check for update +* UPDATED: Vision-based Turn Speed Control (V-TSC) implementation + * Refactored implementation thanks to pfeiferj! + * More accurate and consistent velocity calculation to achieve smoother longitudinal control in curves +* NEW❗: Speed Limit Warning + * Display alert and/or chime to warn the driver when the cruising speed is faster than the speed limit plus the Warning Offset + * Customizable Warning Offset, independent of Speed Limit Control (SLC)'s Limit Offset +* UPDATED: Speed Limit Source Policy + * Selectable speed limit source for Speed Limit Control and Speed Limit Warning + * Applicable to: Speed Limit Control, Speed Limit Warning +* UPDATED: Speed Limit Control (SLC) + * Engage Mode: Removed "Warning Only" mode - this has been replaced by the new Speed Limit Warning sub-menu +* UPDATED: OpenStreetMap (OSM) implementation + * Refactored implementation thanks to pfeiferj! + * Less resource impact + * Significantly smaller sizes with databases + * All regions are available to download + * Weekly map updates thanks to pfeiferj! + * Increased the font size of the road name + * C3X-specific changes + * Altitude (ALT.) display on Developer UI + * Current street name on top of driving screen when "OSM Debug UI" is enabled +* UPDATED: Map-based Turn Speed Control (M-TSC) implementation + * Only available in "staging-c3" and "dev-c3" branches. If you are using "release-c3" branch, navigate to "Software" panel, select the desired target branch, and check for update + * Refactored implementation thanks to pfeiferj! + * Based on the new OpenStreetMap implementation + * Improved predicted curvature calculations from OpenStreetMap data +* UI updates + * RE-ENABLED: Navigation: Full screen support + * Display the map view in full screen + * To switch back to driving view, tap on the border edge +* Hyundai Bayon Non-SCC 2019 support thanks to polein78! + +sunnypilot - 0.9.5.2 (2023-12-07) +======================== +* NEW❗: MADS: Allow Navigate on openpilot in Chill Mode + * Allow navigation to feed map view into the driving model while using Chill Mode + * Support all platforms, including platforms that do not support openpilot longitudinal control & Experimental Mode +* NEW❗: Neural Network Lateral Controller + * Formerly known as "NNFF", this replaces the lateral "torque" controller with one using a neural network trained on each car's (actually, each separate EPS firmware) driving data for increased controls accuracy + * Contact @twilsonco in the sunnypilot Discord server with feedback, or to provide log data for your car if your car is currently unsupported +* NEW❗: Driving Model Selector + * Easily switch between driving models without reinstalling branches. Offering immediate access to the latest models upon release + * An internet connection is required for downloading models. Each model switch currently involves downloading the model again. Future updates may allow for offline switching + * Warning is displayed for metered connections to avoid unexpected data usage if on cellular data + * Change driving models via **Settings -> Software -> Current Driving Model**. +* NEW❗: Hyundai CAN longitudinal: + * NEW❗: Enable radar tracks for certain Santa Fe platforms + * Internal Combustion Engine (ICE) 2021-23 + * Hybrid 2022-23 + * Plug-in Hybrid 2022-23 +* NEW❗: Lane Change: When manually braking with steering engaged, turning on the turn signal will default to Nudge mode +* Volkswagen MQB CC only platforms (radar or no radar) support thanks to jyoung8607! + +sunnypilot - 0.9.5.1 (2023-11-17) +======================== +* UPDATED: Synced with commaai's master commit e94c3c5 +* NEW❗: Farmville driving model +* NEW❗: Onroad Settings Panel + * Onroad buttons (i.e., DLP, GAC) moved to its dedicated panel + * Driving Personality + * Dynamic Lane Profile (DLP) + * Dynamic Experimental Control (DEC) + * Speed Limit Control (SLC) +* NEW❗: Display main feature status on onroad view in real-time + * GAP - Driving Personality + * DLP - Dynamic Lane Profile + * DEC - Dynamic Experimental Control + * SLC - Speed Limit Control +* NEW❗: Dynamic Experimental Control (DEC) thanks to dragonpilot-community! + * Automatically determines and selects between openpilot ACC and openpilot End to End longitudinal based on conditions for a more natural drive + * Dynamic Experimental Control is only active while in Experimental Mode + * When Dynamic Experimental Control is ON, initially setting cruise speed will set to the vehicle's current speed +* NEW❗: Hyundai CAN longitudinal: + * NEW❗: Parse lead info for camera-based SCC platforms + * Improve lead tracking when using openpilot longitudinal + * NEW❗: Parse lead distance to display on car cluster + * Introduced better lead distance calculation to display on the car's cluster, replacing the binary "lead visible" indication on the SCC cluster + * Lead distance is now categorized into different ranges for more detailed and comprehensive information to the driver similar to how stock ACC does it + * NEW❗: Parse speed limit sign recognition from camera for certain supported platforms +* NEW❗: Subaru - Stop and Go auto-resume support thanks to martinl! + * Global (excluding Gen 2 and Hybrid) and Pre-Global support +* NEW❗: Toyota - Stop and Go hack + * Allow some Toyota/Lexus cars to auto resume during stop and go traffic + * Only applicable to certain models and model years +* NEW❗: Toyota: ZSS support thanks to dragonpilot-community and ErichMoraga! +* NEW❗: MSPA (Cereal structs refactor) + * Make sunnypilot Parsable Again - @sshane + * sunnypilot is now parsable with stock openpilot tools +* NEW❗: Display 3D buildings on map thanks to jakethesnake420! +* openpilot Longitudianl Control capable cars only + * UPDATED: Gap Adjust Cruise is now a part of Driving Personality + * [DISTANCE/FOLLOW DISTANCE/GAP DISTANCE] physical button on the steering wheel to select Driving Personality on by default + * Status now viewable in onroad view or Onroad Settings Panel + * REMOVED: Gap Adjust Cruise toggle +* UPDATED: Speed Limit Control (SLC) + * NEW❗: Speed Limit Engage Mode + * Select the desired mode to set the cruising speed to the speed limit + * Warning Only: Warn the driver when the vehicle is driven faster than the speed limit + * Auto: Automatic speed adjustment on motorways based on speed limit data + * User Confirm: Inform the driver to change set speed of Adaptive Cruise Control to help the driver stay within the speed limit + * Supported platforms + * openpilot Longitudinal Control available cars (Excluding certain Toyota/Lexus, Ford, explained below) + * Custom Stock Longitudinal Control available cars + * Unsupported platforms + * Toyota/Lexus and Ford - most platforms do not allow us to control the PCM's set speed, requires testers to verify + * NEW❗: Speed limit source selector + * Select the desired precedence order of sources used to adapt cruise speed to road limits +* UPDATED: Custom Stock Longitudinal Control + * RE-ENABLED: Hyundai/Kia/Genesis CAN-FD platforms +* UPDATED: Custom Offsets reimplementation + * Camera Offset only works in Laneful (Laneful Only or Laneful in Auto mode when using Dynamic Lane Profile) + * Path Offset can be applied to both Laneless and Laneful +* UPDATED: Refactored Torque Lateral Control custom tuning menu + * NEW❗: Less Restrict Settings for Self-Tune (Beta) + * NEW❗: Custom Tuning for setting offline and live values in real-time +* UPDATED: Auto-detect custom Mapbox token if a personal Mapbox token is provided + * REMOVED: "Enable Mapbox Navigation" toggle +* UI updates + * New Settings menu redesign and improved interactions +* FIXED: Retain hotspot/tethering state was not consistently saved +* FIXED: Map stuck in "Map Loading" if comma Prime is active +* FIXED: OpenStreetMap implementation on C3X devices + * M-TSC + * Altitude (ALT.) display on Developer UI + * Current street name on top of driving screen when "OSM Debug UI" is enabled +* Hyundai Kona Non-SCC 2019 support thanks to Quex! +* Kia Seltos Non-SCC 2023-24 support thanks to Moodkiller and jeroid_! + +sunnypilot - 0.9.4.1 (2023-08-11) +======================== +* UPDATED: Synced with commaai's 0.9.4 release +* NEW❗: Moonrise driving model +* NEW❗: Ford upstream models support +* UPDATED: Dynamic Lane Profile selector in the "SP - Controls" menu +* REMOVED: Dynamic Lane Profile driving screen UI button +* FIXED: Disallow torque lateral control for angle control platforms (e.g. Ford, Nissan, Tesla) + * Torque lateral control cannot be used by angle control platforms, and would cause a "Controls Unresponsive" error if Torque lateral control is enforced in settings +* REMOVED: Speed Limit Style override +* Honda Accord 2016-17 support thanks to mlocoteta! + * Serial Steering hardware required. For more information, see https://github.com/mlocoteta/serialSteeringHardware +* mapd: utilize advisory speed limit in curves (#142) thanks to pfeiferj! + +sunnypilot - 0.9.3.1 (2023-07-09) +======================== +* UPDATED: Synced with commaai's 0.9.3 release +* NEW❗: Display Temperature on Sidebar toggle + * Display Ambient temperature, memory temperature, CPU core with the highest temperature, GPU temperature, or max of Memory/CPU/GPU on the sidebar + * Replace "Display CPU Temperature on Sidebar" toggle +* NEW❗: Hot Coffee driving model +* NEW❗: HKG CAN: Smoother Stopping Performance (Beta) toggle + * Smoother stopping behind a stopped car or desired stopping event. + * This is only applicable to HKG CAN platforms using openpilot longitudinal control +* NEW❗: Toyota: TSS2 longitudinal: Custom Tuning + * Smoother longitudinal performance for Toyota/Lexus TSS2/LSS2 cars thanks to dragonpilot-community! +* NEW❗: Enable Screen Recorder toggle + * Enable this will display a button on the onroad screen to toggle on or off real-time screen recording with UI elements. +* IMPROVED: Dynamic Lane Profile: when using Laneline planner via Laneline Mode or Auto Mode, enforce Laneless planner while traveling below 10 MPH or 16 km/h +* REMOVED: Display CPU Temperature on Sidebar + +sunnypilot - 0.9.2.3 (2023-06-18) +======================== +* NEW❗: Auto Lane Change: Delay with Blind Spot + * Toggle to enable a delay timer for seamless lane changes when blind spot monitoring (BSM) detects an obstructing vehicle, ensuring safe maneuvering +* NEW❗: Driving Screen Off: Wake with Non-Critical Events + * When Driving Screen Off Timer is not set to "Always On": + * Enabled: Wake the brightness of the screen to display all events + * Disabled: Wake the brightness of the screen to display critical events + * Currently, all non-nudge modes are default to continue lane change after 1 seconds of blind spot detection +* NEW❗: Fleet Manager PIN Requirement toggle + * User can now enable or disable PIN requirement on the comma device before accessing Fleet Manager +* NEW❗: Reset all sunnypilot settings toggle +* NEW❗: Turn signals display on screen when blinker is used + * Green: Blinker is on + * Red: Blinker is on, car detected in the adjacent blind spot or road edge detected +* IMPROVED: mapd: better exceptions handling when loading dependencies +* UPDATED: Green Traffic Light Chime no longer displays an orange border when executed +* FIXED: mapd: Road name flashing caused by desync with last GPS timestamp +* FIXED: Ram HD (2500/3500): Ignore paramsd sanity check + * Live parameters have trouble with self-tuning on this platform with upstream openpilot 0.9.2 +* Hyundai: Longitudinal support for CAN-based Camera SCC cars thanks to Zack1010OP's Patreon sponsor! + +sunnypilot - 0.9.2.2 (2023-06-13) +======================== +* NEW❗: Toyota: Allow M.A.D.S. toggling with LKAS Button (Beta) +* IMPROVED: Ram: cruise button handling + +sunnypilot - 0.9.2.1 (2023-06-10) +======================== +* UPDATED: Synced with commaai's 0.9.2 release +* UPDATED: feature revamp with better stability +* UPDATED: + * M.A.D.S. + * Path color becomes LIGHT ORANGE during Driver Steering Override + * Gap Adjust Cruise (now known as Driving Personality in upstream openpilot 0.9.3): + * Updated profiles and jerk changes + * Experimental Mode support + * Three settings: Stock, Aggressive, and Maniac + * Stock is recommended and the default + * In Aggressive/Maniac mode, lead follow distance is shorter and quicker gas/brake response + * Dynamic Lane Profile + * Display blue borders on both sides of the driving path when Laneline mode is being used in the planner + * Auto Mode optimization + * Permanent: Laneless during Auto Lane Change execution + * Mapd + * OpenStreetMap Database: new regions added + * Developer UI (Dev UI) + * REMOVED: 2-column design + * NEW❗: 1-column + 1-row design + * Custom Stock Longitudinal Control + * NEW❗: Chrysler/Jeep/Ram support + * NEW❗: Mazda support + * NEW❗: Volkswagen PQ support + * DISABLED: Hyundai/Kia/Genesis CAN-FD platforms +* NEW❗: Switch between Chill (openpilot ACC) and Experimental (E2E longitudinal) with DISTANCE button on the steering wheel + * To switch between Chill and Experimental Mode: press and hold the DISTANCE button on the steering wheel for over 0.5 second + * All openpilot longitudinal capable cars support +* NEW❗: Nicki Minaj driving model +* NEW❗: Nissan and Mazda upstream models support +* NEW❗: Pre-Global Subaru upstream models support +* NEW❗: Display End-to-end Longitudinal Status (Beta) + * Display an icon that appears when the End-to-end model decides to start or stop +* NEW❗: Green Traffic Light Chime (Beta) + * A chime will play when the traffic light you are waiting for turns green, and you have no vehicle in front of you. +* NEW❗: Lead Vehicle Departure Alert + * Notify when the leading vehicle drives away +* NEW❗: Speedometer: Display True Speed + * Display the true vehicle current speed from wheel speed sensors. +* NEW❗: Speedometer: Hide from Onroad Screen +* NEW❗: Auto-Hide UI Buttons + * Hide UI buttons on driving screen after a 30-second timeout. Tap on the screen at anytime to reveal the UI buttons + * Applicable to Dynamic Lane Profile (DLP) and Gap Adjust Cruise (GAC) +* NEW❗: Display DM Camera in Reverse Gear + * Show Driver Monitoring camera while the car is in reverse gear +* NEW❗: Block Lane Change: Road Edge Detection (Beta) + * Block lane change when road edge is detected on the stalk actuated side +* NEW❗: Display CPU Temperature on Sidebar + * Display the CPU core with the highest temperature on the sidebar +* NEW❗: Display current driving model in Software settings +* NEW❗: HKG: smartMDPS automatic detection (installed with applicable firmware) +* FIXED: Unintended siren/alarm from the comma device if the vehicle is turned off too quickly in PARK gear +* FIXED: mapd: Exception handling for loading dependencies +* Fleet Manager via Browser support thanks to actuallylemoncurd, AlexandreSato, ntegan1, and royjr! + * Access your dashcam footage, screen recordings, and error logs when the car is turned off + * Connect to the device via Wi-Fi, mobile hotspot, or tethering on the comma device, then navigate to http://ipAddress:5050 to access. +* Honda Clarity 2018-22 support thanks to mcallbosco, vanillagorillaa and wirelessnet2! +* Ram: Steer to 0/7 MPH support thanks to vincentw56! +* Retain hotspot/tethering state across reboots thanks to rogerioaguas! + +sunnypilot - Version Latest (2023-02-22) +======================== +* UPDATED: Synced with commaai's master branch - 2023.02.19-04:52:00:GMT - 0.9.2 +* Refactor sunnypilot features to be more stable + +sunnypilot - Version Latest (2022-12-16) +======================== +* UPDATED: Synced with commaai's master branch - 2022.12.16-06:31:00:GMT - 0.9.1 +* NEW❗: GM: + * NEW❗: Gap Adjust Cruise support - Chill, Normal, Aggressive + * NEW❗: Experimental Mode: Hold DISTANCE button on the steering wheel for 0.5 second to switch between Experimental Mode and Chill Mode +* REMOVED❌: Toytoa: SnG Hack + * This method is not recommended and may cause some cars to not behave as expected + * SDSU is strongly recommended to enable SnG for Toyota vehicles without SnG from factory +* commaai: radard: add missing accel data for vision-only leads (commaai/openpilot#26619) - pending PR + * VOACC performance is drastically improved when using Chill Mode +* IMPROVED: M.A.D.S. events handling +* IMPROVED: UI: screen recorder button change +* IMPROVED: OpenStreetMap Offline Database optimization +* FIXED: Toyota: vehicles' LKAS button no longer has a delay with toggling M.A.D.S. +* FIXED: Toyota: brake pedal press at standstill causing Cruise Fault +* FIXED: Volkswagen MQB: reduce Camera Malfunction occurrences (requires testing) +* Bug fixes and performance improvements + +sunnypilot - Version Latest (2022-12-10) +======================== +* IMPROVED: NEW❗ Developer UI design + * Second column metrics is now moved to the bottom of the screen + * ACC. = Acceleration + * L.S. = Lead Speed + * E.T. = EPS Torque + * B.D. = Bearing Degree + * FRI. = Friction + * L.A. = Lateral Acceleration + * ALT. = Altitude +* Bug fixes and performance improvements + +sunnypilot - Version Latest (2022-12-07) +======================== +* NEW❗: Screen Recorder support thanks to neokii and Kumar! +* NEW❗: End-to-end longitudinal start/stop status icon + * Only appears when Experimental Mode is enabled +* NEW❗: End-to-end longitudinal car chime when starting + * Hyundai/Kia/Genesis CAN platform, Honda/Acura Bosch/Nidec, Toyota/Lexus + * i.e. Traffic light turns green, stop sign ready to go, etc. + * Only appears when Experimental Mode is enabled AND longitudinal control is disengaged +* Bug fixes and performance improvements + +sunnypilot - Version Latest (2022-12-05) +======================== +* UPDATED: Synced with commaai's master branch - 2022.12.04-22:46:00:GMT - 0.9.1 +* Bug fixes and performance improvements + +sunnypilot - Version Latest (2022-11-12) +======================== +* UPDATED: Synced with commaai's master branch - 2022.11.12-10:02:00:GMT - 0.8.17 +* FIXED: CAN Error for CAN HKG cars that do not have navigation from the factory +* Bug fixes and performance improvements + +sunnypilot - Version Latest (2022-11-11) +======================== +* UPDATED: Synced with commaai's master branch - 2022.11.11-21:22:00:GMT - 0.8.17 +* commaai: AGNOS 6.2 (commaai/openpilot#26441) +* NEW❗: Speed Limit Control - HKG - add speed limit from car's navigation head unit + * Compatible with certain models, trims, and model years +* DISABLED: FCA: RAM HD - steer down to 0 +* FIXED: UI: End-to-end longitudinal button on driving screen synchronization +* FIXED: Honda: Longitudinal status with set cruise speed now displays properly in the car's dashboard +* Bug fixes and performance improvements + +sunnypilot - Version Latest (2022-11-08) +======================== +* ADDED: New Zealand offline OpenStreetMap database + +sunnypilot - Version Latest (2022-11-04) +======================== +* UPDATED: Synced with commaai's master branch - 2022.11.05-01:44:00:GMT - 0.8.17 +* RE-ENABLED: Dynamic Lane Profile - preserves lanelines + * Can be found in "SP - Controls" menu +* NEW❗: DLP: switch to laneless for current/future curves thanks to @twilsonco! + * Can be found in "SP - Controls" menu +* NEW❗: UI: Road Camera Selector + * Enable this will display a button on the driving screen to select the driving camera + * Can be found in "SP - Visuals" menu +* NEW❗: Controls: Camera & Path Custom Offsets + * Only applicable to laneline mode when using Dynamic Lane Profile +* NEW❗: Buttons on driving screen are now sorted based on priority and availability +* Bug fixes and performance improvements + +sunnypilot - Version Latest (2022-10-28) +======================== +* UPDATED: Synced with commaai's master branch - 2022.10.28-03:53:00:GMT - 0.8.17 +* Bug fixes and performance improvements + +sunnypilot - Version Latest (2022-10-26) +======================== +* UPDATED: Synced with commaai's master branch - 2022.10.26-06:20:00:GMT - 0.8.17 +* Bug fixes and performance improvements + +sunnypilot - Version Latest (2022-10-25) +======================== +* UPDATED: Synced with commaai's master branch - 2022.10.25-23:53:00:GMT - 0.8.17 +* Pre-Global Subaru support thanks to @martinl! +* NEW❗: Speed Limit values turn red when current speed is higher than posted speed limit +* Bug fixes and performance improvements + +sunnypilot - Version Latest (2022-10-23) +======================== +* UPDATED: Synced with commaai's master branch - 2022.10.22-23:15:00:GMT - 0.8.17 +* IMPROVED: Custom Stock Longitudinal Control - HKG - only allow engagement on user button press +* IMPROVED: Custom Stock Longitudinal Control - Volkswagen MQB & PQ - more consistent set speed change +* Bug fixes and performance improvements + +sunnypilot - Version Latest (2022-10-21) +======================== +* UPDATED: Synced with commaai's master branch - 2022.10.21-17:33:00:GMT - 0.8.17 +* IMPROVED: Custom Stock Longitudinal Control - Volkswagen MQB & PQ - more predictable button send logic +* Bug fixes and performance improvements + +sunnypilot - Version Latest (2022-10-20) +======================== +* UPDATED: Synced with commaai's master branch - 2022.10.20-20:25:00:GMT - 0.8.17 +* Bug fixes and performance improvements + +sunnypilot - Version Latest (2022-10-19) +======================== +* UPDATED: Synced with commaai's master branch - 2022.10.19-08:31:00:GMT - 0.8.17 +* IMPROVED: Controls: Speed Limit Control - accelerator press only disengage if "Disengage on Accelerator Pedal" is enabled +* Bug fixes and performance improvements + +sunnypilot - Version Latest (2022-10-18) +======================== +* UPDATED: Synced with commaai's master branch - 2022.10.18-04:44:00:GMT - 0.8.17 +* RE-ENABLED: Volkswagen MQB & PQ with Custom Stock Longitudinal Control +* NEW❗: Steering Rate Cost Live Tune + * Enables live tune for Steering Rate Cost. Lower value allows steering wheel to move more freely at low speed + * Can be found in "SP - Controls" menu +* FIXED: MADS: GM - include Regen Paddle logic thanks to @twilsonco! +* Bug fixes and performance improvements + +sunnypilot - Version Latest (2022-10-17) +======================== +* UPDATED: Synced with commaai's master branch - 2022.10.17-23:54:00:GMT+1 - 0.8.17 +* ENABLED: "Custom Stock Longitudinal Control" toggle for CAN-FD cars +* FIXED: HKG CAN-FD: Could not engage when openpilot longitudinal is enabled +* Bug fixes and performance improvements + +sunnypilot - Version Latest (2022-10-13) +======================== +* UPDATED: Synced with commaai's master branch - 2022.10.13-19:43:00:GMT+1 - 0.8.17 +* ADDED: Live Tmux toggle + * Can be found in "SP - General" menu +* IMPROVED: OpenStreetMap Database Update - only check for database update with explicit user decision +* Bug fixes and performance improvements + +sunnypilot - Version Latest (2022-10-11) +======================== +* ADDED: Hyundai openpilot longitudinal improvements - huge thanks to @aragon7777! +* ADDED: Check for OpenStreetMap Database Update button +* UPDATED: commaai: Low speed lateral control improvements (commaai:openpilot#26022, bbcd448) - pending PR +* FIXED: MUTCD speed limit spacing adjusts dynamically when no subtext is shown (i.e., speed limit offset, distance to next speed limit) +* FIXED: MADS: Intermittent CAN Error when engaging for Toyota Prius TSS-P +* Bug fixes and performance improvements + +sunnypilot - Version Latest (2022-10-09) +======================== +* ADDED: commaai: Low speed lateral control improvements (commaai:openpilot#26022, bca288bb) - pending PR +* FIXED: MADS: Intermittent CAN Error when engaging for Toyota Prius TSS-P +* IMPROVED: mapd: stop signs and other supported traffic_calming tags are now slowing/stopping as expected +* Bug fixes and performance improvements + +sunnypilot - Version Latest (2022-10-08) +======================== +* UPDATED: Synced with commaai's master branch - 2022.10.08-12:07:00:GMT+1 - 0.8.17 +* FIXED: MADS: Intermittent CAN Error when engaging for Toyota Prius TSS-P +* IMPROVED: mapd: Speed Humps are now set at 20 MPH or 32 km/h +* IMPROVED: OpenStreetMap Offline Database download experience +* Bug fixes and performance improvements + +sunnypilot - Version Latest (2022-10-07) +======================== +* UPDATED: Synced with commaai's master branch - 2022.10.07-08:16:00:GMT - 0.8.17 +* NEW❗: OpenStreetMap database can now be downloaded locally for offline use + * Now offering US South, US West, US Northeast, US Florida, Taiwan, and South Africa + * Databases updated - 2022.10.05-03:30:00:GMT +* NEW❗: mapd: Stop Sign, Yield, Speed Bump, Speed Hump, Sharp Curve support - huge thanks to @move-fast and @dragonpilot-community! + * Go to https://openstreetmap.org and start mapping out your area! +* Bug fixes and performance improvements + +sunnypilot - Version Latest (2022-09-30) +======================== +* UPDATED: Synced with commaai's master branch - 2022.09.30-22:43:00:GMT - 0.8.17 +* RE-ADDED: Torque Lateral Controller Live Tune Menu +* Bug fixes and performance improvements + +sunnypilot - Version Latest (2022-09-23) +======================== +* ADDED: Developer UI: latAccelFactorFiltered & frictionCoefficientFiltered values displays in green if Torque is using live params +* Bug fixes and performance improvements + +sunnypilot - Version Latest (2022-09-22) +======================== +* UPDATED: Synced with commaai's master branch - 2022.09.19-22:19:00:GMT - 0.8.17 +* NEW❗: Toggle to explicitly enable Custom Stock Longitudinal Control + * Applicable cars only: Honda, Hyundai/Kia/Genesis + * Settings -> Toggles menu + +sunnypilot - Version Latest (2022-09-21) +======================== +* UPDATED: Synced with commaai's master branch - 2022.09.19-22:19:00:GMT - 0.8.17 +* ADDED: Toggle to enable Live Torque (self/auto tune) with Torque lateral controller + * To enable, first enable "Enforce Torque Lateral Controller" toggle +* UPDATED: New metrics in Developer UI (when Live Torque is enabled) + * REMOVED: latAccelFactorRaw & frictionCoefficientRaw from torqued + * ADDED: latAccelFactorFiltered & frictionCoefficientFiltered from torqued +* REMOVED: Temporary remove Torque Lateral Controller Live Tune Menu + +sunnypilot - Version Latest (2022-09-20) +======================== +* UPDATED: Synced with commaai's master branch - 2022.09.19-22:19:00:GMT - 0.8.17 +* ADDED: Toggle to enable Live Torque (self/auto tune) with Torque lateral controller + * To enable, first enable "Enforce Torque Lateral Controller" toggle +* REMOVED: Temporary remove Torque Lateral Controller Live Tune Menu + +sunnypilot - Version Latest (2022-09-18) +======================== +* UPDATED: Synced with commaai's master branch - 2022.09.17-11:23:00:GMT - 0.8.17 +* ADDED: Kia Forte Non-SCC 2019 support for @askalice +* FIXED: Torque Lateral Control Live Tune now syncs with commaai:openpilot#25822 +* FIXED: mapd dependencies no longer need to be re-downloaded after unknown reboots + +sunnypilot - Version Latest (2022-09-17) +======================== +* UPDATED: Synced with commaai's master branch - 2022.09.17-11:23:00:GMT - 0.8.17 +* NEW❗: Non SCC HKG support + * Custom Stock Longitudinal Control + * ❗No❗ openpilot longitudinal control +* FIXED: Honda Bosch random low-value set speed changes + +sunnypilot - Version Latest (2022-09-16) +======================== +* UPDATED: Synced with commaai's master branch - 2022.09.16-20:23:00:GMT - 0.8.17 + +sunnypilot - Version Latest (2022-09-15) +======================== +* UPDATED: Synced with commaai's master branch - 2022.09.16-02:00:00:GMT - 0.8.17 +* FIXED: Block additional auto lane change actions if blinker stays on after the first lane change +* REVERTED: Some Toyota with LKAS button no longer requires double press to engage/disengage M.A.D.S. + +sunnypilot - Version Latest (2022-09-14)u +======================== +* UPDATED: Synced with commaai's master branch - 2022.09.11-02:47:00:GMT - 0.8.17 +* NEW❗: GM models supported in Force Car Recognition (FCR) + * Under "SP - Vehicles" +* NEW❗: Prompt to select car in "SP - Vehicles" if car unrecognized on startup +* FIXED: Some Toyota with LKAS button no longer requires double press to engage/disengage M.A.D.S. +* UPDATED: ESCC: Use radar tracks from radar if available + +sunnypilot - Version Latest (2022-09-13) +======================== +* UPDATED: Synced with commaai's master branch - 2022.09.11-02:47:00:GMT - 0.8.17 +* NEW❗: New metric in Developer UI + * Actual Lateral Acceleration (Roll Compensated) + +sunnypilot - Version Latest (2022-09-12) +======================== +* UPDATED: Synced with commaai's master branch - 2022.09.11-02:47:00:GMT - 0.8.17 +* FIXED: Honda Nidec models not gaining speed when longitudinal engaged + +sunnypilot - Version Latest (2022-09-11) +======================== +* UPDATED: Synced with commaai's master branch - 2022.09.11-02:47:00:GMT - 0.8.17 +* NEW❗: Hyundai Enhanced SCC now forwards FCW and AEB signals and commands from radar to car +* RE-ENABLED: MADS Status Icon toggle + +sunnypilot - Version Latest (2022-09-10) +======================== +* UPDATED: Synced with commaai's master branch - 2022.09.11-02:47:00:GMT - 0.8.17 +* NEW❗: RAM improvement implementation thanks to realfast! +* DISABLED: Chrysler/Jeep/Ram with Custom Stock Longitudinal Control +* DISABLED: Volkswagen MQB & PQ with Custom Stock Longitudinal Control + +sunnypilot - Version Latest (2022-09-09) +======================== +* UPDATED: Synced with commaai's master branch - 2022.09.09-07:35:00:GMT - 0.8.17 +* NEW❗: MADS now supporting General Motors (GM) +* ADDED: Custom Stock Longitudinal Control - Volkswagen + * MQB & PQ +* ADDED: Reverse ACC Change + * ACC +/-: Short=5, Long=1 +* ADDED: Custom Stock Longitudinal Control + * Hyundai/Kia/Genesis + * Honda Bosch +* ADDED: Hyundai: 2015-16 Genesis resume from standstill fix (commaai:openpilot#25579) - pending PR +* Vision Turn Speed Control re-enabled +* Disable Onroad Uploads toggle re-enabled + +sunnypilot - Version Latest (2022-09-08) +======================== +* UPDATED: Synced with commaai's master branch - 2022.09.08-04:05:00:GMT - 0.8.17 +* NEW❗: Block lane change initiation while brake is pressed + +sunnypilot - Version Latest (2022-09-07) +======================== +* UPDATED: Synced with commaai's master branch - 2022.09.08-04:05:00:GMT - 0.8.17 +* NEW❗: Display End-to-end longitudinal 🌮 on screen + * NEW❗: Hold DISTANCE button on the steering wheel for 1 second to switch between E2E Long and ACC mode + * Enable toggle on the driving screen to switch between modes with End-to-end longitudinal + * Only applicable to cars with openpilot longitudinal control +* NEW❗: Block lane change initiation while brake is pressed +* REMOVED: Dynamic Lane Profile - upstream laneless model is now on by default +* REMOVED: hyundai: consistent start from stop (commaai:openpilot#25672) - pending PR + +sunnypilot - Version Latest (2022-09-06) +======================== +* UPDATED: Synced with commaai's master branch - 2022.09.06 - 0.8.17 +* NEW❗: Display useful metrics above the chevron that tracks the lead car + * Under "SP - Visuals" menu + * Only applicable to cars with openpilot longitudinal control +* ADDED: hyundai: consistent start from stop (commaai:openpilot#25672) - pending PR +* FIXED: Vienna speed limit interface now scales properly with the outer box +* REMOVED: Hyundai long improvements (commaai:openpilot#25604) - closed PR + +sunnypilot - Version Latest (2022-09-05) +======================== +* UPDATED: Synced with commaai's master branch - 2022.09.03 - 0.8.17 +* NEW❗: Speed Limit Control (SLC) interface integrated with upstream +* NEW❗: Speed limit from active navigation is now prioritized for Speed Limit Control +* NEW❗: MUTCD (U.S.) or Vienna (E.U.) speed limit interfaces can now be selected under "SP - Controls" + +sunnypilot - Version Latest (2022-09-04) +======================== +* UPDATED: Synced with commaai's master branch - 2022.09.03 - 0.8.17 +* FIXED: Gap Adjust Cruise status now displays properly on screen +* FIXED: mapd - missing index in list caused mapd to crash +* REMOVED: Temporary removed Vision Turn Speed Control + +sunnypilot - Version Latest (2022-09-03) +======================== +* UPDATED: Synced with commaai's master branch - 2022.09.03 - 0.8.17 +* ADDED: New border colors for different operation engagements +* ADDED: UI: Show barrier when car detected in blind spot + * Only applicable to cars that have BSM detection with openpilot +* FIXED: Cruise Cancel button no longer display prompt if cruise not engaged +* TWEAKED: Update changelogs on startup in Settings -> Software -> Version +* REMOVED: Upload Raw Logs and Full Resolution Videos toggles + +sunnypilot - Version Latest (2022-08-31) +======================== +* UPDATED: Synced with commaai's master branch - 2022.08.31 - 0.8.17 +* ADDED: New border colors for different operation engagements +* ADDED: UI: Show barrier when car detected in blind spot + * Only applicable to cars that have BSM detection with openpilot +* FIXED: Cruise Cancel button no longer display prompt if cruise not engaged +* REMOVED: Upload Raw Logs and Full Resolution Videos toggles + +sunnypilot - Version 0.8.16 (2022-07-16) +======================== +* Sync with commaai's master branches +* NEW❗: Add toggle to pause lateral actuation below 30 MPH / 50 KM/H +* IMPROVED: Better controls mismatch handling +* IMPROVED: Less frequent Low Memory alert +* IMPROVED: Only allow lateral control when in forward gears +* IMPROVED: Better alerts handling on gear changes + +sunnypilot - Version 0.8.14-1.3 (2022-06-29) +======================== +* Hyundai/Kia/Genesis + * NEW❗: MADS: Add GAP/Distance button on the steering wheel to engage/disengage + * To engage/disengage MADS: Hold the button for 0.5 second +* NEW❗: Dynamic Lane Profile: Add toggle to enable "Laneless for Curves in Auto Lane" +* HOTFIX🛠: Improve Torque lateral control and reduce ping pong for some Toyota cars + * Torque control: higher low speed gains and better steering angle deadzone logic +* Developer UI: Remove Distance Traveled, replace with Memory Usage % + * This may have a potential to fix the Low Memory alert that may appear + +sunnypilot - Version 0.8.14-1 (2022-06-27) +======================== +* HOTFIX🛠: Honda, Toyota, Volkswagen now initialized correctly with Torque Lateral Live Tune + +sunnypilot - Version 0.8.14-1 (2022-06-27) +======================== +* NEW❗: Added toggle to enable updates for sunnypilot +* HOTFIX🛠: Volkswagen car list now displays properly in Force Car Recognition menu +* REVERTED: Honda - temporary removes CRUISE (MAIN) for MADS engagement + * LKAS button continues to be used for MADS engagement/disengagement + +sunnypilot - Version 0.8.14-1 (2022-06-26) +======================== +Visit https://bit.ly/sunnyreadme for more details +* sunnypilot 0.8.14 release - based on openpilot 0.8.14 devel +* "0.8.14-prod-c3" branch only supports comma three + * If you have a comma two, EON, or other devices than a comma three, visit sunnyhaibin's discord server for more details: https://discord.gg/wRW3meAgtx +* Mono-branch support + * Honda/Acura + * Hyundai/Kia/Genesis + * Toyota/Lexus + * Volkswagen MQB +* Modified Assistive Driving Safety (MADS) Mode + * NEW❗: CRUISE (MAIN) now engages MADS for all supported car makes + * NEW❗: Added toggle to disable disengaging Automatic Lane Centering (ALC) on the brake pedal +* Dynamic Lane Profile (DLP) +* NEW❗: Gap Adjust Cruise (GAC) + * openpilot longitudinal cars can now adjust between the lead car's following distance gap via 3 modes: + * Steering Wheel (SW) | User Interface (UI) | Steering Wheel + User Interface (SW+UI) +* NEW❗: Custom Camera & Path Offsets +* NEW❗: Torque Lateral Control from openpilot 0.8.15 master (as of 2022-06-15) +* NEW❗: Torque Lateral Control Live Tune Menu +* NEW❗: Speed Limit Sign from openpilot 0.8.15 master (as of 2022-06-22) +* NEW❗: Mapbox Speed Limit data will now be utilized in Speed Limit Control (SLC) + * Speed limit data will be utilized in the following availability: + * Mapbox (active navigation) -> OpenStreetMap -> Car Interface (Toyota's TSR) +* Custom Stock Longitudinal Control + * NEW❗: Volkswagen MQB + * Honda + * Hyundai/Kia/Genesis +* NEW❗: Mapbox navigation support for non-Prime users + * Visit sunnyhaibin's discord server for more details: https://discord.gg/wRW3meAgtx +* Hyundai/Kia/Genesis + * NEW❗: Enhanced SCC (ESCC) Support + * Requires hardware modification. Visit sunnyhaibin's discord server for more details: https://discord.gg/wRW3meAgtx + * NEW❗: Smart MDPS (SMDPS) Support - Auto-detection + * Requires hardware modification and custom firmware for the SMDPS. Visit sunnyhaibin's discord server for more details: https://discord.gg/wRW3meAgtx +* Toyota/Lexus + * NEW❗: Added toggle to enforce stock longitudinal control + +sunnypilot - Version 0.8.12-4 +======================== +* NEW❗: Custom Stock Longitudinal Control by setting the target speed via openpilot's "MAX" speed thanks to multikyd! + * Speed Limit Control + * Vision-based Turn Control + * Map-based Turn Control +* NEW❗: HDA status integration with Custom Stock Longitudinal Control on applicable HKG cars only +* NEW❗: Roll Compensation and SteerRatio fix from comma's 0.8.13 +* NEW❗: Dev UI to display different metrics on screen + * Click on the "MAX" box on the top left of the openpilot display to toggle different metrics display + * Lead car relative distance; Lead car relative speed; Actual steering degree; Desired steering degree; Engine RPM; Longitudinal acceleration; Lead car actual speed; EPS torque; Current altitude; Compass direction +* NEW❗: Stand Still Timer to display time spent at a stop with M.A.D.S engaged (i.e., stop lights, stop signs, traffic congestions) +* NEW❗: Current car speed text turns red when the car is braking +* NEW❗: Export GPS tracks into GPX files and upload to OSM thanks to eFini! +* NEW❗: Enable ACC and M.A.D.S with a single press of the RES+/SET- button +* NEW❗: ACC +/-: Short=5, Long=1 + * Change the ACC +/- buttons behavior with cruise speed change in openpilot + * Disabled (Stock): Short=1, Long=5 + * Enabled: Short=5, Long=1 +* NEW❗: Speed Limit Value Offset (not %)* + * Set speed limit higher or lower than actual speed limit for a more personalized drive. + * *To use this feature, turn off "Enable Speed Limit % Offset"* +* NEW❗: Dedicated icon to show the status of M.A.D.S. +* NEW❗: No Offroad Fix for non-official devices that cannot shut down after the car is turned off +* NEW❗: Stop N' Go Resume Alternative + * Offer alternative behavior to auto resume when stopped behind a lead car using stock SCC/ACC. This feature removes the repeating prompt chime when stopped and/or allows some cars to use auto resume (i.e., Genesis) +* IMPROVED: Show the lead car icon in the car's dashboard when a lead car is detected by openpilot's camera vision +* FIXED: MADS button unintentionally set MAX when using stock longitudinal control thanks to Spektor56! + +sunnypilot - Version 0.8.12-3 +======================== +* NEW❗: Bypass "System Malfunction" alert toggle + * Prevent openpilot from returning the "System Malfunction" alert that hinders the ability use openpilot +* FIXED: Hyundai/Kia/Genesis Brake Hold Active now outputs the correct events on screen with M.A.D.S. engaged + +sunnypilot - Version 0.8.12-2 +======================== +* NEW❗: Disable M.A.D.S. toggle to disable the beloved M.A.D.S. feature + * Enable Stock openpilot engagement/disengagement +* ADJUST: Initialize Driving Screen Off Brightness at 50% + +sunnypilot - Version 0.8.12-1 +======================== +* sunnypilot 0.8.12 release - based on openpilot 0.8.12 devel +* Dedicated Hyundai/Kia/Genesis branch support +* NEW❗: OpenStreetMap integration thanks to the Move Fast team! + * NEW❗: Vision-based Turn Control + * NEW❗: Map-Data-based Turn Control + * NEW❗: Speed Limit Control w/ optional Speed Limit Offset + * NEW❗: OpenStreetMap integration debug UI + * Only available to openpilot longitudinal enabled cars +* NEW❗: Hands on Wheel Monitoring according to EU r079r4e regulation +* NEW❗: Disable Onroad Uploads for data-limited Wi-Fi hotspots when using OpenStreetMap related features +* NEW❗: Fast Boot (Prebuilt) +* NEW❗: Auto Lane Change Timer +* NEW❗: Screen Brightness Control (Global) +* NEW❗: Driving Screen Off Timer +* NEW❗: Driving Screen Off Brightness (%) +* NEW❗: Max Time Offroad +* Improved user feedback with M.A.D.S. operations thanks to Spektor56! + * Lane Path + * Green🟢 (Laneful), Red🔴 (Laneless): M.A.D.S. engaged + * White⚪: M.A.D.S. suspended or disengaged + * Black⚫: M.A.D.S. engaged, steering is being manually override by user + * Screen border now only illuminates Green when SCC/ACC is engaged + +sunnypilot - Version 0.8.10-1 (Unreleased) +======================== +* sunnypilot 0.8.10 release - based on openpilot 0.8.10 `devel` +* Add Toyota cars to Force Car Recognition + +sunnypilot - Version 0.8.9-4 +======================== +* Hyundai: Fix Ioniq Hybrid signals + +sunnypilot - Version 0.8.9-3 +======================== +* Update home screen brand and version structure + +sunnypilot - Version 0.8.9-2 +======================== +* Added additional Sonata Hybrid Firmware Versions +* Features + * Modified Assistive Driving Safety (MADS) Mode + * Dynamic Lane Profile (DLP) + * Quiet Drive 🤫 + * Force Car Recognition (FCR) + * PID Controller: add kd into the stock PID controller + +sunnypilot - Version 0.8.9-1 +======================== +* First changelog! +* Features + * Modified Assistive Driving Safety (MADS) Mode + * Dynamic Lane Profile (DLP) + * Quiet Drive 🤫 + * Force Car Recognition (FCR) + * PID Controller: add kd into the stock PID controller diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000000..650b6f55b3 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +# Custom MIT License + +Copyright (c) 2024, Haibin Wen, SUNNYPILOT LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to view and modify the Software, subject to the following conditions: + +1. **Permission Required**: Permission Required for Commercial, For-Profit, or Closed Source Use: Use of the Software, in whole or in part, for any commercial purposes, for-profit projects, or in closed source projects requires explicit written permission from the original author(s). + +2. **Redistribution**: Any redistribution of the Software, modified or unmodified, must retain this license notice and the following acknowledgment: + "This software is licensed under a custom license requiring permission for use." + +3. **Visibility**: Any project that uses the Software must visibly mention the following acknowledgment: + "This project uses software from Haibin Wen and SUNNYPILOT LLC and is licensed under a custom license requiring permission for use." + +4. **No Warranty**: THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Contact sunnypilot Support for permission requests. + +--- + +Haibin Wen, SUNNYPILOT LLC diff --git a/README.md b/README.md index a1f494c33c..71fbb00e4c 100644 --- a/README.md +++ b/README.md @@ -1,111 +1,74 @@ -
+![](https://user-images.githubusercontent.com/47793918/233812617-beab2e71-57b9-479e-8bff-c3931347ca40.png) -

openpilot

+## 🌞 What is sunnypilot? +[sunnypilot](https://github.com/sunnyhaibin/sunnypilot) is a fork of comma.ai's openpilot, an open source driver assistance system. sunnypilot offers the user a unique driving experience for over 300+ supported car makes and models with modified behaviors of driving assist engagements. sunnypilot complies with comma.ai's safety rules as accurately as possible. -

- openpilot is an operating system for robotics. -
- Currently, it upgrades the driver assistance system in 300+ supported cars. -

+## 💭 Join our Community Forum +Join the official sunnypilot community forum to stay up to date with all the latest features and be a part of shaping the future of sunnypilot! +* https://community.sunnypilot.ai/ -

- Docs - · - Roadmap - · - Contribute - · - Community - · - Try it on a comma four -

+## Documentation +https://docs.sunnypilot.ai/ is your one stop shop for everything from features to installation to FAQ about the sunnypilot -Quick start: `bash <(curl -fsSL openpilot.comma.ai)` +## 🚘 Running on a dedicated device in a car +First, check out this list of items you'll need to [get started](https://community.sunnypilot.ai/t/getting-started-using-sunnypilot-in-your-supported-car/251). -[![openpilot tests](https://github.com/commaai/openpilot/actions/workflows/tests.yaml/badge.svg)](https://github.com/commaai/openpilot/actions/workflows/tests.yaml) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -[![X Follow](https://img.shields.io/twitter/follow/comma_ai)](https://x.com/comma_ai) -[![Discord](https://img.shields.io/discord/469524606043160576)](https://discord.comma.ai) +## Installation +Next, refer to the sunnypilot community forum for [installation instructions](https://community.sunnypilot.ai/t/read-before-installing-sunnypilot/254), as well as a complete list of [Recommended Branch Installations](https://community.sunnypilot.ai/t/recommended-branch-installations/235). -
+## 🎆 Pull Requests +We welcome both pull requests and issues on GitHub. Bug fixes are encouraged. - - - - - - -
+Pull requests should be against the most current `master` branch. +## 📊 User Data -Using openpilot in a car ------- +By default, sunnypilot uploads the driving data to comma servers. You can also access your data through [comma connect](https://connect.comma.ai/). -To use openpilot in a car, you need four things: -1. **Supported Device:** a comma four, available at [comma.ai/shop/comma-four](https://www.comma.ai/shop/comma-four). -2. **Software:** The setup procedure for the comma four allows users to enter a URL for custom software. Use the URL `openpilot.comma.ai` to install the release version. -3. **Supported Car:** Ensure that you have one of [the 275+ supported cars](docs/CARS.md). -4. **Car Harness:** You will also need a [car harness](https://comma.ai/shop/car-harness) to connect your comma four to your car. +sunnypilot is open source software. The user is free to disable data collection if they wish to do so. -We have detailed instructions for [how to install the harness and device in a car](https://comma.ai/setup). Note that it's possible to run openpilot on [other hardware](https://blog.comma.ai/self-driving-car-for-free/), although it's not plug-and-play. - - -### Branches - -Running `master` and other branches directly is supported, but it's recommended to run one of the following prebuilt branches: - -| comma four branch | comma 3X branch | URL | description | -|------------------------|------------------------|----------------------------------------|-------------------------------------------------------------------------------------| -| `release-mici` | `release-tizi` | openpilot.comma.ai | This is openpilot's release branch. | -| `release-mici-staging` | `release-tizi-staging` | openpilot-test.comma.ai | This is the staging branch for releases. Use it to get new releases slightly early. | -| `nightly` | `nightly` | openpilot-nightly.comma.ai | This is the bleeding edge development branch. Do not expect this to be stable. | -| `nightly-dev` | `nightly-dev` | installer.comma.ai/commaai/nightly-dev | Same as nightly, but includes experimental development features for some cars. | - -To start developing openpilot ------- - -openpilot is developed by [comma](https://comma.ai/) and by users like you. We welcome both pull requests and issues on [GitHub](http://github.com/commaai/openpilot). - -* Join the [community Discord](https://discord.comma.ai) -* Check out [the contributing docs](docs/CONTRIBUTING.md) -* Check out the [openpilot tools](tools/) -* Code documentation lives at https://docs.comma.ai -* Information about running openpilot lives on the [community wiki](https://github.com/commaai/openpilot/wiki) - -Want to get paid to work on openpilot? [comma is hiring](https://comma.ai/jobs#open-positions) and offers lots of [bounties](https://comma.ai/bounties) for external contributors. - -Safety and Testing ----- - -* openpilot observes [ISO26262](https://en.wikipedia.org/wiki/ISO_26262) guidelines, see [SAFETY.md](docs/SAFETY.md) for more details. -* openpilot has software-in-the-loop [tests](.github/workflows/tests.yaml) that run on every commit. -* The code enforcing the safety model lives in panda and is written in C, see [code rigor](https://github.com/commaai/panda#code-rigor) for more details. -* panda has software-in-the-loop [safety tests](https://github.com/commaai/panda/tree/master/tests/safety). -* Internally, we have a hardware-in-the-loop Jenkins test suite that builds and unit tests the various processes. -* panda has additional hardware-in-the-loop [tests](https://github.com/commaai/panda/blob/master/Jenkinsfile). -* We run the latest openpilot in a testing closet containing 10 comma devices continuously replaying routes. - -
-MIT Licensed - -openpilot is released under the MIT license. Some parts of the software are released under other licenses as specified. - -Any user of this software shall indemnify and hold harmless Comma.ai, Inc. and its directors, officers, employees, agents, stockholders, affiliates, subcontractors and customers from and against all allegations, claims, actions, suits, demands, damages, liabilities, obligations, losses, settlements, judgments, costs and expenses (including without limitation attorneys’ fees and costs) which arise out of, relate to or result from any use of this software by user. - -**THIS IS ALPHA QUALITY SOFTWARE FOR RESEARCH PURPOSES ONLY. THIS IS NOT A PRODUCT. -YOU ARE RESPONSIBLE FOR COMPLYING WITH LOCAL LAWS AND REGULATIONS. -NO WARRANTY EXPRESSED OR IMPLIED.** -
- -
-User Data and comma Account - -By default, openpilot uploads the driving data to our servers. You can also access your data through [comma connect](https://connect.comma.ai/). We use your data to train better models and improve openpilot for everyone. - -openpilot is open source software: the user is free to disable data collection if they wish to do so. - -openpilot logs the road-facing cameras, CAN, GPS, IMU, magnetometer, thermal sensors, crashes, and operating system logs. +sunnypilot logs the road-facing camera, CAN, GPS, IMU, magnetometer, thermal sensors, crashes, and operating system logs. The driver-facing camera and microphone are only logged if you explicitly opt-in in settings. -By using openpilot, you agree to [our Privacy Policy](https://comma.ai/privacy). You understand that use of this software or its related services will generate certain types of user data, which may be logged and stored at the sole discretion of comma. By accepting this agreement, you grant an irrevocable, perpetual, worldwide right to comma for the use of this data. -
+By using this software, you understand that use of this software or its related services will generate certain types of user data, which may be logged and stored at the sole discretion of comma. By accepting this agreement, you grant an irrevocable, perpetual, worldwide right to comma for the use of this data. + +## Licensing + +sunnypilot is released under the [MIT License](LICENSE). This repository includes original work as well as significant portions of code derived from [openpilot by comma.ai](https://github.com/commaai/openpilot), which is also released under the MIT license with additional disclaimers. + +The original openpilot license notice, including comma.ai’s indemnification and alpha software disclaimer, is reproduced below as required: + +> openpilot is released under the MIT license. Some parts of the software are released under other licenses as specified. +> +> Any user of this software shall indemnify and hold harmless Comma.ai, Inc. and its directors, officers, employees, agents, stockholders, affiliates, subcontractors and customers from and against all allegations, claims, actions, suits, demands, damages, liabilities, obligations, losses, settlements, judgments, costs and expenses (including without limitation attorneys’ fees and costs) which arise out of, relate to or result from any use of this software by user. +> +> **THIS IS ALPHA QUALITY SOFTWARE FOR RESEARCH PURPOSES ONLY. THIS IS NOT A PRODUCT. +> YOU ARE RESPONSIBLE FOR COMPLYING WITH LOCAL LAWS AND REGULATIONS. +> NO WARRANTY EXPRESSED OR IMPLIED.** + +For full license terms, please see the [`LICENSE`](LICENSE) file. + +## 💰 Support sunnypilot +If you find any of the features useful, consider becoming a [sponsor on GitHub](https://github.com/sponsors/sunnyhaibin) to support future feature development and improvements. + + +By becoming a sponsor, you will gain access to exclusive content, early access to new features, and the opportunity to directly influence the project's development. + + +

GitHub Sponsor

+ + + Become a Sponsor + +
+ +

PayPal

+ + +PayPal this + +

+ +Your continuous love and support are greatly appreciated! Enjoy 🥰 + +- Jason, Founder of sunnypilot diff --git a/SConstruct b/SConstruct index 119209dcdb..16c0752acf 100644 --- a/SConstruct +++ b/SConstruct @@ -140,7 +140,7 @@ if arch == "larch64": env.Append(LIBPATH=[ "/usr/lib/aarch64-linux-gnu", ]) - arch_flags = ["-D__TICI__", "-mcpu=cortex-a57"] + arch_flags = ["-D__TICI__", "-mcpu=cortex-a57", "-DQCOM2"] env.Append(CCFLAGS=arch_flags) env.Append(CXXFLAGS=arch_flags) elif arch == "Darwin": @@ -202,7 +202,8 @@ Export('envCython', 'np_version') Export('env', 'arch') # Setup cache dir -cache_dir = '/data/scons_cache' if arch == "larch64" else '/tmp/scons_cache' +default_cache_dir = '/data/scons_cache' if arch == "larch64" else '/tmp/scons_cache' +cache_dir = ARGUMENTS.get('cache_dir', default_cache_dir) CacheDir(cache_dir) Clean(["."], cache_dir) @@ -254,6 +255,8 @@ SConscript([ 'selfdrive/ui/SConscript', ]) +SConscript(['sunnypilot/SConscript']) + # Build tools if arch != "larch64": SConscript([ diff --git a/cereal/SConscript b/cereal/SConscript index 73dc61844b..de7ca0d721 100644 --- a/cereal/SConscript +++ b/cereal/SConscript @@ -4,7 +4,7 @@ cereal_dir = Dir('.') gen_dir = Dir('gen') # Build cereal -schema_files = ['log.capnp', 'car.capnp', 'legacy.capnp', 'custom.capnp'] +schema_files = ['log.capnp', 'car.capnp', 'deprecated.capnp', 'custom.capnp'] env.Command([f'gen/cpp/{s}.c++' for s in schema_files] + [f'gen/cpp/{s}.h' for s in schema_files], schema_files, f"capnpc --src-prefix={cereal_dir.path} $SOURCES -o c++:{gen_dir.path}/cpp/") diff --git a/cereal/custom.capnp b/cereal/custom.capnp index 3348e859ef..fe3ed9196f 100644 --- a/cereal/custom.capnp +++ b/cereal/custom.capnp @@ -10,34 +10,450 @@ $Cxx.namespace("cereal"); # DO rename the structs # DON'T change the identifier (e.g. @0x81c2f05a394cf4af) -struct CustomReserved0 @0x81c2f05a394cf4af { +struct ModularAssistiveDrivingSystem { + state @0 :ModularAssistiveDrivingSystemState; + enabled @1 :Bool; + active @2 :Bool; + available @3 :Bool; + + enum ModularAssistiveDrivingSystemState { + disabled @0; + paused @1; + enabled @2; + softDisabling @3; + overriding @4; + } } -struct CustomReserved1 @0xaedffd8f31e7b55d { +struct IntelligentCruiseButtonManagement { + state @0 :IntelligentCruiseButtonManagementState; + sendButton @1 :SendButtonState; + vTarget @2 :Float32; + + enum IntelligentCruiseButtonManagementState { + inactive @0; # No button press or default state + preActive @1; # Pre-active state before transitioning to increasing or decreasing + increasing @2; # Increasing speed + decreasing @3; # Decreasing speed + holding @4; # Holding steady speed + } + + enum SendButtonState { + none @0; + increase @1; + decrease @2; + } } -struct CustomReserved2 @0xf35cc4560bbf6ec2 { +# Same struct as Log.RadarState.LeadData +struct LeadData { + dRel @0 :Float32; + yRel @1 :Float32; + vRel @2 :Float32; + aRel @3 :Float32; + vLead @4 :Float32; + dPath @6 :Float32; + vLat @7 :Float32; + vLeadK @8 :Float32; + aLeadK @9 :Float32; + fcw @10 :Bool; + status @11 :Bool; + aLeadTau @12 :Float32; + modelProb @13 :Float32; + radar @14 :Bool; + radarTrackId @15 :Int32 = -1; + + aLeadDEPRECATED @5 :Float32; } -struct CustomReserved3 @0xda96579883444c35 { +struct SelfdriveStateSP @0x81c2f05a394cf4af { + mads @0 :ModularAssistiveDrivingSystem; + intelligentCruiseButtonManagement @1 :IntelligentCruiseButtonManagement; + + enum AudibleAlert { + none @0; + + engage @1; + disengage @2; + refuse @3; + + warningSoft @4; + warningImmediate @5; + + prompt @6; + promptRepeat @7; + promptDistracted @8; + + # unused, these are reserved for upstream events so we don't collide + reserved9 @9; + reserved10 @10; + reserved11 @11; + reserved12 @12; + reserved13 @13; + reserved14 @14; + reserved15 @15; + reserved16 @16; + reserved17 @17; + reserved18 @18; + reserved19 @19; + reserved20 @20; + reserved21 @21; + reserved22 @22; + reserved23 @23; + reserved24 @24; + reserved25 @25; + reserved26 @26; + reserved27 @27; + reserved28 @28; + reserved29 @29; + reserved30 @30; + + promptSingleLow @31; + promptSingleHigh @32; + } } -struct CustomReserved4 @0x80ae746ee2596b11 { +struct ModelManagerSP @0xaedffd8f31e7b55d { + activeBundle @0 :ModelBundle; + selectedBundle @1 :ModelBundle; + availableBundles @2 :List(ModelBundle); + + struct DownloadUri { + uri @0 :Text; + sha256 @1 :Text; + } + + enum DownloadStatus { + notDownloading @0; + downloading @1; + downloaded @2; + cached @3; + failed @4; + } + + struct DownloadProgress { + status @0 :DownloadStatus; + progress @1 :Float32; + eta @2 :UInt32; + } + + struct Artifact { + fileName @0 :Text; + downloadUri @1 :DownloadUri; + downloadProgress @2 :DownloadProgress; + } + + struct Model { + type @0 :Type; + artifact @1 :Artifact; # Main artifact + metadata @2 :Artifact; # Metadata artifact + + enum Type { + supercombo @0; + navigation @1; + vision @2; + policy @3; + offPolicy @4; + onPolicy @5; + } + } + + enum Runner { + snpe @0; + tinygrad @1; + stock @2; + } + + struct Override { + key @0 :Text; + value @1 :Text; + } + + struct ModelBundle { + index @0 :UInt32; + internalName @1 :Text; + displayName @2 :Text; + models @3 :List(Model); + status @4 :DownloadStatus; + generation @5 :UInt32; + environment @6 :Text; + runner @7 :Runner; + is20hz @8 :Bool; + ref @9 :Text; + minimumSelectorVersion @10 :UInt32; + overrides @11 :List(Override); + } } -struct CustomReserved5 @0xa5cd762cd951a455 { +struct LongitudinalPlanSP @0xf35cc4560bbf6ec2 { + dec @0 :DynamicExperimentalControl; + longitudinalPlanSource @1 :LongitudinalPlanSource; + smartCruiseControl @2 :SmartCruiseControl; + speedLimit @3 :SpeedLimit; + vTarget @4 :Float32; + aTarget @5 :Float32; + events @6 :List(OnroadEventSP.Event); + e2eAlerts @7 :E2eAlerts; + + struct DynamicExperimentalControl { + state @0 :DynamicExperimentalControlState; + enabled @1 :Bool; + active @2 :Bool; + + enum DynamicExperimentalControlState { + acc @0; + blended @1; + } + } + + struct SmartCruiseControl { + vision @0 :Vision; + map @1 :Map; + + struct Vision { + state @0 :VisionState; + vTarget @1 :Float32; + aTarget @2 :Float32; + currentLateralAccel @3 :Float32; + maxPredictedLateralAccel @4 :Float32; + enabled @5 :Bool; + active @6 :Bool; + } + + struct Map { + state @0 :MapState; + vTarget @1 :Float32; + aTarget @2 :Float32; + enabled @3 :Bool; + active @4 :Bool; + } + + enum VisionState { + disabled @0; # System disabled or inactive. + enabled @1; # No predicted substantial turn on vision range. + entering @2; # A substantial turn is predicted ahead, adapting speed to turn comfort levels. + turning @3; # Actively turning. Managing acceleration to provide a roll on turn feeling. + leaving @4; # Road ahead straightens. Start to allow positive acceleration. + overriding @5; # System overriding with manual control. + } + + enum MapState { + disabled @0; # System disabled or inactive. + enabled @1; # No predicted substantial turn on map range. + turning @2; # Actively turning. Managing acceleration to provide a roll on turn feeling. + overriding @3; # System overriding with manual control. + } + } + + struct SpeedLimit { + resolver @0 :Resolver; + assist @1 :Assist; + + struct Resolver { + speedLimit @0 :Float32; + distToSpeedLimit @1 :Float32; + source @2 :Source; + speedLimitOffset @3 :Float32; + speedLimitLast @4 :Float32; + speedLimitFinal @5 :Float32; + speedLimitFinalLast @6 :Float32; + speedLimitValid @7 :Bool; + speedLimitLastValid @8 :Bool; + } + + struct Assist { + state @0 :AssistState; + enabled @1 :Bool; + active @2 :Bool; + vTarget @3 :Float32; + aTarget @4 :Float32; + } + + enum Source { + none @0; + car @1; + map @2; + } + + enum AssistState { + disabled @0; + inactive @1; # No speed limit set or not enabled by parameter. + preActive @2; + pending @3; # Awaiting new speed limit. + adapting @4; # Reducing speed to match new speed limit. + active @5; # Cruising at speed limit. + } + } + + enum LongitudinalPlanSource { + cruise @0; + sccVision @1; + sccMap @2; + speedLimitAssist @3; + } + + struct E2eAlerts { + greenLightAlert @0 :Bool; + leadDepartAlert @1 :Bool; + } } -struct CustomReserved6 @0xf98d843bfd7004a3 { +struct OnroadEventSP @0xda96579883444c35 { + events @0 :List(Event); + + struct Event { + name @0 :EventName; + + # event types + enable @1 :Bool; + noEntry @2 :Bool; + warning @3 :Bool; # alerts presented only when enabled or soft disabling + userDisable @4 :Bool; + softDisable @5 :Bool; + immediateDisable @6 :Bool; + preEnable @7 :Bool; + permanent @8 :Bool; # alerts presented regardless of openpilot state + overrideLateral @10 :Bool; + overrideLongitudinal @9 :Bool; + } + + enum EventName { + lkasEnable @0; + lkasDisable @1; + manualSteeringRequired @2; + manualLongitudinalRequired @3; + silentLkasEnable @4; + silentLkasDisable @5; + silentBrakeHold @6; + silentWrongGear @7; + silentReverseGear @8; + silentDoorOpen @9; + silentSeatbeltNotLatched @10; + silentParkBrake @11; + controlsMismatchLateral @12; + hyundaiRadarTracksConfirmed @13; + experimentalModeSwitched @14; + wrongCarModeAlertOnly @15; + pedalPressedAlertOnly @16; + laneTurnLeft @17; + laneTurnRight @18; + speedLimitPreActive @19; + speedLimitActive @20; + speedLimitChanged @21; + speedLimitPending @22; + e2eChime @23; + } } -struct CustomReserved7 @0xb86e6369214c01c8 { +struct CarParamsSP @0x80ae746ee2596b11 { + flags @0 :UInt32; # flags for car specific quirks in sunnypilot + safetyParam @1 : Int16; # flags for sunnypilot's custom safety flags + pcmCruiseSpeed @3 :Bool; + intelligentCruiseButtonManagementAvailable @4 :Bool; + enableGasInterceptor @5 :Bool; + + neuralNetworkLateralControl @2 :NeuralNetworkLateralControl; + + struct NeuralNetworkLateralControl { + model @0 :Model; + fuzzyFingerprint @1 :Bool; + + struct Model { + path @0 :Text; + name @1 :Text; + } + } } -struct CustomReserved8 @0xf416ec09499d9d19 { +struct CarControlSP @0xa5cd762cd951a455 { + mads @0 :ModularAssistiveDrivingSystem; + params @1 :List(Param); + leadOne @2 :LeadData; + leadTwo @3 :LeadData; + intelligentCruiseButtonManagement @4 :IntelligentCruiseButtonManagement; + + struct Param { + key @0 :Text; + type @2 :ParamType; + value @3 :Data; + + valueDEPRECATED @1 :Text; # The data type change may cause issues with backwards compatibility. + } + + enum ParamType { + string @0; + bool @1; + int @2; + float @3; + time @4; + json @5; + bytes @6; + } } -struct CustomReserved9 @0xa1680744031fdb2d { +struct BackupManagerSP @0xf98d843bfd7004a3 { + backupStatus @0 :Status; + restoreStatus @1 :Status; + backupProgress @2 :Float32; + restoreProgress @3 :Float32; + lastError @4 :Text; + currentBackup @5 :BackupInfo; + backupHistory @6 :List(BackupInfo); + + enum Status { + idle @0; + inProgress @1; + completed @2; + failed @3; + } + + struct Version { + major @0 :UInt16; + minor @1 :UInt16; + patch @2 :UInt16; + build @3 :UInt16; + branch @4 :Text; + } + + struct MetadataEntry { + key @0 :Text; + value @1 :Text; + tags @2 :List(Text); + } + + struct BackupInfo { + deviceId @0 :Text; + version @1 :UInt32; + config @2 :Text; + isEncrypted @3 :Bool; + createdAt @4 :Text; # ISO timestamp + updatedAt @5 :Text; # ISO timestamp + sunnypilotVersion @6 :Version; + backupMetadata @7 :List(MetadataEntry); + } +} + +struct CarStateSP @0xb86e6369214c01c8 { + speedLimit @0 :Float32; +} + +struct LiveMapDataSP @0xf416ec09499d9d19 { + speedLimitValid @0 :Bool; + speedLimit @1 :Float32; + speedLimitAheadValid @2 :Bool; + speedLimitAhead @3 :Float32; + speedLimitAheadDistance @4 :Float32; + roadName @5 :Text; +} + +struct ModelDataV2SP @0xa1680744031fdb2d { + laneTurnDirection @0 :TurnDirection; + + enum TurnDirection { + none @0; + turnLeft @1; + turnRight @2; + } } struct CustomReserved10 @0xcb9fd56c7057593a { diff --git a/cereal/legacy.capnp b/cereal/deprecated.capnp similarity index 71% rename from cereal/legacy.capnp rename to cereal/deprecated.capnp index a8fa5e4a1f..45ce25c682 100644 --- a/cereal/legacy.capnp +++ b/cereal/deprecated.capnp @@ -3,7 +3,7 @@ $Cxx.namespace("cereal"); @0x80ef1ec4889c2a63; -# legacy.capnp: a home for deprecated structs +# deprecated.capnp: a home for deprecated structs struct LogRotate @0x9811e1f38f62f2d1 { segmentNum @0 :Int32; @@ -571,4 +571,219 @@ struct LidarPts @0xe3d6685d4e9d8f7a { pkt @4 :Data; } +struct LiveTracksDEPRECATED @0xb16f60103159415a { + trackId @0 :Int32; + dRel @1 :Float32; + yRel @2 :Float32; + vRel @3 :Float32; + aRel @4 :Float32; + timeStamp @5 :Float32; + status @6 :Float32; + currentTime @7 :Float32; + stationary @8 :Bool; + oncoming @9 :Bool; +} +struct LiveMpcData @0x92a5e332a85f32a0 { + x @0 :List(Float32); + y @1 :List(Float32); + psi @2 :List(Float32); + curvature @3 :List(Float32); + qpIterations @4 :UInt32; + calculationTime @5 :UInt64; + cost @6 :Float64; +} + +struct LiveLongitudinalMpcData @0xe7e17c434f865ae2 { + xEgo @0 :List(Float32); + vEgo @1 :List(Float32); + aEgo @2 :List(Float32); + xLead @3 :List(Float32); + vLead @4 :List(Float32); + aLead @5 :List(Float32); + aLeadTau @6 :Float32; # lead accel time constant + qpIterations @7 :UInt32; + mpcId @8 :UInt32; + calculationTime @9 :UInt64; + cost @10 :Float64; +} + +struct DriverStateDEPRECATED @0xb83c6cc593ed0a00 { + frameId @0 :UInt32; + modelExecutionTime @14 :Float32; + dspExecutionTime @16 :Float32; + rawPredictions @15 :Data; + + faceOrientation @3 :List(Float32); + facePosition @4 :List(Float32); + faceProb @5 :Float32; + leftEyeProb @6 :Float32; + rightEyeProb @7 :Float32; + leftBlinkProb @8 :Float32; + rightBlinkProb @9 :Float32; + faceOrientationStd @11 :List(Float32); + facePositionStd @12 :List(Float32); + sunglassesProb @13 :Float32; + poorVision @17 :Float32; + partialFace @18 :Float32; + distractedPose @19 :Float32; + distractedEyes @20 :Float32; + eyesOnRoad @21 :Float32; + phoneUse @22 :Float32; + occludedProb @23 :Float32; + + readyProb @24 :List(Float32); + notReadyProb @25 :List(Float32); + + irPwrDEPRECATED @10 :Float32; + descriptorDEPRECATED @1 :List(Float32); + stdDEPRECATED @2 :Float32; +} + +struct NavModelData @0xac3de5c437be057a { + frameId @0 :UInt32; + locationMonoTime @6 :UInt64; + modelExecutionTime @1 :Float32; + dspExecutionTime @2 :Float32; + features @3 :List(Float32); + # predicted future position + position @4 :XYData; + desirePrediction @5 :List(Float32); + + # All SI units and in device frame + struct XYData @0xbe09e615b2507e26 { + x @0 :List(Float32); + y @1 :List(Float32); + xStd @2 :List(Float32); + yStd @3 :List(Float32); + } +} + +struct AndroidBuildInfo @0xfe2919d5c21f426c { + board @0 :Text; + bootloader @1 :Text; + brand @2 :Text; + device @3 :Text; + display @4 :Text; + fingerprint @5 :Text; + hardware @6 :Text; + host @7 :Text; + id @8 :Text; + manufacturer @9 :Text; + model @10 :Text; + product @11 :Text; + radioVersion @12 :Text; + serial @13 :Text; + supportedAbis @14 :List(Text); + tags @15 :Text; + time @16 :Int64; + type @17 :Text; + user @18 :Text; + + versionCodename @19 :Text; + versionRelease @20 :Text; + versionSdk @21 :Int32; + versionSecurityPatch @22 :Text; +} + +struct AndroidSensor @0x9b513b93a887dbcd { + id @0 :Int32; + name @1 :Text; + vendor @2 :Text; + version @3 :Int32; + handle @4 :Int32; + type @5 :Int32; + maxRange @6 :Float32; + resolution @7 :Float32; + power @8 :Float32; + minDelay @9 :Int32; + fifoReservedEventCount @10 :UInt32; + fifoMaxEventCount @11 :UInt32; + stringType @12 :Text; + maxDelay @13 :Int32; +} + +struct IosBuildInfo @0xd97e3b28239f5580 { + appVersion @0 :Text; + appBuild @1 :UInt32; + osVersion @2 :Text; + deviceModel @3 :Text; +} + +enum FrameTypeDEPRECATED @0xa37f0d8558e193fd { + unknown @0; + neo @1; + chffrAndroid @2; + front @3; +} + +struct AndroidCaptureResult @0xbcc3efbac41d2048 { + sensitivity @0 :Int32; + frameDuration @1 :Int64; + exposureTime @2 :Int64; + rollingShutterSkew @3 :UInt64; + colorCorrectionTransform @4 :List(Int32); + colorCorrectionGains @5 :List(Float32); + displayRotation @6 :Int8; +} + +enum UsbPowerModeDEPRECATED @0xa8883583b32c9877 { + none @0; + client @1; + cdp @2; + dcp @3; +} + +struct LateralINDIState @0x939463348632375e { + active @0 :Bool; + steeringAngleDeg @1 :Float32; + steeringRateDeg @2 :Float32; + steeringAccelDeg @3 :Float32; + rateSetPoint @4 :Float32; + accelSetPoint @5 :Float32; + accelError @6 :Float32; + delayedOutput @7 :Float32; + delta @8 :Float32; + output @9 :Float32; + saturated @10 :Bool; + steeringAngleDesiredDeg @11 :Float32; + steeringRateDesiredDeg @12 :Float32; +} + +struct LateralLQRState @0x9024e2d790c82ade { + active @0 :Bool; + steeringAngleDeg @1 :Float32; + i @2 :Float32; + output @3 :Float32; + lqrOutput @4 :Float32; + saturated @5 :Bool; + steeringAngleDesiredDeg @6 :Float32; +} + +struct LateralCurvatureState @0xad9d8095c06f7c61 { + active @0 :Bool; + actualCurvature @1 :Float32; + desiredCurvature @2 :Float32; + error @3 :Float32; + p @4 :Float32; + i @5 :Float32; + f @6 :Float32; + output @7 :Float32; + saturated @8 :Bool; +} + +struct LateralPlannerSolution @0x84caeca5a6b4acfe { + x @0 :List(Float32); + y @1 :List(Float32); + yaw @2 :List(Float32); + yawRate @3 :List(Float32); + xStd @4 :List(Float32); + yStd @5 :List(Float32); + yawStd @6 :List(Float32); + yawRateStd @7 :List(Float32); +} + +struct GpsTrajectory @0x8cfeb072f5301000 { + x @0 :List(Float32); + y @1 :List(Float32); +} diff --git a/cereal/log.capnp b/cereal/log.capnp index 7e47ab171d..cf91e017e6 100644 --- a/cereal/log.capnp +++ b/cereal/log.capnp @@ -2,7 +2,7 @@ using Cxx = import "./include/c++.capnp"; $Cxx.namespace("cereal"); using Car = import "car.capnp"; -using Legacy = import "legacy.capnp"; +using Deprecated = import "deprecated.capnp"; using Custom = import "custom.capnp"; @0xf3b1f17e25a4285b; @@ -68,12 +68,12 @@ struct OnroadEvent @0xc4fa6047f024e718 { longitudinalManeuver @30; steerTempUnavailableSilent @31; resumeRequired @32; - preDriverDistracted @33; - promptDriverDistracted @34; - driverDistracted @35; - preDriverUnresponsive @36; - promptDriverUnresponsive @37; - driverUnresponsive @38; + driverDistracted1 @33; + driverDistracted2 @34; + driverDistracted3 @35; + driverUnresponsive1 @36; + driverUnresponsive2 @37; + driverUnresponsive3 @38; belowSteerSpeed @39; lowBattery @40; accFaulted @41; @@ -192,66 +192,16 @@ struct InitData { espVersion @3 :Text; } - # ***** deprecated stuff ***** - gctxDEPRECATED @1 :Text; - androidBuildInfo @5 :AndroidBuildInfo; - androidSensorsDEPRECATED @6 :List(AndroidSensor); - chffrAndroidExtraDEPRECATED @7 :ChffrAndroidExtra; - iosBuildInfoDEPRECATED @14 :IosBuildInfo; - - struct AndroidBuildInfo { - board @0 :Text; - bootloader @1 :Text; - brand @2 :Text; - device @3 :Text; - display @4 :Text; - fingerprint @5 :Text; - hardware @6 :Text; - host @7 :Text; - id @8 :Text; - manufacturer @9 :Text; - model @10 :Text; - product @11 :Text; - radioVersion @12 :Text; - serial @13 :Text; - supportedAbis @14 :List(Text); - tags @15 :Text; - time @16 :Int64; - type @17 :Text; - user @18 :Text; - - versionCodename @19 :Text; - versionRelease @20 :Text; - versionSdk @21 :Int32; - versionSecurityPatch @22 :Text; - } - - struct AndroidSensor { - id @0 :Int32; - name @1 :Text; - vendor @2 :Text; - version @3 :Int32; - handle @4 :Int32; - type @5 :Int32; - maxRange @6 :Float32; - resolution @7 :Float32; - power @8 :Float32; - minDelay @9 :Int32; - fifoReservedEventCount @10 :UInt32; - fifoMaxEventCount @11 :UInt32; - stringType @12 :Text; - maxDelay @13 :Int32; - } - struct ChffrAndroidExtra { allCameraCharacteristics @0 :Map(Text, Text); } - struct IosBuildInfo { - appVersion @0 :Text; - appBuild @1 :UInt32; - osVersion @2 :Text; - deviceModel @3 :Text; + deprecated :group { + gctx @1 :Text; + androidBuildInfo @5 :Deprecated.AndroidBuildInfo; + androidSensors @6 :List(Deprecated.AndroidSensor); + chffrAndroidExtra @7 :ChffrAndroidExtra; + iosBuildInfo @14 :Deprecated.IosBuildInfo; } } @@ -280,13 +230,6 @@ struct FrameData { temperaturesC @24 :List(Float32); - enum FrameTypeDEPRECATED { - unknown @0; - neo @1; - chffrAndroid @2; - front @3; - } - sensor @26 :ImageSensor; enum ImageSensor { unknown @0; @@ -295,26 +238,19 @@ struct FrameData { os04c10 @3; } - frameLengthDEPRECATED @3 :Int32; - globalGainDEPRECATED @5 :Int32; - frameTypeDEPRECATED @7 :FrameTypeDEPRECATED; - androidCaptureResultDEPRECATED @9 :AndroidCaptureResult; - lensPosDEPRECATED @11 :Int32; - lensSagDEPRECATED @12 :Float32; - lensErrDEPRECATED @13 :Float32; - lensTruePosDEPRECATED @14 :Float32; - focusValDEPRECATED @16 :List(Int16); - focusConfDEPRECATED @17 :List(UInt8); - sharpnessScoreDEPRECATED @18 :List(UInt16); - recoverStateDEPRECATED @19 :Int32; - struct AndroidCaptureResult { - sensitivity @0 :Int32; - frameDuration @1 :Int64; - exposureTime @2 :Int64; - rollingShutterSkew @3 :UInt64; - colorCorrectionTransform @4 :List(Int32); - colorCorrectionGains @5 :List(Float32); - displayRotation @6 :Int8; + deprecated :group { + frameLength @3 :Int32; + globalGain @5 :Int32; + frameType @7 :Deprecated.FrameTypeDEPRECATED; + androidCaptureResult @9 :Deprecated.AndroidCaptureResult; + lensPos @11 :Int32; + lensSag @12 :Float32; + lensErr @13 :Float32; + lensTruePos @14 :Float32; + focusVal @16 :List(Int16); + focusConf @17 :List(UInt8); + sharpnessScore @18 :List(UInt16); + recoverState @19 :Int32; } } @@ -343,7 +279,6 @@ struct SensorEventData { sensor @1 :Int32; type @2 :Int32; timestamp @3 :Int64; - uncalibratedDEPRECATED @10 :Bool; union { acceleration @4 :SensorVec; @@ -378,6 +313,10 @@ struct SensorEventData { lsm6ds3trc @10; mmc5603nj @11; } + + deprecated :group { + uncalibrated @10 :Bool; + } } # android struct GpsLocation @@ -463,7 +402,10 @@ struct CanData { address @0 :UInt32; dat @2 :Data; src @3 :UInt8; - busTimeDEPRECATED @1 :UInt16; + + deprecated :group { + busTime @1 :UInt16; + } } struct DeviceState @0xa4d8b5af2aa492eb { @@ -553,26 +495,27 @@ struct DeviceState @0xa4d8b5af2aa492eb { wwanRx @1 :Int64; } - # deprecated - cpu0DEPRECATED @0 :UInt16; - cpu1DEPRECATED @1 :UInt16; - cpu2DEPRECATED @2 :UInt16; - cpu3DEPRECATED @3 :UInt16; - memDEPRECATED @4 :UInt16; - gpuDEPRECATED @5 :UInt16; - batDEPRECATED @6 :UInt32; - pa0DEPRECATED @21 :UInt16; - cpuUsagePercentDEPRECATED @20 :Int8; - batteryStatusDEPRECATED @9 :Text; - batteryVoltageDEPRECATED @16 :Int32; - batteryTempCDEPRECATED @29 :Float32; - batteryPercentDEPRECATED @8 :Int16; - batteryCurrentDEPRECATED @15 :Int32; - chargingErrorDEPRECATED @17 :Bool; - chargingDisabledDEPRECATED @18 :Bool; - usbOnlineDEPRECATED @12 :Bool; - ambientTempCDEPRECATED @30 :Float32; - nvmeTempCDEPRECATED @35 :List(Float32); + deprecated :group { + cpu0 @0 :UInt16; + cpu1 @1 :UInt16; + cpu2 @2 :UInt16; + cpu3 @3 :UInt16; + mem @4 :UInt16; + gpu @5 :UInt16; + bat @6 :UInt32; + pa0 @21 :UInt16; + cpuUsagePercent @20 :Int8; + batteryStatus @9 :Text; + batteryVoltage @16 :Int32; + batteryTempC @29 :Float32; + batteryPercent @8 :Int16; + batteryCurrent @15 :Int32; + chargingError @17 :Bool; + chargingDisabled @18 :Bool; + usbOnline @12 :Bool; + ambientTempC @30 :Float32; + nvmeTempC @35 :List(Float32); + } } struct PandaState @0xa7649e2575e4591e { @@ -615,8 +558,8 @@ struct PandaState @0xa7649e2575e4591e { # these fields are not used by openpilot, but they're # reserved for forks building alternate experiences. - controlsAllowedRESERVED1 @38 :Bool; - controlsAllowedRESERVED2 @39 :Bool; + controlsAllowedLateral @38 :Bool; + controlsAllowedLongitudinal @39 :Bool; enum FaultStatus { none @0; @@ -714,15 +657,17 @@ struct PandaState @0xa7649e2575e4591e { } } - gasInterceptorDetectedDEPRECATED @4 :Bool; - startedSignalDetectedDEPRECATED @5 :Bool; - hasGpsDEPRECATED @6 :Bool; - gmlanSendErrsDEPRECATED @9 :UInt32; - fanSpeedRpmDEPRECATED @11 :UInt16; - usbPowerModeDEPRECATED @12 :PeripheralState.UsbPowerModeDEPRECATED; - safetyParamDEPRECATED @20 :Int16; - safetyParam2DEPRECATED @26 :UInt32; - fanStallCountDEPRECATED @34 :UInt8; + deprecated :group { + gasInterceptorDetected @4 :Bool; + startedSignalDetected @5 :Bool; + hasGps @6 :Bool; + gmlanSendErrs @9 :UInt32; + fanSpeedRpm @11 :UInt16; + usbPowerMode @12 :Deprecated.UsbPowerModeDEPRECATED; + safetyParam @20 :Int16; + safetyParam2 @26 :UInt32; + fanStallCount @34 :UInt8; + } } struct PeripheralState { @@ -731,12 +676,8 @@ struct PeripheralState { current @2 :UInt32; fanSpeedRpm @3 :UInt16; - usbPowerModeDEPRECATED @4 :UsbPowerModeDEPRECATED; - enum UsbPowerModeDEPRECATED @0xa8883583b32c9877 { - none @0; - client @1; - cdp @2; - dcp @3; + deprecated :group { + usbPowerMode @4 :Deprecated.UsbPowerModeDEPRECATED; } } @@ -765,19 +706,22 @@ struct RadarState @0x9a185389d6fdd05f { radar @14 :Bool; radarTrackId @15 :Int32 = -1; - aLeadDEPRECATED @5 :Float32; + deprecated :group { + aLead @5 :Float32; + } } - # deprecated - ftMonoTimeDEPRECATED @7 :UInt64; - warpMatrixDEPRECATED @0 :List(Float32); - angleOffsetDEPRECATED @1 :Float32; - calStatusDEPRECATED @2 :Int8; - calCycleDEPRECATED @8 :Int32; - calPercDEPRECATED @9 :Int8; - canMonoTimesDEPRECATED @10 :List(UInt64); - cumLagMsDEPRECATED @5 :Float32; - radarErrorsDEPRECATED @12 :List(Car.RadarData.ErrorDEPRECATED); + deprecated :group { + ftMonoTime @7 :UInt64; + warpMatrix @0 :List(Float32); + angleOffset @1 :Float32; + calStatus @2 :Int8; + calCycle @8 :Int32; + calPerc @9 :Int8; + canMonoTimes @10 :List(UInt64); + cumLagMs @5 :Float32; + radarErrors @12 :List(Car.RadarData.ErrorDEPRECATED); + } } struct LiveCalibrationData { @@ -795,10 +739,6 @@ struct LiveCalibrationData { wideFromDeviceEuler @10 :List(Float32); height @12 :List(Float32); - warpMatrixDEPRECATED @0 :List(Float32); - calStatusDEPRECATED @1 :Int8; - warpMatrix2DEPRECATED @5 :List(Float32); - warpMatrixBigDEPRECATED @6 :List(Float32); enum Status { uncalibrated @0; @@ -806,19 +746,13 @@ struct LiveCalibrationData { invalid @2; recalibrating @3; } -} -struct LiveTracksDEPRECATED { - trackId @0 :Int32; - dRel @1 :Float32; - yRel @2 :Float32; - vRel @3 :Float32; - aRel @4 :Float32; - timeStamp @5 :Float32; - status @6 :Float32; - currentTime @7 :Float32; - stationary @8 :Bool; - oncoming @9 :Bool; + deprecated :group { + warpMatrix @0 :List(Float32); + calStatus @1 :Int8; + warpMatrix2 @5 :List(Float32); + warpMatrixBig @6 :List(Float32); + } } struct SelfdriveState { @@ -881,25 +815,9 @@ struct ControlsState @0x97ff69c53601abf1 { debugState @59 :LateralDebugState; torqueState @60 :LateralTorqueState; - curvatureStateDEPRECATED @65 :LateralCurvatureState; - lqrStateDEPRECATED @55 :LateralLQRState; - indiStateDEPRECATED @52 :LateralINDIState; - } - - struct LateralINDIState { - active @0 :Bool; - steeringAngleDeg @1 :Float32; - steeringRateDeg @2 :Float32; - steeringAccelDeg @3 :Float32; - rateSetPoint @4 :Float32; - accelSetPoint @5 :Float32; - accelError @6 :Float32; - delayedOutput @7 :Float32; - delta @8 :Float32; - output @9 :Float32; - saturated @10 :Bool; - steeringAngleDesiredDeg @11 :Float32; - steeringRateDesiredDeg @12 :Float32; + curvatureStateDEPRECATED @65 :Deprecated.LateralCurvatureState; + lqrStateDEPRECATED @55 :Deprecated.LateralLQRState; + indiStateDEPRECATED @52 :Deprecated.LateralINDIState; } struct LateralPIDState { @@ -931,16 +849,6 @@ struct ControlsState @0x97ff69c53601abf1 { version @12 :Int32; } - struct LateralLQRState { - active @0 :Bool; - steeringAngleDeg @1 :Float32; - i @2 :Float32; - output @3 :Float32; - lqrOutput @4 :Float32; - saturated @5 :Bool; - steeringAngleDesiredDeg @6 :Float32; - } - struct LateralAngleState { active @0 :Bool; steeringAngleDeg @1 :Float32; @@ -949,18 +857,6 @@ struct ControlsState @0x97ff69c53601abf1 { steeringAngleDesiredDeg @4 :Float32; } - struct LateralCurvatureState { - active @0 :Bool; - actualCurvature @1 :Float32; - desiredCurvature @2 :Float32; - error @3 :Float32; - p @4 :Float32; - i @5 :Float32; - f @6 :Float32; - output @7 :Float32; - saturated @8 :Bool; - } - struct LateralDebugState { active @0 :Bool; steeringAngleDeg @1 :Float32; @@ -968,58 +864,59 @@ struct ControlsState @0x97ff69c53601abf1 { saturated @3 :Bool; } - # deprecated - vEgoDEPRECATED @0 :Float32; - vEgoRawDEPRECATED @32 :Float32; - aEgoDEPRECATED @1 :Float32; - canMonoTimeDEPRECATED @16 :UInt64; - radarStateMonoTimeDEPRECATED @17 :UInt64; - mdMonoTimeDEPRECATED @18 :UInt64; - yActualDEPRECATED @6 :Float32; - yDesDEPRECATED @7 :Float32; - upSteerDEPRECATED @8 :Float32; - uiSteerDEPRECATED @9 :Float32; - ufSteerDEPRECATED @34 :Float32; - aTargetMinDEPRECATED @10 :Float32; - aTargetMaxDEPRECATED @11 :Float32; - rearViewCamDEPRECATED @23 :Bool; - driverMonitoringOnDEPRECATED @43 :Bool; - hudLeadDEPRECATED @14 :Int32; - alertSoundDEPRECATED @45 :Text; - angleModelBiasDEPRECATED @27 :Float32; - gpsPlannerActiveDEPRECATED @40 :Bool; - decelForTurnDEPRECATED @47 :Bool; - decelForModelDEPRECATED @54 :Bool; - awarenessStatusDEPRECATED @26 :Float32; - angleSteersDEPRECATED @13 :Float32; - vCurvatureDEPRECATED @46 :Float32; - mapValidDEPRECATED @49 :Bool; - jerkFactorDEPRECATED @12 :Float32; - steerOverrideDEPRECATED @20 :Bool; - steeringAngleDesiredDegDEPRECATED @29 :Float32; - canMonoTimesDEPRECATED @21 :List(UInt64); - desiredCurvatureRateDEPRECATED @62 :Float32; - canErrorCounterDEPRECATED @57 :UInt32; - vPidDEPRECATED @2 :Float32; - alertBlinkingRateDEPRECATED @42 :Float32; - alertText1DEPRECATED @24 :Text; - alertText2DEPRECATED @25 :Text; - alertStatusDEPRECATED @38 :SelfdriveState.AlertStatus; - alertSizeDEPRECATED @39 :SelfdriveState.AlertSize; - alertTypeDEPRECATED @44 :Text; - alertSound2DEPRECATED @56 :Car.CarControl.HUDControl.AudibleAlert; - engageableDEPRECATED @41 :Bool; # can OP be engaged? - stateDEPRECATED @31 :SelfdriveState.OpenpilotState; - enabledDEPRECATED @19 :Bool; - activeDEPRECATED @36 :Bool; - experimentalModeDEPRECATED @64 :Bool; - personalityDEPRECATED @66 :LongitudinalPersonality; - vCruiseDEPRECATED @22 :Float32; # actual set speed - vCruiseClusterDEPRECATED @63 :Float32; # set speed to display in the UI - startMonoTimeDEPRECATED @48 :UInt64; - cumLagMsDEPRECATED @15 :Float32; - aTargetDEPRECATED @35 :Float32; - vTargetLeadDEPRECATED @3 :Float32; + deprecated :group { + vEgo @0 :Float32; + vEgoRaw @32 :Float32; + aEgo @1 :Float32; + canMonoTime @16 :UInt64; + radarStateMonoTime @17 :UInt64; + mdMonoTime @18 :UInt64; + yActual @6 :Float32; + yDes @7 :Float32; + upSteer @8 :Float32; + uiSteer @9 :Float32; + ufSteer @34 :Float32; + aTargetMin @10 :Float32; + aTargetMax @11 :Float32; + rearViewCam @23 :Bool; + driverMonitoringOn @43 :Bool; + hudLead @14 :Int32; + alertSound @45 :Text; + angleModelBias @27 :Float32; + gpsPlannerActive @40 :Bool; + decelForTurn @47 :Bool; + decelForModel @54 :Bool; + awarenessStatus @26 :Float32; + angleSteers @13 :Float32; + vCurvature @46 :Float32; + mapValid @49 :Bool; + jerkFactor @12 :Float32; + steerOverride @20 :Bool; + steeringAngleDesiredDeg @29 :Float32; + canMonoTimes @21 :List(UInt64); + desiredCurvatureRate @62 :Float32; + canErrorCounter @57 :UInt32; + vPid @2 :Float32; + alertBlinkingRate @42 :Float32; + alertText1 @24 :Text; + alertText2 @25 :Text; + alertStatus @38 :SelfdriveState.AlertStatus; + alertSize @39 :SelfdriveState.AlertSize; + alertType @44 :Text; + alertSound2 @56 :Car.CarControl.HUDControl.AudibleAlert; + engageable @41 :Bool; # can OP be engaged? + state @31 :SelfdriveState.OpenpilotState; + enabled @19 :Bool; + active @36 :Bool; + experimentalMode @64 :Bool; + personality @66 :LongitudinalPersonality; + vCruise @22 :Float32; # actual set speed + vCruiseCluster @63 :Float32; # set speed to display in the UI + startMonoTime @48 :UInt64; + cumLagMs @15 :Float32; + aTarget @35 :Float32; + vTargetLead @3 :Float32; + } } struct DrivingModelData { @@ -1095,16 +992,10 @@ struct ModelDataV2 { meta @12 :MetaData; confidence @23: ConfidenceClass; - # Model perceived motion - temporalPoseDEPRECATED @21 :Pose; - # e2e lateral planner action @26: Action; - gpuExecutionTimeDEPRECATED @17 :Float32; - navEnabledDEPRECATED @22 :Bool; - locationMonoTimeDEPRECATED @24 :UInt64; - lateralPlannerSolutionDEPRECATED @25: LateralPlannerSolution; + lateralPlannerSolutionDEPRECATED @25: Deprecated.LateralPlannerSolution; struct LeadDataV2 { prob @0 :Float32; # probability that car is your lead at time t @@ -1146,10 +1037,11 @@ struct ModelDataV2 { laneChangeDirection @9 :LaneChangeDirection; - # deprecated - brakeDisengageProbDEPRECATED @2 :Float32; - gasDisengageProbDEPRECATED @3 :Float32; - steerOverrideProbDEPRECATED @4 :Float32; + deprecated :group { + brakeDisengageProb @2 :Float32; + gasDisengageProb @3 :Float32; + steerOverrideProb @4 :Float32; + } } enum ConfidenceClass { @@ -1177,22 +1069,18 @@ struct ModelDataV2 { rotStd @3 :List(Float32); # std rad/s in device frame } - struct LateralPlannerSolution { - x @0 :List(Float32); - y @1 :List(Float32); - yaw @2 :List(Float32); - yawRate @3 :List(Float32); - xStd @4 :List(Float32); - yStd @5 :List(Float32); - yawStd @6 :List(Float32); - yawRateStd @7 :List(Float32); - } - struct Action { desiredCurvature @0 :Float32; desiredAcceleration @1 :Float32; shouldStop @2 :Bool; } + + deprecated :group { + temporalPose @21 :Pose; + gpuExecutionTime @17 :Float32; + navEnabled @22 :Bool; + locationMonoTime @24 :UInt64; + } } struct EncodeIndex { @@ -1278,38 +1166,35 @@ struct LongitudinalPlan @0xe00b5b3eba12876c { e2e @4; } - # deprecated - vCruiseDEPRECATED @16 :Float32; - aCruiseDEPRECATED @17 :Float32; - vTargetDEPRECATED @3 :Float32; - vTargetFutureDEPRECATED @14 :Float32; - vStartDEPRECATED @26 :Float32; - aStartDEPRECATED @27 :Float32; - vMaxDEPRECATED @20 :Float32; - radarStateMonoTimeDEPRECATED @10 :UInt64; - jerkFactorDEPRECATED @6 :Float32; - hasLeftLaneDEPRECATED @23 :Bool; - hasRightLaneDEPRECATED @24 :Bool; - aTargetMinDEPRECATED @4 :Float32; - aTargetMaxDEPRECATED @5 :Float32; - lateralValidDEPRECATED @0 :Bool; - longitudinalValidDEPRECATED @2 :Bool; - dPolyDEPRECATED @1 :List(Float32); - laneWidthDEPRECATED @11 :Float32; - vCurvatureDEPRECATED @21 :Float32; - decelForTurnDEPRECATED @22 :Bool; - mapValidDEPRECATED @25 :Bool; - radarValidDEPRECATED @28 :Bool; - radarCanErrorDEPRECATED @30 :Bool; - commIssueDEPRECATED @31 :Bool; - eventsDEPRECATED @13 :List(Car.OnroadEventDEPRECATED); - gpsTrajectoryDEPRECATED @12 :GpsTrajectory; - gpsPlannerActiveDEPRECATED @19 :Bool; - personalityDEPRECATED @36 :LongitudinalPersonality; - struct GpsTrajectory { - x @0 :List(Float32); - y @1 :List(Float32); + deprecated :group { + vCruise @16 :Float32; + aCruise @17 :Float32; + vTarget @3 :Float32; + vTargetFuture @14 :Float32; + vStart @26 :Float32; + aStart @27 :Float32; + vMax @20 :Float32; + radarStateMonoTime @10 :UInt64; + jerkFactor @6 :Float32; + hasLeftLane @23 :Bool; + hasRightLane @24 :Bool; + aTargetMin @4 :Float32; + aTargetMax @5 :Float32; + lateralValid @0 :Bool; + longitudinalValid @2 :Bool; + dPoly @1 :List(Float32); + laneWidth @11 :Float32; + vCurvature @21 :Float32; + decelForTurn @22 :Bool; + mapValid @25 :Bool; + radarValid @28 :Bool; + radarCanError @30 :Bool; + commIssue @31 :Bool; + events @13 :List(Car.OnroadEventDEPRECATED); + gpsTrajectory @12 :Deprecated.GpsTrajectory; + gpsPlannerActive @19 :Bool; + personality @36 :LongitudinalPersonality; } } struct UiPlan { @@ -1320,11 +1205,7 @@ struct UiPlan { struct LateralPlan @0xe1e9318e2ae8b51e { modelMonoTime @31 :UInt64; - laneWidthDEPRECATED @0 :Float32; - lProbDEPRECATED @5 :Float32; - rProbDEPRECATED @7 :Float32; dPathPoints @20 :List(Float32); - dProbDEPRECATED @21 :Float32; mpcSolutionValid @9 :Bool; desire @17 :Desire; @@ -1346,24 +1227,29 @@ struct LateralPlan @0xe1e9318e2ae8b51e { u @1 :List(Float32); } - # deprecated - curvatureDEPRECATED @22 :Float32; - curvatureRateDEPRECATED @23 :Float32; - rawCurvatureDEPRECATED @24 :Float32; - rawCurvatureRateDEPRECATED @25 :Float32; - cProbDEPRECATED @3 :Float32; - dPolyDEPRECATED @1 :List(Float32); - cPolyDEPRECATED @2 :List(Float32); - lPolyDEPRECATED @4 :List(Float32); - rPolyDEPRECATED @6 :List(Float32); - modelValidDEPRECATED @12 :Bool; - commIssueDEPRECATED @15 :Bool; - posenetValidDEPRECATED @16 :Bool; - sensorValidDEPRECATED @14 :Bool; - paramsValidDEPRECATED @10 :Bool; - steeringAngleDegDEPRECATED @8 :Float32; # deg - steeringRateDegDEPRECATED @13 :Float32; # deg/s - angleOffsetDegDEPRECATED @11 :Float32; + deprecated :group { + laneWidth @0 :Float32; + lProb @5 :Float32; + rProb @7 :Float32; + dProb @21 :Float32; + curvature @22 :Float32; + curvatureRate @23 :Float32; + rawCurvature @24 :Float32; + rawCurvatureRate @25 :Float32; + cProb @3 :Float32; + dPoly @1 :List(Float32); + cPoly @2 :List(Float32); + lPoly @4 :List(Float32); + rPoly @6 :List(Float32); + modelValid @12 :Bool; + commIssue @15 :Bool; + posenetValid @16 :Bool; + sensorValid @14 :Bool; + paramsValid @10 :Bool; + steeringAngleDeg @8 :Float32; # deg + steeringRateDeg @13 :Float32; # deg/s + angleOffsetDeg @11 :Float32; + } } struct LiveLocationKalman { @@ -1558,7 +1444,10 @@ struct GnssMeasurements { # Satellite position and velocity [x,y,z] satPos @7 :List(Float64); satVel @8 :List(Float64); - ephemerisSourceDEPRECATED @9 :EphemerisSourceDEPRECATED; + + deprecated :group { + ephemerisSource @9 :EphemerisSourceDEPRECATED; + } } struct EphemerisSourceDEPRECATED { @@ -1713,7 +1602,6 @@ struct UbloxGnss { iDot @26 :Float64; codesL2 @27 :Float64; - gpsWeekDEPRECATED @28 :Float64; l2 @29 :Float64; svAcc @30 :Float64; @@ -1733,6 +1621,10 @@ struct UbloxGnss { towCount @40 :UInt32; toeWeek @41 :UInt16; tocWeek @42 :UInt16; + + deprecated :group { + gpsWeek @28 :Float64; + } } struct IonoData { @@ -1811,7 +1703,6 @@ struct UbloxGnss { age @17 :UInt8; svHealth @18 :UInt8; - tkDEPRECATED @19 :UInt16; tb @20 :UInt16; tauN @21 :Float64; @@ -1823,12 +1714,16 @@ struct UbloxGnss { p3 @26 :UInt8; p4 @27 :UInt8; - freqNumDEPRECATED @28 :UInt32; n4 @29 :UInt8; nt @30 :UInt16; freqNum @31 :Int16; tkSeconds @32 :UInt32; + + deprecated :group { + tk @19 :UInt16; + freqNum @28 :UInt32; + } } } @@ -2129,34 +2024,12 @@ struct QcomGnss @0xde94674b07ae51c1 { struct Clocks { wallTimeNanos @3 :UInt64; # unix epoch time - bootTimeNanosDEPRECATED @0 :UInt64; - monotonicNanosDEPRECATED @1 :UInt64; - monotonicRawNanosDEPRECATD @2 :UInt64; - modemUptimeMillisDEPRECATED @4 :UInt64; -} - -struct LiveMpcData { - x @0 :List(Float32); - y @1 :List(Float32); - psi @2 :List(Float32); - curvature @3 :List(Float32); - qpIterations @4 :UInt32; - calculationTime @5 :UInt64; - cost @6 :Float64; -} - -struct LiveLongitudinalMpcData { - xEgo @0 :List(Float32); - vEgo @1 :List(Float32); - aEgo @2 :List(Float32); - xLead @3 :List(Float32); - vLead @4 :List(Float32); - aLead @5 :List(Float32); - aLeadTau @6 :Float32; # lead accel time constant - qpIterations @7 :UInt32; - mpcId @8 :UInt32; - calculationTime @9 :UInt64; - cost @10 :Float64; + deprecated :group { + bootTimeNanos @0 :UInt64; + monotonicNanos @1 :UInt64; + monotonicRawNanos @2 :UInt64; + modemUptimeMillis @4 :UInt64; + } } struct Joystick { @@ -2184,50 +2057,23 @@ struct DriverStateV2 { eyesVisibleProb @14 :Float32; eyesClosedProb @15 :Float32; phoneProb @13 :Float32; - leftEyeProbDEPRECATED @5 :Float32; - rightEyeProbDEPRECATED @6 :Float32; - leftBlinkProbDEPRECATED @7 :Float32; - rightBlinkProbDEPRECATED @8 :Float32; - sunglassesProbDEPRECATED @9 :Float32; - notReadyProbDEPRECATED @12 :List(Float32); - occludedProbDEPRECATED @10 :Float32; - readyProbDEPRECATED @11 :List(Float32); + + deprecated :group { + leftEyeProb @5 :Float32; + rightEyeProb @6 :Float32; + leftBlinkProb @7 :Float32; + rightBlinkProb @8 :Float32; + sunglassesProb @9 :Float32; + notReadyProb @12 :List(Float32); + occludedProb @10 :Float32; + readyProb @11 :List(Float32); + } } - dspExecutionTimeDEPRECATED @2 :Float32; - poorVisionProbDEPRECATED @4 :Float32; -} - -struct DriverStateDEPRECATED @0xb83c6cc593ed0a00 { - frameId @0 :UInt32; - modelExecutionTime @14 :Float32; - dspExecutionTime @16 :Float32; - rawPredictions @15 :Data; - - faceOrientation @3 :List(Float32); - facePosition @4 :List(Float32); - faceProb @5 :Float32; - leftEyeProb @6 :Float32; - rightEyeProb @7 :Float32; - leftBlinkProb @8 :Float32; - rightBlinkProb @9 :Float32; - faceOrientationStd @11 :List(Float32); - facePositionStd @12 :List(Float32); - sunglassesProb @13 :Float32; - poorVision @17 :Float32; - partialFace @18 :Float32; - distractedPose @19 :Float32; - distractedEyes @20 :Float32; - eyesOnRoad @21 :Float32; - phoneUse @22 :Float32; - occludedProb @23 :Float32; - - readyProb @24 :List(Float32); - notReadyProb @25 :List(Float32); - - irPwrDEPRECATED @10 :Float32; - descriptorDEPRECATED @1 :List(Float32); - stdDEPRECATED @2 :Float32; + deprecated :group { + dspExecutionTime @2 :Float32; + poorVisionProb @4 :Float32; + } } struct DriverMonitoringState @0xb83cda094a1da284 { @@ -2249,11 +2095,13 @@ struct DriverMonitoringState @0xb83cda094a1da284 { isRHD @4 :Bool; uncertainCount @19 :UInt32; - phoneProbOffsetDEPRECATED @20 :Float32; - phoneProbValidCountDEPRECATED @21 :UInt32; - isPreviewDEPRECATED @15 :Bool; - rhdCheckedDEPRECATED @5 :Bool; - eventsDEPRECATED @0 :List(Car.OnroadEventDEPRECATED); + deprecated :group { + phoneProbOffset @20 :Float32; + phoneProbValidCount @21 :UInt32; + isPreview @15 :Bool; + rhdChecked @5 :Bool; + events @0 :List(Car.OnroadEventDEPRECATED); + } } struct Boot { @@ -2262,8 +2110,10 @@ struct Boot { commands @5 :Map(Text, Data); launchLog @3 :Text; - lastKmsgDEPRECATED @1 :Data; - lastPmsgDEPRECATED @2 :Data; + deprecated :group { + lastKmsg @1 :Data; + lastPmsg @2 :Data; + } } struct LiveParametersData { @@ -2288,13 +2138,16 @@ struct LiveParametersData { steerRatioValid @19 :Bool = true; stiffnessFactorValid @20 :Bool = true; - yawRateDEPRECATED @7 :Float32; - filterStateDEPRECATED @15 :LiveLocationKalman.Measurement; struct FilterState { value @0 : List(Float64); std @1 : List(Float64); } + + deprecated :group { + yawRate @7 :Float32; + filterState @15 :LiveLocationKalman.Measurement; + } } struct LiveTorqueParametersData { @@ -2464,25 +2317,6 @@ struct MapRenderState { frameId @2: UInt32; } -struct NavModelData { - frameId @0 :UInt32; - locationMonoTime @6 :UInt64; - modelExecutionTime @1 :Float32; - dspExecutionTime @2 :Float32; - features @3 :List(Float32); - # predicted future position - position @4 :XYData; - desirePrediction @5 :List(Float32); - - # All SI units and in device frame - struct XYData { - x @0 :List(Float32); - y @1 :List(Float32); - xStd @2 :List(Float32); - yStd @3 :List(Float32); - } -} - struct EncodeData { idx @0 :EncodeIndex; data @1 :Data; @@ -2507,7 +2341,9 @@ struct SoundPressure @0xdc24138990726023 { soundPressureWeighted @3 :Float32; soundPressureWeightedDb @1 :Float32; - filteredSoundPressureWeightedDbDEPRECATED @2 :Float32; + deprecated :group { + filteredSoundPressureWeightedDb @2 :Float32; + } } struct AudioData { @@ -2649,16 +2485,16 @@ struct Event { # DO change the name of the field and struct # DON'T change the ID (e.g. @107) # DON'T change which struct it points to - customReserved0 @107 :Custom.CustomReserved0; - customReserved1 @108 :Custom.CustomReserved1; - customReserved2 @109 :Custom.CustomReserved2; - customReserved3 @110 :Custom.CustomReserved3; - customReserved4 @111 :Custom.CustomReserved4; - customReserved5 @112 :Custom.CustomReserved5; - customReserved6 @113 :Custom.CustomReserved6; - customReserved7 @114 :Custom.CustomReserved7; - customReserved8 @115 :Custom.CustomReserved8; - customReserved9 @116 :Custom.CustomReserved9; + selfdriveStateSP @107 :Custom.SelfdriveStateSP; + modelManagerSP @108 :Custom.ModelManagerSP; + longitudinalPlanSP @109 :Custom.LongitudinalPlanSP; + onroadEventsSP @110 :Custom.OnroadEventSP; + carParamsSP @111 :Custom.CarParamsSP; + carControlSP @112 :Custom.CarControlSP; + backupManagerSP @113 :Custom.BackupManagerSP; + carStateSP @114 :Custom.CarStateSP; + liveMapDataSP @115 :Custom.LiveMapDataSP; + modelDataV2SP @116 :Custom.ModelDataV2SP; customReserved10 @136 :Custom.CustomReserved10; customReserved11 @137 :Custom.CustomReserved11; customReserved12 @138 :Custom.CustomReserved12; @@ -2671,48 +2507,48 @@ struct Event { customReserved19 @145 :Custom.CustomReserved19; # *********** legacy + deprecated *********** - model @9 :Legacy.ModelData; # TODO: rename modelV2 and mark this as deprecated - liveMpcDEPRECATED @36 :LiveMpcData; - liveLongitudinalMpcDEPRECATED @37 :LiveLongitudinalMpcData; - liveLocationKalmanLegacyDEPRECATED @51 :Legacy.LiveLocationData; - orbslamCorrectionDEPRECATED @45 :Legacy.OrbslamCorrection; - liveUIDEPRECATED @14 :Legacy.LiveUI; + model @9 :Deprecated.ModelData; # TODO: rename modelV2 and mark this as deprecated + liveMpcDEPRECATED @36 :Deprecated.LiveMpcData; + liveLongitudinalMpcDEPRECATED @37 :Deprecated.LiveLongitudinalMpcData; + liveLocationKalmanDeprecatedDEPRECATED @51 :Deprecated.LiveLocationData; + orbslamCorrectionDEPRECATED @45 :Deprecated.OrbslamCorrection; + liveUIDEPRECATED @14 :Deprecated.LiveUI; sensorEventDEPRECATED @4 :SensorEventData; - liveEventDEPRECATED @8 :List(Legacy.LiveEventData); - liveLocationDEPRECATED @25 :Legacy.LiveLocationData; - ethernetDataDEPRECATED @26 :List(Legacy.EthernetPacket); - cellInfoDEPRECATED @28 :List(Legacy.CellInfo); - wifiScanDEPRECATED @29 :List(Legacy.WifiScan); - uiNavigationEventDEPRECATED @50 :Legacy.UiNavigationEvent; + liveEventDEPRECATED @8 :List(Deprecated.LiveEventData); + liveLocationDEPRECATED @25 :Deprecated.LiveLocationData; + ethernetDataDEPRECATED @26 :List(Deprecated.EthernetPacket); + cellInfoDEPRECATED @28 :List(Deprecated.CellInfo); + wifiScanDEPRECATED @29 :List(Deprecated.WifiScan); + uiNavigationEventDEPRECATED @50 :Deprecated.UiNavigationEvent; liveMapDataDEPRECATED @62 :LiveMapDataDEPRECATED; - gpsPlannerPointsDEPRECATED @40 :Legacy.GPSPlannerPoints; - gpsPlannerPlanDEPRECATED @41 :Legacy.GPSPlannerPlan; + gpsPlannerPointsDEPRECATED @40 :Deprecated.GPSPlannerPoints; + gpsPlannerPlanDEPRECATED @41 :Deprecated.GPSPlannerPlan; applanixRawDEPRECATED @42 :Data; - androidGnssDEPRECATED @30 :Legacy.AndroidGnss; - lidarPtsDEPRECATED @32 :Legacy.LidarPts; - navStatusDEPRECATED @38 :Legacy.NavStatus; - trafficEventsDEPRECATED @43 :List(Legacy.TrafficEvent); - liveLocationTimingDEPRECATED @44 :Legacy.LiveLocationData; - liveLocationCorrectedDEPRECATED @46 :Legacy.LiveLocationData; - navUpdateDEPRECATED @27 :Legacy.NavUpdate; - orbObservationDEPRECATED @47 :List(Legacy.OrbObservation); - locationDEPRECATED @49 :Legacy.LiveLocationData; - orbOdometryDEPRECATED @53 :Legacy.OrbOdometry; - orbFeaturesDEPRECATED @54 :Legacy.OrbFeatures; - applanixLocationDEPRECATED @55 :Legacy.LiveLocationData; - orbKeyFrameDEPRECATED @56 :Legacy.OrbKeyFrame; - orbFeaturesSummaryDEPRECATED @58 :Legacy.OrbFeaturesSummary; - featuresDEPRECATED @10 :Legacy.CalibrationFeatures; - kalmanOdometryDEPRECATED @65 :Legacy.KalmanOdometry; - uiLayoutStateDEPRECATED @57 :Legacy.UiLayoutState; + androidGnssDEPRECATED @30 :Deprecated.AndroidGnss; + lidarPtsDEPRECATED @32 :Deprecated.LidarPts; + navStatusDEPRECATED @38 :Deprecated.NavStatus; + trafficEventsDEPRECATED @43 :List(Deprecated.TrafficEvent); + liveLocationTimingDEPRECATED @44 :Deprecated.LiveLocationData; + liveLocationCorrectedDEPRECATED @46 :Deprecated.LiveLocationData; + navUpdateDEPRECATED @27 :Deprecated.NavUpdate; + orbObservationDEPRECATED @47 :List(Deprecated.OrbObservation); + locationDEPRECATED @49 :Deprecated.LiveLocationData; + orbOdometryDEPRECATED @53 :Deprecated.OrbOdometry; + orbFeaturesDEPRECATED @54 :Deprecated.OrbFeatures; + applanixLocationDEPRECATED @55 :Deprecated.LiveLocationData; + orbKeyFrameDEPRECATED @56 :Deprecated.OrbKeyFrame; + orbFeaturesSummaryDEPRECATED @58 :Deprecated.OrbFeaturesSummary; + featuresDEPRECATED @10 :Deprecated.CalibrationFeatures; + kalmanOdometryDEPRECATED @65 :Deprecated.KalmanOdometry; + uiLayoutStateDEPRECATED @57 :Deprecated.UiLayoutState; pandaStateDEPRECATED @12 :PandaState; - driverStateDEPRECATED @59 :DriverStateDEPRECATED; + driverStateDEPRECATED @59 :Deprecated.DriverStateDEPRECATED; sensorEventsDEPRECATED @11 :List(SensorEventData); lateralPlanDEPRECATED @64 :LateralPlan; - navModelDEPRECATED @104 :NavModelData; + navModelDEPRECATED @104 :Deprecated.NavModelData; uiPlanDEPRECATED @106 :UiPlan; - liveLocationKalmanDEPRECATED @72 :LiveLocationKalman; - liveTracksDEPRECATED @16 :List(LiveTracksDEPRECATED); + liveLocationKalman @72 :LiveLocationKalman; + liveTracksDEPRECATED @16 :List(Deprecated.LiveTracksDEPRECATED); onroadEventsDEPRECATED @68: List(Car.OnroadEventDEPRECATED); gyroscope2DEPRECATED @100 :SensorEventData; accelerometer2DEPRECATED @101 :SensorEventData; diff --git a/cereal/messaging/tests/validate_sp_cereal_upstream.py b/cereal/messaging/tests/validate_sp_cereal_upstream.py new file mode 100755 index 0000000000..9ccd6533ce --- /dev/null +++ b/cereal/messaging/tests/validate_sp_cereal_upstream.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +import argparse +import sys +from typing import Any, List, Tuple + +DEBUG = False + + +def print_debug(string: str) -> None: + if DEBUG: + print(string) + + +def create_schema_instance(struct: Any, prop: Tuple[str, Any]) -> Any: + """ + Create a new instance of a schema type, handling different field types. + + Args: + struct: The Cap'n Proto schema structure + prop: A tuple containing the field name and field metadata + + Returns: + A new initialized schema instance + """ + struct_instance = struct.new_message() + field_name, field_metadata = prop + + try: + field_type = field_metadata.proto.slot.type.which() + + # Initialize different types of fields + if field_type in ('list', 'text', 'data'): + struct_instance.init(field_name, 1) + print_debug(f"Initialized list/text/data field: {field_name}") + elif field_type in ('struct', 'object'): + struct_instance.init(field_name) + print_debug(f"Initialized struct/object field: {field_name}") + + return struct_instance + + except Exception as e: + print(f"Error creating instance for {field_name}: {e}") + return None + + +def get_schema_fields(schema_struct: Any) -> List[Tuple[str, Any]]: + """ + Retrieve all fields from a given schema structure. + + Args: + schema_struct: The Cap'n Proto schema structure + + Returns: + A list of field names and their metadata + """ + try: + # Get all fields from the schema + schema_fields = list(schema_struct.schema.fields.items()) + + print_debug("Discovered schema fields:") + for field_name, field_metadata in schema_fields: + print_debug(f"- {field_name}") + + return schema_fields + + except Exception as e: + print(f"Error retrieving schema fields: {e}") + return [] + + +def generate_schema_instances(schema_struct: Any) -> List[Any]: + """ + Generate instances for all fields in a given schema. + + Args: + schema_struct: The Cap'n Proto schema structure + + Returns: + A list of schema instances + """ + schema_fields = get_schema_fields(schema_struct) + instances = [] + + for field_prop in schema_fields: + try: + instance = create_schema_instance(schema_struct, field_prop) + if instance is not None: + instances.append(instance) + except Exception as e: + print(f"Skipping field due to error: {e}") + + print(f"Generated {len(instances)} schema instances") + return instances + + +def persist_instances(instances: List[Any], filename: str) -> None: + """ + Write schema instances to a binary file. + + Args: + instances: List of schema instances + filename: Output file path + """ + try: + with open(filename, 'wb') as f: + for instance in instances: + f.write(instance.to_bytes()) + + print(f"Successfully wrote {len(instances)} instances to {filename}") + + except Exception as e: + print(f"Error persisting instances: {e}") + sys.exit(1) + + +def read_instances(filename: str, schema_type: Any) -> List[Any]: + """ + Read schema instances from a binary file. + + Args: + filename: Input file path + schema_type: The schema type to use for reading + + Returns: + A list of read schema instances + """ + try: + with open(filename, 'rb') as f: + data = f.read() + + instances = list(schema_type.read_multiple_bytes(data)) + + print(f"Read {len(instances)} instances from {filename}") + return instances + + except Exception as e: + print(f"Error reading instances: {e}") + sys.exit(1) + + +def compare_schemas(original_instances: List[Any], read_instances: List[Any]) -> bool: + """ + Compare original and read-back instances to detect potential breaking changes. + + Args: + original_instances: List of originally generated instances + read_instances: List of instances read back from file + + Returns: + Boolean indicating whether schemas appear compatible + """ + if len(original_instances) != len(read_instances): + print("❌ Schema Compatibility Warning: Instance count mismatch") + return False + + compatible = True + for struct in read_instances: + try: + getattr(struct, struct.which()) # Attempting to access the field to validate readability + except Exception as e: + print(f"❌ Structural change detected: {struct.which()} is not readable.\nFull error: {e}") + compatible = False + + return compatible + + +def main(): + """ + CLI entry point for schema compatibility testing. + """ + # Setup argument parser + parser = argparse.ArgumentParser( + description='Cap\'n Proto Schema Compatibility Testing Tool', + epilog='Test schema compatibility by generating and reading back instances.' + ) + + # Add mutually exclusive group for generation or reading mode + mode_group = parser.add_mutually_exclusive_group(required=True) + mode_group.add_argument('-g', '--generate', action='store_true', + help='Generate schema instances') + mode_group.add_argument('-r', '--read', action='store_true', + help='Read and validate schema instances') + + # Common arguments + parser.add_argument('-f', '--file', + default='schema_instances.bin', + help='Output/input binary file (default: schema_instances.bin)') + + # Parse arguments + args = parser.parse_args() + + # Import the schema dynamically + try: + from cereal import log + schema_type = log.Event + except ImportError: + print("Error: Unable to import schema. Ensure 'cereal' is installed.") + sys.exit(1) + + # Execute based on mode + if args.generate: + print("🔧 Generating Schema Instances") + instances = generate_schema_instances(schema_type) + persist_instances(instances, args.file) + print("✅ Instance generation complete") + + elif args.read: + print("🔍 Reading and Validating Schema Instances") + generated_instances = generate_schema_instances(schema_type) + read_back_instances = read_instances(args.file, schema_type) + + # Compare schemas + if compare_schemas(generated_instances, read_back_instances): + print("✅ Schema Compatibility: No breaking changes detected") + sys.exit(0) + else: + print("❌ Potential Schema Breaking Changes Detected") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/cereal/services.py b/cereal/services.py index 6b98128d64..9d64f67f2f 100755 --- a/cereal/services.py +++ b/cereal/services.py @@ -39,8 +39,8 @@ _services: dict[str, tuple] = { "roadEncodeIdx": (False, 20., 1), "liveTracks": (True, 20.), "sendcan": (True, 100., 139, QueueSize.MEDIUM), - "logMessage": (True, 0.), - "errorLogMessage": (True, 0., 1), + "logMessage": (True, 0., None, QueueSize.BIG), + "errorLogMessage": (True, 0., 1, QueueSize.BIG), "liveCalibration": (True, 4., 4), "liveTorqueParameters": (True, 4., 1), "liveDelay": (True, 4., 1), @@ -90,6 +90,19 @@ _services: dict[str, tuple] = { "wideRoadEncodeData": (False, 20., None, QueueSize.BIG), "qRoadEncodeData": (False, 20., None, QueueSize.BIG), + # sunnypilot + "modelManagerSP": (False, 1., 1, QueueSize.BIG), + "backupManagerSP": (False, 1., 1, QueueSize.BIG), + "selfdriveStateSP": (True, 100., 10), + "longitudinalPlanSP": (True, 20., 10), + "onroadEventsSP": (True, 1., 1), + "carParamsSP": (True, 0.02, 1), + "carControlSP": (True, 100., 10), + "carStateSP": (True, 100., 10), + "liveMapDataSP": (True, 1., 1), + "modelDataV2SP": (True, 20., None, QueueSize.BIG), + "liveLocationKalman": (True, 20.), + # debug "uiDebug": (True, 0., 1), "testJoystick": (True, 0.), diff --git a/common/api.py b/common/api.py deleted file mode 100644 index ebf0290d15..0000000000 --- a/common/api.py +++ /dev/null @@ -1,62 +0,0 @@ -import jwt -import os -import requests -from datetime import datetime, timedelta, UTC -from openpilot.system.hardware.hw import Paths -from openpilot.system.version import get_version - -API_HOST = os.getenv('API_HOST', 'https://api.commadotai.com') - -# name: jwt signature algorithm -KEYS = {"id_rsa": "RS256", - "id_ecdsa": "ES256"} - - -class Api: - def __init__(self, dongle_id): - self.dongle_id = dongle_id - self.jwt_algorithm, self.private_key, _ = get_key_pair() - - def get(self, *args, **kwargs): - return self.request('GET', *args, **kwargs) - - def post(self, *args, **kwargs): - return self.request('POST', *args, **kwargs) - - def request(self, method, endpoint, timeout=None, access_token=None, **params): - return api_get(endpoint, method=method, timeout=timeout, access_token=access_token, **params) - - def get_token(self, payload_extra=None, expiry_hours=1): - now = datetime.now(UTC).replace(tzinfo=None) - payload = { - 'identity': self.dongle_id, - 'nbf': now, - 'iat': now, - 'exp': now + timedelta(hours=expiry_hours) - } - if payload_extra is not None: - payload.update(payload_extra) - token = jwt.encode(payload, self.private_key, algorithm=self.jwt_algorithm) - if isinstance(token, bytes): - token = token.decode('utf8') - return token - - -def api_get(endpoint, method='GET', timeout=None, access_token=None, session=None, **params): - headers = {} - if access_token is not None: - headers['Authorization'] = "JWT " + access_token - - headers['User-Agent'] = "openpilot-" + get_version() - - # TODO: add session to Api - req = requests if session is None else session - return req.request(method, API_HOST + "/" + endpoint, timeout=timeout, headers=headers, params=params) - - -def get_key_pair() -> tuple[str, str, str] | tuple[None, None, None]: - for key in KEYS: - if os.path.isfile(Paths.persist_root() + f'/comma/{key}') and os.path.isfile(Paths.persist_root() + f'/comma/{key}.pub'): - with open(Paths.persist_root() + f'/comma/{key}') as private, open(Paths.persist_root() + f'/comma/{key}.pub') as public: - return KEYS[key], private.read(), public.read() - return None, None, None diff --git a/common/api/__init__.py b/common/api/__init__.py new file mode 100644 index 0000000000..45c73e2a05 --- /dev/null +++ b/common/api/__init__.py @@ -0,0 +1,26 @@ +from openpilot.common.api.comma_connect import CommaConnectApi + + +class Api: + def __init__(self, dongle_id): + self.service = CommaConnectApi(dongle_id) + + def request(self, method, endpoint, **params): + return self.service.request(method, endpoint, **params) + + def get(self, *args, **kwargs): + return self.service.get(*args, **kwargs) + + def post(self, *args, **kwargs): + return self.service.post(*args, **kwargs) + + def get_token(self, payload_extra=None, expiry_hours=1): + return self.service.get_token(payload_extra, expiry_hours) + + +def api_get(endpoint, method='GET', timeout=None, access_token=None, session=None, **params): + return CommaConnectApi(None).api_get(endpoint, method, timeout, access_token, session, **params) + + +def get_key_pair() -> tuple[str, str, str] | tuple[None, None, None]: + return CommaConnectApi(None).get_key_pair() diff --git a/common/api/base.py b/common/api/base.py new file mode 100644 index 0000000000..5cb59347ad --- /dev/null +++ b/common/api/base.py @@ -0,0 +1,72 @@ +import jwt +import os +import requests +import unicodedata +from datetime import datetime, timedelta, UTC +from openpilot.system.hardware.hw import Paths +from openpilot.system.version import get_version + +# name: jwt signature algorithm +KEYS = {"id_rsa": "RS256", + "id_ecdsa": "ES256"} + + +class BaseApi: + def __init__(self, dongle_id, api_host, user_agent="openpilot-"): + self.dongle_id = dongle_id + self.api_host = api_host + self.user_agent = user_agent + self.jwt_algorithm, self.private_key, _ = self.get_key_pair() + + def get(self, *args, **kwargs): + return self.request('GET', *args, **kwargs) + + def post(self, *args, **kwargs): + return self.request('POST', *args, **kwargs) + + def request(self, method, endpoint, timeout=None, access_token=None, **params): + return self.api_get(endpoint, method=method, timeout=timeout, access_token=access_token, **params) + + def _get_token(self, payload_extra=None, expiry_hours=1, **extra_payload): + now = datetime.now(UTC).replace(tzinfo=None) + payload = { + 'identity': self.dongle_id, + 'nbf': now, + 'iat': now, + 'exp': now + timedelta(hours=expiry_hours), + **extra_payload + } + if payload_extra is not None: + payload.update(payload_extra) + token = jwt.encode(payload, self.private_key, algorithm=self.jwt_algorithm) + if isinstance(token, bytes): + token = token.decode('utf8') + return token + + def get_token(self, payload_extra=None, expiry_hours=1): + return self._get_token(payload_extra, expiry_hours) + + def remove_non_ascii_chars(self, text): + normalized_text = unicodedata.normalize('NFD', text) + ascii_encoded_text = normalized_text.encode('ascii', 'ignore') + return ascii_encoded_text.decode() + + def api_get(self, endpoint, method='GET', timeout=None, access_token=None, session=None, json=None, **params): + headers = {} + if access_token is not None: + headers['Authorization'] = "JWT " + access_token + + version = self.remove_non_ascii_chars(get_version()) + headers['User-Agent'] = self.user_agent + version + + # TODO: add session to Api + req = requests if session is None else session + return req.request(method, f"{self.api_host}/{endpoint}", timeout=timeout, headers=headers, json=json, params=params) + + @staticmethod + def get_key_pair() -> tuple[str, str, str] | tuple[None, None, None]: + for key in KEYS: + if os.path.isfile(Paths.persist_root() + f'/comma/{key}') and os.path.isfile(Paths.persist_root() + f'/comma/{key}.pub'): + with open(Paths.persist_root() + f'/comma/{key}') as private, open(Paths.persist_root() + f'/comma/{key}.pub') as public: + return KEYS[key], private.read(), public.read() + return None, None, None diff --git a/common/api/comma_connect.py b/common/api/comma_connect.py new file mode 100644 index 0000000000..1c705f3722 --- /dev/null +++ b/common/api/comma_connect.py @@ -0,0 +1,11 @@ +import os + +from openpilot.common.api.base import BaseApi + +API_HOST = os.getenv('API_HOST', 'https://api.commadotai.com') + + +class CommaConnectApi(BaseApi): + def __init__(self, dongle_id): + super().__init__(dongle_id, API_HOST) + self.user_agent = "openpilot-" diff --git a/common/model.h b/common/model.h new file mode 100644 index 0000000000..fc1110431c --- /dev/null +++ b/common/model.h @@ -0,0 +1 @@ +#define DEFAULT_MODEL "OP Model 7 (Default)" diff --git a/common/params.cc b/common/params.cc index 6af00fe95c..39592cb905 100644 --- a/common/params.cc +++ b/common/params.cc @@ -103,10 +103,12 @@ Params::~Params() { assert(queue.empty()); } -std::vector Params::allKeys() const { +std::vector Params::allKeys(ParamKeyFlag flag) const { std::vector ret; for (auto &p : keys) { - ret.push_back(p.first); + if (flag == ALL || (p.second.flags & flag)) { + ret.push_back(p.first); + } } return ret; } diff --git a/common/params.h b/common/params.h index 8169063ac0..de4f9b435f 100644 --- a/common/params.h +++ b/common/params.h @@ -18,6 +18,7 @@ enum ParamKeyFlag { DONT_LOG = 0x20, DEVELOPMENT_ONLY = 0x40, CLEAR_ON_IGNITION_ON = 0x80, + BACKUP = 0x100, ALL = 0xFFFFFFFF }; @@ -45,7 +46,7 @@ public: Params(const Params&) = delete; Params& operator=(const Params&) = delete; - std::vector allKeys() const; + std::vector allKeys(ParamKeyFlag flag = ALL) const; bool checkKey(const std::string &key); ParamKeyFlag getKeyFlag(const std::string &key); ParamKeyType getKeyType(const std::string &key); diff --git a/common/params_keys.h b/common/params_keys.h index b81a373d08..35a7bc3bc4 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -7,8 +7,8 @@ inline static std::unordered_map keys = { {"AccessToken", {CLEAR_ON_MANAGER_START | DONT_LOG, STRING}}, - {"AdbEnabled", {PERSISTENT, BOOL}}, - {"AlwaysOnDM", {PERSISTENT, BOOL}}, + {"AdbEnabled", {PERSISTENT | BACKUP, BOOL}}, + {"AlwaysOnDM", {PERSISTENT | BACKUP, BOOL}}, {"ApiCache_Device", {PERSISTENT, STRING}}, {"ApiCache_FirehoseStats", {PERSISTENT, JSON}}, {"AssistNowToken", {PERSISTENT, STRING}}, @@ -29,36 +29,36 @@ inline static std::unordered_map keys = { {"CurrentBootlog", {PERSISTENT, STRING}}, {"CurrentRoute", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, STRING}}, {"DisableLogging", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}}, - {"DisablePowerDown", {PERSISTENT, BOOL}}, - {"DisableUpdates", {PERSISTENT, BOOL}}, - {"DisengageOnAccelerator", {PERSISTENT, BOOL, "0"}}, + {"DisablePowerDown", {PERSISTENT | BACKUP, BOOL}}, + {"DisableUpdates", {PERSISTENT | BACKUP, BOOL, "0"}}, + {"DisengageOnAccelerator", {PERSISTENT | BACKUP, BOOL, "0"}}, {"DongleId", {PERSISTENT, STRING}}, {"DoReboot", {CLEAR_ON_MANAGER_START, BOOL}}, {"DoShutdown", {CLEAR_ON_MANAGER_START, BOOL}}, {"DoUninstall", {CLEAR_ON_MANAGER_START, BOOL}}, {"DriverTooDistracted", {CLEAR_ON_MANAGER_START | CLEAR_ON_IGNITION_ON, BOOL}}, - {"AlphaLongitudinalEnabled", {PERSISTENT | DEVELOPMENT_ONLY, BOOL}}, - {"ExperimentalMode", {PERSISTENT, BOOL}}, - {"ExperimentalModeConfirmed", {PERSISTENT, BOOL}}, + {"AlphaLongitudinalEnabled", {PERSISTENT | DEVELOPMENT_ONLY | BACKUP, BOOL}}, + {"ExperimentalMode", {PERSISTENT | BACKUP, BOOL}}, + {"ExperimentalModeConfirmed", {PERSISTENT | BACKUP, BOOL}}, {"FirmwareQueryDone", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}}, {"ForcePowerDown", {PERSISTENT, BOOL}}, {"GitBranch", {PERSISTENT, STRING}}, {"GitCommit", {PERSISTENT, STRING}}, {"GitCommitDate", {PERSISTENT, STRING}}, {"GitDiff", {PERSISTENT, STRING}}, - {"GithubSshKeys", {PERSISTENT, STRING}}, - {"GithubUsername", {PERSISTENT, STRING}}, + {"GithubSshKeys", {PERSISTENT | BACKUP, STRING}}, + {"GithubUsername", {PERSISTENT | BACKUP, STRING}}, {"GitRemote", {PERSISTENT, STRING}}, - {"GsmApn", {PERSISTENT, STRING}}, - {"GsmMetered", {PERSISTENT, BOOL, "1"}}, - {"GsmRoaming", {PERSISTENT, BOOL}}, + {"GsmApn", {PERSISTENT | BACKUP, STRING}}, + {"GsmMetered", {PERSISTENT | BACKUP, BOOL, "1"}}, + {"GsmRoaming", {PERSISTENT | BACKUP, BOOL}}, {"HardwareSerial", {PERSISTENT, STRING}}, {"HasAcceptedTerms", {PERSISTENT, STRING, "0"}}, {"InstallDate", {PERSISTENT, TIME}}, {"IsDriverViewEnabled", {CLEAR_ON_MANAGER_START, BOOL}}, {"IsEngaged", {PERSISTENT, BOOL}}, - {"IsLdwEnabled", {PERSISTENT, BOOL}}, - {"IsMetric", {PERSISTENT, BOOL}}, + {"IsLdwEnabled", {PERSISTENT | BACKUP, BOOL}}, + {"IsMetric", {PERSISTENT | BACKUP, BOOL}}, {"IsOffroad", {CLEAR_ON_MANAGER_START, BOOL}}, {"IsOnroad", {PERSISTENT, BOOL}}, {"IsRhdDetected", {PERSISTENT, BOOL}}, @@ -66,7 +66,7 @@ inline static std::unordered_map keys = { {"IsTakingSnapshot", {CLEAR_ON_MANAGER_START, BOOL}}, {"IsTestedBranch", {CLEAR_ON_MANAGER_START, BOOL}}, {"JoystickDebugMode", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL}}, - {"LanguageSetting", {PERSISTENT, STRING, "en"}}, + {"LanguageSetting", {PERSISTENT | BACKUP, STRING, "en"}}, {"LastAthenaPingTime", {CLEAR_ON_MANAGER_START, INT}}, {"LastGPSPosition", {PERSISTENT, STRING}}, {"LastManagerExitReason", {CLEAR_ON_MANAGER_START, STRING}}, @@ -77,15 +77,15 @@ inline static std::unordered_map keys = { {"LastUpdateRouteCount", {PERSISTENT, INT, "0"}}, {"LastUpdateTime", {PERSISTENT, TIME}}, {"LastUpdateUptimeOnroad", {PERSISTENT, FLOAT, "0.0"}}, - {"LiveDelay", {PERSISTENT, BYTES}}, + {"LiveDelay", {PERSISTENT | BACKUP, BYTES}}, {"LiveParameters", {PERSISTENT, JSON}}, {"LiveParametersV2", {PERSISTENT, BYTES}}, {"LiveTorqueParameters", {PERSISTENT | DONT_LOG, BYTES}}, {"LocationFilterInitialState", {PERSISTENT, BYTES}}, {"LateralManeuverMode", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL}}, {"LongitudinalManeuverMode", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL}}, - {"LongitudinalPersonality", {PERSISTENT, INT, std::to_string(static_cast(cereal::LongitudinalPersonality::STANDARD))}}, - {"NetworkMetered", {PERSISTENT, BOOL}}, + {"LongitudinalPersonality", {PERSISTENT | BACKUP, INT, std::to_string(static_cast(cereal::LongitudinalPersonality::STANDARD))}}, + {"NetworkMetered", {PERSISTENT | BACKUP, BOOL}}, {"ObdMultiplexingChanged", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}}, {"ObdMultiplexingEnabled", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}}, {"Offroad_CarUnrecognized", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}}, @@ -101,20 +101,23 @@ inline static std::unordered_map keys = { {"Offroad_UpdateFailed", {CLEAR_ON_MANAGER_START, JSON}}, {"Offroad_DriverMonitoringUncertain", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}}, {"OnroadCycleRequested", {CLEAR_ON_MANAGER_START, BOOL}}, - {"OpenpilotEnabledToggle", {PERSISTENT, BOOL, "1"}}, + {"OpenpilotEnabledToggle", {PERSISTENT | BACKUP, BOOL, "1"}}, {"PandaHeartbeatLost", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL}}, {"PandaSomResetTriggered", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL}}, {"PandaSignatures", {CLEAR_ON_MANAGER_START, BYTES}}, {"PrimeType", {PERSISTENT, INT}}, - {"RecordAudio", {PERSISTENT, BOOL}}, - {"RecordAudioFeedback", {PERSISTENT, BOOL, "0"}}, - {"RecordFront", {PERSISTENT, BOOL}}, + {"RecordAudio", {PERSISTENT | BACKUP, BOOL}}, + {"RecordAudioFeedback", {PERSISTENT | BACKUP, BOOL, "0"}}, + {"RecordFront", {PERSISTENT | BACKUP, BOOL}}, {"RecordFrontLock", {PERSISTENT, BOOL}}, // for the internal fleet - {"SecOCKey", {PERSISTENT | DONT_LOG, STRING}}, + {"SecOCKey", {PERSISTENT | DONT_LOG | BACKUP, STRING}}, {"ShowDebugInfo", {PERSISTENT, BOOL}}, {"RouteCount", {PERSISTENT, INT, "0"}}, {"SnoozeUpdate", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL}}, - {"SshEnabled", {PERSISTENT, BOOL}}, + {"SshEnabled", {PERSISTENT | BACKUP, BOOL}}, + {"TermsVersion", {PERSISTENT, STRING}}, + {"TorqueBar", {PERSISTENT | BACKUP, BOOL, "0"}}, + {"TrainingVersion", {PERSISTENT, STRING}}, {"UbloxAvailable", {PERSISTENT, BOOL}}, {"UpdateAvailable", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}}, {"UpdateFailedCount", {CLEAR_ON_MANAGER_START, INT}}, @@ -130,4 +133,147 @@ inline static std::unordered_map keys = { {"UptimeOffroad", {PERSISTENT, FLOAT, "0.0"}}, {"UptimeOnroad", {PERSISTENT, FLOAT, "0.0"}}, {"Version", {PERSISTENT, STRING}}, + + // --- sunnypilot params --- // + {"ApiCache_DriveStats", {PERSISTENT, JSON}}, + {"AutoLaneChangeBsmDelay", {PERSISTENT | BACKUP, BOOL, "0"}}, + {"AutoLaneChangeTimer", {PERSISTENT | BACKUP, INT, "0"}}, + {"BlinkerLateralReengageDelay", {PERSISTENT | BACKUP, INT, "0"}}, // seconds + {"BlinkerMinLateralControlSpeed", {PERSISTENT | BACKUP, INT, "20"}}, // MPH or km/h + {"BlinkerPauseLateralControl", {PERSISTENT | BACKUP, INT, "0"}}, + {"Brightness", {PERSISTENT | BACKUP, INT, "0"}}, + {"CarList", {PERSISTENT, JSON}}, + {"CarParamsSP", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BYTES}}, + {"CarParamsSPCache", {CLEAR_ON_MANAGER_START, BYTES}}, + {"CarParamsSPPersistent", {PERSISTENT, BYTES}}, + {"CarPlatformBundle", {PERSISTENT | BACKUP, JSON}}, + {"ChevronInfo", {PERSISTENT | BACKUP, INT, "4"}}, + {"CompletedSunnylinkConsentVersion", {PERSISTENT, STRING, "0"}}, + {"CustomAccIncrementsEnabled", {PERSISTENT | BACKUP, BOOL, "0"}}, + {"CustomAccLongPressIncrement", {PERSISTENT | BACKUP, INT, "5"}}, + {"CustomAccShortPressIncrement", {PERSISTENT | BACKUP, INT, "1"}}, + {"DeviceBootMode", {PERSISTENT | BACKUP, INT, "0"}}, + {"DevUIInfo", {PERSISTENT | BACKUP, INT, "0"}}, + {"EnableCopyparty", {PERSISTENT | BACKUP, BOOL}}, + {"EnableGithubRunner", {PERSISTENT | BACKUP, BOOL}}, + {"GreenLightAlert", {PERSISTENT | BACKUP, BOOL, "0"}}, + {"GithubRunnerSufficientVoltage", {CLEAR_ON_MANAGER_START , BOOL}}, + {"HasAcceptedTermsSP", {PERSISTENT, STRING, "0"}}, + {"HideVEgoUI", {PERSISTENT | BACKUP, BOOL, "0"}}, + {"IntelligentCruiseButtonManagement", {PERSISTENT | BACKUP , BOOL}}, + {"InteractivityTimeout", {PERSISTENT | BACKUP, INT, "0"}}, + {"IsDevelopmentBranch", {CLEAR_ON_MANAGER_START, BOOL}}, + {"IsReleaseSpBranch", {CLEAR_ON_MANAGER_START, BOOL}}, + {"LastGPSPositionLLK", {PERSISTENT, STRING}}, + {"LeadDepartAlert", {PERSISTENT | BACKUP, BOOL, "0"}}, + {"MaxTimeOffroad", {PERSISTENT | BACKUP, INT, "1800"}}, + {"ModelRunnerTypeCache", {CLEAR_ON_ONROAD_TRANSITION, INT}}, + {"OffroadMode", {CLEAR_ON_MANAGER_START, BOOL}}, + {"Offroad_TiciSupport", {CLEAR_ON_MANAGER_START, JSON}}, + {"OnroadScreenOffBrightness", {PERSISTENT | BACKUP, INT, "0"}}, + {"OnroadScreenOffBrightnessMigrated", {PERSISTENT | BACKUP, STRING, "0.0"}}, + {"OnroadScreenOffTimer", {PERSISTENT | BACKUP, INT, "15"}}, + {"OnroadScreenOffTimerMigrated", {PERSISTENT | BACKUP, STRING, "0.0"}}, + {"OnroadUploads", {PERSISTENT | BACKUP, BOOL, "1"}}, + {"QuickBootToggle", {PERSISTENT | BACKUP, BOOL, "0"}}, + {"QuietMode", {PERSISTENT | BACKUP, BOOL, "0"}}, + {"RainbowMode", {PERSISTENT | BACKUP, BOOL, "0"}}, + {"RocketFuel", {PERSISTENT | BACKUP, BOOL, "0"}}, + {"ShowAdvancedControls", {PERSISTENT | BACKUP, BOOL, "0"}}, + {"ShowTurnSignals", {PERSISTENT | BACKUP, BOOL, "0"}}, + {"StandstillTimer", {PERSISTENT | BACKUP, BOOL, "0"}}, + {"TrueVEgoUI", {PERSISTENT | BACKUP, BOOL, "0"}}, + + // MADS params + {"Mads", {PERSISTENT | BACKUP, BOOL, "1"}}, + {"MadsMainCruiseAllowed", {PERSISTENT | BACKUP, BOOL, "1"}}, + {"MadsSteeringMode", {PERSISTENT | BACKUP, INT, "0"}}, + {"MadsUnifiedEngagementMode", {PERSISTENT | BACKUP, BOOL, "1"}}, + + // Model Manager params + {"ModelManager_ActiveBundle", {PERSISTENT, JSON}}, + {"ModelManager_ClearCache", {CLEAR_ON_MANAGER_START, BOOL}}, + {"ModelManager_DownloadIndex", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, INT}}, + {"ModelManager_Favs", {PERSISTENT | BACKUP, STRING}}, + {"ModelManager_LastSyncTime", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, INT, "0"}}, + {"ModelManager_ModelsCache", {PERSISTENT | BACKUP, JSON}}, + + // Neural Network Lateral Control + {"NeuralNetworkLateralControl", {PERSISTENT | BACKUP, BOOL, "0"}}, + + // sunnylink params + {"EnableSunnylinkUploader", {PERSISTENT | BACKUP, BOOL}}, + {"LastSunnylinkPingTime", {CLEAR_ON_MANAGER_START, INT}}, + {"SunnylinkCache_Roles", {PERSISTENT, STRING}}, + {"SunnylinkCache_Users", {PERSISTENT, STRING}}, + {"SunnylinkDongleId", {PERSISTENT, STRING}}, + {"SunnylinkdPid", {PERSISTENT, INT}}, + {"SunnylinkEnabled", {PERSISTENT, BOOL, "1"}}, + {"SunnylinkTempFault", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL, "0"}}, + + // Backup Manager params + {"BackupManager_CreateBackup", {PERSISTENT, BOOL}}, + {"BackupManager_RestoreVersion", {PERSISTENT, STRING}}, + + // sunnypilot car specific params + {"HyundaiLongitudinalTuning", {PERSISTENT | BACKUP, INT, "0"}}, + {"SubaruStopAndGo", {PERSISTENT | BACKUP, BOOL, "0"}}, + {"SubaruStopAndGoManualParkingBrake", {PERSISTENT | BACKUP, BOOL, "0"}}, + {"TeslaCoopSteering", {PERSISTENT | BACKUP, BOOL, "0"}}, + {"ToyotaEnforceStockLongitudinal", {PERSISTENT | BACKUP, BOOL, "0"}}, + {"ToyotaStopAndGoHack", {PERSISTENT | BACKUP, BOOL, "0"}}, + + {"DynamicExperimentalControl", {PERSISTENT | BACKUP, BOOL, "0"}}, + {"BlindSpot", {PERSISTENT | BACKUP, BOOL, "0"}}, + + // sunnypilot model params + {"CameraOffset", {PERSISTENT | BACKUP, FLOAT, "0.0"}}, + {"LagdToggle", {PERSISTENT | BACKUP, BOOL, "1"}}, + {"LagdToggleDelay", {PERSISTENT | BACKUP, FLOAT, "0.2"}}, + {"LagdValueCache", {PERSISTENT, FLOAT, "0.2"}}, + {"LaneTurnDesire", {PERSISTENT | BACKUP, BOOL, "0"}}, + {"LaneTurnValue", {PERSISTENT | BACKUP, FLOAT, "19.0"}}, + {"PlanplusControl", {PERSISTENT | BACKUP, FLOAT, "1.0"}}, + + // mapd + {"MapAdvisorySpeedLimit", {CLEAR_ON_ONROAD_TRANSITION, FLOAT}}, + {"MapdVersion", {PERSISTENT, STRING}}, + {"MapSpeedLimit", {CLEAR_ON_ONROAD_TRANSITION, FLOAT, "0.0"}}, + {"NextMapSpeedLimit", {CLEAR_ON_ONROAD_TRANSITION, JSON}}, + {"Offroad_OSMUpdateRequired", {CLEAR_ON_MANAGER_START, JSON}}, + {"OsmDbUpdatesCheck", {CLEAR_ON_MANAGER_START, BOOL}}, // mapd database update happens with device ON, reset on boot + {"OSMDownloadBounds", {PERSISTENT, STRING}}, + {"OsmDownloadedDate", {PERSISTENT, STRING, "0.0"}}, + {"OSMDownloadLocations", {PERSISTENT, JSON}}, + {"OSMDownloadProgress", {CLEAR_ON_MANAGER_START, JSON}}, + {"OsmLocal", {PERSISTENT, BOOL}}, + {"OsmLocationName", {PERSISTENT, STRING}}, + {"OsmLocationTitle", {PERSISTENT, STRING}}, + {"OsmLocationUrl", {PERSISTENT, STRING}}, + {"OsmStateName", {PERSISTENT, STRING, "All"}}, + {"OsmStateTitle", {PERSISTENT, STRING}}, + {"OsmWayTest", {PERSISTENT, STRING}}, + {"RoadName", {CLEAR_ON_ONROAD_TRANSITION, STRING}}, + {"RoadNameToggle", {PERSISTENT | BACKUP, BOOL, "0"}}, + + // Speed Limit + {"SpeedLimitMode", {PERSISTENT | BACKUP, INT, "1"}}, + {"SpeedLimitOffsetType", {PERSISTENT | BACKUP, INT, "0"}}, + {"SpeedLimitPolicy", {PERSISTENT | BACKUP, INT, "3"}}, + {"SpeedLimitValueOffset", {PERSISTENT | BACKUP, INT, "0"}}, + + // Smart Cruise Control + {"MapTargetVelocities", {CLEAR_ON_ONROAD_TRANSITION, STRING}}, + {"SmartCruiseControlMap", {PERSISTENT | BACKUP, BOOL, "0"}}, + {"SmartCruiseControlVision", {PERSISTENT | BACKUP, BOOL, "0"}}, + + // Torque lateral control custom params + {"CustomTorqueParams", {PERSISTENT | BACKUP , BOOL}}, + {"EnforceTorqueControl", {PERSISTENT | BACKUP, BOOL}}, + {"LiveTorqueParamsToggle", {PERSISTENT | BACKUP , BOOL}}, + {"LiveTorqueParamsRelaxedToggle", {PERSISTENT | BACKUP , BOOL}}, + {"TorqueControlTune", {PERSISTENT | BACKUP, FLOAT, "0.0"}}, + {"TorqueParamsOverrideEnabled", {PERSISTENT | BACKUP, BOOL, "0"}}, + {"TorqueParamsOverrideFriction", {PERSISTENT | BACKUP, FLOAT, "0.1"}}, + {"TorqueParamsOverrideLatAccelFactor", {PERSISTENT | BACKUP, FLOAT, "2.5"}}, }; diff --git a/common/params_pyx.pyx b/common/params_pyx.pyx index 93c550f22a..bffa89b5d3 100644 --- a/common/params_pyx.pyx +++ b/common/params_pyx.pyx @@ -18,6 +18,7 @@ cdef extern from "common/params.h": CLEAR_ON_OFFROAD_TRANSITION DEVELOPMENT_ONLY CLEAR_ON_IGNITION_ON + BACKUP ALL cpdef enum ParamKeyType: @@ -43,7 +44,7 @@ cdef extern from "common/params.h": optional[string] getKeyDefaultValue(string) nogil string getParamPath(string) nogil void clearAll(ParamKeyFlag) - vector[string] allKeys() + vector[string] allKeys(ParamKeyFlag) PYTHON_2_CPP = { (str, STRING): lambda v: v, @@ -181,8 +182,8 @@ cdef class Params: def get_type(self, key): return self.p.getKeyType(self.check_key(key)) - def all_keys(self): - return self.p.allKeys() + def all_keys(self, flag=ParamKeyFlag.ALL): + return self.p.allKeys(flag) def get_default_value(self, key): cdef string k = self.check_key(key) diff --git a/common/swaglog.cc b/common/swaglog.cc index 62a405a2b6..d73f0c1a59 100644 --- a/common/swaglog.cc +++ b/common/swaglog.cc @@ -15,6 +15,8 @@ #include "common/version.h" #include "system/hardware/hw.h" +#include "sunnypilot/common/version.h" + class SwaglogState { public: SwaglogState() { @@ -56,7 +58,7 @@ public: if (char* daemon_name = getenv("MANAGER_DAEMON")) { ctx_j["daemon"] = daemon_name; } - ctx_j["version"] = COMMA_VERSION; + ctx_j["version"] = SUNNYPILOT_VERSION; ctx_j["dirty"] = !getenv("CLEAN"); ctx_j["device"] = Hardware::get_name(); } diff --git a/common/tests/test_markdown.py b/common/tests/test_markdown.py index d3c7e02c69..7e04bf2920 100644 --- a/common/tests/test_markdown.py +++ b/common/tests/test_markdown.py @@ -6,7 +6,7 @@ from openpilot.common.markdown import parse_markdown class TestMarkdown: def test_all_release_notes(self): - with open(os.path.join(BASEDIR, "RELEASES.md")) as f: + with open(os.path.join(BASEDIR, "CHANGELOG.md")) as f: release_notes = f.read().split("\n\n") assert len(release_notes) > 10 diff --git a/common/tests/test_swaglog.cc b/common/tests/test_swaglog.cc index 09bc4c3795..c97f907c24 100644 --- a/common/tests/test_swaglog.cc +++ b/common/tests/test_swaglog.cc @@ -9,6 +9,8 @@ #include "system/hardware/hw.h" #include "third_party/json11/json11.hpp" +#include "sunnypilot/common/version.h" + std::string daemon_name = "testy"; std::string dongle_id = "test_dongle_id"; int LINE_NO = 0; @@ -53,7 +55,7 @@ void recv_log(int thread_cnt, int thread_msg_cnt) { REQUIRE(ctx["dongle_id"].string_value() == dongle_id); REQUIRE(ctx["dirty"].bool_value() == true); - REQUIRE(ctx["version"].string_value() == COMMA_VERSION); + REQUIRE(ctx["version"].string_value() == SUNNYPILOT_VERSION); std::string device = Hardware::get_name(); REQUIRE(ctx["device"].string_value() == device); diff --git a/common/util.h b/common/util.h index e4483ee7a5..98c2883538 100644 --- a/common/util.h +++ b/common/util.h @@ -36,6 +36,7 @@ const double MS_TO_KPH = 3.6; const double MS_TO_MPH = MS_TO_KPH * KM_TO_MILE; const double METER_TO_MILE = KM_TO_MILE / 1000.0; const double METER_TO_FOOT = 3.28084; +const double METER_TO_KM = 1. / 1000.0; #define ALIGNED_SIZE(x, align) (((x) + (align)-1) & ~((align)-1)) diff --git a/common/utils.py b/common/utils.py index faaa96ecbc..28b9274d82 100644 --- a/common/utils.py +++ b/common/utils.py @@ -131,11 +131,11 @@ def get_upload_stream(filepath: str, should_compress: bool) -> tuple[io.Buffered return compressed_stream, compressed_size -# remove all keys that end in DEPRECATED +# remove all keys that end in DEPRECATED, plus any "deprecated" group def strip_deprecated_keys(d): for k in list(d.keys()): if isinstance(k, str): - if k.endswith('DEPRECATED'): + if k.endswith('DEPRECATED') or k == 'deprecated': d.pop(k) elif isinstance(d[k], dict): strip_deprecated_keys(d[k]) diff --git a/conftest.py b/conftest.py index a01ddc2f6b..2f2db08d5d 100644 --- a/conftest.py +++ b/conftest.py @@ -16,6 +16,7 @@ collect_ignore = [ collect_ignore_glob = [ "selfdrive/debug/*.py", "selfdrive/modeld/*.py", + "sunnypilot/modeld*/*.py", ] diff --git a/docs/CARS.md b/docs/CARS.md index 56f80d1606..92e6392236 100644 --- a/docs/CARS.md +++ b/docs/CARS.md @@ -4,12 +4,13 @@ A supported vehicle is one that just works when you install a comma device. All supported cars provide a better experience than any stock system. Supported vehicles reference the US market unless otherwise specified. -# 329 Supported Cars +# 340 Supported Cars |Make|Model|Supported Package|ACC|No ACC accel below|No ALC below|Steering Torque|Resume from stop|Hardware Needed
 |Video|Setup Video| |---|---|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:| |Acura|ILX 2016-18|Technology Plus Package or AcuraWatch Plus|openpilot|26 mph|25 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Acura|ILX 2019|All|openpilot|26 mph|25 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Acura|MDX 2022-24|All|openpilot available[1](#footnotes)|0 mph|43 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Acura|MDX 2025-26|All except Type S|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Acura|RDX 2016-18|AcuraWatch Plus or Advance Package|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Acura|RDX 2019-21|All|openpilot available[1](#footnotes)|0 mph|3 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| @@ -103,6 +104,7 @@ A supported vehicle is one that just works when you install a comma device. All |Honda|N-Box 2018|All|openpilot available[1](#footnotes)|0 mph|11 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|Odyssey 2018-20|Honda Sensing|openpilot|26 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|Odyssey 2021-26|All|openpilot available[1](#footnotes)|0 mph|43 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|Odyssey (Singapore) 2021|Honda Sensing|openpilot|19 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|Odyssey (Taiwan) 2018-19|Honda Sensing|openpilot|19 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|Passport 2019-25|All|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|Passport 2026|All|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| @@ -131,6 +133,7 @@ A supported vehicle is one that just works when you install a comma device. All |Hyundai|Ioniq Plug-in Hybrid 2019|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|32 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Hyundai|Ioniq Plug-in Hybrid 2020-22|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai H connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Hyundai|Kona 2020|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|6 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Hyundai B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Kona 2022-23|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai O connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Hyundai|Kona Electric 2018-21|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai G connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Hyundai|Kona Electric 2022-23|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai O connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Hyundai|Kona Electric (with HDA II, Korea only) 2023|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai R connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| @@ -228,21 +231,29 @@ A supported vehicle is one that just works when you install a comma device. All |Nissan[5](#footnotes)|Leaf 2018-23|ProPILOT Assist|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Nissan A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Nissan[5](#footnotes)|Rogue 2018-20|ProPILOT Assist|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Nissan A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Nissan[5](#footnotes)|X-Trail 2017|ProPILOT Assist|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Nissan A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Ram|1500 2019-24|Adaptive Cruise Control (ACC)|Stock|0 mph|32 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Ram connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Ram|1500 2019-24|Adaptive Cruise Control (ACC)|Stock|32 mph|1 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Ram connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Ram|2500 2020-24|Adaptive Cruise Control (ACC)|Stock|0 mph|36 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Ram connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Ram|3500 2019-22|Adaptive Cruise Control (ACC)|Stock|0 mph|36 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Ram connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Rivian|R1S 2022-24|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Rivian A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Rivian|R1S 2025|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Rivian B connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Rivian|R1T 2022-24|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Rivian A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Rivian|R1T 2025|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Rivian B connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |SEAT[11](#footnotes)|Ateca 2016-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |SEAT[11](#footnotes)|Leon 2014-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Subaru|Ascent 2019-21|All[6](#footnotes)|openpilot available[1,7](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| -|Subaru|Crosstrek 2018-19|EyeSight Driver Assistance[6](#footnotes)|openpilot available[1,7](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| -|Subaru|Crosstrek 2020-23|EyeSight Driver Assistance[6](#footnotes)|openpilot available[1,7](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| -|Subaru|Forester 2019-21|All[6](#footnotes)|openpilot available[1,7](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| -|Subaru|Impreza 2017-19|EyeSight Driver Assistance[6](#footnotes)|openpilot available[1,7](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| -|Subaru|Impreza 2020-22|EyeSight Driver Assistance[6](#footnotes)|openpilot available[1,7](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| +|Subaru|Ascent 2019-21|All[6](#footnotes)|openpilot available[1,7](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| +|Subaru|Crosstrek 2018-19|EyeSight Driver Assistance[6](#footnotes)|openpilot available[1,7](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| +|Subaru|Crosstrek 2020-23|EyeSight Driver Assistance[6](#footnotes)|openpilot available[1,7](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| +|Subaru|Forester 2017-18|EyeSight Driver Assistance[6](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| +|Subaru|Forester 2019-21|All[6](#footnotes)|openpilot available[1,7](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| +|Subaru|Impreza 2017-19|EyeSight Driver Assistance[6](#footnotes)|openpilot available[1,7](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| +|Subaru|Impreza 2020-22|EyeSight Driver Assistance[6](#footnotes)|openpilot available[1,7](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| +|Subaru|Legacy 2015-18|EyeSight Driver Assistance[6](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| |Subaru|Legacy 2020-22|All[6](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru B connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| +|Subaru|Outback 2015-17|EyeSight Driver Assistance[6](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| +|Subaru|Outback 2018-19|EyeSight Driver Assistance[6](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| |Subaru|Outback 2020-22|All[6](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru B connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| -|Subaru|XV 2018-19|EyeSight Driver Assistance[6](#footnotes)|openpilot available[1,7](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| -|Subaru|XV 2020-21|EyeSight Driver Assistance[6](#footnotes)|openpilot available[1,7](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| +|Subaru|XV 2018-19|EyeSight Driver Assistance[6](#footnotes)|openpilot available[1,7](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| +|Subaru|XV 2020-21|EyeSight Driver Assistance[6](#footnotes)|openpilot available[1,7](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| |Škoda|Fabia 2022-23[14](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
[16](#footnotes)||| |Škoda|Kamiq 2021-23[12,14](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
[16](#footnotes)||| |Škoda[11](#footnotes)|Karoq 2019-23[14](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| diff --git a/launch_openpilot.sh b/launch_openpilot.sh index d6e3424c34..d4841b601f 100755 --- a/launch_openpilot.sh +++ b/launch_openpilot.sh @@ -1,3 +1,20 @@ #!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' + +# On any failure, run the fallback launcher +trap 'exec ./launch_chffrplus.sh' ERR +C3_LAUNCH_SH="./sunnypilot/system/hardware/c3/launch_chffrplus.sh" + +MODEL="$(tr -d '\0' < "/sys/firmware/devicetree/base/model")" +export MODEL + +if [ "$MODEL" = "comma tici" ]; then + # Force a failure if the launcher doesn't exist + [ -x "$C3_LAUNCH_SH" ] || false + + # If it exists, run it + exec "$C3_LAUNCH_SH" +fi exec ./launch_chffrplus.sh diff --git a/opendbc_repo b/opendbc_repo index 961839d303..3cc75bb8e1 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit 961839d30350e4eb8b3d586356e115989a290c1b +Subproject commit 3cc75bb8e183842813a63c1ec1ad8fe26845fc45 diff --git a/openpilot/sunnypilot b/openpilot/sunnypilot new file mode 120000 index 0000000000..c4ca692907 --- /dev/null +++ b/openpilot/sunnypilot @@ -0,0 +1 @@ +../sunnypilot \ No newline at end of file diff --git a/panda b/panda index d079b0958b..9d74777cb8 160000 --- a/panda +++ b/panda @@ -1 +1 @@ -Subproject commit d079b0958b51ce33fc313def95317ef52b54b2ec +Subproject commit 9d74777cb8ad0bb798537f1909e06230a965e760 diff --git a/pyproject.toml b/pyproject.toml index a112323400..a05f1a8944 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -142,6 +142,7 @@ testpaths = [ "system", "tools", "cereal", + "sunnypilot", ] [tool.codespell] diff --git a/release/build_release.sh b/release/build_release.sh index 7bc6732c68..22d880991f 100755 --- a/release/build_release.sh +++ b/release/build_release.sh @@ -39,7 +39,7 @@ cd $BUILD_DIR rm -f panda/board/obj/panda.bin.signed rm -f panda/board/obj/panda_h7.bin.signed -VERSION=$(cat common/version.h | awk -F[\"-] '{print $2}') +VERSION=$(cat sunnypilot/common/version.h | awk -F[\"-] '{print $2}') echo "[-] committing version $VERSION T=$SECONDS" git add -f . git commit -a -m "openpilot v$VERSION release" @@ -73,6 +73,7 @@ find . -name 'moc_*' -delete find . -name '__pycache__' -delete rm -rf .sconsign.dblite Jenkinsfile release/ rm -f selfdrive/modeld/models/*.onnx +rm -f sunnypilot/modeld*/models/*.onnx find third_party/ -name '*x86*' -exec rm -r {} + find third_party/ -name '*Darwin*' -exec rm -r {} + diff --git a/release/build_stripped.sh b/release/build_stripped.sh index 6f1a568c25..df5b2a3dd6 100755 --- a/release/build_stripped.sh +++ b/release/build_stripped.sh @@ -25,7 +25,7 @@ cd $TARGET_DIR git checkout --orphan tmp # remove everything except .git -echo "[-] erasing old openpilot T=$SECONDS" +echo "[-] erasing old sunnypilot T=$SECONDS" git submodule deinit -f --all git rm -rf --cached . find . -maxdepth 1 -not -path './.git' -not -name '.' -not -name '..' -exec rm -rf '{}' \; @@ -49,7 +49,7 @@ rm -f panda/board/obj/panda.bin.signed GIT_HASH=$(git --git-dir=$SOURCE_DIR/.git rev-parse HEAD) GIT_COMMIT_DATE=$(git --git-dir=$SOURCE_DIR/.git show --no-patch --format='%ct %ci' HEAD) DATETIME=$(date '+%Y-%m-%dT%H:%M:%S') -VERSION=$(cat $SOURCE_DIR/common/version.h | awk -F\" '{print $2}') +VERSION=$(cat $SOURCE_DIR/sunnypilot/common/version.h | awk -F\" '{print $2}') echo -n "$GIT_HASH" > git_src_commit echo -n "$GIT_COMMIT_DATE" > git_src_commit_date @@ -57,7 +57,7 @@ echo -n "$GIT_COMMIT_DATE" > git_src_commit_date echo "[-] committing version $VERSION T=$SECONDS" git add -f . git status -git commit -a -m "openpilot v$VERSION release +git commit -a -m "sunnypilot v$VERSION release date: $DATETIME master commit: $GIT_HASH diff --git a/release/check-submodules.sh b/release/check-submodules.sh index 93869a7403..7120fa15d8 100755 --- a/release/check-submodules.sh +++ b/release/check-submodules.sh @@ -1,17 +1,43 @@ #!/usr/bin/env bash +has_submodule_changes() { + local submodule_path="$1" + if [ -n "$SUBMODULE_PATHS" ]; then + echo "$SUBMODULE_PATHS" | grep -q "$submodule_path" + return $? + fi + return 1 +} + while read hash submodule ref; do + if [ -z "$hash" ] || [ -z "$submodule" ]; then + continue + fi + + hash=$(echo "$hash" | sed 's/^[+-]//') + if [ "$submodule" = "tinygrad_repo" ]; then echo "Skipping $submodule" continue fi - git -C $submodule fetch --depth 100 origin master - git -C $submodule branch -r --contains $hash | grep "origin/master" - if [ "$?" -eq 0 ]; then - echo "$submodule ok" + if [ "$CHECK_PR_REFS" = "true" ] && has_submodule_changes "$submodule"; then + echo "Checking $submodule (non-master): verifying hash $hash exists" + git -C $submodule fetch --depth 100 origin + if git -C $submodule cat-file -e $hash 2>/dev/null; then + echo "$submodule ok (hash exists)" + else + echo "$submodule: $hash does not exist in the repository" + exit 1 + fi else - echo "$submodule: $hash is not on master" - exit 1 + git -C $submodule fetch --depth 100 origin master + git -C $submodule branch -r --contains $hash | grep "origin/master" + if [ "$?" -eq 0 ]; then + echo "$submodule ok" + else + echo "$submodule: $hash is not on master" + exit 1 + fi fi done <<< $(git submodule status --recursive) diff --git a/release/ci/docker_build_sp.sh b/release/ci/docker_build_sp.sh new file mode 100755 index 0000000000..369daf5233 --- /dev/null +++ b/release/ci/docker_build_sp.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -e + +SCRIPT_DIR=$(dirname "$0") +OPENPILOT_DIR=$SCRIPT_DIR/../../ + +DOCKER_IMAGE=sunnypilot +DOCKER_FILE=Dockerfile.openpilot +DOCKER_REGISTRY=ghcr.io/sunnypilot +COMMIT_SHA=$(git rev-parse HEAD) + +if [ -n "$TARGET_ARCHITECTURE" ]; then + PLATFORM="linux/$TARGET_ARCHITECTURE" + TAG_SUFFIX="-$TARGET_ARCHITECTURE" +else + PLATFORM="linux/$(uname -m)" + TAG_SUFFIX="" +fi + +LOCAL_TAG=$DOCKER_IMAGE$TAG_SUFFIX +REMOTE_TAG=$DOCKER_REGISTRY/$LOCAL_TAG +REMOTE_SHA_TAG=$DOCKER_REGISTRY/$LOCAL_TAG:$COMMIT_SHA + +DOCKER_BUILDKIT=1 docker buildx build --provenance false --pull --platform $PLATFORM --load -t $DOCKER_IMAGE:latest -t $REMOTE_TAG -t $LOCAL_TAG -f $OPENPILOT_DIR/$DOCKER_FILE $OPENPILOT_DIR + +if [ -n "$PUSH_IMAGE" ]; then + docker push $REMOTE_TAG + docker tag $REMOTE_TAG $REMOTE_SHA_TAG + docker push $REMOTE_SHA_TAG +fi diff --git a/release/ci/install_github_runner.sh b/release/ci/install_github_runner.sh new file mode 100755 index 0000000000..9f11e4841c --- /dev/null +++ b/release/ci/install_github_runner.sh @@ -0,0 +1,260 @@ +#!/usr/bin/env bash +set -e + +# Default values +DEFAULT_REPO_URL="https://github.com/sunnypilot" +START_AT_BOOT=false +RESTORE_MODE=false +RUNNER_VERSION="2.325.0" + +# Parse command line arguments +while [[ $# -gt 0 ]]; do + case $1 in + --start-at-boot) + START_AT_BOOT=true + shift + ;; + --token) + GITHUB_TOKEN="$2" + shift 2 + ;; + --repo) + REPO_URL="$2" + shift 2 + ;; + --restore) + RESTORE_MODE=true + shift + ;; + *) + if [ -z "$GITHUB_TOKEN" ]; then + GITHUB_TOKEN="$1" + elif [ -z "$REPO_URL" ]; then + REPO_URL="$1" + fi + shift + ;; + esac +done + +# Determine BASE_DIR based on mount point +if mountpoint -q /data/media; then + BASE_DIR="/data/media/0/github" +else + BASE_DIR="/data/github" +fi + +# Constants +RUNNER_USER="github-runner" +USER_GROUPS="comma,gpu,gpio,sudo" +RUNNER_DIR="${BASE_DIR}/runner" +BUILDS_DIR="${BASE_DIR}/builds" +LOGS_DIR="${BASE_DIR}/logs" +CACHE_DIR="${BASE_DIR}/cache" +OPENPILOT_DIR="${BASE_DIR}/openpilot" + +# Basic utility functions (no dependencies) +remount_rw() { + sudo mount -o remount,rw / +} + +remount_ro() { + sync || true # Try to sync but continue even if it fails + sudo mount -o remount,ro / # Always try to remount as read-only +} + +# Always ensure we try to remount as read-only on exit +trap remount_ro EXIT + +setup_runner_user() { + sudo useradd --comment 'GitHub Runner' --create-home --home-dir ${BASE_DIR} ${RUNNER_USER} --shell /bin/bash -G ${USER_GROUPS} || sudo usermod -aG ${USER_GROUPS} ${RUNNER_USER} +} + +create_sudoers_entry() { + sudo grep -qxF "${RUNNER_USER} ALL=(ALL) NOPASSWD: ALL" /etc/sudoers || echo "${RUNNER_USER} ALL=(ALL) NOPASSWD: ALL" | sudo tee -a /etc/sudoers +} + +set_directory_permissions() { + sudo chown -R ${RUNNER_USER}:comma "$BASE_DIR" + sudo chmod -R g+rwx "$BASE_DIR" + sudo find "$BASE_DIR" -type d -exec chmod g+s {} + +} + +setup_directories() { + echo "Creating necessary directories..." + sudo mkdir -p "$RUNNER_DIR" "$BUILDS_DIR" "$LOGS_DIR" "$CACHE_DIR" "$OPENPILOT_DIR" + mkdir -p "/data/openpilot" + sudo chown -R comma:comma "/data/openpilot" + sync +} + +wipe_bash_logout() { + export BASE_DIR + sudo -u ${RUNNER_USER} bash -c "touch ${BASE_DIR}/.bash_logout" + sudo -u ${RUNNER_USER} bash -c "truncate -s 0 '${BASE_DIR}/.bash_logout'" +} + +# System configuration functions (depends on basic utility functions) +setup_system_configs() { + echo "Setting up system configurations..." + remount_rw + setup_runner_user + create_sudoers_entry + remount_ro + set_directory_permissions + wipe_bash_logout +} + +# Runner setup functions +install_runner() { + echo "Downloading and setting up runner..." + cd "$RUNNER_DIR" + curl -o actions-runner-linux-arm64-${RUNNER_VERSION}.tar.gz -L https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-arm64-${RUNNER_VERSION}.tar.gz + sudo -u ${RUNNER_USER} tar -xzf ./actions-runner-linux-arm64-${RUNNER_VERSION}.tar.gz + sudo rm ./actions-runner-linux-arm64-${RUNNER_VERSION}.tar.gz + sudo chmod +x ./config.sh +} + +configure_runner() { + remount_rw + echo "Configuring runner..." + cd "$RUNNER_DIR" + sudo -u ${RUNNER_USER} ./config.sh --url "$REPO_URL" --token "$GITHUB_TOKEN" --name $(hostname) --runnergroup "tici-tizi" --labels "tici" --work "$BUILDS_DIR" --unattended + remount_ro +} + +create_service_template() { + echo "Creating service template..." + cat < "$RUNNER_DIR/bin/actions.runner.service.template" +[Unit] +Description={{Description}} +After=network-online.target nss-lookup.target time-sync.target +Wants=network-online.target nss-lookup.target time-sync.target +StartLimitInterval=5 +StartLimitBurst=10 + +[Service] +Type=simple +User=root +ExecStart=/usr/bin/unshare -m -- /bin/bash -c 'mount --bind ${OPENPILOT_DIR} /data/openpilot && setpriv --reuid={{User}} --regid={{User}} --init-groups env HOME=${BASE_DIR} USER={{User}} LOGNAME={{User}} MAIL=/var/mail/{{User}} {{RunnerRoot}}/runsvc.sh' +WorkingDirectory={{RunnerRoot}} +KillMode=process +KillSignal=SIGTERM +TimeoutStopSec=5min +Restart=always +RestartSec=120 + +[Install] +WantedBy=multi-user.target +EOL +} + +install_service() { + local service_name + if [ -f "${RUNNER_DIR}/.service" ]; then + service_name=$(cat "${RUNNER_DIR}/.service") + else + service_name="actions.runner.sunnypilot.$(uname -n)" + fi + + create_service_template + remount_rw + local service_path="/etc/systemd/system/${service_name}" + echo "Installing systemd service..." + if [ -f "${service_path}" ]; then + echo "Service ${service_path} found in systemd, we will delete it" + sudo rm -f "${service_path}" + fi + + cd "$RUNNER_DIR" + sudo ./svc.sh install $RUNNER_USER + + if [ "$START_AT_BOOT" = false ]; then + sudo systemctl disable "${service_name}" + fi + remount_ro +} + +check_restore_prerequisites() { + local can_restore=false + local service_name="" + + # Check if base runner directory exists + if [ ! -d "${RUNNER_DIR}" ]; then + echo "ERROR: Runner directory ${RUNNER_DIR} does not exist" + echo "This directory is required for restore operations" + exit 1 + fi + + # First check if we have the required files for restoration + if [ -f "${RUNNER_DIR}/.credentials" ] && [ -f "${RUNNER_DIR}/.service" ]; then + can_restore=true + service_name=$(cat "${RUNNER_DIR}/.service") + echo "Found required runner configuration files" + else + echo "Missing required runner configuration files" + echo "Required: .credentials and .service files in ${RUNNER_DIR}" + exit 1 + fi + + if ! id "${RUNNER_USER}" &>/dev/null; then + echo "User ${RUNNER_USER} does not exist" + fi + + # Only proceed if we can restore AND need to restore + if [ "$can_restore" = true ]; then + echo "Restoration is possible" + return 0 + else + echo "No restoration possible" + exit 0 + fi +} + +perform_restore() { + echo "Starting runner restoration..." + setup_directories + setup_system_configs + install_service + echo "Runner restoration completed successfully" +} + +perform_install() { + echo "Starting fresh installation..." + setup_directories + setup_system_configs + install_runner + set_directory_permissions + configure_runner + install_service + echo "Installation completed successfully" +} + +main() { + if [ "$RESTORE_MODE" = true ]; then + echo "Running in restore mode - will only restore system configurations..." + check_restore_prerequisites + perform_restore + else + # Check required arguments for normal installation + if [ -z "$GITHUB_TOKEN" ]; then + echo "Usage: $0 [--start-at-boot] [--token ] [--repo ] [--restore]" + echo "Required argument (except for --restore): github_token" + echo "Optional arguments:" + echo " --start-at-boot Enable auto-start at boot (default: false)" + echo " --repo Repository URL (default: ${DEFAULT_REPO_URL})" + echo " --restore Restore existing runner configuration" + exit 1 + fi + + # Set repository URL if not provided + REPO_URL="${REPO_URL:-$DEFAULT_REPO_URL}" + perform_install + fi + + echo "Starting runner service..." + cd "$RUNNER_DIR" + sudo ./svc.sh start +} + +main diff --git a/release/ci/model_generator.py b/release/ci/model_generator.py new file mode 100755 index 0000000000..feeb80095b --- /dev/null +++ b/release/ci/model_generator.py @@ -0,0 +1,194 @@ +import os +import pickle +import sys +import hashlib +import json +import re +from pathlib import Path +from datetime import datetime, UTC + +REQUIRED_OUTPUT_KEYS = frozenset({ + "plan", + "lane_lines", + "road_edges", + "lead", + "desire_state", + "desire_pred", + "meta", + "lead_prob", + "lane_lines_prob", + "pose", + "wide_from_device_euler", + "road_transform", + "hidden_state", +}) +OPTIONAL_OUTPUT_KEYS = frozenset({ + "planplus", + "sim_pose", + "desired_curvature", +}) + + +def validate_model_outputs(metadata_paths: list[Path]) -> None: + combined_keys: set[str] = set() + for path in metadata_paths: + with open(path, "rb") as f: + metadata = pickle.load(f) + combined_keys.update(metadata.get("output_slices", {}).keys()) + missing = REQUIRED_OUTPUT_KEYS - combined_keys + if missing: + raise ValueError(f"Combined model metadata is missing required output keys: {sorted(missing)}") + detected_optional = sorted(OPTIONAL_OUTPUT_KEYS & combined_keys) + if detected_optional: + print(f"Optional output keys detected: {detected_optional}") + + +def create_short_name(full_name): + # Remove parentheses and extract alphanumeric words + clean_name = re.sub(r'\([^)]*\)', '', full_name) + words = [re.sub(r'[^a-zA-Z0-9]', '', word) for word in clean_name.split() if re.sub(r'[^a-zA-Z0-9]', '', word)] + + if len(words) == 1: + return words[0][:8].upper() + + # Handle special case: Name + Version (e.g., "Word A1" -> "WordA1") + if len(words) == 2 and re.match(r'^[A-Za-z]\d+$', words[1]): + return (words[0] + words[1])[:8].upper() + + result = "" + for word in words: + # Version or number patterns + if (re.match(r'^\d+[a-zA-Z]+$', word) or + re.match(r'^\d+[vVbB]\d+$', word) or + re.match(r'^[vVbB]\d+$', word) or + re.match(r'^\d{4}$', word)): + result += word.upper() + # All uppercase abbreviations (2-3 letters) + elif re.match(r'^[A-Z]{2,3}$', word): + result += word + # Letters+digits (for example tr15 rev2) + elif re.match(r'^[a-zA-Z]+[0-9]+$', word): + result += word[0].upper() + ''.join(re.findall(r'\d+', word)) + elif word.isalpha(): + result += word[0].upper() + elif word.isdigit(): + result += word + else: + result += word[0].upper() + return result[:8] + + +def generate_metadata(model_path: Path, output_dir: Path, short_name: str): + model_path = model_path + output_path = output_dir + base = model_path.stem + + # Define output files for tinygrad and metadata + tinygrad_file = output_path / f"{base}_tinygrad.pkl" + metadata_file = output_path / f"{base}_metadata.pkl" + + if not tinygrad_file.exists() or not metadata_file.exists(): + print(f"Error: Missing files for model {base} ({tinygrad_file} or {metadata_file})", file=sys.stderr) + return + + # Calculate the sha256 hashes + with open(tinygrad_file, 'rb') as f: + tinygrad_hash = hashlib.sha256(f.read()).hexdigest() + + with open(metadata_file, 'rb') as f: + metadata_hash = hashlib.sha256(f.read()).hexdigest() + + # Rename the files if a custom file name is provided + if short_name: + tinygrad_file = tinygrad_file.rename(output_path / f"{base}_{short_name.lower()}_tinygrad.pkl") + metadata_file = metadata_file.rename(output_path / f"{base}_{short_name.lower()}_metadata.pkl") + + # Build the metadata structure + model_type = "offPolicy" if "off_policy" in base else "onPolicy" if "on_policy" in base else base.split("_")[-1] + + model_metadata = { + "type": model_type, + "artifact": { + "file_name": tinygrad_file.name, + "download_uri": { + "url": "https://gitlab.com/sunnypilot/public/docs.sunnypilot.ai/-/raw/main/", + "sha256": tinygrad_hash + } + }, + "metadata": { + "file_name": metadata_file.name, + "download_uri": { + "url": "https://gitlab.com/sunnypilot/public/docs.sunnypilot.ai/-/raw/main/", + "sha256": metadata_hash + } + } + } + + # Return model metadata + return model_metadata + + +def create_metadata_json(models: list, output_dir: Path, custom_name=None, short_name=None, is_20hz=False, upstream_branch="unknown"): + metadata_json = { + "short_name": short_name, + "display_name": custom_name or upstream_branch, + "is_20hz": is_20hz, + "ref": upstream_branch, + "environment": "development", + "runner": "tinygrad", + "index": -1, + "minimum_selector_version": "-1", + "generation": "-1", + "build_time": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), + "overrides": {}, + "models": models, + } + + # Write metadata to output_dir + with open(output_dir / "metadata.json", "w") as f: + json.dump(metadata_json, f, indent=2) + + print(f"Generated metadata.json with {len(models)} models.") + + +if __name__ == "__main__": + import argparse + import glob + + parser = argparse.ArgumentParser(description="Generate metadata for model files") + parser.add_argument("--model-dir", default="./models", help="Directory containing ONNX model files") + parser.add_argument("--output-dir", default="./output", help="Output directory for metadata") + parser.add_argument("--custom-name", help="Custom display name for the model") + parser.add_argument("--is-20hz", action="store_true", help="Whether this is a 20Hz model") + parser.add_argument("--validate-only", action="store_true") + parser.add_argument("--upstream-branch", default="unknown", help="Upstream branch name") + args = parser.parse_args() + + if args.validate_only: + metadata_paths = glob.glob(os.path.join(args.model_dir, "*_metadata.pkl")) + if not metadata_paths: + print(f"No metadata files found in {args.model_dir}", file=sys.stderr) + sys.exit(1) + validate_model_outputs([Path(p) for p in metadata_paths]) + print(f"Validated {len(metadata_paths)} metadata files successfully.") + sys.exit(0) + + # Find all ONNX files in the given directory + model_paths = glob.glob(os.path.join(args.model_dir, "*.onnx")) + if not model_paths: + print(f"No ONNX files found in {args.model_dir}", file=sys.stderr) + sys.exit(1) + + _output_dir = Path(args.output_dir) + _output_dir.mkdir(exist_ok=True, parents=True) + _models = [] + + for _model_path in model_paths: + _model_metadata = generate_metadata(Path(_model_path), _output_dir, create_short_name(args.custom_name)) + if _model_metadata: + _models.append(_model_metadata) + + if _models: + create_metadata_json(_models, _output_dir, args.custom_name, create_short_name(args.custom_name), args.is_20hz, args.upstream_branch) + else: + print("No models processed.", file=sys.stderr) diff --git a/release/ci/publish.sh b/release/ci/publish.sh new file mode 100755 index 0000000000..315ae22bfe --- /dev/null +++ b/release/ci/publish.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash + +set -e + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)" +cd $DIR + +# Take parameters as arguments +SOURCE_DIR=$1 +OUTPUT_DIR=$2 +DEV_BRANCH=$3 +VERSION=$4 +GIT_ORIGIN=$5 +EXTRA_VERSION_IDENTIFIER=$6 + +# Check parameters +if [ -z "$SOURCE_DIR" ] || [ -z "$OUTPUT_DIR" ]; then + echo "Error: No source or output directory provided." + exit 1 +fi + +if [ -z "$DEV_BRANCH" ] || [ -z "$VERSION" ]; then + echo "Error: No dev branch or version provided." + exit 1 +fi + +if [ -z "$GIT_ORIGIN" ]; then + echo "Error: No GIT_ORIGIN provided" + exit 1 +fi + +# "Tagging" +echo "#define SUNNYPILOT_VERSION \"$VERSION\"" > ${OUTPUT_DIR}/sunnypilot/common/version.h + +## set git identity +#source $DIR/identity.sh +#export GIT_SSH_COMMAND="ssh -i /data/gitkey" + +echo "[-] Setting up repo T=$SECONDS" +cd $OUTPUT_DIR +git init + +# set git username/password +#source /data/identity.sh + +git rm -rf $OUTPUT_DIR/.git || true # Doing cleanup, but it might fail if the .git doesn't exist or not allowed to delete +git remote remove origin || true # ensure cleanup +git remote add origin $GIT_ORIGIN +#git push origin -d $DEV_BRANCH || true # Ensuring we delete the remote branch if it exists as we are wiping it out +git fetch origin $DEV_BRANCH || (git checkout -b $DEV_BRANCH && git commit --allow-empty -m "sunnypilot v$VERSION release" && git push -u origin $DEV_BRANCH) + +echo "[-] committing version $VERSION T=$SECONDS" +git add -f . + +# include source commit hash and build date in commit +GIT_HASH=$(git --git-dir=$SOURCE_DIR/.git rev-parse HEAD) +DATETIME=$(date '+%Y-%m-%dT%H:%M:%S') +SP_VERSION=$(awk -F\" '{print $2}' $SOURCE_DIR/sunnypilot/common/version.h) + +# Commit with detailed message +git commit -a -m "sunnypilot v$VERSION +version: sunnypilot v$SP_VERSION (${EXTRA_VERSION_IDENTIFIER}) +date: $DATETIME +master commit: $GIT_HASH +" +git branch --set-upstream-to=origin/$DEV_BRANCH +git branch -m $DEV_BRANCH + +# Push! +echo "[-] pushing T=$SECONDS" +git push -f origin $DEV_BRANCH diff --git a/release/ci/squash_and_merge.py b/release/ci/squash_and_merge.py new file mode 100755 index 0000000000..f41e03903d --- /dev/null +++ b/release/ci/squash_and_merge.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +import subprocess +import sys +import argparse + + +def run_git_command(command, check=True): + """ + Runs a git command and returns the trimmed stdout output. + Exits the script if the command fails. + """ + print(f"Running: {' '.join(command)}") + result = subprocess.run(command, capture_output=True, text=True) + if check and result.returncode != 0: + print(result.stdout.strip()) + print(result.stderr.strip()) + sys.exit(result.returncode) + return result.stdout.strip() + + +def main(): + parser = argparse.ArgumentParser(description="Merge multiple branches with squash merges.") + parser.add_argument("--base", required=True, help="The base branch name from which the target branch will be created.") + parser.add_argument("--target", required=True, help="The target branch name to merge into.") + parser.add_argument("--title", required=False, help="Title for the commit") + + parser.add_argument("branches", nargs="+", help="List of branch names to merge into the target branch.") + args = parser.parse_args() + + # Checkout the base branch to ensure a common starting point. + run_git_command(["git", "checkout", args.base]) + + # Check if the target branch exists. If not, create it from the base branch. + branch_list = run_git_command(["git", "branch"], check=False) + branch_names = [line.strip("* ").strip() for line in branch_list.splitlines()] + if args.target in branch_names: + run_git_command(["git", "checkout", args.target]) + else: + run_git_command(["git", "checkout", "-b", args.target]) + + # Iterate over each branch, merging it with a squash merge. + for branch in args.branches: + print(f"Merging branch '{branch}' with a squash merge.") + # Merge the branch without creating a merge commit. + run_git_command(["git", "merge", "--squash", branch]) + # Commit the squashed changes with an appropriate message. + commit_message = args.title or f"Squashed merge of branch '{branch}'" + run_git_command(["git", "commit", "-m", commit_message]) + + print(f"All branches have been merged with squashed commits into '{args.target}'.") + + +if __name__ == "__main__": + main() diff --git a/release/ci/squash_and_merge_prs.py b/release/ci/squash_and_merge_prs.py new file mode 100755 index 0000000000..24922288be --- /dev/null +++ b/release/ci/squash_and_merge_prs.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 + +import subprocess +import sys +import os +import argparse +import json +from datetime import datetime + +TRUST_FORK_LABEL = "trust-fork-pr" + +def setup_argument_parser(): + parser = argparse.ArgumentParser(description='Process and squash GitHub PRs') + parser.add_argument('--pr-data', type=str, help='PR data in JSON format') + parser.add_argument('--source-branch', type=str, default='master', + help='Source branch for merging') + parser.add_argument('--target-branch', type=str, default='master-dev-test', + help='Target branch for merging') + parser.add_argument('--squash-script-path', type=str, required=True, + help='Path to the squash_and_merge.py script') + return parser + + +def validate_squash_script(script_path): + if not os.path.isfile(script_path): + raise FileNotFoundError(f"Squash script not found at: {script_path}") + if not os.access(script_path, os.X_OK): + raise PermissionError(f"Squash script is not executable: {script_path}") + + +def sort_prs_by_creation(pr_data): + """Sort PRs by creation date""" + nodes = (pr_data.get('data', {}).get('search', {}).get('nodes', [])) + + return sorted( + nodes, + key=lambda x: datetime.fromisoformat(x.get('createdAt', '').replace('Z', '+00:00')) + ) + + +def add_pr_comments(pr_number, comments: list[str]): + """Adds or updates a comment with multiple comments to a PR using gh cli""" + comment = "\n___\n".join(comments) + _add_pr_comment(pr_number, comment) + + +def _add_pr_comment(pr_number, comment): + """Add or update a comment to a PR using gh cli""" + title = "## Squash and Merge" + + try: + full_comment = f"{title}\n\n{comment}" + subprocess.run( + ['gh', 'pr', 'comment', '--edit-last', '--create-if-none', f"#{pr_number}", '--body', full_comment], + check=True, + capture_output=True, + text=True + ) + + except subprocess.CalledProcessError as e: + print(f"Failed to add/update comment on PR #{pr_number}: {e.stderr}") + except json.JSONDecodeError: + print(f"Failed to parse comments data for PR #{pr_number}") + + +def validate_pr(pr): + """Validate a PR and return (is_valid, skip_reason)""" + pr_number = pr.get('number', 'UNKNOWN') + branch = pr.get('headRefName', '') + + if not branch: + return False, f"missing branch name for PR #{pr_number}" + + # Check if checks have passed + commits = pr.get('commits', {}).get('nodes', []) + if not commits: + return False, "no commit data found" + + # First check if we have the rollup status + status = commits[0].get('commit', {}).get('statusCheckRollup', {}) + + # If status is not SUCCESS, we need to check individual check runs + if not status or status.get('state') != 'SUCCESS': + # Get detailed check runs for this PR + checks_output = subprocess.run( + ['gh', 'pr', 'checks', str(pr_number), '--json', 'name,state'], + capture_output=True, text=True + ) + + try: + checks_data = json.loads(checks_output.stdout) + + # Check if all checks are successful except for our reset-and-squash check + for check in checks_data: + check_name = check.get('name', '') + check_state = check.get('state', '') + + # Skip our own check and any skipped checks + if check_name == 'reset-and-squash' or check_state == 'SKIPPED': + continue + + # If any other check is not successful, the PR is not valid + if check_state != 'SUCCESS': + return False, f"check '{check_name}' has state '{check_state}'" + + except json.JSONDecodeError: + # If we can't parse the JSON, fall back to the original check + return False, "unable to verify check status" + + # Check for merge conflicts + merge_status = subprocess.run(['gh', 'pr', 'view', str(pr_number), '--json', 'mergeable,mergeStateStatus'], + capture_output=True, text=True) + merge_data = json.loads(merge_status.stdout) + if not merge_data.get('mergeable'): + return False, "merge conflicts detected" + + return True, None + + +def process_pr(pr_data, source_branch, target_branch, squash_script_path): + try: + nodes = sort_prs_by_creation(pr_data) + if not nodes: + print("No PRs to squash") + return 0 + + print(f"Deleting target branch {target_branch}") + subprocess.run(['git', 'branch', '-D', target_branch], check=False) + subprocess.run(['git', 'branch', target_branch, f'origin/{source_branch}'], check=True) + success_count = 0 + for pr in nodes: + pr_comments = [] + try: + pr_number = pr.get('number', 'UNKNOWN') + branch = pr.get('headRefName', '') + title = pr.get('title', '') + head_repository = pr.get('headRepository', {}) + pr_labels = pr.get('labels', {}).get('nodes', []) + is_fork = head_repository.get('isFork', False) + trust_fork = any(label.get('name') == TRUST_FORK_LABEL for label in pr_labels) + is_valid, skip_reason = validate_pr(pr) + origin = "origin" if not head_repository.get('isFork', False) else head_repository.get('nameWithOwner', 'origin') + + if is_fork and trust_fork: + print(f"Adding remote {origin} for PR #{pr_number}") + subprocess.run(['git', 'remote', 'add', origin, head_repository.get('url')], check=False) + + if not is_valid: + print(f"Warning: {skip_reason} for PR #{pr_number}, skipping") + pr_comments.append(f"⚠️ This PR was skipped in the automated `{target_branch}` squash because **{skip_reason}**.") + continue + + # Fetch PR branch + subprocess.run(['git', 'fetch', origin, branch], check=True) + # Delete branch if it exists (ignore errors if it doesn't) + subprocess.run(['git', 'branch', '-D', branch], check=False) + # Create new branch pointing to origin's branch + subprocess.run(['git', 'branch', branch, f'{origin}/{branch}'], check=True) + + # Run squash script + result = subprocess.run([ + squash_script_path, + '--target', target_branch, + '--base', source_branch, + '--title', f"{title} (PR-{pr_number})", + branch, + ], capture_output=True, text=True) + + print(result.stdout) + if result.returncode == 0: + print(f"Successfully processed PR #{pr_number}") + success_count += 1 + continue + + print(f"Error processing PR #{pr_number}:") + print(f"Command failed with exit code {result.returncode}") + output = result.stdout + print(f"Error output: {output}") + pr_comments.append(f"⚠️ Error during automated `{target_branch}` squash:\n```\n{output}\n```") + subprocess.run(['git', 'reset', '--hard'], check=True) + continue + except Exception as e: + print(f"Unexpected error processing PR #{pr_number}: {str(e)}") + pr_comments.append(f"⚠️ Unexpected error during automated `{target_branch}` squash:\n```\n{str(e)}\n```") + subprocess.run(['git', 'reset', '--hard'], check=True) + continue + finally: + if pr_comments: + add_pr_comments(pr_number, pr_comments) # This "commits" all the comments generated on this run before leaving loop on continue. + + return success_count + + except Exception as e: + import traceback + print(f"Error in process_pr: {str(e)}") + print("Full traceback:") + print(traceback.format_exc()) + return 0 + + +def main(): + parser = setup_argument_parser() + try: + args = parser.parse_args() + validate_squash_script(args.squash_script_path) + pr_data_json = json.loads(args.pr_data) + + # Process the PRs + success_count = process_pr(pr_data_json, args.source_branch, args.target_branch, args.squash_script_path) + print(f"Successfully processed {success_count} PRs") + + except Exception as e: + print(f"Fatal error: {str(e)}", file=sys.stderr) + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/release/ci/uninstall_github_runner.sh b/release/ci/uninstall_github_runner.sh new file mode 100755 index 0000000000..5f3acfbafd --- /dev/null +++ b/release/ci/uninstall_github_runner.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Determine BASE_DIR based on mount point +if mountpoint -q /data/media; then + GITHUB_BASE_DIR="/data/media/0/github" +else + GITHUB_BASE_DIR="/data/github" +fi + +# Define directories and user +BIN_DIR="$GITHUB_BASE_DIR/bin" +BUILDS_DIR="$GITHUB_BASE_DIR/builds" +OPENPILOT_DIR="$GITHUB_BASE_DIR/openpilot" +LOGS_DIR="$GITHUB_BASE_DIR/logs" +CACHE_DIR="$GITHUB_BASE_DIR/cache" +RUNNER_USERNAME="github-runner" +# Define the systemd service name +SERVICE_NAME="github-runner" +USER_GROUPS="comma,gpu,gpio,sudo" + +# Function to stop and disable the systemd service +stop_and_uninstall_service() { + cd $GITHUB_BASE_DIR/runner + sudo ./svc.sh stop + sudo ./svc.sh uninstall +} + +# Function to remove the systemd service file +remove_runner() { + cd $GITHUB_BASE_DIR/runner + sudo rm .runner + sudo su -c './config.sh remove' github-runner +} + +# Function to delete the Github Runner directories +delete_directories() { + sudo rm -rf "$BIN_DIR/github-runner" + sudo rm -rf "$GITHUB_BASE_DIR" "$BIN_DIR" "$BUILDS_DIR" "$LOGS_DIR" "$CACHE_DIR" "$OPENPILOT_DIR" +} + +# Function to remove the Github Runner user +delete_user() { + for group in ${USER_GROUPS//,/ } + do + sudo gpasswd -d ${RUNNER_USERNAME} ${group} + done + sudo userdel -r ${RUNNER_USERNAME} +} + +# Function to remove sudoers entry +remove_sudoers_entry() { + sudo sed -i.bak "/${RUNNER_USERNAME} ALL=(ALL) NOPASSWD: ALL/d" /etc/sudoers +} + +# Make filesystem writable +sudo mount -o remount rw / + +# Ensure filesystem is remounted as read-only on script exit +trap "sudo mount -o remount ro /" EXIT + +# Call functions +stop_and_uninstall_service +remove_runner +delete_directories +delete_user +remove_sudoers_entry +# End of uninstall script diff --git a/release/identity.sh b/release/identity.sh index c699c94650..1913df3c5b 100644 --- a/release/identity.sh +++ b/release/identity.sh @@ -1,4 +1,4 @@ -export GIT_COMMITTER_NAME="Vehicle Researcher" -export GIT_COMMITTER_EMAIL="user@comma.ai" -export GIT_AUTHOR_NAME="Vehicle Researcher" -export GIT_AUTHOR_EMAIL="user@comma.ai" +export GIT_COMMITTER_NAME="github-actions[bot]" +export GIT_COMMITTER_EMAIL="github-actions[bot]@users.noreply.github.com" +export GIT_AUTHOR_NAME="github-actions[bot]" +export GIT_AUTHOR_EMAIL="github-actions[bot]@users.noreply.github.com" diff --git a/release/release_files.py b/release/release_files.py index 36910293a4..0b9e034bf1 100755 --- a/release/release_files.py +++ b/release/release_files.py @@ -17,12 +17,15 @@ blacklist = [ ".gitattributes", ".git$", ".gitmodules", + ".run/", + ".idea/", ] # gets you through the blacklist whitelist: list[str] = [ ] + if __name__ == "__main__": for f in Path(ROOT).rglob("**/*"): if not (f.is_file() or f.is_symlink()): diff --git a/scripts/lint/lint.sh b/scripts/lint/lint.sh index 5581171e8f..3255c9eb82 100755 --- a/scripts/lint/lint.sh +++ b/scripts/lint/lint.sh @@ -13,7 +13,7 @@ cd $ROOT FAILED=0 -IGNORED_FILES="uv\.lock|docs\/CARS.md" +IGNORED_FILES="uv\.lock|docs\/CARS.md|LICENSE\.md" IGNORED_DIRS="^third_party.*|^msgq.*|^msgq_repo.*|^opendbc.*|^opendbc_repo.*|^cereal.*|^panda.*|^rednose.*|^rednose_repo.*|^tinygrad.*|^tinygrad_repo.*|^teleoprtc.*|^teleoprtc_repo.*" function run() { @@ -56,7 +56,7 @@ function run_tests() { if [[ -z "$FAST" ]]; then run "ty" ty check - run "codespell" codespell $ALL_FILES + run "codespell" codespell $ALL_FILES --ignore-words=$ROOT/.codespellignore fi return $FAILED diff --git a/scripts/manage-powersave.py b/scripts/manage-powersave.py new file mode 100755 index 0000000000..1a82810a7f --- /dev/null +++ b/scripts/manage-powersave.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +import argparse +import multiprocessing +from openpilot.system.hardware import HARDWARE + + +def main(): + parser = argparse.ArgumentParser(description='Control power saving mode') + parser.add_argument('--enable', action='store_true', help='Enable power saving mode') + parser.add_argument('--disable', action='store_true', help='Disable power saving mode') + args = parser.parse_args() + + if args.enable and args.disable: + parser.error("Cannot specify both --enable and --disable") + elif not (args.enable or args.disable): + parser.error("Must specify either --enable or --disable") + + print(f"Number of CPU cores available before: [{multiprocessing.cpu_count()}]") + HARDWARE.set_power_save(args.enable) + + state = "enabled" if args.enable else "disabled" + print(f"Power save mode set to: [{state}]") + print(f"Number of CPU cores available now: [{multiprocessing.cpu_count()}]") + + +if __name__ == "__main__": + main() diff --git a/selfdrive/assets/fonts/Audiowide-Regular.ttf b/selfdrive/assets/fonts/Audiowide-Regular.ttf new file mode 100644 index 0000000000..1b6913947b --- /dev/null +++ b/selfdrive/assets/fonts/Audiowide-Regular.ttf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:434a720871336d359378beff5ebff3f9fd654d958693d272c7c6f2e271c7e41c +size 47676 diff --git a/selfdrive/assets/sounds/prompt_single_high.wav b/selfdrive/assets/sounds/prompt_single_high.wav new file mode 100644 index 0000000000..202483d17f --- /dev/null +++ b/selfdrive/assets/sounds/prompt_single_high.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dbfa5858c0a672411ffdc691efdecb06d01ae458cc1df409bcf3fdeaa4756f72 +size 34638 diff --git a/selfdrive/assets/sounds/prompt_single_low.wav b/selfdrive/assets/sounds/prompt_single_low.wav new file mode 100644 index 0000000000..925401ea27 --- /dev/null +++ b/selfdrive/assets/sounds/prompt_single_low.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:db9671bb03e01f119bba1eb6cc0507e0f039ac4e5b7f9f839a87071c52e86e56 +size 44416 diff --git a/selfdrive/car/card.py b/selfdrive/car/card.py index b64210514a..ca6ca89d7f 100755 --- a/selfdrive/car/card.py +++ b/selfdrive/car/card.py @@ -5,7 +5,7 @@ import threading import cereal.messaging as messaging -from cereal import car, log +from cereal import car, log, custom from openpilot.common.params import Params from openpilot.common.realtime import config_realtime_process, Priority, Ratekeeper @@ -19,6 +19,10 @@ from opendbc.car.car_helpers import get_car, interfaces from opendbc.car.interfaces import CarInterfaceBase, RadarInterfaceBase from openpilot.selfdrive.pandad import can_capnp_to_list, can_list_to_can_capnp from openpilot.selfdrive.car.cruise import VCruiseHelper +from openpilot.selfdrive.car.helpers import convert_carControlSP, convert_to_capnp + +from openpilot.sunnypilot.mads.helpers import set_alternative_experience, set_car_specific_params +from openpilot.sunnypilot.selfdrive.car import interfaces as sunnypilot_interfaces REPLAY = "REPLAY" in os.environ @@ -61,16 +65,19 @@ class Car: CI: CarInterfaceBase RI: RadarInterfaceBase CP: car.CarParams + CP_SP: structs.CarParamsSP + CP_SP_capnp: custom.CarParamsSP def __init__(self, CI=None, RI=None) -> None: self.can_sock = messaging.sub_sock('can', timeout=20) - self.sm = messaging.SubMaster(['pandaStates', 'carControl', 'onroadEvents']) - self.pm = messaging.PubMaster(['sendcan', 'carState', 'carParams', 'carOutput', 'liveTracks']) + self.sm = messaging.SubMaster(['pandaStates', 'carControl', 'onroadEvents'] + ['carControlSP', 'longitudinalPlanSP']) + self.pm = messaging.PubMaster(['sendcan', 'carState', 'carParams', 'carOutput', 'liveTracks'] + ['carParamsSP', 'carStateSP']) self.can_rcv_cum_timeout_counter = 0 self.CC_prev = car.CarControl.new_message() self.CS_prev = car.CarState.new_message() + self.CS_SP_prev = custom.CarStateSP.new_message() self.initialized_prev = False self.last_actuators_output = structs.CarControl.Actuators() @@ -80,6 +87,7 @@ class Car: self.can_callbacks = can_comm_callbacks(self.can_sock, self.pm.sock['sendcan']) is_release = self.params.get_bool("IsReleaseBranch") + is_release_sp = self.params.get_bool("IsReleaseSpBranch") if CI is None: # wait for one pandaState and one CAN packet @@ -97,17 +105,30 @@ class Car: with car.CarParams.from_bytes(cached_params_raw) as _cached_params: cached_params = _cached_params - self.CI = get_car(*self.can_callbacks, obd_callback(self.params), alpha_long_allowed, is_release, cached_params) - self.RI = interfaces[self.CI.CP.carFingerprint].RadarInterface(self.CI.CP) + fixed_fingerprint = (self.params.get("CarPlatformBundle") or {}).get("platform", None) + init_params_list_sp = sunnypilot_interfaces.initialize_params(self.params) + + self.CI = get_car(*self.can_callbacks, obd_callback(self.params), alpha_long_allowed, is_release, cached_params, + fixed_fingerprint, init_params_list_sp, is_release_sp) + sunnypilot_interfaces.setup_interfaces(self.CI, self.params) + self.RI = interfaces[self.CI.CP.carFingerprint].RadarInterface(self.CI.CP, self.CI.CP_SP) self.CP = self.CI.CP + self.CP_SP = self.CI.CP_SP # continue onto next fingerprinting step in pandad self.params.put_bool("FirmwareQueryDone", True) else: - self.CI, self.CP = CI, CI.CP + self.CI, self.CP, self.CP_SP = CI, CI.CP, CI.CP_SP self.RI = RI self.CP.alternativeExperience = 0 + # mads + set_alternative_experience(self.CP, self.CP_SP, self.params) + set_car_specific_params(self.CP, self.CP_SP, self.params) + + # Dynamic Experimental Control + self.dynamic_experimental_control = self.params.get_bool("DynamicExperimentalControl") + openpilot_enabled_toggle = self.params.get_bool("OpenpilotEnabledToggle") controller_available = self.CI.CC is not None and openpilot_enabled_toggle and not self.CP.dashcamOnly self.CP.passive = not controller_available or self.CP.dashcamOnly @@ -148,7 +169,15 @@ class Car: self.params.put_nonblocking("CarParamsCache", cp_bytes) self.params.put_nonblocking("CarParamsPersistent", cp_bytes) - self.v_cruise_helper = VCruiseHelper(self.CP) + # Write CarParamsSP for controls + # convert to pycapnp representation for caching and logging + self.CP_SP_capnp = convert_to_capnp(self.CP_SP) + cp_sp_bytes = self.CP_SP_capnp.to_bytes() + self.params.put("CarParamsSP", cp_sp_bytes) + self.params.put_nonblocking("CarParamsSPCache", cp_sp_bytes) + self.params.put_nonblocking("CarParamsSPPersistent", cp_sp_bytes) + + self.v_cruise_helper = VCruiseHelper(self.CP, self.CP_SP) self.is_metric = self.params.get_bool("IsMetric") self.experimental_mode = self.params.get_bool("ExperimentalMode") @@ -156,14 +185,18 @@ class Car: # card is driven by can recv, expected at 100Hz self.rk = Ratekeeper(100, print_delay_threshold=None) - def state_update(self) -> tuple[car.CarState, structs.RadarDataT | None]: + # log fingerprint in sentry + sunnypilot_interfaces.log_fingerprint(self.CP) + + def state_update(self) -> tuple[car.CarState, custom.CarStateSP, structs.RadarDataT | None]: """carState update loop, driven by can""" can_strs = messaging.drain_sock_raw(self.can_sock, wait_for_one=True) can_list = can_capnp_to_list(can_strs) # Update carState from CAN - CS = self.CI.update(can_list) + CS, CS_SP = self.CI.update(can_list) + CS_SP = convert_to_capnp(CS_SP) # Update radar tracks from CAN RD: structs.RadarDataT | None = self.RI.update(can_list) @@ -179,18 +212,19 @@ class Car: if can_rcv_valid and REPLAY: self.can_log_mono_time = messaging.log_from_bytes(can_strs[0]).logMonoTime + self.v_cruise_helper.update_speed_limit_assist(self.is_metric, self.sm['longitudinalPlanSP']) self.v_cruise_helper.update_v_cruise(CS, self.sm['carControl'].enabled, self.is_metric) if self.sm['carControl'].enabled and not self.CC_prev.enabled: # Use CarState w/ buttons from the step selfdrived enables on - self.v_cruise_helper.initialize_v_cruise(self.CS_prev, self.experimental_mode) + self.v_cruise_helper.initialize_v_cruise(self.CS_prev, self.experimental_mode, self.dynamic_experimental_control) # TODO: mirror the carState.cruiseState struct? CS.vCruise = float(self.v_cruise_helper.v_cruise_kph) CS.vCruiseCluster = float(self.v_cruise_helper.v_cruise_cluster_kph) - return CS, RD + return CS, CS_SP, RD - def state_publish(self, CS: car.CarState, RD: structs.RadarDataT | None): + def state_publish(self, CS: car.CarState, CS_SP: custom.CarStateSP, RD: structs.RadarDataT | None): """carState and carParams publish loop""" # carParams - logged every 50 seconds (> 1 per segment) @@ -220,41 +254,59 @@ class Car: tracks_msg.liveTracks = RD self.pm.send('liveTracks', tracks_msg) - def controls_update(self, CS: car.CarState, CC: car.CarControl): + # carParamsSP - logged every 50 seconds (> 1 per segment) + if self.sm.frame % int(50. / DT_CTRL) == 0: + cp_sp_send = messaging.new_message('carParamsSP') + cp_sp_send.valid = True + cp_sp_send.carParamsSP = self.CP_SP_capnp + self.pm.send('carParamsSP', cp_sp_send) + + cs_sp_send = messaging.new_message('carStateSP') + cs_sp_send.valid = CS.canValid + cs_sp_send.carStateSP = CS_SP + self.pm.send('carStateSP', cs_sp_send) + + def controls_update(self, CS: car.CarState, CC: car.CarControl, CC_SP: custom.CarControlSP): """control update loop, driven by carControl""" if not self.initialized_prev: # Initialize CarInterface, once controls are ready # TODO: this can make us miss at least a few cycles when doing an ECU knockout - self.CI.init(self.CP, *self.can_callbacks) + self.CI.init(self.CP, self.CP_SP, *self.can_callbacks) # signal pandad to switch to car safety mode self.params.put_bool_nonblocking("ControlsReady", True) if self.sm.all_alive(['carControl']): # send car controls over can now_nanos = self.can_log_mono_time if REPLAY else int(time.monotonic() * 1e9) - self.last_actuators_output, can_sends = self.CI.apply(CC, now_nanos) + self.last_actuators_output, can_sends = self.CI.apply(CC, convert_carControlSP(CC_SP), now_nanos) self.pm.send('sendcan', can_list_to_can_capnp(can_sends, msgtype='sendcan', valid=CS.canValid)) self.CC_prev = CC def step(self): - CS, RD = self.state_update() + CS, CS_SP, RD = self.state_update() - self.state_publish(CS, RD) + self.state_publish(CS, CS_SP, RD) initialized = (not any(e.name == EventName.selfdriveInitializing for e in self.sm['onroadEvents']) and self.sm.seen['onroadEvents']) if not self.CP.passive and initialized: - self.controls_update(CS, self.sm['carControl']) + self.controls_update(CS, self.sm['carControl'], self.sm['carControlSP']) self.initialized_prev = initialized self.CS_prev = CS + self.CS_SP_prev = CS_SP def params_thread(self, evt): while not evt.is_set(): self.is_metric = self.params.get_bool("IsMetric") self.experimental_mode = self.params.get_bool("ExperimentalMode") and self.CP.openpilotLongitudinalControl + + # sunnypilot + self.dynamic_experimental_control = self.params.get_bool("DynamicExperimentalControl") + self.v_cruise_helper.read_custom_set_speed_params() + time.sleep(0.1) def card_thread(self): diff --git a/selfdrive/car/cruise.py b/selfdrive/car/cruise.py index 0d761844b5..572dfabc02 100644 --- a/selfdrive/car/cruise.py +++ b/selfdrive/car/cruise.py @@ -3,6 +3,7 @@ import numpy as np from cereal import car from openpilot.common.constants import CV +from openpilot.sunnypilot.selfdrive.car.cruise_ext import VCruiseHelperSP # WARNING: this value was determined based on the model's training distribution, @@ -28,8 +29,9 @@ CRUISE_INTERVAL_SIGN = { } -class VCruiseHelper: - def __init__(self, CP): +class VCruiseHelper(VCruiseHelperSP): + def __init__(self, CP, CP_SP): + VCruiseHelperSP.__init__(self, CP, CP_SP) self.CP = CP self.v_cruise_kph = V_CRUISE_UNSET self.v_cruise_cluster_kph = V_CRUISE_UNSET @@ -44,12 +46,16 @@ class VCruiseHelper: def update_v_cruise(self, CS, enabled, is_metric): self.v_cruise_kph_last = self.v_cruise_kph + self.get_minimum_set_speed(is_metric) + + _enabled = self.update_enabled_state(CS, enabled) + if CS.cruiseState.available: - if not self.CP.pcmCruise: + if not self.CP.pcmCruise or (not self.CP_SP.pcmCruiseSpeed and _enabled): # if stock cruise is completely disabled, then we can use our own set speed logic - self._update_v_cruise_non_pcm(CS, enabled, is_metric) + self._update_v_cruise_non_pcm(CS, _enabled, is_metric) + self.update_speed_limit_assist_v_cruise_non_pcm() self.v_cruise_cluster_kph = self.v_cruise_kph - self.update_button_timers(CS, enabled) else: self.v_cruise_kph = CS.cruiseState.speed * CV.MS_TO_KPH self.v_cruise_cluster_kph = CS.cruiseState.speedCluster * CV.MS_TO_KPH @@ -63,6 +69,9 @@ class VCruiseHelper: self.v_cruise_kph = V_CRUISE_UNSET self.v_cruise_cluster_kph = V_CRUISE_UNSET + if not self.CP.pcmCruise or not self.CP_SP.pcmCruiseSpeed: + self.update_button_timers(CS, enabled) + def _update_v_cruise_non_pcm(self, CS, enabled, is_metric): # handle button presses. TODO: this should be in state_control, but a decelCruise press # would have the effect of both enabling and changing speed is checked after the state transition @@ -99,7 +108,13 @@ class VCruiseHelper: if not self.button_change_states[button_type]["enabled"]: return - v_cruise_delta = v_cruise_delta * (5 if long_press else 1) + # Speed Limit Assist for Non PCM long cars. + # True: Disallow set speed changes when user confirmed the target set speed during preActive state + # False: Allow set speed changes as SLA is not requesting user confirmation + if self.update_speed_limit_assist_pre_active_confirmed(button_type): + return + + long_press, v_cruise_delta = VCruiseHelperSP.update_v_cruise_delta(self, long_press, v_cruise_delta) if long_press and self.v_cruise_kph % v_cruise_delta != 0: # partial interval self.v_cruise_kph = CRUISE_NEAREST_FUNC[button_type](self.v_cruise_kph / v_cruise_delta) * v_cruise_delta else: @@ -109,7 +124,7 @@ class VCruiseHelper: if CS.gasPressed and button_type in (ButtonType.decelCruise, ButtonType.setCruise): self.v_cruise_kph = max(self.v_cruise_kph, CS.vEgo * CV.MS_TO_KPH) - self.v_cruise_kph = np.clip(round(self.v_cruise_kph, 1), V_CRUISE_MIN, V_CRUISE_MAX) + self.v_cruise_kph = np.clip(round(self.v_cruise_kph, 1), self.v_cruise_min, V_CRUISE_MAX) def update_button_timers(self, CS, enabled): # increment timer for buttons still pressed @@ -123,12 +138,13 @@ class VCruiseHelper: self.button_timers[b.type.raw] = 1 if b.pressed else 0 self.button_change_states[b.type.raw] = {"standstill": CS.cruiseState.standstill, "enabled": enabled} - def initialize_v_cruise(self, CS, experimental_mode: bool) -> None: + def initialize_v_cruise(self, CS, experimental_mode: bool, dynamic_experimental_control: bool) -> None: # initializing is handled by the PCM if self.CP.pcmCruise: return - initial = V_CRUISE_INITIAL_EXPERIMENTAL_MODE if experimental_mode else V_CRUISE_INITIAL + initial_experimental_mode = experimental_mode and not dynamic_experimental_control + initial = V_CRUISE_INITIAL_EXPERIMENTAL_MODE if initial_experimental_mode else V_CRUISE_INITIAL if any(b.type in (ButtonType.accelCruise, ButtonType.resumeCruise) for b in CS.buttonEvents) and self.v_cruise_initialized: self.v_cruise_kph = self.v_cruise_kph_last diff --git a/selfdrive/car/helpers.py b/selfdrive/car/helpers.py new file mode 100644 index 0000000000..384152c71b --- /dev/null +++ b/selfdrive/car/helpers.py @@ -0,0 +1,67 @@ +import capnp +from typing import Any + +from cereal import custom +from opendbc.car import structs + +_FIELDS = '__dataclass_fields__' # copy of dataclasses._FIELDS + + +def is_dataclass(obj): + """Similar to dataclasses.is_dataclass without instance type check checking""" + return hasattr(obj, _FIELDS) + + +def _asdictref_inner(obj) -> dict[str, Any] | Any: + if is_dataclass(obj): + ret = {} + for field in getattr(obj, _FIELDS): # similar to dataclasses.fields() + ret[field] = _asdictref_inner(getattr(obj, field)) + return ret + elif isinstance(obj, (tuple, list)): + return type(obj)(_asdictref_inner(v) for v in obj) + else: + return obj + + +def asdictref(obj) -> dict[str, Any]: + """ + Similar to dataclasses.asdict without recursive type checking and copy.deepcopy + Note that the resulting dict will contain references to the original struct as a result + """ + if not is_dataclass(obj): + raise TypeError("asdictref() should be called on dataclass instances") + + return _asdictref_inner(obj) + + +def convert_to_capnp(struct: structs.CarParamsSP | structs.CarStateSP) -> capnp.lib.capnp._DynamicStructBuilder: + struct_dict = asdictref(struct) + + if isinstance(struct, structs.CarParamsSP): + struct_capnp = custom.CarParamsSP.new_message(**struct_dict) + elif isinstance(struct, structs.CarStateSP): + struct_capnp = custom.CarStateSP.new_message(**struct_dict) + else: + raise ValueError(f"Unsupported struct type: {type(struct)}") + + return struct_capnp + + +def convert_carControlSP(struct: capnp.lib.capnp._DynamicStructReader) -> structs.CarControlSP: + # TODO: recursively handle any car struct as needed + def remove_deprecated(s: dict) -> dict: + return {k: v for k, v in s.items() if not k.endswith('DEPRECATED')} + + struct_dict = struct.to_dict() + struct_dataclass = structs.CarControlSP(**remove_deprecated({k: v for k, v in struct_dict.items() if not isinstance(k, dict)})) + + struct_dataclass.mads = structs.ModularAssistiveDrivingSystem(**remove_deprecated(struct_dict.get('mads', {}))) + # struct_dataclass.params = [structs.CarControlSP.Param(**remove_deprecated(p)) for p in struct_dict.get('params', [])] + struct_dataclass.leadOne = structs.LeadData(**remove_deprecated(struct_dict.get('leadOne', {}))) + struct_dataclass.leadTwo = structs.LeadData(**remove_deprecated(struct_dict.get('leadTwo', {}))) + struct_dataclass.intelligentCruiseButtonManagement = structs.IntelligentCruiseButtonManagement( + **remove_deprecated(struct_dict.get('intelligentCruiseButtonManagement', {})) + ) + + return struct_dataclass diff --git a/selfdrive/car/tests/test_car_interfaces.py b/selfdrive/car/tests/test_car_interfaces.py index 1bc59326a2..cd44759cbf 100644 --- a/selfdrive/car/tests/test_car_interfaces.py +++ b/selfdrive/car/tests/test_car_interfaces.py @@ -3,18 +3,21 @@ import hypothesis.strategies as st from hypothesis import Phase, given, settings from openpilot.common.parameterized import parameterized -from cereal import car +from cereal import car, custom from opendbc.car import DT_CTRL from opendbc.car.structs import CarParams from opendbc.car.tests.test_car_interfaces import get_fuzzy_car_interface from opendbc.car.mock.values import CAR as MOCK from opendbc.car.values import PLATFORMS +from openpilot.selfdrive.car.helpers import convert_carControlSP from openpilot.selfdrive.controls.lib.latcontrol_angle import LatControlAngle from openpilot.selfdrive.controls.lib.latcontrol_pid import LatControlPID from openpilot.selfdrive.controls.lib.latcontrol_torque import LatControlTorque from openpilot.selfdrive.controls.lib.longcontrol import LongControl from openpilot.selfdrive.test.fuzzy_generation import FuzzyGenerator +from openpilot.sunnypilot.selfdrive.car import interfaces as sunnypilot_interfaces + MAX_EXAMPLES = int(os.environ.get('MAX_EXAMPLES', '60')) @@ -28,15 +31,20 @@ class TestCarInterfaces: def test_car_interfaces(self, car_name, data): car_interface = get_fuzzy_car_interface(car_name, data.draw) car_params = car_interface.CP.as_reader() + car_params_sp = car_interface.CP_SP + sunnypilot_interfaces.setup_interfaces(car_interface) cc_msg = FuzzyGenerator.get_random_msg(data.draw, car.CarControl, real_floats=True) + cc_sp_msg = FuzzyGenerator.get_random_msg(data.draw, custom.CarControlSP, real_floats=True) # Run car interface now_nanos = 0 CC = car.CarControl.new_message(**cc_msg) CC = CC.as_reader() + CC_SP = custom.CarControlSP.new_message(**cc_sp_msg) + CC_SP = convert_carControlSP(CC_SP.as_reader()) for _ in range(10): car_interface.update([]) - car_interface.apply(CC, now_nanos) + car_interface.apply(CC, CC_SP, now_nanos) now_nanos += DT_CTRL * 1e9 # 10 ms CC = car.CarControl.new_message(**cc_msg) @@ -46,16 +54,16 @@ class TestCarInterfaces: CC = CC.as_reader() for _ in range(10): car_interface.update([]) - car_interface.apply(CC, now_nanos) + car_interface.apply(CC, CC_SP, now_nanos) now_nanos += DT_CTRL * 1e9 # 10ms # Test controller initialization # TODO: wait until card refactor is merged to run controller a few times, # hypothesis also slows down significantly with just one more message draw - LongControl(car_params) + LongControl(car_params, car_params_sp) if car_params.steerControlType == CarParams.SteerControlType.angle: - LatControlAngle(car_params, car_interface, DT_CTRL) + LatControlAngle(car_params, car_params_sp, car_interface, DT_CTRL) elif car_params.lateralTuning.which() == 'pid': - LatControlPID(car_params, car_interface, DT_CTRL) + LatControlPID(car_params, car_params_sp, car_interface, DT_CTRL) elif car_params.lateralTuning.which() == 'torque': - LatControlTorque(car_params, car_interface, DT_CTRL) + LatControlTorque(car_params, car_params_sp, car_interface, DT_CTRL) diff --git a/selfdrive/car/tests/test_cruise_speed.py b/selfdrive/car/tests/test_cruise_speed.py index 05fef93b4e..d3ca6eed4f 100644 --- a/selfdrive/car/tests/test_cruise_speed.py +++ b/selfdrive/car/tests/test_cruise_speed.py @@ -5,7 +5,7 @@ import numpy as np from openpilot.common.parameterized import parameterized_class from cereal import log from openpilot.selfdrive.car.cruise import VCruiseHelper, V_CRUISE_MIN, V_CRUISE_MAX, V_CRUISE_INITIAL, IMPERIAL_INCREMENT -from cereal import car +from cereal import car, custom from openpilot.common.constants import CV from openpilot.selfdrive.test.longitudinal_maneuvers.maneuver import Maneuver @@ -44,12 +44,13 @@ class TestCruiseSpeed: assert simulation_steady_state == pytest.approx(cruise_speed, abs=.01), f'Did not reach {self.speed} m/s' -# TODO: test pcmCruise -@parameterized_class(('pcm_cruise',), [(False,)]) +# TODO: test pcmCruise and pcmCruiseSpeed +@parameterized_class(('pcm_cruise', 'pcm_cruise_speed'), [(False, True)]) class TestVCruiseHelper: def setup_method(self): self.CP = car.CarParams(pcmCruise=self.pcm_cruise) - self.v_cruise_helper = VCruiseHelper(self.CP) + self.CP_SP = custom.CarParamsSP(pcmCruiseSpeed=self.pcm_cruise_speed) + self.v_cruise_helper = VCruiseHelper(self.CP, self.CP_SP) self.reset_cruise_speed_state() def reset_cruise_speed_state(self): @@ -57,16 +58,16 @@ class TestVCruiseHelper: for _ in range(2): self.v_cruise_helper.update_v_cruise(car.CarState(cruiseState={"available": False}), enabled=False, is_metric=False) - def enable(self, v_ego, experimental_mode): + def enable(self, v_ego, experimental_mode, dynamic_experimental_control): # Simulates user pressing set with a current speed - self.v_cruise_helper.initialize_v_cruise(car.CarState(vEgo=v_ego), experimental_mode) + self.v_cruise_helper.initialize_v_cruise(car.CarState(vEgo=v_ego), experimental_mode, dynamic_experimental_control) def test_adjust_speed(self): """ Asserts speed changes on falling edges of buttons. """ - self.enable(V_CRUISE_INITIAL * CV.KPH_TO_MS, False) + self.enable(V_CRUISE_INITIAL * CV.KPH_TO_MS, False, False) for btn in (ButtonType.accelCruise, ButtonType.decelCruise): for pressed in (True, False): @@ -90,7 +91,7 @@ class TestVCruiseHelper: CS.buttonEvents = [ButtonEvent(type=ButtonType.decelCruise, pressed=pressed)] self.v_cruise_helper.update_v_cruise(CS, enabled=enabled, is_metric=False) if pressed: - self.enable(V_CRUISE_INITIAL * CV.KPH_TO_MS, False) + self.enable(V_CRUISE_INITIAL * CV.KPH_TO_MS, False, False) # Expected diff on enabling. Speed should not change on falling edge of pressed assert not pressed == self.v_cruise_helper.v_cruise_kph == self.v_cruise_helper.v_cruise_kph_last @@ -100,7 +101,7 @@ class TestVCruiseHelper: Asserts we don't increment set speed if user presses resume/accel to exit cruise standstill. """ - self.enable(0, False) + self.enable(0, False, False) for standstill in (True, False): for pressed in (True, False): @@ -120,7 +121,7 @@ class TestVCruiseHelper: for v_ego in np.linspace(0, 100, 101): self.reset_cruise_speed_state() - self.enable(V_CRUISE_INITIAL * CV.KPH_TO_MS, False) + self.enable(V_CRUISE_INITIAL * CV.KPH_TO_MS, False, False) # first decrement speed, then perform gas pressed logic expected_v_cruise_kph = self.v_cruise_helper.v_cruise_kph - IMPERIAL_INCREMENT @@ -142,10 +143,11 @@ class TestVCruiseHelper: """ for experimental_mode in (True, False): - for v_ego in np.linspace(0, 100, 101): - self.reset_cruise_speed_state() - assert not self.v_cruise_helper.v_cruise_initialized + for dynamic_experimental_control in (True, False): + for v_ego in np.linspace(0, 100, 101): + self.reset_cruise_speed_state() + assert not self.v_cruise_helper.v_cruise_initialized - self.enable(float(v_ego), experimental_mode) - assert V_CRUISE_INITIAL <= self.v_cruise_helper.v_cruise_kph <= V_CRUISE_MAX - assert self.v_cruise_helper.v_cruise_initialized + self.enable(float(v_ego), experimental_mode, dynamic_experimental_control) + assert V_CRUISE_INITIAL <= self.v_cruise_helper.v_cruise_kph <= V_CRUISE_MAX + assert self.v_cruise_helper.v_cruise_initialized diff --git a/selfdrive/car/tests/test_models.py b/selfdrive/car/tests/test_models.py index a7f3d68c14..209df3b159 100644 --- a/selfdrive/car/tests/test_models.py +++ b/selfdrive/car/tests/test_models.py @@ -1,4 +1,5 @@ import time +import copy import os import pytest import random @@ -150,7 +151,9 @@ class TestCarModelBase(unittest.TestCase): cls.CarInterface = interfaces[cls.platform] cls.CP = cls.CarInterface.get_params(cls.platform, cls.fingerprint, car_fw, alpha_long, False, docs=False) + cls.CP_SP = cls.CarInterface.get_params_sp(cls.CP, cls.platform, cls.fingerprint, car_fw, alpha_long, False, docs=False) assert cls.CP + assert cls.CP_SP assert cls.CP.carFingerprint == cls.platform os.environ["COMMA_CACHE"] = DEFAULT_DOWNLOAD_CACHE_ROOT @@ -160,12 +163,15 @@ class TestCarModelBase(unittest.TestCase): del cls.can_msgs def setUp(self): - self.CI = self.CarInterface(self.CP.copy()) + self.CI = self.CarInterface(self.CP.copy(), copy.deepcopy(self.CP_SP)) assert self.CI # TODO: check safetyModel is in release panda build self.safety = libsafety_py.libsafety + safety_param_sp = self.CP_SP.safetyParam + self.safety.set_current_safety_param_sp(safety_param_sp) + cfg = self.CP.safetyConfigs[-1] set_status = self.safety.set_safety_hooks(cfg.safetyModel.raw, cfg.safetyParam) self.assertEqual(0, set_status, f"failed to set safetyModel {cfg}") @@ -191,10 +197,11 @@ class TestCarModelBase(unittest.TestCase): # TODO: also check for checksum violations from can parser can_invalid_cnt = 0 CC = structs.CarControl().as_reader() + CC_SP = structs.CarControlSP() for i, msg in enumerate(self.can_msgs): - CS = self.CI.update(msg) - self.CI.apply(CC, msg[0]) + CS, _ = self.CI.update(msg) + self.CI.apply(CC, CC_SP, msg[0]) # wait max of 2s for low frequency msgs to be seen if i > 250: @@ -203,7 +210,7 @@ class TestCarModelBase(unittest.TestCase): self.assertEqual(can_invalid_cnt, 0) def test_radar_interface(self): - RI = self.CarInterface.RadarInterface(self.CP) + RI = self.CarInterface.RadarInterface(self.CP, self.CP_SP) assert RI # Since OBD port is multiplexed to bus 1 (commonly radar bus) while fingerprinting, @@ -263,13 +270,13 @@ class TestCarModelBase(unittest.TestCase): if self.CP.notCar: self.skipTest("Skipping test for notCar") - def test_car_controller(car_control): + def test_car_controller(car_control, car_control_sp): now_nanos = 0 msgs_sent = 0 - CI = self.CarInterface(self.CP) + CI = self.CarInterface(self.CP, self.CP_SP) for _ in range(round(10.0 / DT_CTRL)): # make sure we hit the slowest messages CI.update([]) - _, sendcan = CI.apply(car_control, now_nanos) + _, sendcan = CI.apply(car_control, car_control_sp, now_nanos) now_nanos += DT_CTRL * 1e9 msgs_sent += len(sendcan) @@ -282,17 +289,18 @@ class TestCarModelBase(unittest.TestCase): # Make sure we can send all messages while inactive CC = structs.CarControl() - test_car_controller(CC.as_reader()) + CC_SP = structs.CarControlSP() + test_car_controller(CC.as_reader(), CC_SP) # Test cancel + general messages (controls_allowed=False & cruise_engaged=True) self.safety.set_cruise_engaged_prev(True) CC = structs.CarControl(cruiseControl=structs.CarControl.CruiseControl(cancel=True)) - test_car_controller(CC.as_reader()) + test_car_controller(CC.as_reader(), CC_SP) # Test resume + general messages (controls_allowed=True & cruise_engaged=True) self.safety.set_controls_allowed(True) CC = structs.CarControl(cruiseControl=structs.CarControl.CruiseControl(resume=True)) - test_car_controller(CC.as_reader()) + test_car_controller(CC.as_reader(), CC_SP) # Skip stdout/stderr capture with pytest, causes elevated memory usage @pytest.mark.nocapture @@ -334,7 +342,7 @@ class TestCarModelBase(unittest.TestCase): self.safety.safety_rx_hook(to_send) can = [(int(time.monotonic() * 1e9), [CanData(address=address, dat=dat, src=bus)])] - CS = self.CI.update(can) + CS, _ = self.CI.update(can) if n < 5: # CANParser warmup time continue @@ -396,7 +404,8 @@ class TestCarModelBase(unittest.TestCase): checks = defaultdict(int) vehicle_speed_seen = self.CP.steerControlType == SteerControlType.angle and not self.CP.notCar for idx, can in enumerate(self.can_msgs): - CS = self.CI.update(can).as_reader() + CS, _ = self.CI.update(can) + CS = CS.as_reader() for msg in filter(lambda m: m.src < 64, can[1]): to_send = libsafety_py.make_CANPacket(msg.address, msg.src % 4, msg.dat) ret = self.safety.safety_rx_hook(to_send) diff --git a/selfdrive/controls/controlsd.py b/selfdrive/controls/controlsd.py index b49e46604a..b53185ca2b 100755 --- a/selfdrive/controls/controlsd.py +++ b/selfdrive/controls/controlsd.py @@ -20,6 +20,8 @@ from openpilot.selfdrive.controls.lib.longcontrol import LongControl from openpilot.selfdrive.modeld.modeld import LAT_SMOOTH_SECONDS from openpilot.selfdrive.locationd.helpers import PoseCalibrator, Pose +from openpilot.sunnypilot.selfdrive.controls.controlsd_ext import ControlsExt + State = log.SelfdriveState.OpenpilotState LaneChangeState = log.LaneChangeState LaneChangeDirection = log.LaneChangeDirection @@ -27,19 +29,23 @@ LaneChangeDirection = log.LaneChangeDirection ACTUATOR_FIELDS = tuple(car.CarControl.Actuators.schema.fields.keys()) -class Controls: +class Controls(ControlsExt): def __init__(self) -> None: self.params = Params() cloudlog.info("controlsd is waiting for CarParams") self.CP = messaging.log_from_bytes(self.params.get("CarParams", block=True), car.CarParams) cloudlog.info("controlsd got CarParams") - self.CI = interfaces[self.CP.carFingerprint](self.CP) + # Initialize sunnypilot controlsd extension and base model state + ControlsExt.__init__(self, self.CP, self.params) + + self.CI = interfaces[self.CP.carFingerprint](self.CP, self.CP_SP) self.sm = messaging.SubMaster(['liveDelay', 'liveParameters', 'liveTorqueParameters', 'modelV2', 'selfdriveState', 'liveCalibration', 'livePose', 'longitudinalPlan', 'lateralManeuverPlan', 'carState', 'carOutput', - 'driverMonitoringState', 'onroadEvents', 'driverAssistance'], poll='selfdriveState') - self.pm = messaging.PubMaster(['carControl', 'controlsState']) + 'driverMonitoringState', 'onroadEvents', 'driverAssistance', 'liveDelay'] + self.sm_services_ext, + poll='selfdriveState') + self.pm = messaging.PubMaster(['carControl', 'controlsState'] + self.pm_services_ext) self.steer_limited_by_safety = False self.curvature = 0.0 @@ -48,15 +54,17 @@ class Controls: self.pose_calibrator = PoseCalibrator() self.calibrated_pose: Pose | None = None - self.LoC = LongControl(self.CP) + self.LoC = LongControl(self.CP, self.CP_SP) self.VM = VehicleModel(self.CP) self.LaC: LatControl if self.CP.steerControlType == car.CarParams.SteerControlType.angle: - self.LaC = LatControlAngle(self.CP, self.CI, DT_CTRL) + self.LaC = LatControlAngle(self.CP, self.CP_SP, self.CI, DT_CTRL) elif self.CP.lateralTuning.which() == 'pid': - self.LaC = LatControlPID(self.CP, self.CI, DT_CTRL) + self.LaC = LatControlPID(self.CP, self.CP_SP, self.CI, DT_CTRL) elif self.CP.lateralTuning.which() == 'torque': - self.LaC = LatControlTorque(self.CP, self.CI, DT_CTRL) + self.LaC = LatControlTorque(self.CP, self.CP_SP, self.CI, DT_CTRL) + + self.LaC = ControlsExt.initialize_lateral_control(self, self.LaC, self.CI, DT_CTRL) def update(self): self.sm.update(15) @@ -85,6 +93,12 @@ class Controls: self.LaC.update_live_torque_params(torque_params.latAccelFactorFiltered, torque_params.latAccelOffsetFiltered, torque_params.frictionCoefficientFiltered) + self.LaC.extension.update_limits() + + self.LaC.extension.update_model_v2(self.sm['modelV2']) + + self.LaC.extension.update_lateral_lag(self.lat_delay) + long_plan = self.sm['longitudinalPlan'] model_v2 = self.sm['modelV2'] @@ -93,9 +107,14 @@ class Controls: # Check which actuators can be enabled standstill = abs(CS.vEgo) <= max(self.CP.minSteerSpeed, 0.3) or CS.standstill - CC.latActive = self.sm['selfdriveState'].active and not CS.steerFaultTemporary and not CS.steerFaultPermanent and \ + + # Get which state to use for active lateral control + _lat_active = self.get_lat_active(self.sm) + + CC.latActive = _lat_active and not CS.steerFaultTemporary and not CS.steerFaultPermanent and \ (not standstill or self.CP.steerAtStandstill) - CC.longActive = CC.enabled and not any(e.overrideLongitudinal for e in self.sm['onroadEvents']) and self.CP.openpilotLongitudinalControl + CC.longActive = CC.enabled and not any(e.overrideLongitudinal for e in self.sm['onroadEvents']) and \ + (self.CP.openpilotLongitudinalControl or not self.CP_SP.pcmCruiseSpeed) actuators = CC.actuators actuators.longControlState = self.LoC.long_control_state @@ -111,7 +130,7 @@ class Controls: self.LoC.reset() # accel PID loop - pid_accel_limits = self.CI.get_pid_accel_limits(self.CP, CS.vEgo, CS.vCruise * CV.KPH_TO_MS) + pid_accel_limits = self.CI.get_pid_accel_limits(self.CP, self.CP_SP, CS.vEgo, CS.vCruise * CV.KPH_TO_MS) actuators.accel = float(self.LoC.update(CC.longActive, CS, long_plan.aTarget, long_plan.shouldStop, pid_accel_limits)) # Steering PID loop and lateral MPC @@ -126,7 +145,7 @@ class Controls: actuators.curvature = self.desired_curvature steer, steeringAngleDeg, lac_log = self.LaC.update(CC.latActive, CS, self.VM, lp, self.steer_limited_by_safety, self.desired_curvature, - curvature_limited, lat_delay) + self.calibrated_pose, curvature_limited, lat_delay) actuators.torque = float(steer) actuators.steeringAngleDeg = float(steeringAngleDeg) # Ensure no NaNs/Infs @@ -151,7 +170,7 @@ class Controls: CC.orientationNED = self.calibrated_pose.orientation.xyz.tolist() CC.angularVelocity = self.calibrated_pose.angular_velocity.xyz.tolist() - CC.cruiseControl.override = CC.enabled and not CC.longActive and self.CP.openpilotLongitudinalControl + CC.cruiseControl.override = CC.enabled and not CC.longActive and (self.CP.openpilotLongitudinalControl or not self.CP_SP.pcmCruiseSpeed) CC.cruiseControl.cancel = CS.cruiseState.enabled and (not CC.enabled or not self.CP.pcmCruise) CC.cruiseControl.resume = CC.enabled and CS.cruiseState.standstill and not self.sm['longitudinalPlan'].shouldStop @@ -169,7 +188,7 @@ class Controls: hudControl.leftLaneDepart = self.sm['driverAssistance'].leftLaneDeparture hudControl.rightLaneDepart = self.sm['driverAssistance'].rightLaneDeparture - if self.sm['selfdriveState'].active: + if self.get_lat_active(self.sm): CO = self.sm['carOutput'] if self.CP.steerControlType == car.CarParams.SteerControlType.angle: self.steer_limited_by_safety = abs(CC.actuators.steeringAngleDeg - CO.actuatorsOutput.steeringAngleDeg) > \ @@ -218,6 +237,8 @@ class Controls: self.update() CC, lac_log = self.state_control() self.publish(CC, lac_log) + self.get_params_sp(self.sm) + self.run_ext(self.sm, self.pm) rk.monitor_time() diff --git a/selfdrive/controls/lib/desire_helper.py b/selfdrive/controls/lib/desire_helper.py index ee4567f1e9..16908fa3e3 100644 --- a/selfdrive/controls/lib/desire_helper.py +++ b/selfdrive/controls/lib/desire_helper.py @@ -1,9 +1,12 @@ -from cereal import log +from cereal import log, custom from openpilot.common.constants import CV from openpilot.common.realtime import DT_MDL +from openpilot.sunnypilot.selfdrive.controls.lib.auto_lane_change import AutoLaneChangeController, AutoLaneChangeMode +from openpilot.sunnypilot.selfdrive.controls.lib.lane_turn_desire import LaneTurnController LaneChangeState = log.LaneChangeState LaneChangeDirection = log.LaneChangeDirection +TurnDirection = custom.ModelDataV2SP.TurnDirection LANE_CHANGE_SPEED_MIN = 20 * CV.MPH_TO_MS LANE_CHANGE_TIME_MAX = 10. @@ -29,6 +32,12 @@ DESIRES = { }, } +TURN_DESIRES = { + TurnDirection.none: log.Desire.none, + TurnDirection.turnLeft: log.Desire.turnLeft, + TurnDirection.turnRight: log.Desire.turnRight, +} + class DesireHelper: def __init__(self): @@ -39,17 +48,27 @@ class DesireHelper: self.keep_pulse_timer = 0.0 self.prev_one_blinker = False self.desire = log.Desire.none + self.alc = AutoLaneChangeController(self) + self.lane_turn_controller = LaneTurnController(self) + self.lane_turn_direction = TurnDirection.none @staticmethod def get_lane_change_direction(CS): return LaneChangeDirection.left if CS.leftBlinker else LaneChangeDirection.right def update(self, carstate, lateral_active, lane_change_prob): + self.alc.update_params() + self.lane_turn_controller.update_params() v_ego = carstate.vEgo one_blinker = carstate.leftBlinker != carstate.rightBlinker below_lane_change_speed = v_ego < LANE_CHANGE_SPEED_MIN - if not lateral_active or self.lane_change_timer > LANE_CHANGE_TIME_MAX: + # Lane turn controller update + self.lane_turn_controller.update_lane_turn(blindspot_left=carstate.leftBlindspot, blindspot_right=carstate.rightBlindspot, + left_blinker=carstate.leftBlinker, right_blinker=carstate.rightBlinker, v_ego=v_ego) + self.lane_turn_direction = self.lane_turn_controller.get_turn_direction() + + if not lateral_active or self.lane_change_timer > LANE_CHANGE_TIME_MAX or self.alc.lane_change_set_timer == AutoLaneChangeMode.OFF: self.lane_change_state = LaneChangeState.off self.lane_change_direction = LaneChangeDirection.none else: @@ -72,10 +91,12 @@ class DesireHelper: blindspot_detected = ((carstate.leftBlindspot and self.lane_change_direction == LaneChangeDirection.left) or (carstate.rightBlindspot and self.lane_change_direction == LaneChangeDirection.right)) + self.alc.update_lane_change(blindspot_detected, carstate.brakePressed) + if not one_blinker or below_lane_change_speed: self.lane_change_state = LaneChangeState.off self.lane_change_direction = LaneChangeDirection.none - elif torque_applied and not blindspot_detected: + elif (torque_applied or self.alc.auto_lane_change_allowed) and not blindspot_detected: self.lane_change_state = LaneChangeState.laneChangeStarting # LaneChangeState.laneChangeStarting @@ -106,7 +127,10 @@ class DesireHelper: self.prev_one_blinker = one_blinker - self.desire = DESIRES[self.lane_change_direction][self.lane_change_state] + if self.lane_turn_direction != TurnDirection.none: + self.desire = TURN_DESIRES[self.lane_turn_direction] + else: + self.desire = DESIRES[self.lane_change_direction][self.lane_change_state] # Send keep pulse once per second during LaneChangeStart.preLaneChange if self.lane_change_state in (LaneChangeState.off, LaneChangeState.laneChangeStarting): @@ -117,3 +141,5 @@ class DesireHelper: self.keep_pulse_timer = 0.0 elif self.desire in (log.Desire.keepLeft, log.Desire.keepRight): self.desire = log.Desire.none + + self.alc.update_state() diff --git a/selfdrive/controls/lib/drive_helpers.py b/selfdrive/controls/lib/drive_helpers.py index bf6dd04f60..1e2fb27b51 100644 --- a/selfdrive/controls/lib/drive_helpers.py +++ b/selfdrive/controls/lib/drive_helpers.py @@ -39,19 +39,17 @@ def clip_curvature(v_ego, prev_curvature, new_curvature, roll) -> tuple[float, b return float(new_curvature), limited_accel or limited_max_curv -def get_accel_from_plan(speeds, accels, t_idxs, action_t=DT_MDL, vEgoStopping=0.05): +def get_accel_from_plan(speeds, accels, t_idxs, action_t=DT_MDL, vEgoStopping=0.3): if len(speeds) == len(t_idxs): v_now = speeds[0] a_now = accels[0] v_target = np.interp(action_t, t_idxs, speeds) a_target = 2 * (v_target - v_now) / (action_t) - a_now - v_target_1sec = np.interp(action_t + 1.0, t_idxs, speeds) else: + v_now = 0.0 v_target = 0.0 - v_target_1sec = 0.0 a_target = 0.0 - should_stop = (v_target < vEgoStopping and - v_target_1sec < vEgoStopping) + should_stop = (v_now < vEgoStopping and a_target < 0.1) return a_target, should_stop def curv_from_psis(psi_target, psi_rate, vego, action_t): diff --git a/selfdrive/controls/lib/latcontrol.py b/selfdrive/controls/lib/latcontrol.py index d69796738f..4207a188f4 100644 --- a/selfdrive/controls/lib/latcontrol.py +++ b/selfdrive/controls/lib/latcontrol.py @@ -1,9 +1,10 @@ import numpy as np from abc import abstractmethod, ABC +from openpilot.selfdrive.locationd.helpers import Pose class LatControl(ABC): - def __init__(self, CP, CI, dt): + def __init__(self, CP, CP_SP, CI, dt): self.dt = dt self.sat_limit = CP.steerLimitTimer self.sat_time = 0. @@ -13,7 +14,8 @@ class LatControl(ABC): self.steer_max = 1.0 @abstractmethod - def update(self, active: bool, CS, VM, params, steer_limited_by_safety: bool, desired_curvature: float, curvature_limited: bool, lat_delay: float): + def update(self, active: bool, CS, VM, params, steer_limited_by_safety: bool, desired_curvature: float, calibrated_pose: Pose, + curvature_limited: bool, lat_delay: float): pass def reset(self): diff --git a/selfdrive/controls/lib/latcontrol_angle.py b/selfdrive/controls/lib/latcontrol_angle.py index a7d0403248..9aa5b3cd01 100644 --- a/selfdrive/controls/lib/latcontrol_angle.py +++ b/selfdrive/controls/lib/latcontrol_angle.py @@ -8,12 +8,12 @@ STEER_ANGLE_SATURATION_THRESHOLD = 2.5 # Degrees class LatControlAngle(LatControl): - def __init__(self, CP, CI, dt): - super().__init__(CP, CI, dt) + def __init__(self, CP, CP_SP, CI, dt): + super().__init__(CP, CP_SP, CI, dt) self.sat_check_min_speed = 5. self.use_steer_limited_by_safety = CP.brand in ("tesla", "hyundai") - def update(self, active, CS, VM, params, steer_limited_by_safety, desired_curvature, curvature_limited, lat_delay): + def update(self, active, CS, VM, params, steer_limited_by_safety, desired_curvature, calibrated_pose, curvature_limited, lat_delay): angle_log = log.ControlsState.LateralAngleState.new_message() if not active: diff --git a/selfdrive/controls/lib/latcontrol_pid.py b/selfdrive/controls/lib/latcontrol_pid.py index 14ab9f21b5..25b2c8d87e 100644 --- a/selfdrive/controls/lib/latcontrol_pid.py +++ b/selfdrive/controls/lib/latcontrol_pid.py @@ -6,15 +6,15 @@ from openpilot.common.pid import PIDController class LatControlPID(LatControl): - def __init__(self, CP, CI, dt): - super().__init__(CP, CI, dt) + def __init__(self, CP, CP_SP, CI, dt): + super().__init__(CP, CP_SP, CI, dt) self.pid = PIDController((CP.lateralTuning.pid.kpBP, CP.lateralTuning.pid.kpV), (CP.lateralTuning.pid.kiBP, CP.lateralTuning.pid.kiV), pos_limit=self.steer_max, neg_limit=-self.steer_max) self.ff_factor = CP.lateralTuning.pid.kf self.get_steer_feedforward = CI.get_steer_feedforward_function() - def update(self, active, CS, VM, params, steer_limited_by_safety, desired_curvature, curvature_limited, lat_delay): + def update(self, active, CS, VM, params, steer_limited_by_safety, desired_curvature, calibrated_pose, curvature_limited, lat_delay): pid_log = log.ControlsState.LateralPIDState.new_message() pid_log.steeringAngleDeg = float(CS.steeringAngleDeg) pid_log.steeringRateDeg = float(CS.steeringRateDeg) diff --git a/selfdrive/controls/lib/latcontrol_torque.py b/selfdrive/controls/lib/latcontrol_torque.py index 903700d4b3..d0eb40d096 100644 --- a/selfdrive/controls/lib/latcontrol_torque.py +++ b/selfdrive/controls/lib/latcontrol_torque.py @@ -9,6 +9,8 @@ from openpilot.common.filter_simple import FirstOrderFilter from openpilot.selfdrive.controls.lib.latcontrol import LatControl from openpilot.common.pid import PIDController +from openpilot.sunnypilot.selfdrive.controls.lib.latcontrol_torque_ext import LatControlTorqueExt + # At higher speeds (25+mph) we can assume: # Lateral acceleration achieved by a specific car correlates to # torque applied to the steering rack. It does not correlate to @@ -33,8 +35,8 @@ LAT_ACCEL_REQUEST_BUFFER_SECONDS = 1.0 VERSION = 1 class LatControlTorque(LatControl): - def __init__(self, CP, CI, dt): - super().__init__(CP, CI, dt) + def __init__(self, CP, CP_SP, CI, dt): + super().__init__(CP, CP_SP, CI, dt) self.torque_params = CP.lateralTuning.torque.as_builder() self.torque_from_lateral_accel = CI.torque_from_lateral_accel() self.lateral_accel_from_torque = CI.lateral_accel_from_torque() @@ -46,6 +48,8 @@ class LatControlTorque(LatControl): self.lookahead_frames = int(JERK_LOOKAHEAD_SECONDS / self.dt) self.jerk_filter = FirstOrderFilter(0.0, 1 / (2 * np.pi * LP_FILTER_CUTOFF_HZ), self.dt) + self.extension = LatControlTorqueExt(self, CP, CP_SP, CI) + def update_live_torque_params(self, latAccelFactor, latAccelOffset, friction): self.torque_params.latAccelFactor = latAccelFactor self.torque_params.latAccelOffset = latAccelOffset @@ -56,7 +60,11 @@ class LatControlTorque(LatControl): self.pid.set_limits(self.lateral_accel_from_torque(self.steer_max, self.torque_params), self.lateral_accel_from_torque(-self.steer_max, self.torque_params)) - def update(self, active, CS, VM, params, steer_limited_by_safety, desired_curvature, curvature_limited, lat_delay): + def update(self, active, CS, VM, params, steer_limited_by_safety, desired_curvature, calibrated_pose, curvature_limited, lat_delay): + # Override torque params from extension + if self.extension.update_override_torque_params(self.torque_params): + self.update_limits() + pid_log = log.ControlsState.LateralTorqueState.new_message() pid_log.version = VERSION measured_curvature = -VM.calc_curvature(math.radians(CS.steeringAngleDeg - params.angleOffsetDeg), CS.vEgo, params.roll) @@ -93,6 +101,12 @@ class LatControlTorque(LatControl): output_lataccel = self.pid.update(pid_log.error, speed=CS.vEgo, feedforward=ff, freeze_integrator=freeze_integrator) output_torque = self.torque_from_lateral_accel(output_lataccel, self.torque_params) + # Lateral acceleration torque controller extension updates + # Overrides pid_log.error and output_torque + pid_log, output_torque = self.extension.update(CS, VM, self.pid, params, ff, pid_log, setpoint, measurement, calibrated_pose, roll_compensation, + future_desired_lateral_accel, measurement, lateral_accel_deadzone, gravity_adjusted_future_lateral_accel, + desired_curvature, measured_curvature, steer_limited_by_safety, output_torque) + pid_log.active = True pid_log.p = float(self.pid.p) pid_log.i = float(self.pid.i) diff --git a/selfdrive/controls/lib/longcontrol.py b/selfdrive/controls/lib/longcontrol.py index 62dbc842c5..ec714f452e 100644 --- a/selfdrive/controls/lib/longcontrol.py +++ b/selfdrive/controls/lib/longcontrol.py @@ -10,8 +10,11 @@ CONTROL_N_T_IDX = ModelConstants.T_IDXS[:CONTROL_N] LongCtrlState = car.CarControl.Actuators.LongControlState -def long_control_state_trans(CP, active, long_control_state, v_ego, +def long_control_state_trans(CP, CP_SP, active, long_control_state, v_ego, should_stop, brake_pressed, cruise_standstill): + # Gas Interceptor + cruise_standstill = cruise_standstill and not CP_SP.enableGasInterceptor + stopping_condition = should_stop starting_condition = (not should_stop and not cruise_standstill and @@ -45,8 +48,9 @@ def long_control_state_trans(CP, active, long_control_state, v_ego, return long_control_state class LongControl: - def __init__(self, CP): + def __init__(self, CP, CP_SP): self.CP = CP + self.CP_SP = CP_SP self.long_control_state = LongCtrlState.off self.pid = PIDController((CP.longitudinalTuning.kpBP, CP.longitudinalTuning.kpV), (CP.longitudinalTuning.kiBP, CP.longitudinalTuning.kiV), @@ -61,7 +65,7 @@ class LongControl: self.pid.neg_limit = accel_limits[0] self.pid.pos_limit = accel_limits[1] - self.long_control_state = long_control_state_trans(self.CP, active, self.long_control_state, CS.vEgo, + self.long_control_state = long_control_state_trans(self.CP, self.CP_SP, active, self.long_control_state, CS.vEgo, should_stop, CS.brakePressed, CS.cruiseState.standstill) if self.long_control_state == LongCtrlState.off: diff --git a/selfdrive/controls/lib/longitudinal_planner.py b/selfdrive/controls/lib/longitudinal_planner.py index 64de1a8fda..e02b02d2e0 100755 --- a/selfdrive/controls/lib/longitudinal_planner.py +++ b/selfdrive/controls/lib/longitudinal_planner.py @@ -15,6 +15,8 @@ from openpilot.selfdrive.controls.lib.drive_helpers import CONTROL_N, get_accel_ from openpilot.selfdrive.car.cruise import V_CRUISE_MAX, V_CRUISE_UNSET from openpilot.common.swaglog import cloudlog +from openpilot.sunnypilot.selfdrive.controls.lib.longitudinal_planner import LongitudinalPlannerSP + A_CRUISE_MAX_VALS = [1.6, 1.2, 0.8, 0.6] A_CRUISE_MAX_BP = [0., 10.0, 25., 40.] CONTROL_N_T_IDX = ModelConstants.T_IDXS[:CONTROL_N] @@ -45,10 +47,11 @@ def limit_accel_in_turns(v_ego, angle_steers, a_target, CP): return [a_target[0], min(a_target[1], a_x_allowed)] -class LongitudinalPlanner: - def __init__(self, CP, init_v=0.0, init_a=0.0, dt=DT_MDL): +class LongitudinalPlanner(LongitudinalPlannerSP): + def __init__(self, CP, CP_SP, init_v=0.0, init_a=0.0, dt=DT_MDL): self.CP = CP self.mpc = LongitudinalMpc(dt=dt) + LongitudinalPlannerSP.__init__(self, self.CP, CP_SP, self.mpc) self.fcw = False self.dt = dt self.allow_throttle = True @@ -84,6 +87,8 @@ class LongitudinalPlanner: return x, v, a, j, throttle_prob def update(self, sm): + LongitudinalPlannerSP.update(self, sm) + if len(sm['carControl'].orientationNED) == 3: accel_coast = get_coast_accel(sm['carControl'].orientationNED[1]) else: @@ -125,6 +130,9 @@ class LongitudinalPlanner: clipped_accel_coast_interp = np.interp(v_ego, [MIN_ALLOW_THROTTLE_SPEED, MIN_ALLOW_THROTTLE_SPEED*2], [accel_clip[1], clipped_accel_coast]) accel_clip[1] = min(accel_clip[1], clipped_accel_coast_interp) + # Get new v_cruise and a_desired from Smart Cruise Control and Speed Limit Assist + v_cruise, self.a_desired = LongitudinalPlannerSP.update_targets(self, sm, self.v_desired_filter.x, self.a_desired, v_cruise) + if force_slow_decel: v_cruise = 0.0 @@ -152,7 +160,7 @@ class LongitudinalPlanner: output_a_target_e2e = sm['modelV2'].action.desiredAcceleration output_should_stop_e2e = sm['modelV2'].action.shouldStop - if sm['selfdriveState'].experimentalMode: + if self.is_e2e(sm): output_a_target = min(output_a_target_e2e, output_a_target_mpc) self.output_should_stop = output_should_stop_e2e or output_should_stop_mpc if output_a_target < output_a_target_mpc: @@ -190,3 +198,5 @@ class LongitudinalPlanner: longitudinalPlan.allowThrottle = bool(self.allow_throttle) pm.send('longitudinalPlan', plan_send) + + self.publish_longitudinal_plan_sp(sm, pm) diff --git a/selfdrive/controls/plannerd.py b/selfdrive/controls/plannerd.py index bec7eede0b..f7d3370f90 100755 --- a/selfdrive/controls/plannerd.py +++ b/selfdrive/controls/plannerd.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 -from cereal import car +from cereal import car, custom +from openpilot.common.gps import get_gps_location_service from openpilot.common.params import Params from openpilot.common.realtime import Priority, config_realtime_process from openpilot.common.swaglog import cloudlog @@ -16,14 +17,22 @@ def main(): CP = messaging.log_from_bytes(params.get("CarParams", block=True), car.CarParams) cloudlog.info("plannerd got CarParams: %s", CP.brand) + cloudlog.info("plannerd is waiting for CarParamsSP") + CP_SP = messaging.log_from_bytes(params.get("CarParamsSP", block=True), custom.CarParamsSP) + cloudlog.info("plannerd got CarParamsSP") + + gps_location_service = get_gps_location_service(params) + ldw = LaneDepartureWarning() - longitudinal_planner = LongitudinalPlanner(CP) - pm = messaging.PubMaster(['longitudinalPlan', 'driverAssistance']) - sm = messaging.SubMaster(['carControl', 'carState', 'controlsState', 'liveParameters', 'radarState', 'modelV2', 'selfdriveState'], - poll='modelV2') + longitudinal_planner = LongitudinalPlanner(CP, CP_SP) + pm = messaging.PubMaster(['longitudinalPlan', 'driverAssistance', 'longitudinalPlanSP']) + sm = messaging.SubMaster(['carControl', 'carState', 'controlsState', 'liveParameters', 'radarState', 'modelV2', 'selfdriveState', + 'liveMapDataSP', 'carStateSP', gps_location_service], + poll='carState') while True: sm.update() + longitudinal_planner.sla.update_car_state(sm['carState']) if sm.updated['modelV2']: longitudinal_planner.update(sm) longitudinal_planner.publish(sm, pm) diff --git a/selfdrive/controls/radard.py b/selfdrive/controls/radard.py index 98fce1cb26..bee4244054 100755 --- a/selfdrive/controls/radard.py +++ b/selfdrive/controls/radard.py @@ -5,13 +5,17 @@ from collections import deque from typing import Any import capnp -from cereal import messaging, log, car +from cereal import messaging, log, car, custom from openpilot.common.filter_simple import FirstOrderFilter from openpilot.common.params import Params from openpilot.common.realtime import DT_MDL, Priority, config_realtime_process from openpilot.common.swaglog import cloudlog from openpilot.common.simple_kalman import KF1D +from opendbc.car import structs +from opendbc.car.hyundai.values import HyundaiFlags +from opendbc.sunnypilot.car.hyundai.values import HyundaiFlagsSP + # Default lead acceleration decay set to 50% at 1s _LEAD_ACCEL_TAU = 1.5 @@ -157,7 +161,7 @@ def get_RadarState_from_vision(lead_msg: capnp._DynamicStructReader, v_ego: floa def get_lead(v_ego: float, ready: bool, tracks: dict[int, Track], lead_msg: capnp._DynamicStructReader, - model_v_ego: float, low_speed_override: bool = True) -> dict[str, Any]: + model_v_ego: float, CP: structs.CarParams, CP_SP: structs.CarParamsSP, low_speed_override: bool = True) -> dict[str, Any]: # Determine leads, this is where the essential logic happens if len(tracks) > 0 and ready and lead_msg.prob > .5: track = match_vision_to_track(v_ego, lead_msg, tracks) @@ -167,6 +171,7 @@ def get_lead(v_ego: float, ready: bool, tracks: dict[int, Track], lead_msg: capn lead_dict = {'status': False} if track is not None: lead_dict = track.get_RadarState(lead_msg.prob) + lead_dict = get_custom_yrel(CP, CP_SP, lead_dict, lead_msg) elif (track is None) and ready and (lead_msg.prob > .5): lead_dict = get_RadarState_from_vision(lead_msg, v_ego, model_v_ego) @@ -182,8 +187,20 @@ def get_lead(v_ego: float, ready: bool, tracks: dict[int, Track], lead_msg: capn return lead_dict +def get_custom_yrel(CP: structs.CarParams, CP_SP: structs.CarParamsSP, lead_dict: dict[str, Any], + lead_msg: capnp._DynamicStructReader) -> dict[str, Any]: + if CP.brand == "hyundai" and (CP_SP.flags & HyundaiFlagsSP.ENHANCED_SCC or + CP.flags & (HyundaiFlags.CANFD_CAMERA_SCC | HyundaiFlags.CAMERA_SCC)): + lead_dict['yRel'] = float(-lead_msg.y[0]) + + return lead_dict + + class RadarD: - def __init__(self, delay: float = 0.0): + def __init__(self, CP: structs.CarParams, CP_SP: structs.CarParams, delay: float = 0.0): + self.CP = CP + self.CP_SP = CP_SP + self.current_time = 0.0 self.tracks: dict[int, Track] = {} @@ -239,8 +256,8 @@ class RadarD: model_v_ego = self.v_ego leads_v3 = sm['modelV2'].leadsV3 if len(leads_v3) > 1: - self.radar_state.leadOne = get_lead(self.v_ego, self.ready, self.tracks, leads_v3[0], model_v_ego, low_speed_override=True) - self.radar_state.leadTwo = get_lead(self.v_ego, self.ready, self.tracks, leads_v3[1], model_v_ego, low_speed_override=False) + self.radar_state.leadOne = get_lead(self.v_ego, self.ready, self.tracks, leads_v3[0], model_v_ego, self.CP, self.CP_SP, low_speed_override=True) + self.radar_state.leadTwo = get_lead(self.v_ego, self.ready, self.tracks, leads_v3[1], model_v_ego, self.CP, self.CP_SP, low_speed_override=False) def publish(self, pm: messaging.PubMaster): assert self.radar_state is not None @@ -260,11 +277,15 @@ def main() -> None: CP = messaging.log_from_bytes(Params().get("CarParams", block=True), car.CarParams) cloudlog.info("radard got CarParams") + cloudlog.info("radard is waiting for CarParamsSP") + CP_SP = messaging.log_from_bytes(Params().get("CarParamsSP", block=True), custom.CarParamsSP) + cloudlog.info("radard got CarParamsSP") + # *** setup messaging sm = messaging.SubMaster(['modelV2', 'carState', 'liveTracks'], poll='modelV2') pm = messaging.PubMaster(['radarState']) - RD = RadarD(CP.radarDelay) + RD = RadarD(CP, CP_SP, CP.radarDelay) while 1: sm.update() diff --git a/selfdrive/controls/tests/test_latcontrol.py b/selfdrive/controls/tests/test_latcontrol.py index 5c3381edce..d36ed52192 100644 --- a/selfdrive/controls/tests/test_latcontrol.py +++ b/selfdrive/controls/tests/test_latcontrol.py @@ -8,9 +8,13 @@ from opendbc.car.nissan.values import CAR as NISSAN from opendbc.car.gm.values import CAR as GM from opendbc.car.vehicle_model import VehicleModel from openpilot.common.realtime import DT_CTRL +from openpilot.selfdrive.car.helpers import convert_to_capnp from openpilot.selfdrive.controls.lib.latcontrol_pid import LatControlPID from openpilot.selfdrive.controls.lib.latcontrol_torque import LatControlTorque from openpilot.selfdrive.controls.lib.latcontrol_angle import LatControlAngle +from openpilot.selfdrive.locationd.helpers import Pose +from openpilot.common.mock.generators import generate_livePose +from openpilot.sunnypilot.selfdrive.car import interfaces as sunnypilot_interfaces class TestLatControl: @@ -20,10 +24,13 @@ class TestLatControl: def test_saturation(self, car_name, controller): CarInterface = interfaces[car_name] CP = CarInterface.get_non_essential_params(car_name) - CI = CarInterface(CP) + CP_SP = CarInterface.get_non_essential_params_sp(CP, car_name) + CI = CarInterface(CP, CP_SP) + sunnypilot_interfaces.setup_interfaces(CI) + CP_SP = convert_to_capnp(CP_SP) VM = VehicleModel(CP) - controller = controller(CP.as_reader(), CI, DT_CTRL) + controller = controller(CP.as_reader(), CP_SP.as_reader(), CI, DT_CTRL) CS = car.CarState.new_message() CS.vEgo = 30 @@ -31,15 +38,18 @@ class TestLatControl: params = log.LiveParametersData.new_message() + lp = generate_livePose() + pose = Pose.from_live_pose(lp.livePose) + # Saturate for curvature limited and controller limited for _ in range(1000): - _, _, lac_log = controller.update(True, CS, VM, params, False, 0, True, 0.2) + _, _, lac_log = controller.update(True, CS, VM, params, False, 0, pose, True, 0.2) assert lac_log.saturated for _ in range(1000): - _, _, lac_log = controller.update(True, CS, VM, params, False, 0, False, 0.2) + _, _, lac_log = controller.update(True, CS, VM, params, False, 0, pose, False, 0.2) assert not lac_log.saturated for _ in range(1000): - _, _, lac_log = controller.update(True, CS, VM, params, False, 1, False, 0.2) + _, _, lac_log = controller.update(True, CS, VM, params, False, 1, pose, False, 0.2) assert lac_log.saturated diff --git a/selfdrive/controls/tests/test_latcontrol_torque_buffer.py b/selfdrive/controls/tests/test_latcontrol_torque_buffer.py index ab1d2c7b36..b13576a6cf 100644 --- a/selfdrive/controls/tests/test_latcontrol_torque_buffer.py +++ b/selfdrive/controls/tests/test_latcontrol_torque_buffer.py @@ -7,12 +7,20 @@ from opendbc.car.vehicle_model import VehicleModel from openpilot.common.realtime import DT_CTRL from openpilot.selfdrive.controls.lib.latcontrol_torque import LatControlTorque, LAT_ACCEL_REQUEST_BUFFER_SECONDS +from openpilot.selfdrive.car.helpers import convert_to_capnp +from openpilot.selfdrive.locationd.helpers import Pose +from openpilot.common.mock.generators import generate_livePose +from openpilot.sunnypilot.selfdrive.car import interfaces as sunnypilot_interfaces + def get_controller(car_name): CarInterface = interfaces[car_name] CP = CarInterface.get_non_essential_params(car_name) - CI = CarInterface(CP) + CP_SP = CarInterface.get_non_essential_params_sp(CP, car_name) + CI = CarInterface(CP, CP_SP) + sunnypilot_interfaces.setup_interfaces(CI) + CP_SP = convert_to_capnp(CP_SP) VM = VehicleModel(CP) - controller = LatControlTorque(CP.as_reader(), CI, DT_CTRL) + controller = LatControlTorque(CP.as_reader(), CP_SP.as_reader(), CI, DT_CTRL) return controller, VM class TestLatControlTorqueBuffer: @@ -27,10 +35,13 @@ class TestLatControlTorqueBuffer: CS.steeringPressed = False params = log.LiveParametersData.new_message() + lp = generate_livePose() + pose = Pose.from_live_pose(lp.livePose) + for _ in range(buffer_steps): - controller.update(True, CS, VM, params, False, 0.001, False, 0.2) + controller.update(True, CS, VM, params, False, 0.001, pose, False, 0.2) assert all(val != 0 for val in controller.lat_accel_request_buffer) for _ in range(buffer_steps): - controller.update(False, CS, VM, params, False, 0.0, False, 0.2) + controller.update(False, CS, VM, params, False, 0.0, pose, False, 0.2) assert all(val == 0 for val in controller.lat_accel_request_buffer) diff --git a/selfdrive/controls/tests/test_longcontrol.py b/selfdrive/controls/tests/test_longcontrol.py index ab50810d89..cf0ab24e0b 100644 --- a/selfdrive/controls/tests/test_longcontrol.py +++ b/selfdrive/controls/tests/test_longcontrol.py @@ -1,4 +1,4 @@ -from cereal import car +from cereal import car, custom from openpilot.selfdrive.controls.lib.longcontrol import LongCtrlState, long_control_state_trans @@ -8,49 +8,52 @@ class TestLongControlStateTransition: def test_stay_stopped(self): CP = car.CarParams.new_message() + CP_SP = custom.CarParamsSP.new_message() active = True current_state = LongCtrlState.stopping - next_state = long_control_state_trans(CP, active, current_state, v_ego=0.1, + next_state = long_control_state_trans(CP, CP_SP, active, current_state, v_ego=0.1, should_stop=True, brake_pressed=False, cruise_standstill=False) assert next_state == LongCtrlState.stopping - next_state = long_control_state_trans(CP, active, current_state, v_ego=0.1, + next_state = long_control_state_trans(CP, CP_SP, active, current_state, v_ego=0.1, should_stop=False, brake_pressed=True, cruise_standstill=False) assert next_state == LongCtrlState.stopping - next_state = long_control_state_trans(CP, active, current_state, v_ego=0.1, + next_state = long_control_state_trans(CP, CP_SP, active, current_state, v_ego=0.1, should_stop=False, brake_pressed=False, cruise_standstill=True) assert next_state == LongCtrlState.stopping - next_state = long_control_state_trans(CP, active, current_state, v_ego=1.0, + next_state = long_control_state_trans(CP, CP_SP, active, current_state, v_ego=1.0, should_stop=False, brake_pressed=False, cruise_standstill=False) assert next_state == LongCtrlState.pid active = False - next_state = long_control_state_trans(CP, active, current_state, v_ego=1.0, + next_state = long_control_state_trans(CP, CP_SP, active, current_state, v_ego=1.0, should_stop=False, brake_pressed=False, cruise_standstill=False) assert next_state == LongCtrlState.off def test_engage(): CP = car.CarParams.new_message() + CP_SP = custom.CarParamsSP.new_message() active = True current_state = LongCtrlState.off - next_state = long_control_state_trans(CP, active, current_state, v_ego=0.1, + next_state = long_control_state_trans(CP, CP_SP, active, current_state, v_ego=0.1, should_stop=True, brake_pressed=False, cruise_standstill=False) assert next_state == LongCtrlState.stopping - next_state = long_control_state_trans(CP, active, current_state, v_ego=0.1, + next_state = long_control_state_trans(CP, CP_SP, active, current_state, v_ego=0.1, should_stop=False, brake_pressed=True, cruise_standstill=False) assert next_state == LongCtrlState.stopping - next_state = long_control_state_trans(CP, active, current_state, v_ego=0.1, + next_state = long_control_state_trans(CP, CP_SP, active, current_state, v_ego=0.1, should_stop=False, brake_pressed=False, cruise_standstill=True) assert next_state == LongCtrlState.stopping - next_state = long_control_state_trans(CP, active, current_state, v_ego=0.1, + next_state = long_control_state_trans(CP, CP_SP, active, current_state, v_ego=0.1, should_stop=False, brake_pressed=False, cruise_standstill=False) assert next_state == LongCtrlState.pid def test_starting(): CP = car.CarParams.new_message(startingState=True, vEgoStarting=0.5) + CP_SP = custom.CarParamsSP.new_message() active = True current_state = LongCtrlState.starting - next_state = long_control_state_trans(CP, active, current_state, v_ego=0.1, + next_state = long_control_state_trans(CP, CP_SP, active, current_state, v_ego=0.1, should_stop=False, brake_pressed=False, cruise_standstill=False) assert next_state == LongCtrlState.starting - next_state = long_control_state_trans(CP, active, current_state, v_ego=1.0, + next_state = long_control_state_trans(CP, CP_SP, active, current_state, v_ego=1.0, should_stop=False, brake_pressed=False, cruise_standstill=False) assert next_state == LongCtrlState.pid diff --git a/selfdrive/debug/car/hyundai_enable_radar_points.py b/selfdrive/debug/car/hyundai_enable_radar_points.py index df150a5224..1cab7b0270 100755 --- a/selfdrive/debug/car/hyundai_enable_radar_points.py +++ b/selfdrive/debug/car/hyundai_enable_radar_points.py @@ -71,6 +71,13 @@ SUPPORTED_FW_VERSIONS = { b"DLhe SCC FHCUP 1.00 1.02 99110-L7000 \x01 \x102 ": ConfigValues( default_config=b"\x00\x00\x00\x01\x00\x00", tracks_enabled=b"\x00\x00\x00\x01\x00\x01"), + # 2022 Niro EV + b"DEev SCC F-CUP 1.00 1.00 99110-Q4600\x01\x42 ": ConfigValues( + default_config=b"\x00\x00\x00\x01\x00\x00", + tracks_enabled=b"\x00\x00\x00\x01\x00\x01"), + b"DEev SCC F-CUP 1.00 1.00 99110-Q4600 \x07\x03\t% ": ConfigValues( + default_config=b"\x00\x00\x00\x01\x00\x00", + tracks_enabled=b"\x00\x00\x00\x01\x00\x01"), } if __name__ == "__main__": diff --git a/selfdrive/debug/cycle_alerts.py b/selfdrive/debug/cycle_alerts.py index 00fa33ac63..4b1def4fc6 100755 --- a/selfdrive/debug/cycle_alerts.py +++ b/selfdrive/debug/cycle_alerts.py @@ -30,9 +30,9 @@ def cycle_alerts(duration=200, is_metric=False): (EventName.accFaulted, ET.IMMEDIATE_DISABLE), # DM sequence - (EventName.preDriverDistracted, ET.WARNING), - (EventName.promptDriverDistracted, ET.WARNING), - (EventName.driverDistracted, ET.WARNING), + (EventName.driverDistracted1, ET.WARNING), + (EventName.driverDistracted2, ET.WARNING), + (EventName.driverDistracted3, ET.WARNING), ] # debug alerts diff --git a/selfdrive/debug/uiview.py b/selfdrive/debug/uiview.py index 8e75769a85..eac1f8fbf4 100755 --- a/selfdrive/debug/uiview.py +++ b/selfdrive/debug/uiview.py @@ -3,14 +3,23 @@ import time from cereal import car, log, messaging from openpilot.common.params import Params -from openpilot.system.manager.process_config import managed_processes +from openpilot.system.manager.process_config import managed_processes, is_tinygrad_model, is_stock_model from openpilot.system.hardware import HARDWARE if __name__ == "__main__": CP = car.CarParams(notCar=True, wheelbase=1, steerRatio=10) - Params().put("CarParams", CP.to_bytes()) + params = Params() + params.put("CarParams", CP.to_bytes()) - procs = ['camerad', 'ui', 'modeld', 'calibrationd', 'plannerd', 'dmonitoringmodeld', 'dmonitoringd'] + if use_tinygrad_modeld := is_tinygrad_model(False, params, CP): + print("Using TinyGrad modeld") + if use_stock_modeld := is_stock_model(False, params, CP): + print("Using stock modeld") + + HARDWARE.set_power_save(False) + + procs = ['camerad', 'ui', 'calibrationd', 'plannerd', 'dmonitoringmodeld', 'dmonitoringd'] + procs += ["modeld_tinygrad" if use_tinygrad_modeld else "modeld"] for p in procs: managed_processes[p].start() diff --git a/selfdrive/locationd/lagd.py b/selfdrive/locationd/lagd.py index 6232404c30..d037af613a 100755 --- a/selfdrive/locationd/lagd.py +++ b/selfdrive/locationd/lagd.py @@ -12,6 +12,7 @@ from openpilot.common.params import Params from openpilot.common.realtime import config_realtime_process from openpilot.common.swaglog import cloudlog from openpilot.selfdrive.locationd.helpers import PoseCalibrator, Pose, fft_next_good_size, parabolic_peak_interp +from openpilot.sunnypilot.livedelay.lagd_toggle import LagdToggle BLOCK_SIZE = 100 BLOCK_NUM = 50 @@ -394,6 +395,8 @@ def main(): lag, valid_blocks = initial_lag_params lag_learner.reset(lag, valid_blocks) + lagd_toggle = LagdToggle(CP) + while True: sm.update() if sm.all_checks(): @@ -412,3 +415,6 @@ def main(): if sm.frame % 1200 == 0: # cache every 60 seconds params.put_nonblocking("LiveDelay", lag_msg_dat) + + if sm.frame % 60 == 0: # read from and write to params every 3 seconds + lagd_toggle.update(lag_msg) diff --git a/selfdrive/locationd/torqued.py b/selfdrive/locationd/torqued.py index 9a2b6c17b1..28731d2ad7 100755 --- a/selfdrive/locationd/torqued.py +++ b/selfdrive/locationd/torqued.py @@ -11,6 +11,8 @@ from openpilot.common.realtime import config_realtime_process, DT_MDL from openpilot.common.filter_simple import FirstOrderFilter from openpilot.common.swaglog import cloudlog from openpilot.selfdrive.locationd.helpers import PointBuckets, ParameterEstimator, PoseCalibrator, Pose +from openpilot.sunnypilot.livedelay.helpers import get_lat_delay +from openpilot.sunnypilot.selfdrive.locationd.torqued_ext import TorqueEstimatorExt HISTORY = 5 # secs POINTS_PER_BUCKET = 1500 @@ -50,8 +52,11 @@ class TorqueBuckets(PointBuckets): break -class TorqueEstimator(ParameterEstimator): +class TorqueEstimator(ParameterEstimator, TorqueEstimatorExt): def __init__(self, CP, decimated=False, track_all_points=False): + ParameterEstimator.__init__(self) + TorqueEstimatorExt.__init__(self, CP) + self.CP = CP self.hist_len = int(HISTORY / DT_MDL) self.lag = 0.0 self.track_all_points = track_all_points # for offline analysis, without max lateral accel or max steer torque filters @@ -80,6 +85,8 @@ class TorqueEstimator(ParameterEstimator): self.calibrator = PoseCalibrator() + TorqueEstimatorExt.initialize_custom_params(self, decimated) + self.reset() initial_params = { @@ -96,6 +103,7 @@ class TorqueEstimator(ParameterEstimator): # try to restore cached params params = Params() + self.params = params params_cache = params.get("CarParamsPrevRoute") torque_cache = params.get("LiveTorqueParameters") if params_cache is not None and torque_cache is not None: @@ -177,7 +185,7 @@ class TorqueEstimator(ParameterEstimator): elif which == "liveCalibration": self.calibrator.feed_live_calib(msg) elif which == "liveDelay": - self.lag = msg.lateralDelay + self.lag = get_lat_delay(self.params, msg.lateralDelay) # calculate lateral accel from past steering torque elif which == "livePose": is_valid = msg.angularVelocityDevice.valid and msg.orientationNED.valid and msg.inputsOK and msg.sensorsOK and msg.posenetOK @@ -261,6 +269,8 @@ def main(demo=False): t = sm.logMonoTime[which] * 1e-9 estimator.handle_log(t, which, sm[which]) + TorqueEstimatorExt.update_use_params(estimator) + # 4Hz driven by livePose if sm.frame % 5 == 0: pm.send('liveTorqueParameters', estimator.get_msg(valid=sm.all_checks(), with_points=DEBUG)) diff --git a/selfdrive/modeld/SConscript b/selfdrive/modeld/SConscript index bad1cdd500..7a82ff88b8 100644 --- a/selfdrive/modeld/SConscript +++ b/selfdrive/modeld/SConscript @@ -18,7 +18,7 @@ def estimate_pickle_max_size(onnx_size): tg_flags = { 'larch64': 'DEV=QCOM FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0', 'Darwin': f'DEV=CPU THREADS=0 HOME={os.path.expanduser("~")}', # tinygrad calls brew which needs a $HOME in the env -}.get(arch, 'DEV=CPU:LLVM THREADS=0') +}.get(arch, 'DEV=CPU CPU_LLVM=1 THREADS=0') # Get model metadata for model_name in ['driving_vision', 'driving_off_policy', 'driving_on_policy', 'dmonitoring_model']: diff --git a/selfdrive/modeld/compile_warp.py b/selfdrive/modeld/compile_warp.py index 47511f2a2b..75cc65f84c 100755 --- a/selfdrive/modeld/compile_warp.py +++ b/selfdrive/modeld/compile_warp.py @@ -94,11 +94,11 @@ def make_frame_prepare(cam_w, cam_h, model_w, model_h): def make_update_img_input(frame_prepare, model_w, model_h): - def update_img_input_tinygrad(frame_buffer, frame, M_inv): + def update_img_input_tinygrad(tensor, frame, M_inv): M_inv = M_inv.to(Device.DEFAULT) new_img = frame_prepare(frame, M_inv) - frame_buffer.assign(frame_buffer[6:].cat(new_img, dim=0).contiguous()) - return Tensor.cat(frame_buffer[:6], frame_buffer[-6:], dim=0).contiguous().reshape(1, 12, model_h//2, model_w//2) + full_buffer = tensor[6:].cat(new_img, dim=0).contiguous() + return full_buffer, Tensor.cat(full_buffer[:6], full_buffer[-6:], dim=0).contiguous().reshape(1, 12, model_h//2, model_w//2) return update_img_input_tinygrad @@ -107,9 +107,9 @@ def make_update_both_imgs(frame_prepare, model_w, model_h): def update_both_imgs_tinygrad(calib_img_buffer, new_img, M_inv, calib_big_img_buffer, new_big_img, M_inv_big): - calib_img_pair = update_img(calib_img_buffer, new_img, M_inv) - calib_big_img_pair = update_img(calib_big_img_buffer, new_big_img, M_inv_big) - return calib_img_pair, calib_big_img_pair + calib_img_buffer, calib_img_pair = update_img(calib_img_buffer, new_img, M_inv) + calib_big_img_buffer, calib_big_img_pair = update_img(calib_big_img_buffer, new_big_img, M_inv_big) + return calib_img_buffer, calib_img_pair, calib_big_img_buffer, calib_big_img_pair return update_both_imgs_tinygrad @@ -136,18 +136,29 @@ def compile_modeld_warp(cam_w, cam_h): full_buffer = Tensor.zeros(IMG_BUFFER_SHAPE, dtype='uint8').contiguous().realize() big_full_buffer = Tensor.zeros(IMG_BUFFER_SHAPE, dtype='uint8').contiguous().realize() + full_buffer_np = np.zeros(IMG_BUFFER_SHAPE, dtype=np.uint8) + big_full_buffer_np = np.zeros(IMG_BUFFER_SHAPE, dtype=np.uint8) + for i in range(10): + new_frame_np = (32 * np.random.randn(yuv_size).astype(np.float32) + 128).clip(0, 255).astype(np.uint8) img_inputs = [full_buffer, - Tensor(np.random.randint(0, 256, yuv_size, dtype=np.uint8)).realize(), + Tensor.from_blob(new_frame_np.ctypes.data, (yuv_size,), dtype='uint8').realize(), Tensor(Tensor.randn(3, 3).mul(8).realize().numpy(), device='NPY')] + new_big_frame_np = (32 * np.random.randn(yuv_size).astype(np.float32) + 128).clip(0, 255).astype(np.uint8) big_img_inputs = [big_full_buffer, - Tensor(np.random.randint(0, 256, yuv_size, dtype=np.uint8)).realize(), + Tensor.from_blob(new_big_frame_np.ctypes.data, (yuv_size,), dtype='uint8').realize(), Tensor(Tensor.randn(3, 3).mul(8).realize().numpy(), device='NPY')] inputs = img_inputs + big_img_inputs Device.default.synchronize() + inputs_np = [x.numpy() for x in inputs] + inputs_np[0] = full_buffer_np + inputs_np[3] = big_full_buffer_np + st = time.perf_counter() - _ = update_img_jit(*inputs) + out = update_img_jit(*inputs) + full_buffer = out[0].contiguous().realize().clone() + big_full_buffer = out[2].contiguous().realize().clone() mt = time.perf_counter() Device.default.synchronize() et = time.perf_counter() @@ -172,7 +183,7 @@ def compile_dm_warp(cam_w, cam_h): warp_dm_jit = TinyJit(warp_dm, prune=True) for i in range(10): - inputs = [Tensor(np.random.randint(0, 256, yuv_size, dtype=np.uint8)).realize(), + inputs = [Tensor.from_blob((32 * Tensor.randn(yuv_size,) + 128).cast(dtype='uint8').realize().numpy().ctypes.data, (yuv_size,), dtype='uint8'), Tensor(Tensor.randn(3, 3).mul(8).realize().numpy(), device='NPY')] Device.default.synchronize() st = time.perf_counter() diff --git a/selfdrive/modeld/fill_model_msg.py b/selfdrive/modeld/fill_model_msg.py index 82c4c92b1d..7273745c7b 100644 --- a/selfdrive/modeld/fill_model_msg.py +++ b/selfdrive/modeld/fill_model_msg.py @@ -3,6 +3,7 @@ import capnp import numpy as np from cereal import log from openpilot.selfdrive.modeld.constants import ModelConstants, Plan, Meta +from openpilot.sunnypilot.models.helpers import plan_x_idxs_helper SEND_RAW_PRED = os.getenv('SEND_RAW_PRED') @@ -95,8 +96,8 @@ def fill_model_msg(base_msg: capnp._DynamicStructBuilder, extended_msg: capnp._D # action modelV2.action = action - # times at X_IDXS of edges and lines aren't used - LINE_T_IDXS: list[float] = [] + # times at X_IDXS of edges and lines + LINE_T_IDXS: list[float] = plan_x_idxs_helper(ModelConstants, Plan, net_output_data) # lane lines modelV2.init('laneLines', 4) diff --git a/selfdrive/modeld/modeld.py b/selfdrive/modeld/modeld.py index 82e750cf8b..df77c9c0e7 100755 --- a/selfdrive/modeld/modeld.py +++ b/selfdrive/modeld/modeld.py @@ -30,6 +30,9 @@ from openpilot.selfdrive.modeld.fill_model_msg import fill_model_msg, fill_pose_ from openpilot.common.file_chunker import read_file_chunked from openpilot.selfdrive.modeld.constants import ModelConstants, Plan +from openpilot.sunnypilot.livedelay.helpers import get_lat_delay +from openpilot.sunnypilot.modeld_v2.modeld_base import ModelStateBase + PROCESS_NAME = "selfdrive.modeld.modeld" SEND_RAW_PRED = os.getenv('SEND_RAW_PRED') @@ -140,12 +143,14 @@ class InputQueues: out[k] = self.q[k][:, idxs] return out -class ModelState: +class ModelState(ModelStateBase): inputs: dict[str, np.ndarray] output: np.ndarray prev_desire: np.ndarray # for tracking the rising edge of the pulse def __init__(self): + ModelStateBase.__init__(self) + self.LAT_SMOOTH_SECONDS = LAT_SMOOTH_SECONDS with open(VISION_METADATA_PATH, 'rb') as f: vision_metadata = pickle.load(f) self.vision_input_shapes = vision_metadata['input_shapes'] @@ -222,7 +227,8 @@ class ModelState: out = self.update_imgs(self.img_queues['img'], self.full_frames['img'], self.transforms['img'], self.img_queues['big_img'], self.full_frames['big_img'], self.transforms['big_img']) - vision_inputs = {'img': out[0], 'big_img': out[1]} + self.img_queues['img'], self.img_queues['big_img'] = out[0].realize(), out[2].realize() + vision_inputs = {'img': out[1], 'big_img': out[3]} if prepare_only: return None @@ -289,7 +295,7 @@ def main(demo=False): cloudlog.warning(f"connected extra cam with buffer size: {vipc_client_extra.buffer_len} ({vipc_client_extra.width} x {vipc_client_extra.height})") # messaging - pm = PubMaster(["modelV2", "drivingModelData", "cameraOdometry"]) + pm = PubMaster(["modelV2", "drivingModelData", "cameraOdometry", "modelDataV2SP"]) sm = SubMaster(["deviceState", "carState", "roadCameraState", "liveCalibration", "driverMonitoringState", "carControl", "liveDelay"]) publish_state = PublishState() @@ -360,7 +366,9 @@ def main(demo=False): is_rhd = sm["driverMonitoringState"].isRHD frame_id = sm["roadCameraState"].frameId v_ego = max(sm["carState"].vEgo, 0.) - lat_delay = sm["liveDelay"].lateralDelay + LAT_SMOOTH_SECONDS + if sm.frame % 60 == 0: + model.lat_delay = get_lat_delay(params, sm["liveDelay"].lateralDelay) + lat_delay = model.lat_delay + LAT_SMOOTH_SECONDS if sm.updated["liveCalibration"] and sm.seen['roadCameraState'] and sm.seen['deviceState']: device_from_calib_euler = np.array(sm["liveCalibration"].rpyCalib, dtype=np.float32) dc = DEVICE_CAMERAS[(str(sm['deviceState'].deviceType), str(sm['roadCameraState'].sensor))] @@ -404,6 +412,7 @@ def main(demo=False): modelv2_send = messaging.new_message('modelV2') drivingdata_send = messaging.new_message('drivingModelData') posenet_send = messaging.new_message('cameraOdometry') + mdv2sp_send = messaging.new_message('modelDataV2SP') frame_delay = DT_MDL # compensate for time passed since the frame was captured: current_time - timestamp_eof is 50ms on average action_delay = DT_MDL / 2 # middle of the interval between model output (current state) and next frame (expected state) @@ -420,6 +429,7 @@ def main(demo=False): DH.update(sm['carState'], sm['carControl'].latActive, lane_change_prob) modelv2_send.modelV2.meta.laneChangeState = DH.lane_change_state modelv2_send.modelV2.meta.laneChangeDirection = DH.lane_change_direction + mdv2sp_send.modelDataV2SP.laneTurnDirection = DH.lane_turn_direction drivingdata_send.drivingModelData.meta.laneChangeState = DH.lane_change_state drivingdata_send.drivingModelData.meta.laneChangeDirection = DH.lane_change_direction @@ -427,6 +437,7 @@ def main(demo=False): pm.send('modelV2', modelv2_send) pm.send('drivingModelData', drivingdata_send) pm.send('cameraOdometry', posenet_send) + pm.send('modelDataV2SP', mdv2sp_send) last_vipc_frame_id = meta_main.frame_id diff --git a/selfdrive/monitoring/dmonitoringd.py b/selfdrive/monitoring/dmonitoringd.py index 1ac2c2dcba..022415af6d 100755 --- a/selfdrive/monitoring/dmonitoringd.py +++ b/selfdrive/monitoring/dmonitoringd.py @@ -10,7 +10,8 @@ def dmonitoringd_thread(): params = Params() pm = messaging.PubMaster(['driverMonitoringState']) - sm = messaging.SubMaster(['driverStateV2', 'liveCalibration', 'carState', 'selfdriveState', 'modelV2'], poll='driverStateV2') + sm = messaging.SubMaster(['driverStateV2', 'liveCalibration', 'carState', 'selfdriveState', 'modelV2', + 'carControl'], poll='driverStateV2') DM = DriverMonitoring(rhd_saved=params.get_bool("IsRhdDetected"), always_on=params.get_bool("AlwaysOnDM")) demo_mode=False diff --git a/selfdrive/monitoring/helpers.py b/selfdrive/monitoring/helpers.py index 90cc565802..6c81fac7f0 100644 --- a/selfdrive/monitoring/helpers.py +++ b/selfdrive/monitoring/helpers.py @@ -345,10 +345,14 @@ class DriverMonitoring: self._reset_awareness() return - driver_attentive = self.driver_distraction_filter.x < 0.37 awareness_prev = self.awareness + _reaching_pre = self.awareness - self.step_change <= self.threshold_pre + _reaching_terminal = self.awareness - self.step_change <= 0 + standstill_orange_exemption = standstill and _reaching_pre + always_on_red_exemption = always_on_valid and not op_engaged and _reaching_terminal - if (driver_attentive and self.face_detected and self.pose.low_std and self.awareness > 0): + if self.awareness > 0 and \ + ((self.driver_distraction_filter.x < 0.37 and self.face_detected and self.pose.low_std) or standstill_orange_exemption): if driver_engaged: self._reset_awareness() return @@ -361,34 +365,28 @@ class DriverMonitoring: if self.awareness > self.threshold_prompt: return - _reaching_pre = self.awareness - self.step_change <= self.threshold_pre - _reaching_audible = self.awareness - self.step_change <= self.threshold_prompt - _reaching_terminal = self.awareness - self.step_change <= 0 - standstill_exemption = standstill and _reaching_pre - always_on_red_exemption = always_on_valid and not op_engaged and _reaching_terminal - certainly_distracted = self.driver_distraction_filter.x > 0.63 and self.driver_distracted and self.face_detected maybe_distracted = self.hi_stds > self.settings._HI_STD_FALLBACK_TIME or not self.face_detected if certainly_distracted or maybe_distracted: # should always be counting if distracted unless at standstill and reaching green # also will not be reaching 0 if DM is active when not engaged - if not (standstill_exemption or always_on_red_exemption): + if not (standstill_orange_exemption or always_on_red_exemption): self.awareness = max(self.awareness - self.step_change, -0.1) alert = None if self.awareness <= 0.: # terminal red alert: disengagement required - alert = EventName.driverDistracted if self.active_monitoring_mode else EventName.driverUnresponsive + alert = EventName.driverDistracted3 if self.active_monitoring_mode else EventName.driverUnresponsive3 self.terminal_time += 1 if awareness_prev > 0.: self.terminal_alert_cnt += 1 elif self.awareness <= self.threshold_prompt: # prompt orange alert - alert = EventName.promptDriverDistracted if self.active_monitoring_mode else EventName.promptDriverUnresponsive + alert = EventName.driverDistracted2 if self.active_monitoring_mode else EventName.driverUnresponsive2 elif self.awareness <= self.threshold_pre: # pre green alert - alert = EventName.preDriverDistracted if self.active_monitoring_mode else EventName.preDriverUnresponsive + alert = EventName.driverDistracted1 if self.active_monitoring_mode else EventName.driverUnresponsive1 if alert is not None: self.current_events.add(alert) @@ -433,7 +431,7 @@ class DriverMonitoring: rpyCalib = [0., 0., 0.] else: highway_speed = sm['carState'].vEgo - enabled = sm['selfdriveState'].enabled + enabled = sm['selfdriveState'].enabled or sm['carControl'].latActive wrong_gear = sm['carState'].gearShifter not in (car.CarState.GearShifter.drive, car.CarState.GearShifter.low) standstill = sm['carState'].standstill driver_engaged = sm['carState'].steeringPressed or sm['carState'].gasPressed diff --git a/selfdrive/monitoring/test_monitoring.py b/selfdrive/monitoring/test_monitoring.py index 733ea85bc0..ee6028d609 100644 --- a/selfdrive/monitoring/test_monitoring.py +++ b/selfdrive/monitoring/test_monitoring.py @@ -1,6 +1,7 @@ import numpy as np +import pytest -from cereal import log +from cereal import log, car from openpilot.common.realtime import DT_DMON from openpilot.selfdrive.monitoring.helpers import DriverMonitoring, DRIVER_MONITOR_SETTINGS from openpilot.system.hardware import HARDWARE @@ -75,11 +76,11 @@ class TestMonitoring: assert len(events[int((d_status.settings._DISTRACTED_TIME-d_status.settings._DISTRACTED_PRE_TIME_TILL_TERMINAL)/2/DT_DMON)]) == 0 assert events[int((d_status.settings._DISTRACTED_TIME-d_status.settings._DISTRACTED_PRE_TIME_TILL_TERMINAL + \ ((d_status.settings._DISTRACTED_PRE_TIME_TILL_TERMINAL-d_status.settings._DISTRACTED_PROMPT_TIME_TILL_TERMINAL)/2))/DT_DMON)].names[0] == \ - EventName.preDriverDistracted + EventName.driverDistracted1 assert events[int((d_status.settings._DISTRACTED_TIME-d_status.settings._DISTRACTED_PROMPT_TIME_TILL_TERMINAL + \ - ((d_status.settings._DISTRACTED_PROMPT_TIME_TILL_TERMINAL)/2))/DT_DMON)].names[0] == EventName.promptDriverDistracted + ((d_status.settings._DISTRACTED_PROMPT_TIME_TILL_TERMINAL)/2))/DT_DMON)].names[0] == EventName.driverDistracted2 assert events[int((d_status.settings._DISTRACTED_TIME + \ - ((TEST_TIMESPAN-10-d_status.settings._DISTRACTED_TIME)/2))/DT_DMON)].names[0] == EventName.driverDistracted + ((TEST_TIMESPAN-10-d_status.settings._DISTRACTED_TIME)/2))/DT_DMON)].names[0] == EventName.driverDistracted3 assert isinstance(d_status.awareness, float) # engaged, no face detected the whole time, no action @@ -88,11 +89,11 @@ class TestMonitoring: assert len(events[int((d_status.settings._AWARENESS_TIME-d_status.settings._AWARENESS_PRE_TIME_TILL_TERMINAL)/2/DT_DMON)]) == 0 assert events[int((d_status.settings._AWARENESS_TIME-d_status.settings._AWARENESS_PRE_TIME_TILL_TERMINAL + \ ((d_status.settings._AWARENESS_PRE_TIME_TILL_TERMINAL-d_status.settings._AWARENESS_PROMPT_TIME_TILL_TERMINAL)/2))/DT_DMON)].names[0] == \ - EventName.preDriverUnresponsive + EventName.driverUnresponsive1 assert events[int((d_status.settings._AWARENESS_TIME-d_status.settings._AWARENESS_PROMPT_TIME_TILL_TERMINAL + \ - ((d_status.settings._AWARENESS_PROMPT_TIME_TILL_TERMINAL)/2))/DT_DMON)].names[0] == EventName.promptDriverUnresponsive + ((d_status.settings._AWARENESS_PROMPT_TIME_TILL_TERMINAL)/2))/DT_DMON)].names[0] == EventName.driverUnresponsive2 assert events[int((d_status.settings._AWARENESS_TIME + \ - ((TEST_TIMESPAN-10-d_status.settings._AWARENESS_TIME)/2))/DT_DMON)].names[0] == EventName.driverUnresponsive + ((TEST_TIMESPAN-10-d_status.settings._AWARENESS_TIME)/2))/DT_DMON)].names[0] == EventName.driverUnresponsive3 # engaged, down to orange, driver pays attention, back to normal; then down to orange, driver touches wheel # - should have short orange recovery time and no green afterwards; wheel touch only recovers when paying attention @@ -105,10 +106,10 @@ class TestMonitoring: [car_interaction_DETECTED] * (int(TEST_TIMESPAN/DT_DMON)-int(DISTRACTED_SECONDS_TO_ORANGE*3/DT_DMON)) events, _ = self._run_seq(ds_vector, interaction_vector, always_true, always_false) assert len(events[int(DISTRACTED_SECONDS_TO_ORANGE*0.5/DT_DMON)]) == 0 - assert events[int((DISTRACTED_SECONDS_TO_ORANGE-0.1)/DT_DMON)].names[0] == EventName.promptDriverDistracted + assert events[int((DISTRACTED_SECONDS_TO_ORANGE-0.1)/DT_DMON)].names[0] == EventName.driverDistracted2 assert len(events[int(DISTRACTED_SECONDS_TO_ORANGE*1.5/DT_DMON)]) == 0 - assert events[int((DISTRACTED_SECONDS_TO_ORANGE*3-0.1)/DT_DMON)].names[0] == EventName.promptDriverDistracted - assert events[int((DISTRACTED_SECONDS_TO_ORANGE*3+0.1)/DT_DMON)].names[0] == EventName.promptDriverDistracted + assert events[int((DISTRACTED_SECONDS_TO_ORANGE*3-0.1)/DT_DMON)].names[0] == EventName.driverDistracted2 + assert events[int((DISTRACTED_SECONDS_TO_ORANGE*3+0.1)/DT_DMON)].names[0] == EventName.driverDistracted2 assert len(events[int((DISTRACTED_SECONDS_TO_ORANGE*3+2.5)/DT_DMON)]) == 0 # engaged, down to orange, driver dodges camera, then comes back still distracted, down to red, \ @@ -128,9 +129,9 @@ class TestMonitoring: op_vector[int((DISTRACTED_SECONDS_TO_RED+2*_invisible_time+2.5)/DT_DMON):int((DISTRACTED_SECONDS_TO_RED+2*_invisible_time+3)/DT_DMON)] \ = [False] * int(0.5/DT_DMON) events, _ = self._run_seq(ds_vector, interaction_vector, op_vector, always_false) - assert events[int((DISTRACTED_SECONDS_TO_ORANGE+0.5*_invisible_time)/DT_DMON)].names[0] == EventName.promptDriverDistracted - assert events[int((DISTRACTED_SECONDS_TO_RED+1.5*_invisible_time)/DT_DMON)].names[0] == EventName.driverDistracted - assert events[int((DISTRACTED_SECONDS_TO_RED+2*_invisible_time+1.5)/DT_DMON)].names[0] == EventName.driverDistracted + assert events[int((DISTRACTED_SECONDS_TO_ORANGE+0.5*_invisible_time)/DT_DMON)].names[0] == EventName.driverDistracted2 + assert events[int((DISTRACTED_SECONDS_TO_RED+1.5*_invisible_time)/DT_DMON)].names[0] == EventName.driverDistracted3 + assert events[int((DISTRACTED_SECONDS_TO_RED+2*_invisible_time+1.5)/DT_DMON)].names[0] == EventName.driverDistracted3 assert len(events[int((DISTRACTED_SECONDS_TO_RED+2*_invisible_time+3.5)/DT_DMON)]) == 0 # engaged, invisible driver, down to orange, driver touches wheel; then down to orange again, driver appears @@ -144,13 +145,13 @@ class TestMonitoring: interaction_vector[int((INVISIBLE_SECONDS_TO_ORANGE)/DT_DMON):int((INVISIBLE_SECONDS_TO_ORANGE+1)/DT_DMON)] = [True] * int(1/DT_DMON) events, _ = self._run_seq(ds_vector, interaction_vector, 2*always_true, 2*always_false) assert len(events[int(INVISIBLE_SECONDS_TO_ORANGE*0.5/DT_DMON)]) == 0 - assert events[int((INVISIBLE_SECONDS_TO_ORANGE-0.1)/DT_DMON)].names[0] == EventName.promptDriverUnresponsive + assert events[int((INVISIBLE_SECONDS_TO_ORANGE-0.1)/DT_DMON)].names[0] == EventName.driverUnresponsive2 assert len(events[int((INVISIBLE_SECONDS_TO_ORANGE+0.1)/DT_DMON)]) == 0 if _visible_time == 0.5: - assert events[int((INVISIBLE_SECONDS_TO_ORANGE*2+1-0.1)/DT_DMON)].names[0] == EventName.promptDriverUnresponsive - assert events[int((INVISIBLE_SECONDS_TO_ORANGE*2+1+0.1+_visible_time)/DT_DMON)].names[0] == EventName.preDriverUnresponsive + assert events[int((INVISIBLE_SECONDS_TO_ORANGE*2+1-0.1)/DT_DMON)].names[0] == EventName.driverUnresponsive2 + assert events[int((INVISIBLE_SECONDS_TO_ORANGE*2+1+0.1+_visible_time)/DT_DMON)].names[0] == EventName.driverUnresponsive1 elif _visible_time == 10: - assert events[int((INVISIBLE_SECONDS_TO_ORANGE*2+1-0.1)/DT_DMON)].names[0] == EventName.promptDriverUnresponsive + assert events[int((INVISIBLE_SECONDS_TO_ORANGE*2+1-0.1)/DT_DMON)].names[0] == EventName.driverUnresponsive2 assert len(events[int((INVISIBLE_SECONDS_TO_ORANGE*2+1+0.1+_visible_time)/DT_DMON)]) == 0 # engaged, invisible driver, down to red, driver appears and then touches wheel, then disengages/reengages @@ -165,10 +166,10 @@ class TestMonitoring: op_vector[int((INVISIBLE_SECONDS_TO_RED+_visible_time+1)/DT_DMON):int((INVISIBLE_SECONDS_TO_RED+_visible_time+0.5)/DT_DMON)] = [False] * int(0.5/DT_DMON) events, _ = self._run_seq(ds_vector, interaction_vector, op_vector, always_false) assert len(events[int(INVISIBLE_SECONDS_TO_ORANGE*0.5/DT_DMON)]) == 0 - assert events[int((INVISIBLE_SECONDS_TO_ORANGE-0.1)/DT_DMON)].names[0] == EventName.promptDriverUnresponsive - assert events[int((INVISIBLE_SECONDS_TO_RED-0.1)/DT_DMON)].names[0] == EventName.driverUnresponsive - assert events[int((INVISIBLE_SECONDS_TO_RED+0.5*_visible_time)/DT_DMON)].names[0] == EventName.driverUnresponsive - assert events[int((INVISIBLE_SECONDS_TO_RED+_visible_time+0.5)/DT_DMON)].names[0] == EventName.driverUnresponsive + assert events[int((INVISIBLE_SECONDS_TO_ORANGE-0.1)/DT_DMON)].names[0] == EventName.driverUnresponsive2 + assert events[int((INVISIBLE_SECONDS_TO_RED-0.1)/DT_DMON)].names[0] == EventName.driverUnresponsive3 + assert events[int((INVISIBLE_SECONDS_TO_RED+0.5*_visible_time)/DT_DMON)].names[0] == EventName.driverUnresponsive3 + assert events[int((INVISIBLE_SECONDS_TO_RED+_visible_time+0.5)/DT_DMON)].names[0] == EventName.driverUnresponsive3 assert len(events[int((INVISIBLE_SECONDS_TO_RED+_visible_time+1+0.1)/DT_DMON)]) == 0 # disengaged, always distracted driver @@ -186,8 +187,19 @@ class TestMonitoring: events, d_status = self._run_seq(always_distracted, always_false, always_true, standstill_vector) assert len(events[int((_redlight_time-0.1)/DT_DMON)]) == 0 _pre_to_prompt = d_status.settings._DISTRACTED_PRE_TIME_TILL_TERMINAL - d_status.settings._DISTRACTED_PROMPT_TIME_TILL_TERMINAL - assert events[int((_redlight_time+0.5)/DT_DMON)].names[0] == EventName.preDriverDistracted - assert events[int((_redlight_time+_pre_to_prompt+0.5)/DT_DMON)].names[0] == EventName.promptDriverDistracted + assert events[int((_redlight_time+0.5)/DT_DMON)].names[0] == EventName.driverDistracted1 + assert events[int((_redlight_time+_pre_to_prompt+0.5)/DT_DMON)].names[0] == EventName.driverDistracted2 + + # engaged, distracted while moving, then car stops after reaching orange + # - should reset timer to pre green at standstill + def test_distracted_then_stops(self): + _stop_time = DISTRACTED_SECONDS_TO_ORANGE + 1 # stop 1 second after reaching orange + standstill_vector = always_false[:] + standstill_vector[int(_stop_time/DT_DMON):] = [True] * int((TEST_TIMESPAN-_stop_time)/DT_DMON) + events, _ = self._run_seq(always_distracted, always_false, always_true, standstill_vector) + # just before and briefly after stopping: orange alert; goes away quickly after stopped + assert events[int((_stop_time+0.1)/DT_DMON)].names[0] == EventName.driverDistracted2 + assert len(events[int((_stop_time+0.5)/DT_DMON)]) == 0 # engaged, model is somehow uncertain and driver is distracted # - should fall back to wheel touch after uncertain alert @@ -195,10 +207,72 @@ class TestMonitoring: ds_vector = [msg_DISTRACTED_BUT_SOMEHOW_UNCERTAIN] * int(TEST_TIMESPAN/DT_DMON) interaction_vector = always_false[:] events, d_status = self._run_seq(ds_vector, interaction_vector, always_true, always_false) - assert EventName.preDriverUnresponsive in \ + assert EventName.driverUnresponsive1 in \ events[int((INVISIBLE_SECONDS_TO_ORANGE-1+DT_DMON*d_status.settings._HI_STD_FALLBACK_TIME-0.1)/DT_DMON)].names - assert EventName.promptDriverUnresponsive in \ + assert EventName.driverUnresponsive2 in \ events[int((INVISIBLE_SECONDS_TO_ORANGE-1+DT_DMON*d_status.settings._HI_STD_FALLBACK_TIME+0.1)/DT_DMON)].names - assert EventName.driverUnresponsive in \ + assert EventName.driverUnresponsive3 in \ events[int((INVISIBLE_SECONDS_TO_RED-1+DT_DMON*d_status.settings._HI_STD_FALLBACK_TIME+0.1)/DT_DMON)].names + +@pytest.mark.parametrize("enabled_state, lat_active_state, expected", [ + (False, False, False), # Both Disabled + (True, False, True), # OP Enabled, Lat Inactive + (False, True, True), # OP Disabled, Lat Active (e.g. MADS) + (True, True, True) # Both Active +]) +def test_enabled_states(enabled_state, lat_active_state, expected): + """ + Test DriverMonitoring.run_step with all 4 combinations of: + - selfdriveState.enabled (True/False) + - carControl.latActive (True/False) + """ + cs = car.CarState.new_message() + cs.vEgo = 30.0 + cs.gearShifter = car.CarState.GearShifter.drive + cs.standstill = False + cs.steeringPressed = False + cs.gasPressed = False + + ss = log.SelfdriveState.new_message() + ss.enabled = enabled_state + + cc = car.CarControl.new_message() + cc.latActive = lat_active_state + + mv2 = log.ModelDataV2.new_message() + mv2.meta.disengagePredictions.brakeDisengageProbs = [0.0] + + lc = log.LiveCalibrationData.new_message() + lc.rpyCalib = [0.0, 0.0, 0.0] + + ds = make_msg(False) + + sm = { + 'carState': cs, + 'selfdriveState': ss, + 'carControl': cc, + 'modelV2': mv2, + 'liveCalibration': lc, + 'driverStateV2': ds + } + + driver_monitoring = DriverMonitoring() + + # run_test doesn't assign enabled to a variable, so we need to spy on _update_events to see its value + captured_args = [] + original_update_events = driver_monitoring._update_events + + def spy_update_events(driver_engaged, op_engaged, standstill, wrong_gear, car_speed): + captured_args.append(op_engaged) + return original_update_events(driver_engaged, op_engaged, standstill, wrong_gear, car_speed) + + driver_monitoring._update_events = spy_update_events + + driver_monitoring.run_step(sm, demo=False) + + # Assertion + assert len(captured_args) == 1, "Expected _update_events to be called exactly once" + actual_enabled = captured_args[0] + + assert actual_enabled == expected, f"Expected op_engaged={expected}, but got {actual_enabled}" diff --git a/selfdrive/pandad/panda.cc b/selfdrive/pandad/panda.cc index edc2228c0c..acb472ab57 100644 --- a/selfdrive/pandad/panda.cc +++ b/selfdrive/pandad/panda.cc @@ -40,8 +40,8 @@ void Panda::set_safety_model(cereal::CarParams::SafetyModel safety_model, uint16 handle->control_write(0xdc, (uint16_t)safety_model, safety_param); } -void Panda::set_alternative_experience(uint16_t alternative_experience) { - handle->control_write(0xdf, alternative_experience, 0); +void Panda::set_alternative_experience(uint16_t alternative_experience, uint16_t safety_param_sp) { + handle->control_write(0xdf, alternative_experience, safety_param_sp); } std::string Panda::serial_read(int port_number) { @@ -134,8 +134,8 @@ void Panda::enable_deepsleep() { handle->control_write(0xfb, 0, 0); } -void Panda::send_heartbeat(bool engaged) { - handle->control_write(0xf3, engaged, 0); +void Panda::send_heartbeat(bool engaged, bool engaged_mads) { + handle->control_write(0xf3, engaged, engaged_mads); } void Panda::set_can_speed_kbps(uint16_t bus, uint16_t speed) { diff --git a/selfdrive/pandad/panda.h b/selfdrive/pandad/panda.h index 1a066dc5fb..9877e5f739 100644 --- a/selfdrive/pandad/panda.h +++ b/selfdrive/pandad/panda.h @@ -62,7 +62,7 @@ public: // Panda functionality cereal::PandaState::PandaType get_hw_type(); void set_safety_model(cereal::CarParams::SafetyModel safety_model, uint16_t safety_param=0U); - void set_alternative_experience(uint16_t alternative_experience); + void set_alternative_experience(uint16_t alternative_experience, uint16_t safety_param_sp=0U); std::string serial_read(int port_number = 0); void set_uart_baud(int uart, int rate); void set_fan_speed(uint16_t fan_speed); @@ -76,7 +76,7 @@ public: std::optional get_serial(); void set_power_saving(bool power_saving); void enable_deepsleep(); - void send_heartbeat(bool engaged); + void send_heartbeat(bool engaged, bool engaged_mads); void set_can_speed_kbps(uint16_t bus, uint16_t speed); void set_can_fd_auto(uint16_t bus, bool enabled); void set_data_speed_kbps(uint16_t bus, uint16_t speed); diff --git a/selfdrive/pandad/panda_safety.cc b/selfdrive/pandad/panda_safety.cc index 32d129bc2e..8381256f46 100644 --- a/selfdrive/pandad/panda_safety.cc +++ b/selfdrive/pandad/panda_safety.cc @@ -8,7 +8,8 @@ void PandaSafety::configureSafetyMode(bool is_onroad) { auto car_params = fetchCarParams(); if (!car_params.empty()) { - LOGW("got %lu bytes CarParams", car_params.size()); + LOGW("got %lu bytes CarParams", car_params[0].size()); + LOGW("got %lu bytes CarParamsSP", car_params[1].size()); setSafetyMode(car_params); safety_configured_ = true; } @@ -37,7 +38,8 @@ void PandaSafety::updateMultiplexingMode() { } } -std::string PandaSafety::fetchCarParams() { +// TODO-SP: Use structs instead of vector +std::vector PandaSafety::fetchCarParams() { if (!params_.getBool("FirmwareQueryDone")) { return {}; } @@ -50,21 +52,33 @@ std::string PandaSafety::fetchCarParams() { if (!params_.getBool("ControlsReady")) { return {}; } - return params_.get("CarParams"); + return {params_.get("CarParams"), params_.get("CarParamsSP")}; } -void PandaSafety::setSafetyMode(const std::string ¶ms_string) { +// TODO-SP: Use structs instead of vector +void PandaSafety::setSafetyMode(const std::vector ¶ms_string) { AlignedBuffer aligned_buf; - capnp::FlatArrayMessageReader cmsg(aligned_buf.align(params_string.data(), params_string.size())); + AlignedBuffer aligned_buf_sp; + + capnp::FlatArrayMessageReader cmsg(aligned_buf.align(params_string[0].data(), params_string[0].size())); cereal::CarParams::Reader car_params = cmsg.getRoot(); + capnp::FlatArrayMessageReader cmsg_sp(aligned_buf_sp.align(params_string[1].data(), params_string[1].size())); + cereal::CarParamsSP::Reader car_params_sp = cmsg_sp.getRoot(); + auto safety_configs = car_params.getSafetyConfigs(); uint16_t alternative_experience = car_params.getAlternativeExperience(); + uint16_t safety_param_sp = car_params_sp.getSafetyParam(); cereal::CarParams::SafetyModel safety_model = safety_configs[0].getSafetyModel(); uint16_t safety_param = safety_configs[0].getSafetyParam(); - LOGW("setting safety model: %d, param: %d, alternative experience: %d", (int)safety_model, safety_param, alternative_experience); - panda_->set_alternative_experience(alternative_experience); + LOGW("setting safety model: %d, param: %d, alternative experience: %d, param_sp: %d", (int)safety_model, safety_param, alternative_experience, safety_param_sp); + panda_->set_alternative_experience(alternative_experience, safety_param_sp); panda_->set_safety_model(safety_model, safety_param); } + +bool PandaSafety::getOffroadMode() { + auto offroad_mode = params_.getBool("OffroadMode"); + return offroad_mode; +} diff --git a/selfdrive/pandad/pandad.cc b/selfdrive/pandad/pandad.cc index 28d459f458..cafc3e1225 100644 --- a/selfdrive/pandad/pandad.cc +++ b/selfdrive/pandad/pandad.cc @@ -31,6 +31,11 @@ bool check_connected(Panda *panda) { return true; } +bool process_mads_heartbeat(SubMaster *sm) { + const auto &mads = (*sm)["selfdriveStateSP"].getSelfdriveStateSP().getMads(); + return sm->allAliveAndValid({"selfdriveStateSP"}) && mads.getEnabled(); +} + Panda *connect(std::string serial) { std::unique_ptr panda; try { @@ -49,6 +54,13 @@ Panda *connect(std::string serial) { panda->set_can_fd_auto(i, true); } + bool is_supported_panda = std::find(SUPPORTED_PANDA_TYPES.begin(), SUPPORTED_PANDA_TYPES.end(), panda->hw_type) != SUPPORTED_PANDA_TYPES.end(); + + if (!is_supported_panda) { + LOGW("panda %s is not supported (hw_type: %i), skipping firmware check...", panda->hw_serial().c_str(), static_cast(panda->hw_type)); + return panda.release(); + } + if (!panda->up_to_date() && !getenv("BOARDD_SKIP_FW_CHECK")) { throw std::runtime_error("Panda firmware out of date. Run pandad.py to update."); } @@ -131,6 +143,8 @@ void fill_panda_state(cereal::PandaState::Builder &ps, cereal::PandaState::Panda ps.setSbu1Voltage(health.sbu1_voltage_mV / 1000.0f); ps.setSbu2Voltage(health.sbu2_voltage_mV / 1000.0f); ps.setSoundOutputLevel(health.sound_output_level_pkt); + ps.setControlsAllowedLateral(health.controls_allowed_lateral_pkt); + ps.setControlsAllowedLongitudinal(health.controls_allowed_longitudinal_pkt); } void fill_panda_can_state(cereal::PandaState::PandaCanState::Builder &cs, const can_health_t &can_health) { @@ -161,7 +175,7 @@ void fill_panda_can_state(cereal::PandaState::PandaCanState::Builder &cs, const cs.setCanCoreResetCnt(can_health.can_core_reset_cnt); } -std::optional send_panda_states(PubMaster *pm, Panda *panda, bool is_onroad, bool spoofing_started) { +std::optional send_panda_states(PubMaster *pm, Panda *panda, bool is_onroad, bool spoofing_started, bool always_offroad) { // build msg MessageBuilder msg; auto evt = msg.initEvent(); @@ -187,7 +201,7 @@ std::optional send_panda_states(PubMaster *pm, Panda *panda, bool is_onroa health.ignition_line_pkt = 1; } - bool ignition_local = ((health.ignition_line_pkt != 0) || (health.ignition_can_pkt != 0)); + bool ignition_local = ((health.ignition_line_pkt != 0) || (health.ignition_can_pkt != 0)) && !always_offroad; // Make sure CAN buses are live: safety_setter_thread does not work if Panda CAN are silent and there is only one other CAN node if (health.safety_mode_pkt == (uint8_t)(cereal::CarParams::SafetyModel::SILENT)) { @@ -267,8 +281,8 @@ void send_peripheral_state(Panda *panda, PubMaster *pm) { pm->send("peripheralState", msg); } -void process_panda_state(Panda *panda, PubMaster *pm, bool engaged, bool is_onroad, bool spoofing_started) { - auto ignition_opt = send_panda_states(pm, panda, is_onroad, spoofing_started); +void process_panda_state(Panda *panda, PubMaster *pm, bool engaged, bool engaged_mads, bool is_onroad, bool spoofing_started, bool always_offroad) { + auto ignition_opt = send_panda_states(pm, panda, is_onroad, spoofing_started, always_offroad); if (!ignition_opt) { LOGE("Failed to get ignition_opt"); return; @@ -282,7 +296,7 @@ void process_panda_state(Panda *panda, PubMaster *pm, bool engaged, bool is_onro } } - panda->send_heartbeat(engaged); + panda->send_heartbeat(engaged, engaged_mads); } void process_peripheral_state(Panda *panda, PubMaster *pm, bool no_fan_control) { @@ -341,9 +355,9 @@ void process_peripheral_state(Panda *panda, PubMaster *pm, bool no_fan_control) } if (ir_pwr != prev_ir_pwr || sm.frame % 100 == 0) { - int16_t ir_panda = util::map_val(ir_pwr, 0, 100, 0, MAX_IR_PANDA_VAL); + int16_t ir_panda = util::map_val(ir_pwr, 0, 100, 0, MAX_IR_PANDA_VAL); panda->set_ir_pwr(ir_panda); - Hardware::set_ir_power(ir_pwr); + Hardware::set_ir_power(ir_pwr); prev_ir_pwr = ir_pwr; } } @@ -359,11 +373,13 @@ void pandad_run(Panda *panda) { Params params; RateKeeper rk("pandad", 100); - SubMaster sm({"selfdriveState"}); + SubMaster sm({"selfdriveState", "selfdriveStateSP"}); PubMaster pm({"can", "pandaStates", "peripheralState"}); PandaSafety panda_safety(panda); bool engaged = false; + bool engaged_mads = false; bool is_onroad = false; + bool always_offroad = false; // Main loop: receive CAN data and process states while (!do_exit && check_connected(panda)) { @@ -378,8 +394,10 @@ void pandad_run(Panda *panda) { if (rk.frame() % 10 == 0) { sm.update(0); engaged = sm.allAliveAndValid({"selfdriveState"}) && sm["selfdriveState"].getSelfdriveState().getEnabled(); + engaged_mads = process_mads_heartbeat(&sm); is_onroad = params.getBool("IsOnroad"); - process_panda_state(panda, &pm, engaged, is_onroad, spoofing_started); + always_offroad = panda_safety.getOffroadMode(); + process_panda_state(panda, &pm, engaged, engaged_mads, is_onroad, spoofing_started, always_offroad); panda_safety.configureSafetyMode(is_onroad); } diff --git a/selfdrive/pandad/pandad.h b/selfdrive/pandad/pandad.h index aa10d1ae4b..1558d0469e 100644 --- a/selfdrive/pandad/pandad.h +++ b/selfdrive/pandad/pandad.h @@ -7,15 +7,24 @@ void pandad_main_thread(std::string serial); +// deprecated devices +static const std::vector SUPPORTED_PANDA_TYPES = { + cereal::PandaState::PandaType::RED_PANDA, + cereal::PandaState::PandaType::TRES, + cereal::PandaState::PandaType::CUATRO, +}; + + class PandaSafety { public: PandaSafety(Panda *panda) : panda_(panda) {} void configureSafetyMode(bool is_onroad); + bool getOffroadMode(); private: void updateMultiplexingMode(); - std::string fetchCarParams(); - void setSafetyMode(const std::string ¶ms_string); + std::vector fetchCarParams(); + void setSafetyMode(const std::vector ¶ms_string); bool initialized_ = false; bool log_once_ = false; diff --git a/selfdrive/pandad/pandad.py b/selfdrive/pandad/pandad.py index df2b4f7ee8..f65c64259f 100755 --- a/selfdrive/pandad/pandad.py +++ b/selfdrive/pandad/pandad.py @@ -12,6 +12,8 @@ from openpilot.common.params import Params from openpilot.system.hardware import HARDWARE from openpilot.common.swaglog import cloudlog +from openpilot.sunnypilot.selfdrive.pandad.rivian_long_flasher import flash_rivian_long + def get_expected_signature() -> bytes: try: @@ -29,6 +31,11 @@ def flash_panda(panda_serial: str) -> Panda: HARDWARE.recover_internal_panda() raise + # skip flashing if the detected panda is not supported + if panda.get_type() not in Panda.SUPPORTED_DEVICES: + cloudlog.warning(f"Panda {panda_serial} is not supported (hw_type: {panda.get_type()}), skipping flash...") + return panda + fw_signature = get_expected_signature() internal_panda = panda.is_internal() @@ -61,6 +68,22 @@ def flash_panda(panda_serial: str) -> Panda: return panda +def check_panda_support(panda_serials: list[str]) -> list[str]: + spi_serials = set(Panda.spi_list()) + for serial in panda_serials: + if serial in spi_serials: + return [serial] + + for serial in panda_serials: + panda = Panda(serial) + is_internal = panda.is_internal() + panda.close() + if is_internal: + return [serial] + + return [] + + def main() -> None: # signal pandad to close the relay and exit def signal_handler(signum, frame): @@ -110,6 +133,14 @@ def main() -> None: cloudlog.info(f"{len(panda_serials)} panda(s) found, connecting - {panda_serials}") + # custom flasher for xnor's Rivian Longitudinal Upgrade Kit + flash_rivian_long(panda_serials) + + # find the internal supported panda (e.g. skip external Black Panda) + panda_serials = check_panda_support(panda_serials) + if len(panda_serials) == 0: + continue + # Flash the first panda panda_serial = panda_serials[0] panda = flash_panda(panda_serial) diff --git a/selfdrive/selfdrived/alertmanager.py b/selfdrive/selfdrived/alertmanager.py index 385c276a94..34db27c7a4 100644 --- a/selfdrive/selfdrived/alertmanager.py +++ b/selfdrive/selfdrived/alertmanager.py @@ -6,7 +6,8 @@ from dataclasses import dataclass from openpilot.common.basedir import BASEDIR from openpilot.common.params import Params -from openpilot.selfdrive.selfdrived.events import Alert, EmptyAlert +from openpilot.selfdrive.selfdrived.events import Alert +from openpilot.sunnypilot.selfdrive.selfdrived.events_base import EmptyAlert with open(os.path.join(BASEDIR, "selfdrive/selfdrived/alerts_offroad.json")) as f: diff --git a/selfdrive/selfdrived/alerts_offroad.json b/selfdrive/selfdrived/alerts_offroad.json index 0fc11b9636..917b0b6ab7 100644 --- a/selfdrive/selfdrived/alerts_offroad.json +++ b/selfdrive/selfdrived/alerts_offroad.json @@ -4,12 +4,12 @@ "severity": 0 }, "Offroad_ConnectivityNeededPrompt": { - "text": "Immediately connect to the internet to check for updates. If you do not connect to the internet, openpilot won't engage in %1", + "text": "Immediately connect to the internet to check for updates. If you do not connect to the internet, sunnypilot won't engage in %1", "severity": 0, "_comment": "Set extra field to number of days" }, "Offroad_ConnectivityNeeded": { - "text": "Connect to internet to check for updates. openpilot won't automatically start until it connects to internet to check for updates.", + "text": "Connect to internet to check for updates. sunnypilot won't automatically start until it connects to internet to check for updates.", "severity": 1 }, "Offroad_UpdateFailed": { @@ -30,11 +30,15 @@ "severity": 1 }, "Offroad_CarUnrecognized": { - "text": "openpilot was unable to identify your car. Your car is either unsupported or its ECUs are not recognized. Please submit a pull request to add the firmware versions to the proper vehicle. Need help? Join discord.comma.ai.", + "text": "sunnypilot was unable to identify your car. Your car is either unsupported or its ECUs are not recognized. Please submit a pull request to add the firmware versions to the proper vehicle. Need help? Join discord.comma.ai.", "severity": 0 }, "Offroad_Recalibration": { - "text": "openpilot detected a change in the device's mounting position. Ensure the device is fully seated in the mount and the mount is firmly secured to the windshield.", + "text": "sunnypilot detected a change in the device's mounting position. Ensure the device is fully seated in the mount and the mount is firmly secured to the windshield.", + "severity": 0 + }, + "Offroad_OSMUpdateRequired": { + "text": "OpenStreetMap database is out of date. New maps must be downloaded if you wish to continue using OpenStreetMap data for Enhanced Speed Control and road name display.\n\n%1", "severity": 0 }, "Offroad_DriverMonitoringUncertain": { @@ -45,5 +49,10 @@ "text": "Excessive %1 actuation detected on your last drive. Please contact support at https://comma.ai/support and share your device's Dongle ID for troubleshooting.", "severity": 1, "_comment": "Set extra field to lateral or longitudinal." + }, + "Offroad_TiciSupport": { + "text": "Unsupported branch detected - The current version of %1 branch is no longer supported on the comma three. Please go to [Device > Software] and install a supported branch with -tici in the branch name for the comma three.", + "severity": 1, + "_comment": "Set extra field to the current branch name." } } diff --git a/selfdrive/selfdrived/events.py b/selfdrive/selfdrived/events.py index 55af93c42b..c9b281436c 100755 --- a/selfdrive/selfdrived/events.py +++ b/selfdrive/selfdrived/events.py @@ -1,9 +1,6 @@ #!/usr/bin/env python3 -import bisect import math import os -from enum import IntEnum -from collections.abc import Callable from cereal import log, car import cereal.messaging as messaging @@ -15,6 +12,11 @@ from openpilot.system.micd import SAMPLE_RATE, SAMPLE_BUFFER from openpilot.selfdrive.ui.feedback.feedbackd import FEEDBACK_MAX_DURATION from openpilot.system.hardware import HARDWARE +from openpilot.sunnypilot.selfdrive.selfdrived.events_base import EventsBase, Priority, ET, Alert, \ + NoEntryAlert, SoftDisableAlert, UserSoftDisableAlert, ImmediateDisableAlert, EngagementAlert, NormalPermanentAlert, \ + StartupAlert, AlertCallbackType, wrong_car_mode_alert + + AlertSize = log.SelfdriveState.AlertSize AlertStatus = log.SelfdriveState.AlertStatus VisualAlert = car.CarControl.HUDControl.VisualAlert @@ -22,190 +24,23 @@ AudibleAlert = car.CarControl.HUDControl.AudibleAlert EventName = log.OnroadEvent.EventName -# Alert priorities -class Priority(IntEnum): - LOWEST = 0 - LOWER = 1 - LOW = 2 - MID = 3 - HIGH = 4 - HIGHEST = 5 - - -# Event types -class ET: - ENABLE = 'enable' - PRE_ENABLE = 'preEnable' - OVERRIDE_LATERAL = 'overrideLateral' - OVERRIDE_LONGITUDINAL = 'overrideLongitudinal' - NO_ENTRY = 'noEntry' - WARNING = 'warning' - USER_DISABLE = 'userDisable' - SOFT_DISABLE = 'softDisable' - IMMEDIATE_DISABLE = 'immediateDisable' - PERMANENT = 'permanent' - - # get event name from enum EVENT_NAME = {v: k for k, v in EventName.schema.enumerants.items()} -class Events: +class Events(EventsBase): def __init__(self): - self.events: list[int] = [] - self.static_events: list[int] = [] + super().__init__() self.event_counters = dict.fromkeys(EVENTS.keys(), 0) - @property - def names(self) -> list[int]: - return self.events + def get_events_mapping(self) -> dict[int, dict[str, Alert | AlertCallbackType]]: + return EVENTS - def __len__(self) -> int: - return len(self.events) + def get_event_name(self, event: int): + return EVENT_NAME[event] - def add(self, event_name: int, static: bool=False) -> None: - if static: - bisect.insort(self.static_events, event_name) - bisect.insort(self.events, event_name) - - def clear(self) -> None: - self.event_counters = {k: (v + 1 if k in self.events else 0) for k, v in self.event_counters.items()} - self.events = self.static_events.copy() - - def contains(self, event_type: str) -> bool: - return any(event_type in EVENTS.get(e, {}) for e in self.events) - - def create_alerts(self, event_types: list[str], callback_args=None): - if callback_args is None: - callback_args = [] - - ret = [] - for e in self.events: - types = EVENTS[e].keys() - for et in event_types: - if et in types: - alert = EVENTS[e][et] - if not isinstance(alert, Alert): - alert = alert(*callback_args) - - if DT_CTRL * (self.event_counters[e] + 1) >= alert.creation_delay: - alert.alert_type = f"{EVENT_NAME[e]}/{et}" - alert.event_type = et - ret.append(alert) - return ret - - def add_from_msg(self, events): - for e in events: - bisect.insort(self.events, e.name.raw) - - def to_msg(self): - ret = [] - for event_name in self.events: - event = log.OnroadEvent.new_message() - event.name = event_name - for event_type in EVENTS.get(event_name, {}): - setattr(event, event_type, True) - ret.append(event) - return ret - - -class Alert: - def __init__(self, - alert_text_1: str, - alert_text_2: str, - alert_status: log.SelfdriveState.AlertStatus, - alert_size: log.SelfdriveState.AlertSize, - priority: Priority, - visual_alert: car.CarControl.HUDControl.VisualAlert, - audible_alert: car.CarControl.HUDControl.AudibleAlert, - duration: float, - creation_delay: float = 0.): - - self.alert_text_1 = alert_text_1 - self.alert_text_2 = alert_text_2 - self.alert_status = alert_status - self.alert_size = alert_size - self.priority = priority - self.visual_alert = visual_alert - self.audible_alert = audible_alert - - self.duration = int(duration / DT_CTRL) - - self.creation_delay = creation_delay - - self.alert_type = "" - self.event_type: str | None = None - - def __str__(self) -> str: - return f"{self.alert_text_1}/{self.alert_text_2} {self.priority} {self.visual_alert} {self.audible_alert}" - - def __gt__(self, alert2) -> bool: - if not isinstance(alert2, Alert): - return False - return self.priority > alert2.priority - -EmptyAlert = Alert("" , "", AlertStatus.normal, AlertSize.none, Priority.LOWEST, - VisualAlert.none, AudibleAlert.none, 0) - -class NoEntryAlert(Alert): - def __init__(self, alert_text_2: str, - alert_text_1: str = "openpilot Unavailable", - visual_alert: car.CarControl.HUDControl.VisualAlert=VisualAlert.none): - if HARDWARE.get_device_type() == 'mici': - alert_text_1, alert_text_2 = alert_text_2, alert_text_1 - super().__init__(alert_text_1, alert_text_2, AlertStatus.normal, - AlertSize.mid, Priority.LOW, visual_alert, - AudibleAlert.refuse, 3.) - - -class SoftDisableAlert(Alert): - def __init__(self, alert_text_2: str): - super().__init__("TAKE CONTROL IMMEDIATELY", alert_text_2, - AlertStatus.userPrompt, AlertSize.full, - Priority.MID, VisualAlert.steerRequired, - AudibleAlert.warningSoft, 2.), - - -# less harsh version of SoftDisable, where the condition is user-triggered -class UserSoftDisableAlert(SoftDisableAlert): - def __init__(self, alert_text_2: str): - super().__init__(alert_text_2), - self.alert_text_1 = "openpilot will disengage" - - -class ImmediateDisableAlert(Alert): - def __init__(self, alert_text_2: str): - super().__init__("TAKE CONTROL IMMEDIATELY", alert_text_2, - AlertStatus.critical, AlertSize.full, - Priority.HIGHEST, VisualAlert.steerRequired, - AudibleAlert.warningImmediate, 4.), - - -class EngagementAlert(Alert): - def __init__(self, audible_alert: car.CarControl.HUDControl.AudibleAlert): - super().__init__("", "", - AlertStatus.normal, AlertSize.none, - Priority.MID, VisualAlert.none, - audible_alert, .2), - - -class NormalPermanentAlert(Alert): - def __init__(self, alert_text_1: str, alert_text_2: str = "", duration: float = 0.2, priority: Priority = Priority.LOWER, creation_delay: float = 0.): - super().__init__(alert_text_1, alert_text_2, - AlertStatus.normal, AlertSize.mid if len(alert_text_2) else AlertSize.small, - priority, VisualAlert.none, AudibleAlert.none, duration, creation_delay=creation_delay), - - -class StartupAlert(Alert): - def __init__(self, alert_text_1: str, alert_text_2: str = "Always keep hands on wheel and eyes on road", alert_status=AlertStatus.normal): - alert_size = AlertSize.mid - if HARDWARE.get_device_type() == 'mici': - if alert_text_2 == "Always keep hands on wheel and eyes on road": - alert_text_2 = "" - alert_size = AlertSize.small - super().__init__(alert_text_1, alert_text_2, - alert_status, alert_size, - Priority.LOWER, VisualAlert.none, AudibleAlert.none, 5.), + def get_event_msg_type(self): + return log.OnroadEvent @@ -218,8 +53,6 @@ def get_display_speed(speed_ms: float, metric: bool) -> str: # ********** alert callback functions ********** -AlertCallbackType = Callable[[car.CarParams, car.CarState, messaging.SubMaster, bool, int, log.ControlsState], Alert] - def soft_disable_alert(alert_text_2: str) -> AlertCallbackType: def func(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality) -> Alert: @@ -349,13 +182,6 @@ def modeld_lagging_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubM return NormalPermanentAlert("Driving Model Lagging", f"{sm['modelV2'].frameDropPerc:.1f}% frames dropped") -def wrong_car_mode_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality) -> Alert: - text = "Enable Adaptive Cruise to Engage" - if CP.brand == "honda": - text = "Enable Main Switch to Engage" - return NoEntryAlert(text) - - def joystick_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality) -> Alert: gb = sm['carControl'].actuators.accel / 4. steer = sm['carControl'].actuators.torque @@ -512,7 +338,7 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = { Priority.LOW, VisualAlert.steerRequired, AudibleAlert.prompt, 1.8), }, - EventName.preDriverDistracted: { + EventName.driverDistracted1: { ET.PERMANENT: Alert( "Pay Attention", "", @@ -520,7 +346,7 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = { Priority.LOW, VisualAlert.none, AudibleAlert.none, .1), }, - EventName.promptDriverDistracted: { + EventName.driverDistracted2: { ET.PERMANENT: Alert( "Pay Attention", "Driver Distracted", @@ -528,7 +354,7 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = { Priority.MID, VisualAlert.steerRequired, AudibleAlert.promptDistracted, .1), }, - EventName.driverDistracted: { + EventName.driverDistracted3: { ET.PERMANENT: Alert( "DISENGAGE IMMEDIATELY", "Driver Distracted", @@ -536,7 +362,7 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = { Priority.HIGH, VisualAlert.steerRequired, AudibleAlert.warningImmediate, .1), }, - EventName.preDriverUnresponsive: { + EventName.driverUnresponsive1: { ET.PERMANENT: Alert( "Touch Steering Wheel: No Face Detected", "", @@ -544,7 +370,7 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = { Priority.LOW, VisualAlert.steerRequired, AudibleAlert.none, .1), }, - EventName.promptDriverUnresponsive: { + EventName.driverUnresponsive2: { ET.PERMANENT: Alert( "Touch Steering Wheel", "Driver Unresponsive", @@ -552,7 +378,7 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = { Priority.MID, VisualAlert.steerRequired, AudibleAlert.promptDistracted, .1), }, - EventName.driverUnresponsive: { + EventName.driverUnresponsive3: { ET.PERMANENT: Alert( "DISENGAGE IMMEDIATELY", "Driver Unresponsive", @@ -1032,14 +858,14 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = { if HARDWARE.get_device_type() == 'mici': EVENTS.update({ - EventName.preDriverDistracted: { + EventName.driverDistracted1: { ET.PERMANENT: Alert( "Pay Attention", "", AlertStatus.normal, AlertSize.small, Priority.LOW, VisualAlert.none, AudibleAlert.none, 2), }, - EventName.promptDriverDistracted: { + EventName.driverDistracted2: { ET.PERMANENT: Alert( "Pay Attention", "Driver Distracted", diff --git a/selfdrive/selfdrived/selfdrived.py b/selfdrive/selfdrived/selfdrived.py index 6a294ca8d8..94d73392ad 100755 --- a/selfdrive/selfdrived/selfdrived.py +++ b/selfdrive/selfdrived/selfdrived.py @@ -5,7 +5,7 @@ import threading import cereal.messaging as messaging -from cereal import car, log +from cereal import car, log, custom from msgq.visionipc import VisionIpcClient, VisionStreamType @@ -24,6 +24,13 @@ from openpilot.selfdrive.selfdrived.alertmanager import AlertManager, set_offroa from openpilot.system.version import get_build_metadata from openpilot.system.hardware import HARDWARE +from openpilot.sunnypilot.mads.mads import ModularAssistiveDrivingSystem +from openpilot.sunnypilot import get_sanitize_int_param +from openpilot.sunnypilot.selfdrive.car.car_specific import CarSpecificEventsSP +from openpilot.sunnypilot.selfdrive.car.cruise_helpers import CruiseHelper +from openpilot.sunnypilot.selfdrive.car.intelligent_cruise_button_management.controller import IntelligentCruiseButtonManagement +from openpilot.sunnypilot.selfdrive.selfdrived.events import EventsSP + REPLAY = "REPLAY" in os.environ SIMULATION = "SIMULATION" in os.environ TESTING_CLOSET = "TESTING_CLOSET" in os.environ @@ -38,12 +45,13 @@ LaneChangeDirection = log.LaneChangeDirection EventName = log.OnroadEvent.EventName ButtonType = car.CarState.ButtonEvent.Type SafetyModel = car.CarParams.SafetyModel +TurnDirection = custom.ModelDataV2SP.TurnDirection IGNORED_SAFETY_MODES = (SafetyModel.silent, SafetyModel.noOutput) -class SelfdriveD: - def __init__(self, CP=None): +class SelfdriveD(CruiseHelper): + def __init__(self, CP=None, CP_SP=None): self.params = Params() # Ensure the current branch is cached, otherwise the first cycle lags @@ -56,6 +64,13 @@ class SelfdriveD: else: self.CP = CP + if CP_SP is None: + cloudlog.info("selfdrived is waiting for CarParamsSP") + self.CP_SP = messaging.log_from_bytes(self.params.get("CarParamsSP", block=True), custom.CarParamsSP) + cloudlog.info("selfdrived got CarParamsSP") + else: + self.CP_SP = CP_SP + self.car_events = CarSpecificEvents(self.CP) self.pose_calibrator = PoseCalibrator() @@ -64,7 +79,7 @@ class SelfdriveD: self.excessive_actuation = self.params.get("Offroad_ExcessiveActuation") is not None # Setup sockets - self.pm = messaging.PubMaster(['selfdriveState', 'onroadEvents']) + self.pm = messaging.PubMaster(['selfdriveState', 'onroadEvents'] + ['selfdriveStateSP', 'onroadEventsSP']) self.gps_location_service = get_gps_location_service(self.params) self.gps_packets = [self.gps_location_service] @@ -74,7 +89,7 @@ class SelfdriveD: # TODO: de-couple selfdrived with card/conflate on carState without introducing controls mismatches self.car_state_sock = messaging.sub_sock('carState', timeout=20) - ignore = self.sensor_packets + self.gps_packets + ['alertDebug', 'lateralManeuverPlan'] + ignore = self.sensor_packets + self.gps_packets + ['alertDebug', 'lateralManeuverPlan'] + ['modelDataV2SP'] if SIMULATION: ignore += ['driverCameraState', 'managerState'] if REPLAY: @@ -84,7 +99,7 @@ class SelfdriveD: 'carOutput', 'driverMonitoringState', 'longitudinalPlan', 'livePose', 'liveDelay', 'managerState', 'liveParameters', 'radarState', 'liveTorqueParameters', 'controlsState', 'carControl', 'driverAssistance', 'alertDebug', 'userBookmark', 'audioFeedback', - 'lateralManeuverPlan'] + \ + 'lateralManeuverPlan', 'modelDataV2SP', 'longitudinalPlanSP'] + \ self.camera_packets + self.sensor_packets + self.gps_packets, ignore_alive=ignore, ignore_avg_freq=ignore, ignore_valid=ignore, frequency=int(1/DT_CTRL)) @@ -118,13 +133,21 @@ class SelfdriveD: self.logged_comm_issue = None self.not_running_prev = None self.experimental_mode = False - self.personality = self.params.get("LongitudinalPersonality", return_default=True) + self.personality = get_sanitize_int_param( + "LongitudinalPersonality", + min(log.LongitudinalPersonality.schema.enumerants.values()), + max(log.LongitudinalPersonality.schema.enumerants.values()), + self.params + ) self.recalibrating_seen = False self.state_machine = StateMachine() self.rk = Ratekeeper(100, print_delay_threshold=None) + self.ignored_processes = {'mapd', } + # Determine startup event - self.startup_event = EventName.startup if build_metadata.openpilot.comma_remote and build_metadata.tested_channel else EventName.startupMaster + is_remote = build_metadata.openpilot.comma_remote or build_metadata.openpilot.sunnypilot_remote + self.startup_event = EventName.startup if is_remote and build_metadata.tested_channel else EventName.startupMaster if HARDWARE.get_device_type() == 'mici': self.startup_event = None if not car_recognized: @@ -140,10 +163,21 @@ class SelfdriveD: elif self.CP.passive: self.events.add(EventName.dashcamMode, static=True) + self.events_sp = EventsSP() + self.events_sp_prev = [] + + self.mads = ModularAssistiveDrivingSystem(self) + self.icbm = IntelligentCruiseButtonManagement(self.CP, self.CP_SP) + + self.car_events_sp = CarSpecificEventsSP(self.CP, self.CP_SP) + + CruiseHelper.__init__(self, self.CP) + def update_events(self, CS): """Compute onroadEvents from carState""" self.events.clear() + self.events_sp.clear() if self.sm['controlsState'].lateralControlState.which() == 'debugState': self.events.add(EventName.joystickDebug) @@ -184,12 +218,16 @@ class SelfdriveD: if not self.CP.notCar: self.events.add_from_msg(self.sm['driverMonitoringState'].events) + self.events_sp.add_from_msg(self.sm['longitudinalPlanSP'].events) # Add car events, ignore if CAN isn't valid if CS.canValid: car_events = self.car_events.update(CS, self.CS_prev, self.sm['carControl']).to_msg() self.events.add_from_msg(car_events) + car_events_sp = self.car_events_sp.update(CS, self.events).to_msg() + self.events_sp.add_from_msg(car_events_sp) + if self.CP.notCar: # wait for everything to init first if self.sm.frame > int(5. / DT_CTRL) and self.initialized: @@ -272,6 +310,13 @@ class SelfdriveD: LaneChangeState.laneChangeFinishing): self.events.add(EventName.laneChange) + # Handle lane turn + lane_turn_direction = self.sm['modelDataV2SP'].laneTurnDirection + if lane_turn_direction == TurnDirection.turnLeft: + self.events_sp.add(custom.OnroadEventSP.EventName.laneTurnLeft) + elif lane_turn_direction == TurnDirection.turnRight: + self.events_sp.add(custom.OnroadEventSP.EventName.laneTurnRight) + for i, pandaState in enumerate(self.sm['pandaStates']): # All pandas must match the list of safetyConfigs, and if outside this list, must be silent or noOutput if i < len(self.CP.safetyConfigs): @@ -298,7 +343,7 @@ class SelfdriveD: if not_running != self.not_running_prev: cloudlog.event("process_not_running", not_running=not_running, error=True) self.not_running_prev = not_running - if self.sm.recv_frame['managerState'] and not_running: + if self.sm.recv_frame['managerState'] and (not_running - self.ignored_processes): self.events.add(EventName.processNotRunning) else: if not SIMULATION and not self.rk.lagging: @@ -398,12 +443,22 @@ class SelfdriveD: if self.sm['modelV2'].frameDropPerc > 20: self.events.add(EventName.modeldLagging) - # Decrement personality on distance button press + # mute canBusMissing event if in Park, as it sometimes may trigger a false alarm with MADS in Paused state + if CS.gearShifter == car.CarState.GearShifter.park and self.mads.enabled: + self.events.remove(EventName.canBusMissing) + + CruiseHelper.update(self, CS, self.events_sp, self.experimental_mode) + + # decrement personality on distance button press if self.CP.openpilotLongitudinalControl: if any(not be.pressed and be.type == ButtonType.gapAdjustCruise for be in CS.buttonEvents): - self.personality = (self.personality - 1) % 3 - self.params.put_nonblocking('LongitudinalPersonality', self.personality) - self.events.add(EventName.personalityChanged) + if not self.experimental_mode_switched: + self.personality = (self.personality - 1) % 3 + self.params.put_nonblocking('LongitudinalPersonality', self.personality) + self.events.add(EventName.personalityChanged) + self.experimental_mode_switched = False + + self.icbm.run(CS, self.sm['carControl'], self.sm['longitudinalPlanSP'], self.is_metric) def data_sample(self): _car_state = messaging.recv_one(self.car_state_sock) @@ -460,9 +515,13 @@ class SelfdriveD: clear_event_types.add(ET.NO_ENTRY) pers = LONGITUDINAL_PERSONALITY_MAP[self.personality] - alerts = self.events.create_alerts(self.state_machine.current_alert_types, [self.CP, CS, self.sm, self.is_metric, - self.state_machine.soft_disable_timer, pers]) - self.AM.add_many(self.sm.frame, alerts) + callback_args = [self.CP, CS, self.sm, self.is_metric, + self.state_machine.soft_disable_timer, pers] + + alerts = self.events.create_alerts(self.state_machine.current_alert_types, callback_args) + alerts_sp = self.events_sp.create_alerts(self.state_machine.current_alert_types, callback_args) + + self.AM.add_many(self.sm.frame, alerts + alerts_sp) self.AM.process_alerts(self.sm.frame, clear_event_types) def publish_selfdriveState(self, CS): @@ -495,11 +554,38 @@ class SelfdriveD: self.pm.send('onroadEvents', ce_send) self.events_prev = self.events.names.copy() + # selfdriveStateSP + ss_sp_msg = messaging.new_message('selfdriveStateSP') + ss_sp_msg.valid = True + ss_sp = ss_sp_msg.selfdriveStateSP + mads = ss_sp.mads + mads.state = self.mads.state_machine.state + mads.enabled = self.mads.enabled + mads.active = self.mads.active + mads.available = self.mads.enabled_toggle + + icbm = ss_sp.intelligentCruiseButtonManagement + icbm.state = self.icbm.state + icbm.sendButton = self.icbm.cruise_button + icbm.vTarget = self.icbm.v_target + + self.pm.send('selfdriveStateSP', ss_sp_msg) + + # onroadEventsSP - logged every second or on change + if (self.sm.frame % int(1. / DT_CTRL) == 0) or (self.events_sp.names != self.events_sp_prev): + ce_send_sp = messaging.new_message('onroadEventsSP') + ce_send_sp.valid = True + ce_send_sp.onroadEventsSP.events = self.events_sp.to_msg() + self.pm.send('onroadEventsSP', ce_send_sp) + self.events_sp_prev = self.events_sp.names.copy() + def step(self): CS = self.data_sample() self.update_events(CS) if not self.CP.passive and self.initialized: self.enabled, self.active = self.state_machine.update(self.events) + if not self.CP.notCar: + self.mads.update(CS) self.update_alerts(CS) self.publish_selfdriveState(CS) @@ -513,6 +599,8 @@ class SelfdriveD: self.disengage_on_accelerator = self.params.get_bool("DisengageOnAccelerator") self.experimental_mode = self.params.get_bool("ExperimentalMode") and self.CP.openpilotLongitudinalControl self.personality = self.params.get("LongitudinalPersonality", return_default=True) + + self.mads.read_params() time.sleep(0.1) def run(self): diff --git a/selfdrive/selfdrived/tests/test_alertmanager.py b/selfdrive/selfdrived/tests/test_alertmanager.py index 030b7d4515..8f7c79878c 100644 --- a/selfdrive/selfdrived/tests/test_alertmanager.py +++ b/selfdrive/selfdrived/tests/test_alertmanager.py @@ -1,8 +1,10 @@ import random -from openpilot.selfdrive.selfdrived.events import Alert, EmptyAlert, EVENTS +from openpilot.selfdrive.selfdrived.events import Alert, EVENTS from openpilot.selfdrive.selfdrived.alertmanager import AlertManager +from openpilot.sunnypilot.selfdrive.selfdrived.events_base import EmptyAlert + class TestAlertManager: diff --git a/selfdrive/selfdrived/tests/test_alerts.py b/selfdrive/selfdrived/tests/test_alerts.py index c971806999..a1867f2289 100644 --- a/selfdrive/selfdrived/tests/test_alerts.py +++ b/selfdrive/selfdrived/tests/test_alerts.py @@ -41,7 +41,7 @@ class TestAlerts: events = log.OnroadEvent.EventName.schema.enumerants for name, e in events.items(): - if not name.endswith("DEPRECATED"): + if not name.endswith("DEPRECATED") and not name.startswith("eventReserved"): fail_msg = f"{name} @{e} not in EVENTS" assert e in EVENTS.keys(), fail_msg diff --git a/selfdrive/test/fuzzy_generation.py b/selfdrive/test/fuzzy_generation.py index 131dab47b2..9f028a8fc8 100644 --- a/selfdrive/test/fuzzy_generation.py +++ b/selfdrive/test/fuzzy_generation.py @@ -46,8 +46,8 @@ class FuzzyGenerator: def generate_struct(self, schema: capnp.lib.capnp._StructSchema, event: str | None = None) -> st.SearchStrategy[dict[str, Any]]: single_fill: tuple[str, ...] = (event,) if event else (self.draw(st.sampled_from(schema.union_fields)),) if schema.union_fields else () - fields_to_generate = schema.non_union_fields + single_fill - return st.fixed_dictionaries({field: self.generate_field(schema.fields[field]) for field in fields_to_generate if not field.endswith('DEPRECATED')}) + fields_to_generate = [f for f in schema.non_union_fields + single_fill if not f.endswith('DEPRECATED') and f != 'deprecated'] + return st.fixed_dictionaries({field: self.generate_field(schema.fields[field]) for field in fields_to_generate}) @staticmethod @cache diff --git a/selfdrive/test/longitudinal_maneuvers/plant.py b/selfdrive/test/longitudinal_maneuvers/plant.py index b8c6adb436..e82ef2f65e 100755 --- a/selfdrive/test/longitudinal_maneuvers/plant.py +++ b/selfdrive/test/longitudinal_maneuvers/plant.py @@ -51,7 +51,9 @@ class Plant: from opendbc.car.honda.values import CAR from opendbc.car.honda.interface import CarInterface - self.planner = LongitudinalPlanner(CarInterface.get_non_essential_params(CAR.HONDA_CIVIC), init_v=self.speed) + CP = CarInterface.get_non_essential_params(CAR.HONDA_CIVIC) + CP_SP = CarInterface.get_non_essential_params_sp(CP, CAR.HONDA_CIVIC) + self.planner = LongitudinalPlanner(CP, CP_SP, init_v=self.speed) @property def current_time(self): @@ -67,6 +69,9 @@ class Plant: lp = messaging.new_message('liveParameters') car_control = messaging.new_message('carControl') model = messaging.new_message('modelV2') + car_state_sp = messaging.new_message('carStateSP') + live_map_data_sp = messaging.new_message('liveMapDataSP') + gps_data = messaging.new_message('gpsLocation') a_lead = (v_lead - self.v_lead_prev)/self.ts self.v_lead_prev = v_lead @@ -133,7 +138,10 @@ class Plant: 'controlsState': control.controlsState, 'selfdriveState': ss.selfdriveState, 'liveParameters': lp.liveParameters, - 'modelV2': model.modelV2} + 'modelV2': model.modelV2, + 'carStateSP': car_state_sp.carStateSP, + 'liveMapDataSP': live_map_data_sp.liveMapDataSP, + 'gpsLocation': gps_data.gpsLocation} self.planner.update(sm) self.acceleration = self.planner.output_a_target self.speed = self.speed + self.acceleration * self.ts diff --git a/selfdrive/test/process_replay/migration.py b/selfdrive/test/process_replay/migration.py index 14b38e0481..278c366609 100644 --- a/selfdrive/test/process_replay/migration.py +++ b/selfdrive/test/process_replay/migration.py @@ -29,7 +29,8 @@ MigrationFunc = Callable[[list[MessageWithIndex]], MigrationOps] # 3. product is the message type created by the migration function, and the function will be skipped if product type already exists in lr # 4. it must return a list of operations to be applied to the logreader (replace, add, delete) # 5. all migration functions must be independent of each other -def migrate_all(lr: LogIterable, manager_states: bool = False, panda_states: bool = False, camera_states: bool = False): +def migrate_all(lr: LogIterable, manager_states: bool = False, panda_states: bool = False, camera_states: bool = False, + live_location_kalman: bool = True): migrations = [ migrate_sensorEvents, migrate_carParams, @@ -38,7 +39,6 @@ def migrate_all(lr: LogIterable, manager_states: bool = False, panda_states: boo migrate_carOutput, migrate_controlsState, migrate_carState, - migrate_liveLocationKalman, migrate_livePose, migrate_liveTracks, migrate_driverAssistance, @@ -53,6 +53,8 @@ def migrate_all(lr: LogIterable, manager_states: bool = False, panda_states: boo migrations.extend([migrate_pandaStates, migrate_peripheralState]) if camera_states: migrations.append(migrate_cameraStates) + if live_location_kalman: + migrations.append(migrate_liveLocationKalman) return migrate(lr, migrations) @@ -98,6 +100,17 @@ def migration(inputs: list[str], product: str|None=None): return decorator +def migrate_onroad_event(event: capnp.lib.capnp._DynamicStructReader): + event_dict = event.to_dict() + try: + return log.OnroadEvent(**event_dict) + except capnp.lib.capnp.KjException as e: + # Ignore legacy events the current schema no longer defines. + if "enum has no such enumerant" in str(e): + return None + raise + + @migration(inputs=["longitudinalPlan", "carParams"]) def migrate_longitudinalPlan(msgs): ops = [] @@ -214,7 +227,7 @@ def migrate_controlsState(msgs): for field in ("enabled", "active", "state", "engageable", "alertText1", "alertText2", "alertStatus", "alertSize", "alertType", "experimentalMode", "personality"): - setattr(ss, field, getattr(msg.controlsState, field+"DEPRECATED")) + setattr(ss, field, getattr(msg.controlsState.deprecated, field)) add_ops.append(m.as_reader()) return [], add_ops, [] @@ -227,10 +240,10 @@ def migrate_carState(msgs): if msg.which() == 'controlsState': last_cs = msg elif msg.which() == 'carState' and last_cs is not None: - if last_cs.controlsState.vCruiseDEPRECATED - msg.carState.vCruise > 0.1: + if last_cs.controlsState.deprecated.vCruise - msg.carState.vCruise > 0.1: msg = msg.as_builder() - msg.carState.vCruise = last_cs.controlsState.vCruiseDEPRECATED - msg.carState.vCruiseCluster = last_cs.controlsState.vCruiseClusterDEPRECATED + msg.carState.vCruise = last_cs.controlsState.deprecated.vCruise + msg.carState.vCruiseCluster = last_cs.controlsState.deprecated.vCruiseCluster ops.append((index, msg.as_reader())) return ops, [], [] @@ -292,7 +305,7 @@ def migrate_pandaStates(msgs): safety_param_migration = { "TOYOTA_PRIUS": EPS_SCALE["TOYOTA_PRIUS"] | ToyotaSafetyFlags.STOCK_LONGITUDINAL, "TOYOTA_RAV4": EPS_SCALE["TOYOTA_RAV4"] | ToyotaSafetyFlags.ALT_BRAKE, - "KIA_EV6": HyundaiSafetyFlags.EV_GAS | HyundaiSafetyFlags.CANFD_LKA_STEERING, + "KIA_EV6": HyundaiSafetyFlags.EV_GAS | HyundaiSafetyFlags.CANFD_LKA_STEER_MSG, "CHEVROLET_VOLT": GMSafetyFlags.EV, "CHEVROLET_BOLT_EUV": GMSafetyFlags.EV | GMSafetyFlags.HW_CAM, } @@ -456,12 +469,13 @@ def migrate_onroadEvents(msgs): for event in msg.onroadEventsDEPRECATED: try: if not str(event.name).endswith('DEPRECATED'): - # dict converts name enum into string representation - onroadEvents.append(log.OnroadEvent(**event.to_dict())) + migrated_event = migrate_onroad_event(event) + if migrated_event is not None: + onroadEvents.append(migrated_event) except RuntimeError: # Member was null traceback.print_exc() - new_msg = messaging.new_message('onroadEvents', len(msg.onroadEventsDEPRECATED)) + new_msg = messaging.new_message('onroadEvents', len(onroadEvents)) new_msg.valid = msg.valid new_msg.logMonoTime = msg.logMonoTime new_msg.onroadEvents = onroadEvents @@ -476,11 +490,12 @@ def migrate_driverMonitoringState(msgs): for index, msg in msgs: msg = msg.as_builder() events = [] - for event in msg.driverMonitoringState.eventsDEPRECATED: + for event in msg.driverMonitoringState.deprecated.events: try: if not str(event.name).endswith('DEPRECATED'): - # dict converts name enum into string representation - events.append(log.OnroadEvent(**event.to_dict())) + migrated_event = migrate_onroad_event(event) + if migrated_event is not None: + events.append(migrated_event) except RuntimeError: # Member was null traceback.print_exc() diff --git a/selfdrive/test/process_replay/process_replay.py b/selfdrive/test/process_replay/process_replay.py index a74dfcbb43..65587d44b1 100755 --- a/selfdrive/test/process_replay/process_replay.py +++ b/selfdrive/test/process_replay/process_replay.py @@ -26,6 +26,7 @@ from openpilot.common.timeout import Timeout from openpilot.common.realtime import DT_CTRL from openpilot.system.camerad.cameras.nv12_info import get_nv12_info from openpilot.system.manager.process_config import managed_processes +from openpilot.selfdrive.car.card import convert_to_capnp from openpilot.selfdrive.test.process_replay.vision_meta import meta_from_camera_state, available_streams from openpilot.selfdrive.test.process_replay.migration import migrate_all from openpilot.selfdrive.test.process_replay.capture import ProcessOutputCapture @@ -355,6 +356,7 @@ def get_car_params_callback(rc, pm, msgs, fingerprint): if fingerprint: CarInterface = interfaces[fingerprint] CP = CarInterface.get_non_essential_params(fingerprint) + CP_SP = CarInterface.get_non_essential_params_sp(CP, fingerprint) else: can_msgs = ([CanData(can.address, can.dat, can.src) for can in m.can] for m in msgs if m.which() == "can") cached_params_raw = params.get("CarParamsCache") @@ -370,9 +372,11 @@ def get_car_params_callback(rc, pm, msgs, fingerprint): with car.CarParams.from_bytes(cached_params_raw) as _cached_params: cached_params = _cached_params - CP = get_car(can_recv, lambda _msgs: None, lambda obd: None, params.get_bool("AlphaLongitudinalEnabled"), False, cached_params=cached_params).CP + _CI = get_car(can_recv, lambda _msgs: None, lambda obd: None, params.get_bool("AlphaLongitudinalEnabled"), False, cached_params=cached_params) + CP, CP_SP = _CI.CP, _CI.CP_SP params.put("CarParams", CP.to_bytes()) + params.put("CarParamsSP", convert_to_capnp(CP_SP).to_bytes()) def card_rcv_callback(msg, cfg, frame): @@ -508,7 +512,7 @@ CONFIGS = [ pubs=[ "cameraOdometry", "accelerometer", "gyroscope", "liveCalibration", "carState" ], - subs=["livePose"], + subs=["liveLocationKalman", "livePose"], ignore=["logMonoTime"], should_recv_callback=MessageBasedRcvCallback("cameraOdometry"), tolerance=NUMPY_TOLERANCE, diff --git a/selfdrive/ui/feedback/feedbackd.py b/selfdrive/ui/feedback/feedbackd.py index 2d131a0d5e..24f27874eb 100755 --- a/selfdrive/ui/feedback/feedbackd.py +++ b/selfdrive/ui/feedback/feedbackd.py @@ -12,7 +12,7 @@ ButtonType = car.CarState.ButtonEvent.Type def main(): params = Params() pm = messaging.PubMaster(['userBookmark', 'audioFeedback']) - sm = messaging.SubMaster(['rawAudioData', 'bookmarkButton', 'carState']) + sm = messaging.SubMaster(['rawAudioData', 'bookmarkButton', 'carState', 'selfdriveStateSP']) should_record_audio = False block_num = 0 waiting_for_release = False @@ -23,7 +23,8 @@ def main(): should_send_bookmark = False # TODO: https://github.com/commaai/openpilot/issues/36015 - if False and sm.updated['carState'] and sm['carState'].canValid: + # only allow the LKAS button to record feedback when MADS is disabled + if False and sm.updated['carState'] and sm['carState'].canValid and not sm['selfdriveStateSP'].mads.available: for be in sm['carState'].buttonEvents: if be.type == ButtonType.lkas: if be.pressed: diff --git a/selfdrive/ui/installer/installer.cc b/selfdrive/ui/installer/installer.cc index fb661b966d..0832fbb628 100644 --- a/selfdrive/ui/installer/installer.cc +++ b/selfdrive/ui/installer/installer.cc @@ -144,6 +144,7 @@ int cachedFetch(const std::string &cache) { LOGD("Fetching with cache: %s", cache.c_str()); run(util::string_format("cp -rp %s %s", cache.c_str(), TMP_INSTALL_PATH).c_str()); + run(util::string_format("cd %s && git remote set-url origin %s", TMP_INSTALL_PATH, GIT_URL.c_str()).c_str()); run(util::string_format("cd %s && git remote set-branches --add origin %s", TMP_INSTALL_PATH, migrated_branch.c_str()).c_str()); renderProgress(10); diff --git a/selfdrive/ui/layouts/home.py b/selfdrive/ui/layouts/home.py index 183c2d4588..be231dcd4b 100644 --- a/selfdrive/ui/layouts/home.py +++ b/selfdrive/ui/layouts/home.py @@ -228,6 +228,6 @@ class HomeLayout(Widget): self._prev_alerts_present = alerts_present def _get_version_text(self) -> str: - brand = "openpilot" + brand = "sunnypilot" description = self.params.get("UpdaterCurrentDescription") return f"{brand} {description}" if description else brand diff --git a/selfdrive/ui/layouts/main.py b/selfdrive/ui/layouts/main.py index 15d44e24da..2adecfeaa8 100644 --- a/selfdrive/ui/layouts/main.py +++ b/selfdrive/ui/layouts/main.py @@ -10,6 +10,9 @@ from openpilot.selfdrive.ui.ui_state import device, ui_state from openpilot.system.ui.widgets import Widget from openpilot.selfdrive.ui.layouts.onboarding import OnboardingWindow +if gui_app.sunnypilot_ui(): + from openpilot.selfdrive.ui.sunnypilot.layouts.settings.settings import SettingsLayoutSP as SettingsLayout + class MainState(IntEnum): HOME = 0 diff --git a/selfdrive/ui/layouts/onboarding.py b/selfdrive/ui/layouts/onboarding.py index 37205b0e26..452ed53c08 100644 --- a/selfdrive/ui/layouts/onboarding.py +++ b/selfdrive/ui/layouts/onboarding.py @@ -11,7 +11,9 @@ from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.button import Button, ButtonStyle from openpilot.system.ui.widgets.label import Label from openpilot.selfdrive.ui.ui_state import ui_state -from openpilot.system.version import terms_version, training_version +from openpilot.system.version import terms_version, training_version, terms_version_sp + +from openpilot.selfdrive.ui.sunnypilot.layouts.onboarding import SunnylinkOnboarding DEBUG = False @@ -33,6 +35,7 @@ class OnboardingState(IntEnum): TERMS = 0 ONBOARDING = 1 DECLINE = 2 + SUNNYLINK_CONSENT = 3 class TrainingGuide(Widget): @@ -112,15 +115,15 @@ class TermsPage(Widget): self._on_accept = on_accept self._on_decline = on_decline - self._title = Label(tr("Welcome to openpilot"), font_size=90, font_weight=FontWeight.BOLD, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT) - self._desc = Label(tr("You must accept the Terms and Conditions to use openpilot. Read the latest terms at https://comma.ai/terms before continuing."), + self._title = Label(tr("Welcome to sunnypilot"), font_size=90, font_weight=FontWeight.BOLD, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT) + self._desc = Label(tr("You must accept the Terms of Service to use sunnypilot. Read the latest terms at https://sunnypilot.ai/terms before continuing."), font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT) self._decline_btn = Button(tr("Decline"), click_callback=on_decline) self._accept_btn = Button(tr("Agree"), button_style=ButtonStyle.PRIMARY, click_callback=on_accept) def _render(self, _): - welcome_x = self._rect.x + 165 + welcome_x = self._rect.x + 95 welcome_y = self._rect.y + 165 welcome_rect = rl.Rectangle(welcome_x, welcome_y, self._rect.width - welcome_x, 90) self._title.render(welcome_rect) @@ -146,10 +149,10 @@ class TermsPage(Widget): class DeclinePage(Widget): def __init__(self, back_callback=None): super().__init__() - self._text = Label(tr("You must accept the Terms and Conditions in order to use openpilot."), + self._text = Label(tr("You must accept the Terms of Service in order to use sunnypilot."), font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT) self._back_btn = Button(tr("Back"), click_callback=back_callback) - self._uninstall_btn = Button(tr("Decline, uninstall openpilot"), button_style=ButtonStyle.DANGER, + self._uninstall_btn = Button(tr("Decline, uninstall sunnypilot"), button_style=ButtonStyle.DANGER, click_callback=self._on_uninstall_clicked) def _on_uninstall_clicked(self): @@ -183,9 +186,21 @@ class OnboardingWindow(Widget): self._training_guide: TrainingGuide | None = None self._decline_page = DeclinePage(back_callback=self._on_decline_back) + # sunnylink consent pages + self._accepted_terms = self._accepted_terms and ui_state.params.get("HasAcceptedTermsSP") == terms_version_sp + self._sunnylink = SunnylinkOnboarding() + if not self._accepted_terms: + self._state = OnboardingState.TERMS + elif not self._sunnylink.completed: + self._state = OnboardingState.SUNNYLINK_CONSENT + elif not self._training_done: + self._state = OnboardingState.ONBOARDING + else: + self._state = OnboardingState.ONBOARDING + @property def completed(self) -> bool: - return self._accepted_terms and self._training_done + return self._accepted_terms and self._sunnylink.completed and self._training_done def _on_terms_declined(self): self._state = OnboardingState.DECLINE @@ -195,8 +210,12 @@ class OnboardingWindow(Widget): def _on_terms_accepted(self): ui_state.params.put("HasAcceptedTerms", terms_version) - self._state = OnboardingState.ONBOARDING - if self._training_done: + ui_state.params.put("HasAcceptedTermsSP", terms_version_sp) + if not self._sunnylink.completed: + self._state = OnboardingState.SUNNYLINK_CONSENT + elif not self._training_done: + self._state = OnboardingState.ONBOARDING + else: gui_app.pop_widget() def _on_completed_training(self): @@ -208,8 +227,18 @@ class OnboardingWindow(Widget): if self._state == OnboardingState.TERMS: self._terms.render(self._rect) - if self._state == OnboardingState.ONBOARDING: - self._training_guide.render(self._rect) + elif self._state == OnboardingState.SUNNYLINK_CONSENT: + self._sunnylink.render(self._rect) + if self._sunnylink.completed: + if not self._training_done: + self._state = OnboardingState.ONBOARDING + else: + gui_app.pop_widget() + elif self._state == OnboardingState.ONBOARDING: + if not self._training_done: + self._training_guide.render(self._rect) + else: + gui_app.pop_widget() elif self._state == OnboardingState.DECLINE: self._decline_page.render(self._rect) return -1 diff --git a/selfdrive/ui/layouts/settings/developer.py b/selfdrive/ui/layouts/settings/developer.py index c61a406858..b632e4e0d2 100644 --- a/selfdrive/ui/layouts/settings/developer.py +++ b/selfdrive/ui/layouts/settings/developer.py @@ -9,6 +9,9 @@ from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.multilang import tr, tr_noop from openpilot.system.ui.widgets import DialogResult +if gui_app.sunnypilot_ui(): + from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp as toggle_item + # Description constants DESCRIPTIONS = { 'enable_adb': tr_noop( @@ -20,10 +23,11 @@ DESCRIPTIONS = { "other than your own. A comma employee will NEVER ask you to add their GitHub username." ), 'alpha_longitudinal': tr_noop( - "WARNING: openpilot longitudinal control is in alpha for this car and will disable Automatic Emergency Braking (AEB).

" + - "On this car, openpilot defaults to the car's built-in ACC instead of openpilot's longitudinal control. " + - "Enable this to switch to openpilot longitudinal control. Enabling Experimental mode is recommended when enabling openpilot longitudinal control alpha. " + - "Changing this setting will restart openpilot if the car is powered on." + "WARNING: sunnypilot longitudinal control is in alpha for this car and will disable Automatic Emergency Braking (AEB).

" + + "On this car, sunnypilot defaults to the car's built-in ACC instead of sunnypilot's longitudinal control. " + + "Enable this to switch to sunnypilot longitudinal control. " + + "Enabling Experimental mode is recommended when enabling sunnypilot longitudinal control alpha. " + + "Changing this setting will restart sunnypilot if the car is powered on." ), } @@ -75,7 +79,7 @@ class DeveloperLayout(Widget): ) self._alpha_long_toggle = toggle_item( - lambda: tr("openpilot Longitudinal Control (Alpha)"), + lambda: tr("sunnypilot Longitudinal Control (Alpha)"), description=lambda: tr(DESCRIPTIONS["alpha_longitudinal"]), initial_state=self._params.get_bool("AlphaLongitudinalEnabled"), callback=self._on_alpha_long_enabled, @@ -159,6 +163,7 @@ class DeveloperLayout(Widget): self._params.put_bool("ShowDebugInfo", state) gui_app.set_show_touches(state) gui_app.set_show_fps(state) + gui_app.set_show_mouse_coords(state) def _on_enable_adb(self, state: bool): self._params.put_bool("AdbEnabled", state) diff --git a/selfdrive/ui/layouts/settings/device.py b/selfdrive/ui/layouts/settings/device.py index 5c3dae869b..126ad22a3a 100644 --- a/selfdrive/ui/layouts/settings/device.py +++ b/selfdrive/ui/layouts/settings/device.py @@ -18,12 +18,15 @@ from openpilot.system.ui.widgets.list_view import text_item, button_item, dual_b from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog from openpilot.system.ui.widgets.scroller_tici import Scroller +if gui_app.sunnypilot_ui(): + from openpilot.system.ui.sunnypilot.widgets.list_view import button_item_sp as button_item + # Description constants DESCRIPTIONS = { 'pair_device': tr_noop("Pair your device with comma connect (connect.comma.ai) and claim your comma prime offer."), 'driver_camera': tr_noop("Preview the driver facing camera to ensure that driver monitoring has good visibility. (vehicle must be off)"), - 'reset_calibration': tr_noop("openpilot requires the device to be mounted within 4° left or right and within 5° up or 9° down."), - 'review_guide': tr_noop("Review the rules, features, and limitations of openpilot"), + 'reset_calibration': tr_noop("sunnypilot requires the device to be mounted within 4° left or right and within 5° up or 9° down."), + 'review_guide': tr_noop("Review the rules, features, and limitations of sunnypilot"), } @@ -154,8 +157,8 @@ class DeviceLayout(Widget): cloudlog.exception("invalid LiveTorqueParameters") desc += "

" - desc += tr("openpilot is continuously calibrating, resetting is rarely required. " + - "Resetting calibration will restart openpilot if the car is powered on.") + desc += tr("sunnypilot is continuously calibrating, resetting is rarely required. " + + "Resetting calibration will restart sunnypilot if the car is powered on.") self._reset_calib_btn.set_description(desc) diff --git a/selfdrive/ui/layouts/settings/firehose.py b/selfdrive/ui/layouts/settings/firehose.py index ea83e962e6..18514feeb9 100644 --- a/selfdrive/ui/layouts/settings/firehose.py +++ b/selfdrive/ui/layouts/settings/firehose.py @@ -9,7 +9,7 @@ from openpilot.selfdrive.ui.mici.layouts.settings.firehose import FirehoseLayout TITLE = tr_noop("Firehose Mode") DESCRIPTION = tr_noop( - "openpilot learns to drive by watching humans, like you, drive.\n\n" + "sunnypilot learns to drive by watching humans, like you, drive.\n\n" + "Firehose Mode allows you to maximize your training data uploads to improve " + "openpilot's driving models. More data means bigger models, which means better Experimental Mode." ) diff --git a/selfdrive/ui/layouts/settings/software.py b/selfdrive/ui/layouts/settings/software.py index 83a66ef3bd..f42682e2f7 100644 --- a/selfdrive/ui/layouts/settings/software.py +++ b/selfdrive/ui/layouts/settings/software.py @@ -11,6 +11,9 @@ from openpilot.system.ui.widgets.list_view import button_item, text_item, ListIt from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog from openpilot.system.ui.widgets.scroller_tici import Scroller +if gui_app.sunnypilot_ui(): + from openpilot.system.ui.sunnypilot.widgets.list_view import button_item_sp as button_item + # TODO: remove this. updater fails to respond on startup if time is not correct UPDATED_TIMEOUT = 10 # seconds to wait for updated to respond @@ -66,7 +69,6 @@ class SoftwareLayout(Widget): # Branch switcher self._branch_btn = button_item(lambda: tr("Target Branch"), lambda: tr("SELECT"), callback=self._on_select_branch) - self._branch_btn.set_visible(not ui_state.params.get_bool("IsTestedBranch")) self._branch_btn.action_item.set_value(ui_state.params.get("UpdaterTargetBranch") or "") self._branch_dialog: MultiOptionDialog | None = None diff --git a/selfdrive/ui/layouts/settings/toggles.py b/selfdrive/ui/layouts/settings/toggles.py index 711392bdb0..9923f3a356 100644 --- a/selfdrive/ui/layouts/settings/toggles.py +++ b/selfdrive/ui/layouts/settings/toggles.py @@ -9,25 +9,29 @@ from openpilot.system.ui.lib.multilang import tr, tr_noop from openpilot.system.ui.widgets import DialogResult from openpilot.selfdrive.ui.ui_state import ui_state +if gui_app.sunnypilot_ui(): + from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp as toggle_item + from openpilot.system.ui.sunnypilot.widgets.list_view import multiple_button_item_sp as multiple_button_item + PERSONALITY_TO_INT = log.LongitudinalPersonality.schema.enumerants # Description constants DESCRIPTIONS = { "OpenpilotEnabledToggle": tr_noop( - "Use the openpilot system for adaptive cruise control and lane keep driver assistance. " + + "Use the sunnypilot system for adaptive cruise control and lane keep driver assistance. " + "Your attention is required at all times to use this feature." ), - "DisengageOnAccelerator": tr_noop("When enabled, pressing the accelerator pedal will disengage openpilot."), + "DisengageOnAccelerator": tr_noop("When enabled, pressing the accelerator pedal will disengage sunnypilot."), "LongitudinalPersonality": tr_noop( - "Standard is recommended. In aggressive mode, openpilot will follow lead cars closer and be more aggressive with the gas and brake. " + - "In relaxed mode openpilot will stay further away from lead cars. On supported cars, you can cycle through these personalities with " + + "Standard is recommended. In aggressive mode, sunnypilot will follow lead cars closer and be more aggressive with the gas and brake. " + + "In relaxed mode sunnypilot will stay further away from lead cars. On supported cars, you can cycle through these personalities with " + "your steering wheel distance button." ), "IsLdwEnabled": tr_noop( "Receive alerts to steer back into the lane when your vehicle drifts over a detected lane line " + "without a turn signal activated while driving over 31 mph (50 km/h)." ), - "AlwaysOnDM": tr_noop("Enable driver monitoring even when openpilot is not engaged."), + "AlwaysOnDM": tr_noop("Enable driver monitoring even when sunnypilot is not engaged."), 'RecordFront': tr_noop("Upload data from the driver facing camera and help improve the driver monitoring algorithm."), "IsMetric": tr_noop("Display speed in km/h instead of mph."), "RecordAudio": tr_noop("Record and store microphone audio while driving. The audio will be included in the dashcam video in comma connect."), @@ -43,7 +47,7 @@ class TogglesLayout(Widget): # param, title, desc, icon, needs_restart self._toggle_defs = { "OpenpilotEnabledToggle": ( - lambda: tr("Enable openpilot"), + lambda: tr("Enable sunnypilot"), DESCRIPTIONS["OpenpilotEnabledToggle"], "chffr_wheel.png", True, @@ -96,7 +100,7 @@ class TogglesLayout(Widget): lambda: tr("Driving Personality"), lambda: tr(DESCRIPTIONS["LongitudinalPersonality"]), buttons=[lambda: tr("Aggressive"), lambda: tr("Standard"), lambda: tr("Relaxed")], - button_width=255, + button_width=300, callback=self._set_longitudinal_personality, selected_index=self._params.get("LongitudinalPersonality", return_default=True), icon="speed_limit.png" @@ -122,7 +126,7 @@ class TogglesLayout(Widget): # Make description callable for live translation additional_desc = "" if needs_restart and not locked: - additional_desc = tr("Changing this setting will restart openpilot if the car is powered on.") + additional_desc = tr("Changing this setting will restart sunnypilot if the car is powered on.") toggle.set_description(lambda og_desc=toggle.description, add_desc=additional_desc: tr(og_desc) + (" " + tr(add_desc) if add_desc else "")) # track for engaged state updates @@ -156,10 +160,10 @@ class TogglesLayout(Widget): ui_state.update_params() e2e_description = tr( - "openpilot defaults to driving in chill mode. Experimental mode enables alpha-level features that aren't ready for chill mode. " + + "sunnypilot defaults to driving in chill mode. Experimental mode enables alpha-level features that aren't ready for chill mode. " + "Experimental features are listed below:
" + "

End-to-End Longitudinal Control


" + - "Let the driving model control the gas and brakes. openpilot will drive as it thinks a human would, including stopping for red lights and stop signs. " + + "Let the driving model control the gas and brakes. sunnypilot will drive as it thinks a human would, including stopping for red lights and stop signs. " + "Since the driving model decides the speed to drive, the set speed will only act as an upper bound. This is an alpha quality feature; " + "mistakes should be expected.
" + "

New Driving Visualization


" + @@ -181,13 +185,13 @@ class TogglesLayout(Widget): unavailable = tr("Experimental mode is currently unavailable on this car since the car's stock ACC is used for longitudinal control.") - long_desc = unavailable + " " + tr("openpilot longitudinal control may come in a future update.") + long_desc = unavailable + " " + tr("sunnypilot longitudinal control may come in a future update.") if ui_state.CP.alphaLongitudinalAvailable: if self._is_release: - long_desc = unavailable + " " + tr("An alpha version of openpilot longitudinal control can be tested, along with " + + long_desc = unavailable + " " + tr("An alpha version of sunnypilot longitudinal control can be tested, along with " + "Experimental mode, on non-release branches.") else: - long_desc = tr("Enable the openpilot longitudinal control (alpha) toggle to allow Experimental mode.") + long_desc = tr("Enable the sunnypilot longitudinal control (alpha) toggle to allow Experimental mode.") self._toggles["ExperimentalMode"].set_description("" + long_desc + "

" + e2e_description) else: diff --git a/selfdrive/ui/layouts/sidebar.py b/selfdrive/ui/layouts/sidebar.py index a7f3a46279..1dad597ca3 100644 --- a/selfdrive/ui/layouts/sidebar.py +++ b/selfdrive/ui/layouts/sidebar.py @@ -9,6 +9,8 @@ from openpilot.system.ui.lib.multilang import tr, tr_noop from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.widgets import Widget +from openpilot.selfdrive.ui.sunnypilot.layouts.sidebar import SidebarSP + SIDEBAR_WIDTH = 300 METRIC_HEIGHT = 126 METRIC_WIDTH = 240 @@ -62,9 +64,10 @@ class MetricData: self.color = color -class Sidebar(Widget): +class Sidebar(Widget, SidebarSP): def __init__(self): - super().__init__() + Widget.__init__(self) + SidebarSP.__init__(self) self._net_type = NETWORK_TYPES.get(NetworkType.none) self._net_strength = 0 @@ -112,6 +115,7 @@ class Sidebar(Widget): self._update_temperature_status(device_state) self._update_connection_status(device_state) self._update_panda_status() + SidebarSP._update_sunnylink_status(self) def _update_network_status(self, device_state): self._net_type = NETWORK_TYPES.get(device_state.networkType.raw, tr_noop("Unknown")) @@ -200,6 +204,13 @@ class Sidebar(Widget): rl.draw_text_ex(self._font_regular, tr(self._net_type), text_pos, FONT_SIZE, 0, Colors.WHITE) def _draw_metrics(self, rect: rl.Rectangle): + if gui_app.sunnypilot_ui(): + metrics, start_y, spacing = SidebarSP._draw_metrics_w_sunnylink(self, rect, self._temp_status, self._panda_status, self._connect_status) + for idx, metric in enumerate(metrics): + self._draw_metric(rect, metric, start_y + idx * spacing) + + return + metrics = [(self._temp_status, 338), (self._panda_status, 496), (self._connect_status, 654)] for metric, y_offset in metrics: diff --git a/selfdrive/ui/mici/layouts/home.py b/selfdrive/ui/mici/layouts/home.py index 77b665f5d8..8200089c28 100644 --- a/selfdrive/ui/mici/layouts/home.py +++ b/selfdrive/ui/mici/layouts/home.py @@ -103,7 +103,7 @@ class MiciHomeLayout(Widget): self._mic_icon, ], spacing=18) - self._openpilot_label = UnifiedLabel("openpilot", font_size=96, font_weight=FontWeight.DISPLAY, max_width=480, wrap_text=False) + self._openpilot_label = UnifiedLabel("sunnypilot", font_size=96, font_weight=FontWeight.DISPLAY, max_width=480, wrap_text=False) self._version_label = UnifiedLabel("", font_size=36, font_weight=FontWeight.ROMAN, max_width=480, wrap_text=False) self._large_version_label = UnifiedLabel("", font_size=64, text_color=rl.GRAY, font_weight=FontWeight.ROMAN, max_width=480, wrap_text=False) self._date_label = UnifiedLabel("", font_size=36, text_color=rl.GRAY, font_weight=FontWeight.ROMAN, max_width=480, wrap_text=False) diff --git a/selfdrive/ui/mici/layouts/main.py b/selfdrive/ui/mici/layouts/main.py index 95258e2795..2f41e1f172 100644 --- a/selfdrive/ui/mici/layouts/main.py +++ b/selfdrive/ui/mici/layouts/main.py @@ -10,6 +10,9 @@ from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.scroller import Scroller from openpilot.system.ui.lib.application import gui_app +if gui_app.sunnypilot_ui(): + from openpilot.selfdrive.ui.sunnypilot.mici.layouts.settings import SettingsLayoutSP as SettingsLayout + ONROAD_DELAY = 2.5 # seconds diff --git a/selfdrive/ui/mici/layouts/offroad_alerts.py b/selfdrive/ui/mici/layouts/offroad_alerts.py index 0dae5d2075..57ddfdbc48 100644 --- a/selfdrive/ui/mici/layouts/offroad_alerts.py +++ b/selfdrive/ui/mici/layouts/offroad_alerts.py @@ -253,7 +253,7 @@ class MiciOffroadAlerts(Scroller): parts = new_desc.split(" / ") if len(parts) > 3: version, date = parts[0], parts[3] - version_string = f"\nopenpilot {version}, {date}\n" + version_string = f"\nsunnypilot {version}, {date}\n" update_alert_data.text = f"Update available {version_string}. Click to update. Read the release notes at blog.comma.ai." update_alert_data.visible = True diff --git a/selfdrive/ui/mici/layouts/onboarding.py b/selfdrive/ui/mici/layouts/onboarding.py index b918bf6ef6..d6d3f70330 100644 --- a/selfdrive/ui/mici/layouts/onboarding.py +++ b/selfdrive/ui/mici/layouts/onboarding.py @@ -12,11 +12,13 @@ from openpilot.system.ui.widgets.nav_widget import NavWidget from openpilot.system.ui.mici_setup import GreyBigButton, BigPillButton from openpilot.system.ui.widgets.label import gui_label from openpilot.system.ui.lib.multilang import tr -from openpilot.system.version import terms_version, training_version +from openpilot.system.version import terms_version, training_version, terms_version_sp +from openpilot.system.version import sunnylink_consent_version, sunnylink_consent_declined from openpilot.selfdrive.ui.ui_state import ui_state, device from openpilot.selfdrive.ui.mici.widgets.dialog import BigConfirmationCircleButton from openpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer from openpilot.selfdrive.ui.mici.onroad.driver_camera_dialog import BaseDriverCameraDialog +from openpilot.selfdrive.ui.sunnypilot.mici.layouts.onboarding import SunnylinkConsentPage class DriverCameraSetupDialog(BaseDriverCameraDialog): @@ -61,7 +63,7 @@ class TrainingGuidePreDMTutorial(NavScroller): GreyBigButton("driver monitoring\ncheck", "scroll to continue", gui_app.texture("icons_mici/setup/green_dm.png", 64, 64)), GreyBigButton("", "Next, we'll check if comma four can detect the driver properly."), - GreyBigButton("", "openpilot uses the cabin camera to check if the driver is distracted."), + GreyBigButton("", "sunnypilot uses the cabin camera to check if the driver is distracted."), GreyBigButton("", "If it does not have a clear view of the driver, unplug and remount before continuing."), continue_button, ]) @@ -233,7 +235,7 @@ class TrainingGuideRecordFront(NavScroller): self._scroller.add_widgets([ GreyBigButton("driver camera data", "do you want to share video data for training?", gui_app.texture("icons_mici/setup/green_dm.png", 64, 64)), - GreyBigButton("", "Sharing your data with comma helps improve openpilot for everyone."), + GreyBigButton("", "Sharing your data with comma helps improve openpilot and sunnypilot for everyone."), self._accept_button, self._decline_button, ]) @@ -247,9 +249,9 @@ class TrainingGuideAttentionNotice(Scroller): continue_button.set_click_callback(continue_callback) self._scroller.add_widgets([ - GreyBigButton("what is openpilot?", "scroll to continue", + GreyBigButton("what is sunnypilot?", "scroll to continue", gui_app.texture("icons_mici/setup/green_info.png", 64, 64)), - GreyBigButton("", "1. openpilot is a driver assistance system."), + GreyBigButton("", "1. sunnypilot is a driver assistance system."), GreyBigButton("", "2. You must pay attention at all times."), GreyBigButton("", "3. You must be ready to take over at any time."), GreyBigButton("", "4. You are fully responsible for driving the car."), @@ -318,15 +320,15 @@ class TermsPage(Scroller): self._decline_button = BigConfirmationCircleButton("decline &\nuninstall", gui_app.texture("icons_mici/setup/cancel.png", 64, 64), on_decline, red=True, exit_on_confirm=False) - self._terms_header = GreyBigButton("terms and\nconditions", "scroll to continue", + self._terms_header = GreyBigButton("terms of\nservice", "scroll to continue", gui_app.texture("icons_mici/setup/green_info.png", 64, 64)) - self._must_accept_card = GreyBigButton("", "You must accept the Terms & Conditions to use openpilot.") + self._must_accept_card = GreyBigButton("", "You must accept the Terms of Service to use sunnypilot.") self._scroller.add_widgets([ self._terms_header, - GreyBigButton("swipe for QR code", "or go to https://comma.ai/terms", + GreyBigButton("swipe for QR code", "or go to https://sunnypilot.ai/terms", gui_app.texture("icons_mici/setup/small_slider/slider_arrow.png", 64, 56, flip_x=True)), - QRCodeWidget("https://comma.ai/terms"), + QRCodeWidget("https://sunnypilot.ai/terms"), self._must_accept_card, self._accept_button, self._decline_button, @@ -341,17 +343,29 @@ class OnboardingWindow(Widget): def __init__(self, completed_callback: Callable[[], None]): super().__init__() self._completed_callback = completed_callback - self._accepted_terms: bool = ui_state.params.get("HasAcceptedTerms") == terms_version + self._accepted_terms: bool = (ui_state.params.get("HasAcceptedTerms") == terms_version and + ui_state.params.get("HasAcceptedTermsSP") == terms_version_sp) self._training_done: bool = ui_state.params.get("CompletedTrainingVersion") == training_version + self._sunnylink_consent_done: bool = ui_state.params.get("CompletedSunnylinkConsentVersion") in { + sunnylink_consent_version, sunnylink_consent_declined + } self.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) - # Windows + # Windows — all pushed onto nav stack, _terms is always rendered as base layer self._terms = TermsPage(on_accept=self._on_terms_accepted, on_decline=self._on_uninstall) self._terms.set_enabled(lambda: self.enabled) # for nav stack + + self._sunnylink_consent = SunnylinkConsentPage( + on_accept=self._on_sunnylink_accepted, + on_decline=self._on_sunnylink_declined, + ) + self._training_guide = TrainingGuide(completed_callback=self._on_completed_training) self._training_guide.set_enabled(lambda: self.enabled) # for nav stack + self._needs_initial_push = False + def _on_uninstall(self): ui_state.params.put_bool("DoUninstall", True) @@ -359,6 +373,7 @@ class OnboardingWindow(Widget): super().show_event() device.set_override_interactive_timeout(300) device.set_offroad_brightness(100) + self._needs_initial_push = True def hide_event(self): super().hide_event() @@ -368,7 +383,7 @@ class OnboardingWindow(Widget): @property def completed(self) -> bool: - return self._accepted_terms and self._training_done + return self._accepted_terms and self._sunnylink_consent_done and self._training_done def close(self): ui_state.params.put_bool_nonblocking("IsDriverViewEnabled", False) @@ -376,12 +391,47 @@ class OnboardingWindow(Widget): def _on_terms_accepted(self): ui_state.params.put("HasAcceptedTerms", terms_version) - gui_app.push_widget(self._training_guide) + ui_state.params.put("HasAcceptedTermsSP", terms_version_sp) + self._accepted_terms = True + if not self._sunnylink_consent_done: + gui_app.push_widget(self._sunnylink_consent) + elif not self._training_done: + gui_app.push_widget(self._training_guide) + else: + self.close() + + def _on_sunnylink_accepted(self): + ui_state.params.put("CompletedSunnylinkConsentVersion", sunnylink_consent_version) + ui_state.params.put_bool("SunnylinkEnabled", True) + self._sunnylink_consent_done = True + if not self._training_done: + gui_app.push_widget(self._training_guide) + else: + self.close() + + def _on_sunnylink_declined(self): + ui_state.params.put("CompletedSunnylinkConsentVersion", sunnylink_consent_declined) + ui_state.params.put_bool("SunnylinkEnabled", False) + self._sunnylink_consent_done = True + if not self._training_done: + gui_app.push_widget(self._training_guide) + else: + self.close() def _on_completed_training(self): ui_state.params.put("CompletedTrainingVersion", training_version) + self._training_done = True self.close() def _render(self, _): rl.draw_rectangle_rec(self._rect, rl.BLACK) + + # Deferred from show_event to avoid nested push_widget re-enable bug + if self._needs_initial_push: + self._needs_initial_push = False + if self._accepted_terms and not self._sunnylink_consent_done: + gui_app.push_widget(self._sunnylink_consent) + elif self._accepted_terms and self._sunnylink_consent_done and not self._training_done: + gui_app.push_widget(self._training_guide) + self._terms.render(self._rect) diff --git a/selfdrive/ui/mici/layouts/settings/device.py b/selfdrive/ui/mici/layouts/settings/device.py index 3c165b5bb3..e0d89a5419 100644 --- a/selfdrive/ui/mici/layouts/settings/device.py +++ b/selfdrive/ui/mici/layouts/settings/device.py @@ -172,7 +172,7 @@ class UpdateOpenpilotBigButton(BigButton): self._txt_update_icon = gui_app.texture("icons_mici/settings/device/update.png", 64, 75) self._txt_reboot_icon = gui_app.texture("icons_mici/settings/device/reboot.png", 64, 70) self._txt_up_to_date_icon = gui_app.texture("icons_mici/settings/device/up_to_date.png", 64, 64) - super().__init__("update openpilot", "", self._txt_update_icon) + super().__init__("update sunnypilot", "", self._txt_update_icon) self._waiting_for_updater_t: float | None = None self._hide_value_t: float | None = None @@ -211,7 +211,7 @@ class UpdateOpenpilotBigButton(BigButton): if value: self.set_text("") else: - self.set_text("update openpilot") + self.set_text("update sunnypilot") def _update_state(self): super()._update_state() @@ -312,7 +312,7 @@ class DeviceLayoutMici(NavScroller): reset_calibration_btn = EngagedConfirmationButton("reset calibration", "reset", gui_app.texture("icons_mici/settings/device/lkas.png", 122, 64), reset_calibration_callback) - uninstall_openpilot_btn = EngagedConfirmationButton("uninstall openpilot", "uninstall", + uninstall_openpilot_btn = EngagedConfirmationButton("uninstall sunnypilot", "uninstall", gui_app.texture("icons_mici/settings/device/uninstall.png", 64, 64), uninstall_openpilot_callback, exit_on_confirm=False) diff --git a/selfdrive/ui/mici/layouts/settings/firehose.py b/selfdrive/ui/mici/layouts/settings/firehose.py index 4c27a909f9..5bf7426c77 100644 --- a/selfdrive/ui/mici/layouts/settings/firehose.py +++ b/selfdrive/ui/mici/layouts/settings/firehose.py @@ -18,7 +18,7 @@ from openpilot.system.ui.widgets.scroller import NavRawScrollPanel TITLE = tr_noop("Firehose Mode") DESCRIPTION = tr_noop( - "openpilot learns to drive by watching humans, like you, drive.\n\n" + "sunnypilot learns to drive by watching humans, like you, drive.\n\n" + "Firehose Mode allows you to maximize your training data uploads to improve " + "openpilot's driving models. More data means bigger models, which means better Experimental Mode." ) diff --git a/selfdrive/ui/mici/layouts/settings/toggles.py b/selfdrive/ui/mici/layouts/settings/toggles.py index acb502fda0..8635336f97 100644 --- a/selfdrive/ui/mici/layouts/settings/toggles.py +++ b/selfdrive/ui/mici/layouts/settings/toggles.py @@ -20,7 +20,7 @@ class TogglesLayoutMici(NavScroller): always_on_dm_toggle = BigParamControl("always-on driver monitor", "AlwaysOnDM") record_front = BigParamControl("record & upload driver camera", "RecordFront", toggle_callback=restart_needed_callback) record_mic = BigParamControl("record & upload mic audio", "RecordAudio", toggle_callback=restart_needed_callback) - enable_openpilot = BigParamControl("enable openpilot", "OpenpilotEnabledToggle", toggle_callback=restart_needed_callback) + enable_openpilot = BigParamControl("enable sunnypilot", "OpenpilotEnabledToggle", toggle_callback=restart_needed_callback) self._scroller.add_widgets([ self._personality_toggle, diff --git a/selfdrive/ui/mici/onroad/alert_renderer.py b/selfdrive/ui/mici/onroad/alert_renderer.py index 7b006aaaea..5b550030de 100644 --- a/selfdrive/ui/mici/onroad/alert_renderer.py +++ b/selfdrive/ui/mici/onroad/alert_renderer.py @@ -13,6 +13,8 @@ from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.label import UnifiedLabel +from openpilot.selfdrive.ui.sunnypilot.onroad.speed_limit import SpeedLimitAlertRenderer + AlertSize = log.SelfdriveState.AlertSize AlertStatus = log.SelfdriveState.AlertStatus @@ -46,6 +48,7 @@ class IconLayout(NamedTuple): side: IconSide margin_x: int margin_y: int + alpha: float = 255.0 class AlertLayout(NamedTuple): @@ -65,7 +68,7 @@ class Alert: # Pre-defined alert instances ALERT_STARTUP_PENDING = Alert( - text1="openpilot Unavailable", + text1="sunnypilot Unavailable", text2="Waiting to start", size=AlertSize.mid, status=AlertStatus.normal, @@ -86,9 +89,10 @@ ALERT_CRITICAL_REBOOT = Alert( ) -class AlertRenderer(Widget): +class AlertRenderer(Widget, SpeedLimitAlertRenderer): def __init__(self): - super().__init__() + Widget.__init__(self) + SpeedLimitAlertRenderer.__init__(self) self._alert_text1_label = UnifiedLabel(text="", font_size=ALERT_FONT_BIG, font_weight=FontWeight.DISPLAY, line_height=0.86, letter_spacing=-0.02) @@ -155,6 +159,7 @@ class AlertRenderer(Widget): def _icon_helper(self, alert: Alert) -> AlertLayout: icon_side = None txt_icon = None + icon_alpha = 255.0 icon_margin_x = 20 icon_margin_y = 18 @@ -191,6 +196,9 @@ class AlertRenderer(Widget): icon_margin_x = 8 icon_margin_y = 0 + elif event_name == 'speedLimitPreActive': + icon_side, txt_icon, icon_alpha, icon_margin_x, icon_margin_y = SpeedLimitAlertRenderer.speed_limit_pre_active_icon_helper(self) + else: self._turn_signal_timer = 0.0 @@ -212,7 +220,7 @@ class AlertRenderer(Widget): text_width, self._rect.height, ) - icon_layout = IconLayout(txt_icon, icon_side, icon_margin_x, icon_margin_y) if txt_icon is not None and icon_side is not None else None + icon_layout = IconLayout(txt_icon, icon_side, icon_margin_x, icon_margin_y, icon_alpha) if txt_icon is not None and icon_side is not None else None return AlertLayout(text_rect, icon_layout) def _render(self, rect: rl.Rectangle) -> bool: @@ -222,6 +230,9 @@ class AlertRenderer(Widget): self._alert_y_filter.update(self._rect.y - 50 if alert is None else self._rect.y) self._alpha_filter.update(0 if alert is None else 1) + if gui_app.sunnypilot_ui(): + ui_state.onroad_brightness_handle_alerts(ui_state, alert) + if alert is None: # If still animating out, keep the previous alert if self._alpha_filter.x > 0.01 and self._prev_alert is not None: @@ -232,6 +243,9 @@ class AlertRenderer(Widget): self._draw_background(alert) + # update speed limit UI states + SpeedLimitAlertRenderer.update(self) + alert_layout = self._icon_helper(alert) self._draw_text(alert, alert_layout) self._draw_icons(alert_layout) @@ -254,7 +268,7 @@ class AlertRenderer(Widget): pos_x = int(self._rect.x + self._rect.width - alert_layout.icon.margin_x - alert_layout.icon.texture.width) if alert_layout.icon.texture not in (self._txt_turn_signal_left, self._txt_turn_signal_right): - icon_alpha = 255 + icon_alpha = alert_layout.icon.alpha else: icon_alpha = int(min(self._turn_signal_alpha_filter.x, 255)) diff --git a/selfdrive/ui/mici/onroad/augmented_road_view.py b/selfdrive/ui/mici/onroad/augmented_road_view.py index 09d5d57fbb..72be9cbe4d 100644 --- a/selfdrive/ui/mici/onroad/augmented_road_view.py +++ b/selfdrive/ui/mici/onroad/augmented_road_view.py @@ -19,6 +19,10 @@ from openpilot.common.transformations.camera import DEVICE_CAMERAS, DeviceCamera from openpilot.common.transformations.orientation import rot_from_euler from enum import IntEnum +if gui_app.sunnypilot_ui(): + from openpilot.selfdrive.ui.sunnypilot.mici.onroad.hud_renderer import HudRendererSP as HudRenderer + from openpilot.selfdrive.ui.sunnypilot.ui_state import OnroadTimerStatus + OpState = log.SelfdriveState.OpenpilotState CALIBRATED = log.LiveCalibrationData.Status.calibrated ROAD_CAM = VisionStreamType.VISION_STREAM_ROAD @@ -154,7 +158,7 @@ class AugmentedRoadView(CameraView): self._alert_renderer = AlertRenderer() self._driver_state_renderer = DriverStateRenderer() self._confidence_ball = ConfidenceBall() - self._offroad_label = UnifiedLabel("start the car to\nuse openpilot", 54, FontWeight.DISPLAY, + self._offroad_label = UnifiedLabel("start the car to\nuse sunnypilot", 54, FontWeight.DISPLAY, text_color=rl.Color(255, 255, 255, int(255 * 0.9)), alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) @@ -175,7 +179,7 @@ class AugmentedRoadView(CameraView): if ui_state.panda_type == log.PandaState.PandaType.unknown: self._offroad_label.set_text("system booting") else: - self._offroad_label.set_text("start the car to\nuse openpilot") + self._offroad_label.set_text("start the car to\nuse sunnypilot") def _handle_mouse_release(self, mouse_pos: MousePos): # Don't trigger click callback if bookmark was triggered @@ -351,6 +355,14 @@ class AugmentedRoadView(CameraView): return self._cached_matrix + def show_event(self): + if gui_app.sunnypilot_ui(): + ui_state.reset_onroad_sleep_timer(OnroadTimerStatus.RESUME) + + def hide_event(self): + if gui_app.sunnypilot_ui(): + ui_state.reset_onroad_sleep_timer(OnroadTimerStatus.PAUSE) + if __name__ == "__main__": gui_app.init_window("OnRoad Camera View") diff --git a/selfdrive/ui/mici/onroad/confidence_ball.py b/selfdrive/ui/mici/onroad/confidence_ball.py index a5c95470f5..54699eab54 100644 --- a/selfdrive/ui/mici/onroad/confidence_ball.py +++ b/selfdrive/ui/mici/onroad/confidence_ball.py @@ -6,6 +6,8 @@ from openpilot.system.ui.widgets import Widget from openpilot.system.ui.lib.application import gui_app from openpilot.common.filter_simple import FirstOrderFilter +from openpilot.selfdrive.ui.sunnypilot.mici.onroad.confidence_ball import ConfidenceBallSP + def draw_circle_gradient(center_x: float, center_y: float, radius: int, top: rl.Color, bottom: rl.Color) -> None: @@ -21,9 +23,10 @@ def draw_circle_gradient(center_x: float, center_y: float, radius: int, 20, rl.BLACK) -class ConfidenceBall(Widget): +class ConfidenceBall(Widget, ConfidenceBallSP): def __init__(self, demo: bool = False): - super().__init__() + Widget.__init__(self) + ConfidenceBallSP.__init__(self) self._demo = demo self._confidence_filter = FirstOrderFilter(-0.5, 0.5, 1 / gui_app.target_fps) @@ -37,6 +40,8 @@ class ConfidenceBall(Widget): # animate status dot in from bottom if ui_state.status == UIStatus.DISENGAGED: self._confidence_filter.update(-0.5) + elif ui_state.status in (UIStatus.LAT_ONLY, UIStatus.LONG_ONLY): + self._confidence_filter.update(1 - max(self.get_animate_status_probs() or [1])) else: self._confidence_filter.update((1 - max(ui_state.sm['modelV2'].meta.disengagePredictions.brakeDisengageProbs or [1])) * (1 - max(ui_state.sm['modelV2'].meta.disengagePredictions.steerOverrideProbs or [1]))) @@ -65,6 +70,9 @@ class ConfidenceBall(Widget): top_dot_color = rl.Color(255, 0, 21, 255) bottom_dot_color = rl.Color(255, 0, 89, 255) + elif ui_state.status in (UIStatus.LAT_ONLY, UIStatus.LONG_ONLY): + top_dot_color = bottom_dot_color = self.get_lat_long_dot_color() + elif ui_state.status == UIStatus.OVERRIDE: top_dot_color = rl.Color(255, 255, 255, 255) bottom_dot_color = rl.Color(82, 82, 82, 255) diff --git a/selfdrive/ui/mici/onroad/hud_renderer.py b/selfdrive/ui/mici/onroad/hud_renderer.py index 56d83992ff..76724244d0 100644 --- a/selfdrive/ui/mici/onroad/hud_renderer.py +++ b/selfdrive/ui/mici/onroad/hud_renderer.py @@ -153,7 +153,7 @@ class HudRenderer(Widget): v_cruise_cluster = car_state.vCruiseCluster set_speed = ( - controls_state.vCruiseDEPRECATED if v_cruise_cluster == 0.0 else v_cruise_cluster + controls_state.deprecated.vCruise if v_cruise_cluster == 0.0 else v_cruise_cluster ) engaged = sm['selfdriveState'].enabled if (set_speed != self.set_speed and engaged) or (engaged and not self._engaged): @@ -182,11 +182,13 @@ class HudRenderer(Widget): def _draw_steering_wheel(self, rect: rl.Rectangle) -> None: wheel_txt = self._txt_wheel_critical if self._show_wheel_critical else self._txt_wheel + bsm_detected = self._has_blind_spot_detected() if gui_app.sunnypilot_ui() else False + if self._show_wheel_critical: self._wheel_alpha_filter.update(255) self._wheel_y_filter.update(0) else: - if ui_state.status == UIStatus.DISENGAGED: + if ui_state.status == UIStatus.DISENGAGED or bsm_detected: self._wheel_alpha_filter.update(0) self._wheel_y_filter.update(wheel_txt.height / 2) else: diff --git a/selfdrive/ui/mici/onroad/model_renderer.py b/selfdrive/ui/mici/onroad/model_renderer.py index 3f1badfe84..ca051c6d94 100644 --- a/selfdrive/ui/mici/onroad/model_renderer.py +++ b/selfdrive/ui/mici/onroad/model_renderer.py @@ -12,6 +12,8 @@ from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.shader_polygon import draw_polygon, Gradient from openpilot.system.ui.widgets import Widget +from openpilot.selfdrive.ui.sunnypilot.mici.onroad.model_renderer import LANE_LINE_COLORS_SP, ModelRendererSP + CLIP_MARGIN = 500 MIN_DRAW_DISTANCE = 10.0 MAX_DRAW_DISTANCE = 100.0 @@ -32,6 +34,7 @@ LANE_LINE_COLORS = { UIStatus.DISENGAGED: rl.Color(200, 200, 200, 255), UIStatus.OVERRIDE: rl.Color(255, 255, 255, 255), UIStatus.ENGAGED: rl.Color(0, 255, 64, 255), + **LANE_LINE_COLORS_SP, } @@ -48,9 +51,10 @@ class LeadVehicle: fill_alpha: int = 0 -class ModelRenderer(Widget): +class ModelRenderer(Widget, ModelRendererSP): def __init__(self): - super().__init__() + Widget.__init__(self) + ModelRendererSP.__init__(self) self._longitudinal_control = False self._experimental_mode = False self._blend_filter = FirstOrderFilter(1.0, 0.25, 1 / gui_app.target_fps) @@ -77,6 +81,9 @@ class ModelRenderer(Widget): self._transform_dirty = True self._clip_region = None + self._counter = -1 + self._camera_offset = ui_state.params.get("CameraOffset", return_default=True) if ui_state.active_bundle else 0.0 + self._exp_gradient = Gradient( start=(0.0, 1.0), # Bottom of path end=(0.0, 0.0), # Top of path @@ -96,6 +103,10 @@ class ModelRenderer(Widget): def _render(self, rect: rl.Rectangle): sm = ui_state.sm + if self._counter % 180 == 0: # This runs at 60fps, so we query every 3 seconds + self._camera_offset = ui_state.params.get("CameraOffset", return_default=True) if ui_state.active_bundle else 0.0 + self._counter += 1 + self._torque_filter.update(-ui_state.sm['carOutput'].actuatorsOutput.torque) # Check if data is up-to-date @@ -147,13 +158,13 @@ class ModelRenderer(Widget): def _update_raw_points(self, model): """Update raw 3D points from model data""" - self._path.raw_points = np.array([model.position.x, model.position.y, model.position.z], dtype=np.float32).T + self._path.raw_points = np.array([model.position.x, np.array(model.position.y) + self._camera_offset, model.position.z], dtype=np.float32).T for i, lane_line in enumerate(model.laneLines): - self._lane_lines[i].raw_points = np.array([lane_line.x, lane_line.y, lane_line.z], dtype=np.float32).T + self._lane_lines[i].raw_points = np.array([lane_line.x, np.array(lane_line.y) + self._camera_offset, lane_line.z], dtype=np.float32).T for i, road_edge in enumerate(model.roadEdges): - self._road_edges[i].raw_points = np.array([road_edge.x, road_edge.y, road_edge.z], dtype=np.float32).T + self._road_edges[i].raw_points = np.array([road_edge.x, np.array(road_edge.y) + self._camera_offset, road_edge.z], dtype=np.float32).T self._lane_line_probs = np.array(model.laneLineProbs, dtype=np.float32) self._road_edge_stds = np.array(model.roadEdgeStds, dtype=np.float32) @@ -171,7 +182,7 @@ class ModelRenderer(Widget): # Get z-coordinate from path at the lead vehicle position z = self._path.raw_points[idx, 2] if idx < len(self._path.raw_points) else 0.0 - point = self._map_to_screen(d_rel, -y_rel, z + self._path_offset_z) + point = self._map_to_screen(d_rel, -y_rel + self._camera_offset, z + self._path_offset_z) if point: self._lead_vehicles[i] = self._update_lead_vehicle(d_rel, v_rel, point, self._rect) @@ -330,6 +341,10 @@ class ModelRenderer(Widget): allow_throttle = sm['longitudinalPlan'].allowThrottle or not self._longitudinal_control self._blend_filter.update(int(allow_throttle)) + if ui_state.rainbow_path: + self.rainbow_path.draw_rainbow_path(self._rect, self._path) + return + if self._experimental_mode: # Draw with acceleration coloring if ui_state.status == UIStatus.DISENGAGED: diff --git a/selfdrive/ui/mici/onroad/torque_bar.py b/selfdrive/ui/mici/onroad/torque_bar.py index 1338c8dfb3..f0690c0abf 100644 --- a/selfdrive/ui/mici/onroad/torque_bar.py +++ b/selfdrive/ui/mici/onroad/torque_bar.py @@ -149,9 +149,11 @@ DEFAULT_MAX_LAT_ACCEL = 3.0 # m/s^2 class TorqueBar(Widget): - def __init__(self, demo: bool = False): + def __init__(self, demo: bool = False, scale: float = 1.0, always: bool = False): super().__init__() self._demo = demo + self._scale = scale + self._always = always self._torque_filter = FirstOrderFilter(0, 0.1, 1 / gui_app.target_fps) self._torque_line_alpha_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps) @@ -190,22 +192,22 @@ class TorqueBar(Widget): def _render(self, rect: rl.Rectangle) -> None: # adjust y pos with torque - torque_line_offset = np.interp(abs(self._torque_filter.x), [0.5, 1], [22, 26]) - torque_line_height = np.interp(abs(self._torque_filter.x), [0.5, 1], [14, 56]) + torque_line_offset = np.interp(abs(self._torque_filter.x), [0.5, 1], [22 * self._scale, 26 * self._scale]) + torque_line_height = np.interp(abs(self._torque_filter.x), [0.5, 1], [14 * self._scale, 56 * self._scale]) # animate alpha and angle span if not self._demo: - self._torque_line_alpha_filter.update(ui_state.status != UIStatus.DISENGAGED) + self._torque_line_alpha_filter.update(ui_state.status not in (UIStatus.DISENGAGED, UIStatus.LONG_ONLY)) else: self._torque_line_alpha_filter.update(1.0) torque_line_bg_alpha = np.interp(abs(self._torque_filter.x), [0.5, 1.0], [0.25, 0.5]) torque_line_bg_color = rl.Color(255, 255, 255, int(255 * torque_line_bg_alpha * self._torque_line_alpha_filter.x)) - if ui_state.status != UIStatus.ENGAGED and not self._demo: + if ui_state.status not in (UIStatus.ENGAGED, UIStatus.LAT_ONLY) and not self._demo: torque_line_bg_color = rl.Color(255, 255, 255, int(255 * 0.15 * self._torque_line_alpha_filter.x)) # draw curved line polygon torque bar - torque_line_radius = 1200 + torque_line_radius = 1200 * self._scale top_angle = -90 torque_bg_angle_span = self._torque_line_alpha_filter.x * TORQUE_ANGLE_SPAN torque_start_angle = top_angle - torque_bg_angle_span / 2 @@ -217,13 +219,13 @@ class TorqueBar(Widget): cy = rect.y + rect.height + torque_line_radius - torque_line_offset # draw bg torque indicator line - bg_pts = arc_bar_pts(cx, cy, mid_r, torque_line_height, torque_start_angle, torque_end_angle) + bg_pts = arc_bar_pts(cx, cy, mid_r, torque_line_height, torque_start_angle, torque_end_angle, cap_radius=7 * self._scale) draw_polygon(rect, bg_pts, color=torque_line_bg_color) # draw torque indicator line a0s = top_angle a1s = a0s + torque_bg_angle_span / 2 * self._torque_filter.x - sl_pts = arc_bar_pts(cx, cy, mid_r, torque_line_height, a0s, a1s) + sl_pts = arc_bar_pts(cx, cy, mid_r, torque_line_height, a0s, a1s, cap_radius=7 * self._scale) # draw beautiful gradient from center to 65% of the bg torque bar width start_grad_pt = cx / rect.width @@ -244,7 +246,7 @@ class TorqueBar(Widget): max(0, abs(self._torque_filter.x) - 0.75) * 4, ) - if ui_state.status != UIStatus.ENGAGED and not self._demo: + if ui_state.status not in (UIStatus.ENGAGED, UIStatus.LAT_ONLY) and not self._demo: start_color = end_color = rl.Color(255, 255, 255, int(255 * 0.35 * self._torque_line_alpha_filter.x)) gradient = Gradient( @@ -262,5 +264,5 @@ class TorqueBar(Widget): # draw center torque bar dot if abs(self._torque_filter.x) < 0.5: dot_y = self._rect.y + self._rect.height - torque_line_offset - torque_line_height / 2 - rl.draw_circle(int(cx), int(dot_y), 10 // 2, + rl.draw_circle(int(cx), int(dot_y), (10 // 2 * self._scale), rl.Color(182, 182, 182, int(255 * 0.9 * self._torque_line_alpha_filter.x))) diff --git a/selfdrive/ui/onroad/alert_renderer.py b/selfdrive/ui/onroad/alert_renderer.py index a81fbfc440..6e79d23253 100644 --- a/selfdrive/ui/onroad/alert_renderer.py +++ b/selfdrive/ui/onroad/alert_renderer.py @@ -48,7 +48,7 @@ class Alert: # Pre-defined alert instances ALERT_STARTUP_PENDING = Alert( - text1=tr("openpilot Unavailable"), + text1=tr("sunnypilot Unavailable"), text2=tr("Waiting to start"), size=AlertSize.mid, status=AlertStatus.normal, @@ -116,6 +116,10 @@ class AlertRenderer(Widget): def _render(self, rect: rl.Rectangle): alert = self.get_alert(ui_state.sm) + + if gui_app.sunnypilot_ui(): + ui_state.onroad_brightness_handle_alerts(ui_state, alert) + if not alert: return diff --git a/selfdrive/ui/onroad/augmented_road_view.py b/selfdrive/ui/onroad/augmented_road_view.py index 17d89fbd50..8abc8fbb52 100644 --- a/selfdrive/ui/onroad/augmented_road_view.py +++ b/selfdrive/ui/onroad/augmented_road_view.py @@ -14,6 +14,13 @@ from openpilot.system.ui.lib.application import gui_app from openpilot.common.transformations.camera import DEVICE_CAMERAS, DeviceCameraConfig, view_frame_from_device_frame from openpilot.common.transformations.orientation import rot_from_euler +if gui_app.sunnypilot_ui(): + from openpilot.selfdrive.ui.sunnypilot.onroad.alert_renderer import AlertRendererSP as AlertRenderer + from openpilot.selfdrive.ui.sunnypilot.onroad.augmented_road_view import BORDER_COLORS_SP, AugmentedRoadViewSP + from openpilot.selfdrive.ui.sunnypilot.onroad.driver_state import DriverStateRendererSP as DriverStateRenderer + from openpilot.selfdrive.ui.sunnypilot.onroad.hud_renderer import HudRendererSP as HudRenderer + from openpilot.selfdrive.ui.sunnypilot.ui_state import OnroadTimerStatus + OpState = log.SelfdriveState.OpenpilotState CALIBRATED = log.LiveCalibrationData.Status.calibrated ROAD_CAM = VisionStreamType.VISION_STREAM_ROAD @@ -24,6 +31,7 @@ BORDER_COLORS = { UIStatus.DISENGAGED: rl.Color(0x12, 0x28, 0x39, 0xFF), # Blue for disengaged state UIStatus.OVERRIDE: rl.Color(0x89, 0x92, 0x8D, 0xFF), # Gray for override state UIStatus.ENGAGED: rl.Color(0x16, 0x7F, 0x40, 0xFF), # Green for engaged state + **BORDER_COLORS_SP, } WIDE_CAM_MAX_SPEED = 10.0 # m/s (22 mph) @@ -31,9 +39,10 @@ ROAD_CAM_MIN_SPEED = 15.0 # m/s (34 mph) INF_POINT = np.array([1000.0, 0.0, 0.0]) -class AugmentedRoadView(CameraView): +class AugmentedRoadView(CameraView, AugmentedRoadViewSP): def __init__(self, stream_type: VisionStreamType = VisionStreamType.VISION_STREAM_ROAD): - super().__init__("camerad", stream_type) + CameraView.__init__(self, "camerad", stream_type) + AugmentedRoadViewSP.__init__(self) self._set_placeholder_color(BORDER_COLORS[UIStatus.DISENGAGED]) self.device_camera: DeviceCameraConfig | None = None @@ -85,6 +94,7 @@ class AugmentedRoadView(CameraView): # Draw all UI overlays self.model_renderer.render(self._content_rect) + AugmentedRoadViewSP.update_fade_out_bottom_overlay(self, self._content_rect) self._hud_renderer.render(self._content_rect) self.alert_renderer.render(self._content_rect) self.driver_state_renderer.render(self._content_rect) @@ -217,6 +227,14 @@ class AugmentedRoadView(CameraView): return self._cached_matrix + def show_event(self): + if gui_app.sunnypilot_ui(): + ui_state.reset_onroad_sleep_timer(OnroadTimerStatus.RESUME) + + def hide_event(self): + if gui_app.sunnypilot_ui(): + ui_state.reset_onroad_sleep_timer(OnroadTimerStatus.PAUSE) + if __name__ == "__main__": gui_app.init_window("OnRoad Camera View") diff --git a/selfdrive/ui/onroad/hud_renderer.py b/selfdrive/ui/onroad/hud_renderer.py index 79f150deea..73df8b3961 100644 --- a/selfdrive/ui/onroad/hud_renderer.py +++ b/selfdrive/ui/onroad/hud_renderer.py @@ -86,7 +86,7 @@ class HudRenderer(Widget): v_cruise_cluster = car_state.vCruiseCluster self.set_speed = ( - controls_state.vCruiseDEPRECATED if v_cruise_cluster == 0.0 else v_cruise_cluster + controls_state.deprecated.vCruise if v_cruise_cluster == 0.0 else v_cruise_cluster ) self.is_cruise_set = 0 < self.set_speed < SET_SPEED_NA self.is_cruise_available = self.set_speed != -1 diff --git a/selfdrive/ui/onroad/model_renderer.py b/selfdrive/ui/onroad/model_renderer.py index b9f601f8fb..353cc5aa40 100644 --- a/selfdrive/ui/onroad/model_renderer.py +++ b/selfdrive/ui/onroad/model_renderer.py @@ -11,6 +11,8 @@ from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.shader_polygon import draw_polygon, Gradient from openpilot.system.ui.widgets import Widget +from openpilot.selfdrive.ui.sunnypilot.onroad.model_renderer import ChevronMetrics, ModelRendererSP + CLIP_MARGIN = 500 MIN_DRAW_DISTANCE = 10.0 MAX_DRAW_DISTANCE = 100.0 @@ -41,9 +43,11 @@ class LeadVehicle: fill_alpha: int = 0 -class ModelRenderer(Widget): +class ModelRenderer(Widget, ChevronMetrics, ModelRendererSP): def __init__(self): - super().__init__() + Widget.__init__(self) + ChevronMetrics.__init__(self) + ModelRendererSP.__init__(self) self._longitudinal_control = False self._experimental_mode = False self._blend_filter = FirstOrderFilter(1.0, 0.25, 1 / gui_app.target_fps) @@ -52,7 +56,8 @@ class ModelRenderer(Widget): self._road_edge_stds = np.zeros(2, dtype=np.float32) self._lead_vehicles = [LeadVehicle(), LeadVehicle()] self._path_offset_z = HEIGHT_INIT[0] - + self._counter = -1 + self._camera_offset = ui_state.params.get("CameraOffset", return_default=True) if ui_state.active_bundle else 0.0 # Initialize ModelPoints objects self._path = ModelPoints() self._lane_lines = [ModelPoints() for _ in range(4)] @@ -99,6 +104,10 @@ class ModelRenderer(Widget): live_calib = sm['liveCalibration'] self._path_offset_z = live_calib.height[0] if live_calib.height else HEIGHT_INIT[0] + if self._counter % 60 == 0: + self._camera_offset = ui_state.params.get("CameraOffset", return_default=True) if ui_state.active_bundle else 0.0 + self._counter += 1 + if sm.updated['carParams']: self._longitudinal_control = sm['carParams'].openpilotLongitudinalControl @@ -128,16 +137,17 @@ class ModelRenderer(Widget): if render_lead_indicator and radar_state: self._draw_lead_indicator() + self.chevron_metrics.draw_lead_status(sm, radar_state, self._rect, self._lead_vehicles) def _update_raw_points(self, model): """Update raw 3D points from model data""" - self._path.raw_points = np.array([model.position.x, model.position.y, model.position.z], dtype=np.float32).T + self._path.raw_points = np.array([model.position.x, np.array(model.position.y) + self._camera_offset, model.position.z], dtype=np.float32).T for i, lane_line in enumerate(model.laneLines): - self._lane_lines[i].raw_points = np.array([lane_line.x, lane_line.y, lane_line.z], dtype=np.float32).T + self._lane_lines[i].raw_points = np.array([lane_line.x, np.array(lane_line.y) + self._camera_offset, lane_line.z], dtype=np.float32).T for i, road_edge in enumerate(model.roadEdges): - self._road_edges[i].raw_points = np.array([road_edge.x, road_edge.y, road_edge.z], dtype=np.float32).T + self._road_edges[i].raw_points = np.array([road_edge.x, np.array(road_edge.y) + self._camera_offset, road_edge.z], dtype=np.float32).T self._lane_line_probs = np.array(model.laneLineProbs, dtype=np.float32) self._road_edge_stds = np.array(model.roadEdgeStds, dtype=np.float32) @@ -155,7 +165,7 @@ class ModelRenderer(Widget): # Get z-coordinate from path at the lead vehicle position z = self._path.raw_points[idx, 2] if idx < len(self._path.raw_points) else 0.0 - point = self._map_to_screen(d_rel, -y_rel, z + self._path_offset_z) + point = self._map_to_screen(d_rel, -y_rel + self._camera_offset, z + self._path_offset_z) if point: self._lead_vehicles[i] = self._update_lead_vehicle(d_rel, v_rel, point, self._rect) @@ -281,6 +291,10 @@ class ModelRenderer(Widget): allow_throttle = sm['longitudinalPlan'].allowThrottle or not self._longitudinal_control self._blend_filter.update(int(allow_throttle)) + if ui_state.rainbow_path: + self.rainbow_path.draw_rainbow_path(self._rect, self._path) + return + if self._experimental_mode: # Draw with acceleration coloring if len(self._exp_gradient.colors) > 1: diff --git a/selfdrive/ui/soundd.py b/selfdrive/ui/soundd.py index 6a203d3afc..6b64289766 100644 --- a/selfdrive/ui/soundd.py +++ b/selfdrive/ui/soundd.py @@ -4,7 +4,7 @@ import time import wave -from cereal import car, messaging +from cereal import car, messaging, custom from openpilot.common.basedir import BASEDIR from openpilot.common.filter_simple import FirstOrderFilter from openpilot.common.realtime import Ratekeeper @@ -14,10 +14,13 @@ from openpilot.common.swaglog import cloudlog from openpilot.system import micd from openpilot.system.hardware import HARDWARE +from openpilot.sunnypilot.selfdrive.ui.quiet_mode import QuietMode + SAMPLE_RATE = 48000 SAMPLE_BUFFER = 4096 # (approx 100ms) MAX_VOLUME = 1.0 MIN_VOLUME = 0.1 +ALERT_RAMP_TIME = 4 # seconds to ramp to max volume for warningImmediate SELFDRIVE_STATE_TIMEOUT = 5 # 5 seconds FILTER_DT = 1. / (micd.SAMPLE_RATE / micd.FFT_SAMPLES) @@ -30,8 +33,15 @@ if HARDWARE.get_device_type() == "tizi": VOLUME_BASE = 10 AudibleAlert = car.CarControl.HUDControl.AudibleAlert +AudibleAlertSP = custom.SelfdriveStateSP.AudibleAlert +sound_list_sp: dict[int, tuple[str, int | None, float]] = { + # AudibleAlertSP, file name, play count (none for infinite) + AudibleAlertSP.promptSingleLow: ("prompt_single_low.wav", 1, MAX_VOLUME), + AudibleAlertSP.promptSingleHigh: ("prompt_single_high.wav", 1, MAX_VOLUME), +} + sound_list: dict[int, tuple[str, int | None, float]] = { # AudibleAlert, file name, play count (none for infinite) AudibleAlert.engage: ("engage.wav", 1, MAX_VOLUME), @@ -44,6 +54,8 @@ sound_list: dict[int, tuple[str, int | None, float]] = { AudibleAlert.warningSoft: ("warning_soft.wav", None, MAX_VOLUME), AudibleAlert.warningImmediate: ("warning_immediate.wav", None, MAX_VOLUME), + + **sound_list_sp, } if HARDWARE.get_device_type() == "tizi": sound_list.update({ @@ -55,20 +67,25 @@ def check_selfdrive_timeout_alert(sm): ss_missing = time.monotonic() - sm.recv_time['selfdriveState'] if ss_missing > SELFDRIVE_STATE_TIMEOUT: - if sm['selfdriveState'].enabled and (ss_missing - SELFDRIVE_STATE_TIMEOUT) < 10: + if (sm['selfdriveState'].enabled or sm['selfdriveStateSP'].mads.enabled) and (ss_missing - SELFDRIVE_STATE_TIMEOUT) < 10: return True return False -class Soundd: +class Soundd(QuietMode): def __init__(self): + super().__init__() + self.load_sounds() self.current_alert = AudibleAlert.none self.current_volume = MIN_VOLUME self.current_sound_frame = 0 + self.ramp_start_volume = MIN_VOLUME + self.ramp_start_time = 0. + self.selfdrive_timeout_alert = False self.spl_filter_weighted = FirstOrderFilter(0, 2.5, FILTER_DT, initialized=False) @@ -92,7 +109,7 @@ class Soundd: ret = np.zeros(frames, dtype=np.float32) - if self.current_alert != AudibleAlert.none: + if self.should_play_sound(self.current_alert): num_loops = sound_list[self.current_alert][1] sound_data = self.loaded_sounds[self.current_alert] written_frames = 0 @@ -117,6 +134,9 @@ class Soundd: def update_alert(self, new_alert): current_alert_played_once = self.current_alert == AudibleAlert.none or self.current_sound_frame > len(self.loaded_sounds[self.current_alert]) if self.current_alert != new_alert and (new_alert != AudibleAlert.none or current_alert_played_once): + if new_alert == AudibleAlert.warningImmediate: + self.ramp_start_volume = self.current_volume + self.ramp_start_time = time.monotonic() self.current_alert = new_alert self.current_sound_frame = 0 @@ -146,7 +166,7 @@ class Soundd: # sounddevice must be imported after forking processes import sounddevice as sd - sm = messaging.SubMaster(['selfdriveState', 'soundPressure']) + sm = messaging.SubMaster(['selfdriveState', 'selfdriveStateSP', 'soundPressure']) with self.get_stream(sd) as stream: rk = Ratekeeper(20) @@ -155,12 +175,21 @@ class Soundd: while True: sm.update(0) - if sm.updated['soundPressure'] and self.current_alert == AudibleAlert.none: # only update volume filter when not playing alert + self.load_param() + + # Always update volume, even when alert is playing + if sm.updated['soundPressure']: self.spl_filter_weighted.update(sm["soundPressure"].soundPressureWeightedDb) self.current_volume = self.calculate_volume(float(self.spl_filter_weighted.x)) self.get_audible_alert(sm) + # Ramp up immediate warning sound over 4s + if self.current_alert == AudibleAlert.warningImmediate: + elapsed = time.monotonic() - self.ramp_start_time + ramp_vol = float(np.interp(elapsed, [0, ALERT_RAMP_TIME], [self.ramp_start_volume, MAX_VOLUME])) + self.current_volume = max(self.current_volume, ramp_vol) + rk.keep_time() assert stream.active diff --git a/selfdrive/ui/sunnypilot/__init__.py b/selfdrive/ui/sunnypilot/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/selfdrive/ui/sunnypilot/layouts/__init__.py b/selfdrive/ui/sunnypilot/layouts/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/selfdrive/ui/sunnypilot/layouts/onboarding.py b/selfdrive/ui/sunnypilot/layouts/onboarding.py new file mode 100644 index 0000000000..7e532678b0 --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/onboarding.py @@ -0,0 +1,115 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import pyray as rl +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.application import FontWeight +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.button import Button, ButtonStyle +from openpilot.system.ui.widgets.label import Label +from openpilot.system.version import sunnylink_consent_version, sunnylink_consent_declined + + +class SunnylinkConsentPage(Widget): + def __init__(self, done_callback=None): + super().__init__() + self._done_callback = done_callback + self._step = 0 + + self._title = self._child(Label(tr("sunnylink"), font_size=90, font_weight=FontWeight.BOLD, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)) + + self._content = [ + { + "text": tr("sunnylink enables secured remote access to your comma device from anywhere, " + + "including settings management, remote monitoring, real-time dashboard, etc."), + "primary_btn": tr("Enable"), + "secondary_btn": tr("Disable"), + "highlight_primary": True + }, + { + "text": tr("sunnylink is designed to be enabled as part of sunnypilot's core functionality. " + + "If sunnylink is disabled, features such as settings management, remote monitoring, " + + "real-time dashboards will be unavailable."), + "secondary_btn": tr("Back"), + "danger_btn": tr("Disable"), + "highlight_primary": True + } + ] + + self._primary_btn = self._child(Button("", button_style=ButtonStyle.PRIMARY, click_callback=lambda: self._handle_choice("enable"))) + self._secondary_btn = self._child(Button("", button_style=ButtonStyle.NORMAL, click_callback=lambda: self._handle_choice("secondary"))) + self._danger_btn = self._child(Button("", button_style=ButtonStyle.DANGER, click_callback=lambda: self._handle_choice("disable"))) + self._desc = self._child(Label("", font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)) + + def _handle_choice(self, choice): + if choice == "enable": + ui_state.params.put_bool("SunnylinkEnabled", True) + ui_state.params.put("CompletedSunnylinkConsentVersion", sunnylink_consent_version) + if self._done_callback: + self._done_callback() + elif choice == "secondary": + if self._step == 0: + self._step = 1 + elif self._step == 1: + self._step = 0 + elif choice == "disable": + ui_state.params.put_bool("SunnylinkEnabled", False) + ui_state.params.put("CompletedSunnylinkConsentVersion", sunnylink_consent_declined) + if self._done_callback: + self._done_callback() + + def _render(self, _): + step_data = self._content[self._step] + + welcome_x = self._rect.x + 95 + welcome_y = self._rect.y + 165 + welcome_rect = rl.Rectangle(welcome_x, welcome_y, self._rect.width - welcome_x, 90) + self._title.render(welcome_rect) + + desc_x = welcome_x + desc_y = welcome_y + 120 + desc_rect = rl.Rectangle(desc_x, desc_y, self._rect.width - desc_x, self._rect.height - desc_y - 250) + + self._desc.set_text(step_data["text"]) + self._desc.render(desc_rect) + + btn_y = self._rect.y + self._rect.height - 160 - 45 + + if "danger_btn" in step_data: + btn_width = (self._rect.width - 45 * 3) / 2 + + self._secondary_btn.set_text(step_data["secondary_btn"]) + self._secondary_btn.render(rl.Rectangle(self._rect.x + 45, btn_y, btn_width, 160)) + + self._danger_btn.set_text(step_data["danger_btn"]) + self._danger_btn.render(rl.Rectangle(self._rect.x + 45 * 2 + btn_width, btn_y, btn_width, 160)) + + else: + btn_width = (self._rect.width - 45 * 3) / 2 + + self._secondary_btn.set_text(step_data["secondary_btn"]) + self._secondary_btn.render(rl.Rectangle(self._rect.x + 45, btn_y, btn_width, 160)) + + 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)) + + +class SunnylinkOnboarding: + def __init__(self): + self.consent_page = SunnylinkConsentPage(done_callback=self._on_done) + self.consent_done: bool = ui_state.params.get("CompletedSunnylinkConsentVersion") in {sunnylink_consent_version, sunnylink_consent_declined} + + @property + def completed(self) -> bool: + return self.consent_done + + def _on_done(self): + self.consent_done = True + + def render(self, rect): + if not self.consent_done: + self.consent_page.render(rect) diff --git a/selfdrive/ui/sunnypilot/layouts/settings/__init__.py b/selfdrive/ui/sunnypilot/layouts/settings/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/selfdrive/ui/sunnypilot/layouts/settings/cruise.py b/selfdrive/ui/sunnypilot/layouts/settings/cruise.py new file mode 100644 index 0000000000..671174ac7a --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/cruise.py @@ -0,0 +1,193 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from enum import IntEnum + +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.cruise_sub_layouts.speed_limit_settings import SpeedLimitSettingsLayout +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.multilang import tr, tr_noop +from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp, option_item_sp, simple_button_item_sp +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.scroller_tici import Scroller + + +class PanelType(IntEnum): + CRUISE = 0 + SLA = 1 + + +ICBM_DESC = tr_noop("When enabled, sunnypilot will attempt to manage the built-in cruise control buttons " + + "by emulating button presses for limited longitudinal control.") +ICMB_UNAVAILABLE = tr_noop("Intelligent Cruise Button Management is currently unavailable on this platform.") +ICMB_UNAVAILABLE_LONG_AVAILABLE = tr_noop("Disable the sunnypilot Longitudinal Control (alpha) toggle to allow Intelligent Cruise Button Management.") +ICMB_UNAVAILABLE_LONG_UNAVAILABLE = tr_noop("sunnypilot Longitudinal Control is the default longitudinal control for this platform.") + +ACC_ENABLED_DESCRIPTION = tr_noop("Enable custom Short & Long press increments for cruise speed increase/decrease.") +ACC_NOLONG_DESCRIPTION = tr_noop("This feature can only be used with sunnypilot longitudinal control enabled.") +ACC_PCMCRUISE_DISABLED_DESCRIPTION = tr_noop("This feature is not supported on this platform due to vehicle limitations.") +ONROAD_ONLY_DESCRIPTION = tr_noop("Start the vehicle to check vehicle compatibility.") + + +class CruiseLayout(Widget): + def __init__(self): + super().__init__() + self._current_panel = PanelType.CRUISE + self._speed_limit_layout = SpeedLimitSettingsLayout(lambda: self._set_current_panel(PanelType.CRUISE)) + + items = self._initialize_items() + self._scroller = Scroller(items, line_separator=True, spacing=0) + + def _initialize_items(self): + + self.icbm_toggle = toggle_item_sp( + title=tr("Intelligent Cruise Button Management (ICBM) (Alpha)"), + description="", + param="IntelligentCruiseButtonManagement") + + self.scc_v_toggle = toggle_item_sp( + title=tr("Smart Cruise Control - Vision"), + description=tr("Use vision path predictions to estimate the appropriate speed to drive through turns ahead."), + param="SmartCruiseControlVision") + + self.scc_m_toggle = toggle_item_sp( + title=tr("Smart Cruise Control - Map"), + description=tr("Use map data to estimate the appropriate speed to drive through turns ahead."), + param="SmartCruiseControlMap") + + self.custom_acc_toggle = toggle_item_sp( + title=tr("Custom ACC Speed Increments"), + description="", + param="CustomAccIncrementsEnabled", + callback=self._on_custom_acc_toggle) + + self.custom_acc_short_increment = option_item_sp( + title=tr("Short Press Increment"), + param="CustomAccShortPressIncrement", + min_value=1, max_value=10, value_change_step=1, + inline=True) + + self.custom_acc_long_increment = option_item_sp( + title=tr("Long Press Increment"), + param="CustomAccLongPressIncrement", + value_map={1: 1, 2: 5, 3: 10}, + min_value=1, max_value=3, value_change_step=1, + inline=True) + + self.sla_settings_button = simple_button_item_sp( + button_text=lambda: tr("Speed Limit"), + button_width=800, + callback=lambda: self._set_current_panel(PanelType.SLA) + ) + + self.dec_toggle = toggle_item_sp( + title=tr("Enable Dynamic Experimental Control"), + description=tr("Enable toggle to allow the model to determine when to use sunnypilot ACC or sunnypilot End to End Longitudinal."), + param="DynamicExperimentalControl") + + items = [ + self.icbm_toggle, + self.dec_toggle, + self.scc_v_toggle, + self.scc_m_toggle, + self.custom_acc_toggle, + self.custom_acc_short_increment, + self.custom_acc_long_increment, + self.sla_settings_button, + ] + return items + + def _render(self, rect): + if self._current_panel == PanelType.SLA: + self._speed_limit_layout.render(rect) + else: + self._scroller.render(rect) + + def show_event(self): + self._set_current_panel(PanelType.CRUISE) + self._scroller.show_event() + self.icbm_toggle.show_description(True) + self.custom_acc_toggle.show_description(True) + + def _set_current_panel(self, panel: PanelType): + self._current_panel = panel + if panel == PanelType.SLA: + self._speed_limit_layout.show_event() + + def _update_state(self): + super()._update_state() + + if ui_state.CP is not None and ui_state.CP_SP is not None: + has_icbm = ui_state.has_icbm + has_long = ui_state.has_longitudinal_control + + if ui_state.CP_SP.intelligentCruiseButtonManagementAvailable and not has_long: + self.icbm_toggle.action_item.set_enabled(ui_state.is_offroad()) + self.icbm_toggle.set_description(tr(ICBM_DESC)) + else: + ui_state.params.remove("IntelligentCruiseButtonManagement") + self.icbm_toggle.action_item.set_enabled(False) + + long_desc = ICMB_UNAVAILABLE + if has_long: + if ui_state.CP.alphaLongitudinalAvailable: + long_desc += " " + ICMB_UNAVAILABLE_LONG_AVAILABLE + else: + long_desc += " " + ICMB_UNAVAILABLE_LONG_UNAVAILABLE + + new_desc = "" + tr(long_desc) + "\n\n" + tr(ICBM_DESC) + if self.icbm_toggle.description != new_desc: + self.icbm_toggle.set_description(new_desc) + self.icbm_toggle.show_description(True) + + if has_long or has_icbm: + self.custom_acc_toggle.action_item.set_enabled(((has_long and not ui_state.CP.pcmCruise) or has_icbm) and ui_state.is_offroad()) + self.dec_toggle.action_item.set_enabled(has_long) + self.scc_v_toggle.action_item.set_enabled(True) + self.scc_m_toggle.action_item.set_enabled(True) + else: + ui_state.params.remove("CustomAccIncrementsEnabled") + ui_state.params.remove("DynamicExperimentalControl") + ui_state.params.remove("SmartCruiseControlVision") + ui_state.params.remove("SmartCruiseControlMap") + self.custom_acc_toggle.action_item.set_enabled(False) + self.dec_toggle.action_item.set_enabled(False) + self.scc_v_toggle.action_item.set_enabled(False) + self.scc_m_toggle.action_item.set_enabled(False) + + else: + has_icbm = has_long = False + self.icbm_toggle.action_item.set_enabled(False) + self.icbm_toggle.set_description(tr(ONROAD_ONLY_DESCRIPTION)) + + show_custom_acc_desc = False + + if ui_state.is_offroad(): + new_custom_acc_desc = tr(ONROAD_ONLY_DESCRIPTION) + show_custom_acc_desc = True + else: + if has_long or has_icbm: + if has_long and ui_state.CP.pcmCruise: + new_custom_acc_desc = tr(ACC_PCMCRUISE_DISABLED_DESCRIPTION) + show_custom_acc_desc = True + else: + new_custom_acc_desc = tr(ACC_ENABLED_DESCRIPTION) + else: + new_custom_acc_desc = tr(ACC_NOLONG_DESCRIPTION) + show_custom_acc_desc = True + self.custom_acc_toggle.action_item.set_state(False) + + if self.custom_acc_toggle.description != new_custom_acc_desc: + self.custom_acc_toggle.set_description(new_custom_acc_desc) + if show_custom_acc_desc: + self.custom_acc_toggle.show_description(True) + + self._on_custom_acc_toggle(self.custom_acc_toggle.action_item.get_state()) + + def _on_custom_acc_toggle(self, state): + self.custom_acc_short_increment.set_visible(state) + self.custom_acc_long_increment.set_visible(state) + self.custom_acc_short_increment.action_item.set_enabled(self.custom_acc_toggle.action_item.enabled) + self.custom_acc_long_increment.action_item.set_enabled(self.custom_acc_toggle.action_item.enabled) diff --git a/selfdrive/ui/sunnypilot/layouts/settings/cruise_sub_layouts/speed_limit_policy.py b/selfdrive/ui/sunnypilot/layouts/settings/cruise_sub_layouts/speed_limit_policy.py new file mode 100644 index 0000000000..e6846744a2 --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/cruise_sub_layouts/speed_limit_policy.py @@ -0,0 +1,65 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from collections.abc import Callable + +import pyray as rl +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.sunnypilot.widgets.list_view import multiple_button_item_sp +from openpilot.system.ui.widgets.network import NavButton +from openpilot.system.ui.widgets.scroller_tici import Scroller +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.sunnypilot.widgets import get_highlighted_description + +SPEED_LIMIT_POLICY_BUTTONS = [tr("Car Only"), tr("Map Only"), tr("Car First"), tr("Map First"), tr("Combined")] + +SPEED_LIMIT_POLICY_DESCRIPTIONS = [ + tr("Car Only: Use Speed Limit data only from Car"), + tr("Map Only: Use Speed Limit data only from OpenStreetMaps"), + tr("Car First: Use Speed Limit data from Car if available, else use from OpenStreetMaps"), + tr("Map First: Use Speed Limit data from OpenStreetMaps if available, else use from Car"), + tr("Combined: Use combined Speed Limit data from Car & OpenStreetMaps") +] + + +class SpeedLimitPolicyLayout(Widget): + def __init__(self, back_btn_callback: Callable): + super().__init__() + self._back_button = NavButton(tr("Back")) + self._back_button.set_click_callback(back_btn_callback) + + items = self._initialize_items() + self._scroller = Scroller(items, line_separator=False, spacing=0) + + def _initialize_items(self): + self._speed_limit_policy = multiple_button_item_sp( + title=lambda: tr("Speed Limit Source"), + description=self._get_policy_description, + buttons=SPEED_LIMIT_POLICY_BUTTONS, + param="SpeedLimitPolicy", + button_width=250, + ) + + items = [ + self._speed_limit_policy + ] + return items + + @staticmethod + def _get_policy_description(): + return get_highlighted_description(ui_state.params, "SpeedLimitPolicy", SPEED_LIMIT_POLICY_DESCRIPTIONS) + + def _render(self, rect): + self._back_button.set_position(self._rect.x, self._rect.y + 20) + self._back_button.render() + + content_rect = rl.Rectangle(rect.x, rect.y + self._back_button.rect.height + 40, rect.width, rect.height - self._back_button.rect.height - 40) + self._scroller.render(content_rect) + + def show_event(self): + self._scroller.show_event() + self._speed_limit_policy.show_description(True) diff --git a/selfdrive/ui/sunnypilot/layouts/settings/cruise_sub_layouts/speed_limit_settings.py b/selfdrive/ui/sunnypilot/layouts/settings/cruise_sub_layouts/speed_limit_settings.py new file mode 100644 index 0000000000..c14330d9ac --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/cruise_sub_layouts/speed_limit_settings.py @@ -0,0 +1,178 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from collections.abc import Callable +from enum import IntEnum + +import pyray as rl +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.cruise_sub_layouts.speed_limit_policy import SpeedLimitPolicyLayout +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.common import Mode as SpeedLimitMode +from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.common import OffsetType as SpeedLimitOffsetType +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.sunnypilot.widgets import get_highlighted_description +from openpilot.system.ui.sunnypilot.widgets.list_view import multiple_button_item_sp, option_item_sp, simple_button_item_sp, LineSeparatorSP +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.network import NavButton +from openpilot.system.ui.widgets.scroller_tici import Scroller + +SPEED_LIMIT_MODE_BUTTONS = [tr("Off"), tr("Info"), tr("Warning"), tr("Assist")] +SPEED_LIMIT_OFFSET_TYPE_BUTTONS = [tr("None"), tr("Fixed"), tr("%")] + +SPEED_LIMIT_MODE_DESCRIPTIONS = [ + tr("Off: Disables the Speed Limit functions."), + tr("Information: Displays the current road's speed limit."), + tr("Warning: Provides a warning when exceeding the current road's speed limit."), + tr("Assist: Adjusts the vehicle's cruise speed based on the current road's speed limit when operating the +/- buttons."), +] + +SPEED_LIMIT_OFFSET_DESCRIPTIONS = [ + tr("None: No Offset"), + tr("Fixed: Adds a fixed offset [Speed Limit + Offset]"), + tr("Percent: Adds a percent offset [Speed Limit + (Offset % Speed Limit)]"), +] + + +class PanelType(IntEnum): + SETTINGS = 0 + POLICY = 1 + + +class SpeedLimitSettingsLayout(Widget): + def __init__(self, back_btn_callback: Callable): + super().__init__() + self._current_panel = PanelType.SETTINGS + + self._back_button = NavButton(tr("Back")) + self._back_button.set_click_callback(back_btn_callback) + + self._policy_layout = SpeedLimitPolicyLayout(lambda: self._set_current_panel(PanelType.SETTINGS)) + + items = self._initialize_items() + self._scroller = Scroller(items, line_separator=False, spacing=0) + + def _initialize_items(self): + self._speed_limit_mode = multiple_button_item_sp( + title=lambda: tr("Speed Limit"), + description=self._get_mode_description, + buttons=SPEED_LIMIT_MODE_BUTTONS, + param="SpeedLimitMode", + button_width=380, + ) + + self._source_button = simple_button_item_sp( + button_text=lambda: tr("Customize Source"), + button_width=720, + callback=lambda: self._set_current_panel(PanelType.POLICY) + ) + + self._speed_limit_offset_type = multiple_button_item_sp( + title=lambda: tr("Speed Limit Offset"), + description="", + buttons=SPEED_LIMIT_OFFSET_TYPE_BUTTONS, + param="SpeedLimitOffsetType", + button_width=450, + ) + + self._speed_limit_value_offset = option_item_sp( + title="", + param="SpeedLimitValueOffset", + min_value=-30, + max_value=30, + description=self._get_offset_description, + label_callback=self._get_offset_label, + ) + + items = [ + self._speed_limit_mode, + LineSeparatorSP(40), + self._source_button, + LineSeparatorSP(40), + self._speed_limit_offset_type, + self._speed_limit_value_offset + ] + return items + + def _set_current_panel(self, panel: PanelType): + self._current_panel = panel + if panel == PanelType.POLICY: + self._policy_layout.show_event() + + @staticmethod + def _get_mode_description(): + return get_highlighted_description(ui_state.params, "SpeedLimitMode", SPEED_LIMIT_MODE_DESCRIPTIONS) + + @staticmethod + def _get_offset_description(): + return get_highlighted_description(ui_state.params, "SpeedLimitOffsetType", SPEED_LIMIT_OFFSET_DESCRIPTIONS) + + @staticmethod + def _get_offset_label(value): + offset_type = int(ui_state.params.get("SpeedLimitOffsetType", return_default=True)) + unit = tr("km/h") if ui_state.is_metric else tr("mph") + + if offset_type == int(SpeedLimitOffsetType.percentage): + return f"{value}%" + elif offset_type == int(SpeedLimitOffsetType.fixed): + return f"{value} {unit}" + return str(value) + + def _update_state(self): + super()._update_state() + + speed_limit_mode_param = ui_state.params.get("SpeedLimitMode", return_default=True) + if ui_state.CP is not None and ui_state.CP_SP is not None: + brand = ui_state.CP.brand + has_long = ui_state.has_longitudinal_control + has_icbm = ui_state.has_icbm + + """ + Speed Limit Assist is available when: + - has_long or has_icbm, and + - is not a release branch or not a disallowed brand, and + - is not always disallwed + """ + sla_disallow_in_release = brand == "tesla" and ui_state.is_sp_release + sla_always_disallow = brand == "rivian" + sla_available = (has_long or has_icbm) and not sla_disallow_in_release and not sla_always_disallow + + if not sla_available and speed_limit_mode_param == int(SpeedLimitMode.assist): + ui_state.params.put("SpeedLimitMode", int(SpeedLimitMode.warning)) + + else: + sla_available = False + + if not sla_available: + self._speed_limit_mode.action_item.set_enabled_buttons({ + int(SpeedLimitMode.off), + int(SpeedLimitMode.information), + int(SpeedLimitMode.warning), + }) + else: + self._speed_limit_mode.action_item.set_enabled_buttons(None) + + offset_type = ui_state.params.get("SpeedLimitOffsetType", return_default=True) + self._speed_limit_value_offset.set_visible(offset_type != int(SpeedLimitOffsetType.off)) + + def _render(self, rect): + if self._current_panel == PanelType.POLICY: + self._policy_layout.render(rect) + return + + self._back_button.set_position(self._rect.x, self._rect.y + 20) + self._back_button.render() + + content_rect = rl.Rectangle(rect.x, rect.y + self._back_button.rect.height + 40, rect.width, rect.height - self._back_button.rect.height - 40) + self._scroller.render(content_rect) + + def show_event(self): + self._current_panel = PanelType.SETTINGS + self._scroller.show_event() + self._speed_limit_mode.show_description(True) + + def hide_event(self): + self._current_panel = PanelType.SETTINGS + self._scroller.hide_event() diff --git a/selfdrive/ui/sunnypilot/layouts/settings/developer.py b/selfdrive/ui/sunnypilot/layouts/settings/developer.py new file mode 100644 index 0000000000..4cac66e316 --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/developer.py @@ -0,0 +1,106 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import datetime +import os +from pathlib import Path + +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.selfdrive.ui.layouts.settings.developer import DeveloperLayout +from openpilot.system.hardware import PC +from openpilot.system.hardware.hw import Paths +from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.widgets import DialogResult +from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog +from openpilot.system.ui.widgets.list_view import button_item + +from openpilot.system.ui.sunnypilot.widgets.html_render import HtmlModalSP +from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp + +PREBUILT_PATH = os.path.join(Paths.comma_home(), "prebuilt") if PC else "/data/openpilot/prebuilt" + + +class DeveloperLayoutSP(DeveloperLayout): + def __init__(self): + super().__init__() + self.error_log_path = os.path.join(Paths.crash_log_root(), "error.log") + self._is_release_branch: bool = self._is_release or ui_state.params.get_bool("IsReleaseSpBranch") + self._is_development_branch: bool = ui_state.params.get_bool("IsTestedBranch") or ui_state.params.get_bool("IsDevelopmentBranch") + self._initialize_items() + + for item in self.items: + self._scroller.add_widget(item) + + def _initialize_items(self): + self.show_advanced_controls = toggle_item_sp(tr("Show Advanced Controls"), + tr("Toggle visibility of advanced sunnypilot controls.
This only changes the visibility of the toggles; " + + "it does not change the actual enabled/disabled state."), param="ShowAdvancedControls") + + self.enable_github_runner_toggle = toggle_item_sp(tr("GitHub Runner Service"), tr("Enables or disables the GitHub runner service."), + param="EnableGithubRunner") + + self.enable_copyparty_toggle = toggle_item_sp(tr("copyparty Service"), + tr("copyparty is a very capable file server, you can use it to download your routes, view your logs " + + "and even make some edits on some files from your browser. " + + "Requires you to connect to your comma locally via its IP address."), param="EnableCopyparty") + + self.prebuilt_toggle = toggle_item_sp(tr("Quickboot Mode"), "", param="QuickBootToggle", callback=self._on_prebuilt_toggled) + + self.error_log_btn = button_item(tr("Error Log"), tr("VIEW"), tr("View the error log for sunnypilot crashes."), callback=self._on_error_log_clicked) + + self.items: list = [self.show_advanced_controls, self.enable_github_runner_toggle, self.enable_copyparty_toggle, self.prebuilt_toggle, self.error_log_btn,] + + @staticmethod + def _on_prebuilt_toggled(state): + if state: + Path(PREBUILT_PATH).touch(exist_ok=True) + else: + os.remove(PREBUILT_PATH) + ui_state.params.put_bool("QuickBootToggle", state) + + def _on_delete_confirm(self, result): + if result == DialogResult.CONFIRM: + if os.path.exists(self.error_log_path): + os.remove(self.error_log_path) + + def _on_error_log_closed(self, result, log_exists): + if result == DialogResult.CONFIRM and log_exists: + dialog2 = ConfirmDialog(tr("Would you like to delete this log?"), tr("Yes"), tr("No"), rich=False, callback=self._on_delete_confirm) + gui_app.push_widget(dialog2) + + def _on_error_log_clicked(self): + text = "" + if os.path.exists(self.error_log_path): + text = f"{datetime.datetime.fromtimestamp(os.path.getmtime(self.error_log_path)).strftime('%d-%b-%Y %H:%M:%S').upper()}

" + try: + with open(self.error_log_path) as file: + text += file.read() + except Exception: + pass + dialog = HtmlModalSP(text=text, callback=lambda result: self._on_error_log_closed(result, os.path.exists(self.error_log_path))) + gui_app.push_widget(dialog) + + def _update_state(self): + disable_updates = ui_state.params.get_bool("DisableUpdates") + show_advanced = ui_state.params.get_bool("ShowAdvancedControls") + + if (prebuilt_file := os.path.exists(PREBUILT_PATH)) != ui_state.params.get_bool("QuickBootToggle"): + ui_state.params.put_bool("QuickBootToggle", prebuilt_file) + self.prebuilt_toggle.action_item.set_state(prebuilt_file) + + self.prebuilt_toggle.set_visible(show_advanced and not (self._is_release_branch or self._is_development_branch)) + self.prebuilt_toggle.action_item.set_enabled(disable_updates) + + if disable_updates: + self.prebuilt_toggle.set_description(tr("When toggled on, this creates a prebuilt file to allow accelerated boot times. When toggled off, it " + + "removes the prebuilt file so compilation of locally edited cpp files can be made.")) + else: + self.prebuilt_toggle.set_description(tr("Quickboot mode requires updates to be disabled.
Enable 'Disable Updates' in the Software panel first.")) + + self.enable_copyparty_toggle.set_visible(show_advanced) + self.enable_github_runner_toggle.set_visible(show_advanced and not self._is_release_branch) + self.error_log_btn.set_visible(not self._is_release_branch) diff --git a/selfdrive/ui/sunnypilot/layouts/settings/device.py b/selfdrive/ui/sunnypilot/layouts/settings/device.py new file mode 100644 index 0000000000..1fb9314739 --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/device.py @@ -0,0 +1,221 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from openpilot.selfdrive.ui.layouts.settings.device import DeviceLayout +from openpilot.selfdrive.ui.onroad.driver_camera_dialog import DriverCameraDialog +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.hardware import HARDWARE +from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.sunnypilot.widgets.list_view import option_item_sp, multiple_button_item_sp, button_item_sp, \ + dual_button_item_sp, Spacer +from openpilot.system.ui.widgets import DialogResult +from openpilot.system.ui.widgets.button import ButtonStyle +from openpilot.system.ui.widgets.confirm_dialog import alert_dialog, ConfirmDialog +from openpilot.system.ui.widgets.list_view import text_item +from openpilot.system.ui.widgets.scroller_tici import LineSeparator + +offroad_time_options = { + 0: 0, + 1: 5, + 2: 10, + 3: 15, + 4: 30, + 5: 60, + 6: 120, + 7: 180, + 8: 300, + 9: 600, + 10: 1440, + 11: 1800, +} + + +class DeviceLayoutSP(DeviceLayout): + def __init__(self): + DeviceLayout.__init__(self) + self._scroller._line_separator = None + + def _initialize_items(self): + DeviceLayout._initialize_items(self) + + # Using dual button with no right button for better alignment + self._always_offroad_btn = dual_button_item_sp( + left_text=lambda: tr("Enable Always Offroad"), + left_callback=self._handle_always_offroad, + right_text="", + right_callback=None, + ) + self._always_offroad_btn.action_item.right_button.set_visible(False) + + self._max_time_offroad = option_item_sp( + title=lambda: tr("Max Time Offroad"), + description=lambda: tr("Device will automatically shutdown after set time once the engine is turned off.\n(30h is the default)"), + param="MaxTimeOffroad", + min_value=0, + max_value=11, + value_change_step=1, + on_value_changed=None, + enabled=True, + icon="", + value_map=offroad_time_options, + label_width=360, + use_float_scaling=False, + inline=True, + label_callback=self._update_max_time_offroad_label + ) + + self._device_wake_mode = multiple_button_item_sp( + title=lambda: tr("Wake Up Behavior"), + description=self.wake_mode_description, + param="DeviceBootMode", + buttons=[lambda: tr("Default"), lambda: tr("Offroad")], + button_width=364, + callback=None, + inline=True, + ) + + self._quiet_mode_and_dcam = dual_button_item_sp( + left_text=lambda: tr("Quiet Mode"), + right_text=lambda: tr("Driver Camera Preview"), + left_callback=lambda: ui_state.params.put_bool("QuietMode", not ui_state.params.get_bool("QuietMode")), + right_callback=lambda: gui_app.push_widget(DriverCameraDialog()) + ) + self._quiet_mode_and_dcam.action_item.right_button.set_button_style(ButtonStyle.NORMAL) + + self._reg_and_training = dual_button_item_sp( + left_text=lambda: tr("Regulatory"), + left_callback=self._on_regulatory, + right_text=lambda: tr("Training Guide"), + right_callback=self._on_review_training_guide + ) + self._reg_and_training.action_item.right_button.set_button_style(ButtonStyle.NORMAL) + + self._onroad_uploads_and_reset_settings = dual_button_item_sp( + left_text=lambda: tr("Onroad Uploads"), + left_callback=lambda: ui_state.params.put_bool("OnroadUploads", not ui_state.params.get_bool("OnroadUploads")), + right_text=lambda: tr("Reset Settings"), + right_callback=self._reset_settings + ) + + self._power_buttons = dual_button_item_sp( + left_text=lambda: tr("Reboot"), + right_text=lambda: tr("Power Off"), + left_callback=self._reboot_prompt, + right_callback=self._power_off_prompt + ) + + items = [ + text_item(lambda: tr("Dongle ID"), self._params.get("DongleId") or (lambda: tr("N/A"))), + LineSeparator(), + text_item(lambda: tr("Serial"), self._params.get("HardwareSerial") or (lambda: tr("N/A"))), + LineSeparator(), + self._pair_device_btn, + LineSeparator(), + self._reset_calib_btn, + LineSeparator(), + button_item_sp(lambda: tr("Change Language"), lambda: tr("CHANGE"), callback=self._show_language_dialog), + LineSeparator(), + self._device_wake_mode, + LineSeparator(), + self._max_time_offroad, + LineSeparator(height=10), + self._quiet_mode_and_dcam, + self._reg_and_training, + self._onroad_uploads_and_reset_settings, + Spacer(10), + LineSeparator(height=10), + self._power_buttons, + ] + + return items + + def _offroad_transition(self): + self._power_buttons.action_item.right_button.set_visible(ui_state.is_offroad()) + + @staticmethod + def wake_mode_description() -> str: + def_str = tr("Default: Device will boot/wake-up normally & will be ready to engage.") + offrd_str = tr("Offroad: Device will be in Always Offroad mode after boot/wake-up.") + header = tr("Controls state of the device after boot/sleep.") + + return f"{header}\n\n{def_str}\n{offrd_str}" + + @staticmethod + def _reset_settings(): + def _do_reset(result: int): + if result == DialogResult.CONFIRM: + for _key in ui_state.params.all_keys(): + ui_state.params.remove(_key) + HARDWARE.reboot() + + def _second_confirm(result: int): + if result == DialogResult.CONFIRM: + gui_app.push_widget(ConfirmDialog( + text=tr("The reset cannot be undone. You have been warned."), + confirm_text=tr("Confirm"), callback=_do_reset + )) + + gui_app.push_widget(ConfirmDialog( + text=tr("Are you sure you want to reset all sunnypilot settings to default? Once the settings are reset, there is no going back."), + confirm_text=tr("Reset"), callback=_second_confirm + )) + + @staticmethod + def _handle_always_offroad(): + if ui_state.engaged: + gui_app.push_widget(alert_dialog(tr("Disengage to Enter Always Offroad Mode"))) + return + + _offroad_mode_state = ui_state.params.get_bool("OffroadMode") + _offroad_mode_str = tr("Are you sure you want to exit Always Offroad mode?") if _offroad_mode_state else \ + tr("Are you sure you want to enter Always Offroad mode?") + + def _set_always_offroad(result: int): + if result == DialogResult.CONFIRM and not ui_state.engaged: + ui_state.params.put_bool("OffroadMode", not _offroad_mode_state) + + gui_app.push_widget(ConfirmDialog(_offroad_mode_str, tr("Confirm"), callback=lambda result: _set_always_offroad(result))) + + @staticmethod + def _update_max_time_offroad_label(value: int) -> str: + label = tr("Always On") if value == 0 else f"{value}" + tr("m") if value < 60 else f"{value // 60}" + tr("h") + label += tr(" (Default)") if value == 1800 else "" + return label + + def _update_state(self): + super()._update_state() + + # Handle Always Offroad button + always_offroad = ui_state.params.get_bool("OffroadMode") + + # Text & Color + offroad_mode_btn_text = tr("Exit Always Offroad") if always_offroad else tr("Enable Always Offroad") + offroad_mode_btn_style = ButtonStyle.PRIMARY if always_offroad else ButtonStyle.DANGER + self._always_offroad_btn.action_item.left_button.set_text(offroad_mode_btn_text) + self._always_offroad_btn.action_item.left_button.set_button_style(offroad_mode_btn_style) + + # Position + if self._scroller._items.__contains__(self._always_offroad_btn): + self._scroller._items.remove(self._always_offroad_btn) + if ui_state.is_offroad() and not always_offroad: + self._scroller._items.insert(len(self._scroller._items) - 1, self._always_offroad_btn) + else: + self._scroller._items.insert(0, self._always_offroad_btn) + + # Quiet Mode button + self._quiet_mode_and_dcam.action_item.left_button.set_button_style(ButtonStyle.PRIMARY if ui_state.params.get_bool("QuietMode") else ButtonStyle.NORMAL) + + # Onroad Uploads + self._onroad_uploads_and_reset_settings.action_item.left_button.set_button_style( + ButtonStyle.PRIMARY if ui_state.params.get_bool("OnroadUploads") else ButtonStyle.NORMAL + ) + + # Offroad only buttons + self._quiet_mode_and_dcam.action_item.right_button.set_enabled(ui_state.is_offroad()) + self._reg_and_training.action_item.left_button.set_enabled(ui_state.is_offroad()) + self._reg_and_training.action_item.right_button.set_enabled(ui_state.is_offroad()) + self._onroad_uploads_and_reset_settings.action_item.right_button.set_enabled(ui_state.is_offroad()) diff --git a/selfdrive/ui/sunnypilot/layouts/settings/display.py b/selfdrive/ui/sunnypilot/layouts/settings/display.py new file mode 100644 index 0000000000..acd7b52dcc --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/display.py @@ -0,0 +1,107 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from enum import IntEnum + +from openpilot.common.params import Params +from openpilot.system.ui.sunnypilot.widgets.option_control import OptionControlSP +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.widgets.scroller_tici import Scroller +from openpilot.system.ui.sunnypilot.widgets.list_view import option_item_sp, ToggleActionSP +from openpilot.sunnypilot.system.params_migration import ONROAD_BRIGHTNESS_TIMER_VALUES + + +class OnroadBrightness(IntEnum): + AUTO = 0 + AUTO_DARK = 1 + SCREEN_OFF = 2 + + +class DisplayLayout(Widget): + def __init__(self): + super().__init__() + + self._params = Params() + items = self._initialize_items() + self._scroller = Scroller(items, line_separator=True, spacing=0) + + def _initialize_items(self): + self._onroad_brightness = option_item_sp( + param="OnroadScreenOffBrightness", + title=lambda: tr("Onroad Brightness"), + description="", + min_value=0, + max_value=22, + value_change_step=1, + label_callback=lambda value: self.update_onroad_brightness(value), + inline=True + ) + self._onroad_brightness_timer = option_item_sp( + param="OnroadScreenOffTimer", + title=lambda: tr("Onroad Brightness Delay"), + description="", + min_value=0, + max_value=15, + value_change_step=1, + value_map=ONROAD_BRIGHTNESS_TIMER_VALUES, + label_callback=lambda value: f"{value} s" if value < 60 else f"{int(value/60)} m", + inline=True + ) + self._interactivity_timeout = option_item_sp( + param="InteractivityTimeout", + title=lambda: tr("Interactivity Timeout"), + description=lambda: tr("Apply a custom timeout for settings UI." + + "
This is the time after which settings UI closes automatically " + + "if user is not interacting with the screen."), + min_value=0, + max_value=120, + value_change_step=10, + label_callback=lambda value: (tr("Default") if not value or value == 0 else + f"{value} s" if value < 60 else f"{int(value/60)} m"), + inline=True + ) + items = [ + self._onroad_brightness, + self._onroad_brightness_timer, + self._interactivity_timeout, + ] + return items + + @staticmethod + def update_onroad_brightness(val): + if val == OnroadBrightness.AUTO: + return tr("Auto (Default)") + + if val == OnroadBrightness.AUTO_DARK: + return tr("Auto (Dark)") + + if val == OnroadBrightness.SCREEN_OFF: + return tr("Screen Off") + + return f"{(val - 2) * 5} %" + + def _update_state(self): + super()._update_state() + + for _item in self._scroller._items: + if isinstance(_item.action_item, ToggleActionSP) and _item.action_item.toggle.param_key is not None: + _item.action_item.set_state(self._params.get_bool(_item.action_item.toggle.param_key)) + elif isinstance(_item.action_item, OptionControlSP) and _item.action_item.param_key is not None: + raw_value = self._params.get(_item.action_item.param_key, return_default=True) + if _item.action_item.value_map: + reverse_map = {v: k for k, v in _item.action_item.value_map.items()} + raw_value = reverse_map.get(raw_value, _item.action_item.current_value) + _item.action_item.set_value(raw_value) + + brightness_val = self._params.get("OnroadScreenOffBrightness", return_default=True) + self._onroad_brightness_timer.action_item.set_enabled(brightness_val not in (OnroadBrightness.AUTO, OnroadBrightness.AUTO_DARK)) + + def _render(self, rect): + self._scroller.render(rect) + + def show_event(self): + self._scroller.show_event() diff --git a/selfdrive/ui/sunnypilot/layouts/settings/models.py b/selfdrive/ui/sunnypilot/layouts/settings/models.py new file mode 100644 index 0000000000..b34604af0c --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/models.py @@ -0,0 +1,260 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import os +import re +import time +import pyray as rl + +from cereal import custom +from openpilot.common.constants import CV +from openpilot.selfdrive.ui.ui_state import device, ui_state +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.widgets import DialogResult, Widget +from openpilot.system.ui.widgets.confirm_dialog import alert_dialog, ConfirmDialog +from openpilot.system.ui.widgets.scroller_tici import Scroller +from openpilot.system.ui.widgets.toggle import ON_COLOR + +from openpilot.sunnypilot.models.runners.constants import CUSTOM_MODEL_PATH +from openpilot.system.ui.sunnypilot.lib.styles import style +from openpilot.system.ui.sunnypilot.lib.utils import NoElideButtonAction +from openpilot.system.ui.sunnypilot.widgets.list_view import ListItemSP, toggle_item_sp, option_item_sp +from openpilot.system.ui.sunnypilot.widgets.progress_bar import progress_item +from openpilot.system.ui.sunnypilot.widgets.tree_dialog import TreeOptionDialog, TreeNode, TreeFolder + +if gui_app.sunnypilot_ui(): + from openpilot.system.ui.sunnypilot.widgets.list_view import button_item_sp as button_item + + +class ModelsLayout(Widget): + def __init__(self): + super().__init__() + self.model_manager = None + self.download_status = None + self.prev_download_status = None + self.model_dialog = None + self.last_cache_calc_time = 0 + + self._initialize_items() + + self.clear_cache_item.action_item.set_value(f"{self._calculate_cache_size():.2f} MB") + for ctrl, key in [(self.lane_turn_value_control, "LaneTurnValue"), (self.delay_control, "LagdToggleDelay")]: + ctrl.action_item.set_value(int(float(ui_state.params.get(key, return_default=True)) * 100)) + + self._scroller = Scroller(self.items, line_separator=True, spacing=0) + + def _initialize_items(self): + self.current_model_item = ListItemSP( + title=tr("Current Model"), + description="", + action_item=NoElideButtonAction(tr("SELECT")), + callback=self._handle_current_model_clicked + ) + + self.supercombo_label = progress_item(tr("Driving Model")) + self.vision_label = progress_item(tr("Vision Model")) + self.policy_label = progress_item(tr("Policy Model")) + self.off_policy_label = progress_item(tr("Off-Policy Model")) + self.on_policy_label = progress_item(tr("On-Policy Model")) + + self.refresh_item = button_item(tr("Refresh Model List"), tr("REFRESH"), "", + lambda: (ui_state.params.put("ModelManager_LastSyncTime", 0), + gui_app.push_widget(alert_dialog(tr("Fetching Latest Models"))))) + + self.clear_cache_item = ListItemSP( + title=tr("Clear Model Cache"), + description="", + action_item=NoElideButtonAction(tr("CLEAR")), + callback=self._clear_cache + ) + + self.cancel_download_item = button_item(tr("Cancel Download"), tr("Cancel"), "", lambda: ui_state.params.remove("ModelManager_DownloadIndex")) + + self.lane_turn_value_control = option_item_sp(tr("Adjust Lane Turn Speed"), "LaneTurnValue", 500, 2000, + tr("Set the maximum speed for lane turn desires. Default is 19 mph."), + int(round(100 / CV.MPH_TO_KPH)), None, True, "", style.BUTTON_ACTION_WIDTH, None, True, + lambda v: f"{int(round(v / 100 * (CV.MPH_TO_KPH if ui_state.is_metric else 1)))}" + + f" {'km/h' if ui_state.is_metric else 'mph'}") + + self.lane_turn_desire_toggle = toggle_item_sp(tr("Use Lane Turn Desires"), + tr("If you're driving at 20 mph (32 km/h) or below and have your blinker on," + + " the car will plan a turn in that direction at the nearest drivable path. " + + "This prevents situations (like at red lights) where the car might plan the wrong turn direction."), + param="LaneTurnDesire") + + self.delay_control = option_item_sp(tr("Adjust Software Delay"), "LagdToggleDelay", 5, 50, + tr("Adjust the software delay when Live Learning Steer Delay is toggled off. The default software delay value is 0.2"), + 1, None, True, "", style.BUTTON_ACTION_WIDTH, None, True, lambda v: f"{v / 100:.2f}s") + + self.lagd_toggle = toggle_item_sp(tr("Live Learning Steer Delay"), "", param="LagdToggle") + + self.items = [self.current_model_item, self.cancel_download_item, self.supercombo_label, self.vision_label, + self.policy_label, self.off_policy_label, self.on_policy_label, self.refresh_item, self.clear_cache_item, self.lane_turn_desire_toggle, + self.lane_turn_value_control, self.lagd_toggle, self.delay_control] + + def _update_lagd_description(self, lagd_toggle: bool): + desc = tr("Enable this for the car to learn and adapt its steering response time. Disable to use a fixed steering response time. " + + "Keeping this on provides the stock openpilot experience.") + if lagd_toggle: + desc += f"
{tr('Live Steer Delay:')} {ui_state.sm['liveDelay'].lateralDelay:.3f} s" + elif ui_state.CP is not None: + sw = float(ui_state.params.get("LagdToggleDelay", "0.2")) + cp = ui_state.CP.steerActuatorDelay + desc += f"
{tr('Actuator Delay:')} {cp:.2f} s + {tr('Software Delay:')} {sw:.2f} s = {tr('Total Delay:')} {cp + sw:.2f} s" + self.lagd_toggle.set_description(desc) + + def _is_downloading(self): + return (self.model_manager and self.model_manager.selectedBundle and + self.model_manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.downloading) + + @staticmethod + def _calculate_cache_size(): + cache_size = 0.0 + if os.path.exists(CUSTOM_MODEL_PATH): + cache_size = sum(os.path.getsize(os.path.join(CUSTOM_MODEL_PATH, file)) for file in os.listdir(CUSTOM_MODEL_PATH)) / (1024**2) + return cache_size + + def _clear_cache(self): + def _callback(response): + if response == DialogResult.CONFIRM: + ui_state.params.put_bool("ModelManager_ClearCache", True) + self.clear_cache_item.action_item.set_value(f"{self._calculate_cache_size():.2f} MB") + + dialog = ConfirmDialog(tr("This will delete ALL downloaded models from the cache except the currently active model. Are you sure?"), + tr("Clear Cache"), callback=_callback) + gui_app.push_widget(dialog) + + def _handle_bundle_download_progress(self): + labels = {custom.ModelManagerSP.Model.Type.supercombo: self.supercombo_label, + custom.ModelManagerSP.Model.Type.vision: self.vision_label, + custom.ModelManagerSP.Model.Type.policy: self.policy_label, + custom.ModelManagerSP.Model.Type.offPolicy: self.off_policy_label, + custom.ModelManagerSP.Model.Type.onPolicy: self.on_policy_label} + for label in labels.values(): + label.set_visible(False) + self.cancel_download_item.set_visible(False) + + if not self.model_manager or (not self.model_manager.selectedBundle and not self.model_manager.activeBundle): + return + + bundle = self.model_manager.selectedBundle if self._is_downloading() or ( + self.model_manager.selectedBundle and self.model_manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.failed + ) else self.model_manager.activeBundle + if not bundle: + return + + self.download_status = bundle.status + status_changed = self.prev_download_status != self.download_status + self.prev_download_status = self.download_status + + self.cancel_download_item.set_visible(bool(self.model_manager.selectedBundle) and bool(ui_state.params.get("ModelManager_DownloadIndex"))) + + if (current_time := time.monotonic()) - self.last_cache_calc_time > 0.5: + self.last_cache_calc_time = current_time + self.clear_cache_item.action_item.set_value(f"{self._calculate_cache_size():.2f} MB") + + if self.download_status == custom.ModelManagerSP.DownloadStatus.downloading: + device._reset_interactive_timeout() + + for model in bundle.models: + if label := labels.get(getattr(model.type, 'raw', model.type)): + label.set_visible(True) + p = model.artifact.downloadProgress + text, show, color = f"pending - {bundle.displayName}", False, rl.GRAY + if p.status == custom.ModelManagerSP.DownloadStatus.downloading: + text, show = f"{int(p.progress)}% - {bundle.displayName}", True + elif p.status in (custom.ModelManagerSP.DownloadStatus.downloaded, custom.ModelManagerSP.DownloadStatus.cached): + status_text = tr("from cache" if p.status == custom.ModelManagerSP.DownloadStatus.cached else "downloaded") + text, color = f"{bundle.displayName} - {status_text if status_changed else tr('ready')}", ON_COLOR + elif p.status == custom.ModelManagerSP.DownloadStatus.failed: + text, color = f"download failed - {bundle.displayName}", rl.RED + label.action_item.update(p.progress, text, show, color) + + @staticmethod + def _show_reset_params_dialog(): + def _callback(response): + if response == DialogResult.CONFIRM: + ui_state.params.remove("CalibrationParams") + ui_state.params.remove("LiveTorqueParameters") + msg = tr("Model download has started in the background. We suggest resetting calibration. Would you like to do that now?") + dialog = ConfirmDialog(msg, tr("Reset Calibration"), callback=_callback) + gui_app.push_widget(dialog) + + def _on_model_selected(self, result): + if result != DialogResult.CONFIRM: + return + selected_ref = self.model_dialog.selection_ref + if selected_ref == "Default": + ui_state.params.remove("ModelManager_ActiveBundle") + self._show_reset_params_dialog() + elif selected_bundle := next((bundle for bundle in self.model_manager.availableBundles if bundle.ref == selected_ref), None): + ui_state.params.put("ModelManager_DownloadIndex", selected_bundle.index) + if self.model_manager.activeBundle and selected_bundle.generation != self.model_manager.activeBundle.generation: + self._show_reset_params_dialog() + self.model_dialog = None + + @staticmethod + def _bundle_to_node(bundle): + return TreeNode(bundle.ref, {'display_name': bundle.displayName, 'short_name': bundle.internalName}) + + def _get_folders(self, favorites): + bundles = self.model_manager.availableBundles + folders = {} + for bundle in bundles: + folders.setdefault(next((ov_ride.value for ov_ride in bundle.overrides if ov_ride.key == "folder"), ""), []).append(bundle) + + folders_list = [TreeFolder("", [TreeNode("Default", {'display_name': tr("Default Model"), 'short_name': "Default"})])] + for folder, folder_bundles in sorted(folders.items(), key=lambda x: max((bundle.index for bundle in x[1]), default=-1), reverse=True): + folder_bundles.sort(key=lambda bundle: bundle.index, reverse=True) + name = folder + (f" - (Updated: {m.group(1)})" if folder_bundles and (m := re.search(r'\(([^)]*)\)[^(]*$', folder_bundles[0].displayName)) else "") + folders_list.append(TreeFolder(name, [self._bundle_to_node(bundle) for bundle in folder_bundles])) + + if favorites and (fav_bundles := [bundle for bundle in bundles if bundle.ref in favorites]): + folders_list.insert(1, TreeFolder("Favorites", [self._bundle_to_node(bundle) for bundle in fav_bundles])) + return folders_list + + def _handle_current_model_clicked(self): + favs = ui_state.params.get("ModelManager_Favs") + favorites = set(favs.split(';')) if favs else set() + folders_list = self._get_folders(favorites) + + active_ref = self.model_manager.activeBundle.ref if self.model_manager.activeBundle else "Default" + self.model_dialog = TreeOptionDialog(tr("Select a Model"), folders_list, active_ref, "ModelManager_Favs", + get_folders_fn=self._get_folders, on_exit=self._on_model_selected) + gui_app.push_widget(self.model_dialog) + + def _update_state(self): + advanced_controls: bool = ui_state.params.get_bool("ShowAdvancedControls") + turn_desire: bool = ui_state.params.get_bool("LaneTurnDesire") + live_delay: bool = ui_state.params.get_bool("LagdToggle") + + self.lane_turn_desire_toggle.action_item.set_state(turn_desire) + self.lane_turn_value_control.set_visible(turn_desire and advanced_controls) + self.lagd_toggle.action_item.set_state(live_delay) + self.delay_control.set_visible(not live_delay and advanced_controls) + new_step = int(round(100 / CV.MPH_TO_KPH)) if ui_state.is_metric else 100 + if self.lane_turn_value_control.action_item.value_change_step != new_step: + self.lane_turn_value_control.action_item.value_change_step = new_step + + self._update_lagd_description(live_delay) + self.model_manager = ui_state.sm["modelManagerSP"] + self._handle_bundle_download_progress() + active_name = self.model_manager.activeBundle.internalName if self.model_manager and self.model_manager.activeBundle.ref else tr("Default Model") + self.current_model_item.action_item.set_value(active_name) + + if not ui_state.is_offroad(): + self.current_model_item.action_item.set_enabled(False) + self.current_model_item.set_description(tr("Only available when vehicle is off, or always offroad mode is on")) + else: + self.current_model_item.action_item.set_enabled(True) + self.current_model_item.set_description("") + + def _render(self, rect): + self._scroller.render(rect) + + def show_event(self): + self._scroller.show_event() diff --git a/selfdrive/ui/sunnypilot/layouts/settings/navigation.py b/selfdrive/ui/sunnypilot/layouts/settings/navigation.py new file mode 100644 index 0000000000..1f44775bb3 --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/navigation.py @@ -0,0 +1,30 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from openpilot.common.params import Params +from openpilot.system.ui.widgets.scroller_tici import Scroller +from openpilot.system.ui.widgets import Widget + + +class NavigationLayout(Widget): + def __init__(self): + super().__init__() + + self._params = Params() + items = self._initialize_items() + self._scroller = Scroller(items, line_separator=True, spacing=0) + + def _initialize_items(self): + items = [ + + ] + return items + + def _render(self, rect): + self._scroller.render(rect) + + def show_event(self): + self._scroller.show_event() diff --git a/selfdrive/ui/sunnypilot/layouts/settings/network.py b/selfdrive/ui/sunnypilot/layouts/settings/network.py new file mode 100644 index 0000000000..14f573c628 --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/network.py @@ -0,0 +1,46 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import threading +import time +import pyray as rl + +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.widgets.button import Button, ButtonStyle +from openpilot.system.ui.widgets.network import NetworkUI, PanelType + + +class NetworkUISP(NetworkUI): + def __init__(self, wifi_manager): + super().__init__(wifi_manager) + + self.scan_button = Button(tr("Scan"), self._scan_clicked, button_style=ButtonStyle.NORMAL, font_size=60, border_radius=30) + self.scan_button.set_rect(rl.Rectangle(0, 0, 400, 100)) + + self._scanning = False + self._wifi_manager.add_callbacks(networks_updated=self._on_networks_updated) + + def _scan_clicked(self): + self._scanning = True + self.scan_button.set_text(tr("Scanning...")) + self.scan_button.set_enabled(False) + + threading.Thread(target=self._wifi_manager._update_networks, daemon=True).start() + self._wifi_manager._request_scan() + self._wifi_manager._last_network_update = time.monotonic() + + def _on_networks_updated(self, networks): + if self._scanning: + self._scanning = False + self.scan_button.set_text(tr("Scan")) + self.scan_button.set_enabled(True) + + def _render(self, rect: rl.Rectangle): + super()._render(rect) + + if self._current_panel == PanelType.WIFI: + self.scan_button.set_position(self._rect.x, self._rect.y + 20) + self.scan_button.render() diff --git a/selfdrive/ui/sunnypilot/layouts/settings/osm.py b/selfdrive/ui/sunnypilot/layouts/settings/osm.py new file mode 100644 index 0000000000..58236961b9 --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/osm.py @@ -0,0 +1,232 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import datetime +import os +import platform +import requests +import shutil +import threading +from pathlib import Path +from time import monotonic + +from openpilot.common.params import Params +from openpilot.selfdrive.ui.ui_state import device, ui_state +from openpilot.selfdrive.ui.layouts.settings.software import time_ago +from openpilot.system.hardware.hw import Paths +from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.widgets import DialogResult, Widget +from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog +from openpilot.system.ui.widgets.list_view import text_item +from openpilot.system.ui.widgets.scroller_tici import Scroller + +from openpilot.system.ui.sunnypilot.lib.utils import NoElideButtonAction +from openpilot.system.ui.sunnypilot.widgets.list_view import ListItemSP +from openpilot.system.ui.sunnypilot.widgets.tree_dialog import TreeFolder, TreeNode, TreeOptionDialog +from openpilot.system.ui.sunnypilot.widgets.progress_bar import progress_item + +MAP_PATH = Path(Paths.mapd_root()) / "offline" + + +class OSMLayout(Widget): + def __init__(self): + super().__init__() + self._current_percent = 0 + self._last_map_size_update = 0 + self._mem_params = Params("/dev/shm/params") if platform.system() != "Darwin" else ui_state.params + self._initialize_items() + self._update_map_size() + self._progress.set_visible(False) + self._state_btn.set_visible(False) + self._mapd_version.action_item.set_text(ui_state.params.get("MapdVersion") or "Loading...") + self._scroller = Scroller(self.items, line_separator=True, spacing=0) + + def _initialize_items(self): + self._mapd_version = text_item(tr("Mapd Version"), lambda: ui_state.params.get("MapdVersion") or "Loading...") + self._delete_maps_btn = ListItemSP(tr("Downloaded Maps"), action_item=NoElideButtonAction(tr("DELETE"), enabled=True), callback=self._delete_maps) + self._progress = progress_item(tr("Downloading Map")) + self._update_btn = ListItemSP(tr("Database Update"), action_item=NoElideButtonAction(tr("CHECK"), enabled=True), callback=self._update_db) + self._country_btn = ListItemSP(tr("Country"), action_item=NoElideButtonAction(tr("SELECT"), enabled=True), callback=lambda: self._select_region("Country")) + self._state_btn = ListItemSP(tr("State"), action_item=NoElideButtonAction(tr("SELECT"), enabled=True), callback=lambda: self._select_region("State")) + + self.items = [self._mapd_version, self._delete_maps_btn, self._progress, self._update_btn, self._country_btn, self._state_btn] + + def _show_confirm(self, msg, confirm_text, func): + gui_app.push_widget(ConfirmDialog(msg, confirm_text, callback=lambda res: func() if res == DialogResult.CONFIRM else None)) + + def calculate_size(self): + total_size = 0 + directories_to_scan = [MAP_PATH] if MAP_PATH.exists() else [] + while directories_to_scan: + try: + for entry in os.scandir(directories_to_scan.pop()): + if entry.is_file(): + total_size += entry.stat().st_size + elif entry.is_dir(): + directories_to_scan.append(entry.path) + except OSError: + pass + self._delete_maps_btn.action_item.set_value(f"{total_size / 1024 ** 2:.2f} MB" if total_size < 1024 ** 3 else f"{total_size / 1024 ** 3:.2f} GB") + + def _update_map_size(self): + threading.Thread(target=self.calculate_size, daemon=True).start() + + def _do_delete_maps(self): + if MAP_PATH.exists(): + shutil.rmtree(MAP_PATH) + + for param in ("OsmDownloadedDate", "OsmLocal", "OsmLocationName", "OsmLocationTitle", "OsmStateName", "OsmStateTitle"): + ui_state.params.remove(param) + + self._delete_maps_btn.action_item.set_enabled(True) + self._delete_maps_btn.action_item.set_text(tr("DELETE")) + self._update_map_size() + + def _on_confirm_delete_maps(self): + self._delete_maps_btn.action_item.set_enabled(False) + self._delete_maps_btn.action_item.set_text("DELETING...") + threading.Thread(target=self._do_delete_maps).start() + + def _delete_maps(self): + self._show_confirm(tr("This will delete ALL downloaded maps\n\nAre you sure you want to delete all maps?"), + tr("Yes, delete all maps"), self._on_confirm_delete_maps) + + def _update_db(self): + self._show_confirm(tr("This will start the download process and it might take a while to complete."), tr("Start Download"), + lambda: ui_state.params.put_bool("OsmDbUpdatesCheck", True)) + + def _select_region(self, region_type): + is_country = region_type == "Country" + btn = self._country_btn if is_country else self._state_btn + btn.action_item.set_enabled(False) + btn.action_item.set_text(tr("FETCHING...")) + threading.Thread(target=self._do_select_region, args=(region_type, btn)).start() + + def _handle_region_selection(self, region_type, locations, key, res, ref): + if res != DialogResult.CONFIRM or not ref: + if region_type == "State" and res == DialogResult.CANCEL: + if ui_state.params.get("OsmLocationName") == "US" and not ui_state.params.get("OsmStateName"): + ui_state.params.remove("OsmLocationName") + ui_state.params.remove("OsmLocationTitle") + ui_state.params.remove("OsmLocal") + self._update_labels() + return + + if region_type == "Country": + ui_state.params.put_bool("OsmLocal", True) + ui_state.params.remove("OsmStateName") + ui_state.params.remove("OsmStateTitle") + + ui_state.params.put(f"{key}Name", ref) + name = next((n.data['display_name'] for n in locations if n.ref == ref), ref) + ui_state.params.put(f"{key}Title", name) + + if ref == "US" and region_type == "Country": + self._select_region("State") + else: + self._update_db() + + def _do_select_region(self, region_type, btn): + base_url = "https://raw.githubusercontent.com/pfeiferj/openpilot-mapd/main/" + url = base_url + ("nation_bounding_boxes.json" if region_type == "Country" else "us_states_bounding_boxes.json") + try: + data = requests.get(url, timeout=10).json() + locations = sorted([TreeNode(ref=k, data={'display_name': v['full_name']}) for k, v in data.items()], key=lambda n: n.data['display_name']) + except Exception: + locations = [] + + if region_type == "State": + locations.insert(0, TreeNode(ref="All", data={'display_name': tr("All states (~6.0 GB)")})) + + btn.action_item.set_enabled(True) + btn.action_item.set_text(tr("SELECT")) + + key = "OsmLocation" if region_type == "Country" else "OsmState" + current = ui_state.params.get(f"{key}Name") or "" + + dialog = TreeOptionDialog(tr(f"Select {region_type}"), [TreeFolder(folder="", nodes=locations)], current_ref=current, search_prompt="Perform a search") + dialog.on_exit = lambda res: self._handle_region_selection(region_type, locations, key, res, dialog.selection_ref) + gui_app.push_widget(dialog) + + def _update_labels(self): + downloading = bool(self._mem_params.get("OSMDownloadLocations")) + self._country_btn.set_enabled(not downloading) + self._state_btn.set_enabled(not downloading) + self._state_btn.set_visible(ui_state.params.get("OsmLocationName") == "US") + self._update_btn.set_visible(bool(ui_state.params.get("OsmLocationName"))) + + self._country_btn.action_item.set_value(ui_state.params.get("OsmLocationTitle") or "") + self._state_btn.action_item.set_value(ui_state.params.get("OsmStateTitle") or "") + + pending = ui_state.params.get_bool("OsmDbUpdatesCheck") + if downloading or pending: + if downloading: + device._reset_interactive_timeout() + self._update_map_size() + self._progress.set_visible(True) + progress = ui_state.params.get("OSMDownloadProgress") + total = progress.get('total_files', 0) if progress else 0 + done = progress.get('downloaded_files', 0) if progress else 0 + failed = total > 0 and not downloading and done < total + + if total > 0: + progress_perc = max(0.0, min(100.0, (done / total) * 100.0)) + else: + progress_perc = 0.0 + + if failed: + text = "0% - Downloading Maps" + btn_text = tr("Error: Invalid download. Retry.") + self._current_percent = 0.0 + elif total > 0 and downloading: + self._current_percent = progress_perc + perc_int = int(progress_perc) + text = f"{perc_int}% - Downloading Maps" + btn_text = f"{done}/{total} ({perc_int}%)" + else: + self._current_percent = 0.0 + text = "0% - Downloading Maps" + btn_text = tr("Downloading Maps...") + + self._progress.action_item.update(self._current_percent, text, show_progress=total > 0 and downloading and not failed) + self._update_btn.action_item.set_enabled(not downloading) # TODO-SP: introduce CANCEL database download with mapd + self._update_btn.action_item.set_value(btn_text) + self._country_btn.action_item.set_enabled(not downloading) + self._state_btn.action_item.set_enabled(not downloading) + self._delete_maps_btn.action_item.set_enabled(not downloading) + else: + self._progress.set_visible(False) + self._update_btn.action_item.set_enabled(True) + self._country_btn.action_item.set_enabled(True) + self._state_btn.action_item.set_enabled(True) + self._delete_maps_btn.action_item.set_enabled(True) + + ts = ui_state.params.get("OsmDownloadedDate") + dt: datetime.datetime | None = None + + if ts: + try: + ts_f = float(ts) + if ts_f > 0: + dt = datetime.datetime.fromtimestamp(ts_f, tz=datetime.UTC) + except (ValueError, TypeError): + dt = None + + formatted = time_ago(dt) + self._update_btn.action_item.set_value(tr("Last checked {}").format(formatted)) + + def show_event(self): + self._scroller.show_event() + + def _update_state(self): + now = monotonic() + if now - self._last_map_size_update >= 1.0: + self._last_map_size_update = now + self._update_labels() + + def _render(self, rect): + self._scroller.render(rect) diff --git a/selfdrive/ui/sunnypilot/layouts/settings/settings.py b/selfdrive/ui/sunnypilot/layouts/settings/settings.py new file mode 100644 index 0000000000..4917c9a157 --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/settings.py @@ -0,0 +1,201 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from dataclasses import dataclass +from enum import IntEnum + +import pyray as rl +from openpilot.selfdrive.ui.layouts.settings import settings as OP +from openpilot.selfdrive.ui.layouts.settings.firehose import FirehoseLayout +from openpilot.selfdrive.ui.layouts.settings.toggles import TogglesLayout +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.cruise import CruiseLayout +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.developer import DeveloperLayoutSP +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.device import DeviceLayoutSP +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.display import DisplayLayout +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.models import ModelsLayout +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.network import NetworkUISP +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.osm import OSMLayout +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.software import SoftwareLayoutSP +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.steering import SteeringLayout +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.sunnylink import SunnylinkLayout +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.trips import TripsLayout +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle import VehicleLayout +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.visuals import VisualsLayout +from openpilot.system.ui.lib.application import gui_app, MousePos +from openpilot.system.ui.lib.multilang import tr_noop +from openpilot.system.ui.lib.text_measure import measure_text_cached +from openpilot.system.ui.lib.wifi_manager import WifiManager +from openpilot.system.ui.sunnypilot.lib.styles import style +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.scroller_tici import Scroller + +# from openpilot.selfdrive.ui.sunnypilot.layouts.settings.navigation import NavigationLayout + +OP.PANEL_COLOR = rl.Color(10, 10, 10, 255) +ICON_SIZE = 70 + +OP.PanelType = IntEnum( + "PanelType", + [es.name for es in OP.PanelType] + [ + "SUNNYLINK", + "MODELS", + "STEERING", + "CRUISE", + "VISUALS", + "DISPLAY", + "OSM", + "NAVIGATION", + "TRIPS", + "VEHICLE", + ], + start=0, +) + + +@dataclass +class PanelInfo(OP.PanelInfo): + icon: str = "" + + +class NavButton(Widget): + def __init__(self, parent, p_type, p_info): + super().__init__() + self.parent = parent + self.panel_type = p_type + self.panel_info = p_info + + def _render(self, rect): + is_selected = self.panel_type == self.parent._current_panel + text_color = OP.TEXT_SELECTED if is_selected else OP.TEXT_NORMAL + content_x = rect.x + 90 + text_size = measure_text_cached(self.parent._font_medium, self.panel_info.name, 65) + + # Draw background if selected + if is_selected: + self.container_rect = rl.Rectangle( + content_x - 50, rect.y, OP.SIDEBAR_WIDTH - 50, OP.NAV_BTN_HEIGHT + ) + rl.draw_rectangle_rounded(self.container_rect, 0.2, 5, OP.CLOSE_BTN_COLOR) + + if self.panel_info.icon: + icon_texture = gui_app.texture(self.panel_info.icon, ICON_SIZE, ICON_SIZE, keep_aspect_ratio=True) + rl.draw_texture_ex(icon_texture, rl.Vector2(content_x, rect.y + (OP.NAV_BTN_HEIGHT - icon_texture.height) / 2), 0.0, 1.0, rl.WHITE) + content_x += ICON_SIZE + 20 + + # Draw button text (right-aligned) + text_pos = rl.Vector2( + content_x, + rect.y + (OP.NAV_BTN_HEIGHT - text_size.y) / 2 + ) + rl.draw_text_ex(self.parent._font_medium, self.panel_info.name, text_pos, 55, 0, text_color) + + # Store button rect for click detection + self.panel_info.button_rect = rect + + +class SettingsLayoutSP(OP.SettingsLayout): + def __init__(self): + OP.SettingsLayout.__init__(self) + self._nav_items: list[Widget] = [] + + # Create sidebar scroller + self._sidebar_scroller = Scroller([], spacing=0, line_separator=False, pad_end=False) + + # Panel configuration + wifi_manager = WifiManager() + wifi_manager.set_active(False) + + self._panels = { + OP.PanelType.DEVICE: PanelInfo(tr_noop("Device"), DeviceLayoutSP(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_home.png"), + OP.PanelType.NETWORK: PanelInfo(tr_noop("Network"), NetworkUISP(wifi_manager), icon="icons/network.png"), + OP.PanelType.SUNNYLINK: PanelInfo(tr_noop("sunnylink"), SunnylinkLayout(), icon="icons/wifi_strength_full.png"), + OP.PanelType.TOGGLES: PanelInfo(tr_noop("Toggles"), TogglesLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_toggle.png"), + OP.PanelType.SOFTWARE: PanelInfo(tr_noop("Software"), SoftwareLayoutSP(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_software.png"), + OP.PanelType.MODELS: PanelInfo(tr_noop("Models"), ModelsLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_models.png"), + OP.PanelType.STEERING: PanelInfo(tr_noop("Steering"), SteeringLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_lateral.png"), + OP.PanelType.CRUISE: PanelInfo(tr_noop("Cruise"), CruiseLayout(), icon="icons/speed_limit.png"), + OP.PanelType.VISUALS: PanelInfo(tr_noop("Visuals"), VisualsLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_visuals.png"), + OP.PanelType.DISPLAY: PanelInfo(tr_noop("Display"), DisplayLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_display.png"), + OP.PanelType.OSM: PanelInfo(tr_noop("OSM"), OSMLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_map.png"), + # OP.PanelType.NAVIGATION: PanelInfo(tr_noop("Navigation"), NavigationLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_map.png"), + OP.PanelType.TRIPS: PanelInfo(tr_noop("Trips"), TripsLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_trips.png"), + OP.PanelType.VEHICLE: PanelInfo(tr_noop("Vehicle"), VehicleLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_vehicle.png"), + OP.PanelType.FIREHOSE: PanelInfo(tr_noop("Firehose"), FirehoseLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_firehose.png"), + OP.PanelType.DEVELOPER: PanelInfo(tr_noop("Developer"), DeveloperLayoutSP(), icon="icons/shell.png"), + } + + def _draw_sidebar(self, rect: rl.Rectangle): + rl.draw_rectangle_rec(rect, OP.SIDEBAR_COLOR) + + # Close button + close_btn_rect = rl.Rectangle( + rect.x + style.ITEM_PADDING * 3, rect.y + style.ITEM_PADDING * 2, style.CLOSE_BTN_SIZE, style.CLOSE_BTN_SIZE + ) + + pressed = (rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT) and + rl.check_collision_point_rec(rl.get_mouse_position(), close_btn_rect)) + close_color = OP.CLOSE_BTN_PRESSED if pressed else OP.CLOSE_BTN_COLOR + rl.draw_rectangle_rounded(close_btn_rect, 1.0, 20, close_color) + + icon_color = rl.Color(255, 255, 255, 255) if not pressed else rl.Color(220, 220, 220, 255) + icon_dest = rl.Rectangle( + close_btn_rect.x + (close_btn_rect.width - self._close_icon.width) / 2, + close_btn_rect.y + (close_btn_rect.height - self._close_icon.height) / 2, + self._close_icon.width, + self._close_icon.height, + ) + rl.draw_texture_pro( + self._close_icon, + rl.Rectangle(0, 0, self._close_icon.width, self._close_icon.height), + icon_dest, + rl.Vector2(0, 0), + 0, + icon_color, + ) + + # Store close button rect for click detection + self._close_btn_rect = close_btn_rect + + # Navigation buttons with scroller + if not self._nav_items: + for panel_type, panel_info in self._panels.items(): + nav_button = NavButton(self, panel_type, panel_info) + nav_button.rect.width = rect.width - 100 # Full width minus padding + nav_button.rect.height = OP.NAV_BTN_HEIGHT + self._nav_items.append(nav_button) + self._sidebar_scroller.add_widget(nav_button) + + # Draw navigation section with scroller + nav_rect = rl.Rectangle( + rect.x, + self._close_btn_rect.height + style.ITEM_PADDING * 4, # Starting Y position for nav items + rect.width, + rect.height - 300 # Remaining height after close button + ) + + if self._nav_items: + self._sidebar_scroller.render(nav_rect) + return + + def _handle_mouse_release(self, mouse_pos: MousePos) -> bool: + # Check close button + if rl.check_collision_point_rec(mouse_pos, self._close_btn_rect): + if self._close_callback: + self._close_callback() + return True + + # Check navigation buttons + for panel_type, panel_info in self._panels.items(): + if rl.check_collision_point_rec(mouse_pos, panel_info.button_rect) and self._sidebar_scroller.scroll_panel.is_touch_valid(): + self.set_current_panel(panel_type) + return True + + return False + + def show_event(self): + super().show_event() + self._panels[self._current_panel].instance.show_event() + self._sidebar_scroller.show_event() diff --git a/selfdrive/ui/sunnypilot/layouts/settings/software.py b/selfdrive/ui/sunnypilot/layouts/settings/software.py new file mode 100644 index 0000000000..7765a58b65 --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/software.py @@ -0,0 +1,96 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import os + +from openpilot.selfdrive.ui.layouts.settings.software import SoftwareLayout +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.hardware import HARDWARE +from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.multilang import tr, tr_noop +from openpilot.system.ui.widgets import DialogResult +from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog + +from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp +from openpilot.system.ui.sunnypilot.widgets.tree_dialog import TreeOptionDialog, TreeNode, TreeFolder + + +DESCRIPTIONS = { + 'disable_updates_offroad': tr_noop( + "When enabled, automatic software updates will be off.
This requires a reboot to take effect." + ), + 'disable_updates_onroad': tr_noop( + "Please enable \"Always Offroad\" mode or turn off the vehicle to adjust these toggles." + ) +} + + +class SoftwareLayoutSP(SoftwareLayout): + def __init__(self): + super().__init__() + self.disable_updates_toggle = toggle_item_sp( + lambda: tr("Disable Updates"), + description="", + initial_state=ui_state.params.get_bool("DisableUpdates"), + callback=self._on_disable_updates_toggled, + ) + self._scroller.add_widget(self.disable_updates_toggle) + + def _handle_reboot(self, result): + if result == DialogResult.CONFIRM: + ui_state.params.put_bool("DisableUpdates", self.disable_updates_toggle.action_item.get_state()) + ui_state.params.put_bool("DoReboot", True) + else: + self.disable_updates_toggle.action_item.set_state(ui_state.params.get_bool("DisableUpdates")) + + def _on_disable_updates_toggled(self, enabled): + dialog = ConfirmDialog(tr("System reboot required for changes to take effect. Reboot now?"), tr("Reboot"), callback=self._handle_reboot) + gui_app.push_widget(dialog) + + def _on_select_branch(self): + current_git_branch = ui_state.params.get("GitBranch") or "" + branches_str = ui_state.params.get("UpdaterAvailableBranches") or "" + branches = [b for b in branches_str.split(",") if b] + current_target = ui_state.params.get("UpdaterTargetBranch") or "" + top_level_branches = [current_git_branch, "release-mici", "release-tizi", "staging", "dev", "master"] + + if HARDWARE.get_device_type() == "tici": + top_level_branches = ["release-tici", "staging-tici"] + branches = [b for b in branches if b.endswith("-tici")] + + top_level_nodes = [TreeNode(b, {'display_name': b}) for b in top_level_branches if b in branches] + remaining_branches = [b for b in branches if b not in top_level_branches] + prebuilt_nodes = [TreeNode(b, {'display_name': b}) for b in remaining_branches if b.endswith("-prebuilt")] + non_prebuilt_nodes = [TreeNode(b, {'display_name': b}) for b in remaining_branches if not b.endswith("-prebuilt")] + + folders = [ + TreeFolder("", top_level_nodes), + TreeFolder("Prebuilt Branches", prebuilt_nodes), + TreeFolder("Non-Prebuilt Branches", non_prebuilt_nodes), + ] + + def _on_branch_selected(result): + if result == DialogResult.CONFIRM and self._branch_dialog is not None: + selection = self._branch_dialog.selection_ref + if selection: + ui_state.params.put("UpdaterTargetBranch", selection) + self._branch_btn.action_item.set_value(selection) + os.system("pkill -SIGUSR1 -f system.updated.updated") + self._branch_dialog = None + + self._branch_dialog = TreeOptionDialog(tr("Select a branch"), folders, current_target, "", + on_exit=_on_branch_selected) + + gui_app.push_widget(self._branch_dialog) + + def _update_state(self): + super()._update_state() + show_advanced = ui_state.params.get_bool("ShowAdvancedControls") + self.disable_updates_toggle.action_item.set_enabled(ui_state.is_offroad()) + self.disable_updates_toggle.set_visible(show_advanced) + + disable_updates_desc = tr(DESCRIPTIONS["disable_updates_offroad"] if ui_state.is_offroad() else DESCRIPTIONS["disable_updates_onroad"]) + self.disable_updates_toggle.set_description(disable_updates_desc) diff --git a/selfdrive/ui/sunnypilot/layouts/settings/steering.py b/selfdrive/ui/sunnypilot/layouts/settings/steering.py new file mode 100644 index 0000000000..14d840138e --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/steering.py @@ -0,0 +1,161 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from cereal import car +from enum import IntEnum + +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp, simple_button_item_sp, option_item_sp, LineSeparatorSP +from openpilot.system.ui.widgets.scroller_tici import Scroller +from openpilot.system.ui.widgets import Widget +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.steering_sub_layouts.lane_change_settings import LaneChangeSettingsLayout +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.steering_sub_layouts.mads_settings import MadsSettingsLayout +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.steering_sub_layouts.torque_settings import TorqueSettingsLayout + + +class PanelType(IntEnum): + STEERING = 0 + MADS = 1 + LANE_CHANGE = 2 + TORQUE_CONTROL = 3 + + +class SteeringLayout(Widget): + def __init__(self): + super().__init__() + + self._current_panel = PanelType.STEERING + self._lane_change_settings_layout = LaneChangeSettingsLayout(lambda: self._set_current_panel(PanelType.STEERING)) + self._mads_settings_layout = MadsSettingsLayout(lambda: self._set_current_panel(PanelType.STEERING)) + self._torque_control_layout = TorqueSettingsLayout(lambda: self._set_current_panel(PanelType.STEERING)) + + items = self._initialize_items() + self._scroller = Scroller(items, line_separator=False, spacing=0) + + def _initialize_items(self): + self._mads_base_desc = tr("Enable the beloved MADS feature. " + + "Disable toggle to revert back to stock sunnypilot engagement/disengagement.") + self._mads_limited_desc = tr("This platform supports limited MADS settings.") + self._mads_full_desc = tr("This platform supports all MADS settings.") + self._mads_check_compat_desc = tr("Start the vehicle to check vehicle compatibility.") + + self._mads_toggle = toggle_item_sp( + param="Mads", + title=lambda: tr("Modular Assistive Driving System (MADS)"), + description=self._mads_base_desc, + ) + self._mads_settings_button = simple_button_item_sp( + button_text=lambda: tr("Customize MADS"), + button_width=800, + callback=lambda: self._set_current_panel(PanelType.MADS) + ) + self._lane_change_settings_button = simple_button_item_sp( + button_text=lambda: tr("Customize Lane Change"), + button_width=800, + callback=lambda: self._set_current_panel(PanelType.LANE_CHANGE) + ) + self._blinker_control_toggle = toggle_item_sp( + param="BlinkerPauseLateralControl", + description=lambda: tr("Pause lateral control with blinker when traveling below the desired speed selected."), + title=lambda: tr("Pause Lateral Control with Blinker"), + ) + self._blinker_control_options = option_item_sp( + param="BlinkerMinLateralControlSpeed", + title=lambda: tr("Minimum Speed to Pause Lateral Control"), + min_value=0, + max_value=255, + value_change_step=5, + description="", + label_callback=lambda speed: f'{speed} {"km/h" if ui_state.is_metric else "mph"}', + ) + self._blinker_reengage_delay = option_item_sp( + param="BlinkerLateralReengageDelay", + title=lambda: tr("Post-Blinker Delay"), + min_value=0, + max_value=10, + value_change_step=1, + description=lambda: tr("Delay before lateral control resumes after the turn signal ends."), + label_callback=lambda delay: f'{delay} {"s"}' + ) + self._torque_control_toggle = toggle_item_sp( + param="EnforceTorqueControl", + title=lambda: tr("Enforce Torque Lateral Control"), + description=lambda: tr("Enable this to enforce sunnypilot to steer with Torque lateral control."), + ) + self._torque_customization_button = simple_button_item_sp( + button_text=lambda: tr("Customize Torque Params"), + button_width=850, + callback=lambda: self._set_current_panel(PanelType.TORQUE_CONTROL) + ) + self._nnlc_toggle = toggle_item_sp( + param="NeuralNetworkLateralControl", + title=lambda: tr("Neural Network Lateral Control (NNLC)"), + description="" + ) + + items = [ + self._mads_toggle, + self._mads_settings_button, + LineSeparatorSP(40), + self._lane_change_settings_button, + LineSeparatorSP(40), + self._blinker_control_toggle, + self._blinker_control_options, + self._blinker_reengage_delay, + LineSeparatorSP(40), + self._torque_control_toggle, + self._torque_customization_button, + LineSeparatorSP(40), + self._nnlc_toggle, + ] + return items + + def _set_current_panel(self, panel: PanelType): + self._current_panel = panel + + def _update_state(self): + super()._update_state() + + torque_allowed = True + if ui_state.CP is not None: + mads_main_desc = self._mads_limited_desc if self._mads_settings_layout._mads_limited_settings() else self._mads_full_desc + self._mads_toggle.set_description(f"{mads_main_desc}

{self._mads_base_desc}") + + if ui_state.CP.steerControlType == car.CarParams.SteerControlType.angle: + ui_state.params.remove("EnforceTorqueControl") + ui_state.params.remove("NeuralNetworkLateralControl") + torque_allowed = False + else: + self._mads_toggle.set_description(f"{self._mads_check_compat_desc}

{self._mads_base_desc}") + ui_state.params.remove("EnforceTorqueControl") + ui_state.params.remove("NeuralNetworkLateralControl") + torque_allowed = False + + self._mads_toggle.action_item.set_enabled(ui_state.is_offroad()) + self._mads_settings_button.action_item.set_enabled(ui_state.is_offroad() and self._mads_toggle.action_item.get_state()) + self._blinker_control_options.set_visible(self._blinker_control_toggle.action_item.get_state()) + self._blinker_reengage_delay.set_visible(self._blinker_control_toggle.action_item.get_state()) + + enforce_torque_enabled = self._torque_control_toggle.action_item.get_state() + nnlc_enabled = self._nnlc_toggle.action_item.get_state() + self._nnlc_toggle.action_item.set_enabled(ui_state.is_offroad() and torque_allowed and not enforce_torque_enabled) + self._torque_control_toggle.action_item.set_enabled(ui_state.is_offroad() and torque_allowed and not nnlc_enabled) + self._torque_customization_button.action_item.set_enabled(self._torque_control_toggle.action_item.get_state()) + + def _render(self, rect): + if self._current_panel == PanelType.LANE_CHANGE: + self._lane_change_settings_layout.render(rect) + elif self._current_panel == PanelType.MADS: + self._mads_settings_layout.render(rect) + elif self._current_panel == PanelType.TORQUE_CONTROL: + self._torque_control_layout.render(rect) + else: + self._scroller.render(rect) + + def show_event(self): + self._set_current_panel(PanelType.STEERING) + self._scroller.show_event() diff --git a/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/lane_change_settings.py b/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/lane_change_settings.py new file mode 100644 index 0000000000..fbb9ce7cf7 --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/lane_change_settings.py @@ -0,0 +1,81 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from collections.abc import Callable +import pyray as rl + +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp, option_item_sp, LineSeparatorSP +from openpilot.system.ui.widgets.network import NavButton +from openpilot.system.ui.widgets.scroller_tici import Scroller +from openpilot.system.ui.widgets import Widget + +from openpilot.sunnypilot.selfdrive.controls.lib.auto_lane_change import AutoLaneChangeMode + + +class LaneChangeSettingsLayout(Widget): + def __init__(self, back_btn_callback: Callable): + super().__init__() + self._back_button = NavButton(tr("Back")) + self._back_button.set_click_callback(back_btn_callback) + + items = self._initialize_items() + self._scroller = Scroller(items, line_separator=False, spacing=0) + + def _initialize_items(self): + self._lane_change_timer = option_item_sp( + title=lambda: tr("Auto Lane Change by Blinker"), + param="AutoLaneChangeTimer", + description=lambda: tr("Set a timer to delay the auto lane change operation when the blinker is used. " + + "No nudge on the steering wheel is required to auto lane change if a timer is set. Default is Nudge.
" + + "Please use caution when using this feature. Only use the blinker when traffic and road conditions permit."), + min_value=-1, + max_value=5, + value_change_step=1, + label_callback=(lambda x: + tr("Off") if x == -1 else + tr("Nudge") if x == 0 else + tr("Nudgeless") if x == 1 else + f"0.5 {tr('s')}" if x == 2 else + f"1 {tr('s')}" if x == 3 else + f"2 {tr('s')}" if x == 4 else + f"3 {tr('s')}") + ) + self._bsm_delay = toggle_item_sp( + param="AutoLaneChangeBsmDelay", + title=lambda: tr("Auto Lane Change: Delay with Blind Spot"), + description=lambda: tr("Toggle to enable a delay timer for seamless lane changes when blind spot monitoring " + + "(BSM) detects a obstructing vehicle, ensuring safe maneuvering."), + ) + + items = [ + self._lane_change_timer, + LineSeparatorSP(40), + self._bsm_delay, + ] + + return items + + def _update_state(self): + super()._update_state() + self._update_toggles() + + def _render(self, rect): + self._back_button.set_position(self._rect.x, self._rect.y + 20) + self._back_button.render() + # subtract button + content_rect = rl.Rectangle(rect.x, rect.y + self._back_button.rect.height + 40, rect.width, rect.height - self._back_button.rect.height - 40) + self._scroller.render(content_rect) + + def show_event(self): + self._scroller.show_event() + + def _update_toggles(self): + enable_bsm = ui_state.CP is not None and ui_state.CP.enableBsm + if not enable_bsm and ui_state.params.get_bool("AutoLaneChangeBsmDelay"): + ui_state.params.remove("AutoLaneChangeBsmDelay") + self._bsm_delay.action_item.set_enabled(enable_bsm and ui_state.params.get("AutoLaneChangeTimer", return_default=True) > AutoLaneChangeMode.NUDGE) diff --git a/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/mads_settings.py b/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/mads_settings.py new file mode 100644 index 0000000000..098fcf8ce8 --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/mads_settings.py @@ -0,0 +1,137 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from collections.abc import Callable +import pyray as rl + +from opendbc.sunnypilot.car.tesla.values import TeslaFlagsSP +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.sunnypilot.mads.helpers import MadsSteeringModeOnBrake +from openpilot.system.ui.lib.multilang import tr, tr_noop +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.network import NavButton +from openpilot.system.ui.widgets.scroller_tici import Scroller +from openpilot.system.ui.sunnypilot.widgets.list_view import multiple_button_item_sp, toggle_item_sp + +MADS_STEERING_MODE_OPTIONS = [ + (tr("Remain Active"), tr_noop("Remain Active: ALC will remain active when the brake pedal is pressed.")), + (tr("Pause"), tr_noop("Pause: ALC will pause when the brake pedal is pressed.")), + (tr("Disengage"), tr_noop("Disengage: ALC will disengage when the brake pedal is pressed.")), +] + +MADS_MAIN_CRUISE_BASE_DESC = tr("Note: For vehicles without LFA/LKAS button, disabling this will prevent lateral control engagement.") +MADS_UNIFIED_ENGAGEMENT_MODE_BASE_DESC = "{engage}

{note}

".format( + engage=tr("Engage lateral and longitudinal control with cruise control engagement."), + note=tr("Note: Once lateral control is engaged via UEM, it will remain engaged until it is manually disabled via the MADS button or car shut off."), +) + +STATUS_CHECK_COMPATIBILITY = tr("Start the vehicle to check vehicle compatibility.") +DEFAULT_TO_OFF = tr("This feature defaults to OFF, and does not allow selection due to vehicle limitations.") +DEFAULT_TO_ON = tr("This feature defaults to ON, and does not allow selection due to vehicle limitations.") +STATUS_DISENGAGE_ONLY = tr("This platform only supports Disengage mode due to vehicle limitations.") + + +class MadsSettingsLayout(Widget): + def __init__(self, back_btn_callback: Callable): + super().__init__() + self._back_button = NavButton(tr("Back")) + self._back_button.set_click_callback(back_btn_callback) + self._initialize_items() + self._scroller = Scroller(self.items, line_separator=True, spacing=0) + + def _initialize_items(self): + self._main_cruise_toggle = toggle_item_sp( + title=lambda: tr("Toggle with Main Cruise"), + description=MADS_MAIN_CRUISE_BASE_DESC, + param="MadsMainCruiseAllowed", + ) + self._unified_engagement_toggle = toggle_item_sp( + title=lambda: tr("Unified Engagement Mode (UEM)"), + description=MADS_UNIFIED_ENGAGEMENT_MODE_BASE_DESC, + param="MadsUnifiedEngagementMode" + ) + self._steering_mode = multiple_button_item_sp( + param="MadsSteeringMode", + title=lambda: tr("Steering Mode on Brake Pedal"), + description="", + buttons=[opt[0] for opt in MADS_STEERING_MODE_OPTIONS], + inline=False, + button_width=350, + callback=self._update_steering_mode_description, + ) + + self.items = [ + self._main_cruise_toggle, + self._unified_engagement_toggle, + self._steering_mode, + ] + + def _update_state(self): + super()._update_state() + self._update_toggles() + + def _render(self, rect): + self._back_button.set_position(self._rect.x, self._rect.y + 20) + self._back_button.render() + # subtract button + content_rect = rl.Rectangle(rect.x, rect.y + self._back_button.rect.height + 40, rect.width, rect.height - self._back_button.rect.height - 40) + self._scroller.render(content_rect) + + def show_event(self): + self._scroller.show_event() + + @staticmethod + def _mads_limited_settings() -> bool: + brand = "" + if ui_state.is_offroad(): + bundle = ui_state.params.get("CarPlatformBundle") + if bundle: + brand = bundle.get("brand", "") + if not brand: + brand = ui_state.CP.brand if ui_state.CP is not None else "" + + if brand == "rivian": + return True + elif brand == "tesla": + return not (ui_state.CP_SP is not None and ui_state.CP_SP.flags & TeslaFlagsSP.HAS_VEHICLE_BUS) + return False + + def _update_steering_mode_description(self, button_index: int): + base_desc = tr("Choose how Automatic Lane Centering (ALC) behaves after the brake pedal is manually pressed in sunnypilot.") + result = base_desc + "

" + for opt in MADS_STEERING_MODE_OPTIONS: + desc = "" + opt[1] + "" if button_index == MADS_STEERING_MODE_OPTIONS.index(opt) else opt[1] + result += desc + "
" + self._steering_mode.set_description(result) + self._steering_mode.show_description(True) + + def _update_toggles(self): + self._update_steering_mode_description(self._steering_mode.action_item.get_selected_button()) + if self._mads_limited_settings(): + ui_state.params.remove("MadsMainCruiseAllowed") + ui_state.params.put_bool("MadsUnifiedEngagementMode", True) + ui_state.params.put("MadsSteeringMode", MadsSteeringModeOnBrake.DISENGAGE) + + self._main_cruise_toggle.action_item.set_enabled(False) + self._main_cruise_toggle.action_item.set_state(False) + self._main_cruise_toggle.set_description("" + DEFAULT_TO_OFF + "
" + MADS_MAIN_CRUISE_BASE_DESC) + + self._unified_engagement_toggle.action_item.set_enabled(False) + self._unified_engagement_toggle.action_item.set_state(True) + self._unified_engagement_toggle.set_description("" + DEFAULT_TO_ON + "
" + MADS_UNIFIED_ENGAGEMENT_MODE_BASE_DESC) + + self._steering_mode.set_description(STATUS_DISENGAGE_ONLY) + self._steering_mode.action_item.set_selected_button(MadsSteeringModeOnBrake.DISENGAGE) + self._steering_mode.action_item.set_enabled_buttons({MadsSteeringModeOnBrake.DISENGAGE}) + else: + self._main_cruise_toggle.action_item.set_enabled(True) + self._main_cruise_toggle.set_description(MADS_MAIN_CRUISE_BASE_DESC) + + self._unified_engagement_toggle.action_item.set_enabled(True) + self._unified_engagement_toggle.set_description(MADS_UNIFIED_ENGAGEMENT_MODE_BASE_DESC) + + self._steering_mode.action_item.set_enabled(True) + self._steering_mode.action_item.set_enabled_buttons(None) diff --git a/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/torque_settings.py b/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/torque_settings.py new file mode 100644 index 0000000000..d4976b1295 --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/torque_settings.py @@ -0,0 +1,191 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import json +import math +import os +from collections.abc import Callable +import pyray as rl + +from openpilot.common.basedir import BASEDIR +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.sunnypilot.lib.utils import NoElideButtonAction +from openpilot.system.ui.sunnypilot.widgets.list_view import ListItemSP, toggle_item_sp, option_item_sp +from openpilot.system.ui.sunnypilot.widgets.tree_dialog import TreeOptionDialog, TreeFolder, TreeNode +from openpilot.system.ui.widgets import Widget, DialogResult +from openpilot.system.ui.widgets.network import NavButton +from openpilot.system.ui.widgets.scroller_tici import Scroller + +TORQUE_VERSIONS_PATH = os.path.join(BASEDIR, "sunnypilot", "selfdrive", "controls", "lib", "latcontrol_torque_versions.json") + + +class TorqueSettingsLayout(Widget): + def __init__(self, back_btn_callback: Callable): + super().__init__() + self._back_button = NavButton(tr("Back")) + self._back_button.set_click_callback(back_btn_callback) + self._torque_version_dialog: TreeOptionDialog | None = None + self.cached_torque_versions = {} + self._load_versions() + items = self._initialize_items() + self._scroller = Scroller(items, line_separator=True, spacing=0) + + def _load_versions(self): + with open(TORQUE_VERSIONS_PATH) as f: + self.cached_torque_versions = json.load(f) + + def _initialize_items(self): + self._torque_control_versions = ListItemSP( + title=tr("Torque Control Tune Version"), + description="Select the version of Torque Control Tune to use.", + action_item=NoElideButtonAction(tr("SELECT")), + callback=self._show_torque_version_dialog, + ) + self._self_tune_toggle = toggle_item_sp( + param="LiveTorqueParamsToggle", + title=lambda: tr("Self-Tune"), + description=lambda: tr("Enables self-tune for Torque lateral control for platforms that do not use " + + "Torque lateral control by default."), + ) + self._relaxed_tune_toggle = toggle_item_sp( + param="LiveTorqueParamsRelaxedToggle", + title=lambda: tr("Less Restrict Settings for Self-Tune (Beta)"), + description=lambda: tr("Less strict settings when using Self-Tune. This allows torqued to be more " + + "forgiving when learning values."), + ) + self._custom_tune_toggle = toggle_item_sp( + param="CustomTorqueParams", + title=lambda: tr("Enable Custom Tuning"), + description=lambda: tr("Enables custom tuning for Torque lateral control. " + + "Modifying Lateral Acceleration Factor and Friction below will override the offline values " + + "indicated in the YAML files within \"opendbc/car/torque_data\". " + + "The values will also be used live when \"Manual Real-Time Tuning\" toggle is enabled."), + ) + self._torque_prams_override_toggle = toggle_item_sp( + param="TorqueParamsOverrideEnabled", + title=lambda: tr("Manual Real-Time Tuning"), + description=lambda: tr("Enforces the torque lateral controller to use the fixed values instead of the learned " + + "values from Self-Tune. Enabling this toggle overrides Self-Tune values."), + ) + self._torque_lat_accel_factor = option_item_sp( + title=lambda: tr("Lateral Acceleration Factor"), + param="TorqueParamsOverrideLatAccelFactor", + description="", + min_value=1, + max_value=500, + value_change_step=1, + label_callback=(lambda x: f"{x/100} m/s^2"), + use_float_scaling=True + ) + + self._torque_friction = option_item_sp( + title=lambda: tr("Friction"), + param="TorqueParamsOverrideFriction", + description="", + min_value=1, + max_value=100, + value_change_step=1, + label_callback=(lambda x: f"{x/100}"), + use_float_scaling=True + ) + + items = [ + self._torque_control_versions, + self._self_tune_toggle, + self._relaxed_tune_toggle, + self._custom_tune_toggle, + self._torque_prams_override_toggle, + self._torque_lat_accel_factor, + self._torque_friction, + ] + return items + + def _update_state(self): + super()._update_state() + if not ui_state.params.get_bool("LiveTorqueParamsToggle"): + ui_state.params.remove("LiveTorqueParamsRelaxedToggle") + self._relaxed_tune_toggle.action_item.set_state(False) + self._self_tune_toggle.action_item.set_enabled(ui_state.is_offroad()) + self._relaxed_tune_toggle.action_item.set_enabled(ui_state.is_offroad() and self._self_tune_toggle.action_item.get_state()) + self._custom_tune_toggle.action_item.set_enabled(ui_state.is_offroad()) + custom_tune_enabled = self._custom_tune_toggle.action_item.get_state() + self._torque_prams_override_toggle.set_visible(custom_tune_enabled) + self._torque_lat_accel_factor.set_visible(custom_tune_enabled) + self._torque_friction.set_visible(custom_tune_enabled) + + self._torque_prams_override_toggle.action_item.set_enabled(ui_state.is_offroad()) + sliders_enabled = self._torque_prams_override_toggle.action_item.get_state() or ui_state.is_offroad() + self._torque_lat_accel_factor.action_item.set_enabled(sliders_enabled) + self._torque_friction.action_item.set_enabled(sliders_enabled) + + title_text = tr("Real-Time & Offline") if ui_state.params.get("TorqueParamsOverrideEnabled") else tr("Offline Only") + self._torque_lat_accel_factor.set_title(lambda: tr("Lateral Acceleration Factor") + " (" + title_text + ")") + self._torque_friction.set_title(lambda: tr("Friction") + " (" + title_text + ")") + self._torque_control_versions.action_item.set_value(self._get_current_torque_version_label()) + + def _render(self, rect): + self._back_button.set_position(self._rect.x, self._rect.y + 20) + self._back_button.render() + # subtract button + content_rect = rl.Rectangle(rect.x, rect.y + self._back_button.rect.height + 40, rect.width, rect.height - self._back_button.rect.height - 40) + self._scroller.render(content_rect) + + def show_event(self): + self._scroller.show_event() + + def _get_current_torque_version_label(self): + current_val_bytes = ui_state.params.get("TorqueControlTune") + if current_val_bytes is None: + return tr("Default") + + try: + current_val = float(current_val_bytes) + for label, info in self.cached_torque_versions.items(): + if math.isclose(float(info["version"]), current_val, rel_tol=1e-5): + return label + except (ValueError, KeyError): + pass + + return tr("Default") + + def _show_torque_version_dialog(self): + options_map = {} + for label, info in self.cached_torque_versions.items(): + try: + options_map[label] = float(info["version"]) + except (ValueError, KeyError): + pass + + # Sort options by label in descending order + sorted_labels = sorted(options_map.keys(), key=lambda k: options_map[k], reverse=True) + + nodes = [TreeNode(tr("Default"))] + for label in sorted_labels: + nodes.append(TreeNode(label)) + + folders = [TreeFolder("", nodes)] + + current_label = self._get_current_torque_version_label() + + def handle_selection(result: int): + if result == DialogResult.CONFIRM and self._torque_version_dialog: + selected_ref = self._torque_version_dialog.selection_ref + if selected_ref == tr("Default"): + ui_state.params.remove("TorqueControlTune") + elif selected_ref in options_map: + ui_state.params.put("TorqueControlTune", options_map[selected_ref]) + self._torque_version_dialog = None + + self._torque_version_dialog = TreeOptionDialog( + tr("Select Torque Control Tune Version"), + folders, + current_ref=current_label, + option_font_weight=FontWeight.UNIFONT, + on_exit=handle_selection, + ) + gui_app.push_widget(self._torque_version_dialog) diff --git a/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py b/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py new file mode 100644 index 0000000000..1d9b99d5fd --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py @@ -0,0 +1,366 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import pyray as rl +from cereal import custom +from openpilot.selfdrive.ui.sunnypilot.layouts.onboarding import SunnylinkConsentPage +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, FontWeight +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.sunnypilot.widgets.list_view import button_item_sp +from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp +from openpilot.system.ui.sunnypilot.widgets.sunnylink_pairing_dialog import SunnylinkPairingDialog +from openpilot.system.ui.widgets import Widget, DialogResult +from openpilot.system.ui.widgets.button import ButtonStyle, Button +from openpilot.system.ui.widgets.confirm_dialog import alert_dialog, ConfirmDialog +from openpilot.system.ui.widgets.label import UnifiedLabel +from openpilot.system.ui.widgets.list_view import dual_button_item +from openpilot.system.ui.widgets.scroller_tici import Scroller, LineSeparator +from openpilot.system.version import sunnylink_consent_version + + +class SunnylinkHeader(Widget): + def __init__(self): + super().__init__() + + self._title = UnifiedLabel( + text="🚀 sunnylink 🚀", + font_size=90, + font_weight=FontWeight.AUDIOWIDE, + text_color=rl.WHITE, + alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, + wrap_text=False, + elide=False + ) + + self._description = UnifiedLabel( + text=tr("For secure backup, restore, and remote configuration"), + font_size=40, + font_weight=FontWeight.NORMAL, + text_color=rl.Color(0, 255, 0, 255), # Green + alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, + wrap_text=True, + elide=False + ) + + self._sponsor_msg = UnifiedLabel( + text=tr("Sponsorship isn't required for basic backup/restore") + "\n" + + tr("Click the Sponsor button for more details"), + font_size=35, + font_weight=FontWeight.NORMAL, + text_color=rl.Color(255, 165, 0, 255), # Orange + alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, + wrap_text=True, + elide=False + ) + + self._padding = 20 + self._spacing = 10 + + def set_parent_rect(self, parent_rect: rl.Rectangle) -> None: + super().set_parent_rect(parent_rect) + + content_width = int(parent_rect.width - (self._padding * 2)) + + title_height = self._title.get_content_height(content_width) + desc_height = self._description.get_content_height(content_width) + sponsor_height = self._sponsor_msg.get_content_height(content_width) + + total_height = (self._padding + title_height + self._spacing + + desc_height + self._spacing + sponsor_height + self._padding) + + self._rect.width = parent_rect.width + self._rect.height = total_height + + def _render(self, rect: rl.Rectangle): + content_width = rect.width - (self._padding * 2) + current_y = rect.y + self._padding + + # Render title + title_height = self._title.get_content_height(int(content_width)) + title_rect = rl.Rectangle(rect.x + self._padding, current_y, content_width, title_height) + self._title.render(title_rect) + current_y += title_height + self._spacing + + # Render description + desc_height = self._description.get_content_height(int(content_width)) + desc_rect = rl.Rectangle(rect.x + self._padding, current_y, content_width, desc_height) + self._description.render(desc_rect) + current_y += desc_height + self._spacing + + # Render sponsor message + sponsor_height = self._sponsor_msg.get_content_height(int(content_width)) + sponsor_rect = rl.Rectangle(rect.x + self._padding, current_y, content_width, sponsor_height) + self._sponsor_msg.render(sponsor_rect) + + +class SunnylinkDescriptionItem(Widget): + def __init__(self): + super().__init__() + self._description = UnifiedLabel( + text="", + font_size=40, + font_weight=FontWeight.NORMAL, + text_color=rl.WHITE, + alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, + wrap_text=True, + elide=False, + ) + self._padding = 20 + + def set_parent_rect(self, parent_rect: rl.Rectangle) -> None: + super().set_parent_rect(parent_rect) + desc_height = self._description.get_content_height(int(parent_rect.width)) + self._padding * 2 + + self._rect.width = parent_rect.width + self._rect.height = desc_height + + def set_text(self, text: str): + self._description.set_text(text) + + def set_color(self, color: rl.Color): + self._description.set_text_color(color) + + def _render(self, rect: rl.Rectangle): + content_width = rect.width - (self._padding * 2) + + desc_height = self._description.get_content_height(int(content_width)) + desc_rect = rl.Rectangle(rect.x + self._padding, rect.y, content_width, desc_height) + self._description.render(desc_rect) + + +class SunnylinkLayout(Widget): + def __init__(self): + super().__init__() + + self._sunnylink_pairing_dialog: SunnylinkPairingDialog | None = None + self._restore_in_progress = False + self._backup_in_progress = False + self._sunnylink_enabled = ui_state.params.get("SunnylinkEnabled") + + items = self._initialize_items() + self._scroller = Scroller(items, line_separator=False, spacing=0) + + def _initialize_items(self): + self._sunnylink_toggle = toggle_item_sp( + title=tr("Enable sunnylink"), + description=tr("This is the master switch, it will allow you to cutoff any sunnylink requests should you want to do that."), + param="SunnylinkEnabled", + callback=self._sunnylink_toggle_callback + ) + + self._sunnylink_description = SunnylinkDescriptionItem() + self._sunnylink_description.set_visible(False) + + self._sponsor_btn = button_item_sp( + title=tr("Sponsor Status"), + button_text=tr("SPONSOR"), + description=tr( + "Become a sponsor of sunnypilot to get early access to sunnylink features when they become available."), + callback=lambda: self._handle_pair_btn(False) + ) + self._pair_btn = button_item_sp( + title=tr("Pair GitHub Account"), + button_text=tr("Not Paired"), + description=tr( + "Pair your GitHub account to grant your device sponsor benefits, including API access on sunnylink."), + callback=lambda: self._handle_pair_btn(True) + ) + self._sunnylink_uploader_toggle = toggle_item_sp( + title=tr("Enable sunnylink uploader (infrastructure test)"), + description=tr("Enable sunnylink uploader to allow sunnypilot to upload your driving data to sunnypilot servers. ") + + tr("(Only for highest tiers, and does NOT bring ANY benefit to you yet. We are just testing data volume.)"), + param="EnableSunnylinkUploader" + ) + self._sunnylink_backup_restore_buttons = dual_button_item( + description="", + left_text=tr("Backup Settings"), + right_text=tr("Restore Settings"), + left_callback=self._handle_backup_btn, + right_callback=self._handle_restore_btn + ) + self._backup_btn: Button = self._sunnylink_backup_restore_buttons.action_item.left_button # store for easy individual access + self._restore_btn: Button = self._sunnylink_backup_restore_buttons.action_item.right_button + self._backup_btn.set_button_style(ButtonStyle.NORMAL) + self._restore_btn.set_button_style(ButtonStyle.PRIMARY) + + items = [ + SunnylinkHeader(), + LineSeparator(), + self._sunnylink_toggle, + self._sunnylink_description, + LineSeparator(), + self._sponsor_btn, + LineSeparator(), + self._pair_btn, + LineSeparator(), + self._sunnylink_uploader_toggle, + LineSeparator(), + self._sunnylink_backup_restore_buttons + ] + return items + + @staticmethod + def _get_sunnylink_dongle_id() -> str: + return ui_state.params.get("SunnylinkDongleId") or tr("N/A") + + def _handle_pair_btn(self, sponsor_pairing: bool = False): + sunnylink_dongle_id = self._get_sunnylink_dongle_id() + if sunnylink_dongle_id == UNREGISTERED_SUNNYLINK_DONGLE_ID: + gui_app.push_widget(alert_dialog(message=tr("sunnylink Dongle ID not found. ") + + tr("This may be due to weak internet connection or sunnylink registration issue. ") + + tr("Please reboot and try again."))) + elif not self._sunnylink_pairing_dialog: + self._sunnylink_pairing_dialog = SunnylinkPairingDialog(sponsor_pairing) + gui_app.push_widget(self._sunnylink_pairing_dialog) + + def _handle_backup_btn(self): + backup_dialog = ConfirmDialog(text=tr("Are you sure you want to backup your current sunnypilot settings?"), confirm_text="Backup", + callback=self._backup_handler) + gui_app.push_widget(backup_dialog) + + def _handle_restore_btn(self): + self._restore_btn.set_enabled(False) + restore_dialog = ConfirmDialog(text=tr("Are you sure you want to restore the last backed up sunnypilot settings?"), + confirm_text="Restore", callback=self._restore_handler) + gui_app.push_widget(restore_dialog) + + def _backup_handler(self, dialog_result: int): + if dialog_result == DialogResult.CONFIRM: + self._backup_in_progress = True + self._backup_btn.set_enabled(False) + ui_state.params.put_bool("BackupManager_CreateBackup", True) + + def _restore_handler(self, dialog_result: int): + if dialog_result == DialogResult.CONFIRM: + self._restore_in_progress = True + self._restore_btn.set_enabled(False) + ui_state.params.put("BackupManager_RestoreVersion", "latest") + + def handle_backup_restore_progress(self): + sunnylink_backup_manager = ui_state.sm["backupManagerSP"] + + backup_status = sunnylink_backup_manager.backupStatus + restore_status = sunnylink_backup_manager.restoreStatus + backup_progress = sunnylink_backup_manager.backupProgress + restore_progress = sunnylink_backup_manager.restoreProgress + + if self._backup_in_progress: + self._restore_btn.set_enabled(False) + self._backup_btn.set_enabled(False) + + if backup_status == custom.BackupManagerSP.Status.inProgress: + self._backup_in_progress = True + text = tr(f"Backing up {backup_progress}%") + self._backup_btn.set_text(text) + + elif backup_status == custom.BackupManagerSP.Status.failed: + self._backup_in_progress = False + self._backup_btn.set_enabled(not ui_state.is_onroad()) + self._backup_btn.set_text(tr("Backup Failed")) + + elif (backup_status == custom.BackupManagerSP.Status.completed or + (backup_status == custom.BackupManagerSP.Status.idle and backup_progress == 100.0)): + self._backup_in_progress = False + dialog = alert_dialog(tr("Settings backup completed.")) + gui_app.push_widget(dialog) + self._backup_btn.set_enabled(not ui_state.is_onroad()) + + elif self._restore_in_progress: + self._restore_btn.set_enabled(False) + self._backup_btn.set_enabled(False) + + if restore_status == custom.BackupManagerSP.Status.inProgress: + self._restore_in_progress = True + text = tr(f"Restoring {restore_progress}%") + self._restore_btn.set_text(text) + + elif restore_status == custom.BackupManagerSP.Status.failed: + self._restore_in_progress = False + self._restore_btn.set_enabled(not ui_state.is_onroad()) + self._restore_btn.set_text(tr("Restore Failed")) + dialog = alert_dialog(tr("Unable to restore the settings, try again later.")) + gui_app.push_widget(dialog) + + elif (restore_status == custom.BackupManagerSP.Status.completed or + (restore_status == custom.BackupManagerSP.Status.idle and restore_progress == 100.0)): + self._restore_in_progress = False + dialog = ConfirmDialog(tr("Settings restored. Confirm to restart the interface."), tr("OK"), cancel_text="", callback=lambda _: gui_app.request_close()) + gui_app.push_widget(dialog) + + else: + can_enable = self._sunnylink_enabled and not ui_state.is_onroad() + self._backup_btn.set_enabled(can_enable) + self._backup_btn.set_text(tr("Backup Settings")) + self._restore_btn.set_enabled(can_enable) + self._restore_btn.set_text(tr("Restore Settings")) + + def _sunnylink_toggle_callback(self, state: bool): + sl_consent: bool = ui_state.params.get("CompletedSunnylinkConsentVersion") == sunnylink_consent_version + sl_enabled: bool = ui_state.params.get_bool("SunnylinkEnabled") + + if state and not sl_consent and not sl_enabled: + def on_consent_done(): + enabled = ui_state.params.get_bool("SunnylinkEnabled") + self._update_description(enabled) + gui_app.pop_widget() + + sl_terms_dlg = SunnylinkConsentPage(done_callback=on_consent_done) + gui_app.push_widget(sl_terms_dlg) + else: + ui_state.params.put_bool("SunnylinkEnabled", state) + self._update_description(state) + + def _update_description(self, state: bool): + if state: + description = tr( + "Welcome back!! We're excited to see you've enabled sunnylink again!") + color = rl.Color(0, 255, 0, 255) # Green + else: + description = ("😢 " + tr("Not going to lie, it's sad to see you disabled sunnylink") + + tr(", but we'll be here when you're ready to come back.")) + color = rl.Color(255, 165, 0, 255) # Orange + self._sunnylink_description.set_text(description) + self._sunnylink_description.set_color(color) + self._sunnylink_description.set_visible(True) + self._sunnylink_toggle.show_description(False) + + def _update_state(self): + super()._update_state() + self._sunnylink_enabled = ui_state.params.get_bool("SunnylinkEnabled") + self._sunnylink_toggle.set_right_value(tr("Dongle ID") + ": " + self._get_sunnylink_dongle_id()) + self._sunnylink_toggle.action_item.set_enabled(not ui_state.is_onroad()) + self._sunnylink_toggle.action_item.set_state(self._sunnylink_enabled) + self._sunnylink_uploader_toggle.action_item.set_enabled(self._sunnylink_enabled) + self.handle_backup_restore_progress() + + sponsor_btn_text = tr("THANKS ♥") if ui_state.sunnylink_state.is_sponsor() else tr("SPONSOR") + tier_name = ui_state.sunnylink_state.get_sponsor_tier().name.capitalize() or tr("Not Sponsor") + self._sponsor_btn.action_item.set_text(sponsor_btn_text) + self._sponsor_btn.action_item.set_value(tier_name, ui_state.sunnylink_state.get_sponsor_tier_color()) + self._sponsor_btn.action_item.set_enabled(self._sunnylink_enabled) + + pair_btn_text = tr("Paired") if ui_state.sunnylink_state.is_paired() else tr("Not Paired") + self._pair_btn.action_item.set_text(pair_btn_text) + self._pair_btn.action_item.set_enabled(self._sunnylink_enabled) + + def _render(self, rect): + self._scroller.render(rect) + + def show_event(self): + super().show_event() + ui_state.sunnylink_state.set_settings_open(True) + self._scroller.show_event() + self._sunnylink_description.set_visible(False) + + def hide_event(self): + super().hide_event() + ui_state.sunnylink_state.set_settings_open(False) diff --git a/selfdrive/ui/sunnypilot/layouts/settings/trips.py b/selfdrive/ui/sunnypilot/layouts/settings/trips.py new file mode 100644 index 0000000000..066f52507f --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/trips.py @@ -0,0 +1,149 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import requests +import threading +import time +import pyray as rl + +from openpilot.common.api import api_get +from openpilot.common.constants import CV +from openpilot.common.params import Params +from openpilot.common.swaglog import cloudlog +from openpilot.selfdrive.ui.lib.api_helpers import get_token +from openpilot.selfdrive.ui.ui_state import ui_state, device +from openpilot.system.athena.registration import UNREGISTERED_DONGLE_ID +from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.lib.text_measure import measure_text_cached +from openpilot.system.ui.widgets import Widget + + +class TripsLayout(Widget): + PARAM_KEY = "ApiCache_DriveStats" + UPDATE_INTERVAL = 30 # seconds + + def __init__(self): + super().__init__() + self._params = Params() + self._session = requests.Session() + self._stats = self._get_stats() + + self._icon_distance = gui_app.texture("icons/road.png", 100, 100, keep_aspect_ratio=True) + self._icon_drives = gui_app.texture("icons_mici/wheel.png", 80, 80, keep_aspect_ratio=True) + self._icon_hours = gui_app.texture("../../sunnypilot/selfdrive/assets/icons/clock.png", 80, 80, keep_aspect_ratio=True) + + self._running = True + self._update_thread = threading.Thread(target=self._update_loop, daemon=True) + self._update_thread.start() + + def __del__(self): + self._running = False + try: + if self._update_thread and self._update_thread.is_alive(): + self._update_thread.join(timeout=1.0) + except Exception: + pass + + def _get_stats(self): + stats = self._params.get(self.PARAM_KEY) + if not stats: + return {} + try: + return stats + except Exception: + cloudlog.exception(f"Failed to decode drive stats: {stats}") + return {} + + def _fetch_drive_stats(self): + try: + dongle_id = self._params.get("DongleId") + if not dongle_id or dongle_id == UNREGISTERED_DONGLE_ID: + return + identity_token = get_token(dongle_id) + response = api_get(f"v1.1/devices/{dongle_id}/stats", access_token=identity_token, session=self._session) + if response.status_code == 200: + data = response.json() + self._stats = data + self._params.put(self.PARAM_KEY, data) + except Exception as e: + cloudlog.error(f"Failed to fetch drive stats: {e}") + + def _update_loop(self): + while self._running: + if not ui_state.started and device._awake: + self._fetch_drive_stats() + time.sleep(self.UPDATE_INTERVAL) + + def _render_stat_group(self, x, y, width, height, title, data, is_metric): + # Card Background + rl.draw_rectangle_rounded(rl.Rectangle(x, y, width, height), 0.05, 10, rl.Color(30, 30, 30, 255)) + + # Title + title_font = gui_app.font(FontWeight.BOLD) + rl.draw_text_ex(title_font, title, rl.Vector2(x + 60, y + 30), 50 * FONT_SCALE, 0, rl.Color(200, 200, 200, 255)) + + # Internal content area + # Center the content block (Icon + Value + Unit) vertically + content_y = y + (height / 2) - (140 * FONT_SCALE) + col_width = width / 3 + + # Values + number_font = gui_app.font(FontWeight.BOLD) + unit_font = gui_app.font(FontWeight.NORMAL) + number_base_size = 92 + unit_base_size = 55 + number_size = number_base_size * FONT_SCALE + unit_size = unit_base_size * FONT_SCALE + color_unit = rl.Color(160, 160, 160, 255) + + routes = int(data.get("routes", 0)) + distance = data.get("distance", 0) + distance_str = str(int(distance * CV.MPH_TO_KPH)) if is_metric else str(int(distance)) + hours = int(data.get("minutes", 0) / 60) + + dist_unit = tr("KM") if is_metric else tr("Miles") + + def draw_col(col_idx, icon, value, unit): + col_x = x + (col_width * col_idx) + center_x = col_x + (col_width / 2) + + # Icon + icon_x = center_x - (icon.width / 2) + icon_y = content_y + 60 + rl.draw_texture_ex(icon, rl.Vector2(icon_x, icon_y), 0.0, 1.0, rl.WHITE) + + # Value + val_size = measure_text_cached(number_font, value, number_base_size) + rl.draw_text_ex(number_font, value, rl.Vector2(center_x - val_size.x / 1.65, content_y + 145 * FONT_SCALE), number_size, 0, rl.WHITE) + + # Unit + unit_size_vec = measure_text_cached(unit_font, unit, unit_base_size) + rl.draw_text_ex(unit_font, unit, rl.Vector2(center_x - unit_size_vec.x / 1.65, content_y + 255 * FONT_SCALE), unit_size, 0, color_unit) + + draw_col(0, self._icon_drives, str(routes), tr("Drives")) + draw_col(1, self._icon_distance, distance_str, dist_unit) + draw_col(2, self._icon_hours, str(hours), tr("Hours")) + + return y + height + + def _render(self, rect: rl.Rectangle): + x = rect.x + y = rect.y + w = rect.width + + spacing = 30 + available_h = rect.height - 30 + card_height = available_h / 2 + + is_metric = self._params.get_bool("IsMetric") + + all_time = self._stats.get("all", {}) + week = self._stats.get("week", {}) + + 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) diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/__init__.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/__init__.py new file mode 100644 index 0000000000..d8fdd61e72 --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/__init__.py @@ -0,0 +1,67 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.list_view import ButtonAction +from openpilot.system.ui.widgets.scroller_tici import Scroller + +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.factory import BrandSettingsFactory +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.platform_selector import PlatformSelector, LegendWidget +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.sunnypilot.widgets.list_view import ListItemSP + + +class VehicleLayout(Widget): + def __init__(self): + super().__init__() + self._brand_settings = None + self._brand_items = [] + self._current_brand = None + self._platform_selector = PlatformSelector(self._update_brand_settings) + + self._vehicle_item = ListItemSP(title=self._platform_selector.text, action_item=ButtonAction(text=tr("SELECT")), + callback=self._platform_selector._on_clicked) + self._vehicle_item.title_color = self._platform_selector.color + self._legend_widget = LegendWidget(self._platform_selector) + + self.items = [self._vehicle_item, self._legend_widget] + self._scroller = Scroller(self.items, line_separator=True, spacing=0) + + @staticmethod + def get_brand(): + if bundle := ui_state.params.get("CarPlatformBundle"): + return bundle.get("brand", "") + elif ui_state.CP is not None and ui_state.CP.carFingerprint != "MOCK": + return ui_state.CP.brand + return "" + + def _update_brand_settings(self): + self._vehicle_item._title = self._platform_selector.text + self._vehicle_item.title_color = self._platform_selector.color + vehicle_text = tr("REMOVE") if ui_state.params.get("CarPlatformBundle") else tr("SELECT") + self._vehicle_item.action_item.set_text(vehicle_text) + + brand = self.get_brand() + if brand != self._current_brand: + self._current_brand = brand + self._brand_settings = BrandSettingsFactory.create_brand_settings(brand) + self._brand_items = self._brand_settings.items if self._brand_settings else [] + + self.items = [self._vehicle_item, self._legend_widget] + self._brand_items + self._scroller = Scroller(self.items, line_separator=True, spacing=0) + + def _update_state(self): + self._update_brand_settings() + if self._brand_settings: + self._brand_settings.update_settings() + self._platform_selector.refresh() + + def _render(self, rect): + self._scroller.render(rect) + + def show_event(self): + self._scroller.show_event() diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/__init__.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/base.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/base.py new file mode 100644 index 0000000000..8d83fdf916 --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/base.py @@ -0,0 +1,16 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import abc + + +class BrandSettings(abc.ABC): + def __init__(self): + self.items = [] + + @abc.abstractmethod + def update_settings(self) -> None: + """Update the settings based on the current vehicle brand.""" diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/body.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/body.py new file mode 100644 index 0000000000..d1c9ea5d64 --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/body.py @@ -0,0 +1,15 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings + + +class BodySettings(BrandSettings): + def __init__(self): + super().__init__() + + def update_settings(self): + pass diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/chrysler.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/chrysler.py new file mode 100644 index 0000000000..ad62dba56f --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/chrysler.py @@ -0,0 +1,15 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings + + +class ChryslerSettings(BrandSettings): + def __init__(self): + super().__init__() + + def update_settings(self): + pass diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/factory.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/factory.py new file mode 100644 index 0000000000..678732296f --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/factory.py @@ -0,0 +1,45 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.body import BodySettings +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.chrysler import ChryslerSettings +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.ford import FordSettings +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.gm import GMSettings +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.honda import HondaSettings +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.hyundai import HyundaiSettings +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.mazda import MazdaSettings +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.nissan import NissanSettings +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.psa import PSASettings +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.rivian import RivianSettings +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.subaru import SubaruSettings +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.tesla import TeslaSettings +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.toyota import ToyotaSettings +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.volkswagen import VolkswagenSettings + + +class BrandSettingsFactory: + _BRAND_MAP: dict[str, type[BrandSettings]] = { + "body": BodySettings, + "chrysler": ChryslerSettings, + "ford": FordSettings, + "gm": GMSettings, + "honda": HondaSettings, + "hyundai": HyundaiSettings, + "mazda": MazdaSettings, + "nissan": NissanSettings, + "psa": PSASettings, + "rivian": RivianSettings, + "subaru": SubaruSettings, + "tesla": TeslaSettings, + "toyota": ToyotaSettings, + "volkswagen": VolkswagenSettings, + } + + @staticmethod + def create_brand_settings(brand: str) -> BrandSettings | None: + cls = BrandSettingsFactory._BRAND_MAP.get(brand) + return cls() if cls is not None else None diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/ford.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/ford.py new file mode 100644 index 0000000000..8871087e03 --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/ford.py @@ -0,0 +1,15 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings + + +class FordSettings(BrandSettings): + def __init__(self): + super().__init__() + + def update_settings(self): + pass diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/gm.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/gm.py new file mode 100644 index 0000000000..edcd17cdb8 --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/gm.py @@ -0,0 +1,15 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings + + +class GMSettings(BrandSettings): + def __init__(self): + super().__init__() + + def update_settings(self): + pass diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/honda.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/honda.py new file mode 100644 index 0000000000..fec68795a6 --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/honda.py @@ -0,0 +1,15 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings + + +class HondaSettings(BrandSettings): + def __init__(self): + super().__init__() + + def update_settings(self): + pass diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/hyundai.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/hyundai.py new file mode 100644 index 0000000000..20c9903a63 --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/hyundai.py @@ -0,0 +1,59 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.sunnypilot.widgets.list_view import multiple_button_item_sp +from opendbc.car.hyundai.values import CAR, UNSUPPORTED_LONGITUDINAL_CAR + + +class HyundaiSettings(BrandSettings): + def __init__(self): + super().__init__() + self.alpha_long_available = False + + tuning_texts = [tr("Off"), tr("Dynamic"), tr("Predictive")] + self.longitudinal_tuning_item = multiple_button_item_sp(tr("Custom Longitudinal Tuning"), "", tuning_texts, + button_width=300, callback=self._on_tuning_selected, + param="HyundaiLongitudinalTuning", inline=False) + self.items = [self.longitudinal_tuning_item] + + @staticmethod + def _on_tuning_selected(index): + ui_state.params.put("HyundaiLongitudinalTuning", index) + + def update_settings(self): + self.alpha_long_available = False + bundle = ui_state.params.get("CarPlatformBundle") + if bundle: + platform = bundle.get("platform") + self.alpha_long_available = CAR[platform] not in set().union(*UNSUPPORTED_LONGITUDINAL_CAR.values()) + elif ui_state.CP is not None: + self.alpha_long_available = ui_state.CP.alphaLongitudinalAvailable + + tuning_param = int(ui_state.params.get("HyundaiLongitudinalTuning") or "0") + long_enabled = ui_state.has_longitudinal_control + + long_tuning_descs = [ + tr("Your vehicle will use the Default longitudinal tuning."), + tr("Your vehicle will use the Dynamic longitudinal tuning."), + tr("Your vehicle will use the Predictive longitudinal tuning."), + ] + long_tuning_desc = long_tuning_descs[tuning_param] if tuning_param < len(long_tuning_descs) else long_tuning_descs[0] + + longitudinal_tuning_disabled = not ui_state.is_offroad() or not long_enabled + if longitudinal_tuning_disabled: + if not ui_state.is_offroad(): + long_tuning_desc = tr("This feature is unavailable while the car is onroad.") + elif not long_enabled: + long_tuning_desc = tr("This feature is unavailable because sunnypilot Longitudinal Control (Alpha) is not enabled.") + + self.longitudinal_tuning_item.action_item.set_enabled(not longitudinal_tuning_disabled) + self.longitudinal_tuning_item.set_description(long_tuning_desc) + self.longitudinal_tuning_item.show_description(True) + self.longitudinal_tuning_item.action_item.set_selected_button(tuning_param) + self.longitudinal_tuning_item.set_visible(self.alpha_long_available) diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/mazda.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/mazda.py new file mode 100644 index 0000000000..d354f0f34b --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/mazda.py @@ -0,0 +1,15 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings + + +class MazdaSettings(BrandSettings): + def __init__(self): + super().__init__() + + def update_settings(self): + pass diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/nissan.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/nissan.py new file mode 100644 index 0000000000..7b3446a1a7 --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/nissan.py @@ -0,0 +1,15 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings + + +class NissanSettings(BrandSettings): + def __init__(self): + super().__init__() + + def update_settings(self): + pass diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/psa.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/psa.py new file mode 100644 index 0000000000..6b767d332a --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/psa.py @@ -0,0 +1,15 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings + + +class PSASettings(BrandSettings): + def __init__(self): + super().__init__() + + def update_settings(self): + pass diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/rivian.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/rivian.py new file mode 100644 index 0000000000..876aa2d2ea --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/rivian.py @@ -0,0 +1,15 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings + + +class RivianSettings(BrandSettings): + def __init__(self): + super().__init__() + + def update_settings(self): + pass diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/subaru.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/subaru.py new file mode 100644 index 0000000000..5ef53a0e43 --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/subaru.py @@ -0,0 +1,54 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp +from opendbc.car.subaru.values import CAR, SubaruFlags + + +class SubaruSettings(BrandSettings): + def __init__(self): + super().__init__() + self.has_stop_and_go = False + + self.stop_and_go_toggle = toggle_item_sp(tr("Stop and Go (Beta)"), "", param="SubaruStopAndGo", callback=self._on_toggle_changed) + + self.stop_and_go_manual_parking_brake_toggle = toggle_item_sp(tr("Stop and Go for Manual Parking Brake (Beta)"), "", + param="SubaruStopAndGoManualParkingBrake", callback=self._on_toggle_changed) + + self.items = [self.stop_and_go_toggle, self.stop_and_go_manual_parking_brake_toggle] + + def _on_toggle_changed(self, _): + self.update_settings() + + def stop_and_go_disabled_msg(self): + if not self.has_stop_and_go: + return tr("This feature is currently not available on this platform.") + elif not ui_state.is_offroad(): + return tr("Enable \"Always Offroad\" in Device panel, or turn vehicle off to toggle.") + return "" + + def update_settings(self): + bundle = ui_state.params.get("CarPlatformBundle") + if bundle: + platform = bundle.get("platform") + config = CAR[platform].config + self.has_stop_and_go = not (config.flags & (SubaruFlags.GLOBAL_GEN2 | SubaruFlags.HYBRID)) + elif ui_state.CP is not None: + self.has_stop_and_go = not (ui_state.CP.flags & (SubaruFlags.GLOBAL_GEN2 | SubaruFlags.HYBRID)) + + disabled_msg = self.stop_and_go_disabled_msg() + descriptions = [ + tr("Experimental feature to enable auto-resume during stop-and-go for certain supported Subaru platforms."), + tr("Experimental feature to enable stop and go for Subaru Global models with manual handbrake. " + + "Models with electric parking brake should keep this disabled. Thanks to martinl for this implementation!") + ] + + for toggle, desc in zip([self.stop_and_go_toggle, self.stop_and_go_manual_parking_brake_toggle], descriptions, strict=True): + toggle.action_item.set_enabled(self.has_stop_and_go and ui_state.is_offroad()) + toggle.set_description(f"{disabled_msg}

{desc}" if disabled_msg else desc) diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/tesla.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/tesla.py new file mode 100644 index 0000000000..46d536c651 --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/tesla.py @@ -0,0 +1,43 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp + +COOP_STEERING_MIN_KMH = 23 +OEM_STEERING_MIN_KMH = 48 +KM_TO_MILE = 0.621371 + + +class TeslaSettings(BrandSettings): + def __init__(self): + super().__init__() + self.coop_steering_toggle = toggle_item_sp(tr("Cooperative Steering (Beta)"), "", param="TeslaCoopSteering") + self.items = [self.coop_steering_toggle] + + def update_settings(self): + is_metric = ui_state.is_metric + unit = "km/h" if is_metric else "mph" + + display_value_coop = COOP_STEERING_MIN_KMH if is_metric else round(COOP_STEERING_MIN_KMH * KM_TO_MILE) + display_value_oem = OEM_STEERING_MIN_KMH if is_metric else round(OEM_STEERING_MIN_KMH * KM_TO_MILE) + + coop_steering_disabled_msg = tr("Enable \"Always Offroad\" in Device panel, or turn vehicle off to toggle.") + coop_steering_warning = tr(f"Warning: May experience steering oscillations below {display_value_oem} {unit} during turns, " + + "recommend disabling this feature if you experience these.") + coop_steering_desc = ( + f"{coop_steering_warning}

" + + f"{tr('Allows the driver to provide limited steering input while openpilot is engaged.')}
" + + f"{tr(f'Only works above {display_value_coop} {unit}.')}" + ) + + if not ui_state.is_offroad(): + coop_steering_desc = f"{coop_steering_disabled_msg}

{coop_steering_desc}" + + self.coop_steering_toggle.set_description(coop_steering_desc) + self.coop_steering_toggle.action_item.set_enabled(ui_state.is_offroad()) diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/toyota.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/toyota.py new file mode 100644 index 0000000000..5a696466b1 --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/toyota.py @@ -0,0 +1,121 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.multilang import tr, tr_noop +from openpilot.system.ui.widgets import DialogResult +from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog +from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp + + +ONROAD_ONLY_DESCRIPTION = tr_noop("Start the vehicle to check vehicle compatibility.") +SNG_HACK_UNAVAILABLE = tr_noop("sunnypilot Longitudinal Control must be available and enabled for your vehicle to use this feature.") + +DESCRIPTIONS = { + 'enforce_stock_longitudinal': tr_noop( + 'sunnypilot will not take over control of gas and brakes. Factory Toyota longitudinal control will be used.' + ), + 'stop_and_go_hack': tr_noop( + 'sunnypilot will allow some Toyota/Lexus cars to auto resume during stop and go traffic. ' + + 'This feature is only applicable to certain models that are able to use longitudinal control. This is an alpha feature. Use at your own risk.' + ) +} + + +class ToyotaSettings(BrandSettings): + def __init__(self): + super().__init__() + + self.enforce_stock_longitudinal = toggle_item_sp( + lambda: tr("Enforce Factory Longitudinal Control"), + description=lambda: tr(DESCRIPTIONS["enforce_stock_longitudinal"]), + initial_state=ui_state.params.get_bool("ToyotaEnforceStockLongitudinal"), + callback=self._on_enable_enforce_stock_longitudinal, + enabled=lambda: not ui_state.engaged, + ) + + self.stop_and_go_hack = toggle_item_sp( + lambda: tr("Stop and Go Hack (Alpha)"), + description=lambda: tr(DESCRIPTIONS["stop_and_go_hack"]), + initial_state=ui_state.params.get_bool("ToyotaStopAndGoHack"), + callback=self._on_enable_stop_and_go_hack, + enabled=lambda: not ui_state.engaged, + ) + + self.items = [ + self.enforce_stock_longitudinal, + self.stop_and_go_hack, + ] + + def _on_enable_enforce_stock_longitudinal(self, state: bool): + if state: + def confirm_callback(result: int): + if result == DialogResult.CONFIRM: + ui_state.params.put_bool("ToyotaEnforceStockLongitudinal", True) + if ui_state.params.get_bool("AlphaLongitudinalEnabled"): + ui_state.params.put_bool("AlphaLongitudinalEnabled", False) + ui_state.params.put_bool("ToyotaStopAndGoHack", False) + self.stop_and_go_hack.action_item.set_state(False) + ui_state.params.put_bool("OnroadCycleRequested", True) + else: + self.enforce_stock_longitudinal.action_item.set_state(False) + + content = (f"

{self.enforce_stock_longitudinal.title}


" + + f"

{self.enforce_stock_longitudinal.description}

") + + dlg = ConfirmDialog(content, tr("Enable"), rich=True, callback=confirm_callback) + gui_app.push_widget(dlg) + + else: + ui_state.params.put_bool("ToyotaEnforceStockLongitudinal", False) + ui_state.params.put_bool("OnroadCycleRequested", True) + + def _on_enable_stop_and_go_hack(self, state: bool): + if state: + def confirm_callback(result: int): + if result == DialogResult.CONFIRM: + ui_state.params.put_bool("ToyotaStopAndGoHack", True) + ui_state.params.put_bool("OnroadCycleRequested", True) + else: + self.stop_and_go_hack.action_item.set_state(False) + + content = (f"

{self.stop_and_go_hack.title}


" + + f"

{self.stop_and_go_hack.description}

") + + dlg = ConfirmDialog(content, tr("Enable"), rich=True, callback=confirm_callback) + gui_app.push_widget(dlg) + + else: + ui_state.params.put_bool("ToyotaStopAndGoHack", False) + ui_state.params.put_bool("OnroadCycleRequested", True) + + def update_settings(self): + if ui_state.CP is not None: + longitudinal = ui_state.CP.openpilotLongitudinalControl + enforce_stock = self.enforce_stock_longitudinal.action_item.get_state() + + if longitudinal and not enforce_stock: + self.stop_and_go_hack.action_item.set_enabled(not ui_state.engaged) + new_desc = tr(DESCRIPTIONS["stop_and_go_hack"]) + show_desc = False + else: + self.stop_and_go_hack.action_item.set_enabled(False) + self.stop_and_go_hack.action_item.set_state(False) + new_desc = "" + tr(SNG_HACK_UNAVAILABLE) + "\n\n" + tr(DESCRIPTIONS["stop_and_go_hack"]) + show_desc = True + + if self.stop_and_go_hack.description != new_desc: + self.stop_and_go_hack.set_description(new_desc) + if show_desc: + self.stop_and_go_hack.show_description(True) + else: + self.stop_and_go_hack.action_item.set_enabled(False) + new_desc = "" + tr(ONROAD_ONLY_DESCRIPTION) + "\n\n" + tr(DESCRIPTIONS["stop_and_go_hack"]) + if self.stop_and_go_hack.description != new_desc: + self.stop_and_go_hack.set_description(new_desc) + self.stop_and_go_hack.show_description(True) diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/volkswagen.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/volkswagen.py new file mode 100644 index 0000000000..a6d44c5e4d --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/volkswagen.py @@ -0,0 +1,15 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings + + +class VolkswagenSettings(BrandSettings): + def __init__(self): + super().__init__() + + def update_settings(self): + pass diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/platform_selector.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/platform_selector.py new file mode 100644 index 0000000000..6102e8e9b7 --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/platform_selector.py @@ -0,0 +1,137 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import json +import os +import pyray as rl +from collections.abc import Callable +from functools import partial + +from openpilot.common.basedir import BASEDIR +from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.widgets import DialogResult, Widget +from openpilot.system.ui.widgets.button import Button, ButtonStyle +from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog + +from openpilot.system.ui.sunnypilot.lib.styles import style +from openpilot.system.ui.sunnypilot.widgets.tree_dialog import TreeOptionDialog, TreeNode, TreeFolder +from openpilot.selfdrive.ui.ui_state import ui_state + +CAR_LIST_JSON_OUT = os.path.join(BASEDIR, "sunnypilot", "selfdrive", "car", "car_list.json") + + +class LegendWidget(Widget): + def __init__(self, platform_selector): + super().__init__() + self.set_rect(rl.Rectangle(0, 0, 0, 350)) + self._platform_selector = platform_selector + self._font = gui_app.font(FontWeight.NORMAL) + self._bold_font = gui_app.font(FontWeight.BOLD) + + def _render(self, rect): + x = rect.x + 20 + y = rect.y + 20 + rl.draw_text_ex(self._font, tr("Select vehicle to force fingerprint manually."), rl.Vector2(x, y), 40, 0, style.ITEM_DESC_TEXT_COLOR) + y += 80 + rl.draw_text_ex(self._font, tr("Colors represent vehicle fingerprint status:"), rl.Vector2(x, y), 40, 0, style.ITEM_DESC_TEXT_COLOR) + y += 80 + + items = [ + (style.GREEN, tr("Fingerprinted automatically")), + (style.BLUE, tr("Manually selected fingerprint")), + (style.YELLOW, tr("Not fingerprinted or manually selected")), + ] + for color, text in items: + p_color = self._platform_selector.color + is_active = p_color.r == color.r and p_color.g == color.g and p_color.b == color.b and p_color.a == color.a + rl.draw_rectangle(int(x), int(y + 5), 30, 30, color) + font = self._bold_font if is_active else self._font + text_color = rl.WHITE if is_active else style.ITEM_DESC_TEXT_COLOR + rl.draw_text_ex(font, f"- {text}", rl.Vector2(x + 50, y - 7), 40, 0, text_color) + y += 50 + + +class PlatformSelector(Button): + def __init__(self, on_platform_change: Callable[[], None] | None = None): + super().__init__(tr("Vehicle"), self._on_clicked, button_style=ButtonStyle.NORMAL) + self.set_rect(rl.Rectangle(0, 0, 0, 120)) + + with open(CAR_LIST_JSON_OUT) as car_list_json: + self._platforms = json.load(car_list_json) + + self._on_platform_change = on_platform_change + self.refresh() + + @property + def text(self): + return self._label._text + + def set_parent_rect(self, parent_rect): + super().set_parent_rect(parent_rect) + self._rect.width = parent_rect.width + + def _on_clicked(self): + if ui_state.params.get("CarPlatformBundle"): + ui_state.params.remove("CarPlatformBundle") + self.refresh() + if self._on_platform_change: + self._on_platform_change() + else: + self._show_platform_dialog() + + def _set_platform(self, platform_name): + if data := self._platforms.get(platform_name): + ui_state.params.put("CarPlatformBundle", {**data, "name": platform_name}) + self.refresh() + if self._on_platform_change: + self._on_platform_change() + + def _on_platform_selected(self, dialog, res): + if res == DialogResult.CONFIRM and dialog.selection_ref: + offroad_msg = tr("This setting will take effect immediately.") if ui_state.is_offroad else \ + tr("This setting will take effect once the device enters offroad state.") + + callback = partial(self._confirm_platform, dialog.selection_ref) + confirm_dialog = ConfirmDialog(offroad_msg, tr("Confirm"), callback=callback) + gui_app.push_widget(confirm_dialog) + + def _confirm_platform(self, platform_name, res): + if res == DialogResult.CONFIRM: + self._set_platform(platform_name) + + def _show_platform_dialog(self): + platforms = sorted(self._platforms.keys()) + makes = sorted({self._platforms[p].get('make') for p in platforms}) + folders = [TreeFolder(make, [TreeNode(p, { + 'display_name': p, + 'search_tags': f"{p} {self._platforms[p].get('make')} {' '.join(map(str, self._platforms[p].get('year', [])))} {self._platforms[p].get('model', p)}" + }) for p in platforms if self._platforms[p].get('make') == make]) for make in makes] + dialog = TreeOptionDialog( + tr("Select a vehicle"), + folders, + search_title=tr("Search your vehicle"), + search_subtitle=tr("Enter model year (e.g., 2021) and model (Toyota Corolla):"), + search_funcs=[lambda node: node.data.get('display_name', ''), lambda node: node.data.get('search_tags', '')] + ) + callback = partial(self._on_platform_selected, dialog) + dialog.on_exit = callback + gui_app.push_widget(dialog) + + def refresh(self): + self.color = style.YELLOW + self._platform = tr("Unrecognized Vehicle") + self.set_text(tr("No vehicle selected")) + + if bundle := ui_state.params.get("CarPlatformBundle"): + self._platform = bundle.get("name", "") + self.set_text(self._platform) + self.color = style.BLUE + elif ui_state.CP is not None and ui_state.CP.carFingerprint != "MOCK": + self._platform = ui_state.CP.carFingerprint + self.set_text(self._platform) + self.color = style.GREEN + self.set_enabled(True) diff --git a/selfdrive/ui/sunnypilot/layouts/settings/visuals.py b/selfdrive/ui/sunnypilot/layouts/settings/visuals.py new file mode 100644 index 0000000000..84be5a26ab --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/visuals.py @@ -0,0 +1,154 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from openpilot.common.params import Params +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.multilang import tr, tr_noop +from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp, multiple_button_item_sp +from openpilot.system.ui.widgets.scroller_tici import Scroller +from openpilot.system.ui.widgets import Widget + +CHEVRON_INFO_DESCRIPTION = { + "enabled": tr_noop("Display useful metrics below the chevron that tracks the lead car " + + "only applicable to cars with sunnypilot longitudinal control."), + "disabled": tr_noop("This feature requires sunnypilot longitudinal control to be available.") +} + + +class VisualsLayout(Widget): + def __init__(self): + super().__init__() + + self._params = Params() + items = self._initialize_items() + self._scroller = Scroller(items, line_separator=True, spacing=0) + + def _initialize_items(self): + self._toggle_defs = { + "BlindSpot": ( + lambda: tr("Show Blind Spot Warnings"), + tr("Enabling this will display warnings when a vehicle is detected in your " + + "blind spot as long as your car has BSM supported."), + None, + ), + "TorqueBar": ( + lambda: tr("Steering Arc"), + tr("Display steering arc on the driving screen when lateral control is enabled."), + None, + ), + "RainbowMode": ( + lambda: tr("Enable Tesla Rainbow Mode"), + tr("A beautiful rainbow effect on the path the model wants to take. " + + "It does not affect driving in any way."), + None, + ), + "StandstillTimer": ( + lambda: tr("Enable Standstill Timer"), + tr("Show a timer on the HUD when the car is at a standstill."), + None, + ), + "RoadNameToggle": ( + lambda: tr("Display Road Name"), + tr("Displays the name of the road the car is traveling on." + + "
The OpenStreetMap database of the location must be downloaded from " + + "the OSM panel to fetch the road name."), + None, + ), + "GreenLightAlert": ( + lambda: tr("Green Traffic Light Alert (Beta)"), + tr("A chime and on-screen alert will play when the traffic light you are waiting for " + + "turns green and you have no vehicle in front of you." + + "
Note: This chime is only designed as a notification. " + + "It is the driver's responsibility to observe their environment and make decisions accordingly."), + None, + ), + "LeadDepartAlert": ( + lambda: tr("Lead Departure Alert (Beta)"), + tr("A chime and on-screen alert will play when you are stopped, and the vehicle in front of you start moving." + + "
Note: This chime is only designed as a notification. " + + "It is the driver's responsibility to observe their environment and make decisions accordingly."), + None, + ), + "TrueVEgoUI": ( + lambda: tr("Speedometer: Always Display True Speed"), + tr("For applicable vehicles, always display the true vehicle current speed from wheel speed sensors."), + None, + ), + "HideVEgoUI": ( + lambda: tr("Speedometer: Hide from Onroad Screen"), + tr("When enabled, the speedometer on the onroad screen is not displayed."), + None, + ), + "ShowTurnSignals": ( + lambda: tr("Display Turn Signals"), + tr("When enabled, visual turn indicators are drawn on the HUD."), + None, + ), + "RocketFuel": ( + lambda: tr("Real-time Acceleration Bar"), + tr("Show an indicator on the left side of the screen to display real-time vehicle acceleration and deceleration. " + + "This displays what the car is currently doing, not what the planner is requesting."), + None, + ), + } + self._toggles = {} + for param, (title, desc, callback) in self._toggle_defs.items(): + toggle = toggle_item_sp( + title=title, + description=desc, + param=param, + initial_state=ui_state.params.get_bool(param), + callback=callback, + ) + self._toggles[param] = toggle + + self._chevron_info = multiple_button_item_sp( + title=lambda: tr("Display Metrics Below Chevron"), + description="", + buttons=[lambda: tr("Off"), lambda: tr("Distance"), lambda: tr("Speed"), lambda: tr("Time"), lambda: tr("All")], + param="ChevronInfo", + inline=False + ) + self._dev_ui_info = multiple_button_item_sp( + title=lambda: tr("Developer UI"), + description=lambda: tr("Display real-time parameters and metrics from various sources."), + buttons=[lambda: tr("Off"), lambda: tr("Bottom"), lambda: tr("Right"), lambda: tr("Right & Bottom")], + param="DevUIInfo", + button_width=350, + inline=False + ) + + items = list(self._toggles.values()) + [ + self._chevron_info, + self._dev_ui_info, + ] + return items + + def _update_state(self): + super()._update_state() + + for param in self._toggle_defs: + self._toggles[param].action_item.set_state(self._params.get_bool(param)) + + self._dev_ui_info.action_item.set_selected_button(ui_state.params.get("DevUIInfo", return_default=True)) + + if ui_state.has_longitudinal_control: + self._chevron_info.set_description(tr(CHEVRON_INFO_DESCRIPTION["enabled"])) + self._chevron_info.action_item.set_selected_button(ui_state.params.get("ChevronInfo", return_default=True)) + self._chevron_info.action_item.set_enabled(True) + else: + self._chevron_info.set_description(tr(CHEVRON_INFO_DESCRIPTION["disabled"])) + self._chevron_info.action_item.set_enabled(False) + ui_state.params.put("ChevronInfo", 0) + + def _render(self, rect): + self._scroller.render(rect) + + def show_event(self): + self._scroller.show_event() + if not ui_state.has_longitudinal_control: + self._chevron_info.set_description(tr(CHEVRON_INFO_DESCRIPTION["disabled"])) + self._chevron_info.show_description(True) diff --git a/selfdrive/ui/sunnypilot/layouts/sidebar.py b/selfdrive/ui/sunnypilot/layouts/sidebar.py new file mode 100644 index 0000000000..79bb15dbb8 --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/sidebar.py @@ -0,0 +1,87 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import pyray as rl +import time +from dataclasses import dataclass +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.sunnypilot.sunnylink.api import UNREGISTERED_SUNNYLINK_DONGLE_ID +from openpilot.system.ui.lib.multilang import tr_noop + + +PING_TIMEOUT_NS = 80_000_000_000 # 80 seconds in nanoseconds +METRIC_HEIGHT = 126 +METRIC_MARGIN = 30 +METRIC_START_Y = 300 +HOME_BTN = rl.Rectangle(60, 860, 180, 180) + + +# Color scheme +class Colors: + WHITE = rl.WHITE + WHITE_DIM = rl.Color(255, 255, 255, 85) + GRAY = rl.Color(84, 84, 84, 255) + + # Status colors + GOOD = rl.WHITE + WARNING = rl.Color(218, 202, 37, 255) + DANGER = rl.Color(201, 34, 49, 255) + PROGRESS = rl.Color(0, 134, 233, 255) + DISABLED = rl.Color(128, 128, 128, 255) + + # UI elements + METRIC_BORDER = rl.Color(255, 255, 255, 85) + BUTTON_NORMAL = rl.WHITE + BUTTON_PRESSED = rl.Color(255, 255, 255, 166) + + +@dataclass(slots=True) +class MetricData: + label: str + value: str + color: rl.Color + + def update(self, label: str, value: str, color: rl.Color): + self.label = label + self.value = value + self.color = color + + +class SidebarSP: + def __init__(self): + self._sunnylink_status = MetricData(tr_noop("SUNNYLINK"), tr_noop("OFFLINE"), Colors.WARNING) + + def _update_sunnylink_status(self): + if not ui_state.params.get_bool("SunnylinkEnabled"): + self._sunnylink_status.update(tr_noop("SUNNYLINK"), tr_noop("DISABLED"), Colors.DISABLED) + return + + last_ping = ui_state.params.get("LastSunnylinkPingTime") or 0 + dongle_id = ui_state.params.get("SunnylinkDongleId") + + is_online = last_ping and (time.monotonic_ns() - last_ping) < PING_TIMEOUT_NS + is_temp_fault = ui_state.params.get_bool("SunnylinkTempFault") + is_registering = not is_temp_fault and dongle_id in (None, "", UNREGISTERED_SUNNYLINK_DONGLE_ID) + + # Determine status/color pair based on priority + if last_ping: + status, color = (tr_noop("ONLINE"), Colors.GOOD) if is_online else (tr_noop("ERROR"), Colors.DANGER) + elif is_temp_fault: + status, color = (tr_noop("FAULT"), Colors.WARNING) + elif is_registering: + status, color = (tr_noop("REGIST..."), Colors.PROGRESS) + else: + status, color = (tr_noop("OFFLINE"), Colors.DANGER) + + self._sunnylink_status.update(tr_noop("SUNNYLINK"), status, color) + + def _draw_metrics_w_sunnylink(self, rect: rl.Rectangle, _temp, _panda, _connect): + metrics = [_temp, _panda, _connect, self._sunnylink_status] + start_y = int(rect.y) + METRIC_START_Y + available_height = max(0, int(HOME_BTN.y) - METRIC_MARGIN - METRIC_HEIGHT - start_y) + spacing = available_height / max(1, len(metrics) - 1) + + return metrics, start_y, spacing diff --git a/selfdrive/ui/sunnypilot/mici/__init__.py b/selfdrive/ui/sunnypilot/mici/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/selfdrive/ui/sunnypilot/mici/layouts/__init__.py b/selfdrive/ui/sunnypilot/mici/layouts/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/selfdrive/ui/sunnypilot/mici/layouts/models.py b/selfdrive/ui/sunnypilot/mici/layouts/models.py new file mode 100644 index 0000000000..d8da750fe1 --- /dev/null +++ b/selfdrive/ui/sunnypilot/mici/layouts/models.py @@ -0,0 +1,114 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from collections.abc import Callable + +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.scroller import NavScroller + + +class ModelsLayoutMici(NavScroller): + def __init__(self, back_callback: Callable): + super().__init__() + self.set_back_callback(back_callback) + self.original_back_callback = back_callback + self.focused_widget = None + + self.current_model_btn = BigButton(tr("current model")) + self.current_model_btn.set_click_callback(self._show_folders) + + 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 = [self.current_model_btn, self.cancel_download_btn] + self._scroller.add_widgets(self.main_items) + + @property + def model_manager(self): + return ui_state.sm["modelManagerSP"] + + def _get_grouped_bundles(self): + bundles = self.model_manager.availableBundles + folders = {} + for bundle in bundles: + folder = next((override.value for override in bundle.overrides if override.key == "folder"), "") + folders.setdefault(folder, []).append(bundle) + return folders + + 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) + self._scroller.scroll_panel.set_offset(0) + self.set_back_callback(back_callback) + + def _show_folders(self): + self.focused_widget = self.current_model_btn + folders = self._get_grouped_bundles() + folder_buttons = [] + default_btn = BigButton(tr("default model")) + default_btn.set_click_callback(self._select_default) + folder_buttons.append(default_btn) + + for folder in sorted(folders.keys(), key=lambda f: max((bundle.index for bundle in folders[f]), default=-1), reverse=True): + if folder.lower() in ["release models", "master models"]: + btn = BigButton(folder.lower()) + btn.set_click_callback(lambda f=folder: self._select_folder(f)) + folder_buttons.append(btn) + self._show_selection_view(folder_buttons, self._reset_main_view) + + def _select_model(self, bundle): + ui_state.params.put("ModelManager_DownloadIndex", bundle.index) + self._reset_main_view() + + def _select_default(self): + ui_state.params.remove("ModelManager_ActiveBundle") + self._reset_main_view() + + def _select_folder(self, folder_name): + folders = self._get_grouped_bundles() + bundles = sorted(folders.get(folder_name, []), key=lambda b: b.index, reverse=True) + + btns = [] + for bundle in bundles: + txt = bundle.displayName.lower() + btn = BigButton(txt) + btn.set_click_callback(lambda b=bundle: self._select_model(b)) + btns.append(btn) + self._show_selection_view(btns, self._show_folders) + + def _reset_main_view(self): + self._scroller._items = self.main_items + self.set_back_callback(self.original_back_callback) + if self.focused_widget and self.focused_widget in self.main_items: + x = self._scroller._pad + for item in self.main_items: + if not item.is_visible: + continue + if item == self.focused_widget: + break + x += item.rect.width + self._scroller._spacing + self._scroller.scroll_panel.set_offset(0) + self._scroller.scroll_to(x) + self.focused_widget = None + else: + self._scroller.scroll_panel.set_offset(0) + + def _update_state(self): + super()._update_state() + + manager = self.model_manager + if manager.selectedBundle and manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.downloading: + self.current_model_btn.set_value("downloading...") + self.cancel_download_btn.set_visible(True) + else: + self.current_model_btn.set_value(manager.activeBundle.internalName.lower() if manager.activeBundle else tr("default model")) + 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")) diff --git a/selfdrive/ui/sunnypilot/mici/layouts/onboarding.py b/selfdrive/ui/sunnypilot/mici/layouts/onboarding.py new file mode 100644 index 0000000000..a98f5a2e2e --- /dev/null +++ b/selfdrive/ui/sunnypilot/mici/layouts/onboarding.py @@ -0,0 +1,31 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from collections.abc import Callable + +from openpilot.selfdrive.ui.mici.widgets.dialog import BigConfirmationCircleButton +from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.mici_setup import GreyBigButton +from openpilot.system.ui.widgets.scroller import NavScroller + + +class SunnylinkConsentPage(NavScroller): + def __init__(self, on_accept: Callable | None = None, on_decline: Callable | None = None): + super().__init__() + + self._accept_button = BigConfirmationCircleButton("enable\nsunnylink", gui_app.texture("icons_mici/setup/driver_monitoring/dm_check.png", 64, 64), + on_accept, exit_on_confirm=False) + + self._decline_button = BigConfirmationCircleButton("disable\nsunnylink", gui_app.texture("icons_mici/setup/cancel.png", 64, 64), + on_decline, red=True, exit_on_confirm=False) + + self._scroller.add_widgets([ + GreyBigButton("sunnylink", "scroll to continue", + gui_app.texture("../../sunnypilot/selfdrive/assets/logo.png", 64, 64)), + GreyBigButton("", "sunnylink enables secured remote access to your comma device from anywhere."), + self._accept_button, + self._decline_button, + ]) diff --git a/selfdrive/ui/sunnypilot/mici/layouts/settings.py b/selfdrive/ui/sunnypilot/mici/layouts/settings.py new file mode 100644 index 0000000000..9e160521c7 --- /dev/null +++ b/selfdrive/ui/sunnypilot/mici/layouts/settings.py @@ -0,0 +1,34 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from openpilot.selfdrive.ui.mici.layouts.settings import settings as OP +from openpilot.selfdrive.ui.mici.widgets.button import BigButton +from openpilot.selfdrive.ui.sunnypilot.mici.layouts.sunnylink import SunnylinkLayoutMici +from openpilot.selfdrive.ui.sunnypilot.mici.layouts.models import ModelsLayoutMici +from openpilot.system.ui.lib.application import gui_app + +ICON_SIZE = 70 + + +class SettingsLayoutSP(OP.SettingsLayout): + def __init__(self): + OP.SettingsLayout.__init__(self) + + sunnylink_panel = SunnylinkLayoutMici(back_callback=gui_app.pop_widget) + sunnylink_btn = BigButton("sunnylink", "", gui_app.texture("icons_mici/settings/developer/ssh.png", ICON_SIZE, ICON_SIZE)) + sunnylink_btn.set_click_callback(lambda: gui_app.push_widget(sunnylink_panel)) + + models_panel = ModelsLayoutMici(back_callback=gui_app.pop_widget) + models_btn = BigButton("models", "", gui_app.texture("../../sunnypilot/selfdrive/assets/offroad/icon_models.png", ICON_SIZE, ICON_SIZE)) + models_btn.set_click_callback(lambda: gui_app.push_widget(models_panel)) + + items = self._scroller._items.copy() + + items.insert(1, sunnylink_btn) + items.insert(2, models_btn) + self._scroller._items.clear() + for item in items: + self._scroller.add_widget(item) diff --git a/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py b/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py new file mode 100644 index 0000000000..43ea07643e --- /dev/null +++ b/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py @@ -0,0 +1,205 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from collections.abc import Callable + +from cereal import custom +from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigToggle +from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationDialog +from openpilot.selfdrive.ui.sunnypilot.mici.layouts.onboarding import SunnylinkConsentPage +from openpilot.selfdrive.ui.sunnypilot.mici.widgets.sunnylink_pairing_dialog import SunnylinkPairingDialog +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.scroller import NavScroller +from openpilot.system.version import sunnylink_consent_version, sunnylink_consent_declined + + +class SunnylinkLayoutMici(NavScroller): + def __init__(self, back_callback: Callable): + super().__init__() + self.set_back_callback(back_callback) + self._restore_in_progress = False + self._backup_in_progress = False + self._sunnylink_enabled = ui_state.params.get("SunnylinkEnabled") + + self._sunnylink_toggle = BigToggle(text=tr("enable sunnylink"), + initial_state=self._sunnylink_enabled, + toggle_callback=self._sunnylink_toggle_callback) + self._sunnylink_sponsor_button = SunnylinkPairBigButton(sponsor_pairing=False) + self._sunnylink_pair_button = SunnylinkPairBigButton(sponsor_pairing=True) + self._backup_btn = BigButton(tr("backup settings"), "") + self._backup_btn.set_click_callback(lambda: self._handle_backup_restore_btn(restore=False)) + self._restore_btn = BigButton(tr("restore settings"), "") + self._restore_btn.set_click_callback(lambda: self._handle_backup_restore_btn(restore=True)) + self._sunnylink_uploader_toggle = BigToggle(text=tr("sunnylink uploader"), initial_state=False, + toggle_callback=self._sunnylink_uploader_callback) + + 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 + ]) + + def _update_state(self): + super()._update_state() + self._sunnylink_enabled = ui_state.params.get("SunnylinkEnabled") + self._sunnylink_toggle.set_checked(self._sunnylink_enabled) + self._sunnylink_pair_button.set_visible(self._sunnylink_enabled) + self._sunnylink_sponsor_button.set_visible(self._sunnylink_enabled) + self._backup_btn.set_visible(self._sunnylink_enabled) + self._restore_btn.set_visible(self._sunnylink_enabled) + self._sunnylink_uploader_toggle.set_visible(self._sunnylink_enabled) + self.handle_backup_restore_progress() + + if ui_state.sunnylink_state.is_sponsor(): + self._sunnylink_sponsor_button.set_text(tr("thanks")) + self._sunnylink_sponsor_button.set_value(ui_state.sunnylink_state.get_sponsor_tier().name.lower()) + self._sunnylink_sponsor_button.set_enabled(False) + else: + self._sunnylink_sponsor_button.set_text(tr("sponsor")) + self._sunnylink_sponsor_button.set_value("") + + if ui_state.sunnylink_state.is_paired(): + self._sunnylink_pair_button.set_text(tr("paired")) + else: + self._sunnylink_pair_button.set_text(tr("pair")) + + def show_event(self): + super().show_event() + ui_state.update_params() + + @staticmethod + def _sunnylink_toggle_callback(state: bool): + sl_consent: bool = ui_state.params.get("CompletedSunnylinkConsentVersion") == sunnylink_consent_version + sl_enabled: bool = ui_state.params.get("SunnylinkEnabled") + + def sl_terms_accepted(): + ui_state.params.put("CompletedSunnylinkConsentVersion", sunnylink_consent_version) + ui_state.params.put_bool("SunnylinkEnabled", True) + gui_app.pop_widget() + + def sl_terms_declined(): + ui_state.params.put("CompletedSunnylinkConsentVersion", sunnylink_consent_declined) + ui_state.params.put_bool("SunnylinkEnabled", False) + gui_app.pop_widget() + + if state and not sl_consent and not sl_enabled: + sl_terms_dlg = SunnylinkConsentPage(on_accept=sl_terms_accepted, on_decline=sl_terms_declined) + gui_app.push_widget(sl_terms_dlg) + else: + ui_state.params.put_bool("SunnylinkEnabled", state) + + ui_state.update_params() + + @staticmethod + def _sunnylink_uploader_callback(state: bool): + ui_state.params.put_bool("EnableSunnylinkUploader", state) + + def _handle_backup_restore_btn(self, restore: bool = False): + lbl = tr("slide to restore") if restore else tr("slide to backup") + icon = gui_app.texture("icons_mici/settings/device/update.png", 64, 64) + dlg = BigConfirmationDialog(lbl, icon, confirm_callback=self._restore_handler if restore else self._backup_handler) + gui_app.push_widget(dlg) + + def _backup_handler(self): + self._backup_in_progress = True + self._backup_btn.set_enabled(False) + ui_state.params.put_bool("BackupManager_CreateBackup", True) + + def _restore_handler(self): + self._restore_in_progress = True + self._restore_btn.set_enabled(False) + ui_state.params.put("BackupManager_RestoreVersion", "latest") + + def handle_backup_restore_progress(self): + sunnylink_backup_manager = ui_state.sm["backupManagerSP"] + + backup_status = sunnylink_backup_manager.backupStatus + restore_status = sunnylink_backup_manager.restoreStatus + backup_progress = sunnylink_backup_manager.backupProgress + restore_progress = sunnylink_backup_manager.restoreProgress + + if self._backup_in_progress: + self._restore_btn.set_enabled(False) + self._backup_btn.set_enabled(False) + + if backup_status == custom.BackupManagerSP.Status.inProgress: + self._backup_in_progress = True + self._backup_btn.set_text(tr("backing up")) + text = tr(f"{backup_progress}%") + self._backup_btn.set_value(text) + + elif backup_status == custom.BackupManagerSP.Status.failed: + self._backup_in_progress = False + self._backup_btn.set_enabled(not ui_state.is_onroad()) + self._backup_btn.set_text(tr("backup")) + self._backup_btn.set_value(tr("failed")) + + elif (backup_status == custom.BackupManagerSP.Status.completed or + (backup_status == custom.BackupManagerSP.Status.idle and backup_progress == 100.0)): + self._backup_in_progress = False + gui_app.push_widget(BigDialog(title=tr("settings backed up"), description="")) + self._backup_btn.set_enabled(not ui_state.is_onroad()) + + elif self._restore_in_progress: + self._restore_btn.set_enabled(False) + self._backup_btn.set_enabled(False) + + if restore_status == custom.BackupManagerSP.Status.inProgress: + self._restore_in_progress = True + self._restore_btn.set_text(tr("restoring")) + text = tr(f"{restore_progress}%") + self._restore_btn.set_value(text) + + elif restore_status == custom.BackupManagerSP.Status.failed: + self._restore_in_progress = False + self._restore_btn.set_enabled(not ui_state.is_onroad()) + self._restore_btn.set_text(tr("restore")) + self._restore_btn.set_value(tr("failed")) + gui_app.push_widget(BigDialog(title=tr("unable to restore"), description="try again later.")) + + elif (restore_status == custom.BackupManagerSP.Status.completed or + (restore_status == custom.BackupManagerSP.Status.idle and restore_progress == 100.0)): + self._restore_in_progress = False + gui_app.push_widget(BigConfirmationDialog( + title="slide to restart", icon=gui_app.texture("icons_mici/settings/device/reboot.png", 64, 64), + confirm_callback=lambda: gui_app.request_close())) + + else: + can_enable = self._sunnylink_enabled and not ui_state.is_onroad() + self._backup_btn.set_enabled(can_enable) + self._backup_btn.set_text(tr("backup settings")) + self._backup_btn.set_value("") + self._restore_btn.set_enabled(can_enable) + self._restore_btn.set_text(tr("restore settings")) + self._restore_btn.set_value("") + + +class SunnylinkPairBigButton(BigButton): + def __init__(self, sponsor_pairing: bool = False): + self.sponsor_pairing = sponsor_pairing + super().__init__("") + + def _update_state(self): + super()._update_state() + + def _handle_mouse_release(self, mouse_pos: MousePos): + super()._handle_mouse_release(mouse_pos) + + dlg: BigDialog | SunnylinkPairingDialog | None = None + if UNREGISTERED_SUNNYLINK_DONGLE_ID == (ui_state.params.get("SunnylinkDongleId") or UNREGISTERED_SUNNYLINK_DONGLE_ID): + dlg = BigDialog(tr("sunnylink Dongle ID not found. Please reboot & try again."), "") + elif self.sponsor_pairing: + dlg = SunnylinkPairingDialog(sponsor_pairing=True) + elif not self.sponsor_pairing: + dlg = SunnylinkPairingDialog(sponsor_pairing=False) + if dlg: + gui_app.push_widget(dlg) diff --git a/selfdrive/ui/sunnypilot/mici/onroad/__init__.py b/selfdrive/ui/sunnypilot/mici/onroad/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/selfdrive/ui/sunnypilot/mici/onroad/confidence_ball.py b/selfdrive/ui/sunnypilot/mici/onroad/confidence_ball.py new file mode 100644 index 0000000000..4a1aa92241 --- /dev/null +++ b/selfdrive/ui/sunnypilot/mici/onroad/confidence_ball.py @@ -0,0 +1,26 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from openpilot.selfdrive.ui.onroad.augmented_road_view import BORDER_COLORS +from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus + + +class ConfidenceBallSP: + @staticmethod + def get_animate_status_probs(): + if ui_state.status == UIStatus.LAT_ONLY: + return ui_state.sm['modelV2'].meta.disengagePredictions.steerOverrideProbs + + # UIStatus.LONG_ONLY + return ui_state.sm['modelV2'].meta.disengagePredictions.brakeDisengageProbs + + @staticmethod + def get_lat_long_dot_color(): + if ui_state.status == UIStatus.LAT_ONLY: + return BORDER_COLORS[UIStatus.LAT_ONLY] + + # UIStatus.LONG_ONLY + return BORDER_COLORS[UIStatus.LONG_ONLY] diff --git a/selfdrive/ui/sunnypilot/mici/onroad/hud_renderer.py b/selfdrive/ui/sunnypilot/mici/onroad/hud_renderer.py new file mode 100644 index 0000000000..9d39d01727 --- /dev/null +++ b/selfdrive/ui/sunnypilot/mici/onroad/hud_renderer.py @@ -0,0 +1,28 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import pyray as rl + +from openpilot.selfdrive.ui.mici.onroad.hud_renderer import HudRenderer +from openpilot.selfdrive.ui.sunnypilot.onroad.blind_spot_indicators import BlindSpotIndicators + + +class HudRendererSP(HudRenderer): + def __init__(self): + super().__init__() + self.blind_spot_indicators = BlindSpotIndicators() + + def _update_state(self) -> None: + super()._update_state() + 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: + + return self.blind_spot_indicators.detected diff --git a/selfdrive/ui/sunnypilot/mici/onroad/model_renderer.py b/selfdrive/ui/sunnypilot/mici/onroad/model_renderer.py new file mode 100644 index 0000000000..e3de9401ce --- /dev/null +++ b/selfdrive/ui/sunnypilot/mici/onroad/model_renderer.py @@ -0,0 +1,19 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import pyray as rl +from openpilot.selfdrive.ui.ui_state import UIStatus +from openpilot.selfdrive.ui.sunnypilot.onroad.rainbow_path import RainbowPath + +LANE_LINE_COLORS_SP = { + UIStatus.LAT_ONLY: rl.Color(0, 255, 64, 255), + UIStatus.LONG_ONLY: rl.Color(0, 255, 64, 255), +} + + +class ModelRendererSP: + def __init__(self): + self.rainbow_path = RainbowPath() diff --git a/selfdrive/ui/sunnypilot/mici/widgets/__init__.py b/selfdrive/ui/sunnypilot/mici/widgets/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/selfdrive/ui/sunnypilot/mici/widgets/sunnylink_pairing_dialog.py b/selfdrive/ui/sunnypilot/mici/widgets/sunnylink_pairing_dialog.py new file mode 100644 index 0000000000..c727e4fc1c --- /dev/null +++ b/selfdrive/ui/sunnypilot/mici/widgets/sunnylink_pairing_dialog.py @@ -0,0 +1,57 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import base64 + +import pyray as rl +from openpilot.common.swaglog import cloudlog +from openpilot.selfdrive.ui.mici.widgets.pairing_dialog import PairingDialog +from openpilot.sunnypilot.sunnylink.api import SunnylinkApi, UNREGISTERED_SUNNYLINK_DONGLE_ID, API_HOST +from openpilot.system.ui.lib.application import FontWeight, gui_app +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.widgets.nav_widget import NavWidget +from openpilot.system.ui.widgets.label import UnifiedLabel + + +class SunnylinkPairingDialog(PairingDialog): + """Dialog for device pairing with QR code.""" + + def __init__(self, sponsor_pairing: bool = False): + PairingDialog.__init__(self) + self._sponsor_pairing = sponsor_pairing + label_text = tr("pair with sunnylink") if sponsor_pairing else tr("become a sunnypilot sponsor") + self._pair_label = UnifiedLabel(label_text, font_size=48, font_weight=FontWeight.BOLD, + text_color=rl.Color(255, 255, 255, int(255 * 0.9)), line_height=0.8) + + def _get_pairing_url(self) -> str: + qr_string = "https://github.com/sponsors/sunnyhaibin" + + if self._sponsor_pairing: + try: + sl_dongle_id = self._params.get("SunnylinkDongleId") or UNREGISTERED_SUNNYLINK_DONGLE_ID + token = SunnylinkApi(sl_dongle_id).get_token() + inner_string = f"1|{sl_dongle_id}|{token}" + payload_bytes = base64.b64encode(inner_string.encode('utf-8')).decode('utf-8') + qr_string = f"{API_HOST}/sso?state={payload_bytes}" + except Exception: + cloudlog.exception("Failed to get pairing token") + + return qr_string + + def _update_state(self): + NavWidget._update_state(self) + + +if __name__ == "__main__": + gui_app.init_window("pairing device") + pairing = SunnylinkPairingDialog(sponsor_pairing=True) + try: + for _ in gui_app.render(): + result = pairing.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) + if result != -1: + break + finally: + del pairing diff --git a/selfdrive/ui/sunnypilot/onroad/__init__.py b/selfdrive/ui/sunnypilot/onroad/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/selfdrive/ui/sunnypilot/onroad/alert_renderer.py b/selfdrive/ui/sunnypilot/onroad/alert_renderer.py new file mode 100644 index 0000000000..9f48287a70 --- /dev/null +++ b/selfdrive/ui/sunnypilot/onroad/alert_renderer.py @@ -0,0 +1,103 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import pyray as rl +from openpilot.selfdrive.ui.onroad.alert_renderer import AlertRenderer, AlertSize, ALERT_FONT_MEDIUM, ALERT_FONT_BIG, \ + ALERT_FONT_SMALL, ALERT_MARGIN, ALERT_HEIGHTS, ALERT_PADDING, Alert +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.text_measure import measure_text_cached +from openpilot.system.ui.lib.wrap_text import wrap_text + +ALERT_LINE_SPACING = 15 + + +class AlertRendererSP(AlertRenderer): + def __init__(self): + super().__init__() + + def _draw_text(self, rect: rl.Rectangle, alert: Alert) -> None: + if alert.size == AlertSize.small: + self._draw_multiline_centered(alert.text1, rect, self.font_bold, ALERT_FONT_MEDIUM) + + elif alert.size == AlertSize.mid: + wrap_width = int(rect.width) + lines1 = wrap_text(self.font_bold, alert.text1, ALERT_FONT_BIG, wrap_width) + lines2 = wrap_text(self.font_regular, alert.text2, ALERT_FONT_SMALL, wrap_width) if alert.text2 else [] + + total_text_height = len(lines1) * measure_text_cached(self.font_bold, "A", ALERT_FONT_BIG).y + if lines2: + total_text_height += ALERT_LINE_SPACING + len(lines2) * measure_text_cached(self.font_regular, "A", ALERT_FONT_SMALL).y + + curr_y = rect.y + (rect.height - total_text_height) / 2 + + for line in lines1: + line_height = measure_text_cached(self.font_bold, alert.text1, ALERT_FONT_BIG).y + self._draw_line_centered(line, rl.Rectangle(rect.x, curr_y, rect.width, line_height), self.font_bold, ALERT_FONT_BIG) + curr_y += line_height + + if lines2: + curr_y += ALERT_LINE_SPACING + for line in lines2: + line_height = measure_text_cached(self.font_regular, alert.text2, ALERT_FONT_SMALL).y + self._draw_line_centered(line, rl.Rectangle(rect.x, curr_y, rect.width, line_height), self.font_regular, ALERT_FONT_SMALL) + curr_y += line_height + + else: + super()._draw_text(rect, alert) + + def _draw_multiline_centered(self, text, rect, font, font_size, color=rl.WHITE) -> None: + lines = wrap_text(font, text, font_size, rect.width) + line_height = measure_text_cached(font, text, font_size).y + total_height = len(lines) * line_height + curr_y = rect.y + (rect.height - total_height) / 2 + for line in lines: + self._draw_line_centered(line, rl.Rectangle(rect.x, curr_y, rect.width, line_height), font, font_size, color) + curr_y += line_height + + def _draw_line_centered(self, text, rect, font, font_size, color=rl.WHITE) -> None: + text_size = measure_text_cached(font, text, font_size) + x = rect.x + (rect.width - text_size.x) / 2 + y = rect.y + rl.draw_text_ex(font, text, rl.Vector2(x, y), font_size, 0, color) + + def _get_alert_rect(self, rect: rl.Rectangle, size: int) -> rl.Rectangle: + if size == AlertSize.full: + return rect + + dev_ui_info = ui_state.developer_ui + v_adjustment = 40 if dev_ui_info in {2, 3} and size != AlertSize.full else 0 + h_adjustment = 230 if dev_ui_info in {1, 3} and size != AlertSize.full else 0 + + w = int(rect.width - ALERT_MARGIN * 2 - h_adjustment) + h = self._calculate_dynamic_height(size, w) + return rl.Rectangle(rect.x + ALERT_MARGIN, rect.y + rect.height - h + ALERT_MARGIN - v_adjustment, w, + h - ALERT_MARGIN * 2) + + def _calculate_dynamic_height(self, size: int, width: int) -> int: + alert = self.get_alert(ui_state.sm) + if not alert: + return ALERT_HEIGHTS.get(size, 271) + + height = 2 * ALERT_PADDING + wrap_width = width - 2 * ALERT_PADDING + + if size == AlertSize.small: + lines = wrap_text(self.font_bold, alert.text1, ALERT_FONT_MEDIUM, wrap_width) + line_height = measure_text_cached(self.font_bold, alert.text1, ALERT_FONT_MEDIUM).y + height += int(len(lines) * line_height) + elif size == AlertSize.mid: + lines1 = wrap_text(self.font_bold, alert.text1, ALERT_FONT_BIG, wrap_width) + line_height1 = measure_text_cached(self.font_bold, alert.text1, ALERT_FONT_BIG).y + height += int(len(lines1) * line_height1) + + if alert.text2: + lines2 = wrap_text(self.font_regular, alert.text2, ALERT_FONT_SMALL, wrap_width) + line_height2 = measure_text_cached(self.font_regular, alert.text2, ALERT_FONT_SMALL).y + height += int(ALERT_LINE_SPACING + len(lines2) * line_height2) + else: + height = ALERT_HEIGHTS.get(size, 271) + + return int(height) diff --git a/selfdrive/ui/sunnypilot/onroad/augmented_road_view.py b/selfdrive/ui/sunnypilot/onroad/augmented_road_view.py new file mode 100644 index 0000000000..c7dedee540 --- /dev/null +++ b/selfdrive/ui/sunnypilot/onroad/augmented_road_view.py @@ -0,0 +1,31 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import pyray as rl +from openpilot.common.filter_simple import FirstOrderFilter +from openpilot.selfdrive.ui.ui_state import UIStatus, ui_state +from openpilot.system.ui.lib.application import gui_app + +BORDER_COLORS_SP = { + UIStatus.LAT_ONLY: rl.Color(0x00, 0xC8, 0xC8, 0xFF), # Cyan for lateral-only state + UIStatus.LONG_ONLY: rl.Color(0x96, 0x1C, 0xA8, 0xFF), # Purple for longitudinal-only state +} + + +class AugmentedRoadViewSP: + def __init__(self): + self._fade_texture = gui_app.texture("icons_mici/onroad/onroad_fade.png") + self._fade_alpha_filter = FirstOrderFilter(0, 0.1, 1 / gui_app.target_fps) + + def update_fade_out_bottom_overlay(self, _content_rect): + # Fade out bottom of overlays for looks (only when engaged) + fade_alpha = self._fade_alpha_filter.update(ui_state.status != UIStatus.DISENGAGED) + if ui_state.torque_bar and fade_alpha > 1e-2: + # Scale the fade texture to the content rect + rl.draw_texture_pro(self._fade_texture, + rl.Rectangle(0, 0, self._fade_texture.width, self._fade_texture.height), + _content_rect, rl.Vector2(0, 0), 0.0, + rl.Color(255, 255, 255, int(255 * fade_alpha))) diff --git a/selfdrive/ui/sunnypilot/onroad/blind_spot_indicators.py b/selfdrive/ui/sunnypilot/onroad/blind_spot_indicators.py new file mode 100644 index 0000000000..2efda17a2b --- /dev/null +++ b/selfdrive/ui/sunnypilot/onroad/blind_spot_indicators.py @@ -0,0 +1,52 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import pyray as rl + +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.application import gui_app +from openpilot.common.filter_simple import FirstOrderFilter + + +class BlindSpotIndicators: + def __init__(self): + self._txt_blind_spot_left: rl.Texture = gui_app.texture('icons_mici/onroad/blind_spot_left.png', 108, 128) + self._txt_blind_spot_right: rl.Texture = gui_app.texture('icons_mici/onroad/blind_spot_left.png', 108, 128, flip_x=True) + + self._blind_spot_left_alpha_filter = FirstOrderFilter(0, 0.15, 1 / gui_app.target_fps) + self._blind_spot_right_alpha_filter = FirstOrderFilter(0, 0.15, 1 / gui_app.target_fps) + + def update(self) -> None: + sm = ui_state.sm + CS = sm['carState'] + + self._blind_spot_left_alpha_filter.update(1.0 if CS.leftBlindspot else 0.0) + self._blind_spot_right_alpha_filter.update(1.0 if CS.rightBlindspot else 0.0) + + @property + def detected(self) -> bool: + return ui_state.blindspot and (self._blind_spot_left_alpha_filter.x > 0.01 or self._blind_spot_right_alpha_filter.x > 0.01) + + def render(self, rect: rl.Rectangle) -> None: + if not ui_state.blindspot: + return + + BLIND_SPOT_MARGIN_X = 20 # Distance from edge of screen + BLIND_SPOT_Y_OFFSET = 100 # Distance from top of screen + + if self._blind_spot_left_alpha_filter.x > 0.01: + pos_x = int(rect.x + BLIND_SPOT_MARGIN_X) + pos_y = int(rect.y + BLIND_SPOT_Y_OFFSET) + alpha = int(255 * self._blind_spot_left_alpha_filter.x) + color = rl.Color(255, 255, 255, alpha) + rl.draw_texture_ex(self._txt_blind_spot_left, rl.Vector2(pos_x, pos_y), 0.0, 1.0, color) + + if self._blind_spot_right_alpha_filter.x > 0.01: + pos_x = int(rect.x + rect.width - BLIND_SPOT_MARGIN_X - self._txt_blind_spot_right.width) + pos_y = int(rect.y + BLIND_SPOT_Y_OFFSET) + alpha = int(255 * self._blind_spot_right_alpha_filter.x) + color = rl.Color(255, 255, 255, alpha) + rl.draw_texture_ex(self._txt_blind_spot_right, rl.Vector2(pos_x, pos_y), 0.0, 1.0, color) diff --git a/selfdrive/ui/sunnypilot/onroad/chevron_metrics.py b/selfdrive/ui/sunnypilot/onroad/chevron_metrics.py new file mode 100644 index 0000000000..a8a342c129 --- /dev/null +++ b/selfdrive/ui/sunnypilot/onroad/chevron_metrics.py @@ -0,0 +1,147 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import numpy as np + +import pyray as rl +from openpilot.common.constants import CV +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.text_measure import measure_text_cached + + +class ChevronOptions: + OFF = 0 + DISTANCE_ONLY = 1 + SPEED_ONLY = 2 + TTC_ONLY = 3 + ALL = 4 + + +class ChevronMetrics: + def __init__(self): + self._lead_status_alpha: float = 0.0 + self._font = gui_app.font(FontWeight.SEMI_BOLD) + + def update_alpha(self, has_lead: bool): + """Update the alpha value for fade in/out animation""" + if not has_lead: + self._lead_status_alpha = max(0.0, self._lead_status_alpha - 0.05) + else: + self._lead_status_alpha = min(1.0, self._lead_status_alpha + 0.1) + + def should_render(self) -> bool: + """Check if dev UI should be rendered""" + return ui_state.chevron_metrics != ChevronOptions.OFF and self._lead_status_alpha > 0.0 + + def _draw_lead(self, lead_data, lead_vehicle, v_ego: float, rect: rl.Rectangle): + """Draw lead vehicle status information (distance, speed, TTC)""" + if not self.should_render(): + return + + d_rel = lead_data.dRel + v_rel = lead_data.vRel + + if not lead_vehicle.chevron or len(lead_vehicle.chevron) < 2: + return + + chevron_x = lead_vehicle.chevron[1][0] + chevron_y = lead_vehicle.chevron[1][1] + sz = np.clip((25 * 30) / (d_rel / 3 + 30), 15.0, 30.0) * 2.35 + + text_lines = self._build_text_lines(d_rel, v_rel, v_ego) + if not text_lines: + return + + self._render_text_lines(text_lines, chevron_x, chevron_y, sz, rect) + + @staticmethod + def _build_text_lines(d_rel: float, v_rel: float, v_ego: float) -> list[str]: + """Build text lines based on chevron info setting""" + text_lines = [] + + # Distance + if ui_state.chevron_metrics == ChevronOptions.DISTANCE_ONLY or ui_state.chevron_metrics == ChevronOptions.ALL: + val = max(0.0, d_rel) + unit = "m" if ui_state.is_metric else "ft" + if not ui_state.is_metric: + val *= 3.28084 + text_lines.append(f"{val:.0f} {unit}") + + # Speed + if ui_state.chevron_metrics == ChevronOptions.SPEED_ONLY or ui_state.chevron_metrics == ChevronOptions.ALL: + multiplier = CV.MS_TO_KPH if ui_state.is_metric else CV.MS_TO_MPH + val = max(0.0, (v_rel + v_ego) * multiplier) + unit = "km/h" if ui_state.is_metric else "mph" + text_lines.append(f"{val:.0f} {unit}") + + # Time to collision + if ui_state.chevron_metrics == ChevronOptions.TTC_ONLY or ui_state.chevron_metrics == ChevronOptions.ALL: + val = (d_rel / v_ego) if (d_rel > 0 and v_ego > 0) else 0.0 + ttc_text = f"{val:.1f} s" if (0 < val < 200) else "---" + text_lines.append(ttc_text) + + return text_lines + + def _render_text_lines(self, text_lines: list[str], chevron_x: float, chevron_y: float, + sz: float, rect: rl.Rectangle): + """Render text lines with proper centering and positioning""" + font_size = 40 + line_height = 50 + margin = 20 + + text_y = chevron_y + sz + 15 + total_height = len(text_lines) * line_height + + # Adjust Y position if text would go off screen + if text_y + total_height > rect.height - margin: + y_max = min(chevron_y, rect.height - margin) + text_y = y_max - 15 - total_height + text_y = max(margin, text_y) + + alpha = int(255 * self._lead_status_alpha) + text_color = rl.Color(255, 255, 255, alpha) + shadow_color = rl.Color(0, 0, 0, int(200 * self._lead_status_alpha)) + + for i, line in enumerate(text_lines): + y = int(text_y + (i * line_height)) + if y + line_height > rect.height - margin: + break + + # Measure actual text width for proper centering + text_size = measure_text_cached(self._font, line, font_size, 0) + text_width = text_size.x + + # Center the text horizontally on the chevron + x = int(chevron_x - text_width / 2) + x = int(np.clip(x, margin, rect.width - text_width - margin)) + + # Draw shadow + rl.draw_text_ex(self._font, line, rl.Vector2(x + 2, y + 2), font_size, 0, shadow_color) + # Draw text + rl.draw_text_ex(self._font, line, rl.Vector2(x, y), font_size, 0, text_color) + + def draw_lead_status(self, sm, radar_state, rect, lead_vehicles): + lead_one = radar_state.leadOne + lead_two = radar_state.leadTwo + + has_lead_one = lead_one.status if lead_one else False + has_lead_two = lead_two.status if lead_two else False + + self.update_alpha(has_lead_one or has_lead_two) + + if not self.should_render(): + return + + v_ego = sm['carState'].vEgo + + if has_lead_one and lead_vehicles[0].chevron: + self._draw_lead(lead_one, lead_vehicles[0], v_ego, rect) + + if has_lead_two and lead_vehicles[1].chevron: + d_rel_diff = abs(lead_one.dRel - lead_two.dRel) if has_lead_one else float('inf') + if d_rel_diff > 3.0: + self._draw_lead(lead_two, lead_vehicles[1], v_ego, rect) diff --git a/selfdrive/ui/sunnypilot/onroad/circular_alerts.py b/selfdrive/ui/sunnypilot/onroad/circular_alerts.py new file mode 100644 index 0000000000..f90fc81914 --- /dev/null +++ b/selfdrive/ui/sunnypilot/onroad/circular_alerts.py @@ -0,0 +1,140 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import pyray as rl + +from cereal import log +from openpilot.selfdrive.ui import UI_BORDER_SIZE +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.selfdrive.ui.sunnypilot.onroad.developer_ui import DeveloperUiState +from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE +from openpilot.system.ui.lib.text_measure import measure_text_cached + + +class CircularAlertsRenderer: + def __init__(self): + self._green_light_alert_img = gui_app.texture("../../sunnypilot/selfdrive/assets/images/green_light.png", 250, 250) + self._lead_depart_alert_img = gui_app.texture("../../sunnypilot/selfdrive/assets/images/lead_depart.png", 250, 250) + + self._e2e_alert_display_timer = 0 + self._e2e_alert_frame = 0 + self._green_light_alert = False + self._lead_depart_alert = False + self._standstill_elapsed_time = 0.0 + self._is_standstill = False + self._alert_text = "" + self._alert_img = None + self._allow_e2e_alerts = False + + def update(self) -> None: + sm = ui_state.sm + lp_sp = sm['longitudinalPlanSP'] + car_state = sm['carState'] + self._green_light_alert = lp_sp.e2eAlerts.greenLightAlert + self._lead_depart_alert = lp_sp.e2eAlerts.leadDepartAlert + self._is_standstill = car_state.standstill + + if not ui_state.started: + self._standstill_elapsed_time = 0.0 + + self._allow_e2e_alerts = sm['selfdriveState'].alertSize == log.SelfdriveState.AlertSize.none and \ + sm.recv_frame['driverStateV2'] > ui_state.started_frame + + if self._green_light_alert or self._lead_depart_alert: + self._e2e_alert_display_timer = 3 * gui_app.target_fps + # reset onroad sleep timer for e2e alerts + ui_state.reset_onroad_sleep_timer() + + if self._e2e_alert_display_timer > 0: + self._e2e_alert_frame += 1 + self._e2e_alert_display_timer -= 1 + + if self._green_light_alert: + self._alert_text = "GREEN\nLIGHT" + self._alert_img = self._green_light_alert_img + elif self._lead_depart_alert: + self._alert_text = "LEAD VEHICLE\nDEPARTING" + self._alert_img = self._lead_depart_alert_img + + elif ui_state.standstill_timer and self._is_standstill: + self._alert_img = None + self._standstill_elapsed_time += 1.0 / gui_app.target_fps + minute = int(self._standstill_elapsed_time / 60) + second = int(self._standstill_elapsed_time - (minute * 60)) + self._alert_text = f"{minute:01d}:{second:02d}" + self._e2e_alert_frame += 1 + + else: + self._e2e_alert_frame = 0 + if not self._is_standstill: + self._standstill_elapsed_time = 0.0 + + def render(self, rect: rl.Rectangle) -> None: + if not self._allow_e2e_alerts or (self._e2e_alert_display_timer <= 0 and not (ui_state.standstill_timer and self._is_standstill)): + return + + e2e_alert_size = 250 + dev_ui_width_adjustment = 180 if ui_state.developer_ui in (DeveloperUiState.RIGHT, DeveloperUiState.BOTH) else 100 + + x = rect.x + rect.width - e2e_alert_size - dev_ui_width_adjustment - (UI_BORDER_SIZE * 3) + y = rect.y + rect.height / 2 + 20 + + alert_rect = rl.Rectangle(x - e2e_alert_size, y - e2e_alert_size, e2e_alert_size * 2, e2e_alert_size * 2) + center = rl.Vector2(alert_rect.x + alert_rect.width / 2, alert_rect.y + alert_rect.height / 2) + + # Pulse logic + is_pulsing = (self._e2e_alert_frame % gui_app.target_fps) < (gui_app.target_fps / 2.5) + + # Standstill Timer (STOPPED) should be static white + if self._e2e_alert_display_timer == 0 and ui_state.standstill_timer and self._is_standstill: + frame_color = rl.Color(255, 255, 255, 75) + else: + frame_color = rl.Color(255, 255, 255, 75) if is_pulsing else rl.Color(0, 255, 0, 75) + + # Draw Circle + rl.draw_circle_v(center, e2e_alert_size, rl.Color(0, 0, 0, 190)) + # Draw Ring (Border) + rl.draw_ring(center, e2e_alert_size - 7.5, e2e_alert_size + 7.5, 0, 360, 0, frame_color) + + # Draw Image + if self._alert_img and self._e2e_alert_display_timer > 0: + img_x = center.x - self._alert_img.width / 2 + img_y = center.y - self._alert_img.height / 2 + rl.draw_texture_ex(self._alert_img, rl.Vector2(img_x, img_y), 0.0, 1.0, rl.WHITE) + + # Draw Text + txt_color = rl.Color(255, 255, 255, 255) if is_pulsing else rl.Color(0, 255, 0, 190) + font = gui_app.font(FontWeight.BOLD) + text_size = 48 + spacing = 0 + + lines = self._alert_text.split('\n') + + # Position text at bottom of alert circle + bottom_y = (alert_rect.y + alert_rect.height) - (alert_rect.height / 7) + + # Draw lines upwards from bottom + current_y = bottom_y - (len(lines) * text_size * FONT_SCALE) + + if self._e2e_alert_display_timer == 0 and ui_state.standstill_timer and self._is_standstill: + # Standstill Timer Text + alert_alt_text = "STOPPED" + top_text_size = 80 + measure_top = measure_text_cached(font, alert_alt_text, top_text_size, spacing) + top_y = alert_rect.y + alert_rect.height / 3.5 + rl.draw_text_ex(font, alert_alt_text, rl.Vector2(center.x - measure_top.x / 2, top_y), top_text_size, spacing, rl.Color(255, 175, 3, 240)) + + # Timer + timer_text_size = 100 + measure_timer = measure_text_cached(font, self._alert_text, timer_text_size, spacing) + timer_y = (alert_rect.y + alert_rect.height) - (alert_rect.height / 5) - measure_timer.y + rl.draw_text_ex(font, self._alert_text, rl.Vector2(center.x - measure_timer.x / 2, timer_y), timer_text_size, spacing, rl.WHITE) + else: + for line in lines: + measure = measure_text_cached(font, line, text_size, spacing) + line_x = center.x - measure.x / 2 + rl.draw_text_ex(font, line, rl.Vector2(line_x, current_y), text_size, spacing, txt_color) + current_y += text_size * FONT_SCALE diff --git a/selfdrive/ui/sunnypilot/onroad/developer_ui/__init__.py b/selfdrive/ui/sunnypilot/onroad/developer_ui/__init__.py new file mode 100644 index 0000000000..8204253d32 --- /dev/null +++ b/selfdrive/ui/sunnypilot/onroad/developer_ui/__init__.py @@ -0,0 +1,191 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from enum import IntEnum + +import pyray as rl +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.selfdrive.ui.sunnypilot.onroad.developer_ui.elements import ( + UiElement, RelDistElement, RelSpeedElement, SteeringAngleElement, + DesiredLateralAccelElement, ActualLateralAccelElement, DesiredSteeringAngleElement, + AEgoElement, LeadSpeedElement, FrictionCoefficientElement, LatAccelFactorElement, + SteeringTorqueEpsElement, BearingDegElement, AltitudeElement, DesiredSteeringPIDElement +) +from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.text_measure import measure_text_cached +from openpilot.system.ui.widgets import Widget + + +def get_bottom_dev_ui_offset(): + if ui_state.developer_ui in (DeveloperUiState.BOTTOM, DeveloperUiState.BOTH): + return 60 + return 0 + + +class DeveloperUiState(IntEnum): + OFF = 0 + BOTTOM = 1 + RIGHT = 2 + BOTH = 3 + + +class DeveloperUiRenderer(Widget): + def __init__(self): + super().__init__() + self._font_bold: rl.Font = gui_app.font(FontWeight.BOLD) + self._font_semi_bold: rl.Font = gui_app.font(FontWeight.SEMI_BOLD) + self.dev_ui_mode = DeveloperUiState.OFF + + self.rel_dist_elem = RelDistElement() + self.rel_speed_elem = RelSpeedElement() + self.steering_angle_elem = SteeringAngleElement() + self.desired_lat_accel_elem = DesiredLateralAccelElement() + self.actual_lat_accel_elem = ActualLateralAccelElement() + self.desired_steer_elem = DesiredSteeringAngleElement() + self.desired_pid_steer_elem = DesiredSteeringPIDElement() + self.a_ego_elem = AEgoElement() + self.lead_speed_elem = LeadSpeedElement() + self.friction_elem = FrictionCoefficientElement() + self.lat_accel_factor_elem = LatAccelFactorElement() + self.steering_torque_elem = SteeringTorqueEpsElement() + self.bearing_elem = BearingDegElement() + self.altitude_elem = AltitudeElement() + + def _update_state(self) -> None: + self.dev_ui_mode = ui_state.developer_ui + + def _render(self, rect: rl.Rectangle) -> None: + if self.dev_ui_mode == DeveloperUiState.OFF: + return + + sm = ui_state.sm + if sm.recv_frame["carState"] < ui_state.started_frame: + return + + if self.dev_ui_mode == DeveloperUiState.BOTTOM: + self._draw_bottom_dev_ui(rect) + elif self.dev_ui_mode == DeveloperUiState.RIGHT: + self._draw_right_dev_ui(rect) + elif self.dev_ui_mode == DeveloperUiState.BOTH: + self._draw_right_dev_ui(rect) + self._draw_bottom_dev_ui(rect) + + def _draw_right_dev_ui(self, rect: rl.Rectangle) -> None: + sm = ui_state.sm + controls_state = sm['controlsState'] + + UI_BORDER_SIZE = 20 + container_width = 184 + x = int(rect.x + rect.width - container_width - UI_BORDER_SIZE * 2) + y = int(rect.y + UI_BORDER_SIZE * 1.5) + + elements = [ + self.rel_dist_elem.update(sm, ui_state.is_metric), + self.rel_speed_elem.update(sm, ui_state.is_metric), + self.steering_angle_elem.update(sm, ui_state.is_metric), + ] + if controls_state.lateralControlState.which() == 'torqueState': + elements.append(self.desired_lat_accel_elem.update(sm, ui_state.is_metric)) + elif controls_state.lateralControlState.which() == 'angleState': + elements.append(self.desired_steer_elem.update(sm, ui_state.is_metric)) + elif controls_state.lateralControlState.which() == 'pidState': + elements.append(self.desired_pid_steer_elem.update(sm, ui_state.is_metric)) + + elements.append(self.actual_lat_accel_elem.update(sm, ui_state.is_metric)) + + current_y = y + for element in elements: + current_y += self._draw_right_dev_ui_element(x, current_y, element) + + def _draw_right_dev_ui_element(self, x: int, y: int, element: UiElement) -> int: + x += 0 + y += 230 + container_width = 184 + label_size = 28 + value_size = 60 + unit_size = 28 + label_width = measure_text_cached(self._font_bold, element.label, label_size, 0).x + centered_label_x = x + (container_width - label_width) / 2 + rl.draw_text_ex(self._font_bold, element.label, rl.Vector2(centered_label_x, y), label_size, 0, rl.WHITE) + + y += 45 + value_width = measure_text_cached(self._font_bold, element.value, value_size, 0).x + centered_value_x = x + (container_width - value_width) / 2 + rl.draw_text_ex(self._font_bold, element.value, rl.Vector2(centered_value_x, y), value_size, 0, element.color) + + if element.unit: + units_height = measure_text_cached(self._font_bold, element.unit, unit_size, 0).x + + units_x = x + container_width + units_y = y + (value_size / 2) + (units_height / 2) + + rl.draw_text_pro(self._font_bold, element.unit, rl.Vector2(units_x, units_y), rl.Vector2(0, 0), -90.0, unit_size, 0, rl.WHITE) + + return 130 + + def _draw_bottom_dev_ui(self, rect: rl.Rectangle) -> None: + sm = ui_state.sm + bar_height = 61 + y = int(rect.y + rect.height - bar_height) + + rl.draw_rectangle(int(rect.x), y, int(rect.width), bar_height, + rl.Color(0, 0, 0, 100)) + + elements = [ + self.a_ego_elem.update(sm, ui_state.is_metric), + self.lead_speed_elem.update(sm, ui_state.is_metric), + ] + + # Add torque-specific elements if using torque control + if sm['controlsState'].lateralControlState.which() == 'torqueState': + if sm.valid['liveTorqueParameters']: + elements.extend([ + self.friction_elem.update(sm, ui_state.is_metric), + self.lat_accel_factor_elem.update(sm, ui_state.is_metric), + ]) + else: + # Non-torque: show steering torque and GPS data + elements.append(self.steering_torque_elem.update(sm, ui_state.is_metric)) + + if sm.valid['gpsLocationExternal'] or sm.valid['gpsLocation']: + elements.append(self.bearing_elem.update(sm, ui_state.is_metric)) + + # Add altitude if GPS available + if sm.valid['gpsLocationExternal'] or sm.valid['gpsLocation']: + elements.append(self.altitude_elem.update(sm, ui_state.is_metric)) + + if not elements: + return + + font_size = 38 + element_widths = [] + for element in elements: + element.measure(self._font_bold, font_size) + element_widths.append(element.total_width) + + total_element_width = sum(element_widths) + num_gaps = len(elements) + 1 + available_width = rect.width + gap_width = (available_width - total_element_width) / num_gaps + + center_y = y + bar_height // 2 + current_x = rect.x + gap_width + + for i, element in enumerate(elements): + element_center_x = int(current_x + element_widths[i] / 2) + self._draw_bottom_dev_ui_element(element_center_x, center_y, element) + current_x += element_widths[i] + gap_width + + def _draw_bottom_dev_ui_element(self, center_x: int, y: int, element: UiElement) -> None: + font_size = 38 + start_x = center_x - element.total_width / 2 + + rl.draw_text_ex(self._font_bold, element.label_text, rl.Vector2(start_x, y - font_size // 2), font_size, 0, rl.WHITE) + rl.draw_text_ex(self._font_bold, element.val_text, rl.Vector2(start_x + element.label_width, y - font_size // 2), font_size, 0, element.color) + + if element.unit: + rl.draw_text_ex(self._font_bold, element.unit_text, rl.Vector2(start_x + element.label_width + element.val_width, y - font_size // 2), + font_size, 0, rl.WHITE) diff --git a/selfdrive/ui/sunnypilot/onroad/developer_ui/elements.py b/selfdrive/ui/sunnypilot/onroad/developer_ui/elements.py new file mode 100644 index 0000000000..94e3af42eb --- /dev/null +++ b/selfdrive/ui/sunnypilot/onroad/developer_ui/elements.py @@ -0,0 +1,349 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import pyray as rl +from dataclasses import dataclass + +from openpilot.common.constants import CV + + +from openpilot.system.ui.lib.text_measure import measure_text_cached + + +@dataclass +class UiElement: + value: str + label: str + unit: str + color: rl.Color + val_text: str = "" + label_text: str = "" + unit_text: str = "" + val_width: float = 0.0 + label_width: float = 0.0 + unit_width: float = 0.0 + total_width: float = 0.0 + + def measure(self, font, font_size: int): + self.label_text = f"{self.label} " + self.val_text = self.value + self.unit_text = f" {self.unit}" if self.unit else "" + + self.label_width = measure_text_cached(font, self.label_text, font_size, 0).x + self.val_width = measure_text_cached(font, self.val_text, font_size, 0).x + self.unit_width = measure_text_cached(font, self.unit_text, font_size, 0).x if self.unit else 0 + + self.total_width = self.label_width + self.val_width + self.unit_width + + +class LeadInfoElement: + @staticmethod + def get_lead_status(sm): + lead_one = sm['radarState'].leadOne + return lead_one.status, lead_one.dRel, lead_one.vRel + + @staticmethod + def get_lead_color(lead_d_rel: float, lead_v_rel: float = 0.0, use_v_rel: bool = False) -> rl.Color: + if use_v_rel: + if lead_v_rel < -4.4704: + return rl.RED + elif lead_v_rel < 0: + return rl.Color(255, 188, 0, 255) # Orange + else: + if lead_d_rel < 5: + return rl.RED + elif lead_d_rel < 15: + return rl.Color(255, 188, 0, 255) # Orange + return rl.WHITE + + +class LateralControlElement: + @staticmethod + def get_lat_color(lat_active: bool, steer_override: bool, angle_steers: float = 0.0, + check_angle: bool = False) -> rl.Color: + color = rl.WHITE + if lat_active: + color = rl.Color(145, 155, 149, 255) if steer_override else rl.Color(0, 255, 0, 255) + + if check_angle and lat_active: + if abs(angle_steers) > 180: + color = rl.RED + elif abs(angle_steers) > 90: + color = rl.Color(255, 188, 0, 255) + else: + # Keep green/grey from above + pass + elif check_angle and not lat_active: + if abs(angle_steers) > 180: + color = rl.RED + elif abs(angle_steers) > 90: + color = rl.Color(255, 188, 0, 255) + + return color + + +class RelDistElement(LeadInfoElement): + def __init__(self): + self.unit = "m" + + def update(self, sm, is_metric: bool) -> UiElement: + lead_status, lead_d_rel, _ = self.get_lead_status(sm) + value = f"{lead_d_rel:.0f}" if lead_status else "-" + color = self.get_lead_color(lead_d_rel) if lead_status else rl.WHITE + return UiElement(value, "REL DIST", self.unit, color) + + +class RelSpeedElement(LeadInfoElement): + def __init__(self): + self.unit = "km/h" + + def update(self, sm, is_metric: bool) -> UiElement: + lead_status, _, lead_v_rel = self.get_lead_status(sm) + + self.unit = "km/h" if is_metric else "mph" + + conversion = CV.MS_TO_KPH if is_metric else CV.MS_TO_MPH + value = f"{lead_v_rel * conversion:.0f}" if lead_status else "-" + color = self.get_lead_color(0, lead_v_rel, use_v_rel=True) if lead_status else rl.WHITE + + return UiElement(value, "REL SPEED", self.unit, color) + + +class SteeringAngleElement(LateralControlElement): + def __init__(self): + self.unit = "" + + def update(self, sm, is_metric: bool) -> UiElement: + car_state = sm['carState'] + angle_steers = car_state.steeringAngleDeg + lat_active = sm['carControl'].latActive + steer_override = car_state.steeringPressed + + value = f"{angle_steers:.1f}°" + color = self.get_lat_color(lat_active, steer_override, angle_steers, check_angle=True) + + return UiElement(value, "REAL STEER", self.unit, color) + + +class DesiredSteeringAngleElement(LateralControlElement): + def __init__(self): + self.unit = "" + + def update(self, sm, is_metric: bool) -> UiElement: + car_state = sm['carState'] + controls_state = sm['controlsState'] + lat_active = sm['carControl'].latActive + angle_steers = car_state.steeringAngleDeg + steer_angle_desired = controls_state.lateralControlState.angleState.steeringAngleDeg + + value = f"{steer_angle_desired:.1f}°" if lat_active else "-" + + color = rl.WHITE + if lat_active: + if abs(angle_steers) > 180: + color = rl.RED + elif abs(angle_steers) > 90: + color = rl.Color(255, 188, 0, 255) + else: + color = rl.Color(0, 255, 0, 255) + + return UiElement(value, "DESIRED STEER", self.unit, color) + + +class ActualLateralAccelElement(LateralControlElement): + def __init__(self): + self.unit = "m/s^2" + + def update(self, sm, is_metric: bool) -> UiElement: + controls_state = sm['controlsState'] + curvature = controls_state.curvature + v_ego = sm['carState'].vEgo + roll = sm['liveParameters'].roll if sm.valid['liveParameters'] else 0.0 + lat_active = sm['carControl'].latActive + steer_override = sm['carState'].steeringPressed + + actual_lat_accel = (curvature * v_ego ** 2) - (roll * 9.81) + value = f"{actual_lat_accel:.2f}" + color = self.get_lat_color(lat_active, steer_override) + + return UiElement(value, "ACTUAL L.A.", self.unit, color) + + +class DesiredLateralAccelElement(LateralControlElement): + def __init__(self): + self.unit = "m/s^2" + + def update(self, sm, is_metric: bool) -> UiElement: + controls_state = sm['controlsState'] + desired_curvature = controls_state.desiredCurvature + v_ego = sm['carState'].vEgo + roll = sm['liveParameters'].roll if sm.valid['liveParameters'] else 0.0 + lat_active = sm['carControl'].latActive + steer_override = sm['carState'].steeringPressed + + desired_lat_accel = (desired_curvature * v_ego ** 2) - (roll * 9.81) + value = f"{desired_lat_accel:.2f}" if lat_active else "-" + color = self.get_lat_color(lat_active, steer_override) + + return UiElement(value, "DESIRED L.A.", self.unit, color) + + +class DesiredSteeringPIDElement(LateralControlElement): + def __init__(self): + self.unit = "" + + def update(self, sm, is_metric: bool) -> UiElement: + car_state = sm['carState'] + controls_state = sm['controlsState'] + lat_active = sm['carControl'].latActive + angle_steers = car_state.steeringAngleDeg + steer_angle_desired = controls_state.lateralControlState.pidState.steeringAngleDesiredDeg + + value = f"{steer_angle_desired:.1f}°" if lat_active else "-" + + color = rl.WHITE + if lat_active: + if abs(angle_steers) > 180: + color = rl.RED + elif abs(angle_steers) > 90: + color = rl.Color(255, 188, 0, 255) + else: + color = rl.Color(0, 255, 0, 255) + + return UiElement(value, "DESIRED STEER", self.unit, color) + + +class AEgoElement: + def __init__(self): + self.unit = "m/s^2" + + def update(self, sm, is_metric: bool) -> UiElement: + a_ego = sm['carState'].aEgo + value = f"{a_ego:.1f}" + return UiElement(value, "ACC.", self.unit, rl.WHITE) + + +class LeadSpeedElement(LeadInfoElement): + def __init__(self): + self.unit = "km/h" + + def update(self, sm, is_metric: bool) -> UiElement: + lead_status, _, lead_v_rel = self.get_lead_status(sm) + v_ego = sm['carState'].vEgo + + self.unit = "km/h" if is_metric else "mph" + + conversion = CV.MS_TO_KPH if is_metric else CV.MS_TO_MPH + value = f"{(lead_v_rel + v_ego) * conversion:.0f}" if lead_status else "-" + color = self.get_lead_color(0, lead_v_rel, use_v_rel=True) if lead_status else rl.WHITE + + return UiElement(value, "L.S.", self.unit, color) + + +class FrictionCoefficientElement: + def __init__(self): + self.unit = "" + + def update(self, sm, is_metric: bool) -> UiElement: + ltp = sm['liveTorqueParameters'] + friction_coef = ltp.frictionCoefficientFiltered + live_valid = ltp.liveValid + + value = f"{friction_coef:.3f}" + color = rl.Color(0, 255, 0, 255) if live_valid else rl.WHITE + return UiElement(value, "FRIC.", self.unit, color) + + +class LatAccelFactorElement: + def __init__(self): + self.unit = "" + + def update(self, sm, is_metric: bool) -> UiElement: + ltp = sm['liveTorqueParameters'] + lat_accel_factor = ltp.latAccelFactorFiltered + live_valid = ltp.liveValid + + value = f"{lat_accel_factor:.3f}" + color = rl.Color(0, 255, 0, 255) if live_valid else rl.WHITE + return UiElement(value, "L.A.F.", self.unit, color) + + +class SteeringTorqueEpsElement: + def __init__(self): + self.unit = "N·dm" + + def update(self, sm, is_metric: bool) -> UiElement: + steering_torque_eps = sm['carState'].steeringTorqueEps + value = f"{abs(steering_torque_eps):.1f}" + return UiElement(value, "E.T.", self.unit, rl.WHITE) + + +class GpsInfoElement: + @staticmethod + def get_gps_data(sm): + if sm.valid['gpsLocationExternal']: + return sm['gpsLocationExternal'], True + elif sm.valid['gpsLocation']: + return sm['gpsLocation'], True + return None, False + + +class BearingDegElement(GpsInfoElement): + def __init__(self): + self.unit = "" + + def update(self, sm, is_metric: bool) -> UiElement: + gps_data, valid = self.get_gps_data(sm) + if not valid: + return UiElement("OFF | -", "B.D.", self.unit, rl.WHITE) + + bearing_accuracy_deg = gps_data.bearingAccuracyDeg + bearing_deg = gps_data.bearingDeg + + if bearing_accuracy_deg != 180.0: + value = f"{bearing_deg:.0f}°" + if (337.5 <= bearing_deg <= 360) or (0 <= bearing_deg <= 22.5): + dir_value = "N" + elif 22.5 < bearing_deg < 67.5: + dir_value = "NE" + elif 67.5 <= bearing_deg <= 112.5: + dir_value = "E" + elif 112.5 < bearing_deg < 157.5: + dir_value = "SE" + elif 157.5 <= bearing_deg <= 202.5: + dir_value = "S" + elif 202.5 < bearing_deg < 247.5: + dir_value = "SW" + elif 247.5 <= bearing_deg <= 292.5: + dir_value = "W" + else: # 292.5 < bearing_deg < 337.5 + dir_value = "NW" + else: + value = "-" + dir_value = "OFF" + + return UiElement(f"{dir_value} | {value}", "B.D.", self.unit, rl.WHITE) + + +class AltitudeElement(GpsInfoElement): + def __init__(self): + self.unit = "m" + + def update(self, sm, is_metric: bool) -> UiElement: + gps_data, valid = self.get_gps_data(sm) + + gps_accuracy = 0.0 + altitude = 0.0 + + if valid: + altitude = gps_data.altitude + if sm.valid['gpsLocationExternal']: + gps_accuracy = gps_data.horizontalAccuracy + else: + gps_accuracy = 1.0 # Simulate valid for legacy check + + value = f"{altitude:.1f}" if gps_accuracy != 0.0 else "-" + return UiElement(value, "ALT.", self.unit, rl.WHITE) diff --git a/selfdrive/ui/sunnypilot/onroad/driver_state.py b/selfdrive/ui/sunnypilot/onroad/driver_state.py new file mode 100644 index 0000000000..d3239b9e3d --- /dev/null +++ b/selfdrive/ui/sunnypilot/onroad/driver_state.py @@ -0,0 +1,48 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import numpy as np + +from openpilot.selfdrive.ui import UI_BORDER_SIZE +from openpilot.selfdrive.ui.onroad.driver_state import DriverStateRenderer, BTN_SIZE, ARC_LENGTH +from openpilot.selfdrive.ui.sunnypilot.onroad.developer_ui import get_bottom_dev_ui_offset + + +class DriverStateRendererSP(DriverStateRenderer): + def __init__(self): + super().__init__() + + def _pre_calculate_drawing_elements(self): + """Pre-calculate all drawing elements based on the current rectangle""" + # Calculate icon position (bottom-left or bottom-right) + width, height = self._rect.width, self._rect.height + offset = UI_BORDER_SIZE + BTN_SIZE // 2 + self.position_x = self._rect.x + (width - offset if self.is_rhd else offset) + self.position_y = self._rect.y + height - offset - get_bottom_dev_ui_offset() + + # Pre-calculate the face lines positions + positioned_keypoints = self.face_keypoints_transformed + np.array([self.position_x, self.position_y]) + for i in range(len(positioned_keypoints)): + self.face_lines[i].x = positioned_keypoints[i][0] + self.face_lines[i].y = positioned_keypoints[i][1] + + # Calculate arc dimensions based on head rotation + delta_x = -self.driver_pose_sins[1] * ARC_LENGTH / 2.0 # Horizontal movement + delta_y = -self.driver_pose_sins[0] * ARC_LENGTH / 2.0 # Vertical movement + + # Horizontal arc + h_width = abs(delta_x) + self.h_arc_data = self._calculate_arc_data( + delta_x, h_width, self.position_x, self.position_y - ARC_LENGTH / 2, + self.driver_pose_sins[1], self.driver_pose_diff[1], is_horizontal=True + ) + + # Vertical arc + v_height = abs(delta_y) + self.v_arc_data = self._calculate_arc_data( + delta_y, v_height, self.position_x - ARC_LENGTH / 2, self.position_y, + self.driver_pose_sins[0], self.driver_pose_diff[0], is_horizontal=False + ) diff --git a/selfdrive/ui/sunnypilot/onroad/hud_renderer.py b/selfdrive/ui/sunnypilot/onroad/hud_renderer.py new file mode 100644 index 0000000000..f8e4257733 --- /dev/null +++ b/selfdrive/ui/sunnypilot/onroad/hud_renderer.py @@ -0,0 +1,146 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import pyray as rl + +from openpilot.common.constants import CV +from openpilot.selfdrive.ui.mici.onroad.torque_bar import TorqueBar +from openpilot.selfdrive.ui.sunnypilot.onroad.developer_ui import DeveloperUiRenderer, DeveloperUiState, get_bottom_dev_ui_offset +from openpilot.selfdrive.ui.sunnypilot.onroad.road_name import RoadNameRenderer +from openpilot.selfdrive.ui.sunnypilot.onroad.rocket_fuel import RocketFuel +from openpilot.selfdrive.ui.sunnypilot.onroad.speed_limit import SpeedLimitRenderer +from openpilot.selfdrive.ui.sunnypilot.onroad.smart_cruise_control import SmartCruiseControlRenderer +from openpilot.selfdrive.ui.sunnypilot.onroad.turn_signal import TurnSignalController +from openpilot.selfdrive.ui.sunnypilot.onroad.circular_alerts import CircularAlertsRenderer +from openpilot.selfdrive.ui.sunnypilot.onroad.speed_renderer import SpeedRenderer +from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus +from openpilot.selfdrive.ui.onroad.hud_renderer import HudRenderer, UI_CONFIG, FONT_SIZES, COLORS, CRUISE_DISABLED_CHAR +from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.lib.text_measure import measure_text_cached + +SLA_ACTIVE_COLOR = rl.Color(0x91, 0x9b, 0x95, 0xff) + + +class HudRendererSP(HudRenderer): + def __init__(self): + super().__init__() + self.developer_ui = DeveloperUiRenderer() + self.road_name_renderer = RoadNameRenderer() + self.rocket_fuel = RocketFuel() + self.speed_limit_renderer = SpeedLimitRenderer() + self.smart_cruise_control_renderer = SmartCruiseControlRenderer() + self.turn_signal_controller = TurnSignalController() + self.circular_alerts_renderer = CircularAlertsRenderer() + self.speed_renderer = SpeedRenderer() + self._torque_bar = TorqueBar(scale=3.0, always=True) + + self.pcm_cruise_speed: bool = True + self.show_icbm_status: bool = False + self.icbm_active_counter: int = 0 + self.speed_cluster: float = 0.0 + self.speed_conv: float = CV.MS_TO_KPH if ui_state.is_metric else CV.MS_TO_MPH + + def _update_state(self) -> None: + if ui_state.sm.recv_frame["carState"] < ui_state.started_frame: + return + + if ui_state.CP_SP is not None: + self.pcm_cruise_speed = ui_state.CP_SP.pcmCruiseSpeed + self.speed_conv = CV.MS_TO_KPH if ui_state.is_metric else CV.MS_TO_MPH + self.speed_cluster = ui_state.sm['carState'].cruiseState.speedCluster * self.speed_conv + + super()._update_state() + self.road_name_renderer.update() + self.speed_limit_renderer.update() + self.smart_cruise_control_renderer.update() + self.turn_signal_controller.update() + self.circular_alerts_renderer.update() + self.speed_renderer.update() + + def _get_icbm_status(self): + if not self.pcm_cruise_speed and ui_state.sm['carControl'].enabled: + if round(self.set_speed) != round(self.speed_cluster): + self.icbm_active_counter = 3 * gui_app.target_fps # 3 seconds usually + elif self.icbm_active_counter > 0: + self.icbm_active_counter -= 1 + else: + self.icbm_active_counter = 0 + + self.show_icbm_status = self.icbm_active_counter > 0 + + def _draw_set_speed(self, rect: rl.Rectangle) -> None: + long_plan_sp = ui_state.sm['longitudinalPlanSP'] + long_override = ui_state.sm['carControl'].cruiseControl.override + self._get_icbm_status() + + set_speed_width = UI_CONFIG.set_speed_width_metric if ui_state.is_metric else UI_CONFIG.set_speed_width_imperial + x = rect.x + 60 + (UI_CONFIG.set_speed_width_imperial - set_speed_width) // 2 + y = rect.y + 45 + + set_speed_rect = rl.Rectangle(x, y, set_speed_width, UI_CONFIG.set_speed_height) + rl.draw_rectangle_rounded(set_speed_rect, 0.35, 10, COLORS.BLACK_TRANSLUCENT) + rl.draw_rectangle_rounded_lines_ex(set_speed_rect, 0.35, 10, 6, COLORS.BORDER_TRANSLUCENT) + + max_color = COLORS.GREY + set_speed_color = COLORS.DARK_GREY + if self.is_cruise_set: + set_speed_color = COLORS.WHITE + if long_plan_sp.speedLimit.assist.active: + set_speed_color = SLA_ACTIVE_COLOR if long_override else rl.Color(0, 0xff, 0, 0xff) + max_color = SLA_ACTIVE_COLOR if long_override else rl.Color(0x80, 0xd8, 0xa6, 0xff) + else: + if ui_state.status == UIStatus.ENGAGED: + max_color = COLORS.ENGAGED + elif ui_state.status == UIStatus.DISENGAGED: + max_color = COLORS.DISENGAGED + elif ui_state.status == UIStatus.OVERRIDE: + max_color = COLORS.OVERRIDE + + max_str_size = 60 if self.show_icbm_status else 40 + max_str_y = 15 if self.show_icbm_status else 27 + + max_text = str(round(self.speed_cluster)) if self.show_icbm_status else tr("MAX") + max_text_width = measure_text_cached(self._font_semi_bold, max_text, max_str_size).x + rl.draw_text_ex( + self._font_semi_bold, + max_text, + rl.Vector2(x + (set_speed_width - max_text_width) / 2, y + max_str_y), + max_str_size, + 0, + max_color, + ) + + set_speed_text = CRUISE_DISABLED_CHAR if not self.is_cruise_set else str(round(self.set_speed)) + speed_text_width = measure_text_cached(self._font_bold, set_speed_text, FONT_SIZES.set_speed).x + rl.draw_text_ex( + self._font_bold, + set_speed_text, + rl.Vector2(x + (set_speed_width - speed_text_width) / 2, y + 77), + FONT_SIZES.set_speed, + 0, + set_speed_color, + ) + + def _draw_current_speed(self, rect: rl.Rectangle) -> None: + self.speed_renderer.render(rect) + + def _render(self, rect: rl.Rectangle) -> None: + super()._render(rect) + + if ui_state.torque_bar: + torque_rect = rect + if ui_state.developer_ui in (DeveloperUiState.BOTTOM, DeveloperUiState.BOTH): + torque_rect = rl.Rectangle(rect.x, rect.y, rect.width, rect.height - get_bottom_dev_ui_offset()) + self._torque_bar.render(torque_rect) + + self.developer_ui.render(rect) + self.road_name_renderer.render(rect) + self.speed_limit_renderer.render(rect) + self.smart_cruise_control_renderer.render(rect) + self.turn_signal_controller.render(rect) + self.circular_alerts_renderer.render(rect) + self.rocket_fuel.render(rect, ui_state.sm) diff --git a/selfdrive/ui/sunnypilot/onroad/model_renderer.py b/selfdrive/ui/sunnypilot/onroad/model_renderer.py new file mode 100644 index 0000000000..5d78997662 --- /dev/null +++ b/selfdrive/ui/sunnypilot/onroad/model_renderer.py @@ -0,0 +1,14 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from openpilot.selfdrive.ui.sunnypilot.onroad.chevron_metrics import ChevronMetrics +from openpilot.selfdrive.ui.sunnypilot.onroad.rainbow_path import RainbowPath + + +class ModelRendererSP: + def __init__(self): + self.rainbow_path = RainbowPath() + self.chevron_metrics = ChevronMetrics() diff --git a/selfdrive/ui/sunnypilot/onroad/rainbow_path.py b/selfdrive/ui/sunnypilot/onroad/rainbow_path.py new file mode 100644 index 0000000000..de383a659d --- /dev/null +++ b/selfdrive/ui/sunnypilot/onroad/rainbow_path.py @@ -0,0 +1,79 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import time +import colorsys +import pyray as rl +from openpilot.system.ui.lib.shader_polygon import draw_polygon, Gradient + + +class RainbowPath: + DEFAULT_NUM_SEGMENTS = 8 + DEFAULT_SPEED = 50.0 # degrees per second + DEFAULT_SATURATION = 0.9 + DEFAULT_LIGHTNESS = 0.6 + BASE_ALPHA = 0.8 + ALPHA_FADE = 0.3 # Alpha reduction from bottom to top + + def __init__(self, num_segments: int = DEFAULT_NUM_SEGMENTS, speed: float = DEFAULT_SPEED, + saturation: float = DEFAULT_SATURATION, lightness: float = DEFAULT_LIGHTNESS): + self.num_segments = num_segments + self.speed = speed + self.saturation = saturation + self.lightness = lightness + + def set_speed(self, speed: float): + self.speed = speed + + def set_num_segments(self, num_segments: int): + self.num_segments = num_segments + + def set_saturation(self, saturation: float): + self.saturation = max(0.0, min(1.0, saturation)) + + def set_lightness(self, lightness: float): + self.lightness = max(0.0, min(1.0, lightness)) + + def get_gradient(self) -> Gradient: + time_offset = time.monotonic() + hue_offset = (time_offset * self.speed) % 360.0 + + segment_colors = [] + gradient_stops = [] + + for i in range(self.num_segments): + position = i / (self.num_segments - 1) + hue = (hue_offset + position * 360.0) % 360.0 + alpha = self.BASE_ALPHA * (1.0 - position * self.ALPHA_FADE) + color = self._hsla_to_color( + hue / 360.0, + self.saturation, + self.lightness, + alpha + ) + gradient_stops.append(position) + segment_colors.append(color) + + return Gradient( + start=(0.0, 1.0), # Bottom of path + end=(0.0, 0.0), # Top of path + colors=segment_colors, + stops=gradient_stops, + ) + + @staticmethod + def _hsla_to_color(h: float, s: float, l: float, a: float) -> rl.Color: + rgb = colorsys.hls_to_rgb(h, l, s) + return rl.Color( + int(rgb[0] * 255), + int(rgb[1] * 255), + int(rgb[2] * 255), + int(a * 255) + ) + + def draw_rainbow_path(self, rect, path): + gradient = self.get_gradient() + draw_polygon(rect, path.projected_points, gradient=gradient) diff --git a/selfdrive/ui/sunnypilot/onroad/road_name.py b/selfdrive/ui/sunnypilot/onroad/road_name.py new file mode 100644 index 0000000000..f85285ef53 --- /dev/null +++ b/selfdrive/ui/sunnypilot/onroad/road_name.py @@ -0,0 +1,56 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import pyray as rl + +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.text_measure import measure_text_cached +from openpilot.system.ui.widgets import Widget + + +class RoadNameRenderer(Widget): + def __init__(self): + super().__init__() + self.road_name = "" + self.is_metric = False + self.font_demi = gui_app.font(FontWeight.SEMI_BOLD) + + def update(self): + sm = ui_state.sm + if sm.recv_frame["carState"] < ui_state.started_frame: + return + + self.is_metric = ui_state.is_metric + + if sm.updated["liveMapDataSP"]: + lmd = sm["liveMapDataSP"] + self.road_name = lmd.roadName + + def _render(self, rect: rl.Rectangle): + if not self.road_name or not ui_state.road_name_toggle: + return + + text = self.road_name + text_size = measure_text_cached(self.font_demi, text, 46) + + padding = 40 + rect_width = max(200, min(text_size.x + padding, rect.width - 40)) + + road_rect = rl.Rectangle(rect.x + rect.width / 2 - rect_width / 2, rect.y - 4, rect_width, 60) + + rl.draw_rectangle_rounded(road_rect, 0.2, 10, rl.Color(0, 0, 0, 120)) + + max_text_width = road_rect.width - 20 + if text_size.x > max_text_width: + while text_size.x > max_text_width and len(text) > 3: + text = text[:-1] + text_size = measure_text_cached(self.font_demi, text + "...", 46) + text = text + "..." + + sz = measure_text_cached(self.font_demi, text, 46) + origin = rl.Vector2(road_rect.x + road_rect.width / 2 - sz.x / 2, road_rect.y + road_rect.height / 2 - sz.y / 2) + rl.draw_text_ex(self.font_demi, text, origin, 46, 0, rl.Color(255, 255, 255, 200)) diff --git a/selfdrive/ui/sunnypilot/onroad/rocket_fuel.py b/selfdrive/ui/sunnypilot/onroad/rocket_fuel.py new file mode 100644 index 0000000000..cb1012890e --- /dev/null +++ b/selfdrive/ui/sunnypilot/onroad/rocket_fuel.py @@ -0,0 +1,50 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import pyray as rl + +from openpilot.selfdrive.ui.ui_state import ui_state + + +class RocketFuel: + def __init__(self): + self.vc_accel = 0.0 + + def render(self, rect: rl.Rectangle, sm) -> None: + if not ui_state.rocket_fuel: + return + + vc_accel0 = sm['carState'].aEgo + + # Smooth the acceleration + self.vc_accel = self.vc_accel + (vc_accel0 - self.vc_accel) / 5.0 + + hha = 0.0 + color = rl.Color(0, 0, 0, 0) # Transparent by default + + if self.vc_accel > 0: + hha = 0.85 - 0.1 / self.vc_accel # only extend up to 85% + color = rl.Color(0, 245, 0, 200) + elif self.vc_accel < 0: + hha = 0.85 + 0.1 / self.vc_accel # only extend up to 85% + color = rl.Color(245, 0, 0, 200) + + if hha < 0: + hha = 0.0 + + hha = hha * rect.height + wp = 28.0 + + # Draw + rect_h = rect.height + + if self.vc_accel > 0: + ra_y = rect_h / 2.0 - hha / 2.0 + else: + ra_y = rect_h / 2.0 + + if hha > 0: + rl.draw_rectangle(int(rect.x), int(rect.y + ra_y), int(wp), int(hha / 2.0), color) diff --git a/selfdrive/ui/sunnypilot/onroad/smart_cruise_control.py b/selfdrive/ui/sunnypilot/onroad/smart_cruise_control.py new file mode 100644 index 0000000000..c89bd914be --- /dev/null +++ b/selfdrive/ui/sunnypilot/onroad/smart_cruise_control.py @@ -0,0 +1,105 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import pyray as rl + +from openpilot.selfdrive.ui.onroad.hud_renderer import COLORS +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.text_measure import measure_text_cached +from openpilot.system.ui.sunnypilot.lib.utils import AlertFadeAnimator +from openpilot.system.ui.widgets import Widget + + +class SmartCruiseControlRenderer(Widget): + def __init__(self): + super().__init__() + self.vision_enabled = False + self.vision_active = False + self.map_enabled = False + self.map_active = False + self.long_override = False + + self._vision_fade = AlertFadeAnimator(gui_app.target_fps) + self._map_fade = AlertFadeAnimator(gui_app.target_fps) + + self.font = gui_app.font(FontWeight.BOLD) + + def update(self): + sm = ui_state.sm + if sm.updated["longitudinalPlanSP"]: + lp_sp = sm["longitudinalPlanSP"] + vision = lp_sp.smartCruiseControl.vision + map_ = lp_sp.smartCruiseControl.map + + self.vision_enabled = vision.enabled + self.vision_active = vision.active + self.map_enabled = map_.enabled + self.map_active = map_.active + + if sm.updated["carControl"]: + self.long_override = sm["carControl"].cruiseControl.override + + self._vision_fade.update(self.vision_active) + self._map_fade.update(self.map_active) + + def _draw_icon(self, rect_center_x, rect_height, x_offset, y_offset, name, alpha=1.0): + text = name + font_size = 36 + padding_v = 5 + box_width = 160 + + sz = measure_text_cached(self.font, text, font_size) + box_height = int(sz.y + padding_v * 2) + + if self.long_override: + color = COLORS.OVERRIDE + box_color = rl.Color(color.r, color.g, color.b, int(alpha * 255)) + else: + box_color = rl.Color(0, 255, 0, int(alpha * 255)) + + text_color = rl.Color(0, 0, 0, int(alpha * 255)) + + screen_y = rect_height / 4 + y_offset + + box_x = rect_center_x + x_offset - box_width / 2 + box_y = screen_y - box_height / 2 + + # Draw rounded background box + if alpha > 0.01: + rl.draw_rectangle_rounded(rl.Rectangle(box_x, box_y, box_width, box_height), 0.2, 10, box_color) + + # Draw text centered in the box (black color for contrast against bright green/grey) + text_pos_x = box_x + (box_width - sz.x) / 2 + text_pos_y = box_y + (box_height - sz.y) / 2 + + rl.draw_text_ex(self.font, text, rl.Vector2(text_pos_x, text_pos_y), font_size, 0, text_color) + + def _render(self, rect: rl.Rectangle): + x_offset = -260 + y1_offset = -40 + y2_offset = -100 + + orders = [y1_offset, y2_offset] + y_scc_v = 0 + y_scc_m = 0 + idx = 0 + + if self.vision_enabled: + y_scc_v = orders[idx] + idx += 1 + + if self.map_enabled: + y_scc_m = orders[idx] + idx += 1 + + if self.vision_enabled: + alpha = self._vision_fade.alpha if self.vision_active else 1.0 + self._draw_icon(rect.x + rect.width / 2, rect.height, x_offset, y_scc_v, "SCC-V", alpha) + + if self.map_enabled: + alpha = self._map_fade.alpha if self.map_active else 1.0 + self._draw_icon(rect.x + rect.width / 2, rect.height, x_offset, y_scc_m, "SCC-M", alpha) diff --git a/selfdrive/ui/sunnypilot/onroad/speed_limit.py b/selfdrive/ui/sunnypilot/onroad/speed_limit.py new file mode 100644 index 0000000000..98f5b29087 --- /dev/null +++ b/selfdrive/ui/sunnypilot/onroad/speed_limit.py @@ -0,0 +1,328 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" + +from dataclasses import dataclass +from enum import StrEnum +import pyray as rl + +from cereal import custom +from openpilot.common.constants import CV +from openpilot.selfdrive.ui.onroad.hud_renderer import UI_CONFIG +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.common import Mode as SpeedLimitMode +from openpilot.system.hardware import HARDWARE +from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.lib.text_measure import measure_text_cached +from openpilot.system.ui.sunnypilot.lib.utils import AlertFadeAnimator +from openpilot.system.ui.widgets import Widget + +METER_TO_FOOT = 3.28084 +METER_TO_MILE = 0.000621371 +AHEAD_THRESHOLD = 5 +SET_SPEED_NA = 255 +KM_TO_MILE = 0.621371 + +AssistState = custom.LongitudinalPlanSP.SpeedLimit.AssistState +SpeedLimitSource = custom.LongitudinalPlanSP.SpeedLimit.Source + + +@dataclass(frozen=True) +class Colors: + WHITE = rl.WHITE + BLACK = rl.BLACK + RED = rl.Color(235, 32, 32, 255) + GREY = rl.Color(145, 155, 149, 255) + DARK_GREY = rl.Color(77, 77, 77, 255) + SUB_BG = rl.Color(0, 0, 0, 180) + MUTCD_LINES = rl.Color(255, 255, 255, 100) + + +class IconSide(StrEnum): + left = 'left' + right = 'right' + + +class SpeedLimitAlertRenderer: + ARROW_SIZE = 90 if HARDWARE.get_device_type() == 'mici' else 200 + + def __init__(self): + self.arrow_up = gui_app.texture("../../sunnypilot/selfdrive/assets/img_plus_arrow_up.png", self.ARROW_SIZE, self.ARROW_SIZE) + self.arrow_down = gui_app.texture("../../sunnypilot/selfdrive/assets/img_minus_arrow_down.png", self.ARROW_SIZE, self.ARROW_SIZE) + + blank_image = rl.gen_image_color(self.ARROW_SIZE, self.ARROW_SIZE, rl.Color(0, 0, 0, 0)) + self.arrow_blank = rl.load_texture_from_image(blank_image) + rl.unload_image(blank_image) + + self._pre_active_fade = AlertFadeAnimator(gui_app.target_fps, duration_on=0.75, rc=0.05) + + def update(self): + assist_state = ui_state.sm['longitudinalPlanSP'].speedLimit.assist.state + self._pre_active_fade.update(assist_state == AssistState.preActive) + + def speed_limit_pre_active_icon_helper(self): + icon_alpha = max(0.0, min(self._pre_active_fade.alpha * 255.0, 255.0)) + txt_icon = self.arrow_blank + icon_margin_x = 10 + icon_margin_y = 18 + + if icon_alpha > 0: + speed_conv = CV.MS_TO_KPH if ui_state.is_metric else CV.MS_TO_MPH + speed_limit_final_last = ui_state.sm['longitudinalPlanSP'].speedLimit.resolver.speedLimitFinalLast + + v_cruise_cluster = ui_state.sm['carState'].vCruiseCluster + set_speed = ui_state.sm['controlsState'].vCruiseDEPRECATED if v_cruise_cluster == 0.0 else v_cruise_cluster + if not ui_state.is_metric: + set_speed *= KM_TO_MILE + + set_speed_round = round(set_speed) + speed_limit_round = round(speed_limit_final_last * speed_conv) + + if set_speed_round < speed_limit_round: + txt_icon = self.arrow_up + elif set_speed_round > speed_limit_round: + txt_icon = self.arrow_down + + return IconSide.right, txt_icon, icon_alpha, icon_margin_x, icon_margin_y + + +class SpeedLimitRenderer(Widget, SpeedLimitAlertRenderer): + def __init__(self): + Widget.__init__(self) + SpeedLimitAlertRenderer.__init__(self) + + self.speed_limit = 0.0 + self.speed_limit_last = 0.0 + self.speed_limit_offset = 0.0 + self.speed_limit_valid = False + self.speed_limit_last_valid = False + self.speed_limit_final_last = 0.0 + self.speed_limit_source = SpeedLimitSource.none + self.speed_limit_assist_state = AssistState.disabled + + self.speed_limit_ahead = 0.0 + self.speed_limit_ahead_dist = 0.0 + self.speed_limit_ahead_dist_prev = 0.0 + self.speed_limit_ahead_valid = False + self.speed_limit_ahead_frame = 0 + + self.is_cruise_set: bool = False + self.is_cruise_available: bool = True + self.set_speed: float = SET_SPEED_NA + self.speed: float = 0.0 + self.v_ego_cluster_seen: bool = False + + self.font_bold = gui_app.font(FontWeight.BOLD) + self.font_demi = gui_app.font(FontWeight.SEMI_BOLD) + self.font_norm = gui_app.font(FontWeight.NORMAL) + + @property + def speed_conv(self): + return CV.MS_TO_KPH if ui_state.is_metric else CV.MS_TO_MPH + + def update(self): + SpeedLimitAlertRenderer.update(self) + sm = ui_state.sm + if sm.recv_frame["carState"] < ui_state.started_frame: + self.set_speed = SET_SPEED_NA + self.speed = 0.0 + return + + if sm.updated["longitudinalPlanSP"]: + lp_sp = sm["longitudinalPlanSP"] + resolver = lp_sp.speedLimit.resolver + assist = lp_sp.speedLimit.assist + + self.speed_limit = resolver.speedLimit * self.speed_conv + self.speed_limit_last = resolver.speedLimitLast * self.speed_conv + self.speed_limit_offset = resolver.speedLimitOffset * self.speed_conv + self.speed_limit_valid = resolver.speedLimitValid + self.speed_limit_last_valid = resolver.speedLimitLastValid + self.speed_limit_final_last = resolver.speedLimitFinalLast * self.speed_conv + self.speed_limit_source = resolver.source + self.speed_limit_assist_state = assist.state + + if sm.updated["liveMapDataSP"]: + lmd = sm["liveMapDataSP"] + self.speed_limit_ahead_valid = lmd.speedLimitAheadValid + self.speed_limit_ahead = lmd.speedLimitAhead * self.speed_conv + self.speed_limit_ahead_dist = lmd.speedLimitAheadDistance + + if self.speed_limit_ahead_dist < self.speed_limit_ahead_dist_prev and self.speed_limit_ahead_frame < AHEAD_THRESHOLD: + self.speed_limit_ahead_frame += 1 + elif self.speed_limit_ahead_dist > self.speed_limit_ahead_dist_prev and self.speed_limit_ahead_frame > 0: + self.speed_limit_ahead_frame -= 1 + + self.speed_limit_ahead_dist_prev = self.speed_limit_ahead_dist + + controls_state = sm['controlsState'] + car_state = sm["carState"] + + v_cruise_cluster = car_state.vCruiseCluster + self.set_speed = ( + controls_state.vCruiseDEPRECATED if v_cruise_cluster == 0.0 else v_cruise_cluster + ) + self.is_cruise_set = 0 < self.set_speed < SET_SPEED_NA + self.is_cruise_available = self.set_speed != -1 + + if self.is_cruise_set and not ui_state.is_metric: + self.set_speed *= KM_TO_MILE + + self.v_ego_cluster_seen = self.v_ego_cluster_seen or car_state.vEgoCluster != 0.0 + v_ego = car_state.vEgoCluster if self.v_ego_cluster_seen else car_state.vEgo + self.speed = max(0.0, v_ego * self.speed_conv) + + @staticmethod + def _draw_text_centered(font, text, size, pos_center, color): + sz = measure_text_cached(font, text, size) + rl.draw_text_ex(font, text, rl.Vector2(pos_center.x - sz.x / 2, pos_center.y - sz.y / 2), size, 0, color) + + def _render(self, rect: rl.Rectangle): + width = UI_CONFIG.set_speed_width_metric if ui_state.is_metric else UI_CONFIG.set_speed_width_imperial + x = rect.x + 60 + width + 30 - 6 + y = rect.y + 45 - 6 + + sign_rect = rl.Rectangle(x, y, width, UI_CONFIG.set_speed_height + 6 * 2) + + alpha = self._pre_active_fade.alpha + + if ui_state.speed_limit_mode != SpeedLimitMode.off: + self._draw_sign_main(sign_rect, alpha) + if self.speed_limit_assist_state == AssistState.preActive: + self._draw_pre_active_arrow(sign_rect) + else: + self._draw_ahead_info(sign_rect) + + def _draw_sign_main(self, rect, alpha=1.0): + speed_limit_warning_enabled = ui_state.speed_limit_mode >= SpeedLimitMode.warning + has_limit = self.speed_limit_valid or self.speed_limit_last_valid + is_overspeed = has_limit and round(self.speed_limit_final_last) < round(self.speed) + + limit_str = str(round(self.speed_limit_last)) if has_limit else "---" + sub_text = "" + if self.speed_limit_offset != 0: + sign = "" if self.speed_limit_offset > 0 else "-" + sub_text = f"{sign}{round(abs(self.speed_limit_offset))}" + + txt_color = Colors.BLACK + if speed_limit_warning_enabled and is_overspeed: + txt_color = Colors.RED + elif not self.speed_limit_valid: + txt_color = Colors.GREY + + if ui_state.is_metric: + self._render_vienna(rect, limit_str, sub_text, txt_color, has_limit, alpha) + else: + self._render_mutcd(rect, limit_str, sub_text, txt_color, has_limit, alpha) + + def _draw_pre_active_arrow(self, sign_rect): + _, txt_icon, icon_alpha, _, _ = SpeedLimitAlertRenderer.speed_limit_pre_active_icon_helper(self) + if icon_alpha > 0 and txt_icon != self.arrow_blank: + sign_margin = 12 + arrow_spacing = int(sign_margin * 1.4) + arrow_x = sign_rect.x + sign_rect.width + arrow_spacing + arrow_y = sign_rect.y + (sign_rect.height - txt_icon.height) / 2 + color = rl.Color(255, 255, 255, int(icon_alpha)) + rl.draw_texture_ex(txt_icon, rl.Vector2(arrow_x, arrow_y), 0.0, 1.0, color) + + def _render_vienna(self, rect, val, sub, color, has_limit, alpha=1.0): + center = rl.Vector2(rect.x + rect.width / 2, rect.y + rect.height / 2) + radius = (rect.width + 18) / 2 + + white = rl.color_alpha(Colors.WHITE, alpha) + red = rl.color_alpha(Colors.RED, alpha) + black = rl.color_alpha(Colors.BLACK, alpha) + dark_grey = rl.color_alpha(Colors.DARK_GREY, alpha) + text_color = rl.color_alpha(color, alpha) + + rl.draw_circle_v(center, radius, white) + rl.draw_ring(center, radius * 0.75, radius, 0, 360, 36, red) + + font_size = 70 if len(val) >= 3 else 85 + self._draw_text_centered(self.font_bold, val, font_size, center, text_color) + + if sub and has_limit: + s_radius = radius * 0.4 + s_center = rl.Vector2(rect.x + rect.width - s_radius / 2, rect.y + s_radius / 2) + + rl.draw_circle_v(s_center, s_radius, black) + rl.draw_ring(s_center, s_radius - 3, s_radius, 0, 360, 36, dark_grey) + + font_scale = 0.5 if len(sub) < 3 else 0.45 + self._draw_text_centered(self.font_bold, sub, int(s_radius * 2 * font_scale), s_center, white) + + def _render_mutcd(self, rect, val, sub, color, has_limit, alpha=1.0): + white = rl.color_alpha(Colors.WHITE, alpha) + black = rl.color_alpha(Colors.BLACK, alpha) + dark_grey = rl.color_alpha(Colors.DARK_GREY, alpha) + text_color = rl.color_alpha(color, alpha) + + rl.draw_rectangle_rounded(rect, 0.35, 10, white) + + inner = rl.Rectangle(rect.x + 10, rect.y + 10, rect.width - 20, rect.height - 20) + outer_radius = 0.35 * rect.width / 2.0 + inner_radius = outer_radius - 10.0 + inner_roundness = inner_radius / (inner.width / 2.0) + + rl.draw_rectangle_rounded_lines_ex(inner, inner_roundness, 10, 4, black) + + self._draw_text_centered(self.font_demi, "SPEED", 40, rl.Vector2(rect.x + rect.width / 2, rect.y + 40), black) + self._draw_text_centered(self.font_demi, "LIMIT", 40, rl.Vector2(rect.x + rect.width / 2, rect.y + 80), black) + self._draw_text_centered(self.font_bold, val, 90, rl.Vector2(rect.x + rect.width / 2, rect.y + 150), text_color) + + if sub and has_limit: + box_sz = rect.width * 0.3 + overlap = box_sz * 0.2 + s_rect = rl.Rectangle(rect.x + rect.width - box_sz / 1.5 + overlap, rect.y - box_sz / 1.25 + overlap, box_sz, box_sz) + + rl.draw_rectangle_rounded(s_rect, 0.35, 10, black) + rl.draw_rectangle_rounded_lines_ex(s_rect, 0.35, 10, 6, dark_grey) + + f_scale = 0.6 if len(sub) < 3 else 0.475 + self._draw_text_centered(self.font_bold, sub, int(box_sz * f_scale), rl.Vector2(s_rect.x + box_sz / 2, s_rect.y + box_sz / 2), white) + + def _draw_ahead_info(self, sign_rect): + source_is_map = self.speed_limit_source == SpeedLimitSource.map + valid = self.speed_limit_ahead_valid and self.speed_limit_ahead > 0 and self.speed_limit_ahead != self.speed_limit + + if not (valid and source_is_map): + return + + rect = rl.Rectangle(sign_rect.x + (sign_rect.width - 170) / 2, sign_rect.y + sign_rect.height + 10, 170, 160) + rl.draw_rectangle_rounded(rect, 0.35, 10, Colors.SUB_BG) + rl.draw_rectangle_rounded_lines_ex(rect, 0.35, 10, 3, Colors.MUTCD_LINES) + + mid_x = rect.x + rect.width / 2 + self._draw_text_centered(self.font_demi, "AHEAD", 40, rl.Vector2(mid_x, rect.y + 28), Colors.GREY) + self._draw_text_centered(self.font_bold, str(round(self.speed_limit_ahead)), 70, rl.Vector2(mid_x, rect.y + 82), Colors.WHITE) + self._draw_text_centered(self.font_norm, self._format_dist(self.speed_limit_ahead_dist), 36, rl.Vector2(mid_x, rect.y + 134), Colors.GREY) + + @staticmethod + def _format_dist(d): + # metric + if ui_state.is_metric: + if d < 50: + return tr("Near") + + if d >= 1000: + return f"{d / 1000:.1f} km" + + d_rounded = round(d, -1) if d < 200 else round(d, -2) + return f"{int(d_rounded)} m" + + # imperial + d_ft = d * METER_TO_FOOT + if d_ft < 100: + return tr("Near") + + if d_ft >= 900: + return f"{d * METER_TO_MILE:.1f} mi" + + if d_ft < 500: + return f"{int(round(d_ft / 50) * 50)} ft" + + return f"{int(round(d_ft / 100) * 100)} ft" diff --git a/selfdrive/ui/sunnypilot/onroad/speed_renderer.py b/selfdrive/ui/sunnypilot/onroad/speed_renderer.py new file mode 100644 index 0000000000..0a017876e1 --- /dev/null +++ b/selfdrive/ui/sunnypilot/onroad/speed_renderer.py @@ -0,0 +1,46 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import pyray as rl + +from openpilot.common.constants import CV +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.lib.text_measure import measure_text_cached +from openpilot.selfdrive.ui.onroad.hud_renderer import FONT_SIZES, COLORS + + +class SpeedRenderer: + def __init__(self): + self.speed: float = 0.0 + self.v_ego_cluster_seen: bool = False + + self._font_bold: rl.Font = gui_app.font(FontWeight.BOLD) + self._font_medium: rl.Font = gui_app.font(FontWeight.MEDIUM) + + def update(self) -> None: + car_state = ui_state.sm['carState'] + v_ego_cluster = car_state.vEgoCluster + self.v_ego_cluster_seen = self.v_ego_cluster_seen or v_ego_cluster != 0.0 + v_ego = v_ego_cluster if self.v_ego_cluster_seen and not ui_state.true_v_ego_ui else car_state.vEgo + speed_conversion = CV.MS_TO_KPH if ui_state.is_metric else CV.MS_TO_MPH + self.speed = max(0.0, v_ego * speed_conversion) + + def render(self, rect: rl.Rectangle) -> None: + if ui_state.hide_v_ego_ui: + return + + # Draw current speed and unit + speed_text = str(round(self.speed)) + speed_text_size = measure_text_cached(self._font_bold, speed_text, FONT_SIZES.current_speed) + speed_pos = rl.Vector2(rect.x + rect.width / 2 - speed_text_size.x / 2, 180 - speed_text_size.y / 2) + rl.draw_text_ex(self._font_bold, speed_text, speed_pos, FONT_SIZES.current_speed, 0, COLORS.WHITE) + + unit_text = tr("km/h") if ui_state.is_metric else tr("mph") + unit_text_size = measure_text_cached(self._font_medium, unit_text, FONT_SIZES.speed_unit) + unit_pos = rl.Vector2(rect.x + rect.width / 2 - unit_text_size.x / 2, 290 - unit_text_size.y / 2) + rl.draw_text_ex(self._font_medium, unit_text, unit_pos, FONT_SIZES.speed_unit, 0, COLORS.WHITE_TRANSLUCENT) diff --git a/selfdrive/ui/sunnypilot/onroad/turn_signal.py b/selfdrive/ui/sunnypilot/onroad/turn_signal.py new file mode 100644 index 0000000000..fc6f7eb915 --- /dev/null +++ b/selfdrive/ui/sunnypilot/onroad/turn_signal.py @@ -0,0 +1,119 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import pyray as rl +import time +from dataclasses import dataclass + +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.selfdrive.ui.mici.onroad.alert_renderer import IconSide, TURN_SIGNAL_BLINK_PERIOD +from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.widgets import Widget +from openpilot.common.filter_simple import FirstOrderFilter + + +@dataclass(frozen=True) +class TurnSignalConfig: + left_x: int = 80 + left_y: int = 190 + right_x: int = 80 + right_y: int = 190 + size: int = 150 + + +class TurnSignalWidget(Widget): + def __init__(self, direction: IconSide): + super().__init__() + self._direction = direction + self._active = False + self._type = 'signal' + + self._turn_signal_timer = 0.0 + self._turn_signal_alpha_filter = FirstOrderFilter(0.0, 0.3, 1 / gui_app.target_fps) + + self._signal_texture = gui_app.texture('icons_mici/onroad/turn_signal_left.png', 120, 109, flip_x=(direction == IconSide.right)) + self._blind_spot_texture = gui_app.texture('icons_mici/onroad/blind_spot_left.png', 120, 109, flip_x=(direction == IconSide.right)) + self._texture = self._signal_texture + + def _render(self, _): + if not self._active: + return + + if self._type == 'signal': + if time.monotonic() - self._turn_signal_timer > TURN_SIGNAL_BLINK_PERIOD: + self._turn_signal_timer = time.monotonic() + self._turn_signal_alpha_filter.x = 255 * 2 + else: + self._turn_signal_alpha_filter.update(255 * 0.2) + icon_alpha = int(min(self._turn_signal_alpha_filter.x, 255)) + else: + icon_alpha = 255 + + self._texture = self._blind_spot_texture if self._type == 'blind_spot' else self._signal_texture + + if self._texture: + pos_x = self._rect.x + (self._rect.width - self._texture.width) / 2 + pos_y = self._rect.y + (self._rect.height - self._texture.height) / 2 + color = rl.Color(255, 255, 255, icon_alpha) + rl.draw_texture_ex(self._texture, rl.Vector2(pos_x, pos_y), 0.0, 1.0, color) + + def activate(self, _type: str = 'signal'): + if not self._active or self._type != _type: + self._turn_signal_timer = 0.0 + self._active = True + self._type = _type + + def deactivate(self): + self._active = False + self._turn_signal_timer = 0.0 + + +class TurnSignalController: + def __init__(self): + self._config = TurnSignalConfig() + self._left_signal = TurnSignalWidget(direction=IconSide.left) + self._right_signal = TurnSignalWidget(direction=IconSide.right) + + @staticmethod + def _update_signal(signal, blindspot, blinker): + if ui_state.blindspot and blindspot: + signal.activate('blind_spot') + elif ui_state.turn_signals and blinker: + signal.activate('signal') + else: + signal.deactivate() + + def update(self): + CS = ui_state.sm['carState'] + + self._update_signal(self._left_signal, CS.leftBlindspot, CS.leftBlinker) + self._update_signal(self._right_signal, CS.rightBlindspot, CS.rightBlinker) + + def render(self, rect: rl.Rectangle): + if not ui_state.turn_signals and not ui_state.blindspot: + return + + x = rect.x + rect.width / 2 + + left_x = x - self._config.left_x - self._config.size + left_y = rect.y + self._config.left_y + + right_x = x + self._config.right_x + right_y = rect.y + self._config.right_y + + if self._left_signal._active: + self._left_signal.render(rl.Rectangle(left_x, left_y, self._config.size, self._config.size)) + + if self._right_signal._active: + self._right_signal.render(rl.Rectangle(right_x, right_y, self._config.size, self._config.size)) + + @property + def config(self) -> TurnSignalConfig: + return self._config + + @config.setter + def config(self, new_config: TurnSignalConfig): + self._config = new_config diff --git a/selfdrive/ui/sunnypilot/ui_state.py b/selfdrive/ui/sunnypilot/ui_state.py new file mode 100644 index 0000000000..7766c353ad --- /dev/null +++ b/selfdrive/ui/sunnypilot/ui_state.py @@ -0,0 +1,192 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from enum import Enum + +from cereal import messaging, log, custom +from openpilot.common.params import Params +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.display import OnroadBrightness +from openpilot.sunnypilot.sunnylink.sunnylink_state import SunnylinkState +from openpilot.system.ui.lib.application import gui_app + +OpenpilotState = log.SelfdriveState.OpenpilotState +MADSState = custom.ModularAssistiveDrivingSystem.ModularAssistiveDrivingSystemState + +ONROAD_BRIGHTNESS_TIMER_PAUSED = -1 + + +class OnroadTimerStatus(Enum): + NONE = 0 + PAUSE = 1 + RESUME = 2 + + +class UIStateSP: + def __init__(self): + self.CP_SP: custom.CarParamsSP | None = None + self.params = Params() + self.sm_services_ext = [ + "modelManagerSP", "selfdriveStateSP", "longitudinalPlanSP", "backupManagerSP", + "gpsLocation", "liveTorqueParameters", "carStateSP", "liveMapDataSP", "carParamsSP", "liveDelay" + ] + + self.sunnylink_state = SunnylinkState() + self.update_params() + + self.onroad_brightness_timer: int = 0 + self.custom_interactive_timeout: int = self.params.get("InteractivityTimeout", return_default=True) + self.reset_onroad_sleep_timer() + self.CP_SP: custom.CarParamsSP | None = None + self.has_icbm: bool = False + self.is_sp_release: bool = self.params.get_bool("IsReleaseSpBranch") + + def update(self) -> None: + if self.sunnylink_enabled: + self.sunnylink_state.start() + else: + self.sunnylink_state.stop() + + def onroad_brightness_handle_alerts(self, _ui_state, alert): + if _ui_state.sm.recv_frame["carState"] < _ui_state.started_frame: + return + + has_alert = _ui_state.started and self.onroad_brightness != OnroadBrightness.AUTO and alert is not None + + self.update_onroad_brightness(has_alert) + if has_alert: + self.reset_onroad_sleep_timer() + + def update_onroad_brightness(self, has_alert: bool) -> None: + if has_alert: + return + + if self.onroad_brightness_timer > 0: + self.onroad_brightness_timer -= 1 + + def reset_onroad_sleep_timer(self, timer_status: OnroadTimerStatus = OnroadTimerStatus.NONE) -> None: + # Toggling from active state to inactive + if timer_status == OnroadTimerStatus.PAUSE and self.onroad_brightness_timer != ONROAD_BRIGHTNESS_TIMER_PAUSED: + self.onroad_brightness_timer = ONROAD_BRIGHTNESS_TIMER_PAUSED + # Toggling from a previously inactive state or resetting an active timer + elif (self.onroad_brightness_timer_param >= 0 and self.onroad_brightness != OnroadBrightness.AUTO and + self.onroad_brightness_timer != ONROAD_BRIGHTNESS_TIMER_PAUSED) or timer_status == OnroadTimerStatus.RESUME: + if self.onroad_brightness == OnroadBrightness.AUTO_DARK: + self.onroad_brightness_timer = 15 * gui_app.target_fps + else: + self.onroad_brightness_timer = self.onroad_brightness_timer_param * gui_app.target_fps + + @property + def onroad_brightness_timer_expired(self) -> bool: + return self.onroad_brightness != OnroadBrightness.AUTO and self.onroad_brightness_timer == 0 + + @property + def auto_onroad_brightness(self) -> bool: + return self.onroad_brightness in (OnroadBrightness.AUTO, OnroadBrightness.AUTO_DARK) + + @staticmethod + def update_status(ss, ss_sp, onroad_evt) -> str: + state = ss.state + mads = ss_sp.mads + mads_state = mads.state + + if state == OpenpilotState.preEnabled: + return "override" + + if state == OpenpilotState.overriding: + if not mads.available: + return "override" + + if any(e.overrideLongitudinal for e in onroad_evt): + return "override" + + if mads_state in (MADSState.paused, MADSState.overriding): + return "override" + + # MADS specific statuses + if not mads.available: + return "engaged" if ss.enabled else "disengaged" + + if not mads.enabled and not ss.enabled: + return "disengaged" + + if mads.enabled and ss.enabled: + return "engaged" + + if mads.enabled: + return "lat_only" + + if ss.enabled: + return "long_only" + + return "disengaged" + + def update_params(self) -> None: + CP_SP_bytes = self.params.get("CarParamsSPPersistent") + if CP_SP_bytes is not None: + self.CP_SP = messaging.log_from_bytes(CP_SP_bytes, custom.CarParamsSP) + self.has_icbm = self.CP_SP.intelligentCruiseButtonManagementAvailable and self.params.get_bool("IntelligentCruiseButtonManagement") + self.active_bundle = self.params.get("ModelManager_ActiveBundle") + self.blindspot = self.params.get_bool("BlindSpot") + self.chevron_metrics = self.params.get("ChevronInfo") + self.custom_interactive_timeout = self.params.get("InteractivityTimeout", return_default=True) + self.developer_ui = self.params.get("DevUIInfo") + self.hide_v_ego_ui = self.params.get_bool("HideVEgoUI") + self.onroad_brightness = int(float(self.params.get("OnroadScreenOffBrightness", return_default=True))) + self.onroad_brightness_timer_param = self.params.get("OnroadScreenOffTimer", return_default=True) + self.rainbow_path = self.params.get_bool("RainbowMode") + self.road_name_toggle = self.params.get_bool("RoadNameToggle") + self.rocket_fuel = self.params.get_bool("RocketFuel") + self.speed_limit_mode = self.params.get("SpeedLimitMode", return_default=True) + self.standstill_timer = self.params.get_bool("StandstillTimer") + self.sunnylink_enabled = self.params.get_bool("SunnylinkEnabled") + self.torque_bar = self.params.get_bool("TorqueBar") + self.true_v_ego_ui = self.params.get_bool("TrueVEgoUI") + self.turn_signals = self.params.get_bool("ShowTurnSignals") + self.boot_offroad_mode = self.params.get("DeviceBootMode", return_default=True) + + +class DeviceSP: + @staticmethod + def _set_awake(on: bool, _ui_state): + if _ui_state.boot_offroad_mode == 1 and not on: + _ui_state.params.put_bool("OffroadMode", True) + + @staticmethod + def set_onroad_brightness(_ui_state, awake: bool, cur_brightness: float) -> float: + if not awake or not _ui_state.started: + return cur_brightness + + if _ui_state.onroad_brightness_timer != 0: + if _ui_state.onroad_brightness == OnroadBrightness.AUTO_DARK: + return max(30.0, cur_brightness) + # For AUTO (Default) and Manual modes (while timer running), use standard brightness + return cur_brightness + + # 0: Auto (Default), 1: Auto (Dark), 2: Screen Off + if _ui_state.onroad_brightness == OnroadBrightness.AUTO: + return cur_brightness + if _ui_state.onroad_brightness == OnroadBrightness.AUTO_DARK: + return cur_brightness + if _ui_state.onroad_brightness == OnroadBrightness.SCREEN_OFF: + return 0.0 + + # 3-22: 5% - 100% + return float((_ui_state.onroad_brightness - 2) * 5) + + @staticmethod + def set_min_onroad_brightness(_ui_state, min_brightness: int) -> int: + if _ui_state.onroad_brightness == OnroadBrightness.AUTO_DARK: + min_brightness = 10 + + return min_brightness + + @staticmethod + def wake_from_dimmed_onroad_brightness(_ui_state, evs) -> None: + if _ui_state.started and (_ui_state.onroad_brightness_timer_expired or _ui_state.onroad_brightness == OnroadBrightness.AUTO_DARK): + if any(ev.left_down for ev in evs): + if _ui_state.onroad_brightness_timer_expired: + gui_app.mouse_events.clear() + _ui_state.reset_onroad_sleep_timer() diff --git a/selfdrive/ui/tests/diff/replay.py b/selfdrive/ui/tests/diff/replay.py index b38026048d..fd82e325a3 100755 --- a/selfdrive/ui/tests/diff/replay.py +++ b/selfdrive/ui/tests/diff/replay.py @@ -14,7 +14,7 @@ from openpilot.common.params import Params from openpilot.common.prefix import OpenpilotPrefix from openpilot.selfdrive.ui.tests.diff.diff import DIFF_OUT_DIR from openpilot.system.updated.updated import parse_release_notes -from openpilot.system.version import terms_version, training_version +from openpilot.system.version import terms_version, training_version, terms_version_sp, sunnylink_consent_version LayoutVariant = Literal["mici", "tizi"] @@ -30,6 +30,9 @@ def setup_state(): # Combined description for layouts that still use it (BIG home, settings/software) params.put("UpdaterCurrentDescription", "0.10.1 / test-branch / abc1234 / Nov 30") params.put("UpdaterCurrentReleaseNotes", parse_release_notes(BASEDIR)) + params.put("HasAcceptedTermsSP", terms_version_sp) + params.put("CompletedSunnylinkConsentVersion", sunnylink_consent_version) + # Params for mici home params.put("Version", "0.10.1") params.put("GitBranch", "test-branch") diff --git a/selfdrive/ui/tests/test_soundd.py b/selfdrive/ui/tests/test_soundd.py index a9da8455eb..226117ae8f 100644 --- a/selfdrive/ui/tests/test_soundd.py +++ b/selfdrive/ui/tests/test_soundd.py @@ -10,8 +10,8 @@ AudibleAlert = car.CarControl.HUDControl.AudibleAlert class TestSoundd: def test_check_selfdrive_timeout_alert(self): - sm = SubMaster(['selfdriveState']) - pm = PubMaster(['selfdriveState']) + sm = SubMaster(['selfdriveState', 'selfdriveStateSP']) + pm = PubMaster(['selfdriveState', 'selfdriveStateSP']) for _ in range(100): cs = messaging.new_message('selfdriveState') @@ -31,5 +31,31 @@ class TestSoundd: assert check_selfdrive_timeout_alert(sm) + def test_check_selfdrive_timeout_alert_mads_lateral_only(self): + sm = SubMaster(['selfdriveState', 'selfdriveStateSP']) + pm = PubMaster(['selfdriveState', 'selfdriveStateSP']) + + for _ in range(100): + cs = messaging.new_message('selfdriveState') + cs.selfdriveState.enabled = False + + ss_sp = messaging.new_message('selfdriveStateSP') + ss_sp.selfdriveStateSP.mads.enabled = True + + pm.send("selfdriveState", cs) + pm.send("selfdriveStateSP", ss_sp) + + time.sleep(0.01) + + sm.update(0) + + assert not check_selfdrive_timeout_alert(sm) + + for _ in range(SELFDRIVE_STATE_TIMEOUT * 110): + sm.update(0) + time.sleep(0.01) + + assert check_selfdrive_timeout_alert(sm) + # TODO: add test with micd for checking that soundd actually outputs sounds diff --git a/selfdrive/ui/translations/app.pot b/selfdrive/ui/translations/app.pot index 0872ed538e..37a80ff223 100644 --- a/selfdrive/ui/translations/app.pot +++ b/selfdrive/ui/translations/app.pot @@ -2,10 +2,48 @@ msgid "" msgstr "" "Content-Type: text/plain; charset=UTF-8\n" -#: openpilot/selfdrive/ui/layouts/sidebar.py -#: system/ui/widgets/confirm_dialog.py -#: system/ui/widgets/html_render.py -msgid "OK" +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Search" +msgstr "" + +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Enter search query" +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Pair your GitHub account" +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Early Access: Become a sunnypilot Sponsor" +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to login to your GitHub account" +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Follow the prompts to complete the pairing process" +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Re-enter the \"sunnylink\" panel to verify sponsorship status" +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai" +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to visit sunnyhaibin's GitHub Sponsors page" +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Choose your sponsorship tier and confirm your support" +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Join our Community Forum at https://community.sunnypilot.ai and reach out to a moderator if you have issues" msgstr "" #: system/ui/widgets/confirm_dialog.py @@ -137,10 +175,60 @@ msgstr "" msgid "Error" msgstr "" +#: openpilot/selfdrive/ui/layouts/sidebar.py +#: system/ui/widgets/confirm_dialog.py +#: system/ui/widgets/html_render.py +msgid "OK" +msgstr "" + #: system/ui/widgets/option_dialog.py msgid "Select" msgstr "" +#: openpilot/selfdrive/ui/widgets/ssh_key.py +msgid "LOADING" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/ssh_key.py +msgid "ADD" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/ssh_key.py +msgid "REMOVE" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/ssh_key.py +msgid "Request timed out" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/ssh_key.py +msgid "Enter your GitHub username" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/ssh_key.py +msgid "No SSH keys found for user '{}'" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py +msgid "Close" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py +msgid "Snooze Update" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py +msgid "Acknowledge Excessive Actuation" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py +msgid "Reboot and Update" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py +msgid "No release notes available." +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/device.py #: openpilot/selfdrive/ui/widgets/setup.py msgid "Pair your device with comma connect (connect.comma.ai) and claim your comma prime offer." @@ -170,68 +258,12 @@ msgstr "" msgid "Please connect to Wi-Fi to complete initial pairing" msgstr "" -#: openpilot/selfdrive/ui/widgets/offroad_alerts.py -msgid "Close" +#: openpilot/selfdrive/ui/widgets/exp_mode_button.py +msgid "EXPERIMENTAL MODE ON" msgstr "" -#: openpilot/selfdrive/ui/widgets/offroad_alerts.py -msgid "Snooze Update" -msgstr "" - -#: openpilot/selfdrive/ui/widgets/offroad_alerts.py -msgid "Acknowledge Excessive Actuation" -msgstr "" - -#: openpilot/selfdrive/ui/widgets/offroad_alerts.py -msgid "Reboot and Update" -msgstr "" - -#: openpilot/selfdrive/ui/widgets/offroad_alerts.py -msgid "No release notes available." -msgstr "" - -#: openpilot/selfdrive/ui/widgets/ssh_key.py -msgid "LOADING" -msgstr "" - -#: openpilot/selfdrive/ui/widgets/ssh_key.py -msgid "ADD" -msgstr "" - -#: openpilot/selfdrive/ui/widgets/ssh_key.py -msgid "REMOVE" -msgstr "" - -#: openpilot/selfdrive/ui/widgets/ssh_key.py -msgid "Request timed out" -msgstr "" - -#: openpilot/selfdrive/ui/widgets/ssh_key.py -msgid "Enter your GitHub username" -msgstr "" - -#: openpilot/selfdrive/ui/widgets/ssh_key.py -msgid "No SSH keys found for user '{}'" -msgstr "" - -#: openpilot/selfdrive/ui/widgets/pairing_dialog.py -msgid "Pair your device to your comma account" -msgstr "" - -#: openpilot/selfdrive/ui/widgets/pairing_dialog.py -msgid "Go to https://connect.comma.ai on your phone" -msgstr "" - -#: openpilot/selfdrive/ui/widgets/pairing_dialog.py -msgid "Click \"add new device\" and scan the QR code on the right" -msgstr "" - -#: openpilot/selfdrive/ui/widgets/pairing_dialog.py -msgid "Bookmark connect.comma.ai to your home screen to use it like an app" -msgstr "" - -#: openpilot/selfdrive/ui/widgets/pairing_dialog.py -msgid "QR Code Error" +#: openpilot/selfdrive/ui/widgets/exp_mode_button.py +msgid "CHILL MODE ON" msgstr "" #: openpilot/selfdrive/ui/widgets/prime.py @@ -270,12 +302,24 @@ msgstr "" msgid "Become a comma prime member at connect.comma.ai" msgstr "" -#: openpilot/selfdrive/ui/widgets/exp_mode_button.py -msgid "EXPERIMENTAL MODE ON" +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py +msgid "Pair your device to your comma account" msgstr "" -#: openpilot/selfdrive/ui/widgets/exp_mode_button.py -msgid "CHILL MODE ON" +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py +msgid "Go to https://connect.comma.ai on your phone" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py +msgid "Click \"add new device\" and scan the QR code on the right" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py +msgid "Bookmark connect.comma.ai to your home screen to use it like an app" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py +msgid "QR Code Error" msgstr "" #: openpilot/selfdrive/ui/layouts/home.py @@ -288,30 +332,6 @@ msgid_plural "{} ALERTS" msgstr[0] "" msgstr[1] "" -#: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "Welcome to openpilot" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "You must accept the Terms and Conditions to use openpilot. Read the latest terms at https://comma.ai/terms before continuing." -msgstr "" - -#: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "Decline" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "Agree" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "You must accept the Terms and Conditions in order to use openpilot." -msgstr "" - -#: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "Decline, uninstall openpilot" -msgstr "" - #: openpilot/selfdrive/ui/layouts/sidebar.py msgid "--" msgstr "" @@ -380,16 +400,64 @@ msgstr "" msgid "ERROR" msgstr "" +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "Welcome to sunnypilot" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "You must accept the Terms of Service to use sunnypilot. Read the latest terms at https://sunnypilot.ai/terms before continuing." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "Decline" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "Agree" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "You must accept the Terms of Service in order to use sunnypilot." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "Decline, uninstall sunnypilot" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/settings.py +msgid "Device" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/settings.py +msgid "Network" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/settings.py +msgid "Toggles" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/settings.py +msgid "Software" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/settings.py +msgid "Firehose" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/settings.py +msgid "Developer" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Preview the driver facing camera to ensure that driver monitoring has good visibility. (vehicle must be off)" msgstr "" #: openpilot/selfdrive/ui/layouts/settings/device.py -msgid "openpilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." +msgid "sunnypilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." msgstr "" #: openpilot/selfdrive/ui/layouts/settings/device.py -msgid "Review the rules, features, and limitations of openpilot" +msgid "Review the rules, features, and limitations of sunnypilot" msgstr "" #: openpilot/selfdrive/ui/layouts/settings/device.py @@ -528,16 +596,6 @@ msgstr "" msgid " Steering torque response calibration is {}% complete." msgstr "" -#: openpilot/selfdrive/ui/layouts/settings/firehose.py -msgid "Firehose Mode" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/firehose.py -msgid "{} segment of your driving is in the training dataset so far." -msgid_plural "{} segments of your driving is in the training dataset so far." -msgstr[0] "" -msgstr[1] "" - #: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Enable ADB" msgstr "" @@ -559,7 +617,11 @@ msgid "Longitudinal Maneuver Mode" msgstr "" #: openpilot/selfdrive/ui/layouts/settings/developer.py -msgid "openpilot Longitudinal Control (Alpha)" +msgid "Lateral Maneuver Mode" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "sunnypilot Longitudinal Control (Alpha)" msgstr "" #: openpilot/selfdrive/ui/layouts/settings/developer.py @@ -571,6 +633,100 @@ msgstr "" msgid "Enable" msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/firehose.py +msgid "Firehose Mode" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/firehose.py +msgid "{} segment of your driving is in the training dataset so far." +msgid_plural "{} segments of your driving is in the training dataset so far." +msgstr[0] "" +msgstr[1] "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "When enabled, pressing the accelerator pedal will disengage sunnypilot." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable driver monitoring even when sunnypilot is not engaged." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Upload data from the driver facing camera and help improve the driver monitoring algorithm." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Display speed in km/h instead of mph." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Record and store microphone audio while driving. The audio will be included in the dashcam video in comma connect." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Driving Personality" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Changing this setting will restart sunnypilot if the car is powered on." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Experimental mode is currently unavailable on this car since the car's stock ACC is used for longitudinal control." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable sunnypilot" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Experimental Mode" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Disengage on Accelerator Pedal" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable Lane Departure Warnings" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Always-On Driver Monitoring" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Record and Upload Driver Camera" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Record and Upload Microphone Audio" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Use Metric System" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "sunnypilot longitudinal control may come in a future update." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Aggressive" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Standard" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Relaxed" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable the sunnypilot longitudinal control (alpha) toggle to allow Experimental mode." +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/software.py msgid "checking..." msgstr "" @@ -677,132 +833,8 @@ msgstr "" msgid "up to date, last checked {}" msgstr "" -#: openpilot/selfdrive/ui/layouts/settings/settings.py -msgid "Device" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/settings.py -msgid "Network" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/settings.py -msgid "Toggles" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/settings.py -msgid "Software" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/settings.py -msgid "Firehose" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/settings.py -msgid "Developer" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "When enabled, pressing the accelerator pedal will disengage openpilot." -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable driver monitoring even when openpilot is not engaged." -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Upload data from the driver facing camera and help improve the driver monitoring algorithm." -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Display speed in km/h instead of mph." -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Record and store microphone audio while driving. The audio will be included in the dashcam video in comma connect." -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Driving Personality" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Changing this setting will restart openpilot if the car is powered on." -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Experimental mode is currently unavailable on this car since the car's stock ACC is used for longitudinal control." -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable openpilot" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Experimental Mode" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Disengage on Accelerator Pedal" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable Lane Departure Warnings" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Always-On Driver Monitoring" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Record and Upload Driver Camera" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Record and Upload Microphone Audio" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Use Metric System" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "openpilot longitudinal control may come in a future update." -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Aggressive" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Standard" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Relaxed" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable the openpilot longitudinal control (alpha) toggle to allow Experimental mode." -msgstr "" - -#: openpilot/selfdrive/ui/onroad/driver_camera_dialog.py -msgid "camera starting" -msgstr "" - -#: openpilot/selfdrive/ui/onroad/hud_renderer.py -msgid "MAX" -msgstr "" - -#: openpilot/selfdrive/ui/onroad/hud_renderer.py -msgid "km/h" -msgstr "" - -#: openpilot/selfdrive/ui/onroad/hud_renderer.py -msgid "mph" -msgstr "" - #: openpilot/selfdrive/ui/onroad/alert_renderer.py -msgid "openpilot Unavailable" +msgid "sunnypilot Unavailable" msgstr "" #: openpilot/selfdrive/ui/onroad/alert_renderer.py @@ -821,3 +853,19 @@ msgstr "" msgid "Reboot Device" msgstr "" +#: openpilot/selfdrive/ui/onroad/hud_renderer.py +msgid "MAX" +msgstr "" + +#: openpilot/selfdrive/ui/onroad/hud_renderer.py +msgid "km/h" +msgstr "" + +#: openpilot/selfdrive/ui/onroad/hud_renderer.py +msgid "mph" +msgstr "" + +#: openpilot/selfdrive/ui/onroad/driver_camera_dialog.py +msgid "camera starting" +msgstr "" + diff --git a/selfdrive/ui/translations/app_de.po b/selfdrive/ui/translations/app_de.po index 287ecde1a0..b4af3fcc1d 100644 --- a/selfdrive/ui/translations/app_de.po +++ b/selfdrive/ui/translations/app_de.po @@ -142,8 +142,12 @@ msgid "Change Language" msgstr "Sprache ändern" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Changing this setting will restart openpilot if the car is powered on." -msgstr " Durch Ändern dieser Einstellung wird openpilot neu gestartet, wenn das Auto eingeschaltet ist." +msgid "Changing this setting will restart sunnypilot if the car is powered on." +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Choose your sponsorship tier and confirm your support" +msgstr "" #: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Click \"add new device\" and scan the QR code on the right" @@ -166,8 +170,8 @@ msgid "Decline" msgstr "Ablehnen" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "Decline, uninstall openpilot" -msgstr "Ablehnen, openpilot deinstallieren" +msgid "Decline, uninstall sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Developer" @@ -229,6 +233,10 @@ msgstr "ETH" msgid "EXPERIMENTAL MODE ON" msgstr "EXPERIMENTALMODUS AKTIV" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Early Access: Become a sunnypilot Sponsor" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/developer.py #: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" @@ -255,16 +263,16 @@ msgid "Enable Tethering" msgstr "Tethering aktivieren" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable driver monitoring even when openpilot is not engaged." -msgstr "Fahrerüberwachung auch aktivieren, wenn openpilot nicht aktiv ist." +msgid "Enable driver monitoring even when sunnypilot is not engaged." +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable openpilot" -msgstr "openpilot aktivieren" +msgid "Enable sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable the openpilot longitudinal control (alpha) toggle to allow Experimental mode." -msgstr "Den Schalter für die openpilot-Längsregelung (Alpha) aktivieren, um den Experimentalmodus zu erlauben." +msgid "Enable the sunnypilot longitudinal control (alpha) toggle to allow Experimental mode." +msgstr "" #: system/ui/widgets/network.py msgid "Enter APN" @@ -282,6 +290,10 @@ msgstr "Neues Tethering‑Passwort eingeben" msgid "Enter password" msgstr "Passwort eingeben" +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Enter search query" +msgstr "" + #: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "Enter your GitHub username" msgstr "Geben Sie Ihren GitHub‑Benutzernamen ein" @@ -314,6 +326,10 @@ msgstr "Datenstrom" msgid "Firehose Mode" msgstr "Firehose‑Modus" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Follow the prompts to complete the pairing process" +msgstr "" + #: system/ui/widgets/network.py msgid "Forget" msgstr "Vergessen" @@ -346,10 +362,18 @@ msgstr "INSTALLIEREN" msgid "IP Address" msgstr "IP‑Adresse" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Install Update" msgstr "Update installieren" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Join our Community Forum at https://community.sunnypilot.ai and reach out to a moderator if you have issues" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Joystick Debug Mode" msgstr "Joystick‑Debugmodus" @@ -362,6 +386,10 @@ msgstr "LADEN" msgid "LTE" msgstr "LTE" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "Lateral Maneuver Mode" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Longitudinal Maneuver Mode" msgstr "Längsmanövermodus" @@ -436,6 +464,10 @@ msgstr "Gerät koppeln" msgid "Pair device" msgstr "Gerät koppeln" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Pair your GitHub account" +msgstr "" + #: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Pair your device to your comma account" msgstr "Koppeln Sie Ihr Gerät mit Ihrem comma‑Konto" @@ -481,6 +513,10 @@ msgstr "ZURÜCKSETZEN" msgid "REVIEW" msgstr "ANSEHEN" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Re-enter the \"sunnylink\" panel to verify sponsorship status" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reboot" msgstr "Neustart" @@ -538,8 +574,8 @@ msgid "Review Training Guide" msgstr "Trainingsanleitung ansehen" #: openpilot/selfdrive/ui/layouts/settings/device.py -msgid "Review the rules, features, and limitations of openpilot" -msgstr "Überprüfen Sie die Regeln, Funktionen und Einschränkungen von openpilot" +msgid "Review the rules, features, and limitations of sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/software.py msgid "SELECT" @@ -549,10 +585,22 @@ msgstr "WÄHLEN" msgid "SSH Keys" msgstr "SSH‑Schlüssel" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to login to your GitHub account" +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to visit sunnyhaibin's GitHub Sponsors page" +msgstr "" + #: system/ui/widgets/network.py msgid "Scanning Wi-Fi networks..." msgstr "WLAN‑Netzwerke werden gesucht..." +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Search" +msgstr "" + #: system/ui/widgets/option_dialog.py msgid "Select" msgstr "Auswählen" @@ -654,12 +702,12 @@ msgid "Waiting to start" msgstr "Warten auf Start" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "Welcome to openpilot" -msgstr "Willkommen bei openpilot" +msgid "Welcome to sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "When enabled, pressing the accelerator pedal will disengage openpilot." -msgstr "Wenn aktiviert, deaktiviert das Drücken des Gaspedals openpilot." +msgid "When enabled, pressing the accelerator pedal will disengage sunnypilot." +msgstr "" #: openpilot/selfdrive/ui/layouts/sidebar.py msgid "Wi-Fi" @@ -674,12 +722,12 @@ msgid "Wrong password" msgstr "Falsches Passwort" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "You must accept the Terms and Conditions in order to use openpilot." -msgstr "Sie müssen die Nutzungsbedingungen akzeptieren, um openpilot zu verwenden." +msgid "You must accept the Terms of Service in order to use sunnypilot." +msgstr "" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "You must accept the Terms and Conditions to use openpilot. Read the latest terms at https://comma.ai/terms before continuing." -msgstr "Sie müssen die Nutzungsbedingungen akzeptieren, um openpilot zu verwenden. Lesen Sie die aktuellen Bedingungen unter https://comma.ai/terms, bevor Sie fortfahren." +msgid "You must accept the Terms of Service to use sunnypilot. Read the latest terms at https://sunnypilot.ai/terms before continuing." +msgstr "" #: openpilot/selfdrive/ui/onroad/driver_camera_dialog.py msgid "camera starting" @@ -745,26 +793,26 @@ msgstr "nie" msgid "now" msgstr "jetzt" -#: openpilot/selfdrive/ui/layouts/settings/developer.py -msgid "openpilot Longitudinal Control (Alpha)" -msgstr "openpilot Längsregelung (Alpha)" - -#: openpilot/selfdrive/ui/onroad/alert_renderer.py -msgid "openpilot Unavailable" -msgstr "openpilot nicht verfügbar" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "openpilot longitudinal control may come in a future update." -msgstr "Die openpilot‑Längsregelung könnte in einem zukünftigen Update kommen." - -#: openpilot/selfdrive/ui/layouts/settings/device.py -msgid "openpilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." -msgstr "openpilot erfordert, dass das Gerät innerhalb von 4° nach links oder rechts und innerhalb von 5° nach oben oder 9° nach unten montiert ist." - #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "right" msgstr "rechts" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "sunnypilot Longitudinal Control (Alpha)" +msgstr "" + +#: openpilot/selfdrive/ui/onroad/alert_renderer.py +msgid "sunnypilot Unavailable" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "sunnypilot longitudinal control may come in a future update." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "sunnypilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." +msgstr "" + #: system/ui/widgets/network.py msgid "unmetered" msgstr "unbegrenzt" diff --git a/selfdrive/ui/translations/app_en.po b/selfdrive/ui/translations/app_en.po index 9f99c42b11..ad2ed1241f 100644 --- a/selfdrive/ui/translations/app_en.po +++ b/selfdrive/ui/translations/app_en.po @@ -142,8 +142,12 @@ msgid "Change Language" msgstr "Change Language" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Changing this setting will restart openpilot if the car is powered on." -msgstr "Changing this setting will restart openpilot if the car is powered on." +msgid "Changing this setting will restart sunnypilot if the car is powered on." +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Choose your sponsorship tier and confirm your support" +msgstr "" #: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Click \"add new device\" and scan the QR code on the right" @@ -166,8 +170,8 @@ msgid "Decline" msgstr "Decline" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "Decline, uninstall openpilot" -msgstr "Decline, uninstall openpilot" +msgid "Decline, uninstall sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Developer" @@ -229,6 +233,10 @@ msgstr "ETH" msgid "EXPERIMENTAL MODE ON" msgstr "EXPERIMENTAL MODE ON" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Early Access: Become a sunnypilot Sponsor" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/developer.py #: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" @@ -255,16 +263,16 @@ msgid "Enable Tethering" msgstr "Enable Tethering" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable driver monitoring even when openpilot is not engaged." -msgstr "Enable driver monitoring even when openpilot is not engaged." +msgid "Enable driver monitoring even when sunnypilot is not engaged." +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable openpilot" -msgstr "Enable openpilot" +msgid "Enable sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable the openpilot longitudinal control (alpha) toggle to allow Experimental mode." -msgstr "Enable the openpilot longitudinal control (alpha) toggle to allow Experimental mode." +msgid "Enable the sunnypilot longitudinal control (alpha) toggle to allow Experimental mode." +msgstr "" #: system/ui/widgets/network.py msgid "Enter APN" @@ -282,6 +290,10 @@ msgstr "Enter new tethering password" msgid "Enter password" msgstr "Enter password" +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Enter search query" +msgstr "" + #: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "Enter your GitHub username" msgstr "Enter your GitHub username" @@ -314,6 +326,10 @@ msgstr "Firehose" msgid "Firehose Mode" msgstr "Firehose Mode" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Follow the prompts to complete the pairing process" +msgstr "" + #: system/ui/widgets/network.py msgid "Forget" msgstr "Forget" @@ -346,10 +362,18 @@ msgstr "INSTALL" msgid "IP Address" msgstr "IP Address" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Install Update" msgstr "Install Update" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Join our Community Forum at https://community.sunnypilot.ai and reach out to a moderator if you have issues" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Joystick Debug Mode" msgstr "Joystick Debug Mode" @@ -362,6 +386,10 @@ msgstr "LOADING" msgid "LTE" msgstr "LTE" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "Lateral Maneuver Mode" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Longitudinal Maneuver Mode" msgstr "Longitudinal Maneuver Mode" @@ -436,6 +464,10 @@ msgstr "Pair Device" msgid "Pair device" msgstr "Pair device" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Pair your GitHub account" +msgstr "" + #: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Pair your device to your comma account" msgstr "Pair your device to your comma account" @@ -481,6 +513,10 @@ msgstr "RESET" msgid "REVIEW" msgstr "REVIEW" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Re-enter the \"sunnylink\" panel to verify sponsorship status" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reboot" msgstr "Reboot" @@ -538,8 +574,8 @@ msgid "Review Training Guide" msgstr "Review Training Guide" #: openpilot/selfdrive/ui/layouts/settings/device.py -msgid "Review the rules, features, and limitations of openpilot" -msgstr "Review the rules, features, and limitations of openpilot" +msgid "Review the rules, features, and limitations of sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/software.py msgid "SELECT" @@ -549,10 +585,22 @@ msgstr "SELECT" msgid "SSH Keys" msgstr "SSH Keys" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to login to your GitHub account" +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to visit sunnyhaibin's GitHub Sponsors page" +msgstr "" + #: system/ui/widgets/network.py msgid "Scanning Wi-Fi networks..." msgstr "Scanning Wi-Fi networks..." +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Search" +msgstr "" + #: system/ui/widgets/option_dialog.py msgid "Select" msgstr "Select" @@ -654,12 +702,12 @@ msgid "Waiting to start" msgstr "Waiting to start" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "Welcome to openpilot" -msgstr "Welcome to openpilot" +msgid "Welcome to sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "When enabled, pressing the accelerator pedal will disengage openpilot." -msgstr "When enabled, pressing the accelerator pedal will disengage openpilot." +msgid "When enabled, pressing the accelerator pedal will disengage sunnypilot." +msgstr "" #: openpilot/selfdrive/ui/layouts/sidebar.py msgid "Wi-Fi" @@ -674,12 +722,12 @@ msgid "Wrong password" msgstr "Wrong password" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "You must accept the Terms and Conditions in order to use openpilot." -msgstr "You must accept the Terms and Conditions in order to use openpilot." +msgid "You must accept the Terms of Service in order to use sunnypilot." +msgstr "" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "You must accept the Terms and Conditions to use openpilot. Read the latest terms at https://comma.ai/terms before continuing." -msgstr "You must accept the Terms and Conditions to use openpilot. Read the latest terms at https://comma.ai/terms before continuing." +msgid "You must accept the Terms of Service to use sunnypilot. Read the latest terms at https://sunnypilot.ai/terms before continuing." +msgstr "" #: openpilot/selfdrive/ui/onroad/driver_camera_dialog.py msgid "camera starting" @@ -745,26 +793,26 @@ msgstr "never" msgid "now" msgstr "now" -#: openpilot/selfdrive/ui/layouts/settings/developer.py -msgid "openpilot Longitudinal Control (Alpha)" -msgstr "openpilot Longitudinal Control (Alpha)" - -#: openpilot/selfdrive/ui/onroad/alert_renderer.py -msgid "openpilot Unavailable" -msgstr "openpilot Unavailable" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "openpilot longitudinal control may come in a future update." -msgstr "openpilot longitudinal control may come in a future update." - -#: openpilot/selfdrive/ui/layouts/settings/device.py -msgid "openpilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." -msgstr "openpilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." - #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "right" msgstr "right" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "sunnypilot Longitudinal Control (Alpha)" +msgstr "" + +#: openpilot/selfdrive/ui/onroad/alert_renderer.py +msgid "sunnypilot Unavailable" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "sunnypilot longitudinal control may come in a future update." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "sunnypilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." +msgstr "" + #: system/ui/widgets/network.py msgid "unmetered" msgstr "unmetered" diff --git a/selfdrive/ui/translations/app_es.po b/selfdrive/ui/translations/app_es.po index 707816bc00..2298fdb5a1 100644 --- a/selfdrive/ui/translations/app_es.po +++ b/selfdrive/ui/translations/app_es.po @@ -142,8 +142,12 @@ msgid "Change Language" msgstr "Cambiar idioma" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Changing this setting will restart openpilot if the car is powered on." -msgstr " Cambiar esta configuración reiniciará openpilot si el coche está encendido." +msgid "Changing this setting will restart sunnypilot if the car is powered on." +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Choose your sponsorship tier and confirm your support" +msgstr "" #: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Click \"add new device\" and scan the QR code on the right" @@ -166,8 +170,8 @@ msgid "Decline" msgstr "Rechazar" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "Decline, uninstall openpilot" -msgstr "Rechazar, desinstalar openpilot" +msgid "Decline, uninstall sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Developer" @@ -229,6 +233,10 @@ msgstr "ETH" msgid "EXPERIMENTAL MODE ON" msgstr "MODO EXPERIMENTAL ACTIVADO" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Early Access: Become a sunnypilot Sponsor" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/developer.py #: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" @@ -255,16 +263,16 @@ msgid "Enable Tethering" msgstr "Activar anclaje" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable driver monitoring even when openpilot is not engaged." -msgstr "Activar la supervisión del conductor incluso cuando openpilot no esté activado." +msgid "Enable driver monitoring even when sunnypilot is not engaged." +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable openpilot" -msgstr "Activar openpilot" +msgid "Enable sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable the openpilot longitudinal control (alpha) toggle to allow Experimental mode." -msgstr "Activa el interruptor de control longitudinal de openpilot (alpha) para permitir el modo Experimental." +msgid "Enable the sunnypilot longitudinal control (alpha) toggle to allow Experimental mode." +msgstr "" #: system/ui/widgets/network.py msgid "Enter APN" @@ -282,6 +290,10 @@ msgstr "Ingrese una nueva contraseña de anclaje a red" msgid "Enter password" msgstr "Introduce la contraseña" +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Enter search query" +msgstr "" + #: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "Enter your GitHub username" msgstr "Introduce tu nombre de usuario de GitHub" @@ -314,6 +326,10 @@ msgstr "Flujo masivo" msgid "Firehose Mode" msgstr "Modo Firehose" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Follow the prompts to complete the pairing process" +msgstr "" + #: system/ui/widgets/network.py msgid "Forget" msgstr "Olvidar" @@ -346,10 +362,18 @@ msgstr "INSTALAR" msgid "IP Address" msgstr "Dirección IP" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Install Update" msgstr "Instalar actualización" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Join our Community Forum at https://community.sunnypilot.ai and reach out to a moderator if you have issues" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Joystick Debug Mode" msgstr "Modo de depuración de joystick" @@ -362,6 +386,10 @@ msgstr "CARGANDO" msgid "LTE" msgstr "LTE" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "Lateral Maneuver Mode" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Longitudinal Maneuver Mode" msgstr "Modo de maniobra longitudinal" @@ -436,6 +464,10 @@ msgstr "Emparejar dispositivo" msgid "Pair device" msgstr "Emparejar dispositivo" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Pair your GitHub account" +msgstr "" + #: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Pair your device to your comma account" msgstr "Empareja tu dispositivo con tu cuenta de comma" @@ -481,6 +513,10 @@ msgstr "RESTABLECER" msgid "REVIEW" msgstr "REVISAR" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Re-enter the \"sunnylink\" panel to verify sponsorship status" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reboot" msgstr "Reiniciar" @@ -538,8 +574,8 @@ msgid "Review Training Guide" msgstr "Revisar guía de entrenamiento" #: openpilot/selfdrive/ui/layouts/settings/device.py -msgid "Review the rules, features, and limitations of openpilot" -msgstr "Revisa las reglas, funciones y limitaciones de openpilot" +msgid "Review the rules, features, and limitations of sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/software.py msgid "SELECT" @@ -549,10 +585,22 @@ msgstr "SELECCIONAR" msgid "SSH Keys" msgstr "Claves SSH" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to login to your GitHub account" +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to visit sunnyhaibin's GitHub Sponsors page" +msgstr "" + #: system/ui/widgets/network.py msgid "Scanning Wi-Fi networks..." msgstr "Escaneando redes Wi-Fi..." +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Search" +msgstr "" + #: system/ui/widgets/option_dialog.py msgid "Select" msgstr "Seleccionar" @@ -654,12 +702,12 @@ msgid "Waiting to start" msgstr "Esperando para iniciar" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "Welcome to openpilot" -msgstr "Bienvenido a openpilot" +msgid "Welcome to sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "When enabled, pressing the accelerator pedal will disengage openpilot." -msgstr "Cuando está activado, al presionar el pedal del acelerador se desactivará openpilot." +msgid "When enabled, pressing the accelerator pedal will disengage sunnypilot." +msgstr "" #: openpilot/selfdrive/ui/layouts/sidebar.py msgid "Wi-Fi" @@ -674,12 +722,12 @@ msgid "Wrong password" msgstr "Contraseña incorrecta" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "You must accept the Terms and Conditions in order to use openpilot." -msgstr "Debes aceptar los Términos y Condiciones para poder usar openpilot." +msgid "You must accept the Terms of Service in order to use sunnypilot." +msgstr "" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "You must accept the Terms and Conditions to use openpilot. Read the latest terms at https://comma.ai/terms before continuing." -msgstr "Debes aceptar los Términos y Condiciones para usar openpilot. Lee los términos más recientes en https://comma.ai/terms antes de continuar." +msgid "You must accept the Terms of Service to use sunnypilot. Read the latest terms at https://sunnypilot.ai/terms before continuing." +msgstr "" #: openpilot/selfdrive/ui/onroad/driver_camera_dialog.py msgid "camera starting" @@ -745,26 +793,26 @@ msgstr "nunca" msgid "now" msgstr "ahora" -#: openpilot/selfdrive/ui/layouts/settings/developer.py -msgid "openpilot Longitudinal Control (Alpha)" -msgstr "Control longitudinal de openpilot (Alpha)" - -#: openpilot/selfdrive/ui/onroad/alert_renderer.py -msgid "openpilot Unavailable" -msgstr "openpilot no disponible" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "openpilot longitudinal control may come in a future update." -msgstr "El control longitudinal de openpilot podría llegar en una actualización futura." - -#: openpilot/selfdrive/ui/layouts/settings/device.py -msgid "openpilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." -msgstr "openpilot requiere que el dispositivo esté montado dentro de 4° a izquierda o derecha y dentro de 5° hacia arriba o 9° hacia abajo." - #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "right" msgstr "derecha" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "sunnypilot Longitudinal Control (Alpha)" +msgstr "" + +#: openpilot/selfdrive/ui/onroad/alert_renderer.py +msgid "sunnypilot Unavailable" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "sunnypilot longitudinal control may come in a future update." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "sunnypilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." +msgstr "" + #: system/ui/widgets/network.py msgid "unmetered" msgstr "sin medir" diff --git a/selfdrive/ui/translations/app_fr.po b/selfdrive/ui/translations/app_fr.po index 7c0aecc9ec..ac5b4deea5 100644 --- a/selfdrive/ui/translations/app_fr.po +++ b/selfdrive/ui/translations/app_fr.po @@ -6,11 +6,11 @@ msgstr "" #: openpilot/selfdrive/ui/layouts/settings/device.py msgid " Steering torque response calibration is complete." -msgstr " L'étalonnage de la réponse du couple de direction est terminé." +msgstr " Calibration de la réponse du couple de direction terminée." #: openpilot/selfdrive/ui/layouts/settings/device.py msgid " Steering torque response calibration is {}% complete." -msgstr " L'étalonnage de la réponse du couple de direction est terminé à {}%." +msgstr " Calibration du couple de direction : {}% effectué." #: openpilot/selfdrive/ui/layouts/settings/device.py msgid " Your device is pointed {:.1f}° {} and {:.1f}° {}." @@ -42,11 +42,11 @@ msgstr "5G" #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "

Steering lag calibration is complete." -msgstr "

L'étalonnage du délai de réponse de la direction est terminé." +msgstr "

La calibration du délai de direction est terminée." #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "

Steering lag calibration is {}% complete." -msgstr "

L'étalonnage du délai de réponse de la direction est terminé à {}%." +msgstr "

Calibration du délai de direction : {}% effectué." #: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "ADD" @@ -54,7 +54,7 @@ msgstr "AJOUTER" #: system/ui/widgets/network.py msgid "APN Setting" -msgstr "Paramètres APN" +msgstr "Paramètre APN" #: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Acknowledge Excessive Actuation" @@ -124,7 +124,7 @@ msgstr "CONNECTER" #: system/ui/widgets/network.py msgid "CONNECTING..." -msgstr "CONNECTER..." +msgstr "CONNEXION..." #: system/ui/widgets/confirm_dialog.py #: system/ui/widgets/keyboard.py @@ -135,15 +135,19 @@ msgstr "Annuler" #: system/ui/widgets/network.py msgid "Cellular Metered" -msgstr "Données cellulaire limitées" +msgstr "Données cellulaires limitées" #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Change Language" msgstr "Changer la langue" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Changing this setting will restart openpilot if the car is powered on." -msgstr "La modification de ce réglage redémarrera openpilot si la voiture est sous tension." +msgid "Changing this setting will restart sunnypilot if the car is powered on." +msgstr "La modification de ce réglage redémarrera sunnypilot si la voiture est sous tension." + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Choose your sponsorship tier and confirm your support" +msgstr "Choisissez votre niveau de parrainage et confirmez votre soutien" #: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Click \"add new device\" and scan the QR code on the right" @@ -166,8 +170,8 @@ msgid "Decline" msgstr "Refuser" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "Decline, uninstall openpilot" -msgstr "Refuser, désinstaller openpilot" +msgid "Decline, uninstall sunnypilot" +msgstr "Refuser, désinstaller sunnypilot" #: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Developer" @@ -215,7 +219,7 @@ msgstr "Personnalité de conduite" #: system/ui/widgets/network.py msgid "EDIT" -msgstr "EDITER" +msgstr "MODIFIER" #: openpilot/selfdrive/ui/layouts/sidebar.py msgid "ERROR" @@ -229,6 +233,10 @@ msgstr "ETH" msgid "EXPERIMENTAL MODE ON" msgstr "MODE EXPÉRIMENTAL ACTIVÉ" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Early Access: Become a sunnypilot Sponsor" +msgstr "Accès anticipé : Devenez parrain sunnypilot" + #: openpilot/selfdrive/ui/layouts/settings/developer.py #: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" @@ -244,7 +252,7 @@ msgstr "Activer les alertes de sortie de voie" #: system/ui/widgets/network.py msgid "Enable Roaming" -msgstr "Activer openpilot" +msgstr "Activer l'itinérance" #: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Enable SSH" @@ -252,35 +260,39 @@ msgstr "Activer SSH" #: system/ui/widgets/network.py msgid "Enable Tethering" -msgstr "Activer les alertes de sortie de voie" +msgstr "Activer le partage de connexion" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable driver monitoring even when openpilot is not engaged." -msgstr "Activer la surveillance du conducteur même lorsque openpilot n'est pas engagé." +msgid "Enable driver monitoring even when sunnypilot is not engaged." +msgstr "Activer la surveillance du conducteur même lorsque sunnypilot n'est pas engagé." #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable openpilot" -msgstr "Activer openpilot" +msgid "Enable sunnypilot" +msgstr "Activer sunnypilot" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable the openpilot longitudinal control (alpha) toggle to allow Experimental mode." -msgstr "Activez l'option de contrôle longitudinal openpilot (alpha) pour autoriser le mode expérimental." +msgid "Enable the sunnypilot longitudinal control (alpha) toggle to allow Experimental mode." +msgstr "Activez l'option de contrôle longitudinal sunnypilot (alpha) pour autoriser le mode expérimental." #: system/ui/widgets/network.py msgid "Enter APN" -msgstr "Saisir l'APN" +msgstr "Entrez l'APN" #: system/ui/widgets/network.py msgid "Enter SSID" -msgstr "Entrer le SSID" +msgstr "Entrez le SSID" #: system/ui/widgets/network.py msgid "Enter new tethering password" -msgstr "Saisir le mot de passe du partage de connexion" +msgstr "Entrez un nouveau mot de passe de partage" #: system/ui/widgets/network.py msgid "Enter password" -msgstr "Saisir le mot de passe" +msgstr "Entrez le mot de passe" + +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Enter search query" +msgstr "Entrez votre recherche" #: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "Enter your GitHub username" @@ -300,31 +312,35 @@ msgstr "Le mode expérimental est actuellement indisponible sur cette voiture ca #: system/ui/widgets/network.py msgid "FORGETTING..." -msgstr "OUBLIER..." +msgstr "SUPPRESSION..." #: openpilot/selfdrive/ui/widgets/setup.py msgid "Finish Setup" -msgstr "Terminer la configuration" +msgstr "Finir la config." #: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Firehose" -msgstr "Flux continu" +msgstr "Firehose" #: openpilot/selfdrive/ui/layouts/settings/firehose.py msgid "Firehose Mode" msgstr "Mode Firehose" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Follow the prompts to complete the pairing process" +msgstr "Suivez les instructions pour terminer le processus d'association" + #: system/ui/widgets/network.py msgid "Forget" msgstr "Oublier" #: system/ui/widgets/network.py msgid "Forget Wi-Fi Network \"{}\"?" -msgstr "Oublier le réseau Wi-Fi \"{}\" ?" +msgstr "Oublier le réseau Wi‑Fi \"{}\" ?" #: openpilot/selfdrive/ui/layouts/sidebar.py msgid "GOOD" -msgstr "BON" +msgstr "BONNE" #: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Go to https://connect.comma.ai on your phone" @@ -336,7 +352,7 @@ msgstr "ÉLEVÉ" #: system/ui/widgets/network.py msgid "Hidden Network" -msgstr "Réseau" +msgstr "Réseau masqué" #: openpilot/selfdrive/ui/layouts/settings/software.py msgid "INSTALL" @@ -346,10 +362,18 @@ msgstr "INSTALLER" msgid "IP Address" msgstr "Adresse IP" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai" +msgstr "Si le statut de parrainage n'a pas été mis à jour, veuillez contacter un modérateur sur le forum communautaire à https://community.sunnypilot.ai" + #: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Install Update" msgstr "Installer la mise à jour" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Join our Community Forum at https://community.sunnypilot.ai and reach out to a moderator if you have issues" +msgstr "Rejoignez notre forum communautaire à https://community.sunnypilot.ai et contactez un modérateur si vous avez des problèmes" + #: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Joystick Debug Mode" msgstr "Mode débogage joystick" @@ -362,13 +386,17 @@ msgstr "CHARGEMENT" msgid "LTE" msgstr "LTE" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "Lateral Maneuver Mode" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Longitudinal Maneuver Mode" msgstr "Mode de manœuvre longitudinale" #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "MAX" -msgstr "MAX." +msgstr "MAX" #: openpilot/selfdrive/ui/widgets/setup.py msgid "Maximize your training data uploads to improve openpilot's driving models." @@ -376,7 +404,7 @@ msgstr "Maximisez vos envois de données d'entraînement pour améliorer les mod #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "N/A" -msgstr "NC" +msgstr "N/A" #: openpilot/selfdrive/ui/layouts/sidebar.py msgid "NO" @@ -436,6 +464,10 @@ msgstr "Associer l'appareil" msgid "Pair device" msgstr "Associer l'appareil" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Pair your GitHub account" +msgstr "Associer votre compte GitHub" + #: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Pair your device to your comma account" msgstr "Associez votre appareil à votre compte comma" @@ -455,11 +487,11 @@ msgstr "Éteindre" #: system/ui/widgets/network.py msgid "Prevent large data uploads when on a metered Wi-Fi connection" -msgstr "Eviter les transferts de données volumineux lorsque vous êtes connecté à un réseau Wi-Fi limité" +msgstr "Empêcher les téléversements volumineux sur une connexion Wi‑Fi limitée" #: system/ui/widgets/network.py msgid "Prevent large data uploads when on a metered cellular connection" -msgstr "Eviter les transferts de données volumineux lors d'une connexion à un réseau cellulaire limité" +msgstr "Empêcher les téléversements volumineux sur une connexion cellulaire limitée" #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Preview the driver facing camera to ensure that driver monitoring has good visibility. (vehicle must be off)" @@ -481,6 +513,10 @@ msgstr "RÉINITIALISER" msgid "REVIEW" msgstr "CONSULTER" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Re-enter the \"sunnylink\" panel to verify sponsorship status" +msgstr "Retournez dans le panneau \"sunnylink\" pour vérifier le statut de parrainage" + #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reboot" msgstr "Redémarrer" @@ -538,20 +574,32 @@ msgid "Review Training Guide" msgstr "Consulter le guide d'entraînement" #: openpilot/selfdrive/ui/layouts/settings/device.py -msgid "Review the rules, features, and limitations of openpilot" -msgstr "Consultez les règles, fonctionnalités et limitations d'openpilot" +msgid "Review the rules, features, and limitations of sunnypilot" +msgstr "Consultez les règles, fonctionnalités et limitations de sunnypilot" #: openpilot/selfdrive/ui/layouts/settings/software.py msgid "SELECT" -msgstr "SELECTIONNER" +msgstr "CHOISIR" #: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "SSH Keys" -msgstr "Clefs SSH" +msgstr "Clés SSH" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to login to your GitHub account" +msgstr "Scannez le code QR pour vous connecter à votre compte GitHub" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to visit sunnyhaibin's GitHub Sponsors page" +msgstr "Scannez le code QR pour visiter la page GitHub Sponsors de sunnyhaibin" #: system/ui/widgets/network.py msgid "Scanning Wi-Fi networks..." -msgstr "Analyse des réseaux Wi-Fi..." +msgstr "Recherche des réseaux Wi‑Fi..." + +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Search" +msgstr "Rechercher" #: system/ui/widgets/option_dialog.py msgid "Select" @@ -563,7 +611,7 @@ msgstr "Sélectionner une branche" #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Select a language" -msgstr "Sélectionner un langage" +msgstr "Sélectionner une langue" #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Serial" @@ -579,7 +627,7 @@ msgstr "Logiciel" #: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Standard" -msgstr "Normal" +msgstr "Standard" #: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "System Unresponsive" @@ -591,7 +639,7 @@ msgstr "REPRENEZ IMMÉDIATEMENT LE CONTRÔLE" #: openpilot/selfdrive/ui/layouts/sidebar.py msgid "TEMP" -msgstr "TEMPÉRATURE" +msgstr "TEMP." #: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Target Branch" @@ -599,7 +647,7 @@ msgstr "Branche cible" #: system/ui/widgets/network.py msgid "Tethering Password" -msgstr "Mot de passe du partage de connexion" +msgstr "Mot de passe de partage" #: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Toggles" @@ -607,11 +655,11 @@ msgstr "Options" #: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "UI Debug Mode" -msgstr "Mode de débogage de l'interface utilisateur" +msgstr "Mode débogage UI" #: openpilot/selfdrive/ui/layouts/settings/software.py msgid "UNINSTALL" -msgstr "DÉSINSTALLER" +msgstr "SUPPRIMER" #: openpilot/selfdrive/ui/layouts/home.py msgid "UPDATE" @@ -631,7 +679,7 @@ msgstr "Les mises à jour ne sont téléchargées que lorsque la voiture est ét #: openpilot/selfdrive/ui/widgets/prime.py msgid "Upgrade Now" -msgstr "Mettre à niveau maintenant" +msgstr "Mettre à jour" #: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Upload data from the driver facing camera and help improve the driver monitoring algorithm." @@ -654,12 +702,12 @@ msgid "Waiting to start" msgstr "En attente de démarrage" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "Welcome to openpilot" -msgstr "Bienvenue sur openpilot" +msgid "Welcome to sunnypilot" +msgstr "Bienvenue sur sunnypilot" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "When enabled, pressing the accelerator pedal will disengage openpilot." -msgstr "Lorsque activé, appuyer sur la pédale d'accélérateur désengagera openpilot." +msgid "When enabled, pressing the accelerator pedal will disengage sunnypilot." +msgstr "Lorsqu'activé, appuyer sur la pédale d'accélérateur désengagera sunnypilot." #: openpilot/selfdrive/ui/layouts/sidebar.py msgid "Wi-Fi" @@ -667,19 +715,19 @@ msgstr "Wi‑Fi" #: system/ui/widgets/network.py msgid "Wi-Fi Network Metered" -msgstr "Réseau Wi-Fi limité" +msgstr "Réseau Wi‑Fi limité" #: system/ui/widgets/network.py msgid "Wrong password" -msgstr "Mauvais mot de passe" +msgstr "Mot de passe incorrect" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "You must accept the Terms and Conditions in order to use openpilot." -msgstr "Vous devez accepter les conditions générales pour utiliser openpilot." +msgid "You must accept the Terms of Service in order to use sunnypilot." +msgstr "Vous devez accepter les conditions d'utilisation pour utiliser sunnypilot." #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "You must accept the Terms and Conditions to use openpilot. Read the latest terms at https://comma.ai/terms before continuing." -msgstr "Vous devez accepter les conditions générales pour utiliser openpilot. Lisez les dernières conditions sur https://comma.ai/terms avant de continuer." +msgid "You must accept the Terms of Service to use sunnypilot. Read the latest terms at https://sunnypilot.ai/terms before continuing." +msgstr "Vous devez accepter les conditions d'utilisation pour utiliser sunnypilot. Consultez les dernières conditions sur https://sunnypilot.ai/terms avant de continuer." #: openpilot/selfdrive/ui/onroad/driver_camera_dialog.py msgid "camera starting" @@ -695,7 +743,7 @@ msgstr "comma prime" #: system/ui/widgets/network.py msgid "default" -msgstr "défaut" +msgstr "par défaut" #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "down" @@ -723,7 +771,7 @@ msgstr "km/h" #: system/ui/widgets/network.py msgid "leave blank for automatic configuration" -msgstr "ne pas remplir pour une configuration automatique" +msgstr "laisser vide pour configuration automatique" #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "left" @@ -745,29 +793,29 @@ msgstr "jamais" msgid "now" msgstr "maintenant" -#: openpilot/selfdrive/ui/layouts/settings/developer.py -msgid "openpilot Longitudinal Control (Alpha)" -msgstr "Contrôle longitudinal openpilot (Alpha)" - -#: openpilot/selfdrive/ui/onroad/alert_renderer.py -msgid "openpilot Unavailable" -msgstr "openpilot indisponible" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "openpilot longitudinal control may come in a future update." -msgstr "Le contrôle longitudinal openpilot pourra arriver dans une future mise à jour." - -#: openpilot/selfdrive/ui/layouts/settings/device.py -msgid "openpilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." -msgstr "openpilot exige que l'appareil soit monté à moins de 4° à gauche ou à droite et à moins de 5° vers le haut ou 9° vers le bas." - #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "right" msgstr "droite" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "sunnypilot Longitudinal Control (Alpha)" +msgstr "Contrôle longitudinal sunnypilot (Alpha)" + +#: openpilot/selfdrive/ui/onroad/alert_renderer.py +msgid "sunnypilot Unavailable" +msgstr "sunnypilot indisponible" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "sunnypilot longitudinal control may come in a future update." +msgstr "Le contrôle longitudinal sunnypilot pourrait arriver dans une future mise à jour." + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "sunnypilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." +msgstr "" + #: system/ui/widgets/network.py msgid "unmetered" -msgstr "non limité" +msgstr "illimité" #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "up" diff --git a/selfdrive/ui/translations/app_ja.po b/selfdrive/ui/translations/app_ja.po index 78d3cf17c6..1d701bd8b4 100644 --- a/selfdrive/ui/translations/app_ja.po +++ b/selfdrive/ui/translations/app_ja.po @@ -142,8 +142,12 @@ msgid "Change Language" msgstr "言語を変更" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Changing this setting will restart openpilot if the car is powered on." -msgstr "車が起動中の場合、この設定を変更するとopenpilotが再起動します。" +msgid "Changing this setting will restart sunnypilot if the car is powered on." +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Choose your sponsorship tier and confirm your support" +msgstr "" #: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Click \"add new device\" and scan the QR code on the right" @@ -166,8 +170,8 @@ msgid "Decline" msgstr "拒否する" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "Decline, uninstall openpilot" -msgstr "拒否してopenpilotをアンインストール" +msgid "Decline, uninstall sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Developer" @@ -229,6 +233,10 @@ msgstr "ETH" msgid "EXPERIMENTAL MODE ON" msgstr "実験モードON" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Early Access: Become a sunnypilot Sponsor" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/developer.py #: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" @@ -255,16 +263,16 @@ msgid "Enable Tethering" msgstr "テザリングを有効化" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable driver monitoring even when openpilot is not engaged." -msgstr "openpilotが未作動でもドライバーモニタリングを有効にします。" +msgid "Enable driver monitoring even when sunnypilot is not engaged." +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable openpilot" -msgstr "openpilotを有効化" +msgid "Enable sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable the openpilot longitudinal control (alpha) toggle to allow Experimental mode." -msgstr "openpilot縦制御(アルファ)のトグルを有効にすると実験モードが使用できます。" +msgid "Enable the sunnypilot longitudinal control (alpha) toggle to allow Experimental mode." +msgstr "" #: system/ui/widgets/network.py msgid "Enter APN" @@ -282,6 +290,10 @@ msgstr "新しいテザリングのパスワードを入力" msgid "Enter password" msgstr "パスワードを入力" +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Enter search query" +msgstr "" + #: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "Enter your GitHub username" msgstr "GitHubユーザー名を入力" @@ -314,6 +326,10 @@ msgstr "大量配信" msgid "Firehose Mode" msgstr "Firehoseモード" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Follow the prompts to complete the pairing process" +msgstr "" + #: system/ui/widgets/network.py msgid "Forget" msgstr "削除" @@ -346,10 +362,18 @@ msgstr "インストール" msgid "IP Address" msgstr "IPアドレス" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Install Update" msgstr "アップデートをインストール" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Join our Community Forum at https://community.sunnypilot.ai and reach out to a moderator if you have issues" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Joystick Debug Mode" msgstr "ジョイスティックデバッグモード" @@ -362,6 +386,10 @@ msgstr "読み込み中" msgid "LTE" msgstr "LTE" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "Lateral Maneuver Mode" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Longitudinal Maneuver Mode" msgstr "縦制御マヌーバーモード" @@ -436,6 +464,10 @@ msgstr "デバイスをペアリング" msgid "Pair device" msgstr "デバイスをペアリング" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Pair your GitHub account" +msgstr "" + #: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Pair your device to your comma account" msgstr "デバイスをあなたの comma アカウントにペアリング" @@ -481,6 +513,10 @@ msgstr "リセット" msgid "REVIEW" msgstr "確認" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Re-enter the \"sunnylink\" panel to verify sponsorship status" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reboot" msgstr "再起動" @@ -538,8 +574,8 @@ msgid "Review Training Guide" msgstr "トレーニングガイドを確認" #: openpilot/selfdrive/ui/layouts/settings/device.py -msgid "Review the rules, features, and limitations of openpilot" -msgstr "openpilotのルール、機能、制限を確認" +msgid "Review the rules, features, and limitations of sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/software.py msgid "SELECT" @@ -549,10 +585,22 @@ msgstr "選択" msgid "SSH Keys" msgstr "SSH鍵" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to login to your GitHub account" +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to visit sunnyhaibin's GitHub Sponsors page" +msgstr "" + #: system/ui/widgets/network.py msgid "Scanning Wi-Fi networks..." msgstr "Wi‑Fiネットワークを検索中..." +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Search" +msgstr "" + #: system/ui/widgets/option_dialog.py msgid "Select" msgstr "選択" @@ -654,12 +702,12 @@ msgid "Waiting to start" msgstr "開始待機中" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "Welcome to openpilot" -msgstr "openpilotへようこそ" +msgid "Welcome to sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "When enabled, pressing the accelerator pedal will disengage openpilot." -msgstr "有効にすると、アクセルを踏むとopenpilotが解除されます。" +msgid "When enabled, pressing the accelerator pedal will disengage sunnypilot." +msgstr "" #: openpilot/selfdrive/ui/layouts/sidebar.py msgid "Wi-Fi" @@ -674,12 +722,12 @@ msgid "Wrong password" msgstr "パスワードが違います" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "You must accept the Terms and Conditions in order to use openpilot." -msgstr "openpilotを使用するには、利用規約に同意する必要があります。" +msgid "You must accept the Terms of Service in order to use sunnypilot." +msgstr "" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "You must accept the Terms and Conditions to use openpilot. Read the latest terms at https://comma.ai/terms before continuing." -msgstr "openpilotを使用するには利用規約に同意する必要があります。続行する前に https://comma.ai/terms の最新の規約をお読みください。" +msgid "You must accept the Terms of Service to use sunnypilot. Read the latest terms at https://sunnypilot.ai/terms before continuing." +msgstr "" #: openpilot/selfdrive/ui/onroad/driver_camera_dialog.py msgid "camera starting" @@ -745,26 +793,26 @@ msgstr "なし" msgid "now" msgstr "今" -#: openpilot/selfdrive/ui/layouts/settings/developer.py -msgid "openpilot Longitudinal Control (Alpha)" -msgstr "openpilot 縦制御(アルファ)" - -#: openpilot/selfdrive/ui/onroad/alert_renderer.py -msgid "openpilot Unavailable" -msgstr "openpilotは利用できません" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "openpilot longitudinal control may come in a future update." -msgstr "openpilotの縦制御は将来のアップデートで提供される可能性があります。" - -#: openpilot/selfdrive/ui/layouts/settings/device.py -msgid "openpilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." -msgstr "openpilotでは、デバイスの取り付け角度が左右±4°、上方向5°以内、下方向9°以内である必要があります。" - #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "right" msgstr "右" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "sunnypilot Longitudinal Control (Alpha)" +msgstr "" + +#: openpilot/selfdrive/ui/onroad/alert_renderer.py +msgid "sunnypilot Unavailable" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "sunnypilot longitudinal control may come in a future update." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "sunnypilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." +msgstr "" + #: system/ui/widgets/network.py msgid "unmetered" msgstr "非従量" diff --git a/selfdrive/ui/translations/app_ko.po b/selfdrive/ui/translations/app_ko.po index 24306ae02a..d5c5eb34a2 100644 --- a/selfdrive/ui/translations/app_ko.po +++ b/selfdrive/ui/translations/app_ko.po @@ -142,8 +142,12 @@ msgid "Change Language" msgstr "언어 변경" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Changing this setting will restart openpilot if the car is powered on." -msgstr "차량 전원이 켜져 있으면 이 설정을 변경할 때 openpilot이 재시작됩니다." +msgid "Changing this setting will restart sunnypilot if the car is powered on." +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Choose your sponsorship tier and confirm your support" +msgstr "" #: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Click \"add new device\" and scan the QR code on the right" @@ -166,8 +170,8 @@ msgid "Decline" msgstr "거부" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "Decline, uninstall openpilot" -msgstr "거부하고 openpilot 제거" +msgid "Decline, uninstall sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Developer" @@ -229,6 +233,10 @@ msgstr "ETH" msgid "EXPERIMENTAL MODE ON" msgstr "실험 모드 켜짐" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Early Access: Become a sunnypilot Sponsor" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/developer.py #: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" @@ -255,16 +263,16 @@ msgid "Enable Tethering" msgstr "테더링 사용" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable driver monitoring even when openpilot is not engaged." -msgstr "openpilot이 작동 중이 아닐 때도 운전자 모니터링을 사용합니다." +msgid "Enable driver monitoring even when sunnypilot is not engaged." +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable openpilot" -msgstr "openpilot 사용" +msgid "Enable sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable the openpilot longitudinal control (alpha) toggle to allow Experimental mode." -msgstr "실험 모드를 사용하려면 openpilot 롱컨 제어(알파) 토글을 켜세요." +msgid "Enable the sunnypilot longitudinal control (alpha) toggle to allow Experimental mode." +msgstr "" #: system/ui/widgets/network.py msgid "Enter APN" @@ -282,6 +290,10 @@ msgstr "새 테더링 비밀번호 입력" msgid "Enter password" msgstr "비밀번호 입력" +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Enter search query" +msgstr "" + #: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "Enter your GitHub username" msgstr "GitHub 사용자 이름 입력" @@ -314,6 +326,10 @@ msgstr "파이어호스" msgid "Firehose Mode" msgstr "파이어호스 모드" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Follow the prompts to complete the pairing process" +msgstr "" + #: system/ui/widgets/network.py msgid "Forget" msgstr "삭제" @@ -346,10 +362,18 @@ msgstr "설치" msgid "IP Address" msgstr "IP 주소" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Install Update" msgstr "업데이트 설치" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Join our Community Forum at https://community.sunnypilot.ai and reach out to a moderator if you have issues" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Joystick Debug Mode" msgstr "조이스틱 디버그 모드" @@ -362,6 +386,10 @@ msgstr "로딩 중" msgid "LTE" msgstr "LTE" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "Lateral Maneuver Mode" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Longitudinal Maneuver Mode" msgstr "롱컨 기동 모드" @@ -436,6 +464,10 @@ msgstr "장치 페어링" msgid "Pair device" msgstr "장치 페어링" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Pair your GitHub account" +msgstr "" + #: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Pair your device to your comma account" msgstr "장치를 귀하의 comma 계정에 페어링하세요" @@ -481,6 +513,10 @@ msgstr "재설정" msgid "REVIEW" msgstr "검토" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Re-enter the \"sunnylink\" panel to verify sponsorship status" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reboot" msgstr "재시작" @@ -538,8 +574,8 @@ msgid "Review Training Guide" msgstr "학습 가이드 검토" #: openpilot/selfdrive/ui/layouts/settings/device.py -msgid "Review the rules, features, and limitations of openpilot" -msgstr "openpilot의 규칙, 기능 및 제한을 검토" +msgid "Review the rules, features, and limitations of sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/software.py msgid "SELECT" @@ -549,10 +585,22 @@ msgstr "선택" msgid "SSH Keys" msgstr "SSH 키" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to login to your GitHub account" +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to visit sunnyhaibin's GitHub Sponsors page" +msgstr "" + #: system/ui/widgets/network.py msgid "Scanning Wi-Fi networks..." msgstr "Wi‑Fi 네트워크 검색 중..." +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Search" +msgstr "" + #: system/ui/widgets/option_dialog.py msgid "Select" msgstr "선택" @@ -654,12 +702,12 @@ msgid "Waiting to start" msgstr "시작 대기 중" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "Welcome to openpilot" -msgstr "openpilot에 오신 것을 환영합니다" +msgid "Welcome to sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "When enabled, pressing the accelerator pedal will disengage openpilot." -msgstr "이 옵션을 켜면 가속 페달을 밟을 때 openpilot이 해제됩니다." +msgid "When enabled, pressing the accelerator pedal will disengage sunnypilot." +msgstr "" #: openpilot/selfdrive/ui/layouts/sidebar.py msgid "Wi-Fi" @@ -674,12 +722,12 @@ msgid "Wrong password" msgstr "비밀번호가 올바르지 않습니다" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "You must accept the Terms and Conditions in order to use openpilot." -msgstr "openpilot을 사용하려면 약관에 동의해야 합니다." +msgid "You must accept the Terms of Service in order to use sunnypilot." +msgstr "" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "You must accept the Terms and Conditions to use openpilot. Read the latest terms at https://comma.ai/terms before continuing." -msgstr "openpilot을 사용하려면 약관에 동의해야 합니다. 계속하기 전에 https://comma.ai/terms 에서 최신 약관을 읽어주세요." +msgid "You must accept the Terms of Service to use sunnypilot. Read the latest terms at https://sunnypilot.ai/terms before continuing." +msgstr "" #: openpilot/selfdrive/ui/onroad/driver_camera_dialog.py msgid "camera starting" @@ -745,26 +793,26 @@ msgstr "없음" msgid "now" msgstr "지금" -#: openpilot/selfdrive/ui/layouts/settings/developer.py -msgid "openpilot Longitudinal Control (Alpha)" -msgstr "openpilot 롱컨 제어(알파)" - -#: openpilot/selfdrive/ui/onroad/alert_renderer.py -msgid "openpilot Unavailable" -msgstr "openpilot 사용 불가" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "openpilot longitudinal control may come in a future update." -msgstr "openpilot 롱컨 제어는 향후 업데이트에서 제공될 수 있습니다." - -#: openpilot/selfdrive/ui/layouts/settings/device.py -msgid "openpilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." -msgstr "openpilot은 장치를 좌우 4°, 위쪽 5°, 아래쪽 9° 이내로 장착해야 합니다." - #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "right" msgstr "오른쪽" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "sunnypilot Longitudinal Control (Alpha)" +msgstr "" + +#: openpilot/selfdrive/ui/onroad/alert_renderer.py +msgid "sunnypilot Unavailable" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "sunnypilot longitudinal control may come in a future update." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "sunnypilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." +msgstr "" + #: system/ui/widgets/network.py msgid "unmetered" msgstr "비종량제" diff --git a/selfdrive/ui/translations/app_pt-BR.po b/selfdrive/ui/translations/app_pt-BR.po index 58f2094479..5244d9f63f 100644 --- a/selfdrive/ui/translations/app_pt-BR.po +++ b/selfdrive/ui/translations/app_pt-BR.po @@ -142,8 +142,12 @@ msgid "Change Language" msgstr "Alterar Idioma" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Changing this setting will restart openpilot if the car is powered on." -msgstr "Alterar esta configuração reiniciará o openpilot se o carro estiver ligado." +msgid "Changing this setting will restart sunnypilot if the car is powered on." +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Choose your sponsorship tier and confirm your support" +msgstr "" #: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Click \"add new device\" and scan the QR code on the right" @@ -166,8 +170,8 @@ msgid "Decline" msgstr "Recusar" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "Decline, uninstall openpilot" -msgstr "Recusar, desinstalar o openpilot" +msgid "Decline, uninstall sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Developer" @@ -229,6 +233,10 @@ msgstr "ETH" msgid "EXPERIMENTAL MODE ON" msgstr "MODO EXPERIMENTAL ATIVO" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Early Access: Become a sunnypilot Sponsor" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/developer.py #: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" @@ -255,16 +263,16 @@ msgid "Enable Tethering" msgstr "Ativar compartilhamento" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable driver monitoring even when openpilot is not engaged." -msgstr "Ativar monitoramento do motorista mesmo quando o openpilot não está engajado." +msgid "Enable driver monitoring even when sunnypilot is not engaged." +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable openpilot" -msgstr "Ativar openpilot" +msgid "Enable sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable the openpilot longitudinal control (alpha) toggle to allow Experimental mode." -msgstr "Ative a opção de controle longitudinal do openpilot (alpha) para permitir o Modo Experimental." +msgid "Enable the sunnypilot longitudinal control (alpha) toggle to allow Experimental mode." +msgstr "" #: system/ui/widgets/network.py msgid "Enter APN" @@ -282,6 +290,10 @@ msgstr "Digite nova senha tethering" msgid "Enter password" msgstr "Digite a senha" +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Enter search query" +msgstr "" + #: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "Enter your GitHub username" msgstr "Digite seu nome de usuário do GitHub" @@ -314,6 +326,10 @@ msgstr "Fluxo contínuo" msgid "Firehose Mode" msgstr "Modo Firehose" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Follow the prompts to complete the pairing process" +msgstr "" + #: system/ui/widgets/network.py msgid "Forget" msgstr "Esquecer" @@ -346,10 +362,18 @@ msgstr "INSTALAR" msgid "IP Address" msgstr "Endereço IP" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Install Update" msgstr "Instalar Atualização" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Join our Community Forum at https://community.sunnypilot.ai and reach out to a moderator if you have issues" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Joystick Debug Mode" msgstr "Modo de Depuração do Joystick" @@ -362,6 +386,10 @@ msgstr "CARREGANDO" msgid "LTE" msgstr "LTE" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "Lateral Maneuver Mode" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Longitudinal Maneuver Mode" msgstr "Modo de Manobra Longitudinal" @@ -436,6 +464,10 @@ msgstr "Emparelhar Dispositivo" msgid "Pair device" msgstr "Emparelhar dispositivo" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Pair your GitHub account" +msgstr "" + #: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Pair your device to your comma account" msgstr "Emparelhe seu dispositivo à sua conta comma" @@ -481,6 +513,10 @@ msgstr "REDEFINIR" msgid "REVIEW" msgstr "REVISAR" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Re-enter the \"sunnylink\" panel to verify sponsorship status" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reboot" msgstr "Reiniciar" @@ -538,8 +574,8 @@ msgid "Review Training Guide" msgstr "Revisar Guia de Treinamento" #: openpilot/selfdrive/ui/layouts/settings/device.py -msgid "Review the rules, features, and limitations of openpilot" -msgstr "Revise as regras, recursos e limitações do openpilot" +msgid "Review the rules, features, and limitations of sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/software.py msgid "SELECT" @@ -549,10 +585,22 @@ msgstr "SELECIONAR" msgid "SSH Keys" msgstr "Chaves SSH" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to login to your GitHub account" +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to visit sunnyhaibin's GitHub Sponsors page" +msgstr "" + #: system/ui/widgets/network.py msgid "Scanning Wi-Fi networks..." msgstr "Procurando redes Wi-Fi..." +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Search" +msgstr "" + #: system/ui/widgets/option_dialog.py msgid "Select" msgstr "Selecione" @@ -654,12 +702,12 @@ msgid "Waiting to start" msgstr "Aguardando para iniciar" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "Welcome to openpilot" -msgstr "Bem-vindo ao openpilot" +msgid "Welcome to sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "When enabled, pressing the accelerator pedal will disengage openpilot." -msgstr "Quando ativado, pressionar o pedal do acelerador desengajará o openpilot." +msgid "When enabled, pressing the accelerator pedal will disengage sunnypilot." +msgstr "" #: openpilot/selfdrive/ui/layouts/sidebar.py msgid "Wi-Fi" @@ -674,12 +722,12 @@ msgid "Wrong password" msgstr "Senha errada" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "You must accept the Terms and Conditions in order to use openpilot." -msgstr "Você deve aceitar os Termos e Condições para usar o openpilot." +msgid "You must accept the Terms of Service in order to use sunnypilot." +msgstr "" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "You must accept the Terms and Conditions to use openpilot. Read the latest terms at https://comma.ai/terms before continuing." -msgstr "Você deve aceitar os Termos e Condições para usar o openpilot. Leia os termos mais recentes em https://comma.ai/terms antes de continuar." +msgid "You must accept the Terms of Service to use sunnypilot. Read the latest terms at https://sunnypilot.ai/terms before continuing." +msgstr "" #: openpilot/selfdrive/ui/onroad/driver_camera_dialog.py msgid "camera starting" @@ -745,26 +793,26 @@ msgstr "nunca" msgid "now" msgstr "agora" -#: openpilot/selfdrive/ui/layouts/settings/developer.py -msgid "openpilot Longitudinal Control (Alpha)" -msgstr "Controle Longitudinal do openpilot (Alpha)" - -#: openpilot/selfdrive/ui/onroad/alert_renderer.py -msgid "openpilot Unavailable" -msgstr "openpilot Indisponível" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "openpilot longitudinal control may come in a future update." -msgstr "o controle longitudinal do openpilot pode vir em uma atualização futura." - -#: openpilot/selfdrive/ui/layouts/settings/device.py -msgid "openpilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." -msgstr "o openpilot requer que o dispositivo seja montado dentro de 4° para a esquerda ou direita e dentro de 5° para cima ou 9° para baixo." - #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "right" msgstr "à direita" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "sunnypilot Longitudinal Control (Alpha)" +msgstr "" + +#: openpilot/selfdrive/ui/onroad/alert_renderer.py +msgid "sunnypilot Unavailable" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "sunnypilot longitudinal control may come in a future update." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "sunnypilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." +msgstr "" + #: system/ui/widgets/network.py msgid "unmetered" msgstr "ilimitados" diff --git a/selfdrive/ui/translations/app_th.po b/selfdrive/ui/translations/app_th.po index 4e45cae14b..4897809528 100644 --- a/selfdrive/ui/translations/app_th.po +++ b/selfdrive/ui/translations/app_th.po @@ -142,8 +142,12 @@ msgid "Change Language" msgstr "เปลี่ยนภาษา" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Changing this setting will restart openpilot if the car is powered on." -msgstr "การเปลี่ยนการตั้งค่านี้จะรีสตาร์ท Openpilot หากรถเปิดอยู่" +msgid "Changing this setting will restart sunnypilot if the car is powered on." +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Choose your sponsorship tier and confirm your support" +msgstr "" #: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Click \"add new device\" and scan the QR code on the right" @@ -166,8 +170,8 @@ msgid "Decline" msgstr "ปฏิเสธ" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "Decline, uninstall openpilot" -msgstr "ปฏิเสธ ถอนการติดตั้ง openpilot" +msgid "Decline, uninstall sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Developer" @@ -229,6 +233,10 @@ msgstr "ผลประโยชน์ทับซ้อน" msgid "EXPERIMENTAL MODE ON" msgstr "โหมดทดลองเปิดอยู่" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Early Access: Become a sunnypilot Sponsor" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/developer.py #: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" @@ -255,16 +263,16 @@ msgid "Enable Tethering" msgstr "เปิดใช้งานการปล่อยสัญญาณ" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable driver monitoring even when openpilot is not engaged." -msgstr "เปิดใช้งานการตรวจสอบไดรเวอร์แม้ว่าจะไม่ได้ใช้งาน openpilot ก็ตาม" +msgid "Enable driver monitoring even when sunnypilot is not engaged." +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable openpilot" -msgstr "เปิดใช้งานโอเพ่นไพลอต" +msgid "Enable sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable the openpilot longitudinal control (alpha) toggle to allow Experimental mode." -msgstr "เปิดใช้งานการสลับการควบคุมตามยาวของ openpilot (อัลฟา) เพื่ออนุญาตโหมดการทดลอง" +msgid "Enable the sunnypilot longitudinal control (alpha) toggle to allow Experimental mode." +msgstr "" #: system/ui/widgets/network.py msgid "Enter APN" @@ -282,6 +290,10 @@ msgstr "ป้อนรหัสผ่านการปล่อยสัญญ msgid "Enter password" msgstr "ใส่รหัสผ่าน" +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Enter search query" +msgstr "" + #: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "Enter your GitHub username" msgstr "ป้อนชื่อผู้ใช้ GitHub ของคุณ" @@ -314,6 +326,10 @@ msgstr "สายดับเพลิง" msgid "Firehose Mode" msgstr "โหมดสายดับเพลิง" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Follow the prompts to complete the pairing process" +msgstr "" + #: system/ui/widgets/network.py msgid "Forget" msgstr "ลืม" @@ -346,10 +362,18 @@ msgstr "ติดตั้ง" msgid "IP Address" msgstr "ที่อยู่ IP" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Install Update" msgstr "ติดตั้งอัปเดต" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Join our Community Forum at https://community.sunnypilot.ai and reach out to a moderator if you have issues" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Joystick Debug Mode" msgstr "โหมดดีบักจอยสติ๊ก" @@ -362,6 +386,10 @@ msgstr "กำลังโหลด" msgid "LTE" msgstr "แอลทีที" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "Lateral Maneuver Mode" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Longitudinal Maneuver Mode" msgstr "โหมดการซ้อมรบตามยาว" @@ -436,6 +464,10 @@ msgstr "จับคู่อุปกรณ์" msgid "Pair device" msgstr "จับคู่อุปกรณ์" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Pair your GitHub account" +msgstr "" + #: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Pair your device to your comma account" msgstr "จับคู่อุปกรณ์ของคุณกับบัญชีลูกน้ำของคุณ" @@ -481,6 +513,10 @@ msgstr "รีเซ็ต" msgid "REVIEW" msgstr "ทบทวน" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Re-enter the \"sunnylink\" panel to verify sponsorship status" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reboot" msgstr "รีบูต" @@ -538,8 +574,8 @@ msgid "Review Training Guide" msgstr "ทบทวนคู่มือการฝึกอบรม" #: openpilot/selfdrive/ui/layouts/settings/device.py -msgid "Review the rules, features, and limitations of openpilot" -msgstr "ตรวจสอบกฎ คุณสมบัติ และข้อจำกัดของ openpilot" +msgid "Review the rules, features, and limitations of sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/software.py msgid "SELECT" @@ -549,10 +585,22 @@ msgstr "เลือก" msgid "SSH Keys" msgstr "คีย์ SSH" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to login to your GitHub account" +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to visit sunnyhaibin's GitHub Sponsors page" +msgstr "" + #: system/ui/widgets/network.py msgid "Scanning Wi-Fi networks..." msgstr "กำลังสแกนเครือข่าย Wi-Fi..." +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Search" +msgstr "" + #: system/ui/widgets/option_dialog.py msgid "Select" msgstr "เลือก" @@ -654,12 +702,12 @@ msgid "Waiting to start" msgstr "กำลังรอที่จะเริ่ม" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "Welcome to openpilot" -msgstr "ยินดีต้อนรับสู่ openpilot" +msgid "Welcome to sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "When enabled, pressing the accelerator pedal will disengage openpilot." -msgstr "เมื่อเปิดใช้งาน การกดแป้นคันเร่งจะเป็นการปลดโอเพ่นไพลอต" +msgid "When enabled, pressing the accelerator pedal will disengage sunnypilot." +msgstr "" #: openpilot/selfdrive/ui/layouts/sidebar.py msgid "Wi-Fi" @@ -674,12 +722,12 @@ msgid "Wrong password" msgstr "รหัสผ่านผิด" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "You must accept the Terms and Conditions in order to use openpilot." -msgstr "คุณต้องยอมรับข้อกำหนดและเงื่อนไขเพื่อใช้งาน openpilot" +msgid "You must accept the Terms of Service in order to use sunnypilot." +msgstr "" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "You must accept the Terms and Conditions to use openpilot. Read the latest terms at https://comma.ai/terms before continuing." -msgstr "คุณต้องยอมรับข้อกำหนดและเงื่อนไขเพื่อใช้ openpilot อ่านข้อกำหนดล่าสุดได้ที่ https://comma.ai/terms ก่อนดำเนินการต่อ" +msgid "You must accept the Terms of Service to use sunnypilot. Read the latest terms at https://sunnypilot.ai/terms before continuing." +msgstr "" #: openpilot/selfdrive/ui/onroad/driver_camera_dialog.py msgid "camera starting" @@ -745,26 +793,26 @@ msgstr "ไม่เคย" msgid "now" msgstr "ตอนนี้" -#: openpilot/selfdrive/ui/layouts/settings/developer.py -msgid "openpilot Longitudinal Control (Alpha)" -msgstr "การควบคุมตามยาวของ openpilot (อัลฟา)" - -#: openpilot/selfdrive/ui/onroad/alert_renderer.py -msgid "openpilot Unavailable" -msgstr "openpilot ไม่พร้อมใช้งาน" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "openpilot longitudinal control may come in a future update." -msgstr "ระบบควบคุมตามยาวของ openpilot อาจมาในการอัปเดตครั้งถัดไป" - -#: openpilot/selfdrive/ui/layouts/settings/device.py -msgid "openpilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." -msgstr "openpilot ต้องติดตั้งอุปกรณ์ให้อยู่ในช่วงเอียงซ้ายหรือขวาไม่เกิน 4° และเอียงขึ้นไม่เกิน 5° หรือเอียงลงไม่เกิน 9°" - #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "right" msgstr "ขวา" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "sunnypilot Longitudinal Control (Alpha)" +msgstr "" + +#: openpilot/selfdrive/ui/onroad/alert_renderer.py +msgid "sunnypilot Unavailable" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "sunnypilot longitudinal control may come in a future update." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "sunnypilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." +msgstr "" + #: system/ui/widgets/network.py msgid "unmetered" msgstr "ไม่จำกัดปริมาณ" diff --git a/selfdrive/ui/translations/app_tr.po b/selfdrive/ui/translations/app_tr.po index dbb5b325a6..a6da586bbd 100644 --- a/selfdrive/ui/translations/app_tr.po +++ b/selfdrive/ui/translations/app_tr.po @@ -142,8 +142,12 @@ msgid "Change Language" msgstr "Dili Değiştir" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Changing this setting will restart openpilot if the car is powered on." -msgstr " Bu ayarı değiştirmek, araç çalışıyorsa openpilot'u yeniden başlatacaktır." +msgid "Changing this setting will restart sunnypilot if the car is powered on." +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Choose your sponsorship tier and confirm your support" +msgstr "" #: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Click \"add new device\" and scan the QR code on the right" @@ -166,8 +170,8 @@ msgid "Decline" msgstr "Reddet" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "Decline, uninstall openpilot" -msgstr "Reddet, openpilot'u kaldır" +msgid "Decline, uninstall sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Developer" @@ -229,6 +233,10 @@ msgstr "ETH" msgid "EXPERIMENTAL MODE ON" msgstr "DENEYSEL MOD AÇIK" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Early Access: Become a sunnypilot Sponsor" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/developer.py #: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" @@ -255,16 +263,16 @@ msgid "Enable Tethering" msgstr "İnternet Paylaşımını Etkinleştir" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable driver monitoring even when openpilot is not engaged." -msgstr "openpilot devrede değilken bile sürücü izlemesini etkinleştir." +msgid "Enable driver monitoring even when sunnypilot is not engaged." +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable openpilot" -msgstr "openpilot'u etkinleştir" +msgid "Enable sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable the openpilot longitudinal control (alpha) toggle to allow Experimental mode." -msgstr "Deneysel modu etkinleştirmek için openpilot boylamsal kontrolünü (alfa) açın." +msgid "Enable the sunnypilot longitudinal control (alpha) toggle to allow Experimental mode." +msgstr "" #: system/ui/widgets/network.py msgid "Enter APN" @@ -282,6 +290,10 @@ msgstr "Yeni internet paylaşımı şifresini girin" msgid "Enter password" msgstr "Şifre girin" +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Enter search query" +msgstr "" + #: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "Enter your GitHub username" msgstr "GitHub kullanıcı adınızı girin" @@ -314,6 +326,10 @@ msgstr "Yoğun veri akışı" msgid "Firehose Mode" msgstr "Firehose Modu" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Follow the prompts to complete the pairing process" +msgstr "" + #: system/ui/widgets/network.py msgid "Forget" msgstr "Unut" @@ -346,10 +362,18 @@ msgstr "YÜKLE" msgid "IP Address" msgstr "IP Adresi" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Install Update" msgstr "Güncellemeyi Yükle" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Join our Community Forum at https://community.sunnypilot.ai and reach out to a moderator if you have issues" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Joystick Debug Mode" msgstr "Joystick Hata Ayıklama Modu" @@ -362,6 +386,10 @@ msgstr "YÜKLENİYOR" msgid "LTE" msgstr "LTE" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "Lateral Maneuver Mode" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Longitudinal Maneuver Mode" msgstr "Boylamsal Manevra Modu" @@ -436,6 +464,10 @@ msgstr "Cihazı Eşle" msgid "Pair device" msgstr "Cihazı eşle" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Pair your GitHub account" +msgstr "" + #: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Pair your device to your comma account" msgstr "Cihazınızı comma hesabınızla eşleştirin" @@ -481,6 +513,10 @@ msgstr "SIFIRLA" msgid "REVIEW" msgstr "GÖZDEN GEÇİR" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Re-enter the \"sunnylink\" panel to verify sponsorship status" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reboot" msgstr "Yeniden Başlat" @@ -538,8 +574,8 @@ msgid "Review Training Guide" msgstr "Eğitim Kılavuzunu İncele" #: openpilot/selfdrive/ui/layouts/settings/device.py -msgid "Review the rules, features, and limitations of openpilot" -msgstr "openpilot'un kurallarını, özelliklerini ve sınırlamalarını gözden geçirin" +msgid "Review the rules, features, and limitations of sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/software.py msgid "SELECT" @@ -549,10 +585,22 @@ msgstr "SEÇ" msgid "SSH Keys" msgstr "SSH Anahtarları" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to login to your GitHub account" +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to visit sunnyhaibin's GitHub Sponsors page" +msgstr "" + #: system/ui/widgets/network.py msgid "Scanning Wi-Fi networks..." msgstr "Wi‑Fi ağları taranıyor..." +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Search" +msgstr "" + #: system/ui/widgets/option_dialog.py msgid "Select" msgstr "Seç" @@ -654,12 +702,12 @@ msgid "Waiting to start" msgstr "Başlatma bekleniyor" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "Welcome to openpilot" -msgstr "openpilot'a hoş geldiniz" +msgid "Welcome to sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "When enabled, pressing the accelerator pedal will disengage openpilot." -msgstr "Etkinleştirildiğinde, gaz pedalına basmak openpilot'u devreden çıkarır." +msgid "When enabled, pressing the accelerator pedal will disengage sunnypilot." +msgstr "" #: openpilot/selfdrive/ui/layouts/sidebar.py msgid "Wi-Fi" @@ -674,12 +722,12 @@ msgid "Wrong password" msgstr "Yanlış şifre" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "You must accept the Terms and Conditions in order to use openpilot." -msgstr "openpilot'u kullanmak için Şartlar ve Koşulları kabul etmelisiniz." +msgid "You must accept the Terms of Service in order to use sunnypilot." +msgstr "" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "You must accept the Terms and Conditions to use openpilot. Read the latest terms at https://comma.ai/terms before continuing." -msgstr "openpilot'u kullanmak için Şartlar ve Koşulları kabul etmelisiniz. Devam etmeden önce en güncel şartları https://comma.ai/terms adresinde okuyun." +msgid "You must accept the Terms of Service to use sunnypilot. Read the latest terms at https://sunnypilot.ai/terms before continuing." +msgstr "" #: openpilot/selfdrive/ui/onroad/driver_camera_dialog.py msgid "camera starting" @@ -745,26 +793,26 @@ msgstr "asla" msgid "now" msgstr "şimdi" -#: openpilot/selfdrive/ui/layouts/settings/developer.py -msgid "openpilot Longitudinal Control (Alpha)" -msgstr "openpilot Boylamsal Kontrol (Alfa)" - -#: openpilot/selfdrive/ui/onroad/alert_renderer.py -msgid "openpilot Unavailable" -msgstr "openpilot Kullanılamıyor" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "openpilot longitudinal control may come in a future update." -msgstr "openpilot boylamsal kontrolü gelecekteki bir güncellemede gelebilir." - -#: openpilot/selfdrive/ui/layouts/settings/device.py -msgid "openpilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." -msgstr "openpilot, cihazın sağa/sola 4° ve yukarı 5° veya aşağı 9° içinde monte edilmesini gerektirir." - #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "right" msgstr "sağ" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "sunnypilot Longitudinal Control (Alpha)" +msgstr "" + +#: openpilot/selfdrive/ui/onroad/alert_renderer.py +msgid "sunnypilot Unavailable" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "sunnypilot longitudinal control may come in a future update." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "sunnypilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." +msgstr "" + #: system/ui/widgets/network.py msgid "unmetered" msgstr "ölçüsüz" diff --git a/selfdrive/ui/translations/app_uk.po b/selfdrive/ui/translations/app_uk.po index 3f3d186657..43d78fad1c 100644 --- a/selfdrive/ui/translations/app_uk.po +++ b/selfdrive/ui/translations/app_uk.po @@ -142,8 +142,12 @@ msgid "Change Language" msgstr "Змінити мову" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Changing this setting will restart openpilot if the car is powered on." -msgstr "Зміна цього параметра призведе до перезапуску openpilot, якщо автомобіль увімкнено." +msgid "Changing this setting will restart sunnypilot if the car is powered on." +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Choose your sponsorship tier and confirm your support" +msgstr "" #: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Click \"add new device\" and scan the QR code on the right" @@ -166,8 +170,8 @@ msgid "Decline" msgstr "Відхилити" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "Decline, uninstall openpilot" -msgstr "Відхилити, видалити openpilot" +msgid "Decline, uninstall sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Developer" @@ -229,6 +233,10 @@ msgstr "ETH" msgid "EXPERIMENTAL MODE ON" msgstr "ЕКСПЕРИМЕНТ. РЕЖИМ" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Early Access: Become a sunnypilot Sponsor" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/developer.py #: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" @@ -255,16 +263,16 @@ msgid "Enable Tethering" msgstr "Увімкнути точку доступу" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable driver monitoring even when openpilot is not engaged." -msgstr "Увімкнути моніторинг водія, навіть коли openpilot не ввімкнено." +msgid "Enable driver monitoring even when sunnypilot is not engaged." +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable openpilot" -msgstr "Увімкнути openpilot" +msgid "Enable sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable the openpilot longitudinal control (alpha) toggle to allow Experimental mode." -msgstr "Увімкніть перемикач поздовжнього керування openpilot (альфа), щоб увімкнути експериментальний режим." +msgid "Enable the sunnypilot longitudinal control (alpha) toggle to allow Experimental mode." +msgstr "" #: system/ui/widgets/network.py msgid "Enter APN" @@ -282,6 +290,10 @@ msgstr "Введіть новий пароль для модему" msgid "Enter password" msgstr "Введіть пароль" +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Enter search query" +msgstr "" + #: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "Enter your GitHub username" msgstr "Введіть ваш логін GitHub" @@ -314,6 +326,10 @@ msgstr "Злива" msgid "Firehose Mode" msgstr "Режим зливи" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Follow the prompts to complete the pairing process" +msgstr "" + #: system/ui/widgets/network.py msgid "Forget" msgstr "Забути" @@ -346,10 +362,18 @@ msgstr "ВСТАНОВ." msgid "IP Address" msgstr "IP-адреса" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Install Update" msgstr "Встановити оновлення" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Join our Community Forum at https://community.sunnypilot.ai and reach out to a moderator if you have issues" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Joystick Debug Mode" msgstr "Режим зневадження джойстика" @@ -362,6 +386,10 @@ msgstr "ЗАВАНТАЖЕННЯ" msgid "LTE" msgstr "LTE" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "Lateral Maneuver Mode" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Longitudinal Maneuver Mode" msgstr "Режим поздовжнього маневрування" @@ -436,6 +464,10 @@ msgstr "Підключити пристрій" msgid "Pair device" msgstr "Підключити пристрій" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Pair your GitHub account" +msgstr "" + #: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Pair your device to your comma account" msgstr "Підключіть свій пристрій до обліковки comma connect" @@ -481,6 +513,10 @@ msgstr "Скинути" msgid "REVIEW" msgstr "ДИВИТИСЬ" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Re-enter the \"sunnylink\" panel to verify sponsorship status" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reboot" msgstr "Перезавантажити" @@ -538,8 +574,8 @@ msgid "Review Training Guide" msgstr "Переглянути посібник з навчання" #: openpilot/selfdrive/ui/layouts/settings/device.py -msgid "Review the rules, features, and limitations of openpilot" -msgstr "Перегляньте правила, функції та обмеження openpilot" +msgid "Review the rules, features, and limitations of sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/software.py msgid "SELECT" @@ -549,10 +585,22 @@ msgstr "ВИБРАТИ" msgid "SSH Keys" msgstr "SSH ключі" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to login to your GitHub account" +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to visit sunnyhaibin's GitHub Sponsors page" +msgstr "" + #: system/ui/widgets/network.py msgid "Scanning Wi-Fi networks..." msgstr "Пошук мереж..." +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Search" +msgstr "" + #: system/ui/widgets/option_dialog.py msgid "Select" msgstr "Вибрати" @@ -654,12 +702,12 @@ msgid "Waiting to start" msgstr "Очікування початку" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "Welcome to openpilot" -msgstr "Ласкаво просимо до openpilot" +msgid "Welcome to sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "When enabled, pressing the accelerator pedal will disengage openpilot." -msgstr "Якщо увімкнено, натискання на педаль акселератора вимкне openpilot." +msgid "When enabled, pressing the accelerator pedal will disengage sunnypilot." +msgstr "" #: openpilot/selfdrive/ui/layouts/sidebar.py msgid "Wi-Fi" @@ -674,12 +722,12 @@ msgid "Wrong password" msgstr "Невірний пароль" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "You must accept the Terms and Conditions in order to use openpilot." -msgstr "Ви повинні прийняти Умови та положення, щоб користуватися openpilot." +msgid "You must accept the Terms of Service in order to use sunnypilot." +msgstr "" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "You must accept the Terms and Conditions to use openpilot. Read the latest terms at https://comma.ai/terms before continuing." -msgstr "Ви повинні прийняти Умови використання, щоб користуватися openpilot. Перед тим, як продовжити, ознайомтеся з останніми умовами на сайті https://comma.ai/terms." +msgid "You must accept the Terms of Service to use sunnypilot. Read the latest terms at https://sunnypilot.ai/terms before continuing." +msgstr "" #: openpilot/selfdrive/ui/onroad/driver_camera_dialog.py msgid "camera starting" @@ -745,26 +793,26 @@ msgstr "ніколи" msgid "now" msgstr "зараз" -#: openpilot/selfdrive/ui/layouts/settings/developer.py -msgid "openpilot Longitudinal Control (Alpha)" -msgstr "Поздовжнє керування openpilot (Альфа)" - -#: openpilot/selfdrive/ui/onroad/alert_renderer.py -msgid "openpilot Unavailable" -msgstr "openpilot Недоступний" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "openpilot longitudinal control may come in a future update." -msgstr "Поздовжнє керування openpilot може з'явитися в майбутньому оновленні." - -#: openpilot/selfdrive/ui/layouts/settings/device.py -msgid "openpilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." -msgstr "Для роботи openpilot потрібно, щоб пристрій був встановлений з нахилом не більше 4° вліво або вправо та не більше 5° вгору або 9° вниз. openpilot постійно калібрується, тому скидання калібрування потрібне рідко." - #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "right" msgstr "вправо" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "sunnypilot Longitudinal Control (Alpha)" +msgstr "" + +#: openpilot/selfdrive/ui/onroad/alert_renderer.py +msgid "sunnypilot Unavailable" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "sunnypilot longitudinal control may come in a future update." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "sunnypilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." +msgstr "" + #: system/ui/widgets/network.py msgid "unmetered" msgstr "необмеж." diff --git a/selfdrive/ui/translations/app_zh-CHS.po b/selfdrive/ui/translations/app_zh-CHS.po index 55a7c329f6..b01ba69392 100644 --- a/selfdrive/ui/translations/app_zh-CHS.po +++ b/selfdrive/ui/translations/app_zh-CHS.po @@ -142,8 +142,12 @@ msgid "Change Language" msgstr "更改语言" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Changing this setting will restart openpilot if the car is powered on." -msgstr "若车辆通电,更改此设置将重启 openpilot。" +msgid "Changing this setting will restart sunnypilot if the car is powered on." +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Choose your sponsorship tier and confirm your support" +msgstr "" #: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Click \"add new device\" and scan the QR code on the right" @@ -166,8 +170,8 @@ msgid "Decline" msgstr "拒绝" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "Decline, uninstall openpilot" -msgstr "拒绝并卸载 openpilot" +msgid "Decline, uninstall sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Developer" @@ -229,6 +233,10 @@ msgstr "ETH" msgid "EXPERIMENTAL MODE ON" msgstr "实验模式已开启" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Early Access: Become a sunnypilot Sponsor" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/developer.py #: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" @@ -255,16 +263,16 @@ msgid "Enable Tethering" msgstr "启用网络共享" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable driver monitoring even when openpilot is not engaged." -msgstr "即使未启用 openpilot 也启用驾驶员监控。" +msgid "Enable driver monitoring even when sunnypilot is not engaged." +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable openpilot" -msgstr "启用 openpilot" +msgid "Enable sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable the openpilot longitudinal control (alpha) toggle to allow Experimental mode." -msgstr "启用 openpilot 纵向控制(alpha)开关,以使用实验模式。" +msgid "Enable the sunnypilot longitudinal control (alpha) toggle to allow Experimental mode." +msgstr "" #: system/ui/widgets/network.py msgid "Enter APN" @@ -282,6 +290,10 @@ msgstr "输入新的网络共享密码" msgid "Enter password" msgstr "输入密码" +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Enter search query" +msgstr "" + #: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "Enter your GitHub username" msgstr "输入您的 GitHub 用户名" @@ -314,6 +326,10 @@ msgstr "数据洪流" msgid "Firehose Mode" msgstr "Firehose 模式" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Follow the prompts to complete the pairing process" +msgstr "" + #: system/ui/widgets/network.py msgid "Forget" msgstr "忘记" @@ -346,10 +362,18 @@ msgstr "安装" msgid "IP Address" msgstr "IP 地址" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Install Update" msgstr "安装更新" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Join our Community Forum at https://community.sunnypilot.ai and reach out to a moderator if you have issues" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Joystick Debug Mode" msgstr "摇杆调试模式" @@ -362,6 +386,10 @@ msgstr "加载中" msgid "LTE" msgstr "LTE" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "Lateral Maneuver Mode" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Longitudinal Maneuver Mode" msgstr "纵向操作模式" @@ -436,6 +464,10 @@ msgstr "配对设备" msgid "Pair device" msgstr "配对设备" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Pair your GitHub account" +msgstr "" + #: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Pair your device to your comma account" msgstr "将设备配对到您的 comma 账号" @@ -481,6 +513,10 @@ msgstr "重置" msgid "REVIEW" msgstr "查看" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Re-enter the \"sunnylink\" panel to verify sponsorship status" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reboot" msgstr "重启" @@ -538,8 +574,8 @@ msgid "Review Training Guide" msgstr "查看训练指南" #: openpilot/selfdrive/ui/layouts/settings/device.py -msgid "Review the rules, features, and limitations of openpilot" -msgstr "查看 openpilot 的规则、功能与限制" +msgid "Review the rules, features, and limitations of sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/software.py msgid "SELECT" @@ -549,10 +585,22 @@ msgstr "选择" msgid "SSH Keys" msgstr "SSH 密钥" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to login to your GitHub account" +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to visit sunnyhaibin's GitHub Sponsors page" +msgstr "" + #: system/ui/widgets/network.py msgid "Scanning Wi-Fi networks..." msgstr "正在扫描 Wi‑Fi 网络…" +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Search" +msgstr "" + #: system/ui/widgets/option_dialog.py msgid "Select" msgstr "选择" @@ -654,12 +702,12 @@ msgid "Waiting to start" msgstr "等待开始" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "Welcome to openpilot" -msgstr "欢迎使用 openpilot" +msgid "Welcome to sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "When enabled, pressing the accelerator pedal will disengage openpilot." -msgstr "启用后,踩下加速踏板将会脱离 openpilot。" +msgid "When enabled, pressing the accelerator pedal will disengage sunnypilot." +msgstr "" #: openpilot/selfdrive/ui/layouts/sidebar.py msgid "Wi-Fi" @@ -674,12 +722,12 @@ msgid "Wrong password" msgstr "密码错误" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "You must accept the Terms and Conditions in order to use openpilot." -msgstr "您必须接受条款与条件才能使用 openpilot。" +msgid "You must accept the Terms of Service in order to use sunnypilot." +msgstr "" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "You must accept the Terms and Conditions to use openpilot. Read the latest terms at https://comma.ai/terms before continuing." -msgstr "您必须接受条款与条件才能使用 openpilot。继续前请阅读 https://comma.ai/terms 上的最新条款。" +msgid "You must accept the Terms of Service to use sunnypilot. Read the latest terms at https://sunnypilot.ai/terms before continuing." +msgstr "" #: openpilot/selfdrive/ui/onroad/driver_camera_dialog.py msgid "camera starting" @@ -745,26 +793,26 @@ msgstr "从不" msgid "now" msgstr "现在" -#: openpilot/selfdrive/ui/layouts/settings/developer.py -msgid "openpilot Longitudinal Control (Alpha)" -msgstr "openpilot 纵向控制(Alpha)" - -#: openpilot/selfdrive/ui/onroad/alert_renderer.py -msgid "openpilot Unavailable" -msgstr "openpilot 无法使用" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "openpilot longitudinal control may come in a future update." -msgstr "openpilot 纵向控制可能会在未来更新中提供。" - -#: openpilot/selfdrive/ui/layouts/settings/device.py -msgid "openpilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." -msgstr "openpilot 要求设备安装在左右 4°、上 5° 或下 9° 以内。" - #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "right" msgstr "右" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "sunnypilot Longitudinal Control (Alpha)" +msgstr "" + +#: openpilot/selfdrive/ui/onroad/alert_renderer.py +msgid "sunnypilot Unavailable" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "sunnypilot longitudinal control may come in a future update." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "sunnypilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." +msgstr "" + #: system/ui/widgets/network.py msgid "unmetered" msgstr "不限流量" diff --git a/selfdrive/ui/translations/app_zh-CHT.po b/selfdrive/ui/translations/app_zh-CHT.po index 93f9b9ed8e..3cdcfe4e20 100644 --- a/selfdrive/ui/translations/app_zh-CHT.po +++ b/selfdrive/ui/translations/app_zh-CHT.po @@ -142,8 +142,12 @@ msgid "Change Language" msgstr "變更語言" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Changing this setting will restart openpilot if the car is powered on." -msgstr "若車輛通電,變更此設定將重新啟動 openpilot。" +msgid "Changing this setting will restart sunnypilot if the car is powered on." +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Choose your sponsorship tier and confirm your support" +msgstr "" #: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Click \"add new device\" and scan the QR code on the right" @@ -166,8 +170,8 @@ msgid "Decline" msgstr "拒絕" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "Decline, uninstall openpilot" -msgstr "拒絕並解除安裝 openpilot" +msgid "Decline, uninstall sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Developer" @@ -229,6 +233,10 @@ msgstr "ETH" msgid "EXPERIMENTAL MODE ON" msgstr "實驗模式已開啟" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Early Access: Become a sunnypilot Sponsor" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/developer.py #: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" @@ -255,16 +263,16 @@ msgid "Enable Tethering" msgstr "啟用網路共享" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable driver monitoring even when openpilot is not engaged." -msgstr "即使未啟動 openpilot 亦啟用駕駛監控。" +msgid "Enable driver monitoring even when sunnypilot is not engaged." +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable openpilot" -msgstr "啟用 openpilot" +msgid "Enable sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable the openpilot longitudinal control (alpha) toggle to allow Experimental mode." -msgstr "啟用 openpilot 縱向控制(alpha)切換,以使用實驗模式。" +msgid "Enable the sunnypilot longitudinal control (alpha) toggle to allow Experimental mode." +msgstr "" #: system/ui/widgets/network.py msgid "Enter APN" @@ -282,6 +290,10 @@ msgstr "輸入新的網路共享密碼" msgid "Enter password" msgstr "輸入密碼" +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Enter search query" +msgstr "" + #: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "Enter your GitHub username" msgstr "輸入您的 GitHub 使用者名稱" @@ -314,6 +326,10 @@ msgstr "資料洪流" msgid "Firehose Mode" msgstr "Firehose 模式" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Follow the prompts to complete the pairing process" +msgstr "" + #: system/ui/widgets/network.py msgid "Forget" msgstr "忘記" @@ -346,10 +362,18 @@ msgstr "安裝" msgid "IP Address" msgstr "IP 位址" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Install Update" msgstr "安裝更新" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Join our Community Forum at https://community.sunnypilot.ai and reach out to a moderator if you have issues" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Joystick Debug Mode" msgstr "搖桿除錯模式" @@ -362,6 +386,10 @@ msgstr "載入中" msgid "LTE" msgstr "LTE" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "Lateral Maneuver Mode" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Longitudinal Maneuver Mode" msgstr "縱向操作模式" @@ -436,6 +464,10 @@ msgstr "配對裝置" msgid "Pair device" msgstr "配對裝置" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Pair your GitHub account" +msgstr "" + #: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Pair your device to your comma account" msgstr "將裝置配對至您的 comma 帳號" @@ -481,6 +513,10 @@ msgstr "重設" msgid "REVIEW" msgstr "檢視" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Re-enter the \"sunnylink\" panel to verify sponsorship status" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reboot" msgstr "重新啟動" @@ -538,8 +574,8 @@ msgid "Review Training Guide" msgstr "檢視訓練指南" #: openpilot/selfdrive/ui/layouts/settings/device.py -msgid "Review the rules, features, and limitations of openpilot" -msgstr "檢視 openpilot 的規則、功能與限制" +msgid "Review the rules, features, and limitations of sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/software.py msgid "SELECT" @@ -549,10 +585,22 @@ msgstr "選取" msgid "SSH Keys" msgstr "SSH 金鑰" +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to login to your GitHub account" +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to visit sunnyhaibin's GitHub Sponsors page" +msgstr "" + #: system/ui/widgets/network.py msgid "Scanning Wi-Fi networks..." msgstr "正在掃描 Wi‑Fi 網路…" +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Search" +msgstr "" + #: system/ui/widgets/option_dialog.py msgid "Select" msgstr "選取" @@ -654,12 +702,12 @@ msgid "Waiting to start" msgstr "等待開始" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "Welcome to openpilot" -msgstr "歡迎使用 openpilot" +msgid "Welcome to sunnypilot" +msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "When enabled, pressing the accelerator pedal will disengage openpilot." -msgstr "啟用後,踩下加速踏板將會脫離 openpilot。" +msgid "When enabled, pressing the accelerator pedal will disengage sunnypilot." +msgstr "" #: openpilot/selfdrive/ui/layouts/sidebar.py msgid "Wi-Fi" @@ -674,12 +722,12 @@ msgid "Wrong password" msgstr "密碼錯誤" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "You must accept the Terms and Conditions in order to use openpilot." -msgstr "您必須接受條款與細則才能使用 openpilot。" +msgid "You must accept the Terms of Service in order to use sunnypilot." +msgstr "" #: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "You must accept the Terms and Conditions to use openpilot. Read the latest terms at https://comma.ai/terms before continuing." -msgstr "您必須接受條款與細則才能使用 openpilot。繼續前請閱讀 https://comma.ai/terms 上的最新條款。" +msgid "You must accept the Terms of Service to use sunnypilot. Read the latest terms at https://sunnypilot.ai/terms before continuing." +msgstr "" #: openpilot/selfdrive/ui/onroad/driver_camera_dialog.py msgid "camera starting" @@ -745,26 +793,26 @@ msgstr "從不" msgid "now" msgstr "現在" -#: openpilot/selfdrive/ui/layouts/settings/developer.py -msgid "openpilot Longitudinal Control (Alpha)" -msgstr "openpilot 縱向控制(Alpha)" - -#: openpilot/selfdrive/ui/onroad/alert_renderer.py -msgid "openpilot Unavailable" -msgstr "openpilot 無法使用" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "openpilot longitudinal control may come in a future update." -msgstr "openpilot 縱向控制可能於未來更新提供。" - -#: openpilot/selfdrive/ui/layouts/settings/device.py -msgid "openpilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." -msgstr "openpilot 要求裝置安裝在左右 4°、上 5° 或下 9° 以內。" - #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "right" msgstr "右" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "sunnypilot Longitudinal Control (Alpha)" +msgstr "" + +#: openpilot/selfdrive/ui/onroad/alert_renderer.py +msgid "sunnypilot Unavailable" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "sunnypilot longitudinal control may come in a future update." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "sunnypilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." +msgstr "" + #: system/ui/widgets/network.py msgid "unmetered" msgstr "不限流量" diff --git a/selfdrive/ui/ui_state.py b/selfdrive/ui/ui_state.py index 30a6565095..f7a5d44d9a 100644 --- a/selfdrive/ui/ui_state.py +++ b/selfdrive/ui/ui_state.py @@ -12,6 +12,8 @@ from openpilot.selfdrive.ui.lib.prime_state import PrimeState from openpilot.system.ui.lib.application import gui_app from openpilot.system.hardware import HARDWARE, PC +from openpilot.selfdrive.ui.sunnypilot.ui_state import UIStateSP, DeviceSP + BACKLIGHT_OFFROAD = 65 if HARDWARE.get_device_type() == "mici" else 50 @@ -19,9 +21,11 @@ class UIStatus(Enum): DISENGAGED = "disengaged" ENGAGED = "engaged" OVERRIDE = "override" + LAT_ONLY = "lat_only" + LONG_ONLY = "long_only" -class UIState: +class UIState(UIStateSP): _instance: 'UIState | None' = None def __new__(cls): @@ -31,6 +35,7 @@ class UIState: return cls._instance def _initialize(self): + UIStateSP.__init__(self) self.params = Params() self.sm = messaging.SubMaster( [ @@ -55,7 +60,7 @@ class UIState: "carControl", "liveParameters", "rawAudioData", - ] + ] + self.sm_services_ext ) self.prime_state = PrimeState() @@ -95,7 +100,7 @@ class UIState: @property def engaged(self) -> bool: - return self.started and self.sm["selfdriveState"].enabled + return self.started and (self.sm["selfdriveState"].enabled or self.sm["selfdriveStateSP"].mads.enabled) def is_onroad(self) -> bool: return self.started @@ -111,6 +116,7 @@ class UIState: if time.monotonic() - self._param_update_time > 5.0: self.update_params() device.update() + UIStateSP.update(self) def _update_state(self) -> None: # Handle panda states updates @@ -152,6 +158,8 @@ class UIState: else: self.status = UIStatus.ENGAGED if ss.enabled else UIStatus.DISENGAGED + self.status = UIStatus(UIStateSP.update_status(ss, self.sm["selfdriveStateSP"], self.sm["onroadEvents"])) + # Check for engagement state changes if self.engaged != self._engaged_prev: for callback in self._engaged_transition_callbacks: @@ -180,11 +188,13 @@ class UIState: self.has_longitudinal_control = self.params.get_bool("AlphaLongitudinalEnabled") else: self.has_longitudinal_control = self.CP.openpilotLongitudinalControl + UIStateSP.update_params(self) self._param_update_time = time.monotonic() -class Device: +class Device(DeviceSP): def __init__(self): + DeviceSP.__init__(self) self._ignition = False self._interaction_time: float = -1 self._override_interactive_timeout: int | None = None @@ -211,6 +221,9 @@ class Device: if self._override_interactive_timeout is not None: return self._override_interactive_timeout + if gui_app.sunnypilot_ui() and ui_state.custom_interactive_timeout != 0: + return ui_state.custom_interactive_timeout + ignition_timeout = 10 if gui_app.big_ui() else 5 return ignition_timeout if ui_state.ignition else 30 @@ -245,9 +258,17 @@ class Device: else: clipped_brightness = ((clipped_brightness + 16.0) / 116.0) ** 3.0 - clipped_brightness = float(np.interp(clipped_brightness, [0, 1], [30, 100])) + min_brightness = 30 + if gui_app.sunnypilot_ui(): + min_brightness = DeviceSP.set_min_onroad_brightness(ui_state, min_brightness) + + clipped_brightness = float(np.interp(clipped_brightness, [0, 1], [min_brightness, 100])) brightness = round(self._brightness_filter.update(clipped_brightness)) + + if gui_app.sunnypilot_ui(): + brightness = DeviceSP.set_onroad_brightness(ui_state, self._awake, brightness) + if not self._awake: brightness = 0 @@ -263,6 +284,9 @@ class Device: self._ignition = ui_state.ignition if ignition_just_turned_off or any(ev.left_down for ev in gui_app.mouse_events): + if gui_app.sunnypilot_ui(): + DeviceSP.wake_from_dimmed_onroad_brightness(ui_state, gui_app.mouse_events) + self._reset_interactive_timeout() interaction_timeout = time.monotonic() > self._interaction_time @@ -275,6 +299,7 @@ class Device: def _set_awake(self, on: bool): if on != self._awake: + DeviceSP._set_awake(on, ui_state) self._awake = on cloudlog.debug(f"setting display power {int(on)}") HARDWARE.set_display_power(on) diff --git a/sunnypilot/SConscript b/sunnypilot/SConscript new file mode 100644 index 0000000000..09ad39ab43 --- /dev/null +++ b/sunnypilot/SConscript @@ -0,0 +1,3 @@ +SConscript(['common/transformations/SConscript']) +SConscript(['modeld_v2/SConscript']) +SConscript(['selfdrive/locationd/SConscript']) diff --git a/sunnypilot/__init__.py b/sunnypilot/__init__.py new file mode 100644 index 0000000000..ccacd0be0a --- /dev/null +++ b/sunnypilot/__init__.py @@ -0,0 +1,39 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" + +from enum import IntEnum +import hashlib + +PARAMS_UPDATE_PERIOD = 3 # seconds + + +def get_file_hash(path: str) -> str: + sha256_hash = hashlib.sha256() + with open(path, "rb") as f: + for byte_block in iter(lambda: f.read(4096), b""): + sha256_hash.update(byte_block) + return sha256_hash.hexdigest() + + +class IntEnumBase(IntEnum): + @classmethod + def min(cls): + return min(cls) + + @classmethod + def max(cls): + return max(cls) + + +def get_sanitize_int_param(key: str, min_val: int, max_val: int, params) -> int: + val: int = params.get(key, return_default=True) + clipped_val = max(min_val, min(max_val, val)) + + if clipped_val != val: + params.put(key, clipped_val) + + return clipped_val diff --git a/sunnypilot/common/transformations/SConscript b/sunnypilot/common/transformations/SConscript new file mode 100644 index 0000000000..ffeaf18012 --- /dev/null +++ b/sunnypilot/common/transformations/SConscript @@ -0,0 +1,4 @@ +Import('env') + +transformations = env.Library('transformations', ['orientation.cc', 'coordinates.cc']) +Export('transformations') diff --git a/sunnypilot/common/transformations/coordinates.cc b/sunnypilot/common/transformations/coordinates.cc new file mode 100644 index 0000000000..776a5529d2 --- /dev/null +++ b/sunnypilot/common/transformations/coordinates.cc @@ -0,0 +1,100 @@ +#define _USE_MATH_DEFINES + +#include "sunnypilot/common/transformations/coordinates.hpp" + +#include +#include +#include + +double a = 6378137; // lgtm [cpp/short-global-name] +double b = 6356752.3142; // lgtm [cpp/short-global-name] +double esq = 6.69437999014 * 0.001; // lgtm [cpp/short-global-name] +double e1sq = 6.73949674228 * 0.001; + + +static Geodetic to_degrees(Geodetic geodetic){ + geodetic.lat = RAD2DEG(geodetic.lat); + geodetic.lon = RAD2DEG(geodetic.lon); + return geodetic; +} + +static Geodetic to_radians(Geodetic geodetic){ + geodetic.lat = DEG2RAD(geodetic.lat); + geodetic.lon = DEG2RAD(geodetic.lon); + return geodetic; +} + + +ECEF geodetic2ecef(const Geodetic &geodetic) { + auto g = to_radians(geodetic); + double xi = sqrt(1.0 - esq * pow(sin(g.lat), 2)); + double x = (a / xi + g.alt) * cos(g.lat) * cos(g.lon); + double y = (a / xi + g.alt) * cos(g.lat) * sin(g.lon); + double z = (a / xi * (1.0 - esq) + g.alt) * sin(g.lat); + return {x, y, z}; +} + +Geodetic ecef2geodetic(const ECEF &e) { + // Convert from ECEF to geodetic using Ferrari's methods + // https://en.wikipedia.org/wiki/Geographic_coordinate_conversion#Ferrari.27s_solution + double x = e.x; + double y = e.y; + double z = e.z; + + double r = sqrt(x * x + y * y); + double Esq = a * a - b * b; + double F = 54 * b * b * z * z; + double G = r * r + (1 - esq) * z * z - esq * Esq; + double C = (esq * esq * F * r * r) / (pow(G, 3)); + double S = cbrt(1 + C + sqrt(C * C + 2 * C)); + double P = F / (3 * pow((S + 1 / S + 1), 2) * G * G); + double Q = sqrt(1 + 2 * esq * esq * P); + double r_0 = -(P * esq * r) / (1 + Q) + sqrt(0.5 * a * a*(1 + 1.0 / Q) - P * (1 - esq) * z * z / (Q * (1 + Q)) - 0.5 * P * r * r); + double U = sqrt(pow((r - esq * r_0), 2) + z * z); + double V = sqrt(pow((r - esq * r_0), 2) + (1 - esq) * z * z); + double Z_0 = b * b * z / (a * V); + double h = U * (1 - b * b / (a * V)); + + double lat = atan((z + e1sq * Z_0) / r); + double lon = atan2(y, x); + + return to_degrees({lat, lon, h}); +} + +LocalCoord::LocalCoord(const Geodetic &geodetic, const ECEF &e) { + init_ecef << e.x, e.y, e.z; + + auto g = to_radians(geodetic); + + ned2ecef_matrix << + -sin(g.lat)*cos(g.lon), -sin(g.lon), -cos(g.lat)*cos(g.lon), + -sin(g.lat)*sin(g.lon), cos(g.lon), -cos(g.lat)*sin(g.lon), + cos(g.lat), 0, -sin(g.lat); + ecef2ned_matrix = ned2ecef_matrix.transpose(); +} + +NED LocalCoord::ecef2ned(const ECEF &e) { + Eigen::Vector3d ecef; + ecef << e.x, e.y, e.z; + + Eigen::Vector3d ned = (ecef2ned_matrix * (ecef - init_ecef)); + return {ned[0], ned[1], ned[2]}; +} + +ECEF LocalCoord::ned2ecef(const NED &n) { + Eigen::Vector3d ned; + ned << n.n, n.e, n.d; + + Eigen::Vector3d ecef = (ned2ecef_matrix * ned) + init_ecef; + return {ecef[0], ecef[1], ecef[2]}; +} + +NED LocalCoord::geodetic2ned(const Geodetic &g) { + ECEF e = ::geodetic2ecef(g); + return ecef2ned(e); +} + +Geodetic LocalCoord::ned2geodetic(const NED &n) { + ECEF e = ned2ecef(n); + return ::ecef2geodetic(e); +} diff --git a/sunnypilot/common/transformations/coordinates.hpp b/sunnypilot/common/transformations/coordinates.hpp new file mode 100644 index 0000000000..dc8ff7a4b6 --- /dev/null +++ b/sunnypilot/common/transformations/coordinates.hpp @@ -0,0 +1,43 @@ +#pragma once + +#include + +#define DEG2RAD(x) ((x) * M_PI / 180.0) +#define RAD2DEG(x) ((x) * 180.0 / M_PI) + +struct ECEF { + double x, y, z; + Eigen::Vector3d to_vector() const { + return Eigen::Vector3d(x, y, z); + } +}; + +struct NED { + double n, e, d; + Eigen::Vector3d to_vector() const { + return Eigen::Vector3d(n, e, d); + } +}; + +struct Geodetic { + double lat, lon, alt; + bool radians=false; +}; + +ECEF geodetic2ecef(const Geodetic &g); +Geodetic ecef2geodetic(const ECEF &e); + +class LocalCoord { +public: + Eigen::Matrix3d ned2ecef_matrix; + Eigen::Matrix3d ecef2ned_matrix; + Eigen::Vector3d init_ecef; + LocalCoord(const Geodetic &g, const ECEF &e); + LocalCoord(const Geodetic &g) : LocalCoord(g, ::geodetic2ecef(g)) {} + LocalCoord(const ECEF &e) : LocalCoord(::ecef2geodetic(e), e) {} + + NED ecef2ned(const ECEF &e); + ECEF ned2ecef(const NED &n); + NED geodetic2ned(const Geodetic &g); + Geodetic ned2geodetic(const NED &n); +}; diff --git a/sunnypilot/common/transformations/orientation.cc b/sunnypilot/common/transformations/orientation.cc new file mode 100644 index 0000000000..42c92b68b7 --- /dev/null +++ b/sunnypilot/common/transformations/orientation.cc @@ -0,0 +1,143 @@ +#define _USE_MATH_DEFINES + +#include +#include +#include + +#include "sunnypilot/common/transformations/orientation.hpp" +#include "sunnypilot/common/transformations/coordinates.hpp" + +Eigen::Quaterniond ensure_unique(const Eigen::Quaterniond &quat) { + if (quat.w() > 0){ + return quat; + } else { + return Eigen::Quaterniond(-quat.w(), -quat.x(), -quat.y(), -quat.z()); + } +} + +Eigen::Quaterniond euler2quat(const Eigen::Vector3d &euler) { + Eigen::Quaterniond q; + + q = Eigen::AngleAxisd(euler(2), Eigen::Vector3d::UnitZ()) + * Eigen::AngleAxisd(euler(1), Eigen::Vector3d::UnitY()) + * Eigen::AngleAxisd(euler(0), Eigen::Vector3d::UnitX()); + return ensure_unique(q); +} + + +Eigen::Vector3d quat2euler(const Eigen::Quaterniond &quat) { + // TODO: switch to eigen implementation if the range of the Euler angles doesn't matter anymore + // Eigen::Vector3d euler = quat.toRotationMatrix().eulerAngles(2, 1, 0); + // return {euler(2), euler(1), euler(0)}; + double gamma = atan2(2 * (quat.w() * quat.x() + quat.y() * quat.z()), 1 - 2 * (quat.x()*quat.x() + quat.y()*quat.y())); + double asin_arg_clipped = std::clamp(2 * (quat.w() * quat.y() - quat.z() * quat.x()), -1.0, 1.0); + double theta = asin(asin_arg_clipped); + double psi = atan2(2 * (quat.w() * quat.z() + quat.x() * quat.y()), 1 - 2 * (quat.y()*quat.y() + quat.z()*quat.z())); + return {gamma, theta, psi}; +} + +Eigen::Matrix3d quat2rot(const Eigen::Quaterniond &quat) { + return quat.toRotationMatrix(); +} + +Eigen::Quaterniond rot2quat(const Eigen::Matrix3d &rot) { + return ensure_unique(Eigen::Quaterniond(rot)); +} + +Eigen::Matrix3d euler2rot(const Eigen::Vector3d &euler) { + return quat2rot(euler2quat(euler)); +} + +Eigen::Vector3d rot2euler(const Eigen::Matrix3d &rot) { + return quat2euler(rot2quat(rot)); +} + +Eigen::Matrix3d rot_matrix(double roll, double pitch, double yaw) { + return euler2rot({roll, pitch, yaw}); +} + +Eigen::Matrix3d rot(const Eigen::Vector3d &axis, double angle) { + Eigen::Quaterniond q; + q = Eigen::AngleAxisd(angle, axis); + return q.toRotationMatrix(); +} + + +Eigen::Vector3d ecef_euler_from_ned(const ECEF &ecef_init, const Eigen::Vector3d &ned_pose) { + /* + Using Rotations to Build Aerospace Coordinate Systems + Don Koks + https://apps.dtic.mil/dtic/tr/fulltext/u2/a484864.pdf + */ + LocalCoord converter = LocalCoord(ecef_init); + Eigen::Vector3d zero = ecef_init.to_vector(); + + Eigen::Vector3d x0 = converter.ned2ecef({1, 0, 0}).to_vector() - zero; + Eigen::Vector3d y0 = converter.ned2ecef({0, 1, 0}).to_vector() - zero; + Eigen::Vector3d z0 = converter.ned2ecef({0, 0, 1}).to_vector() - zero; + + Eigen::Vector3d x1 = rot(z0, ned_pose(2)) * x0; + Eigen::Vector3d y1 = rot(z0, ned_pose(2)) * y0; + Eigen::Vector3d z1 = rot(z0, ned_pose(2)) * z0; + + Eigen::Vector3d x2 = rot(y1, ned_pose(1)) * x1; + Eigen::Vector3d y2 = rot(y1, ned_pose(1)) * y1; + Eigen::Vector3d z2 = rot(y1, ned_pose(1)) * z1; + + Eigen::Vector3d x3 = rot(x2, ned_pose(0)) * x2; + Eigen::Vector3d y3 = rot(x2, ned_pose(0)) * y2; + + + x0 = Eigen::Vector3d(1, 0, 0); + y0 = Eigen::Vector3d(0, 1, 0); + z0 = Eigen::Vector3d(0, 0, 1); + + double psi = atan2(x3.dot(y0), x3.dot(x0)); + double theta = atan2(-x3.dot(z0), sqrt(pow(x3.dot(x0), 2) + pow(x3.dot(y0), 2))); + + y2 = rot(z0, psi) * y0; + z2 = rot(y2, theta) * z0; + + double phi = atan2(y3.dot(z2), y3.dot(y2)); + + return {phi, theta, psi}; +} + +Eigen::Vector3d ned_euler_from_ecef(const ECEF &ecef_init, const Eigen::Vector3d &ecef_pose) { + /* + Using Rotations to Build Aerospace Coordinate Systems + Don Koks + https://apps.dtic.mil/dtic/tr/fulltext/u2/a484864.pdf + */ + LocalCoord converter = LocalCoord(ecef_init); + + Eigen::Vector3d x0 = Eigen::Vector3d(1, 0, 0); + Eigen::Vector3d y0 = Eigen::Vector3d(0, 1, 0); + Eigen::Vector3d z0 = Eigen::Vector3d(0, 0, 1); + + Eigen::Vector3d x1 = rot(z0, ecef_pose(2)) * x0; + Eigen::Vector3d y1 = rot(z0, ecef_pose(2)) * y0; + Eigen::Vector3d z1 = rot(z0, ecef_pose(2)) * z0; + + Eigen::Vector3d x2 = rot(y1, ecef_pose(1)) * x1; + Eigen::Vector3d y2 = rot(y1, ecef_pose(1)) * y1; + Eigen::Vector3d z2 = rot(y1, ecef_pose(1)) * z1; + + Eigen::Vector3d x3 = rot(x2, ecef_pose(0)) * x2; + Eigen::Vector3d y3 = rot(x2, ecef_pose(0)) * y2; + + Eigen::Vector3d zero = ecef_init.to_vector(); + x0 = converter.ned2ecef({1, 0, 0}).to_vector() - zero; + y0 = converter.ned2ecef({0, 1, 0}).to_vector() - zero; + z0 = converter.ned2ecef({0, 0, 1}).to_vector() - zero; + + double psi = atan2(x3.dot(y0), x3.dot(x0)); + double theta = atan2(-x3.dot(z0), sqrt(pow(x3.dot(x0), 2) + pow(x3.dot(y0), 2))); + + y2 = rot(z0, psi) * y0; + z2 = rot(y2, theta) * z0; + + double phi = atan2(y3.dot(z2), y3.dot(y2)); + + return {phi, theta, psi}; +} diff --git a/sunnypilot/common/transformations/orientation.hpp b/sunnypilot/common/transformations/orientation.hpp new file mode 100644 index 0000000000..045340901c --- /dev/null +++ b/sunnypilot/common/transformations/orientation.hpp @@ -0,0 +1,17 @@ +#pragma once +#include +#include "sunnypilot/common/transformations/coordinates.hpp" + + +Eigen::Quaterniond ensure_unique(const Eigen::Quaterniond &quat); + +Eigen::Quaterniond euler2quat(const Eigen::Vector3d &euler); +Eigen::Vector3d quat2euler(const Eigen::Quaterniond &quat); +Eigen::Matrix3d quat2rot(const Eigen::Quaterniond &quat); +Eigen::Quaterniond rot2quat(const Eigen::Matrix3d &rot); +Eigen::Matrix3d euler2rot(const Eigen::Vector3d &euler); +Eigen::Vector3d rot2euler(const Eigen::Matrix3d &rot); +Eigen::Matrix3d rot_matrix(double roll, double pitch, double yaw); +Eigen::Matrix3d rot(const Eigen::Vector3d &axis, double angle); +Eigen::Vector3d ecef_euler_from_ned(const ECEF &ecef_init, const Eigen::Vector3d &ned_pose); +Eigen::Vector3d ned_euler_from_ecef(const ECEF &ecef_init, const Eigen::Vector3d &ecef_pose); diff --git a/sunnypilot/common/version.h b/sunnypilot/common/version.h new file mode 100644 index 0000000000..1c7fcd2e64 --- /dev/null +++ b/sunnypilot/common/version.h @@ -0,0 +1 @@ +#define SUNNYPILOT_VERSION "2026.001.000" diff --git a/sunnypilot/livedelay/__init__.py b/sunnypilot/livedelay/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sunnypilot/livedelay/helpers.py b/sunnypilot/livedelay/helpers.py new file mode 100644 index 0000000000..0f7437ceea --- /dev/null +++ b/sunnypilot/livedelay/helpers.py @@ -0,0 +1,14 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from openpilot.common.params import Params + + +def get_lat_delay(params: Params, stock_lat_delay: float) -> float: + if params.get_bool("LagdToggle"): + return float(params.get("LagdValueCache", return_default=True)) + + return stock_lat_delay diff --git a/sunnypilot/livedelay/lagd_toggle.py b/sunnypilot/livedelay/lagd_toggle.py new file mode 100644 index 0000000000..8495dcee0a --- /dev/null +++ b/sunnypilot/livedelay/lagd_toggle.py @@ -0,0 +1,38 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from cereal import log + +from opendbc.car import structs +from openpilot.common.params import Params + + +class LagdToggle: + def __init__(self, CP: structs.CarParams): + self.CP = CP + self.params = Params() + self.lag = 0.0 + + self.lagd_toggle = self.params.get_bool("LagdToggle") + self.software_delay = self.params.get("LagdToggleDelay", return_default=True) + + def read_params(self) -> None: + self.lagd_toggle = self.params.get_bool("LagdToggle") + self.software_delay = self.params.get("LagdToggleDelay", return_default=True) + + def update(self, lag_msg: log.LiveDelayData) -> None: + self.read_params() + + if not self.lagd_toggle: + steer_actuator_delay = self.CP.steerActuatorDelay + delay = self.software_delay + self.lag = (steer_actuator_delay + delay) + self.params.put_nonblocking("LagdValueCache", self.lag) + return + + lateral_delay = lag_msg.liveDelay.lateralDelay + self.lag = lateral_delay + self.params.put_nonblocking("LagdValueCache", self.lag) diff --git a/sunnypilot/mads/helpers.py b/sunnypilot/mads/helpers.py new file mode 100644 index 0000000000..f9c4057bab --- /dev/null +++ b/sunnypilot/mads/helpers.py @@ -0,0 +1,73 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" + +from openpilot.common.params import Params +from opendbc.car import structs +from opendbc.safety import ALTERNATIVE_EXPERIENCE +from opendbc.sunnypilot.car.hyundai.values import HyundaiFlagsSP, HyundaiSafetyFlagsSP +from opendbc.sunnypilot.car.tesla.values import TeslaFlagsSP + + +MADS_NO_ACC_MAIN_BUTTON = ("rivian", "tesla") + + +class MadsSteeringModeOnBrake: + REMAIN_ACTIVE = 0 + PAUSE = 1 + DISENGAGE = 2 + + +def get_mads_limited_brands(CP: structs.CarParams, CP_SP: structs.CarParamsSP) -> bool: + if CP.brand == 'rivian': + return True + if CP.brand == 'tesla': + return not CP_SP.flags & TeslaFlagsSP.HAS_VEHICLE_BUS + + return False + + +def read_steering_mode_param(CP: structs.CarParams, CP_SP: structs.CarParamsSP, params: Params): + if get_mads_limited_brands(CP, CP_SP): + return MadsSteeringModeOnBrake.DISENGAGE + + return params.get("MadsSteeringMode", return_default=True) + + +def set_alternative_experience(CP: structs.CarParams, CP_SP: structs.CarParamsSP, params: Params): + enabled = params.get_bool("Mads") + steering_mode = read_steering_mode_param(CP, CP_SP, params) + + if enabled: + CP.alternativeExperience |= ALTERNATIVE_EXPERIENCE.ENABLE_MADS + + if steering_mode == MadsSteeringModeOnBrake.DISENGAGE: + CP.alternativeExperience |= ALTERNATIVE_EXPERIENCE.MADS_DISENGAGE_LATERAL_ON_BRAKE + elif steering_mode == MadsSteeringModeOnBrake.PAUSE: + CP.alternativeExperience |= ALTERNATIVE_EXPERIENCE.MADS_PAUSE_LATERAL_ON_BRAKE + + +def set_car_specific_params(CP: structs.CarParams, CP_SP: structs.CarParamsSP, params: Params): + if CP.brand == "hyundai": + # TODO-SP: This should be separated from MADS module for future implementations + # Use "HyundaiLongitudinalMainCruiseToggleable" param + hyundai_cruise_main_toggleable = True + if hyundai_cruise_main_toggleable: + CP_SP.flags |= HyundaiFlagsSP.LONGITUDINAL_MAIN_CRUISE_TOGGLEABLE.value + CP_SP.safetyParam |= HyundaiSafetyFlagsSP.LONG_MAIN_CRUISE_TOGGLEABLE + + # MADS Partial Support + # MADS is currently partially supported for these platforms due to lack of consistent states to engage controls + # Only MadsSteeringModeOnBrake.DISENGAGE is supported for these platforms + # TODO-SP: To enable MADS full support for Rivian and most Tesla, identify consistent signals for MADS toggling + mads_partial_support = get_mads_limited_brands(CP, CP_SP) + if mads_partial_support: + params.put("MadsSteeringMode", 2) + params.put_bool("MadsUnifiedEngagementMode", True) + + # no ACC MAIN button for these brands + if CP.brand in MADS_NO_ACC_MAIN_BUTTON: + params.remove("MadsMainCruiseAllowed") diff --git a/sunnypilot/mads/mads.py b/sunnypilot/mads/mads.py new file mode 100644 index 0000000000..0c87da4a2a --- /dev/null +++ b/sunnypilot/mads/mads.py @@ -0,0 +1,221 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" + +from cereal import log, custom + +from opendbc.car import structs +from opendbc.car.hyundai.values import HyundaiFlags +from openpilot.common.params import Params +from openpilot.sunnypilot.mads.helpers import MadsSteeringModeOnBrake, read_steering_mode_param, MADS_NO_ACC_MAIN_BUTTON +from openpilot.sunnypilot.mads.state import StateMachine, GEARS_ALLOW_PAUSED_SILENT + +State = custom.ModularAssistiveDrivingSystem.ModularAssistiveDrivingSystemState +ButtonType = structs.CarState.ButtonEvent.Type +EventName = log.OnroadEvent.EventName +EventNameSP = custom.OnroadEventSP.EventName +GearShifter = structs.CarState.GearShifter +SafetyModel = structs.CarParams.SafetyModel + +SET_SPEED_BUTTONS = (ButtonType.accelCruise, ButtonType.resumeCruise, ButtonType.decelCruise, ButtonType.setCruise) +IGNORED_SAFETY_MODES = (SafetyModel.silent, SafetyModel.noOutput) + + +class ModularAssistiveDrivingSystem: + def __init__(self, selfdrive): + self.CP = selfdrive.CP + self.CP_SP = selfdrive.CP_SP + self.params = selfdrive.params + + self.enabled = False + self.active = False + self.available = False + self.lateral_mismatch_counter = 0 + self.allow_always = False + self.no_main_cruise = False + self.selfdrive = selfdrive + self.selfdrive.enabled_prev = False + self.state_machine = StateMachine(self) + self.events = self.selfdrive.events + self.events_sp = self.selfdrive.events_sp + self.disengage_on_accelerator = Params().get_bool("DisengageOnAccelerator") + if self.CP.brand == "hyundai": + if self.CP.flags & (HyundaiFlags.HAS_LDA_BUTTON | HyundaiFlags.CANFD): + self.allow_always = True + if self.CP.brand == "tesla": + self.allow_always = True + + if self.CP.brand in MADS_NO_ACC_MAIN_BUTTON: + self.no_main_cruise = True + + # read params on init + self.enabled_toggle = self.params.get_bool("Mads") + self.main_enabled_toggle = self.params.get_bool("MadsMainCruiseAllowed") + self.steering_mode_on_brake = read_steering_mode_param(self.CP, self.CP_SP, self.params) + self.unified_engagement_mode = self.params.get_bool("MadsUnifiedEngagementMode") + + def read_params(self): + self.main_enabled_toggle = self.params.get_bool("MadsMainCruiseAllowed") + self.unified_engagement_mode = self.params.get_bool("MadsUnifiedEngagementMode") + + def pedal_pressed_non_gas_pressed(self, CS: structs.CarState) -> bool: + # ignore `pedalPressed` events caused by gas presses + if self.events.has(EventName.pedalPressed) and not (CS.gasPressed and not self.selfdrive.CS_prev.gasPressed and self.disengage_on_accelerator): + return True + + return False + + def should_silent_lkas_enable(self, CS: structs.CarState) -> bool: + if self.steering_mode_on_brake == MadsSteeringModeOnBrake.PAUSE and self.pedal_pressed_non_gas_pressed(CS): + return False + + if self.events_sp.contains_in_list(GEARS_ALLOW_PAUSED_SILENT): + return False + + return True + + def block_unified_engagement_mode(self) -> bool: + # UEM disabled + if not self.unified_engagement_mode: + return True + + if self.enabled: + return True + + if self.selfdrive.enabled and self.selfdrive.enabled_prev: + return True + + return False + + def get_wrong_car_mode(self, alert_only: bool) -> None: + if alert_only: + if self.events.has(EventName.wrongCarMode): + self.replace_event(EventName.wrongCarMode, EventNameSP.wrongCarModeAlertOnly) + else: + self.events.remove(EventName.wrongCarMode) + + def transition_paused_state(self): + if self.state_machine.state != State.paused: + self.events_sp.add(EventNameSP.silentLkasDisable) + + def replace_event(self, old_event: int, new_event: int): + self.events.remove(old_event) + self.events_sp.add(new_event) + + def data_sample(self): + # When the safety and selfdrived do not agree on controls_allowed_lateral + # we want to disengage sunnypilot. However the status from the panda goes through + # another socket other than the CAN messages and one can arrive earlier than the other. + # Therefore we allow a mismatch for two samples, then we trigger the disengagement. + if not self.active or self.selfdrive.enabled: + self.lateral_mismatch_counter = 0 + elif any(not ps.controlsAllowedLateral for ps in self.selfdrive.sm['pandaStates'] + if ps.safetyModel not in IGNORED_SAFETY_MODES): + self.lateral_mismatch_counter += 1 + + def update_events(self, CS: structs.CarState): + if not self.selfdrive.enabled and self.enabled: + if CS.standstill: + if self.events.has(EventName.doorOpen): + self.replace_event(EventName.doorOpen, EventNameSP.silentDoorOpen) + self.transition_paused_state() + if self.events.has(EventName.seatbeltNotLatched): + self.replace_event(EventName.seatbeltNotLatched, EventNameSP.silentSeatbeltNotLatched) + self.transition_paused_state() + if self.events.has(EventName.wrongGear) and (CS.vEgo < 2.5 or CS.gearShifter == GearShifter.reverse): + self.replace_event(EventName.wrongGear, EventNameSP.silentWrongGear) + self.transition_paused_state() + if self.events.has(EventName.reverseGear): + self.replace_event(EventName.reverseGear, EventNameSP.silentReverseGear) + self.transition_paused_state() + if self.events.has(EventName.brakeHold): + self.replace_event(EventName.brakeHold, EventNameSP.silentBrakeHold) + self.transition_paused_state() + if self.events.has(EventName.parkBrake): + self.replace_event(EventName.parkBrake, EventNameSP.silentParkBrake) + self.transition_paused_state() + + if self.steering_mode_on_brake == MadsSteeringModeOnBrake.PAUSE: + if self.pedal_pressed_non_gas_pressed(CS): + self.transition_paused_state() + + self.events.remove(EventName.preEnableStandstill) + self.events.remove(EventName.belowEngageSpeed) + self.events.remove(EventName.speedTooLow) + self.events.remove(EventName.cruiseDisabled) + self.events.remove(EventName.manualRestart) + + selfdrive_enable_events = self.events.has(EventName.pcmEnable) or self.events.has(EventName.buttonEnable) + set_speed_btns_enable = any(be.type in SET_SPEED_BUTTONS for be in CS.buttonEvents) + + # wrongCarMode alert only or actively block control + self.get_wrong_car_mode(selfdrive_enable_events or set_speed_btns_enable) + + if selfdrive_enable_events: + if self.pedal_pressed_non_gas_pressed(CS): + self.events_sp.add(EventNameSP.pedalPressedAlertOnly) + + if self.block_unified_engagement_mode(): + self.events.remove(EventName.pcmEnable) + self.events.remove(EventName.buttonEnable) + else: + if self.main_enabled_toggle: + if CS.cruiseState.available and not self.selfdrive.CS_prev.cruiseState.available: + self.events_sp.add(EventNameSP.lkasEnable) + + for be in CS.buttonEvents: + if be.type == ButtonType.cancel: + if not self.selfdrive.enabled and self.selfdrive.enabled_prev: + self.events_sp.add(EventNameSP.manualLongitudinalRequired) + if be.type == ButtonType.lkas and be.pressed and (CS.cruiseState.available or self.allow_always): + if self.enabled: + if self.selfdrive.enabled: + self.events_sp.add(EventNameSP.manualSteeringRequired) + else: + self.events_sp.add(EventNameSP.lkasDisable) + else: + self.events_sp.add(EventNameSP.lkasEnable) + + if not CS.cruiseState.available and not self.no_main_cruise: + self.events.remove(EventName.buttonEnable) + if self.selfdrive.CS_prev.cruiseState.available: + self.events_sp.add(EventNameSP.lkasDisable) + + if self.steering_mode_on_brake == MadsSteeringModeOnBrake.DISENGAGE: + if self.pedal_pressed_non_gas_pressed(CS): + if self.enabled: + self.events_sp.add(EventNameSP.lkasDisable) + else: + # block lkasEnable if being sent, then send pedalPressedAlertOnly event + if self.events_sp.contains(EventNameSP.lkasEnable): + self.events_sp.remove(EventNameSP.lkasEnable) + self.events_sp.add(EventNameSP.pedalPressedAlertOnly) + + if self.should_silent_lkas_enable(CS): + if self.state_machine.state == State.paused: + self.events_sp.add(EventNameSP.silentLkasEnable) + + if self.lateral_mismatch_counter >= 200: + self.events_sp.add(EventNameSP.controlsMismatchLateral) + + self.events.remove(EventName.pcmDisable) + self.events.remove(EventName.buttonCancel) + self.events.remove(EventName.pedalPressed) + self.events.remove(EventName.wrongCruiseMode) + + def update(self, CS: structs.CarState): + if not self.enabled_toggle: + return + + self.data_sample() + + self.update_events(CS) + + if not self.CP.passive and self.selfdrive.initialized: + self.enabled, self.active = self.state_machine.update() + + # Copy of previous SelfdriveD states for MADS events handling + self.selfdrive.enabled_prev = self.selfdrive.enabled diff --git a/sunnypilot/mads/state.py b/sunnypilot/mads/state.py new file mode 100644 index 0000000000..73240c790c --- /dev/null +++ b/sunnypilot/mads/state.py @@ -0,0 +1,135 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" + +from cereal import log, custom +from openpilot.selfdrive.selfdrived.events import ET +from openpilot.selfdrive.selfdrived.state import SOFT_DISABLE_TIME +from openpilot.common.realtime import DT_CTRL + +State = custom.ModularAssistiveDrivingSystem.ModularAssistiveDrivingSystemState +EventName = log.OnroadEvent.EventName +EventNameSP = custom.OnroadEventSP.EventName + +ACTIVE_STATES = (State.enabled, State.softDisabling, State.overriding) +ENABLED_STATES = (State.paused, *ACTIVE_STATES) + +GEARS_ALLOW_PAUSED_SILENT = [EventNameSP.silentWrongGear, EventNameSP.silentReverseGear, EventNameSP.silentBrakeHold, + EventNameSP.silentDoorOpen, EventNameSP.silentSeatbeltNotLatched, EventNameSP.silentParkBrake] +GEARS_ALLOW_PAUSED = [EventName.wrongGear, EventName.reverseGear, EventName.brakeHold, + EventName.doorOpen, EventName.seatbeltNotLatched, EventName.parkBrake] + + +class StateMachine: + def __init__(self, mads): + self.selfdrive = mads.selfdrive + self.ss_state_machine = mads.selfdrive.state_machine + self._events = mads.selfdrive.events + self._events_sp = mads.selfdrive.events_sp + + self.state = State.disabled + + def add_current_alert_types(self, alert_type): + if not self.selfdrive.enabled: + self.ss_state_machine.current_alert_types.append(alert_type) + + def check_contains(self, event_type: str) -> bool: + return bool(self._events.contains(event_type) or self._events_sp.contains(event_type)) + + def check_contains_in_list(self) -> bool: + return bool(self._events.contains_in_list(GEARS_ALLOW_PAUSED) or self._events_sp.contains_in_list(GEARS_ALLOW_PAUSED_SILENT)) + + def update(self): + # soft disable timer and current alert types are from the state machine of openpilot + # decrement the soft disable timer at every step, as it's reset on + # entrance in SOFT_DISABLING state + + # ENABLED, SOFT DISABLING, PAUSED, OVERRIDING + if self.state != State.disabled: + # user and immediate disable always have priority in a non-disabled state + if self.check_contains(ET.USER_DISABLE): + if self._events_sp.has(EventNameSP.silentLkasDisable): + self.state = State.paused + else: + self.state = State.disabled + self.ss_state_machine.current_alert_types.append(ET.USER_DISABLE) + + elif self.check_contains(ET.IMMEDIATE_DISABLE): + self.state = State.disabled + self.add_current_alert_types(ET.IMMEDIATE_DISABLE) + + else: + # ENABLED + if self.state == State.enabled: + if self.check_contains(ET.SOFT_DISABLE): + self.state = State.softDisabling + if not self.selfdrive.enabled: + self.ss_state_machine.soft_disable_timer = int(SOFT_DISABLE_TIME / DT_CTRL) + self.ss_state_machine.current_alert_types.append(ET.SOFT_DISABLE) + + elif self.check_contains(ET.OVERRIDE_LATERAL): + self.state = State.overriding + self.add_current_alert_types(ET.OVERRIDE_LATERAL) + + # SOFT DISABLING + elif self.state == State.softDisabling: + if not self.check_contains(ET.SOFT_DISABLE): + # no more soft disabling condition, so go back to ENABLED + self.state = State.enabled + + elif self.ss_state_machine.soft_disable_timer > 0: + self.add_current_alert_types(ET.SOFT_DISABLE) + + elif self.ss_state_machine.soft_disable_timer <= 0: + self.state = State.disabled + + # PAUSED + elif self.state == State.paused: + if self.check_contains(ET.ENABLE): + if self.check_contains(ET.NO_ENTRY): + self.add_current_alert_types(ET.NO_ENTRY) + + else: + if self.check_contains(ET.OVERRIDE_LATERAL): + self.state = State.overriding + else: + self.state = State.enabled + self.add_current_alert_types(ET.ENABLE) + + # OVERRIDING + elif self.state == State.overriding: + if self.check_contains(ET.SOFT_DISABLE): + self.state = State.softDisabling + if not self.selfdrive.enabled: + self.ss_state_machine.soft_disable_timer = int(SOFT_DISABLE_TIME / DT_CTRL) + self.ss_state_machine.current_alert_types.append(ET.SOFT_DISABLE) + elif not self.check_contains(ET.OVERRIDE_LATERAL): + self.state = State.enabled + else: + self.ss_state_machine.current_alert_types += [ET.OVERRIDE_LATERAL] + + # DISABLED + elif self.state == State.disabled: + if self.check_contains(ET.ENABLE): + if self.check_contains(ET.NO_ENTRY): + if self.check_contains_in_list(): + self.state = State.paused + self.add_current_alert_types(ET.NO_ENTRY) + + else: + if self.check_contains(ET.OVERRIDE_LATERAL): + self.state = State.overriding + else: + self.state = State.enabled + self.add_current_alert_types(ET.ENABLE) + + # check if MADS is engaged and actuators are enabled + enabled = self.state in ENABLED_STATES + active = self.state in ACTIVE_STATES + if active: + self.add_current_alert_types(ET.WARNING) + + return enabled, active diff --git a/sunnypilot/mads/tests/test_mads_state_machine.py b/sunnypilot/mads/tests/test_mads_state_machine.py new file mode 100644 index 0000000000..7bc556a0fc --- /dev/null +++ b/sunnypilot/mads/tests/test_mads_state_machine.py @@ -0,0 +1,144 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" + +import pytest +from pytest_mock import MockerFixture + +from cereal import custom +from openpilot.common.realtime import DT_CTRL +from openpilot.sunnypilot.mads.state import StateMachine, SOFT_DISABLE_TIME +from openpilot.selfdrive.selfdrived.events import ET, NormalPermanentAlert, Events +from openpilot.sunnypilot.selfdrive.selfdrived.events import EventsSP, EVENTS_SP + +State = custom.ModularAssistiveDrivingSystem.ModularAssistiveDrivingSystemState +EventNameSP = custom.OnroadEventSP.EventName + +# The event types that maintain the current state +MAINTAIN_STATES = {State.enabled: (None,), State.disabled: (None,), State.softDisabling: (ET.SOFT_DISABLE,), + State.paused: (None,), State.overriding: (ET.OVERRIDE_LATERAL,)} +ALL_STATES = (State.schema.enumerants.values()) +# The event types checked in DISABLED section of state machine +ENABLE_EVENT_TYPES = (ET.ENABLE, ET.OVERRIDE_LATERAL) + + +def make_event(event_types): + event = {} + for ev in event_types: + event[ev] = NormalPermanentAlert("alert") + EVENTS_SP[0] = event + return 0 + + +class MockMADS: + def __init__(self, mocker: MockerFixture): + self.selfdrive = mocker.MagicMock() + self.selfdrive.state_machine = mocker.MagicMock() + self.selfdrive.events = Events() + self.selfdrive.events_sp = EventsSP() + + +class TestMADSStateMachine: + @pytest.fixture(autouse=True) + def setup_method(self, mocker: MockerFixture): + self.mads = MockMADS(mocker) + self.state_machine = StateMachine(self.mads) + self.events = self.mads.selfdrive.events + self.events_sp = self.mads.selfdrive.events_sp + self.mads.selfdrive.state_machine.soft_disable_timer = int(SOFT_DISABLE_TIME / DT_CTRL) + + def clear_events(self): + self.events.clear() + self.events_sp.clear() + + def test_immediate_disable(self): + for state in ALL_STATES: + for et in MAINTAIN_STATES[state]: + self.events_sp.add(make_event([et, ET.IMMEDIATE_DISABLE])) + self.state_machine.state = state + self.state_machine.update() + assert State.disabled == self.state_machine.state + self.clear_events() + + def test_user_disable(self): + for state in ALL_STATES: + for et in MAINTAIN_STATES[state]: + self.events_sp.add(make_event([et, ET.USER_DISABLE])) + self.state_machine.state = state + self.state_machine.update() + assert State.disabled == self.state_machine.state + self.clear_events() + + def test_user_disable_to_paused(self): + paused_events = (EventNameSP.silentLkasDisable, ) + for state in ALL_STATES: + for et in MAINTAIN_STATES[state]: + self.events_sp.add(make_event([et, ET.USER_DISABLE])) + for en in paused_events: + self.events_sp.add(en) + self.state_machine.state = state + self.state_machine.update() + final_state = State.paused if self.events_sp.has(en) and state != State.disabled else State.disabled + assert self.state_machine.state == final_state + self.clear_events() + + def test_soft_disable(self): + for state in ALL_STATES: + for et in MAINTAIN_STATES[state]: + self.events_sp.add(make_event([et, ET.SOFT_DISABLE])) + self.state_machine.state = state + self.state_machine.update() + assert self.state_machine.state == State.disabled if state == State.disabled else State.softDisabling + self.clear_events() + + def test_soft_disable_timer(self): + self.state_machine.state = State.enabled + self.events_sp.add(make_event([ET.SOFT_DISABLE])) + self.state_machine.update() + for _ in range(int(SOFT_DISABLE_TIME / DT_CTRL)): + assert self.state_machine.state == State.softDisabling + self.mads.selfdrive.state_machine.soft_disable_timer -= 1 + self.state_machine.update() + + assert self.state_machine.state == State.disabled + self.clear_events() + + def test_no_entry(self): + for et in ENABLE_EVENT_TYPES: + self.events_sp.add(make_event([ET.NO_ENTRY, et])) + self.state_machine.update() + assert self.state_machine.state == State.disabled + self.clear_events() + + def test_no_entry_paused(self): + self.state_machine.state = State.paused + self.events_sp.add(make_event([ET.NO_ENTRY])) + self.state_machine.update() + assert self.state_machine.state == State.paused + self.clear_events() + + def test_override_lateral(self): + self.state_machine.state = State.enabled + self.events_sp.add(make_event([ET.OVERRIDE_LATERAL])) + self.state_machine.update() + assert self.state_machine.state == State.overriding + self.clear_events() + + def test_paused_to_enabled(self): + self.state_machine.state = State.paused + self.events_sp.add(make_event([ET.ENABLE])) + self.state_machine.update() + assert self.state_machine.state == State.enabled + self.clear_events() + + def test_maintain_states(self): + for state in ALL_STATES: + for et in MAINTAIN_STATES[state]: + self.state_machine.state = state + self.events_sp.add(make_event([et])) + self.state_machine.update() + assert self.state_machine.state == state + self.clear_events() diff --git a/sunnypilot/mapd/__init__.py b/sunnypilot/mapd/__init__.py new file mode 100644 index 0000000000..7ad6f74149 --- /dev/null +++ b/sunnypilot/mapd/__init__.py @@ -0,0 +1,5 @@ +import os +from openpilot.common.basedir import BASEDIR + +MAPD_BIN_DIR = os.path.join(BASEDIR, 'third_party/mapd_pfeiferj') +MAPD_PATH = os.path.join(MAPD_BIN_DIR, 'mapd') diff --git a/sunnypilot/mapd/live_map_data/__init__.py b/sunnypilot/mapd/live_map_data/__init__.py new file mode 100644 index 0000000000..557ad06515 --- /dev/null +++ b/sunnypilot/mapd/live_map_data/__init__.py @@ -0,0 +1,22 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from openpilot.common.swaglog import cloudlog + +LOOK_AHEAD_HORIZON_TIME = 15. # s. Time horizon for look ahead of turn speed sections to provide on liveMapDataSP msg. +_DEBUG = False +_CLOUDLOG_DEBUG = False +ROAD_NAME_TIMEOUT = 30 # secs +R = 6373000.0 # approximate radius of earth in mts +QUERY_RADIUS = 3000 # mts. Radius to use on OSM data queries. +QUERY_RADIUS_OFFLINE = 2250 # mts. Radius to use on offline OSM data queries. + + +def get_debug(msg, log_to_cloud=True): + if _CLOUDLOG_DEBUG and log_to_cloud: + cloudlog.debug(msg) + if _DEBUG: + print(msg) diff --git a/sunnypilot/mapd/live_map_data/base_map_data.py b/sunnypilot/mapd/live_map_data/base_map_data.py new file mode 100644 index 0000000000..723864dd2a --- /dev/null +++ b/sunnypilot/mapd/live_map_data/base_map_data.py @@ -0,0 +1,65 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from abc import abstractmethod, ABC + +import cereal.messaging as messaging +from openpilot.common.params import Params +from openpilot.common.constants import CV +from openpilot.selfdrive.car.cruise import V_CRUISE_UNSET +from openpilot.sunnypilot.navd.helpers import coordinate_from_param + +MAX_SPEED_LIMIT = V_CRUISE_UNSET * CV.KPH_TO_MS + + +class BaseMapData(ABC): + def __init__(self): + self.params = Params() + + self.sm = messaging.SubMaster(['liveLocationKalman']) + self.pm = messaging.PubMaster(['liveMapDataSP']) + + self.localizer_valid = False + self.last_bearing = None + self.last_position = coordinate_from_param("LastGPSPositionLLK", self.params) + + @abstractmethod + def update_location(self) -> None: + pass + + @abstractmethod + def get_current_speed_limit(self) -> float: + pass + + @abstractmethod + def get_next_speed_limit_and_distance(self) -> tuple[float, float]: + pass + + @abstractmethod + def get_current_road_name(self) -> str: + pass + + def publish(self) -> None: + speed_limit = self.get_current_speed_limit() + next_speed_limit, next_speed_limit_distance = self.get_next_speed_limit_and_distance() + + mapd_sp_send = messaging.new_message('liveMapDataSP') + mapd_sp_send.valid = self.sm['liveLocationKalman'].gpsOK + live_map_data = mapd_sp_send.liveMapDataSP + + live_map_data.speedLimitValid = bool(MAX_SPEED_LIMIT > speed_limit > 0) + live_map_data.speedLimit = speed_limit + live_map_data.speedLimitAheadValid = bool(MAX_SPEED_LIMIT > next_speed_limit > 0) + live_map_data.speedLimitAhead = next_speed_limit + live_map_data.speedLimitAheadDistance = next_speed_limit_distance + live_map_data.roadName = self.get_current_road_name() + + self.pm.send('liveMapDataSP', mapd_sp_send) + + def tick(self) -> None: + self.sm.update(0) + self.update_location() + self.publish() diff --git a/sunnypilot/mapd/live_map_data/debug.py b/sunnypilot/mapd/live_map_data/debug.py new file mode 100644 index 0000000000..794f2ef8ff --- /dev/null +++ b/sunnypilot/mapd/live_map_data/debug.py @@ -0,0 +1,56 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +# DISCLAIMER: This code is intended principally for development and debugging purposes. +# Although it provides a standalone entry point to the program, users should refer +# to the actual implementations for consumption. Usage outside of development scenarios +# is not advised and could lead to unpredictable results. + +import threading +import traceback + +from cereal import messaging +from openpilot.common.gps import get_gps_location_service +from openpilot.common.params import Params +from openpilot.common.realtime import config_realtime_process +from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit_controller.common import Policy +from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit_controller.speed_limit_resolver import SpeedLimitResolver +from openpilot.sunnypilot.mapd.live_map_data import get_debug + + +def excepthook(args): + get_debug(f'MapD: Threading exception:\n{args}') + traceback.print_exception(args.exc_type, args.exc_value, args.exc_traceback) + + +def live_map_data_sp_thread(): + config_realtime_process([0, 1, 2, 3], 5) + + params = Params() + gps_location_service = get_gps_location_service(params) + + while True: + live_map_data_sp_thread_debug(gps_location_service) + + +def live_map_data_sp_thread_debug(gps_location_service): + _sub_master = messaging.SubMaster(['carState', 'livePose', 'liveMapDataSP', 'longitudinalPlanSP', 'carStateSP', gps_location_service]) + _sub_master.update() + + v_ego = _sub_master['carState'].vEgo + _resolver = SpeedLimitResolver() + _resolver.policy = Policy.car_state_priority + _resolver.update(v_ego, _sub_master) + print(_resolver.speed_limit, _resolver.distance, _resolver.source) + + +def main(): + threading.excepthook = excepthook + live_map_data_sp_thread() + + +if __name__ == "__main__": + main() diff --git a/sunnypilot/mapd/live_map_data/osm_map_data.py b/sunnypilot/mapd/live_map_data/osm_map_data.py new file mode 100644 index 0000000000..0662e2d589 --- /dev/null +++ b/sunnypilot/mapd/live_map_data/osm_map_data.py @@ -0,0 +1,61 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import json +import math +import platform + +from cereal import log +from openpilot.common.params import Params +from openpilot.sunnypilot.mapd.live_map_data.base_map_data import BaseMapData +from openpilot.sunnypilot.navd.helpers import Coordinate + + +class OsmMapData(BaseMapData): + def __init__(self): + super().__init__() + self.mem_params = Params("/dev/shm/params") if platform.system() != "Darwin" else self.params + + def update_location(self) -> None: + location = self.sm['liveLocationKalman'] + self.localizer_valid = (location.status == log.LiveLocationKalman.Status.valid) and location.positionGeodetic.valid + + if self.localizer_valid: + self.last_bearing = math.degrees(location.calibratedOrientationNED.value[2]) + self.last_position = Coordinate(location.positionGeodetic.value[0], location.positionGeodetic.value[1]) + + if self.last_position is None: + return + + params = { + "latitude": self.last_position.latitude, + "longitude": self.last_position.longitude, + } + + if self.last_bearing is not None: + params['bearing'] = self.last_bearing + + self.mem_params.put("LastGPSPosition", json.dumps(params)) + + def get_current_speed_limit(self) -> float: + return float(self.mem_params.get("MapSpeedLimit") or 0.0) + + def get_current_road_name(self) -> str: + return str(self.mem_params.get("RoadName") or "") + + def get_next_speed_limit_and_distance(self) -> tuple[float, float]: + next_speed_limit_section_str = self.mem_params.get("NextMapSpeedLimit") + next_speed_limit_section = next_speed_limit_section_str if next_speed_limit_section_str else {} + next_speed_limit = next_speed_limit_section.get('speedlimit', 0.0) + next_speed_limit_latitude = next_speed_limit_section.get('latitude') + next_speed_limit_longitude = next_speed_limit_section.get('longitude') + next_speed_limit_distance = 0.0 + + if next_speed_limit_latitude and next_speed_limit_longitude: + next_speed_limit_coordinates = Coordinate(next_speed_limit_latitude, next_speed_limit_longitude) + next_speed_limit_distance = (self.last_position or Coordinate(0, 0)).distance_to(next_speed_limit_coordinates) + + return next_speed_limit, next_speed_limit_distance diff --git a/sunnypilot/mapd/live_map_data/standalone.py b/sunnypilot/mapd/live_map_data/standalone.py new file mode 100644 index 0000000000..f73ecc7724 --- /dev/null +++ b/sunnypilot/mapd/live_map_data/standalone.py @@ -0,0 +1,42 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +# DISCLAIMER: This code is intended principally for development and debugging purposes. +# Although it provides a standalone entry point to the program, users should refer +# to the actual implementations for consumption. Usage outside of development scenarios +# is not advised and could lead to unpredictable results. + +import threading +import traceback + +from openpilot.common.realtime import Ratekeeper, config_realtime_process +from openpilot.sunnypilot.mapd.live_map_data import get_debug +from openpilot.sunnypilot.mapd.live_map_data.osm_map_data import OsmMapData + + +def excepthook(args): + get_debug(f'MapD: Threading exception:\n{args}') + traceback.print_exception(args.exc_type, args.exc_value, args.exc_traceback) + + +def live_map_data_sp_thread(): + config_realtime_process([0, 1, 2, 3], 5) + + live_map_sp = OsmMapData() + rk = Ratekeeper(1, print_delay_threshold=None) + + while True: + live_map_sp.tick() + rk.keep_time() + + +def main(): + threading.excepthook = excepthook + live_map_data_sp_thread() + + +if __name__ == "__main__": + main() diff --git a/sunnypilot/mapd/mapd_installer.py b/sunnypilot/mapd/mapd_installer.py new file mode 100755 index 0000000000..08d17376d6 --- /dev/null +++ b/sunnypilot/mapd/mapd_installer.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import logging +import os +import stat +import time +import traceback +import requests +from pathlib import Path +from urllib.request import urlopen + +from cereal import messaging +from openpilot.common.params import Params +from openpilot.system.hardware.hw import Paths +from openpilot.common.spinner import Spinner +from openpilot.system.version import is_prebuilt +from openpilot.sunnypilot.mapd import MAPD_PATH, MAPD_BIN_DIR +import openpilot.system.sentry as sentry + +VERSION = "v1.12.0" +URL = f"https://github.com/pfeiferj/openpilot-mapd/releases/download/{VERSION}/mapd" + + +def update_installed_version(version: str, params: Params = None) -> None: + if params is None: + params = Params() + + params.put("MapdVersion", version) + + +class MapdInstallManager: + def __init__(self, spinner_ref: Spinner): + self._spinner = spinner_ref + self._params = Params() + + def download(self) -> None: + self.ensure_directories_exist() + self._download_file() + update_installed_version(VERSION, self._params) + + def check_and_download(self) -> None: + if self.download_needed(): + self.download() + + def download_needed(self) -> bool: + return not os.path.exists(MAPD_PATH) or self.get_installed_version() != VERSION + + @staticmethod + def ensure_directories_exist() -> None: + if not os.path.exists(Paths.mapd_root()): + os.makedirs(Paths.mapd_root()) + if not os.path.exists(MAPD_BIN_DIR): + os.makedirs(MAPD_BIN_DIR) + + @staticmethod + def _safe_write_and_set_executable(file_path: Path, content: bytes) -> None: + with open(file_path, 'wb') as output: + output.write(content) + output.flush() + os.fsync(output.fileno()) + current_permissions = stat.S_IMODE(os.lstat(file_path).st_mode) + os.chmod(file_path, current_permissions | stat.S_IEXEC) + + def _download_file(self, num_retries=5) -> None: + temp_file = Path(MAPD_PATH + ".tmp") + download_timeout = 60 + for cnt in range(num_retries): + try: + response = requests.get(URL, stream=True, timeout=download_timeout) + response.raise_for_status() + self._safe_write_and_set_executable(temp_file, response.content) + # No exceptions encountered. Safe to replace original file. + temp_file.replace(MAPD_PATH) + return + except requests.exceptions.ReadTimeout: + self._spinner.update(f"ReadTimeout caught. Timeout is [{download_timeout}]. Retrying download... [{cnt}]") + time.sleep(0.5) + except requests.exceptions.RequestException as e: + self._spinner.update(f"RequestException caught: {e}. Retrying download... [{cnt}]") + time.sleep(0.5) + + # Delete temp file if the process was not successful. + if temp_file.exists(): + temp_file.unlink() + logging.error("Failed to download file after all retries") + + def get_installed_version(self) -> str: + return str(self._params.get("MapdVersion") or "") + + def wait_for_internet_connection(self, return_on_failure: bool = False) -> bool: + max_retries = 10 + for retries in range(max_retries + 1): + self._spinner.update(f"Waiting for internet connection... [{retries}/{max_retries}]") + time.sleep(2) + try: + _ = urlopen('https://sentry.io', timeout=10) + return True + except Exception as e: + print(f'Wait for internet failed: {e}') + if return_on_failure and retries == max_retries: + return False + + return False + + def non_prebuilt_install(self) -> None: + sm = messaging.SubMaster(['deviceState']) + metered = sm['deviceState'].networkMetered + + if metered: + self._spinner.update("Can't proceed with mapd install since network is metered!") + time.sleep(5) + return + + try: + self.ensure_directories_exist() + if not self.download_needed(): + self._spinner.update("Mapd is good!") + time.sleep(0.1) + return + + if self.wait_for_internet_connection(return_on_failure=True): + self._spinner.update(f"Downloading pfeiferj's mapd [{self.get_installed_version()}] => [{VERSION}].") + time.sleep(0.1) + self.check_and_download() + self._spinner.close() + + except Exception: + for i in range(6): + self._spinner.update("Failed to download OSM maps won't work until properly downloaded!" + + "Try again manually rebooting. " + + f"Boot will continue in {5 - i}s...") + time.sleep(1) + + sentry.init(sentry.SentryProject.SELFDRIVE) + traceback.print_exc() + sentry.capture_exception() + + +if __name__ == "__main__": + spinner = Spinner() + install_manager = MapdInstallManager(spinner) + install_manager.ensure_directories_exist() + if is_prebuilt(): + debug_msg = f"[DEBUG] This is prebuilt, no mapd install required. VERSION: [{VERSION}], Param [{install_manager.get_installed_version()}]" + spinner.update(debug_msg) + update_installed_version(VERSION) + else: + spinner.update(f"Checking if mapd is installed and valid. Prebuilt [{is_prebuilt()}]") + install_manager.non_prebuilt_install() diff --git a/sunnypilot/mapd/mapd_manager.py b/sunnypilot/mapd/mapd_manager.py new file mode 100755 index 0000000000..9de7dee8b9 --- /dev/null +++ b/sunnypilot/mapd/mapd_manager.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import json +import platform +import os +import glob +import shutil +from datetime import datetime + +from openpilot.common.params import Params +from openpilot.common.realtime import Ratekeeper, config_realtime_process +from openpilot.common.swaglog import cloudlog +from openpilot.selfdrive.selfdrived.alertmanager import set_offroad_alert +from openpilot.sunnypilot.mapd.live_map_data.osm_map_data import OsmMapData +from openpilot.system.hardware.hw import Paths +from openpilot.sunnypilot.mapd import MAPD_PATH +from openpilot.sunnypilot.mapd.mapd_installer import VERSION, update_installed_version + +# PFEIFER - MAPD {{ +params = Params() +mem_params = Params("/dev/shm/params") if platform.system() != "Darwin" else params +# }} PFEIFER - MAPD + + +def get_files_for_cleanup() -> list[str]: + paths = [ + f"{Paths.mapd_root()}/db", + f"{Paths.mapd_root()}/v*" + ] + files_to_remove = [] + for path in paths: + if os.path.exists(path): + files = glob.glob(path + '/**', recursive=True) + files_to_remove.extend(files) + # check for version and mapd files + if not os.path.isfile(MAPD_PATH): + files_to_remove.append(MAPD_PATH) + return files_to_remove + + +def cleanup_old_osm_data(files_to_remove: list[str]) -> None: + for file in files_to_remove: + # Remove trailing slash if path is file + if file.endswith('/') and os.path.isfile(file[:-1]): + file = file[:-1] + # Try to remove as file or symbolic link first + if os.path.islink(file) or os.path.isfile(file): + os.remove(file) + elif os.path.isdir(file): # If it's a directory + shutil.rmtree(file, ignore_errors=False) + + +def request_refresh_osm_location_data(nations: list[str], states: list[str] | None = None) -> None: + params.put("OsmDownloadedDate", str(datetime.now().timestamp())) + params.put_bool("OsmDbUpdatesCheck", False) + + osm_download_locations = { + "nations": nations, + "states": states or [] + } + + print(f"Downloading maps for {json.dumps(osm_download_locations)}") + mem_params.put("OSMDownloadLocations", osm_download_locations) + + +def filter_nations_and_states(nations: list[str], states: list[str] | None = None) -> tuple[list[str], list[str]]: + """Filters and prepares nation and state data for OSM map download. + + If the nation is 'US' and a specific state is provided, the nation 'US' is removed from the list. + If the nation is 'US' and the state is 'All', the 'All' is removed from the list. + The idea behind these filters is that if a specific state in the US is provided, + there's no need to download map data for the entire US. Conversely, + if the state is unspecified (i.e., 'All'), we intend to download map data for the whole US, + and 'All' isn't a valid state name, so it's removed. + + Parameters: + nations (list): A list of nations for which the map data is to be downloaded. + states (list, optional): A list of states for which the map data is to be downloaded. Defaults to None. + + Returns: + tuple: Two lists. The first list is filtered nations and the second list is filtered states. + """ + + if "US" in nations and states and not any(x.lower() == "all" for x in states): + # If a specific state in the US is provided, remove 'US' from nations + nations.remove("US") + elif "US" in nations and states and any(x.lower() == "all" for x in states): + # If 'All' is provided as a state (case invariant), remove those instances from states + states = [x for x in states if x.lower() != "all"] + elif "US" not in nations and states and any(x.lower() == "all" for x in states): + states.remove("All") + return nations, states or [] + + +def update_osm_db() -> None: + if params.get_bool("OsmDbUpdatesCheck"): + cleanup_old_osm_data(get_files_for_cleanup()) + country = params.get("OsmLocationName", return_default=True) + state = params.get("OsmStateName", return_default=True) + filtered_nations, filtered_states = filter_nations_and_states([country], [state]) + request_refresh_osm_location_data(filtered_nations, filtered_states) + + if not mem_params.get("OSMDownloadBounds"): + mem_params.put("OSMDownloadBounds", "") + + if not mem_params.get("LastGPSPosition"): + mem_params.put("LastGPSPosition", "{}") + + +def main_thread(): + update_installed_version(VERSION, params) + config_realtime_process([0, 1, 2, 3], 5) + + rk = Ratekeeper(1, print_delay_threshold=None) + live_map_sp = OsmMapData() + + # Create folder needed for OSM + try: + os.mkdir(Paths.mapd_root()) + except FileExistsError: + pass + except PermissionError: + cloudlog.exception(f"mapd: failed to make {Paths.mapd_root()}") + + while True: + show_alert = get_files_for_cleanup() and params.get_bool("OsmLocal") + set_offroad_alert("Offroad_OSMUpdateRequired", show_alert, "This alert will be cleared when new maps are downloaded.") + + update_osm_db() + live_map_sp.tick() + rk.keep_time() + + +def main(): + main_thread() + + +if __name__ == "__main__": + main() diff --git a/sunnypilot/mapd/tests/__init__.py b/sunnypilot/mapd/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sunnypilot/mapd/tests/mapd_hash b/sunnypilot/mapd/tests/mapd_hash new file mode 100644 index 0000000000..0322ea97ca --- /dev/null +++ b/sunnypilot/mapd/tests/mapd_hash @@ -0,0 +1 @@ +fdb3b49ee19956e6ce09fdc3373cbba557f1263b2180e9f344c1d4053852284b \ No newline at end of file diff --git a/sunnypilot/mapd/tests/test_mapd_version.py b/sunnypilot/mapd/tests/test_mapd_version.py new file mode 100644 index 0000000000..5619d2ec29 --- /dev/null +++ b/sunnypilot/mapd/tests/test_mapd_version.py @@ -0,0 +1,19 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from openpilot.sunnypilot import get_file_hash +from openpilot.sunnypilot.mapd import MAPD_PATH +from openpilot.sunnypilot.mapd.update_version import MAPD_HASH_PATH + + +class TestMapdVersion: + def test_compare_versions(self): + mapd_hash = get_file_hash(MAPD_PATH) + + with open(MAPD_HASH_PATH) as f: + current_hash = f.read().strip() + + assert current_hash == mapd_hash, "Run sunnypilot/mapd/update_version.py to update the current mapd version and hash" diff --git a/sunnypilot/mapd/update_version.py b/sunnypilot/mapd/update_version.py new file mode 100755 index 0000000000..c5e08b3f8f --- /dev/null +++ b/sunnypilot/mapd/update_version.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import argparse +import os +import re + +from openpilot.sunnypilot import get_file_hash +from openpilot.common.basedir import BASEDIR +from openpilot.sunnypilot.mapd import MAPD_PATH + +MAPD_HASH_PATH = os.path.join(BASEDIR, "sunnypilot", "mapd", "tests", "mapd_hash") +MAPD_VERSION_PATH = os.path.join(BASEDIR, "sunnypilot", "mapd", "mapd_installer.py") + + +def update_mapd_hash(): + mapd_hash = get_file_hash(MAPD_PATH) + + with open(MAPD_HASH_PATH, "w") as f: + f.write(mapd_hash) + + print(f"Generated and updated new mapd hash to {MAPD_HASH_PATH}") + + +def get_current_mapd_version(path: str) -> str: + print("[GET CURRENT MAPD VERSION]") + with open(path) as f: + for line in f: + if line.strip().startswith("VERSION"): + # Match VERSION = 'v1.11.0' or VERSION="v1.11.0" (with optional spaces) + match = re.search(r'VERSION\s*=\s*[\'"]([^\'"]+)[\'"]', line) + if match: + ver = match.group(1) + print(f'Current mapd version: "{ver}"') + return ver + else: + print("[ERROR] VERSION line found but no quoted value detected.") + return "" + print("[ERROR] VERSION not found in file!") + return "" + + +def update_mapd_version(ver: str, path: str): + print("[CHANGE CURRENT MAPD VERSION]") + + with open(path) as f: + lines = f.readlines() + + found = False + new_lines = [] + for line in lines: + if not found and line.startswith("VERSION ="): + new_lines.append(f'VERSION = "{ver}"\n') + found = True + new_lines.extend(lines[lines.index(line) + 1:]) + break + else: + new_lines.append(line) + + if not found: + print("[ERROR] VERSION line not found! Aborting without writing.") + return + + with open(path, "w") as f: + f.writelines(new_lines) + + print(f'New mapd version: "{ver}"') + print("[DONE]") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Update mapd version and hash") + parser.add_argument("--new_ver", type=str, help="New mapd version") + args = parser.parse_args() + + if not args.new_ver: + print("Warning: No new mapd version provided. Use --new_ver to specify") + print("Example:") + print(" python sunnypilot/mapd/update_version.py --new_ver \"v1.12.0\"") + print("Current mapd version and hash will not be updated! (aborted)") + exit(0) + + current_ver = get_current_mapd_version(MAPD_VERSION_PATH) + new_ver = f"{args.new_ver}" + if current_ver == new_ver: + print(f'Proposed mapd version: "{new_ver}"') + confirm = input("Proposed mapd version is the same as the current mapd version. Confirm? (y/n): ").upper().strip() + if confirm != "Y": + print("Current mapd version and hash will not be updated! (aborted)") + exit(0) + + update_mapd_version(new_ver, MAPD_VERSION_PATH) + update_mapd_hash() diff --git a/sunnypilot/mapd/version.py b/sunnypilot/mapd/version.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sunnypilot/modeld_v2/.gitignore b/sunnypilot/modeld_v2/.gitignore new file mode 100644 index 0000000000..742d3d1205 --- /dev/null +++ b/sunnypilot/modeld_v2/.gitignore @@ -0,0 +1 @@ +*_pyx.cpp diff --git a/sunnypilot/modeld_v2/SConscript b/sunnypilot/modeld_v2/SConscript new file mode 100644 index 0000000000..ddf889c0c0 --- /dev/null +++ b/sunnypilot/modeld_v2/SConscript @@ -0,0 +1,59 @@ +import os +import glob + +Import('env', 'arch') +lenv = env.Clone() +tinygrad_files = ["#"+x for x in glob.glob(env.Dir("#tinygrad_repo").relpath + "/**", recursive=True, root_dir=env.Dir("#").abspath) if 'pycache' not in x] + +# Get model metadata +PC = not os.path.isfile('/TICI') +if PC: + inputs = tinygrad_files + [File(Dir("#sunnypilot/modeld_v2").File("install_models_pc.py").abspath)] + outputs = [] + model_dir = Dir("models").abspath + cmd = f'python3 {Dir("#sunnypilot/modeld_v2").abspath}/install_models_pc.py {model_dir}' + + for model_name in ['supercombo', 'driving_vision', 'driving_off_policy', 'driving_policy']: + if File(f"models/{model_name}.onnx").exists(): + inputs.append(File(f"models/{model_name}.onnx")) + inputs.append(File(f"models/{model_name}_tinygrad.pkl")) + outputs.append(File(f"models/{model_name}_metadata.pkl")) + if outputs: + lenv.Command(outputs, inputs, cmd) + +tg_flags = { + 'larch64': 'DEV=QCOM FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0', + 'Darwin': f'DEV=CPU THREADS=0 HOME={os.path.expanduser("~")}', +}.get(arch, 'DEV=CPU CPU_LLVM=1 THREADS=0') + +image_flag = { + 'larch64': 'IMAGE=2', +}.get(arch, 'IMAGE=0') + +def tg_compile(flags, model_name): + pythonpath_string = 'PYTHONPATH="${PYTHONPATH}:' + env.Dir("#tinygrad_repo").abspath + '"' + fn = File(f"models/{model_name}").abspath + out = fn + "_tinygrad.pkl" + + return lenv.Command( + out, + [fn + ".onnx"] + tinygrad_files, + f'{pythonpath_string} {flags} {image_flag} python3 {Dir("#tinygrad_repo").abspath}/examples/openpilot/compile3.py {fn}.onnx {out}' + ) + +# Compile models +for model_name in ['supercombo', 'driving_vision', 'driving_off_policy', 'driving_policy']: + if File(f"models/{model_name}.onnx").exists(): + tg_compile(tg_flags, model_name) + +script_files = [File("warp.py"), File(Dir("#selfdrive/modeld").File("compile_warp.py").abspath)] +pythonpath_string = 'PYTHONPATH="${PYTHONPATH}:' + env.Dir("#tinygrad_repo").abspath + ':' + env.Dir("#").abspath + '"' +compile_warp_cmd = f'{pythonpath_string} {tg_flags} python3 -m sunnypilot.modeld_v2.warp' + +from openpilot.common.transformations.camera import _ar_ox_fisheye, _os_fisheye +warp_targets = [] +for cam in [_ar_ox_fisheye, _os_fisheye]: + w, h = cam.width, cam.height + for bl in [2, 5]: + warp_targets.append(File(f"models/warp_{w}x{h}_b{bl}_tinygrad.pkl").abspath) +lenv.Command(warp_targets, tinygrad_files + script_files, compile_warp_cmd) diff --git a/sunnypilot/modeld_v2/__init__.py b/sunnypilot/modeld_v2/__init__.py new file mode 100644 index 0000000000..639622e827 --- /dev/null +++ b/sunnypilot/modeld_v2/__init__.py @@ -0,0 +1,5 @@ +from pathlib import Path + +MODEL_PATH = Path(__file__).parent / 'models/supercombo.onnx' +MODEL_PKL_PATH = Path(__file__).parent / 'models/supercombo_tinygrad.pkl' +METADATA_PATH = Path(__file__).parent / 'models/supercombo_metadata.pkl' diff --git a/sunnypilot/modeld_v2/camera_offset_helper.py b/sunnypilot/modeld_v2/camera_offset_helper.py new file mode 100644 index 0000000000..7502c3eeb0 --- /dev/null +++ b/sunnypilot/modeld_v2/camera_offset_helper.py @@ -0,0 +1,39 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import numpy as np + +from openpilot.common.transformations.camera import DEVICE_CAMERAS + + +class CameraOffsetHelper: + def __init__(self): + self.camera_offset = 0.0 + self.actual_camera_offset = 0.0 + + @staticmethod + def apply_camera_offset(model_transform, intrinsics, height, offset_param): + cy = intrinsics[1, 2] + shear = np.eye(3, dtype=np.float32) + shear[0, 1] = offset_param / height + shear[0, 2] = -offset_param / height * cy + model_transform = (shear @ model_transform).astype(np.float32) + return model_transform + + def set_offset(self, offset): + self.camera_offset = offset + + def update(self, model_transform_main, model_transform_extra, sm, main_wide_camera): + self.actual_camera_offset = (0.9 * self.actual_camera_offset) + (0.1 * self.camera_offset) + dc = DEVICE_CAMERAS[(str(sm['deviceState'].deviceType), str(sm['roadCameraState'].sensor))] + height = sm["liveCalibration"].height[0] if sm['liveCalibration'].height else 1.22 + + intrinsics_main = dc.ecam.intrinsics if main_wide_camera else dc.fcam.intrinsics + model_transform_main = self.apply_camera_offset(model_transform_main, intrinsics_main, height, self.actual_camera_offset) + + intrinsics_extra = dc.ecam.intrinsics + model_transform_extra = self.apply_camera_offset(model_transform_extra, intrinsics_extra, height, self.actual_camera_offset) + return model_transform_main, model_transform_extra diff --git a/sunnypilot/modeld_v2/constants.py b/sunnypilot/modeld_v2/constants.py new file mode 100644 index 0000000000..cf5157591e --- /dev/null +++ b/sunnypilot/modeld_v2/constants.py @@ -0,0 +1,84 @@ +import numpy as np + +def index_function(idx, max_val=192, max_idx=32): + return (max_val) * ((idx/max_idx)**2) + +class ModelConstants: + # time and distance indices + IDX_N = 33 + T_IDXS = [index_function(idx, max_val=10.0) for idx in range(IDX_N)] + X_IDXS = [index_function(idx, max_val=192.0) for idx in range(IDX_N)] + LEAD_T_IDXS = [0., 2., 4., 6., 8., 10.] + LEAD_T_OFFSETS = [0., 2., 4.] + META_T_IDXS = [2., 4., 6., 8., 10.] + + # model inputs constants + MODEL_FREQ = 20 + FEATURE_LEN = 512 + FULL_HISTORY_BUFFER_LEN = 99 + DESIRE_LEN = 8 + TRAFFIC_CONVENTION_LEN = 2 + LAT_PLANNER_STATE_LEN = 4 + LATERAL_CONTROL_PARAMS_LEN = 2 + PREV_DESIRED_CURV_LEN = 1 + + # model outputs constants + FCW_THRESHOLDS_5MS2 = np.array([.05, .05, .15, .15, .15], dtype=np.float32) + FCW_THRESHOLDS_3MS2 = np.array([.7, .7], dtype=np.float32) + FCW_5MS2_PROBS_WIDTH = 5 + FCW_3MS2_PROBS_WIDTH = 2 + + DISENGAGE_WIDTH = 5 + POSE_WIDTH = 6 + WIDE_FROM_DEVICE_WIDTH = 3 + LEAD_WIDTH = 4 + LANE_LINES_WIDTH = 2 + ROAD_EDGES_WIDTH = 2 + PLAN_WIDTH = 15 + DESIRE_PRED_WIDTH = 8 + LAT_PLANNER_SOLUTION_WIDTH = 4 + DESIRED_CURV_WIDTH = 1 + + NUM_LANE_LINES = 4 + NUM_ROAD_EDGES = 2 + + LEAD_TRAJ_LEN = 6 + DESIRE_PRED_LEN = 4 + + PLAN_MHP_N = 5 + LEAD_MHP_N = 2 + PLAN_MHP_SELECTION = 1 + LEAD_MHP_SELECTION = 3 + + FCW_THRESHOLD_5MS2_HIGH = 0.15 + FCW_THRESHOLD_5MS2_LOW = 0.05 + FCW_THRESHOLD_3MS2 = 0.7 + + CONFIDENCE_BUFFER_LEN = 5 + RYG_GREEN = 0.01165 + RYG_YELLOW = 0.06157 + + POLY_PATH_DEGREE = 4 + +# model outputs slices +class Plan: + POSITION = slice(0, 3) + VELOCITY = slice(3, 6) + ACCELERATION = slice(6, 9) + T_FROM_CURRENT_EULER = slice(9, 12) + ORIENTATION_RATE = slice(12, 15) + +class Meta: + ENGAGED = slice(0, 1) + # next 2, 4, 6, 8, 10 seconds + GAS_DISENGAGE = slice(1, 31, 6) + BRAKE_DISENGAGE = slice(2, 31, 6) + STEER_OVERRIDE = slice(3, 31, 6) + HARD_BRAKE_3 = slice(4, 31, 6) + HARD_BRAKE_4 = slice(5, 31, 6) + HARD_BRAKE_5 = slice(6, 31, 6) + # next 0, 2, 4, 6, 8, 10 seconds + GAS_PRESS = slice(31, 55, 4) + BRAKE_PRESS = slice(32, 55, 4) + LEFT_BLINKER = slice(33, 55, 4) + RIGHT_BLINKER = slice(34, 55, 4) diff --git a/sunnypilot/modeld_v2/fill_model_msg.py b/sunnypilot/modeld_v2/fill_model_msg.py new file mode 100644 index 0000000000..57e968d02f --- /dev/null +++ b/sunnypilot/modeld_v2/fill_model_msg.py @@ -0,0 +1,218 @@ +import os +import capnp +import numpy as np +from cereal import log +from openpilot.sunnypilot.modeld_v2.constants import ModelConstants, Plan +from openpilot.sunnypilot.models.helpers import plan_x_idxs_helper +from openpilot.selfdrive.controls.lib.drive_helpers import get_curvature_from_plan + +SEND_RAW_PRED = os.getenv('SEND_RAW_PRED') + +ConfidenceClass = log.ModelDataV2.ConfidenceClass + + +def get_curvature_from_output(output, plan, vego, lat_action_t, mlsim): + if not mlsim: + if desired_curv := output.get('desired_curvature'): # If the model outputs the desired curvature, use that directly + return float(desired_curv[0, 0]) + + return float(get_curvature_from_plan(plan[:, Plan.T_FROM_CURRENT_EULER][:, 2], plan[:, Plan.ORIENTATION_RATE][:, 2], + ModelConstants.T_IDXS, vego, lat_action_t)) + + +class PublishState: + def __init__(self): + self.disengage_buffer = np.zeros(ModelConstants.CONFIDENCE_BUFFER_LEN*ModelConstants.DISENGAGE_WIDTH, dtype=np.float32) + self.prev_brake_5ms2_probs = np.zeros(ModelConstants.FCW_5MS2_PROBS_WIDTH, dtype=np.float32) + self.prev_brake_3ms2_probs = np.zeros(ModelConstants.FCW_3MS2_PROBS_WIDTH, dtype=np.float32) + +def fill_xyzt(builder, t, x, y, z, x_std=None, y_std=None, z_std=None): + builder.t = t + builder.x = x.tolist() + builder.y = y.tolist() + builder.z = z.tolist() + if x_std is not None: + builder.xStd = x_std.tolist() + if y_std is not None: + builder.yStd = y_std.tolist() + if z_std is not None: + builder.zStd = z_std.tolist() + +def fill_xyvat(builder, t, x, y, v, a, x_std=None, y_std=None, v_std=None, a_std=None): + builder.t = t + builder.x = x.tolist() + builder.y = y.tolist() + builder.v = v.tolist() + builder.a = a.tolist() + if x_std is not None: + builder.xStd = x_std.tolist() + if y_std is not None: + builder.yStd = y_std.tolist() + if v_std is not None: + builder.vStd = v_std.tolist() + if a_std is not None: + builder.aStd = a_std.tolist() + +def fill_xyz_poly(builder, degree, x, y, z): + xyz = np.stack([x, y, z], axis=1) + coeffs = np.polynomial.polynomial.polyfit(ModelConstants.T_IDXS, xyz, deg=degree) + builder.xCoefficients = coeffs[:, 0].tolist() + builder.yCoefficients = coeffs[:, 1].tolist() + builder.zCoefficients = coeffs[:, 2].tolist() + +def fill_lane_line_meta(builder, lane_lines, lane_line_probs): + builder.leftY = lane_lines[1].y[0] + builder.leftProb = lane_line_probs[1] + builder.rightY = lane_lines[2].y[0] + builder.rightProb = lane_line_probs[2] + +def fill_model_msg(base_msg: capnp._DynamicStructBuilder, extended_msg: capnp._DynamicStructBuilder, + net_output_data: dict[str, np.ndarray], action: log.ModelDataV2.Action, + publish_state: PublishState, vipc_frame_id: int, vipc_frame_id_extra: int, + frame_id: int, frame_drop: float, timestamp_eof: int, model_execution_time: float, + valid: bool, model_meta) -> None: + frame_age = frame_id - vipc_frame_id if frame_id > vipc_frame_id else 0 + frame_drop_perc = frame_drop * 100 + extended_msg.valid = valid + base_msg.valid = valid + + driving_model_data = base_msg.drivingModelData + + driving_model_data.frameId = vipc_frame_id + driving_model_data.frameIdExtra = vipc_frame_id_extra + driving_model_data.frameDropPerc = frame_drop_perc + driving_model_data.modelExecutionTime = model_execution_time + driving_model_data.action = action + + modelV2 = extended_msg.modelV2 + modelV2.frameId = vipc_frame_id + modelV2.frameIdExtra = vipc_frame_id_extra + modelV2.frameAge = frame_age + modelV2.frameDropPerc = frame_drop_perc + modelV2.timestampEof = timestamp_eof + modelV2.modelExecutionTime = model_execution_time + + # plan + fill_xyzt(modelV2.position, ModelConstants.T_IDXS, *net_output_data['plan'][0,:,Plan.POSITION].T, *net_output_data['plan_stds'][0,:,Plan.POSITION].T) + fill_xyzt(modelV2.velocity, ModelConstants.T_IDXS, *net_output_data['plan'][0,:,Plan.VELOCITY].T) + fill_xyzt(modelV2.acceleration, ModelConstants.T_IDXS, *net_output_data['plan'][0,:,Plan.ACCELERATION].T) + fill_xyzt(modelV2.orientation, ModelConstants.T_IDXS, *net_output_data['plan'][0,:,Plan.T_FROM_CURRENT_EULER].T) + fill_xyzt(modelV2.orientationRate, ModelConstants.T_IDXS, *net_output_data['plan'][0,:,Plan.ORIENTATION_RATE].T) + + # temporal pose + temporal_pose = modelV2.temporalPoseDEPRECATED + if 'sim_pose' in net_output_data: + temporal_pose.trans = net_output_data['sim_pose'][0,:ModelConstants.POSE_WIDTH//2].tolist() + temporal_pose.transStd = net_output_data['sim_pose_stds'][0,:ModelConstants.POSE_WIDTH//2].tolist() + temporal_pose.rot = net_output_data['sim_pose'][0,ModelConstants.POSE_WIDTH//2:].tolist() + temporal_pose.rotStd = net_output_data['sim_pose_stds'][0,ModelConstants.POSE_WIDTH//2:].tolist() + else: + temporal_pose.trans = net_output_data['plan'][0,0,Plan.VELOCITY].tolist() + temporal_pose.transStd = net_output_data['plan_stds'][0,0,Plan.VELOCITY].tolist() + temporal_pose.rot = net_output_data['plan'][0,0,Plan.ORIENTATION_RATE].tolist() + temporal_pose.rotStd = net_output_data['plan_stds'][0,0,Plan.ORIENTATION_RATE].tolist() + + # poly path + fill_xyz_poly(driving_model_data.path, ModelConstants.POLY_PATH_DEGREE, *net_output_data['plan'][0,:,Plan.POSITION].T) + + # action (includes lateral planning now) + modelV2.action = action + + # times at X_IDXS of edges and lines + LINE_T_IDXS: list[float] = plan_x_idxs_helper(ModelConstants, Plan, net_output_data) + + # lane lines + modelV2.init('laneLines', 4) + for i in range(4): + lane_line = modelV2.laneLines[i] + fill_xyzt(lane_line, LINE_T_IDXS, np.array(ModelConstants.X_IDXS), net_output_data['lane_lines'][0,i,:,0], net_output_data['lane_lines'][0,i,:,1]) + modelV2.laneLineStds = net_output_data['lane_lines_stds'][0,:,0,0].tolist() + modelV2.laneLineProbs = net_output_data['lane_lines_prob'][0,1::2].tolist() + + fill_lane_line_meta(driving_model_data.laneLineMeta, modelV2.laneLines, modelV2.laneLineProbs) + + # road edges + modelV2.init('roadEdges', 2) + for i in range(2): + road_edge = modelV2.roadEdges[i] + fill_xyzt(road_edge, LINE_T_IDXS, np.array(ModelConstants.X_IDXS), net_output_data['road_edges'][0,i,:,0], net_output_data['road_edges'][0,i,:,1]) + modelV2.roadEdgeStds = net_output_data['road_edges_stds'][0,:,0,0].tolist() + + # leads + modelV2.init('leadsV3', 3) + for i in range(3): + lead = modelV2.leadsV3[i] + fill_xyvat(lead, ModelConstants.LEAD_T_IDXS, *net_output_data['lead'][0,i].T, *net_output_data['lead_stds'][0,i].T) + lead.prob = net_output_data['lead_prob'][0,i].tolist() + lead.probTime = ModelConstants.LEAD_T_OFFSETS[i] + + # meta + meta = modelV2.meta + meta.desireState = net_output_data['desire_state'][0].reshape(-1).tolist() + meta.desirePrediction = net_output_data['desire_pred'][0].reshape(-1).tolist() + meta.engagedProb = net_output_data['meta'][0,model_meta.ENGAGED].item() + meta.init('disengagePredictions') + disengage_predictions = meta.disengagePredictions + disengage_predictions.t = ModelConstants.META_T_IDXS + disengage_predictions.brakeDisengageProbs = net_output_data['meta'][0,model_meta.BRAKE_DISENGAGE].tolist() + disengage_predictions.gasDisengageProbs = net_output_data['meta'][0,model_meta.GAS_DISENGAGE].tolist() + disengage_predictions.steerOverrideProbs = net_output_data['meta'][0,model_meta.STEER_OVERRIDE].tolist() + disengage_predictions.brake3MetersPerSecondSquaredProbs = net_output_data['meta'][0,model_meta.HARD_BRAKE_3].tolist() + disengage_predictions.brake4MetersPerSecondSquaredProbs = net_output_data['meta'][0,model_meta.HARD_BRAKE_4].tolist() + disengage_predictions.brake5MetersPerSecondSquaredProbs = net_output_data['meta'][0,model_meta.HARD_BRAKE_5].tolist() + + if hasattr(model_meta, 'GAS_PRESS') and hasattr(model_meta, 'BRAKE_PRESS'): + disengage_predictions.gasPressProbs = net_output_data['meta'][0,model_meta.GAS_PRESS].tolist() + disengage_predictions.brakePressProbs = net_output_data['meta'][0,model_meta.BRAKE_PRESS].tolist() + + publish_state.prev_brake_5ms2_probs[:-1] = publish_state.prev_brake_5ms2_probs[1:] + publish_state.prev_brake_5ms2_probs[-1] = net_output_data['meta'][0,model_meta.HARD_BRAKE_5][0] + publish_state.prev_brake_3ms2_probs[:-1] = publish_state.prev_brake_3ms2_probs[1:] + publish_state.prev_brake_3ms2_probs[-1] = net_output_data['meta'][0,model_meta.HARD_BRAKE_3][0] + hard_brake_predicted = (publish_state.prev_brake_5ms2_probs > ModelConstants.FCW_THRESHOLDS_5MS2).all() and \ + (publish_state.prev_brake_3ms2_probs > ModelConstants.FCW_THRESHOLDS_3MS2).all() + meta.hardBrakePredicted = hard_brake_predicted.item() + + # confidence + if vipc_frame_id % (2*ModelConstants.MODEL_FREQ) == 0: + # any disengage prob + brake_disengage_probs = net_output_data['meta'][0,model_meta.BRAKE_DISENGAGE] + gas_disengage_probs = net_output_data['meta'][0,model_meta.GAS_DISENGAGE] + steer_override_probs = net_output_data['meta'][0,model_meta.STEER_OVERRIDE] + any_disengage_probs = 1-((1-brake_disengage_probs)*(1-gas_disengage_probs)*(1-steer_override_probs)) + # independent disengage prob for each 2s slice + ind_disengage_probs = np.r_[any_disengage_probs[0], np.diff(any_disengage_probs) / (1 - any_disengage_probs[:-1])] + # rolling buf for 2, 4, 6, 8, 10s + publish_state.disengage_buffer[:-ModelConstants.DISENGAGE_WIDTH] = publish_state.disengage_buffer[ModelConstants.DISENGAGE_WIDTH:] + publish_state.disengage_buffer[-ModelConstants.DISENGAGE_WIDTH:] = ind_disengage_probs + + score = 0. + for i in range(ModelConstants.DISENGAGE_WIDTH): + score += publish_state.disengage_buffer[i*ModelConstants.DISENGAGE_WIDTH+ModelConstants.DISENGAGE_WIDTH-1-i].item() / ModelConstants.DISENGAGE_WIDTH + if score < ModelConstants.RYG_GREEN: + modelV2.confidence = ConfidenceClass.green + elif score < ModelConstants.RYG_YELLOW: + modelV2.confidence = ConfidenceClass.yellow + else: + modelV2.confidence = ConfidenceClass.red + + # raw prediction if enabled + if SEND_RAW_PRED: + modelV2.rawPredictions = net_output_data['raw_pred'].tobytes() + +def fill_pose_msg(msg: capnp._DynamicStructBuilder, net_output_data: dict[str, np.ndarray], + vipc_frame_id: int, vipc_dropped_frames: int, timestamp_eof: int, live_calib_seen: bool) -> None: + msg.valid = live_calib_seen & (vipc_dropped_frames < 1) + cameraOdometry = msg.cameraOdometry + + cameraOdometry.frameId = vipc_frame_id + cameraOdometry.timestampEof = timestamp_eof + + cameraOdometry.trans = net_output_data['pose'][0,:3].tolist() + cameraOdometry.rot = net_output_data['pose'][0,3:].tolist() + cameraOdometry.wideFromDeviceEuler = net_output_data['wide_from_device_euler'][0,:].tolist() + cameraOdometry.roadTransformTrans = net_output_data['road_transform'][0,:3].tolist() + cameraOdometry.transStd = net_output_data['pose_stds'][0,:3].tolist() + cameraOdometry.rotStd = net_output_data['pose_stds'][0,3:].tolist() + cameraOdometry.wideFromDeviceEulerStd = net_output_data['wide_from_device_euler_stds'][0,:].tolist() + cameraOdometry.roadTransformTransStd = net_output_data['road_transform_stds'][0,:3].tolist() diff --git a/sunnypilot/modeld_v2/get_model_metadata.py b/sunnypilot/modeld_v2/get_model_metadata.py new file mode 100755 index 0000000000..838b1e9f40 --- /dev/null +++ b/sunnypilot/modeld_v2/get_model_metadata.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +import sys +import pathlib +import codecs +import pickle +from typing import Any + +from tinygrad.nn.onnx import OnnxPBParser + + +class MetadataOnnxPBParser(OnnxPBParser): + def _parse_ModelProto(self) -> dict: + obj: dict[str, Any] = {"graph": {"input": [], "output": []}, "metadata_props": []} + for fid, wire_type in self._parse_message(self.reader.len): + match fid: + case 7: + obj["graph"] = self._parse_GraphProto() + case 14: + obj["metadata_props"].append(self._parse_StringStringEntryProto()) + case _: + self.reader.skip_field(wire_type) + return obj + + +def get_name_and_shape(value_info: dict[str, Any]) -> tuple[str, tuple[int, ...]]: + shape = tuple(int(dim) if isinstance(dim, int) else 0 for dim in value_info["parsed_type"].shape) + name = value_info["name"] + return name, shape + + +def get_metadata_value_by_name(model: dict[str, Any], name: str) -> str | Any: + for prop in model["metadata_props"]: + if prop["key"] == name: + return prop["value"] + return None + + +if __name__ == "__main__": + model_path = pathlib.Path(sys.argv[1]) + model = MetadataOnnxPBParser(model_path).parse() + output_slices = get_metadata_value_by_name(model, 'output_slices') + assert output_slices is not None, 'output_slices not found in metadata' + + metadata = { + 'model_checkpoint': get_metadata_value_by_name(model, 'model_checkpoint'), + 'output_slices': pickle.loads(codecs.decode(output_slices.encode(), "base64")), + 'input_shapes': dict(get_name_and_shape(x) for x in model["graph"]["input"]), + 'output_shapes': dict(get_name_and_shape(x) for x in model["graph"]["output"]), + } + + metadata_path = model_path.parent / (model_path.stem + '_metadata.pkl') + with open(metadata_path, 'wb') as f: + pickle.dump(metadata, f) + + print(f'saved metadata to {metadata_path}') diff --git a/sunnypilot/modeld_v2/install_models_pc.py b/sunnypilot/modeld_v2/install_models_pc.py new file mode 100755 index 0000000000..1bba001abd --- /dev/null +++ b/sunnypilot/modeld_v2/install_models_pc.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +import sys +import shutil +import pickle +import codecs +from pathlib import Path + +from openpilot.system.hardware.hw import Paths +from sunnypilot.modeld_v2.get_model_metadata import MetadataOnnxPBParser, get_name_and_shape, get_metadata_value_by_name + + +def generate_metadata_pkl(model_path, output_path): + try: + model = MetadataOnnxPBParser(model_path).parse() + output_slices = get_metadata_value_by_name(model, 'output_slices') + if not output_slices: + return False + metadata = { + 'model_checkpoint': get_metadata_value_by_name(model, 'model_checkpoint'), + 'output_slices': pickle.loads(codecs.decode(output_slices.encode(), "base64")), + 'input_shapes': dict(get_name_and_shape(x) for x in model["graph"]["input"]), + 'output_shapes': dict(get_name_and_shape(x) for x in model["graph"]["output"]), + } + with open(output_path, 'wb') as f: + pickle.dump(metadata, f) + return True + except Exception: + return False + + +def install_models(model_dir): + model_dir = Path(model_dir) + models = ["driving_off_policy", "driving_on_policy", "driving_vision"] + found_models = [] + + for model in models: + if (model_dir / f"{model}.onnx").exists(): + found_models.append(model) + + if not found_models: + return + + try: + custom_name = input(f"Found models ({', '.join(found_models)}). Enter model short name (e.g. wmiv4): ").strip() + except EOFError: + return + + if not custom_name: + print("No name provided, skipping installation.") + return + + dest_dir = Path(Paths.model_root()) + dest_dir.mkdir(parents=True, exist_ok=True) + + for model in found_models: + onnx_path = model_dir / f"{model}.onnx" + tinygrad_pkl = model_dir / f"{model}_tinygrad.pkl" + metadata_pkl = model_dir / f"{model}_metadata.pkl" + + if not metadata_pkl.exists(): + generate_metadata_pkl(onnx_path, metadata_pkl) + + dest_tinygrad = dest_dir / f"{model}_{custom_name}_tinygrad.pkl" + dest_metadata = dest_dir / f"{model}_{custom_name}_metadata.pkl" + + if tinygrad_pkl.exists(): + shutil.move(str(tinygrad_pkl), str(dest_tinygrad)) + if metadata_pkl.exists(): + shutil.move(str(metadata_pkl), str(dest_metadata)) + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: install_models_pc.py ") + sys.exit(1) + install_models(sys.argv[1]) diff --git a/sunnypilot/modeld_v2/meta_20hz.py b/sunnypilot/modeld_v2/meta_20hz.py new file mode 100644 index 0000000000..8a61925aaa --- /dev/null +++ b/sunnypilot/modeld_v2/meta_20hz.py @@ -0,0 +1,17 @@ +from openpilot.sunnypilot.modeld_v2.constants import Meta + + +class Meta20hz(Meta): + ENGAGED = slice(0, 1) + # next 2, 4, 6, 8, 10 seconds + GAS_DISENGAGE = slice(1, 31, 6) + BRAKE_DISENGAGE = slice(2, 31, 6) + STEER_OVERRIDE = slice(3, 31, 6) + HARD_BRAKE_3 = slice(4, 31, 6) + HARD_BRAKE_4 = slice(5, 31, 6) + HARD_BRAKE_5 = slice(6, 31, 6) + # next 0, 2, 4, 6, 8, 10 seconds + GAS_PRESS = slice(31, 55, 4) + BRAKE_PRESS = slice(32, 55, 4) + LEFT_BLINKER = slice(33, 55, 4) + RIGHT_BLINKER = slice(34, 55, 4) diff --git a/sunnypilot/modeld_v2/meta_helper.py b/sunnypilot/modeld_v2/meta_helper.py new file mode 100644 index 0000000000..3fa10b2415 --- /dev/null +++ b/sunnypilot/modeld_v2/meta_helper.py @@ -0,0 +1,26 @@ +from openpilot.sunnypilot.modeld_v2.constants import Meta +from cereal import custom +from openpilot.sunnypilot.modeld_v2.meta_20hz import Meta20hz +from openpilot.sunnypilot.models.helpers import get_active_bundle + +ModelBundle = custom.ModelManagerSP.ModelBundle + + +def load_meta_constants(): + """ + Determines and loads the appropriate meta model class based on the metadata provided. The function checks + specific keys and conditions within the provided metadata dictionary to identify the corresponding meta + model class to return. + + :param model_metadata: Dictionary containing metadata about the model. It includes + details such as input shapes, output slices, and other configurations for identifying + metadata-dependent meta model classes. + :type model_metadata: dict + :return: The appropriate meta model class (Meta, MetaSimPose, or MetaTombRaider) + based on the conditions and metadata provided. + :rtype: type + """ + if (bundle := get_active_bundle()) and bundle.is20hz: + return Meta20hz + + return Meta # Default diff --git a/sunnypilot/modeld_v2/modeld b/sunnypilot/modeld_v2/modeld new file mode 100755 index 0000000000..5ba4688554 --- /dev/null +++ b/sunnypilot/modeld_v2/modeld @@ -0,0 +1,4 @@ +#!/usr/bin/env bash + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)" +exec "$DIR/modeld.py" "$@" diff --git a/sunnypilot/modeld_v2/modeld.py b/sunnypilot/modeld_v2/modeld.py new file mode 100755 index 0000000000..f862286181 --- /dev/null +++ b/sunnypilot/modeld_v2/modeld.py @@ -0,0 +1,378 @@ +#!/usr/bin/env python3 +import os +from openpilot.system.hardware import TICI +os.environ['DEV'] = 'QCOM' if TICI else 'CPU' +USBGPU = "USBGPU" in os.environ +if USBGPU: + os.environ['DEV'] = 'AMD' + os.environ['AMD_IFACE'] = 'USB' +import time +import numpy as np +import cereal.messaging as messaging +from cereal import car, log +from setproctitle import setproctitle +from cereal.messaging import PubMaster, SubMaster +from msgq.visionipc import VisionIpcClient, VisionStreamType, VisionBuf +from opendbc.car.car_helpers import get_demo_car_params +from openpilot.common.swaglog import cloudlog +from openpilot.common.params import Params +from openpilot.common.filter_simple import FirstOrderFilter +from openpilot.common.realtime import config_realtime_process, DT_MDL +from openpilot.common.transformations.camera import DEVICE_CAMERAS +from openpilot.common.transformations.model import get_warp_matrix +from openpilot.system import sentry +from openpilot.selfdrive.controls.lib.desire_helper import DesireHelper +from openpilot.selfdrive.controls.lib.drive_helpers import get_accel_from_plan, smooth_value + +from openpilot.sunnypilot.modeld_v2.fill_model_msg import fill_model_msg, fill_pose_msg, PublishState, get_curvature_from_output +from openpilot.sunnypilot.modeld_v2.constants import Plan +from openpilot.sunnypilot.modeld_v2.warp import Warp +from openpilot.sunnypilot.modeld_v2.meta_helper import load_meta_constants +from openpilot.sunnypilot.modeld_v2.camera_offset_helper import CameraOffsetHelper + +from openpilot.sunnypilot.livedelay.helpers import get_lat_delay +from openpilot.sunnypilot.modeld_v2.modeld_base import ModelStateBase +from openpilot.sunnypilot.models.helpers import get_active_bundle +from openpilot.sunnypilot.models.runners.helpers import get_model_runner + +PROCESS_NAME = "selfdrive.modeld.modeld_tinygrad" + + +class FrameMeta: + frame_id: int = 0 + timestamp_sof: int = 0 + timestamp_eof: int = 0 + + def __init__(self, vipc=None): + if vipc is not None: + self.frame_id, self.timestamp_sof, self.timestamp_eof = vipc.frame_id, vipc.timestamp_sof, vipc.timestamp_eof + + +class ModelState(ModelStateBase): + frames: dict[str, Warp] + inputs: dict[str, np.ndarray] + prev_desire: np.ndarray # for tracking the rising edge of the pulse + temporal_idxs: slice | np.ndarray + + def __init__(self): + ModelStateBase.__init__(self) + try: + self.model_runner = get_model_runner() + self.constants = self.model_runner.constants + except Exception as e: + cloudlog.exception(f"Failed to initialize model runner: {str(e)}") + raise + + model_bundle = get_active_bundle() + self.generation = model_bundle.generation if model_bundle is not None else None + overrides = {override.key: override.value for override in model_bundle.overrides} + + self.LAT_SMOOTH_SECONDS = float(overrides.get('lat', ".0")) + self.LONG_SMOOTH_SECONDS = float(overrides.get('long', ".0")) + self.MIN_LAT_CONTROL_SPEED = 0.3 + self.PLANPLUS_CONTROL: float = 1.0 + + buffer_length = 5 if self.model_runner.is_20hz else 2 + self.warp = Warp(buffer_length) + self.prev_desire = np.zeros(self.constants.DESIRE_LEN, dtype=np.float32) + self.numpy_inputs = {} + self.temporal_buffers = {} + self.temporal_idxs_map = {} + + for key, shape in self.model_runner.input_shapes.items(): + if key not in self.model_runner.vision_input_names: # Policy inputs + self.numpy_inputs[key] = np.zeros(shape, dtype=np.float32) + + # Temporal input: shape is [batch, history, features] + if len(shape) == 3 and shape[1] > 1: + buffer_history_len = shape[1] * 4 if shape[1] < 99 else shape[1] # Allow for higher history buffers in the future + feature_len = shape[2] + features_buffer_shape = self.model_runner.input_shapes.get('features_buffer') + if shape[1] in (24, 25) and features_buffer_shape is not None and features_buffer_shape[1] == 24: # 20Hz + buffer_history_len = (features_buffer_shape[1] + 1) * 4 + step = int(-buffer_history_len / shape[1]) + self.temporal_idxs_map[key] = np.arange(step, step * (shape[1] + 1), step)[::-1] + elif shape[1] == 25: # Split + skip = buffer_history_len // shape[1] + self.temporal_idxs_map[key] = np.arange(buffer_history_len)[-1 - (skip * (shape[1] - 1))::skip] + elif shape[1] >= 99: # non20hz + self.temporal_idxs_map[key] = np.arange(shape[1]) + self.temporal_buffers[key] = np.zeros((1, buffer_history_len, feature_len), dtype=np.float32) + + @property + def mlsim(self) -> bool: + return bool(self.generation is not None and self.generation >= 11) + + @property + def desire_key(self) -> str: + return next(key for key in self.numpy_inputs if key.startswith('desire')) + + def run(self, bufs: dict[str, VisionBuf], transforms: dict[str, np.ndarray], + inputs: dict[str, np.ndarray], prepare_only: bool) -> dict[str, np.ndarray] | None: + # Model decides when action is completed, so desire input is just a pulse triggered on rising edge + inputs[self.desire_key][0] = 0 + new_desire = np.where(inputs[self.desire_key] - self.prev_desire > .99, inputs[self.desire_key], 0) + self.prev_desire[:] = inputs[self.desire_key] + self.temporal_buffers[self.desire_key][0,:-1] = self.temporal_buffers[self.desire_key][0,1:] + self.temporal_buffers[self.desire_key][0,-1] = new_desire + + # Roll buffer and assign based on desire.shape[1] value + if self.temporal_buffers[self.desire_key].shape[1] > self.numpy_inputs[self.desire_key].shape[1]: + skip = self.temporal_buffers[self.desire_key].shape[1] // self.numpy_inputs[self.desire_key].shape[1] + self.numpy_inputs[self.desire_key][:] = (self.temporal_buffers[self.desire_key][0].reshape( + self.numpy_inputs[self.desire_key].shape[0], self.numpy_inputs[self.desire_key].shape[1], skip, -1).max(axis=2)) + else: + self.numpy_inputs[self.desire_key][:] = self.temporal_buffers[self.desire_key][0, self.temporal_idxs_map[self.desire_key]] + + for key in self.numpy_inputs: + if key in inputs and key not in [self.desire_key]: + self.numpy_inputs[key][:] = inputs[key] + + imgs_tensors = self.warp.process(bufs, transforms) + for name, tensor in imgs_tensors.items(): + self.model_runner.inputs[name] = tensor + self.model_runner.prepare_inputs(self.numpy_inputs) + + if prepare_only: + return None + + # Run model inference + outputs = self.model_runner.run_model() + + # Update features_buffer + self.temporal_buffers['features_buffer'][0, :-1] = self.temporal_buffers['features_buffer'][0, 1:] + self.temporal_buffers['features_buffer'][0, -1] = outputs['hidden_state'][0, :] + self.numpy_inputs['features_buffer'][:] = self.temporal_buffers['features_buffer'][0, self.temporal_idxs_map['features_buffer']] + + if "desired_curvature" in outputs: + input_name_prev = None + if "prev_desired_curv" in self.numpy_inputs.keys(): + input_name_prev = 'prev_desired_curv' + if input_name_prev and input_name_prev in self.temporal_buffers: + self.process_desired_curvature(outputs, input_name_prev) + + return outputs + + def process_desired_curvature(self, outputs, input_name_prev): + self.temporal_buffers[input_name_prev][0,:-1] = self.temporal_buffers[input_name_prev][0,1:] + self.temporal_buffers[input_name_prev][0,-1,:] = outputs['desired_curvature'][0, :] + self.numpy_inputs[input_name_prev][:] = self.temporal_buffers[input_name_prev][0, self.temporal_idxs_map[input_name_prev]] + if self.mlsim: + self.numpy_inputs[input_name_prev][:] = 0*self.temporal_buffers[input_name_prev][0, self.temporal_idxs_map[input_name_prev]] + + def get_action_from_model(self, model_output: dict[str, np.ndarray], prev_action: log.ModelDataV2.Action, + lat_action_t: float, long_action_t: float, v_ego: float) -> log.ModelDataV2.Action: + plan = model_output['plan'][0] + desired_accel, should_stop = get_accel_from_plan(plan[:, Plan.VELOCITY][:, 0], plan[:, Plan.ACCELERATION][:, 0], self.constants.T_IDXS, + action_t=long_action_t) + desired_accel = smooth_value(desired_accel, prev_action.desiredAcceleration, self.LONG_SMOOTH_SECONDS) + + curvature_plan = plan + (self.PLANPLUS_CONTROL - 1.0) * model_output['planplus'][0] if 'planplus' in model_output and self.PLANPLUS_CONTROL != 1.0 else plan + desired_curvature = get_curvature_from_output(model_output, curvature_plan, v_ego, lat_action_t, self.mlsim) + if self.generation is not None and self.generation >= 10: # smooth curvature for post FOF models + if v_ego > self.MIN_LAT_CONTROL_SPEED: + desired_curvature = smooth_value(desired_curvature, prev_action.desiredCurvature, self.LAT_SMOOTH_SECONDS) + else: + desired_curvature = prev_action.desiredCurvature + + return log.ModelDataV2.Action(desiredCurvature=float(desired_curvature),desiredAcceleration=float(desired_accel), shouldStop=bool(should_stop)) + + +def main(demo=False): + cloudlog.warning("modeld init") + + sentry.set_tag("daemon", PROCESS_NAME) + cloudlog.bind(daemon=PROCESS_NAME) + setproctitle(PROCESS_NAME) + config_realtime_process(7, 54) + + cloudlog.warning("loading model") + model = ModelState() + cloudlog.warning("models loaded, modeld starting") + + # visionipc clients + while True: + available_streams = VisionIpcClient.available_streams("camerad", block=False) + if available_streams: + use_extra_client = VisionStreamType.VISION_STREAM_WIDE_ROAD in available_streams and VisionStreamType.VISION_STREAM_ROAD in available_streams + main_wide_camera = VisionStreamType.VISION_STREAM_ROAD not in available_streams + break + time.sleep(.1) + + vipc_client_main_stream = VisionStreamType.VISION_STREAM_WIDE_ROAD if main_wide_camera else VisionStreamType.VISION_STREAM_ROAD + vipc_client_main = VisionIpcClient("camerad", vipc_client_main_stream, True) + vipc_client_extra = VisionIpcClient("camerad", VisionStreamType.VISION_STREAM_WIDE_ROAD, False) + cloudlog.warning(f"vision stream set up, main_wide_camera: {main_wide_camera}, use_extra_client: {use_extra_client}") + + while not vipc_client_main.connect(False): + time.sleep(0.1) + while use_extra_client and not vipc_client_extra.connect(False): + time.sleep(0.1) + + cloudlog.warning(f"connected main cam with buffer size: {vipc_client_main.buffer_len} ({vipc_client_main.width} x {vipc_client_main.height})") + if use_extra_client: + cloudlog.warning(f"connected extra cam with buffer size: {vipc_client_extra.buffer_len} ({vipc_client_extra.width} x {vipc_client_extra.height})") + + # messaging + pm = PubMaster(["modelV2", "drivingModelData", "cameraOdometry", "modelDataV2SP"]) + sm = SubMaster(["deviceState", "carState", "roadCameraState", "liveCalibration", "driverMonitoringState", "carControl", "liveDelay"]) + + publish_state = PublishState() + params = Params() + + # setup filter to track dropped frames + frame_dropped_filter = FirstOrderFilter(0., 10., 1. / model.constants.MODEL_FREQ) + frame_id = 0 + last_vipc_frame_id = 0 + run_count = 0 + + model_transform_main = np.zeros((3, 3), dtype=np.float32) + model_transform_extra = np.zeros((3, 3), dtype=np.float32) + live_calib_seen = False + buf_main, buf_extra = None, None + meta_main = FrameMeta() + meta_extra = FrameMeta() + camera_offset_helper = CameraOffsetHelper() + + + if demo: + CP = get_demo_car_params() + else: + CP = messaging.log_from_bytes(params.get("CarParams", block=True), car.CarParams) + cloudlog.info("modeld got CarParams: %s", CP.brand) + + # TODO Move smooth seconds to action function + long_delay = CP.longitudinalActuatorDelay + model.LONG_SMOOTH_SECONDS + prev_action = log.ModelDataV2.Action() + + DH = DesireHelper() + + while True: + # Keep receiving frames until we are at least 1 frame ahead of previous extra frame + while meta_main.timestamp_sof < meta_extra.timestamp_sof + 25000000: + buf_main = vipc_client_main.recv() + meta_main = FrameMeta(vipc_client_main) + if buf_main is None: + break + + if buf_main is None: + cloudlog.debug("vipc_client_main no frame") + continue + + if use_extra_client: + # Keep receiving extra frames until frame id matches main camera + while True: + buf_extra = vipc_client_extra.recv() + meta_extra = FrameMeta(vipc_client_extra) + if buf_extra is None or meta_main.timestamp_sof < meta_extra.timestamp_sof + 25000000: + break + + if buf_extra is None: + cloudlog.debug("vipc_client_extra no frame") + continue + + if abs(meta_main.timestamp_sof - meta_extra.timestamp_sof) > 10000000: + cloudlog.error(f"frames out of sync! main: {meta_main.frame_id} ({meta_main.timestamp_sof / 1e9:.5f}),\ + extra: {meta_extra.frame_id} ({meta_extra.timestamp_sof / 1e9:.5f})") + + else: + # Use single camera + buf_extra = buf_main + meta_extra = meta_main + + sm.update(0) + desire = DH.desire + is_rhd = sm["driverMonitoringState"].isRHD + frame_id = sm["roadCameraState"].frameId + v_ego = max(sm["carState"].vEgo, 0.) + if sm.frame % 60 == 0: + model.lat_delay = get_lat_delay(params, sm["liveDelay"].lateralDelay) + model.PLANPLUS_CONTROL = params.get("PlanplusControl", return_default=True) + camera_offset_helper.set_offset(params.get("CameraOffset", return_default=True)) + lat_delay = model.lat_delay + model.LAT_SMOOTH_SECONDS + if sm.updated["liveCalibration"] and sm.seen['roadCameraState'] and sm.seen['deviceState']: + device_from_calib_euler = np.array(sm["liveCalibration"].rpyCalib, dtype=np.float32) + dc = DEVICE_CAMERAS[(str(sm['deviceState'].deviceType), str(sm['roadCameraState'].sensor))] + model_transform_main = get_warp_matrix(device_from_calib_euler, dc.ecam.intrinsics if main_wide_camera else dc.fcam.intrinsics, False).astype(np.float32) + model_transform_extra = get_warp_matrix(device_from_calib_euler, dc.ecam.intrinsics, True).astype(np.float32) + model_transform_main, model_transform_extra = camera_offset_helper.update(model_transform_main, model_transform_extra, sm, main_wide_camera) + live_calib_seen = True + + traffic_convention = np.zeros(2) + traffic_convention[int(is_rhd)] = 1 + + vec_desire = np.zeros(model.constants.DESIRE_LEN, dtype=np.float32) + if desire >= 0 and desire < model.constants.DESIRE_LEN: + vec_desire[desire] = 1 + + # tracked dropped frames + vipc_dropped_frames = max(0, meta_main.frame_id - last_vipc_frame_id - 1) + frames_dropped = frame_dropped_filter.update(min(vipc_dropped_frames, 10)) + if run_count < 10: # let frame drops warm up + frame_dropped_filter.x = 0. + frames_dropped = 0. + run_count = run_count + 1 + + frame_drop_ratio = frames_dropped / (1 + frames_dropped) + prepare_only = vipc_dropped_frames > 0 + if prepare_only: + cloudlog.error(f"skipping model eval. Dropped {vipc_dropped_frames} frames") + + bufs = {name: buf_extra if 'big' in name else buf_main for name in model.model_runner.vision_input_names} + transforms = {name: model_transform_extra if 'big' in name else model_transform_main for name in model.model_runner.vision_input_names} + inputs:dict[str, np.ndarray] = { + model.desire_key: vec_desire, + 'traffic_convention': traffic_convention, + } + + if "lateral_control_params" in model.numpy_inputs.keys(): + inputs['lateral_control_params'] = np.array([v_ego, lat_delay], dtype=np.float32) + + mt1 = time.perf_counter() + model_output = model.run(bufs, transforms, inputs, prepare_only) + mt2 = time.perf_counter() + model_execution_time = mt2 - mt1 + + if model_output is not None: + modelv2_send = messaging.new_message('modelV2') + drivingdata_send = messaging.new_message('drivingModelData') + posenet_send = messaging.new_message('cameraOdometry') + mdv2sp_send = messaging.new_message('modelDataV2SP') + + action = model.get_action_from_model(model_output, prev_action, lat_delay + DT_MDL, long_delay + DT_MDL, v_ego) + prev_action = action + fill_model_msg(drivingdata_send, modelv2_send, model_output, action, + publish_state, meta_main.frame_id, meta_extra.frame_id, frame_id, + frame_drop_ratio, meta_main.timestamp_eof, model_execution_time, live_calib_seen, load_meta_constants()) + + desire_state = modelv2_send.modelV2.meta.desireState + l_lane_change_prob = desire_state[log.Desire.laneChangeLeft] + r_lane_change_prob = desire_state[log.Desire.laneChangeRight] + lane_change_prob = l_lane_change_prob + r_lane_change_prob + DH.update(sm['carState'], sm['carControl'].latActive, lane_change_prob) + modelv2_send.modelV2.meta.laneChangeState = DH.lane_change_state + modelv2_send.modelV2.meta.laneChangeDirection = DH.lane_change_direction + mdv2sp_send.modelDataV2SP.laneTurnDirection = DH.lane_turn_direction + drivingdata_send.drivingModelData.meta.laneChangeState = DH.lane_change_state + drivingdata_send.drivingModelData.meta.laneChangeDirection = DH.lane_change_direction + + fill_pose_msg(posenet_send, model_output, meta_main.frame_id, vipc_dropped_frames, meta_main.timestamp_eof, live_calib_seen) + pm.send('modelV2', modelv2_send) + pm.send('drivingModelData', drivingdata_send) + pm.send('cameraOdometry', posenet_send) + pm.send('modelDataV2SP', mdv2sp_send) + last_vipc_frame_id = meta_main.frame_id + + +if __name__ == "__main__": + try: + import argparse + parser = argparse.ArgumentParser() + parser.add_argument('--demo', action='store_true', help='A boolean for demo mode.') + args = parser.parse_args() + main(demo=args.demo) + except KeyboardInterrupt: + cloudlog.warning(f"child {PROCESS_NAME} got SIGINT") + except Exception: + sentry.capture_exception() + raise diff --git a/sunnypilot/modeld_v2/modeld_base.py b/sunnypilot/modeld_v2/modeld_base.py new file mode 100644 index 0000000000..ba57659b09 --- /dev/null +++ b/sunnypilot/modeld_v2/modeld_base.py @@ -0,0 +1,12 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from openpilot.common.params import Params + + +class ModelStateBase: + def __init__(self): + self.lat_delay = Params().get("LagdValueCache", return_default=True) diff --git a/sunnypilot/modeld_v2/models/README.md b/sunnypilot/modeld_v2/models/README.md new file mode 100644 index 0000000000..9e11ca8255 --- /dev/null +++ b/sunnypilot/modeld_v2/models/README.md @@ -0,0 +1,62 @@ +## Neural networks in openpilot +To view the architecture of the ONNX networks, you can use [netron](https://netron.app/) + +## Supercombo +### Supercombo input format (Full size: 799906 x float32) +* **image stream** + * Two consecutive images (256 * 512 * 3 in RGB) recorded at 20 Hz : 393216 = 2 * 6 * 128 * 256 + * Each 256 * 512 image is represented in YUV420 with 6 channels : 6 * 128 * 256 + * Channels 0,1,2,3 represent the full-res Y channel and are represented in numpy as Y[::2, ::2], Y[::2, 1::2], Y[1::2, ::2], and Y[1::2, 1::2] + * Channel 4 represents the half-res U channel + * Channel 5 represents the half-res V channel +* **wide image stream** + * Two consecutive images (256 * 512 * 3 in RGB) recorded at 20 Hz : 393216 = 2 * 6 * 128 * 256 + * Each 256 * 512 image is represented in YUV420 with 6 channels : 6 * 128 * 256 + * Channels 0,1,2,3 represent the full-res Y channel and are represented in numpy as Y[::2, ::2], Y[::2, 1::2], Y[1::2, ::2], and Y[1::2, 1::2] + * Channel 4 represents the half-res U channel + * Channel 5 represents the half-res V channel +* **desire** + * one-hot encoded buffer to command model to execute certain actions, bit needs to be sent for the past 5 seconds (at 20FPS) : 100 * 8 +* **traffic convention** + * one-hot encoded vector to tell model whether traffic is right-hand or left-hand traffic : 2 +* **feature buffer** + * A buffer of intermediate features that gets appended to the current feature to form a 5 seconds temporal context (at 20FPS) : 99 * 512 + + +### Supercombo output format (Full size: XXX x float32) +Read [here](https://github.com/commaai/openpilot/blob/90af436a121164a51da9fa48d093c29f738adf6a/selfdrive/modeld/models/driving.h#L236) for more. + + +## Driver Monitoring Model +* .onnx model can be run with onnx runtimes +* .dlc file is a pre-quantized model and only runs on qualcomm DSPs + +### input format +* single image W = 1440 H = 960 luminance channel (Y) from the planar YUV420 format: + * full input size is 1440 * 960 = 1382400 + * normalized ranging from 0.0 to 1.0 in float32 (onnx runner) or ranging from 0 to 255 in uint8 (snpe runner) +* camera calibration angles (roll, pitch, yaw) from liveCalibration: 3 x float32 inputs + +### output format +* 84 x float32 outputs = 2 + 41 * 2 ([parsing example](https://github.com/commaai/openpilot/blob/22ce4e17ba0d3bfcf37f8255a4dd1dc683fe0c38/selfdrive/modeld/models/dmonitoring.cc#L33)) + * for each person in the front seats (2 * 41) + * face pose: 12 = 6 + 6 + * face orientation [pitch, yaw, roll] in camera frame: 3 + * face position [dx, dy] relative to image center: 2 + * normalized face size: 1 + * standard deviations for above outputs: 6 + * face visible probability: 1 + * eyes: 20 = (8 + 1) + (8 + 1) + 1 + 1 + * eye position and size, and their standard deviations: 8 + * eye visible probability: 1 + * eye closed probability: 1 + * wearing sunglasses probability: 1 + * face occluded probability: 1 + * touching wheel probability: 1 + * paying attention probability: 1 + * (deprecated) distracted probabilities: 2 + * using phone probability: 1 + * distracted probability: 1 + * common outputs 2 + * poor camera vision probability: 1 + * left hand drive probability: 1 diff --git a/sunnypilot/modeld_v2/models/__init__.py b/sunnypilot/modeld_v2/models/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sunnypilot/modeld_v2/parse_model_outputs.py b/sunnypilot/modeld_v2/parse_model_outputs.py new file mode 100644 index 0000000000..c71a146454 --- /dev/null +++ b/sunnypilot/modeld_v2/parse_model_outputs.py @@ -0,0 +1,107 @@ +import numpy as np +from openpilot.sunnypilot.modeld_v2.constants import ModelConstants + +def safe_exp(x, out=None): + # -11 is around 10**14, more causes float16 overflow + return np.exp(np.clip(x, -np.inf, 11), out=out) + +def sigmoid(x): + return 1. / (1. + safe_exp(-x)) + +def softmax(x, axis=-1): + x -= np.max(x, axis=axis, keepdims=True) + if x.dtype == np.float32 or x.dtype == np.float64: + safe_exp(x, out=x) + else: + x = safe_exp(x) + x /= np.sum(x, axis=axis, keepdims=True) + return x + +class Parser: + def __init__(self, ignore_missing=False): + self.ignore_missing = ignore_missing + + def check_missing(self, outs, name): + if name not in outs and not self.ignore_missing: + raise ValueError(f"Missing output {name}") + return name not in outs + + def parse_categorical_crossentropy(self, name, outs, out_shape=None): + if self.check_missing(outs, name): + return + raw = outs[name] + if out_shape is not None: + raw = raw.reshape((raw.shape[0],) + out_shape) + outs[name] = softmax(raw, axis=-1) + + def parse_binary_crossentropy(self, name, outs): + if self.check_missing(outs, name): + return + raw = outs[name] + outs[name] = sigmoid(raw) + + def parse_mdn(self, name, outs, in_N=0, out_N=1, out_shape=None): + if self.check_missing(outs, name): + return + raw = outs[name] + raw = raw.reshape((raw.shape[0], max(in_N, 1), -1)) + + n_values = (raw.shape[2] - out_N)//2 + pred_mu = raw[:,:,:n_values] + pred_std = safe_exp(raw[:,:,n_values: 2*n_values]) + + if in_N > 1: + weights = np.zeros((raw.shape[0], in_N, out_N), dtype=raw.dtype) + for i in range(out_N): + weights[:,:,i - out_N] = softmax(raw[:,:,i - out_N], axis=-1) + + if out_N == 1: + for fidx in range(weights.shape[0]): + idxs = np.argsort(weights[fidx][:,0])[::-1] + weights[fidx] = weights[fidx][idxs] + pred_mu[fidx] = pred_mu[fidx][idxs] + pred_std[fidx] = pred_std[fidx][idxs] + full_shape = tuple([raw.shape[0], in_N] + list(out_shape)) + outs[name + '_weights'] = weights + outs[name + '_hypotheses'] = pred_mu.reshape(full_shape) + outs[name + '_stds_hypotheses'] = pred_std.reshape(full_shape) + + pred_mu_final = np.zeros((raw.shape[0], out_N, n_values), dtype=raw.dtype) + pred_std_final = np.zeros((raw.shape[0], out_N, n_values), dtype=raw.dtype) + for fidx in range(weights.shape[0]): + for hidx in range(out_N): + idxs = np.argsort(weights[fidx,:,hidx])[::-1] + pred_mu_final[fidx, hidx] = pred_mu[fidx, idxs[0]] + pred_std_final[fidx, hidx] = pred_std[fidx, idxs[0]] + else: + pred_mu_final = pred_mu + pred_std_final = pred_std + + if out_N > 1: + final_shape = tuple([raw.shape[0], out_N] + list(out_shape)) + else: + final_shape = tuple([raw.shape[0],] + list(out_shape)) + outs[name] = pred_mu_final.reshape(final_shape) + outs[name + '_stds'] = pred_std_final.reshape(final_shape) + + def parse_outputs(self, outs: dict[str, np.ndarray]) -> dict[str, np.ndarray]: + self.parse_mdn('plan', outs, in_N=ModelConstants.PLAN_MHP_N, out_N=ModelConstants.PLAN_MHP_SELECTION, + out_shape=(ModelConstants.IDX_N,ModelConstants.PLAN_WIDTH)) + self.parse_mdn('lane_lines', outs, in_N=0, out_N=0, out_shape=(ModelConstants.NUM_LANE_LINES,ModelConstants.IDX_N,ModelConstants.LANE_LINES_WIDTH)) + self.parse_mdn('road_edges', outs, in_N=0, out_N=0, out_shape=(ModelConstants.NUM_ROAD_EDGES,ModelConstants.IDX_N,ModelConstants.LANE_LINES_WIDTH)) + self.parse_mdn('pose', outs, in_N=0, out_N=0, out_shape=(ModelConstants.POSE_WIDTH,)) + self.parse_mdn('road_transform', outs, in_N=0, out_N=0, out_shape=(ModelConstants.POSE_WIDTH,)) + if 'sim_pose' in outs: + self.parse_mdn('sim_pose', outs, in_N=0, out_N=0, out_shape=(ModelConstants.POSE_WIDTH,)) + self.parse_mdn('wide_from_device_euler', outs, in_N=0, out_N=0, out_shape=(ModelConstants.WIDE_FROM_DEVICE_WIDTH,)) + self.parse_mdn('lead', outs, in_N=ModelConstants.LEAD_MHP_N, out_N=ModelConstants.LEAD_MHP_SELECTION, + out_shape=(ModelConstants.LEAD_TRAJ_LEN,ModelConstants.LEAD_WIDTH)) + if 'lat_planner_solution' in outs: + self.parse_mdn('lat_planner_solution', outs, in_N=0, out_N=0, out_shape=(ModelConstants.IDX_N,ModelConstants.LAT_PLANNER_SOLUTION_WIDTH)) + if 'desired_curvature' in outs: + self.parse_mdn('desired_curvature', outs, in_N=0, out_N=0, out_shape=(ModelConstants.DESIRED_CURV_WIDTH,)) + for k in ['lead_prob', 'lane_lines_prob', 'meta']: + self.parse_binary_crossentropy(k, outs) + self.parse_categorical_crossentropy('desire_state', outs, out_shape=(ModelConstants.DESIRE_PRED_WIDTH,)) + self.parse_categorical_crossentropy('desire_pred', outs, out_shape=(ModelConstants.DESIRE_PRED_LEN,ModelConstants.DESIRE_PRED_WIDTH)) + return outs diff --git a/sunnypilot/modeld_v2/parse_model_outputs_split.py b/sunnypilot/modeld_v2/parse_model_outputs_split.py new file mode 100644 index 0000000000..831649e3c1 --- /dev/null +++ b/sunnypilot/modeld_v2/parse_model_outputs_split.py @@ -0,0 +1,154 @@ +import numpy as np +from openpilot.sunnypilot.models.split_model_constants import SplitModelConstants + + +def safe_exp(x, out=None): + # -11 is around 10**14, more causes float16 overflow + return np.exp(np.clip(x, -np.inf, 11), out=out) + + +def sigmoid(x): + return 1. / (1. + safe_exp(-x)) + + +def softmax(x, axis=-1): + x -= np.max(x, axis=axis, keepdims=True) + if x.dtype == np.float32 or x.dtype == np.float64: + safe_exp(x, out=x) + else: + x = safe_exp(x) + x /= np.sum(x, axis=axis, keepdims=True) + return x + + +class Parser: + def __init__(self, ignore_missing=False): + self.ignore_missing = ignore_missing + + def check_missing(self, outs, name): + if name not in outs and not self.ignore_missing: + raise ValueError(f"Missing output {name}") + return name not in outs + + def parse_categorical_crossentropy(self, name, outs, out_shape=None): + if self.check_missing(outs, name): + return + raw = outs[name] + if out_shape is not None: + raw = raw.reshape((raw.shape[0],) + out_shape) + outs[name] = softmax(raw, axis=-1) + + def parse_binary_crossentropy(self, name, outs): + if self.check_missing(outs, name): + return + raw = outs[name] + outs[name] = sigmoid(raw) + + def parse_mdn(self, name, outs, in_N=0, out_N=1, out_shape=None): + if self.check_missing(outs, name): + return + raw = outs[name] + raw = raw.reshape((raw.shape[0], max(in_N, 1), -1)) + + n_values = (raw.shape[2] - out_N)//2 + pred_mu = raw[:,:,:n_values] + pred_std = safe_exp(raw[:,:,n_values: 2*n_values]) + + if in_N > 1: + weights = np.zeros((raw.shape[0], in_N, out_N), dtype=raw.dtype) + for i in range(out_N): + weights[:,:,i - out_N] = softmax(raw[:,:,i - out_N], axis=-1) + + if out_N == 1: + for fidx in range(weights.shape[0]): + idxs = np.argsort(weights[fidx][:,0])[::-1] + weights[fidx] = weights[fidx][idxs] + pred_mu[fidx] = pred_mu[fidx][idxs] + pred_std[fidx] = pred_std[fidx][idxs] + full_shape = tuple([raw.shape[0], in_N] + list(out_shape)) + outs[name + '_weights'] = weights + outs[name + '_hypotheses'] = pred_mu.reshape(full_shape) + outs[name + '_stds_hypotheses'] = pred_std.reshape(full_shape) + + pred_mu_final = np.zeros((raw.shape[0], out_N, n_values), dtype=raw.dtype) + pred_std_final = np.zeros((raw.shape[0], out_N, n_values), dtype=raw.dtype) + for fidx in range(weights.shape[0]): + for hidx in range(out_N): + idxs = np.argsort(weights[fidx,:,hidx])[::-1] + pred_mu_final[fidx, hidx] = pred_mu[fidx, idxs[0]] + pred_std_final[fidx, hidx] = pred_std[fidx, idxs[0]] + else: + pred_mu_final = pred_mu + pred_std_final = pred_std + + if out_N > 1: + final_shape = tuple([raw.shape[0], out_N] + list(out_shape)) + else: + final_shape = tuple([raw.shape[0],] + list(out_shape)) + outs[name] = pred_mu_final.reshape(final_shape) + outs[name + '_stds'] = pred_std_final.reshape(final_shape) + + def is_mhp(self, outs, name, shape): + if self.check_missing(outs, name): + return False + if outs[name].shape[1] == 2 * shape: + return False + return True + + def parse_dynamic_outputs(self, outs: dict[str, np.ndarray]) -> None: + if 'lead' in outs: + lead_mhp = self.is_mhp(outs, 'lead', + SplitModelConstants.LEAD_MHP_SELECTION * SplitModelConstants.LEAD_TRAJ_LEN * SplitModelConstants.LEAD_WIDTH) + lead_in_N, lead_out_N = (SplitModelConstants.LEAD_MHP_N, SplitModelConstants.LEAD_MHP_SELECTION) if lead_mhp else (0, 0) + lead_out_shape = (SplitModelConstants.LEAD_TRAJ_LEN, SplitModelConstants.LEAD_WIDTH) if lead_mhp else \ + (SplitModelConstants.LEAD_MHP_SELECTION, SplitModelConstants.LEAD_TRAJ_LEN, SplitModelConstants.LEAD_WIDTH) + self.parse_mdn('lead', outs, in_N=lead_in_N, out_N=lead_out_N, out_shape=lead_out_shape) + if 'plan' in outs: + plan_mhp = self.is_mhp(outs, 'plan', SplitModelConstants.IDX_N * SplitModelConstants.PLAN_WIDTH) + plan_in_N, plan_out_N = (SplitModelConstants.PLAN_MHP_N, SplitModelConstants.PLAN_MHP_SELECTION) if plan_mhp else (0, 0) + self.parse_mdn('plan', outs, in_N=plan_in_N, out_N=plan_out_N, + out_shape=(SplitModelConstants.IDX_N, SplitModelConstants.PLAN_WIDTH)) + if 'planplus' in outs: + self.parse_mdn('planplus', outs, in_N=0, out_N=0, out_shape=(SplitModelConstants.IDX_N, SplitModelConstants.PLAN_WIDTH)) + + def split_outputs(self, outs: dict[str, np.ndarray]) -> None: + if 'desired_curvature' in outs: + self.parse_mdn('desired_curvature', outs, in_N=0, out_N=0, out_shape=(SplitModelConstants.DESIRED_CURV_WIDTH,)) + if 'desire_pred' in outs: + self.parse_categorical_crossentropy('desire_pred', outs, out_shape=(SplitModelConstants.DESIRE_PRED_LEN,SplitModelConstants.DESIRE_PRED_WIDTH)) + if 'desire_state' in outs: + self.parse_categorical_crossentropy('desire_state', outs, out_shape=(SplitModelConstants.DESIRE_PRED_WIDTH,)) + if 'lane_lines' in outs: + self.parse_mdn('lane_lines', outs, in_N=0, out_N=0, + out_shape=(SplitModelConstants.NUM_LANE_LINES,SplitModelConstants.IDX_N,SplitModelConstants.LANE_LINES_WIDTH)) + if 'lane_lines_prob' in outs: + self.parse_binary_crossentropy('lane_lines_prob', outs) + if 'lead_prob' in outs: + self.parse_binary_crossentropy('lead_prob', outs) + if 'lat_planner_solution' in outs: + self.parse_mdn('lat_planner_solution', outs, in_N=0, out_N=0, out_shape=(SplitModelConstants.IDX_N,SplitModelConstants.LAT_PLANNER_SOLUTION_WIDTH)) + if 'meta' in outs: + self.parse_binary_crossentropy('meta', outs) + if 'road_edges' in outs: + self.parse_mdn('road_edges', outs, in_N=0, out_N=0, + out_shape=(SplitModelConstants.NUM_ROAD_EDGES,SplitModelConstants.IDX_N,SplitModelConstants.LANE_LINES_WIDTH)) + if 'sim_pose' in outs: + self.parse_mdn('sim_pose', outs, in_N=0, out_N=0, out_shape=(SplitModelConstants.POSE_WIDTH,)) + + def parse_vision_outputs(self, outs: dict[str, np.ndarray]) -> dict[str, np.ndarray]: + self.parse_mdn('pose', outs, in_N=0, out_N=0, out_shape=(SplitModelConstants.POSE_WIDTH,)) + self.parse_mdn('wide_from_device_euler', outs, in_N=0, out_N=0, out_shape=(SplitModelConstants.WIDE_FROM_DEVICE_WIDTH,)) + self.parse_mdn('road_transform', outs, in_N=0, out_N=0, out_shape=(SplitModelConstants.POSE_WIDTH,)) + self.parse_dynamic_outputs(outs) + self.split_outputs(outs) + return outs + + def parse_policy_outputs(self, outs: dict[str, np.ndarray]) -> dict[str, np.ndarray]: + self.parse_dynamic_outputs(outs) + self.split_outputs(outs) + return outs + + def parse_outputs(self, outs: dict[str, np.ndarray]) -> dict[str, np.ndarray]: + outs = self.parse_vision_outputs(outs) + outs = self.parse_policy_outputs(outs) + return outs diff --git a/sunnypilot/modeld_v2/tests/__init__.py b/sunnypilot/modeld_v2/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sunnypilot/modeld_v2/tests/dmon_lag/repro.cc b/sunnypilot/modeld_v2/tests/dmon_lag/repro.cc new file mode 100644 index 0000000000..c4c1c65cbe --- /dev/null +++ b/sunnypilot/modeld_v2/tests/dmon_lag/repro.cc @@ -0,0 +1,101 @@ +// clang++ -O2 repro.cc && ./a.out + +#include +#include +#include + +#include +#include +#include +#include +#include + +static inline double millis_since_boot() { + struct timespec t; + clock_gettime(CLOCK_BOOTTIME, &t); + return t.tv_sec * 1000.0 + t.tv_nsec * 1e-6; +} + +#define MODEL_WIDTH 320 +#define MODEL_HEIGHT 640 + +// null function still breaks it +#define input_lambda(x) x + +// this is copied from models/dmonitoring.cc, and is the code that triggers the issue +void inner(uint8_t *resized_buf, float *net_input_buf) { + int resized_width = MODEL_WIDTH; + int resized_height = MODEL_HEIGHT; + + // one shot conversion, O(n) anyway + // yuvframe2tensor, normalize + for (int r = 0; r < MODEL_HEIGHT/2; r++) { + for (int c = 0; c < MODEL_WIDTH/2; c++) { + // Y_ul + net_input_buf[(c*MODEL_HEIGHT/2) + r] = input_lambda(resized_buf[(2*r*resized_width) + (2*c)]); + // Y_ur + net_input_buf[(c*MODEL_HEIGHT/2) + r + (2*(MODEL_WIDTH/2)*(MODEL_HEIGHT/2))] = input_lambda(resized_buf[(2*r*resized_width) + (2*c+1)]); + // Y_dl + net_input_buf[(c*MODEL_HEIGHT/2) + r + ((MODEL_WIDTH/2)*(MODEL_HEIGHT/2))] = input_lambda(resized_buf[(2*r*resized_width+1) + (2*c)]); + // Y_dr + net_input_buf[(c*MODEL_HEIGHT/2) + r + (3*(MODEL_WIDTH/2)*(MODEL_HEIGHT/2))] = input_lambda(resized_buf[(2*r*resized_width+1) + (2*c+1)]); + // U + net_input_buf[(c*MODEL_HEIGHT/2) + r + (4*(MODEL_WIDTH/2)*(MODEL_HEIGHT/2))] = input_lambda(resized_buf[(resized_width*resized_height) + (r*resized_width/2) + c]); + // V + net_input_buf[(c*MODEL_HEIGHT/2) + r + (5*(MODEL_WIDTH/2)*(MODEL_HEIGHT/2))] = input_lambda(resized_buf[(resized_width*resized_height) + ((resized_width/2)*(resized_height/2)) + (r*resized_width/2) + c]); + } + } +} + +float trial() { + int resized_width = MODEL_WIDTH; + int resized_height = MODEL_HEIGHT; + + int yuv_buf_len = (MODEL_WIDTH/2) * (MODEL_HEIGHT/2) * 6; // Y|u|v -> y|y|y|y|u|v + + // allocate the buffers + uint8_t *resized_buf = (uint8_t*)malloc(resized_width*resized_height*3/2); + float *net_input_buf = (float*)malloc(yuv_buf_len*sizeof(float)); + printf("allocate -- %p 0x%x -- %p 0x%lx\n", resized_buf, resized_width*resized_height*3/2, net_input_buf, yuv_buf_len*sizeof(float)); + + // test for bad buffers + static int CNT = 20; + float avg = 0.0; + for (int i = 0; i < CNT; i++) { + double s4 = millis_since_boot(); + inner(resized_buf, net_input_buf); + double s5 = millis_since_boot(); + avg += s5-s4; + } + avg /= CNT; + + // once it's bad, it's reliably bad + if (avg > 10) { + printf("HIT %f\n", avg); + printf("BAD\n"); + + for (int i = 0; i < 200; i++) { + double s4 = millis_since_boot(); + inner(resized_buf, net_input_buf); + double s5 = millis_since_boot(); + printf("%.2f ", s5-s4); + } + printf("\n"); + + exit(0); + } + + // don't free so we get a different buffer each time + //free(resized_buf); + //free(net_input_buf); + + return avg; +} + +int main() { + while (true) { + float ret = trial(); + printf("got %f\n", ret); + } +} + diff --git a/sunnypilot/modeld_v2/tests/test_buffer_logic_inspect.py b/sunnypilot/modeld_v2/tests/test_buffer_logic_inspect.py new file mode 100644 index 0000000000..15009c94d9 --- /dev/null +++ b/sunnypilot/modeld_v2/tests/test_buffer_logic_inspect.py @@ -0,0 +1,263 @@ +import numpy as np +import pytest +from typing import Any + +import openpilot.sunnypilot.models.helpers as helpers +import openpilot.sunnypilot.models.runners.helpers as runner_helpers +import openpilot.sunnypilot.modeld_v2.modeld as modeld_module + +ModelState = modeld_module.ModelState + +# These are the shapes extracted/loaded from the model onnx +SHAPE_MODE_PARAMS = [ + ({'desire': (1, 100, 8), 'features_buffer': (1, 99, 512), "nav_features": (1, 256), "nav_instructions": (1, 150)}, 'non20hz'), # Optimus Prime + ({'desire': (1, 100, 8), 'features_buffer': (1, 99, 512), "lat_planner_state": (1, 4),}, 'non20hz'), # farmville + ({'desire': (1, 100, 8), 'features_buffer': (1, 99, 512), "lateral_control_params": (1, 2), "prev_desired_curv": (1, 100, 1)}, 'non20hz'), # wd40 + ({'desire': (1, 100, 8), 'features_buffer': (1, 99, 512), 'prev_desired_curv': (1, 100, 1), "lateral_control_params": (1, 2),}, 'non20hz'), # NTS + ({'desire': (1, 25, 8), 'features_buffer': (1, 24, 512)}, '20hz'), # NPR + ({'desire': (1, 100, 8), 'features_buffer': (1, 99, 512), 'prev_desired_curv': (1, 100, 1), "lateral_control_params": (1, 2),}, 'non20hz'), # NTS + ({'desire': (1, 25, 8), 'features_buffer': (1, 25, 512)}, 'split'), # Steam Powered v2 + ({'desire_pulse': (1, 25, 8), 'features_buffer': (1, 25, 512)}, 'split'), # desire rename +] + + +# This creates a dummy runner, override, and bundle instance for the tests to run, without actually trying to load a physical model. +class DummyOverride: + def __init__(self, key: str, value: str) -> None: + self.key = key + self.value = value + + +class DummyBundle: + def __init__(self) -> None: + self.overrides = [DummyOverride('lat', '.1'), DummyOverride('long', '.3')] + self.generation = 10 # default to non-mlsim for buffer-update tests, as raising to 11 here will zero curvature buffer + + +class DummyModelRunner: + def __init__(self, input_shapes: dict[str, tuple[int, int, int]], constants: Any = None) -> None: + self.input_shapes = input_shapes + self.constants = constants or type('C', (), { + 'FULL_HISTORY_BUFFER_LEN': 100, + 'FEATURE_LEN': 512, + 'DESIRE_LEN': 8, + 'PREV_DESIRED_CURV_LEN': 1, + 'INPUT_HISTORY_BUFFER_LEN': 25, + 'TEMPORAL_SKIP': 4, + })() + self.vision_input_names: list[str] = [] + shape = input_shapes.get('desire', (1, 0, 0)) # [batch, history, features] + if shape[1] == 25: + self.is_20hz = True + else: + self.is_20hz = False + + # Minimal prepare/run methods so ModelState can be run without actually running the model + def prepare_inputs(self, numpy_inputs): + return None + + def run_model(self): + return { + 'hidden_state': np.zeros((1, self.constants.FEATURE_LEN), dtype=np.float32), + 'desired_curvature': np.zeros((1, 1), dtype=np.float32), + } + + +@pytest.fixture +def shapes(request): + return request.param + + +@pytest.fixture +def bundle() -> DummyBundle: + return DummyBundle() + + +@pytest.fixture +def runner(shapes) -> DummyModelRunner: + return DummyModelRunner(shapes) + + +@pytest.fixture +def apply_patches(monkeypatch: pytest.MonkeyPatch, bundle: DummyBundle, runner: DummyModelRunner): + monkeypatch.setattr(helpers, 'get_active_bundle', lambda params=None: bundle, raising=False) + monkeypatch.setattr(runner_helpers, 'get_model_runner', lambda: runner, raising=False) + monkeypatch.setattr(modeld_module, 'get_model_runner', lambda: runner, raising=False) + monkeypatch.setattr(modeld_module, 'get_active_bundle', lambda params=None: bundle, raising=False) + + +# These are expected shapes and indices based on the time the model was presented +def get_expected_indices(shape, constants, mode, key=None): + if mode == 'split': + start = -1 - (constants.TEMPORAL_SKIP * (constants.INPUT_HISTORY_BUFFER_LEN - 1)) + arr = np.arange(constants.FULL_HISTORY_BUFFER_LEN) + idxs = arr[start::constants.TEMPORAL_SKIP] + return idxs + elif mode == '20hz': + num_elements = shape[1] + step_size = int(-100 / num_elements) + idxs = np.arange(step_size, step_size * (num_elements + 1), step_size)[::-1] + return idxs + elif mode == 'non20hz': + return np.arange(shape[1]) + return None + + +@pytest.mark.parametrize("shapes,mode", SHAPE_MODE_PARAMS, indirect=["shapes"]) +def test_buffer_shapes_and_indices(shapes, mode, apply_patches): + state = ModelState() + constants = DummyModelRunner(shapes).constants + for key in shapes: + buf = state.temporal_buffers.get(key, None) + idxs = state.temporal_idxs_map.get(key, None) + if buf is None: + continue # not all shapes are 3D, and the non-3D ones are not buffered + # Buffer shape logic + if mode == 'split': + expected_shape = (1, constants.FULL_HISTORY_BUFFER_LEN, shapes[key][2]) + expected_idxs = get_expected_indices(shapes[key], constants, 'split', key) + elif mode == '20hz': + expected_shape = (1, constants.FULL_HISTORY_BUFFER_LEN, shapes[key][2]) + expected_idxs = get_expected_indices(shapes[key], constants, '20hz', key) + elif mode == 'non20hz': + expected_shape = (1, shapes[key][1], shapes[key][2]) + expected_idxs = get_expected_indices(shapes[key], constants, 'non20hz', key) + + assert buf is not None, f"{key}: buffer not found" + assert buf.shape == expected_shape, f"{key}: buffer shape {buf.shape} != expected {expected_shape}" + if expected_idxs is not None: + assert np.all(idxs == expected_idxs), f"{key}: buffer idxs {idxs} != expected {expected_idxs}" + else: + assert idxs is None or idxs.size == 0, f"{key}: buffer idxs should be None or empty" + + +def legacy_buffer_update(buf, new_val, mode, key, constants, idxs, input_shape, prev_desire=None): + # This is what we compare the new dynamic logic to, to ensure it does the same thing + if mode == 'split': + if key == 'desire' or key.startswith('desire'): + buf[0,:-1] = buf[0,1:] + buf[0,-1] = new_val + return buf.reshape((1, constants.INPUT_HISTORY_BUFFER_LEN, constants.TEMPORAL_SKIP, -1)).max(axis=2) + elif key == 'features_buffer': + buf[0,:-1] = buf[0,1:] + buf[0,-1] = new_val + return buf[0, idxs] + elif key == 'prev_desired_curv': + buf[0,:-1] = buf[0,1:] + buf[0,-1,:] = new_val + return buf[0, idxs] + elif mode == '20hz': + if key == 'desire': + buf[:-1] = buf[1:] + buf[-1] = new_val + reshape_dims = (1, buf.shape[1], -1, buf.shape[2]) + reshaped = buf.reshape(reshape_dims).max(axis=2) + # Slice to last shape[1] elements to match model input shape + input_len = reshaped.shape[1] + model_input_len = 25 # For 20hz mode, desire shape[1] is 25 + if input_len > model_input_len: + reshaped = reshaped[:, -model_input_len:, :] + return reshaped + elif key == 'features_buffer': + buffer_history_len = buf.shape[1] + legacy_buf = np.zeros((buffer_history_len, buf.shape[2]), dtype=np.float32) + legacy_buf[:] = buf[0] + legacy_buf[:-1] = legacy_buf[1:] + legacy_buf[-1] = new_val + return legacy_buf[idxs] + elif key == 'prev_desired_curv': + buffer_history_len = buf.shape[1] + legacy_buf = np.zeros((buffer_history_len, buf.shape[2]), dtype=np.float32) + legacy_buf[:] = buf[0] + legacy_buf[:-1] = legacy_buf[1:] + legacy_buf[-1,:] = new_val + return legacy_buf[idxs] + elif mode == 'non20hz': + if key == 'desire': + desire_len = constants.DESIRE_LEN + if prev_desire is None: + prev_desire = np.zeros(desire_len, dtype=np.float32) + # Set first element to zero + new_val = new_val.copy() + new_val[0] = 0 + # Shift buffer by desire len + buf[0][:-desire_len] = buf[0][desire_len:] + # Only insert new desire if rising edge + buf[0][-desire_len:] = np.where(new_val - prev_desire > 0.99, new_val, 0) + prev_desire[:] = new_val + return buf[0] + elif key == 'features_buffer': + buf[0, :-1] = buf[0, 1:] + buf[0, -1] = new_val + return buf[0, -input_shape[1]:] # (99, 512) + elif key == 'prev_desired_curv': + length = new_val.shape[0] + buf[0,:-length,0] = buf[0,length:,0] + buf[0,-length:,0] = new_val[:length] + return buf[0] + return None + + +def dynamic_buffer_update(state, key, new_val, mode): + if key == 'desire' or key.startswith('desire'): + inputs = {k: np.zeros(v[2], dtype=np.float32) if len(v) == 3 else np.zeros(v[1], dtype=np.float32) + for k, v in state.model_runner.input_shapes.items() if k != key} + inputs[key] = new_val.copy() + # ModelState.run expects desire as a pulse, so we zero the first element. + inputs[key][0] = 0 + state.run({}, {}, inputs, prepare_only=False) + return state.numpy_inputs[key] + + if key == 'features_buffer': + inputs = {k: np.zeros(v[2], dtype=np.float32) if len(v) == 3 else np.zeros(v[1], dtype=np.float32) + for k, v in state.model_runner.input_shapes.items() if k != 'features_buffer'} + def run_model_stub(): + return { + 'hidden_state': np.asarray(new_val, dtype=np.float32).reshape(1, -1), + } + state.model_runner.run_model = run_model_stub + state.run({}, {}, inputs, prepare_only=False) + return state.numpy_inputs['features_buffer'][0] + + if key == 'prev_desired_curv': + inputs = {k: np.zeros(v[2], dtype=np.float32) if len(v) == 3 else np.zeros(v[1], dtype=np.float32) + for k, v in state.model_runner.input_shapes.items() if k != 'prev_desired_curv'} + def run_model_stub(): + return { + 'hidden_state': np.zeros((1, state.constants.FEATURE_LEN), dtype=np.float32), + 'desired_curvature': np.asarray(new_val, dtype=np.float32).reshape(1, -1), + } + state.model_runner.run_model = run_model_stub + state.run({}, {}, inputs, prepare_only=False) + return state.numpy_inputs['prev_desired_curv'][0] + return None + + +@pytest.mark.parametrize("shapes,mode", SHAPE_MODE_PARAMS, indirect=["shapes"]) +@pytest.mark.parametrize("key", ["desire", "features_buffer", "prev_desired_curv"]) +def test_buffer_update_equivalence(shapes, mode, key, apply_patches): + state = ModelState() + if key == "desire": + desire_keys = [k for k in shapes.keys() if k.startswith('desire')] + if desire_keys: + actual_key = desire_keys[0] # Use the first (and likely only) desire key + else: + actual_key = key + + if actual_key not in state.numpy_inputs: + pytest.skip() + + constants = DummyModelRunner(shapes).constants + buf = state.temporal_buffers.get(actual_key, None) + idxs = state.temporal_idxs_map.get(actual_key, None) + input_shape = shapes[actual_key] + prev_desire = np.zeros(constants.DESIRE_LEN, dtype=np.float32) if key == 'desire' else None + + for step in range(20): # multiple steps to ensure history is built up + new_val = np.full((input_shape[2],), step, dtype=np.float32) + expected = legacy_buffer_update(buf, new_val, mode, actual_key, constants, idxs, input_shape, prev_desire) + actual = dynamic_buffer_update(state, actual_key, new_val, mode) + if expected is not None and actual is not None and expected.shape != actual.shape: + if expected.ndim == 2 and actual.ndim == 2 and expected.shape[1] == actual.shape[1]: + expected = expected[-actual.shape[0]:] + assert np.allclose(actual, expected), f"{mode} {actual_key}: dynamic buffer update does not match legacy logic" diff --git a/sunnypilot/modeld_v2/tests/test_camera_offset_helper.py b/sunnypilot/modeld_v2/tests/test_camera_offset_helper.py new file mode 100644 index 0000000000..f25bcd0a35 --- /dev/null +++ b/sunnypilot/modeld_v2/tests/test_camera_offset_helper.py @@ -0,0 +1,84 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import numpy as np + +from openpilot.common.transformations.camera import DEVICE_CAMERAS +from openpilot.common.transformations.model import get_warp_matrix +from openpilot.sunnypilot.modeld_v2.camera_offset_helper import CameraOffsetHelper + + +class MockStruct: + def __init__(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + + def __getitem__(self, item): + return getattr(self, item) + + +class TestCameraOffset: + def setup_method(self): + self.camera_offset = CameraOffsetHelper() + self.dc = DEVICE_CAMERAS[('mici', 'os04c10')] + + def test_smoothing(self): + self.camera_offset.set_offset(0.2) + + sm = MockStruct( + deviceState=MockStruct(deviceType='mici'), + roadCameraState=MockStruct(sensor='os04c10'), + liveCalibration=MockStruct(rpyCalib=[0.0, 0.0, 0.0], height=[1.22]) + ) + + intrinsics_main = self.dc.fcam.intrinsics + intrinsics_extra = self.dc.ecam.intrinsics + device_from_calib_euler = np.array([0.0, 0.0, 0.0], dtype=np.float32) + main_transform = get_warp_matrix(device_from_calib_euler, intrinsics_main, False).astype(np.float32) + extra_transform = get_warp_matrix(device_from_calib_euler, intrinsics_extra, True).astype(np.float32) + + self.camera_offset.update(main_transform, extra_transform, sm, False) + np.testing.assert_almost_equal(self.camera_offset.actual_camera_offset, 0.02) + self.camera_offset.update(main_transform, extra_transform, sm, False) + np.testing.assert_almost_equal(self.camera_offset.actual_camera_offset, 0.038) + + def test_camera_offset_(self): + intrinsics = self.dc.fcam.intrinsics + transform = np.eye(3, dtype=np.float32) + height = 1.22 + offset = 0.1 + + cy = intrinsics[1, 2] + expected_shear = np.eye(3, dtype=np.float32) + expected_shear[0, 1] = offset / height + expected_shear[0, 2] = -offset / height * cy + + result = CameraOffsetHelper.apply_camera_offset(transform, intrinsics, height, offset) + np.testing.assert_array_almost_equal(result, expected_shear) + + def test_update(self): + sm = MockStruct( + deviceState=MockStruct(deviceType='mici'), + roadCameraState=MockStruct(sensor='os04c10'), + liveCalibration=MockStruct(rpyCalib=[0.0, 0.0, 0.0], height=[1.22]) + ) + intrinsics_main = self.dc.fcam.intrinsics + intrinsics_extra = self.dc.ecam.intrinsics + device_from_calib_euler = np.array([0.0, 0.0, 0.0], dtype=np.float32) + main_transform = get_warp_matrix(device_from_calib_euler, intrinsics_main, False).astype(np.float32) + extra_transform = get_warp_matrix(device_from_calib_euler, intrinsics_extra, True).astype(np.float32) + + self.camera_offset.set_offset(0.0) # test default offset doesn't change transformation + main_out, extra_out = self.camera_offset.update(main_transform, extra_transform, sm, False) + np.testing.assert_array_equal(main_out, main_transform) + np.testing.assert_array_equal(extra_out, extra_transform) + + self.camera_offset.set_offset(0.2) # test valid offset changes transformation + main_out, extra_out = self.camera_offset.update(main_transform, extra_transform, sm, False) + assert not np.array_equal(main_out, main_transform) + assert not np.array_equal(extra_out, extra_transform) + assert main_out[0, 1] != 0.0 + assert main_out[0, 2] != 0.0 diff --git a/sunnypilot/modeld_v2/tests/test_recovery_power.py b/sunnypilot/modeld_v2/tests/test_recovery_power.py new file mode 100644 index 0000000000..cfa2272386 --- /dev/null +++ b/sunnypilot/modeld_v2/tests/test_recovery_power.py @@ -0,0 +1,70 @@ +import numpy as np + +from cereal import log + +from openpilot.sunnypilot.modeld_v2.constants import Plan +from openpilot.sunnypilot.modeld_v2.modeld import ModelState +import openpilot.sunnypilot.modeld_v2.modeld as modeld + + +class MockStruct: + def __init__(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + + +def test_recovery_power_scaling(): + state = MockStruct( + PLANPLUS_CONTROL=0.75, + LONG_SMOOTH_SECONDS=0.3, + LAT_SMOOTH_SECONDS=0.1, + MIN_LAT_CONTROL_SPEED=0.3, + mlsim=True, + generation=12, + constants=MockStruct(T_IDXS=np.arange(100), DESIRE_LEN=8) + ) + prev_action = log.ModelDataV2.Action() + recorded_vel: list = [] + recorded_curv_plans: list = [] + + def mock_accel(plan_vel, plan_accel, t_idxs, action_t=0.0): + recorded_vel.append(plan_vel.copy()) + return 0.0, False + + def mock_curvature(output, plan, vego, lat_action_t, mlsim): + recorded_curv_plans.append(plan.copy()) + return 0.0 + + modeld.get_accel_from_plan = mock_accel + modeld.get_curvature_from_output = mock_curvature + plan = np.random.rand(1, 100, 15).astype(np.float32) + planplus = np.random.rand(1, 100, 15).astype(np.float32) + merged_plan = plan + planplus + + model_output: dict = { + 'plan': merged_plan.copy(), + 'planplus': planplus.copy() + } + + test_cases: list = [ + # (control, v_ego) + (0.55, 20.0), + (1.0, 25.0), + (1.5, 25.1), + (2.0, 20.0), + (0.75, 19.0), + (0.8, 25.1), + ] + + for control, v_ego in test_cases: + state.PLANPLUS_CONTROL = control + recorded_vel.clear() + recorded_curv_plans.clear() + ModelState.get_action_from_model(state, model_output, prev_action, 0.0, 0.0, v_ego) + + expected_accel_plan_vel = plan[0, :, Plan.VELOCITY][:, 0] + planplus[0, :, Plan.VELOCITY][:, 0] + np.testing.assert_allclose(recorded_vel[0], expected_accel_plan_vel, rtol=1e-5, atol=1e-6) + + # For the below, yes, I know this isn't the same slicing as fillmodlmsg. This is to show that the values are only scaled on curv + expected_curv_plan_vel = plan[0, :, Plan.VELOCITY][:, 0] + control * planplus[0, :, Plan.VELOCITY][:, 0] + np.testing.assert_allclose(recorded_curv_plans[0][:, Plan.VELOCITY][:, 0], expected_curv_plan_vel, rtol=1e-5, atol=1e-6) diff --git a/sunnypilot/modeld_v2/tests/test_warp.py b/sunnypilot/modeld_v2/tests/test_warp.py new file mode 100644 index 0000000000..daf0dd528c --- /dev/null +++ b/sunnypilot/modeld_v2/tests/test_warp.py @@ -0,0 +1,102 @@ +import os +os.environ['DEV'] = 'CPU' +import pytest +import numpy as np +from openpilot.selfdrive.modeld.compile_warp import get_nv12_info, CAMERA_CONFIGS +from openpilot.sunnypilot.modeld_v2.warp import Warp, MODEL_W, MODEL_H + +VISION_NAME_PAIRS = [ # needed to account for supercombos input_imgs + ('img', 'big_img'), + ('input_imgs', 'big_input_imgs'), +] + + +class MockVisionBuf: + def __init__(self, w, h): + self.width = w + self.height = h + _, _, _, yuv_size = get_nv12_info(w, h) + self.data = np.zeros(yuv_size, dtype=np.uint8) + + +@pytest.mark.parametrize("buffer_length", [2, 5]) +def test_warp_initialization(buffer_length): + warp = Warp(buffer_length) + assert warp.buffer_length == buffer_length + assert warp.img_buffer_shape == (buffer_length * 6, MODEL_H // 2, MODEL_W // 2) + + +@pytest.mark.parametrize("buffer_length", [2, 5]) +@pytest.mark.parametrize("cam_w, cam_h", CAMERA_CONFIGS) +@pytest.mark.parametrize("road, wide", VISION_NAME_PAIRS) +def test_warp_process(buffer_length, cam_w, cam_h, road, wide): + warp = Warp(buffer_length) + mock_buf = MockVisionBuf(cam_w, cam_h) + transform = np.eye(3, dtype=np.float32).flatten() + bufs = {road: mock_buf, wide: mock_buf} + transforms = {road: transform, wide: transform} + + out = warp.process(bufs, transforms) + assert isinstance(out, dict) + assert road in out and wide in out + assert out[road].shape == (1, 12, MODEL_H // 2, MODEL_W // 2) + assert out[wide].shape == (1, 12, MODEL_H // 2, MODEL_W // 2) + + key = (cam_w, cam_h) + assert key in warp.jit_cache + + out2 = warp.process(bufs, transforms) + assert out2[road].shape == out[road].shape + + +@pytest.mark.parametrize("road, wide", VISION_NAME_PAIRS) +def test_warp_buffer_shift(road, wide): + warp = Warp(2) + cam_w, cam_h = CAMERA_CONFIGS[1] + transform = np.eye(3, dtype=np.float32).flatten() + + buf1 = MockVisionBuf(cam_w, cam_h) + buf1.data[0] = 255 + bufs1 = {road: buf1, wide: buf1} + transforms = {road: transform, wide: transform} + out1 = warp.process(bufs1, transforms) + road1 = out1[road].numpy().copy() + + buf2 = MockVisionBuf(cam_w, cam_h) + buf2.data[0] = 128 + bufs2 = {road: buf2, wide: buf2} + out2 = warp.process(bufs2, transforms) + assert not np.array_equal(road1, out2[road].numpy()) + + +@pytest.mark.parametrize("buffer_length", [2, 5]) +@pytest.mark.parametrize("road, wide", VISION_NAME_PAIRS) +def test_warp_buffer_accumulation(buffer_length, road, wide): + warp = Warp(buffer_length) + cam_w, cam_h = CAMERA_CONFIGS[0] + transform = np.eye(3, dtype=np.float32).flatten() + transforms = {road: transform, wide: transform} + outputs = [] + + for i in range(buffer_length + 1): + buf = MockVisionBuf(cam_w, cam_h) + buf.data[:] = i * 10 + out = warp.process({road: buf, wide: buf}, transforms) + outputs.append(out[road].numpy().copy()) + + assert warp.full_buffers['img'].shape == (buffer_length * 6, MODEL_H // 2, MODEL_W // 2) + for i in range(1, len(outputs)): + assert not np.array_equal(outputs[i - 1], outputs[i]) + + +def test_warp_different_cameras_same_instance(): + warp = Warp(2) + transform = np.eye(3, dtype=np.float32).flatten() + + buf1 = MockVisionBuf(*CAMERA_CONFIGS[0]) + warp.process({'img': buf1, 'big_img': buf1}, {'img': transform, 'big_img': transform}) + assert len(warp.jit_cache) == 1 + + buf2 = MockVisionBuf(*CAMERA_CONFIGS[1]) + warp.process({'img': buf2, 'big_img': buf2}, {'img': transform, 'big_img': transform}) + assert len(warp.jit_cache) == 2 diff --git a/sunnypilot/modeld_v2/tests/tf_test/build.sh b/sunnypilot/modeld_v2/tests/tf_test/build.sh new file mode 100755 index 0000000000..df1d24761e --- /dev/null +++ b/sunnypilot/modeld_v2/tests/tf_test/build.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +clang++ -I /home/batman/one/external/tensorflow/include/ -L /home/batman/one/external/tensorflow/lib -Wl,-rpath=/home/batman/one/external/tensorflow/lib main.cc -ltensorflow diff --git a/sunnypilot/modeld_v2/tests/tf_test/main.cc b/sunnypilot/modeld_v2/tests/tf_test/main.cc new file mode 100644 index 0000000000..b00f7f95e8 --- /dev/null +++ b/sunnypilot/modeld_v2/tests/tf_test/main.cc @@ -0,0 +1,69 @@ +#include +#include +#include +#include "tensorflow/c/c_api.h" + +void* read_file(const char* path, size_t* out_len) { + FILE* f = fopen(path, "r"); + if (!f) { + return NULL; + } + fseek(f, 0, SEEK_END); + long f_len = ftell(f); + rewind(f); + + char* buf = (char*)calloc(f_len, 1); + assert(buf); + + size_t num_read = fread(buf, f_len, 1, f); + fclose(f); + + if (num_read != 1) { + free(buf); + return NULL; + } + + if (out_len) { + *out_len = f_len; + } + + return buf; +} + +static void DeallocateBuffer(void* data, size_t) { + free(data); +} + +int main(int argc, char* argv[]) { + TF_Buffer* buf; + TF_Graph* graph; + TF_Status* status; + char *path = argv[1]; + + // load model + { + size_t model_size; + char tmp[1024]; + snprintf(tmp, sizeof(tmp), "%s.pb", path); + printf("loading model %s\n", tmp); + uint8_t *model_data = (uint8_t *)read_file(tmp, &model_size); + buf = TF_NewBuffer(); + buf->data = model_data; + buf->length = model_size; + buf->data_deallocator = DeallocateBuffer; + printf("loaded model of size %d\n", model_size); + } + + // import graph + status = TF_NewStatus(); + graph = TF_NewGraph(); + TF_ImportGraphDefOptions *opts = TF_NewImportGraphDefOptions(); + TF_GraphImportGraphDef(graph, buf, opts, status); + TF_DeleteImportGraphDefOptions(opts); + TF_DeleteBuffer(buf); + if (TF_GetCode(status) != TF_OK) { + printf("FAIL: %s\n", TF_Message(status)); + } else { + printf("SUCCESS\n"); + } +} diff --git a/sunnypilot/modeld_v2/tests/tf_test/pb_loader.py b/sunnypilot/modeld_v2/tests/tf_test/pb_loader.py new file mode 100755 index 0000000000..3e476628eb --- /dev/null +++ b/sunnypilot/modeld_v2/tests/tf_test/pb_loader.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 +import sys +import tensorflow as tf + +with open(sys.argv[1], "rb") as f: + graph_def = tf.compat.v1.GraphDef() + graph_def.ParseFromString(f.read()) + #tf.io.write_graph(graph_def, '', sys.argv[1]+".try") diff --git a/sunnypilot/modeld_v2/tests/timing/benchmark.py b/sunnypilot/modeld_v2/tests/timing/benchmark.py new file mode 100755 index 0000000000..3e81f73fa3 --- /dev/null +++ b/sunnypilot/modeld_v2/tests/timing/benchmark.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 + +import os +import time +import numpy as np + +import cereal.messaging as messaging +from openpilot.system.manager.process_config import managed_processes + + +N = int(os.getenv("N", "5")) +TIME = int(os.getenv("TIME", "30")) + +if __name__ == "__main__": + sock = messaging.sub_sock('modelV2', conflate=False, timeout=1000) + + execution_times = [] + + for _ in range(N): + os.environ['LOGPRINT'] = 'debug' + managed_processes['modeld'].start() + time.sleep(5) + + t = [] + start = time.monotonic() + while time.monotonic() - start < TIME: + msgs = messaging.drain_sock(sock, wait_for_one=True) + for m in msgs: + t.append(m.modelV2.modelExecutionTime) + + execution_times.append(np.array(t[10:]) * 1000) + managed_processes['modeld'].stop() + + print("\n\n") + print(f"ran modeld {N} times for {TIME}s each") + for _, t in enumerate(execution_times): + print(f"\tavg: {sum(t)/len(t):0.2f}ms, min: {min(t):0.2f}ms, max: {max(t):0.2f}ms") + print("\n\n") diff --git a/sunnypilot/modeld_v2/warp.py b/sunnypilot/modeld_v2/warp.py new file mode 100644 index 0000000000..829cbcca49 --- /dev/null +++ b/sunnypilot/modeld_v2/warp.py @@ -0,0 +1,137 @@ +import pickle +import time +import numpy as np +from pathlib import Path +from tinygrad.tensor import Tensor +from tinygrad.engine.jit import TinyJit +from tinygrad.device import Device + +from openpilot.system.camerad.cameras.nv12_info import get_nv12_info +from openpilot.selfdrive.modeld.compile_warp import ( + CAMERA_CONFIGS, MEDMODEL_INPUT_SIZE, make_frame_prepare, make_update_both_imgs, + warp_pkl_path, +) + +MODELS_DIR = Path(__file__).parent / 'models' +MODEL_W, MODEL_H = MEDMODEL_INPUT_SIZE +UPSTREAM_BUFFER_LENGTH = 5 + + +def v2_warp_pkl_path(cam_w, cam_h, buffer_length): + return MODELS_DIR / f'warp_{cam_w}x{cam_h}_b{buffer_length}_tinygrad.pkl' + + +def compile_v2_warp(cam_w, cam_h, buffer_length): + _, _, _, yuv_size = get_nv12_info(cam_w, cam_h) + img_buffer_shape = (buffer_length * 6, MODEL_H // 2, MODEL_W // 2) + + print(f"Compiling v2 warp for {cam_w}x{cam_h} buffer_length={buffer_length}...") + + frame_prepare = make_frame_prepare(cam_w, cam_h, MODEL_W, MODEL_H) + update_both_imgs = make_update_both_imgs(frame_prepare, MODEL_W, MODEL_H) + update_img_jit = TinyJit(update_both_imgs, prune=True) + + full_buffer = Tensor.zeros(img_buffer_shape, dtype='uint8').contiguous().realize() + big_full_buffer = Tensor.zeros(img_buffer_shape, dtype='uint8').contiguous().realize() + full_buffer_np = np.zeros(img_buffer_shape, dtype=np.uint8) + big_full_buffer_np = np.zeros(img_buffer_shape, dtype=np.uint8) + + for i in range(10): + new_frame_np = (32 * np.random.randn(yuv_size).astype(np.float32) + 128).clip(0, 255).astype(np.uint8) + img_inputs = [full_buffer, + Tensor.from_blob(new_frame_np.ctypes.data, (yuv_size,), dtype='uint8').realize(), + Tensor(Tensor.randn(3, 3).mul(8).realize().numpy(), device='NPY')] + new_big_frame_np = (32 * np.random.randn(yuv_size).astype(np.float32) + 128).clip(0, 255).astype(np.uint8) + big_img_inputs = [big_full_buffer, + Tensor.from_blob(new_big_frame_np.ctypes.data, (yuv_size,), dtype='uint8').realize(), + Tensor(Tensor.randn(3, 3).mul(8).realize().numpy(), device='NPY')] + inputs = img_inputs + big_img_inputs + Device.default.synchronize() + + inputs_np = [x.numpy() for x in inputs] + inputs_np[0] = full_buffer_np + inputs_np[3] = big_full_buffer_np + + st = time.perf_counter() + out = update_img_jit(*inputs) + full_buffer = out[0].contiguous().realize().clone() + big_full_buffer = out[2].contiguous().realize().clone() + mt = time.perf_counter() + Device.default.synchronize() + et = time.perf_counter() + print(f" [{i+1}/10] enqueue {(mt-st)*1e3:6.2f} ms -- total {(et-st)*1e3:6.2f} ms") + + pkl_path = v2_warp_pkl_path(cam_w, cam_h, buffer_length) + with open(pkl_path, "wb") as f: + pickle.dump(update_img_jit, f) + print(f" Saved to {pkl_path}") + + jit = pickle.load(open(pkl_path, "rb")) + jit(*inputs) + + +class Warp: + def __init__(self, buffer_length=2): + self.buffer_length = buffer_length + self.img_buffer_shape = (buffer_length * 6, MODEL_H // 2, MODEL_W // 2) + + self.jit_cache = {} + self.full_buffers = {k: Tensor.zeros(self.img_buffer_shape, dtype='uint8').contiguous().realize() for k in ['img', 'big_img']} + self._blob_cache: dict[int, Tensor] = {} + self._nv12_cache: dict[tuple[int, int], int] = {} + self.transforms_np = {k: np.zeros((3, 3), dtype=np.float32) for k in ['img', 'big_img']} + self.transforms = {k: Tensor(v, device='NPY').realize() for k, v in self.transforms_np.items()} + + def process(self, bufs, transforms): + if not bufs: + return {} + road = next(n for n in bufs if 'big' not in n) + wide = next(n for n in bufs if 'big' in n) + cam_w, cam_h = bufs[road].width, bufs[road].height + key = (cam_w, cam_h) + + if key not in self.jit_cache: + v2_pkl = v2_warp_pkl_path(cam_w, cam_h, self.buffer_length) + if v2_pkl.exists(): + with open(v2_pkl, 'rb') as f: + self.jit_cache[key] = pickle.load(f) + elif self.buffer_length == UPSTREAM_BUFFER_LENGTH: + upstream_pkl = warp_pkl_path(cam_w, cam_h) + if upstream_pkl.exists(): + with open(upstream_pkl, 'rb') as f: + self.jit_cache[key] = pickle.load(f) + if key not in self.jit_cache: + frame_prepare = make_frame_prepare(cam_w, cam_h, MODEL_W, MODEL_H) + update_both_imgs = make_update_both_imgs(frame_prepare, MODEL_W, MODEL_H) + self.jit_cache[key] = TinyJit(update_both_imgs, prune=True) + + if key not in self._nv12_cache: + self._nv12_cache[key] = get_nv12_info(cam_w, cam_h)[3] + yuv_size = self._nv12_cache[key] + + road_ptr = bufs[road].data.ctypes.data + wide_ptr = bufs[wide].data.ctypes.data + if road_ptr not in self._blob_cache: + self._blob_cache[road_ptr] = Tensor.from_blob(road_ptr, (yuv_size,), dtype='uint8') + if wide_ptr not in self._blob_cache: + self._blob_cache[wide_ptr] = Tensor.from_blob(wide_ptr, (yuv_size,), dtype='uint8') + road_blob = self._blob_cache[road_ptr] + wide_blob = self._blob_cache[wide_ptr] if wide_ptr != road_ptr else Tensor.from_blob(wide_ptr, (yuv_size,), dtype='uint8') + np.copyto(self.transforms_np['img'], transforms[road].reshape(3, 3)) + np.copyto(self.transforms_np['big_img'], transforms[wide].reshape(3, 3)) + + Device.default.synchronize() + res = self.jit_cache[key]( + self.full_buffers['img'], road_blob, self.transforms['img'], + self.full_buffers['big_img'], wide_blob, self.transforms['big_img'], + ) + self.full_buffers['img'], out_road = res[0].realize(), res[1].realize() + self.full_buffers['big_img'], out_wide = res[2].realize(), res[3].realize() + + return {road: out_road, wide: out_wide} + + +if __name__ == "__main__": + for cam_w, cam_h in CAMERA_CONFIGS: + for bl in [2, 5]: + compile_v2_warp(cam_w, cam_h, bl) diff --git a/sunnypilot/models/README.md b/sunnypilot/models/README.md new file mode 100644 index 0000000000..bf4fb72a98 --- /dev/null +++ b/sunnypilot/models/README.md @@ -0,0 +1,63 @@ +# Model Selector Version Compatibility + +This document explains the version compatibility mechanism used by the Model Selector system, and the rationale behind certain version constraints and JSON file management strategies. + +## Overview + +The Model Selector is responsible for selecting and validating model bundles based on their metadata and version constraints. Each model bundle is distributed via a JSON file and includes a `minimumSelectorVersion` field indicating the minimum selector version required to load it. + +To ensure robust compatibility and prevent mismatches between model expectations and selector capabilities, the selector enforces two version boundaries: + +* **`REQUIRED_MIN_SELECTOR_VERSION`**: the oldest selector version we support. +* **`CURRENT_SELECTOR_VERSION`**: the current version of the selector logic. + +## Version Compatibility Check + +A model bundle is considered compatible if: + +```python +REQUIRED_MIN_SELECTOR_VERSION <= bundle["minimumSelectorVersion"] <= CURRENT_SELECTOR_VERSION +``` + +This ensures: + +* **Old bundles are rejected** if they rely on deprecated selector behavior. +* **Future bundles are ignored** if they expect logic that the current selector doesn't yet implement. + +## Handling Breaking Changes + +When a deep change in selector behavior requires *all* models to be recompiled (e.g., due to a major architectural update), we: + +1. **Create a new JSON file** (e.g., from `models_v4.json` to `models_v5.json`). +2. **Assign updated `minimumSelectorVersion` values** in the new bundles. + +This allows older selector versions to continue using the previous JSON file, while newer versions point to the new one, preventing cross-contamination. + +## Why `REQUIRED_MIN_SELECTOR_VERSION` Still Matters + +Despite using new JSON files to isolate breaking changes, `REQUIRED_MIN_SELECTOR_VERSION` plays a critical role: + +### 1. **Cached Bundle Validation** + +Model bundles are cached locally (e.g., in-memory or on disk). A user might have previously loaded a now-invalid bundle from an older JSON file. + +`REQUIRED_MIN_SELECTOR_VERSION` prevents the selector from reloading or trusting that stale cached bundle, even if the original JSON is gone. + +### 2. **Explicit Deprecation Boundary** + +By raising `REQUIRED_MIN_SELECTOR_VERSION`, we declare older bundles officially unsupported, even if they technically still exist in a legacy JSON file. + +### 3. **Avoiding Race Conditions** + +Some clients may have intermittent access to updated JSONs. The runtime check ensures version compatibility is enforced independently of external file state. + +## Summary + +| Component | Purpose | +| ------------------------------- | --------------------------------------------------------------------- | +| `minimumSelectorVersion` | Declares the minimum selector version required to load a model bundle | +| `REQUIRED_MIN_SELECTOR_VERSION` | Prevents loading bundles that are too old (e.g., from stale cache) | +| `CURRENT_SELECTOR_VERSION` | Prevents loading bundles that are too new or forward-incompatible | +| JSON file renaming | Isolates bundles by selector generation to handle full recompiles | + +This layered strategy ensures safe evolution of the model selection system while maintaining backward compatibility and runtime protection against stale or incompatible bundles. diff --git a/sunnypilot/models/__init__.py b/sunnypilot/models/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sunnypilot/models/constants.py b/sunnypilot/models/constants.py new file mode 100644 index 0000000000..18abc6c960 --- /dev/null +++ b/sunnypilot/models/constants.py @@ -0,0 +1,121 @@ +import numpy as np + +def index_function(idx, max_val=192, max_idx=32): + return max_val * ((idx/max_idx)**2) + + +class ModelConstants: + # time and distance indices + IDX_N = 33 + T_IDXS = [index_function(idx, max_val=10.0) for idx in range(IDX_N)] + X_IDXS = [index_function(idx, max_val=192.0) for idx in range(IDX_N)] + LEAD_T_IDXS = [0., 2., 4., 6., 8., 10.] + LEAD_T_OFFSETS = [0., 2., 4.] + META_T_IDXS = [2., 4., 6., 8., 10.] + + # model inputs constants + MODEL_FREQ = 20 + FEATURE_LEN = 512 + HISTORY_BUFFER_LEN = 99 + DESIRE_LEN = 8 + TRAFFIC_CONVENTION_LEN = 2 + NAV_FEATURE_LEN = 256 + NAV_INSTRUCTION_LEN = 150 + LAT_PLANNER_STATE_LEN = 4 + LATERAL_CONTROL_PARAMS_LEN = 2 + PREV_DESIRED_CURV_LEN = 1 + + # model outputs constants + FCW_THRESHOLDS_5MS2 = np.array([.05, .05, .15, .15, .15], dtype=np.float32) + FCW_THRESHOLDS_3MS2 = np.array([.7, .7], dtype=np.float32) + FCW_5MS2_PROBS_WIDTH = 5 + FCW_3MS2_PROBS_WIDTH = 2 + + DISENGAGE_WIDTH = 5 + POSE_WIDTH = 6 + WIDE_FROM_DEVICE_WIDTH = 3 + SIM_POSE_WIDTH = 6 + LEAD_WIDTH = 4 + LANE_LINES_WIDTH = 2 + ROAD_EDGES_WIDTH = 2 + PLAN_WIDTH = 15 + DESIRE_PRED_WIDTH = 8 + LAT_PLANNER_SOLUTION_WIDTH = 4 + DESIRED_CURV_WIDTH = 1 + + NUM_LANE_LINES = 4 + NUM_ROAD_EDGES = 2 + + LEAD_TRAJ_LEN = 6 + DESIRE_PRED_LEN = 4 + + PLAN_MHP_N = 5 + LEAD_MHP_N = 2 + PLAN_MHP_SELECTION = 1 + LEAD_MHP_SELECTION = 3 + + FCW_THRESHOLD_5MS2_HIGH = 0.15 + FCW_THRESHOLD_5MS2_LOW = 0.05 + FCW_THRESHOLD_3MS2 = 0.7 + + CONFIDENCE_BUFFER_LEN = 5 + RYG_GREEN = 0.01165 + RYG_YELLOW = 0.06157 + + POLY_PATH_DEGREE = 4 + + +# model outputs slices +class Plan: + POSITION = slice(0, 3) + VELOCITY = slice(3, 6) + ACCELERATION = slice(6, 9) + T_FROM_CURRENT_EULER = slice(9, 12) + ORIENTATION_RATE = slice(12, 15) + + +class Meta: + ENGAGED = slice(0, 1) + # next 2, 4, 6, 8, 10 seconds + GAS_DISENGAGE = slice(1, 31, 6) + BRAKE_DISENGAGE = slice(2, 31, 6) + STEER_OVERRIDE = slice(3, 31, 6) + HARD_BRAKE_3 = slice(4, 31, 6) + HARD_BRAKE_4 = slice(5, 31, 6) + HARD_BRAKE_5 = slice(6, 31, 6) + # next 0, 2, 4, 6, 8, 10 seconds + GAS_PRESS = slice(31, 55, 4) + BRAKE_PRESS = slice(32, 55, 4) + LEFT_BLINKER = slice(33, 55, 4) + RIGHT_BLINKER = slice(34, 55, 4) + + +class MetaTombRaider: + ENGAGED = slice(0, 1) + # next 2, 4, 6, 8, 10 seconds + GAS_DISENGAGE = slice(1, 41, 8) + BRAKE_DISENGAGE = slice(2, 41, 8) + STEER_OVERRIDE = slice(3, 41, 8) + HARD_BRAKE_3 = slice(4, 41, 8) + HARD_BRAKE_4 = slice(5, 41, 8) + HARD_BRAKE_5 = slice(6, 41, 8) + GAS_PRESS = slice(7, 41, 8) + BRAKE_PRESS = slice(8, 41, 8) + # next 0, 2, 4, 6, 8, 10 seconds + LEFT_BLINKER = slice(41, 53, 2) + RIGHT_BLINKER = slice(42, 53, 2) + + +class MetaSimPose: + ENGAGED = slice(0, 1) + # next 2, 4, 6, 8, 10 seconds + GAS_DISENGAGE = slice(1, 36, 7) + BRAKE_DISENGAGE = slice(2, 36, 7) + STEER_OVERRIDE = slice(3, 36, 7) + HARD_BRAKE_3 = slice(4, 36, 7) + HARD_BRAKE_4 = slice(5, 36, 7) + HARD_BRAKE_5 = slice(6, 36, 7) + GAS_PRESS = slice(7, 36, 7) + # next 0, 2, 4, 6, 8, 10 seconds + LEFT_BLINKER = slice(36, 48, 2) + RIGHT_BLINKER = slice(37, 48, 2) diff --git a/sunnypilot/models/default_model.py b/sunnypilot/models/default_model.py new file mode 100755 index 0000000000..d540efbffd --- /dev/null +++ b/sunnypilot/models/default_model.py @@ -0,0 +1,65 @@ +import argparse +import os +import hashlib + +from openpilot.common.basedir import BASEDIR +from openpilot.sunnypilot import get_file_hash + +DEFAULT_MODEL_NAME_PATH = os.path.join(BASEDIR, "common", "model.h") +MODEL_HASH_PATH = os.path.join(BASEDIR, "sunnypilot", "models", "tests", "model_hash") +VISION_ONNX_PATH = os.path.join(BASEDIR, "selfdrive", "modeld", "models", "driving_vision.onnx") +OFF_POLICY_ONNX_PATH = os.path.join(BASEDIR, "selfdrive", "modeld", "models", "driving_off_policy.onnx") +ON_POLICY_ONNX_PATH = os.path.join(BASEDIR, "selfdrive", "modeld", "models", "driving_on_policy.onnx") + + +def update_model_hash(): + vision_hash = get_file_hash(VISION_ONNX_PATH) + off_policy_hash = get_file_hash(OFF_POLICY_ONNX_PATH) + on_policy_hash = get_file_hash(ON_POLICY_ONNX_PATH) + + combined_hash = hashlib.sha256((vision_hash + off_policy_hash + on_policy_hash).encode()).hexdigest() + + with open(MODEL_HASH_PATH, "w") as f: + f.write(combined_hash) + + print(f"Generated and updated new combined model hash to {MODEL_HASH_PATH}") + + +def get_current_default_model_name(): + print("[GET DEFAULT MODEL NAME]") + with open(DEFAULT_MODEL_NAME_PATH) as f: + name = f.read().split('"')[1] + print(f'Current default model name: "{name}"') + + return name + + +def update_default_model_name(name: str): + print("[CHANGE DEFAULT MODEL NAME]") + with open(DEFAULT_MODEL_NAME_PATH, "w") as f: + f.write(f'#define DEFAULT_MODEL "{name}"\n') + print(f'New default model name: "{name}"') + print("[DONE]") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Update default model name and hash") + parser.add_argument("--new_name", type=str, help="New default model name") + args = parser.parse_args() + + if not args.new_name: + print("Warning: No new default model name provided. Use --new_name to specify") + print("Default model name and hash will not be updated! (aborted)") + exit(0) + + current_name = get_current_default_model_name() + new_name = f"{args.new_name} (Default)" + if current_name == new_name: + print(f'Proposed default model name: "{new_name}"') + confirm = input("Proposed default model name is the same as the current default model name. Confirm? (y/n): ").upper().strip() + if confirm != "Y": + print("Default model name and hash will not be updated! (aborted)") + exit(0) + + update_default_model_name(new_name) + update_model_hash() diff --git a/sunnypilot/models/fetcher.py b/sunnypilot/models/fetcher.py new file mode 100644 index 0000000000..0b6853da8a --- /dev/null +++ b/sunnypilot/models/fetcher.py @@ -0,0 +1,187 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" + +import time + +import requests +from requests.exceptions import (SSLError, RequestException, HTTPError) +from openpilot.common.params import Params +from openpilot.common.swaglog import cloudlog +from openpilot.sunnypilot.models.helpers import is_bundle_version_compatible + +from cereal import custom + + +class ModelParser: + """Handles parsing of model data into cereal objects""" + + @staticmethod + def _parse_download_uri(download_uri_data) -> custom.ModelManagerSP.DownloadUri: + download_uri = custom.ModelManagerSP.DownloadUri() + download_uri.uri = download_uri_data.get("url") + download_uri.sha256 = download_uri_data.get("sha256") + return download_uri + + @staticmethod + def _parse_artifact(artifact_data) -> custom.ModelManagerSP.Artifact: + artifact = custom.ModelManagerSP.Artifact() + artifact.fileName = artifact_data.get("file_name") + artifact.downloadUri = ModelParser._parse_download_uri(artifact_data.get("download_uri", {})) + return artifact + + @staticmethod + def _parse_model(model_data) -> custom.ModelManagerSP.Model: + model = custom.ModelManagerSP.Model() + + model.type = model_data.get("type") + model.artifact = ModelParser._parse_artifact(model_data.get("artifact", {})) + if metadata := model_data.get("metadata"): + model.metadata = ModelParser._parse_artifact(metadata) + return model + + @staticmethod + def _parse_overrides(overrides_data: dict[str, str]) -> list[custom.ModelManagerSP.Override]: + overrides = [] + for key, value in overrides_data.items(): + override = custom.ModelManagerSP.Override() + override.key = key + override.value = value + overrides.append(override) + return overrides + + @staticmethod + def _parse_bundle(bundle) -> custom.ModelManagerSP.ModelBundle: + model_bundle = custom.ModelManagerSP.ModelBundle() + model_bundle.index = int(bundle["index"]) + model_bundle.internalName = bundle["short_name"] + model_bundle.displayName = bundle["display_name"] + model_bundle.models = [ModelParser._parse_model(model) for model in bundle.get("models",[])] + model_bundle.status = 0 + model_bundle.generation = int(bundle["generation"]) + model_bundle.environment = bundle["environment"] + model_bundle.runner = bundle.get("runner", custom.ModelManagerSP.Runner.snpe) + model_bundle.is20hz = bundle.get("is_20hz", False) + model_bundle.minimumSelectorVersion = int(bundle["minimum_selector_version"]) + model_bundle.overrides = ModelParser._parse_overrides(bundle.get("overrides", {})) + model_bundle.ref = bundle.get("ref") + + return model_bundle + + @staticmethod + def parse_models(json_data: dict) -> list[custom.ModelManagerSP.ModelBundle]: + found_bundles = [ModelParser._parse_bundle(bundle) for bundle in json_data.get("bundles", [])] + return [bundle for bundle in found_bundles if is_bundle_version_compatible(bundle.to_dict())] + + +class ModelCache: + """Handles caching of model data to avoid frequent remote fetches""" + + def __init__(self, params: Params, cache_timeout: int = int(3600 * 1e9)): + self.params = params + self.cache_timeout = cache_timeout + self._LAST_SYNC_KEY = "ModelManager_LastSyncTime" + self._CACHE_KEY = "ModelManager_ModelsCache" + + def _is_expired(self) -> bool: + """Checks if the cache has expired""" + current_time = int(time.monotonic() * 1e9) + last_sync = self.params.get(self._LAST_SYNC_KEY) or 0 + return bool(last_sync == 0) or (current_time - last_sync) >= self.cache_timeout + + def get(self) -> tuple[dict, bool]: + """ + Retrieves cached model data and expiration status atomically. + Returns: Tuple of (cached_data, is_expired) + If no cached data exists or on error, returns an empty dict + """ + try: + cached_data = self.params.get(self._CACHE_KEY) + if not cached_data: + cloudlog.warning("No cached model data available") + return {}, True + return cached_data, self._is_expired() + except Exception as e: + cloudlog.exception(f"Error retrieving cached model data: {str(e)}") + return {}, True + + def set(self, data: dict) -> None: + """Updates the cache with new model data""" + self.params.put(self._CACHE_KEY, data) + self.params.put(self._LAST_SYNC_KEY, int(time.monotonic() * 1e9)) + + +class ModelFetcher: + """Handles fetching and caching of model data from remote source""" + MODEL_URL = "https://raw.githubusercontent.com/sunnypilot/sunnypilot-models/refs/heads/gh-pages/docs/driving_models_v16.json" + + def __init__(self, params: Params): + self.params = params + self.model_cache = ModelCache(params) + self.model_parser = ModelParser() + + def _fetch_and_cache_models(self) -> list[custom.ModelManagerSP.ModelBundle] | None: + """Fetches fresh model data from remote and updates cache. + Returns None on transport errors. Raises on 404 and other fatal HTTP errors. + """ + try: + response = requests.get(self.MODEL_URL, timeout=10) + + # Explicitly handle 404 differently + if response.status_code == 404: + cloudlog.error(f"Models URL returned 404 Not Found: {self.MODEL_URL}") + raise HTTPError(f"404 Not Found: {self.MODEL_URL}", response=response) + + # Raise for any other 4xx/5xx + response.raise_for_status() + + json_data = response.json() + self.model_cache.set(json_data) + cloudlog.debug("Successfully updated models cache") + return self.model_parser.parse_models(json_data) + + except ConnectionError as e: + cloudlog.warning(f"DNS/connection error while fetching models: {e}") + except SSLError as e: + cloudlog.warning(f"SSL error while fetching models: {e}") + except RequestException as e: + cloudlog.warning(f"Request transport error while fetching models: {e}") + except Exception as e: + cloudlog.exception(f"Unexpected error fetching models: {e}") + + return None + + def get_available_bundles(self) -> list[custom.ModelManagerSP.ModelBundle]: + """Gets the list of available models, with smart cache handling""" + cached_data, is_expired = self.model_cache.get() + + if cached_data and not is_expired: + cloudlog.debug("Using valid cached models data") + return self.model_parser.parse_models(cached_data) + + fetched_bundles = self._fetch_and_cache_models() + if fetched_bundles is not None: + return fetched_bundles + + if not cached_data: + cloudlog.warning("Failed to fetch fresh data and no cache available") + + cloudlog.warning("Failed to fetch fresh data. Using expired cache as fallback") + return self.model_parser.parse_models(cached_data) + +if __name__ == "__main__": + params = Params() + model_fetcher = ModelFetcher(params) + bundles = model_fetcher.get_available_bundles() + for bundle in bundles: + for model in bundle.models: + model_overrides = {override.key: override.value for override in bundle.overrides} + # Print model details + print(f"Bundle: {bundle.internalName}, Type: {model.type}, Status: {bundle.status}, Overrides: {model_overrides}") + # Print artifact details + print(f"Artifact: {model.artifact.fileName}, Download URI: {model.artifact.downloadUri.uri}") + # Print metadata details + print(f"Metadata: {model.metadata.fileName}, Download URI: {model.metadata.downloadUri.uri}") diff --git a/sunnypilot/models/helpers.py b/sunnypilot/models/helpers.py new file mode 100644 index 0000000000..5627080319 --- /dev/null +++ b/sunnypilot/models/helpers.py @@ -0,0 +1,198 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" + +import hashlib +import os +import pickle +import numpy as np + +from openpilot.common.params import Params +from cereal import custom +from openpilot.sunnypilot.models.constants import Meta, MetaTombRaider, MetaSimPose +from openpilot.system.hardware.hw import Paths +from pathlib import Path + +# see the README.md for more details on the model selector versioning +CURRENT_SELECTOR_VERSION = 15 +REQUIRED_MIN_SELECTOR_VERSION = 14 + + +CUSTOM_MODEL_PATH = Paths.model_root() +METADATA_PATH = Path(__file__).parent / '../models/supercombo_metadata.pkl' + +ModelManager = custom.ModelManagerSP + + +async def verify_file(file_path: str, expected_hash: str) -> bool: + """Verifies file hash against expected hash""" + if not os.path.exists(file_path): + return False + + sha256_hash = hashlib.sha256() + with open(file_path, "rb") as file: + for chunk in iter(lambda: file.read(4096), b""): + sha256_hash.update(chunk) + + return sha256_hash.hexdigest().lower() == expected_hash.lower() + + +def is_bundle_version_compatible(bundle: dict) -> bool: + """ + Checks whether the model bundle is compatible with the current selector version constraints. + + The bundle specifies a `minimum_selector_version`, which defines the minimum selector version + required to load the model. This function ensures that: + + 1. The model is not too old: the bundle must require at least `REQUIRED_MIN_SELECTOR_VERSION`. + 2. The model is not too new: it must support the current selector version (`CURRENT_SELECTOR_VERSION`). + + This allows the selector to enforce both a minimum and maximum range of supported models, + even if a model would otherwise be compatible. + + :param bundle: Dictionary containing `minimum_selector_version`, as defined by the model bundle. + :type bundle: Dict + :return: True if the selector version is within the accepted range for the bundle; otherwise False. + :rtype: Bool + """ + return bool(REQUIRED_MIN_SELECTOR_VERSION <= bundle.get("minimumSelectorVersion", 0) <= CURRENT_SELECTOR_VERSION) + + +def get_active_bundle(params: Params = None) -> custom.ModelManagerSP.ModelBundle: + """Gets the active model bundle from cache""" + if params is None: + params = Params() + + try: + if (active_bundle := params.get("ModelManager_ActiveBundle") or {}) and is_bundle_version_compatible(active_bundle): + return custom.ModelManagerSP.ModelBundle(**active_bundle) + except Exception: + pass + + return None + + +def get_active_model_runner(params: Params = None, force_check=False) -> custom.ModelManagerSP.Runner: + """ + Determines and returns the active model runner type, based on provided parameters. + The function utilizes caching to prevent redundant calculations and checks. + + If the cached "ModelRunnerTypeCache" exists in the provided parameters and `force_check` + is set to False, the cached value is directly returned. Otherwise, the function determines + the runner type based on the active model bundle. If a model bundle containing a drive + model exists, the runner type is derived based on the filename of the drive model. + Finally, it updates the cache with the determined runner type, if needed. + + :param params: The parameter set used to retrieve caching and runner details. If `None`, + a default `Params` instance is created internally. + :type params: Params + :param force_check: A flag indicating whether to bypass cached results and always + re-determine the runner type. Defaults to `False`. + :type force_check: bool + :return: The determined or cached model runner type. + :rtype: custom.ModelManagerSP.Runner + """ + if params is None: + params = Params() + + if (cached_runner_type := params.get("ModelRunnerTypeCache")) and not force_check: + if isinstance(cached_runner_type, str) and cached_runner_type.isdigit(): + return int(cached_runner_type) + + runner_type = custom.ModelManagerSP.Runner.stock + + if active_bundle := get_active_bundle(params): + runner_type = active_bundle.runner.raw + + if cached_runner_type != runner_type: + params.put("ModelRunnerTypeCache", int(runner_type)) + + return runner_type + +def _get_model(): + if bundle := get_active_bundle(): + drive_model = next(model for model in bundle.models if model.type == ModelManager.Model.Type.supercombo) + return drive_model + + return None + +def load_metadata(): + metadata_path = METADATA_PATH + + if model := _get_model(): + metadata_path = f"{CUSTOM_MODEL_PATH}/{model.metadata.fileName}" + + with open(metadata_path, 'rb') as f: + return pickle.load(f) + + +def prepare_inputs(model_metadata) -> dict[str, np.ndarray]: + # img buffers are managed in openCL transform code so we don't pass them as inputs + inputs = { + k: np.zeros(v, dtype=np.float32).flatten() + for k, v in model_metadata['input_shapes'].items() + if 'img' not in k + } + + return inputs + + +def load_meta_constants(model_metadata): + """ + Determines and loads the appropriate meta model class based on the metadata provided. The function checks + specific keys and conditions within the provided metadata dictionary to identify the corresponding meta + model class to return. + + :param model_metadata: Dictionary containing metadata about the model. It includes + details such as input shapes, output slices, and other configurations for identifying + metadata-dependent meta model classes. + :type model_metadata: dict + :return: The appropriate meta model class (Meta, MetaSimPose, or MetaTombRaider) + based on the conditions and metadata provided. + :rtype: type + """ + meta = Meta # Default Meta + + if 'sim_pose' in model_metadata['input_shapes'].keys(): + # Meta for models with sim_pose input + meta = MetaSimPose + else: + # Meta for Tomb Raider, it does not include sim_pose input but has the same meta slice as previous models + meta_slice = model_metadata['output_slices']['meta'] + meta_tf_slice = slice(5868, 5921, None) + + if ( + meta_slice.start == meta_tf_slice.start and + meta_slice.stop == meta_tf_slice.stop and + meta_slice.step == meta_tf_slice.step + ): + meta = MetaTombRaider + + return meta + + +# The following method(s) are modeld helper methods +def plan_x_idxs_helper(constants, plan, model_output) -> list[float]: + # times at X_IDXS according to plan. + LINE_T_IDXS = [np.nan] * constants.IDX_N + LINE_T_IDXS[0] = 0.0 + plan_x = model_output['plan'][0, :, plan.POSITION][:, 0].tolist() + for xidx in range(1, constants.IDX_N): + tidx = 0 + # increment tidx until we find an element that's further away than the current xidx + while tidx < constants.IDX_N - 1 and plan_x[tidx + 1] < constants.X_IDXS[xidx]: + tidx += 1 + if tidx == constants.IDX_N - 1: + # if the plan doesn't extend far enough, set plan_t to the max value (10s), then break + LINE_T_IDXS[xidx] = constants.T_IDXS[constants.IDX_N - 1] + break + # interpolate to find `t` for the current xidx + current_x_val = plan_x[tidx] + next_x_val = plan_x[tidx + 1] + p = (constants.X_IDXS[xidx] - current_x_val) / (next_x_val - current_x_val) if abs( + next_x_val - current_x_val) > 1e-9 else float('nan') + LINE_T_IDXS[xidx] = p * constants.T_IDXS[tidx + 1] + (1 - p) * constants.T_IDXS[tidx] + return LINE_T_IDXS diff --git a/sunnypilot/models/manager.py b/sunnypilot/models/manager.py new file mode 100644 index 0000000000..8fee0798b6 --- /dev/null +++ b/sunnypilot/models/manager.py @@ -0,0 +1,226 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" + +import asyncio +import os +import time + +import aiohttp +from openpilot.common.params import Params +from openpilot.common.realtime import Ratekeeper +from openpilot.common.swaglog import cloudlog +from openpilot.system.hardware.hw import Paths + +from cereal import messaging, custom +from openpilot.sunnypilot.models.fetcher import ModelFetcher +from openpilot.sunnypilot.models.helpers import verify_file, get_active_bundle + + +class ModelManagerSP: + """Manages model downloads and status reporting""" + + def __init__(self): + self.params = Params() + self.model_fetcher = ModelFetcher(self.params) + self.pm = messaging.PubMaster(["modelManagerSP"]) + self.available_models: list[custom.ModelManagerSP.ModelBundle] = [] + self.selected_bundle: custom.ModelManagerSP.ModelBundle = None + self.active_bundle: custom.ModelManagerSP.ModelBundle = get_active_bundle(self.params) + self._chunk_size = 128 * 1000 # 128 KB chunks + self._download_start_times: dict[str, float] = {} # Track start time per model + + def _calculate_eta(self, filename: str, progress: float) -> int: + """Calculate ETA based on elapsed time and current progress""" + if filename not in self._download_start_times or progress <= 0: + return 60 # Default ETA for new downloads + + elapsed_time = time.monotonic() - self._download_start_times[filename] + if elapsed_time <= 0: + return 60 + + # If we're at X% after Y seconds, we can estimate total time as (Y / X) * 100 + total_estimated_time = (elapsed_time / progress) * 100 + eta = total_estimated_time - elapsed_time + + return max(1, int(eta)) # Return at least 1 second if download is ongoing + + async def _download_file(self, url: str, path: str, model) -> None: + """Downloads a file with progress tracking""" + self._download_start_times[model.fileName] = time.monotonic() + + async with aiohttp.ClientSession() as session: + async with session.get(url) as response: + response.raise_for_status() + total_size = int(response.headers.get("content-length", 0)) + bytes_downloaded = 0 + + with open(path, 'wb') as f: + async for chunk in response.content.iter_chunked(self._chunk_size): # type: bytes + f.write(chunk) + bytes_downloaded += len(chunk) + + if not self.params.get("ModelManager_DownloadIndex"): + raise Exception("Download cancelled") + + if total_size > 0: + progress = (bytes_downloaded / total_size) * 100 + model.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.downloading + model.downloadProgress.progress = progress + model.downloadProgress.eta = self._calculate_eta(model.fileName, progress) + self._report_status() + + # Clean up start time after download completes + del self._download_start_times[model.fileName] + + async def _process_artifact(self, artifact, destination_path: str) -> None: + """Processes a single model download including verification""" + if not artifact.downloadUri.uri: + return None + + url = artifact.downloadUri.uri + expected_hash = artifact.downloadUri.sha256 + filename = artifact.fileName + full_path = os.path.join(destination_path, filename) + + try: + # Check existing file + if os.path.exists(full_path) and await verify_file(full_path, expected_hash): + artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.cached + artifact.downloadProgress.progress = 100 + artifact.downloadProgress.eta = 0 + self._report_status() + return + + # Download and verify + await self._download_file(url, full_path, artifact) + if not await verify_file(full_path, expected_hash): + raise ValueError(f"Hash validation failed for {filename}") + + artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.downloaded + artifact.downloadProgress.eta = 0 + self._report_status() + + except Exception as e: + cloudlog.error(f"Error downloading {filename}: {str(e)}") + if os.path.exists(full_path): + os.remove(full_path) + artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.failed + artifact.downloadProgress.eta = 0 + self.selected_bundle.status = custom.ModelManagerSP.DownloadStatus.failed + self._report_status() + # Clean up start time if it exists + self._download_start_times.pop(artifact.fileName, None) + raise + + async def _process_model(self, model, destination_path: str) -> None: + """Processes a single model download including verification""" + model_artifact = model.artifact + metadata_artifact = model.metadata + + await self._process_artifact(metadata_artifact, destination_path) + await self._process_artifact(model_artifact, destination_path) + + def _report_status(self) -> None: + """Reports current status through messaging system""" + msg = messaging.new_message('modelManagerSP', valid=True) + model_manager_state = msg.modelManagerSP + if self.selected_bundle: + model_manager_state.selectedBundle = self.selected_bundle + + if self.active_bundle: + model_manager_state.activeBundle = self.active_bundle + + model_manager_state.availableBundles = self.available_models + self.pm.send('modelManagerSP', msg) + + async def _download_bundle(self, model_bundle: custom.ModelManagerSP.ModelBundle, destination_path: str) -> None: + """Downloads all models in a bundle""" + self.selected_bundle = model_bundle + self.selected_bundle.status = custom.ModelManagerSP.DownloadStatus.downloading + os.makedirs(destination_path, exist_ok=True) + + try: + tasks = [self._process_model(model, destination_path) for model in self.selected_bundle.models] + await asyncio.gather(*tasks) + self.active_bundle = self.selected_bundle + self.active_bundle.status = custom.ModelManagerSP.DownloadStatus.downloaded + self.params.put("ModelManager_ActiveBundle", self.active_bundle.to_dict()) + self.selected_bundle = None + + except Exception: + self.selected_bundle.status = custom.ModelManagerSP.DownloadStatus.failed + raise + + finally: + self._report_status() + + def download(self, model_bundle: custom.ModelManagerSP.ModelBundle, destination_path: str) -> None: + """Main entry point for downloading a model bundle""" + asyncio.run(self._download_bundle(model_bundle, destination_path)) + + def main_thread(self) -> None: + """Main thread for model management""" + rk = Ratekeeper(1, print_delay_threshold=None) + + while True: + try: + self.available_models = self.model_fetcher.get_available_bundles() + self.active_bundle = get_active_bundle(self.params) + + if (index_to_download := self.params.get("ModelManager_DownloadIndex")) is not None: + if model_to_download := next((model for model in self.available_models if model.index == index_to_download), None): + try: + self.download(model_to_download, Paths.model_root()) + except Exception as e: + cloudlog.exception(e) + finally: + self.params.remove("ModelManager_DownloadIndex") + self.selected_bundle = None + + if self.params.get("ModelManager_ClearCache"): + self.clear_model_cache() + self.params.remove("ModelManager_ClearCache") + + self._report_status() + rk.keep_time() + + except Exception as e: + cloudlog.exception(f"Error in main thread: {str(e)}") + rk.keep_time() + + def clear_model_cache(self) -> None: + """ + Clears the model cache directory of all files except those in the active model bundle. + """ + + # Get list of files used by active model bundle + active_files = [] + if self.active_bundle is not None: # When the default model is active + for model in self.active_bundle.models: + if hasattr(model, 'artifact') and model.artifact.fileName: + active_files.append(model.artifact.fileName) + if hasattr(model, 'metadata') and model.metadata.fileName: + active_files.append(model.metadata.fileName) + + # Remove all files except active ones + model_dir = Paths.model_root() + try: + for filename in os.listdir(model_dir): + if filename not in active_files: + file_path = os.path.join(model_dir, filename) + if os.path.isfile(file_path): + os.remove(file_path) + cloudlog.info("Model cache cleared, keeping active model files") + except Exception as e: + cloudlog.exception(f"Error clearing model cache: {str(e)}") + +def main(): + ModelManagerSP().main_thread() + + +if __name__ == "__main__": + main() diff --git a/sunnypilot/models/runners/constants.py b/sunnypilot/models/runners/constants.py new file mode 100644 index 0000000000..acb316888c --- /dev/null +++ b/sunnypilot/models/runners/constants.py @@ -0,0 +1,15 @@ +import os +import numpy as np +from openpilot.system.hardware.hw import Paths +from cereal import custom + +# Type definitions for clarity +NumpyDict = dict[str, np.ndarray] +ShapeDict = dict[str, tuple[int, ...]] +SliceDict = dict[str, slice] + +ModelType = custom.ModelManagerSP.Model.Type +Model = custom.ModelManagerSP.Model + +SEND_RAW_PRED = os.getenv('SEND_RAW_PRED') +CUSTOM_MODEL_PATH = Paths.model_root() diff --git a/sunnypilot/models/runners/helpers.py b/sunnypilot/models/runners/helpers.py new file mode 100644 index 0000000000..b34a62132b --- /dev/null +++ b/sunnypilot/models/runners/helpers.py @@ -0,0 +1,28 @@ +from openpilot.sunnypilot.models.helpers import get_active_bundle +from openpilot.sunnypilot.models.runners.model_runner import ModelRunner +from openpilot.sunnypilot.models.runners.tinygrad.tinygrad_runner import TinygradRunner, TinygradSplitRunner +from openpilot.sunnypilot.models.runners.constants import ModelType + + +def get_model_runner() -> ModelRunner: + """ + Factory function to create and return the appropriate ModelRunner instance. + + Selects TinygradRunner, choosing TinygradSplitRunner if separate vision/policy + models are detected in the active bundle. + + :return: An instance of a ModelRunner subclass (ONNXRunner, TinygradRunner, or TinygradSplitRunner). + """ + bundle = get_active_bundle() + if bundle and bundle.models: + model_types = {m.type.raw for m in bundle.models} + # Check if the bundle uses separate vision and policy models (legacy or new split format) + split_types = {ModelType.vision, ModelType.policy, ModelType.offPolicy, ModelType.onPolicy} + if model_types & split_types: + return TinygradSplitRunner() + # Otherwise, assume a single model (likely supercombo) + if bundle.models: + return TinygradRunner(bundle.models[0].type.raw) + + # Default fallback to TinygradRunner with the supercombo type if bundle info is missing/incomplete + return TinygradRunner(ModelType.supercombo) diff --git a/sunnypilot/models/runners/model_runner.py b/sunnypilot/models/runners/model_runner.py new file mode 100644 index 0000000000..051fa349db --- /dev/null +++ b/sunnypilot/models/runners/model_runner.py @@ -0,0 +1,174 @@ +from abc import abstractmethod, ABC + +import numpy as np +from openpilot.sunnypilot.models.helpers import get_active_bundle +from openpilot.sunnypilot.models.runners.constants import NumpyDict, ShapeDict, Model, SliceDict, SEND_RAW_PRED +from openpilot.system.hardware.hw import Paths +import pickle + +CUSTOM_MODEL_PATH = Paths.model_root() + + +class ModelData: + """ + Stores metadata and configuration for a specific machine learning model. + + This class loads model metadata (like input shapes and output slices) + from a pickle file associated with a model instance. + + :param model: The machine learning model object containing metadata. + """ + def __init__(self, model: Model): + self.model = model + self.metadata = model.metadata + self.input_shapes: ShapeDict = {} + self.output_slices: SliceDict = {} + if self.metadata: + self._load_metadata() + + def _load_metadata(self) -> None: + """Loads input shapes and output slices from the model's metadata pickle file.""" + metadata_path = f"{CUSTOM_MODEL_PATH}/{self.metadata.fileName}" + with open(metadata_path, 'rb') as f: + model_metadata = pickle.load(f) + self.input_shapes = model_metadata.get('input_shapes', {}) + self.output_slices = model_metadata.get('output_slices', {}) + + +class ModularRunner(ABC): + """ + Represents a modular runner for handling and slicing model outputs. + + This abstract base class is designed to provide an interface for modular + parsing and processing of model outputs. Classes inheriting from it must + implement the specified abstract methods, defining how model outputs + should be handled and stored. The primary goal is to enable structured + parsing of outputs through a dictionary-based method mapping. + + :ivar parser_method_dict: Mapping dictionary containing parser methods + for handling specific types of outputs. + :type parser_method_dict: dict + """ + + @property + @abstractmethod + def parser_method_dict(self) -> dict: + pass + + @parser_method_dict.setter + @abstractmethod + def parser_method_dict(self, value: dict) -> None: + pass + + @abstractmethod + def _slice_outputs(self, model_outputs: np.ndarray) -> NumpyDict: + pass + + +class ModelRunner(ModularRunner): + """ + Abstract base class for managing and executing machine learning models. + + Provides a common interface for loading models, preparing inputs, running + inference, and slicing/parsing outputs based on model metadata. Derived + classes implement the specifics of input preparation and model execution + for different frameworks (e.g., Tinygrad, ONNX). + """ + + def __init__(self): + """Initializes the model runner, loading the active model bundle.""" + self.is_20hz: bool | None = None + self.is_20hz_3d: bool | None = None + self.models: dict[int, ModelData] = {} + self._model_data: ModelData | None = None # Active model data for current operation + self._parser_method_dict: dict = {} + self.inputs: dict = {} + self._parser = None + self._load_models() + self._constants = None + + @property + def constants(self): + return self._constants + + @property + def parser_method_dict(self) -> dict: + """Returns the dictionary mapping model types to their respective parsing methods.""" + return self._parser_method_dict + + @parser_method_dict.setter + def parser_method_dict(self, value: dict) -> None: + """Sets the dictionary mapping model types to their respective parsing methods.""" + self._parser_method_dict = value + + def _load_models(self) -> None: + """Loads the active model bundle configuration and sets up ModelData.""" + bundle = get_active_bundle() + if not bundle: + raise ValueError("No active model bundle found, why are we being executed?") + + self.models = {model.type.raw: ModelData(model) for model in bundle.models} + self.is_20hz = bundle.is20hz + self.is_20hz_3d = False + + @property + def input_shapes(self) -> ShapeDict: + """Returns the input shapes for the currently active model.""" + if self._model_data: + return self._model_data.input_shapes + raise ValueError("Model data is not available. Ensure the model is loaded correctly.") + + @property + def output_slices(self) -> SliceDict: + """Returns the output slices for the currently active model.""" + if self._model_data: + return self._model_data.output_slices + raise ValueError("Model data is not available. Ensure the model is loaded correctly.") + + @property + def vision_input_names(self) -> list[str]: + """Returns the list of vision input names from the input shapes.""" + if self._model_data: + return list(self._model_data.input_shapes.keys()) + raise ValueError("Model data is not available. Ensure the model is loaded correctly.") + + @abstractmethod + def prepare_inputs(self, numpy_inputs: NumpyDict) -> dict: + """ + Abstract method to prepare inputs for model inference. + + :param numpy_inputs: Dictionary of numpy arrays for non-image inputs. + :return: Dictionary of prepared inputs ready for the model. + """ + raise NotImplementedError + + @abstractmethod + def _run_model(self) -> NumpyDict: + """ + Abstract method to execute model inference with prepared inputs. + + :return: Dictionary containing the model's raw output arrays. + """ + raise NotImplementedError + + def _slice_outputs(self, model_outputs: np.ndarray) -> NumpyDict: + """ + Slices the raw model output array based on the output_slices metadata. + + :param model_outputs: The raw numpy array output from the model. + :return: A dictionary where keys are output names and values are sliced numpy arrays. + """ + if not self._model_data: + raise ValueError("Model data is not available. Ensure the model is loaded correctly.") + sliced_outputs = {k: model_outputs[np.newaxis, v] for k, v in self._model_data.output_slices.items()} + if SEND_RAW_PRED: + sliced_outputs['raw_pred'] = model_outputs.copy() # Optionally include the full raw output + return sliced_outputs + + def run_model(self) -> NumpyDict: + """ + Executes the model inference pipeline: runs the model and parses outputs. + + :return: Dictionary containing the final parsed model outputs. + """ + return self._run_model() # Parsing is handled within specific runner implementations diff --git a/sunnypilot/models/runners/tinygrad/model_types.py b/sunnypilot/models/runners/tinygrad/model_types.py new file mode 100644 index 0000000000..015adc035f --- /dev/null +++ b/sunnypilot/models/runners/tinygrad/model_types.py @@ -0,0 +1,91 @@ +import os +from abc import ABC + +import numpy as np +from openpilot.sunnypilot.modeld_v2.parse_model_outputs import Parser as CombinedParser +from openpilot.sunnypilot.modeld_v2.parse_model_outputs_split import Parser as SplitParser +from openpilot.sunnypilot.models.runners.constants import ModelType, NumpyDict +from openpilot.sunnypilot.models.runners.model_runner import ModularRunner +from openpilot.system.hardware.hw import Paths + + +SEND_RAW_PRED = os.getenv('SEND_RAW_PRED') +CUSTOM_MODEL_PATH = Paths.model_root() + + +class OffPolicyTinygrad(ModularRunner, ABC): + """ + A TinygradRunner specialized for off-policy models. + + Uses a SplitParser to handle outputs specific to the off-policy part of a split model setup. + """ + def __init__(self): + self._off_policy_parser = SplitParser() + self.parser_method_dict[ModelType.offPolicy] = self._parse_off_policy_outputs + + def _parse_off_policy_outputs(self, model_outputs: np.ndarray) -> NumpyDict: + """Parses off-policy model outputs using SplitParser.""" + result: NumpyDict = self._off_policy_parser.parse_policy_outputs(self._slice_outputs(model_outputs)) + return result + + +class OnPolicyTinygrad(ModularRunner, ABC): + """ + A TinygradRunner specialized for on-policy models. + + Uses a SplitParser to handle outputs specific to the on-policy part of a split model setup. + """ + def __init__(self): + self._on_policy_parser = SplitParser() + self.parser_method_dict[ModelType.onPolicy] = self._parse_on_policy_outputs + + def _parse_on_policy_outputs(self, model_outputs: np.ndarray) -> NumpyDict: + """Parses on-policy model outputs using SplitParser.""" + result: NumpyDict = self._on_policy_parser.parse_policy_outputs(self._slice_outputs(model_outputs)) + return result + + +class PolicyTinygrad(ModularRunner, ABC): + """ + A TinygradRunner specialized for policy-only models. + + Uses a SplitParser to handle outputs specific to the policy part of a split model setup. + """ + def __init__(self): + self._policy_parser = SplitParser() + self.parser_method_dict[ModelType.policy] = self._parse_policy_outputs + + def _parse_policy_outputs(self, model_outputs: np.ndarray) -> NumpyDict: + """Parses policy model outputs using SplitParser.""" + result: NumpyDict = self._policy_parser.parse_policy_outputs(self._slice_outputs(model_outputs)) + return result + +class VisionTinygrad(ModularRunner, ABC): + """ + A TinygradRunner specialized for vision-only models. + + Uses a SplitParser to handle outputs specific to the vision part of a split model setup. + """ + def __init__(self): + self._vision_parser = SplitParser() + self.parser_method_dict[ModelType.vision] = self._parse_vision_outputs + + def _parse_vision_outputs(self, model_outputs: np.ndarray) -> NumpyDict: + """Parses vision model outputs using SplitParser.""" + result: NumpyDict = self._vision_parser.parse_vision_outputs(self._slice_outputs(model_outputs)) + return result + +class SupercomboTinygrad(ModularRunner, ABC): + """ + A TinygradRunner specialized for vision-only models. + + Uses a SplitParser to handle outputs specific to the vision part of a split model setup. + """ + def __init__(self): + self._supercombo_parser = CombinedParser() + self.parser_method_dict[ModelType.supercombo] = self._parse_supercombo_outputs + + def _parse_supercombo_outputs(self, model_outputs: np.ndarray) -> NumpyDict: + """Parses vision model outputs using SplitParser.""" + result: NumpyDict = self._supercombo_parser.parse_outputs(self._slice_outputs(model_outputs)) + return result diff --git a/sunnypilot/models/runners/tinygrad/tinygrad_runner.py b/sunnypilot/models/runners/tinygrad/tinygrad_runner.py new file mode 100644 index 0000000000..4e17bd5ead --- /dev/null +++ b/sunnypilot/models/runners/tinygrad/tinygrad_runner.py @@ -0,0 +1,179 @@ +import pickle + +import numpy as np +from openpilot.sunnypilot.models.runners.constants import NumpyDict, ModelType, ShapeDict, CUSTOM_MODEL_PATH, SliceDict +from openpilot.sunnypilot.models.runners.model_runner import ModelRunner +from openpilot.sunnypilot.models.runners.tinygrad.model_types import PolicyTinygrad, VisionTinygrad, SupercomboTinygrad, OffPolicyTinygrad, OnPolicyTinygrad +from openpilot.sunnypilot.models.split_model_constants import SplitModelConstants +from openpilot.sunnypilot.modeld_v2.constants import ModelConstants + +from tinygrad.tensor import Tensor + + +class TinygradRunner(ModelRunner, SupercomboTinygrad, PolicyTinygrad, VisionTinygrad, OffPolicyTinygrad, OnPolicyTinygrad): + """ + A ModelRunner implementation for executing Tinygrad models. + + Handles loading Tinygrad model artifacts (.pkl), preparing inputs as Tinygrad + Tensors (potentially using QCOM extensions on TICI), running inference, + and parsing the outputs. + + :param model_type: The type of model (e.g., supercombo) to load and run. + """ + def __init__(self, model_type: int = ModelType.supercombo): + ModelRunner.__init__(self) + SupercomboTinygrad.__init__(self) + PolicyTinygrad.__init__(self) + VisionTinygrad.__init__(self) + OffPolicyTinygrad.__init__(self) + OnPolicyTinygrad.__init__(self) + self._constants = ModelConstants + self._model_data = self.models.get(model_type) + if not self._model_data or not self._model_data.model: + raise ValueError(f"Model data for type {model_type} not available.") + + artifact_filename = self._model_data.model.artifact.fileName + assert artifact_filename.endswith('_tinygrad.pkl'), \ + f"Invalid model file {artifact_filename} for TinygradRunner" + + model_pkl_path = f"{CUSTOM_MODEL_PATH}/{artifact_filename}" + with open(model_pkl_path, "rb") as f: + try: + # Load the compiled Tinygrad model runner function + self.model_run = pickle.load(f) + except FileNotFoundError as e: + # Provide a helpful error message if the model was built for a different platform + assert "/dev/kgsl-3d0" not in str(e), "Model was built on C3 or C3X, but is being loaded on PC" + raise + + # Map input names to their required dtype and device from the loaded model + self.input_to_dtype = {} + self.input_to_device = {} + for idx, name in enumerate(self.model_run.captured.expected_names): + info = self.model_run.captured.expected_input_info[idx] + self.input_to_dtype[name] = info[2] # dtype + self.input_to_device[name] = info[3] # device + self._policy_cached = False + + @property + def vision_input_names(self) -> list[str]: + """Returns the list of vision input names from the input shapes.""" + return [name for name in self.input_shapes.keys() if 'img' in name] + + + def prepare_policy_inputs(self, numpy_inputs: NumpyDict): + if not self._policy_cached: + for key, value in numpy_inputs.items(): + self.inputs[key] = Tensor(value, device='NPY').realize() + self._policy_cached = True + + def prepare_inputs(self, numpy_inputs: NumpyDict) -> dict: + """Prepares all vision and policy inputs for the model.""" + self.prepare_policy_inputs(numpy_inputs) + for key in self.vision_input_names: + if key in self.inputs: + self.inputs[key] = self.inputs[key].cast(self.input_to_dtype[key]) + return self.inputs + + def _run_model(self) -> NumpyDict: + """Runs the Tinygrad model inference and parses the outputs.""" + outputs = self.model_run(**self.inputs).contiguous().realize().uop.base.buffer.numpy().flatten() + return self._parse_outputs(outputs) + + def _parse_outputs(self, model_outputs: np.ndarray) -> NumpyDict: + """Parses the raw model outputs using the standard Parser.""" + if self._model_data is None: + raise ValueError("Model data is not available. Ensure the model is loaded correctly.") + + result: NumpyDict = self.parser_method_dict[self._model_data.model.type.raw](model_outputs) + return result + + +class TinygradSplitRunner(ModelRunner): + """ + A ModelRunner that coordinates separate TinygradVisionRunner and TinygradPolicyRunner instances. + + Manages the execution of split vision and policy models, combining their inputs and outputs. + """ + def __init__(self): + super().__init__() + self.is_20hz_3d = True + self.vision_runner = TinygradRunner(ModelType.vision) + self.policy_runner = TinygradRunner(ModelType.policy) if self.models.get(ModelType.policy) else None + self.off_policy_runner = TinygradRunner(ModelType.offPolicy) if self.models.get(ModelType.offPolicy) else None + self.on_policy_runner = TinygradRunner(ModelType.onPolicy) if self.models.get(ModelType.onPolicy) else None + self._constants = SplitModelConstants + + def _run_model(self) -> NumpyDict: + """Runs both vision and policy models and merges their parsed outputs.""" + vision_output = self.vision_runner.run_model() + outputs = {**vision_output} + + if self.policy_runner: + policy_output = self.policy_runner.run_model() + outputs.update(policy_output) + + if self.off_policy_runner: + off_policy_output = self.off_policy_runner.run_model() + if self.on_policy_runner: + off_policy_output.pop('plan', None) + outputs.update(off_policy_output) + + if self.on_policy_runner: + on_policy_output = self.on_policy_runner.run_model() + outputs.update(on_policy_output) + + if 'planplus' in outputs and 'plan' in outputs: + outputs['plan'] = outputs['plan'] + outputs['planplus'] + + return outputs + + @property + def vision_input_names(self) -> list[str]: + """Returns the list of vision input names from the vision runner.""" + return list(self.vision_runner.vision_input_names) + + @property + def input_shapes(self) -> ShapeDict: + """Returns the combined input shapes from both vision and policy models.""" + shapes = {**self.vision_runner.input_shapes} + if self.policy_runner: + shapes.update(self.policy_runner.input_shapes) + if self.off_policy_runner: + shapes.update(self.off_policy_runner.input_shapes) + if self.on_policy_runner: + shapes.update(self.on_policy_runner.input_shapes) + return shapes + + @property + def output_slices(self) -> SliceDict: + """Returns the combined output slices from both vision and policy models.""" + slices = {**self.vision_runner.output_slices} + if self.policy_runner: + slices.update(self.policy_runner.output_slices) + if self.off_policy_runner: + slices.update(self.off_policy_runner.output_slices) + if self.on_policy_runner: + slices.update(self.on_policy_runner.output_slices) + return slices + + def prepare_inputs(self, numpy_inputs: NumpyDict) -> dict: + """Prepares inputs for both vision and policy models.""" + if self.policy_runner: + self.policy_runner.prepare_policy_inputs(numpy_inputs) + + for key in self.vision_input_names: + if key in self.inputs: + self.vision_runner.inputs[key] = self.inputs[key].cast(self.vision_runner.input_to_dtype[key]) + + inputs = {**self.vision_runner.inputs} + if self.policy_runner: + inputs.update(self.policy_runner.inputs) + + if self.off_policy_runner: + self.off_policy_runner.prepare_policy_inputs(numpy_inputs) + inputs.update(self.off_policy_runner.inputs) + if self.on_policy_runner: + self.on_policy_runner.prepare_policy_inputs(numpy_inputs) + inputs.update(self.on_policy_runner.inputs) + return inputs diff --git a/sunnypilot/models/split_model_constants.py b/sunnypilot/models/split_model_constants.py new file mode 100644 index 0000000000..a3e1dce8f6 --- /dev/null +++ b/sunnypilot/models/split_model_constants.py @@ -0,0 +1,94 @@ +import numpy as np + + +def index_function(idx, max_val=192, max_idx=32): + return max_val * ((idx/max_idx)**2) + + +class SplitModelConstants: + # time and distance indices + IDX_N = 33 + T_IDXS = [index_function(idx, max_val=10.0) for idx in range(IDX_N)] + X_IDXS = [index_function(idx, max_val=192.0) for idx in range(IDX_N)] + LEAD_T_IDXS = [0., 2., 4., 6., 8., 10.] + LEAD_T_OFFSETS = [0., 2., 4.] + META_T_IDXS = [2., 4., 6., 8., 10.] + + # model inputs constants + MODEL_FREQ = 20 + HISTORY_FREQ = 5 + HISTORY_LEN_SECONDS = 5 + TEMPORAL_SKIP = MODEL_FREQ // HISTORY_FREQ + FULL_HISTORY_BUFFER_LEN = MODEL_FREQ * HISTORY_LEN_SECONDS + INPUT_HISTORY_BUFFER_LEN = HISTORY_FREQ * HISTORY_LEN_SECONDS + + FEATURE_LEN = 512 + + DESIRE_LEN = 8 + TRAFFIC_CONVENTION_LEN = 2 + LAT_PLANNER_STATE_LEN = 4 + LATERAL_CONTROL_PARAMS_LEN = 2 + PREV_DESIRED_CURV_LEN = 1 + + # model outputs constants + FCW_THRESHOLDS_5MS2 = np.array([.05, .05, .15, .15, .15], dtype=np.float32) + FCW_THRESHOLDS_3MS2 = np.array([.7, .7], dtype=np.float32) + FCW_5MS2_PROBS_WIDTH = 5 + FCW_3MS2_PROBS_WIDTH = 2 + + DISENGAGE_WIDTH = 5 + POSE_WIDTH = 6 + WIDE_FROM_DEVICE_WIDTH = 3 + LEAD_WIDTH = 4 + LANE_LINES_WIDTH = 2 + ROAD_EDGES_WIDTH = 2 + PLAN_WIDTH = 15 + DESIRE_PRED_WIDTH = 8 + LAT_PLANNER_SOLUTION_WIDTH = 4 + DESIRED_CURV_WIDTH = 1 + + NUM_LANE_LINES = 4 + NUM_ROAD_EDGES = 2 + + LEAD_TRAJ_LEN = 6 + DESIRE_PRED_LEN = 4 + + PLAN_MHP_N = 5 + LEAD_MHP_N = 2 + PLAN_MHP_SELECTION = 1 + LEAD_MHP_SELECTION = 3 + + FCW_THRESHOLD_5MS2_HIGH = 0.15 + FCW_THRESHOLD_5MS2_LOW = 0.05 + FCW_THRESHOLD_3MS2 = 0.7 + + CONFIDENCE_BUFFER_LEN = 5 + RYG_GREEN = 0.01165 + RYG_YELLOW = 0.06157 + + POLY_PATH_DEGREE = 4 + + +# model outputs slices +class Plan: + POSITION = slice(0, 3) + VELOCITY = slice(3, 6) + ACCELERATION = slice(6, 9) + T_FROM_CURRENT_EULER = slice(9, 12) + ORIENTATION_RATE = slice(12, 15) + + +class Meta: + ENGAGED = slice(0, 1) + # next 2, 4, 6, 8, 10 seconds + GAS_DISENGAGE = slice(1, 31, 6) + BRAKE_DISENGAGE = slice(2, 31, 6) + STEER_OVERRIDE = slice(3, 31, 6) + HARD_BRAKE_3 = slice(4, 31, 6) + HARD_BRAKE_4 = slice(5, 31, 6) + HARD_BRAKE_5 = slice(6, 31, 6) + # next 0, 2, 4, 6, 8, 10 seconds + GAS_PRESS = slice(31, 55, 4) + BRAKE_PRESS = slice(32, 55, 4) + LEFT_BLINKER = slice(33, 55, 4) + RIGHT_BLINKER = slice(34, 55, 4) diff --git a/sunnypilot/models/tests/__init__.py b/sunnypilot/models/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sunnypilot/models/tests/model_hash b/sunnypilot/models/tests/model_hash new file mode 100644 index 0000000000..3fdf97dd1b --- /dev/null +++ b/sunnypilot/models/tests/model_hash @@ -0,0 +1 @@ +793b5d480edb5a30eed3d0d3bdb43259522978670f6bc3dea7a4d661261d3c48 diff --git a/sunnypilot/models/tests/model_manager_audit.py b/sunnypilot/models/tests/model_manager_audit.py new file mode 100644 index 0000000000..4cd2b7d78e --- /dev/null +++ b/sunnypilot/models/tests/model_manager_audit.py @@ -0,0 +1,20 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" + +from cereal import messaging, custom + +if __name__ == "__main__": + sm = messaging.SubMaster(["modelManagerSP"]) + while True: + sm.update(500) + if sm.updated: + msg = sm["modelManagerSP"] + for model in msg.selectedBundle.models: + if model.downloadProgress.status == custom.ModelManagerSP.DownloadStatus.downloading: + print("") + print(f"{model.fileName}: {model.downloadProgress}") + print("") diff --git a/sunnypilot/models/tests/test_default_model.py b/sunnypilot/models/tests/test_default_model.py new file mode 100644 index 0000000000..abe685c36a --- /dev/null +++ b/sunnypilot/models/tests/test_default_model.py @@ -0,0 +1,24 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" + +from openpilot.sunnypilot import get_file_hash +from openpilot.sunnypilot.models.default_model import MODEL_HASH_PATH, VISION_ONNX_PATH, OFF_POLICY_ONNX_PATH, ON_POLICY_ONNX_PATH +import hashlib + + +class TestDefaultModel: + def test_compare_onnx_hashes(self): + vision_hash = get_file_hash(VISION_ONNX_PATH) + off_policy_hash = get_file_hash(OFF_POLICY_ONNX_PATH) + on_policy_hash = get_file_hash(ON_POLICY_ONNX_PATH) + + combined_hash = hashlib.sha256((vision_hash + off_policy_hash + on_policy_hash).encode()).hexdigest() + + with open(MODEL_HASH_PATH) as f: + current_hash = f.read().strip() + + assert combined_hash == current_hash, "Run sunnypilot/models/default_model.py to update the default model name and hash" diff --git a/sunnypilot/models/tests/test_tinygrad_ref.py b/sunnypilot/models/tests/test_tinygrad_ref.py new file mode 100644 index 0000000000..3e60ab5308 --- /dev/null +++ b/sunnypilot/models/tests/test_tinygrad_ref.py @@ -0,0 +1,23 @@ +import requests + +from openpilot.sunnypilot.models.tinygrad_ref import get_tinygrad_ref +from openpilot.sunnypilot.models.fetcher import ModelFetcher + + +def fetch_tinygrad_ref(): + response = requests.get(ModelFetcher.MODEL_URL, timeout=10) + response.raise_for_status() + json_data = response.json() + return json_data.get("tinygrad_ref") + + +def test_tinygrad_ref(): + current_ref = get_tinygrad_ref() + remote_ref = fetch_tinygrad_ref() + assert remote_ref == current_ref, ( + f"""tinygrad_repo ref does not match remote tinygrad_ref of current compiled driving models json. + Current: {current_ref} + Remote: {remote_ref} + Please run build-all workflow to update models.""" + ) + print("tinygrad_repo ref matches current compiled driving models json ref.") diff --git a/sunnypilot/models/tinygrad_ref.py b/sunnypilot/models/tinygrad_ref.py new file mode 100644 index 0000000000..4dd333e323 --- /dev/null +++ b/sunnypilot/models/tinygrad_ref.py @@ -0,0 +1,36 @@ +import os + +from openpilot.common.basedir import BASEDIR + + +def get_tinygrad_ref(): + repo_path = os.path.join(BASEDIR, "tinygrad_repo") + git_path = os.path.join(repo_path, ".git") + try: + if os.path.isdir(git_path): + git_dir = git_path + else: + with open(git_path) as f: + line = f.read().strip() + git_dir = os.path.join(repo_path, line[8:]) + with open(os.path.join(git_dir, "HEAD")) as f: + ref = f.read().strip() + if ref.startswith("ref:"): + with open(os.path.join(git_dir, ref.split(" ", 1)[1])) as f: + return f.read().strip() + return ref + except Exception as e: + print(f"Error getting tinygrad_repo ref: {e}") + return None + + +def main(): + current_ref = get_tinygrad_ref() + if current_ref: + print(current_ref) + else: + print("") + + +if __name__ == "__main__": + main() diff --git a/sunnypilot/navd/helpers.py b/sunnypilot/navd/helpers.py new file mode 100644 index 0000000000..c57706d32a --- /dev/null +++ b/sunnypilot/navd/helpers.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +import json +import math +import numpy as np +from typing import Any, cast + +from openpilot.common.constants import CV +from openpilot.common.params import Params + +DIRECTIONS = ('left', 'right', 'straight') +MODIFIABLE_DIRECTIONS = ('left', 'right') + +EARTH_MEAN_RADIUS = 6371007.2 +SPEED_CONVERSIONS = { + 'km/h': CV.KPH_TO_MS, + 'mph': CV.MPH_TO_MS, +} + + +class Coordinate: + def __init__(self, latitude: float, longitude: float) -> None: + self.latitude = latitude + self.longitude = longitude + self.annotations: dict[str, float] = {} + + @classmethod + def from_mapbox_tuple(cls, t: tuple[float, float]) -> Coordinate: + return cls(t[1], t[0]) + + def as_dict(self) -> dict[str, float]: + return {'latitude': self.latitude, 'longitude': self.longitude} + + def __str__(self) -> str: + return f'Coordinate({self.latitude}, {self.longitude})' + + def __repr__(self) -> str: + return self.__str__() + + def __eq__(self, other) -> bool: + if not isinstance(other, Coordinate): + return False + return (self.latitude == other.latitude) and (self.longitude == other.longitude) + + def __sub__(self, other: Coordinate) -> Coordinate: + return Coordinate(self.latitude - other.latitude, self.longitude - other.longitude) + + def __add__(self, other: Coordinate) -> Coordinate: + return Coordinate(self.latitude + other.latitude, self.longitude + other.longitude) + + def __mul__(self, c: float) -> Coordinate: + return Coordinate(self.latitude * c, self.longitude * c) + + def dot(self, other: Coordinate) -> float: + return self.latitude * other.latitude + self.longitude * other.longitude + + def distance_to(self, other: Coordinate) -> float: + # Haversine formula + dlat = math.radians(other.latitude - self.latitude) + dlon = math.radians(other.longitude - self.longitude) + + haversine_dlat = math.sin(dlat / 2.0) + haversine_dlat *= haversine_dlat + haversine_dlon = math.sin(dlon / 2.0) + haversine_dlon *= haversine_dlon + + y = haversine_dlat \ + + math.cos(math.radians(self.latitude)) \ + * math.cos(math.radians(other.latitude)) \ + * haversine_dlon + x = 2 * math.asin(math.sqrt(y)) + return x * EARTH_MEAN_RADIUS + + +def minimum_distance(a: Coordinate, b: Coordinate, p: Coordinate): + if a.distance_to(b) < 0.01: + return a.distance_to(p) + + ap = p - a + ab = b - a + t = np.clip(ap.dot(ab) / ab.dot(ab), 0.0, 1.0) + projection = a + ab * t + return projection.distance_to(p) + + +def distance_along_geometry(geometry: list[Coordinate], pos: Coordinate) -> float: + if len(geometry) <= 2: + return geometry[0].distance_to(pos) + + # 1. Find segment that is closest to current position + # 2. Total distance is sum of distance to start of closest segment + # + all previous segments + total_distance = 0.0 + total_distance_closest = 0.0 + closest_distance = 1e9 + + for i in range(len(geometry) - 1): + d = minimum_distance(geometry[i], geometry[i + 1], pos) + + if d < closest_distance: + closest_distance = d + total_distance_closest = total_distance + geometry[i].distance_to(pos) + + total_distance += geometry[i].distance_to(geometry[i + 1]) + + return total_distance_closest + + +def coordinate_from_param(param: str, params: Params = None) -> Coordinate | None: + if params is None: + params = Params() + + json_str = params.get(param) + if json_str is None: + return None + + pos = json.loads(json_str) + if 'latitude' not in pos or 'longitude' not in pos: + return None + + return Coordinate(pos['latitude'], pos['longitude']) + + +def string_to_direction(direction: str) -> str: + for d in DIRECTIONS: + if d in direction: + if 'slight' in direction and d in MODIFIABLE_DIRECTIONS: + return 'slight' + d.capitalize() + return d + return 'none' + + +def maxspeed_to_ms(maxspeed: dict[str, str | float]) -> float: + unit = cast(str, maxspeed['unit']) + speed = cast(float, maxspeed['speed']) + return float(SPEED_CONVERSIONS[unit] * speed) + + +def field_valid(dat: dict, field: str) -> bool: + return field in dat and dat[field] is not None + + +def parse_banner_instructions(banners: Any, distance_to_maneuver: float = 0.0) -> dict[str, Any] | None: + if not len(banners): + return None + + instruction = {} + + # A segment can contain multiple banners, find one that we need to show now + current_banner = banners[0] + for banner in banners: + if distance_to_maneuver < banner['distanceAlongGeometry']: + current_banner = banner + + # Only show banner when close enough to maneuver + instruction['showFull'] = distance_to_maneuver < current_banner['distanceAlongGeometry'] + + # Primary + p = current_banner['primary'] + if field_valid(p, 'text'): + instruction['maneuverPrimaryText'] = p['text'] + if field_valid(p, 'type'): + instruction['maneuverType'] = p['type'] + if field_valid(p, 'modifier'): + instruction['maneuverModifier'] = p['modifier'] + + # Secondary + if field_valid(current_banner, 'secondary'): + instruction['maneuverSecondaryText'] = current_banner['secondary']['text'] + + # Lane lines + if field_valid(current_banner, 'sub'): + lanes = [] + for component in current_banner['sub']['components']: + if component['type'] != 'lane': + continue + + lane = { + 'active': component['active'], + 'directions': [string_to_direction(d) for d in component['directions']], + } + + if field_valid(component, 'active_direction'): + lane['activeDirection'] = string_to_direction(component['active_direction']) + + lanes.append(lane) + instruction['lanes'] = lanes + + return instruction diff --git a/sunnypilot/neural_network_data b/sunnypilot/neural_network_data new file mode 160000 index 0000000000..03cac2d30e --- /dev/null +++ b/sunnypilot/neural_network_data @@ -0,0 +1 @@ +Subproject commit 03cac2d30e111e0689c0429cb8c1fe6cb5a905af diff --git a/sunnypilot/selfdrive/__init__.py b/sunnypilot/selfdrive/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sunnypilot/selfdrive/assets/icons/clock.png b/sunnypilot/selfdrive/assets/icons/clock.png new file mode 100644 index 0000000000..e04d1db949 --- /dev/null +++ b/sunnypilot/selfdrive/assets/icons/clock.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e095cfc4de71788bd4a99699a2e7ab4098cd426277d672b9e43981c5fab8b40f +size 16407 diff --git a/sunnypilot/selfdrive/assets/icons/star-empty.png b/sunnypilot/selfdrive/assets/icons/star-empty.png new file mode 100644 index 0000000000..bf60dec374 --- /dev/null +++ b/sunnypilot/selfdrive/assets/icons/star-empty.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3731604f80e83a1fdb7c258baf6530b81190eeec82e6443172e84a35b7a74c02 +size 1088 diff --git a/sunnypilot/selfdrive/assets/icons/star-filled.png b/sunnypilot/selfdrive/assets/icons/star-filled.png new file mode 100644 index 0000000000..3667231bf0 --- /dev/null +++ b/sunnypilot/selfdrive/assets/icons/star-filled.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0c2a513b7f2da004f145b7d689654cc65137f1b146d484fbce7ce727a297b62c +size 861 diff --git a/sunnypilot/selfdrive/assets/images/green_light.png b/sunnypilot/selfdrive/assets/images/green_light.png new file mode 100644 index 0000000000..2da2c13a82 --- /dev/null +++ b/sunnypilot/selfdrive/assets/images/green_light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3aa5ec9ac1daee6a549e62647d90bcaa66d2485f7df7f386ff902fcfb04c1716 +size 6583 diff --git a/sunnypilot/selfdrive/assets/images/lead_depart.png b/sunnypilot/selfdrive/assets/images/lead_depart.png new file mode 100644 index 0000000000..6030ee67cf --- /dev/null +++ b/sunnypilot/selfdrive/assets/images/lead_depart.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:087db35bd469e85aefe3b45636f11ab3e8b55ceb7bc94ea059cfd9a69c2f338f +size 8914 diff --git a/sunnypilot/selfdrive/assets/images/spinner_sunnypilot.png b/sunnypilot/selfdrive/assets/images/spinner_sunnypilot.png new file mode 100644 index 0000000000..edc5c6e5c2 --- /dev/null +++ b/sunnypilot/selfdrive/assets/images/spinner_sunnypilot.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9dbacb55581a5ea8cbcd5cea0560ec21d96ac7185463a40fff0f81bebf044ffa +size 23187 diff --git a/sunnypilot/selfdrive/assets/img_minus_arrow_down.png b/sunnypilot/selfdrive/assets/img_minus_arrow_down.png new file mode 100644 index 0000000000..2dc99789a5 --- /dev/null +++ b/sunnypilot/selfdrive/assets/img_minus_arrow_down.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ad42ecaeff96a0a6c1a6db67b01e3c0452566906dd3a7f0336a1010ae854d27b +size 60924 diff --git a/sunnypilot/selfdrive/assets/img_plus_arrow_up.png b/sunnypilot/selfdrive/assets/img_plus_arrow_up.png new file mode 100644 index 0000000000..4247827340 --- /dev/null +++ b/sunnypilot/selfdrive/assets/img_plus_arrow_up.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1fdea39873f60a0c4b216b75792e826f3633091a72ea2abd05740bd6e4e72089 +size 63058 diff --git a/sunnypilot/selfdrive/assets/logo.png b/sunnypilot/selfdrive/assets/logo.png new file mode 100644 index 0000000000..690cf7fb70 --- /dev/null +++ b/sunnypilot/selfdrive/assets/logo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:66b3aefa108dd0c7f64205a11e424430c318e6fd06de31b5550d0b9d05616e6a +size 19035 diff --git a/sunnypilot/selfdrive/assets/offroad/icon_display.png b/sunnypilot/selfdrive/assets/offroad/icon_display.png new file mode 100644 index 0000000000..547977f522 --- /dev/null +++ b/sunnypilot/selfdrive/assets/offroad/icon_display.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:614767308d435d165a9ab78e3eac22ee15697d94066df8200ef32afb33ef8d60 +size 5698 diff --git a/sunnypilot/selfdrive/assets/offroad/icon_exit_offroad.png b/sunnypilot/selfdrive/assets/offroad/icon_exit_offroad.png new file mode 100644 index 0000000000..1b8308a0ac --- /dev/null +++ b/sunnypilot/selfdrive/assets/offroad/icon_exit_offroad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d44f71f7603b106986fff7835f35c4c18c4c619a29e6292f8626ae3eedd85e57 +size 7811 diff --git a/sunnypilot/selfdrive/assets/offroad/icon_firehose.png b/sunnypilot/selfdrive/assets/offroad/icon_firehose.png new file mode 100644 index 0000000000..d579937f3f --- /dev/null +++ b/sunnypilot/selfdrive/assets/offroad/icon_firehose.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a258a022f0ab90368327d899ed4fb85b47dd0e35c93508e35851d9c3184528c0 +size 36382 diff --git a/sunnypilot/selfdrive/assets/offroad/icon_firehose.svg b/sunnypilot/selfdrive/assets/offroad/icon_firehose.svg new file mode 100644 index 0000000000..9e5353da30 --- /dev/null +++ b/sunnypilot/selfdrive/assets/offroad/icon_firehose.svg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fefe69ef501c2c18ddba6d946ac0646c9b07d408ee03bcebbf2e91195b6ccb76 +size 1914 diff --git a/sunnypilot/selfdrive/assets/offroad/icon_home.png b/sunnypilot/selfdrive/assets/offroad/icon_home.png new file mode 100644 index 0000000000..1229af846d --- /dev/null +++ b/sunnypilot/selfdrive/assets/offroad/icon_home.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8623dbc5c7dd043a91d98777cb423cfd116014ed6390af6d7d00c1f8dea3c6e8 +size 1181 diff --git a/sunnypilot/selfdrive/assets/offroad/icon_home.svg b/sunnypilot/selfdrive/assets/offroad/icon_home.svg new file mode 100644 index 0000000000..ca90cc7bf6 --- /dev/null +++ b/sunnypilot/selfdrive/assets/offroad/icon_home.svg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1673f8d46251a05787b60346193852991739345506dc7e9b106dfb370d3611ed +size 489 diff --git a/sunnypilot/selfdrive/assets/offroad/icon_lateral.png b/sunnypilot/selfdrive/assets/offroad/icon_lateral.png new file mode 100644 index 0000000000..06dbfda947 --- /dev/null +++ b/sunnypilot/selfdrive/assets/offroad/icon_lateral.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f993debd55727b9ad0c726b8a0d5ec76b146ee3f296b30eba8e53d61b33b24fd +size 21750 diff --git a/sunnypilot/selfdrive/assets/offroad/icon_map.png b/sunnypilot/selfdrive/assets/offroad/icon_map.png new file mode 100644 index 0000000000..82c0236a48 --- /dev/null +++ b/sunnypilot/selfdrive/assets/offroad/icon_map.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:57a92adcf88c7223b07697f8c2b315f4f4a34b32a866284610d5250144863c6f +size 28235 diff --git a/sunnypilot/selfdrive/assets/offroad/icon_models.png b/sunnypilot/selfdrive/assets/offroad/icon_models.png new file mode 100644 index 0000000000..0131759703 --- /dev/null +++ b/sunnypilot/selfdrive/assets/offroad/icon_models.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d9b431c274a55437b5a65ada7c85503e137bc2ffa8917510ad9affb14a407d82 +size 14366 diff --git a/sunnypilot/selfdrive/assets/offroad/icon_software.png b/sunnypilot/selfdrive/assets/offroad/icon_software.png new file mode 100644 index 0000000000..70915e2906 --- /dev/null +++ b/sunnypilot/selfdrive/assets/offroad/icon_software.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:01fd38c0d5b05c3ce4ed07bfe4cd47a3ea61f68f24e6cc3058190d90d8200f9b +size 4785 diff --git a/sunnypilot/selfdrive/assets/offroad/icon_toggle.png b/sunnypilot/selfdrive/assets/offroad/icon_toggle.png new file mode 100644 index 0000000000..51906798b9 --- /dev/null +++ b/sunnypilot/selfdrive/assets/offroad/icon_toggle.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:11336d2f65100bc0dc2a53e09efae41ae85817b27e1a5bf134cc995e631c5b52 +size 4073 diff --git a/sunnypilot/selfdrive/assets/offroad/icon_trips.png b/sunnypilot/selfdrive/assets/offroad/icon_trips.png new file mode 100644 index 0000000000..95dec05c29 --- /dev/null +++ b/sunnypilot/selfdrive/assets/offroad/icon_trips.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f8e5ba807427b7533f513fdd9ca270e420f3ccb63b2182203ed71453449c127a +size 5354 diff --git a/sunnypilot/selfdrive/assets/offroad/icon_vehicle.png b/sunnypilot/selfdrive/assets/offroad/icon_vehicle.png new file mode 100644 index 0000000000..4c036d9602 --- /dev/null +++ b/sunnypilot/selfdrive/assets/offroad/icon_vehicle.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3d7726f7c6a8106c767ae974ce7395a399dbf0a934779a7710b748917c29cf6e +size 11365 diff --git a/sunnypilot/selfdrive/assets/offroad/icon_visuals.png b/sunnypilot/selfdrive/assets/offroad/icon_visuals.png new file mode 100644 index 0000000000..3cc9c3b145 --- /dev/null +++ b/sunnypilot/selfdrive/assets/offroad/icon_visuals.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3ecaac6fb687fcab3300d1fe2dfea96fb49734d634a663e2c02ee0b26ebd773e +size 20632 diff --git a/sunnypilot/selfdrive/car/__init__.py b/sunnypilot/selfdrive/car/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sunnypilot/selfdrive/car/car_list.json b/sunnypilot/selfdrive/car/car_list.json new file mode 120000 index 0000000000..a4ae7b75f9 --- /dev/null +++ b/sunnypilot/selfdrive/car/car_list.json @@ -0,0 +1 @@ +../../../opendbc_repo/opendbc/sunnypilot/car/car_list.json \ No newline at end of file diff --git a/sunnypilot/selfdrive/car/car_specific.py b/sunnypilot/selfdrive/car/car_specific.py new file mode 100644 index 0000000000..bce496ed28 --- /dev/null +++ b/sunnypilot/selfdrive/car/car_specific.py @@ -0,0 +1,51 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" + +from cereal import log, custom +from opendbc.car import structs + +from opendbc.car.chrysler.values import RAM_DT +from openpilot.selfdrive.selfdrived.events import Events +from openpilot.sunnypilot.selfdrive.selfdrived.events import EventsSP + +EventName = log.OnroadEvent.EventName +EventNameSP = custom.OnroadEventSP.EventName +GearShifter = structs.CarState.GearShifter + + +class CarSpecificEventsSP: + def __init__(self, CP: structs.CarParams, CP_SP: structs.CarParamsSP): + self.CP = CP + self.CP_SP = CP_SP + + self.low_speed_alert = False + + def update(self, CS: structs.CarState, events: Events): + events_sp = EventsSP() + + if self.CP.brand == 'chrysler': + if self.CP.carFingerprint in RAM_DT: + # remove belowSteerSpeed event from CarSpecificEvents as RAM_DT uses a different logic + if events.has(EventName.belowSteerSpeed): + events.remove(EventName.belowSteerSpeed) + + # TODO-SP: use if/elif to have the gear shifter condition takes precedence over the speed condition + # TODO-SP: add 1 m/s hysteresis + if CS.vEgo >= self.CP.minEnableSpeed: + self.low_speed_alert = False + if self.CP.minEnableSpeed >= 14.5 and CS.gearShifter != GearShifter.drive: + self.low_speed_alert = True + if self.low_speed_alert: + events.add(EventName.belowSteerSpeed) + + elif self.CP.brand == 'toyota': + if self.CP.openpilotLongitudinalControl: + if CS.cruiseState.standstill and not CS.brakePressed and self.CP_SP.enableGasInterceptor: + if events.has(EventName.resumeRequired): + events.remove(EventName.resumeRequired) + + return events_sp diff --git a/sunnypilot/selfdrive/car/cruise_ext.py b/sunnypilot/selfdrive/car/cruise_ext.py new file mode 100644 index 0000000000..3691c35972 --- /dev/null +++ b/sunnypilot/selfdrive/car/cruise_ext.py @@ -0,0 +1,138 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import numpy as np + +from cereal import car, custom +from opendbc.car import structs +from openpilot.common.constants import CV +from openpilot.common.params import Params +from openpilot.sunnypilot.selfdrive.car.intelligent_cruise_button_management.helpers import get_minimum_set_speed +from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.speed_limit_assist import ACTIVE_STATES as SLA_ACTIVE_STATES +from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.helpers import compare_cluster_target + +ButtonType = car.CarState.ButtonEvent.Type +SpeedLimitAssistState = custom.LongitudinalPlanSP.SpeedLimit.AssistState + +CRUISE_BUTTON_TIMER = {ButtonType.decelCruise: 0, ButtonType.accelCruise: 0, + ButtonType.setCruise: 0, ButtonType.resumeCruise: 0, + ButtonType.cancel: 0, ButtonType.mainCruise: 0} + +V_CRUISE_MIN = 8 +V_CRUISE_MAX = 145 +V_CRUISE_UNSET = 255 + + +def update_manual_button_timers(CS: car.CarState, button_timers: dict[car.CarState.ButtonEvent.Type, int]) -> None: + # increment timer for buttons still pressed + for k in button_timers: + if button_timers[k] > 0: + button_timers[k] += 1 + + for b in CS.buttonEvents: + if b.type.raw in button_timers: + # Start/end timer and store current state on change of button pressed + button_timers[b.type.raw] = 1 if b.pressed else 0 + + +class VCruiseHelperSP: + def __init__(self, CP: structs.CarParams, CP_SP: structs.CarParamsSP) -> None: + self.CP = CP + self.CP_SP = CP_SP + self.v_cruise_kph = V_CRUISE_UNSET + self.v_cruise_cluster_kph = V_CRUISE_UNSET + self.params = Params() + self.v_cruise_min = 0 + self.enabled_prev = False + + self.custom_acc_enabled = self.params.get_bool("CustomAccIncrementsEnabled") + self.short_increment = self.params.get("CustomAccShortPressIncrement", return_default=True) + self.long_increment = self.params.get("CustomAccLongPressIncrement", return_default=True) + + self.enable_button_timers = CRUISE_BUTTON_TIMER + + # Speed Limit Assist + self.sla_state = SpeedLimitAssistState.disabled + self.prev_sla_state = SpeedLimitAssistState.disabled + self.has_speed_limit = False + self.speed_limit_final_last = 0. + self.speed_limit_final_last_kph = 0. + self.prev_speed_limit_final_last_kph = 0. + self.req_plus = False + self.req_minus = False + + def read_custom_set_speed_params(self) -> None: + self.custom_acc_enabled = self.params.get_bool("CustomAccIncrementsEnabled") + self.short_increment = self.params.get("CustomAccShortPressIncrement", return_default=True) + self.long_increment = self.params.get("CustomAccLongPressIncrement", return_default=True) + + def update_v_cruise_delta(self, long_press: bool, v_cruise_delta: float) -> tuple[bool, float]: + if not self.custom_acc_enabled: + v_cruise_delta = v_cruise_delta * (5 if long_press else 1) + return long_press, v_cruise_delta + + # Apply user-specified multipliers to the base increment + short_increment = np.clip(self.short_increment, 1, 10) + long_increment = np.clip(self.long_increment, 1, 10) + + actual_increment = long_increment if long_press else short_increment + round_to_nearest = actual_increment in (5, 10) + v_cruise_delta = v_cruise_delta * actual_increment + + return round_to_nearest, v_cruise_delta + + def get_minimum_set_speed(self, is_metric: bool) -> None: + if self.CP_SP.pcmCruiseSpeed: + self.v_cruise_min = V_CRUISE_MIN + return + + self.v_cruise_min = get_minimum_set_speed(is_metric) + + def update_enabled_state(self, CS: car.CarState, enabled: bool) -> bool: + # special enabled state for non pcmCruiseSpeed, unchanged for non pcmCruise + if not self.CP_SP.pcmCruiseSpeed: + update_manual_button_timers(CS, self.enable_button_timers) + button_pressed = any(self.enable_button_timers[k] > 0 for k in self.enable_button_timers) + + if enabled and not self.enabled_prev: + self.enabled_prev = not button_pressed + enabled = False + elif not enabled: + self.enabled_prev = enabled + + return enabled and self.enabled_prev + + return enabled + + def update_speed_limit_assist(self, is_metric, LP_SP: custom.LongitudinalPlanSP) -> None: + resolver = LP_SP.speedLimit.resolver + self.has_speed_limit = resolver.speedLimitValid or resolver.speedLimitLastValid + self.speed_limit_final_last = LP_SP.speedLimit.resolver.speedLimitFinalLast + self.speed_limit_final_last_kph = self.speed_limit_final_last * CV.MS_TO_KPH + self.sla_state = LP_SP.speedLimit.assist.state + self.req_plus, self.req_minus = compare_cluster_target(self.v_cruise_cluster_kph * CV.KPH_TO_MS, + self.speed_limit_final_last, is_metric) + + @property + def update_speed_limit_final_last_changed(self) -> bool: + return self.has_speed_limit and bool(self.speed_limit_final_last_kph != self.prev_speed_limit_final_last_kph) + + def update_speed_limit_assist_pre_active_confirmed(self, button_type: car.CarState.ButtonEvent.Type) -> bool: + if self.sla_state == SpeedLimitAssistState.preActive or self.prev_sla_state == SpeedLimitAssistState.preActive: + if button_type == ButtonType.decelCruise and self.req_minus: + return True + if button_type == ButtonType.accelCruise and self.req_plus: + return True + + return False + + def update_speed_limit_assist_v_cruise_non_pcm(self) -> None: + if self.sla_state in SLA_ACTIVE_STATES and (self.prev_sla_state not in SLA_ACTIVE_STATES or + self.update_speed_limit_final_last_changed): + self.v_cruise_kph = np.clip(round(self.speed_limit_final_last_kph, 1), self.v_cruise_min, V_CRUISE_MAX) + + self.prev_sla_state = self.sla_state + self.prev_speed_limit_final_last_kph = self.speed_limit_final_last_kph diff --git a/sunnypilot/selfdrive/car/cruise_helpers.py b/sunnypilot/selfdrive/car/cruise_helpers.py new file mode 100644 index 0000000000..1c0026e12f --- /dev/null +++ b/sunnypilot/selfdrive/car/cruise_helpers.py @@ -0,0 +1,50 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" + +from cereal import car, custom +from opendbc.car import structs +from openpilot.common.params import Params + +ButtonType = car.CarState.ButtonEvent.Type +EventNameSP = custom.OnroadEventSP.EventName + +DISTANCE_LONG_PRESS = 50 + + +class CruiseHelper: + def __init__(self, CP: structs.CarParams): + self.CP = CP + self.params = Params() + + self.button_frame_counts = {ButtonType.gapAdjustCruise: 0} + self._experimental_mode = False + self.experimental_mode_switched = False + + def update(self, CS, events, experimental_mode) -> None: + if self.CP.openpilotLongitudinalControl: + if CS.cruiseState.available: + self.update_button_frame_counts(CS) + + # toggle experimental mode once on distance button hold + self.update_experimental_mode(events, experimental_mode) + + def update_button_frame_counts(self, CS) -> None: + for button in self.button_frame_counts: + if self.button_frame_counts[button] > 0: + self.button_frame_counts[button] += 1 + + for button_event in CS.buttonEvents: + button = button_event.type.raw + if button in self.button_frame_counts: + self.button_frame_counts[button] = int(button_event.pressed) + + def update_experimental_mode(self, events, experimental_mode) -> None: + if self.button_frame_counts[ButtonType.gapAdjustCruise] >= DISTANCE_LONG_PRESS and not self.experimental_mode_switched: + self._experimental_mode = not experimental_mode + self.params.put_bool_nonblocking("ExperimentalMode", self._experimental_mode) + events.add(EventNameSP.experimentalModeSwitched) + self.experimental_mode_switched = True diff --git a/sunnypilot/selfdrive/car/intelligent_cruise_button_management/__init__.py b/sunnypilot/selfdrive/car/intelligent_cruise_button_management/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sunnypilot/selfdrive/car/intelligent_cruise_button_management/controller.py b/sunnypilot/selfdrive/car/intelligent_cruise_button_management/controller.py new file mode 100644 index 0000000000..1f491e0f58 --- /dev/null +++ b/sunnypilot/selfdrive/car/intelligent_cruise_button_management/controller.py @@ -0,0 +1,127 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from cereal import car, custom +from opendbc.car import structs, apply_hysteresis +from openpilot.common.constants import CV +from openpilot.common.realtime import DT_CTRL +from openpilot.sunnypilot.selfdrive.car.intelligent_cruise_button_management.helpers import get_minimum_set_speed +from openpilot.sunnypilot.selfdrive.car.cruise_ext import CRUISE_BUTTON_TIMER, update_manual_button_timers + +LongitudinalPlanSource = custom.LongitudinalPlanSP.LongitudinalPlanSource +State = custom.IntelligentCruiseButtonManagement.IntelligentCruiseButtonManagementState +SendButtonState = custom.IntelligentCruiseButtonManagement.SendButtonState + +ALLOWED_SPEED_THRESHOLD = 1.8 # m/s, ~4 MPH +HYST_GAP = 0.0 # currently disabled; TODO-SP: might need to be brand-specific +INACTIVE_TIMER = 0.4 + + +SEND_BUTTONS = { + State.increasing: SendButtonState.increase, + State.decreasing: SendButtonState.decrease, +} + + +class IntelligentCruiseButtonManagement: + def __init__(self, CP: structs.CarParams, CP_SP: structs.CarParamsSP): + self.CP = CP + self.CP_SP = CP_SP + + self.v_target = 0 + self.v_cruise_cluster = 0 + self.v_cruise_min = 0 + self.cruise_button = SendButtonState.none + self.state = State.inactive + self.pre_active_timer = 0 + + self.is_ready = False + self.is_ready_prev = False + self.v_target_ms_last = 0.0 + self.is_metric = False + + self.cruise_button_timers = CRUISE_BUTTON_TIMER + + @property + def v_cruise_equal(self) -> bool: + return self.v_target == self.v_cruise_cluster + + def update_calculations(self, CS: car.CarState, LP_SP: custom.LongitudinalPlanSP) -> None: + speed_conv = CV.MS_TO_KPH if self.is_metric else CV.MS_TO_MPH + ms_conv = CV.KPH_TO_MS if self.is_metric else CV.MPH_TO_MS + + self.v_target_ms_last = apply_hysteresis(LP_SP.vTarget, self.v_target_ms_last, HYST_GAP * ms_conv) + + self.v_target = round(self.v_target_ms_last * speed_conv) + self.v_cruise_min = get_minimum_set_speed(self.is_metric) + self.v_cruise_cluster = round(CS.cruiseState.speedCluster * speed_conv) + + def update_state_machine(self) -> custom.IntelligentCruiseButtonManagement.SendButtonState: + self.pre_active_timer = max(0, self.pre_active_timer - 1) + + # HOLDING, ACCELERATING, DECELERATING, PRE_ACTIVE + if self.state != State.inactive: + if not self.is_ready: + self.state = State.inactive + + else: + # PRE_ACTIVE + if self.state == State.preActive: + if self.pre_active_timer <= 0: + if self.v_cruise_equal: + self.state = State.holding + + elif self.v_target > self.v_cruise_cluster: + self.state = State.increasing + + elif self.v_target < self.v_cruise_cluster and self.v_cruise_cluster > self.v_cruise_min: + self.state = State.decreasing + + # HOLDING + elif self.state == State.holding: + if not self.v_cruise_equal: + self.state = State.preActive + + # ACCELERATING + elif self.state == State.increasing: + if self.v_target <= self.v_cruise_cluster: + self.state = State.holding + + # DECELERATING + elif self.state == State.decreasing: + if self.v_target >= self.v_cruise_cluster or self.v_cruise_cluster <= self.v_cruise_min: + self.state = State.holding + + # INACTIVE + elif self.state == State.inactive: + if self.is_ready and not self.is_ready_prev: + self.pre_active_timer = int(INACTIVE_TIMER / DT_CTRL) + self.state = State.preActive + + send_button = SEND_BUTTONS.get(self.state, SendButtonState.none) + + return send_button + + def update_readiness(self, CS: car.CarState, CC: car.CarControl) -> None: + update_manual_button_timers(CS, self.cruise_button_timers) + + ready = CC.enabled and not CC.cruiseControl.override and not CC.cruiseControl.cancel and not CC.cruiseControl.resume + button_pressed = any(self.cruise_button_timers[k] > 0 for k in self.cruise_button_timers) + + self.is_ready = ready and not button_pressed + + def run(self, CS: car.CarState, CC: car.CarControl, LP_SP: custom.LongitudinalPlanSP, is_metric: bool) -> None: + if self.CP_SP.pcmCruiseSpeed: + return + + self.is_metric = is_metric + + self.update_calculations(CS, LP_SP) + self.update_readiness(CS, CC) + + self.cruise_button = self.update_state_machine() + + self.is_ready_prev = self.is_ready diff --git a/sunnypilot/selfdrive/car/intelligent_cruise_button_management/helpers.py b/sunnypilot/selfdrive/car/intelligent_cruise_button_management/helpers.py new file mode 100644 index 0000000000..eb4bbdecb5 --- /dev/null +++ b/sunnypilot/selfdrive/car/intelligent_cruise_button_management/helpers.py @@ -0,0 +1,8 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +def get_minimum_set_speed(is_metric: bool) -> int: + return 30 if is_metric else 20 diff --git a/sunnypilot/selfdrive/car/interfaces.py b/sunnypilot/selfdrive/car/interfaces.py new file mode 100644 index 0000000000..e4db6c8e97 --- /dev/null +++ b/sunnypilot/selfdrive/car/interfaces.py @@ -0,0 +1,137 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from typing import Any + +from opendbc.car import structs +from opendbc.car.interfaces import CarInterfaceBase +from openpilot.common.params import Params +from openpilot.common.swaglog import cloudlog +from openpilot.sunnypilot.selfdrive.controls.lib.nnlc.helpers import get_nn_model_path +from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.helpers import set_speed_limit_assist_availability + +import openpilot.system.sentry as sentry + +from openpilot.sunnypilot.sunnylink.statsd import STATSLOGSP + + +def log_fingerprint(CP: structs.CarParams) -> None: + if CP.carFingerprint == "MOCK": + sentry.capture_fingerprint_mock() + else: + sentry.capture_fingerprint(CP.carFingerprint, CP.brand) + + +def _enforce_torque_lateral_control(CP: structs.CarParams, params: Params = None, enabled: bool = False) -> bool: + if params is None: + params = Params() + + if CP.steerControlType != structs.CarParams.SteerControlType.angle: + enabled = params.get_bool("EnforceTorqueControl") + + return enabled + + +def _initialize_neural_network_lateral_control(CP: structs.CarParams, CP_SP: structs.CarParamsSP, + params: Params = None, enabled: bool = False) -> bool: + if params is None: + params = Params() + + nnlc_model_path, nnlc_model_name, exact_match = get_nn_model_path(CP) + + if nnlc_model_name == "MOCK": + cloudlog.error({"nnlc event": "car doesn't match any Neural Network model"}) + + if nnlc_model_name != "MOCK" and CP.steerControlType != structs.CarParams.SteerControlType.angle: + enabled = params.get_bool("NeuralNetworkLateralControl") + + CP_SP.neuralNetworkLateralControl.model.path = nnlc_model_path + CP_SP.neuralNetworkLateralControl.model.name = nnlc_model_name + CP_SP.neuralNetworkLateralControl.fuzzyFingerprint = not exact_match + + return enabled + + +def _initialize_intelligent_cruise_button_management(CP: structs.CarParams, CP_SP: structs.CarParamsSP, params: Params = None) -> None: + if params is None: + params = Params() + + icbm_enabled = params.get_bool("IntelligentCruiseButtonManagement") + if icbm_enabled and CP_SP.intelligentCruiseButtonManagementAvailable and not CP.openpilotLongitudinalControl: + CP_SP.pcmCruiseSpeed = False + + +def _initialize_torque_lateral_control(CI: CarInterfaceBase, CP: structs.CarParams, enforce_torque: bool, nnlc_enabled: bool) -> None: + if nnlc_enabled or enforce_torque: + CI.configure_torque_tune(CP.carFingerprint, CP.lateralTuning) + + +def _cleanup_unsupported_params(CP: structs.CarParams, CP_SP: structs.CarParamsSP, params: Params = None) -> None: + if params is None: + params = Params() + + if CP.steerControlType == structs.CarParams.SteerControlType.angle: + cloudlog.warning("SteerControlType is angle, cleaning up params") + params.remove("NeuralNetworkLateralControl") + params.remove("EnforceTorqueControl") + + if not CP_SP.intelligentCruiseButtonManagementAvailable or CP.openpilotLongitudinalControl: + cloudlog.warning("ICBM not available or openpilot Longitudinal Control enabled, cleaning up params") + params.remove("IntelligentCruiseButtonManagement") + + if not CP.openpilotLongitudinalControl and CP_SP.pcmCruiseSpeed: + cloudlog.warning("openpilot Longitudinal Control and ICBM not available, cleaning up params") + params.remove("DynamicExperimentalControl") + params.remove("CustomAccIncrementsEnabled") + params.remove("SmartCruiseControlVision") + params.remove("SmartCruiseControlMap") + + set_speed_limit_assist_availability(CP, CP_SP, params) + + +def setup_interfaces(CI: CarInterfaceBase, params: Params = None) -> None: + CP = CI.CP + CP_SP = CI.CP_SP + + enforce_torque = _enforce_torque_lateral_control(CP, params) + nnlc_enabled = _initialize_neural_network_lateral_control(CP, CP_SP, params) + _initialize_intelligent_cruise_button_management(CP, CP_SP, params) + _initialize_torque_lateral_control(CI, CP, enforce_torque, nnlc_enabled) + _cleanup_unsupported_params(CP, CP_SP) + + try: + STATSLOGSP.raw('sunnypilot.car_params', CP.to_dict()) + except RuntimeError: + pass # to_dict fails on macOS due to library issues. + # STATSLOGSP.raw('sunnypilot_params.car_params_sp', CP_SP.to_dict()) # https://github.com/sunnypilot/opendbc/pull/361 + + +def initialize_params(params) -> list[dict[str, Any]]: + keys: list = [] + + # hyundai + keys.extend([ + "HyundaiLongitudinalTuning", + ]) + + # subaru + keys.extend([ + "SubaruStopAndGo", + "SubaruStopAndGoManualParkingBrake", + ]) + + # tesla + keys.extend([ + "TeslaCoopSteering", + ]) + + # toyota + keys.extend([ + "ToyotaEnforceStockLongitudinal", + "ToyotaStopAndGoHack", + ]) + + return [{k: params.get(k, return_default=True)} for k in keys] diff --git a/sunnypilot/selfdrive/car/sync_car_list_param.py b/sunnypilot/selfdrive/car/sync_car_list_param.py new file mode 100755 index 0000000000..5e25f6da9a --- /dev/null +++ b/sunnypilot/selfdrive/car/sync_car_list_param.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import json +import os + +from openpilot.common.basedir import BASEDIR +from openpilot.common.params import Params +from openpilot.common.swaglog import cloudlog + +CAR_LIST_JSON_OUT = os.path.join(BASEDIR, "sunnypilot", "selfdrive", "car", "car_list.json") + + +def update_car_list_param(): + with open(CAR_LIST_JSON_OUT) as f: + current_car_list = json.load(f) + + params = Params() + if params.get("CarList") != current_car_list: + params.put("CarList", current_car_list) + cloudlog.warning("Updated CarList param with latest platform list") + else: + cloudlog.warning("CarList param is up to date, no need to update") + + +if __name__ == "__main__": + update_car_list_param() diff --git a/sunnypilot/selfdrive/car/tests/__init__.py b/sunnypilot/selfdrive/car/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sunnypilot/selfdrive/car/tests/test_cruise_mode.py b/sunnypilot/selfdrive/car/tests/test_cruise_mode.py new file mode 100644 index 0000000000..2fad14a703 --- /dev/null +++ b/sunnypilot/selfdrive/car/tests/test_cruise_mode.py @@ -0,0 +1,66 @@ +from cereal import car +from openpilot.common.parameterized import parameterized_class +from openpilot.selfdrive.selfdrived.events import Events +from openpilot.sunnypilot.selfdrive.car.cruise_helpers import CruiseHelper, DISTANCE_LONG_PRESS + +ButtonEvent = car.CarState.ButtonEvent +ButtonType = car.CarState.ButtonEvent.Type + + +@parameterized_class(('openpilot_longitudinal',), [(True,)]) +class TestCruiseHelper: + def setup_method(self): + self.CP = car.CarParams(openpilotLongitudinalControl=self.openpilot_longitudinal) + self.cruise_helper = CruiseHelper(self.CP) + self.cruise_helper.experimental_mode_switched = False + self.events = Events() + + def reset(self): + for _ in range(2): + CS = car.CarState(cruiseState={"available": False}) + CS.buttonEvents = [ButtonEvent(type=ButtonType.gapAdjustCruise, pressed=False)] + self.cruise_helper._experimental_mode = False + self.cruise_helper.experimental_mode_switched = False + self.cruise_helper.update(CS, self.events, False) + + + def test_gap_adjust_cruise_long_press_toggle_mode(self) -> None: + for pressed in (True, False): + for experimental_mode in (True, False): + self.reset() + self.cruise_helper._experimental_mode = experimental_mode + toggled_mode = not experimental_mode if pressed else experimental_mode + + for i in range(DISTANCE_LONG_PRESS): + CS = car.CarState(cruiseState={"available": True}) + CS.buttonEvents = [ButtonEvent(type=ButtonType.gapAdjustCruise, pressed=pressed)] if i == 0 else [] + self.cruise_helper.update(CS, self.events, experimental_mode) + + # mode should be toggled + assert self.cruise_helper._experimental_mode == toggled_mode + assert self.cruise_helper.experimental_mode_switched is pressed + + # keep holding button after switching mode + for _ in range(DISTANCE_LONG_PRESS): + CS = car.CarState(cruiseState={"available": True}) + CS.buttonEvents = [ButtonEvent(type=ButtonType.gapAdjustCruise, pressed=pressed)] + self.cruise_helper.update(CS, self.events, toggled_mode) + + # mode should not be toggled + assert self.cruise_helper._experimental_mode == toggled_mode + assert self.cruise_helper.experimental_mode_switched is pressed + + def test_gap_adjust_cruise_short_press_toggle_mode(self) -> None: + for pressed in (True, False): + for experimental_mode in (True, False): + self.reset() + self.cruise_helper._experimental_mode = experimental_mode + + for i in range(DISTANCE_LONG_PRESS - 1): + CS = car.CarState(cruiseState={"available": True}) + CS.buttonEvents = [ButtonEvent(type=ButtonType.gapAdjustCruise, pressed=pressed)] if i == 0 else [] + self.cruise_helper.update(CS, self.events, experimental_mode) + + # mode should not be toggled + assert self.cruise_helper._experimental_mode == experimental_mode + assert self.cruise_helper.experimental_mode_switched is False diff --git a/sunnypilot/selfdrive/car/tests/test_custom_cruise.py b/sunnypilot/selfdrive/car/tests/test_custom_cruise.py new file mode 100644 index 0000000000..7276af43e7 --- /dev/null +++ b/sunnypilot/selfdrive/car/tests/test_custom_cruise.py @@ -0,0 +1,150 @@ +import pytest + +from cereal import car +from openpilot.common.constants import CV +from openpilot.common.parameterized import parameterized_class +from openpilot.common.params import Params +from openpilot.selfdrive.car.cruise import V_CRUISE_INITIAL +from openpilot.selfdrive.car.tests.test_cruise_speed import TestVCruiseHelper + +ButtonEvent = car.CarState.ButtonEvent +ButtonType = car.CarState.ButtonEvent.Type + + +# TODO: test pcmCruise and pcmCruiseSpeed +@parameterized_class(('pcm_cruise', 'pcm_cruise_speed'), [(False, True)]) +class TestCustomAccIncrements(TestVCruiseHelper): + def setup_method(self): + TestVCruiseHelper.setup_method(self) + self.params = Params() + self.reset_custom_params() + + def reset_custom_params(self) -> None: + """Reset to default custom ACC parameters""" + self.params.put_bool("CustomAccIncrementsEnabled", False) + self.params.put("CustomAccShortPressIncrement", 1) + self.params.put("CustomAccLongPressIncrement", 5) + self.v_cruise_helper.read_custom_set_speed_params() + + def press_button_short(self, button_type: car.CarState.ButtonEvent.Type) -> None: + """Simulate a short button press (press + release)""" + CS = car.CarState(cruiseState={"available": True}) + CS.buttonEvents = [ButtonEvent(type=button_type, pressed=True)] + self.v_cruise_helper.update_v_cruise(CS, enabled=True, is_metric=True) + + CS.buttonEvents = [ButtonEvent(type=button_type, pressed=False)] + self.v_cruise_helper.update_v_cruise(CS, enabled=True, is_metric=True) + + def press_button_long(self, button_type: car.CarState.ButtonEvent.Type) -> None: + """Simulate a long button press (50+ frames)""" + CS = car.CarState(cruiseState={"available": True}) + CS.buttonEvents = [ButtonEvent(type=button_type, pressed=True)] + self.v_cruise_helper.update_v_cruise(CS, enabled=True, is_metric=True) + + # Hold for 50 frames to trigger long press + CS.buttonEvents = [] + for _ in range(50): + self.v_cruise_helper.update_v_cruise(CS, enabled=True, is_metric=True) + + CS.buttonEvents = [ButtonEvent(type=button_type, pressed=False)] + self.v_cruise_helper.update_v_cruise(CS, enabled=True, is_metric=True) + + def set_custom_increments(self, enabled: bool, short_inc: int, long_inc: int) -> None: + """Set custom ACC increment parameters""" + self.params.put_bool("CustomAccIncrementsEnabled", enabled) + self.params.put("CustomAccShortPressIncrement", short_inc) + self.params.put("CustomAccLongPressIncrement", long_inc) + self.v_cruise_helper.read_custom_set_speed_params() + + def test_default_behavior_when_disabled(self): + """Test that default increments are used when custom ACC is disabled""" + self.set_custom_increments(enabled=False, short_inc=5, long_inc=10) + self.enable(V_CRUISE_INITIAL * CV.KPH_TO_MS, False, False) + + initial_speed = self.v_cruise_helper.v_cruise_kph + + # Short press should increment by 1 (default) + self.press_button_short(ButtonType.accelCruise) + assert self.v_cruise_helper.v_cruise_kph == initial_speed + 1 + + @pytest.mark.parametrize("increment", (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)) + def test_custom_short_press_increments(self, increment): + """Test custom short press increments (1-10)""" + self.set_custom_increments(enabled=True, short_inc=increment, long_inc=5) + self.enable(50 * CV.KPH_TO_MS, False, False) + + initial_speed = self.v_cruise_helper.v_cruise_kph + self.press_button_short(ButtonType.accelCruise) + + if increment in (5, 10): + # Should round to nearest increment + expected_speed = ((initial_speed // increment) + 1) * increment + else: + expected_speed = initial_speed + increment + + assert self.v_cruise_helper.v_cruise_kph == expected_speed + + @pytest.mark.parametrize("increment", (1, 5, 10)) + def test_custom_long_press_increments(self, increment): + """Test custom long press increments (1, 5, 10)""" + self.set_custom_increments(enabled=True, short_inc=1, long_inc=increment) + self.enable(50 * CV.KPH_TO_MS, False, False) + + initial_speed = self.v_cruise_helper.v_cruise_kph + self.press_button_long(ButtonType.accelCruise) + + if increment in (5, 10): + # Should round to nearest increment + expected_speed = ((initial_speed // increment) + 1) * increment + else: + expected_speed = initial_speed + increment + + assert self.v_cruise_helper.v_cruise_kph == expected_speed + + @pytest.mark.parametrize("button_type", [ButtonType.accelCruise, ButtonType.decelCruise]) + def test_accel_decel_symmetry(self, button_type): + """Test that acceleration and deceleration work symmetrically""" + self.set_custom_increments(enabled=True, short_inc=3, long_inc=5) + self.enable(50 * CV.KPH_TO_MS, False, False) + + initial_speed = self.v_cruise_helper.v_cruise_kph + self.press_button_short(button_type) + + expected_change = 3 if button_type == ButtonType.accelCruise else -3 + assert self.v_cruise_helper.v_cruise_kph == initial_speed + expected_change + + def test_rounding_behavior(self): + """Test rounding behavior for 5 and 10 increments""" + test_cases = [ + (47, 5, 50), # 47 -> 50 (round up to next 5) + (45, 5, 50), # 45 -> 50 (already at 5, increment by 5) + (43, 10, 50), # 43 -> 50 (round up to next 10) + (40, 10, 50), # 40 -> 50 (already at 10, increment by 10) + ] + + for initial, increment, expected in test_cases: + self.set_custom_increments(enabled=True, short_inc=increment, long_inc=increment) + self.reset_cruise_speed_state() + self.enable(initial * CV.KPH_TO_MS, False, False) + + self.press_button_short(ButtonType.accelCruise) + assert self.v_cruise_helper.v_cruise_kph == expected + + def test_invalid_values_fallback(self): + """Test that invalid values fallback to safe defaults""" + # Test invalid short increment + self.set_custom_increments(enabled=True, short_inc=-1, long_inc=5) + self.enable(50 * CV.KPH_TO_MS, False, False) + + initial_speed = self.v_cruise_helper.v_cruise_kph + self.press_button_short(ButtonType.accelCruise) + assert self.v_cruise_helper.v_cruise_kph == initial_speed + 1 # Should fallback to 1 + + # Test invalid long increment + self.reset_cruise_speed_state() + self.set_custom_increments(enabled=True, short_inc=1, long_inc=99) + self.enable(50 * CV.KPH_TO_MS, False, False) + + initial_speed = self.v_cruise_helper.v_cruise_kph + self.press_button_long(ButtonType.accelCruise) + assert self.v_cruise_helper.v_cruise_kph == initial_speed + 10 # Should fallback to 10 diff --git a/sunnypilot/selfdrive/controls/__init__.py b/sunnypilot/selfdrive/controls/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sunnypilot/selfdrive/controls/controlsd_ext.py b/sunnypilot/selfdrive/controls/controlsd_ext.py new file mode 100644 index 0000000000..4b6c92ae2c --- /dev/null +++ b/sunnypilot/selfdrive/controls/controlsd_ext.py @@ -0,0 +1,111 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import time + +import cereal.messaging as messaging +from cereal import log, custom + +from opendbc.car import structs +from openpilot.common.params import Params +from openpilot.common.swaglog import cloudlog +from openpilot.sunnypilot import PARAMS_UPDATE_PERIOD +from openpilot.sunnypilot.livedelay.helpers import get_lat_delay +from openpilot.sunnypilot.modeld_v2.modeld_base import ModelStateBase +from openpilot.sunnypilot.selfdrive.controls.lib.blinker_pause_lateral import BlinkerPauseLateral +from openpilot.sunnypilot.selfdrive.controls.lib.latcontrol_torque_v0 import LatControlTorque as LatControlTorqueV0 + + +class ControlsExt(ModelStateBase): + def __init__(self, CP: structs.CarParams, params: Params): + ModelStateBase.__init__(self) + self.CP = CP + self.params = params + self._param_update_time: float = 0.0 + self.blinker_pause_lateral = BlinkerPauseLateral() + + cloudlog.info("controlsd_ext is waiting for CarParamsSP") + self.CP_SP = messaging.log_from_bytes(params.get("CarParamsSP", block=True), custom.CarParamsSP) + cloudlog.info("controlsd_ext got CarParamsSP") + + self.sm_services_ext = ['radarState', 'selfdriveStateSP'] + self.pm_services_ext = ['carControlSP'] + + def initialize_lateral_control(self, lac, CI, dt): + enforce_torque_control = self.params.get_bool("EnforceTorqueControl") + torque_versions = self.params.get("TorqueControlTune") + if not enforce_torque_control: + return lac + + if torque_versions == 0.0: # v0 + return LatControlTorqueV0(self.CP, self.CP_SP, CI, dt) + else: + return lac + + def get_params_sp(self, sm: messaging.SubMaster) -> None: + if time.monotonic() - self._param_update_time > PARAMS_UPDATE_PERIOD: + self.blinker_pause_lateral.get_params() + + if self.CP.lateralTuning.which() == 'torque': + self.lat_delay = get_lat_delay(self.params, sm["liveDelay"].lateralDelay) + + self._param_update_time = time.monotonic() + + def get_lat_active(self, sm: messaging.SubMaster) -> bool: + if self.blinker_pause_lateral.update(sm['carState']): + return False + + ss_sp = sm['selfdriveStateSP'] + if ss_sp.mads.available: + return bool(ss_sp.mads.active) + + # MADS not available, use stock state to engage + return bool(sm['selfdriveState'].active) + + @staticmethod + def get_lead_data(ld: log.RadarState.LeadData) -> dict: + return { + "dRel": ld.dRel, + "yRel": ld.yRel, + "vRel": ld.vRel, + "aRel": ld.aRel, + "vLead": ld.vLead, + "dPath": ld.dPath, + "vLat": ld.vLat, + "vLeadK": ld.vLeadK, + "aLeadK": ld.aLeadK, + "fcw": ld.fcw, + "status": ld.status, + "aLeadTau": ld.aLeadTau, + "modelProb": ld.modelProb, + "radar": ld.radar, + "radarTrackId": ld.radarTrackId, + } + + def state_control_ext(self, sm: messaging.SubMaster) -> custom.CarControlSP: + CC_SP = custom.CarControlSP.new_message() + + CC_SP.leadOne = self.get_lead_data(sm['radarState'].leadOne) + CC_SP.leadTwo = self.get_lead_data(sm['radarState'].leadTwo) + + # MADS state + CC_SP.mads = sm['selfdriveStateSP'].mads + + CC_SP.intelligentCruiseButtonManagement = sm['selfdriveStateSP'].intelligentCruiseButtonManagement + + return CC_SP + + @staticmethod + def publish_ext(CC_SP: custom.CarControlSP, sm: messaging.SubMaster, pm: messaging.PubMaster) -> None: + cc_sp_send = messaging.new_message('carControlSP') + cc_sp_send.valid = sm['carState'].canValid + cc_sp_send.carControlSP = CC_SP + + pm.send('carControlSP', cc_sp_send) + + def run_ext(self, sm: messaging.SubMaster, pm: messaging.PubMaster) -> None: + CC_SP = self.state_control_ext(sm) + self.publish_ext(CC_SP, sm, pm) diff --git a/sunnypilot/selfdrive/controls/lib/__init__.py b/sunnypilot/selfdrive/controls/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sunnypilot/selfdrive/controls/lib/auto_lane_change.py b/sunnypilot/selfdrive/controls/lib/auto_lane_change.py new file mode 100644 index 0000000000..cf8de2a1b2 --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/auto_lane_change.py @@ -0,0 +1,112 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from cereal import log + +from openpilot.common.params import Params +from openpilot.common.realtime import DT_MDL + + +class AutoLaneChangeMode: + OFF = -1 + NUDGE = 0 # default + NUDGELESS = 1 + HALF_SECOND = 2 + ONE_SECOND = 3 + TWO_SECONDS = 4 + THREE_SECONDS = 5 + + +AUTO_LANE_CHANGE_TIMER = { + AutoLaneChangeMode.OFF: 0.0, # Off + AutoLaneChangeMode.NUDGE: 0.0, # Nudge + AutoLaneChangeMode.NUDGELESS: 0.05, # Nudgeless + AutoLaneChangeMode.HALF_SECOND: 0.5, # 0.5-second delay + AutoLaneChangeMode.ONE_SECOND: 1.0, # 1-second delay + AutoLaneChangeMode.TWO_SECONDS: 2.0, # 2-second delay + AutoLaneChangeMode.THREE_SECONDS: 3.0, # 3-second delay +} + +ONE_SECOND_DELAY = -1 + + +class AutoLaneChangeController: + def __init__(self, desire_helper): + self.DH = desire_helper + self.params = Params() + + self.lane_change_wait_timer = 0.0 + self.param_read_counter = 0 + self.lane_change_delay = 0.0 + + self.lane_change_set_timer = self.params.get("AutoLaneChangeTimer", return_default=True) + self.lane_change_bsm_delay = False + + self.prev_brake_pressed = False + self.auto_lane_change_allowed = False + self.prev_lane_change = False + + self.read_params() + + def reset(self) -> None: + # Auto reset if parent state indicates we should + if self.DH.lane_change_state == log.LaneChangeState.off and \ + self.DH.lane_change_direction == log.LaneChangeDirection.none: + self.lane_change_wait_timer = 0.0 + self.prev_brake_pressed = False + self.prev_lane_change = False + + def read_params(self) -> None: + self.lane_change_bsm_delay = self.params.get_bool("AutoLaneChangeBsmDelay") + self.lane_change_set_timer = self.params.get("AutoLaneChangeTimer", return_default=True) + + def update_params(self) -> None: + if self.param_read_counter % 50 == 0: + self.read_params() + self.param_read_counter += 1 + + def update_lane_change_timers(self, blindspot_detected: bool) -> None: + self.lane_change_delay = AUTO_LANE_CHANGE_TIMER.get(self.lane_change_set_timer, + AUTO_LANE_CHANGE_TIMER[AutoLaneChangeMode.NUDGE]) + + self.lane_change_wait_timer += DT_MDL + + if self.lane_change_bsm_delay and blindspot_detected and self.lane_change_delay > 0: + if self.lane_change_delay == AUTO_LANE_CHANGE_TIMER[AutoLaneChangeMode.NUDGELESS]: + self.lane_change_wait_timer = ONE_SECOND_DELAY + else: + self.lane_change_wait_timer = self.lane_change_delay + ONE_SECOND_DELAY + + def update_allowed(self) -> bool: + # Auto lane change allowed if: + # 1. A valid delay is set (non-zero) + # 2. Brake wasn't previously pressed + # 3. We've waited long enough + + if self.lane_change_set_timer in (AutoLaneChangeMode.OFF, AutoLaneChangeMode.NUDGE): + return False + + if self.prev_brake_pressed: + return False + + if self.prev_lane_change: + return False + + return bool(self.lane_change_wait_timer > self.lane_change_delay) + + def update_lane_change(self, blindspot_detected: bool, brake_pressed: bool) -> None: + if brake_pressed and not self.prev_brake_pressed: + self.prev_brake_pressed = brake_pressed + + self.update_lane_change_timers(blindspot_detected) + + self.auto_lane_change_allowed = self.update_allowed() + + def update_state(self): + if self.DH.lane_change_state == log.LaneChangeState.laneChangeStarting: + self.prev_lane_change = True + + self.reset() diff --git a/sunnypilot/selfdrive/controls/lib/blinker_pause_lateral.py b/sunnypilot/selfdrive/controls/lib/blinker_pause_lateral.py new file mode 100644 index 0000000000..98757cd532 --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/blinker_pause_lateral.py @@ -0,0 +1,44 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from cereal import car + +from openpilot.common.constants import CV +from openpilot.common.params import Params + + +class BlinkerPauseLateral: + def __init__(self): + self.params = Params() + + self.enabled = self.params.get_bool("BlinkerPauseLateralControl") + self.is_metric = self.params.get_bool("IsMetric") + self.min_speed = 0 + self.reengage_delay = 0 + self.blinker_off_timer = 0.0 + + def get_params(self) -> None: + self.enabled = self.params.get_bool("BlinkerPauseLateralControl") + self.is_metric = self.params.get_bool("IsMetric") + self.min_speed = self.params.get("BlinkerMinLateralControlSpeed", return_default=True) + self.reengage_delay = self.params.get("BlinkerLateralReengageDelay", return_default=True) + + def update(self, CS: car.CarState, DT_CTRL: float = 0.01) -> bool: + if not self.enabled: + return False + + one_blinker = CS.leftBlinker != CS.rightBlinker + speed_factor = CV.KPH_TO_MS if self.is_metric else CV.MPH_TO_MS + min_speed_ms = self.min_speed * speed_factor + + below_speed = CS.vEgo < min_speed_ms + + if one_blinker and below_speed: + self.blinker_off_timer = self.reengage_delay + elif self.blinker_off_timer > 0: + self.blinker_off_timer -= DT_CTRL + + return bool((one_blinker and below_speed) or self.blinker_off_timer > 0) diff --git a/sunnypilot/selfdrive/controls/lib/dec/__init__.py b/sunnypilot/selfdrive/controls/lib/dec/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sunnypilot/selfdrive/controls/lib/dec/constants.py b/sunnypilot/selfdrive/controls/lib/dec/constants.py new file mode 100644 index 0000000000..4586afbc9f --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/dec/constants.py @@ -0,0 +1,17 @@ +class WMACConstants: + # Lead detection parameters + LEAD_WINDOW_SIZE = 6 # Stable detection window + LEAD_PROB = 0.45 # Balanced threshold for lead detection + + # Slow down detection parameters + SLOW_DOWN_WINDOW_SIZE = 5 # Responsive but stable + SLOW_DOWN_PROB = 0.3 # Balanced threshold for slow down scenarios + + # Optimized slow down distance curve - smooth and progressive + SLOW_DOWN_BP = [0., 10., 20., 30., 40., 50., 55., 60.] + SLOW_DOWN_DIST = [32., 46., 64., 86., 108., 130., 145., 165.] + + # Slowness detection parameters + SLOWNESS_WINDOW_SIZE = 10 # Stable slowness detection + SLOWNESS_PROB = 0.55 # Clear threshold for slowness + SLOWNESS_CRUISE_OFFSET = 1.025 # Conservative cruise speed offset diff --git a/sunnypilot/selfdrive/controls/lib/dec/dec.py b/sunnypilot/selfdrive/controls/lib/dec/dec.py new file mode 100644 index 0000000000..46cad1b048 --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/dec/dec.py @@ -0,0 +1,388 @@ +""" +Copyright (c) 2021-, rav4kumar, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +# Version = 2025-6-30 + +from cereal import messaging +from opendbc.car import structs +from numpy import interp +from openpilot.common.params import Params +from openpilot.common.realtime import DT_MDL +from openpilot.sunnypilot.selfdrive.controls.lib.dec.constants import WMACConstants +from typing import Literal + +# d-e2e, from modeldata.h +TRAJECTORY_SIZE = 33 +SET_MODE_TIMEOUT = 15 + +# Define the valid mode types +ModeType = Literal['acc', 'blended'] + + +class SmoothKalmanFilter: + """Enhanced Kalman filter with smoothing for stable decision making.""" + + def __init__(self, initial_value=0, measurement_noise=0.1, process_noise=0.01, + alpha=1.0, smoothing_factor=0.85): + self.x = initial_value + self.P = 1.0 + self.R = measurement_noise + self.Q = process_noise + self.alpha = alpha + self.smoothing_factor = smoothing_factor + self.initialized = False + self.history = [] + self.max_history = 10 + self.confidence = 0.0 + + def add_data(self, measurement): + if len(self.history) >= self.max_history: + self.history.pop(0) + self.history.append(measurement) + + if not self.initialized: + self.x = measurement + self.initialized = True + self.confidence = 0.1 + return + + self.P = self.alpha * self.P + self.Q + + K = self.P / (self.P + self.R) + effective_K = K * (1.0 - self.smoothing_factor) + self.smoothing_factor * 0.1 + + innovation = measurement - self.x + self.x = self.x + effective_K * innovation + self.P = (1 - effective_K) * self.P + + if abs(innovation) < 0.1: + self.confidence = min(1.0, self.confidence + 0.05) + else: + self.confidence = max(0.1, self.confidence - 0.02) + + def get_value(self): + return self.x if self.initialized else None + + def get_confidence(self): + return self.confidence + + def reset_data(self): + self.initialized = False + self.history = [] + self.confidence = 0.0 + + +class ModeTransitionManager: + """Manages smooth transitions between driving modes with hysteresis.""" + + def __init__(self): + self.current_mode: ModeType = 'acc' + self.mode_confidence = {'acc': 1.0, 'blended': 0.0} + self.transition_timeout = 0 + self.min_mode_duration = 10 + self.mode_duration = 0 + self.emergency_override = False + + def request_mode(self, mode: ModeType, confidence: float = 1.0, emergency: bool = False): + # Emergency override for critical situations (stops, collisions) + if emergency: + self.emergency_override = True + self.current_mode = mode + self.transition_timeout = SET_MODE_TIMEOUT + self.mode_duration = 0 + return + + self.mode_confidence[mode] = min(1.0, self.mode_confidence[mode] + 0.1 * confidence) + for m in self.mode_confidence: + if m != mode: + self.mode_confidence[m] = max(0.0, self.mode_confidence[m] - 0.05) + + # Require minimum duration in current mode (unless emergency) + if self.mode_duration < self.min_mode_duration and not self.emergency_override: + return + + # Hysteresis: higher threshold for mode changes + confidence_threshold = 0.6 if mode != self.current_mode else 0.3 # Lower threshold for faster response + + if self.mode_confidence[mode] > confidence_threshold: + if mode != self.current_mode and self.transition_timeout == 0: + self.transition_timeout = SET_MODE_TIMEOUT + self.current_mode = mode + self.mode_duration = 0 + + def update(self): + if self.transition_timeout > 0: + self.transition_timeout -= 1 + self.mode_duration += 1 + + # Reset emergency override after some time + if self.emergency_override and self.mode_duration > 20: + self.emergency_override = False + + # Gradual confidence decay + for mode in self.mode_confidence: + self.mode_confidence[mode] *= 0.98 + + def get_mode(self) -> ModeType: + return self.current_mode + + +class DynamicExperimentalController: + def __init__(self, CP: structs.CarParams, mpc, params=None): + self._CP = CP + self._mpc = mpc + self._params = params or Params() + self._enabled: bool = self._params.get_bool("DynamicExperimentalControl") + self._active: bool = False + self._frame: int = 0 + self._urgency = 0.0 + + self._mode_manager = ModeTransitionManager() + + # Smooth filters for stable decision making with faster response for critical scenarios + self._lead_filter = SmoothKalmanFilter( + measurement_noise=0.15, + process_noise=0.05, + alpha=1.02, + smoothing_factor=0.8 + ) + + self._slow_down_filter = SmoothKalmanFilter( + measurement_noise=0.1, + process_noise=0.1, + alpha=1.05, + smoothing_factor=0.7 + ) + + self._slowness_filter = SmoothKalmanFilter( + measurement_noise=0.1, + process_noise=0.06, + alpha=1.015, + smoothing_factor=0.92 + ) + + self._mpc_fcw_filter = SmoothKalmanFilter( + measurement_noise=0.2, + process_noise=0.1, + alpha=1.1, + smoothing_factor=0.5 + ) + self._has_lead_filtered = False + self._has_slow_down = False + self._has_slowness = False + self._has_mpc_fcw = False + self._v_ego_kph = 0.0 + self._v_cruise_kph = 0.0 + self._has_standstill = False + self._mpc_fcw_crash_cnt = 0 + self._standstill_count = 0 + # debug + self._endpoint_x = float('inf') + self._expected_distance = 0.0 + self._trajectory_valid = False + + def _read_params(self) -> None: + if self._frame % int(1. / DT_MDL) == 0: + self._enabled = self._params.get_bool("DynamicExperimentalControl") + + def mode(self) -> str: + return self._mode_manager.get_mode() + + def enabled(self) -> bool: + return self._enabled + + def active(self) -> bool: + return self._active + + def set_mpc_fcw_crash_cnt(self) -> None: + """Set MPC FCW crash count""" + self._mpc_fcw_crash_cnt = self._mpc.crash_cnt + + def _update_calculations(self, sm: messaging.SubMaster) -> None: + car_state = sm['carState'] + lead_one = sm['radarState'].leadOne + md = sm['modelV2'] + + self._v_ego_kph = car_state.vEgo * 3.6 + self._v_cruise_kph = car_state.vCruise + self._has_standstill = car_state.standstill + + # standstill detection + if self._has_standstill: + self._standstill_count = min(20, self._standstill_count + 1) + else: + self._standstill_count = max(0, self._standstill_count - 1) + + # Lead detection + self._lead_filter.add_data(float(lead_one.status)) + lead_value = self._lead_filter.get_value() or 0.0 + self._has_lead_filtered = lead_value > WMACConstants.LEAD_PROB + + # MPC FCW detection + fcw_filtered_value = self._mpc_fcw_filter.get_value() or 0.0 + self._mpc_fcw_filter.add_data(float(self._mpc_fcw_crash_cnt > 0)) + self._has_mpc_fcw = fcw_filtered_value > 0.5 + + # Slow down detection + self._calculate_slow_down(md) + + # Slowness detection + if not (self._standstill_count > 5) and not self._has_slow_down: + current_slowness = float(self._v_ego_kph <= (self._v_cruise_kph * WMACConstants.SLOWNESS_CRUISE_OFFSET)) + self._slowness_filter.add_data(current_slowness) + slowness_value = self._slowness_filter.get_value() or 0.0 + + # Hysteresis for slowness + threshold = WMACConstants.SLOWNESS_PROB * (0.8 if self._has_slowness else 1.1) + self._has_slowness = slowness_value > threshold + + def _calculate_slow_down(self, md): + """Calculate urgency based on trajectory endpoint vs expected distance.""" + + # Reset to safe defaults + urgency = 0.0 + self._endpoint_x = float('inf') + self._trajectory_valid = False + + #Require exact trajectory size + position_valid = len(md.position.x) == TRAJECTORY_SIZE + orientation_valid = len(md.orientation.x) == TRAJECTORY_SIZE + + if not (position_valid and orientation_valid): + # Invalid trajectory - this itself might indicate a stop scenario + # Apply moderate urgency for incomplete trajectories at speed + if self._v_ego_kph > 20.0: + urgency = 0.3 + + self._slow_down_filter.add_data(urgency) + urgency_filtered = self._slow_down_filter.get_value() or 0.0 + self._has_slow_down = urgency_filtered > WMACConstants.SLOW_DOWN_PROB + self._urgency = urgency_filtered + return + + # We have a valid full trajectory + self._trajectory_valid = True + + # Use the exact endpoint (33rd point, index 32) + endpoint_x = md.position.x[TRAJECTORY_SIZE - 1] + self._endpoint_x = endpoint_x + + # Get expected distance based on current speed using tuned constants + expected_distance = interp(self._v_ego_kph, + WMACConstants.SLOW_DOWN_BP, + WMACConstants.SLOW_DOWN_DIST) + self._expected_distance = expected_distance + + # Calculate urgency based on trajectory shortage + if endpoint_x < expected_distance: + shortage = expected_distance - endpoint_x + shortage_ratio = shortage / expected_distance + + # Base urgency on shortage ratio + urgency = min(1.0, shortage_ratio * 2.0) + + # Increase urgency for very short trajectories (imminent stops) + critical_distance = expected_distance * 0.3 + if endpoint_x < critical_distance: + urgency = min(1.0, urgency * 2.0) + + # Speed-based urgency adjustment + if self._v_ego_kph > 25.0: + speed_factor = 1.0 + (self._v_ego_kph - 25.0) / 80.0 + urgency = min(1.0, urgency * speed_factor) + + # Apply filtering but with less smoothing for stops + self._slow_down_filter.add_data(urgency) + urgency_filtered = self._slow_down_filter.get_value() or 0.0 + + # Update state with lower threshold for better stop detection + self._has_slow_down = urgency_filtered > (WMACConstants.SLOW_DOWN_PROB * 0.8) + self._urgency = urgency_filtered + + def _radarless_mode(self) -> None: + """Radarless mode decision logic with emergency handling.""" + + # EMERGENCY: MPC FCW - immediate blended mode + if self._has_mpc_fcw: + self._mode_manager.request_mode('blended', confidence=1.0, emergency=True) + return + + # Standstill: use blended + if self._standstill_count > 3: + self._mode_manager.request_mode('blended', confidence=0.9) + return + + # Slow down scenarios: emergency for high urgency, normal for lower urgency + if self._has_slow_down: + if self._urgency > 0.7: + # Emergency: immediate blended mode for high urgency stops + self._mode_manager.request_mode('blended', confidence=1.0, emergency=True) + else: + # Normal: blended with urgency-based confidence + confidence = min(1.0, self._urgency * 1.5) + self._mode_manager.request_mode('blended', confidence=confidence) + return + + # Driving slow: use ACC (but not if actively slowing down) + if self._has_slowness and not self._has_slow_down: + self._mode_manager.request_mode('acc', confidence=0.8) + return + + # Default: ACC + self._mode_manager.request_mode('acc', confidence=0.7) + + def _radar_mode(self) -> None: + """Radar mode with emergency handling.""" + + # EMERGENCY: MPC FCW - immediate blended mode + if self._has_mpc_fcw: + self._mode_manager.request_mode('blended', confidence=1.0, emergency=True) + return + + # If lead detected and not in standstill: always use ACC + if self._has_lead_filtered and not (self._standstill_count > 3): + self._mode_manager.request_mode('acc', confidence=1.0) + return + + # Slow down scenarios: emergency for high urgency, normal for lower urgency + if self._has_slow_down: + if self._urgency > 0.7: + # Emergency: immediate blended mode for high urgency stops + self._mode_manager.request_mode('blended', confidence=1.0, emergency=True) + else: + # Normal: blended with urgency-based confidence + confidence = min(1.0, self._urgency * 1.3) + self._mode_manager.request_mode('blended', confidence=confidence) + return + + # Standstill: use blended + if self._standstill_count > 3: + self._mode_manager.request_mode('blended', confidence=0.9) + return + + # Driving slow: use ACC (but not if actively slowing down) + if self._has_slowness and not self._has_slow_down: + self._mode_manager.request_mode('acc', confidence=0.8) + return + + # Default: ACC + self._mode_manager.request_mode('acc', confidence=0.7) + + def update(self, sm: messaging.SubMaster) -> None: + self._read_params() + + self.set_mpc_fcw_crash_cnt() + + self._update_calculations(sm) + + if self._CP.radarUnavailable: + self._radarless_mode() + else: + self._radar_mode() + + self._mode_manager.update() + self._active = sm['selfdriveState'].experimentalMode and self._enabled + self._frame += 1 diff --git a/sunnypilot/selfdrive/controls/lib/dec/tests/__init__.py b/sunnypilot/selfdrive/controls/lib/dec/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sunnypilot/selfdrive/controls/lib/dec/tests/pytest_dynamic_controller.py b/sunnypilot/selfdrive/controls/lib/dec/tests/pytest_dynamic_controller.py new file mode 100644 index 0000000000..f9da39c03b --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/dec/tests/pytest_dynamic_controller.py @@ -0,0 +1,94 @@ +import pytest + +from openpilot.sunnypilot.selfdrive.controls.lib.dec.dec import DynamicExperimentalController + +class MockLeadOne: + def __init__(self, status=0.0): + self.status = status + +class MockRadarState: + def __init__(self, status=0.0): + self.leadOne = MockLeadOne(status=status) + +class MockCarState: + def __init__(self, vEgo=0.0, vCruise=0.0, standstill=False): + self.vEgo = vEgo + self.vCruise = vCruise + self.standstill = standstill + +class MockModelData: + def __init__(self, valid=True): + size = 33 if valid else 10 # incomplete if invalid + self.position = type("Pos", (), {"x": [0.0] * size})() + self.orientation = type("Ori", (), {"x": [0.0] * size})() + +class MockSelfDriveState: + def __init__(self, experimentalMode=False): + self.experimentalMode = experimentalMode + +class MockParams: + def get_bool(self, name): + return True + +@pytest.fixture +def default_sm(): + sm = { + 'carState': MockCarState(vEgo=10.0, vCruise=20.0), + 'radarState': MockRadarState(status=1.0), + 'modelV2': MockModelData(valid=True), + 'selfdriveState': MockSelfDriveState(experimentalMode=True), + } + return sm + +@pytest.fixture +def mock_cp(): + class CP: + radarUnavailable = False + return CP() + +@pytest.fixture +def mock_mpc(): + class MPC: + crash_cnt = 0 + return MPC() + +# Fake Kalman Filter that always returns a given value +class FakeKalman: + def __init__(self, value=1.0): + self.value = value + def add_data(self, v): pass + def get_value(self): return self.value + def get_confidence(self): return 1.0 + def reset_data(self): pass + +def test_initial_mode_is_acc(mock_cp, mock_mpc): + controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams()) + assert controller.mode() == "acc" + +def test_standstill_triggers_blended(mock_cp, mock_mpc, default_sm): + controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams()) + default_sm['carState'].standstill = True + for _ in range(10): + controller.update(default_sm) + assert controller.mode() == "blended" + +def test_emergency_blended_on_fcw(mock_cp, mock_mpc, default_sm): + controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams()) + mock_mpc.crash_cnt = 1 # simulate FCW + for _ in range(2): + controller.update(default_sm) + assert controller.mode() == "blended" + +def test_radarless_slowdown_triggers_blended(mock_cp, mock_mpc, default_sm): + mock_cp.radarUnavailable = True + controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams()) + + # Force conditions to simulate slowdown + controller._slow_down_filter = FakeKalman(value=1.0) # Ensure urgency triggers slowdown + controller._v_ego_kph = 35.0 + default_sm['modelV2'] = MockModelData(valid=False) # Incomplete trajectory + + for _ in range(3): + controller.update(default_sm) + + assert controller.mode() == "blended" diff --git a/sunnypilot/selfdrive/controls/lib/e2e_alerts_helper.py b/sunnypilot/selfdrive/controls/lib/e2e_alerts_helper.py new file mode 100644 index 0000000000..944bf617e9 --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/e2e_alerts_helper.py @@ -0,0 +1,170 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" + +from cereal import messaging, custom + +from openpilot.common.params import Params +from openpilot.common.realtime import DT_MDL +from openpilot.sunnypilot import PARAMS_UPDATE_PERIOD +from openpilot.sunnypilot.selfdrive.selfdrived.events import EventsSP + +GREEN_LIGHT_X_THRESHOLD = 30 +LEAD_DEPART_DIST_THRESHOLD = 1.0 +TRIGGER_TIMER_THRESHOLD = 0.3 + + +class E2EStates: + INACTIVE = 0 + ARMED = 1 + CONSUMED = 2 + + +class E2EAlertsHelper: + def __init__(self): + self._params = Params() + self.frame = -1 + self.green_light_state = E2EStates.INACTIVE + self.prev_green_light_state = E2EStates.INACTIVE + self.lead_depart_state = E2EStates.INACTIVE + self.prev_lead_depart_state = E2EStates.INACTIVE + + self.green_light_alert = False + self.green_light_alert_enabled = self._params.get_bool("GreenLightAlert") + self.lead_depart_alert = False + self.lead_depart_alert_enabled = self._params.get_bool("LeadDepartAlert") + + self.green_light_trigger_timer = 0 + self.lead_depart_trigger_timer = 0 + self.last_lead_distance = -1 + self.last_moving_frame = -1 + + self.allowed = False + self.last_allowed = False + self.has_lead = False + + self.lead_depart_arm_timer = 0 + self.lead_depart_confirmed_lead = False + self.lead_depart_armed = False + + def _read_params(self) -> None: + if self.frame % int(PARAMS_UPDATE_PERIOD / DT_MDL) == 0: + self.green_light_alert_enabled = self._params.get_bool("GreenLightAlert") + self.lead_depart_alert_enabled = self._params.get_bool("LeadDepartAlert") + + def update_alert_trigger(self, sm: messaging.SubMaster): + CS = sm['carState'] + CC = sm['carControl'] + + model_x = sm['modelV2'].position.x + max_idx = len(model_x) - 1 + self.has_lead = sm['radarState'].leadOne.status + lead_dRel = sm['radarState'].leadOne.dRel + + standstill = CS.standstill + moving = not standstill and CS.vEgo > 0.1 + + if moving: + self.last_moving_frame = self.frame + recent_moving = self.last_moving_frame == -1 or (self.frame - self.last_moving_frame) * DT_MDL < 2.0 + + self.allowed = not moving and not CS.gasPressed and not CC.enabled and not recent_moving + + # Green Light Alert + green_light_trigger = False + if self.green_light_state == E2EStates.ARMED: + if model_x[max_idx] > GREEN_LIGHT_X_THRESHOLD: + self.green_light_trigger_timer += 1 + else: + self.green_light_trigger_timer = 0 + + if self.green_light_trigger_timer * DT_MDL > TRIGGER_TIMER_THRESHOLD: + green_light_trigger = True + elif self.green_light_state != E2EStates.ARMED: + self.green_light_trigger_timer = 0 + + # Lead Departure Alert + close_lead_valid = self.has_lead and lead_dRel < 8.0 + if self.allowed and not self.last_allowed and close_lead_valid: + self.lead_depart_confirmed_lead = True + elif not self.allowed: + self.lead_depart_confirmed_lead = False + + if self.allowed and self.lead_depart_confirmed_lead and close_lead_valid: + self.lead_depart_arm_timer += 1 + + if self.lead_depart_arm_timer * DT_MDL >= 1.0: + self.lead_depart_armed = True + else: + self.lead_depart_arm_timer = 0 + self.lead_depart_armed = False + + lead_depart_trigger = False + if self.lead_depart_state == E2EStates.ARMED: + if self.last_lead_distance == -1 or lead_dRel < self.last_lead_distance: + self.last_lead_distance = lead_dRel + + if self.last_lead_distance != -1 and (lead_dRel - self.last_lead_distance > LEAD_DEPART_DIST_THRESHOLD): + self.lead_depart_trigger_timer += 1 + else: + self.lead_depart_trigger_timer = 0 + + if self.lead_depart_trigger_timer * DT_MDL > TRIGGER_TIMER_THRESHOLD: + lead_depart_trigger = True + elif self.lead_depart_state != E2EStates.ARMED: + self.last_lead_distance = -1 + self.lead_depart_trigger_timer = 0 + + self.last_allowed = self.allowed + + return green_light_trigger, lead_depart_trigger + + @staticmethod + def update_state_machine(state: int, enabled: bool, allowed: bool, triggered: bool) -> tuple[int, bool]: + if state != E2EStates.INACTIVE: + if not allowed or not enabled: + state = E2EStates.INACTIVE + + else: + if state == E2EStates.ARMED: + if triggered: + state = E2EStates.CONSUMED + + elif state == E2EStates.CONSUMED: + pass + + elif state == E2EStates.INACTIVE: + if allowed and enabled: + state = E2EStates.ARMED + + return state, triggered + + def update(self, sm: messaging.SubMaster, events_sp: EventsSP) -> None: + self._read_params() + + green_light_trigger, lead_depart_trigger = self.update_alert_trigger(sm) + + self.prev_green_light_state = self.green_light_state + self.prev_lead_depart_state = self.lead_depart_state + + self.green_light_state, self.green_light_alert = self.update_state_machine( + self.green_light_state, + self.green_light_alert_enabled, + self.allowed and not self.has_lead, + green_light_trigger + ) + + self.lead_depart_state, self.lead_depart_alert = self.update_state_machine( + self.lead_depart_state, + self.lead_depart_alert_enabled, + self.allowed and self.lead_depart_armed, + lead_depart_trigger + ) + + if self.green_light_alert or self.lead_depart_alert: + events_sp.add(custom.OnroadEventSP.EventName.e2eChime) + + self.frame += 1 diff --git a/sunnypilot/selfdrive/controls/lib/lane_turn_desire.py b/sunnypilot/selfdrive/controls/lib/lane_turn_desire.py new file mode 100644 index 0000000000..fa35ebb125 --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/lane_turn_desire.py @@ -0,0 +1,47 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from cereal import custom + +from openpilot.common.constants import CV +from openpilot.common.params import Params + +TurnDirection = custom.ModelDataV2SP.TurnDirection + +LANE_CHANGE_SPEED_MIN = 20 * CV.MPH_TO_MS + + +class LaneTurnController: + def __init__(self, desire_helper): + self.DH = desire_helper + self.turn_direction = TurnDirection.none + self.params = Params() + self.lane_turn_value = float(self.params.get("LaneTurnValue", return_default=True)) * CV.MPH_TO_MS + self.param_read_counter = 0 + self.enabled = self.params.get_bool("LaneTurnDesire") + + def read_params(self): + self.enabled = self.params.get_bool("LaneTurnDesire") + value = float(self.params.get("LaneTurnValue", return_default=True)) * CV.MPH_TO_MS + self.lane_turn_value = min(float(LANE_CHANGE_SPEED_MIN), value) + + def update_params(self) -> None: + if self.param_read_counter % 50 == 0: + self.read_params() + self.param_read_counter += 1 + + def update_lane_turn(self, blindspot_left: bool, blindspot_right: bool, left_blinker: bool, right_blinker: bool, v_ego: float) -> None: + if left_blinker and not right_blinker and v_ego < self.lane_turn_value and not blindspot_left: + self.turn_direction = TurnDirection.turnLeft + elif right_blinker and not left_blinker and v_ego < self.lane_turn_value and not blindspot_right: + self.turn_direction = TurnDirection.turnRight + else: + self.turn_direction = TurnDirection.none + + def get_turn_direction(self): + if not self.enabled: + return TurnDirection.none + return self.turn_direction diff --git a/sunnypilot/selfdrive/controls/lib/latcontrol_torque_ext.py b/sunnypilot/selfdrive/controls/lib/latcontrol_torque_ext.py new file mode 100644 index 0000000000..39525b3b8e --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/latcontrol_torque_ext.py @@ -0,0 +1,38 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" + +from openpilot.sunnypilot.selfdrive.controls.lib.nnlc.nnlc import NeuralNetworkLateralControl +from openpilot.sunnypilot.selfdrive.controls.lib.latcontrol_torque_ext_override import LatControlTorqueExtOverride + + +class LatControlTorqueExt(NeuralNetworkLateralControl, LatControlTorqueExtOverride): + def __init__(self, lac_torque, CP, CP_SP, CI): + NeuralNetworkLateralControl.__init__(self, lac_torque, CP, CP_SP, CI) + LatControlTorqueExtOverride.__init__(self, CP) + + def update(self, CS, VM, pid, params, ff, pid_log, setpoint, measurement, calibrated_pose, roll_compensation, + desired_lateral_accel, actual_lateral_accel, lateral_accel_deadzone, gravity_adjusted_lateral_accel, + desired_curvature, actual_curvature, steer_limited_by_safety, output_torque): + self._ff = ff + self._pid = pid + self._pid_log = pid_log + self._setpoint = setpoint + self._measurement = measurement + self._roll_compensation = roll_compensation + self._lateral_accel_deadzone = lateral_accel_deadzone + self._desired_lateral_accel = desired_lateral_accel + self._actual_lateral_accel = actual_lateral_accel + self._desired_curvature = desired_curvature + self._actual_curvature = actual_curvature + self._gravity_adjusted_lateral_accel = gravity_adjusted_lateral_accel + self._steer_limited_by_safety = steer_limited_by_safety + self._output_torque = output_torque + + self.update_calculations(CS, VM, desired_lateral_accel) + self.update_neural_network_feedforward(CS, params, calibrated_pose) + + return self._pid_log, self._output_torque diff --git a/sunnypilot/selfdrive/controls/lib/latcontrol_torque_ext_base.py b/sunnypilot/selfdrive/controls/lib/latcontrol_torque_ext_base.py new file mode 100644 index 0000000000..c6658bdc78 --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/latcontrol_torque_ext_base.py @@ -0,0 +1,136 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import math +import numpy as np + +from openpilot.common.pid import PIDController +from openpilot.selfdrive.controls.lib.drive_helpers import CONTROL_N +from openpilot.selfdrive.modeld.constants import ModelConstants + +LAT_PLAN_MIN_IDX = 5 +LATERAL_LAG_MOD = 0.0 # seconds, modifies how far in the future we look ahead for the lateral plan + +# from selfdrive/controls/lib/latcontrol_torque.py +KP = 0.8 +KI = 0.15 +INTERP_SPEEDS = [1, 1.5, 2.0, 3.0, 5, 7.5, 10, 15, 30] +KP_INTERP = [250, 120, 65, 30, 11.5, 5.5, 3.5, 2.0, KP] + + +def get_predicted_lateral_jerk(lat_accels, t_diffs): + # compute finite difference between subsequent model_v2.acceleration.y values + # this is just two calls of np.diff followed by an element-wise division + lat_accel_diffs = np.diff(lat_accels) + lat_jerk = lat_accel_diffs / t_diffs + # return as python list + return lat_jerk.tolist() + + +def sign(x): + return 1.0 if x > 0.0 else (-1.0 if x < 0.0 else 0.0) + + +def get_lookahead_value(future_vals, current_val): + if len(future_vals) == 0: + return current_val + + same_sign_vals = [v for v in future_vals if sign(v) == sign(current_val)] + + # if any future val has opposite sign of current val, return 0 + if len(same_sign_vals) < len(future_vals): + return 0.0 + + # otherwise return the value with minimum absolute value + min_val = min(same_sign_vals + [current_val], key=lambda x: abs(x)) + return min_val + + +class LatControlTorqueExtBase: + def __init__(self, lac_torque, CP, CP_SP, CI): + self.model_v2 = None + self.model_valid = False + self.lac_torque = lac_torque + + self.actual_lateral_jerk: float = 0.0 + self.lateral_jerk_setpoint: float = 0.0 + self.lateral_jerk_measurement: float = 0.0 + self.lookahead_lateral_jerk: float = 0.0 + + self.torque_from_lateral_accel_in_torque_space = CI.torque_from_lateral_accel_in_torque_space() + + self._ff = 0.0 + self._pid = PIDController([INTERP_SPEEDS, KP_INTERP], KI) + self._pid_log = None + self._setpoint = 0.0 + self._measurement = 0.0 + self._roll_compensation = 0.0 + self._lateral_accel_deadzone = 0.0 + self._desired_lateral_accel = 0.0 + self._actual_lateral_accel = 0.0 + self._desired_curvature = 0.0 + self._actual_curvature = 0.0 + self._gravity_adjusted_lateral_accel = 0.0 + self._steer_limited_by_safety = False + self._output_torque = 0.0 + + # twilsonco's Lateral Neural Network Feedforward + # Instantaneous lateral jerk changes very rapidly, making it not useful on its own, + # however, we can "look ahead" to the future planned lateral jerk in order to gauge + # whether the current desired lateral jerk will persist into the future, i.e. + # whether it's "deliberate" or not. This allows us to simply ignore short-lived jerk. + # Note that LAT_PLAN_MIN_IDX is defined above and is used in order to prevent + # using a "future" value that is actually planned to occur before the "current" desired + # value, which is offset by the steerActuatorDelay. + # TODO-SP: Reevaluate lookahead v values that determines how low a desired lateral jerk signal needs to + # persist in order to be used. + self.friction_look_ahead_v = [1.4, 2.0] # how many seconds in the future to look ahead in [0, ~2.1] in 0.1 increments + self.friction_look_ahead_bp = [9.0, 30.0] # corresponding speeds in m/s in [0, ~40] in 1.0 increments + + # Scaling the lateral acceleration "friction response" could be helpful for some. + # Increase for a stronger response, decrease for a weaker response. + self.lat_jerk_friction_factor = 0.4 + self.lat_accel_friction_factor = 0.7 # in [0, 3], in 0.05 increments. 3 is arbitrary safety limit + + # precompute time differences between ModelConstants.T_IDXS + self.t_diffs = np.diff(ModelConstants.T_IDXS) + self.desired_lat_jerk_time = CP.steerActuatorDelay + LATERAL_LAG_MOD + + def update_model_v2(self, model_v2): + self.model_v2 = model_v2 + self.model_valid = self.model_v2 is not None and len(self.model_v2.orientation.x) >= CONTROL_N + + def update_lateral_lag(self, lag): + self.desired_lat_jerk_time = max(0.01, lag) + LATERAL_LAG_MOD + + def update_friction_input(self, val_1, val_2): + _error = val_1 - val_2 + _value = self.lat_accel_friction_factor * _error + self.lat_jerk_friction_factor * self.lookahead_lateral_jerk + + return _value + + def update_calculations(self, CS, VM, desired_lateral_accel): + self.actual_lateral_jerk = 0.0 + self.lateral_jerk_setpoint = 0.0 + self.lateral_jerk_measurement = 0.0 + self.lookahead_lateral_jerk = 0.0 + + actual_curvature_rate = -VM.calc_curvature(math.radians(CS.steeringRateDeg), CS.vEgo, 0.0) + self.actual_lateral_jerk = actual_curvature_rate * CS.vEgo ** 2 + + if self.model_valid: + # prepare "look-ahead" desired lateral jerk + lookahead = np.interp(CS.vEgo, self.friction_look_ahead_bp, self.friction_look_ahead_v) + friction_upper_idx = next((i for i, val in enumerate(ModelConstants.T_IDXS) if val > lookahead), 16) + predicted_lateral_jerk = get_predicted_lateral_jerk(self.model_v2.acceleration.y, self.t_diffs) + desired_lateral_jerk = (np.interp(self.desired_lat_jerk_time, ModelConstants.T_IDXS, + self.model_v2.acceleration.y) - desired_lateral_accel) / self.desired_lat_jerk_time + self.lookahead_lateral_jerk = get_lookahead_value(predicted_lateral_jerk[LAT_PLAN_MIN_IDX:friction_upper_idx], desired_lateral_jerk) + if self.lookahead_lateral_jerk == 0.0: + self.actual_lateral_jerk = 0.0 + self.lat_accel_friction_factor = 1.0 + self.lateral_jerk_setpoint = self.lat_jerk_friction_factor * self.lookahead_lateral_jerk + self.lateral_jerk_measurement = self.lat_jerk_friction_factor * self.actual_lateral_jerk diff --git a/sunnypilot/selfdrive/controls/lib/latcontrol_torque_ext_override.py b/sunnypilot/selfdrive/controls/lib/latcontrol_torque_ext_override.py new file mode 100644 index 0000000000..f07a7292c1 --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/latcontrol_torque_ext_override.py @@ -0,0 +1,34 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" + +from openpilot.common.params import Params + + +class LatControlTorqueExtOverride: + def __init__(self, CP): + self.CP = CP + self.params = Params() + self.enforce_torque_control_toggle = self.params.get_bool("EnforceTorqueControl") # only during init + self.torque_override_enabled = self.params.get_bool("TorqueParamsOverrideEnabled") + self.frame = -1 + + def update_override_torque_params(self, torque_params) -> bool: + if not self.enforce_torque_control_toggle: + return False + + self.frame += 1 + if self.frame % 300 == 0: + self.torque_override_enabled = self.params.get_bool("TorqueParamsOverrideEnabled") + + if not self.torque_override_enabled: + return False + + torque_params.latAccelFactor = float(self.params.get("TorqueParamsOverrideLatAccelFactor", return_default=True)) + torque_params.friction = float(self.params.get("TorqueParamsOverrideFriction", return_default=True)) + return True + + return False diff --git a/sunnypilot/selfdrive/controls/lib/latcontrol_torque_v0.py b/sunnypilot/selfdrive/controls/lib/latcontrol_torque_v0.py new file mode 100644 index 0000000000..f7874cc539 --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/latcontrol_torque_v0.py @@ -0,0 +1,128 @@ +import math +import numpy as np +from collections import deque + +from cereal import log +from opendbc.car.lateral import get_friction +from openpilot.common.constants import ACCELERATION_DUE_TO_GRAVITY +from openpilot.common.filter_simple import FirstOrderFilter +from openpilot.selfdrive.controls.lib.latcontrol import LatControl +from openpilot.common.pid import PIDController + +from openpilot.sunnypilot.selfdrive.controls.lib.latcontrol_torque_ext import LatControlTorqueExt + +# At higher speeds (25+mph) we can assume: +# Lateral acceleration achieved by a specific car correlates to +# torque applied to the steering rack. It does not correlate to +# wheel slip, or to speed. + +# This controller applies torque to achieve desired lateral +# accelerations. To compensate for the low speed effects the +# proportional gain is increased at low speeds by the PID controller. +# Additionally, there is friction in the steering wheel that needs +# to be overcome to move it at all, this is compensated for too. + +KP = 1.0 +KI = 0.3 +KD = 0.0 +INTERP_SPEEDS = [1, 1.5, 2.0, 3.0, 5, 7.5, 10, 15, 30] +KP_INTERP = [250, 120, 65, 30, 11.5, 5.5, 3.5, 2.0, KP] + +LP_FILTER_CUTOFF_HZ = 1.2 +LAT_ACCEL_REQUEST_BUFFER_SECONDS = 1.0 +FRICTION_THRESHOLD = 0.3 +VERSION = 0 + + +class LatControlTorque(LatControl): + def __init__(self, CP, CP_SP, CI, dt): + super().__init__(CP, CP_SP, CI, dt) + self.torque_params = CP.lateralTuning.torque.as_builder() + self.torque_from_lateral_accel = CI.torque_from_lateral_accel() + self.lateral_accel_from_torque = CI.lateral_accel_from_torque() + self.pid = PIDController([INTERP_SPEEDS, KP_INTERP], KI, KD, rate=1/self.dt) + self.update_limits() + self.steering_angle_deadzone_deg = self.torque_params.steeringAngleDeadzoneDeg + self.lat_accel_request_buffer_len = int(LAT_ACCEL_REQUEST_BUFFER_SECONDS / self.dt) + self.lat_accel_request_buffer = deque([0.] * self.lat_accel_request_buffer_len , maxlen=self.lat_accel_request_buffer_len) + self.previous_measurement = 0.0 + self.measurement_rate_filter = FirstOrderFilter(0.0, 1 / (2 * np.pi * LP_FILTER_CUTOFF_HZ), self.dt) + + self.extension = LatControlTorqueExt(self, CP, CP_SP, CI) + + def update_live_torque_params(self, latAccelFactor, latAccelOffset, friction): + self.torque_params.latAccelFactor = latAccelFactor + self.torque_params.latAccelOffset = latAccelOffset + self.torque_params.friction = friction + self.update_limits() + + def update_limits(self): + self.pid.set_limits(self.lateral_accel_from_torque(self.steer_max, self.torque_params), + self.lateral_accel_from_torque(-self.steer_max, self.torque_params)) + + def update(self, active, CS, VM, params, steer_limited_by_safety, desired_curvature, calibrated_pose, curvature_limited, lat_delay): + # Override torque params from extension + if self.extension.update_override_torque_params(self.torque_params): + self.update_limits() + + pid_log = log.ControlsState.LateralTorqueState.new_message() + pid_log.version = VERSION + if not active: + output_torque = 0.0 + pid_log.active = False + else: + measured_curvature = -VM.calc_curvature(math.radians(CS.steeringAngleDeg - params.angleOffsetDeg), CS.vEgo, params.roll) + roll_compensation = params.roll * ACCELERATION_DUE_TO_GRAVITY + curvature_deadzone = abs(VM.calc_curvature(math.radians(self.steering_angle_deadzone_deg), CS.vEgo, 0.0)) + lateral_accel_deadzone = curvature_deadzone * CS.vEgo ** 2 + + delay_frames = int(np.clip(lat_delay / self.dt, 1, self.lat_accel_request_buffer_len)) + expected_lateral_accel = self.lat_accel_request_buffer[-delay_frames] + # TODO factor out lateral jerk from error to later replace it with delay independent alternative + future_desired_lateral_accel = desired_curvature * CS.vEgo ** 2 + self.lat_accel_request_buffer.append(future_desired_lateral_accel) + gravity_adjusted_future_lateral_accel = future_desired_lateral_accel - roll_compensation + desired_lateral_jerk = (future_desired_lateral_accel - expected_lateral_accel) / lat_delay + + measurement = measured_curvature * CS.vEgo ** 2 + measurement_rate = self.measurement_rate_filter.update((measurement - self.previous_measurement) / self.dt) + self.previous_measurement = measurement + + setpoint = lat_delay * desired_lateral_jerk + expected_lateral_accel + error = setpoint - measurement + + # do error correction in lateral acceleration space, convert at end to handle non-linear torque responses correctly + pid_log.error = float(error) + ff = gravity_adjusted_future_lateral_accel + # latAccelOffset corrects roll compensation bias from device roll misalignment relative to car roll + ff -= self.torque_params.latAccelOffset + # TODO jerk is weighted by lat_delay for legacy reasons, but should be made independent of it + ff += get_friction(error, lateral_accel_deadzone, FRICTION_THRESHOLD, self.torque_params) + + freeze_integrator = steer_limited_by_safety or CS.steeringPressed or CS.vEgo < 5 + output_lataccel = self.pid.update(pid_log.error, + -measurement_rate, + feedforward=ff, + speed=CS.vEgo, + freeze_integrator=freeze_integrator) + output_torque = self.torque_from_lateral_accel(output_lataccel, self.torque_params) + + # Lateral acceleration torque controller extension updates + # Overrides pid_log.error and output_torque + pid_log, output_torque = self.extension.update(CS, VM, self.pid, params, ff, pid_log, setpoint, measurement, calibrated_pose, roll_compensation, + future_desired_lateral_accel, measurement, lateral_accel_deadzone, gravity_adjusted_future_lateral_accel, + desired_curvature, measured_curvature, steer_limited_by_safety, output_torque) + + pid_log.active = True + pid_log.p = float(self.pid.p) + pid_log.i = float(self.pid.i) + pid_log.d = float(self.pid.d) + pid_log.f = float(self.pid.f) + pid_log.output = float(-output_torque) # TODO: log lat accel? + pid_log.actualLateralAccel = float(measurement) + pid_log.desiredLateralAccel = float(setpoint) + pid_log.desiredLateralJerk = float(desired_lateral_jerk) + pid_log.saturated = bool(self._check_saturation(self.steer_max - abs(output_torque) < 1e-3, CS, steer_limited_by_safety, curvature_limited)) + + # TODO left is positive in this convention + return -output_torque, 0.0, pid_log diff --git a/sunnypilot/selfdrive/controls/lib/latcontrol_torque_versions.json b/sunnypilot/selfdrive/controls/lib/latcontrol_torque_versions.json new file mode 100644 index 0000000000..21b5884a01 --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/latcontrol_torque_versions.json @@ -0,0 +1,8 @@ +{ + "v1.0": { + "version": "1.0" + }, + "v0.0": { + "version": "0.0" + } +} diff --git a/sunnypilot/selfdrive/controls/lib/longitudinal_planner.py b/sunnypilot/selfdrive/controls/lib/longitudinal_planner.py new file mode 100644 index 0000000000..6efda4585f --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/longitudinal_planner.py @@ -0,0 +1,141 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" + +from cereal import messaging, custom +from opendbc.car import structs +from openpilot.common.constants import CV +from openpilot.selfdrive.car.cruise import V_CRUISE_MAX +from openpilot.sunnypilot.selfdrive.controls.lib.dec.dec import DynamicExperimentalController +from openpilot.sunnypilot.selfdrive.controls.lib.e2e_alerts_helper import E2EAlertsHelper +from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control.smart_cruise_control import SmartCruiseControl +from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.speed_limit_assist import SpeedLimitAssist +from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.speed_limit_resolver import SpeedLimitResolver +from openpilot.sunnypilot.selfdrive.selfdrived.events import EventsSP +from openpilot.sunnypilot.models.helpers import get_active_bundle + +DecState = custom.LongitudinalPlanSP.DynamicExperimentalControl.DynamicExperimentalControlState +LongitudinalPlanSource = custom.LongitudinalPlanSP.LongitudinalPlanSource + + +class LongitudinalPlannerSP: + def __init__(self, CP: structs.CarParams, CP_SP: structs.CarParamsSP, mpc): + self.events_sp = EventsSP() + self.resolver = SpeedLimitResolver() + self.dec = DynamicExperimentalController(CP, mpc) + self.scc = SmartCruiseControl() + self.resolver = SpeedLimitResolver() + self.sla = SpeedLimitAssist(CP, CP_SP) + self.generation = int(model_bundle.generation) if (model_bundle := get_active_bundle()) else None + self.source = LongitudinalPlanSource.cruise + self.e2e_alerts_helper = E2EAlertsHelper() + + self.output_v_target = 0. + self.output_a_target = 0. + + def is_e2e(self, sm: messaging.SubMaster) -> bool: + experimental_mode = sm['selfdriveState'].experimentalMode + if not self.dec.active(): + return experimental_mode + + return experimental_mode and self.dec.mode() == "blended" + + def update_targets(self, sm: messaging.SubMaster, v_ego: float, a_ego: float, v_cruise: float) -> tuple[float, float]: + CS = sm['carState'] + v_cruise_cluster_kph = min(CS.vCruiseCluster, V_CRUISE_MAX) + v_cruise_cluster = v_cruise_cluster_kph * CV.KPH_TO_MS + + long_enabled = sm['carControl'].enabled + long_override = sm['carControl'].cruiseControl.override + + # Smart Cruise Control + self.scc.update(sm, long_enabled, long_override, v_ego, a_ego, v_cruise) + + # Speed Limit Resolver + self.resolver.update(v_ego, sm) + + # Speed Limit Assist + has_speed_limit = self.resolver.speed_limit_valid or self.resolver.speed_limit_last_valid + self.sla.update(long_enabled, long_override, v_ego, a_ego, v_cruise_cluster, self.resolver.speed_limit, + self.resolver.speed_limit_final_last, has_speed_limit, self.resolver.distance, self.events_sp) + + targets = { + LongitudinalPlanSource.cruise: (v_cruise, a_ego), + LongitudinalPlanSource.sccVision: (self.scc.vision.output_v_target, self.scc.vision.output_a_target), + LongitudinalPlanSource.sccMap: (self.scc.map.output_v_target, self.scc.map.output_a_target), + LongitudinalPlanSource.speedLimitAssist: (self.sla.output_v_target, self.sla.output_a_target), + } + + self.source = min(targets, key=lambda k: targets[k][0]) + self.output_v_target, self.output_a_target = targets[self.source] + return self.output_v_target, self.output_a_target + + def update(self, sm: messaging.SubMaster) -> None: + self.events_sp.clear() + self.dec.update(sm) + self.e2e_alerts_helper.update(sm, self.events_sp) + + def publish_longitudinal_plan_sp(self, sm: messaging.SubMaster, pm: messaging.PubMaster) -> None: + plan_sp_send = messaging.new_message('longitudinalPlanSP') + + plan_sp_send.valid = sm.all_checks(service_list=['carState', 'controlsState']) + + longitudinalPlanSP = plan_sp_send.longitudinalPlanSP + longitudinalPlanSP.longitudinalPlanSource = self.source + longitudinalPlanSP.vTarget = float(self.output_v_target) + longitudinalPlanSP.aTarget = float(self.output_a_target) + longitudinalPlanSP.events = self.events_sp.to_msg() + + # Dynamic Experimental Control + dec = longitudinalPlanSP.dec + dec.state = DecState.blended if self.dec.mode() == 'blended' else DecState.acc + dec.enabled = self.dec.enabled() + dec.active = self.dec.active() + + # Smart Cruise Control + smartCruiseControl = longitudinalPlanSP.smartCruiseControl + # Vision Control + sccVision = smartCruiseControl.vision + sccVision.state = self.scc.vision.state + sccVision.vTarget = float(self.scc.vision.output_v_target) + sccVision.aTarget = float(self.scc.vision.output_a_target) + sccVision.currentLateralAccel = float(self.scc.vision.current_lat_acc) + sccVision.maxPredictedLateralAccel = float(self.scc.vision.max_pred_lat_acc) + sccVision.enabled = self.scc.vision.is_enabled + sccVision.active = self.scc.vision.is_active + # Map Control + sccMap = smartCruiseControl.map + sccMap.state = self.scc.map.state + sccMap.vTarget = float(self.scc.map.output_v_target) + sccMap.aTarget = float(self.scc.map.output_a_target) + sccMap.enabled = self.scc.map.is_enabled + sccMap.active = self.scc.map.is_active + + # Speed Limit + speedLimit = longitudinalPlanSP.speedLimit + resolver = speedLimit.resolver + resolver.speedLimit = float(self.resolver.speed_limit) + resolver.speedLimitLast = float(self.resolver.speed_limit_last) + resolver.speedLimitFinal = float(self.resolver.speed_limit_final) + resolver.speedLimitFinalLast = float(self.resolver.speed_limit_final_last) + resolver.speedLimitValid = self.resolver.speed_limit_valid + resolver.speedLimitLastValid = self.resolver.speed_limit_last_valid + resolver.speedLimitOffset = float(self.resolver.speed_limit_offset) + resolver.distToSpeedLimit = float(self.resolver.distance) + resolver.source = self.resolver.source + assist = speedLimit.assist + assist.state = self.sla.state + assist.enabled = self.sla.is_enabled + assist.active = self.sla.is_active + assist.vTarget = float(self.sla.output_v_target) + assist.aTarget = float(self.sla.output_a_target) + + # E2E Alerts + e2eAlerts = longitudinalPlanSP.e2eAlerts + e2eAlerts.greenLightAlert = self.e2e_alerts_helper.green_light_alert + e2eAlerts.leadDepartAlert = self.e2e_alerts_helper.lead_depart_alert + + pm.send('longitudinalPlanSP', plan_sp_send) diff --git a/sunnypilot/selfdrive/controls/lib/nnlc/__init__.py b/sunnypilot/selfdrive/controls/lib/nnlc/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sunnypilot/selfdrive/controls/lib/nnlc/helpers.py b/sunnypilot/selfdrive/controls/lib/nnlc/helpers.py new file mode 100644 index 0000000000..c34fa435c2 --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/nnlc/helpers.py @@ -0,0 +1,68 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import os +import tomllib +from difflib import SequenceMatcher + +from opendbc.car import structs +from openpilot.common.basedir import BASEDIR + +TORQUE_NN_MODEL_PATH = os.path.join(BASEDIR, "sunnypilot", "neural_network_data", "neural_network_lateral_control") +TORQUE_NN_MODEL_SUBSTITUTE_PATH = os.path.join(BASEDIR, "opendbc", "car", "torque_data/substitute.toml") +MOCK_MODEL_PATH = os.path.join(TORQUE_NN_MODEL_PATH, "MOCK.json") + + +def similarity(s1: str, s2: str) -> float: + return SequenceMatcher(None, s1, s2).ratio() + + +def get_nn_model_path(CP: structs.CarParams) -> tuple[str, str, bool]: + car_fingerprint = CP.carFingerprint + eps_fw = str(next((fw.fwVersion for fw in CP.carFw if fw.ecu == "eps"), "")) + + def check_nn_path(_nn_candidate): + _model_path = None + _max_similarity = -1.0 + for f in os.listdir(TORQUE_NN_MODEL_PATH): + if f.endswith(".json"): + model = os.path.splitext(f)[0] + similarity_score = similarity(model, _nn_candidate) + if similarity_score > _max_similarity: + _max_similarity = similarity_score + _model_path = os.path.join(TORQUE_NN_MODEL_PATH, f) + return _model_path, _max_similarity + + if len(eps_fw) > 3: + eps_fw = eps_fw.replace("\\", "") + nn_candidate = f"{car_fingerprint} {eps_fw}" + else: + nn_candidate = car_fingerprint + + model_path, max_similarity = check_nn_path(nn_candidate) + exact_match = max_similarity >= 0.99 + + if car_fingerprint not in model_path or 0.0 <= max_similarity < 0.9: + nn_candidate = car_fingerprint + model_path, max_similarity = check_nn_path(nn_candidate) + exact_match = max_similarity >= 0.99 + + if 0.0 <= max_similarity < 0.9: + with open(TORQUE_NN_MODEL_SUBSTITUTE_PATH, 'rb') as f: + sub = tomllib.load(f) + sub_candidate = sub.get(car_fingerprint, car_fingerprint) + + for candidate in [car_fingerprint, sub_candidate]: + model_path, max_similarity = check_nn_path(candidate) + + exact_match = False + + if CP.steerControlType == structs.CarParams.SteerControlType.angle: + model_path = MOCK_MODEL_PATH + + model_name = os.path.splitext(os.path.basename(model_path))[0] + + return model_path, model_name, exact_match diff --git a/sunnypilot/selfdrive/controls/lib/nnlc/model.py b/sunnypilot/selfdrive/controls/lib/nnlc/model.py new file mode 100644 index 0000000000..ab6706bf36 --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/nnlc/model.py @@ -0,0 +1,83 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from json import load +import numpy as np + +from openpilot.selfdrive.modeld.parse_model_outputs import safe_exp + +# dict used to rename activation functions whose names aren't valid python identifiers +ACTIVATION_FUNCTION_NAMES = {'σ': 'sigmoid'} + + +class NNTorqueModel: + def __init__(self, params_file, zero_bias=False): + with open(params_file) as f: + params = load(f) + + self.input_size = params["input_size"] + self.output_size = params["output_size"] + self.input_mean = np.array(params["input_mean"], dtype=np.float32).T + self.input_std = np.array(params["input_std"], dtype=np.float32).T + self.layers = [] + self.friction_override = False + + for layer_params in params["layers"]: + W = np.array(layer_params[next(key for key in layer_params.keys() if key.endswith('_W'))], dtype=np.float32).T + b = np.array(layer_params[next(key for key in layer_params.keys() if key.endswith('_b'))], dtype=np.float32).T + if zero_bias: + b = np.zeros_like(b) + activation = layer_params["activation"] + for k, v in ACTIVATION_FUNCTION_NAMES.items(): + activation = activation.replace(k, v) + self.layers.append((W, b, activation)) + + self.validate_layers() + self.check_for_friction_override() + + # Begin activation functions. + # These are called by name using the keys in the model json file + @staticmethod + def sigmoid(x): + return 1 / (1 + safe_exp(-x)) + + @staticmethod + def identity(x): + return x + # End activation functions + + def forward(self, x): + for W, b, activation in self.layers: + x = getattr(self, activation)(x.dot(W) + b) + return x + + def evaluate(self, input_array): + in_len = len(input_array) + if in_len != self.input_size: + # If the input is length 2-4, then it's a simplified evaluation. + # In that case, need to add on zeros to fill out the input array to match the correct length. + if 2 <= in_len: + input_array = input_array + [0] * (self.input_size - in_len) + else: + raise ValueError(f"Input array length {len(input_array)} must be length 2 or greater") + + input_array = np.array(input_array, dtype=np.float32) + + # Rescale the input array using the input_mean and input_std + input_array = (input_array - self.input_mean) / self.input_std + + output_array = self.forward(input_array) + + return float(output_array[0, 0]) + + def validate_layers(self): + for _, _, activation in self.layers: + if not hasattr(self, activation): + raise ValueError(f"Unknown activation: {activation}") + + def check_for_friction_override(self): + y = self.evaluate([10.0, 0.0, 0.2]) + self.friction_override = (y < 0.1) diff --git a/sunnypilot/selfdrive/controls/lib/nnlc/nnlc.py b/sunnypilot/selfdrive/controls/lib/nnlc/nnlc.py new file mode 100644 index 0000000000..2db88299ca --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/nnlc/nnlc.py @@ -0,0 +1,164 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from collections import deque +import math +import numpy as np + +from opendbc.car.lateral import FRICTION_THRESHOLD, get_friction +from opendbc.sunnypilot.car.interfaces import LatControlInputs +from opendbc.sunnypilot.car.lateral_ext import get_friction as get_friction_in_torque_space +from openpilot.common.filter_simple import FirstOrderFilter +from openpilot.common.params import Params +from openpilot.selfdrive.modeld.constants import ModelConstants +from openpilot.sunnypilot.selfdrive.controls.lib.latcontrol_torque_ext_base import LatControlTorqueExtBase, sign +from openpilot.sunnypilot.selfdrive.controls.lib.nnlc.helpers import MOCK_MODEL_PATH +from openpilot.sunnypilot.selfdrive.controls.lib.nnlc.model import NNTorqueModel + +LOW_SPEED_X = [0, 10, 20, 30] +LOW_SPEED_Y = [12, 3, 1, 0] + + +# At a given roll, if pitch magnitude increases, the +# gravitational acceleration component starts pointing +# in the longitudinal direction, decreasing the lateral +# acceleration component. Here we do the same thing +# to the roll value itself, then passed to nnff. +def roll_pitch_adjust(roll, pitch): + return roll * math.cos(pitch) + + +class NeuralNetworkLateralControl(LatControlTorqueExtBase): + def __init__(self, lac_torque, CP, CP_SP, CI): + super().__init__(lac_torque, CP, CP_SP, CI) + self.params = Params() + self.enabled = self.params.get_bool("NeuralNetworkLateralControl") + self.has_nn_model = CP_SP.neuralNetworkLateralControl.model.path != MOCK_MODEL_PATH + + # NN model takes current v_ego, lateral_accel, lat accel/jerk error, roll, and past/future/planned data + # of lat accel and roll + # Past value is computed using previous desired lat accel and observed roll + self.model = NNTorqueModel(CP_SP.neuralNetworkLateralControl.model.path) + + self.pitch = FirstOrderFilter(0.0, 0.5, 0.01) + self.pitch_last = 0.0 + + # setup future time offsets + self.future_times = [0.3, 0.6, 1.0, 1.5] # seconds in the future + self.nn_future_times = [i + self.desired_lat_jerk_time for i in self.future_times] + + # setup past time offsets + self.past_times = [-0.3, -0.2, -0.1] + history_check_frames = [int(abs(i)*100) for i in self.past_times] + self.history_frame_offsets = [history_check_frames[0] - i for i in history_check_frames] + self.lateral_accel_desired_deque = deque(maxlen=history_check_frames[0]) + self.roll_deque = deque(maxlen=history_check_frames[0]) + self.error_deque = deque(maxlen=history_check_frames[0]) + self.past_future_len = len(self.past_times) + len(self.nn_future_times) + + @property + def _nnlc_enabled(self): + return self.enabled and self.model_valid and self.has_nn_model + + def update_limits(self): + if not self._nnlc_enabled: + return + + self._pid.set_limits(self.lac_torque.steer_max, -self.lac_torque.steer_max) + + def update_lateral_lag(self, lag): + super().update_lateral_lag(lag) + self.nn_future_times = [t + self.desired_lat_jerk_time for t in self.future_times] + + def update_feedforward_torque_space(self, CS): + torque_from_setpoint = self.torque_from_lateral_accel_in_torque_space(LatControlInputs(self._setpoint, self._roll_compensation, CS.vEgo, CS.aEgo), + self.lac_torque.torque_params, gravity_adjusted=False) + torque_from_measurement = self.torque_from_lateral_accel_in_torque_space(LatControlInputs(self._measurement, self._roll_compensation, CS.vEgo, CS.aEgo), + self.lac_torque.torque_params, gravity_adjusted=False) + self._pid_log.error = float(torque_from_setpoint - torque_from_measurement) + self._ff = self.torque_from_lateral_accel_in_torque_space(LatControlInputs(self._gravity_adjusted_lateral_accel, self._roll_compensation, + CS.vEgo, CS.aEgo), self.lac_torque.torque_params, gravity_adjusted=True) + self._ff += get_friction_in_torque_space(self._desired_lateral_accel - self._actual_lateral_accel, self._lateral_accel_deadzone, + FRICTION_THRESHOLD, self.lac_torque.torque_params) + + def update_output_torque(self, CS): + freeze_integrator = self._steer_limited_by_safety or CS.steeringPressed or CS.vEgo < 5 + self._output_torque = self._pid.update(self._pid_log.error, + feedforward=self._ff, + speed=CS.vEgo, + freeze_integrator=freeze_integrator) + + def update_neural_network_feedforward(self, CS, params, calibrated_pose) -> None: + if not self._nnlc_enabled: + return + + self.update_feedforward_torque_space(CS) + + low_speed_factor = float(np.interp(CS.vEgo, LOW_SPEED_X, LOW_SPEED_Y)) ** 2 + self._setpoint = self._desired_lateral_accel + low_speed_factor * self._desired_curvature + self._measurement = self._actual_lateral_accel + low_speed_factor * self._actual_curvature + + # update past data + roll = params.roll + if calibrated_pose is not None: + pitch = self.pitch.update(calibrated_pose.orientation.pitch) + roll = roll_pitch_adjust(roll, pitch) + self.pitch_last = pitch + self.roll_deque.append(roll) + self.lateral_accel_desired_deque.append(self._desired_lateral_accel) + + # prepare past and future values + # adjust future times to account for longitudinal acceleration + adjusted_future_times = [t + 0.5 * CS.aEgo * (t / max(CS.vEgo, 1.0)) for t in self.nn_future_times] + past_rolls = [self.roll_deque[min(len(self.roll_deque) - 1, i)] for i in self.history_frame_offsets] + future_rolls = [roll_pitch_adjust(np.interp(t, ModelConstants.T_IDXS, self.model_v2.orientation.x) + roll, + np.interp(t, ModelConstants.T_IDXS, self.model_v2.orientation.y) + self.pitch_last) for t in + adjusted_future_times] + past_lateral_accels_desired = [self.lateral_accel_desired_deque[min(len(self.lateral_accel_desired_deque) - 1, i)] + for i in self.history_frame_offsets] + future_planned_lateral_accels = [np.interp(t, ModelConstants.T_IDXS, self.model_v2.acceleration.y) for t in + adjusted_future_times] + + # compute NNFF error response + nnff_setpoint_input = [CS.vEgo, self._setpoint, self.lateral_jerk_setpoint, roll] \ + + [self._setpoint] * self.past_future_len \ + + past_rolls + future_rolls + # past lateral accel error shouldn't count, so use past desired like the setpoint input + nnff_measurement_input = [CS.vEgo, self._measurement, self.lateral_jerk_measurement, roll] \ + + [self._measurement] * self.past_future_len \ + + past_rolls + future_rolls + torque_from_setpoint = self.model.evaluate(nnff_setpoint_input) + torque_from_measurement = self.model.evaluate(nnff_measurement_input) + self._pid_log.error = torque_from_setpoint - torque_from_measurement + + # The "pure" NNLC error response can be too weak for cars whose models were trained + # with a lack of high-magnitude lateral acceleration data, for which the NNLC model + # torque response flattens out at high lateral accelerations. + # This workaround blends in a guaranteed stronger error response only when the + # desired lateral acceleration is high enough to warrant it, by using the lateral acceleration + # error as the input to the NNLC model. This is not ideal, and potentially degrades the NNLC + # accuracy for cars that don't have this issue, but it's necessary until a better NNLC model + # structure is used that doesn't create this issue when high-magnitude data is missing. + error_blend_factor = float(np.interp(abs(self._desired_lateral_accel), [1.0, 2.0], [0.0, 1.0])) + if error_blend_factor > 0.0: # blend in stronger error response when in high lat accel + # NNFF inputs 5+ are optional, and if left out are replaced with 0.0 inside the NNFF class + nnff_error_input = [CS.vEgo, self._setpoint - self._measurement, self.lateral_jerk_setpoint - self.lateral_jerk_measurement, 0.0] + torque_from_error = self.model.evaluate(nnff_error_input) + if sign(self._pid_log.error) == sign(torque_from_error) and abs(self._pid_log.error) < abs(torque_from_error): + self._pid_log.error = self._pid_log.error * (1.0 - error_blend_factor) + torque_from_error * error_blend_factor + + # compute feedforward (same as nn setpoint output) + friction_input = self.update_friction_input(self._setpoint, self._measurement) + nn_input = [CS.vEgo, self._desired_lateral_accel, friction_input, roll] \ + + past_lateral_accels_desired + future_planned_lateral_accels \ + + past_rolls + future_rolls + self._ff = self.model.evaluate(nn_input) + + # apply friction override for cars with low NN friction response + if self.model.friction_override: + self._pid_log.error += get_friction(friction_input, self._lateral_accel_deadzone, FRICTION_THRESHOLD, self.lac_torque.torque_params) + + self.update_output_torque(CS) diff --git a/sunnypilot/selfdrive/controls/lib/nnlc/tests/__init__.py b/sunnypilot/selfdrive/controls/lib/nnlc/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sunnypilot/selfdrive/controls/lib/nnlc/tests/test_fingerprint.py b/sunnypilot/selfdrive/controls/lib/nnlc/tests/test_fingerprint.py new file mode 100644 index 0000000000..07e7d2852d --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/nnlc/tests/test_fingerprint.py @@ -0,0 +1,43 @@ +from opendbc.car.car_helpers import interfaces +from opendbc.car.honda.values import CAR as HONDA +from opendbc.car.hyundai.values import CAR as HYUNDAI +from opendbc.car.nissan.values import CAR as NISSAN +from opendbc.car.toyota.values import CAR as TOYOTA +from opendbc.car.tesla.values import CAR as TESLA +from openpilot.common.parameterized import parameterized +from openpilot.common.params import Params +from openpilot.sunnypilot.selfdrive.car import interfaces as sunnypilot_interfaces + + +FINGERPRINT_EXACT_MATCH = [HONDA.HONDA_CIVIC_BOSCH, TOYOTA.TOYOTA_RAV4_TSS2_2022, HYUNDAI.HYUNDAI_IONIQ_5] +FINGERPRINT_FUZZY_MATCH = [HONDA.HONDA_CIVIC_BOSCH_DIESEL, HYUNDAI.GENESIS_G70_2020, HYUNDAI.HYUNDAI_IONIQ_6] +FINGERPRINT_ANGLE_NO_MATCH = [TOYOTA.TOYOTA_RAV4_TSS2_2023, NISSAN.NISSAN_LEAF, TESLA.TESLA_MODEL_3] + + +class TestNNLCFingerprintBase: + + @staticmethod + def _setup_platform(car_name): + CarInterface = interfaces[car_name] + CP = CarInterface.get_non_essential_params(car_name) + CP_SP = CarInterface.get_non_essential_params_sp(CP, car_name) + CI = CarInterface(CP, CP_SP) + + sunnypilot_interfaces.setup_interfaces(CI, Params()) + + return CI + + @parameterized.expand(FINGERPRINT_EXACT_MATCH) + def test_exact_fingerprint(self, car_name): + CI = self._setup_platform(car_name) + assert CI.CP_SP.neuralNetworkLateralControl.model.name != "MOCK" and not CI.CP_SP.neuralNetworkLateralControl.fuzzyFingerprint + + @parameterized.expand(FINGERPRINT_FUZZY_MATCH) + def test_fuzzy_fingerprint(self, car_name): + CI = self._setup_platform(car_name) + assert CI.CP_SP.neuralNetworkLateralControl.model.name != "MOCK" and CI.CP_SP.neuralNetworkLateralControl.fuzzyFingerprint + + @parameterized.expand(FINGERPRINT_ANGLE_NO_MATCH) + def test_no_fingerprint(self, car_name): + CI = self._setup_platform(car_name) + assert CI.CP_SP.neuralNetworkLateralControl.model.name == "MOCK" diff --git a/sunnypilot/selfdrive/controls/lib/nnlc/tests/test_load_model.py b/sunnypilot/selfdrive/controls/lib/nnlc/tests/test_load_model.py new file mode 100644 index 0000000000..3594588ea2 --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/nnlc/tests/test_load_model.py @@ -0,0 +1,31 @@ +from opendbc.car.car_helpers import interfaces +from opendbc.car.honda.values import CAR as HONDA +from opendbc.car.hyundai.values import CAR as HYUNDAI +from opendbc.car.toyota.values import CAR as TOYOTA +from openpilot.common.parameterized import parameterized +from openpilot.common.params import Params +from openpilot.common.realtime import DT_CTRL +from openpilot.selfdrive.car.helpers import convert_to_capnp +from openpilot.selfdrive.controls.lib.latcontrol_torque import LatControlTorque +from openpilot.sunnypilot.selfdrive.car import interfaces as sunnypilot_interfaces + + +class TestNNTorqueModel: + + @parameterized.expand([HONDA.HONDA_CIVIC, TOYOTA.TOYOTA_RAV4, HYUNDAI.HYUNDAI_SANTA_CRUZ_1ST_GEN]) + def test_load_model(self, car_name): + params = Params() + params.put_bool("NeuralNetworkLateralControl", True) + + CarInterface = interfaces[car_name] + CP = CarInterface.get_non_essential_params(car_name) + CP_SP = CarInterface.get_non_essential_params_sp(CP, car_name) + CI = CarInterface(CP, CP_SP) + + sunnypilot_interfaces.setup_interfaces(CI, params) + + CP_SP = convert_to_capnp(CP_SP) + + controller = LatControlTorque(CP.as_reader(), CP_SP.as_reader(), CI, DT_CTRL) + + assert controller.extension.has_nn_model diff --git a/sunnypilot/selfdrive/controls/lib/nnlc/tests/test_nnlc.py b/sunnypilot/selfdrive/controls/lib/nnlc/tests/test_nnlc.py new file mode 100644 index 0000000000..a108e4ce29 --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/nnlc/tests/test_nnlc.py @@ -0,0 +1,102 @@ +import numpy as np + +from cereal import car, log, messaging +from opendbc.car.car_helpers import interfaces +from opendbc.car.gm.values import CAR as GM +from opendbc.car.honda.values import CAR as HONDA +from opendbc.car.hyundai.values import CAR as HYUNDAI +from opendbc.car.toyota.values import CAR as TOYOTA +from opendbc.car.vehicle_model import VehicleModel +from openpilot.common.parameterized import parameterized +from openpilot.common.params import Params +from openpilot.common.realtime import DT_CTRL +from openpilot.selfdrive.car.helpers import convert_to_capnp +from openpilot.selfdrive.controls.lib.latcontrol_torque import LatControlTorque +from openpilot.selfdrive.locationd.helpers import Pose +from openpilot.common.mock.generators import generate_livePose +from openpilot.sunnypilot.selfdrive.car import interfaces as sunnypilot_interfaces +from openpilot.selfdrive.modeld.constants import ModelConstants + + +def generate_modelV2(): + model = messaging.new_message('modelV2') + position = log.XYZTData.new_message() + speed = 30 + position.x = [float(x) for x in (speed + 0.5) * np.array(ModelConstants.T_IDXS)] + model.modelV2.position = position + orientation = log.XYZTData.new_message() + curvature = 0.05 + orientation.x = [float(curvature) for _ in ModelConstants.T_IDXS] + orientation.y = [0.0 for _ in ModelConstants.T_IDXS] + model.modelV2.orientation = orientation + velocity = log.XYZTData.new_message() + velocity.x = [float(x) for x in (speed + 0.5) * np.ones_like(ModelConstants.T_IDXS)] + velocity.x[0] = float(speed) # always start at current speed + model.modelV2.velocity = velocity + acceleration = log.XYZTData.new_message() + acceleration.x = [float(x) for x in np.zeros_like(ModelConstants.T_IDXS)] + acceleration.y = [float(y) for y in np.zeros_like(ModelConstants.T_IDXS)] + model.modelV2.acceleration = acceleration + + return model + + +class TestNeuralNetworkLateralControl: + + @parameterized.expand([HONDA.HONDA_CIVIC, TOYOTA.TOYOTA_RAV4, HYUNDAI.HYUNDAI_SANTA_CRUZ_1ST_GEN, GM.CHEVROLET_BOLT_EUV]) + def test_saturation(self, car_name): + params = Params() + params.put_bool("NeuralNetworkLateralControl", True) + + CarInterface = interfaces[car_name] + CP = CarInterface.get_non_essential_params(car_name) + CP_SP = CarInterface.get_non_essential_params_sp(CP, car_name) + CI = CarInterface(CP, CP_SP) + + sunnypilot_interfaces.setup_interfaces(CI, params) + + CP_SP = convert_to_capnp(CP_SP) + VM = VehicleModel(CP) + + controller = LatControlTorque(CP.as_reader(), CP_SP.as_reader(), CI, DT_CTRL) + torque_params = CP.lateralTuning.torque + + CS = car.CarState.new_message() + CS.vEgo = 30 + CS.steeringPressed = False + + params = log.LiveParametersData.new_message() + + lp = generate_livePose() + pose = Pose.from_live_pose(lp.livePose) + + mdl = generate_modelV2() + sm = {'modelV2': mdl.modelV2} + model_v2 = sm['modelV2'] + controller.extension.model_v2 = model_v2 + + # Saturate for curvature limited and controller limited + test_lag = 0.3 + for _ in range(1000): + controller.extension.update_model_v2(model_v2) + controller.extension.update_lateral_lag(test_lag) + controller.update_live_torque_params(torque_params.latAccelFactor, torque_params.latAccelOffset, torque_params.friction) + controller.extension.update_limits() + _, _, lac_log = controller.update(True, CS, VM, params, False, 0, pose, True, 0.2) + assert lac_log.saturated + + for _ in range(1000): + controller.extension.update_model_v2(model_v2) + controller.extension.update_lateral_lag(test_lag) + controller.update_live_torque_params(torque_params.latAccelFactor, torque_params.latAccelOffset, torque_params.friction) + controller.extension.update_limits() + _, _, lac_log = controller.update(True, CS, VM, params, False, 0, pose, False, 0.2) + assert not lac_log.saturated + + for _ in range(1000): + controller.extension.update_model_v2(model_v2) + controller.extension.update_lateral_lag(test_lag) + controller.update_live_torque_params(torque_params.latAccelFactor, torque_params.latAccelOffset, torque_params.friction) + controller.extension.update_limits() + _, _, lac_log = controller.update(True, CS, VM, params, False, 1, pose, False, 0.2) + assert lac_log.saturated diff --git a/sunnypilot/selfdrive/controls/lib/smart_cruise_control/__init__.py b/sunnypilot/selfdrive/controls/lib/smart_cruise_control/__init__.py new file mode 100644 index 0000000000..271f49dcc2 --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/smart_cruise_control/__init__.py @@ -0,0 +1,9 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from openpilot.common.constants import CV + +MIN_V = 20 * CV.KPH_TO_MS # Do not operate under 20 km/h diff --git a/sunnypilot/selfdrive/controls/lib/smart_cruise_control/map_controller.py b/sunnypilot/selfdrive/controls/lib/smart_cruise_control/map_controller.py new file mode 100644 index 0000000000..c7f11a1bb2 --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/smart_cruise_control/map_controller.py @@ -0,0 +1,261 @@ +import json +import math +import platform + +from cereal import custom +from openpilot.common.params import Params +from openpilot.common.realtime import DT_MDL +from openpilot.selfdrive.car.cruise import V_CRUISE_UNSET +from openpilot.sunnypilot import PARAMS_UPDATE_PERIOD +from openpilot.sunnypilot.navd.helpers import coordinate_from_param, Coordinate +from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control import MIN_V + +MapState = VisionState = custom.LongitudinalPlanSP.SmartCruiseControl.MapState + +ACTIVE_STATES = (MapState.turning, ) +ENABLED_STATES = (MapState.enabled, MapState.overriding, *ACTIVE_STATES) + +R = 6373000.0 # approximate radius of earth in meters +TO_RADIANS = math.pi / 180 +TO_DEGREES = 180 / math.pi +TARGET_JERK = -0.6 # m/s^3 There's some jounce limits that are not consistent so we're fudging this some +TARGET_ACCEL = -1.2 # m/s^2 should match up with the long planner limit +TARGET_OFFSET = 1.0 # seconds - This controls how soon before the curve you reach the target velocity. It also helps + # reach the target velocity when inaccuracies in the distance modeling logic would cause overshoot. + # The value is multiplied against the target velocity to determine the additional distance. This is + # done to keep the distance calculations consistent but results in the offset actually being less + # time than specified depending on how much of a speed differential there is between v_ego and the + # target velocity. + + +def velocities_from_param(param: str, params: Params): + if params is None: + params = Params() + + json_str = params.get(param) + if json_str is None: + return None + + velocities = json.loads(json_str) + + return velocities + + +def calculate_accel(t, target_jerk, a_ego): + return a_ego + target_jerk * t + + +def calculate_velocity(t, target_jerk, a_ego, v_ego): + return v_ego + a_ego * t + target_jerk/2 * (t ** 2) + + +def calculate_distance(t, target_jerk, a_ego, v_ego): + return t * v_ego + a_ego/2 * (t ** 2) + target_jerk/6 * (t ** 3) + + +# points should be in radians +# output is meters +def distance_to_point(ax, ay, bx, by): + a = math.sin((bx-ax)/2)*math.sin((bx-ax)/2) + math.cos(ax) * math.cos(bx)*math.sin((by-ay)/2)*math.sin((by-ay)/2) + c = 2 * math.atan2(math.sqrt(a), math.sqrt(1-a)) + + return R * c # in meters + + +class SmartCruiseControlMap: + v_target: float = 0 + a_target: float = 0. + v_ego: float = 0. + a_ego: float = 0. + output_v_target: float = V_CRUISE_UNSET + output_a_target: float = 0. + + def __init__(self): + self.params = Params() + self.mem_params = Params("/dev/shm/params") if platform.system() != "Darwin" else self.params + self.enabled = self.params.get_bool("SmartCruiseControlMap") + self.long_enabled = False + self.long_override = False + self.is_enabled = False + self.is_active = False + self.state = MapState.disabled + self.v_cruise = 0 + self.target_lat = 0.0 + self.target_lon = 0.0 + self.frame = -1 + + self.last_position = coordinate_from_param("LastGPSPosition", self.mem_params) or Coordinate(0.0, 0.0) + self.target_velocities = velocities_from_param("MapTargetVelocities", self.mem_params) or [] + + def get_v_target_from_control(self) -> float: + if self.is_active: + return max(self.v_target, MIN_V) + + return V_CRUISE_UNSET + + def get_a_target_from_control(self) -> float: + return self.a_ego + + def update_params(self): + if self.frame % int(PARAMS_UPDATE_PERIOD / DT_MDL) == 0: + self.enabled = self.params.get_bool("SmartCruiseControlMap") + + def update_calculations(self) -> None: + self.last_position = coordinate_from_param("LastGPSPosition", self.mem_params) or Coordinate(0.0, 0.0) + lat = self.last_position.latitude + lon = self.last_position.longitude + + self.target_velocities = velocities_from_param("MapTargetVelocities", self.mem_params) or [] + + if self.last_position is None or self.target_velocities is None: + return + + min_dist = 1000 + min_idx = 0 + distances = [] + + # find our location in the path + for i in range(len(self.target_velocities)): + target_velocity = self.target_velocities[i] + tlat = target_velocity["latitude"] + tlon = target_velocity["longitude"] + d = distance_to_point(lat * TO_RADIANS, lon * TO_RADIANS, tlat * TO_RADIANS, tlon * TO_RADIANS) + distances.append(d) + if d < min_dist: + min_dist = d + min_idx = i + + # only look at values from our current position forward + forward_points = self.target_velocities[min_idx:] + forward_distances = distances[min_idx:] + + # find velocities that we are within the distance we need to adjust for + valid_velocities = [] + for i in range(len(forward_points)): + target_velocity = forward_points[i] + tlat = target_velocity["latitude"] + tlon = target_velocity["longitude"] + tv = target_velocity["velocity"] + if tv > self.v_ego: + continue + + d = forward_distances[i] + + a_diff = (self.a_ego - TARGET_ACCEL) + accel_t = abs(a_diff / TARGET_JERK) + min_accel_v = calculate_velocity(accel_t, TARGET_JERK, self.a_ego, self.v_ego) + + max_d = 0 + if tv > min_accel_v: + # calculate time needed based on target jerk + a = 0.5 * TARGET_JERK + b = self.a_ego + c = self.v_ego - tv + t_a = -1 * ((b**2 - 4 * a * c) ** 0.5 + b) / 2 * a + t_b = ((b**2 - 4 * a * c) ** 0.5 - b) / 2 * a + if not isinstance(t_a, complex) and t_a > 0: + t = t_a + else: + t = t_b + if isinstance(t, complex): + continue + + max_d = max_d + calculate_distance(t, TARGET_JERK, self.a_ego, self.v_ego) + else: + t = accel_t + max_d = calculate_distance(t, TARGET_JERK, self.a_ego, self.v_ego) + + # calculate additional time needed based on target accel + t = abs((min_accel_v - tv) / TARGET_ACCEL) + max_d += calculate_distance(t, 0, TARGET_ACCEL, min_accel_v) + + if d < max_d + tv * TARGET_OFFSET: + valid_velocities.append((float(tv), tlat, tlon)) + + # Find the smallest velocity we need to adjust for + min_v = 100.0 + target_lat = 0.0 + target_lon = 0.0 + for tv, lat, lon in valid_velocities: + if tv < min_v: + min_v = tv + target_lat = lat + target_lon = lon + + if self.v_target < min_v and not (self.target_lat == 0 and self.target_lon == 0): + for i in range(len(forward_points)): + target_velocity = forward_points[i] + tlat = target_velocity["latitude"] + tlon = target_velocity["longitude"] + tv = target_velocity["velocity"] + if tv > self.v_ego: + continue + + if tlat == self.target_lat and tlon == self.target_lon and tv == self.v_target: + return + + # not found so let's reset + self.v_target = 0.0 + self.target_lat = 0.0 + self.target_lon = 0.0 + + self.v_target = min_v + self.target_lat = target_lat + self.target_lon = target_lon + + def _update_state_machine(self) -> tuple[bool, bool]: + # ENABLED, TURNING + if self.state != MapState.disabled: + if not self.long_enabled or not self.enabled: + self.state = MapState.disabled + elif self.long_override: + self.state = MapState.overriding + + else: + # ENABLED + if self.state == MapState.enabled: + if self.v_cruise > self.v_target != 0: + self.state = MapState.turning + + # TURNING + elif self.state == MapState.turning: + if self.v_cruise <= self.v_target or self.v_target == 0: + self.state = MapState.enabled + + # OVERRIDING + elif self.state == MapState.overriding: + if not self.long_override: + if self.v_cruise > self.v_target != 0: + self.state = MapState.turning + else: + self.state = MapState.enabled + + # DISABLED + elif self.state == MapState.disabled: + if self.long_enabled and self.enabled: + if self.long_override: + self.state = MapState.overriding + else: + self.state = MapState.enabled + + enabled = self.state in ENABLED_STATES + active = self.state in ACTIVE_STATES + + return enabled, active + + def update(self, long_enabled: bool, long_override: bool, v_ego, a_ego, v_cruise) -> None: + self.long_enabled = long_enabled + self.long_override = long_override + self.v_ego = v_ego + self.a_ego = a_ego + self.v_cruise = v_cruise + + self.update_params() + self.update_calculations() + + self.is_enabled, self.is_active = self._update_state_machine() + + self.output_v_target = self.get_v_target_from_control() + self.output_a_target = self.get_a_target_from_control() + + self.frame += 1 diff --git a/sunnypilot/selfdrive/controls/lib/smart_cruise_control/smart_cruise_control.py b/sunnypilot/selfdrive/controls/lib/smart_cruise_control/smart_cruise_control.py new file mode 100644 index 0000000000..4ca45202fc --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/smart_cruise_control/smart_cruise_control.py @@ -0,0 +1,19 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import cereal.messaging as messaging +from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control.vision_controller import SmartCruiseControlVision +from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control.map_controller import SmartCruiseControlMap + + +class SmartCruiseControl: + def __init__(self): + self.vision = SmartCruiseControlVision() + self.map = SmartCruiseControlMap() + + def update(self, sm: messaging.SubMaster, long_enabled: bool, long_override: bool, v_ego: float, a_ego: float, v_cruise: float) -> None: + self.map.update(long_enabled, long_override, v_ego, a_ego, v_cruise) + self.vision.update(sm, long_enabled, long_override, v_ego, a_ego, v_cruise) diff --git a/sunnypilot/selfdrive/controls/lib/smart_cruise_control/tests/test_map_controller.py b/sunnypilot/selfdrive/controls/lib/smart_cruise_control/tests/test_map_controller.py new file mode 100644 index 0000000000..537f0033f0 --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/smart_cruise_control/tests/test_map_controller.py @@ -0,0 +1,58 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import platform + +from cereal import custom +from openpilot.common.params import Params +from openpilot.common.realtime import DT_MDL +from openpilot.selfdrive.car.cruise import V_CRUISE_UNSET +from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control.map_controller import SmartCruiseControlMap + +MapState = VisionState = custom.LongitudinalPlanSP.SmartCruiseControl.MapState + + +class TestSmartCruiseControlMap: + + def setup_method(self): + self.params = Params() + self.mem_params = Params("/dev/shm/params") if platform.system() != "Darwin" else self.params + self.reset_params() + self.scc_m = SmartCruiseControlMap() + + def reset_params(self): + self.params.put_bool("SmartCruiseControlMap", True) + + # TODO-SP: mock data from gpsLocation + self.params.put("LastGPSPosition", "{}") + self.params.put("MapTargetVelocities", "{}") + + def test_initial_state(self): + assert self.scc_m.state == VisionState.disabled + assert not self.scc_m.is_active + assert self.scc_m.output_v_target == V_CRUISE_UNSET + assert self.scc_m.output_a_target == 0. + + def test_system_disabled(self): + self.params.put_bool("SmartCruiseControlMap", False) + self.scc_m.enabled = self.params.get_bool("SmartCruiseControlMap") + + for _ in range(int(10. / DT_MDL)): + self.scc_m.update(True, False, 0., 0., 0.) + assert self.scc_m.state == VisionState.disabled + assert not self.scc_m.is_active + + def test_disabled(self): + for _ in range(int(10. / DT_MDL)): + self.scc_m.update(False, False, 0., 0., 0.) + assert self.scc_m.state == VisionState.disabled + + def test_transition_disabled_to_enabled(self): + for _ in range(int(10. / DT_MDL)): + self.scc_m.update(True, False, 0., 0., 0.) + assert self.scc_m.state == VisionState.enabled + + # TODO-SP: mock data from modelV2 to test other states diff --git a/sunnypilot/selfdrive/controls/lib/smart_cruise_control/tests/test_vision_controller.py b/sunnypilot/selfdrive/controls/lib/smart_cruise_control/tests/test_vision_controller.py new file mode 100644 index 0000000000..d35b79a73b --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/smart_cruise_control/tests/test_vision_controller.py @@ -0,0 +1,214 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import numpy as np +import pytest + +import cereal.messaging as messaging +from cereal import custom, log +from openpilot.common.params import Params +from openpilot.common.realtime import DT_MDL +from openpilot.selfdrive.car.cruise import V_CRUISE_UNSET +from openpilot.selfdrive.modeld.constants import ModelConstants +from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control import MIN_V +from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control.vision_controller import SmartCruiseControlVision, _ENTERING_PRED_LAT_ACC_TH + +VisionState = custom.LongitudinalPlanSP.SmartCruiseControl.VisionState + + +def _th_above_f32(th: float) -> float: + """ + Return the next representable float32 *above* `th`. + This avoids flaky comparisons around thresholds due to float32 rounding. + """ + th32 = np.float32(th) + above32 = np.nextafter(th32, np.float32(np.inf), dtype=np.float32) + return float(above32) + + +def _build_single_spike_filtered(n: int, base: float = 1.0) -> np.ndarray: + """ + Create an array where max() is >= threshold but p97 is < threshold. + This demonstrates the behavior difference vs np.amax(). + + Note: We intentionally construct using float32-representable values to match + the data path through cereal/capnp. + """ + th = float(_ENTERING_PRED_LAT_ACC_TH) + th32 = float(np.float32(th)) + + # numpy percentile default is linear interpolation: idx=(n-1)*p/100 + idx = (n - 1) * 0.97 + w = float(idx - np.floor(idx)) + + base32 = float(np.float32(base)) + + # Choose spike so that p97 = base + w*(spike-base) < th + # -> spike < base + (th-base)/w. Use a margin (0.9) and ensure spike >= th. + if w == 0.0: + spike = th32 + 1.0 + else: + spike = base32 + (th32 - base32) / w * 0.9 + spike = max(spike, th32 + 0.01) + + arr = np.full(n, base32, dtype=np.float32) + arr[-1] = np.float32(spike) + return arr + + +def generate_modelV2(): + model = messaging.new_message('modelV2') + position = log.XYZTData.new_message() + speed = 30 + position.x = [float(x) for x in (speed + 0.5) * np.array(ModelConstants.T_IDXS)] + model.modelV2.position = position + orientation = log.XYZTData.new_message() + curvature = 0.05 + orientation.x = [float(curvature) for _ in ModelConstants.T_IDXS] + orientation.y = [0.0 for _ in ModelConstants.T_IDXS] + model.modelV2.orientation = orientation + orientationRate = log.XYZTData.new_message() + orientationRate.z = [float(z) for z in ModelConstants.T_IDXS] + model.modelV2.orientationRate = orientationRate + velocity = log.XYZTData.new_message() + velocity.x = [float(x) for x in (speed + 0.5) * np.ones_like(ModelConstants.T_IDXS)] + velocity.x[0] = float(speed) # always start at current speed + model.modelV2.velocity = velocity + acceleration = log.XYZTData.new_message() + acceleration.x = [float(x) for x in np.zeros_like(ModelConstants.T_IDXS)] + acceleration.y = [float(y) for y in np.zeros_like(ModelConstants.T_IDXS)] + model.modelV2.acceleration = acceleration + + return model + + +def generate_carState(): + car_state = messaging.new_message('carState') + speed = 30 + v_cruise = 50 + car_state.carState.vEgo = float(speed) + car_state.carState.standstill = False + car_state.carState.vCruise = float(v_cruise * 3.6) + + return car_state + + +def generate_controlsState(): + controls_state = messaging.new_message('controlsState') + controls_state.controlsState.curvature = 0.05 + + return controls_state + + +class TestSmartCruiseControlVision: + + def setup_method(self): + self.params = Params() + self.reset_params() + self.scc_v = SmartCruiseControlVision() + + mdl = generate_modelV2() + cs = generate_carState() + controls_state = generate_controlsState() + self.sm = {'modelV2': mdl.modelV2, 'carState': cs.carState, 'controlsState': controls_state.controlsState} + + def reset_params(self): + self.params.put_bool("SmartCruiseControlVision", True) + + def test_initial_state(self): + assert self.scc_v.state == VisionState.disabled + assert not self.scc_v.is_active + assert self.scc_v.output_v_target == V_CRUISE_UNSET + assert self.scc_v.output_a_target == 0. + + def test_system_disabled(self): + self.params.put_bool("SmartCruiseControlVision", False) + self.scc_v.enabled = self.params.get_bool("SmartCruiseControlVision") + + for _ in range(int(10. / DT_MDL)): + self.scc_v.update(self.sm, True, False, 0., 0., 0.) + assert self.scc_v.state == VisionState.disabled + assert not self.scc_v.is_active + + def test_disabled(self): + for _ in range(int(10. / DT_MDL)): + self.scc_v.update(self.sm, False, False, 0., 0., 0.) + assert self.scc_v.state == VisionState.disabled + + def test_transition_disabled_to_enabled(self): + for _ in range(int(10. / DT_MDL)): + self.scc_v.update(self.sm, True, False, 0., 0., 0.) + assert self.scc_v.state == VisionState.enabled + + @pytest.mark.parametrize( + "case, should_enter", + [ + ("p97_just_above_threshold", True), + ("single_spike_filtered", False), + ("persistent_high_values", True), + ], + ids=[ + "p97>threshold_enters", + "single_spike_max_large_but_p97_below_threshold", + "high_values_persist_trigger_entering", + ], + ) + def test_max_pred_lat_acc_uses_p97_and_threshold(self, case, should_enter): + n = len(ModelConstants.T_IDXS) + th = float(_ENTERING_PRED_LAT_ACC_TH) + + if case == "p97_just_above_threshold": + # Use the next representable float32 above threshold to avoid float32 rounding flakiness. + val = _th_above_f32(th) + pred_lat_accels = np.full(n, np.float32(val), dtype=np.float32) + + elif case == "single_spike_filtered": + pred_lat_accels = _build_single_spike_filtered(n, base=1.0) + + elif case == "persistent_high_values": + # Make enough "high" samples so p97 is driven by the persistent trend, not a single outlier. + high_count = max(2, int(np.ceil(n * 0.03)) + 1) + pred_lat_accels = np.full(n, np.float32(1.0), dtype=np.float32) + pred_lat_accels[-high_count:] = np.float32(2.0) + pred_lat_accels[-1] = np.float32(8.0) # keep one big outlier too + + else: + raise AssertionError(f"Unknown case: {case}") + + # Override model predictions so: + # predicted_lat_accels = abs(orientationRate.z) * velocity.x == pred_lat_accels + mdl = generate_modelV2() + mdl.modelV2.velocity.x = [1.0 for _ in range(n)] + mdl.modelV2.orientationRate.z = [float(x) for x in pred_lat_accels] + self.sm["modelV2"] = mdl.modelV2 + + v_ego = float(MIN_V + 5.0) + + # 1st update: disabled -> enabled + self.scc_v.update(self.sm, True, False, v_ego, 0.0, 0.0) + # 2nd update: evaluate entering condition from enabled state + self.scc_v.update(self.sm, True, False, v_ego, 0.0, 0.0) + + # Controller does percentile on numpy float64 arrays (values already quantized by capnp), + # so compute expected in float64 to match behavior and avoid interpolation/rounding deltas. + expected_p97 = float(np.percentile(pred_lat_accels.astype(np.float64), 97)) + + # allow tiny numeric differences due to float conversions/interpolation + assert np.isclose(self.scc_v.max_pred_lat_acc, expected_p97, rtol=1e-6, atol=1e-5) + + if should_enter: + # We assert entering primarily by state (this is the actual intended behavior). + assert self.scc_v.state == VisionState.entering + # Optional sanity: should be >= threshold with some margin (since we used nextafter above threshold). + assert self.scc_v.max_pred_lat_acc > th + + else: + # Difference vs np.amax(): max can be above threshold, but p97 stays below it. + assert float(np.max(pred_lat_accels)) >= th + assert self.scc_v.max_pred_lat_acc < th + assert self.scc_v.state == VisionState.enabled + + # TODO-SP: mock modelV2 data to test other states diff --git a/sunnypilot/selfdrive/controls/lib/smart_cruise_control/vision_controller.py b/sunnypilot/selfdrive/controls/lib/smart_cruise_control/vision_controller.py new file mode 100644 index 0000000000..a9d2a66227 --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/smart_cruise_control/vision_controller.py @@ -0,0 +1,203 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import numpy as np + +import cereal.messaging as messaging +from cereal import custom +from openpilot.common.params import Params +from openpilot.common.realtime import DT_MDL +from openpilot.selfdrive.car.cruise import V_CRUISE_UNSET +from openpilot.sunnypilot import PARAMS_UPDATE_PERIOD +from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control import MIN_V + +VisionState = custom.LongitudinalPlanSP.SmartCruiseControl.VisionState + +ACTIVE_STATES = (VisionState.entering, VisionState.turning, VisionState.leaving) +ENABLED_STATES = (VisionState.enabled, VisionState.overriding, *ACTIVE_STATES) + +_ENTERING_PRED_LAT_ACC_TH = 1.3 # Predicted Lat Acc threshold to trigger entering turn state. +_ABORT_ENTERING_PRED_LAT_ACC_TH = 1.1 # Predicted Lat Acc threshold to abort entering state if speed drops. + +_TURNING_LAT_ACC_TH = 1.6 # Lat Acc threshold to trigger turning state. + +_LEAVING_LAT_ACC_TH = 1.3 # Lat Acc threshold to trigger leaving turn state. +_FINISH_LAT_ACC_TH = 1.1 # Lat Acc threshold to trigger the end of the turn cycle. + +_A_LAT_REG_MAX = 2. # Maximum lateral acceleration + +_NO_OVERSHOOT_TIME_HORIZON = 4. # s. Time to use for velocity desired based on a_target when not overshooting. + +# Lookup table for the minimum smooth deceleration during the ENTERING state +# depending on the actual maximum absolute lateral acceleration predicted on the turn ahead. +_ENTERING_SMOOTH_DECEL_V = [-0.2, -1.] # min decel value allowed on ENTERING state +_ENTERING_SMOOTH_DECEL_BP = [1.3, 3.] # absolute value of lat acc ahead + +# Lookup table for the acceleration for the TURNING state +# depending on the current lateral acceleration of the vehicle. +_TURNING_ACC_V = [0.5, 0., -0.4] # acc value +_TURNING_ACC_BP = [1.5, 2.3, 3.] # absolute value of current lat acc + +_LEAVING_ACC = 0.5 # Conformable acceleration to regain speed while leaving a turn. + + +class SmartCruiseControlVision: + v_target: float = 0 + a_target: float = 0. + v_ego: float = 0. + a_ego: float = 0. + output_v_target: float = V_CRUISE_UNSET + output_a_target: float = 0. + + def __init__(self): + self.params = Params() + self.frame = -1 + self.long_enabled = False + self.long_override = False + self.is_enabled = False + self.is_active = False + self.enabled = self.params.get_bool("SmartCruiseControlVision") + self.v_cruise_setpoint = 0. + + self.state = VisionState.disabled + self.current_lat_acc = 0. + self.max_pred_lat_acc = 0. + + def get_a_target_from_control(self) -> float: + return self.a_target + + def get_v_target_from_control(self) -> float: + if self.is_active: + return max(self.v_target, MIN_V) + self.a_target * _NO_OVERSHOOT_TIME_HORIZON + + return V_CRUISE_UNSET + + def _update_params(self) -> None: + if self.frame % int(PARAMS_UPDATE_PERIOD / DT_MDL) == 0: + self.enabled = self.params.get_bool("SmartCruiseControlVision") + + def _update_calculations(self, sm: messaging.SubMaster) -> None: + if not self.long_enabled: + return + else: + rate_plan = np.array(np.abs(sm['modelV2'].orientationRate.z)) + vel_plan = np.array(sm['modelV2'].velocity.x) + + self.current_lat_acc = self.v_ego ** 2 * abs(sm['controlsState'].curvature) + + # get the maximum lat accel from the model + predicted_lat_accels = rate_plan * vel_plan + self.max_pred_lat_acc = np.percentile(predicted_lat_accels, 97) + + # get the maximum curve based on the current velocity + v_ego = max(self.v_ego, 0.1) # ensure a value greater than 0 for calculations + max_curve = self.max_pred_lat_acc / (v_ego**2) + + # Get the target velocity for the maximum curve + self.v_target = (_A_LAT_REG_MAX / max_curve) ** 0.5 + + def _update_state_machine(self) -> tuple[bool, bool]: + # ENABLED, ENTERING, TURNING, LEAVING, OVERRIDING + if self.state != VisionState.disabled: + # longitudinal and feature disable always have priority in a non-disabled state + if not self.long_enabled or not self.enabled: + self.state = VisionState.disabled + elif self.long_override: + self.state = VisionState.overriding + + else: + # ENABLED + if self.state == VisionState.enabled: + # Do not enter a turn control cycle if the speed is low. + if self.v_ego <= MIN_V: + pass + # If significant lateral acceleration is predicted ahead, then move to Entering turn state. + elif self.max_pred_lat_acc >= _ENTERING_PRED_LAT_ACC_TH: + self.state = VisionState.entering + + # OVERRIDING + elif self.state == VisionState.overriding: + if not self.long_override: + self.state = VisionState.enabled + + # ENTERING + elif self.state == VisionState.entering: + # Transition to Turning if current lateral acceleration is over the threshold. + if self.current_lat_acc >= _TURNING_LAT_ACC_TH: + self.state = VisionState.turning + # Abort if the predicted lateral acceleration drops + elif self.max_pred_lat_acc < _ABORT_ENTERING_PRED_LAT_ACC_TH: + self.state = VisionState.enabled + + # TURNING + elif self.state == VisionState.turning: + # Transition to Leaving if current lateral acceleration drops below a threshold. + if self.current_lat_acc <= _LEAVING_LAT_ACC_TH: + self.state = VisionState.leaving + + # LEAVING + elif self.state == VisionState.leaving: + # Transition back to Turning if current lateral acceleration goes back over the threshold. + if self.current_lat_acc >= _TURNING_LAT_ACC_TH: + self.state = VisionState.turning + # Finish if current lateral acceleration goes below a threshold. + elif self.current_lat_acc < _FINISH_LAT_ACC_TH: + self.state = VisionState.enabled + + # DISABLED + elif self.state == VisionState.disabled: + if self.long_enabled and self.enabled: + if self.long_override: + self.state = VisionState.overriding + else: + self.state = VisionState.enabled + + enabled = self.state in ENABLED_STATES + active = self.state in ACTIVE_STATES + + return enabled, active + + def _update_solution(self) -> float: + # DISABLED, ENABLED, OVERRIDING + if self.state not in ACTIVE_STATES: + # when not overshooting, calculate v_turn as the speed at the prediction horizon when following + # the smooth deceleration. + a_target = self.a_ego + # ENTERING + elif self.state == VisionState.entering: + # when not overshooting, target a smooth deceleration in preparation for a sharp turn to come. + a_target = np.interp(self.max_pred_lat_acc, _ENTERING_SMOOTH_DECEL_BP, _ENTERING_SMOOTH_DECEL_V) + # TURNING + elif self.state == VisionState.turning: + # When turning, we provide a target acceleration that is comfortable for the lateral acceleration felt. + a_target = np.interp(self.current_lat_acc, _TURNING_ACC_BP, _TURNING_ACC_V) + # LEAVING + elif self.state == VisionState.leaving: + # When leaving, we provide a comfortable acceleration to regain speed. + a_target = _LEAVING_ACC + else: + raise NotImplementedError(f"SCC-V state not supported: {self.state}") + + return a_target + + def update(self, sm: messaging.SubMaster, long_enabled: bool, long_override: bool, v_ego: float, a_ego: float, + v_cruise_setpoint: float) -> None: + self.long_enabled = long_enabled + self.long_override = long_override + self.v_ego = v_ego + self.a_ego = a_ego + self.v_cruise_setpoint = v_cruise_setpoint + + self._update_params() + self._update_calculations(sm) + + self.is_enabled, self.is_active = self._update_state_machine() + self.a_target = self._update_solution() + + self.output_v_target = self.get_v_target_from_control() + self.output_a_target = self.get_a_target_from_control() + + self.frame += 1 diff --git a/sunnypilot/selfdrive/controls/lib/speed_limit/__init__.py b/sunnypilot/selfdrive/controls/lib/speed_limit/__init__.py new file mode 100644 index 0000000000..22ea75fe19 --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/speed_limit/__init__.py @@ -0,0 +1,19 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +LIMIT_ADAPT_ACC = -1. # m/s^2 Ideal acceleration for the adapting (braking) phase when approaching speed limits. +LIMIT_MAX_MAP_DATA_AGE = 10. # s Maximum time to hold to map data, then consider it invalid inside limits controllers. + +# Speed Limit Assist constants +PCM_LONG_REQUIRED_MAX_SET_SPEED = { + True: (33.3333, 36.1111), # km/h, (120, 130) + False: (31.2928, 35.7632), # mph, (70, 80) +} + +CONFIRM_SPEED_THRESHOLD = { + True: 80, # km/h + False: 50, # mph +} diff --git a/sunnypilot/selfdrive/controls/lib/speed_limit/common.py b/sunnypilot/selfdrive/controls/lib/speed_limit/common.py new file mode 100644 index 0000000000..c46768464e --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/speed_limit/common.py @@ -0,0 +1,29 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" + +from openpilot.sunnypilot import IntEnumBase + + +class Policy(IntEnumBase): + car_state_only = 0 + map_data_only = 1 + car_state_priority = 2 + map_data_priority = 3 + combined = 4 + + +class OffsetType(IntEnumBase): + off = 0 + fixed = 1 + percentage = 2 + + +class Mode(IntEnumBase): + off = 0 + information = 1 + warning = 2 + assist = 3 diff --git a/sunnypilot/selfdrive/controls/lib/speed_limit/helpers.py b/sunnypilot/selfdrive/controls/lib/speed_limit/helpers.py new file mode 100644 index 0000000000..4f388befc7 --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/speed_limit/helpers.py @@ -0,0 +1,44 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" + +from cereal import custom, car +from openpilot.common.constants import CV +from openpilot.common.params import Params +from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.common import Mode as SpeedLimitMode + + +def compare_cluster_target(v_cruise_cluster: float, target_set_speed: float, is_metric: bool) -> tuple[bool, bool]: + speed_conv = CV.MS_TO_KPH if is_metric else CV.MS_TO_MPH + v_cruise_cluster_conv = round(v_cruise_cluster * speed_conv) + target_set_speed_conv = round(target_set_speed * speed_conv) + + req_plus = v_cruise_cluster_conv < target_set_speed_conv + req_minus = v_cruise_cluster_conv > target_set_speed_conv + + return req_plus, req_minus + + +def set_speed_limit_assist_availability(CP: car.CarParams, CP_SP: custom.CarParamsSP, params: Params = None) -> bool: + if params is None: + params = Params() + + is_release = params.get_bool("IsReleaseSpBranch") + disallow_in_release = CP.brand == "tesla" and is_release + always_disallow = CP.brand == "rivian" + allowed = True + + if disallow_in_release or always_disallow: + allowed = False + + if not CP.openpilotLongitudinalControl and CP_SP.pcmCruiseSpeed: + allowed = False + + if not allowed: + if params.get("SpeedLimitMode", return_default=True) == SpeedLimitMode.assist: + params.put("SpeedLimitMode", int(SpeedLimitMode.warning)) + + return allowed diff --git a/sunnypilot/selfdrive/controls/lib/speed_limit/speed_limit_assist.py b/sunnypilot/selfdrive/controls/lib/speed_limit/speed_limit_assist.py new file mode 100644 index 0000000000..ff7be8a8be --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/speed_limit/speed_limit_assist.py @@ -0,0 +1,414 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import time + +from cereal import custom, car +from openpilot.common.params import Params +from openpilot.common.constants import CV +from openpilot.common.realtime import DT_MDL +from openpilot.selfdrive.controls.lib.drive_helpers import CONTROL_N +from openpilot.selfdrive.modeld.constants import ModelConstants +from openpilot.sunnypilot import PARAMS_UPDATE_PERIOD +from openpilot.sunnypilot.selfdrive.selfdrived.events import EventsSP +from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit import PCM_LONG_REQUIRED_MAX_SET_SPEED, CONFIRM_SPEED_THRESHOLD +from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.common import Mode +from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.helpers import compare_cluster_target, set_speed_limit_assist_availability + +ButtonType = car.CarState.ButtonEvent.Type +EventNameSP = custom.OnroadEventSP.EventName +SpeedLimitAssistState = custom.LongitudinalPlanSP.SpeedLimit.AssistState +SpeedLimitSource = custom.LongitudinalPlanSP.SpeedLimit.Source + +ACTIVE_STATES = (SpeedLimitAssistState.active, SpeedLimitAssistState.adapting) +ENABLED_STATES = (SpeedLimitAssistState.preActive, SpeedLimitAssistState.pending, *ACTIVE_STATES) + +DISABLED_GUARD_PERIOD = 0.5 # secs. +# secs. Time to wait after activation before considering temp deactivation signal. +PRE_ACTIVE_GUARD_PERIOD = { + True: 15, + False: 5, +} +SPEED_LIMIT_CHANGED_HOLD_PERIOD = 1 # secs. Time to wait after speed limit change before switching to preActive. + +LIMIT_MIN_ACC = -1.5 # m/s^2 Maximum deceleration allowed for limit controllers to provide. +LIMIT_MAX_ACC = 1.0 # m/s^2 Maximum acceleration allowed for limit controllers to provide while active. +LIMIT_MIN_SPEED = 8.33 # m/s, Minimum speed limit to provide as solution on limit controllers. +LIMIT_SPEED_OFFSET_TH = -1. # m/s Maximum offset between speed limit and current speed for adapting state. +V_CRUISE_UNSET = 255. + +CRUISE_BUTTONS_PLUS = (ButtonType.accelCruise, ButtonType.resumeCruise) +CRUISE_BUTTONS_MINUS = (ButtonType.decelCruise, ButtonType.setCruise) +CRUISE_BUTTON_CONFIRM_HOLD = 0.5 # secs. + + +class SpeedLimitAssist: + _speed_limit_final_last: float + _distance: float + v_ego: float + a_ego: float + v_offset: float + + def __init__(self, CP: car.CarParams, CP_SP: custom.CarParamsSP): + self.params = Params() + self.CP = CP + self.CP_SP = CP_SP + self.frame = -1 + self.long_engaged_timer = 0 + self.pre_active_timer = 0 + self.is_metric = self.params.get_bool("IsMetric") + set_speed_limit_assist_availability(self.CP, self.CP_SP, self.params) + self.enabled = self.params.get("SpeedLimitMode", return_default=True) == Mode.assist + self.long_enabled = False + self.long_enabled_prev = False + self.is_enabled = False + self.is_active = False + self.output_v_target = V_CRUISE_UNSET + self.output_a_target = 0. + self.v_ego = 0. + self.a_ego = 0. + self.v_offset = 0. + self.target_set_speed_conv = 0 + self.prev_target_set_speed_conv = 0 + self.v_cruise_cluster = 0. + self.v_cruise_cluster_prev = 0. + self.v_cruise_cluster_conv = 0 + self.prev_v_cruise_cluster_conv = 0 + self._has_speed_limit = False + self._speed_limit = 0. + self._speed_limit_final_last = 0. + self.speed_limit_prev = 0. + self.speed_limit_final_last_conv = 0 + self.prev_speed_limit_final_last_conv = 0 + self._distance = 0. + self.state = SpeedLimitAssistState.disabled + self._state_prev = SpeedLimitAssistState.disabled + self.pcm_op_long = CP.openpilotLongitudinalControl and CP.pcmCruise + + self._plus_hold = 0. + self._minus_hold = 0. + self._last_carstate_ts = 0. + + # TODO-SP: SLA's own output_a_target for planner + # Solution functions mapped to respective states + self.acceleration_solutions = { + SpeedLimitAssistState.disabled: self.get_current_acceleration_as_target, + SpeedLimitAssistState.inactive: self.get_current_acceleration_as_target, + SpeedLimitAssistState.preActive: self.get_current_acceleration_as_target, + SpeedLimitAssistState.pending: self.get_current_acceleration_as_target, + SpeedLimitAssistState.adapting: self.get_adapting_state_target_acceleration, + SpeedLimitAssistState.active: self.get_active_state_target_acceleration, + } + + @property + def speed_limit_changed(self) -> bool: + return self._has_speed_limit and bool(self._speed_limit != self.speed_limit_prev) + + @property + def v_cruise_cluster_changed(self) -> bool: + return bool(self.v_cruise_cluster_conv != self.prev_v_cruise_cluster_conv) + + @property + def target_set_speed_confirmed(self) -> bool: + return bool(self.v_cruise_cluster_conv == self.target_set_speed_conv) + + @property + def v_cruise_cluster_below_confirm_speed_threshold(self) -> bool: + return bool(self.v_cruise_cluster_conv < CONFIRM_SPEED_THRESHOLD[self.is_metric]) + + def update_active_event(self, events_sp: EventsSP) -> None: + if self.v_cruise_cluster_below_confirm_speed_threshold: + events_sp.add(EventNameSP.speedLimitChanged) + else: + events_sp.add(EventNameSP.speedLimitActive) + + def get_v_target_from_control(self) -> float: + if self._has_speed_limit: + if self.pcm_op_long and self.is_enabled: + return self._speed_limit_final_last + if not self.pcm_op_long and self.is_active: + return self._speed_limit_final_last + + # Fallback + return V_CRUISE_UNSET + + # TODO-SP: SLA's own output_a_target for planner + def get_a_target_from_control(self) -> float: + return self.a_ego + + def update_params(self) -> None: + if self.frame % int(PARAMS_UPDATE_PERIOD / DT_MDL) == 0: + self.is_metric = self.params.get_bool("IsMetric") + set_speed_limit_assist_availability(self.CP, self.CP_SP, self.params) + self.enabled = self.params.get("SpeedLimitMode", return_default=True) == Mode.assist + + def update_car_state(self, CS: car.CarState) -> None: + now = time.monotonic() + self._last_carstate_ts = now + + for b in CS.buttonEvents: + if not b.pressed: + if b.type in CRUISE_BUTTONS_PLUS: + self._plus_hold = max(self._plus_hold, now + CRUISE_BUTTON_CONFIRM_HOLD) + elif b.type in CRUISE_BUTTONS_MINUS: + self._minus_hold = max(self._minus_hold, now + CRUISE_BUTTON_CONFIRM_HOLD) + + def _get_button_release(self, req_plus: bool, req_minus: bool) -> bool: + now = time.monotonic() + if req_plus and now <= self._plus_hold: + self._plus_hold = 0. + return True + elif req_minus and now <= self._minus_hold: + self._minus_hold = 0. + return True + + # expired + if now > self._plus_hold: + self._plus_hold = 0. + if now > self._minus_hold: + self._minus_hold = 0. + return False + + def update_calculations(self, v_cruise_cluster: float) -> None: + speed_conv = CV.MS_TO_KPH if self.is_metric else CV.MS_TO_MPH + self.v_cruise_cluster = v_cruise_cluster + + # Update current velocity offset (error) + self.v_offset = self._speed_limit_final_last - self.v_ego + + self.speed_limit_final_last_conv = round(self._speed_limit_final_last * speed_conv) + self.v_cruise_cluster_conv = round(self.v_cruise_cluster * speed_conv) + + cst_low, cst_high = PCM_LONG_REQUIRED_MAX_SET_SPEED[self.is_metric] + pcm_long_required_max = cst_low if self._has_speed_limit and self.speed_limit_final_last_conv < CONFIRM_SPEED_THRESHOLD[self.is_metric] else \ + cst_high + pcm_long_required_max_set_speed_conv = round(pcm_long_required_max * speed_conv) + + self.target_set_speed_conv = pcm_long_required_max_set_speed_conv if self.pcm_op_long else self.speed_limit_final_last_conv + + @property + def apply_confirm_speed_threshold(self) -> bool: + # below CST: always require user confirmation + if self.v_cruise_cluster_below_confirm_speed_threshold: + return True + + # at/above CST: + # - new speed limit >= CST: auto change + # - new speed limit < CST: user confirmation required + return bool(self.speed_limit_final_last_conv < CONFIRM_SPEED_THRESHOLD[self.is_metric]) + + def get_current_acceleration_as_target(self) -> float: + return self.a_ego + + def get_adapting_state_target_acceleration(self) -> float: + if self._distance > 0: + return (self._speed_limit_final_last ** 2 - self.v_ego ** 2) / (2. * self._distance) + + return self.v_offset / float(ModelConstants.T_IDXS[CONTROL_N]) + + def get_active_state_target_acceleration(self) -> float: + return self.v_offset / float(ModelConstants.T_IDXS[CONTROL_N]) + + def _update_confirmed_state(self): + if self._has_speed_limit: + if self.v_offset < LIMIT_SPEED_OFFSET_TH: + self.state = SpeedLimitAssistState.adapting + else: + self.state = SpeedLimitAssistState.active + else: + self.state = SpeedLimitAssistState.pending + + def _update_non_pcm_long_confirmed_state(self) -> bool: + if self.target_set_speed_confirmed: + return True + + if self.state != SpeedLimitAssistState.preActive: + return False + + req_plus, req_minus = compare_cluster_target(self.v_cruise_cluster, self._speed_limit_final_last, self.is_metric) + + return self._get_button_release(req_plus, req_minus) + + def update_state_machine_pcm_op_long(self): + self.long_engaged_timer = max(0, self.long_engaged_timer - 1) + self.pre_active_timer = max(0, self.pre_active_timer - 1) + + # ACTIVE, ADAPTING, PENDING, PRE_ACTIVE, INACTIVE + if self.state != SpeedLimitAssistState.disabled: + if not self.long_enabled or not self.enabled: + self.state = SpeedLimitAssistState.disabled + + else: + # ACTIVE + if self.state == SpeedLimitAssistState.active: + if self.v_cruise_cluster_changed: + self.state = SpeedLimitAssistState.inactive + elif self.speed_limit_changed and self.apply_confirm_speed_threshold: + self.state = SpeedLimitAssistState.preActive + self.pre_active_timer = int(PRE_ACTIVE_GUARD_PERIOD[self.pcm_op_long] / DT_MDL) + elif self._has_speed_limit and self.v_offset < LIMIT_SPEED_OFFSET_TH: + self.state = SpeedLimitAssistState.adapting + + # ADAPTING + elif self.state == SpeedLimitAssistState.adapting: + if self.v_cruise_cluster_changed: + self.state = SpeedLimitAssistState.inactive + elif self.speed_limit_changed and self.apply_confirm_speed_threshold: + self.state = SpeedLimitAssistState.preActive + self.pre_active_timer = int(PRE_ACTIVE_GUARD_PERIOD[self.pcm_op_long] / DT_MDL) + elif self.v_offset >= LIMIT_SPEED_OFFSET_TH: + self.state = SpeedLimitAssistState.active + + # PENDING + elif self.state == SpeedLimitAssistState.pending: + if self.target_set_speed_confirmed: + self._update_confirmed_state() + elif self.speed_limit_changed: + self.state = SpeedLimitAssistState.preActive + self.pre_active_timer = int(PRE_ACTIVE_GUARD_PERIOD[self.pcm_op_long] / DT_MDL) + + # PRE_ACTIVE + elif self.state == SpeedLimitAssistState.preActive: + if self.target_set_speed_confirmed: + self._update_confirmed_state() + elif self.pre_active_timer <= 0: + # Timeout - session ended + self.state = SpeedLimitAssistState.inactive + + # INACTIVE + elif self.state == SpeedLimitAssistState.inactive: + pass + + # DISABLED + elif self.state == SpeedLimitAssistState.disabled: + if self.long_enabled and self.enabled: + # start or reset preActive timer if initially enabled or manual set speed change detected + if not self.long_enabled_prev or self.v_cruise_cluster_changed: + self.long_engaged_timer = int(DISABLED_GUARD_PERIOD / DT_MDL) + + elif self.long_engaged_timer <= 0: + if self.target_set_speed_confirmed: + self._update_confirmed_state() + elif self._has_speed_limit: + self.state = SpeedLimitAssistState.preActive + self.pre_active_timer = int(PRE_ACTIVE_GUARD_PERIOD[self.pcm_op_long] / DT_MDL) + else: + self.state = SpeedLimitAssistState.pending + + enabled = self.state in ENABLED_STATES + active = self.state in ACTIVE_STATES + + return enabled, active + + def update_state_machine_non_pcm_long(self): + self.long_engaged_timer = max(0, self.long_engaged_timer - 1) + self.pre_active_timer = max(0, self.pre_active_timer - 1) + + # ACTIVE, ADAPTING, PENDING, PRE_ACTIVE, INACTIVE + if self.state != SpeedLimitAssistState.disabled: + if not self.long_enabled or not self.enabled: + self.state = SpeedLimitAssistState.disabled + + else: + # ACTIVE + if self.state == SpeedLimitAssistState.active: + if self.v_cruise_cluster_changed: + self.state = SpeedLimitAssistState.inactive + + elif self.speed_limit_changed and self.apply_confirm_speed_threshold: + self.state = SpeedLimitAssistState.preActive + self.pre_active_timer = int(PRE_ACTIVE_GUARD_PERIOD[self.pcm_op_long] / DT_MDL) + + # PRE_ACTIVE + elif self.state == SpeedLimitAssistState.preActive: + if self._update_non_pcm_long_confirmed_state(): + self.state = SpeedLimitAssistState.active + elif self.pre_active_timer <= 0: + # Timeout - session ended + self.state = SpeedLimitAssistState.inactive + + # INACTIVE + elif self.state == SpeedLimitAssistState.inactive: + if self.speed_limit_changed: + self.state = SpeedLimitAssistState.preActive + self.pre_active_timer = int(PRE_ACTIVE_GUARD_PERIOD[self.pcm_op_long] / DT_MDL) + elif self._update_non_pcm_long_confirmed_state(): + self.state = SpeedLimitAssistState.active + + # DISABLED + elif self.state == SpeedLimitAssistState.disabled: + if self.long_enabled and self.enabled: + # start or reset preActive timer if initially enabled or manual set speed change detected + if not self.long_enabled_prev or self.v_cruise_cluster_changed: + self.long_engaged_timer = int(DISABLED_GUARD_PERIOD / DT_MDL) + + elif self.long_engaged_timer <= 0: + if self._update_non_pcm_long_confirmed_state(): + self.state = SpeedLimitAssistState.active + elif self._has_speed_limit: + self.state = SpeedLimitAssistState.preActive + self.pre_active_timer = int(PRE_ACTIVE_GUARD_PERIOD[self.pcm_op_long] / DT_MDL) + else: + self.state = SpeedLimitAssistState.inactive + + enabled = self.state in ENABLED_STATES + active = self.state in ACTIVE_STATES + + return enabled, active + + def update_events(self, events_sp: EventsSP) -> None: + if self.state == SpeedLimitAssistState.preActive: + events_sp.add(EventNameSP.speedLimitPreActive) + + if self.state == SpeedLimitAssistState.pending and self._state_prev != SpeedLimitAssistState.pending: + events_sp.add(EventNameSP.speedLimitPending) + + if self.is_active: + if self._state_prev not in ACTIVE_STATES: + self.update_active_event(events_sp) + + # only notify if we acquire a valid speed limit + # do not check has_speed_limit here + elif self._speed_limit != self.speed_limit_prev: + if self.speed_limit_prev <= 0: + self.update_active_event(events_sp) + elif self.speed_limit_prev > 0 and self._speed_limit > 0: + self.update_active_event(events_sp) + + def update(self, long_enabled: bool, long_override: bool, v_ego: float, a_ego: float, v_cruise_cluster: float, speed_limit: float, + speed_limit_final_last: float, has_speed_limit: bool, distance: float, events_sp: EventsSP) -> None: + self.long_enabled = long_enabled + self.v_ego = v_ego + self.a_ego = a_ego + + self._has_speed_limit = has_speed_limit + self._speed_limit = speed_limit + self._speed_limit_final_last = speed_limit_final_last + self._distance = distance + + self.update_params() + self.update_calculations(v_cruise_cluster) + + self._state_prev = self.state + if self.pcm_op_long: + self.is_enabled, self.is_active = self.update_state_machine_pcm_op_long() + else: + self.is_enabled, self.is_active = self.update_state_machine_non_pcm_long() + + self.update_events(events_sp) + + # Update change tracking variables + self.speed_limit_prev = self._speed_limit + self.v_cruise_cluster_prev = self.v_cruise_cluster + self.long_enabled_prev = self.long_enabled + self.prev_target_set_speed_conv = self.target_set_speed_conv + self.prev_v_cruise_cluster_conv = self.v_cruise_cluster_conv + self.prev_speed_limit_final_last_conv = self.speed_limit_final_last_conv + + self.output_v_target = self.get_v_target_from_control() + self.output_a_target = self.get_a_target_from_control() + + self.frame += 1 diff --git a/sunnypilot/selfdrive/controls/lib/speed_limit/speed_limit_resolver.py b/sunnypilot/selfdrive/controls/lib/speed_limit/speed_limit_resolver.py new file mode 100644 index 0000000000..35965c0e18 --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/speed_limit/speed_limit_resolver.py @@ -0,0 +1,190 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import time + +import cereal.messaging as messaging +from cereal import custom +from openpilot.common.constants import CV +from openpilot.common.gps import get_gps_location_service +from openpilot.common.params import Params +from openpilot.common.realtime import DT_MDL +from openpilot.sunnypilot import PARAMS_UPDATE_PERIOD, get_sanitize_int_param +from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit import LIMIT_MAX_MAP_DATA_AGE, LIMIT_ADAPT_ACC +from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.common import Policy, OffsetType + +SpeedLimitSource = custom.LongitudinalPlanSP.SpeedLimit.Source + +ALL_SOURCES = tuple(SpeedLimitSource.schema.enumerants.values()) + + +class SpeedLimitResolver: + limit_solutions: dict[custom.LongitudinalPlanSP.SpeedLimit.Source, float] + distance_solutions: dict[custom.LongitudinalPlanSP.SpeedLimit.Source, float] + v_ego: float + speed_limit: float + speed_limit_last: float + speed_limit_final: float + speed_limit_final_last: float + distance: float + source: custom.LongitudinalPlanSP.SpeedLimit.Source + speed_limit_offset: float + + def __init__(self): + self.params = Params() + self.frame = -1 + + self._gps_location_service = get_gps_location_service(self.params) + self.limit_solutions = {} # Store for speed limit solutions from different sources + self.distance_solutions = {} # Store for distance to current speed limit start for different sources + + self.policy = self.params.get("SpeedLimitPolicy", return_default=True) + self.policy = get_sanitize_int_param( + "SpeedLimitPolicy", + Policy.min().value, + Policy.max().value, + self.params + ) + self._policy_to_sources_map = { + Policy.car_state_only: [SpeedLimitSource.car], + Policy.map_data_only: [SpeedLimitSource.map], + Policy.car_state_priority: [SpeedLimitSource.car, SpeedLimitSource.map], + Policy.map_data_priority: [SpeedLimitSource.map, SpeedLimitSource.car], + Policy.combined: [SpeedLimitSource.car, SpeedLimitSource.map], + } + self.source = SpeedLimitSource.none + for source in ALL_SOURCES: + self._reset_limit_sources(source) + + self.is_metric = self.params.get_bool("IsMetric") + self.offset_type = get_sanitize_int_param( + "SpeedLimitOffsetType", + OffsetType.min().value, + OffsetType.max().value, + self.params + ) + self.offset_value = self.params.get("SpeedLimitValueOffset", return_default=True) + + self.speed_limit = 0. + self.speed_limit_last = 0. + self.speed_limit_final = 0. + self.speed_limit_final_last = 0. + self.speed_limit_offset = 0. + + def update_speed_limit_states(self) -> None: + self.speed_limit_final = self.speed_limit + self.speed_limit_offset + + if self.speed_limit > 0.: + self.speed_limit_last = self.speed_limit + self.speed_limit_final_last = self.speed_limit_final + + @property + def speed_limit_valid(self) -> bool: + return self.speed_limit > 0. + + @property + def speed_limit_last_valid(self) -> bool: + return self.speed_limit_last > 0. + + def update_params(self): + if self.frame % int(PARAMS_UPDATE_PERIOD / DT_MDL) == 0: + self.policy = self.params.get("SpeedLimitPolicy", return_default=True) + self.is_metric = self.params.get_bool("IsMetric") + self.offset_type = self.params.get("SpeedLimitOffsetType", return_default=True) + self.offset_value = self.params.get("SpeedLimitValueOffset", return_default=True) + + def _get_speed_limit_offset(self) -> float: + if self.offset_type == OffsetType.off: + return 0 + elif self.offset_type == OffsetType.fixed: + return float(self.offset_value * (CV.KPH_TO_MS if self.is_metric else CV.MPH_TO_MS)) + elif self.offset_type == OffsetType.percentage: + return float(self.offset_value * 0.01 * self.speed_limit) + else: + raise NotImplementedError("Offset not supported") + + def _reset_limit_sources(self, source: custom.LongitudinalPlanSP.SpeedLimit.Source) -> None: + self.limit_solutions[source] = 0. + self.distance_solutions[source] = 0. + + def _get_from_car_state(self, sm: messaging.SubMaster) -> None: + self._reset_limit_sources(SpeedLimitSource.car) + self.limit_solutions[SpeedLimitSource.car] = sm['carStateSP'].speedLimit + self.distance_solutions[SpeedLimitSource.car] = 0. + + def _get_from_map_data(self, sm: messaging.SubMaster) -> None: + self._reset_limit_sources(SpeedLimitSource.map) + self._process_map_data(sm) + + def _process_map_data(self, sm: messaging.SubMaster) -> None: + gps_data = sm[self._gps_location_service] + map_data = sm['liveMapDataSP'] + + gps_fix_age = time.monotonic() - gps_data.unixTimestampMillis * 1e-3 + if gps_fix_age > LIMIT_MAX_MAP_DATA_AGE: + return + + speed_limit = map_data.speedLimit if map_data.speedLimitValid else 0. + next_speed_limit = map_data.speedLimitAhead if map_data.speedLimitAheadValid else 0. + + self._calculate_map_data_limits(sm, speed_limit, next_speed_limit) + + def _calculate_map_data_limits(self, sm: messaging.SubMaster, speed_limit: float, next_speed_limit: float) -> None: + gps_data = sm[self._gps_location_service] + map_data = sm['liveMapDataSP'] + + distance_since_fix = self.v_ego * (time.monotonic() - gps_data.unixTimestampMillis * 1e-3) + distance_to_speed_limit_ahead = max(0., map_data.speedLimitAheadDistance - distance_since_fix) + + self.limit_solutions[SpeedLimitSource.map] = speed_limit + self.distance_solutions[SpeedLimitSource.map] = 0. + + # FIXME-SP: this is not working as expected + if 0. < next_speed_limit < self.v_ego: + adapt_time = (next_speed_limit - self.v_ego) / LIMIT_ADAPT_ACC + adapt_distance = self.v_ego * adapt_time + 0.5 * LIMIT_ADAPT_ACC * adapt_time ** 2 + + if distance_to_speed_limit_ahead <= adapt_distance: + self.limit_solutions[SpeedLimitSource.map] = next_speed_limit + self.distance_solutions[SpeedLimitSource.map] = distance_to_speed_limit_ahead + + def _get_source_solution_according_to_policy(self) -> custom.LongitudinalPlanSP.SpeedLimit.Source: + sources_for_policy = self._policy_to_sources_map[self.policy] + + if self.policy != Policy.combined: + # They are ordered in the order of preference, so we pick the first that's non-zero + for source in sources_for_policy: + if self.limit_solutions[source] > 0.: + return source + return SpeedLimitSource.none + + sources_with_limits = [(s, limit) for s, limit in [(s, self.limit_solutions[s]) for s in sources_for_policy] if limit > 0.] + if sources_with_limits: + return min(sources_with_limits, key=lambda x: x[1])[0] + + return SpeedLimitSource.none + + def _resolve_limit_sources(self, sm: messaging.SubMaster) -> tuple[float, float, custom.LongitudinalPlanSP.SpeedLimit.Source]: + """Get limit solutions from each data source""" + self._get_from_car_state(sm) + self._get_from_map_data(sm) + + source = self._get_source_solution_according_to_policy() + speed_limit = self.limit_solutions[source] if source else 0. + distance = self.distance_solutions[source] if source else 0. + + return speed_limit, distance, source + + def update(self, v_ego: float, sm: messaging.SubMaster) -> None: + self.v_ego = v_ego + self.update_params() + + self.speed_limit, self.distance, self.source = self._resolve_limit_sources(sm) + self.speed_limit_offset = self._get_speed_limit_offset() + + self.update_speed_limit_states() + + self.frame += 1 diff --git a/sunnypilot/selfdrive/controls/lib/speed_limit/tests/__init__.py b/sunnypilot/selfdrive/controls/lib/speed_limit/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sunnypilot/selfdrive/controls/lib/speed_limit/tests/test_speed_limit_assist.py b/sunnypilot/selfdrive/controls/lib/speed_limit/tests/test_speed_limit_assist.py new file mode 100644 index 0000000000..f857966a73 --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/speed_limit/tests/test_speed_limit_assist.py @@ -0,0 +1,278 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" + +import pytest + +from cereal import custom +from opendbc.car.car_helpers import interfaces +from opendbc.car.rivian.values import CAR as RIVIAN +from opendbc.car.tesla.values import CAR as TESLA +from opendbc.car.toyota.values import CAR as TOYOTA +from openpilot.common.constants import CV +from openpilot.common.params import Params +from openpilot.common.realtime import DT_MDL +from openpilot.selfdrive.car.cruise import V_CRUISE_UNSET +from openpilot.sunnypilot import PARAMS_UPDATE_PERIOD +from openpilot.sunnypilot.selfdrive.car import interfaces as sunnypilot_interfaces +from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit import PCM_LONG_REQUIRED_MAX_SET_SPEED +from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.common import Mode +from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.speed_limit_assist import SpeedLimitAssist, \ + PRE_ACTIVE_GUARD_PERIOD, ACTIVE_STATES +from openpilot.sunnypilot.selfdrive.selfdrived.events import EventsSP + +SpeedLimitAssistState = custom.LongitudinalPlanSP.SpeedLimit.AssistState + +ALL_STATES = tuple(SpeedLimitAssistState.schema.enumerants.values()) + +SPEED_LIMITS = { + 'residential': 25 * CV.MPH_TO_MS, # 25 mph + 'city': 35 * CV.MPH_TO_MS, # 35 mph + 'highway': 65 * CV.MPH_TO_MS, # 65 mph + 'freeway': 80 * CV.MPH_TO_MS, # 80 mph +} + +DEFAULT_CAR = TOYOTA.TOYOTA_RAV4_TSS2 + + +@pytest.fixture +def car_name(request): + return getattr(request, "param", DEFAULT_CAR) + + +@pytest.fixture(autouse=True) +def set_car_name_on_instance(request, car_name): + instance = getattr(request, "instance", None) + if instance: + instance.car_name = car_name + + +class TestSpeedLimitAssist: + + def setup_method(self, method): + self.params = Params() + self.reset_custom_params() + self.events_sp = EventsSP() + CI = self._setup_platform(self.car_name) + self.sla = SpeedLimitAssist(CI.CP, CI.CP_SP) + self.sla.pre_active_timer = int(PRE_ACTIVE_GUARD_PERIOD[self.sla.pcm_op_long] / DT_MDL) + self.pcm_long_max_set_speed = PCM_LONG_REQUIRED_MAX_SET_SPEED[self.sla.is_metric][1] # use 80 MPH for now + self.speed_conv = CV.MS_TO_KPH if self.sla.is_metric else CV.MS_TO_MPH + + def teardown_method(self, method): + self.reset_state() + + def _setup_platform(self, car_name): + CarInterface = interfaces[car_name] + CP = CarInterface.get_non_essential_params(car_name) + CP_SP = CarInterface.get_non_essential_params_sp(CP, car_name) + CI = CarInterface(CP, CP_SP) + CI.CP.openpilotLongitudinalControl = True # always assume it's openpilot longitudinal + sunnypilot_interfaces.setup_interfaces(CI, self.params) + return CI + + def reset_custom_params(self): + self.params.put("IsReleaseSpBranch", True) + self.params.put("SpeedLimitMode", int(Mode.assist)) + self.params.put_bool("IsMetric", False) + self.params.put("SpeedLimitOffsetType", 0) + self.params.put("SpeedLimitValueOffset", 0) + + def reset_state(self): + self.sla.state = SpeedLimitAssistState.disabled + self.sla.frame = -1 + self.sla.last_op_engaged_frame = 0 + self.sla.op_engaged = False + self.sla.op_engaged_prev = False + self.sla._speed_limit = 0. + self.sla.speed_limit_prev = 0. + self.sla.last_valid_speed_limit_offsetted = 0. + self.sla._distance = 0. + self.events_sp.clear() + + def initialize_active_state(self, initialize_v_cruise): + self.sla.state = SpeedLimitAssistState.active + self.sla.v_cruise_cluster = initialize_v_cruise + self.sla.v_cruise_cluster_prev = initialize_v_cruise + self.sla.prev_v_cruise_cluster_conv = round(initialize_v_cruise * self.speed_conv) + + def test_initial_state(self): + assert self.sla.state == SpeedLimitAssistState.disabled + assert not self.sla.is_enabled + assert not self.sla.is_active + assert V_CRUISE_UNSET == self.sla.get_v_target_from_control() + + @pytest.mark.parametrize("car_name", [RIVIAN.RIVIAN_R1, TESLA.TESLA_MODEL_Y], indirect=True) + def test_disallowed_brands(self, car_name): + """ + Speed Limit Assist is disabled for the following brands and conditions: + - All Tesla and is a release branch; + - All Rivian + """ + assert not self.sla.enabled + + # stay disallowed even when the param may have changed from somewhere else + self.params.put("SpeedLimitMode", int(Mode.assist)) + for _ in range(int(PARAMS_UPDATE_PERIOD / DT_MDL)): + self.sla.update(True, False, SPEED_LIMITS['city'], 0, SPEED_LIMITS['highway'], SPEED_LIMITS['city'], + SPEED_LIMITS['city'], True, 0, self.events_sp) + assert not self.sla.enabled + + def test_disabled(self): + self.params.put("SpeedLimitMode", int(Mode.off)) + for _ in range(int(10. / DT_MDL)): + self.sla.update(True, False, SPEED_LIMITS['city'], 0, SPEED_LIMITS['highway'], SPEED_LIMITS['city'], SPEED_LIMITS['city'], True, 0, self.events_sp) + assert self.sla.state == SpeedLimitAssistState.disabled + + def test_transition_disabled_to_preactive(self): + for _ in range(int(3. / DT_MDL)): + self.sla.update(True, False, SPEED_LIMITS['city'], 0, SPEED_LIMITS['highway'], SPEED_LIMITS['city'], SPEED_LIMITS['city'], True, 0, self.events_sp) + assert self.sla.state == SpeedLimitAssistState.preActive + assert self.sla.is_enabled and not self.sla.is_active + + def test_transition_disabled_to_pending_no_speed_limit_not_max_initial_set_speed(self): + for _ in range(int(3. / DT_MDL)): + self.sla.update(True, False, SPEED_LIMITS['highway'], 0, SPEED_LIMITS['city'], 0, 0, False, 0, self.events_sp) + assert self.sla.state == SpeedLimitAssistState.pending + assert self.sla.is_enabled and not self.sla.is_active + + def test_preactive_to_active_with_max_speed_confirmation(self): + self.sla.state = SpeedLimitAssistState.preActive + self.sla.update(True, False, SPEED_LIMITS['city'], 0, self.pcm_long_max_set_speed, SPEED_LIMITS['highway'], + SPEED_LIMITS['highway'], True, 0, self.events_sp) + assert self.sla.state == SpeedLimitAssistState.active + assert self.sla.is_enabled and self.sla.is_active + assert self.sla.output_v_target == SPEED_LIMITS['highway'] + + def test_preactive_timeout_to_inactive(self): + self.sla.state = SpeedLimitAssistState.preActive + self.sla.update(True, False, SPEED_LIMITS['city'], 0, SPEED_LIMITS['highway'], SPEED_LIMITS['city'], SPEED_LIMITS['city'], True, 0, self.events_sp) + + for _ in range(int(PRE_ACTIVE_GUARD_PERIOD[self.sla.pcm_op_long] / DT_MDL)): + self.sla.update(True, False, SPEED_LIMITS['city'], 0, SPEED_LIMITS['highway'], SPEED_LIMITS['city'], SPEED_LIMITS['city'], True, 0, self.events_sp) + assert self.sla.state == SpeedLimitAssistState.inactive + + def test_preactive_to_pending_no_speed_limit(self): + self.sla.state = SpeedLimitAssistState.preActive + self.sla.update(True, False, SPEED_LIMITS['highway'], 0, self.pcm_long_max_set_speed, 0, 0, False, 0, self.events_sp) + assert self.sla.state == SpeedLimitAssistState.pending + assert self.sla.is_enabled and not self.sla.is_active + + def test_pending_to_active_when_speed_limit_available(self): + self.sla.state = SpeedLimitAssistState.pending + self.sla.v_cruise_cluster_prev = self.pcm_long_max_set_speed + self.sla.prev_v_cruise_cluster_conv = round(self.pcm_long_max_set_speed * self.speed_conv) + + self.sla.update(True, False, SPEED_LIMITS['highway'], 0, self.pcm_long_max_set_speed, + SPEED_LIMITS['highway'], SPEED_LIMITS['highway'], True, 0, self.events_sp) + assert self.sla.state == SpeedLimitAssistState.active + + def test_pending_to_adapting_when_below_speed_limit(self): + self.sla.state = SpeedLimitAssistState.pending + self.sla.v_cruise_cluster_prev = self.pcm_long_max_set_speed + self.sla.prev_v_cruise_cluster_conv = round(self.pcm_long_max_set_speed * self.speed_conv) + + self.sla.update(True, False, SPEED_LIMITS['highway'] + 5, 0, self.pcm_long_max_set_speed, + SPEED_LIMITS['highway'], SPEED_LIMITS['highway'], True, 0, self.events_sp) + assert self.sla.state == SpeedLimitAssistState.adapting + assert self.sla.is_enabled and self.sla.is_active + + def test_active_to_adapting_transition(self): + self.initialize_active_state(self.pcm_long_max_set_speed) + + self.sla.update(True, False, SPEED_LIMITS['highway'] + 2, 0, self.pcm_long_max_set_speed, SPEED_LIMITS['highway'], + SPEED_LIMITS['highway'], True, 0, self.events_sp) + assert self.sla.state == SpeedLimitAssistState.adapting + + def test_adapting_to_active_transition(self): + self.sla.state = SpeedLimitAssistState.adapting + self.sla.v_cruise_cluster_prev = self.pcm_long_max_set_speed + self.sla.prev_v_cruise_cluster_conv = round(self.pcm_long_max_set_speed * self.speed_conv) + + self.sla.update(True, False, SPEED_LIMITS['city'], 0, self.pcm_long_max_set_speed, SPEED_LIMITS['highway'], + SPEED_LIMITS['highway'], True, 0, self.events_sp) + assert self.sla.state == SpeedLimitAssistState.active + + def test_manual_cruise_change_detection(self): + self.sla.state = SpeedLimitAssistState.active + expected_cruise = SPEED_LIMITS['highway'] + self.sla.v_cruise_cluster_prev = expected_cruise + + different_cruise = SPEED_LIMITS['highway'] + 5 + self.sla.update(True, False, SPEED_LIMITS['city'], 0, different_cruise, SPEED_LIMITS['city'], SPEED_LIMITS['city'], True, 0, self.events_sp) + assert self.sla.state == SpeedLimitAssistState.inactive + + # TODO-SP: test lower CST cases + def test_rapid_speed_limit_changes(self): + self.initialize_active_state(self.pcm_long_max_set_speed) + speed_limits = [SPEED_LIMITS['highway'], SPEED_LIMITS['freeway']] + + for _, speed_limit in enumerate(speed_limits): + self.sla.update(True, False, speed_limit, 0, self.pcm_long_max_set_speed, speed_limit, speed_limit, True, 0, self.events_sp) + assert self.sla.state in ACTIVE_STATES + + def test_invalid_speed_limits_handling(self): + self.initialize_active_state(self.pcm_long_max_set_speed) + + invalid_limits = [-10, 0, 200 * CV.MPH_TO_MS] + + for invalid_limit in invalid_limits: + self.sla.update(True, False, SPEED_LIMITS['city'], 0, self.pcm_long_max_set_speed, invalid_limit, SPEED_LIMITS['city'], True, 0, self.events_sp) + assert isinstance(self.sla.output_v_target, (int, float)) + assert self.sla.output_v_target == V_CRUISE_UNSET or self.sla.output_v_target > 0 + + def test_stale_data_handling(self): + self.initialize_active_state(self.pcm_long_max_set_speed) + old_speed_limit = SPEED_LIMITS['city'] + + self.sla.update(True, False, SPEED_LIMITS['city'], 0, self.pcm_long_max_set_speed, 0, old_speed_limit, True, 0, self.events_sp) + assert self.sla.state in ACTIVE_STATES + assert self.sla.output_v_target == old_speed_limit + + def test_distance_based_adapting(self): + self.sla.state = SpeedLimitAssistState.adapting + self.sla.v_cruise_cluster_prev = self.pcm_long_max_set_speed + self.sla.prev_v_cruise_cluster_conv = round(self.pcm_long_max_set_speed * self.speed_conv) + + distance = 100.0 + current_speed = SPEED_LIMITS['freeway'] + target_speed = SPEED_LIMITS['highway'] + + self.sla.update(True, False, current_speed, 0, self.pcm_long_max_set_speed, target_speed, target_speed, True, distance, self.events_sp) + assert self.sla.state == SpeedLimitAssistState.adapting + assert self.sla.output_v_target == target_speed # TODO-SP: assert expected accel, need to enable self.acceleration_solutions + + def test_long_disengaged_to_disabled(self): + self.initialize_active_state(self.pcm_long_max_set_speed) + + self.sla.update(False, False, SPEED_LIMITS['city'], 0, self.pcm_long_max_set_speed, SPEED_LIMITS['city'], + SPEED_LIMITS['city'], True, 0, self.events_sp) + assert self.sla.state == SpeedLimitAssistState.disabled + assert self.sla.output_v_target == V_CRUISE_UNSET + + def test_maintain_states_with_no_changes(self): + """Test that states are maintained when no significant changes occur""" + test_states = [ + SpeedLimitAssistState.preActive, + SpeedLimitAssistState.pending, + SpeedLimitAssistState.active, + SpeedLimitAssistState.adapting + ] + + for state in test_states: + self.sla.state = state + self.sla.op_engaged = True + + initial_state = state + + self.sla.update(True, False, SPEED_LIMITS['city'], 0, self.pcm_long_max_set_speed, SPEED_LIMITS['city'], SPEED_LIMITS['city'], True, 0, self.events_sp) + + assert self.sla.state in ALL_STATES # Sanity check + + if initial_state == SpeedLimitAssistState.preActive: + assert self.sla.state in [SpeedLimitAssistState.preActive, SpeedLimitAssistState.active] + elif initial_state in ACTIVE_STATES: + assert self.sla.state in ACTIVE_STATES diff --git a/sunnypilot/selfdrive/controls/lib/speed_limit/tests/test_speed_limit_resolver.py b/sunnypilot/selfdrive/controls/lib/speed_limit/tests/test_speed_limit_resolver.py new file mode 100644 index 0000000000..c02831d71a --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/speed_limit/tests/test_speed_limit_resolver.py @@ -0,0 +1,144 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import random +import time + +import pytest +from pytest_mock import MockerFixture + +from cereal import custom +from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit import LIMIT_MAX_MAP_DATA_AGE + +from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.speed_limit_resolver import SpeedLimitResolver, ALL_SOURCES +from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.common import Policy + +SpeedLimitSource = custom.LongitudinalPlanSP.SpeedLimit.Source + + +def create_mock(properties, mocker: MockerFixture): + mock = mocker.MagicMock() + for _property, value in properties.items(): + setattr(mock, _property, value) + return mock + + +def setup_sm_mock(mocker: MockerFixture): + cruise_speed_limit = random.uniform(0, 120) + live_map_data_limit = random.uniform(0, 120) + + car_state = create_mock({ + 'gasPressed': False, + 'brakePressed': False, + 'standstill': False, + }, mocker) + car_state_sp = create_mock({ + 'speedLimit': cruise_speed_limit, + }, mocker) + live_map_data = create_mock({ + 'speedLimit': live_map_data_limit, + 'speedLimitValid': True, + 'speedLimitAhead': 0., + 'speedLimitAheadValid': 0., + 'speedLimitAheadDistance': 0., + }, mocker) + gps_data = create_mock({ + 'unixTimestampMillis': time.monotonic() * 1e3, + }, mocker) + sm_mock = mocker.MagicMock() + sm_mock.__getitem__.side_effect = lambda key: { + 'carState': car_state, + 'liveMapDataSP': live_map_data, + 'carStateSP': car_state_sp, + 'gpsLocation': gps_data, + }[key] + return sm_mock + + +parametrized_policies = pytest.mark.parametrize( + "policy, sm_key, function_key", [ + (Policy.car_state_only, 'carStateSP', SpeedLimitSource.car), + (Policy.car_state_priority, 'carStateSP', SpeedLimitSource.car), + (Policy.map_data_only, 'liveMapDataSP', SpeedLimitSource.map), + (Policy.map_data_priority, 'liveMapDataSP', SpeedLimitSource.map), + ], + ids=lambda val: val.name if hasattr(val, 'name') else str(val) +) + + +@pytest.mark.parametrize("resolver_class", [SpeedLimitResolver]) +class TestSpeedLimitResolverValidation: + + @pytest.mark.parametrize("policy", list(Policy), ids=lambda policy: policy.name) + def test_initial_state(self, resolver_class, policy): + resolver = resolver_class() + resolver.policy = policy + for source in ALL_SOURCES: + if source in resolver.limit_solutions: + assert resolver.limit_solutions[source] == 0. + assert resolver.distance_solutions[source] == 0. + + @parametrized_policies + def test_resolver(self, resolver_class, policy, sm_key, function_key, mocker: MockerFixture): + resolver = resolver_class() + resolver.policy = policy + sm_mock = setup_sm_mock(mocker) + source_speed_limit = sm_mock[sm_key].speedLimit + + # Assert the resolver + resolver.update(source_speed_limit, sm_mock) + assert resolver.speed_limit == source_speed_limit + assert resolver.source == ALL_SOURCES[function_key] + + def test_resolver_combined(self, resolver_class, mocker: MockerFixture): + resolver = resolver_class() + resolver.policy = Policy.combined + sm_mock = setup_sm_mock(mocker) + socket_to_source = {'carStateSP': SpeedLimitSource.car, 'liveMapDataSP': SpeedLimitSource.map} + minimum_key, minimum_speed_limit = min( + ((key, sm_mock[key].speedLimit) for key in + socket_to_source.keys()), key=lambda x: x[1]) + + # Assert the resolver + resolver.update(minimum_speed_limit, sm_mock) + assert resolver.speed_limit == minimum_speed_limit + assert resolver.source == socket_to_source[minimum_key] + + @parametrized_policies + def test_parser(self, resolver_class, policy, sm_key, function_key, mocker: MockerFixture): + resolver = resolver_class() + resolver.policy = policy + sm_mock = setup_sm_mock(mocker) + source_speed_limit = sm_mock[sm_key].speedLimit + + # Assert the parsing + resolver.update(source_speed_limit, sm_mock) + assert resolver.limit_solutions[ALL_SOURCES[function_key]] == source_speed_limit + assert resolver.distance_solutions[ALL_SOURCES[function_key]] == 0. + + @pytest.mark.parametrize("policy", list(Policy), ids=lambda policy: policy.name) + def test_resolve_interaction_in_update(self, resolver_class, policy, mocker: MockerFixture): + v_ego = 50 + resolver = resolver_class() + resolver.policy = policy + + sm_mock = setup_sm_mock(mocker) + resolver.update(v_ego, sm_mock) + + # After resolution + assert resolver.speed_limit is not None + assert resolver.distance is not None + assert resolver.source is not None + + @pytest.mark.parametrize("policy", list(Policy), ids=lambda policy: policy.name) + def test_old_map_data_ignored(self, resolver_class, policy, mocker: MockerFixture): + resolver = resolver_class() + resolver.policy = policy + sm_mock = mocker.MagicMock() + sm_mock['gpsLocation'].unixTimestampMillis = (time.monotonic() - 2 * LIMIT_MAX_MAP_DATA_AGE) * 1e3 + resolver._get_from_map_data(sm_mock) + assert resolver.limit_solutions[SpeedLimitSource.map] == 0. + assert resolver.distance_solutions[SpeedLimitSource.map] == 0. diff --git a/sunnypilot/selfdrive/controls/lib/tests/test_auto_lane_change.py b/sunnypilot/selfdrive/controls/lib/tests/test_auto_lane_change.py new file mode 100644 index 0000000000..b4ec5041c8 --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/tests/test_auto_lane_change.py @@ -0,0 +1,211 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from openpilot.common.parameterized import parameterized +from openpilot.common.realtime import DT_MDL +from openpilot.selfdrive.controls.lib.desire_helper import DesireHelper, LaneChangeState, LaneChangeDirection +from openpilot.sunnypilot.selfdrive.controls.lib.auto_lane_change import AutoLaneChangeController, AutoLaneChangeMode, \ + AUTO_LANE_CHANGE_TIMER, ONE_SECOND_DELAY + +AUTO_LANE_CHANGE_TIMER_COMBOS = [ + (AutoLaneChangeMode.NUDGELESS, AUTO_LANE_CHANGE_TIMER[AutoLaneChangeMode.NUDGELESS]), + (AutoLaneChangeMode.HALF_SECOND, AUTO_LANE_CHANGE_TIMER[AutoLaneChangeMode.HALF_SECOND]), + (AutoLaneChangeMode.ONE_SECOND, AUTO_LANE_CHANGE_TIMER[AutoLaneChangeMode.ONE_SECOND]), + (AutoLaneChangeMode.TWO_SECONDS, AUTO_LANE_CHANGE_TIMER[AutoLaneChangeMode.TWO_SECONDS]), + (AutoLaneChangeMode.THREE_SECONDS, AUTO_LANE_CHANGE_TIMER[AutoLaneChangeMode.THREE_SECONDS]) +] + + +class TestAutoLaneChangeController: + def setup_method(self): + self.DH = DesireHelper() + self.alc = AutoLaneChangeController(self.DH) + + def _reset_states(self): + self.alc.lane_change_bsm_delay = False + self.alc.lane_change_set_timer = AutoLaneChangeMode.NUDGE + self.lane_change_wait_timer = 0.0 + self.prev_brake_pressed = False + self.prev_lane_change = False + + def test_reset(self): + """Test that reset correctly sets timers back to default.""" + # Set some non-default values + self.alc.lane_change_wait_timer = 2.0 + self.alc.prev_brake_pressed = True + + # Set the DesireHelper to trigger a reset + self.DH.lane_change_state = LaneChangeState.off + self.DH.lane_change_direction = LaneChangeDirection.none + + # Call reset + self.alc.reset() + + # Check values were reset + assert self.alc.lane_change_wait_timer == 0.0 + assert not self.alc.prev_brake_pressed + + @parameterized.expand([(AutoLaneChangeMode.OFF, ), (AutoLaneChangeMode.NUDGE, )]) + + def test_off_and_nudge_mode(self, timer_state): + """Test the default OFF and NUDGE mode behavior.""" + self._reset_states() + # Setup mode + self.alc.lane_change_bsm_delay = False # BSM delay off + self.alc.lane_change_set_timer = timer_state + + # Update controller + num_updates = int(5.0 / DT_MDL) + for _ in range(num_updates): # Run for 5 seconds + self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False) + + # Mode should not allow lane change immediately + assert not self.alc.auto_lane_change_allowed + + def test_nudgeless_mode(self): + """Test the NUDGELESS mode behavior.""" + self._reset_states() + # Setup NUDGELESS mode + self.alc.lane_change_bsm_delay = False # BSM delay off + self.alc.lane_change_set_timer = AutoLaneChangeMode.NUDGELESS + + # Update controller once to read params + self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False) + + # Update multiple times to exceed the timer threshold + for _ in range(1): # Should exceed 0.1s with multiple DT_MDL updates + self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False) + + # Now lane change should be allowed + assert self.alc.lane_change_wait_timer > self.alc.lane_change_delay + assert self.alc.auto_lane_change_allowed + + @parameterized.expand(AUTO_LANE_CHANGE_TIMER_COMBOS) + def test_timers(self, timer_state, timer_delay): + self._reset_states() + self.alc.lane_change_bsm_delay = False # BSM delay off + self.alc.lane_change_set_timer = timer_state + + # Update controller once + self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False) + + # The timer should still be below the threshold after one update + assert not self.alc.auto_lane_change_allowed + + # Update enough times to exceed the threshold (seconds / DT_MDL) + num_updates = int(timer_delay / DT_MDL) + 1 # Add one extra updates to ensure we exceed the threshold + for _ in range(num_updates): + self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False) + + # Now lane change should be allowed + assert self.alc.lane_change_wait_timer > self.alc.lane_change_delay + assert self.alc.auto_lane_change_allowed + + @parameterized.expand(AUTO_LANE_CHANGE_TIMER_COMBOS) + def test_brake_pressed_disables_auto_lane_change(self, timer_state, timer_delay): + """Test that pressing the brake disables auto lane change.""" + self._reset_states() + # Setup auto lane change mode + self.alc.lane_change_bsm_delay = False + self.alc.lane_change_set_timer = timer_state + num_updates = int(timer_delay / DT_MDL) + 1 # Add one extra updates to ensure we exceed the threshold + + # Update with brake pressed for 1 second + for _ in range(num_updates): + self.alc.update_lane_change(blindspot_detected=False, brake_pressed=True) + + # Even though it is an auto lane change mode, lane change should be disallowed due to brake pressed prior initiating lane change + assert not self.alc.auto_lane_change_allowed + + # Check that prev_brake_pressed is saved + assert self.alc.prev_brake_pressed + + # Even releasing brake shouldn't allow auto lane change + for _ in range(num_updates): + self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False) + + assert not self.alc.auto_lane_change_allowed + + @parameterized.expand(AUTO_LANE_CHANGE_TIMER_COMBOS) + def test_blindspot_detected_with_bsm_delay(self, timer_state, timer_delay): + """Test behavior when blindspot is detected with BSM delay enabled.""" + # Blindspot detected - should prevent auto lane change + self._reset_states() + self.alc.lane_change_bsm_delay = True # BSM delay on + self.alc.lane_change_set_timer = timer_state + + # Update with blindspot detected - this should prevent auto lane change + self.alc.update_lane_change(blindspot_detected=True, brake_pressed=False) + assert not self.alc.auto_lane_change_allowed + + # Keep updating with blindspot detected - should still prevent auto lane change + num_updates = int(timer_delay / DT_MDL) + 1 # Add one extra updates to ensure we exceed the threshold + for _ in range(num_updates): + self.alc.update_lane_change(blindspot_detected=True, brake_pressed=False) + assert not self.alc.auto_lane_change_allowed + + @parameterized.expand(AUTO_LANE_CHANGE_TIMER_COMBOS) + def test_blindspot_detected_then_undetected_with_bsm_delay(self, timer_state, timer_delay): + """Test behavior when blindspot is detected then undetected with BSM delay enabled.""" + # Blindspot clears - should allow auto lane change after sufficient time + self._reset_states() + self.alc.lane_change_bsm_delay = True + self.alc.lane_change_set_timer = timer_state + + # First update with blindspot detected to set the negative timer + self.alc.update_lane_change(blindspot_detected=True, brake_pressed=False) + assert not self.alc.auto_lane_change_allowed + + # Now update with blindspot cleared - should start incrementing timer from negative value + num_updates = int((timer_delay + abs(ONE_SECOND_DELAY)) / DT_MDL) + 1 + for _ in range(num_updates): + self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False) + + # After sufficient updates with no blindspot, auto lane change should be allowed + assert self.alc.auto_lane_change_allowed + + @parameterized.expand(AUTO_LANE_CHANGE_TIMER_COMBOS) + def test_disallow_continuous_auto_lane_change(self, timer_state, timer_delay): + self._reset_states() + self.alc.lane_change_bsm_delay = False # BSM delay off + self.alc.lane_change_set_timer = timer_state + num_updates = int(timer_delay / DT_MDL) + 1 # Add one extra updates to ensure we exceed the threshold + + # Update enough times to exceed the threshold (seconds / DT_MDL) + for _ in range(num_updates): + self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False) + + # Now lane change should be allowed + assert self.alc.lane_change_wait_timer > self.alc.lane_change_delay + assert self.alc.auto_lane_change_allowed + + # Simulate lane change is initiated + self.DH.lane_change_state = LaneChangeState.laneChangeStarting + self.alc.update_state() + + # Simulate lane change is completed, and one_blinker stays on + self.DH.lane_change_state = LaneChangeState.preLaneChange + self.alc.update_state() + + # Update enough times to exceed the threshold (seconds / DT_MDL) + for _ in range(num_updates): + self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False) + + assert not self.alc.auto_lane_change_allowed + + def test_auto_lane_change_mode_off_disallows_lane_change(self): + """Test that OFF mode never allows auto lane change.""" + self._reset_states() + self.alc.lane_change_bsm_delay = False + self.alc.lane_change_set_timer = AutoLaneChangeMode.OFF + + # Simulate updates for a long period of time (e.g., 10 seconds) + num_updates = int(10.0 / DT_MDL) + for _ in range(num_updates): + self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False) + + # Lane change should never be allowed + assert not self.alc.auto_lane_change_allowed diff --git a/sunnypilot/selfdrive/controls/lib/tests/test_blinker_pause_lateral.py b/sunnypilot/selfdrive/controls/lib/tests/test_blinker_pause_lateral.py new file mode 100644 index 0000000000..b547ea96b6 --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/tests/test_blinker_pause_lateral.py @@ -0,0 +1,144 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from cereal import car + +from openpilot.common.constants import CV +from openpilot.sunnypilot.selfdrive.controls.lib.blinker_pause_lateral import BlinkerPauseLateral + + +class TestBlinkerPauseLateral: + + def setup_method(self): + self.blinker_pause_lateral = BlinkerPauseLateral() + self._reset_states() + + def _reset_states(self): + self.blinker_pause_lateral.enabled = True + self.blinker_pause_lateral.is_metric = False + self.blinker_pause_lateral.min_speed = 20 # MPH + self.blinker_pause_lateral.reengage_delay = 0 + self.blinker_pause_lateral.blinker_off_timer = 0.0 + + self.CS = car.CarState.new_message() + self.CS.vEgo = 0 + self.CS.leftBlinker = False + self.CS.rightBlinker = False + + def _test_should_blinker_pause_lateral(self, expected_results) -> None: + for left in (True, False): + for right in (True, False): + self.CS.leftBlinker = left + self.CS.rightBlinker = right + + result = self.blinker_pause_lateral.update(self.CS) + assert result == expected_results[(left, right)] + + def test_below_min_speed_blinker(self): + self.CS.vEgo = 4.5 # ~10 MPH + + expected_results = { + (False, False): False, + (True, False): True, + (False, True): True, + (True, True): False + } + self._test_should_blinker_pause_lateral(expected_results) + + def test_reengage_delay(self): + self.blinker_pause_lateral.reengage_delay = 2 # seconds + self.CS.vEgo = 4.5 # ~10 MPH + + expected_results = { + (False, False): True, + (True, False): True, + (False, True): True, + (True, True): False + } + self._test_should_blinker_pause_lateral(expected_results) + + def test_above_min_speed_blinker(self): + self.CS.vEgo = 13.4 # ~30 MPH + + expected_results = { + (False, False): False, + (True, False): False, + (False, True): False, + (True, True): False + } + self._test_should_blinker_pause_lateral(expected_results) + + def test_just_below_min_speed(self): + self.CS.vEgo = (20 * CV.MPH_TO_MS) - 0.01 + + expected_results = { + (False, False): False, + (True, False): True, + (False, True): True, + (True, True): False + } + self._test_should_blinker_pause_lateral(expected_results) + + def test_disabled(self): + self.blinker_pause_lateral.enabled = False + self.CS.vEgo = 4.5 # ~10 MPH + + expected_results = { + (False, False): False, + (True, False): False, + (False, True): False, + (True, True): False + } + self._test_should_blinker_pause_lateral(expected_results) + + def test_metric_units_below_min_speed(self): + self.blinker_pause_lateral.is_metric = True + self.CS.vEgo = 5.0 # ~18 km/h + + expected_results = { + (False, False): False, + (True, False): True, + (False, True): True, + (True, True): False + } + self._test_should_blinker_pause_lateral(expected_results) + + def test_metric_units_above_threshold(self): + self.blinker_pause_lateral.is_metric = True + self.CS.vEgo = 6.0 # ~21.6 km/h + + expected_results = { + (False, False): False, + (True, False): False, + (False, True): False, + (True, True): False + } + self._test_should_blinker_pause_lateral(expected_results) + + def test_change_min_speed_threshold(self): + self.blinker_pause_lateral.min_speed = 30 # MPH + + # below min speed + self.CS.vEgo = 11.2 # ~25 MPH + + expected_results = { + (False, False): False, + (True, False): True, + (False, True): True, + (True, True): False + } + self._test_should_blinker_pause_lateral(expected_results) + + # above min speed + self.CS.vEgo = 15.6 # ~35 MPH + + expected_results = { + (False, False): False, + (True, False): False, + (False, True): False, + (True, True): False + } + self._test_should_blinker_pause_lateral(expected_results) diff --git a/sunnypilot/selfdrive/controls/lib/tests/test_lane_turn_desire.py b/sunnypilot/selfdrive/controls/lib/tests/test_lane_turn_desire.py new file mode 100644 index 0000000000..57fe7b684f --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/tests/test_lane_turn_desire.py @@ -0,0 +1,113 @@ +import pytest +from cereal import log, custom +from openpilot.common.params import Params + +from openpilot.selfdrive.controls.lib.desire_helper import DesireHelper +from openpilot.sunnypilot.selfdrive.controls.lib.lane_turn_desire import LaneTurnController, LANE_CHANGE_SPEED_MIN +from openpilot.sunnypilot.selfdrive.controls.lib.auto_lane_change import AutoLaneChangeMode + +TurnDirection = custom.ModelDataV2SP.TurnDirection + + +@pytest.mark.parametrize("left_blinker,right_blinker,v_ego,blindspot_left,blindspot_right,expected", [ + (True, False, 5, False, False, TurnDirection.turnLeft), + (False, True, 6, False, False, TurnDirection.turnRight), + (True, False, 9, False, False, TurnDirection.none), + (True, False, 7, True, False, TurnDirection.none), + (False, True, 6, False, True, TurnDirection.none), + (False, False, 5, False, False, TurnDirection.none), + (True, True, 5, False, False, TurnDirection.none), +]) +def test_lane_turn_desire_conditions(left_blinker, right_blinker, v_ego, blindspot_left, blindspot_right, expected): + dh = DesireHelper() + controller = LaneTurnController(dh) + controller.enabled = True + controller.lane_turn_value = LANE_CHANGE_SPEED_MIN + controller.turn_direction = TurnDirection.none + controller.update_lane_turn(blindspot_left, blindspot_right, left_blinker, right_blinker, v_ego) + assert controller.get_turn_direction() == expected + + +def test_lane_turn_desire_disabled(): + dh = DesireHelper() + controller = LaneTurnController(dh) + controller.enabled = False + controller.lane_turn_value = LANE_CHANGE_SPEED_MIN + controller.turn_direction = TurnDirection.none + controller.update_lane_turn(False, False, True, False, 7) + assert controller.get_turn_direction() == TurnDirection.none + + +def test_lane_turn_overrides_lane_change(): + dh = DesireHelper() + controller = LaneTurnController(dh) + controller.enabled = True + controller.lane_turn_value = LANE_CHANGE_SPEED_MIN + controller.turn_direction = TurnDirection.none + # left turn desire + controller.update_lane_turn(False, False, True, False, 5) + assert controller.get_turn_direction() == TurnDirection.turnLeft + # right turn desire + controller.update_lane_turn(False, False, False, True, 6) + assert controller.get_turn_direction() == TurnDirection.turnRight + # no turn + controller.update_lane_turn(False, False, False, False, 7) + assert controller.get_turn_direction() == TurnDirection.none + + +@pytest.mark.parametrize("v_ego,expected", [ + (8.93, TurnDirection.turnLeft), # just below threshold + (8.96, TurnDirection.none), # above threshold + (8.95, TurnDirection.none), # just above threshold +]) +def test_lane_turn_desire_speed_boundary(v_ego, expected): + dh = DesireHelper() + controller = LaneTurnController(dh) + controller.enabled = True + controller.lane_turn_value = LANE_CHANGE_SPEED_MIN + controller.turn_direction = TurnDirection.none + controller.update_lane_turn(False, True, True, False, v_ego) + assert controller.get_turn_direction() == expected + + +class DummyCarState: + def __init__(self, vEgo=0, leftBlinker=False, rightBlinker=False, leftBlindspot=False, rightBlindspot=False, + steeringPressed=False, steeringTorque=0, brakePressed=False): + self.vEgo = vEgo + self.leftBlinker = leftBlinker + self.rightBlinker = rightBlinker + self.leftBlindspot = leftBlindspot + self.rightBlindspot = rightBlindspot + self.steeringPressed = steeringPressed + self.steeringTorque = steeringTorque + self.brakePressed = brakePressed + + +@pytest.fixture +def set_lane_turn_params(): + params = Params() + params.put("LaneTurnDesire", True) + params.put("LaneTurnValue", 20.0) + + +@pytest.mark.parametrize("carstate, lateral_active, lane_change_prob, expected_desire", [ + # Lane turn desire overrides lane change desire + (DummyCarState(vEgo=5, leftBlinker=True, rightBlinker=False, leftBlindspot=False, rightBlindspot=False), True, 1.0, + log.Desire.turnLeft), + (DummyCarState(vEgo=7, leftBlinker=False, rightBlinker=True, leftBlindspot=False, rightBlindspot=False), True, 1.0, + log.Desire.turnRight), + # Lane change desire only (no turn desires) + (DummyCarState(vEgo=9, leftBlinker=True, rightBlinker=False, leftBlindspot=False, rightBlindspot=False, + steeringPressed=True, steeringTorque=1), True, 1.0, log.Desire.laneChangeLeft), + (DummyCarState(vEgo=9, leftBlinker=False, rightBlinker=True, leftBlindspot=False, rightBlindspot=False, + steeringPressed=True, steeringTorque=-1), True, 1.0, log.Desire.laneChangeRight), + # No desire (inactive) + (DummyCarState(vEgo=9, leftBlinker=False, rightBlinker=False), False, 1.0, log.Desire.none), + (DummyCarState(vEgo=4, leftBlinker=False, rightBlinker=False), True, 1.0, log.Desire.none), # No blinkers? no desire! +]) +def test_desire_helper_integration(carstate, lateral_active, lane_change_prob, expected_desire, set_lane_turn_params): + dh = DesireHelper() + dh.alc.lane_change_set_timer = AutoLaneChangeMode.NUDGE + for _ in range(10): + dh.update(carstate, lateral_active, lane_change_prob) + assert dh.desire == expected_desire # The first four tests were unit tests to test the controller, where this tests the integration in desire helpers diff --git a/sunnypilot/selfdrive/locationd/.gitignore b/sunnypilot/selfdrive/locationd/.gitignore new file mode 100644 index 0000000000..11b9f127b2 --- /dev/null +++ b/sunnypilot/selfdrive/locationd/.gitignore @@ -0,0 +1,3 @@ +params_learner +paramsd +locationd diff --git a/sunnypilot/selfdrive/locationd/SConscript b/sunnypilot/selfdrive/locationd/SConscript new file mode 100644 index 0000000000..3e71f36782 --- /dev/null +++ b/sunnypilot/selfdrive/locationd/SConscript @@ -0,0 +1,30 @@ +Import('env', 'arch', 'common', 'messaging', 'rednose', 'transformations') + +loc_libs = [messaging, common, 'pthread', 'dl'] + +# build ekf models +rednose_gen_dir = 'models/generated' +rednose_gen_deps = [ + "models/constants.py", +] +live_ekf = env.RednoseCompileFilter( + target='live', + filter_gen_script='models/live_kf.py', + output_dir=rednose_gen_dir, + extra_gen_artifacts=['live_kf_constants.h'], + gen_script_deps=rednose_gen_deps, +) + +# locationd build +locationd_sources = ["locationd.cc", "models/live_kf.cc"] + +lenv = env.Clone() +# ekf filter libraries need to be linked, even if no symbols are used +if arch != "Darwin": + lenv["LINKFLAGS"] += ["-Wl,--no-as-needed"] + +lenv["LIBPATH"].append(Dir(rednose_gen_dir).abspath) +lenv["RPATH"].append(Dir(rednose_gen_dir).abspath) +locationd = lenv.Program("locationd", locationd_sources, LIBS=["live", "ekf_sym"] + loc_libs + transformations) +lenv.Depends(locationd, rednose) +lenv.Depends(locationd, live_ekf) diff --git a/sunnypilot/selfdrive/locationd/__init__.py b/sunnypilot/selfdrive/locationd/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sunnypilot/selfdrive/locationd/locationd.cc b/sunnypilot/selfdrive/locationd/locationd.cc new file mode 100644 index 0000000000..c42ab13f25 --- /dev/null +++ b/sunnypilot/selfdrive/locationd/locationd.cc @@ -0,0 +1,750 @@ +#include "sunnypilot/selfdrive/locationd/locationd.h" + +#include +#include + +#include +#include +#include + +using namespace EKFS; +using namespace Eigen; + +ExitHandler do_exit; +const double ACCEL_SANITY_CHECK = 100.0; // m/s^2 +const double ROTATION_SANITY_CHECK = 10.0; // rad/s +const double TRANS_SANITY_CHECK = 200.0; // m/s +const double CALIB_RPY_SANITY_CHECK = 0.5; // rad (+- 30 deg) +const double ALTITUDE_SANITY_CHECK = 10000; // m +const double MIN_STD_SANITY_CHECK = 1e-5; // m or rad +const double VALID_TIME_SINCE_RESET = 1.0; // s +const double VALID_POS_STD = 50.0; // m +const double MAX_RESET_TRACKER = 5.0; +const double SANE_GPS_UNCERTAINTY = 1500.0; // m +const double INPUT_INVALID_THRESHOLD = 0.5; // same as reset tracker +const double RESET_TRACKER_DECAY = 0.99995; +const double DECAY = 0.9993; // ~10 secs to resume after a bad input +const double MAX_FILTER_REWIND_TIME = 0.8; // s +const double YAWRATE_CROSS_ERR_CHECK_FACTOR = 30; + +// TODO: GPS sensor time offsets are empirically calculated +// They should be replaced with synced time from a real clock +const double GPS_QUECTEL_SENSOR_TIME_OFFSET = 0.630; // s +const double GPS_UBLOX_SENSOR_TIME_OFFSET = 0.095; // s +const float GPS_POS_STD_THRESHOLD = 50.0; +const float GPS_VEL_STD_THRESHOLD = 5.0; +const float GPS_POS_ERROR_RESET_THRESHOLD = 300.0; +const float GPS_POS_STD_RESET_THRESHOLD = 2.0; +const float GPS_VEL_STD_RESET_THRESHOLD = 0.5; +const float GPS_ORIENTATION_ERROR_RESET_THRESHOLD = 1.0; +const int GPS_ORIENTATION_ERROR_RESET_CNT = 3; + +const bool DEBUG = getenv("DEBUG") != nullptr && std::string(getenv("DEBUG")) != "0"; + +static VectorXd floatlist2vector(const capnp::List::Reader& floatlist) { + VectorXd res(floatlist.size()); + for (int i = 0; i < floatlist.size(); i++) { + res[i] = floatlist[i]; + } + return res; +} + +static Vector4d quat2vector(const Quaterniond& quat) { + return Vector4d(quat.w(), quat.x(), quat.y(), quat.z()); +} + +static Quaterniond vector2quat(const VectorXd& vec) { + return Quaterniond(vec(0), vec(1), vec(2), vec(3)); +} + +static void init_measurement(cereal::LiveLocationKalman::Measurement::Builder meas, const VectorXd& val, const VectorXd& std, bool valid) { + meas.setValue(kj::arrayPtr(val.data(), val.size())); + meas.setStd(kj::arrayPtr(std.data(), std.size())); + meas.setValid(valid); +} + + +static MatrixXdr rotate_cov(const MatrixXdr& rot_matrix, const MatrixXdr& cov_in) { + // To rotate a covariance matrix, the cov matrix needs to multiplied left and right by the transform matrix + return ((rot_matrix * cov_in) * rot_matrix.transpose()); +} + +static VectorXd rotate_std(const MatrixXdr& rot_matrix, const VectorXd& std_in) { + // Stds cannot be rotated like values, only covariances can be rotated + return rotate_cov(rot_matrix, std_in.array().square().matrix().asDiagonal()).diagonal().array().sqrt(); +} + +Localizer::Localizer(LocalizerGnssSource gnss_source) { + this->kf = std::make_unique(); + this->reset_kalman(); + + this->calib = Vector3d(0.0, 0.0, 0.0); + this->device_from_calib = MatrixXdr::Identity(3, 3); + this->calib_from_device = MatrixXdr::Identity(3, 3); + + for (int i = 0; i < POSENET_STD_HIST_HALF * 2; i++) { + this->posenet_stds.push_back(10.0); + } + + VectorXd ecef_pos = this->kf->get_x().segment(STATE_ECEF_POS_START); + this->converter = std::make_unique((ECEF) { .x = ecef_pos[0], .y = ecef_pos[1], .z = ecef_pos[2] }); + this->configure_gnss_source(gnss_source); +} + +void Localizer::build_live_location(cereal::LiveLocationKalman::Builder& fix) { + VectorXd predicted_state = this->kf->get_x(); + MatrixXdr predicted_cov = this->kf->get_P(); + VectorXd predicted_std = predicted_cov.diagonal().array().sqrt(); + + VectorXd fix_ecef = predicted_state.segment(STATE_ECEF_POS_START); + ECEF fix_ecef_ecef = { .x = fix_ecef(0), .y = fix_ecef(1), .z = fix_ecef(2) }; + VectorXd fix_ecef_std = predicted_std.segment(STATE_ECEF_POS_ERR_START); + VectorXd vel_ecef = predicted_state.segment(STATE_ECEF_VELOCITY_START); + VectorXd vel_ecef_std = predicted_std.segment(STATE_ECEF_VELOCITY_ERR_START); + VectorXd fix_pos_geo_vec = this->get_position_geodetic(); + VectorXd orientation_ecef = quat2euler(vector2quat(predicted_state.segment(STATE_ECEF_ORIENTATION_START))); + VectorXd orientation_ecef_std = predicted_std.segment(STATE_ECEF_ORIENTATION_ERR_START); + MatrixXdr orientation_ecef_cov = predicted_cov.block(STATE_ECEF_ORIENTATION_ERR_START, STATE_ECEF_ORIENTATION_ERR_START); + MatrixXdr device_from_ecef = euler2rot(orientation_ecef).transpose(); + VectorXd calibrated_orientation_ecef = rot2euler((this->calib_from_device * device_from_ecef).transpose()); + + VectorXd acc_calib = this->calib_from_device * predicted_state.segment(STATE_ACCELERATION_START); + MatrixXdr acc_calib_cov = predicted_cov.block(STATE_ACCELERATION_ERR_START, STATE_ACCELERATION_ERR_START); + VectorXd acc_calib_std = rotate_cov(this->calib_from_device, acc_calib_cov).diagonal().array().sqrt(); + VectorXd ang_vel_calib = this->calib_from_device * predicted_state.segment(STATE_ANGULAR_VELOCITY_START); + + MatrixXdr vel_angular_cov = predicted_cov.block(STATE_ANGULAR_VELOCITY_ERR_START, STATE_ANGULAR_VELOCITY_ERR_START); + VectorXd ang_vel_calib_std = rotate_cov(this->calib_from_device, vel_angular_cov).diagonal().array().sqrt(); + + VectorXd vel_device = device_from_ecef * vel_ecef; + VectorXd device_from_ecef_eul = quat2euler(vector2quat(predicted_state.segment(STATE_ECEF_ORIENTATION_START))).transpose(); + MatrixXdr condensed_cov(STATE_ECEF_ORIENTATION_ERR_LEN + STATE_ECEF_VELOCITY_ERR_LEN, STATE_ECEF_ORIENTATION_ERR_LEN + STATE_ECEF_VELOCITY_ERR_LEN); + condensed_cov.topLeftCorner() = + predicted_cov.block(STATE_ECEF_ORIENTATION_ERR_START, STATE_ECEF_ORIENTATION_ERR_START); + condensed_cov.topRightCorner() = + predicted_cov.block(STATE_ECEF_ORIENTATION_ERR_START, STATE_ECEF_VELOCITY_ERR_START); + condensed_cov.bottomRightCorner() = + predicted_cov.block(STATE_ECEF_VELOCITY_ERR_START, STATE_ECEF_VELOCITY_ERR_START); + condensed_cov.bottomLeftCorner() = + predicted_cov.block(STATE_ECEF_VELOCITY_ERR_START, STATE_ECEF_ORIENTATION_ERR_START); + VectorXd H_input(device_from_ecef_eul.size() + vel_ecef.size()); + H_input << device_from_ecef_eul, vel_ecef; + MatrixXdr HH = this->kf->H(H_input); + MatrixXdr vel_device_cov = (HH * condensed_cov) * HH.transpose(); + VectorXd vel_device_std = vel_device_cov.diagonal().array().sqrt(); + + VectorXd vel_calib = this->calib_from_device * vel_device; + VectorXd vel_calib_std = rotate_cov(this->calib_from_device, vel_device_cov).diagonal().array().sqrt(); + + VectorXd orientation_ned = ned_euler_from_ecef(fix_ecef_ecef, orientation_ecef); + VectorXd orientation_ned_std = rotate_cov(this->converter->ecef2ned_matrix, orientation_ecef_cov).diagonal().array().sqrt(); + VectorXd calibrated_orientation_ned = ned_euler_from_ecef(fix_ecef_ecef, calibrated_orientation_ecef); + VectorXd nextfix_ecef = fix_ecef + vel_ecef; + VectorXd ned_vel = this->converter->ecef2ned((ECEF) { .x = nextfix_ecef(0), .y = nextfix_ecef(1), .z = nextfix_ecef(2) }).to_vector() - converter->ecef2ned(fix_ecef_ecef).to_vector(); + + VectorXd accDevice = predicted_state.segment(STATE_ACCELERATION_START); + VectorXd accDeviceErr = predicted_std.segment(STATE_ACCELERATION_ERR_START); + + VectorXd angVelocityDevice = predicted_state.segment(STATE_ANGULAR_VELOCITY_START); + VectorXd angVelocityDeviceErr = predicted_std.segment(STATE_ANGULAR_VELOCITY_ERR_START); + + Vector3d nans = Vector3d(NAN, NAN, NAN); + + // TODO fill in NED and Calibrated stds + // write measurements to msg + init_measurement(fix.initPositionGeodetic(), fix_pos_geo_vec, nans, this->gps_mode); + init_measurement(fix.initPositionECEF(), fix_ecef, fix_ecef_std, this->gps_mode); + init_measurement(fix.initVelocityECEF(), vel_ecef, vel_ecef_std, this->gps_mode); + init_measurement(fix.initVelocityNED(), ned_vel, nans, this->gps_mode); + init_measurement(fix.initVelocityDevice(), vel_device, vel_device_std, true); + init_measurement(fix.initAccelerationDevice(), accDevice, accDeviceErr, true); + init_measurement(fix.initOrientationECEF(), orientation_ecef, orientation_ecef_std, this->gps_mode); + init_measurement(fix.initCalibratedOrientationECEF(), calibrated_orientation_ecef, nans, this->calibrated && this->gps_mode); + init_measurement(fix.initOrientationNED(), orientation_ned, orientation_ned_std, this->gps_mode); + init_measurement(fix.initCalibratedOrientationNED(), calibrated_orientation_ned, nans, this->calibrated && this->gps_mode); + init_measurement(fix.initAngularVelocityDevice(), angVelocityDevice, angVelocityDeviceErr, true); + init_measurement(fix.initVelocityCalibrated(), vel_calib, vel_calib_std, this->calibrated); + init_measurement(fix.initAngularVelocityCalibrated(), ang_vel_calib, ang_vel_calib_std, this->calibrated); + init_measurement(fix.initAccelerationCalibrated(), acc_calib, acc_calib_std, this->calibrated); + if (DEBUG) { + init_measurement(fix.initFilterState(), predicted_state, predicted_std, true); + } + + double old_mean = 0.0, new_mean = 0.0; + int i = 0; + for (double x : this->posenet_stds) { + if (i < POSENET_STD_HIST_HALF) { + old_mean += x; + } else { + new_mean += x; + } + i++; + } + old_mean /= POSENET_STD_HIST_HALF; + new_mean /= POSENET_STD_HIST_HALF; + // experimentally found these values, no false positives in 20k minutes of driving + bool std_spike = (new_mean / old_mean > 4.0 && new_mean > 7.0); + + fix.setPosenetOK(!(std_spike && this->car_speed > 5.0)); + fix.setDeviceStable(!this->device_fell); + fix.setExcessiveResets(this->reset_tracker > MAX_RESET_TRACKER); + fix.setTimeToFirstFix(std::isnan(this->ttff) ? -1. : this->ttff); + this->device_fell = false; + + //fix.setGpsWeek(this->time.week); + //fix.setGpsTimeOfWeek(this->time.tow); + fix.setUnixTimestampMillis(this->unix_timestamp_millis); + + double time_since_reset = this->kf->get_filter_time() - this->last_reset_time; + fix.setTimeSinceReset(time_since_reset); + if (fix_ecef_std.norm() < VALID_POS_STD && this->calibrated && time_since_reset > VALID_TIME_SINCE_RESET) { + fix.setStatus(cereal::LiveLocationKalman::Status::VALID); + } else if (fix_ecef_std.norm() < VALID_POS_STD && time_since_reset > VALID_TIME_SINCE_RESET) { + fix.setStatus(cereal::LiveLocationKalman::Status::UNCALIBRATED); + } else { + fix.setStatus(cereal::LiveLocationKalman::Status::UNINITIALIZED); + } +} + +VectorXd Localizer::get_position_geodetic() { + VectorXd fix_ecef = this->kf->get_x().segment(STATE_ECEF_POS_START); + ECEF fix_ecef_ecef = { .x = fix_ecef(0), .y = fix_ecef(1), .z = fix_ecef(2) }; + Geodetic fix_pos_geo = ecef2geodetic(fix_ecef_ecef); + return Vector3d(fix_pos_geo.lat, fix_pos_geo.lon, fix_pos_geo.alt); +} + +VectorXd Localizer::get_state() { + return this->kf->get_x(); +} + +VectorXd Localizer::get_stdev() { + return this->kf->get_P().diagonal().array().sqrt(); +} + +bool Localizer::are_inputs_ok() { + return this->critical_services_valid(this->observation_values_invalid) && !this->observation_timings_invalid; +} + +void Localizer::observation_timings_invalid_reset(){ + this->observation_timings_invalid = false; +} + +void Localizer::handle_sensor(double current_time, const cereal::SensorEventData::Reader& log) { + // TODO does not yet account for double sensor readings in the log + + // Ignore empty readings (e.g. in case the magnetometer had no data ready) + if (log.getTimestamp() == 0) { + return; + } + + double sensor_time = 1e-9 * log.getTimestamp(); + + // sensor time and log time should be close + if (std::abs(current_time - sensor_time) > 0.1) { + LOGE("Sensor reading ignored, sensor timestamp more than 100ms off from log time"); + this->observation_timings_invalid = true; + return; + } else if (!this->is_timestamp_valid(sensor_time)) { + this->observation_timings_invalid = true; + return; + } + + // TODO: handle messages from two IMUs at the same time + if (log.getSource() == cereal::SensorEventData::SensorSource::BMX055) { + return; + } + + // Gyro Uncalibrated + if (log.getSensor() == SENSOR_GYRO_UNCALIBRATED && log.getType() == SENSOR_TYPE_GYROSCOPE_UNCALIBRATED) { + auto v = log.getGyroUncalibrated().getV(); + auto meas = Vector3d(-v[2], -v[1], -v[0]); + + VectorXd gyro_bias = this->kf->get_x().segment(STATE_GYRO_BIAS_START); + float gyro_camodo_yawrate_err = std::abs((meas[2] - gyro_bias[2]) - this->camodo_yawrate_distribution[0]); + float gyro_camodo_yawrate_err_threshold = YAWRATE_CROSS_ERR_CHECK_FACTOR * this->camodo_yawrate_distribution[1]; + bool gyro_valid = gyro_camodo_yawrate_err < gyro_camodo_yawrate_err_threshold; + + if ((meas.norm() < ROTATION_SANITY_CHECK) && gyro_valid) { + this->kf->predict_and_observe(sensor_time, OBSERVATION_PHONE_GYRO, { meas }); + this->observation_values_invalid["gyroscope"] *= DECAY; + } else { + this->observation_values_invalid["gyroscope"] += 1.0; + } + } + + // Accelerometer + if (log.getSensor() == SENSOR_ACCELEROMETER && log.getType() == SENSOR_TYPE_ACCELEROMETER) { + auto v = log.getAcceleration().getV(); + + // TODO: reduce false positives and re-enable this check + // check if device fell, estimate 10 for g + // 40m/s**2 is a good filter for falling detection, no false positives in 20k minutes of driving + // this->device_fell |= (floatlist2vector(v) - Vector3d(10.0, 0.0, 0.0)).norm() > 40.0; + + auto meas = Vector3d(-v[2], -v[1], -v[0]); + if (meas.norm() < ACCEL_SANITY_CHECK) { + this->kf->predict_and_observe(sensor_time, OBSERVATION_PHONE_ACCEL, { meas }); + this->observation_values_invalid["accelerometer"] *= DECAY; + } else { + this->observation_values_invalid["accelerometer"] += 1.0; + } + } +} + +void Localizer::input_fake_gps_observations(double current_time) { + // This is done to make sure that the error estimate of the position does not blow up + // when the filter is in no-gps mode + // Steps : first predict -> observe current obs with reasonable STD + this->kf->predict(current_time); + + VectorXd current_x = this->kf->get_x(); + VectorXd ecef_pos = current_x.segment(STATE_ECEF_POS_START); + VectorXd ecef_vel = current_x.segment(STATE_ECEF_VELOCITY_START); + const MatrixXdr &ecef_pos_R = this->kf->get_fake_gps_pos_cov(); + const MatrixXdr &ecef_vel_R = this->kf->get_fake_gps_vel_cov(); + + this->kf->predict_and_observe(current_time, OBSERVATION_ECEF_POS, { ecef_pos }, { ecef_pos_R }); + this->kf->predict_and_observe(current_time, OBSERVATION_ECEF_VEL, { ecef_vel }, { ecef_vel_R }); +} + +void Localizer::handle_gps(double current_time, const cereal::GpsLocationData::Reader& log, const double sensor_time_offset) { + bool gps_unreasonable = (Vector2d(log.getHorizontalAccuracy(), log.getVerticalAccuracy()).norm() >= SANE_GPS_UNCERTAINTY); + bool gps_accuracy_insane = ((log.getVerticalAccuracy() <= 0) || (log.getSpeedAccuracy() <= 0) || (log.getBearingAccuracyDeg() <= 0)); + bool gps_lat_lng_alt_insane = ((std::abs(log.getLatitude()) > 90) || (std::abs(log.getLongitude()) > 180) || (std::abs(log.getAltitude()) > ALTITUDE_SANITY_CHECK)); + bool gps_vel_insane = (floatlist2vector(log.getVNED()).norm() > TRANS_SANITY_CHECK); + + if (!log.getHasFix() || gps_unreasonable || gps_accuracy_insane || gps_lat_lng_alt_insane || gps_vel_insane) { + //this->gps_valid = false; + this->determine_gps_mode(current_time); + return; + } + + double sensor_time = current_time - sensor_time_offset; + + // Process message + //this->gps_valid = true; + this->gps_mode = true; + Geodetic geodetic = { log.getLatitude(), log.getLongitude(), log.getAltitude() }; + this->converter = std::make_unique(geodetic); + + VectorXd ecef_pos = this->converter->ned2ecef({ 0.0, 0.0, 0.0 }).to_vector(); + VectorXd ecef_vel = this->converter->ned2ecef({ log.getVNED()[0], log.getVNED()[1], log.getVNED()[2] }).to_vector() - ecef_pos; + float ecef_pos_std = std::sqrt(this->gps_variance_factor * std::pow(log.getHorizontalAccuracy(), 2) + this->gps_vertical_variance_factor * std::pow(log.getVerticalAccuracy(), 2)); + MatrixXdr ecef_pos_R = Vector3d::Constant(std::pow(this->gps_std_factor * ecef_pos_std, 2)).asDiagonal(); + MatrixXdr ecef_vel_R = Vector3d::Constant(std::pow(this->gps_std_factor * log.getSpeedAccuracy(), 2)).asDiagonal(); + + this->unix_timestamp_millis = log.getUnixTimestampMillis(); + double gps_est_error = (this->kf->get_x().segment(STATE_ECEF_POS_START) - ecef_pos).norm(); + + VectorXd orientation_ecef = quat2euler(vector2quat(this->kf->get_x().segment(STATE_ECEF_ORIENTATION_START))); + VectorXd orientation_ned = ned_euler_from_ecef({ ecef_pos(0), ecef_pos(1), ecef_pos(2) }, orientation_ecef); + VectorXd orientation_ned_gps = Vector3d(0.0, 0.0, DEG2RAD(log.getBearingDeg())); + VectorXd orientation_error = (orientation_ned - orientation_ned_gps).array() - M_PI; + for (int i = 0; i < orientation_error.size(); i++) { + orientation_error(i) = std::fmod(orientation_error(i), 2.0 * M_PI); + if (orientation_error(i) < 0.0) { + orientation_error(i) += 2.0 * M_PI; + } + orientation_error(i) -= M_PI; + } + VectorXd initial_pose_ecef_quat = quat2vector(euler2quat(ecef_euler_from_ned({ ecef_pos(0), ecef_pos(1), ecef_pos(2) }, orientation_ned_gps))); + + if (ecef_vel.norm() > 5.0 && orientation_error.norm() > 1.0) { + LOGE("Locationd vs ubloxLocation orientation difference too large, kalman reset"); + this->reset_kalman(NAN, initial_pose_ecef_quat, ecef_pos, ecef_vel, ecef_pos_R, ecef_vel_R); + this->kf->predict_and_observe(sensor_time, OBSERVATION_ECEF_ORIENTATION_FROM_GPS, { initial_pose_ecef_quat }); + } else if (gps_est_error > 100.0) { + LOGE("Locationd vs ubloxLocation position difference too large, kalman reset"); + this->reset_kalman(NAN, initial_pose_ecef_quat, ecef_pos, ecef_vel, ecef_pos_R, ecef_vel_R); + } + + this->last_gps_msg = sensor_time; + this->kf->predict_and_observe(sensor_time, OBSERVATION_ECEF_POS, { ecef_pos }, { ecef_pos_R }); + this->kf->predict_and_observe(sensor_time, OBSERVATION_ECEF_VEL, { ecef_vel }, { ecef_vel_R }); +} + +void Localizer::handle_gnss(double current_time, const cereal::GnssMeasurements::Reader& log) { + + if (!log.getPositionECEF().getValid() || !log.getVelocityECEF().getValid()) { + this->determine_gps_mode(current_time); + return; + } + + double sensor_time = log.getMeasTime() * 1e-9; + sensor_time -= this->gps_time_offset; + + auto ecef_pos_v = log.getPositionECEF().getValue(); + VectorXd ecef_pos = Vector3d(ecef_pos_v[0], ecef_pos_v[1], ecef_pos_v[2]); + + // indexed at 0 cause all std values are the same MAE + auto ecef_pos_std = log.getPositionECEF().getStd()[0]; + MatrixXdr ecef_pos_R = Vector3d::Constant(pow(this->gps_std_factor*ecef_pos_std, 2)).asDiagonal(); + + auto ecef_vel_v = log.getVelocityECEF().getValue(); + VectorXd ecef_vel = Vector3d(ecef_vel_v[0], ecef_vel_v[1], ecef_vel_v[2]); + + // indexed at 0 cause all std values are the same MAE + auto ecef_vel_std = log.getVelocityECEF().getStd()[0]; + MatrixXdr ecef_vel_R = Vector3d::Constant(pow(this->gps_std_factor*ecef_vel_std, 2)).asDiagonal(); + + double gps_est_error = (this->kf->get_x().segment(STATE_ECEF_POS_START) - ecef_pos).norm(); + + VectorXd orientation_ecef = quat2euler(vector2quat(this->kf->get_x().segment(STATE_ECEF_ORIENTATION_START))); + VectorXd orientation_ned = ned_euler_from_ecef({ ecef_pos[0], ecef_pos[1], ecef_pos[2] }, orientation_ecef); + + LocalCoord convs((ECEF){ .x = ecef_pos[0], .y = ecef_pos[1], .z = ecef_pos[2] }); + ECEF next_ecef = {.x = ecef_pos[0] + ecef_vel[0], .y = ecef_pos[1] + ecef_vel[1], .z = ecef_pos[2] + ecef_vel[2]}; + VectorXd ned_vel = convs.ecef2ned(next_ecef).to_vector(); + double bearing_rad = atan2(ned_vel[1], ned_vel[0]); + + VectorXd orientation_ned_gps = Vector3d(0.0, 0.0, bearing_rad); + VectorXd orientation_error = (orientation_ned - orientation_ned_gps).array() - M_PI; + for (int i = 0; i < orientation_error.size(); i++) { + orientation_error(i) = std::fmod(orientation_error(i), 2.0 * M_PI); + if (orientation_error(i) < 0.0) { + orientation_error(i) += 2.0 * M_PI; + } + orientation_error(i) -= M_PI; + } + VectorXd initial_pose_ecef_quat = quat2vector(euler2quat(ecef_euler_from_ned({ ecef_pos(0), ecef_pos(1), ecef_pos(2) }, orientation_ned_gps))); + + if (ecef_pos_std > GPS_POS_STD_THRESHOLD || ecef_vel_std > GPS_VEL_STD_THRESHOLD) { + this->determine_gps_mode(current_time); + return; + } + + // prevent jumping gnss measurements (covered lots, standstill...) + bool orientation_reset = ecef_vel_std < GPS_VEL_STD_RESET_THRESHOLD; + orientation_reset &= orientation_error.norm() > GPS_ORIENTATION_ERROR_RESET_THRESHOLD; + orientation_reset &= !this->standstill; + if (orientation_reset) { + this->orientation_reset_count++; + } else { + this->orientation_reset_count = 0; + } + + if ((gps_est_error > GPS_POS_ERROR_RESET_THRESHOLD && ecef_pos_std < GPS_POS_STD_RESET_THRESHOLD) || this->last_gps_msg == 0) { + // always reset on first gps message and if the location is off but the accuracy is high + LOGE("Locationd vs gnssMeasurement position difference too large, kalman reset"); + this->reset_kalman(NAN, initial_pose_ecef_quat, ecef_pos, ecef_vel, ecef_pos_R, ecef_vel_R); + } else if (orientation_reset_count > GPS_ORIENTATION_ERROR_RESET_CNT) { + LOGE("Locationd vs gnssMeasurement orientation difference too large, kalman reset"); + this->reset_kalman(NAN, initial_pose_ecef_quat, ecef_pos, ecef_vel, ecef_pos_R, ecef_vel_R); + this->kf->predict_and_observe(sensor_time, OBSERVATION_ECEF_ORIENTATION_FROM_GPS, { initial_pose_ecef_quat }); + this->orientation_reset_count = 0; + } + + this->gps_mode = true; + this->last_gps_msg = sensor_time; + this->kf->predict_and_observe(sensor_time, OBSERVATION_ECEF_POS, { ecef_pos }, { ecef_pos_R }); + this->kf->predict_and_observe(sensor_time, OBSERVATION_ECEF_VEL, { ecef_vel }, { ecef_vel_R }); +} + +void Localizer::handle_car_state(double current_time, const cereal::CarState::Reader& log) { + this->car_speed = std::abs(log.getVEgo()); + this->standstill = log.getStandstill(); + if (this->standstill) { + this->kf->predict_and_observe(current_time, OBSERVATION_NO_ROT, { Vector3d(0.0, 0.0, 0.0) }); + this->kf->predict_and_observe(current_time, OBSERVATION_NO_ACCEL, { Vector3d(0.0, 0.0, 0.0) }); + } +} + +void Localizer::handle_cam_odo(double current_time, const cereal::CameraOdometry::Reader& log) { + VectorXd rot_device = this->device_from_calib * floatlist2vector(log.getRot()); + VectorXd trans_device = this->device_from_calib * floatlist2vector(log.getTrans()); + + if (!this->is_timestamp_valid(current_time)) { + this->observation_timings_invalid = true; + return; + } + + if ((rot_device.norm() > ROTATION_SANITY_CHECK) || (trans_device.norm() > TRANS_SANITY_CHECK)) { + this->observation_values_invalid["cameraOdometry"] += 1.0; + return; + } + + VectorXd rot_calib_std = floatlist2vector(log.getRotStd()); + VectorXd trans_calib_std = floatlist2vector(log.getTransStd()); + + if ((rot_calib_std.minCoeff() <= MIN_STD_SANITY_CHECK) || (trans_calib_std.minCoeff() <= MIN_STD_SANITY_CHECK)) { + this->observation_values_invalid["cameraOdometry"] += 1.0; + return; + } + + if ((rot_calib_std.norm() > 10 * ROTATION_SANITY_CHECK) || (trans_calib_std.norm() > 10 * TRANS_SANITY_CHECK)) { + this->observation_values_invalid["cameraOdometry"] += 1.0; + return; + } + + this->posenet_stds.pop_front(); + this->posenet_stds.push_back(trans_calib_std[0]); + + // Multiply by 10 to avoid to high certainty in kalman filter because of temporally correlated noise + trans_calib_std *= 10.0; + rot_calib_std *= 10.0; + MatrixXdr rot_device_cov = rotate_std(this->device_from_calib, rot_calib_std).array().square().matrix().asDiagonal(); + MatrixXdr trans_device_cov = rotate_std(this->device_from_calib, trans_calib_std).array().square().matrix().asDiagonal(); + this->kf->predict_and_observe(current_time, OBSERVATION_CAMERA_ODO_ROTATION, + { rot_device }, { rot_device_cov }); + this->kf->predict_and_observe(current_time, OBSERVATION_CAMERA_ODO_TRANSLATION, + { trans_device }, { trans_device_cov }); + this->observation_values_invalid["cameraOdometry"] *= DECAY; + this->camodo_yawrate_distribution = Vector2d(rot_device[2], rotate_std(this->device_from_calib, rot_calib_std)[2]); +} + +void Localizer::handle_live_calib(double current_time, const cereal::LiveCalibrationData::Reader& log) { + if (!this->is_timestamp_valid(current_time)) { + this->observation_timings_invalid = true; + return; + } + + if (log.getRpyCalib().size() > 0) { + auto live_calib = floatlist2vector(log.getRpyCalib()); + if ((live_calib.minCoeff() < -CALIB_RPY_SANITY_CHECK) || (live_calib.maxCoeff() > CALIB_RPY_SANITY_CHECK)) { + this->observation_values_invalid["liveCalibration"] += 1.0; + return; + } + + this->calib = live_calib; + this->device_from_calib = euler2rot(this->calib); + this->calib_from_device = this->device_from_calib.transpose(); + this->calibrated = log.getCalStatus() == cereal::LiveCalibrationData::Status::CALIBRATED; + this->observation_values_invalid["liveCalibration"] *= DECAY; + } +} + +void Localizer::reset_kalman(double current_time) { + const VectorXd &init_x = this->kf->get_initial_x(); + const MatrixXdr &init_P = this->kf->get_initial_P(); + this->reset_kalman(current_time, init_x, init_P); +} + +void Localizer::finite_check(double current_time) { + bool all_finite = this->kf->get_x().array().isFinite().all() or this->kf->get_P().array().isFinite().all(); + if (!all_finite) { + LOGE("Non-finite values detected, kalman reset"); + this->reset_kalman(current_time); + } +} + +void Localizer::time_check(double current_time) { + if (std::isnan(this->last_reset_time)) { + this->last_reset_time = current_time; + } + if (std::isnan(this->first_valid_log_time)) { + this->first_valid_log_time = current_time; + } + double filter_time = this->kf->get_filter_time(); + bool big_time_gap = !std::isnan(filter_time) && (current_time - filter_time > 10); + if (big_time_gap) { + LOGE("Time gap of over 10s detected, kalman reset"); + this->reset_kalman(current_time); + } +} + +void Localizer::update_reset_tracker() { + // reset tracker is tuned to trigger when over 1reset/10s over 2min period + if (this->is_gps_ok()) { + this->reset_tracker *= RESET_TRACKER_DECAY; + } else { + this->reset_tracker = 0.0; + } +} + +void Localizer::reset_kalman(double current_time, const VectorXd &init_orient, const VectorXd &init_pos, const VectorXd &init_vel, const MatrixXdr &init_pos_R, const MatrixXdr &init_vel_R) { + // too nonlinear to init on completely wrong + VectorXd current_x = this->kf->get_x(); + MatrixXdr current_P = this->kf->get_P(); + MatrixXdr init_P = this->kf->get_initial_P(); + const MatrixXdr &reset_orientation_P = this->kf->get_reset_orientation_P(); + int non_ecef_state_err_len = init_P.rows() - (STATE_ECEF_POS_ERR_LEN + STATE_ECEF_ORIENTATION_ERR_LEN + STATE_ECEF_VELOCITY_ERR_LEN); + + current_x.segment(STATE_ECEF_ORIENTATION_START) = init_orient; + current_x.segment(STATE_ECEF_VELOCITY_START) = init_vel; + current_x.segment(STATE_ECEF_POS_START) = init_pos; + + init_P.block(STATE_ECEF_POS_ERR_START, STATE_ECEF_POS_ERR_START).diagonal() = init_pos_R.diagonal(); + init_P.block(STATE_ECEF_ORIENTATION_ERR_START, STATE_ECEF_ORIENTATION_ERR_START).diagonal() = reset_orientation_P.diagonal(); + init_P.block(STATE_ECEF_VELOCITY_ERR_START, STATE_ECEF_VELOCITY_ERR_START).diagonal() = init_vel_R.diagonal(); + init_P.block(STATE_ANGULAR_VELOCITY_ERR_START, STATE_ANGULAR_VELOCITY_ERR_START, non_ecef_state_err_len, non_ecef_state_err_len).diagonal() = current_P.block(STATE_ANGULAR_VELOCITY_ERR_START, + STATE_ANGULAR_VELOCITY_ERR_START, non_ecef_state_err_len, non_ecef_state_err_len).diagonal(); + + this->reset_kalman(current_time, current_x, init_P); +} + +void Localizer::reset_kalman(double current_time, const VectorXd &init_x, const MatrixXdr &init_P) { + this->kf->init_state(init_x, init_P, current_time); + this->last_reset_time = current_time; + this->reset_tracker += 1.0; +} + +void Localizer::handle_msg_bytes(const char *data, const size_t size) { + AlignedBuffer aligned_buf; + + capnp::FlatArrayMessageReader cmsg(aligned_buf.align(data, size)); + cereal::Event::Reader event = cmsg.getRoot(); + + this->handle_msg(event); +} + +void Localizer::handle_msg(const cereal::Event::Reader& log) { + double t = log.getLogMonoTime() * 1e-9; + this->time_check(t); + if (log.isAccelerometer()) { + this->handle_sensor(t, log.getAccelerometer()); + } else if (log.isGyroscope()) { + this->handle_sensor(t, log.getGyroscope()); + } else if (log.isGpsLocation()) { + this->handle_gps(t, log.getGpsLocation(), GPS_QUECTEL_SENSOR_TIME_OFFSET); + } else if (log.isGpsLocationExternal()) { + this->handle_gps(t, log.getGpsLocationExternal(), GPS_UBLOX_SENSOR_TIME_OFFSET); + //} else if (log.isGnssMeasurements()) { + // this->handle_gnss(t, log.getGnssMeasurements()); + } else if (log.isCarState()) { + this->handle_car_state(t, log.getCarState()); + } else if (log.isCameraOdometry()) { + this->handle_cam_odo(t, log.getCameraOdometry()); + } else if (log.isLiveCalibration()) { + this->handle_live_calib(t, log.getLiveCalibration()); + } + this->finite_check(); + this->update_reset_tracker(); +} + +kj::ArrayPtr Localizer::get_message_bytes(MessageBuilder& msg_builder, bool inputsOK, + bool sensorsOK, bool gpsOK, bool msgValid) { + cereal::Event::Builder evt = msg_builder.initEvent(); + evt.setValid(msgValid); + cereal::LiveLocationKalman::Builder liveLoc = evt.initLiveLocationKalman(); + this->build_live_location(liveLoc); + liveLoc.setSensorsOK(sensorsOK); + liveLoc.setGpsOK(gpsOK); + liveLoc.setInputsOK(inputsOK); + return msg_builder.toBytes(); +} + +bool Localizer::is_gps_ok() { + return (this->kf->get_filter_time() - this->last_gps_msg) < 2.0; +} + +bool Localizer::critical_services_valid(const std::map &critical_services) { + for (auto &kv : critical_services){ + if (kv.second >= INPUT_INVALID_THRESHOLD){ + return false; + } + } + return true; +} + +bool Localizer::is_timestamp_valid(double current_time) { + double filter_time = this->kf->get_filter_time(); + if (!std::isnan(filter_time) && ((filter_time - current_time) > MAX_FILTER_REWIND_TIME)) { + LOGE("Observation timestamp is older than the max rewind threshold of the filter"); + return false; + } + return true; +} + +void Localizer::determine_gps_mode(double current_time) { + // 1. If the pos_std is greater than what's not acceptable and localizer is in gps-mode, reset to no-gps-mode + // 2. If the pos_std is greater than what's not acceptable and localizer is in no-gps-mode, fake obs + // 3. If the pos_std is smaller than what's not acceptable, let gps-mode be whatever it is + VectorXd current_pos_std = this->kf->get_P().block(STATE_ECEF_POS_ERR_START, STATE_ECEF_POS_ERR_START).diagonal().array().sqrt(); + if (current_pos_std.norm() > SANE_GPS_UNCERTAINTY){ + if (this->gps_mode){ + this->gps_mode = false; + this->reset_kalman(current_time); + } else { + this->input_fake_gps_observations(current_time); + } + } +} + +void Localizer::configure_gnss_source(const LocalizerGnssSource &source) { + this->gnss_source = source; + if (source == LocalizerGnssSource::UBLOX) { + this->gps_std_factor = 10.0; + this->gps_variance_factor = 1.0; + this->gps_vertical_variance_factor = 1.0; + this->gps_time_offset = GPS_UBLOX_SENSOR_TIME_OFFSET; + } else { + this->gps_std_factor = 2.0; + this->gps_variance_factor = 0.0; + this->gps_vertical_variance_factor = 3.0; + this->gps_time_offset = GPS_QUECTEL_SENSOR_TIME_OFFSET; + } +} + +int Localizer::locationd_thread() { + Params params; + LocalizerGnssSource source; + const char* gps_location_socket; + if (params.getBool("UbloxAvailable")) { + source = LocalizerGnssSource::UBLOX; + gps_location_socket = "gpsLocationExternal"; + } else { + source = LocalizerGnssSource::QCOM; + gps_location_socket = "gpsLocation"; + } + + this->configure_gnss_source(source); + const std::initializer_list service_list = {gps_location_socket, "cameraOdometry", "liveCalibration", + "carState", "accelerometer", "gyroscope"}; + + SubMaster sm(service_list, {}, nullptr, {gps_location_socket}); + PubMaster pm({"liveLocationKalman"}); + + uint64_t cnt = 0; + bool filterInitialized = false; + const std::vector critical_input_services = {"cameraOdometry", "liveCalibration", "accelerometer", "gyroscope"}; + for (std::string service : critical_input_services) { + this->observation_values_invalid.insert({service, 0.0}); + } + + while (!do_exit) { + sm.update(); + if (filterInitialized){ + this->observation_timings_invalid_reset(); + for (const char* service : service_list) { + if (sm.updated(service) && sm.valid(service)){ + const cereal::Event::Reader log = sm[service]; + this->handle_msg(log); + } + } + } else { + filterInitialized = sm.allAliveAndValid(); + } + + const char* trigger_msg = "cameraOdometry"; + if (sm.updated(trigger_msg)) { + bool inputsOK = sm.allValid() && this->are_inputs_ok(); + bool gpsOK = this->is_gps_ok(); + bool sensorsOK = sm.allAliveAndValid({"accelerometer", "gyroscope"}); + + // Log time to first fix + if (gpsOK && std::isnan(this->ttff) && !std::isnan(this->first_valid_log_time)) { + this->ttff = std::max(1e-3, (sm[trigger_msg].getLogMonoTime() * 1e-9) - this->first_valid_log_time); + } + + MessageBuilder msg_builder; + kj::ArrayPtr bytes = this->get_message_bytes(msg_builder, inputsOK, sensorsOK, gpsOK, filterInitialized); + pm.send("liveLocationKalman", bytes.begin(), bytes.size()); + + if (cnt % 1200 == 0 && gpsOK) { // once a minute + VectorXd posGeo = this->get_position_geodetic(); + std::string lastGPSPosJSON = util::string_format( + "{\"latitude\": %.15f, \"longitude\": %.15f, \"altitude\": %.15f}", posGeo(0), posGeo(1), posGeo(2)); + params.putNonBlocking("LastGPSPositionLLK", lastGPSPosJSON); + } + cnt++; + } + } + return 0; +} + +int main() { + util::set_realtime_priority(5); + + Localizer localizer; + return localizer.locationd_thread(); +} diff --git a/sunnypilot/selfdrive/locationd/locationd.h b/sunnypilot/selfdrive/locationd/locationd.h new file mode 100644 index 0000000000..a6ce697f30 --- /dev/null +++ b/sunnypilot/selfdrive/locationd/locationd.h @@ -0,0 +1,100 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "cereal/messaging/messaging.h" +#include "common/params.h" +#include "common/swaglog.h" +#include "common/timing.h" +#include "common/util.h" + +#include "sunnypilot/common/transformations/coordinates.hpp" +#include "sunnypilot/common/transformations/orientation.hpp" +#include "sunnypilot/system/sensord/sensors/constants.h" +#include "sunnypilot/selfdrive/locationd/models/live_kf.h" + +#define VISION_DECIMATION 2 +#define SENSOR_DECIMATION 10 +#define POSENET_STD_HIST_HALF 20 + +enum LocalizerGnssSource { + UBLOX, QCOM +}; + +class Localizer { +public: + Localizer(LocalizerGnssSource gnss_source = LocalizerGnssSource::UBLOX); + + int locationd_thread(); + + void reset_kalman(double current_time = NAN); + void reset_kalman(double current_time, const Eigen::VectorXd &init_orient, const Eigen::VectorXd &init_pos, const Eigen::VectorXd &init_vel, const MatrixXdr &init_pos_R, const MatrixXdr &init_vel_R); + void reset_kalman(double current_time, const Eigen::VectorXd &init_x, const MatrixXdr &init_P); + void finite_check(double current_time = NAN); + void time_check(double current_time = NAN); + void update_reset_tracker(); + bool is_gps_ok(); + bool critical_services_valid(const std::map &critical_services); + bool is_timestamp_valid(double current_time); + void determine_gps_mode(double current_time); + bool are_inputs_ok(); + void observation_timings_invalid_reset(); + + kj::ArrayPtr get_message_bytes(MessageBuilder& msg_builder, + bool inputsOK, bool sensorsOK, bool gpsOK, bool msgValid); + void build_live_location(cereal::LiveLocationKalman::Builder& fix); + + Eigen::VectorXd get_position_geodetic(); + Eigen::VectorXd get_state(); + Eigen::VectorXd get_stdev(); + + void handle_msg_bytes(const char *data, const size_t size); + void handle_msg(const cereal::Event::Reader& log); + void handle_sensor(double current_time, const cereal::SensorEventData::Reader& log); + void handle_gps(double current_time, const cereal::GpsLocationData::Reader& log, const double sensor_time_offset); + void handle_gnss(double current_time, const cereal::GnssMeasurements::Reader& log); + void handle_car_state(double current_time, const cereal::CarState::Reader& log); + void handle_cam_odo(double current_time, const cereal::CameraOdometry::Reader& log); + void handle_live_calib(double current_time, const cereal::LiveCalibrationData::Reader& log); + + void input_fake_gps_observations(double current_time); + +private: + std::unique_ptr kf; + + Eigen::VectorXd calib; + MatrixXdr device_from_calib; + MatrixXdr calib_from_device; + bool calibrated = false; + + double car_speed = 0.0; + double last_reset_time = NAN; + std::deque posenet_stds; + + std::unique_ptr converter; + + int64_t unix_timestamp_millis = 0; + double reset_tracker = 0.0; + bool device_fell = false; + bool gps_mode = false; + double first_valid_log_time = NAN; + double ttff = NAN; + double last_gps_msg = 0; + LocalizerGnssSource gnss_source; + bool observation_timings_invalid = false; + std::map observation_values_invalid; + bool standstill = true; + int32_t orientation_reset_count = 0; + float gps_std_factor; + float gps_variance_factor; + float gps_vertical_variance_factor; + double gps_time_offset; + Eigen::VectorXd camodo_yawrate_distribution = Eigen::Vector2d(0.0, 10.0); // mean, std + + void configure_gnss_source(const LocalizerGnssSource &source); +}; diff --git a/sunnypilot/selfdrive/locationd/models/.gitignore b/sunnypilot/selfdrive/locationd/models/.gitignore new file mode 100644 index 0000000000..9ab870da89 --- /dev/null +++ b/sunnypilot/selfdrive/locationd/models/.gitignore @@ -0,0 +1 @@ +generated/ diff --git a/sunnypilot/selfdrive/locationd/models/__init__.py b/sunnypilot/selfdrive/locationd/models/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sunnypilot/selfdrive/locationd/models/car_kf.py b/sunnypilot/selfdrive/locationd/models/car_kf.py new file mode 100755 index 0000000000..9964cab973 --- /dev/null +++ b/sunnypilot/selfdrive/locationd/models/car_kf.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +import math +import sys +from typing import Any + +import numpy as np + +from openpilot.common.constants import ACCELERATION_DUE_TO_GRAVITY +from openpilot.sunnypilot.selfdrive.locationd.models.constants import ObservationKind +from openpilot.common.swaglog import cloudlog + +from rednose.helpers.kalmanfilter import KalmanFilter + +if __name__ == '__main__': # Generating sympy + import sympy as sp + from rednose.helpers.ekf_sym import gen_code +else: + from rednose.helpers.ekf_sym_pyx import EKF_sym_pyx + + +i = 0 + +def _slice(n): + global i + s = slice(i, i + n) + i += n + + return s + + +class States: + # Vehicle model params + STIFFNESS = _slice(1) # [-] + STEER_RATIO = _slice(1) # [-] + ANGLE_OFFSET = _slice(1) # [rad] + ANGLE_OFFSET_FAST = _slice(1) # [rad] + + VELOCITY = _slice(2) # (x, y) [m/s] + YAW_RATE = _slice(1) # [rad/s] + STEER_ANGLE = _slice(1) # [rad] + ROAD_ROLL = _slice(1) # [rad] + + +class CarKalman(KalmanFilter): + name = 'car' + + initial_x = np.array([ + 1.0, + 15.0, + 0.0, + 0.0, + + 10.0, 0.0, + 0.0, + 0.0, + 0.0 + ]) + + # process noise + Q = np.diag([ + (.05 / 100)**2, + .01**2, + math.radians(0.02)**2, + math.radians(0.25)**2, + + .1**2, .01**2, + math.radians(0.1)**2, + math.radians(0.1)**2, + math.radians(1)**2, + ]) + P_initial = Q.copy() + + obs_noise: dict[int, Any] = { + ObservationKind.STEER_ANGLE: np.atleast_2d(math.radians(0.05)**2), + ObservationKind.ANGLE_OFFSET_FAST: np.atleast_2d(math.radians(10.0)**2), + ObservationKind.ROAD_ROLL: np.atleast_2d(math.radians(1.0)**2), + ObservationKind.STEER_RATIO: np.atleast_2d(5.0**2), + ObservationKind.STIFFNESS: np.atleast_2d(0.5**2), + ObservationKind.ROAD_FRAME_X_SPEED: np.atleast_2d(0.1**2), + } + + global_vars = [ + 'mass', + 'rotational_inertia', + 'center_to_front', + 'center_to_rear', + 'stiffness_front', + 'stiffness_rear', + ] + + @staticmethod + def generate_code(generated_dir): + dim_state = CarKalman.initial_x.shape[0] + name = CarKalman.name + + # Linearized single-track lateral dynamics, equations 7.211-7.213 + # Massimo Guiggiani, The Science of Vehicle Dynamics: Handling, Braking, and Ride of Road and Race Cars + # Springer Cham, 2023. doi: https://doi.org/10.1007/978-3-031-06461-6 + + # globals + global_vars = [sp.Symbol(name) for name in CarKalman.global_vars] + m, j, aF, aR, cF_orig, cR_orig = global_vars + + # make functions and jacobians with sympy + # state variables + state_sym = sp.MatrixSymbol('state', dim_state, 1) + state = sp.Matrix(state_sym) + + # Vehicle model constants + sf = state[States.STIFFNESS, :][0, 0] + + cF, cR = sf * cF_orig, sf * cR_orig + angle_offset = state[States.ANGLE_OFFSET, :][0, 0] + angle_offset_fast = state[States.ANGLE_OFFSET_FAST, :][0, 0] + theta = state[States.ROAD_ROLL, :][0, 0] + sa = state[States.STEER_ANGLE, :][0, 0] + + sR = state[States.STEER_RATIO, :][0, 0] + u, v = state[States.VELOCITY, :] + r = state[States.YAW_RATE, :][0, 0] + + A = sp.Matrix(np.zeros((2, 2))) + A[0, 0] = -(cF + cR) / (m * u) + A[0, 1] = -(cF * aF - cR * aR) / (m * u) - u + A[1, 0] = -(cF * aF - cR * aR) / (j * u) + A[1, 1] = -(cF * aF**2 + cR * aR**2) / (j * u) + + B = sp.Matrix(np.zeros((2, 1))) + B[0, 0] = cF / m / sR + B[1, 0] = (cF * aF) / j / sR + + C = sp.Matrix(np.zeros((2, 1))) + C[0, 0] = ACCELERATION_DUE_TO_GRAVITY + C[1, 0] = 0 + + x = sp.Matrix([v, r]) # lateral velocity, yaw rate + x_dot = A * x + B * (sa - angle_offset - angle_offset_fast) - C * theta + + dt = sp.Symbol('dt') + state_dot = sp.Matrix(np.zeros((dim_state, 1))) + state_dot[States.VELOCITY.start + 1, 0] = x_dot[0] + state_dot[States.YAW_RATE.start, 0] = x_dot[1] + + # Basic descretization, 1st order integrator + # Can be pretty bad if dt is big + f_sym = state + dt * state_dot + + # + # Observation functions + # + obs_eqs = [ + [sp.Matrix([r]), ObservationKind.ROAD_FRAME_YAW_RATE, None], + [sp.Matrix([u, v]), ObservationKind.ROAD_FRAME_XY_SPEED, None], + [sp.Matrix([u]), ObservationKind.ROAD_FRAME_X_SPEED, None], + [sp.Matrix([sa]), ObservationKind.STEER_ANGLE, None], + [sp.Matrix([angle_offset_fast]), ObservationKind.ANGLE_OFFSET_FAST, None], + [sp.Matrix([sR]), ObservationKind.STEER_RATIO, None], + [sp.Matrix([sf]), ObservationKind.STIFFNESS, None], + [sp.Matrix([theta]), ObservationKind.ROAD_ROLL, None], + ] + + gen_code(generated_dir, name, f_sym, dt, state_sym, obs_eqs, dim_state, dim_state, global_vars=global_vars) + + def __init__(self, generated_dir): + dim_state, dim_state_err = CarKalman.initial_x.shape[0], CarKalman.P_initial.shape[0] + self.filter = EKF_sym_pyx(generated_dir, CarKalman.name, CarKalman.Q, CarKalman.initial_x, CarKalman.P_initial, + dim_state, dim_state_err, global_vars=CarKalman.global_vars, logger=cloudlog) + + def set_globals(self, mass, rotational_inertia, center_to_front, center_to_rear, stiffness_front, stiffness_rear): + self.filter.set_global("mass", mass) + self.filter.set_global("rotational_inertia", rotational_inertia) + self.filter.set_global("center_to_front", center_to_front) + self.filter.set_global("center_to_rear", center_to_rear) + self.filter.set_global("stiffness_front", stiffness_front) + self.filter.set_global("stiffness_rear", stiffness_rear) + + +if __name__ == "__main__": + generated_dir = sys.argv[2] + CarKalman.generate_code(generated_dir) diff --git a/sunnypilot/selfdrive/locationd/models/constants.py b/sunnypilot/selfdrive/locationd/models/constants.py new file mode 100644 index 0000000000..6d328ce6f5 --- /dev/null +++ b/sunnypilot/selfdrive/locationd/models/constants.py @@ -0,0 +1,92 @@ +import os + +GENERATED_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), 'generated')) + +class ObservationKind: + UNKNOWN = 0 + NO_OBSERVATION = 1 + GPS_NED = 2 + ODOMETRIC_SPEED = 3 + PHONE_GYRO = 4 + GPS_VEL = 5 + PSEUDORANGE_GPS = 6 + PSEUDORANGE_RATE_GPS = 7 + SPEED = 8 + NO_ROT = 9 + PHONE_ACCEL = 10 + ORB_POINT = 11 + ECEF_POS = 12 + CAMERA_ODO_TRANSLATION = 13 + CAMERA_ODO_ROTATION = 14 + ORB_FEATURES = 15 + MSCKF_TEST = 16 + FEATURE_TRACK_TEST = 17 + LANE_PT = 18 + IMU_FRAME = 19 + PSEUDORANGE_GLONASS = 20 + PSEUDORANGE_RATE_GLONASS = 21 + PSEUDORANGE = 22 + PSEUDORANGE_RATE = 23 + ECEF_VEL = 35 + ECEF_ORIENTATION_FROM_GPS = 32 + NO_ACCEL = 33 + ORB_FEATURES_WIDE = 34 + + ROAD_FRAME_XY_SPEED = 24 # (x, y) [m/s] + ROAD_FRAME_YAW_RATE = 25 # [rad/s] + STEER_ANGLE = 26 # [rad] + ANGLE_OFFSET_FAST = 27 # [rad] + STIFFNESS = 28 # [-] + STEER_RATIO = 29 # [-] + ROAD_FRAME_X_SPEED = 30 # (x) [m/s] + ROAD_ROLL = 31 # [rad] + + names = [ + 'Unknown', + 'No observation', + 'GPS NED', + 'Odometric speed', + 'Phone gyro', + 'GPS velocity', + 'GPS pseudorange', + 'GPS pseudorange rate', + 'Speed', + 'No rotation', + 'Phone acceleration', + 'ORB point', + 'ECEF pos', + 'camera odometric translation', + 'camera odometric rotation', + 'ORB features', + 'MSCKF test', + 'Feature track test', + 'Lane ecef point', + 'imu frame eulers', + 'GLONASS pseudorange', + 'GLONASS pseudorange rate', + 'pseudorange', + 'pseudorange rate', + + 'Road Frame x,y speed', + 'Road Frame yaw rate', + 'Steer Angle', + 'Fast Angle Offset', + 'Stiffness', + 'Steer Ratio', + 'Road Frame x speed', + 'Road Roll', + 'ECEF orientation from GPS', + 'NO accel', + 'ORB features wide camera', + 'ECEF_VEL', + ] + + @classmethod + def to_string(cls, kind): + return cls.names[kind] + + +SAT_OBS = [ObservationKind.PSEUDORANGE_GPS, + ObservationKind.PSEUDORANGE_RATE_GPS, + ObservationKind.PSEUDORANGE_GLONASS, + ObservationKind.PSEUDORANGE_RATE_GLONASS] diff --git a/sunnypilot/selfdrive/locationd/models/live_kf.cc b/sunnypilot/selfdrive/locationd/models/live_kf.cc new file mode 100644 index 0000000000..7ef6be638e --- /dev/null +++ b/sunnypilot/selfdrive/locationd/models/live_kf.cc @@ -0,0 +1,122 @@ +#include "sunnypilot/selfdrive/locationd/models/live_kf.h" + +using namespace EKFS; +using namespace Eigen; + +Eigen::Map get_mapvec(const Eigen::VectorXd &vec) { + return Eigen::Map((double*)vec.data(), vec.rows(), vec.cols()); +} + +Eigen::Map get_mapmat(const MatrixXdr &mat) { + return Eigen::Map((double*)mat.data(), mat.rows(), mat.cols()); +} + +std::vector> get_vec_mapvec(const std::vector &vec_vec) { + std::vector> res; + for (const Eigen::VectorXd &vec : vec_vec) { + res.push_back(get_mapvec(vec)); + } + return res; +} + +std::vector> get_vec_mapmat(const std::vector &mat_vec) { + std::vector> res; + for (const MatrixXdr &mat : mat_vec) { + res.push_back(get_mapmat(mat)); + } + return res; +} + +LiveKalman::LiveKalman() { + this->dim_state = live_initial_x.rows(); + this->dim_state_err = live_initial_P_diag.rows(); + + this->initial_x = live_initial_x; + this->initial_P = live_initial_P_diag.asDiagonal(); + this->fake_gps_pos_cov = live_fake_gps_pos_cov_diag.asDiagonal(); + this->fake_gps_vel_cov = live_fake_gps_vel_cov_diag.asDiagonal(); + this->reset_orientation_P = live_reset_orientation_diag.asDiagonal(); + this->Q = live_Q_diag.asDiagonal(); + for (auto& pair : live_obs_noise_diag) { + this->obs_noise[pair.first] = pair.second.asDiagonal(); + } + + // init filter + this->filter = std::make_shared(this->name, get_mapmat(this->Q), get_mapvec(this->initial_x), + get_mapmat(initial_P), this->dim_state, this->dim_state_err, 0, 0, 0, std::vector(), + std::vector{3}, std::vector(), 0.8); +} + +void LiveKalman::init_state(const VectorXd &state, const VectorXd &covs_diag, double filter_time) { + MatrixXdr covs = covs_diag.asDiagonal(); + this->filter->init_state(get_mapvec(state), get_mapmat(covs), filter_time); +} + +void LiveKalman::init_state(const VectorXd &state, const MatrixXdr &covs, double filter_time) { + this->filter->init_state(get_mapvec(state), get_mapmat(covs), filter_time); +} + +void LiveKalman::init_state(const VectorXd &state, double filter_time) { + MatrixXdr covs = this->filter->covs(); + this->filter->init_state(get_mapvec(state), get_mapmat(covs), filter_time); +} + +VectorXd LiveKalman::get_x() { + return this->filter->state(); +} + +MatrixXdr LiveKalman::get_P() { + return this->filter->covs(); +} + +double LiveKalman::get_filter_time() { + return this->filter->get_filter_time(); +} + +std::vector LiveKalman::get_R(int kind, int n) { + std::vector R; + for (int i = 0; i < n; i++) { + R.push_back(this->obs_noise[kind]); + } + return R; +} + +std::optional LiveKalman::predict_and_observe(double t, int kind, const std::vector &meas, std::vector R) { + std::optional r; + if (R.size() == 0) { + R = this->get_R(kind, meas.size()); + } + r = this->filter->predict_and_update_batch(t, kind, get_vec_mapvec(meas), get_vec_mapmat(R)); + return r; +} + +void LiveKalman::predict(double t) { + this->filter->predict(t); +} + +const Eigen::VectorXd &LiveKalman::get_initial_x() { + return this->initial_x; +} + +const MatrixXdr &LiveKalman::get_initial_P() { + return this->initial_P; +} + +const MatrixXdr &LiveKalman::get_fake_gps_pos_cov() { + return this->fake_gps_pos_cov; +} + +const MatrixXdr &LiveKalman::get_fake_gps_vel_cov() { + return this->fake_gps_vel_cov; +} + +const MatrixXdr &LiveKalman::get_reset_orientation_P() { + return this->reset_orientation_P; +} + +MatrixXdr LiveKalman::H(const VectorXd &in) { + assert(in.size() == 6); + Matrix res; + this->filter->get_extra_routine("H")((double*)in.data(), res.data()); + return res; +} diff --git a/sunnypilot/selfdrive/locationd/models/live_kf.h b/sunnypilot/selfdrive/locationd/models/live_kf.h new file mode 100644 index 0000000000..e4b3e326b3 --- /dev/null +++ b/sunnypilot/selfdrive/locationd/models/live_kf.h @@ -0,0 +1,66 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include +#include + +#include "generated/live_kf_constants.h" +#include "rednose/helpers/ekf_sym.h" + +#define EARTH_GM 3.986005e14 // m^3/s^2 (gravitational constant * mass of earth) + +using namespace EKFS; + +Eigen::Map get_mapvec(const Eigen::VectorXd &vec); +Eigen::Map get_mapmat(const MatrixXdr &mat); +std::vector> get_vec_mapvec(const std::vector &vec_vec); +std::vector> get_vec_mapmat(const std::vector &mat_vec); + +class LiveKalman { +public: + LiveKalman(); + + void init_state(const Eigen::VectorXd &state, const Eigen::VectorXd &covs_diag, double filter_time); + void init_state(const Eigen::VectorXd &state, const MatrixXdr &covs, double filter_time); + void init_state(const Eigen::VectorXd &state, double filter_time); + + Eigen::VectorXd get_x(); + MatrixXdr get_P(); + double get_filter_time(); + std::vector get_R(int kind, int n); + + std::optional predict_and_observe(double t, int kind, const std::vector &meas, std::vector R = {}); + std::optional predict_and_update_odo_speed(std::vector speed, double t, int kind); + std::optional predict_and_update_odo_trans(std::vector trans, double t, int kind); + std::optional predict_and_update_odo_rot(std::vector rot, double t, int kind); + void predict(double t); + + const Eigen::VectorXd &get_initial_x(); + const MatrixXdr &get_initial_P(); + const MatrixXdr &get_fake_gps_pos_cov(); + const MatrixXdr &get_fake_gps_vel_cov(); + const MatrixXdr &get_reset_orientation_P(); + + MatrixXdr H(const Eigen::VectorXd &in); + +private: + std::string name = "live"; + + std::shared_ptr filter; + + int dim_state; + int dim_state_err; + + Eigen::VectorXd initial_x; + MatrixXdr initial_P; + MatrixXdr fake_gps_pos_cov; + MatrixXdr fake_gps_vel_cov; + MatrixXdr reset_orientation_P; + MatrixXdr Q; // process noise + std::unordered_map obs_noise; +}; diff --git a/sunnypilot/selfdrive/locationd/models/live_kf.py b/sunnypilot/selfdrive/locationd/models/live_kf.py new file mode 100755 index 0000000000..e5626b8f28 --- /dev/null +++ b/sunnypilot/selfdrive/locationd/models/live_kf.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 + +import sys +import os +import numpy as np + +from openpilot.sunnypilot.selfdrive.locationd.models.constants import ObservationKind + +import sympy as sp +import inspect +from rednose.helpers.sympy_helpers import euler_rotate, quat_matrix_r, quat_rotate +from rednose.helpers.ekf_sym import gen_code + +EARTH_GM = 3.986005e14 # m^3/s^2 (gravitational constant * mass of earth) + + +def numpy2eigenstring(arr): + assert(len(arr.shape) == 1) + arr_str = np.array2string(arr, precision=20, separator=',')[1:-1].replace(' ', '').replace('\n', '') + return f"(Eigen::VectorXd({len(arr)}) << {arr_str}).finished()" + + +class States: + ECEF_POS = slice(0, 3) # x, y and z in ECEF in meters + ECEF_ORIENTATION = slice(3, 7) # quat for pose of phone in ecef + ECEF_VELOCITY = slice(7, 10) # ecef velocity in m/s + ANGULAR_VELOCITY = slice(10, 13) # roll, pitch and yaw rates in device frame in radians/s + GYRO_BIAS = slice(13, 16) # roll, pitch and yaw biases + ACCELERATION = slice(16, 19) # Acceleration in device frame in m/s**2 + ACC_BIAS = slice(19, 22) # Acceletometer bias in m/s**2 + + # Error-state has different slices because it is an ESKF + ECEF_POS_ERR = slice(0, 3) + ECEF_ORIENTATION_ERR = slice(3, 6) # euler angles for orientation error + ECEF_VELOCITY_ERR = slice(6, 9) + ANGULAR_VELOCITY_ERR = slice(9, 12) + GYRO_BIAS_ERR = slice(12, 15) + ACCELERATION_ERR = slice(15, 18) + ACC_BIAS_ERR = slice(18, 21) + + +class LiveKalman: + name = 'live' + + initial_x = np.array([3.88e6, -3.37e6, 3.76e6, + 0.42254641, -0.31238054, -0.83602975, -0.15788347, # NED [0,0,0] -> ECEF Quat + 0, 0, 0, + 0, 0, 0, + 0, 0, 0, + 0, 0, 0, + 0, 0, 0]) + + # state covariance + initial_P_diag = np.array([10**2, 10**2, 10**2, + 0.01**2, 0.01**2, 0.01**2, + 10**2, 10**2, 10**2, + 1**2, 1**2, 1**2, + 1**2, 1**2, 1**2, + 100**2, 100**2, 100**2, + 0.01**2, 0.01**2, 0.01**2]) + + # state covariance when resetting midway in a segment + reset_orientation_diag = np.array([1**2, 1**2, 1**2]) + + # fake observation covariance, to ensure the uncertainty estimate of the filter is under control + fake_gps_pos_cov_diag = np.array([1000**2, 1000**2, 1000**2]) + fake_gps_vel_cov_diag = np.array([10**2, 10**2, 10**2]) + + # process noise + Q_diag = np.array([0.03**2, 0.03**2, 0.03**2, + 0.001**2, 0.001**2, 0.001**2, + 0.01**2, 0.01**2, 0.01**2, + 0.1**2, 0.1**2, 0.1**2, + (0.005 / 100)**2, (0.005 / 100)**2, (0.005 / 100)**2, + 3**2, 3**2, 3**2, + 0.005**2, 0.005**2, 0.005**2]) + + obs_noise_diag = {ObservationKind.PHONE_GYRO: np.array([0.025**2, 0.025**2, 0.025**2]), + ObservationKind.PHONE_ACCEL: np.array([.5**2, .5**2, .5**2]), + ObservationKind.CAMERA_ODO_ROTATION: np.array([0.05**2, 0.05**2, 0.05**2]), + ObservationKind.NO_ROT: np.array([0.005**2, 0.005**2, 0.005**2]), + ObservationKind.NO_ACCEL: np.array([0.05**2, 0.05**2, 0.05**2]), + ObservationKind.ECEF_POS: np.array([5**2, 5**2, 5**2]), + ObservationKind.ECEF_VEL: np.array([.5**2, .5**2, .5**2]), + ObservationKind.ECEF_ORIENTATION_FROM_GPS: np.array([.2**2, .2**2, .2**2, .2**2])} + + @staticmethod + def generate_code(generated_dir): + name = LiveKalman.name + dim_state = LiveKalman.initial_x.shape[0] + dim_state_err = LiveKalman.initial_P_diag.shape[0] + + state_sym = sp.MatrixSymbol('state', dim_state, 1) + state = sp.Matrix(state_sym) + x, y, z = state[States.ECEF_POS, :] + q = state[States.ECEF_ORIENTATION, :] + v = state[States.ECEF_VELOCITY, :] + vx, vy, vz = v + omega = state[States.ANGULAR_VELOCITY, :] + vroll, vpitch, vyaw = omega + roll_bias, pitch_bias, yaw_bias = state[States.GYRO_BIAS, :] + acceleration = state[States.ACCELERATION, :] + acc_bias = state[States.ACC_BIAS, :] + + dt = sp.Symbol('dt') + + # calibration and attitude rotation matrices + quat_rot = quat_rotate(*q) + + # Got the quat predict equations from here + # A New Quaternion-Based Kalman Filter for + # Real-Time Attitude Estimation Using the Two-Step + # Geometrically-Intuitive Correction Algorithm + A = 0.5 * sp.Matrix([[0, -vroll, -vpitch, -vyaw], + [vroll, 0, vyaw, -vpitch], + [vpitch, -vyaw, 0, vroll], + [vyaw, vpitch, -vroll, 0]]) + q_dot = A * q + + # Time derivative of the state as a function of state + state_dot = sp.Matrix(np.zeros((dim_state, 1))) + state_dot[States.ECEF_POS, :] = v + state_dot[States.ECEF_ORIENTATION, :] = q_dot + state_dot[States.ECEF_VELOCITY, 0] = quat_rot * acceleration + + # Basic descretization, 1st order intergrator + # Can be pretty bad if dt is big + f_sym = state + dt * state_dot + + state_err_sym = sp.MatrixSymbol('state_err', dim_state_err, 1) + state_err = sp.Matrix(state_err_sym) + quat_err = state_err[States.ECEF_ORIENTATION_ERR, :] + v_err = state_err[States.ECEF_VELOCITY_ERR, :] + omega_err = state_err[States.ANGULAR_VELOCITY_ERR, :] + acceleration_err = state_err[States.ACCELERATION_ERR, :] + + # Time derivative of the state error as a function of state error and state + quat_err_matrix = euler_rotate(quat_err[0], quat_err[1], quat_err[2]) + q_err_dot = quat_err_matrix * quat_rot * (omega + omega_err) + state_err_dot = sp.Matrix(np.zeros((dim_state_err, 1))) + state_err_dot[States.ECEF_POS_ERR, :] = v_err + state_err_dot[States.ECEF_ORIENTATION_ERR, :] = q_err_dot + state_err_dot[States.ECEF_VELOCITY_ERR, :] = quat_err_matrix * quat_rot * (acceleration + acceleration_err) + f_err_sym = state_err + dt * state_err_dot + + # Observation matrix modifier + H_mod_sym = sp.Matrix(np.zeros((dim_state, dim_state_err))) + H_mod_sym[States.ECEF_POS, States.ECEF_POS_ERR] = np.eye(States.ECEF_POS.stop - States.ECEF_POS.start) + H_mod_sym[States.ECEF_ORIENTATION, States.ECEF_ORIENTATION_ERR] = 0.5 * quat_matrix_r(state[3:7])[:, 1:] + H_mod_sym[States.ECEF_ORIENTATION.stop:, States.ECEF_ORIENTATION_ERR.stop:] = np.eye(dim_state - States.ECEF_ORIENTATION.stop) + + # these error functions are defined so that say there + # is a nominal x and true x: + # true x = err_function(nominal x, delta x) + # delta x = inv_err_function(nominal x, true x) + nom_x = sp.MatrixSymbol('nom_x', dim_state, 1) + true_x = sp.MatrixSymbol('true_x', dim_state, 1) + delta_x = sp.MatrixSymbol('delta_x', dim_state_err, 1) + + err_function_sym = sp.Matrix(np.zeros((dim_state, 1))) + delta_quat = sp.Matrix(np.ones(4)) + delta_quat[1:, :] = sp.Matrix(0.5 * delta_x[States.ECEF_ORIENTATION_ERR, :]) + err_function_sym[States.ECEF_POS, :] = sp.Matrix(nom_x[States.ECEF_POS, :] + delta_x[States.ECEF_POS_ERR, :]) + err_function_sym[States.ECEF_ORIENTATION, 0] = quat_matrix_r(nom_x[States.ECEF_ORIENTATION, 0]) * delta_quat + err_function_sym[States.ECEF_ORIENTATION.stop:, :] = sp.Matrix(nom_x[States.ECEF_ORIENTATION.stop:, :] + delta_x[States.ECEF_ORIENTATION_ERR.stop:, :]) + + inv_err_function_sym = sp.Matrix(np.zeros((dim_state_err, 1))) + inv_err_function_sym[States.ECEF_POS_ERR, 0] = sp.Matrix(-nom_x[States.ECEF_POS, 0] + true_x[States.ECEF_POS, 0]) + delta_quat = quat_matrix_r(nom_x[States.ECEF_ORIENTATION, 0]).T * true_x[States.ECEF_ORIENTATION, 0] + inv_err_function_sym[States.ECEF_ORIENTATION_ERR, 0] = sp.Matrix(2 * delta_quat[1:]) + inv_err_function_sym[States.ECEF_ORIENTATION_ERR.stop:, 0] = sp.Matrix(-nom_x[States.ECEF_ORIENTATION.stop:, 0] + true_x[States.ECEF_ORIENTATION.stop:, 0]) + + eskf_params = [[err_function_sym, nom_x, delta_x], + [inv_err_function_sym, nom_x, true_x], + H_mod_sym, f_err_sym, state_err_sym] + # + # Observation functions + # + h_gyro_sym = sp.Matrix([ + vroll + roll_bias, + vpitch + pitch_bias, + vyaw + yaw_bias]) + + pos = sp.Matrix([x, y, z]) + gravity = quat_rot.T * ((EARTH_GM / ((x**2 + y**2 + z**2)**(3.0 / 2.0))) * pos) + h_acc_sym = (gravity + acceleration + acc_bias) + h_acc_stationary_sym = acceleration + h_phone_rot_sym = sp.Matrix([vroll, vpitch, vyaw]) + h_pos_sym = sp.Matrix([x, y, z]) + h_vel_sym = sp.Matrix([vx, vy, vz]) + h_orientation_sym = q + h_relative_motion = sp.Matrix(quat_rot.T * v) + + obs_eqs = [[h_gyro_sym, ObservationKind.PHONE_GYRO, None], + [h_phone_rot_sym, ObservationKind.NO_ROT, None], + [h_acc_sym, ObservationKind.PHONE_ACCEL, None], + [h_pos_sym, ObservationKind.ECEF_POS, None], + [h_vel_sym, ObservationKind.ECEF_VEL, None], + [h_orientation_sym, ObservationKind.ECEF_ORIENTATION_FROM_GPS, None], + [h_relative_motion, ObservationKind.CAMERA_ODO_TRANSLATION, None], + [h_phone_rot_sym, ObservationKind.CAMERA_ODO_ROTATION, None], + [h_acc_stationary_sym, ObservationKind.NO_ACCEL, None]] + + # this returns a sympy routine for the jacobian of the observation function of the local vel + in_vec = sp.MatrixSymbol('in_vec', 6, 1) # roll, pitch, yaw, vx, vy, vz + h = euler_rotate(in_vec[0], in_vec[1], in_vec[2]).T * (sp.Matrix([in_vec[3], in_vec[4], in_vec[5]])) + extra_routines = [('H', h.jacobian(in_vec), [in_vec])] + + gen_code(generated_dir, name, f_sym, dt, state_sym, obs_eqs, dim_state, dim_state_err, eskf_params, extra_routines=extra_routines) + + # write constants to extra header file for use in cpp + live_kf_header = "#pragma once\n\n" + live_kf_header += "#include \n" + live_kf_header += "#include \n\n" + for state, slc in inspect.getmembers(States, lambda x: isinstance(x, slice)): + assert(slc.step is None) # unsupported + live_kf_header += f'#define STATE_{state}_START {slc.start}\n' + live_kf_header += f'#define STATE_{state}_END {slc.stop}\n' + live_kf_header += f'#define STATE_{state}_LEN {slc.stop - slc.start}\n' + live_kf_header += "\n" + + for kind, val in inspect.getmembers(ObservationKind, lambda x: isinstance(x, int)): + live_kf_header += f'#define OBSERVATION_{kind} {val}\n' + live_kf_header += "\n" + + live_kf_header += f"static const Eigen::VectorXd live_initial_x = {numpy2eigenstring(LiveKalman.initial_x)};\n" + live_kf_header += f"static const Eigen::VectorXd live_initial_P_diag = {numpy2eigenstring(LiveKalman.initial_P_diag)};\n" + live_kf_header += f"static const Eigen::VectorXd live_fake_gps_pos_cov_diag = {numpy2eigenstring(LiveKalman.fake_gps_pos_cov_diag)};\n" + live_kf_header += f"static const Eigen::VectorXd live_fake_gps_vel_cov_diag = {numpy2eigenstring(LiveKalman.fake_gps_vel_cov_diag)};\n" + live_kf_header += f"static const Eigen::VectorXd live_reset_orientation_diag = {numpy2eigenstring(LiveKalman.reset_orientation_diag)};\n" + live_kf_header += f"static const Eigen::VectorXd live_Q_diag = {numpy2eigenstring(LiveKalman.Q_diag)};\n" + live_kf_header += "static const std::unordered_map> live_obs_noise_diag = {\n" + for kind, noise in LiveKalman.obs_noise_diag.items(): + live_kf_header += f" {{ {kind}, {numpy2eigenstring(noise)} }},\n" + live_kf_header += "};\n\n" + + open(os.path.join(generated_dir, "live_kf_constants.h"), 'w').write(live_kf_header) + + +if __name__ == "__main__": + generated_dir = sys.argv[2] + LiveKalman.generate_code(generated_dir) diff --git a/sunnypilot/selfdrive/locationd/tests/.gitignore b/sunnypilot/selfdrive/locationd/tests/.gitignore new file mode 100644 index 0000000000..89f9ac04aa --- /dev/null +++ b/sunnypilot/selfdrive/locationd/tests/.gitignore @@ -0,0 +1 @@ +out/ diff --git a/sunnypilot/selfdrive/locationd/tests/__init__.py b/sunnypilot/selfdrive/locationd/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sunnypilot/selfdrive/locationd/tests/test_locationd.py b/sunnypilot/selfdrive/locationd/tests/test_locationd.py new file mode 100644 index 0000000000..877bc821da --- /dev/null +++ b/sunnypilot/selfdrive/locationd/tests/test_locationd.py @@ -0,0 +1,94 @@ +import pytest +import platform +import json +import random +import time +import capnp + +import cereal.messaging as messaging +from cereal.services import SERVICE_LIST +from openpilot.common.params import Params +from openpilot.common.transformations.coordinates import ecef2geodetic + +from openpilot.system.manager.process_config import managed_processes + + +if platform.system() == 'Darwin': + pytest.skip("Skipping locationd test on macOS due to unsupported msgq.", allow_module_level=True) + + +class TestLocationdProc: + LLD_MSGS = ['gpsLocationExternal', 'cameraOdometry', 'carState', 'liveCalibration', + 'accelerometer', 'gyroscope', 'magnetometer'] + + def setup_method(self): + self.pm = messaging.PubMaster(self.LLD_MSGS) + + self.params = Params() + self.params.put_bool("UbloxAvailable", True) + managed_processes['locationd_llk'].prepare() + managed_processes['locationd_llk'].start() + + def teardown_method(self): + managed_processes['locationd_llk'].stop() + + def get_msg(self, name, t): + try: + msg = messaging.new_message(name) + except capnp.lib.capnp.KjException: + msg = messaging.new_message(name, 0) + + if name == "gpsLocationExternal": + msg.gpsLocationExternal.flags = 1 + msg.gpsLocationExternal.hasFix = True + msg.gpsLocationExternal.verticalAccuracy = 1.0 + msg.gpsLocationExternal.speedAccuracy = 1.0 + msg.gpsLocationExternal.bearingAccuracyDeg = 1.0 + msg.gpsLocationExternal.vNED = [0.0, 0.0, 0.0] + msg.gpsLocationExternal.latitude = float(self.lat) + msg.gpsLocationExternal.longitude = float(self.lon) + msg.gpsLocationExternal.unixTimestampMillis = t * 1e6 + msg.gpsLocationExternal.altitude = float(self.alt) + #if name == "gnssMeasurements": + # msg.gnssMeasurements.measTime = t + # msg.gnssMeasurements.positionECEF.value = [self.x , self.y, self.z] + # msg.gnssMeasurements.positionECEF.std = [0,0,0] + # msg.gnssMeasurements.positionECEF.valid = True + # msg.gnssMeasurements.velocityECEF.value = [] + # msg.gnssMeasurements.velocityECEF.std = [0,0,0] + # msg.gnssMeasurements.velocityECEF.valid = True + elif name == 'cameraOdometry': + msg.cameraOdometry.rot = [0.0, 0.0, 0.0] + msg.cameraOdometry.rotStd = [0.0, 0.0, 0.0] + msg.cameraOdometry.trans = [0.0, 0.0, 0.0] + msg.cameraOdometry.transStd = [0.0, 0.0, 0.0] + msg.logMonoTime = t + msg.valid = True + return msg + + def test_params_gps(self): + random.seed(123489234) + self.params.remove('LastGPSPositionLLK') + + self.x = -2710700 + (random.random() * 1e5) + self.y = -4280600 + (random.random() * 1e5) + self.z = 3850300 + (random.random() * 1e5) + self.lat, self.lon, self.alt = ecef2geodetic([self.x, self.y, self.z]) + + # get fake messages at the correct frequency, listed in services.py + msgs = [] + for sec in range(65): + for name in self.LLD_MSGS: + for j in range(int(SERVICE_LIST[name].frequency)): + msgs.append(self.get_msg(name, int((sec + j / SERVICE_LIST[name].frequency) * 1e9))) + + for msg in sorted(msgs, key=lambda x: x.logMonoTime): + self.pm.send(msg.which(), msg) + if msg.which() == "cameraOdometry": + self.pm.wait_for_readers_to_update(msg.which(), 0.1, dt=0.005) + time.sleep(1) # wait for async params write + + lastGPS = json.loads(self.params.get('LastGPSPositionLLK')) + assert lastGPS['latitude'] == pytest.approx(self.lat, abs=0.001) + assert lastGPS['longitude'] == pytest.approx(self.lon, abs=0.001) + assert lastGPS['altitude'] == pytest.approx(self.alt, abs=0.001) diff --git a/sunnypilot/selfdrive/locationd/torqued_ext.py b/sunnypilot/selfdrive/locationd/torqued_ext.py new file mode 100644 index 0000000000..58a23da00e --- /dev/null +++ b/sunnypilot/selfdrive/locationd/torqued_ext.py @@ -0,0 +1,65 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" + +import numpy as np + +from cereal import car + +from openpilot.common.params import Params +from openpilot.common.realtime import DT_MDL +from openpilot.sunnypilot import PARAMS_UPDATE_PERIOD + +RELAXED_MIN_BUCKET_POINTS = np.array([1, 200, 300, 500, 500, 300, 200, 1]) + +ALLOWED_CARS = ['toyota', 'hyundai', 'rivian', 'honda'] + + + +class TorqueEstimatorExt: + def __init__(self, CP: car.CarParams): + self.CP = CP + self._params = Params() + self.frame = -1 + + self.enforce_torque_control_toggle = self._params.get_bool("EnforceTorqueControl") # only during init + self.use_params = self.CP.brand in ALLOWED_CARS and self.CP.lateralTuning.which() == 'torque' + self.use_live_torque_params = self._params.get_bool("LiveTorqueParamsToggle") + self.torque_override_enabled = self._params.get_bool("TorqueParamsOverrideEnabled") + self.min_bucket_points = RELAXED_MIN_BUCKET_POINTS + self.factor_sanity = 0.0 + self.friction_sanity = 0.0 + self.offline_latAccelFactor = 0.0 + self.offline_friction = 0.0 + + def initialize_custom_params(self, decimated=False): + self.update_use_params() + + if self.enforce_torque_control_toggle: + if self._params.get_bool("LiveTorqueParamsRelaxedToggle"): + self.min_bucket_points = RELAXED_MIN_BUCKET_POINTS / (10 if decimated else 1) + self.factor_sanity = 0.5 if decimated else 1.0 + self.friction_sanity = 0.8 if decimated else 1.0 + + if self._params.get_bool("CustomTorqueParams"): + self.offline_latAccelFactor = float(self._params.get("TorqueParamsOverrideLatAccelFactor", return_default=True)) + self.offline_friction = float(self._params.get("TorqueParamsOverrideFriction", return_default=True)) + + def _update_params(self): + if self.frame % int(PARAMS_UPDATE_PERIOD / DT_MDL) == 0: + self.use_live_torque_params = self._params.get_bool("LiveTorqueParamsToggle") + self.torque_override_enabled = self._params.get_bool("TorqueParamsOverrideEnabled") + + def update_use_params(self): + self._update_params() + + if self.enforce_torque_control_toggle: + if self.torque_override_enabled: + self.use_params = False + else: + self.use_params = self.use_live_torque_params + + self.frame += 1 diff --git a/sunnypilot/selfdrive/pandad/__init__.py b/sunnypilot/selfdrive/pandad/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sunnypilot/selfdrive/pandad/rivian_long_flasher.py b/sunnypilot/selfdrive/pandad/rivian_long_flasher.py new file mode 100755 index 0000000000..305b994c78 --- /dev/null +++ b/sunnypilot/selfdrive/pandad/rivian_long_flasher.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import os +from itertools import accumulate + +from cereal import car, messaging +from panda import Panda +from openpilot.common.params import Params +from openpilot.common.swaglog import cloudlog + +FW_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "rivian_long_fw.bin.signed") +SECTOR_SIZES = [0x4000] * 4 + [0x10000] + [0x20000] * 11 + + +def _is_rivian() -> bool: + params = Params() + + # check fixed fingerprint + if bundle := params.get("CarPlatformBundle"): + if bundle.get("brand") == "rivian": + return True + + # check cached fingerprint + CP_bytes = params.get("CarParamsPersistent") + if CP_bytes is not None: + CP = messaging.log_from_bytes(CP_bytes, car.CarParams) + if CP.brand == "rivian": + return True + + return False + + +def _flash_static(handle, code): + assert Panda.flasher_present(handle) + last_sector = next((i + 1 for i, v in enumerate(accumulate(SECTOR_SIZES[1:])) if v > len(code)), -1) + assert 1 <= last_sector < 7, "Invalid firmware size" + + handle.controlWrite(Panda.REQUEST_IN, 0xb1, 0, 0, b'') + for i in range(1, last_sector + 1): + handle.controlWrite(Panda.REQUEST_IN, 0xb2, i, 0, b'') + for i in range(0, len(code), 0x10): + handle.bulkWrite(2, code[i:i + 0x10]) + try: + handle.controlWrite(Panda.REQUEST_IN, 0xd8, 0, 0, b'', expect_disconnect=True) + except Exception: + pass + + +def _flash_panda(panda: Panda) -> None: + expected_sig = Panda.get_signature_from_firmware(FW_PATH) + if not panda.bootstub and panda.get_signature() == expected_sig: + cloudlog.info(f"F4 panda {panda.get_usb_serial()} already up to date") + return + + cloudlog.info(f"Flashing F4 panda {panda.get_usb_serial()}") + with open(FW_PATH, "rb") as f: + code = f.read() + + if not panda.bootstub: + # enter bootstub directly, panda.reset() rejects deprecated hw types + try: + panda._handle.controlWrite(Panda.REQUEST_IN, 0xd1, 1, 0, b'', timeout=15000, expect_disconnect=True) + except Exception: + pass + panda.close() + panda.reconnect() + + _flash_static(panda._handle, code) + panda.reconnect() + cloudlog.info(f"Successfully flashed xnor's Rivian Longitudinal Upgrade Kit: {panda.get_usb_serial()}") + + +def flash_rivian_long(panda_serials: list[str]) -> None: + if not os.path.isfile(FW_PATH): + cloudlog.error(f"Rivian longitudinal upgrade firmware not found at {FW_PATH}") + return + + if not _is_rivian(): + cloudlog.info("Not a Rivian, skipping longitudinal upgrade...") + return + + # only check USB connected pandas, internal panda uses SPI and is never an external panda + usb_serials = set(Panda.usb_list()) + for serial in panda_serials: + if serial not in usb_serials: + continue + panda = Panda(serial) + # only flash external black pandas (HW_TYPE_BLACK = 0x03) + if panda.get_type() == b'\x03' and not panda.is_internal(): + try: + _flash_panda(panda) + except Exception: + cloudlog.exception(f"Failed to flash xnor's Rivian Longitudinal Upgrade Kit: {serial}") + panda.close() + + return + + +if __name__ == '__main__': + flash_rivian_long(Panda.list()) diff --git a/sunnypilot/selfdrive/pandad/rivian_long_fw.bin.signed b/sunnypilot/selfdrive/pandad/rivian_long_fw.bin.signed new file mode 100644 index 0000000000..bdbd237ba9 Binary files /dev/null and b/sunnypilot/selfdrive/pandad/rivian_long_fw.bin.signed differ diff --git a/sunnypilot/selfdrive/selfdrived/__init__.py b/sunnypilot/selfdrive/selfdrived/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sunnypilot/selfdrive/selfdrived/events.py b/sunnypilot/selfdrive/selfdrived/events.py new file mode 100644 index 0000000000..b1343b2a08 --- /dev/null +++ b/sunnypilot/selfdrive/selfdrived/events.py @@ -0,0 +1,246 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import cereal.messaging as messaging +from cereal import log, car, custom +from openpilot.common.constants import CV +from openpilot.sunnypilot.selfdrive.selfdrived.events_base import EventsBase, Priority, ET, Alert, \ + NoEntryAlert, ImmediateDisableAlert, EngagementAlert, NormalPermanentAlert, AlertCallbackType, wrong_car_mode_alert +from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit import PCM_LONG_REQUIRED_MAX_SET_SPEED, CONFIRM_SPEED_THRESHOLD +from openpilot.system.hardware import HARDWARE + +AlertSize = log.SelfdriveState.AlertSize +AlertStatus = log.SelfdriveState.AlertStatus +VisualAlert = car.CarControl.HUDControl.VisualAlert +AudibleAlert = car.CarControl.HUDControl.AudibleAlert +AudibleAlertSP = custom.SelfdriveStateSP.AudibleAlert +EventNameSP = custom.OnroadEventSP.EventName + + +# get event name from enum +EVENT_NAME_SP = {v: k for k, v in EventNameSP.schema.enumerants.items()} + +IS_MICI = HARDWARE.get_device_type() == 'mici' + + +def speed_limit_adjust_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality) -> Alert: + speedLimit = sm['longitudinalPlanSP'].speedLimit.resolver.speedLimit + speed = round(speedLimit * (CV.MS_TO_KPH if metric else CV.MS_TO_MPH)) + message = f'Adjusting to {speed} {"km/h" if metric else "mph"} speed limit' + return Alert( + message, + "", + AlertStatus.normal, AlertSize.small, + Priority.LOW, VisualAlert.none, AudibleAlert.none, 4.) + + +def speed_limit_pre_active_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality) -> Alert: + speed_conv = CV.MS_TO_KPH if metric else CV.MS_TO_MPH + v_cruise_cluster = CS.vCruiseCluster + set_speed = sm['controlsState'].vCruiseDEPRECATED if v_cruise_cluster == 0.0 else v_cruise_cluster + set_speed_conv = round(set_speed * speed_conv) + + speed_limit_final_last = sm['longitudinalPlanSP'].speedLimit.resolver.speedLimitFinalLast + speed_limit_final_last_conv = round(speed_limit_final_last * speed_conv) + alert_1_str = "" + alert_size = AlertSize.small + + if CP.openpilotLongitudinalControl and CP.pcmCruise: + # PCM long + cst_low, cst_high = PCM_LONG_REQUIRED_MAX_SET_SPEED[metric] + pcm_long_required_max = cst_low if speed_limit_final_last_conv < CONFIRM_SPEED_THRESHOLD[metric] else cst_high + pcm_long_required_max_set_speed_conv = round(pcm_long_required_max * speed_conv) + speed_unit = "km/h" if metric else "mph" + + alert_1_str = f"Speed Limit Assist: set to {pcm_long_required_max_set_speed_conv} {speed_unit} to engage" + else: + if IS_MICI: + if set_speed_conv < speed_limit_final_last_conv: + alert_1_str = "Press + to confirm speed limit" + elif set_speed_conv > speed_limit_final_last_conv: + alert_1_str = "Press - to confirm speed limit" + else: + alert_size = AlertSize.none + + return Alert( + alert_1_str, + "", + AlertStatus.normal, alert_size, + Priority.LOW, VisualAlert.none, AudibleAlertSP.promptSingleLow, .1) + + +class EventsSP(EventsBase): + def __init__(self): + super().__init__() + self.event_counters = dict.fromkeys(EVENTS_SP.keys(), 0) + + def get_events_mapping(self) -> dict[int, dict[str, Alert | AlertCallbackType]]: + return EVENTS_SP + + def get_event_name(self, event: int): + return EVENT_NAME_SP[event] + + def get_event_msg_type(self): + return custom.OnroadEventSP.Event + + +EVENTS_SP: dict[int, dict[str, Alert | AlertCallbackType]] = { + # sunnypilot + EventNameSP.lkasEnable: { + ET.ENABLE: EngagementAlert(AudibleAlert.engage), + }, + + EventNameSP.lkasDisable: { + ET.USER_DISABLE: EngagementAlert(AudibleAlert.disengage), + }, + + EventNameSP.manualSteeringRequired: { + ET.USER_DISABLE: Alert( + "Automatic Lane Centering is OFF", + "Manual Steering Required", + AlertStatus.normal, AlertSize.mid, + Priority.LOW, VisualAlert.none, AudibleAlert.disengage, 1.), + }, + + EventNameSP.manualLongitudinalRequired: { + ET.WARNING: Alert( + "Smart/Adaptive Cruise Control: OFF", + "Manual Speed Control Required", + AlertStatus.normal, AlertSize.mid, + Priority.LOW, VisualAlert.none, AudibleAlert.none, 1.), + }, + + EventNameSP.silentLkasEnable: { + ET.ENABLE: EngagementAlert(AudibleAlert.none), + }, + + EventNameSP.silentLkasDisable: { + ET.USER_DISABLE: EngagementAlert(AudibleAlert.none), + }, + + EventNameSP.silentBrakeHold: { + ET.WARNING: EngagementAlert(AudibleAlert.none), + ET.NO_ENTRY: NoEntryAlert("Brake Hold Active"), + }, + + EventNameSP.silentWrongGear: { + ET.WARNING: Alert( + "", + "", + AlertStatus.normal, AlertSize.none, + Priority.LOWEST, VisualAlert.none, AudibleAlert.none, 0.), + ET.NO_ENTRY: Alert( + "Gear not D", + "openpilot Unavailable", + AlertStatus.normal, AlertSize.mid, + Priority.LOW, VisualAlert.none, AudibleAlert.none, 0.), + }, + + EventNameSP.silentReverseGear: { + ET.PERMANENT: Alert( + "Reverse\nGear", + "", + AlertStatus.normal, AlertSize.full, + Priority.LOWEST, VisualAlert.none, AudibleAlert.none, .2, creation_delay=0.5), + ET.NO_ENTRY: NoEntryAlert("Reverse Gear"), + }, + + EventNameSP.silentDoorOpen: { + ET.WARNING: Alert( + "", + "", + AlertStatus.normal, AlertSize.none, + Priority.LOWEST, VisualAlert.none, AudibleAlert.none, 0.), + ET.NO_ENTRY: NoEntryAlert("Door Open"), + }, + + EventNameSP.silentSeatbeltNotLatched: { + ET.WARNING: Alert( + "", + "", + AlertStatus.normal, AlertSize.none, + Priority.LOWEST, VisualAlert.none, AudibleAlert.none, 0.), + ET.NO_ENTRY: NoEntryAlert("Seatbelt Unlatched"), + }, + + EventNameSP.silentParkBrake: { + ET.WARNING: Alert( + "", + "", + AlertStatus.normal, AlertSize.none, + Priority.LOWEST, VisualAlert.none, AudibleAlert.none, 0.), + ET.NO_ENTRY: NoEntryAlert("Parking Brake Engaged"), + }, + + EventNameSP.controlsMismatchLateral: { + ET.IMMEDIATE_DISABLE: ImmediateDisableAlert("Controls Mismatch: Lateral"), + ET.NO_ENTRY: NoEntryAlert("Controls Mismatch: Lateral"), + }, + + EventNameSP.experimentalModeSwitched: { + ET.WARNING: NormalPermanentAlert("Experimental Mode Switched", duration=1.5) + }, + + EventNameSP.wrongCarModeAlertOnly: { + ET.WARNING: wrong_car_mode_alert, + }, + + EventNameSP.pedalPressedAlertOnly: { + ET.WARNING: NoEntryAlert("Pedal Pressed") + }, + + EventNameSP.laneTurnLeft: { + ET.WARNING: Alert( + "Turning Left", + "", + AlertStatus.normal, AlertSize.small, + Priority.LOW, VisualAlert.none, AudibleAlert.none, 1.), + }, + + EventNameSP.laneTurnRight: { + ET.WARNING: Alert( + "Turning Right", + "", + AlertStatus.normal, AlertSize.small, + Priority.LOW, VisualAlert.none, AudibleAlert.none, 1.), + }, + + EventNameSP.speedLimitActive: { + ET.WARNING: Alert( + "Auto adjusting to speed limit", + "", + AlertStatus.normal, AlertSize.small, + Priority.LOW, VisualAlert.none, AudibleAlertSP.promptSingleHigh, 5.), + }, + + EventNameSP.speedLimitChanged: { + ET.WARNING: Alert( + "Set speed changed", + "", + AlertStatus.normal, AlertSize.small, + Priority.LOW, VisualAlert.none, AudibleAlertSP.promptSingleHigh, 5.), + }, + + EventNameSP.speedLimitPreActive: { + ET.WARNING: speed_limit_pre_active_alert, + }, + + EventNameSP.speedLimitPending: { + ET.WARNING: Alert( + "Auto adjusting to last speed limit", + "", + AlertStatus.normal, AlertSize.small, + Priority.LOW, VisualAlert.none, AudibleAlertSP.promptSingleHigh, 5.), + }, + + EventNameSP.e2eChime: { + ET.PERMANENT: Alert( + "", + "", + AlertStatus.normal, AlertSize.none, + Priority.MID, VisualAlert.none, AudibleAlert.prompt, 3.), + }, +} diff --git a/sunnypilot/selfdrive/selfdrived/events_base.py b/sunnypilot/selfdrive/selfdrived/events_base.py new file mode 100644 index 0000000000..5f9f8edf94 --- /dev/null +++ b/sunnypilot/selfdrive/selfdrived/events_base.py @@ -0,0 +1,243 @@ +import bisect +from enum import IntEnum +from abc import abstractmethod +from collections.abc import Callable + +from cereal import log, car +import cereal.messaging as messaging +from openpilot.common.realtime import DT_CTRL +from openpilot.system.hardware import HARDWARE + +AlertSize = log.SelfdriveState.AlertSize +AlertStatus = log.SelfdriveState.AlertStatus +VisualAlert = car.CarControl.HUDControl.VisualAlert +AudibleAlert = car.CarControl.HUDControl.AudibleAlert + + +# Alert priorities +class Priority(IntEnum): + LOWEST = 0 + LOWER = 1 + LOW = 2 + MID = 3 + HIGH = 4 + HIGHEST = 5 + + +# Event types +class ET: + ENABLE = 'enable' + PRE_ENABLE = 'preEnable' + OVERRIDE_LATERAL = 'overrideLateral' + OVERRIDE_LONGITUDINAL = 'overrideLongitudinal' + NO_ENTRY = 'noEntry' + WARNING = 'warning' + USER_DISABLE = 'userDisable' + SOFT_DISABLE = 'softDisable' + IMMEDIATE_DISABLE = 'immediateDisable' + PERMANENT = 'permanent' + + +class Alert: + def __init__(self, + alert_text_1: str, + alert_text_2: str, + alert_status: log.SelfdriveState.AlertStatus, + alert_size: log.SelfdriveState.AlertSize, + priority: Priority, + visual_alert: car.CarControl.HUDControl.VisualAlert, + audible_alert: car.CarControl.HUDControl.AudibleAlert, + duration: float, + creation_delay: float = 0.): + + self.alert_text_1 = alert_text_1 + self.alert_text_2 = alert_text_2 + self.alert_status = alert_status + self.alert_size = alert_size + self.priority = priority + self.visual_alert = visual_alert + self.audible_alert = audible_alert + + self.duration = int(duration / DT_CTRL) + + self.creation_delay = creation_delay + + self.alert_type = "" + self.event_type: str | None = None + + def __str__(self) -> str: + return f"{self.alert_text_1}/{self.alert_text_2} {self.priority} {self.visual_alert} {self.audible_alert}" + + def __gt__(self, alert2) -> bool: + if not isinstance(alert2, Alert): + return False + return self.priority > alert2.priority + +class AlertBase(Alert): + def __init__(self, alert_text_1: str, alert_text_2: str, alert_status: log.SelfdriveState.AlertStatus, + alert_size: log.SelfdriveState.AlertSize, priority: Priority, + visual_alert: car.CarControl.HUDControl.VisualAlert, + audible_alert: car.CarControl.HUDControl.AudibleAlert, duration: float): + super().__init__(alert_text_1, alert_text_2, alert_status, alert_size, priority, visual_alert, audible_alert, duration) + + +AlertCallbackType = Callable[[car.CarParams, car.CarState, messaging.SubMaster, bool, int, log.ControlsState], Alert] + + +# ********** alert callback functions ********** + + +def wrong_car_mode_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality) -> Alert: + text = "Enable Adaptive Cruise to Engage" + if CP.brand == "honda": + text = "Enable Main Switch to Engage" + return NoEntryAlert(text) + + +class EventsBase: + def __init__(self): + self.events: list[int] = [] + self.static_events: list[int] = [] + self.event_counters = {} + + @property + def names(self) -> list[int]: + return self.events + + def __len__(self) -> int: + return len(self.events) + + def add(self, event_name: int, static: bool = False) -> None: + if static: + bisect.insort(self.static_events, event_name) + bisect.insort(self.events, event_name) + + def clear(self) -> None: + self.event_counters = {k: (v + 1 if k in self.events else 0) for k, v in self.event_counters.items()} + self.events = self.static_events.copy() + + def contains(self, event_type: str) -> bool: + return any(event_type in self.get_events_mapping().get(e, {}) for e in self.events) + + def create_alerts(self, event_types: list[str], callback_args=None): + if callback_args is None: + callback_args = [] + + ret = [] + for e in self.events: + types = self.get_events_mapping()[e].keys() + for et in event_types: + if et in types: + alert = self.get_events_mapping()[e][et] + if not isinstance(alert, Alert): + alert = alert(*callback_args) + + if DT_CTRL * (self.event_counters[e] + 1) >= alert.creation_delay: + alert.alert_type = f"{self.get_event_name(e)}/{et}" + alert.event_type = et + ret.append(alert) + return ret + + def add_from_msg(self, events): + for e in events: + bisect.insort(self.events, e.name.raw) + + def to_msg(self): + ret = [] + for event_name in self.events: + event = self.get_event_msg_type().new_message() + event.name = event_name + for event_type in self.get_events_mapping().get(event_name, {}): + setattr(event, event_type, True) + ret.append(event) + return ret + + def has(self, event_name: int) -> bool: + return event_name in self.events + + def contains_in_list(self, events_list: list[int]) -> bool: + return any(event_name in self.events for event_name in events_list) + + def remove(self, event_name: int, static: bool = False) -> None: + if static and event_name in self.static_events: + self.static_events.remove(event_name) + + if event_name in self.events: + self.event_counters[event_name] = self.event_counters[event_name] + 1 + self.events.remove(event_name) + + @abstractmethod + def get_events_mapping(self) -> dict[int, dict[str, Alert | AlertCallbackType]]: + raise NotImplementedError + + @abstractmethod + def get_event_name(self, event: int) -> str: + raise NotImplementedError + + @abstractmethod + def get_event_msg_type(self): + raise NotImplementedError + + +EmptyAlert = Alert("" , "", AlertStatus.normal, AlertSize.none, Priority.LOWEST, + VisualAlert.none, AudibleAlert.none, 0) + +class NoEntryAlert(Alert): + def __init__(self, alert_text_2: str, + alert_text_1: str = "openpilot Unavailable", + visual_alert: car.CarControl.HUDControl.VisualAlert=VisualAlert.none): + if HARDWARE.get_device_type() == 'mici': + alert_text_1, alert_text_2 = alert_text_2, alert_text_1 + super().__init__(alert_text_1, alert_text_2, AlertStatus.normal, + AlertSize.mid, Priority.LOW, visual_alert, + AudibleAlert.refuse, 3.) + + +class SoftDisableAlert(Alert): + def __init__(self, alert_text_2: str): + super().__init__("TAKE CONTROL IMMEDIATELY", alert_text_2, + AlertStatus.userPrompt, AlertSize.full, + Priority.MID, VisualAlert.steerRequired, + AudibleAlert.warningSoft, 2.), + + +# less harsh version of SoftDisable, where the condition is user-triggered +class UserSoftDisableAlert(SoftDisableAlert): + def __init__(self, alert_text_2: str): + super().__init__(alert_text_2), + self.alert_text_1 = "openpilot will disengage" + + +class ImmediateDisableAlert(Alert): + def __init__(self, alert_text_2: str): + super().__init__("TAKE CONTROL IMMEDIATELY", alert_text_2, + AlertStatus.critical, AlertSize.full, + Priority.HIGHEST, VisualAlert.steerRequired, + AudibleAlert.warningImmediate, 4.), + + +class EngagementAlert(Alert): + def __init__(self, audible_alert: car.CarControl.HUDControl.AudibleAlert): + super().__init__("", "", + AlertStatus.normal, AlertSize.none, + Priority.MID, VisualAlert.none, + audible_alert, .2), + + +class NormalPermanentAlert(Alert): + def __init__(self, alert_text_1: str, alert_text_2: str = "", duration: float = 0.2, priority: Priority = Priority.LOWER, creation_delay: float = 0.): + super().__init__(alert_text_1, alert_text_2, + AlertStatus.normal, AlertSize.mid if len(alert_text_2) else AlertSize.small, + priority, VisualAlert.none, AudibleAlert.none, duration, creation_delay=creation_delay), + + +class StartupAlert(Alert): + def __init__(self, alert_text_1: str, alert_text_2: str = "Always keep hands on wheel and eyes on road", alert_status=AlertStatus.normal): + alert_size = AlertSize.mid + if HARDWARE.get_device_type() == 'mici': + if alert_text_2 == "Always keep hands on wheel and eyes on road": + alert_text_2 = "" + alert_size = AlertSize.small + super().__init__(alert_text_1, alert_text_2, + alert_status, alert_size, + Priority.LOWER, VisualAlert.none, AudibleAlert.none, 5.), diff --git a/sunnypilot/selfdrive/ui/quiet_mode.py b/sunnypilot/selfdrive/ui/quiet_mode.py new file mode 100644 index 0000000000..739ea1392c --- /dev/null +++ b/sunnypilot/selfdrive/ui/quiet_mode.py @@ -0,0 +1,40 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from cereal import car + +from openpilot.common.params import Params + +AudibleAlert = car.CarControl.HUDControl.AudibleAlert + +ALERTS_ALWAYS_PLAY = { + AudibleAlert.warningSoft, + AudibleAlert.warningImmediate, + AudibleAlert.promptDistracted, + AudibleAlert.promptRepeat, +} + + +class QuietMode: + def __init__(self): + self.params = Params() + self.enabled: bool = self.params.get_bool("QuietMode") + self._frame = 0 + + def load_param(self) -> None: + self._frame += 1 + if self._frame % 50 == 0: # 2.5 seconds + self.enabled = self.params.get_bool("QuietMode") + + def should_play_sound(self, current_alert: int) -> bool: + """ + Check if a sound should be played based on the Quiet Mode setting + and the current alert. + """ + if not self.enabled: + return bool(current_alert != AudibleAlert.none) + + return current_alert in ALERTS_ALWAYS_PLAY diff --git a/sunnypilot/sunnylink/__init__.py b/sunnypilot/sunnylink/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sunnypilot/sunnylink/api.py b/sunnypilot/sunnylink/api.py new file mode 100644 index 0000000000..bda97262cb --- /dev/null +++ b/sunnypilot/sunnylink/api.py @@ -0,0 +1,155 @@ +import json +import os +import random +import time +import jwt +from typing import cast +from datetime import datetime, timedelta, UTC + +from openpilot.common.api.base import BaseApi +from openpilot.common.params import Params +from openpilot.system.hardware import HARDWARE +from openpilot.system.hardware.hw import Paths + +API_HOST = os.getenv('SUNNYLINK_API_HOST', 'https://stg.api.sunnypilot.ai') +UNREGISTERED_SUNNYLINK_DONGLE_ID = "UnregisteredDevice" +MAX_RETRIES = 6 +CRASH_LOG_DIR = Paths.crash_log_root() + + +class SunnylinkApi(BaseApi): + def __init__(self, dongle_id): + super().__init__(dongle_id, API_HOST) + self.user_agent = "sunnypilot-" + self.spinner = None + self.params = Params() + + def api_get(self, endpoint, method='GET', timeout=10, access_token=None, session=None, json=None, **kwargs): + if not self.params.get_bool("SunnylinkEnabled"): + return None + + return super().api_get(endpoint, method, timeout, access_token, session, json, **kwargs) + + def resume_queued(self, timeout=10, **kwargs): + sunnylinkId, commaId = self._resolve_dongle_ids() + return self.api_get(f"ws/{sunnylinkId}/resume_queued", "POST", timeout, access_token=self.get_token(), **kwargs) + + def get_token(self, payload_extra=None, expiry_hours=1): + # Add your additional data here + additional_data = {} + return super()._get_token(payload_extra, expiry_hours, **additional_data) + + def _status_update(self, message): + print(message) + if self.spinner: + self.spinner.update(message) + time.sleep(0.5) + + def _resolve_dongle_ids(self): + sunnylink_dongle_id = self.params.get("SunnylinkDongleId") + comma_dongle_id = self.dongle_id or self.params.get("DongleId") + return sunnylink_dongle_id, comma_dongle_id + + def _resolve_imeis(self): + imei1, imei2 = None, None + imei_try = 0 + while imei1 is None and imei2 is None and imei_try < MAX_RETRIES: + try: + imei1, imei2 = HARDWARE.get_imei(0), HARDWARE.get_imei(1) + except Exception: + self._status_update(f"Error getting imei, trying again... [{imei_try + 1}/{MAX_RETRIES}]") + time.sleep(1) + imei_try += 1 + return imei1, imei2 + + def _resolve_serial(self): + return (self.params.get("HardwareSerial") + or HARDWARE.get_serial()) + + def register_device(self, spinner=None, timeout=60, verbose=False): + self.spinner = spinner + + sunnylink_dongle_id, comma_dongle_id = self._resolve_dongle_ids() + + if comma_dongle_id is None: + self._status_update("Comma dongle ID not found, deferring sunnylink's registration to comma's registration process.") + return None + + imei1, imei2 = self._resolve_imeis() + serial = self._resolve_serial() + + if sunnylink_dongle_id not in (None, UNREGISTERED_SUNNYLINK_DONGLE_ID): + return sunnylink_dongle_id + + jwt_algo, private_key, public_key = BaseApi.get_key_pair() + + start_time = time.monotonic() + successful_registration = False + if not public_key: + sunnylink_dongle_id = UNREGISTERED_SUNNYLINK_DONGLE_ID + self._status_update("Public key not found, setting dongle ID to unregistered.") + else: + Params().put("LastSunnylinkPingTime", 0) # Reset the last ping time to 0 if we are trying to register + + backoff = 1 + while True: + register_token = jwt.encode({'register': True, 'exp': datetime.now(UTC).replace(tzinfo=None) + timedelta(hours=1)}, + cast(str, private_key), algorithm=jwt_algo) + try: + if verbose or time.monotonic() - start_time < timeout / 2: + self._status_update("Registering device to sunnylink...") + elif time.monotonic() - start_time >= timeout / 2: + self._status_update("Still registering device to sunnylink...") + + resp = self.api_get("v2/pilotauth/", method='POST', timeout=15, imei=imei1, imei2=imei2, serial=serial, + comma_dongle_id=comma_dongle_id, public_key=public_key, register_token=register_token) + + if resp is None: + raise Exception("Unable to register device, request was None") + + if resp.status_code in (409, 412): + timeout = time.monotonic() - start_time # Don't retry if the public key is already in use + key_in_use = "Public key is already in use, is your key unique? Contact your vendor for a new key." + unsafe_key = "Public key is known to not be unique and it's unsafe. Contact your vendor for a new key." + error_message = key_in_use if resp.status_code == 409 else unsafe_key + raise Exception(error_message) + + if resp.status_code != 200: + raise Exception(f"Failed to register with sunnylink. Status code: {resp.status_code}\nData\n:{resp.text}") + + dongleauth = json.loads(resp.text) + sunnylink_dongle_id = dongleauth["device_id"] + if sunnylink_dongle_id: + self._status_update("Device registered successfully.") + successful_registration = True + break + except Exception as e: + if verbose: + self._status_update(f"Waiting {backoff}s before retry, Exception occurred during registration: [{str(e)}]") + + if not os.path.exists(CRASH_LOG_DIR): + os.makedirs(CRASH_LOG_DIR) + + with open(f'{CRASH_LOG_DIR}/error.txt', 'a') as f: + f.write(f"[{datetime.now()}] sunnylink: {str(e)}\n") + + backoff = min(backoff * 2 * (0.5 + random.random()), 60) + time.sleep(backoff) + + if time.monotonic() - start_time > timeout: + self._status_update(f"Giving up on sunnylink's registration after {timeout}s. Will retry on next boot.") + time.sleep(3) + break + + self.params.put("SunnylinkDongleId", sunnylink_dongle_id or UNREGISTERED_SUNNYLINK_DONGLE_ID) + + # Set the last ping time to the current time since we were just talking to the API + last_ping = int((time.monotonic() if successful_registration else start_time) * 1e9) + Params().put("LastSunnylinkPingTime", last_ping) + + # Disable sunnylink if registration was not successful + if not successful_registration: + Params().put_bool("SunnylinkEnabled", False) + + self.spinner = None + return sunnylink_dongle_id diff --git a/sunnypilot/sunnylink/athena/__init__.py b/sunnypilot/sunnylink/athena/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sunnypilot/sunnylink/athena/manage_sunnylinkd.py b/sunnypilot/sunnylink/athena/manage_sunnylinkd.py new file mode 100755 index 0000000000..377b6990f7 --- /dev/null +++ b/sunnypilot/sunnylink/athena/manage_sunnylinkd.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 +from openpilot.system.athena.manage_athenad import manage_athenad + +if __name__ == '__main__': + manage_athenad("SunnylinkDongleId", "SunnylinkdPid", 'sunnylinkd', 'sunnypilot.sunnylink.athena.sunnylinkd') diff --git a/sunnypilot/sunnylink/athena/sunnylinkd.py b/sunnypilot/sunnylink/athena/sunnylinkd.py new file mode 100755 index 0000000000..39fe5679ce --- /dev/null +++ b/sunnypilot/sunnylink/athena/sunnylinkd.py @@ -0,0 +1,386 @@ +#!/usr/bin/env python3 +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from __future__ import annotations + +import base64 +import errno +import gzip +import json +import os +import ssl +import threading +import time + +from jsonrpc import dispatcher +from functools import partial +from openpilot.common.params import Params +from openpilot.common.realtime import set_core_affinity +from openpilot.common.swaglog import cloudlog +from openpilot.system.hardware.hw import Paths +from openpilot.system.athena.athenad import ws_send, jsonrpc_handler, \ + recv_queue, UploadQueueCache, upload_queue, cur_upload_items, backoff, ws_manage, log_handler, start_local_proxy_shim, upload_handler, stat_handler +from websocket import (ABNF, WebSocket, WebSocketException, WebSocketTimeoutException, + create_connection, WebSocketConnectionClosedException) + +import cereal.messaging as messaging +from openpilot.sunnypilot.selfdrive.car.sync_car_list_param import update_car_list_param +from openpilot.sunnypilot.sunnylink.api import SunnylinkApi +from openpilot.sunnypilot.sunnylink.utils import sunnylink_need_register, sunnylink_ready, get_param_as_byte, save_param_from_base64_encoded_string + +SUNNYLINK_ATHENA_HOST = os.getenv('SUNNYLINK_ATHENA_HOST', 'wss://ws.stg.api.sunnypilot.ai') +HANDLER_THREADS = int(os.getenv('HANDLER_THREADS', "4")) +LOCAL_PORT_WHITELIST = {8022} +SUNNYLINK_LOG_ATTR_NAME = "user.sunny.upload" +SUNNYLINK_RECONNECT_TIMEOUT_S = 70 # FYI changing this will also would require a change on sidebar.cc +DISALLOW_LOG_UPLOAD = threading.Event() +METADATA_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "params_metadata.json") + +params = Params() + +# Parameters that should never be remotely modified +BLOCKED_PARAMS = { + "CompletedSunnylinkConsentVersion", + "CompletedTrainingVersion", + "GithubUsername", # Could grant SSH access + "GithubSshKeys", # Direct SSH key injection + "HasAcceptedTerms", + "HasAcceptedTermsSP", +} + + +def handle_long_poll(ws: WebSocket, exit_event: threading.Event | None) -> None: + cloudlog.info("sunnylinkd.handle_long_poll started") + sm = messaging.SubMaster(['deviceState']) + end_event = threading.Event() + comma_prime_cellular_end_event = threading.Event() + + threads = [ + threading.Thread(target=ws_manage, args=(ws, end_event), name='ws_manage'), + threading.Thread(target=ws_recv, args=(ws, end_event), name='ws_recv'), + threading.Thread(target=ws_send, args=(ws, end_event), name='ws_send'), + threading.Thread(target=ws_ping, args=(ws, end_event), name='ws_ping'), + threading.Thread(target=ws_queue, args=(end_event,), name='ws_queue'), + threading.Thread(target=upload_handler, args=(end_event,), name='upload_handler'), + threading.Thread(target=sunny_log_handler, args=(end_event, comma_prime_cellular_end_event), name='log_handler'), + threading.Thread(target=stat_handler, args=(end_event, Paths.stats_sp_root(), True), name='stat_handler'), + ] + [ + threading.Thread(target=jsonrpc_handler, args=(end_event, partial(startLocalProxy, end_event),), name=f'worker_{x}') + for x in range(HANDLER_THREADS) + ] + + for thread in threads: + thread.start() + try: + while not end_event.wait(0.1): + if not sunnylink_ready(params): + cloudlog.warning("Exiting sunnylinkd.handle_long_poll as SunnylinkEnabled is False") + break + + sm.update(0) + if exit_event is not None and exit_event.is_set(): + end_event.set() + comma_prime_cellular_end_event.set() + + prime_type = params.get("PrimeType") or 0 + metered = sm['deviceState'].networkMetered + + if DISALLOW_LOG_UPLOAD.is_set() and not comma_prime_cellular_end_event.is_set(): + cloudlog.debug("sunnylinkd.handle_long_poll: DISALLOW_LOG_UPLOAD, setting comma_prime_cellular_end_event") + comma_prime_cellular_end_event.set() + elif metered and int(prime_type) > 2: + cloudlog.debug(f"sunnylinkd.handle_long_poll: PrimeType({prime_type}) > 2 and networkMetered({metered})") + comma_prime_cellular_end_event.set() + elif comma_prime_cellular_end_event.is_set() and not DISALLOW_LOG_UPLOAD.is_set(): + cloudlog.debug( + f"sunnylinkd.handle_long_poll: comma_prime_cellular_end_event is set and not PrimeType({prime_type}) > 2 or not networkMetered({metered})") + comma_prime_cellular_end_event.clear() + finally: + end_event.set() + comma_prime_cellular_end_event.set() + for thread in threads: + cloudlog.debug(f"sunnylinkd athena.joining {thread.name}") + thread.join() + cloudlog.debug(f"sunnylinkd athena.joined {thread.name}") + + +def ws_recv(ws: WebSocket, end_event: threading.Event) -> None: + last_ping = int(time.monotonic() * 1e9) + while not end_event.is_set(): + try: + opcode, data = ws.recv_data(control_frame=True) + if opcode in (ABNF.OPCODE_TEXT, ABNF.OPCODE_BINARY): + if opcode == ABNF.OPCODE_TEXT: + data = data.decode("utf-8") + recv_queue.put_nowait(data) + cloudlog.debug(f"sunnylinkd.ws_recv.recv {data}") + elif opcode in (ABNF.OPCODE_PING, ABNF.OPCODE_PONG): + cloudlog.debug("sunnylinkd.ws_recv.pong") + last_ping = int(time.monotonic() * 1e9) + Params().put("LastSunnylinkPingTime", last_ping) + except WebSocketTimeoutException: + ns_since_last_ping = int(time.monotonic() * 1e9) - last_ping + if ns_since_last_ping > SUNNYLINK_RECONNECT_TIMEOUT_S * 1e9: + cloudlog.warning("sunnylinkd.ws_recv.timeout") + end_event.set() + except Exception as e: + if isinstance(e, WebSocketConnectionClosedException): + cloudlog.warning(f"sunnylinkd.ws_recv.{type(e).__name__}") + else: + cloudlog.exception("sunnylinkd.ws_recv.exception") + end_event.set() + + +def ws_ping(ws: WebSocket, end_event: threading.Event) -> None: + ws.ping() # Send the first ping + while not end_event.wait(SUNNYLINK_RECONNECT_TIMEOUT_S * 0.7): # Sleep about 70% before a timeout + try: + ws.ping() + cloudlog.debug("sunnylinkd.ws_recv.ws_ping: Pinging") + except Exception: + cloudlog.exception("sunnylinkd.ws_ping.exception") + end_event.set() + cloudlog.debug("sunnylinkd.ws_ping.end_event is set, exiting ws_ping thread") + + +def ws_queue(end_event: threading.Event) -> None: + sunnylink_dongle_id = params.get("SunnylinkDongleId") + sunnylink_api = SunnylinkApi(sunnylink_dongle_id) + resume_requested = False + tries = 0 + + while not end_event.is_set() and not resume_requested: + try: + if not resume_requested: + cloudlog.debug("sunnylinkd.ws_queue.resume_queued") + sunnylink_api.resume_queued(timeout=29) + resume_requested = True + tries = 0 + except Exception as e: + if isinstance(e, (ConnectionError, TimeoutError)): + cloudlog.warning(f"sunnylinkd.ws_queue.resume_queued.{type(e).__name__}") + else: + cloudlog.exception("sunnylinkd.ws_queue.resume_queued.exception") + + resume_requested = False + tries += 1 + time.sleep(backoff(tries)) + + if end_event.is_set(): + cloudlog.debug("end_event is set, exiting ws_queue thread") + elif resume_requested: + cloudlog.debug(f"Resume requested to server after {tries} tries") + else: + cloudlog.error(f"Reached end of ws_queue while end_event is not set and resume_requested is {resume_requested}") + + +def sunny_log_handler(end_event: threading.Event, comma_prime_cellular_end_event: threading.Event) -> None: + while not end_event.wait(0.1): + if not comma_prime_cellular_end_event.is_set(): + log_handler(comma_prime_cellular_end_event, SUNNYLINK_LOG_ATTR_NAME) + comma_prime_cellular_end_event.set() + + +@dispatcher.add_method +def toggleLogUpload(enabled: bool): + DISALLOW_LOG_UPLOAD.clear() if enabled and DISALLOW_LOG_UPLOAD.is_set() else DISALLOW_LOG_UPLOAD.set() + + +@dispatcher.add_method +def getParamsAllKeys() -> list[str]: + keys: list[str] = [k.decode('utf-8') for k in Params().all_keys()] + return keys + + +@dispatcher.add_method +def getParamsAllKeysV1() -> dict[str, str]: + try: + with open(METADATA_PATH) as f: + metadata = json.load(f) + except Exception: + cloudlog.exception("sunnylinkd.getParamsAllKeysV1.metadata.exception") + metadata = {} + + try: + available_keys: list[str] = [k.decode('utf-8') for k in Params().all_keys()] + + params_dict: dict[str, list[dict[str, str | bool | int | object | dict | None]]] = {"params": []} + for key in available_keys: + value = get_param_as_byte(key, get_default=True) + + param_entry = { + "key": key, + "type": int(params.get_type(key).value), + "default_value": base64.b64encode(value).decode('utf-8') if value else None, + } + + if key in metadata: + meta_copy = metadata[key].copy() + param_entry["_extra"] = meta_copy + + params_dict["params"].append(param_entry) + return {"keys": json.dumps(params_dict.get("params", []))} + except Exception: + cloudlog.exception("sunnylinkd.getParamsAllKeysV1.exception") + raise + + +@dispatcher.add_method +def getParamsMetadata() -> str: + """Compressed equivalent of getParamsAllKeysV1 — same struct, gzipped + base64.""" + try: + with open(METADATA_PATH) as f: + metadata = json.load(f) + except Exception: + cloudlog.exception("sunnylinkd.getParamsMetadata.exception") + metadata = {} + + try: + available_keys: list[str] = [k.decode('utf-8') for k in Params().all_keys()] + + params_list: list[dict] = [] + for key in available_keys: + value = get_param_as_byte(key, get_default=True) + + param_entry: dict = { + "key": key, + "type": int(params.get_type(key).value), + "default_value": base64.b64encode(value).decode('utf-8') if value else None, + } + + if key in metadata: + param_entry["_extra"] = metadata[key] + + params_list.append(param_entry) + + raw = json.dumps(params_list, separators=(',', ':')).encode('utf-8') + return base64.b64encode(gzip.compress(raw)).decode('utf-8') + except Exception: + cloudlog.exception("sunnylinkd.getParamsMetadata.exception") + raise + + +@dispatcher.add_method +def getParams(params_keys: list[str], compression: bool = False) -> str | dict[str, str]: + params = Params() + available_keys: list[str] = [k.decode('utf-8') for k in Params().all_keys()] + + try: + param_keys_validated = [key for key in params_keys if key in available_keys] + params_dict: dict[str, list[dict[str, str | bool | int]]] = {"params": []} + for key in param_keys_validated: + value = get_param_as_byte(key) + if value is None: + continue + + params_dict["params"].append({ + "key": key, + "value": base64.b64encode(gzip.compress(value) if compression else value).decode('utf-8'), + "type": int(params.get_type(key).value), + "is_compressed": compression + }) + + response = {str(param.get('key')): str(param.get('value')) for param in params_dict.get("params", [])} + response |= {"params": json.dumps(params_dict.get("params", []))} # Upcoming for settings v1 + return response + + except Exception as e: + cloudlog.exception("sunnylinkd.getParams.exception", e) + raise + + +@dispatcher.add_method +def saveParams(params_to_update: dict[str, str], compression: bool = False) -> None: + for key, value in params_to_update.items(): + # disallow modifications to blocked parameters + if key in BLOCKED_PARAMS: + cloudlog.warning(f"sunnylinkd.saveParams.blocked: Attempted to modify blocked parameter '{key}'") + continue + + try: + save_param_from_base64_encoded_string(key, value, compression) + except Exception as e: + cloudlog.error(f"sunnylinkd.saveParams.exception {e}") + + +def startLocalProxy(global_end_event: threading.Event, remote_ws_uri: str, local_port: int) -> dict[str, int]: + sunnylink_dongle_id = params.get("SunnylinkDongleId") + sunnylink_api = SunnylinkApi(sunnylink_dongle_id) + + cloudlog.debug("athena.startLocalProxy.starting") + ws = create_connection( + remote_ws_uri, header={"Authorization": f"Bearer {sunnylink_api.get_token()}"}, enable_multithread=True, sslopt={"cert_reqs": ssl.CERT_NONE} + ) + + return start_local_proxy_shim(global_end_event, local_port, ws) + + +def main(exit_event: threading.Event | None = None): + try: + set_core_affinity([0, 1, 2, 3]) + except Exception: + cloudlog.exception("failed to set core affinity") + + while sunnylink_need_register(params): + cloudlog.info("Waiting for sunnylink registration to complete") + time.sleep(10) + + sunnylink_dongle_id = params.get("SunnylinkDongleId") + sunnylink_api = SunnylinkApi(sunnylink_dongle_id) + UploadQueueCache.initialize(upload_queue) + + update_car_list_param() + + ws_uri = f"{SUNNYLINK_ATHENA_HOST}" + conn_start = None + conn_retries = 0 + while (exit_event is None or not exit_event.is_set()) and sunnylink_ready(params): + try: + if conn_start is None: + conn_start = time.monotonic() + + cloudlog.event("sunnylinkd.main.connecting_ws", ws_uri=ws_uri, retries=conn_retries) + ws = create_connection( + ws_uri, + header={"Authorization": f"Bearer {sunnylink_api.get_token()}"}, + enable_multithread=True, + sslopt={"cert_reqs": ssl.CERT_NONE if "localhost" in ws_uri else ssl.CERT_REQUIRED}, + timeout=SUNNYLINK_RECONNECT_TIMEOUT_S, + ) + cloudlog.event("sunnylinkd.main.connected_ws", ws_uri=ws_uri, retries=conn_retries, + duration=time.monotonic() - conn_start) + conn_start = None + + conn_retries = 0 + cur_upload_items.clear() + + handle_long_poll(ws, exit_event) + except (KeyboardInterrupt, SystemExit): + break + except Exception as e: + conn_retries += 1 + params.remove("LastSunnylinkPingTime") + + if isinstance(e, (ConnectionError, TimeoutError, WebSocketException)): + cloudlog.warning(f"sunnylinkd.main.{type(e).__name__}") + elif isinstance(e, OSError): + name = errno.errorcode.get(e.errno or -1, "UNKNOWN") + msg = f"sunnylinkd.main.OSError.{name} ({e.errno})" + is_expected_error = e.errno in (errno.ENETDOWN, errno.ENETRESET, errno.ENETUNREACH) + cloudlog.warning(msg) if is_expected_error else cloudlog.exception(msg) + else: + cloudlog.exception("sunnylinkd.main.exception") + + time.sleep(backoff(conn_retries)) + + if not sunnylink_ready(params): + cloudlog.debug("Reached end of sunnylinkd.main while sunnylink is not ready. Waiting 60s before retrying") + time.sleep(60) + + +if __name__ == "__main__": + main() diff --git a/sunnypilot/sunnylink/athena/tests/test_sunnylinkd.py b/sunnypilot/sunnylink/athena/tests/test_sunnylinkd.py new file mode 100644 index 0000000000..616bff037e --- /dev/null +++ b/sunnypilot/sunnylink/athena/tests/test_sunnylinkd.py @@ -0,0 +1,59 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from openpilot.sunnypilot.sunnylink.athena import sunnylinkd + + +class TestSunnylinkdMethods: + def setup_method(self): + self.saved_params = [] + + self.original_save = sunnylinkd.save_param_from_base64_encoded_string + + def mock_save_param(key, value, compression=False): + self.saved_params.append((key, value, compression)) + + sunnylinkd.save_param_from_base64_encoded_string = mock_save_param + + def teardown_method(self): + sunnylinkd.save_param_from_base64_encoded_string = self.original_save + + def test_saveParams_blocked(self): + blocked_params = { + "GithubUsername": "attacker", + "GithubSshKeys": "ssh-rsa attacker_key", + } + + sunnylinkd.saveParams(blocked_params) + + assert len(self.saved_params) == 0 + + def test_saveParams_allowed(self): + allowed_params = { + "SpeedLimitOffset": "5", + "MyCustomParam": "123" + } + + sunnylinkd.saveParams(allowed_params) + + # verify content + assert len(self.saved_params) == 2 + keys_saved = [p[0] for p in self.saved_params] + assert "SpeedLimitOffset" in keys_saved + assert "MyCustomParam" in keys_saved + + def test_saveParams_mixed(self): + mixed_params = { + "GithubUsername": "attacker", + "SpeedLimitOffset": "10" + } + + sunnylinkd.saveParams(mixed_params) + + # should save allowed one + assert len(self.saved_params) == 1 + assert self.saved_params[0][0] == "SpeedLimitOffset" + assert self.saved_params[0][1] == "10" diff --git a/sunnypilot/sunnylink/backups/AESCipher.py b/sunnypilot/sunnylink/backups/AESCipher.py new file mode 100644 index 0000000000..36cb149bba --- /dev/null +++ b/sunnypilot/sunnylink/backups/AESCipher.py @@ -0,0 +1,34 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" + +from Crypto.Cipher import AES + + +class AESCipher: + def __init__(self, key: bytes, iv: bytes): + if len(key) not in (16, 32): + raise ValueError("Key must be 16 bytes (AES-128) or 32 bytes (AES-256).") + if len(iv) != 16: + raise ValueError("IV must be 16 bytes.") + + self.key = key + self.iv = iv + + def encrypt(self, data: bytes) -> bytes: + block_size = 16 + padding_length = block_size - (len(data) % block_size) + padding = bytes([padding_length]) * padding_length + padded_data = data + padding + + cipher = AES.new(self.key, AES.MODE_CBC, self.iv) + return cipher.encrypt(padded_data) + + def decrypt(self, encrypted_data: bytes) -> bytes: + cipher = AES.new(self.key, AES.MODE_CBC, self.iv) + decrypted_data = cipher.decrypt(encrypted_data) + padding_length = decrypted_data[-1] + return decrypted_data[:-padding_length] diff --git a/sunnypilot/sunnylink/backups/__init__.py b/sunnypilot/sunnylink/backups/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sunnypilot/sunnylink/backups/manager.py b/sunnypilot/sunnylink/backups/manager.py new file mode 100644 index 0000000000..44cdb3bff2 --- /dev/null +++ b/sunnypilot/sunnylink/backups/manager.py @@ -0,0 +1,286 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" + +import base64 +import json +import requests +import time +from enum import Enum +from typing import Any + +from openpilot.common.git import get_branch +from openpilot.common.params import Params, ParamKeyFlag +from openpilot.common.realtime import Ratekeeper +from openpilot.common.swaglog import cloudlog +from openpilot.system.version import get_version + +from cereal import messaging, custom +from openpilot.sunnypilot.sunnylink.api import SunnylinkApi +from openpilot.sunnypilot.sunnylink.backups.utils import decrypt_compressed_data, encrypt_compressed_data, SnakeCaseEncoder +from openpilot.sunnypilot.sunnylink.utils import get_param_as_byte, save_param_from_base64_encoded_string + + +class OperationType(Enum): + BACKUP = "backup" + RESTORE = "restore" + + +class BackupManagerSP: + """Manages device configuration backups to/from sunnylink""" + + def __init__(self): + self.params = Params() + self.device_id = self.params.get("SunnylinkDongleId") + self.api = SunnylinkApi(self.device_id) + self.pm = messaging.PubMaster(["backupManagerSP"]) + + # Status tracking + self.backup_status = custom.BackupManagerSP.Status.idle + self.restore_status = custom.BackupManagerSP.Status.idle + + # Unified progress & operation type (only one operation runs at a time) + self.progress = 0.0 + self.operation: OperationType | None = None + + self.last_error = "" + self._session = requests.Session() # reuse session to reduce SSL handshake overhead + + def _report_status(self) -> None: + """Reports current backup manager state through the messaging system.""" + msg = messaging.new_message('backupManagerSP', valid=True) + backup_state = msg.backupManagerSP + + backup_state.backupStatus = self.backup_status + backup_state.restoreStatus = self.restore_status + # Both progress fields use the unified progress value + backup_state.backupProgress = self.progress + backup_state.restoreProgress = self.progress + backup_state.lastError = self.last_error + + # Optionally, add a field for operation type if supported: + # backup_state.operationType = self.operation.value if self.operation else "none" + + self.pm.send('backupManagerSP', msg) + + def _update_progress(self, progress: float, op_type: OperationType) -> None: + """Updates the unified progress and operation type, then reports status.""" + self.progress = progress + self.operation = op_type + self._report_status() + + def _collect_config_data(self) -> dict[str, Any]: + """Collects configuration data to be backed up.""" + config_data = {} + params_to_backup = [k.decode('utf-8') for k in self.params.all_keys(ParamKeyFlag.BACKUP)] + for param in params_to_backup: + value = get_param_as_byte(param) + if value is not None: + config_data[param] = base64.b64encode(value).decode('utf-8') + return config_data + + def _get_metadata_value(self, metadata_list, key, default_value=None): + return next((entry.get("value") for entry in metadata_list if entry.get("key") == key), default_value) + + async def create_backup(self) -> bool: + """Creates and uploads a new backup to sunnylink.""" + try: + self.backup_status = custom.BackupManagerSP.Status.inProgress + self._update_progress(0.0, OperationType.BACKUP) + + # Collect configuration data + config_data = self._collect_config_data() + self._update_progress(25.0, OperationType.BACKUP) + + # Serialize and encrypt config data + config_json = json.dumps(config_data) + encrypted_config = encrypt_compressed_data(config_json, use_aes_256=True) + self._update_progress(50.0, OperationType.BACKUP) + + backup_info = custom.BackupManagerSP.BackupInfo() + backup_info.deviceId = self.device_id + backup_info.config = encrypted_config + backup_info.isEncrypted = True + backup_info.createdAt = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime()) + backup_info.updatedAt = backup_info.createdAt + backup_info.sunnypilotVersion = self._get_current_version() + backup_info.backupMetadata = [ + custom.BackupManagerSP.MetadataEntry(key="creator", value="BackupManagerSP"), + custom.BackupManagerSP.MetadataEntry(key="all_values_encoded", value="True"), + custom.BackupManagerSP.MetadataEntry(key="AES", value="256") + ] + + payload = json.loads(json.dumps(backup_info.to_dict(), cls=SnakeCaseEncoder)) + self._update_progress(75.0, OperationType.BACKUP) + + cloudlog.debug(f"Uploading backup with payload: {json.dumps(payload)}") + # Upload to sunnylink + result = self.api.api_get( + f"backup/{self.device_id}", + method='PUT', + access_token=self.api.get_token(), + json=payload, + session=self._session + ) + + if result: + self.backup_status = custom.BackupManagerSP.Status.completed + self._update_progress(100.0, OperationType.BACKUP) + cloudlog.info("Backup successfully created and uploaded") + else: + self.backup_status = custom.BackupManagerSP.Status.failed + self.last_error = "Failed to upload backup" + cloudlog.error(result) + self._report_status() + + return bool(self.backup_status == custom.BackupManagerSP.Status.completed) + + except Exception as e: + cloudlog.exception(f"Error creating backup: {str(e)}") + self.backup_status = custom.BackupManagerSP.Status.failed + self.last_error = str(e) + self._report_status() + return False + + async def restore_backup(self, version: int | None = None) -> bool: + """Restores a backup from sunnylink.""" + try: + self.restore_status = custom.BackupManagerSP.Status.inProgress + self._update_progress(0.0, OperationType.RESTORE) + + # Get backup data from API for the specified version + endpoint = f"backup/{self.device_id}" + f"/{version or ''}" + "?api-version=1" + backup_data = self.api.api_get(endpoint, access_token=self.api.get_token(), session=self._session) + if not backup_data: + raise Exception(f"No backup found for device {self.device_id}") + + self._update_progress(25.0, OperationType.RESTORE) + + data = backup_data.json() + backup_metadata = data.get("backup_metadata", []) + encrypted_config = data.get("config", "") + if not encrypted_config: + raise Exception("Empty backup configuration") + self._update_progress(50.0, OperationType.RESTORE) + + # Decrypt config and load data + use_aes_256 = self._get_metadata_value(backup_metadata, "AES", "128") == "256" + config_json = decrypt_compressed_data(encrypted_config, use_aes_256) + if not config_json: + raise Exception("Failed to decrypt backup configuration") + + config_data = json.loads(config_json) + self._update_progress(75.0, OperationType.RESTORE) + + # Apply configuration + self._apply_config(config_data) + + self.restore_status = custom.BackupManagerSP.Status.completed + self._update_progress(100.0, OperationType.RESTORE) + return True + + except Exception as e: + cloudlog.exception(f"Error restoring backup: {str(e)}") + self.restore_status = custom.BackupManagerSP.Status.failed + self.last_error = str(e) + self._report_status() + return False + + def _apply_config(self, config_data: dict[str, str]) -> None: + """Applies configuration data from a backup, but only for parameters marked as backupable.""" + backupable_params = [k.decode('utf-8') for k in self.params.all_keys(ParamKeyFlag.BACKUP)] + backupable_set_lower = {p.lower() for p in backupable_params} + + restored_count = 0 + skipped_count = 0 + + for param, encoded_value in config_data.items(): + if param.lower() in backupable_set_lower: + # Find real param name (with correct casing) + real_param = next(p for p in backupable_params if p.lower() == param.lower()) + try: + save_param_from_base64_encoded_string(real_param, encoded_value) + restored_count += 1 + except Exception as e: + cloudlog.error(f"Failed to restore param {param}: {str(e)}") + else: + skipped_count += 1 + cloudlog.info(f"Skipped restoring param {param}: not marked for backup in current version") + + cloudlog.info(f"Restore complete: {restored_count} params restored, {skipped_count} params skipped") + + def _get_current_version(self) -> custom.BackupManagerSP.Version: + """Gets current sunnypilot version information.""" + version_obj = custom.BackupManagerSP.Version() + version_str = get_version() + + version_parts = version_str.split('-') # For when version is like "1.2.3-456" + version_nums = version_parts[0].split('.') + + # Extract build number from hyphen format or as 4th version component + build = 0 + if len(version_parts) > 1 and version_parts[1].isdigit(): + build = int(version_parts[1]) + elif len(version_nums) > 3 and version_nums[3].isdigit(): + build = int(version_nums[3]) + + # Set version components with safer defaults + version_obj.major = int(version_nums[0]) if len(version_nums) > 0 and version_nums[0].isdigit() else 0 + version_obj.minor = int(version_nums[1]) if len(version_nums) > 1 and version_nums[1].isdigit() else 0 + version_obj.patch = int(version_nums[2]) if len(version_nums) > 2 and version_nums[2].isdigit() else 0 + version_obj.build = build + version_obj.branch = get_branch() + + return version_obj + + async def main_thread(self) -> None: + """Main thread for backup management.""" + rk = Ratekeeper(1, print_delay_threshold=None) + reset_progress = False + + while True: + try: + if reset_progress: + self.progress = 100.0 + self.operation = None + self.restore_status = custom.BackupManagerSP.Status.idle + self.backup_status = custom.BackupManagerSP.Status.idle + + # Check for backup command + if self.params.get_bool("BackupManager_CreateBackup"): + try: + if await self.create_backup(): + reset_progress = True + finally: + self.params.remove("BackupManager_CreateBackup") + + # Check for restore command + restore_version = self.params.get("BackupManager_RestoreVersion") + if restore_version: + try: + version = int(restore_version) if restore_version.isdigit() else None + await self.restore_backup(version) + reset_progress = True + finally: + self.params.remove("BackupManager_RestoreVersion") + + self._report_status() + rk.keep_time() + + except Exception as e: + cloudlog.exception(f"Error in backup manager main thread: {str(e)}") + self.last_error = str(e) + self._report_status() + rk.keep_time() + + +def main(): + import asyncio + asyncio.run(BackupManagerSP().main_thread()) + + +if __name__ == "__main__": + main() diff --git a/sunnypilot/sunnylink/backups/utils.py b/sunnypilot/sunnylink/backups/utils.py new file mode 100644 index 0000000000..eb59205128 --- /dev/null +++ b/sunnypilot/sunnylink/backups/utils.py @@ -0,0 +1,188 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import base64 +import hashlib +import os +import zlib +import re +import json +from pathlib import Path + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa, ec + +from openpilot.common.api.base import KEYS +from openpilot.sunnypilot.sunnylink.backups.AESCipher import AESCipher +from openpilot.system.hardware.hw import Paths + + +class KeyDerivation: + @staticmethod + def _load_key(file_path: str) -> bytes: + with open(file_path, 'rb') as f: + return f.read() + + @staticmethod + def derive_aes_key_iv(key_path: str, use_aes_256: bool) -> tuple[bytes, bytes]: + key_pem: bytes = KeyDerivation._load_key(key_path) + key_plain = key_pem.decode(errors="ignore") + + if "private" in key_plain.lower(): + private_key = serialization.load_pem_private_key(key_pem, password=None, backend=default_backend()) + if isinstance(private_key, (rsa.RSAPrivateKey, ec.EllipticCurvePrivateKey)): + public_key = private_key.public_key() + else: + raise ValueError("Invalid key format: Unable to determine if key is public or private.") + elif "public" in key_plain.lower(): + public_key = serialization.load_pem_public_key(key_pem, backend=default_backend()) + if not isinstance(public_key, (rsa.RSAPublicKey, ec.EllipticCurvePublicKey)): + raise ValueError("Invalid key format: Unable to determine if key is public or private.") + else: + raise ValueError("Invalid key format: Unable to determine if key is public or private.") + + if isinstance(public_key, rsa.RSAPublicKey): + der_data = public_key.public_bytes(encoding=serialization.Encoding.DER, format=serialization.PublicFormat.PKCS1) + elif isinstance(public_key, ec.EllipticCurvePublicKey): + der_data = public_key.public_bytes(encoding=serialization.Encoding.DER, format=serialization.PublicFormat.SubjectPublicKeyInfo) + else: + raise ValueError("Unsupported key type.") + + if use_aes_256: + # AES-256-CBC + key = hashlib.sha256(der_data).digest() + iv = hashlib.md5(der_data).digest() + else: + # AES-128-CBC + key = hashlib.md5(der_data).digest() + iv = hashlib.md5(der_data).digest() # Insecure IV reuse, kept for compatibility + + return key, iv + + +def uncompress_dat(data): + """ + Decompress data using zlib. + + Args: + data (bytes): Compressed data + + Returns: + bytes: Decompressed data + """ + data_stripped_4 = data[4:] + return zlib.decompress(data_stripped_4) + + +def compress_dat(data): + """ + Compress data using zlib. + + Args: + data (bytes): Data to compress + + Returns: + bytes: Compressed data + """ + compressed_data = zlib.compress(data, level=9) + return b"ZLIB" + compressed_data + + +def get_key_path(use_aes_256=False) -> str: + key_path = "" + for key in KEYS: + if os.path.isfile(Paths.persist_root() + f'/comma/{key}') and os.path.isfile(Paths.persist_root() + f'/comma/{key}.pub'): + key_path = str(Path(Paths.persist_root() + f'/comma/{key}') if use_aes_256 else Path(Paths.persist_root() + f'/comma/{key}.pub')) + break + + if not key_path: + raise FileNotFoundError("No valid key pair found in persist storage.") + + return key_path + + +def decrypt_compressed_data(encrypted_base64, use_aes_256=False): + """ + Decrypt and decompress data from base64 string. + + Args: + encrypted_base64 (str): Base64 encoded encrypted data + key_path (str, optional): Path to RSA public key + + Returns: + str: Decrypted and decompressed string + """ + try: + # Decode base64 + encrypted_data = base64.b64decode(encrypted_base64) + + # Decrypt + key, iv = KeyDerivation.derive_aes_key_iv(get_key_path(use_aes_256), use_aes_256) + cipher = AESCipher(key, iv) + decrypted_data = cipher.decrypt(encrypted_data) + + # Decompress + decompressed_data = uncompress_dat(decrypted_data) + + # Decode UTF-8 + result = decompressed_data.decode('utf-8') + return result + except Exception as e: + print(f"Decryption and decompression failed: {e}") + return "" + + +def encrypt_compressed_data(text, use_aes_256=True): + """ + Compress and encrypt string data to base64. + + Args: + text (str): Text to compress and encrypt + key_path (str, optional): Path to RSA public key + + Returns: + str: Base64 encoded encrypted data + """ + try: + # Encode to UTF-8 + text_bytes = text.encode('utf-8') + + # Compress + compressed_data = compress_dat(text_bytes) + + # Encrypt + key, iv = KeyDerivation.derive_aes_key_iv(get_key_path(use_aes_256), use_aes_256) + cipher = AESCipher(key, iv) + encrypted_data = cipher.encrypt(compressed_data) + + # Encode to base64 + result = base64.b64encode(encrypted_data).decode('utf-8') + return result + except Exception as e: + print(f"Compression and encryption failed: {e}") + return "" + + +def camel_to_snake(name): + """Convert camelCase to snake_case.""" + name = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name) + return re.sub('([a-z0-9])([A-Z])', r'\1_\2', name).lower() + + +def transform_dict(obj): + """Recursively transform dictionary keys from camelCase to snake_case.""" + if isinstance(obj, dict): + return {camel_to_snake(k): transform_dict(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [transform_dict(item) for item in obj] + return obj + + +class SnakeCaseEncoder(json.JSONEncoder): + def encode(self, obj): + transformed_obj = transform_dict(obj) + return super().encode(transformed_obj) diff --git a/sunnypilot/sunnylink/params_metadata.json b/sunnypilot/sunnylink/params_metadata.json new file mode 100644 index 0000000000..41725883a8 --- /dev/null +++ b/sunnypilot/sunnylink/params_metadata.json @@ -0,0 +1,1395 @@ +{ + "AccessToken": { + "title": "AccessTokenIsNice", + "description": "" + }, + "AdbEnabled": { + "title": "Enable ADB", + "description": "" + }, + "AlphaLongitudinalEnabled": { + "title": "Alpha Longitudinal", + "description": "" + }, + "AlwaysOnDM": { + "title": "Always-on Driver Monitor", + "description": "" + }, + "ApiCache_Device": { + "title": "Api Cache Device", + "description": "" + }, + "ApiCache_DriveStats": { + "title": "Api Cache Drive Stats", + "description": "" + }, + "ApiCache_FirehoseStats": { + "title": "Firehose Mode Stats", + "description": "" + }, + "AssistNowToken": { + "title": "Assist Now Token", + "description": "" + }, + "AthenadPid": { + "title": "Athenad Pid", + "description": "" + }, + "AthenadRecentlyViewedRoutes": { + "title": "Athenad Recently Viewed Routes", + "description": "" + }, + "AthenadUploadQueue": { + "title": "Athenad Upload Queue", + "description": "" + }, + "AutoLaneChangeBsmDelay": { + "title": "Auto Lane Change BSM Delay", + "description": "" + }, + "AutoLaneChangeTimer": { + "title": "Auto Lane Change Timer", + "description": "", + "options": [ + { + "value": -1, + "label": "Off" + }, + { + "value": 0, + "label": "Nudge" + }, + { + "value": 1, + "label": "Nudgeless" + }, + { + "value": 2, + "label": "0.5s" + }, + { + "value": 3, + "label": "1s" + }, + { + "value": 4, + "label": "2s" + }, + { + "value": 5, + "label": "3s" + } + ] + }, + "BackupManager_CreateBackup": { + "title": "Create Backup", + "description": "" + }, + "BackupManager_RestoreVersion": { + "title": "Restore Version", + "description": "" + }, + "BlindSpot": { + "title": "[TIZI/TICI only] Blind Spot Detection", + "description": "Enabling this will display warnings when a vehicle is detected in your blind spot as long as your car has BSM supported." + }, + "BlinkerLateralReengageDelay": { + "title": "Post-Blinker Delay", + "description": "Delay before lateral control resumes after the turn signal ends." + }, + "BlinkerMinLateralControlSpeed": { + "title": "Blinker Min Lateral Control Speed", + "description": "" + }, + "BlinkerPauseLateralControl": { + "title": "Blinker Pause Lateral Control", + "description": "" + }, + "BootCount": { + "title": "Boot Count", + "description": "" + }, + "Brightness": { + "title": "Screen Brightness", + "description": "" + }, + "CalibrationParams": { + "title": "Calibration Params", + "description": "" + }, + "CameraDebugExpGain": { + "title": "Camera Debug Exp Gain", + "description": "" + }, + "CameraDebugExpTime": { + "title": "Camera Debug Exp Time", + "description": "" + }, + "CameraOffset": { + "title": "Adjust Camera Offset", + "description": "Virtually shift camera's perspective to move model's center to Left(+ values) or Right (- values)", + "min": -0.35, + "max": 0.35, + "step": 0.01, + "unit": "meters" + }, + "CarBatteryCapacity": { + "title": "Car Battery Capacity", + "description": "Battery Size", + "unit": "kWh" + }, + "CarList": { + "title": "Supported Car List", + "description": "All supported platform in sunnypilot" + }, + "CarParams": { + "title": "Car Params", + "description": "" + }, + "CarParamsCache": { + "title": "Car Params Cache", + "description": "" + }, + "CarParamsPersistent": { + "title": "Car Params Persistent", + "description": "" + }, + "CarParamsPrevRoute": { + "title": "Car Params Prev Route", + "description": "" + }, + "CarParamsSP": { + "title": "Car Params Sp", + "description": "" + }, + "CarParamsSPCache": { + "title": "Car Params Sp Cache", + "description": "" + }, + "CarParamsSPPersistent": { + "title": "Car Params Sp Persistent", + "description": "" + }, + "CarPlatformBundle": { + "title": "Car Platform Bundle", + "description": "" + }, + "ChevronInfo": { + "title": "Chevron Info", + "description": "" + }, + "CompletedSunnylinkConsentVersion": { + "title": "Completed sunnylink Consent Version", + "description": "" + }, + "CompletedTrainingVersion": { + "title": "Completed Training Version", + "description": "" + }, + "ControlsReady": { + "title": "Controls Ready", + "description": "" + }, + "CurrentBootlog": { + "title": "Current Bootlog", + "description": "" + }, + "CurrentRoute": { + "title": "Current Route", + "description": "" + }, + "CustomAccIncrementsEnabled": { + "title": "Custom ACC Increments", + "description": "" + }, + "CustomAccLongPressIncrement": { + "title": "Custom ACC Long Press Increment", + "description": "", + "min": 1, + "max": 10, + "step": 1 + }, + "CustomAccShortPressIncrement": { + "title": "Custom ACC Short Press Increment", + "description": "", + "min": 1, + "max": 10, + "step": 1 + }, + "CustomTorqueParams": { + "title": "Enable Custom Torque Tuning", + "description": "Enables custom tuning for Torque lateral control" + }, + "DevUIInfo": { + "title": "Developer UI Info", + "description": "" + }, + "DeviceBootMode": { + "title": "Device Boot Mode", + "description": "", + "options": [ + { + "value": 0, + "label": "Standard" + }, + { + "value": 1, + "label": "Always Offroad" + } + ] + }, + "DisableLogging": { + "title": "Disable Logging", + "description": "" + }, + "DisablePowerDown": { + "title": "Disable Power Down", + "description": "" + }, + "DisableUpdates": { + "title": "Disable Updates", + "description": "" + }, + "DisengageOnAccelerator": { + "title": "Disengage On Accelerator", + "description": "" + }, + "DoReboot": { + "title": "Reboot", + "description": "" + }, + "DoShutdown": { + "title": "Power Off", + "description": "" + }, + "DoUninstall": { + "title": "Uninstall sunnypilot", + "description": "" + }, + "DongleId": { + "title": "Device ID", + "description": "" + }, + "DriverTooDistracted": { + "title": "Driver Too Distracted", + "description": "" + }, + "DynamicExperimentalControl": { + "title": "Dynamic Experimental Control", + "description": "" + }, + "EnableCopyparty": { + "title": "copyparty Service", + "description": "" + }, + "EnableGithubRunner": { + "title": "GitHub Runner Service", + "description": "" + }, + "EnableSunnylinkUploader": { + "title": "Enable sunnylink Uploader", + "description": "" + }, + "EnforceTorqueControl": { + "title": "Enforce Torque Control", + "description": "Enable this to enforce sunnypilot to steer with Torque lateral control." + }, + "ExperimentalMode": { + "title": "Experimental Mode", + "description": "" + }, + "ExperimentalModeConfirmed": { + "title": "Experimental Mode Confirmed", + "description": "" + }, + "FirmwareQueryDone": { + "title": "Firmware Query Done", + "description": "" + }, + "ForcePowerDown": { + "title": "Force Power Down", + "description": "" + }, + "GitBranch": { + "title": "Git Branch", + "description": "" + }, + "GitCommit": { + "title": "Git Commit", + "description": "" + }, + "GitCommitDate": { + "title": "Git Commit Date", + "description": "" + }, + "GitDiff": { + "title": "Git Diff", + "description": "" + }, + "GitRemote": { + "title": "Git Remote", + "description": "" + }, + "GithubRunnerSufficientVoltage": { + "title": "Github Runner Sufficient Voltage", + "description": "" + }, + "GithubSshKeys": { + "title": "Github Ssh Keys", + "description": "" + }, + "GithubUsername": { + "title": "GitHub Username", + "description": "" + }, + "GreenLightAlert": { + "title": "Green Traffic Light Alert (Beta)", + "description": "A chime and on-screen alert (TIZI/TICI only) will play when the traffic light you are waiting for turns green and you have no vehicle in front of you.
Note: This chime is only designed as a notification. It is the driver's responsibility to observe their environment and make decisions accordingly." + }, + "GsmApn": { + "title": "GSM APN", + "description": "" + }, + "GsmMetered": { + "title": "Gsm Metered", + "description": "" + }, + "GsmRoaming": { + "title": "GSM Roaming", + "description": "" + }, + "HardwareSerial": { + "title": "Serial Number", + "description": "" + }, + "HasAcceptedTerms": { + "title": "Has Accepted Terms", + "description": "" + }, + "HasAcceptedTermsSP": { + "title": "Has Accepted sunnypilot Terms", + "description": "" + }, + "HideVEgoUI": { + "title": "[TIZI/TICI only] Speedometer: Hide from Onroad Screen", + "description": "When enabled, the speedometer on the onroad screen is not displayed." + }, + "HyundaiLongitudinalTuning": { + "title": "Hyundai Longitudinal Tuning", + "description": "", + "options": [ + { + "value": 0, + "label": "Off" + }, + { + "value": 1, + "label": "Dynamic" + }, + { + "value": 2, + "label": "Predictive" + } + ] + }, + "InstallDate": { + "title": "Install Date", + "description": "" + }, + "IntelligentCruiseButtonManagement": { + "title": "Intelligent Cruise Button Management", + "description": "" + }, + "InteractivityTimeout": { + "title": "Interactivity Timeout", + "description": "Apply a custom timeout for settings UI. This is the time after which settings UI closes automatically if user is not interacting with the screen.", + "options": [ + { + "value": 0, + "label": "Default" + }, + { + "value": 10, + "label": "10 s" + }, + { + "value": 20, + "label": "20 s" + }, + { + "value": 30, + "label": "30 s" + }, + { + "value": 40, + "label": "40 s" + }, + { + "value": 50, + "label": "50 s" + }, + { + "value": 60, + "label": "1 m" + }, + { + "value": 70, + "label": "1 m" + }, + { + "value": 80, + "label": "1 m" + }, + { + "value": 90, + "label": "1 m" + }, + { + "value": 100, + "label": "1 m" + }, + { + "value": 110, + "label": "1 m" + }, + { + "value": 120, + "label": "2 m" + } + ] + }, + "IsDevelopmentBranch": { + "title": "Is Development Branch", + "description": "" + }, + "IsDriverViewEnabled": { + "title": "Is Driver View Enabled", + "description": "" + }, + "IsEngaged": { + "title": "Is Engaged", + "description": "" + }, + "IsLdwEnabled": { + "title": "Lane Departure Warnings", + "description": "" + }, + "IsMetric": { + "title": "Use Metric Units", + "description": "" + }, + "IsOffroad": { + "title": "Is Offroad", + "description": "" + }, + "IsOnroad": { + "title": "Is Onroad", + "description": "" + }, + "IsReleaseBranch": { + "title": "Is Release Branch", + "description": "" + }, + "IsReleaseSpBranch": { + "title": "Is Release Sp Branch", + "description": "" + }, + "IsRhdDetected": { + "title": "Is Rhd Detected", + "description": "" + }, + "IsTakingSnapshot": { + "title": "Is Taking Snapshot", + "description": "" + }, + "IsTestedBranch": { + "title": "Is Tested Branch", + "description": "" + }, + "JoystickDebugMode": { + "title": "Joystick Debug Mode", + "description": "" + }, + "LagdToggle": { + "title": "Live Learning Steer Delay", + "description": "Allow device to learn and adapt car's steering response time" + }, + "LagdToggleDelay": { + "title": "Manual Software Delay", + "description": "Software delay to use when Live Learning Steer Delay is toggled off", + "min": 0.05, + "max": 0.5, + "step": 0.01 + }, + "LagdValueCache": { + "title": "LaGD Value Cache", + "description": "" + }, + "LaneTurnDesire": { + "title": "Lane Turn Desire", + "description": "Force model to plan an intent to turn based on blinker" + }, + "LaneTurnValue": { + "title": "Lane Turn Speed", + "description": "Maximum speed for lane turn desire", + "min": 0, + "max": 20, + "step": 1 + }, + "LanguageSetting": { + "title": "Language", + "description": "" + }, + "LastAgnosPowerMonitorShutdown": { + "title": "Last AGNOS Power Monitor Shutdown", + "description": "" + }, + "LastAthenaPingTime": { + "title": "Last Athena Ping Time", + "description": "" + }, + "LastGPSPosition": { + "title": "Last Gps Position", + "description": "" + }, + "LastGPSPositionLLK": { + "title": "Last GPS Position LLK", + "description": "" + }, + "LastManagerExitReason": { + "title": "Last Manager Exit Reason", + "description": "" + }, + "LastOffroadStatusPacket": { + "title": "Last Offroad Status Packet", + "description": "" + }, + "LastPowerDropDetected": { + "title": "Last Power Drop Detected", + "description": "" + }, + "LastSunnylinkPingTime": { + "title": "Last sunnylink Ping Time", + "description": "" + }, + "LastUpdateException": { + "title": "Last Update Exception", + "description": "" + }, + "LastUpdateRouteCount": { + "title": "Last Update Route Count", + "description": "" + }, + "LastUpdateTime": { + "title": "Last Update Time", + "description": "" + }, + "LastUpdateUptimeOnroad": { + "title": "Last Update Uptime Onroad", + "description": "" + }, + "LateralManeuverMode": { + "title": "Lateral Maneuver Mode", + "description": "" + }, + "LeadDepartAlert": { + "title": "Lead Departure Alert (Beta)", + "description": "A chime and on-screen alert (TIZI/TICI only) will play when you are stopped, and the vehicle in front of you start moving.
Note: This chime is only designed as a notification. It is the driver's responsibility to observe their environment and make decisions accordingly." + }, + "LiveDelay": { + "title": "Live Delay", + "description": "" + }, + "LiveParameters": { + "title": "Live Parameters", + "description": "" + }, + "LiveParametersV2": { + "title": "Live Parameters V2", + "description": "" + }, + "LiveTorqueParameters": { + "title": "Live Torque Parameters", + "description": "" + }, + "LiveTorqueParamsRelaxedToggle": { + "title": "Less Restrict Settings for Self-Tune (Beta)", + "description": "Less strict settings when using Self-Tune. This allows torqued to be more forgiving when learning values." + }, + "LiveTorqueParamsToggle": { + "title": "Self-Tune", + "description": "Enables self-tune for Torque lateral control" + }, + "LocationFilterInitialState": { + "title": "Location Filter Initial State", + "description": "" + }, + "LongitudinalManeuverMode": { + "title": "Longitudinal Maneuver Mode", + "description": "" + }, + "LongitudinalPersonality": { + "title": "Driving Personality", + "description": "", + "options": [ + { + "value": 0, + "label": "Aggressive" + }, + { + "value": 1, + "label": "Standard" + }, + { + "value": 2, + "label": "Relaxed" + } + ] + }, + "Mads": { + "title": "MADS Enabled", + "description": "" + }, + "MadsMainCruiseAllowed": { + "title": "MADS Main Cruise Allowed", + "description": "" + }, + "MadsSteeringMode": { + "title": "MADS Steering Mode", + "description": "", + "options": [ + { + "value": 0, + "label": "Remain Active" + }, + { + "value": 1, + "label": "Pause" + }, + { + "value": 2, + "label": "Disengage" + } + ] + }, + "MadsUnifiedEngagementMode": { + "title": "MADS Unified Engagement Mode", + "description": "" + }, + "MapAdvisorySpeedLimit": { + "title": "Map Advisory Speed Limit", + "description": "" + }, + "MapSpeedLimit": { + "title": "Map Speed Limit", + "description": "" + }, + "MapTargetVelocities": { + "title": "Map Target Velocities", + "description": "" + }, + "MapdVersion": { + "title": "Mapd Version", + "description": "" + }, + "MaxTimeOffroad": { + "title": "Max Time Offroad", + "description": "", + "unit": "minutes" + }, + "ModelManager_ActiveBundle": { + "title": "Model Manager Active Bundle", + "description": "" + }, + "ModelManager_ClearCache": { + "title": "Model Manager Clear Cache", + "description": "" + }, + "ModelManager_DownloadIndex": { + "title": "Model Manager Download Index", + "description": "" + }, + "ModelManager_Favs": { + "title": "Model Manager Favorites", + "description": "" + }, + "ModelManager_LastSyncTime": { + "title": "Model Manager Last Sync Time", + "description": "" + }, + "ModelManager_ModelsCache": { + "title": "Model Manager Models Cache", + "description": "" + }, + "ModelRunnerTypeCache": { + "title": "Model Runner Type Cache", + "description": "" + }, + "NetworkMetered": { + "title": "Network Usage", + "description": "", + "options": [ + { + "value": 0, + "label": "Default" + }, + { + "value": 1, + "label": "Metered" + }, + { + "value": 2, + "label": "Unmetered" + } + ] + }, + "NeuralNetworkLateralControl": { + "title": "Neural Network Lateral Control", + "description": "" + }, + "NextMapSpeedLimit": { + "title": "Next Map Speed Limit", + "description": "" + }, + "OSMDownloadBounds": { + "title": "OSM Download Bounds", + "description": "" + }, + "OSMDownloadLocations": { + "title": "OSM Download Locations", + "description": "" + }, + "OSMDownloadProgress": { + "title": "OSM Download Progress", + "description": "" + }, + "ObdMultiplexingChanged": { + "title": "Obd Multiplexing Changed", + "description": "" + }, + "ObdMultiplexingEnabled": { + "title": "Obd Multiplexing Enabled", + "description": "" + }, + "OffroadMode": { + "title": "Force Offroad Mode", + "description": "" + }, + "Offroad_CarUnrecognized": { + "title": "Offroad Car Unrecognized", + "description": "" + }, + "Offroad_ConnectivityNeeded": { + "title": "Offroad Connectivity Needed", + "description": "" + }, + "Offroad_ConnectivityNeededPrompt": { + "title": "Offroad Connectivity Needed Prompt", + "description": "" + }, + "Offroad_DriverMonitoringUncertain": { + "title": "Offroad Driver Monitoring Uncertain", + "description": "" + }, + "Offroad_ExcessiveActuation": { + "title": "Offroad Excessive Actuation", + "description": "" + }, + "Offroad_IsTakingSnapshot": { + "title": "Offroad Is Taking Snapshot", + "description": "" + }, + "Offroad_NeosUpdate": { + "title": "Offroad Neos Update", + "description": "" + }, + "Offroad_NoFirmware": { + "title": "Offroad No Firmware", + "description": "" + }, + "Offroad_OSMUpdateRequired": { + "title": "Offroad OSM Update Required", + "description": "" + }, + "Offroad_Recalibration": { + "title": "Offroad Recalibration", + "description": "" + }, + "Offroad_TemperatureTooHigh": { + "title": "Offroad Temperature Too High", + "description": "" + }, + "Offroad_TiciSupport": { + "title": "Offroad Tici Support", + "description": "" + }, + "Offroad_UnregisteredHardware": { + "title": "Offroad Unregistered Hardware", + "description": "" + }, + "Offroad_UpdateFailed": { + "title": "Offroad Update Failed", + "description": "" + }, + "OnroadCycleRequested": { + "title": "Onroad Cycle Requested", + "description": "" + }, + "OnroadScreenOffBrightness": { + "title": "Onroad Brightness", + "description": "", + "options": [ + { + "value": 0, + "label": "Auto (Default)" + }, + { + "value": 1, + "label": "Auto (Dark)" + }, + { + "value": 2, + "label": "Screen Off" + }, + { + "value": 3, + "label": "5 %" + }, + { + "value": 4, + "label": "10 %" + }, + { + "value": 5, + "label": "15 %" + }, + { + "value": 6, + "label": "20 %" + }, + { + "value": 7, + "label": "25 %" + }, + { + "value": 8, + "label": "30 %" + }, + { + "value": 9, + "label": "35 %" + }, + { + "value": 10, + "label": "40 %" + }, + { + "value": 11, + "label": "45 %" + }, + { + "value": 12, + "label": "50 %" + }, + { + "value": 13, + "label": "55 %" + }, + { + "value": 14, + "label": "60 %" + }, + { + "value": 15, + "label": "65 %" + }, + { + "value": 16, + "label": "70 %" + }, + { + "value": 17, + "label": "75 %" + }, + { + "value": 18, + "label": "80 %" + }, + { + "value": 19, + "label": "85 %" + }, + { + "value": 20, + "label": "90 %" + }, + { + "value": 21, + "label": "95 %" + }, + { + "value": 22, + "label": "100 %" + } + ] + }, + "OnroadScreenOffBrightnessMigrated": { + "title": "Onroad Brightness Migration Version", + "description": "This param is to track whether OnroadScreenOffBrightness needs to be migrated." + }, + "OnroadScreenOffControl": { + "title": "Onroad Brightness", + "description": "Adjusts the screen brightness while it's in onroad state." + }, + "OnroadScreenOffTimer": { + "title": "Onroad Brightness Delay", + "description": "", + "options": [ + { + "value": 3, + "label": "3s" + }, + { + "value": 5, + "label": "5s" + }, + { + "value": 7, + "label": "7s" + }, + { + "value": 10, + "label": "10s" + }, + { + "value": 15, + "label": "15s" + }, + { + "value": 30, + "label": "30s" + }, + { + "value": 60, + "label": "1m" + }, + { + "value": 120, + "label": "2m" + }, + { + "value": 180, + "label": "3m" + }, + { + "value": 240, + "label": "4m" + }, + { + "value": 300, + "label": "5m" + }, + { + "value": 360, + "label": "6m" + }, + { + "value": 420, + "label": "7m" + }, + { + "value": 480, + "label": "8m" + }, + { + "value": 540, + "label": "9m" + }, + { + "value": 600, + "label": "10m" + } + ] + }, + "OnroadScreenOffTimerMigrated": { + "title": "Onroad Brightness Delay Migration Version", + "description": "This param is to track whether OnroadScreenOffTimer needs to be migrated." + }, + "OnroadUploads": { + "title": "Onroad Uploads", + "description": "" + }, + "OpenpilotEnabledToggle": { + "title": "Enable sunnypilot", + "description": "" + }, + "OsmDbUpdatesCheck": { + "title": "OSM DB Updates Check", + "description": "" + }, + "OsmDownloadedDate": { + "title": "OSM Downloaded Date", + "description": "" + }, + "OsmLocal": { + "title": "OSM Local", + "description": "" + }, + "OsmLocationName": { + "title": "OSM Location Name", + "description": "" + }, + "OsmLocationTitle": { + "title": "OSM Location Title", + "description": "" + }, + "OsmLocationUrl": { + "title": "OSM Location URL", + "description": "" + }, + "OsmStateName": { + "title": "OSM State Name", + "description": "" + }, + "OsmStateTitle": { + "title": "OSM State Title", + "description": "" + }, + "OsmWayTest": { + "title": "OSM Way Test", + "description": "" + }, + "PandaHeartbeatLost": { + "title": "Panda Heartbeat Lost", + "description": "" + }, + "PandaSignatures": { + "title": "Panda Signatures", + "description": "" + }, + "PandaSomResetTriggered": { + "title": "Panda Som Reset Triggered", + "description": "" + }, + "PlanplusControl": { + "title": "Plan Plus Controls", + "description": "Adjust planplus model recentering strength. The higher this number the more aggressively the model will recover to lanecenter, too high and it will ping-pong", + "min": 0.0, + "max": 2.0, + "step": 0.1 + }, + "PrimeType": { + "title": "Prime Type", + "description": "" + }, + "QuickBootToggle": { + "title": "Quick Boot", + "description": "" + }, + "QuietMode": { + "title": "Quiet Mode", + "description": "" + }, + "RainbowMode": { + "title": "Rainbow Mode", + "description": "" + }, + "RecordAudio": { + "title": "Record & Upload Mic Audio", + "description": "" + }, + "RecordAudioFeedback": { + "title": "Record Audio Feedback", + "description": "" + }, + "RecordFront": { + "title": "Record & Upload Driver Camera", + "description": "" + }, + "RecordFrontLock": { + "title": "Record Front Lock", + "description": "" + }, + "RoadName": { + "title": "Road Name", + "description": "" + }, + "RoadNameToggle": { + "title": "[TIZI/TICI only] Display Road Name", + "description": "Displays the name of the road the car is traveling on.
The OpenStreetMap database of the location must be downloaded to fetch the road name." + }, + "RocketFuel": { + "title": "[TIZI/TICI only] Real-time Acceleration Bar", + "description": "Show an indicator on the left side of the screen to display real-time vehicle acceleration and deceleration. This displays what the car is currently doing, not what the planner is requesting." + }, + "RouteCount": { + "title": "Route Count", + "description": "" + }, + "SecOCKey": { + "title": "Sec Oc Key", + "description": "" + }, + "ShowAdvancedControls": { + "title": "Show Advanced Controls", + "description": "Enable to show advanced controls on device" + }, + "ShowDebugInfo": { + "title": "UI Debug Mode", + "description": "" + }, + "ShowTurnSignals": { + "title": "[TIZI/TICI only] Display Turn Signals", + "description": "When enabled, visual turn indicators are drawn on the HUD." + }, + "SmartCruiseControlMap": { + "title": "Smart Cruise Control - Map", + "description": "" + }, + "SmartCruiseControlVision": { + "title": "Smart Cruise Control - Vision", + "description": "" + }, + "SnoozeUpdate": { + "title": "Snooze Update", + "description": "" + }, + "SpeedLimitMode": { + "title": "Speed Limit Assist Mode", + "description": "", + "options": [ + { + "value": 0, + "label": "Off" + }, + { + "value": 1, + "label": "Information" + }, + { + "value": 2, + "label": "Warning" + }, + { + "value": 3, + "label": "Assist" + } + ] + }, + "SpeedLimitOffsetType": { + "title": "Speed Limit Offset Type", + "description": "", + "options": [ + { + "value": 0, + "label": "Off" + }, + { + "value": 1, + "label": "Fixed" + }, + { + "value": 2, + "label": "Percentage" + } + ] + }, + "SpeedLimitPolicy": { + "title": "Speed Limit Source", + "description": "", + "options": [ + { + "value": 0, + "label": "Car State Only" + }, + { + "value": 1, + "label": "Map Data Only" + }, + { + "value": 2, + "label": "Car State Priority" + }, + { + "value": 3, + "label": "Map Data Priority" + }, + { + "value": 4, + "label": "Combined" + } + ] + }, + "SpeedLimitValueOffset": { + "title": "Speed Limit Offset Value", + "description": "", + "min": -30, + "max": 30, + "step": 1 + }, + "SshEnabled": { + "title": "Enable SSH", + "description": "" + }, + "StandstillTimer": { + "title": "[TIZI/TICI only] Standstill Timer", + "description": "Show a timer on the HUD when the car is at a standstill." + }, + "SubaruStopAndGo": { + "title": "Subaru Stop and Go", + "description": "" + }, + "SubaruStopAndGoManualParkingBrake": { + "title": "Subaru Stop and Go Manual Parking Brake", + "description": "" + }, + "SunnylinkCache_Roles": { + "title": "sunnylink Cache Roles", + "description": "" + }, + "SunnylinkCache_Users": { + "title": "sunnylink Cache Users", + "description": "" + }, + "SunnylinkDongleId": { + "title": "sunnylink Dongle ID", + "description": "" + }, + "SunnylinkEnabled": { + "title": "sunnylink Enabled", + "description": "" + }, + "SunnylinkTempFault": { + "title": "sunnylink Temp Fault", + "description": "" + }, + "SunnylinkdPid": { + "title": "Sunnylinkd Pid", + "description": "" + }, + "TermsVersion": { + "title": "Terms Version", + "description": "" + }, + "TeslaCoopSteering": { + "title": "Tesla Coop Steering", + "description": "" + }, + "TorqueBar": { + "title": "[TIZI/TICI only] Steering Arc", + "description": "Display steering arc on the driving screen when lateral control is enabled." + }, + "TorqueControlTune": { + "title": "Torque Control Tune Version", + "description": "Select the version of Torque Control Tune to use.", + "options": [ + { + "value": "", + "label": "Default" + }, + { + "value": 1.0, + "label": "v1.0" + }, + { + "value": 0.0, + "label": "v0.0" + } + ] + }, + "TorqueParamsOverrideEnabled": { + "title": "Manual Real-Time Tuning", + "description": "" + }, + "TorqueParamsOverrideFriction": { + "title": "Manual Tune - Friction", + "description": "", + "min": 0.0, + "max": 1.0, + "step": 0.01 + }, + "TorqueParamsOverrideLatAccelFactor": { + "title": "Manual Tune - Lateral Acceleration Factor", + "description": "", + "min": 0.1, + "max": 5.0, + "step": 0.1, + "unit": "m/s\u00b2" + }, + "ToyotaEnforceStockLongitudinal": { + "title": "Toyota: Enforce Factory Longitudinal Control", + "description": "When enabled, sunnypilot will not take over control of gas and brakes. Factory Toyota longitudinal control will be used." + }, + "ToyotaStopAndGoHack": { + "title": "Toyota: Stop and Go Hack (Alpha)", + "description": "sunnypilot will allow some Toyota/Lexus cars to auto resume during stop and go traffic. This feature is only applicable to certain models that are able to use longitudinal control. This is an alpha feature. Use at your own risk." + }, + "TrainingVersion": { + "title": "Training Version", + "description": "" + }, + "TrueVEgoUI": { + "title": "[TIZI/TICI only] Speedometer: Always Display True Speed", + "description": "For applicable vehicles, always display the true vehicle current speed from wheel speed sensors." + }, + "UbloxAvailable": { + "title": "Ublox Available", + "description": "" + }, + "UpdateAvailable": { + "title": "Update Available", + "description": "" + }, + "UpdateFailedCount": { + "title": "Update Failed Count", + "description": "" + }, + "UpdaterAvailableBranches": { + "title": "Updater Available Branches", + "description": "" + }, + "UpdaterCurrentDescription": { + "title": "Updater Current Description", + "description": "" + }, + "UpdaterCurrentReleaseNotes": { + "title": "Updater Current Release Notes", + "description": "" + }, + "UpdaterFetchAvailable": { + "title": "Updater Fetch Available", + "description": "" + }, + "UpdaterLastFetchTime": { + "title": "Updater Last Fetch Time", + "description": "" + }, + "UpdaterNewDescription": { + "title": "Updater New Description", + "description": "" + }, + "UpdaterNewReleaseNotes": { + "title": "Updater New Release Notes", + "description": "" + }, + "UpdaterState": { + "title": "Updater State", + "description": "" + }, + "UpdaterTargetBranch": { + "title": "Updater Target Branch", + "description": "" + }, + "UptimeOffroad": { + "title": "Uptime Offroad", + "description": "" + }, + "UptimeOnroad": { + "title": "Uptime Onroad", + "description": "" + }, + "Version": { + "title": "openpilot Version", + "description": "" + } +} diff --git a/sunnypilot/sunnylink/registration_manager.py b/sunnypilot/sunnylink/registration_manager.py new file mode 100755 index 0000000000..1b822e5c2d --- /dev/null +++ b/sunnypilot/sunnylink/registration_manager.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +import time + +from openpilot.common.params import Params +from openpilot.common.realtime import Ratekeeper +from openpilot.common.swaglog import cloudlog + +from cereal import log, messaging +from openpilot.sunnypilot.sunnylink.utils import register_sunnylink + +NetworkType = log.DeviceState.NetworkType + + +def main(): + """The main method is expected to be called by the manager when the device boots up.""" + try: + rk = Ratekeeper(.5) + sm = messaging.SubMaster(['deviceState'], poll='deviceState') + while True: + sm.update(1000) + if sm['deviceState'].networkType != NetworkType.none: + break + + cloudlog.info(f"Waiting to become online... {time.monotonic()}") + rk.keep_time() + + register_sunnylink() + except Exception: + cloudlog.exception("Sunnylink registration failed") + Params().put_bool("SunnylinkTempFault", True) + raise + + +if __name__ == "__main__": + main() diff --git a/sunnypilot/sunnylink/statsd.py b/sunnypilot/sunnylink/statsd.py new file mode 100755 index 0000000000..233b531e85 --- /dev/null +++ b/sunnypilot/sunnylink/statsd.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +import base64 +import json +import os +import threading +import traceback + +import zmq +import time +import uuid +from pathlib import Path +from collections import defaultdict +from datetime import datetime, UTC + +from openpilot.common.params import Params +from cereal.messaging import SubMaster +from openpilot.system.hardware.hw import Paths +from openpilot.common.swaglog import cloudlog +from openpilot.system.hardware import HARDWARE +from openpilot.common.utils import atomic_write +from openpilot.system.version import get_build_metadata +from openpilot.system.loggerd.config import STATS_DIR_FILE_LIMIT, STATS_SOCKET, STATS_FLUSH_TIME_S +from openpilot.system.statsd import METRIC_TYPE, StatLogSP +from openpilot.common.realtime import Ratekeeper + +STATSLOGSP = StatLogSP(intercept=False) + +def sp_stats(end_event): + """Collect sunnypilot-specific statistics and send as raw metrics.""" + rk = Ratekeeper(.1, print_delay_threshold=None) + statlogsp = STATSLOGSP + params = Params() + + def flatten_dict(d, parent_key='', sep='.'): + items = {} + if isinstance(d, dict): + for k, v in d.items(): + new_key = f"{parent_key}{sep}{k}" if parent_key else k + items.update(flatten_dict(v, new_key, sep=sep)) + elif isinstance(d, (list, tuple)): + for i, v in enumerate(d): + new_key = f"{parent_key}[{i}]" + items.update(flatten_dict(v, new_key, sep=sep)) + else: + items[parent_key] = d + return items + + # Collect sunnypilot parameters + stats_dict = {} + + param_keys = [ + 'SunnylinkEnabled', + 'AutoLaneChangeBsmDelay', + 'AutoLaneChangeTimer', + 'CarPlatformBundle', + 'CurrentRoute', + 'DevUIInfo', + 'EnableCopyparty', + 'IntelligentCruiseButtonManagement', + 'QuietMode', + 'RainbowMode', + 'ShowAdvancedControls', + 'Mads', + 'MadsMainCruiseAllowed', + 'MadsSteeringMode', + 'MadsUnifiedEngagementMode', + 'ModelManager_ActiveBundle', + 'ModelManager_Favs', + 'EnableSunnylinkUploader', + 'SunnylinkEnabled', + 'InstallDate', + 'UptimeOffroad', + 'UptimeOnroad', + ] + + while not end_event.is_set(): + try: + for key in param_keys: + + try: + value = params.get(key) + except Exception as e: + stats_dict[key] = e + continue + + if value is None: + continue + + if isinstance(value, (dict, list, tuple)): + stats_dict.update(flatten_dict(value, key)) + else: + stats_dict[key] = value + + if stats_dict: + statlogsp.raw('sunnypilot.device_params', stats_dict) + except Exception as e: + cloudlog.error(f"Exception {e}") + finally: + rk.keep_time() + + +def stats_main(end_event): + comma_dongle_id = Params().get("DongleId") + sunnylink_dongle_id = Params().get("SunnylinkDongleId") + + def get_influxdb_line(measurement: str, value: float | dict[str, float], timestamp: datetime, tags: dict) -> str: + res = f"{measurement}" + for k, v in tags.items(): + res += f",{k}={str(v)}" + res += " " + + if isinstance(value, float): + value = {'value': value} + + for k, v in value.items(): + res += f"{k}={str(v)}," + + res += f"sunnylink_dongle_id=\"{sunnylink_dongle_id}\",comma_dongle_id=\"{comma_dongle_id}\" {int(timestamp.timestamp() * 1e9)}\n" + return res + + def get_influxdb_line_raw(measurement: str, value: dict, timestamp: datetime, tags: dict) -> str: + res = f"{measurement}" + try: + custom_tags = "" + for k, v in tags.items(): + custom_tags += f",{k}={str(v)}" + res += custom_tags + + fields = "" + for k, v in value.items(): + # Skip complex types - only keep simple scalar values + if isinstance(v, (dict, list, bytes, bytearray)): + continue + + fields += f"{k}={json.dumps(v)}," + + res += f" {fields}" + except Exception as e: + cloudlog.error(f"Unable to get influxdb line for: {value}") + res += f",invalid=1 reason={e}," + + res += f"sunnylink_dongle_id=\"{sunnylink_dongle_id}\",comma_dongle_id=\"{comma_dongle_id}\" {int(timestamp.timestamp() * 1e9)}\n" + return res + + # open statistics socket + ctx = zmq.Context.instance() + sock = ctx.socket(zmq.PULL) + sock.bind(f"{STATS_SOCKET}_sp") + + STATS_DIR = Paths.stats_sp_root() + + # initialize stats directory + Path(STATS_DIR).mkdir(parents=True, exist_ok=True) + + build_metadata = get_build_metadata() + + # initialize tags + tags = { + 'started': False, + 'version': build_metadata.openpilot.version, + 'branch': build_metadata.channel, + 'dirty': build_metadata.openpilot.is_dirty, + 'origin': build_metadata.openpilot.git_normalized_origin, + 'deviceType': HARDWARE.get_device_type(), + } + + # subscribe to deviceState for started state + sm = SubMaster(['deviceState']) + + idx = 0 + boot_uid = str(uuid.uuid4())[:8] + last_flush_time = time.monotonic() + gauges = {} + samples: dict[str, list[float]] = defaultdict(list) + raws: dict = defaultdict() + try: + while not end_event.is_set(): + started_prev = sm['deviceState'].started + sm.update() + + # Update metrics + while True: + try: + metric = sock.recv_string(zmq.NOBLOCK) + try: + metric_type = metric.split('|')[1] + metric_name = metric.split(':')[0] + metric_value_raw = metric.split('|')[0].split(':')[1] + + if metric_type == METRIC_TYPE.GAUGE: + metric_value = float(metric_value_raw) + gauges[metric_name] = metric_value + elif metric_type == METRIC_TYPE.SAMPLE: + metric_value = float(metric_value_raw) + samples[metric_name].append(metric_value) + elif metric_type == METRIC_TYPE.RAW: + raws[metric_name] = metric_value_raw + else: + cloudlog.event("unknown metric type", metric_type=metric_type) + except Exception: + print(traceback.format_exc()) + cloudlog.event("malformed metric", metric=metric) + except zmq.error.Again: + break + + # flush when started state changes or after FLUSH_TIME_S + if (time.monotonic() > last_flush_time + STATS_FLUSH_TIME_S) or (sm['deviceState'].started != started_prev): + result = "" + current_time = datetime.now(UTC) + tags['started'] = sm['deviceState'].started + + for key, value in raws.items(): + decoded_value = json.loads(base64.b64decode(value).decode('utf-8')) + result += get_influxdb_line_raw(key, decoded_value, current_time, tags) + + for key, value in gauges.items(): + result += get_influxdb_line(f"gauge.{key}", value, current_time, tags) + + for key, values in samples.items(): + values.sort() + sample_count = len(values) + sample_sum = sum(values) + + stats = { + 'count': sample_count, + 'min': values[0], + 'max': values[-1], + 'mean': sample_sum / sample_count, + } + for percentile in [0.05, 0.5, 0.95]: + value = values[int(round(percentile * (sample_count - 1)))] + stats[f"p{int(percentile * 100)}"] = value + + result += get_influxdb_line(f"sample.{key}", stats, current_time, tags) + + # clear intermediate data + gauges.clear() + samples.clear() + last_flush_time = time.monotonic() + + # check that we aren't filling up the drive + if len(os.listdir(STATS_DIR)) < STATS_DIR_FILE_LIMIT: + if len(result) > 0: + stats_path = os.path.join(STATS_DIR, f"{boot_uid}_{idx}") + with atomic_write(stats_path) as f: + f.write(result) + idx += 1 + else: + cloudlog.error("stats dir full") + finally: + sock.close() + ctx.term() + + +def main(): + rk = Ratekeeper(1, print_delay_threshold=None) + end_event = threading.Event() + + threads = [ + threading.Thread(target=stats_main, args=(end_event,)), + threading.Thread(target=sp_stats, args=(end_event,)), + ] + + for t in threads: + t.start() + + try: + while all(t.is_alive() for t in threads): + rk.keep_time() + finally: + end_event.set() + + for t in threads: + t.join() + + +if __name__ == "__main__": + main() diff --git a/sunnypilot/sunnylink/sunnylink_state.py b/sunnypilot/sunnylink/sunnylink_state.py new file mode 100644 index 0000000000..927d041991 --- /dev/null +++ b/sunnypilot/sunnylink/sunnylink_state.py @@ -0,0 +1,235 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from enum import IntEnum +import threading +import requests +import time +import json +import pyray as rl + +from cereal import messaging +from openpilot.common.params import Params +from openpilot.common.swaglog import cloudlog +from openpilot.sunnypilot.sunnylink.api import UNREGISTERED_SUNNYLINK_DONGLE_ID, SunnylinkApi +from openpilot.system.ui.sunnypilot.lib.styles import style + + +class RoleType(IntEnum): + READONLY = 0 + SPONSOR = 1 + ADMIN = 2 + + +class SponsorTier(IntEnum): + FREE = 0 + NOVICE = 1 + SUPPORTER = 2 + CONTRIBUTOR = 3 + BENEFACTOR = 4 + GUARDIAN = 5 + + +class User: + device_id: str + user_id: str + created_at: int + updated_at: int + token_hash: str + + def __init__(self, json_data): + self.device_id = json_data.get("device_id") + self.user_id = json_data.get("user_id") + self.created_at = json_data.get("created_at") + self.updated_at = json_data.get("updated_at") + self.token_hash = json_data.get("token_hash") + + +class Role: + role_type: str + role_tier: str + + def __init__(self, json_data): + self.role_type = json_data.get("role_type") + self.role_tier = json_data.get("role_tier") + + +def _parse_roles(roles: str) -> list[Role]: + lst_roles = [] + try: + roles_list = json.loads(roles) + for r in roles_list: + try: + role = Role(r) + lst_roles.append(role) + except Exception as e: + cloudlog.exception(f"Failed to parse role {r}: {e}") + return lst_roles + except Exception as e: + cloudlog.exception(f"Error parsing roles: {e}") + return [] + + +def _parse_users(users: str) -> list[User]: + lst_users = [] + try: + users_list = json.loads(users) + for u in users_list: + try: + user = User(u) + lst_users.append(user) + except Exception as e: + cloudlog.exception(f"Failed to parse user {u}: {e}") + return lst_users + except Exception as e: + cloudlog.exception(f"Error parsing users: {e}") + return [] + + +class SunnylinkState: + FETCH_INTERVAL = 5.0 # seconds between API calls + API_TIMEOUT = 10.0 # seconds for API requests + SLEEP_INTERVAL = 0.5 # seconds to sleep between checks in the worker thread + NOT_PAIRED_USERNAMES = ["unregisteredsponsor", "temporarysponsor"] + + def __init__(self): + self._params = Params() + self._lock = threading.Lock() + self._session = requests.Session() # reuse session to reduce SSL handshake overhead + self._running = False + self._thread = None + self._sm = messaging.SubMaster(['deviceState']) + + self._roles: list[Role] = [] + self._users: list[User] = [] + self.sponsor_tier: SponsorTier = SponsorTier.FREE + self.sunnylink_dongle_id = self._params.get("SunnylinkDongleId") + self._api = SunnylinkApi(self.sunnylink_dongle_id) + + self._panel_open = False + + self._load_initial_state() + + def _load_initial_state(self) -> None: + roles_cache = self._params.get("SunnylinkCache_Roles") + users_cache = self._params.get("SunnylinkCache_Users") + if roles_cache is not None: + self._roles = _parse_roles(roles_cache) + self.sponsor_tier = self._get_highest_tier() + if users_cache is not None: + self._users = _parse_users(users_cache) + + def _get_highest_tier(self) -> SponsorTier: + role_tier = SponsorTier.FREE + for role in self._roles: + try: + if RoleType[role.role_type.upper()] == RoleType.SPONSOR: + role_tier = max(role_tier, SponsorTier[role.role_tier.upper()]) + except Exception as e: + cloudlog.exception(f"Error parsing role {role}: {e} for dongle id {self.sunnylink_dongle_id}") + return role_tier + + def _fetch_roles(self) -> None: + if not self.sunnylink_dongle_id or self.sunnylink_dongle_id == UNREGISTERED_SUNNYLINK_DONGLE_ID: + return + + try: + token = self._api.get_token() + response = self._api.api_get(f"device/{self.sunnylink_dongle_id}/roles", method='GET', access_token=token, session=self._session) + if response.status_code == 200: + roles = response.text + self._params.put("SunnylinkCache_Roles", roles) + with self._lock: + self._roles = _parse_roles(roles) + sponsor_tier = self._get_highest_tier() + if sponsor_tier != self.sponsor_tier: + self.sponsor_tier = sponsor_tier + cloudlog.info(f"Sunnylink sponsor tier updated to {sponsor_tier.name}") + except Exception as e: + cloudlog.exception(f"Failed to fetch sunnylink roles: {e} for dongle id {self.sunnylink_dongle_id}") + + def _fetch_users(self) -> None: + if not self.sunnylink_dongle_id or self.sunnylink_dongle_id == UNREGISTERED_SUNNYLINK_DONGLE_ID: + return + + try: + token = self._api.get_token() + response = self._api.api_get(f"device/{self.sunnylink_dongle_id}/users", method='GET', access_token=token, session=self._session) + if response.status_code == 200: + users = response.text + self._params.put("SunnylinkCache_Users", users) + with self._lock: + self._users = _parse_users(users) + except Exception as e: + cloudlog.exception(f"Failed to fetch sunnylink users: {e} for dongle id {self.sunnylink_dongle_id}") + + def _worker_thread(self) -> None: + while self._running: + with self._lock: + panel_open = self._panel_open + + if panel_open: + self._sm.update() + if self.is_connected(): + self._fetch_roles() + self._fetch_users() + + for _ in range(int(self.FETCH_INTERVAL / self.SLEEP_INTERVAL)): + if not self._running: + break + time.sleep(self.SLEEP_INTERVAL) + + def start(self) -> None: + if self._thread and self._thread.is_alive(): + return + self._running = True + self._thread = threading.Thread(target=self._worker_thread, daemon=True) + self._thread.start() + + def stop(self) -> None: + self._running = False + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=1.0) + + def get_sponsor_tier(self) -> SponsorTier: + with self._lock: + return self.sponsor_tier + + def is_sponsor(self) -> bool: + with self._lock: + is_sponsor = any(role.role_type.upper() == RoleType.SPONSOR.name and role.role_tier.upper() != SponsorTier.FREE.name + for role in self._roles) + return is_sponsor + + def is_paired(self) -> bool: + with self._lock: + is_paired = any(user.user_id not in self.NOT_PAIRED_USERNAMES for user in self._users) + return is_paired + + def is_connected(self) -> bool: + network_type = self._sm["deviceState"].networkType + return bool(network_type != 0) + + def get_sponsor_tier_color(self) -> rl.Color: + tier = self.get_sponsor_tier() + + if tier == SponsorTier.GUARDIAN: + return rl.Color(255, 215, 0, 255) + elif tier == SponsorTier.BENEFACTOR: + return rl.Color(60, 179, 113, 255) + elif tier == SponsorTier.CONTRIBUTOR: + return rl.Color(70, 130, 180, 255) + elif tier == SponsorTier.SUPPORTER: + return rl.Color(147, 112, 219, 255) + else: + return style.ITEM_TEXT_VALUE_COLOR + + def set_settings_open(self, _open: bool) -> None: + with self._lock: + self._panel_open = _open + + def __del__(self): + self.stop() diff --git a/sunnypilot/sunnylink/tests/test_params_metadata.py b/sunnypilot/sunnylink/tests/test_params_metadata.py new file mode 100644 index 0000000000..f4f1fbc4b1 --- /dev/null +++ b/sunnypilot/sunnylink/tests/test_params_metadata.py @@ -0,0 +1,86 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import json + +from openpilot.sunnypilot.sunnylink.athena.sunnylinkd import getParamsAllKeysV1, METADATA_PATH + + +def test_get_params_all_keys_v1(): + """ + Test the getParamsAllKeysV1 API endpoint. + + Why: + This endpoint is used by the UI (and potentially external tools) to fetch the list of + available parameters along with their metadata (titles, descriptions, options, constraints). + We need to ensure it returns the correct structure and that the metadata from + params_metadata.json is correctly merged into the response. + + Expected: + - The response should contain a "keys" field which is a JSON string of a list of parameters. + - Each parameter object should have "key", "type", "default_value", and optionally "_extra". + - The "_extra" field should contain the rich metadata (title, options, min/max, etc.) matching + the source of truth (params_metadata.json). + """ + response = getParamsAllKeysV1() + assert "keys" in response + + keys_json = response["keys"] + params_list = json.loads(keys_json) + + assert isinstance(params_list, list) + assert len(params_list) > 0 + + # Check structure of first item + first_param = params_list[0] + assert "key" in first_param + assert "type" in first_param + assert "default_value" in first_param + + if "_extra" in first_param: + assert isinstance(first_param["_extra"], dict) + assert "default" not in first_param["_extra"] + assert "type" not in first_param["_extra"] + + # Load the source of truth + with open(METADATA_PATH) as f: + metadata = json.load(f) + + # Verify that the API response matches the metadata file for a few sample keys + # This ensures the plumbing is working without being brittle to content changes + + # 1. Check a key that should have metadata + keys_with_metadata = [k for k in params_list if k["key"] in metadata] + assert len(keys_with_metadata) > 0, "No parameters found that match metadata keys" + + for param in keys_with_metadata[:5]: # Check first 5 matches + key = param["key"] + expected_meta = metadata[key] + + assert "_extra" in param, f"Parameter {key} should have _extra field" + actual_meta = param["_extra"] + + # Verify all fields in JSON are present in the API response + for meta_key, meta_val in expected_meta.items(): + assert meta_key in actual_meta, f"Missing {meta_key} in API response for {key}" + assert actual_meta[meta_key] == meta_val, f"Mismatch for {key}.{meta_key}: expected {meta_val}, got {actual_meta[meta_key]}" + + # 2. Check that we are correctly serving options if they exist + params_with_options = [k for k in keys_with_metadata if "options" in k.get("_extra", {})] + if params_with_options: + param = params_with_options[0] + key = param["key"] + assert isinstance(param["_extra"]["options"], list), f"Options for {key} should be a list" + assert param["_extra"]["options"] == metadata[key]["options"] + + # 3. Check that we are correctly serving numeric constraints if they exist + params_with_constraints = [k for k in keys_with_metadata if "min" in k.get("_extra", {})] + if params_with_constraints: + param = params_with_constraints[0] + key = param["key"] + assert param["_extra"]["min"] == metadata[key]["min"] + assert param["_extra"]["max"] == metadata[key]["max"] + assert param["_extra"]["step"] == metadata[key]["step"] diff --git a/sunnypilot/sunnylink/tests/test_params_sync.py b/sunnypilot/sunnylink/tests/test_params_sync.py new file mode 100644 index 0000000000..05114de49a --- /dev/null +++ b/sunnypilot/sunnylink/tests/test_params_sync.py @@ -0,0 +1,284 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import json +import os +import pytest + +from openpilot.common.params import Params +from openpilot.sunnypilot.sunnylink.athena.sunnylinkd import METADATA_PATH + + +def test_metadata_json_exists(): + """ + Test that the params_metadata.json file exists at the expected path. + + Why: + The metadata file is the source of truth for parameter descriptions, options, and constraints. + If it's missing, the UI will not be able to display rich information for parameters. + + Expected: + The file should exist at sunnypilot/sunnylink/params_metadata.json. + """ + assert os.path.exists(METADATA_PATH), f"Metadata file not found at {METADATA_PATH}" + + +def test_metadata_json_valid(): + """ + Test that the params_metadata.json file contains valid JSON. + + Why: + Invalid JSON will cause the metadata loading to fail, potentially crashing the UI or + resulting in missing metadata. + + Expected: + The file content should be parseable as a JSON object (dictionary). + """ + with open(METADATA_PATH) as f: + try: + data = json.load(f) + except json.JSONDecodeError: + pytest.fail("Metadata file is not valid JSON") + + assert isinstance(data, dict), "Metadata root must be a dictionary" + + +def test_all_params_have_metadata(): + """ + Test that every parameter in the codebase has a corresponding entry in params_metadata.json. + + Why: + We want to ensure 100% coverage of parameter metadata. Any parameter added to the codebase + should also be documented in the metadata file. + + Expected: + There should be no parameters in Params() that are missing from the metadata file. + If this fails, run 'python3 sunnypilot/sunnylink/tools/update_params_metadata.py'. + """ + params = Params() + all_keys = [k.decode('utf-8') for k in params.all_keys()] + + with open(METADATA_PATH) as f: + metadata = json.load(f) + + missing_keys = [key for key in all_keys if key not in metadata] + + if missing_keys: + pytest.fail( + f"The following parameters are missing from metadata: {missing_keys}. " + + "Please run 'python3 sunnypilot/sunnylink/tools/update_params_metadata.py' to update." + ) + + +def test_metadata_keys_exist_in_params(): + """ + Test that all keys in params_metadata.json actually exist in the codebase. + + Why: + We want to avoid stale metadata for parameters that have been removed or renamed. + This keeps the metadata file clean and relevant. + + Expected: + There should be no keys in the metadata file that are not present in Params(). + This prints a warning rather than failing, as it's less critical than missing metadata. + """ + params = Params() + all_keys = {k.decode('utf-8') for k in params.all_keys()} + + with open(METADATA_PATH) as f: + metadata = json.load(f) + + extra_keys = [key for key in metadata.keys() if key not in all_keys] + + if extra_keys: + print(f"Warning: The following keys in metadata do not exist in Params: {extra_keys}") + + +def test_no_default_titles(): + """ + Test that no parameter has a title that is identical to its key. + + Why: + The default behavior of the update script is to set the title equal to the key. + We want to force developers to provide human-readable, descriptive titles for all parameters. + + Expected: + No parameter metadata should have 'title' == 'key'. + """ + with open(METADATA_PATH) as f: + metadata = json.load(f) + + default_title_keys = [key for key, meta in metadata.items() if meta.get("title") == key] + + if default_title_keys: + pytest.fail( + f"The following parameters have default titles (title == key): {default_title_keys}. " + + "Please update 'params_metadata.json' with descriptive titles." + ) + + +def test_options_structure(): + """ + Test that the 'options' field in metadata follows the correct structure. + + Why: + The UI expects 'options' to be a list of objects with 'value' and 'label' keys. + Incorrect structure will break the UI rendering for dropdowns/toggles. + + Expected: + If 'options' is present, it must be a list of dicts, and each dict must have 'value' and 'label'. + """ + with open(METADATA_PATH) as f: + metadata = json.load(f) + + for key, meta in metadata.items(): + if "options" in meta: + options = meta["options"] + assert isinstance(options, list), f"Options for {key} must be a list" + for option in options: + assert isinstance(option, dict), f"Option in {key} must be a dictionary" + assert "value" in option, f"Option in {key} must have a 'value' key" + assert "label" in option, f"Option in {key} must have a 'label' key" + + +def test_numeric_constraints(): + """ + Test that numeric parameters have valid 'min', 'max', and 'step' constraints. + + Why: + The UI uses these constraints to validate user input and render sliders/steppers. + Missing or invalid constraints can lead to UI bugs or invalid parameter values. + + Expected: + If any of min/max/step is present, ALL of them must be present. + They must be numbers (int/float), and min must be less than max. + """ + with open(METADATA_PATH) as f: + metadata = json.load(f) + + for key, meta in metadata.items(): + if "min" in meta or "max" in meta or "step" in meta: + assert "min" in meta, f"Numeric param {key} must have 'min'" + assert "max" in meta, f"Numeric param {key} must have 'max'" + assert "step" in meta, f"Numeric param {key} must have 'step'" + + assert isinstance(meta["min"], (int, float)), f"Min for {key} must be number" + assert isinstance(meta["max"], (int, float)), f"Max for {key} must be number" + assert isinstance(meta["step"], (int, float)), f"Step for {key} must be number" + assert meta["min"] < meta["max"], f"Min must be less than max for {key}" + + +def test_known_params_metadata(): + """ + Test specific known parameters to ensure they have the expected rich metadata. + + Why: + This acts as a spot check to ensure that our rich metadata population logic is working correctly + and that critical parameters (like LongitudinalPersonality) have their options and constraints preserved. + + Expected: + 'LongitudinalPersonality' should have 3 options (Aggressive, Standard, Relaxed). + 'CustomAccLongPressIncrement' should have min=1, max=10, step=1. + """ + with open(METADATA_PATH) as f: + metadata = json.load(f) + + # Check an enum-like param + lp = metadata.get("LongitudinalPersonality") + assert lp is not None + assert "options" in lp + assert len(lp["options"]) == 3 + assert lp["options"][0]["label"] == "Aggressive" + assert lp["options"][0]["value"] == 0 + + # Check a numeric param + acc_long = metadata.get("CustomAccLongPressIncrement") + assert acc_long is not None + assert acc_long["min"] == 1 + assert acc_long["max"] == 10 + assert acc_long["step"] == 1 + + +def test_torque_control_tune_versions_in_sync(): + """ + Test that TorqueControlTune options in params_metadata.json match versions in latcontrol_torque_versions.json. + + Why: + The TorqueControlTune dropdown in the UI should always reflect the available torque tune versions. + If versions are added/removed from latcontrol_torque_versions.json, the metadata must be updated accordingly. + + Expected: + - TorqueControlTune should have a 'Default' option with empty string value + - All versions from latcontrol_torque_versions.json should be present in the options + - The version values and labels should match between both files + """ + from openpilot.common.basedir import BASEDIR + + versions_json_path = os.path.join(BASEDIR, "sunnypilot", "selfdrive", "controls", "lib", "latcontrol_torque_versions.json") + sync_script_path = "python3 sunnypilot/sunnylink/tools/sync_torque_versions.py" + + # Load both files + with open(METADATA_PATH) as f: + metadata = json.load(f) + + with open(versions_json_path) as f: + versions = json.load(f) + + # Get TorqueControlTune metadata + torque_tune = metadata.get("TorqueControlTune") + if torque_tune is None: + pytest.fail(f"TorqueControlTune not found in params_metadata.json. Please run '{sync_script_path}' to sync.") + + if "options" not in torque_tune: + pytest.fail(f"TorqueControlTune must have options. Please run '{sync_script_path}' to sync.") + + options = torque_tune["options"] + if not isinstance(options, list): + pytest.fail(f"TorqueControlTune options must be a list. Please run '{sync_script_path}' to sync.") + + if len(options) == 0: + pytest.fail(f"TorqueControlTune must have at least one option. Please run '{sync_script_path}' to sync.") + + # Check that Default option exists + default_option = next((opt for opt in options if opt.get("value") == ""), None) + if default_option is None: + pytest.fail(f"TorqueControlTune must have a 'Default' option with empty string value. Please run '{sync_script_path}' to sync.") + + if default_option.get("label") != "Default": + pytest.fail(f"Default option must have label 'Default'. Please run '{sync_script_path}' to sync.") + + # Build expected options from versions.json + expected_version_keys = set(versions.keys()) + actual_version_keys = set() + + for option in options: + if option.get("value") == "": + continue # Skip the default option + + label = option.get("label") + value = option.get("value") + + # Check that this option corresponds to a version + if label not in versions: + pytest.fail(f"Option label '{label}' not found in latcontrol_torque_versions.json. Please run '{sync_script_path}' to sync.") + + # Check that the value matches the version number + expected_value = float(versions[label]["version"]) + if value != expected_value: + pytest.fail(f"Option '{label}' has value {value}, expected {expected_value}. Please run '{sync_script_path}' to sync.") + + actual_version_keys.add(label) + + # Check that all versions are represented + missing_versions = expected_version_keys - actual_version_keys + if missing_versions: + pytest.fail(f"The following versions are missing from TorqueControlTune options: {missing_versions}. " + + f"Please run '{sync_script_path}' to sync.") + + extra_versions = actual_version_keys - expected_version_keys + if extra_versions: + pytest.fail("The following versions in TorqueControlTune options are not in latcontrol_torque_versions.json: " + + f"{extra_versions}. Please run '{sync_script_path}' to sync.") diff --git a/sunnypilot/sunnylink/tools/update_params_metadata.py b/sunnypilot/sunnylink/tools/update_params_metadata.py new file mode 100755 index 0000000000..ea3765420d --- /dev/null +++ b/sunnypilot/sunnylink/tools/update_params_metadata.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import json +import os + +from openpilot.common.basedir import BASEDIR +from openpilot.common.params import Params +from openpilot.sunnypilot.system.params_migration import ONROAD_BRIGHTNESS_TIMER_VALUES + +METADATA_PATH = os.path.join(os.path.dirname(__file__), "../params_metadata.json") +TORQUE_VERSIONS_JSON = os.path.join(BASEDIR, "sunnypilot", "selfdrive", "controls", "lib", "latcontrol_torque_versions.json") + + +def main(): + params = Params() + all_keys = params.all_keys() + + if os.path.exists(METADATA_PATH): + with open(METADATA_PATH) as f: + try: + data = json.load(f) + except json.JSONDecodeError: + data = {} + else: + data = {} + + # Add new keys + for key in all_keys: + key_str = key.decode("utf-8") + if key_str not in data: + print(f"Adding new key: {key_str}") + data[key_str] = { + "title": key_str, + "description": "", + } + + # Remove deleted keys + # keys_to_remove = [k for k in data.keys() if k.encode("utf-8") not in all_keys] + # for k in keys_to_remove: + # print(f"Removing deleted key: {k}") + # del data[k] + + # Sort keys + sorted_data = dict(sorted(data.items())) + + with open(METADATA_PATH, "w") as f: + json.dump(sorted_data, f, indent=2) + f.write("\n") + + print(f"Updated {METADATA_PATH}") + + # update onroad screen brightness params + update_onroad_brightness_param() + + # update onroad screen brightness timer params + update_onroad_brightness_timer_param() + + # update torque versions param + update_torque_versions_param() + + +def update_onroad_brightness_param(): + try: + with open(METADATA_PATH) as f: + params_metadata = json.load(f) + if "OnroadScreenOffBrightness" in params_metadata: + options = [ + {"value": 0, "label": "Auto (Default)"}, + {"value": 1, "label": "Auto (Dark)"}, + {"value": 2, "label": "Screen Off"}, + ] + for i in range(3, 23): + options.append({"value": i, "label": f"{(i - 2) * 5} %"}) + params_metadata["OnroadScreenOffBrightness"]["options"] = options + with open(METADATA_PATH, 'w') as f: + json.dump(params_metadata, f, indent=2) + f.write('\n') + print(f"Updated OnroadScreenOffBrightness options in params_metadata.json with {len(options)} options.") + except Exception as e: + print(f"Failed to update OnroadScreenOffBrightness versions in params_metadata.json: {e}") + + +def update_onroad_brightness_timer_param(): + try: + with open(METADATA_PATH) as f: + params_metadata = json.load(f) + if "OnroadScreenOffTimer" in params_metadata: + options = [] + for _index, seconds in sorted(ONROAD_BRIGHTNESS_TIMER_VALUES.items()): + label = f"{seconds}s" if seconds < 60 else f"{seconds // 60}m" + options.append({"value": seconds, "label": label}) + params_metadata["OnroadScreenOffTimer"]["options"] = options + with open(METADATA_PATH, 'w') as f: + json.dump(params_metadata, f, indent=2) + f.write('\n') + print(f"Updated OnroadScreenOffTimer options in params_metadata.json with {len(options)} options.") + except Exception as e: + print(f"Failed to update OnroadScreenOffTimer options in params_metadata.json: {e}") + + +def update_torque_versions_param(): + with open(TORQUE_VERSIONS_JSON) as f: + current_versions = json.load(f) + + try: + with open(METADATA_PATH) as f: + params_metadata = json.load(f) + + options = [{"value": "", "label": "Default"}] + for version_key, version_data in current_versions.items(): + version_value = float(version_data["version"]) + options.append({"value": version_value, "label": str(version_key)}) + + if "TorqueControlTune" in params_metadata: + params_metadata["TorqueControlTune"]["options"] = options + + with open(METADATA_PATH, 'w') as f: + json.dump(params_metadata, f, indent=2) + f.write('\n') + + print(f"Updated TorqueControlTune options in params_metadata.json with {len(options)} options: \n{options}") + + except Exception as e: + print(f"Failed to update TorqueControlTune versions in params_metadata.json: {e}") + + +if __name__ == "__main__": + main() diff --git a/sunnypilot/sunnylink/uploader.py b/sunnypilot/sunnylink/uploader.py new file mode 100755 index 0000000000..0b7eb78edb --- /dev/null +++ b/sunnypilot/sunnylink/uploader.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +import json +import os +import random +import requests +import threading +import time +import traceback +import datetime +from collections.abc import Iterator + +from cereal import log +import cereal.messaging as messaging +from openpilot.sunnypilot.sunnylink.api import SunnylinkApi +from openpilot.common.utils import get_upload_stream +from openpilot.common.params import Params +from openpilot.common.realtime import set_core_affinity +from openpilot.system.hardware.hw import Paths +from openpilot.system.loggerd.xattr_cache import getxattr, setxattr +from openpilot.common.swaglog import cloudlog + +NetworkType = log.DeviceState.NetworkType +UPLOAD_ATTR_NAME = 'user.sunny.upload' +UPLOAD_ATTR_VALUE = b'1' + +MAX_UPLOAD_SIZES = { + "qlog": 25*1e6, # can't be too restrictive here since we use qlogs to find + # bugs, including ones that can cause massive log sizes + "qcam": 5*1e6, +} + +allow_sleep = bool(os.getenv("UPLOADER_SLEEP", "1")) +force_wifi = os.getenv("FORCEWIFI") is not None +fake_upload = os.getenv("FAKEUPLOAD") is not None + + +class FakeRequest: + def __init__(self): + self.headers = {"Content-Length": "0"} + + +class FakeResponse: + def __init__(self): + self.status_code = 200 + self.request = FakeRequest() + + +def get_directory_sort(d: str) -> list[str]: + # ensure old format is sorted sooner + o = ["0", ] if d.startswith("2024-") else ["1", ] + return o + [s.rjust(10, '0') for s in d.rsplit('--', 1)] + +def listdir_by_creation(d: str) -> list[str]: + if not os.path.isdir(d): + return [] + + try: + paths = [f for f in os.listdir(d) if os.path.isdir(os.path.join(d, f))] + paths = sorted(paths, key=get_directory_sort) + return paths + except OSError: + cloudlog.exception("listdir_by_creation failed") + return [] + +def clear_locks(root: str) -> None: + for logdir in os.listdir(root): + path = os.path.join(root, logdir) + try: + for fname in os.listdir(path): + if fname.endswith(".lock"): + os.unlink(os.path.join(path, fname)) + except OSError: + cloudlog.exception("clear_locks failed") + + +class Uploader: + def __init__(self, dongle_id: str, root: str): + self.dongle_id = dongle_id + self.api = SunnylinkApi(dongle_id) + self.root = root + + self.params = Params() + + # stats for last successfully uploaded file + self.last_filename = "" + + self.immediate_folders = ["crash/", "boot/"] + self.immediate_priority = {"qlog": 0, "qlog.zst": 0, "qcamera.ts": 1} + + def list_upload_files(self, metered: bool) -> Iterator[tuple[str, str, str]]: + r = self.params.get("AthenadRecentlyViewedRoutes") + requested_routes = [] if r is None else [route for route in r.split(",") if route] + + for logdir in listdir_by_creation(self.root): + path = os.path.join(self.root, logdir) + try: + names = os.listdir(path) + except OSError: + continue + + if any(name.endswith(".lock") for name in names): + continue + + for name in sorted(names, key=lambda n: self.immediate_priority.get(n, 1000)): + key = os.path.join(logdir, name) + fn = os.path.join(path, name) + # skip files already uploaded + try: + ctime = os.path.getctime(fn) + is_uploaded = getxattr(fn, UPLOAD_ATTR_NAME) == UPLOAD_ATTR_VALUE + except OSError: + cloudlog.event("uploader_getxattr_failed", key=key, fn=fn) + # deleter could have deleted, so skip + continue + if is_uploaded: + continue + + # limit uploading on metered connections + if metered: + dt = datetime.timedelta(hours=12) + if logdir in self.immediate_folders and (datetime.datetime.now() - datetime.datetime.fromtimestamp(ctime)) < dt: + continue + + if name == "qcamera.ts" and not any(logdir.startswith(r.split('|')[-1]) for r in requested_routes): + continue + + yield name, key, fn + + def next_file_to_upload(self, metered: bool) -> tuple[str, str, str] | None: + upload_files = list(self.list_upload_files(metered)) + + for name, key, fn in upload_files: + if any(f in fn for f in self.immediate_folders): + return name, key, fn + + for name, key, fn in upload_files: + if name in self.immediate_priority: + return name, key, fn + + return None + + def do_upload(self, key: str, fn: str): + url_resp = self.api.get( + f"device/{self.dongle_id}/upload_url/", + timeout=10, + path=key, + access_token=self.api.get_token(), + ) + if url_resp.status_code == 412: + return url_resp + + url_resp_json = json.loads(url_resp.text) + url = url_resp_json['url'] + headers = url_resp_json['headers'] + cloudlog.debug("sunnylink upload_url %s | Headers: %s", url, headers) + + if fake_upload: + return FakeResponse() + + stream = None + try: + compress = key.endswith('.zst') and not fn.endswith('.zst') + stream, _ = get_upload_stream(fn, compress) + response = requests.put(url, data=stream, headers=headers, timeout=10) + return response + finally: + if stream: + stream.close() + + def upload(self, name: str, key: str, fn: str, network_type: int, metered: bool) -> bool: + try: + sz = os.path.getsize(fn) + except OSError: + cloudlog.exception("upload: getsize failed") + return False + + cloudlog.event("upload_start", key=key, fn=fn, sz=sz, network_type=network_type, metered=metered) + + if sz == 0: + # tag files of 0 size as uploaded + success = True + elif name in MAX_UPLOAD_SIZES and sz > MAX_UPLOAD_SIZES[name]: + cloudlog.event("uploader_too_large", key=key, fn=fn, sz=sz) + success = True + else: + start_time = time.monotonic() + + stat = None + last_exc = None + try: + stat = self.do_upload(key, fn) + except Exception as e: + last_exc = (e, traceback.format_exc()) + + if stat is not None and stat.status_code in (200, 201, 412): + self.last_filename = fn + dt = time.monotonic() - start_time + if stat.status_code == 412: + cloudlog.event("upload_ignored", key=key, fn=fn, sz=sz, network_type=network_type, metered=metered) + else: + content_length = int(stat.request.headers.get("Content-Length", 0)) + speed = (content_length / 1e6) / dt + cloudlog.event("upload_success", key=key, fn=fn, sz=sz, content_length=content_length, + network_type=network_type, metered=metered, speed=speed) + success = True + elif stat is not None: # 401, 403... Not sure why they were up to begin with + success = False + cloudlog.event("upload_failed with content", stat=stat, exc=last_exc, key=key, fn=fn, sz=sz, network_type=network_type, metered=metered, + error=stat.content.decode("utf-8")) + else: + success = False + cloudlog.event("upload_failed", stat=stat, exc=last_exc, key=key, fn=fn, sz=sz, network_type=network_type, metered=metered) + + if success: + # tag file as uploaded + try: + setxattr(fn, UPLOAD_ATTR_NAME, UPLOAD_ATTR_VALUE) + except OSError: + cloudlog.event("uploader_setxattr_failed", exc=last_exc, key=key, fn=fn, sz=sz) + + return success + + + def step(self, network_type: int, metered: bool) -> bool | None: + d = self.next_file_to_upload(metered) + if d is None: + return None + + name, key, fn = d + + # qlogs and bootlogs need to be compressed before uploading + if key.endswith(('qlog', 'rlog')) or (key.startswith('boot/') and not key.endswith('.zst')): + key += ".zst" + + return self.upload(name, key, fn, network_type, metered) + + +def main(exit_event: threading.Event | None = None) -> None: + if exit_event is None: + exit_event = threading.Event() + + try: + set_core_affinity([0, 1, 2, 3]) + except Exception: + cloudlog.exception("failed to set core affinity") + + clear_locks(Paths.log_root()) + + params = Params() + dongle_id = params.get("SunnylinkDongleId") + + if dongle_id is None: + cloudlog.info("uploader missing dongle_id") + raise Exception("uploader can't start without dongle id") + + sm = messaging.SubMaster(['deviceState']) + uploader = Uploader(dongle_id, Paths.log_root()) + + backoff = 0.1 + while not exit_event.is_set(): + sm.update(0) + offroad = params.get_bool("IsOffroad") + network_type = sm['deviceState'].networkType if not force_wifi else NetworkType.wifi + if network_type == NetworkType.none: + if allow_sleep: + time.sleep(60 if offroad else 5) + continue + + success = uploader.step(sm['deviceState'].networkType.raw, sm['deviceState'].networkMetered) + if success is None: + backoff = 60 if offroad else 5 + elif success: + backoff = 0.1 + else: + cloudlog.info("upload backoff %r", backoff) + backoff = min(backoff*2, 120) + if allow_sleep: + time.sleep(backoff + random.uniform(0, backoff)) + + +if __name__ == "__main__": + main() diff --git a/sunnypilot/sunnylink/utils.py b/sunnypilot/sunnylink/utils.py new file mode 100644 index 0000000000..f4f14f7e81 --- /dev/null +++ b/sunnypilot/sunnylink/utils.py @@ -0,0 +1,127 @@ +import base64 +import gzip +import json +from openpilot.sunnypilot.sunnylink.api import SunnylinkApi, UNREGISTERED_SUNNYLINK_DONGLE_ID +from openpilot.common.params import Params, ParamKeyType +from openpilot.system.version import is_prebuilt + + +def get_sunnylink_status(params=None) -> tuple[bool, bool, bool]: + """Get the status of Sunnylink on the device. Returns a tuple of (is_sunnylink_enabled, is_registered).""" + params = params or Params() + is_sunnylink_enabled = params.get_bool("SunnylinkEnabled") + is_registered = params.get("SunnylinkDongleId") not in (None, UNREGISTERED_SUNNYLINK_DONGLE_ID) + is_on_temporary_fault = params.get_bool("SunnylinkTempFault") + return is_sunnylink_enabled, is_registered, is_on_temporary_fault + + +def sunnylink_ready(params=None) -> bool: + """Check if the device is ready to communicate with Sunnylink. That means it is enabled and registered.""" + params = params or Params() + is_sunnylink_enabled, is_registered, is_on_temporary_fault = get_sunnylink_status(params) + return is_sunnylink_enabled and is_registered and not is_on_temporary_fault + + +def use_sunnylink_uploader(params) -> bool: + """Check if the device is ready to use Sunnylink and the uploader is enabled.""" + return not params.get_bool("NetworkMetered") and sunnylink_ready(params) and params.get_bool("EnableSunnylinkUploader") + + +def sunnylink_need_register(params=None) -> bool: + """Check if the device needs to be registered with Sunnylink.""" + params = params or Params() + is_sunnylink_enabled, is_registered, is_on_temporary_fault = get_sunnylink_status(params) + return is_sunnylink_enabled and not is_registered and not is_on_temporary_fault + + +def register_sunnylink(): + """Register the device with Sunnylink if it is enabled.""" + extra_args = {} + + if not Params().get_bool("SunnylinkEnabled"): + print("Sunnylink is not enabled. Exiting.") + exit(0) + + if not is_prebuilt(): + extra_args = { + "verbose": True, + "timeout": 60 + } + + try: + sunnylink_id = SunnylinkApi(None).register_device(None, **extra_args) + print(f"SunnyLinkId: {sunnylink_id}") + except Exception: + Params().put_bool("SunnylinkTempFault", True) + raise + + +def get_api_token(): + """Get the API token for the device.""" + params = Params() + sunnylink_dongle_id = params.get("SunnylinkDongleId") + sunnylink_api = SunnylinkApi(sunnylink_dongle_id) + token = sunnylink_api.get_token() + print(f"API Token: {token}") + + +def get_param_as_byte(param_name: str, params=None, get_default=False) -> bytes | None: + """Get a parameter as bytes. Returns None if the parameter does not exist.""" + params = params or Params() + param = params.get(param_name) if not get_default else params.get_default_value(param_name) + + if param is None: + return None + + param_type = params.get_type(param_name) + return _to_bytes(param, param_type) + + +def _to_bytes(param: bytes, param_type: ParamKeyType) -> bytes | None: + """Convert a parameter value to bytes based on its type.""" + if param_type == ParamKeyType.BYTES: + return bytes(param) + elif param_type == ParamKeyType.JSON: + return json.dumps(param).encode('utf-8') + return str(param).encode('utf-8') + + +def save_param_from_base64_encoded_string(param_name: str, base64_encoded_data: str, is_compressed=False) -> None: + """Save a parameter from bytes. Overwrites the parameter if it already exists.""" + params = Params() + # Find real param name (with correct casing) + param_type = params.get_type(param_name) + value = base64.b64decode(base64_encoded_data) + + if is_compressed: + value = gzip.decompress(value) + + # We convert to string anything that isn't bytes first. We later transform further. + param_value = _convert_param_to_type(value, param_type) + params.put(param_name, param_value) + + +def _convert_param_to_type(value: bytes, param_type: ParamKeyType) -> bytes | str | int | float | bool | dict | None: + """ + Convert a byte value to the specified param type. Used internally when getting a Param to convert it to the right type. + If this method looks familiar, it's because on SP we have a similar one in openpilot/sunnypilot/car/__init__.py. + """ + + # We convert to string anything that isn't bytes first. We later transform further. + if param_type != ParamKeyType.BYTES: + value = value.decode('utf-8') + + if param_type == ParamKeyType.STRING: + value = value + elif param_type == ParamKeyType.BOOL: + value = value.lower() in ('true', '1', 'yes') + elif param_type == ParamKeyType.INT: + value = int(value) + elif param_type == ParamKeyType.FLOAT: + value = float(value) + elif param_type == ParamKeyType.TIME: + value = str(value) + elif param_type == ParamKeyType.JSON: + value = json.loads(value) + + return value diff --git a/sunnypilot/system/__init__.py b/sunnypilot/system/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sunnypilot/system/hardware/c3/README.md b/sunnypilot/system/hardware/c3/README.md new file mode 100644 index 0000000000..f74a210191 --- /dev/null +++ b/sunnypilot/system/hardware/c3/README.md @@ -0,0 +1,3 @@ +# C3 specific hardware code + +`c3` is known as `tici` and comma three by comma. Not to confuse it with `c3x` which is known as `tizi`. \ No newline at end of file diff --git a/sunnypilot/system/hardware/c3/agnos.json b/sunnypilot/system/hardware/c3/agnos.json new file mode 100644 index 0000000000..941a4956bf --- /dev/null +++ b/sunnypilot/system/hardware/c3/agnos.json @@ -0,0 +1,84 @@ +[ + { + "name": "xbl", + "url": "https://commadist.azureedge.net/agnosupdate/xbl-effa23294138e2297b85a5b482a885184c437b5ab25d74f2a62d4fce4e68f63b.img.xz", + "hash": "effa23294138e2297b85a5b482a885184c437b5ab25d74f2a62d4fce4e68f63b", + "hash_raw": "effa23294138e2297b85a5b482a885184c437b5ab25d74f2a62d4fce4e68f63b", + "size": 3282256, + "sparse": false, + "full_check": true, + "has_ab": true, + "ondevice_hash": "ed61a650bea0c56652dd0fc68465d8fc722a4e6489dc8f257630c42c6adcdc89" + }, + { + "name": "xbl_config", + "url": "https://commadist.azureedge.net/agnosupdate/xbl_config-63d019efed684601f145ef37628e62c8da73f5053a8e51d7de09e72b8b11f97c.img.xz", + "hash": "63d019efed684601f145ef37628e62c8da73f5053a8e51d7de09e72b8b11f97c", + "hash_raw": "63d019efed684601f145ef37628e62c8da73f5053a8e51d7de09e72b8b11f97c", + "size": 98124, + "sparse": false, + "full_check": true, + "has_ab": true, + "ondevice_hash": "b12801ffaa81e58e3cef914488d3b447e35483ba549b28c6cd9deb4814c3265f" + }, + { + "name": "abl", + "url": "https://commadist.azureedge.net/agnosupdate/abl-32a2174b5f764e95dfc54cf358ba01752943b1b3b90e626149c3da7d5f1830b6.img.xz", + "hash": "32a2174b5f764e95dfc54cf358ba01752943b1b3b90e626149c3da7d5f1830b6", + "hash_raw": "32a2174b5f764e95dfc54cf358ba01752943b1b3b90e626149c3da7d5f1830b6", + "size": 274432, + "sparse": false, + "full_check": true, + "has_ab": true, + "ondevice_hash": "32a2174b5f764e95dfc54cf358ba01752943b1b3b90e626149c3da7d5f1830b6" + }, + { + "name": "aop", + "url": "https://commadist.azureedge.net/agnosupdate/aop-21370172e590bd4ea907a558bcd6df20dc7a6c7d38b8e62fdde18f4a512ba9e9.img.xz", + "hash": "21370172e590bd4ea907a558bcd6df20dc7a6c7d38b8e62fdde18f4a512ba9e9", + "hash_raw": "21370172e590bd4ea907a558bcd6df20dc7a6c7d38b8e62fdde18f4a512ba9e9", + "size": 184364, + "sparse": false, + "full_check": true, + "has_ab": true, + "ondevice_hash": "c1be2f4aac5b3af49b904b027faec418d05efd7bd5144eb4fdfcba602bcf2180" + }, + { + "name": "devcfg", + "url": "https://commadist.azureedge.net/agnosupdate/devcfg-d7d7e52963bbedbbf8a7e66847579ca106a0a729ce2cf60f4b8d8ea4b535d620.img.xz", + "hash": "d7d7e52963bbedbbf8a7e66847579ca106a0a729ce2cf60f4b8d8ea4b535d620", + "hash_raw": "d7d7e52963bbedbbf8a7e66847579ca106a0a729ce2cf60f4b8d8ea4b535d620", + "size": 40336, + "sparse": false, + "full_check": true, + "has_ab": true, + "ondevice_hash": "17b229668b20305ff8fa3cd5f94716a3aaa1e5bf9d1c24117eff7f2f81ae719f" + }, + { + "name": "boot", + "url": "https://commadist.azureedge.net/agnosupdate/boot-0191529aa97d90d1fa04b472d80230b777606459e1e1e9e2323c9519839827b4.img.xz", + "hash": "0191529aa97d90d1fa04b472d80230b777606459e1e1e9e2323c9519839827b4", + "hash_raw": "0191529aa97d90d1fa04b472d80230b777606459e1e1e9e2323c9519839827b4", + "size": 18515968, + "sparse": false, + "full_check": true, + "has_ab": true, + "ondevice_hash": "492ae27f569e8db457c79d0e358a7a6297d1a1c685c2b1ae6deba7315d3a6cb0" + }, + { + "name": "system", + "url": "https://commadist.azureedge.net/agnosupdate/system-e0007afa5d1026671c1943d44bb7f7ad26259f673392dd00a03073a2870df087.img.xz", + "hash": "1468d50b7ad0fda0f04074755d21e786e3b1b6ca5dd5b17eb2608202025e6126", + "hash_raw": "e0007afa5d1026671c1943d44bb7f7ad26259f673392dd00a03073a2870df087", + "size": 5368709120, + "sparse": true, + "full_check": false, + "has_ab": true, + "ondevice_hash": "242aa5adad1c04e1398e00e2440d1babf962022eb12b89adf2e60ee3068946e7", + "alt": { + "hash": "e0007afa5d1026671c1943d44bb7f7ad26259f673392dd00a03073a2870df087", + "url": "https://commadist.azureedge.net/agnosupdate/system-e0007afa5d1026671c1943d44bb7f7ad26259f673392dd00a03073a2870df087.img", + "size": 5368709120 + } + } +] \ No newline at end of file diff --git a/sunnypilot/system/hardware/c3/launch_chffrplus.sh b/sunnypilot/system/hardware/c3/launch_chffrplus.sh new file mode 100755 index 0000000000..45cc950537 --- /dev/null +++ b/sunnypilot/system/hardware/c3/launch_chffrplus.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash + +SP_C3_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" +DIR="$( cd "$SP_C3_DIR/../../../.." >/dev/null 2>&1 && pwd )" + +source "$SP_C3_DIR/launch_env.sh" + +function agnos_init { + # TODO: move this to agnos + sudo rm -f /data/etc/NetworkManager/system-connections/*.nmmeta + + # set success flag for current boot slot + sudo abctl --set_success + + # TODO: do this without udev in AGNOS + # udev does this, but sometimes we startup faster + sudo chgrp gpu /dev/adsprpc-smd /dev/ion /dev/kgsl-3d0 + sudo chmod 660 /dev/adsprpc-smd /dev/ion /dev/kgsl-3d0 + + + if [ $(< /VERSION) != "$AGNOS_VERSION" ]; then + AGNOS_PY="$DIR/system/hardware/tici/agnos.py" + MANIFEST="$SP_C3_DIR/agnos.json" + if $AGNOS_PY --verify $MANIFEST; then + sudo reboot + fi + $DIR/system/hardware/tici/updater $AGNOS_PY $MANIFEST + fi +} + +function launch { + # Remove orphaned git lock if it exists on boot + [ -f "$DIR/.git/index.lock" ] && rm -f $DIR/.git/index.lock + + # Check to see if there's a valid overlay-based update available. Conditions + # are as follows: + # + # 1. The DIR init file has to exist, with a newer modtime than anything in + # the DIR Git repo. This checks for local development work or the user + # switching branches/forks, which should not be overwritten. + # 2. The FINALIZED consistent file has to exist, indicating there's an update + # that completed successfully and synced to disk. + + if [ -f "${DIR}/.overlay_init" ]; then + find ${DIR}/.git -newer ${DIR}/.overlay_init | grep -q '.' 2> /dev/null + if [ $? -eq 0 ]; then + echo "${DIR} has been modified, skipping overlay update installation" + else + if [ -f "${STAGING_ROOT}/finalized/.overlay_consistent" ]; then + if [ ! -d /data/safe_staging/old_openpilot ]; then + echo "Valid overlay update found, installing" + LAUNCHER_LOCATION="${BASH_SOURCE[0]}" + + mv $DIR /data/safe_staging/old_openpilot + mv "${STAGING_ROOT}/finalized" $DIR + cd $DIR + + echo "Restarting launch script ${LAUNCHER_LOCATION}" + unset AGNOS_VERSION + exec "${LAUNCHER_LOCATION}" + else + echo "openpilot backup found, not updating" + # TODO: restore backup? This means the updater didn't start after swapping + fi + fi + fi + fi + + # handle pythonpath + ln -sfn $(pwd) /data/pythonpath + export PYTHONPATH="$PWD" + + # hardware specific init + if [ -f /AGNOS ]; then + agnos_init + fi + + # write tmux scrollback to a file + tmux capture-pane -pq -S-1000 > /tmp/launch_log + + # start manager + cd $DIR/system/manager + if [ ! -f $DIR/prebuilt ]; then + ./build.py + fi + ./manager.py + + # if broken, keep on screen error + while true; do sleep 1; done +} + +launch diff --git a/sunnypilot/system/hardware/c3/launch_env.sh b/sunnypilot/system/hardware/c3/launch_env.sh new file mode 100755 index 0000000000..4c011c6ac0 --- /dev/null +++ b/sunnypilot/system/hardware/c3/launch_env.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +export OMP_NUM_THREADS=1 +export MKL_NUM_THREADS=1 +export NUMEXPR_NUM_THREADS=1 +export OPENBLAS_NUM_THREADS=1 +export VECLIB_MAXIMUM_THREADS=1 + +if [ -z "$AGNOS_VERSION" ]; then + export AGNOS_VERSION="12.8" +fi + +export STAGING_ROOT="/data/safe_staging" diff --git a/sunnypilot/system/params_migration.py b/sunnypilot/system/params_migration.py new file mode 100644 index 0000000000..5e524de06e --- /dev/null +++ b/sunnypilot/system/params_migration.py @@ -0,0 +1,47 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from openpilot.common.swaglog import cloudlog + +ONROAD_BRIGHTNESS_MIGRATION_VERSION: str = "1.0" +ONROAD_BRIGHTNESS_TIMER_MIGRATION_VERSION: str = "1.0" + +# index → seconds mapping for OnroadScreenOffTimer (SSoT) +ONROAD_BRIGHTNESS_TIMER_VALUES = {0: 3, 1: 5, 2: 7, 3: 10, 4: 15, 5: 30, **{i: (i - 5) * 60 for i in range(6, 16)}} +VALID_TIMER_VALUES = set(ONROAD_BRIGHTNESS_TIMER_VALUES.values()) + + +def run_migration(_params): + # migrate OnroadScreenOffBrightness + if _params.get("OnroadScreenOffBrightnessMigrated") != ONROAD_BRIGHTNESS_MIGRATION_VERSION: + try: + val = _params.get("OnroadScreenOffBrightness", return_default=True) + if val >= 2: # old: 5%, new: Screen Off + new_val = val + 1 + _params.put("OnroadScreenOffBrightness", new_val) + log_str = f"Successfully migrated OnroadScreenOffBrightness from {val} to {new_val}." + else: + log_str = "Migration not required for OnroadScreenOffBrightness." + + _params.put("OnroadScreenOffBrightnessMigrated", ONROAD_BRIGHTNESS_MIGRATION_VERSION) + cloudlog.info(log_str + f" Setting OnroadScreenOffBrightnessMigrated to {ONROAD_BRIGHTNESS_MIGRATION_VERSION}") + except Exception as e: + cloudlog.exception(f"Error migrating OnroadScreenOffBrightness: {e}") + + # migrate OnroadScreenOffTimer + if _params.get("OnroadScreenOffTimerMigrated") != ONROAD_BRIGHTNESS_TIMER_MIGRATION_VERSION: + try: + val = _params.get("OnroadScreenOffTimer", return_default=True) + if val not in VALID_TIMER_VALUES: + _params.put("OnroadScreenOffTimer", 15) + log_str = f"Successfully migrated OnroadScreenOffTimer from {val} to 15 (default)." + else: + log_str = "Migration not required for OnroadScreenOffTimer." + + _params.put("OnroadScreenOffTimerMigrated", ONROAD_BRIGHTNESS_TIMER_MIGRATION_VERSION) + cloudlog.info(log_str + f" Setting OnroadScreenOffTimerMigrated to {ONROAD_BRIGHTNESS_TIMER_MIGRATION_VERSION}") + except Exception as e: + cloudlog.exception(f"Error migrating OnroadScreenOffTimer: {e}") diff --git a/sunnypilot/system/sensord/.gitignore b/sunnypilot/system/sensord/.gitignore new file mode 100644 index 0000000000..e17675e254 --- /dev/null +++ b/sunnypilot/system/sensord/.gitignore @@ -0,0 +1 @@ +sensord diff --git a/sunnypilot/system/sensord/SConscript b/sunnypilot/system/sensord/SConscript new file mode 100644 index 0000000000..1222f0bfcd --- /dev/null +++ b/sunnypilot/system/sensord/SConscript @@ -0,0 +1,13 @@ +Import('env', 'arch', 'common', 'messaging') + +sensors = [ + 'sensors/i2c_sensor.cc', + 'sensors/lsm6ds3_accel.cc', + 'sensors/lsm6ds3_gyro.cc', + 'sensors/lsm6ds3_temp.cc', + 'sensors/mmc5603nj_magn.cc', +] +libs = [common, messaging, 'pthread'] +if arch == "larch64": + libs.append('i2c') +env.Program('sensord', ['sensors_qcom2.cc'] + sensors, LIBS=libs) diff --git a/sunnypilot/system/sensord/sensors/bmx055_accel.cc b/sunnypilot/system/sensord/sensors/bmx055_accel.cc new file mode 100644 index 0000000000..5db10c3b5b --- /dev/null +++ b/sunnypilot/system/sensord/sensors/bmx055_accel.cc @@ -0,0 +1,85 @@ +#include "sunnypilot/system/sensord/sensors/bmx055_accel.h" + +#include + +#include "common/swaglog.h" +#include "common/timing.h" +#include "common/util.h" + +BMX055_Accel::BMX055_Accel(I2CBus *bus) : I2CSensor(bus) {} + +int BMX055_Accel::init() { + int ret = verify_chip_id(BMX055_ACCEL_I2C_REG_ID, {BMX055_ACCEL_CHIP_ID}); + if (ret == -1) { + goto fail; + } + + ret = set_register(BMX055_ACCEL_I2C_REG_PMU, BMX055_ACCEL_NORMAL_MODE); + if (ret < 0) { + goto fail; + } + + // bmx055 accel has a 1.3ms wakeup time from deep suspend mode + util::sleep_for(10); + + // High bandwidth + // ret = set_register(BMX055_ACCEL_I2C_REG_HBW, BMX055_ACCEL_HBW_ENABLE); + // if (ret < 0) { + // goto fail; + // } + + // Low bandwidth + ret = set_register(BMX055_ACCEL_I2C_REG_HBW, BMX055_ACCEL_HBW_DISABLE); + if (ret < 0) { + goto fail; + } + + ret = set_register(BMX055_ACCEL_I2C_REG_BW, BMX055_ACCEL_BW_125HZ); + if (ret < 0) { + goto fail; + } + + enabled = true; + +fail: + return ret; +} + +int BMX055_Accel::shutdown() { + if (!enabled) return 0; + + // enter deep suspend mode (lowest power mode) + int ret = set_register(BMX055_ACCEL_I2C_REG_PMU, BMX055_ACCEL_DEEP_SUSPEND); + if (ret < 0) { + LOGE("Could not move BMX055 ACCEL in deep suspend mode!"); + } + + return ret; +} + +bool BMX055_Accel::get_event(MessageBuilder &msg, uint64_t ts) { + uint64_t start_time = nanos_since_boot(); + uint8_t buffer[6]; + int len = read_register(BMX055_ACCEL_I2C_REG_X_LSB, buffer, sizeof(buffer)); + assert(len == 6); + + // 12 bit = +-2g + float scale = 9.81 * 2.0f / (1 << 11); + float x = -read_12_bit(buffer[0], buffer[1]) * scale; + float y = -read_12_bit(buffer[2], buffer[3]) * scale; + float z = read_12_bit(buffer[4], buffer[5]) * scale; + + auto event = msg.initEvent().initAccelerometer2(); + event.setSource(cereal::SensorEventData::SensorSource::BMX055); + event.setVersion(1); + event.setSensor(SENSOR_ACCELEROMETER); + event.setType(SENSOR_TYPE_ACCELEROMETER); + event.setTimestamp(start_time); + + float xyz[] = {x, y, z}; + auto svec = event.initAcceleration(); + svec.setV(xyz); + svec.setStatus(true); + + return true; +} diff --git a/sunnypilot/system/sensord/sensors/bmx055_accel.h b/sunnypilot/system/sensord/sensors/bmx055_accel.h new file mode 100644 index 0000000000..4aea163c8b --- /dev/null +++ b/sunnypilot/system/sensord/sensors/bmx055_accel.h @@ -0,0 +1,41 @@ +#pragma once + +#include "sunnypilot/system/sensord/sensors/i2c_sensor.h" + +// Address of the chip on the bus +#define BMX055_ACCEL_I2C_ADDR 0x18 + +// Registers of the chip +#define BMX055_ACCEL_I2C_REG_ID 0x00 +#define BMX055_ACCEL_I2C_REG_X_LSB 0x02 +#define BMX055_ACCEL_I2C_REG_TEMP 0x08 +#define BMX055_ACCEL_I2C_REG_BW 0x10 +#define BMX055_ACCEL_I2C_REG_PMU 0x11 +#define BMX055_ACCEL_I2C_REG_HBW 0x13 +#define BMX055_ACCEL_I2C_REG_FIFO 0x3F + +// Constants +#define BMX055_ACCEL_CHIP_ID 0xFA + +#define BMX055_ACCEL_HBW_ENABLE 0b10000000 +#define BMX055_ACCEL_HBW_DISABLE 0b00000000 +#define BMX055_ACCEL_DEEP_SUSPEND 0b00100000 +#define BMX055_ACCEL_NORMAL_MODE 0b00000000 + +#define BMX055_ACCEL_BW_7_81HZ 0b01000 +#define BMX055_ACCEL_BW_15_63HZ 0b01001 +#define BMX055_ACCEL_BW_31_25HZ 0b01010 +#define BMX055_ACCEL_BW_62_5HZ 0b01011 +#define BMX055_ACCEL_BW_125HZ 0b01100 +#define BMX055_ACCEL_BW_250HZ 0b01101 +#define BMX055_ACCEL_BW_500HZ 0b01110 +#define BMX055_ACCEL_BW_1000HZ 0b01111 + +class BMX055_Accel : public I2CSensor { + uint8_t get_device_address() {return BMX055_ACCEL_I2C_ADDR;} +public: + BMX055_Accel(I2CBus *bus); + int init(); + bool get_event(MessageBuilder &msg, uint64_t ts = 0); + int shutdown(); +}; diff --git a/sunnypilot/system/sensord/sensors/bmx055_gyro.cc b/sunnypilot/system/sensord/sensors/bmx055_gyro.cc new file mode 100644 index 0000000000..4701c70d71 --- /dev/null +++ b/sunnypilot/system/sensord/sensors/bmx055_gyro.cc @@ -0,0 +1,92 @@ +#include "sunnypilot/system/sensord/sensors/bmx055_gyro.h" + +#include +#include + +#include "common/swaglog.h" +#include "common/util.h" + +#define DEG2RAD(x) ((x) * M_PI / 180.0) + + +BMX055_Gyro::BMX055_Gyro(I2CBus *bus) : I2CSensor(bus) {} + +int BMX055_Gyro::init() { + int ret = verify_chip_id(BMX055_GYRO_I2C_REG_ID, {BMX055_GYRO_CHIP_ID}); + if (ret == -1) return -1; + + ret = set_register(BMX055_GYRO_I2C_REG_LPM1, BMX055_GYRO_NORMAL_MODE); + if (ret < 0) { + goto fail; + } + // bmx055 gyro has a 30ms wakeup time from deep suspend mode + util::sleep_for(50); + + // High bandwidth + // ret = set_register(BMX055_GYRO_I2C_REG_HBW, BMX055_GYRO_HBW_ENABLE); + // if (ret < 0) { + // goto fail; + // } + + // Low bandwidth + ret = set_register(BMX055_GYRO_I2C_REG_HBW, BMX055_GYRO_HBW_DISABLE); + if (ret < 0) { + goto fail; + } + + // 116 Hz filter + ret = set_register(BMX055_GYRO_I2C_REG_BW, BMX055_GYRO_BW_116HZ); + if (ret < 0) { + goto fail; + } + + // +- 125 deg/s range + ret = set_register(BMX055_GYRO_I2C_REG_RANGE, BMX055_GYRO_RANGE_125); + if (ret < 0) { + goto fail; + } + + enabled = true; + +fail: + return ret; +} + +int BMX055_Gyro::shutdown() { + if (!enabled) return 0; + + // enter deep suspend mode (lowest power mode) + int ret = set_register(BMX055_GYRO_I2C_REG_LPM1, BMX055_GYRO_DEEP_SUSPEND); + if (ret < 0) { + LOGE("Could not move BMX055 GYRO in deep suspend mode!"); + } + + return ret; +} + +bool BMX055_Gyro::get_event(MessageBuilder &msg, uint64_t ts) { + uint64_t start_time = nanos_since_boot(); + uint8_t buffer[6]; + int len = read_register(BMX055_GYRO_I2C_REG_RATE_X_LSB, buffer, sizeof(buffer)); + assert(len == 6); + + // 16 bit = +- 125 deg/s + float scale = 125.0f / (1 << 15); + float x = -DEG2RAD(read_16_bit(buffer[0], buffer[1]) * scale); + float y = -DEG2RAD(read_16_bit(buffer[2], buffer[3]) * scale); + float z = DEG2RAD(read_16_bit(buffer[4], buffer[5]) * scale); + + auto event = msg.initEvent().initGyroscope2(); + event.setSource(cereal::SensorEventData::SensorSource::BMX055); + event.setVersion(1); + event.setSensor(SENSOR_GYRO_UNCALIBRATED); + event.setType(SENSOR_TYPE_GYROSCOPE_UNCALIBRATED); + event.setTimestamp(start_time); + + float xyz[] = {x, y, z}; + auto svec = event.initGyroUncalibrated(); + svec.setV(xyz); + svec.setStatus(true); + + return true; +} diff --git a/sunnypilot/system/sensord/sensors/bmx055_gyro.h b/sunnypilot/system/sensord/sensors/bmx055_gyro.h new file mode 100644 index 0000000000..489184bb18 --- /dev/null +++ b/sunnypilot/system/sensord/sensors/bmx055_gyro.h @@ -0,0 +1,41 @@ +#pragma once + +#include "sunnypilot/system/sensord/sensors/i2c_sensor.h" + +// Address of the chip on the bus +#define BMX055_GYRO_I2C_ADDR 0x68 + +// Registers of the chip +#define BMX055_GYRO_I2C_REG_ID 0x00 +#define BMX055_GYRO_I2C_REG_RATE_X_LSB 0x02 +#define BMX055_GYRO_I2C_REG_RANGE 0x0F +#define BMX055_GYRO_I2C_REG_BW 0x10 +#define BMX055_GYRO_I2C_REG_LPM1 0x11 +#define BMX055_GYRO_I2C_REG_HBW 0x13 +#define BMX055_GYRO_I2C_REG_FIFO 0x3F + +// Constants +#define BMX055_GYRO_CHIP_ID 0x0F + +#define BMX055_GYRO_HBW_ENABLE 0b10000000 +#define BMX055_GYRO_HBW_DISABLE 0b00000000 +#define BMX055_GYRO_DEEP_SUSPEND 0b00100000 +#define BMX055_GYRO_NORMAL_MODE 0b00000000 + +#define BMX055_GYRO_RANGE_2000 0b000 +#define BMX055_GYRO_RANGE_1000 0b001 +#define BMX055_GYRO_RANGE_500 0b010 +#define BMX055_GYRO_RANGE_250 0b011 +#define BMX055_GYRO_RANGE_125 0b100 + +#define BMX055_GYRO_BW_116HZ 0b0010 + + +class BMX055_Gyro : public I2CSensor { + uint8_t get_device_address() {return BMX055_GYRO_I2C_ADDR;} +public: + BMX055_Gyro(I2CBus *bus); + int init(); + bool get_event(MessageBuilder &msg, uint64_t ts = 0); + int shutdown(); +}; diff --git a/sunnypilot/system/sensord/sensors/bmx055_magn.cc b/sunnypilot/system/sensord/sensors/bmx055_magn.cc new file mode 100644 index 0000000000..00b35aa136 --- /dev/null +++ b/sunnypilot/system/sensord/sensors/bmx055_magn.cc @@ -0,0 +1,258 @@ +#include "sunnypilot/system/sensord/sensors/bmx055_magn.h" + +#include + +#include +#include +#include + +#include "common/swaglog.h" +#include "common/util.h" + +static int16_t compensate_x(trim_data_t trim_data, int16_t mag_data_x, uint16_t data_rhall) { + uint16_t process_comp_x0 = data_rhall; + int32_t process_comp_x1 = ((int32_t)trim_data.dig_xyz1) * 16384; + uint16_t process_comp_x2 = ((uint16_t)(process_comp_x1 / process_comp_x0)) - ((uint16_t)0x4000); + int16_t retval = ((int16_t)process_comp_x2); + int32_t process_comp_x3 = (((int32_t)retval) * ((int32_t)retval)); + int32_t process_comp_x4 = (((int32_t)trim_data.dig_xy2) * (process_comp_x3 / 128)); + int32_t process_comp_x5 = (int32_t)(((int16_t)trim_data.dig_xy1) * 128); + int32_t process_comp_x6 = ((int32_t)retval) * process_comp_x5; + int32_t process_comp_x7 = (((process_comp_x4 + process_comp_x6) / 512) + ((int32_t)0x100000)); + int32_t process_comp_x8 = ((int32_t)(((int16_t)trim_data.dig_x2) + ((int16_t)0xA0))); + int32_t process_comp_x9 = ((process_comp_x7 * process_comp_x8) / 4096); + int32_t process_comp_x10 = ((int32_t)mag_data_x) * process_comp_x9; + retval = ((int16_t)(process_comp_x10 / 8192)); + retval = (retval + (((int16_t)trim_data.dig_x1) * 8)) / 16; + + return retval; +} + +static int16_t compensate_y(trim_data_t trim_data, int16_t mag_data_y, uint16_t data_rhall) { + uint16_t process_comp_y0 = trim_data.dig_xyz1; + int32_t process_comp_y1 = (((int32_t)trim_data.dig_xyz1) * 16384) / process_comp_y0; + uint16_t process_comp_y2 = ((uint16_t)process_comp_y1) - ((uint16_t)0x4000); + int16_t retval = ((int16_t)process_comp_y2); + int32_t process_comp_y3 = ((int32_t) retval) * ((int32_t)retval); + int32_t process_comp_y4 = ((int32_t)trim_data.dig_xy2) * (process_comp_y3 / 128); + int32_t process_comp_y5 = ((int32_t)(((int16_t)trim_data.dig_xy1) * 128)); + int32_t process_comp_y6 = ((process_comp_y4 + (((int32_t)retval) * process_comp_y5)) / 512); + int32_t process_comp_y7 = ((int32_t)(((int16_t)trim_data.dig_y2) + ((int16_t)0xA0))); + int32_t process_comp_y8 = (((process_comp_y6 + ((int32_t)0x100000)) * process_comp_y7) / 4096); + int32_t process_comp_y9 = (((int32_t)mag_data_y) * process_comp_y8); + retval = (int16_t)(process_comp_y9 / 8192); + retval = (retval + (((int16_t)trim_data.dig_y1) * 8)) / 16; + + return retval; +} + +static int16_t compensate_z(trim_data_t trim_data, int16_t mag_data_z, uint16_t data_rhall) { + int16_t process_comp_z0 = ((int16_t)data_rhall) - ((int16_t) trim_data.dig_xyz1); + int32_t process_comp_z1 = (((int32_t)trim_data.dig_z3) * ((int32_t)(process_comp_z0))) / 4; + int32_t process_comp_z2 = (((int32_t)(mag_data_z - trim_data.dig_z4)) * 32768); + int32_t process_comp_z3 = ((int32_t)trim_data.dig_z1) * (((int16_t)data_rhall) * 2); + int16_t process_comp_z4 = (int16_t)((process_comp_z3 + (32768)) / 65536); + int32_t retval = ((process_comp_z2 - process_comp_z1) / (trim_data.dig_z2 + process_comp_z4)); + + /* saturate result to +/- 2 micro-tesla */ + retval = std::clamp(retval, -32767, 32767); + + /* Conversion of LSB to micro-tesla*/ + retval = retval / 16; + + return (int16_t)retval; +} + +BMX055_Magn::BMX055_Magn(I2CBus *bus) : I2CSensor(bus) {} + +int BMX055_Magn::init() { + uint8_t trim_x1y1[2] = {0}; + uint8_t trim_x2y2[2] = {0}; + uint8_t trim_xy1xy2[2] = {0}; + uint8_t trim_z1[2] = {0}; + uint8_t trim_z2[2] = {0}; + uint8_t trim_z3[2] = {0}; + uint8_t trim_z4[2] = {0}; + uint8_t trim_xyz1[2] = {0}; + + // suspend -> sleep + int ret = set_register(BMX055_MAGN_I2C_REG_PWR_0, 0x01); + if (ret < 0) { + LOGD("Enabling power failed: %d", ret); + goto fail; + } + util::sleep_for(5); // wait until the chip is powered on + + ret = verify_chip_id(BMX055_MAGN_I2C_REG_ID, {BMX055_MAGN_CHIP_ID}); + if (ret == -1) { + goto fail; + } + + // Load magnetometer trim + ret = read_register(BMX055_MAGN_I2C_REG_DIG_X1, trim_x1y1, 2); + if (ret < 0) goto fail; + ret = read_register(BMX055_MAGN_I2C_REG_DIG_X2, trim_x2y2, 2); + if (ret < 0) goto fail; + ret = read_register(BMX055_MAGN_I2C_REG_DIG_XY2, trim_xy1xy2, 2); + if (ret < 0) goto fail; + ret = read_register(BMX055_MAGN_I2C_REG_DIG_Z1_LSB, trim_z1, 2); + if (ret < 0) goto fail; + ret = read_register(BMX055_MAGN_I2C_REG_DIG_Z2_LSB, trim_z2, 2); + if (ret < 0) goto fail; + ret = read_register(BMX055_MAGN_I2C_REG_DIG_Z3_LSB, trim_z3, 2); + if (ret < 0) goto fail; + ret = read_register(BMX055_MAGN_I2C_REG_DIG_Z4_LSB, trim_z4, 2); + if (ret < 0) goto fail; + ret = read_register(BMX055_MAGN_I2C_REG_DIG_XYZ1_LSB, trim_xyz1, 2); + if (ret < 0) goto fail; + + // Read trim data + trim_data.dig_x1 = trim_x1y1[0]; + trim_data.dig_y1 = trim_x1y1[1]; + + trim_data.dig_x2 = trim_x2y2[0]; + trim_data.dig_y2 = trim_x2y2[1]; + + trim_data.dig_xy1 = trim_xy1xy2[1]; // NB: MSB/LSB swapped + trim_data.dig_xy2 = trim_xy1xy2[0]; + + trim_data.dig_z1 = read_16_bit(trim_z1[0], trim_z1[1]); + trim_data.dig_z2 = read_16_bit(trim_z2[0], trim_z2[1]); + trim_data.dig_z3 = read_16_bit(trim_z3[0], trim_z3[1]); + trim_data.dig_z4 = read_16_bit(trim_z4[0], trim_z4[1]); + + trim_data.dig_xyz1 = read_16_bit(trim_xyz1[0], trim_xyz1[1] & 0x7f); + assert(trim_data.dig_xyz1 != 0); + + perform_self_test(); + + // f_max = 1 / (145us * nXY + 500us * NZ + 980us) + // Chose NXY = 7, NZ = 12, which gives 125 Hz, + // and has the same ratio as the high accuracy preset + ret = set_register(BMX055_MAGN_I2C_REG_REPXY, (7 - 1) / 2); + if (ret < 0) { + goto fail; + } + + ret = set_register(BMX055_MAGN_I2C_REG_REPZ, 12 - 1); + if (ret < 0) { + goto fail; + } + + enabled = true; + return 0; + + fail: + return ret; +} + +int BMX055_Magn::shutdown() { + if (!enabled) return 0; + + // move to suspend mode + int ret = set_register(BMX055_MAGN_I2C_REG_PWR_0, 0); + if (ret < 0) { + LOGE("Could not move BMX055 MAGN in suspend mode!"); + } + + return ret; +} + +bool BMX055_Magn::perform_self_test() { + uint8_t buffer[8]; + int16_t x, y; + int16_t neg_z, pos_z; + + // Increase z reps for less false positives (~30 Hz ODR) + set_register(BMX055_MAGN_I2C_REG_REPXY, 1); + set_register(BMX055_MAGN_I2C_REG_REPZ, 64 - 1); + + // Clean existing measurement + read_register(BMX055_MAGN_I2C_REG_DATAX_LSB, buffer, sizeof(buffer)); + + uint8_t forced = BMX055_MAGN_FORCED; + + // Negative current + set_register(BMX055_MAGN_I2C_REG_MAG, forced | (uint8_t(0b10) << 6)); + util::sleep_for(100); + + read_register(BMX055_MAGN_I2C_REG_DATAX_LSB, buffer, sizeof(buffer)); + parse_xyz(buffer, &x, &y, &neg_z); + + // Positive current + set_register(BMX055_MAGN_I2C_REG_MAG, forced | (uint8_t(0b11) << 6)); + util::sleep_for(100); + + read_register(BMX055_MAGN_I2C_REG_DATAX_LSB, buffer, sizeof(buffer)); + parse_xyz(buffer, &x, &y, &pos_z); + + // Put back in normal mode + set_register(BMX055_MAGN_I2C_REG_MAG, 0); + + int16_t diff = pos_z - neg_z; + bool passed = (diff > 180) && (diff < 240); + + if (!passed) { + LOGE("self test failed: neg %d pos %d diff %d", neg_z, pos_z, diff); + } + + return passed; +} + +bool BMX055_Magn::parse_xyz(uint8_t buffer[8], int16_t *x, int16_t *y, int16_t *z) { + bool ready = buffer[6] & 0x1; + if (ready) { + int16_t mdata_x = (int16_t) (((int16_t)buffer[1] << 8) | buffer[0]) >> 3; + int16_t mdata_y = (int16_t) (((int16_t)buffer[3] << 8) | buffer[2]) >> 3; + int16_t mdata_z = (int16_t) (((int16_t)buffer[5] << 8) | buffer[4]) >> 1; + uint16_t data_r = (uint16_t) (((uint16_t)buffer[7] << 8) | buffer[6]) >> 2; + assert(data_r != 0); + + *x = compensate_x(trim_data, mdata_x, data_r); + *y = compensate_y(trim_data, mdata_y, data_r); + *z = compensate_z(trim_data, mdata_z, data_r); + } + return ready; +} + + +bool BMX055_Magn::get_event(MessageBuilder &msg, uint64_t ts) { + uint64_t start_time = nanos_since_boot(); + uint8_t buffer[8]; + int16_t _x, _y, x, y, z; + + int len = read_register(BMX055_MAGN_I2C_REG_DATAX_LSB, buffer, sizeof(buffer)); + assert(len == sizeof(buffer)); + + bool parsed = parse_xyz(buffer, &_x, &_y, &z); + if (parsed) { + + auto event = msg.initEvent().initMagnetometer(); + event.setSource(cereal::SensorEventData::SensorSource::BMX055); + event.setVersion(2); + event.setSensor(SENSOR_MAGNETOMETER_UNCALIBRATED); + event.setType(SENSOR_TYPE_MAGNETIC_FIELD_UNCALIBRATED); + event.setTimestamp(start_time); + + // Move magnetometer into same reference frame as accel/gryo + x = -_y; + y = _x; + + // Axis convention + x = -x; + y = -y; + + float xyz[] = {(float)x, (float)y, (float)z}; + auto svec = event.initMagneticUncalibrated(); + svec.setV(xyz); + svec.setStatus(true); + } + + // The BMX055 Magnetometer has no FIFO mode. Self running mode only goes + // up to 30 Hz. Therefore we put in forced mode, and request measurements + // at a 100 Hz. When reading the registers we have to check the ready bit + // To verify the measurement was completed this cycle. + set_register(BMX055_MAGN_I2C_REG_MAG, BMX055_MAGN_FORCED); + + return parsed; +} diff --git a/sunnypilot/system/sensord/sensors/bmx055_magn.h b/sunnypilot/system/sensord/sensors/bmx055_magn.h new file mode 100644 index 0000000000..7b1f7853d0 --- /dev/null +++ b/sunnypilot/system/sensord/sensors/bmx055_magn.h @@ -0,0 +1,64 @@ +#pragma once +#include + +#include "sunnypilot/system/sensord/sensors/i2c_sensor.h" + +// Address of the chip on the bus +#define BMX055_MAGN_I2C_ADDR 0x10 + +// Registers of the chip +#define BMX055_MAGN_I2C_REG_ID 0x40 +#define BMX055_MAGN_I2C_REG_PWR_0 0x4B +#define BMX055_MAGN_I2C_REG_MAG 0x4C +#define BMX055_MAGN_I2C_REG_DATAX_LSB 0x42 +#define BMX055_MAGN_I2C_REG_RHALL_LSB 0x48 +#define BMX055_MAGN_I2C_REG_REPXY 0x51 +#define BMX055_MAGN_I2C_REG_REPZ 0x52 + +#define BMX055_MAGN_I2C_REG_DIG_X1 0x5D +#define BMX055_MAGN_I2C_REG_DIG_Y1 0x5E +#define BMX055_MAGN_I2C_REG_DIG_Z4_LSB 0x62 +#define BMX055_MAGN_I2C_REG_DIG_Z4_MSB 0x63 +#define BMX055_MAGN_I2C_REG_DIG_X2 0x64 +#define BMX055_MAGN_I2C_REG_DIG_Y2 0x65 +#define BMX055_MAGN_I2C_REG_DIG_Z2_LSB 0x68 +#define BMX055_MAGN_I2C_REG_DIG_Z2_MSB 0x69 +#define BMX055_MAGN_I2C_REG_DIG_Z1_LSB 0x6A +#define BMX055_MAGN_I2C_REG_DIG_Z1_MSB 0x6B +#define BMX055_MAGN_I2C_REG_DIG_XYZ1_LSB 0x6C +#define BMX055_MAGN_I2C_REG_DIG_XYZ1_MSB 0x6D +#define BMX055_MAGN_I2C_REG_DIG_Z3_LSB 0x6E +#define BMX055_MAGN_I2C_REG_DIG_Z3_MSB 0x6F +#define BMX055_MAGN_I2C_REG_DIG_XY2 0x70 +#define BMX055_MAGN_I2C_REG_DIG_XY1 0x71 + +// Constants +#define BMX055_MAGN_CHIP_ID 0x32 +#define BMX055_MAGN_FORCED (0b01 << 1) + +struct trim_data_t { + int8_t dig_x1; + int8_t dig_y1; + int8_t dig_x2; + int8_t dig_y2; + uint16_t dig_z1; + int16_t dig_z2; + int16_t dig_z3; + int16_t dig_z4; + uint8_t dig_xy1; + int8_t dig_xy2; + uint16_t dig_xyz1; +}; + + +class BMX055_Magn : public I2CSensor{ + uint8_t get_device_address() {return BMX055_MAGN_I2C_ADDR;} + trim_data_t trim_data = {0}; + bool perform_self_test(); + bool parse_xyz(uint8_t buffer[8], int16_t *x, int16_t *y, int16_t *z); +public: + BMX055_Magn(I2CBus *bus); + int init(); + bool get_event(MessageBuilder &msg, uint64_t ts = 0); + int shutdown(); +}; diff --git a/sunnypilot/system/sensord/sensors/bmx055_temp.cc b/sunnypilot/system/sensord/sensors/bmx055_temp.cc new file mode 100644 index 0000000000..3932c66fa6 --- /dev/null +++ b/sunnypilot/system/sensord/sensors/bmx055_temp.cc @@ -0,0 +1,31 @@ +#include "sunnypilot/system/sensord/sensors/bmx055_temp.h" + +#include + +#include "sunnypilot/system/sensord/sensors/bmx055_accel.h" +#include "common/swaglog.h" +#include "common/timing.h" + +BMX055_Temp::BMX055_Temp(I2CBus *bus) : I2CSensor(bus) {} + +int BMX055_Temp::init() { + return verify_chip_id(BMX055_ACCEL_I2C_REG_ID, {BMX055_ACCEL_CHIP_ID}) == -1 ? -1 : 0; +} + +bool BMX055_Temp::get_event(MessageBuilder &msg, uint64_t ts) { + uint64_t start_time = nanos_since_boot(); + uint8_t buffer[1]; + int len = read_register(BMX055_ACCEL_I2C_REG_TEMP, buffer, sizeof(buffer)); + assert(len == sizeof(buffer)); + + float temp = 23.0f + int8_t(buffer[0]) / 2.0f; + + auto event = msg.initEvent().initTemperatureSensor(); + event.setSource(cereal::SensorEventData::SensorSource::BMX055); + event.setVersion(1); + event.setType(SENSOR_TYPE_AMBIENT_TEMPERATURE); + event.setTimestamp(start_time); + event.setTemperature(temp); + + return true; +} diff --git a/sunnypilot/system/sensord/sensors/bmx055_temp.h b/sunnypilot/system/sensord/sensors/bmx055_temp.h new file mode 100644 index 0000000000..02c48dc92c --- /dev/null +++ b/sunnypilot/system/sensord/sensors/bmx055_temp.h @@ -0,0 +1,13 @@ +#pragma once + +#include "sunnypilot/system/sensord/sensors/bmx055_accel.h" +#include "sunnypilot/system/sensord/sensors/i2c_sensor.h" + +class BMX055_Temp : public I2CSensor { + uint8_t get_device_address() {return BMX055_ACCEL_I2C_ADDR;} +public: + BMX055_Temp(I2CBus *bus); + int init(); + bool get_event(MessageBuilder &msg, uint64_t ts = 0); + int shutdown() { return 0; } +}; diff --git a/sunnypilot/system/sensord/sensors/constants.h b/sunnypilot/system/sensord/sensors/constants.h new file mode 100644 index 0000000000..c216f838a5 --- /dev/null +++ b/sunnypilot/system/sensord/sensors/constants.h @@ -0,0 +1,18 @@ +#pragma once + + +#define SENSOR_ACCELEROMETER 1 +#define SENSOR_MAGNETOMETER 2 +#define SENSOR_MAGNETOMETER_UNCALIBRATED 3 +#define SENSOR_GYRO 4 +#define SENSOR_GYRO_UNCALIBRATED 5 +#define SENSOR_LIGHT 7 + +#define SENSOR_TYPE_ACCELEROMETER 1 +#define SENSOR_TYPE_GEOMAGNETIC_FIELD 2 +#define SENSOR_TYPE_GYROSCOPE 4 +#define SENSOR_TYPE_LIGHT 5 +#define SENSOR_TYPE_AMBIENT_TEMPERATURE 13 +#define SENSOR_TYPE_MAGNETIC_FIELD_UNCALIBRATED 14 +#define SENSOR_TYPE_MAGNETIC_FIELD SENSOR_TYPE_GEOMAGNETIC_FIELD +#define SENSOR_TYPE_GYROSCOPE_UNCALIBRATED 16 diff --git a/sunnypilot/system/sensord/sensors/i2c_sensor.cc b/sunnypilot/system/sensord/sensors/i2c_sensor.cc new file mode 100644 index 0000000000..e29f98c721 --- /dev/null +++ b/sunnypilot/system/sensord/sensors/i2c_sensor.cc @@ -0,0 +1,50 @@ +#include "sunnypilot/system/sensord/sensors/i2c_sensor.h" + +int16_t read_12_bit(uint8_t lsb, uint8_t msb) { + uint16_t combined = (uint16_t(msb) << 8) | uint16_t(lsb & 0xF0); + return int16_t(combined) / (1 << 4); +} + +int16_t read_16_bit(uint8_t lsb, uint8_t msb) { + uint16_t combined = (uint16_t(msb) << 8) | uint16_t(lsb); + return int16_t(combined); +} + +int32_t read_20_bit(uint8_t b2, uint8_t b1, uint8_t b0) { + uint32_t combined = (uint32_t(b0) << 16) | (uint32_t(b1) << 8) | uint32_t(b2); + return int32_t(combined) / (1 << 4); +} + +I2CSensor::I2CSensor(I2CBus *bus, int gpio_nr, bool shared_gpio) : + bus(bus), gpio_nr(gpio_nr), shared_gpio(shared_gpio) {} + +I2CSensor::~I2CSensor() { + if (gpio_fd != -1) { + close(gpio_fd); + } +} + +int I2CSensor::read_register(uint register_address, uint8_t *buffer, uint8_t len) { + return bus->read_register(get_device_address(), register_address, buffer, len); +} + +int I2CSensor::set_register(uint register_address, uint8_t data) { + return bus->set_register(get_device_address(), register_address, data); +} + +int I2CSensor::init_gpio() { + if (shared_gpio || gpio_nr == 0) { + return 0; + } + + gpio_fd = gpiochip_get_ro_value_fd("sensord", GPIOCHIP_INT, gpio_nr); + if (gpio_fd < 0) { + return -1; + } + + return 0; +} + +bool I2CSensor::has_interrupt_enabled() { + return gpio_nr != 0; +} diff --git a/sunnypilot/system/sensord/sensors/i2c_sensor.h b/sunnypilot/system/sensord/sensors/i2c_sensor.h new file mode 100644 index 0000000000..6513db7934 --- /dev/null +++ b/sunnypilot/system/sensord/sensors/i2c_sensor.h @@ -0,0 +1,51 @@ +#pragma once + +#include +#include +#include +#include "cereal/gen/cpp/log.capnp.h" + +#include "common/i2c.h" +#include "common/gpio.h" + +#include "common/swaglog.h" +#include "sunnypilot/system/sensord/sensors/constants.h" +#include "sunnypilot/system/sensord/sensors/sensor.h" + +int16_t read_12_bit(uint8_t lsb, uint8_t msb); +int16_t read_16_bit(uint8_t lsb, uint8_t msb); +int32_t read_20_bit(uint8_t b2, uint8_t b1, uint8_t b0); + + +class I2CSensor : public Sensor { +private: + I2CBus *bus; + int gpio_nr; + bool shared_gpio; + virtual uint8_t get_device_address() = 0; + +public: + I2CSensor(I2CBus *bus, int gpio_nr = 0, bool shared_gpio = false); + ~I2CSensor(); + int read_register(uint register_address, uint8_t *buffer, uint8_t len); + int set_register(uint register_address, uint8_t data); + int init_gpio(); + bool has_interrupt_enabled(); + virtual int init() = 0; + virtual bool get_event(MessageBuilder &msg, uint64_t ts = 0) = 0; + virtual int shutdown() = 0; + + int verify_chip_id(uint8_t address, const std::vector &expected_ids) { + uint8_t chip_id = 0; + int ret = read_register(address, &chip_id, 1); + if (ret < 0) { + LOGD("Reading chip ID failed: %d", ret); + return -1; + } + for (int i = 0; i < expected_ids.size(); ++i) { + if (chip_id == expected_ids[i]) return chip_id; + } + LOGE("Chip ID wrong. Got: %d, Expected %d", chip_id, expected_ids[0]); + return -1; + } +}; diff --git a/sunnypilot/system/sensord/sensors/lsm6ds3_accel.cc b/sunnypilot/system/sensord/sensors/lsm6ds3_accel.cc new file mode 100644 index 0000000000..2857106944 --- /dev/null +++ b/sunnypilot/system/sensord/sensors/lsm6ds3_accel.cc @@ -0,0 +1,250 @@ +#include "sunnypilot/system/sensord/sensors/lsm6ds3_accel.h" + +#include +#include +#include + +#include "common/swaglog.h" +#include "common/timing.h" +#include "common/util.h" + +LSM6DS3_Accel::LSM6DS3_Accel(I2CBus *bus, int gpio_nr, bool shared_gpio) : + I2CSensor(bus, gpio_nr, shared_gpio) {} + +void LSM6DS3_Accel::wait_for_data_ready() { + uint8_t drdy = 0; + uint8_t buffer[6]; + + do { + read_register(LSM6DS3_ACCEL_I2C_REG_STAT_REG, &drdy, sizeof(drdy)); + drdy &= LSM6DS3_ACCEL_DRDY_XLDA; + } while (drdy == 0); + + read_register(LSM6DS3_ACCEL_I2C_REG_OUTX_L_XL, buffer, sizeof(buffer)); +} + +void LSM6DS3_Accel::read_and_avg_data(float* out_buf) { + uint8_t drdy = 0; + uint8_t buffer[6]; + + float scaling = 0.061f; + if (source == cereal::SensorEventData::SensorSource::LSM6DS3TRC) { + scaling = 0.122f; + } + + for (int i = 0; i < 5; i++) { + do { + read_register(LSM6DS3_ACCEL_I2C_REG_STAT_REG, &drdy, sizeof(drdy)); + drdy &= LSM6DS3_ACCEL_DRDY_XLDA; + } while (drdy == 0); + + int len = read_register(LSM6DS3_ACCEL_I2C_REG_OUTX_L_XL, buffer, sizeof(buffer)); + assert(len == sizeof(buffer)); + + for (int j = 0; j < 3; j++) { + out_buf[j] += (float)read_16_bit(buffer[j*2], buffer[j*2+1]) * scaling; + } + } + + for (int i = 0; i < 3; i++) { + out_buf[i] /= 5.0f; + } +} + +int LSM6DS3_Accel::self_test(int test_type) { + float val_st_off[3] = {0}; + float val_st_on[3] = {0}; + float test_val[3] = {0}; + uint8_t ODR_FS_MO = LSM6DS3_ACCEL_ODR_52HZ; // full scale: +-2g, ODR: 52Hz + + // prepare sensor for self-test + + // enable block data update and automatic increment + int ret = set_register(LSM6DS3_ACCEL_I2C_REG_CTRL3_C, LSM6DS3_ACCEL_IF_INC_BDU); + if (ret < 0) { + return ret; + } + + if (source == cereal::SensorEventData::SensorSource::LSM6DS3TRC) { + ODR_FS_MO = LSM6DS3_ACCEL_FS_4G | LSM6DS3_ACCEL_ODR_52HZ; + } + ret = set_register(LSM6DS3_ACCEL_I2C_REG_CTRL1_XL, ODR_FS_MO); + if (ret < 0) { + return ret; + } + + // wait for stable output, and discard first values + util::sleep_for(100); + wait_for_data_ready(); + read_and_avg_data(val_st_off); + + // enable Self Test positive (or negative) + ret = set_register(LSM6DS3_ACCEL_I2C_REG_CTRL5_C, test_type); + if (ret < 0) { + return ret; + } + + // wait for stable output, and discard first values + util::sleep_for(100); + wait_for_data_ready(); + read_and_avg_data(val_st_on); + + // disable sensor + ret = set_register(LSM6DS3_ACCEL_I2C_REG_CTRL1_XL, 0); + if (ret < 0) { + return ret; + } + + // disable self test + ret = set_register(LSM6DS3_ACCEL_I2C_REG_CTRL5_C, 0); + if (ret < 0) { + return ret; + } + + // calculate the mg values for self test + for (int i = 0; i < 3; i++) { + test_val[i] = fabs(val_st_on[i] - val_st_off[i]); + } + + // verify test result + for (int i = 0; i < 3; i++) { + if ((LSM6DS3_ACCEL_MIN_ST_LIMIT_mg > test_val[i]) || + (test_val[i] > LSM6DS3_ACCEL_MAX_ST_LIMIT_mg)) { + return -1; + } + } + + return ret; +} + +int LSM6DS3_Accel::init() { + uint8_t value = 0; + bool do_self_test = false; + + const char* env_lsm_selftest = std::getenv("LSM_SELF_TEST"); + if (env_lsm_selftest != nullptr && strncmp(env_lsm_selftest, "1", 1) == 0) { + do_self_test = true; + } + + int ret = verify_chip_id(LSM6DS3_ACCEL_I2C_REG_ID, {LSM6DS3_ACCEL_CHIP_ID, LSM6DS3TRC_ACCEL_CHIP_ID}); + if (ret == -1) return -1; + + if (ret == LSM6DS3TRC_ACCEL_CHIP_ID) { + source = cereal::SensorEventData::SensorSource::LSM6DS3TRC; + } + + ret = self_test(LSM6DS3_ACCEL_POSITIVE_TEST); + if (ret < 0) { + LOGE("LSM6DS3 accel positive self-test failed!"); + if (do_self_test) goto fail; + } + + ret = self_test(LSM6DS3_ACCEL_NEGATIVE_TEST); + if (ret < 0) { + LOGE("LSM6DS3 accel negative self-test failed!"); + if (do_self_test) goto fail; + } + + ret = init_gpio(); + if (ret < 0) { + goto fail; + } + + // enable continuous update, and automatic increase + ret = set_register(LSM6DS3_ACCEL_I2C_REG_CTRL3_C, LSM6DS3_ACCEL_IF_INC); + if (ret < 0) { + goto fail; + } + + // TODO: set scale and bandwidth. Default is +- 2G, 50 Hz + ret = set_register(LSM6DS3_ACCEL_I2C_REG_CTRL1_XL, LSM6DS3_ACCEL_ODR_104HZ); + if (ret < 0) { + goto fail; + } + + ret = set_register(LSM6DS3_ACCEL_I2C_REG_DRDY_CFG, LSM6DS3_ACCEL_DRDY_PULSE_MODE); + if (ret < 0) { + goto fail; + } + + // enable data ready interrupt for accel on INT1 + // (without resetting existing interrupts) + ret = read_register(LSM6DS3_ACCEL_I2C_REG_INT1_CTRL, &value, 1); + if (ret < 0) { + goto fail; + } + + value |= LSM6DS3_ACCEL_INT1_DRDY_XL; + ret = set_register(LSM6DS3_ACCEL_I2C_REG_INT1_CTRL, value); + +fail: + return ret; +} + +int LSM6DS3_Accel::shutdown() { + int ret = 0; + + // disable data ready interrupt for accel on INT1 + uint8_t value = 0; + ret = read_register(LSM6DS3_ACCEL_I2C_REG_INT1_CTRL, &value, 1); + if (ret < 0) { + goto fail; + } + + value &= ~(LSM6DS3_ACCEL_INT1_DRDY_XL); + ret = set_register(LSM6DS3_ACCEL_I2C_REG_INT1_CTRL, value); + if (ret < 0) { + LOGE("Could not disable lsm6ds3 acceleration interrupt!"); + goto fail; + } + + // enable power-down mode + value = 0; + ret = read_register(LSM6DS3_ACCEL_I2C_REG_CTRL1_XL, &value, 1); + if (ret < 0) { + goto fail; + } + + value &= 0x0F; + ret = set_register(LSM6DS3_ACCEL_I2C_REG_CTRL1_XL, value); + if (ret < 0) { + LOGE("Could not power-down lsm6ds3 accelerometer!"); + goto fail; + } + +fail: + return ret; +} + +bool LSM6DS3_Accel::get_event(MessageBuilder &msg, uint64_t ts) { + + // INT1 shared with gyro, check STATUS_REG who triggered + uint8_t status_reg = 0; + read_register(LSM6DS3_ACCEL_I2C_REG_STAT_REG, &status_reg, sizeof(status_reg)); + if ((status_reg & LSM6DS3_ACCEL_DRDY_XLDA) == 0) { + return false; + } + + uint8_t buffer[6]; + int len = read_register(LSM6DS3_ACCEL_I2C_REG_OUTX_L_XL, buffer, sizeof(buffer)); + assert(len == sizeof(buffer)); + + float scale = 9.81 * 2.0f / (1 << 15); + float x = read_16_bit(buffer[0], buffer[1]) * scale; + float y = read_16_bit(buffer[2], buffer[3]) * scale; + float z = read_16_bit(buffer[4], buffer[5]) * scale; + + auto event = msg.initEvent().initAccelerometer(); + event.setSource(source); + event.setVersion(1); + event.setSensor(SENSOR_ACCELEROMETER); + event.setType(SENSOR_TYPE_ACCELEROMETER); + event.setTimestamp(ts); + + float xyz[] = {y, -x, z}; + auto svec = event.initAcceleration(); + svec.setV(xyz); + svec.setStatus(true); + + return true; +} diff --git a/sunnypilot/system/sensord/sensors/lsm6ds3_accel.h b/sunnypilot/system/sensord/sensors/lsm6ds3_accel.h new file mode 100644 index 0000000000..2ac61c5a0f --- /dev/null +++ b/sunnypilot/system/sensord/sensors/lsm6ds3_accel.h @@ -0,0 +1,49 @@ +#pragma once + +#include "sunnypilot/system/sensord/sensors/i2c_sensor.h" + +// Address of the chip on the bus +#define LSM6DS3_ACCEL_I2C_ADDR 0x6A + +// Registers of the chip +#define LSM6DS3_ACCEL_I2C_REG_DRDY_CFG 0x0B +#define LSM6DS3_ACCEL_I2C_REG_ID 0x0F +#define LSM6DS3_ACCEL_I2C_REG_INT1_CTRL 0x0D +#define LSM6DS3_ACCEL_I2C_REG_CTRL1_XL 0x10 +#define LSM6DS3_ACCEL_I2C_REG_CTRL3_C 0x12 +#define LSM6DS3_ACCEL_I2C_REG_CTRL5_C 0x14 +#define LSM6DS3_ACCEL_I2C_REG_CTR9_XL 0x18 +#define LSM6DS3_ACCEL_I2C_REG_STAT_REG 0x1E +#define LSM6DS3_ACCEL_I2C_REG_OUTX_L_XL 0x28 + +// Constants +#define LSM6DS3_ACCEL_CHIP_ID 0x69 +#define LSM6DS3TRC_ACCEL_CHIP_ID 0x6A +#define LSM6DS3_ACCEL_FS_4G (0b10 << 2) +#define LSM6DS3_ACCEL_ODR_52HZ (0b0011 << 4) +#define LSM6DS3_ACCEL_ODR_104HZ (0b0100 << 4) +#define LSM6DS3_ACCEL_INT1_DRDY_XL 0b1 +#define LSM6DS3_ACCEL_DRDY_XLDA 0b1 +#define LSM6DS3_ACCEL_DRDY_PULSE_MODE (1 << 7) +#define LSM6DS3_ACCEL_IF_INC 0b00000100 +#define LSM6DS3_ACCEL_IF_INC_BDU 0b01000100 +#define LSM6DS3_ACCEL_XYZ_DEN 0b11100000 +#define LSM6DS3_ACCEL_POSITIVE_TEST 0b01 +#define LSM6DS3_ACCEL_NEGATIVE_TEST 0b10 +#define LSM6DS3_ACCEL_MIN_ST_LIMIT_mg 90.0f +#define LSM6DS3_ACCEL_MAX_ST_LIMIT_mg 1700.0f + +class LSM6DS3_Accel : public I2CSensor { + uint8_t get_device_address() {return LSM6DS3_ACCEL_I2C_ADDR;} + cereal::SensorEventData::SensorSource source = cereal::SensorEventData::SensorSource::LSM6DS3; + + // self test functions + int self_test(int test_type); + void wait_for_data_ready(); + void read_and_avg_data(float* val_st_off); +public: + LSM6DS3_Accel(I2CBus *bus, int gpio_nr = 0, bool shared_gpio = false); + int init(); + bool get_event(MessageBuilder &msg, uint64_t ts = 0); + int shutdown(); +}; diff --git a/sunnypilot/system/sensord/sensors/lsm6ds3_gyro.cc b/sunnypilot/system/sensord/sensors/lsm6ds3_gyro.cc new file mode 100644 index 0000000000..e881f3942a --- /dev/null +++ b/sunnypilot/system/sensord/sensors/lsm6ds3_gyro.cc @@ -0,0 +1,233 @@ +#include "sunnypilot/system/sensord/sensors/lsm6ds3_gyro.h" + +#include +#include +#include + +#include "common/swaglog.h" +#include "common/timing.h" +#include "common/util.h" + +#define DEG2RAD(x) ((x) * M_PI / 180.0) + +LSM6DS3_Gyro::LSM6DS3_Gyro(I2CBus *bus, int gpio_nr, bool shared_gpio) : + I2CSensor(bus, gpio_nr, shared_gpio) {} + +void LSM6DS3_Gyro::wait_for_data_ready() { + uint8_t drdy = 0; + uint8_t buffer[6]; + + do { + read_register(LSM6DS3_GYRO_I2C_REG_STAT_REG, &drdy, sizeof(drdy)); + drdy &= LSM6DS3_GYRO_DRDY_GDA; + } while (drdy == 0); + + read_register(LSM6DS3_GYRO_I2C_REG_OUTX_L_G, buffer, sizeof(buffer)); +} + +void LSM6DS3_Gyro::read_and_avg_data(float* out_buf) { + uint8_t drdy = 0; + uint8_t buffer[6]; + + for (int i = 0; i < 5; i++) { + do { + read_register(LSM6DS3_GYRO_I2C_REG_STAT_REG, &drdy, sizeof(drdy)); + drdy &= LSM6DS3_GYRO_DRDY_GDA; + } while (drdy == 0); + + int len = read_register(LSM6DS3_GYRO_I2C_REG_OUTX_L_G, buffer, sizeof(buffer)); + assert(len == sizeof(buffer)); + + for (int j = 0; j < 3; j++) { + out_buf[j] += (float)read_16_bit(buffer[j*2], buffer[j*2+1]) * 70.0f; + } + } + + // calculate the mg average values + for (int i = 0; i < 3; i++) { + out_buf[i] /= 5.0f; + } +} + +int LSM6DS3_Gyro::self_test(int test_type) { + float val_st_off[3] = {0}; + float val_st_on[3] = {0}; + float test_val[3] = {0}; + + // prepare sensor for self-test + + // full scale: 2000dps, ODR: 208Hz + int ret = set_register(LSM6DS3_GYRO_I2C_REG_CTRL2_G, LSM6DS3_GYRO_ODR_208HZ | LSM6DS3_GYRO_FS_2000dps); + if (ret < 0) { + return ret; + } + + // wait for stable output, and discard first values + util::sleep_for(150); + wait_for_data_ready(); + read_and_avg_data(val_st_off); + + // enable Self Test positive (or negative) + ret = set_register(LSM6DS3_GYRO_I2C_REG_CTRL5_C, test_type); + if (ret < 0) { + return ret; + } + + // wait for stable output, and discard first values + util::sleep_for(50); + wait_for_data_ready(); + read_and_avg_data(val_st_on); + + // disable sensor + ret = set_register(LSM6DS3_GYRO_I2C_REG_CTRL2_G, 0); + if (ret < 0) { + return ret; + } + + // disable self test + ret = set_register(LSM6DS3_GYRO_I2C_REG_CTRL5_C, 0); + if (ret < 0) { + return ret; + } + + // calculate the mg values for self test + for (int i = 0; i < 3; i++) { + test_val[i] = fabs(val_st_on[i] - val_st_off[i]); + } + + // verify test result + for (int i = 0; i < 3; i++) { + if ((LSM6DS3_GYRO_MIN_ST_LIMIT_mdps > test_val[i]) || + (test_val[i] > LSM6DS3_GYRO_MAX_ST_LIMIT_mdps)) { + return -1; + } + } + + return ret; +} + +int LSM6DS3_Gyro::init() { + uint8_t value = 0; + bool do_self_test = false; + + const char* env_lsm_selftest = std::getenv("LSM_SELF_TEST"); + if (env_lsm_selftest != nullptr && strncmp(env_lsm_selftest, "1", 1) == 0) { + do_self_test = true; + } + + int ret = verify_chip_id(LSM6DS3_GYRO_I2C_REG_ID, {LSM6DS3_GYRO_CHIP_ID, LSM6DS3TRC_GYRO_CHIP_ID}); + if (ret == -1) return -1; + + if (ret == LSM6DS3TRC_GYRO_CHIP_ID) { + source = cereal::SensorEventData::SensorSource::LSM6DS3TRC; + } + + ret = init_gpio(); + if (ret < 0) { + goto fail; + } + + ret = self_test(LSM6DS3_GYRO_POSITIVE_TEST); + if (ret < 0) { + LOGE("LSM6DS3 gyro positive self-test failed!"); + if (do_self_test) goto fail; + } + + ret = self_test(LSM6DS3_GYRO_NEGATIVE_TEST); + if (ret < 0) { + LOGE("LSM6DS3 gyro negative self-test failed!"); + if (do_self_test) goto fail; + } + + // TODO: set scale. Default is +- 250 deg/s + ret = set_register(LSM6DS3_GYRO_I2C_REG_CTRL2_G, LSM6DS3_GYRO_ODR_104HZ); + if (ret < 0) { + goto fail; + } + + ret = set_register(LSM6DS3_GYRO_I2C_REG_DRDY_CFG, LSM6DS3_GYRO_DRDY_PULSE_MODE); + if (ret < 0) { + goto fail; + } + + // enable data ready interrupt for gyro on INT1 + // (without resetting existing interrupts) + ret = read_register(LSM6DS3_GYRO_I2C_REG_INT1_CTRL, &value, 1); + if (ret < 0) { + goto fail; + } + + value |= LSM6DS3_GYRO_INT1_DRDY_G; + ret = set_register(LSM6DS3_GYRO_I2C_REG_INT1_CTRL, value); + +fail: + return ret; +} + +int LSM6DS3_Gyro::shutdown() { + int ret = 0; + + // disable data ready interrupt for gyro on INT1 + uint8_t value = 0; + ret = read_register(LSM6DS3_GYRO_I2C_REG_INT1_CTRL, &value, 1); + if (ret < 0) { + goto fail; + } + + value &= ~(LSM6DS3_GYRO_INT1_DRDY_G); + ret = set_register(LSM6DS3_GYRO_I2C_REG_INT1_CTRL, value); + if (ret < 0) { + LOGE("Could not disable lsm6ds3 gyroscope interrupt!"); + goto fail; + } + + // enable power-down mode + value = 0; + ret = read_register(LSM6DS3_GYRO_I2C_REG_CTRL2_G, &value, 1); + if (ret < 0) { + goto fail; + } + + value &= 0x0F; + ret = set_register(LSM6DS3_GYRO_I2C_REG_CTRL2_G, value); + if (ret < 0) { + LOGE("Could not power-down lsm6ds3 gyroscope!"); + goto fail; + } + +fail: + return ret; +} + +bool LSM6DS3_Gyro::get_event(MessageBuilder &msg, uint64_t ts) { + + // INT1 shared with accel, check STATUS_REG who triggered + uint8_t status_reg = 0; + read_register(LSM6DS3_GYRO_I2C_REG_STAT_REG, &status_reg, sizeof(status_reg)); + if ((status_reg & LSM6DS3_GYRO_DRDY_GDA) == 0) { + return false; + } + + uint8_t buffer[6]; + int len = read_register(LSM6DS3_GYRO_I2C_REG_OUTX_L_G, buffer, sizeof(buffer)); + assert(len == sizeof(buffer)); + + float scale = 8.75 / 1000.0; + float x = DEG2RAD(read_16_bit(buffer[0], buffer[1]) * scale); + float y = DEG2RAD(read_16_bit(buffer[2], buffer[3]) * scale); + float z = DEG2RAD(read_16_bit(buffer[4], buffer[5]) * scale); + + auto event = msg.initEvent().initGyroscope(); + event.setSource(source); + event.setVersion(2); + event.setSensor(SENSOR_GYRO_UNCALIBRATED); + event.setType(SENSOR_TYPE_GYROSCOPE_UNCALIBRATED); + event.setTimestamp(ts); + + float xyz[] = {y, -x, z}; + auto svec = event.initGyroUncalibrated(); + svec.setV(xyz); + svec.setStatus(true); + + return true; +} diff --git a/sunnypilot/system/sensord/sensors/lsm6ds3_gyro.h b/sunnypilot/system/sensord/sensors/lsm6ds3_gyro.h new file mode 100644 index 0000000000..686a6a7169 --- /dev/null +++ b/sunnypilot/system/sensord/sensors/lsm6ds3_gyro.h @@ -0,0 +1,45 @@ +#pragma once + +#include "sunnypilot/system/sensord/sensors/i2c_sensor.h" + +// Address of the chip on the bus +#define LSM6DS3_GYRO_I2C_ADDR 0x6A + +// Registers of the chip +#define LSM6DS3_GYRO_I2C_REG_DRDY_CFG 0x0B +#define LSM6DS3_GYRO_I2C_REG_ID 0x0F +#define LSM6DS3_GYRO_I2C_REG_INT1_CTRL 0x0D +#define LSM6DS3_GYRO_I2C_REG_CTRL2_G 0x11 +#define LSM6DS3_GYRO_I2C_REG_CTRL5_C 0x14 +#define LSM6DS3_GYRO_I2C_REG_STAT_REG 0x1E +#define LSM6DS3_GYRO_I2C_REG_OUTX_L_G 0x22 +#define LSM6DS3_GYRO_POSITIVE_TEST (0b01 << 2) +#define LSM6DS3_GYRO_NEGATIVE_TEST (0b11 << 2) + +// Constants +#define LSM6DS3_GYRO_CHIP_ID 0x69 +#define LSM6DS3TRC_GYRO_CHIP_ID 0x6A +#define LSM6DS3_GYRO_FS_2000dps (0b11 << 2) +#define LSM6DS3_GYRO_ODR_104HZ (0b0100 << 4) +#define LSM6DS3_GYRO_ODR_208HZ (0b0101 << 4) +#define LSM6DS3_GYRO_INT1_DRDY_G 0b10 +#define LSM6DS3_GYRO_DRDY_GDA 0b10 +#define LSM6DS3_GYRO_DRDY_PULSE_MODE (1 << 7) +#define LSM6DS3_GYRO_MIN_ST_LIMIT_mdps 150000.0f +#define LSM6DS3_GYRO_MAX_ST_LIMIT_mdps 700000.0f + + +class LSM6DS3_Gyro : public I2CSensor { + uint8_t get_device_address() {return LSM6DS3_GYRO_I2C_ADDR;} + cereal::SensorEventData::SensorSource source = cereal::SensorEventData::SensorSource::LSM6DS3; + + // self test functions + int self_test(int test_type); + void wait_for_data_ready(); + void read_and_avg_data(float* val_st_off); +public: + LSM6DS3_Gyro(I2CBus *bus, int gpio_nr = 0, bool shared_gpio = false); + int init(); + bool get_event(MessageBuilder &msg, uint64_t ts = 0); + int shutdown(); +}; diff --git a/sunnypilot/system/sensord/sensors/lsm6ds3_temp.cc b/sunnypilot/system/sensord/sensors/lsm6ds3_temp.cc new file mode 100644 index 0000000000..390f192a2d --- /dev/null +++ b/sunnypilot/system/sensord/sensors/lsm6ds3_temp.cc @@ -0,0 +1,37 @@ +#include "sunnypilot/system/sensord/sensors/lsm6ds3_temp.h" + +#include + +#include "common/swaglog.h" +#include "common/timing.h" + +LSM6DS3_Temp::LSM6DS3_Temp(I2CBus *bus) : I2CSensor(bus) {} + +int LSM6DS3_Temp::init() { + int ret = verify_chip_id(LSM6DS3_TEMP_I2C_REG_ID, {LSM6DS3_TEMP_CHIP_ID, LSM6DS3TRC_TEMP_CHIP_ID}); + if (ret == -1) return -1; + + if (ret == LSM6DS3TRC_TEMP_CHIP_ID) { + source = cereal::SensorEventData::SensorSource::LSM6DS3TRC; + } + return 0; +} + +bool LSM6DS3_Temp::get_event(MessageBuilder &msg, uint64_t ts) { + uint64_t start_time = nanos_since_boot(); + uint8_t buffer[2]; + int len = read_register(LSM6DS3_TEMP_I2C_REG_OUT_TEMP_L, buffer, sizeof(buffer)); + assert(len == sizeof(buffer)); + + float scale = (source == cereal::SensorEventData::SensorSource::LSM6DS3TRC) ? 256.0f : 16.0f; + float temp = 25.0f + read_16_bit(buffer[0], buffer[1]) / scale; + + auto event = msg.initEvent().initTemperatureSensor(); + event.setSource(source); + event.setVersion(1); + event.setType(SENSOR_TYPE_AMBIENT_TEMPERATURE); + event.setTimestamp(start_time); + event.setTemperature(temp); + + return true; +} diff --git a/sunnypilot/system/sensord/sensors/lsm6ds3_temp.h b/sunnypilot/system/sensord/sensors/lsm6ds3_temp.h new file mode 100644 index 0000000000..c314d456e8 --- /dev/null +++ b/sunnypilot/system/sensord/sensors/lsm6ds3_temp.h @@ -0,0 +1,26 @@ +#pragma once + +#include "sunnypilot/system/sensord/sensors/i2c_sensor.h" + +// Address of the chip on the bus +#define LSM6DS3_TEMP_I2C_ADDR 0x6A + +// Registers of the chip +#define LSM6DS3_TEMP_I2C_REG_ID 0x0F +#define LSM6DS3_TEMP_I2C_REG_OUT_TEMP_L 0x20 + +// Constants +#define LSM6DS3_TEMP_CHIP_ID 0x69 +#define LSM6DS3TRC_TEMP_CHIP_ID 0x6A + + +class LSM6DS3_Temp : public I2CSensor { + uint8_t get_device_address() {return LSM6DS3_TEMP_I2C_ADDR;} + cereal::SensorEventData::SensorSource source = cereal::SensorEventData::SensorSource::LSM6DS3; + +public: + LSM6DS3_Temp(I2CBus *bus); + int init(); + bool get_event(MessageBuilder &msg, uint64_t ts = 0); + int shutdown() { return 0; } +}; diff --git a/sunnypilot/system/sensord/sensors/mmc5603nj_magn.cc b/sunnypilot/system/sensord/sensors/mmc5603nj_magn.cc new file mode 100644 index 0000000000..12d438ebd1 --- /dev/null +++ b/sunnypilot/system/sensord/sensors/mmc5603nj_magn.cc @@ -0,0 +1,108 @@ +#include "sunnypilot/system/sensord/sensors/mmc5603nj_magn.h" + +#include +#include +#include + +#include "common/swaglog.h" +#include "common/timing.h" +#include "common/util.h" + +MMC5603NJ_Magn::MMC5603NJ_Magn(I2CBus *bus) : I2CSensor(bus) {} + +int MMC5603NJ_Magn::init() { + int ret = verify_chip_id(MMC5603NJ_I2C_REG_ID, {MMC5603NJ_CHIP_ID}); + if (ret == -1) return -1; + + // Set ODR to 0 + ret = set_register(MMC5603NJ_I2C_REG_ODR, 0); + if (ret < 0) { + goto fail; + } + + // Set BW to 0b01 for 1-150 Hz operation + ret = set_register(MMC5603NJ_I2C_REG_INTERNAL_1, 0b01); + if (ret < 0) { + goto fail; + } + +fail: + return ret; +} + +int MMC5603NJ_Magn::shutdown() { + int ret = 0; + + // disable auto reset of measurements + uint8_t value = 0; + ret = read_register(MMC5603NJ_I2C_REG_INTERNAL_0, &value, 1); + if (ret < 0) { + goto fail; + } + + value &= ~(MMC5603NJ_CMM_FREQ_EN | MMC5603NJ_AUTO_SR_EN); + ret = set_register(MMC5603NJ_I2C_REG_INTERNAL_0, value); + if (ret < 0) { + goto fail; + } + + // set ODR to 0 to leave continuous mode + ret = set_register(MMC5603NJ_I2C_REG_ODR, 0); + if (ret < 0) { + goto fail; + } + return ret; + +fail: + LOGE("Could not disable mmc5603nj auto set reset"); + return ret; +} + +void MMC5603NJ_Magn::start_measurement() { + set_register(MMC5603NJ_I2C_REG_INTERNAL_0, 0b01); + util::sleep_for(5); +} + +std::vector MMC5603NJ_Magn::read_measurement() { + int len; + uint8_t buffer[9]; + len = read_register(MMC5603NJ_I2C_REG_XOUT0, buffer, sizeof(buffer)); + assert(len == sizeof(buffer)); + float scale = 1.0 / 16384.0; + float x = (read_20_bit(buffer[6], buffer[1], buffer[0]) * scale) - 32.0; + float y = (read_20_bit(buffer[7], buffer[3], buffer[2]) * scale) - 32.0; + float z = (read_20_bit(buffer[8], buffer[5], buffer[4]) * scale) - 32.0; + std::vector xyz = {x, y, z}; + return xyz; +} + +bool MMC5603NJ_Magn::get_event(MessageBuilder &msg, uint64_t ts) { + uint64_t start_time = nanos_since_boot(); + // SET - RESET cycle + set_register(MMC5603NJ_I2C_REG_INTERNAL_0, MMC5603NJ_SET); + util::sleep_for(5); + MMC5603NJ_Magn::start_measurement(); + std::vector xyz = MMC5603NJ_Magn::read_measurement(); + + set_register(MMC5603NJ_I2C_REG_INTERNAL_0, MMC5603NJ_RESET); + util::sleep_for(5); + MMC5603NJ_Magn::start_measurement(); + std::vector reset_xyz = MMC5603NJ_Magn::read_measurement(); + + auto event = msg.initEvent().initMagnetometer(); + event.setSource(cereal::SensorEventData::SensorSource::MMC5603NJ); + event.setVersion(1); + event.setSensor(SENSOR_MAGNETOMETER_UNCALIBRATED); + event.setType(SENSOR_TYPE_MAGNETIC_FIELD_UNCALIBRATED); + event.setTimestamp(start_time); + + float vals[] = {xyz[0], xyz[1], xyz[2], reset_xyz[0], reset_xyz[1], reset_xyz[2]}; + bool valid = true; + if (std::any_of(std::begin(vals), std::end(vals), [](float val) { return val == -32.0; })) { + valid = false; + } + auto svec = event.initMagneticUncalibrated(); + svec.setV(vals); + svec.setStatus(valid); + return true; +} diff --git a/sunnypilot/system/sensord/sensors/mmc5603nj_magn.h b/sunnypilot/system/sensord/sensors/mmc5603nj_magn.h new file mode 100644 index 0000000000..9311631c06 --- /dev/null +++ b/sunnypilot/system/sensord/sensors/mmc5603nj_magn.h @@ -0,0 +1,37 @@ +#pragma once + +#include + +#include "sunnypilot/system/sensord/sensors/i2c_sensor.h" + +// Address of the chip on the bus +#define MMC5603NJ_I2C_ADDR 0x30 + +// Registers of the chip +#define MMC5603NJ_I2C_REG_XOUT0 0x00 +#define MMC5603NJ_I2C_REG_ODR 0x1A +#define MMC5603NJ_I2C_REG_INTERNAL_0 0x1B +#define MMC5603NJ_I2C_REG_INTERNAL_1 0x1C +#define MMC5603NJ_I2C_REG_INTERNAL_2 0x1D +#define MMC5603NJ_I2C_REG_ID 0x39 + +// Constants +#define MMC5603NJ_CHIP_ID 0x10 +#define MMC5603NJ_CMM_FREQ_EN (1 << 7) +#define MMC5603NJ_AUTO_SR_EN (1 << 5) +#define MMC5603NJ_CMM_EN (1 << 4) +#define MMC5603NJ_EN_PRD_SET (1 << 3) +#define MMC5603NJ_SET (1 << 3) +#define MMC5603NJ_RESET (1 << 4) + +class MMC5603NJ_Magn : public I2CSensor { +private: + uint8_t get_device_address() {return MMC5603NJ_I2C_ADDR;} + void start_measurement(); + std::vector read_measurement(); +public: + MMC5603NJ_Magn(I2CBus *bus); + int init(); + bool get_event(MessageBuilder &msg, uint64_t ts = 0); + int shutdown(); +}; diff --git a/sunnypilot/system/sensord/sensors/sensor.h b/sunnypilot/system/sensord/sensors/sensor.h new file mode 100644 index 0000000000..ccf998d161 --- /dev/null +++ b/sunnypilot/system/sensord/sensors/sensor.h @@ -0,0 +1,24 @@ +#pragma once + +#include "cereal/messaging/messaging.h" + +class Sensor { +public: + int gpio_fd = -1; + bool enabled = false; + uint64_t start_ts = 0; + uint64_t init_delay = 500e6; // default dealy 500ms + + virtual ~Sensor() {} + virtual int init() = 0; + virtual bool get_event(MessageBuilder &msg, uint64_t ts = 0) = 0; + virtual bool has_interrupt_enabled() = 0; + virtual int shutdown() = 0; + + virtual bool is_data_valid(uint64_t current_ts) { + if (start_ts == 0) { + start_ts = current_ts; + } + return (current_ts - start_ts) > init_delay; + } +}; diff --git a/sunnypilot/system/sensord/sensors_qcom2.cc b/sunnypilot/system/sensord/sensors_qcom2.cc new file mode 100644 index 0000000000..f4ecc7fc21 --- /dev/null +++ b/sunnypilot/system/sensord/sensors_qcom2.cc @@ -0,0 +1,179 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include "cereal/services.h" +#include "cereal/messaging/messaging.h" +#include "common/i2c.h" +#include "common/ratekeeper.h" +#include "common/swaglog.h" +#include "common/timing.h" +#include "common/util.h" +#include "sunnypilot/system/sensord/sensors/bmx055_accel.h" +#include "sunnypilot/system/sensord/sensors/bmx055_gyro.h" +#include "sunnypilot/system/sensord/sensors/bmx055_magn.h" +#include "sunnypilot/system/sensord/sensors/bmx055_temp.h" +#include "sunnypilot/system/sensord/sensors/constants.h" +#include "sunnypilot/system/sensord/sensors/lsm6ds3_accel.h" +#include "sunnypilot/system/sensord/sensors/lsm6ds3_gyro.h" +#include "sunnypilot/system/sensord/sensors/lsm6ds3_temp.h" +#include "sunnypilot/system/sensord/sensors/mmc5603nj_magn.h" + +#define I2C_BUS_IMU 1 + +ExitHandler do_exit; + +void interrupt_loop(std::vector> sensors) { + PubMaster pm({"gyroscope", "accelerometer"}); + + int fd = -1; + for (auto &[sensor, msg_name] : sensors) { + if (sensor->has_interrupt_enabled()) { + fd = sensor->gpio_fd; + break; + } + } + + uint64_t offset = nanos_since_epoch() - nanos_since_boot(); + struct pollfd fd_list[1] = {0}; + fd_list[0].fd = fd; + fd_list[0].events = POLLIN | POLLPRI; + + while (!do_exit) { + int err = poll(fd_list, 1, 100); + if (err == -1) { + if (errno == EINTR) { + continue; + } + return; + } else if (err == 0) { + LOGE("poll timed out"); + continue; + } + + if ((fd_list[0].revents & (POLLIN | POLLPRI)) == 0) { + LOGE("no poll events set"); + continue; + } + + // Read all events + struct gpioevent_data evdata[16]; + err = HANDLE_EINTR(read(fd, evdata, sizeof(evdata))); + if (err < 0 || err % sizeof(*evdata) != 0) { + LOGE("error reading event data %d", err); + continue; + } + + uint64_t cur_offset = nanos_since_epoch() - nanos_since_boot(); + uint64_t diff = cur_offset > offset ? cur_offset - offset : offset - cur_offset; + if (diff > 10*1e6) { // 10ms + LOGW("time jumped: %lu %lu", cur_offset, offset); + offset = cur_offset; + + // we don't have a valid timestamp since the + // time jumped, so throw out this measurement. + continue; + } + + int num_events = err / sizeof(*evdata); + uint64_t ts = evdata[num_events - 1].timestamp - cur_offset; + + for (auto &[sensor, msg_name] : sensors) { + if (!sensor->has_interrupt_enabled()) { + continue; + } + + MessageBuilder msg; + if (!sensor->get_event(msg, ts)) { + continue; + } + + if (!sensor->is_data_valid(ts)) { + continue; + } + + pm.send(msg_name.c_str(), msg); + } + } +} + +void polling_loop(Sensor *sensor, std::string msg_name) { + PubMaster pm({msg_name.c_str()}); + RateKeeper rk(msg_name, services.at(msg_name).frequency); + while (!do_exit) { + MessageBuilder msg; + if (sensor->get_event(msg) && sensor->is_data_valid(nanos_since_boot())) { + pm.send(msg_name.c_str(), msg); + } + rk.keepTime(); + } +} + +int sensor_loop(I2CBus *i2c_bus_imu) { + // Sensor init + std::vector> sensors_init = { + {new BMX055_Accel(i2c_bus_imu), "accelerometer2"}, + {new BMX055_Gyro(i2c_bus_imu), "gyroscope2"}, + {new BMX055_Magn(i2c_bus_imu), "magnetometer"}, + {new BMX055_Temp(i2c_bus_imu), "temperatureSensor2"}, + + {new LSM6DS3_Accel(i2c_bus_imu, GPIO_LSM_INT), "accelerometer"}, + {new LSM6DS3_Gyro(i2c_bus_imu, GPIO_LSM_INT, true), "gyroscope"}, + {new LSM6DS3_Temp(i2c_bus_imu), "temperatureSensor"}, + + {new MMC5603NJ_Magn(i2c_bus_imu), "magnetometer"}, + }; + + // Initialize sensors + std::vector threads; + for (auto &[sensor, msg_name] : sensors_init) { + int err = sensor->init(); + if (err < 0) { + continue; + } + + if (!sensor->has_interrupt_enabled()) { + threads.emplace_back(polling_loop, sensor, msg_name); + } + } + + // increase interrupt quality by pinning interrupt and process to core 1 + setpriority(PRIO_PROCESS, 0, -18); + util::set_core_affinity({1}); + + // TODO: get the IRQ number from gpiochip + std::string irq_path = "/proc/irq/336/smp_affinity_list"; + if (!util::file_exists(irq_path)) { + irq_path = "/proc/irq/335/smp_affinity_list"; + } + std::system(util::string_format("sudo su -c 'echo 1 > %s'", irq_path.c_str()).c_str()); + + // thread for reading events via interrupts + threads.emplace_back(&interrupt_loop, std::ref(sensors_init)); + + // wait for all threads to finish + for (auto &t : threads) { + t.join(); + } + + for (auto &[sensor, msg_name] : sensors_init) { + sensor->shutdown(); + delete sensor; + } + return 0; +} + +int main(int argc, char *argv[]) { + try { + auto i2c_bus_imu = std::make_unique(I2C_BUS_IMU); + return sensor_loop(i2c_bus_imu.get()); + } catch (std::exception &e) { + LOGE("I2CBus init failed"); + return -1; + } +} diff --git a/sunnypilot/system/sensord/tests/test_sensord.py b/sunnypilot/system/sensord/tests/test_sensord.py new file mode 100644 index 0000000000..dc5f93d97b --- /dev/null +++ b/sunnypilot/system/sensord/tests/test_sensord.py @@ -0,0 +1,251 @@ +import os +import pytest +import time +import numpy as np +from collections import namedtuple, defaultdict + +import cereal.messaging as messaging +from cereal import log +from cereal.services import SERVICE_LIST +from openpilot.common.gpio import get_irqs_for_action +from openpilot.common.timeout import Timeout +from openpilot.system.hardware import HARDWARE +from openpilot.system.manager.process_config import managed_processes + +BMX = { + ('bmx055', 'acceleration'), + ('bmx055', 'gyroUncalibrated'), + ('bmx055', 'magneticUncalibrated'), + ('bmx055', 'temperature'), +} + +LSM = { + ('lsm6ds3', 'acceleration'), + ('lsm6ds3', 'gyroUncalibrated'), + ('lsm6ds3', 'temperature'), +} +LSM_C = {(x[0]+'trc', x[1]) for x in LSM} + +MMC = { + ('mmc5603nj', 'magneticUncalibrated'), +} + +SENSOR_CONFIGURATIONS: list[set] = [ + BMX | LSM, + MMC | LSM, + BMX | LSM_C, + MMC| LSM_C, +] +if HARDWARE.get_device_type() == "mici": + SENSOR_CONFIGURATIONS = [ + LSM, + LSM_C, + ] + +Sensor = log.SensorEventData.SensorSource +SensorConfig = namedtuple('SensorConfig', ['type', 'sanity_min', 'sanity_max']) +ALL_SENSORS = { + Sensor.lsm6ds3: { + SensorConfig("acceleration", 5, 15), + SensorConfig("gyroUncalibrated", 0, .2), + SensorConfig("temperature", 0, 60), + }, + + Sensor.lsm6ds3trc: { + SensorConfig("acceleration", 5, 15), + SensorConfig("gyroUncalibrated", 0, .2), + SensorConfig("temperature", 0, 60), + }, + + Sensor.bmx055: { + SensorConfig("acceleration", 5, 15), + SensorConfig("gyroUncalibrated", 0, .2), + SensorConfig("magneticUncalibrated", 0, 300), + SensorConfig("temperature", 0, 60), + }, + + Sensor.mmc5603nj: { + SensorConfig("magneticUncalibrated", 0, 300), + } +} + + +def get_irq_count(irq: int): + with open(f"/sys/kernel/irq/{irq}/per_cpu_count") as f: + per_cpu = map(int, f.read().split(",")) + return sum(per_cpu) + +def read_sensor_events(duration_sec): + sensor_types = ['accelerometer', 'gyroscope', 'magnetometer', 'accelerometer2', + 'gyroscope2', 'temperatureSensor', 'temperatureSensor2'] + socks = {} + poller = messaging.Poller() + events = defaultdict(list) + for stype in sensor_types: + socks[stype] = messaging.sub_sock(stype, poller=poller, timeout=100) + + # wait for sensors to come up + with Timeout(int(os.environ.get("SENSOR_WAIT", "5")), "sensors didn't come up"): + while len(poller.poll(250)) == 0: + pass + time.sleep(1) + for s in socks.values(): + messaging.drain_sock_raw(s) + + st = time.monotonic() + while time.monotonic() - st < duration_sec: + for s in socks: + events[s] += messaging.drain_sock(socks[s]) + time.sleep(0.1) + + assert sum(map(len, events.values())) != 0, "No sensor events collected!" + + return {k: v for k, v in events.items() if len(v) > 0} + +@pytest.mark.tici +class TestSensord: + @classmethod + def setup_class(cls): + # enable LSM self test + os.environ["LSM_SELF_TEST"] = "1" + + # read initial sensor values every test case can use + os.system("pkill -f \\\\./sensord") + try: + managed_processes["sensord"].start() + cls.sample_secs = int(os.getenv("SAMPLE_SECS", "10")) + cls.events = read_sensor_events(cls.sample_secs) + + # determine sensord's irq + cls.sensord_irq = get_irqs_for_action("sensord")[0] + finally: + # teardown won't run if this doesn't succeed + managed_processes["sensord"].stop() + + @classmethod + def teardown_class(cls): + managed_processes["sensord"].stop() + + def teardown_method(self): + managed_processes["sensord"].stop() + + def test_sensors_present(self): + # verify correct sensors configuration + seen = set() + for etype in self.events: + for measurement in self.events[etype]: + m = getattr(measurement, measurement.which()) + seen.add((str(m.source), m.which())) + + assert seen in SENSOR_CONFIGURATIONS + + def test_lsm6ds3_timing(self, subtests): + # verify measurements are sampled and published at 104Hz + + sensor_t = { + 1: [], # accel + 5: [], # gyro + } + + for measurement in self.events['accelerometer']: + m = getattr(measurement, measurement.which()) + sensor_t[m.sensor].append(m.timestamp) + + for measurement in self.events['gyroscope']: + m = getattr(measurement, measurement.which()) + sensor_t[m.sensor].append(m.timestamp) + + for s, vals in sensor_t.items(): + with subtests.test(sensor=s): + assert len(vals) > 0 + tdiffs = np.diff(vals) / 1e6 # millis + + high_delay_diffs = list(filter(lambda d: d >= 20., tdiffs)) + assert len(high_delay_diffs) < 15, f"Too many large diffs: {high_delay_diffs}" + + avg_diff = sum(tdiffs)/len(tdiffs) + avg_freq = 1. / (avg_diff * 1e-3) + assert 92. < avg_freq < 114., f"avg freq {avg_freq}Hz wrong, expected 104Hz" + + stddev = np.std(tdiffs) + assert stddev < 2.0, f"Standard-dev to big {stddev}" + + def test_sensor_frequency(self, subtests): + for s, msgs in self.events.items(): + with subtests.test(sensor=s): + freq = len(msgs) / self.sample_secs + ef = SERVICE_LIST[s].frequency + assert ef*0.85 <= freq <= ef*1.15 + + def test_logmonottime_timestamp_diff(self): + # ensure diff between the message logMonotime and sample timestamp is small + + tdiffs = list() + for etype in self.events: + for measurement in self.events[etype]: + m = getattr(measurement, measurement.which()) + + # check if gyro and accel timestamps are before logMonoTime + if str(m.source).startswith("lsm6ds3") and m.which() != 'temperature': + err_msg = f"Timestamp after logMonoTime: {m.timestamp} > {measurement.logMonoTime}" + assert m.timestamp < measurement.logMonoTime, err_msg + + # negative values might occur, as non interrupt packages created + # before the sensor is read + tdiffs.append(abs(measurement.logMonoTime - m.timestamp) / 1e6) + + # some sensors have a read procedure that will introduce an expected diff on the order of 20ms + high_delay_diffs = set(filter(lambda d: d >= 25., tdiffs)) + assert len(high_delay_diffs) < 20, f"Too many measurements published: {high_delay_diffs}" + + avg_diff = round(sum(tdiffs)/len(tdiffs), 4) + assert avg_diff < 4, f"Avg packet diff: {avg_diff:.1f}ms" + + def test_sensor_values(self): + sensor_values = dict() + for etype in self.events: + for measurement in self.events[etype]: + m = getattr(measurement, measurement.which()) + key = (m.source.raw, m.which()) + values = getattr(m, m.which()) + + if hasattr(values, 'v'): + values = values.v + values = np.atleast_1d(values) + + if key in sensor_values: + sensor_values[key].append(values) + else: + sensor_values[key] = [values] + + # Sanity check sensor values + for sensor, stype in sensor_values: + for s in ALL_SENSORS[sensor]: + if s.type != stype: + continue + + key = (sensor, s.type) + mean_norm = np.mean(np.linalg.norm(sensor_values[key], axis=1)) + err_msg = f"Sensor '{sensor} {s.type}' failed sanity checks {mean_norm} is not between {s.sanity_min} and {s.sanity_max}" + assert s.sanity_min <= mean_norm <= s.sanity_max, err_msg + + def test_sensor_verify_no_interrupts_after_stop(self): + managed_processes["sensord"].start() + time.sleep(3) + + # read /proc/interrupts to verify interrupts are received + state_one = get_irq_count(self.sensord_irq) + time.sleep(1) + state_two = get_irq_count(self.sensord_irq) + + error_msg = f"no interrupts received after sensord start!\n{state_one} {state_two}" + assert state_one != state_two, error_msg + + managed_processes["sensord"].stop() + time.sleep(1) + + # read /proc/interrupts to verify no more interrupts are received + state_one = get_irq_count(self.sensord_irq) + time.sleep(1) + state_two = get_irq_count(self.sensord_irq) + assert state_one == state_two, "Interrupts received after sensord stop!" diff --git a/sunnypilot/system/sensord/tests/ttff_test.py b/sunnypilot/system/sensord/tests/ttff_test.py new file mode 100755 index 0000000000..a9cc16d707 --- /dev/null +++ b/sunnypilot/system/sensord/tests/ttff_test.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 + +import time +import atexit + +from cereal import messaging +from openpilot.system.manager.process_config import managed_processes + +TIMEOUT = 10*60 + +def kill(): + for proc in ['ubloxd', 'pigeond']: + managed_processes[proc].stop(retry=True, block=True) + +if __name__ == "__main__": + # start ubloxd + managed_processes['ubloxd'].start() + atexit.register(kill) + + sm = messaging.SubMaster(['ubloxGnss']) + + times = [] + for i in range(20): + # start pigeond + st = time.monotonic() + managed_processes['pigeond'].start() + + # wait for a >4 satellite fix + while True: + sm.update(0) + msg = sm['ubloxGnss'] + if msg.which() == 'measurementReport' and sm.updated["ubloxGnss"]: + report = msg.measurementReport + if report.numMeas > 4: + times.append(time.monotonic() - st) + print(f"\033[94m{i}: Got a fix in {round(times[-1], 2)} seconds\033[0m") + break + + if time.monotonic() - st > TIMEOUT: + raise TimeoutError("\033[91mFailed to get a fix in {TIMEOUT} seconds!\033[0m") + + time.sleep(0.1) + + # stop pigeond + managed_processes['pigeond'].stop(retry=True, block=True) + time.sleep(20) + + print(f"\033[92mAverage TTFF: {round(sum(times) / len(times), 2)}s\033[0m") diff --git a/system/athena/athenad.py b/system/athena/athenad.py index b52ef21ba6..9773eec090 100755 --- a/system/athena/athenad.py +++ b/system/athena/athenad.py @@ -14,6 +14,7 @@ import sys import tempfile import threading import time +import gzip from dataclasses import asdict, dataclass, replace from datetime import datetime from functools import partial, total_ordering @@ -134,6 +135,7 @@ cancelled_uploads: set[str] = set() cur_upload_items: dict[int, UploadItem | None] = {} +# TODO-SP: adapt zst for sunnylink def strip_zst_extension(fn: str) -> str: if fn.endswith('.zst'): return fn[:-4] @@ -199,8 +201,8 @@ def handle_long_poll(ws: WebSocket, exit_event: threading.Event | None) -> None: thread.join() -def jsonrpc_handler(end_event: threading.Event) -> None: - dispatcher["startLocalProxy"] = partial(startLocalProxy, end_event) +def jsonrpc_handler(end_event: threading.Event, localProxyHandler = None) -> None: + dispatcher["startLocalProxy"] = localProxyHandler or partial(startLocalProxy, end_event) while not end_event.is_set(): try: data = recv_queue.get(timeout=1) @@ -366,20 +368,35 @@ def getVersion() -> dict[str, str]: } -def scan_dir(path: str, prefix: str) -> list[str]: +@dispatcher.add_method +def setNavDestination(latitude: int = 0, longitude: int = 0, place_name: str | None = None, place_details: str | None = None) -> dict[str, int]: + destination = { + "latitude": latitude, + "longitude": longitude, + "place_name": place_name, + "place_details": place_details, + } + Params().put("NavDestination", json.dumps(destination)) + + return {"success": 1} + + +def scan_dir(path: str, prefix: str, base: str | None = None) -> list[str]: + if base is None: + base = path files = [] # only walk directories that match the prefix # (glob and friends traverse entire dir tree) with os.scandir(path) as i: for e in i: - rel_path = os.path.relpath(e.path, Paths.log_root()) + rel_path = os.path.relpath(e.path, base) if e.is_dir(follow_symlinks=False): # add trailing slash rel_path = os.path.join(rel_path, '') # if prefix is a partial dir name, current dir will start with prefix # if prefix is a partial file name, prefix with start with dir name if rel_path.startswith(prefix) or prefix.startswith(rel_path): - files.extend(scan_dir(e.path, prefix)) + files.extend(scan_dir(e.path, prefix, base)) else: if rel_path.startswith(prefix): files.append(rel_path) @@ -387,7 +404,12 @@ def scan_dir(path: str, prefix: str) -> list[str]: @dispatcher.add_method def listDataDirectory(prefix='') -> list[str]: - return scan_dir(Paths.log_root(), prefix) + internal_files = scan_dir(Paths.log_root(), prefix, Paths.log_root()) + try: + external_files = scan_dir(Paths.log_root_external(), prefix, Paths.log_root_external()) + except FileNotFoundError: + external_files = [] + return sorted(set(internal_files + external_files)) @dispatcher.add_method @@ -412,8 +434,13 @@ def uploadFilesToUrls(files_data: list[UploadFileDict]) -> UploadFilesToUrlRespo failed.append(file.fn) continue - path = os.path.join(Paths.log_root(), file.fn) - if not os.path.exists(path) and not os.path.exists(strip_zst_extension(path)): + path_internal = os.path.join(Paths.log_root(), file.fn) + path_external = os.path.join(Paths.log_root_external(), file.fn) + if os.path.exists(path_internal) or os.path.exists(strip_zst_extension(path_internal)): + path = path_internal + elif os.path.exists(path_external) or os.path.exists(strip_zst_extension(path_external)): + path = path_external + else: failed.append(file.fn) continue @@ -482,7 +509,19 @@ def setRouteViewed(route: str) -> dict[str, int | str]: def startLocalProxy(global_end_event: threading.Event, remote_ws_uri: str, local_port: int) -> dict[str, int]: + cloudlog.debug("athena.startLocalProxy.starting") + dongle_id = Params().get("DongleId") + identity_token = Api(dongle_id).get_token() + ws = create_connection(remote_ws_uri, cookie="jwt=" + identity_token, enable_multithread=True) + + return start_local_proxy_shim(global_end_event, local_port, ws) + + +def start_local_proxy_shim(global_end_event: threading.Event, local_port: int, ws: WebSocket) -> dict[str, int]: try: + if ws.sock is None: + raise Exception("WebSocket is not connected") + # migration, can be removed once 0.9.8 is out for a while if local_port == 8022: local_port = 22 @@ -490,14 +529,6 @@ def startLocalProxy(global_end_event: threading.Event, remote_ws_uri: str, local if local_port not in LOCAL_PORT_WHITELIST: raise Exception("Requested local port not whitelisted") - cloudlog.debug("athena.startLocalProxy.starting") - - dongle_id = Params().get("DongleId") - identity_token = Api(dongle_id).get_token() - ws = create_connection(remote_ws_uri, - cookie="jwt=" + identity_token, - enable_multithread=True) - # Set TOS to keep connection responsive while under load. ws.sock.setsockopt(socket.IPPROTO_IP, socket.IP_TOS, SSH_TOS) @@ -575,7 +606,7 @@ def takeSnapshot() -> str | dict[str, str] | None: raise Exception("not available while camerad is started") -def get_logs_to_send_sorted() -> list[str]: +def get_logs_to_send_sorted(log_attr_name=LOG_ATTR_NAME) -> list[str]: # TODO: scan once then use inotify to detect file creation/deletion curr_time = int(time.time()) # noqa: TID251 logs = [] @@ -583,7 +614,7 @@ def get_logs_to_send_sorted() -> list[str]: log_path = os.path.join(Paths.swaglog_root(), log_entry) time_sent = 0 try: - value = getxattr(log_path, LOG_ATTR_NAME) + value = getxattr(log_path, log_attr_name) if value is not None: time_sent = int.from_bytes(value, sys.byteorder) except (ValueError, TypeError): @@ -595,8 +626,69 @@ def get_logs_to_send_sorted() -> list[str]: return sorted(logs)[:-1] -def log_handler(end_event: threading.Event) -> None: +def add_log_to_queue(log_path, log_id, is_sunnylink=False): + MAX_SIZE_KB = 32 + MAX_SIZE_BYTES = MAX_SIZE_KB * 1024 + + with open(log_path) as f: + data = f.read() + + # Check if the file is empty + if not data: + cloudlog.warning(f"Log file {log_path} is empty.") + return + + # Initialize variables for encoding + payload = data + is_compressed = False + + # Log the current size of the file + current_size = len(json.dumps(payload).encode("utf-8")) + len(log_id.encode("utf-8")) + 100 # Add 100 bytes to account for encoding overhead + cloudlog.debug(f"Current size of log file {log_path}: {current_size} bytes") + + if is_sunnylink and current_size > MAX_SIZE_BYTES: + # Compress and encode the data if it exceeds the maximum size + compressed_data = gzip.compress(data.encode()) + payload = base64.b64encode(compressed_data).decode() + is_compressed = True + + # Log the size after compression and encoding + compressed_size = len(compressed_data) + encoded_size = len(payload) + cloudlog.debug(f"Size of log file {log_path} " + + f"after compression: {compressed_size} bytes, " + + f"after encoding: {encoded_size} bytes") + + jsonrpc = { + "method": "forwardLogs", + "params": { + "logs": payload + }, + "jsonrpc": "2.0", + "id": log_id + } + + if is_sunnylink and is_compressed: + jsonrpc["params"]["compressed"] = is_compressed + + jsonrpc_str = json.dumps(jsonrpc) + size_in_bytes = len(jsonrpc_str.encode('utf-8')) + + if is_sunnylink and size_in_bytes <= MAX_SIZE_BYTES: + cloudlog.debug(f"Target is sunnylink and log file {log_path} is small enough to send in one request ({size_in_bytes} bytes).") + low_priority_send_queue.put_nowait(jsonrpc_str) + elif is_sunnylink: + cloudlog.warning(f"Target is sunnylink and log file {log_path} is too large to send in one request.") + else: + cloudlog.debug(f"Target is not sunnylink, proceeding to send log file {log_path} in one request ({size_in_bytes} bytes).") + low_priority_send_queue.put_nowait(jsonrpc_str) + + +def log_handler(end_event: threading.Event, log_attr_name=LOG_ATTR_NAME) -> None: + is_sunnylink = log_attr_name != LOG_ATTR_NAME if PC: + cloudlog.debug("athena.log_handler: Not supported on PC") + time.sleep(1) return log_files = [] @@ -605,7 +697,7 @@ def log_handler(end_event: threading.Event) -> None: try: curr_scan = time.monotonic() if curr_scan - last_scan > 10: - log_files = get_logs_to_send_sorted() + log_files = get_logs_to_send_sorted(log_attr_name) last_scan = curr_scan # send one log @@ -616,18 +708,10 @@ def log_handler(end_event: threading.Event) -> None: try: curr_time = int(time.time()) # noqa: TID251 log_path = os.path.join(Paths.swaglog_root(), log_entry) - setxattr(log_path, LOG_ATTR_NAME, int.to_bytes(curr_time, 4, sys.byteorder)) - with open(log_path) as f: - jsonrpc = { - "method": "forwardLogs", - "params": { - "logs": f.read() - }, - "jsonrpc": "2.0", - "id": log_entry - } - low_priority_send_queue.put_nowait(json.dumps(jsonrpc)) - curr_log = log_entry + setxattr(log_path, log_attr_name, int.to_bytes(curr_time, 4, sys.byteorder)) + + add_log_to_queue(log_path, log_entry, is_sunnylink) + curr_log = log_entry except OSError: pass # file could be deleted by log rotation @@ -644,7 +728,7 @@ def log_handler(end_event: threading.Event) -> None: if log_entry and log_success: log_path = os.path.join(Paths.swaglog_root(), log_entry) try: - setxattr(log_path, LOG_ATTR_NAME, LOG_ATTR_VALUE_MAX_UNIX_TIME) + setxattr(log_path, log_attr_name, LOG_ATTR_VALUE_MAX_UNIX_TIME) except OSError: pass # file could be deleted by log rotation if curr_log == log_entry: @@ -657,26 +741,40 @@ def log_handler(end_event: threading.Event) -> None: cloudlog.exception("athena.log_handler.exception") -def stat_handler(end_event: threading.Event) -> None: - STATS_DIR = Paths.stats_root() +def stat_handler(end_event: threading.Event, stats_dir=None, is_sunnylink=False) -> None: + stats_dir = stats_dir or Paths.stats_root() last_scan = 0.0 while not end_event.is_set(): curr_scan = time.monotonic() try: if curr_scan - last_scan > 10: - stat_filenames = list(filter(lambda name: not name.startswith(tempfile.gettempprefix()), os.listdir(STATS_DIR))) + stat_filenames = list(filter(lambda name: not name.startswith(tempfile.gettempprefix()), os.listdir(stats_dir))) if len(stat_filenames) > 0: - stat_path = os.path.join(STATS_DIR, stat_filenames[0]) + stat_path = os.path.join(stats_dir, stat_filenames[0]) with open(stat_path) as f: + payload = f.read() + is_compressed = False + + # Log the current size of the file + if is_sunnylink: + # Compress and encode the data if it exceeds the maximum size + compressed_data = gzip.compress(payload.encode()) + payload = base64.b64encode(compressed_data).decode() + is_compressed = True + jsonrpc = { "method": "storeStats", "params": { - "stats": f.read() + "stats": payload }, "jsonrpc": "2.0", "id": stat_filenames[0] } + + if is_sunnylink and is_compressed: + jsonrpc["params"]["compressed"] = is_compressed + low_priority_send_queue.put_nowait(json.dumps(jsonrpc)) os.remove(stat_path) last_scan = curr_scan @@ -780,7 +878,7 @@ def ws_manage(ws: WebSocket, end_event: threading.Event) -> None: onroad_prev = None sock = ws.sock - while True: + while not end_event.wait(5): onroad = params.get_bool("IsOnroad") if onroad != onroad_prev: onroad_prev = onroad @@ -793,13 +891,11 @@ def ws_manage(ws: WebSocket, end_event: threading.Event) -> None: sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_USER_TIMEOUT, 16000 if onroad else 0) sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 7 if onroad else 30) elif sys.platform == 'darwin': + sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPALIVE, 7 if onroad else 30) sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 7 if onroad else 10) sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 2 if onroad else 3) - if end_event.wait(5): - break - def backoff(retries: int) -> int: return random.randrange(0, min(128, int(2 ** retries))) diff --git a/system/athena/manage_athenad.py b/system/athena/manage_athenad.py index ee63606b66..98557db06a 100755 --- a/system/athena/manage_athenad.py +++ b/system/athena/manage_athenad.py @@ -13,8 +13,12 @@ ATHENA_MGR_PID_PARAM = "AthenadPid" def main(): + manage_athenad("DongleId", ATHENA_MGR_PID_PARAM, 'athenad', 'system.athena.athenad') + + +def manage_athenad(dongle_id_param, pid_param, process_name, target): params = Params() - dongle_id = params.get("DongleId") + dongle_id = params.get(dongle_id_param) build_metadata = get_build_metadata() cloudlog.bind_global(dongle_id=dongle_id, @@ -27,17 +31,16 @@ def main(): try: while 1: - cloudlog.info("starting athena daemon") - proc = Process(name='athenad', target=launcher, args=('system.athena.athenad', 'athenad')) + cloudlog.info(f"starting {process_name} daemon") + proc = Process(name=process_name, target=launcher, args=(target, process_name)) proc.start() proc.join() - cloudlog.event("athenad exited", exitcode=proc.exitcode) + cloudlog.event(f"{process_name} exited", exitcode=proc.exitcode) time.sleep(5) except Exception: - cloudlog.exception("manage_athenad.exception") + cloudlog.exception(f"manage_{process_name}.exception") finally: - params.remove(ATHENA_MGR_PID_PARAM) - + params.remove(pid_param) if __name__ == '__main__': main() diff --git a/system/athena/registration.py b/system/athena/registration.py index 405b2423f2..26a2adb1af 100755 --- a/system/athena/registration.py +++ b/system/athena/registration.py @@ -73,6 +73,7 @@ def register(show_spinner=False) -> str | None: register_token = jwt.encode({'register': True, 'exp': datetime.now(UTC).replace(tzinfo=None) + timedelta(hours=1)}, cast(str, private_key), algorithm=jwt_algo) cloudlog.info("getting pilotauth") + cloudlog.info("getting pilotauth") resp = api_get("v2/pilotauth/", method='POST', timeout=15, imei=imei1, imei2=imei2, serial=serial, public_key=public_key, register_token=register_token) @@ -90,6 +91,7 @@ def register(show_spinner=False) -> str | None: if time.monotonic() - start_time > 60 and show_spinner: spinner.update(f"registering device - serial: {serial}, IMEI: ({imei1}, {imei2})") + return UNREGISTERED_DONGLE_ID # hotfix to prevent an infinite wait for registration if show_spinner: spinner.close() diff --git a/system/hardware/esim.py b/system/hardware/esim.py index 9b7d4f9ec0..40600d26b5 100755 --- a/system/hardware/esim.py +++ b/system/hardware/esim.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 import argparse -import time from openpilot.system.hardware import HARDWARE @@ -13,16 +12,13 @@ if __name__ == '__main__': parser.add_argument('--nickname', nargs=2, metavar=('iccid', 'name'), help='update the nickname for a profile') args = parser.parse_args() - mutated = False lpa = HARDWARE.get_sim_lpa() if args.switch: lpa.switch_profile(args.switch) - mutated = True elif args.delete: confirm = input('are you sure you want to delete this profile? (y/N) ') if confirm == 'y': lpa.delete_profile(args.delete) - mutated = True else: print('cancelled') exit(0) @@ -33,11 +29,6 @@ if __name__ == '__main__': else: parser.print_help() - if mutated: - HARDWARE.reboot_modem() - # eUICC needs a small delay post-reboot before querying profiles - time.sleep(.5) - profiles = lpa.list_profiles() print(f'\n{len(profiles)} profile{"s" if len(profiles) > 1 else ""}:') for p in profiles: diff --git a/system/hardware/hardwared.py b/system/hardware/hardwared.py index aad30f77b2..60721b6144 100755 --- a/system/hardware/hardwared.py +++ b/system/hardware/hardwared.py @@ -17,14 +17,13 @@ from openpilot.common.filter_simple import FirstOrderFilter from openpilot.common.params import Params from openpilot.common.realtime import DT_HW from openpilot.selfdrive.selfdrived.alertmanager import set_offroad_alert -from openpilot.system.hardware import HARDWARE, TICI, AGNOS, PC +from openpilot.system.hardware import HARDWARE, TICI, AGNOS from openpilot.system.loggerd.config import get_available_percent from openpilot.system.statsd import statlog from openpilot.common.swaglog import cloudlog from openpilot.system.hardware.power_monitoring import PowerMonitoring from openpilot.system.hardware.fan_controller import FanController -from openpilot.system.version import terms_version, training_version -from openpilot.system.athena.registration import UNREGISTERED_DONGLE_ID +from openpilot.system.version import terms_version, training_version, get_build_metadata, terms_version_sp ThermalStatus = log.DeviceState.ThermalStatus NetworkType = log.DeviceState.NetworkType @@ -304,6 +303,7 @@ def hardware_thread(end_event, hw_queue) -> None: startup_conditions["no_excessive_actuation"] = params.get("Offroad_ExcessiveActuation") is None startup_conditions["not_uninstalling"] = not params.get_bool("DoUninstall") startup_conditions["accepted_terms"] = params.get("HasAcceptedTerms") == terms_version + startup_conditions["accepted_terms_sp"] = params.get("HasAcceptedTermsSP") == terms_version_sp # with 2% left, we killall, otherwise the phone will take a long time to boot startup_conditions["free_space"] = msg.deviceState.freeSpacePercent > 2 @@ -317,18 +317,27 @@ def hardware_thread(end_event, hw_queue) -> None: # ensure device is fully booted startup_conditions["device_booted"] = startup_conditions.get("device_booted", False) or HARDWARE.booted() + # user-forced status + offroad_mode = params.get_bool("OffroadMode") + startup_conditions["not_always_offroad"] = not offroad_mode + onroad_conditions["not_always_offroad"] = not offroad_mode + + # if an unsupported device and branch is detected, going onroad is blocked + # only allow going onroad when: + # - TIZI, or + # - TICI and channel_type is "tici" + build_metadata = get_build_metadata() + is_unsupported_combo = TICI and HARDWARE.get_device_type() == "tici" and build_metadata.channel_type != "tici" + startup_conditions["not_tici"] = not is_unsupported_combo + onroad_conditions["not_tici"] = not is_unsupported_combo + set_offroad_alert("Offroad_TiciSupport", is_unsupported_combo, extra_text=build_metadata.channel) + # if the temperature enters the danger zone, go offroad to cool down onroad_conditions["device_temp_good"] = thermal_status < ThermalStatus.danger extra_text = f"{offroad_comp_temp:.1f}C" show_alert = (not onroad_conditions["device_temp_good"] or not startup_conditions["device_temp_engageable"]) and onroad_conditions["ignition"] set_offroad_alert_if_changed("Offroad_TemperatureTooHigh", show_alert, extra_text=extra_text) - # *** registration check *** - if not PC: - # we enforce this for our software, but you are welcome - # to make a different decision in your software - startup_conditions["registered_device"] = PC or (params.get("DongleId") != UNREGISTERED_DONGLE_ID) - # Handle offroad/onroad transition should_start = all(onroad_conditions.values()) if started_ts is None: @@ -377,6 +386,11 @@ def hardware_thread(end_event, hw_queue) -> None: # Offroad power monitoring voltage = None if peripheralState.pandaType == log.PandaState.PandaType.unknown else peripheralState.voltage + + # GitHub runner auto off: 9V is used as the threshold because most desktop runners + # will rarely exceed 5V so 9V is set as our buffer between desk use and car use. + params.put_bool_nonblocking("GithubRunnerSufficientVoltage", ((voltage or 0) and voltage > 9000)) + power_monitor.calculate(voltage, onroad_conditions["ignition"]) msg.deviceState.offroadPowerUsageUwh = power_monitor.get_power_used() msg.deviceState.carBatteryCapacityUwh = max(0, power_monitor.get_car_battery_capacity()) @@ -393,7 +407,7 @@ def hardware_thread(end_event, hw_queue) -> None: cloudlog.warning(f"shutting device down, offroad since {off_ts}") params.put_bool("DoShutdown", True) - msg.deviceState.started = started_ts is not None + msg.deviceState.started = started_ts is not None and not offroad_mode msg.deviceState.startedMonoTime = int(1e9*(started_ts or 0)) last_ping = params.get("LastAthenaPingTime") diff --git a/system/hardware/hw.h b/system/hardware/hw.h index a9058401ce..c9af7e8a33 100644 --- a/system/hardware/hw.h +++ b/system/hardware/hw.h @@ -55,4 +55,8 @@ namespace Path { return "/dev/shm"; #endif } + + inline std::string model_root() { + return Hardware::PC() ? Path::comma_home() + "/media/0/models" : "/data/media/0/models"; + } } // namespace Path diff --git a/system/hardware/hw.py b/system/hardware/hw.py index dc36dc0474..3527aac872 100644 --- a/system/hardware/hw.py +++ b/system/hardware/hw.py @@ -20,6 +20,10 @@ class Paths: else: return '/data/media/0/realdata/' + @staticmethod + def log_root_external() -> str: + return '/mnt/external_realdata/' + @staticmethod def swaglog_root() -> str: if PC: @@ -51,6 +55,13 @@ class Paths: else: return "/data/stats/" + @staticmethod + def stats_sp_root() -> str: + if PC: + return str(Path(Paths.comma_home()) / "stats") + else: + return "/data/stats_sp/" + @staticmethod def config_root() -> str: if PC: @@ -63,3 +74,24 @@ class Paths: if PC and platform.system() == "Darwin": return "/tmp" # This is not really shared memory on macOS, but it's the closest we can get return "/dev/shm" + + @staticmethod + def model_root() -> str: + if PC: + return str(Path(Paths.comma_home()) / "media" / "0" / "models") + else: + return "/data/media/0/models" + + @staticmethod + def crash_log_root() -> str: + if PC: + return str(Path(Paths.comma_home()) / "community" / "crashes") + else: + return "/data/community/crashes" + + @staticmethod + def mapd_root() -> str: + if PC: + return str(Path(Paths.comma_home()) / "media" / "0" / "osm") + else: + return "/data/media/0/osm" diff --git a/system/hardware/power_monitoring.py b/system/hardware/power_monitoring.py index f8b0e8b629..d0a1e623a7 100644 --- a/system/hardware/power_monitoring.py +++ b/system/hardware/power_monitoring.py @@ -104,6 +104,21 @@ class PowerMonitoring: def get_car_battery_capacity(self) -> int: return int(self.car_battery_capacity_uWh) + # Max Time Offroad + def max_time_offroad_exceeded(self, offroad_time): + """ + Check if the max time offroad has been exceeded. If the value is 0, it means no limit. + :param offroad_time: Time spent offroad in seconds + :return: True if the max time offroad has been exceeded, False otherwise + """ + try: + param = self.params.get("MaxTimeOffroad") + sp_max_time_val_s = param * 60 if param is not None and param >= 0 else MAX_TIME_OFFROAD_S + except Exception: + sp_max_time_val_s = MAX_TIME_OFFROAD_S + + return 0 < sp_max_time_val_s <= offroad_time + # See if we need to shutdown def should_shutdown(self, ignition: bool, in_car: bool, offroad_timestamp: float | None, started_seen: bool): if offroad_timestamp is None: @@ -114,7 +129,7 @@ class PowerMonitoring: offroad_time = (now - offroad_timestamp) low_voltage_shutdown = (self.car_voltage_mV < (VBATT_PAUSE_CHARGING * 1e3) and offroad_time > VOLTAGE_SHUTDOWN_MIN_OFFROAD_TIME_S) - should_shutdown |= offroad_time > MAX_TIME_OFFROAD_S + should_shutdown |= self.max_time_offroad_exceeded(offroad_time) should_shutdown |= low_voltage_shutdown should_shutdown |= (self.car_battery_capacity_uWh <= 0) should_shutdown &= not ignition diff --git a/system/hardware/tests/test_power_monitoring.py b/system/hardware/tests/test_power_monitoring.py index 1dff6c6c5f..451efcd735 100644 --- a/system/hardware/tests/test_power_monitoring.py +++ b/system/hardware/tests/test_power_monitoring.py @@ -2,7 +2,7 @@ import pytest from openpilot.common.params import Params from openpilot.system.hardware.power_monitoring import PowerMonitoring, CAR_BATTERY_CAPACITY_uWh, \ - CAR_CHARGING_RATE_W, VBATT_PAUSE_CHARGING, DELAY_SHUTDOWN_TIME_S + CAR_CHARGING_RATE_W, VBATT_PAUSE_CHARGING, DELAY_SHUTDOWN_TIME_S, MAX_TIME_OFFROAD_S # Create fake time ssb = 0. @@ -197,3 +197,38 @@ class TestPowerMonitoring: offroad_timestamp, started_seen), \ f"Should shutdown after {DELAY_SHUTDOWN_TIME_S} seconds offroad time" + + @pytest.mark.parametrize( + "max_time_offroad, offroad_time_min, expected_result", + [ + # No max time set – fallback to default (30 hours) + (None, 0, False), + (None, MAX_TIME_OFFROAD_S + 1, True), # exceeds 30h (1800+ mins) + + # Valid max time values (in minutes) + (60, 59, False), # under limit + (60, 120, True), # over limit + (10, 8, False), # under limit + (10, 11, True), # over limit + + # Edge case: max time is zero → no limit enforced + (0, 0, False), + (0, 400, False), + + # Invalid max time formats or negative values → fallback to 30 hours + (-100, 100, False), # should fallback to 30h + (-1, MAX_TIME_OFFROAD_S + 1, True), # should fallback to 30h, and exceed it + ] + ) + def test_max_time_offroad_exceeded(self, max_time_offroad, offroad_time_min, expected_result): + # Set the parameter if provided + if max_time_offroad is not None: + self.params.put("MaxTimeOffroad", max_time_offroad) + + # Convert offroad time from minutes to seconds + offroad_time_s = offroad_time_min * 60 + + pm = PowerMonitoring() + result = pm.max_time_offroad_exceeded(offroad_time_s) + + assert result == expected_result diff --git a/system/hardware/tici/lpa.py b/system/hardware/tici/lpa.py index 2e7e6a0ba9..ceb901e2f7 100644 --- a/system/hardware/tici/lpa.py +++ b/system/hardware/tici/lpa.py @@ -2,17 +2,23 @@ import atexit import base64 +import fcntl import math import os import serial +import subprocess import sys +import termios +import time -from collections.abc import Generator +from collections.abc import Callable, Generator +from contextlib import contextmanager +from typing import Any -from openpilot.system.hardware.base import LPABase, Profile +from openpilot.system.hardware.base import LPABase, LPAError, Profile -DEFAULT_DEVICE = "/dev/ttyUSB2" +DEFAULT_DEVICE = "/dev/modem_at0" DEFAULT_BAUD = 9600 DEFAULT_TIMEOUT = 5.0 # https://euicc-manual.osmocom.org/docs/lpa/applet-id/ @@ -20,44 +26,82 @@ ISDR_AID = "A0000005591010FFFFFFFF8900000100" MM = "org.freedesktop.ModemManager1" MM_MODEM = MM + ".Modem" ES10X_MSS = 120 +OPEN_ISDR_RETRIES = 10 +OPEN_ISDR_RETRY_DELAY_S = 0.25 +OPEN_ISDR_RESET_ATTEMPT = 5 +SEND_APDU_RETRIES = 3 +LOCK_FILE = '/dev/shm/modem_lpa.lock' DEBUG = os.environ.get("DEBUG") == "1" + # TLV Tags TAG_ICCID = 0x5A +TAG_STATUS = 0x80 TAG_PROFILE_INFO_LIST = 0xBF2D +TAG_SET_NICKNAME = 0xBF29 +TAG_ENABLE_PROFILE = 0xBF31 +TAG_DELETE_PROFILE = 0xBF33 +TAG_OK = 0xA0 + +PROFILE_OK = 0x00 +PROFILE_NOT_IN_DISABLED_STATE = 0x02 +PROFILE_CAT_BUSY = 0x05 + +PROFILE_ERROR_CODES = { + 0x01: "iccidOrAidNotFound", PROFILE_NOT_IN_DISABLED_STATE: "profileNotInDisabledState", + 0x03: "disallowedByPolicy", 0x04: "wrongProfileReenabling", + PROFILE_CAT_BUSY: "catBusy", 0x06: "undefinedError", +} STATE_LABELS = {0: "disabled", 1: "enabled", 255: "unknown"} ICON_LABELS = {0: "jpeg", 1: "png", 255: "unknown"} CLASS_LABELS = {0: "test", 1: "provisioning", 2: "operational", 255: "unknown"} +# TLV tag -> (field_name, decoder) +FieldMap = dict[int, tuple[str, Callable[[bytes], Any]]] + def b64e(data: bytes) -> str: return base64.b64encode(data).decode("ascii") +def base64_trim(s: str) -> str: + return "".join(c for c in s if c not in "\n\r \t") + + +def b64d(s: str) -> bytes: + return base64.b64decode(base64_trim(s)) + + class AtClient: - def __init__(self, device: str, baud: int, timeout: float, debug: bool) -> None: - self.debug = debug + def __init__(self, device: str, baud: int, timeout: float) -> None: self.channel: str | None = None + self._device = device + self._baud = baud self._timeout = timeout self._serial: serial.Serial | None = None - try: - self._serial = serial.Serial(device, baudrate=baud, timeout=timeout) - self._serial.reset_input_buffer() - except (serial.SerialException, PermissionError, OSError): - pass + self._use_dbus = not os.path.exists(device) + + def send_raw(self, data: bytes) -> None: + self._ensure_serial() + self._serial.reset_input_buffer() + self._serial.write(data) + self._serial.flush() def close(self) -> None: try: if self.channel: - self.query(f"AT+CCHC={self.channel}") + try: + self.query(f"AT+CCHC={self.channel}") + except (RuntimeError, TimeoutError): + pass self.channel = None finally: if self._serial: self._serial.close() def _send(self, cmd: str) -> None: - if self.debug: + if DEBUG: print(f"SER >> {cmd}", file=sys.stderr) self._serial.write((cmd + "\r").encode("ascii")) @@ -70,7 +114,7 @@ class AtClient: line = raw.decode(errors="ignore").strip() if not line: continue - if self.debug: + if DEBUG: print(f"SER << {line}", file=sys.stderr) if line == "OK": return lines @@ -78,6 +122,18 @@ class AtClient: raise RuntimeError(f"AT command failed: {line}") lines.append(line) + def _ensure_serial(self, reconnect: bool = False) -> None: + if reconnect: + self.channel = None + try: + if self._serial: + self._serial.close() + except Exception: + pass + self._serial = None + if self._serial is None: + self._serial = serial.Serial(self._device, baudrate=self._baud, timeout=self._timeout) + def _get_modem(self): import dbus bus = dbus.SystemBus() @@ -87,48 +143,88 @@ class AtClient: return bus.get_object(MM, modem_path) def _dbus_query(self, cmd: str) -> list[str]: - if self.debug: + if DEBUG: print(f"DBUS >> {cmd}", file=sys.stderr) try: result = str(self._get_modem().Command(cmd, math.ceil(self._timeout), dbus_interface=MM_MODEM, timeout=self._timeout)) except Exception as e: raise RuntimeError(f"AT command failed: {e}") from e lines = [line.strip() for line in result.splitlines() if line.strip()] - if self.debug: + if DEBUG: for line in lines: print(f"DBUS << {line}", file=sys.stderr) return lines def query(self, cmd: str) -> list[str]: - if self._serial: + if self._use_dbus: + return self._dbus_query(cmd) + self._ensure_serial() + try: + self._send(cmd) + return self._expect() + except serial.SerialException: + self._ensure_serial(reconnect=True) self._send(cmd) return self._expect() - return self._dbus_query(cmd) - def open_isdr(self) -> None: - # close any stale logical channel from a previous crashed session - try: - self.query("AT+CCHC=1") - except RuntimeError: - pass + def _open_isdr_once(self) -> None: + if self.channel: + try: + self.query(f"AT+CCHC={self.channel}") + except RuntimeError: + pass + self.channel = None + # drain any unsolicited responses before opening + if self._serial and not self._use_dbus: + try: + self._serial.reset_input_buffer() + except (OSError, serial.SerialException, termios.error): + self._ensure_serial(reconnect=True) for line in self.query(f'AT+CCHO="{ISDR_AID}"'): if line.startswith("+CCHO:") and (ch := line.split(":", 1)[1].strip()): self.channel = ch return raise RuntimeError("Failed to open ISD-R application") + def _reset_modem(self) -> None: + if self._serial: + try: + self._serial.close() + except Exception: + pass + self._serial = None + subprocess.run(['/usr/comma/lte/lte.sh', 'start'], capture_output=True) + + def open_isdr(self) -> None: + for attempt in range(OPEN_ISDR_RETRIES): + try: + self._open_isdr_once() + return + except (RuntimeError, TimeoutError, termios.error, serial.SerialException): + time.sleep(OPEN_ISDR_RETRY_DELAY_S) + if attempt == OPEN_ISDR_RESET_ATTEMPT: + self._reset_modem() + raise RuntimeError("Failed to open ISD-R after retries") + def send_apdu(self, apdu: bytes) -> tuple[bytes, int, int]: - if not self.channel: - raise RuntimeError("Logical channel is not open") - hex_payload = apdu.hex().upper() - for line in self.query(f'AT+CGLA={self.channel},{len(hex_payload)},"{hex_payload}"'): - if line.startswith("+CGLA:"): - parts = line.split(":", 1)[1].split(",", 1) - if len(parts) == 2: - data = bytes.fromhex(parts[1].strip().strip('"')) - if len(data) >= 2: - return data[:-2], data[-2], data[-1] - raise RuntimeError("Missing +CGLA response") + for attempt in range(SEND_APDU_RETRIES): + try: + if not self.channel: + self.open_isdr() + hex_payload = apdu.hex().upper() + for line in self.query(f'AT+CGLA={self.channel},{len(hex_payload)},"{hex_payload}"'): + if line.startswith("+CGLA:"): + parts = line.split(":", 1)[1].split(",", 1) + if len(parts) == 2: + data = bytes.fromhex(parts[1].strip().strip('"')) + if len(data) >= 2: + return data[:-2], data[-2], data[-1] + raise RuntimeError("Missing +CGLA response") + except (RuntimeError, ValueError): + self.channel = None + if attempt == SEND_APDU_RETRIES - 1: + raise + raise RuntimeError("send_apdu failed") # --- TLV utilities --- @@ -170,12 +266,37 @@ def find_tag(data: bytes, target: int) -> bytes | None: return next((v for t, v in iter_tlv(data) if t == target), None) +def require_tag(data: bytes, target: int, label: str = "") -> bytes: + v = find_tag(data, target) + if v is None: + raise RuntimeError(f"Missing {label or f'tag 0x{target:X}'}") + return v + + def tbcd_to_string(raw: bytes) -> str: return "".join(str(n) for b in raw for n in (b & 0x0F, b >> 4) if n <= 9) -# Profile field decoders: TLV tag -> (field_name, decoder) -_PROFILE_FIELDS = { +def string_to_tbcd(s: str) -> bytes: + digits = [int(c) for c in s if c.isdigit()] + return bytes(digits[i] | ((digits[i + 1] if i + 1 < len(digits) else 0xF) << 4) for i in range(0, len(digits), 2)) + + +def encode_tlv(tag: int, value: bytes) -> bytes: + tag_bytes = bytes([(tag >> 8) & 0xFF, tag & 0xFF]) if tag > 255 else bytes([tag]) + vlen = len(value) + if vlen <= 127: + return tag_bytes + bytes([vlen]) + value + length_bytes = vlen.to_bytes((vlen.bit_length() + 7) // 8, "big") + return tag_bytes + bytes([0x80 | len(length_bytes)]) + length_bytes + value + + +def int_bytes(n: int) -> bytes: + """Encode a positive integer as minimal big-endian bytes (at least 1 byte).""" + return n.to_bytes((n.bit_length() + 7) // 8 or 1, "big") + + +PROFILE: FieldMap = { TAG_ICCID: ("iccid", tbcd_to_string), 0x4F: ("isdpAid", lambda v: v.hex().upper()), 0x9F70: ("profileState", lambda v: STATE_LABELS.get(v[0], "unknown")), @@ -188,11 +309,11 @@ _PROFILE_FIELDS = { } -def _decode_profile_fields(data: bytes) -> dict: - """Parse known profile metadata TLV fields into a dict.""" - result = {} +def decode_struct(data: bytes, field_map: FieldMap) -> dict[str, Any]: + """Parse TLV data using a {tag: (field_name, decoder)} map into a dict.""" + result: dict[str, Any] = {name: None for name, _ in field_map.values()} for tag, value in iter_tlv(data): - if (field := _PROFILE_FIELDS.get(tag)): + if (field := field_map.get(tag)): result[field[0]] = field[1](value) return result @@ -225,57 +346,102 @@ def es10x_command(client: AtClient, data: bytes) -> bytes: # --- Profile operations --- def decode_profiles(blob: bytes) -> list[dict]: - root = find_tag(blob, TAG_PROFILE_INFO_LIST) - if root is None: - raise RuntimeError("Missing ProfileInfoList") - list_ok = find_tag(root, 0xA0) + root = require_tag(blob, TAG_PROFILE_INFO_LIST, "ProfileInfoList") + list_ok = find_tag(root, TAG_OK) if list_ok is None: return [] - defaults = {name: None for name, _ in _PROFILE_FIELDS.values()} - return [{**defaults, **_decode_profile_fields(value)} for tag, value in iter_tlv(list_ok) if tag == 0xE3] + return [decode_struct(value, PROFILE) for tag, value in iter_tlv(list_ok) if tag == 0xE3] def list_profiles(client: AtClient) -> list[dict]: return decode_profiles(es10x_command(client, TAG_PROFILE_INFO_LIST.to_bytes(2, "big") + b"\x00")) +def set_profile_nickname(client: AtClient, iccid: str, nickname: str) -> None: + nickname_bytes = nickname.encode("utf-8") + if len(nickname_bytes) > 64: + raise ValueError("Profile nickname must be 64 bytes or less") + content = encode_tlv(TAG_ICCID, string_to_tbcd(iccid)) + encode_tlv(0x90, nickname_bytes) + response = es10x_command(client, encode_tlv(TAG_SET_NICKNAME, content)) + code = require_tag(require_tag(response, TAG_SET_NICKNAME, "SetNicknameResponse"), TAG_STATUS, "SetNickname status")[0] + if code == 0x01: + raise LPAError(f"profile {iccid} not found") + if code != 0x00: + raise RuntimeError(f"SetNickname failed with status 0x{code:02X}") + + class TiciLPA(LPABase): - _instance = None - - def __new__(cls): - if cls._instance is None: - cls._instance = super().__new__(cls) - return cls._instance - def __init__(self): if hasattr(self, '_client'): return - self._client = AtClient(DEFAULT_DEVICE, DEFAULT_BAUD, DEFAULT_TIMEOUT, debug=DEBUG) - self._client.open_isdr() + self._client = AtClient(DEFAULT_DEVICE, DEFAULT_BAUD, DEFAULT_TIMEOUT) atexit.register(self._client.close) + @contextmanager + def _acquire_channel(self): + fd = os.open(LOCK_FILE, os.O_CREAT | os.O_RDWR) + try: + fcntl.flock(fd, fcntl.LOCK_EX) + self._client.open_isdr() + yield + finally: + if self._client.channel: + try: + self._client.query(f"AT+CCHC={self._client.channel}") + except (RuntimeError, TimeoutError): + pass + self._client.channel = None + fcntl.flock(fd, fcntl.LOCK_UN) + os.close(fd) + def list_profiles(self) -> list[Profile]: - return [ - Profile( - iccid=p.get("iccid", ""), - nickname=p.get("profileNickname") or "", - enabled=p.get("profileState") == "enabled", - provider=p.get("serviceProviderName") or "", - ) - for p in list_profiles(self._client) - ] + with self._acquire_channel(): + return [ + Profile( + iccid=p.get("iccid", ""), + nickname=p.get("profileNickname") or "", + enabled=p.get("profileState") == "enabled", + provider=p.get("serviceProviderName") or "", + ) + for p in list_profiles(self._client) + ] def get_active_profile(self) -> Profile | None: return None def delete_profile(self, iccid: str) -> None: - return None + if self.is_comma_profile(iccid): + raise LPAError("refusing to delete a comma profile") + with self._acquire_channel(): + request = encode_tlv(TAG_DELETE_PROFILE, encode_tlv(TAG_ICCID, string_to_tbcd(iccid))) + response = es10x_command(self._client, request) + code = require_tag(require_tag(response, TAG_DELETE_PROFILE, "DeleteProfileResponse"), TAG_STATUS, "DeleteProfile status")[0] + if code != PROFILE_OK: + raise LPAError(f"DeleteProfile failed: {PROFILE_ERROR_CODES.get(code, 'unknown')} (0x{code:02X})") def download_profile(self, qr: str, nickname: str | None = None) -> None: return None def nickname_profile(self, iccid: str, nickname: str) -> None: - return None + with self._acquire_channel(): + set_profile_nickname(self._client, iccid, nickname) + + def _enable_profile(self, iccid: str) -> int: + inner = encode_tlv(TAG_OK, encode_tlv(TAG_ICCID, string_to_tbcd(iccid))) + inner += b'\x01\x01\x01' # refreshFlag=1 + response = es10x_command(self._client, encode_tlv(TAG_ENABLE_PROFILE, inner)) + return require_tag(require_tag(response, TAG_ENABLE_PROFILE, "EnableProfileResponse"), TAG_STATUS, "EnableProfile status")[0] def switch_profile(self, iccid: str) -> None: - return None + with self._acquire_channel(): + code = self._enable_profile(iccid) + if code == PROFILE_CAT_BUSY: # stale eUICC transaction, reset and retry + self._client._reset_modem() + self._client.open_isdr() + code = self._enable_profile(iccid) + if code not in (PROFILE_OK, PROFILE_NOT_IN_DISABLED_STATE): + raise LPAError(f"EnableProfile failed: {PROFILE_ERROR_CODES.get(code, 'unknown')} (0x{code:02X})") + from openpilot.system.hardware import HARDWARE + if HARDWARE.get_device_type() == "mici": + self._client.send_raw(b'AT+CFUN=0\rAT+CFUN=1\r') # mici has no SIM presence pin; raw because CFUN=0 drops serial + self._client._ensure_serial(reconnect=True) diff --git a/system/loggerd/config.py b/system/loggerd/config.py index e1c47c768d..d9befb5613 100644 --- a/system/loggerd/config.py +++ b/system/loggerd/config.py @@ -9,21 +9,26 @@ STATS_DIR_FILE_LIMIT = 10000 STATS_SOCKET = "ipc:///tmp/stats" STATS_FLUSH_TIME_S = 60 -def get_available_percent(default: float) -> float: +PATH_DICT = { + "internal": Paths.log_root(), + "external": Paths.log_root_external() +} + +def get_available_percent(default: float, path_type="internal") -> float: try: - statvfs = os.statvfs(Paths.log_root()) + statvfs = os.statvfs(PATH_DICT[path_type]) available_percent = 100.0 * statvfs.f_bavail / statvfs.f_blocks - except OSError: + except (OSError, KeyError): available_percent = default return available_percent -def get_available_bytes(default: int) -> int: +def get_available_bytes(default: int, path_type="internal") -> int: try: - statvfs = os.statvfs(Paths.log_root()) + statvfs = os.statvfs(PATH_DICT[path_type]) available_bytes = statvfs.f_bavail * statvfs.f_frsize - except OSError: + except (OSError, KeyError): available_bytes = default return available_bytes diff --git a/system/loggerd/deleter.py b/system/loggerd/deleter.py index eb8fd35f21..058f5c301d 100755 --- a/system/loggerd/deleter.py +++ b/system/loggerd/deleter.py @@ -1,7 +1,9 @@ #!/usr/bin/env python3 import os +import time import shutil import threading +from pathlib import Path from openpilot.system.hardware.hw import Paths from openpilot.common.swaglog import cloudlog from openpilot.system.loggerd.config import get_available_bytes, get_available_percent @@ -61,6 +63,41 @@ def deleter_thread(exit_event: threading.Event): if any(name.endswith(".lock") for name in os.listdir(delete_path)): continue + if Path(Paths.log_root_external()).is_mount(): + out_of_bytes_external = get_available_bytes(default=MIN_BYTES + 1, path_type="external") < MIN_BYTES + out_of_percent_external = get_available_percent(default=MIN_PERCENT + 1, path_type="external") < MIN_PERCENT + + if out_of_percent_external or out_of_bytes_external: + dirs_external = listdir_by_creation(Paths.log_root_external()) + + # remove the earliest external directory we can + for delete_dir_external in sorted(dirs_external): + delete_path_external = os.path.join(Paths.log_root_external(), delete_dir_external) + try: + cloudlog.warning(f"deleting {delete_path_external}") + shutil.rmtree(delete_path_external) + break + except OSError: + cloudlog.exception(f"issue deleting {delete_path_external}") + + # move directory from internal to external + path_external = os.path.join(Paths.log_root_external(), delete_dir) + try: + cloudlog.warning(f"moving {delete_path} to {path_external}") + start = time.monotonic() + shutil.move(delete_path, path_external) + cloudlog.warning(f"moved {delete_path} to {path_external} in {time.monotonic() - start:.2f}s") + break + except Exception: + cloudlog.error(f"issue moving {delete_path} to {path_external}") + try: + cloudlog.warning(f"deleting {delete_path}") + shutil.rmtree(delete_path) + break + except OSError: + cloudlog.exception(f"issue deleting {delete_path}") + continue + try: cloudlog.info(f"deleting {delete_path}") shutil.rmtree(delete_path) diff --git a/system/loggerd/logger.cc b/system/loggerd/logger.cc index 0ebe323939..e8aeb96d02 100644 --- a/system/loggerd/logger.cc +++ b/system/loggerd/logger.cc @@ -11,6 +11,8 @@ #include "common/swaglog.h" #include "common/version.h" +#include "sunnypilot/common/version.h" + // ***** log metadata ***** kj::Array logger_build_init_data() { uint64_t wall_time = nanos_since_epoch(); @@ -19,7 +21,7 @@ kj::Array logger_build_init_data() { auto init = msg.initEvent().initInitData(); init.setWallTimeNanos(wall_time); - init.setVersion(COMMA_VERSION); + init.setVersion(SUNNYPILOT_VERSION); init.setDirty(!getenv("CLEAN")); init.setDeviceType(Hardware::get_device_type()); diff --git a/system/manager/github_runner.sh b/system/manager/github_runner.sh new file mode 100755 index 0000000000..f2170cfc70 --- /dev/null +++ b/system/manager/github_runner.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash + +# Define the service name +SERVICE_NAME="actions.runner.sunnypilot.$(uname -n)" + +# Function to control the service +control_service() { + local action=$1 # Store the function argument in a local variable + sudo systemctl $action ${SERVICE_NAME} +} + +service_exists_and_is_loaded() { + sudo systemctl status ${SERVICE_NAME} &>/dev/null + if [[ $? -ne 4 ]]; then + return 0 # Service is known to systemd (i.e., loaded) + else + return 1 # Service is unknown to systemd (i.e., not loaded) + fi +} + +# Check for required argument +if [[ -z $1 ]] || { [[ $1 != "start" ]] && [[ $1 != "stop" ]]; }; then + echo "Usage: $0 {start|stop}" + exit 1 +fi + +# Store the script argument in a descriptive variable +ACTION=$1 + +# Trap EXIT signal (Ctrl+C) and stop the service +trap 'control_service stop ; exit' SIGINT SIGKILL EXIT + +# Enter the main loop +while true; do + # Check if the service is actually present on the system + if service_exists_and_is_loaded; then + control_service $ACTION # Call the function with the specified action + fi + sleep 1 # Pause before the next iteration +done \ No newline at end of file diff --git a/system/manager/manager.py b/system/manager/manager.py index 2d80c78ff5..3ec554f904 100755 --- a/system/manager/manager.py +++ b/system/manager/manager.py @@ -20,6 +20,9 @@ from openpilot.system.athena.registration import register, UNREGISTERED_DONGLE_I from openpilot.common.swaglog import cloudlog, add_file_handler from openpilot.system.version import get_build_metadata from openpilot.system.hardware.hw import Paths +from openpilot.system.hardware import PC + +from openpilot.sunnypilot.system.params_migration import run_migration def manager_init() -> None: @@ -35,9 +38,22 @@ def manager_init() -> None: if build_metadata.release_channel: params.clear_all(ParamKeyFlag.DEVELOPMENT_ONLY) + # device boot mode + if params.get("DeviceBootMode") == 1: # start in Always Offroad mode + params.put_bool("OffroadMode", True) + + # quick boot + if params.get_bool("QuickBootToggle") and not PC: + prebuilt_path = "/data/openpilot/prebuilt" + if not os.path.exists(prebuilt_path): + open(prebuilt_path, 'x').close() + if params.get_bool("RecordFrontLock"): params.put_bool("RecordFront", True) + if not PC: + run_migration(params) + # set unset params to their default value for k in params.all_keys(): default_value = params.get_default_value(k) @@ -59,8 +75,10 @@ def manager_init() -> None: params.put("GitCommitDate", build_metadata.openpilot.git_commit_date) params.put("GitBranch", build_metadata.channel) params.put("GitRemote", build_metadata.openpilot.git_origin) + params.put_bool("IsDevelopmentBranch", build_metadata.development_channel) params.put_bool("IsTestedBranch", build_metadata.tested_channel) params.put_bool("IsReleaseBranch", build_metadata.release_channel) + params.put_bool("IsReleaseSpBranch", build_metadata.release_sp_channel) params.put("HardwareSerial", serial) # set dongle id diff --git a/system/manager/process_config.py b/system/manager/process_config.py index 7e96b7776a..a9ecf302f2 100644 --- a/system/manager/process_config.py +++ b/system/manager/process_config.py @@ -2,10 +2,16 @@ import os import operator import platform -from cereal import car +from cereal import car, custom from openpilot.common.params import Params from openpilot.system.hardware import PC, TICI from openpilot.system.manager.process import PythonProcess, NativeProcess, DaemonProcess +from openpilot.system.hardware.hw import Paths + +from openpilot.sunnypilot.mapd.mapd_manager import MAPD_PATH + +from openpilot.sunnypilot.models.helpers import get_active_model_runner +from openpilot.sunnypilot.sunnylink.utils import sunnylink_need_register, sunnylink_ready, use_sunnylink_uploader WEBCAM = os.getenv("USE_WEBCAM") is not None @@ -58,6 +64,42 @@ def only_onroad(started: bool, params: Params, CP: car.CarParams) -> bool: def only_offroad(started: bool, params: Params, CP: car.CarParams) -> bool: return not started +def use_github_runner(started, params, CP: car.CarParams) -> bool: + return not PC and params.get_bool("EnableGithubRunner") and ( + not params.get_bool("NetworkMetered") and not params.get_bool("GithubRunnerSufficientVoltage")) + +def use_copyparty(started, params, CP: car.CarParams) -> bool: + return bool(params.get_bool("EnableCopyparty")) + +def sunnylink_ready_shim(started, params, CP: car.CarParams) -> bool: + """Shim for sunnylink_ready to match the process manager signature.""" + return sunnylink_ready(params) + +def sunnylink_need_register_shim(started, params, CP: car.CarParams) -> bool: + """Shim for sunnylink_need_register to match the process manager signature.""" + return sunnylink_need_register(params) + +def use_sunnylink_uploader_shim(started, params, CP: car.CarParams) -> bool: + """Shim for use_sunnylink_uploader to match the process manager signature.""" + return use_sunnylink_uploader(params) + +def is_tinygrad_model(started, params, CP: car.CarParams) -> bool: + """Check if the active model runner is SNPE.""" + return bool(get_active_model_runner(params, not started) == custom.ModelManagerSP.Runner.tinygrad) + +def is_stock_model(started, params, CP: car.CarParams) -> bool: + """Check if the active model runner is stock.""" + return bool(get_active_model_runner(params, not started) == custom.ModelManagerSP.Runner.stock) + +def mapd_ready(started: bool, params: Params, CP: car.CarParams) -> bool: + return bool(os.path.exists(Paths.mapd_root())) + +def uploader_ready(started: bool, params: Params, CP: car.CarParams) -> bool: + if not params.get_bool("OnroadUploads"): + return only_offroad(started, params, CP) + + return always_run(started, params, CP) + def or_(*fns): return lambda *args: operator.or_(*(fn(*args) for fn in fns)) @@ -79,7 +121,7 @@ procs = [ PythonProcess("micd", "system.micd", iscar), PythonProcess("timed", "system.timed", always_run, enabled=not PC), - PythonProcess("modeld", "selfdrive.modeld.modeld", only_onroad), + PythonProcess("modeld", "selfdrive.modeld.modeld", and_(only_onroad, is_stock_model)), PythonProcess("dmonitoringmodeld", "selfdrive.modeld.dmonitoringmodeld", driverview, enabled=(WEBCAM or not PC)), PythonProcess("sensord", "system.sensord.sensord", only_onroad, enabled=not PC), @@ -108,7 +150,7 @@ procs = [ PythonProcess("hardwared", "system.hardware.hardwared", always_run), PythonProcess("tombstoned", "system.tombstoned", always_run, enabled=not PC), PythonProcess("updated", "system.updated.updated", only_offroad, enabled=not PC), - PythonProcess("uploader", "system.loggerd.uploader", always_run), + PythonProcess("uploader", "system.loggerd.uploader", uploader_ready), PythonProcess("statsd", "system.statsd", always_run), PythonProcess("feedbackd", "selfdrive.ui.feedback.feedbackd", only_onroad), @@ -117,6 +159,45 @@ procs = [ PythonProcess("webrtcd", "system.webrtc.webrtcd", notcar), PythonProcess("webjoystick", "tools.bodyteleop.web", notcar), PythonProcess("joystick", "tools.joystick.joystick_control", and_(joystick, iscar)), + + # sunnylink <3 + DaemonProcess("manage_sunnylinkd", "sunnypilot.sunnylink.athena.manage_sunnylinkd", "SunnylinkdPid"), + PythonProcess("sunnylink_registration_manager", "sunnypilot.sunnylink.registration_manager", sunnylink_need_register_shim), + PythonProcess("statsd_sp", "sunnypilot.sunnylink.statsd", and_(always_run, sunnylink_ready_shim)), ] +# sunnypilot +procs += [ + # Models + PythonProcess("models_manager", "sunnypilot.models.manager", only_offroad), + NativeProcess("modeld_tinygrad", "sunnypilot/modeld_v2", ["./modeld"], and_(only_onroad, is_tinygrad_model)), + + # Backup + PythonProcess("backup_manager", "sunnypilot.sunnylink.backups.manager", and_(only_offroad, sunnylink_ready_shim)), + + # mapd + NativeProcess("mapd", Paths.mapd_root(), ["bash", "-c", f"{MAPD_PATH} > /dev/null 2>&1"], mapd_ready), + PythonProcess("mapd_manager", "sunnypilot.mapd.mapd_manager", always_run), + + # locationd + NativeProcess("locationd_llk", "sunnypilot/selfdrive/locationd", ["./locationd"], only_onroad), +] + +if os.path.exists("./github_runner.sh"): + procs += [NativeProcess("github_runner_start", "system/manager", ["./github_runner.sh", "start"], and_(only_offroad, use_github_runner), sigkill=False)] + +if os.path.exists("../../sunnypilot/sunnylink/uploader.py"): + procs += [PythonProcess("sunnylink_uploader", "sunnypilot.sunnylink.uploader", use_sunnylink_uploader_shim)] + +if os.path.exists("../../third_party/copyparty/copyparty-sfx.py"): + sunnypilot_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) + copyparty_args = [f"-v{Paths.crash_log_root()}:/swaglogs:r"] + copyparty_args += [f"-v{Paths.log_root()}:/routes:r"] + copyparty_args += [f"-v{Paths.model_root()}:/models:rw"] + copyparty_args += [f"-v{sunnypilot_root}:/sunnypilot:rw"] + copyparty_args += ["-p8080"] + copyparty_args += ["-z"] + copyparty_args += ["-q"] + procs += [NativeProcess("copyparty-sfx", "third_party/copyparty", ["./copyparty-sfx.py", *copyparty_args], and_(only_offroad, use_copyparty))] + managed_processes = {p.name: p for p in procs} diff --git a/system/sentry.py b/system/sentry.py index 47d64ba0fd..a1fb604dea 100644 --- a/system/sentry.py +++ b/system/sentry.py @@ -1,26 +1,35 @@ """Install exception handler for process crash.""" +import os +import traceback +from datetime import datetime import sentry_sdk from enum import Enum from sentry_sdk.integrations.threading import ThreadingIntegration from openpilot.common.params import Params -from openpilot.system.athena.registration import is_registered_device -from openpilot.system.hardware import HARDWARE, PC +from openpilot.system.athena.registration import UNREGISTERED_DONGLE_ID +from openpilot.system.hardware import HARDWARE +from openpilot.system.hardware.hw import Paths from openpilot.common.swaglog import cloudlog from openpilot.system.version import get_build_metadata, get_version +from openpilot.sunnypilot.sunnylink.api import UNREGISTERED_SUNNYLINK_DONGLE_ID + +CRASHES_DIR = Paths.crash_log_root() + class SentryProject(Enum): # python project - SELFDRIVE = "https://6f3c7076c1e14b2aa10f5dde6dda0cc4@o33823.ingest.sentry.io/77924" + SELFDRIVE = "https://186a6736b7927e5ae9b92c869ba81b6b@o1138119.ingest.us.sentry.io/4508660076052480" # native project - SELFDRIVE_NATIVE = "https://3e4b586ed21a4479ad5d85083b639bc6@o33823.ingest.sentry.io/157615" + SELFDRIVE_NATIVE = SELFDRIVE def report_tombstone(fn: str, message: str, contents: str) -> None: cloudlog.error({'tombstone': message}) with sentry_sdk.configure_scope() as scope: + set_user() scope.set_extra("tombstone_fn", fn) scope.set_extra("tombstone", contents) sentry_sdk.capture_message(message=message) @@ -31,25 +40,85 @@ def capture_exception(*args, **kwargs) -> None: cloudlog.error("crash", exc_info=kwargs.get('exc_info', 1)) try: + save_exception(traceback.format_exc()) + + set_user() sentry_sdk.capture_exception(*args, **kwargs) sentry_sdk.flush() # https://github.com/getsentry/sentry-python/issues/291 except Exception: cloudlog.exception("sentry exception") +def save_exception(content: str) -> None: + try: + if not os.path.exists(CRASHES_DIR): + os.makedirs(CRASHES_DIR) + + files = [ + os.path.join(CRASHES_DIR, datetime.now().strftime("%Y-%m-%d--%H-%M-%S.log")), + os.path.join(CRASHES_DIR, "error.log") + ] + + for fn in files: + with open(fn, 'w') as f: + if fn == "error.log": + lines = content.splitlines()[-3:] + f.write("\n".join(lines)) + else: + f.write(content) + + cloudlog.error(f"logged crash to {files}") + except Exception: + cloudlog.exception("error when attempting to save exception") + + +def capture_fingerprint_mock() -> None: + try: + set_user() + message = "car doesn't match any fingerprints" + sentry_sdk.capture_message(message=message, level="error") + sentry_sdk.flush() + except Exception as e: + cloudlog.exception(f"sentry fingerprint MOCK exception: {e}") + + +def capture_fingerprint(candidate: str, car_name: str) -> None: + try: + set_user() + sentry_sdk.set_tag("carFingerprint", candidate) + sentry_sdk.set_tag("carName", car_name) + + message = f"Fingerprinted {candidate}" + sentry_sdk.capture_message(message=message, level="info") + sentry_sdk.flush() + except Exception as e: + cloudlog.exception(f"sentry fingerprint exception: {e}") + + def set_tag(key: str, value: str) -> None: sentry_sdk.set_tag(key, value) +def set_user() -> None: + dongle_id, git_username, _ = get_properties() + sentry_sdk.set_user({"id": dongle_id, "name": git_username}) + + +def get_properties() -> tuple[str, str, str]: + params = Params() + hardware_serial: str = params.get("HardwareSerial") or "" + git_username: str = params.get("GithubUsername") or "" + dongle_id: str = params.get("DongleId") or f"{UNREGISTERED_DONGLE_ID}-{hardware_serial}" + sunnylink_dongle_id: str = params.get("SunnylinkDongleId") or UNREGISTERED_SUNNYLINK_DONGLE_ID + + return dongle_id, git_username, sunnylink_dongle_id + + def init(project: SentryProject) -> bool: build_metadata = get_build_metadata() - # forks like to mess with this, so double check - comma_remote = build_metadata.openpilot.comma_remote and "commaai" in build_metadata.openpilot.git_origin - if not comma_remote or not is_registered_device() or PC: - return False - env = "release" if build_metadata.tested_channel else "master" - dongle_id = Params().get("DongleId") + env = build_metadata.channel_type + dongle_id, git_username, sunnylink_dongle_id = get_properties() integrations = [] if project == SentryProject.SELFDRIVE: @@ -63,11 +132,12 @@ def init(project: SentryProject) -> bool: max_value_length=8192, environment=env) - sentry_sdk.set_user({"id": dongle_id}) + sentry_sdk.set_user({"id": dongle_id, "name": git_username}) sentry_sdk.set_tag("dirty", build_metadata.openpilot.is_dirty) sentry_sdk.set_tag("origin", build_metadata.openpilot.git_origin) sentry_sdk.set_tag("branch", build_metadata.channel) sentry_sdk.set_tag("commit", build_metadata.openpilot.git_commit) sentry_sdk.set_tag("device", HARDWARE.get_device_type()) + sentry_sdk.set_tag("sunnylink_dongle_id", sunnylink_dongle_id) return True diff --git a/system/statsd.py b/system/statsd.py index 33e9e9912d..3a67dd44c2 100755 --- a/system/statsd.py +++ b/system/statsd.py @@ -1,11 +1,15 @@ #!/usr/bin/env python3 +import base64 +import json import os +from decimal import Decimal + import zmq import time import uuid from pathlib import Path from collections import defaultdict -from datetime import datetime, UTC +from datetime import datetime, UTC, date from typing import NoReturn from openpilot.common.params import Params @@ -21,18 +25,21 @@ from openpilot.system.loggerd.config import STATS_DIR_FILE_LIMIT, STATS_SOCKET, class METRIC_TYPE: GAUGE = 'g' SAMPLE = 'sa' + RAW = 'r' + class StatLog: def __init__(self): self.pid = None self.zctx = None self.sock = None + self.stats_socket = STATS_SOCKET def connect(self) -> None: - self.zctx = zmq.Context() + self.zctx = zmq.Context.instance() or zmq.Context() self.sock = self.zctx.socket(zmq.PUSH) self.sock.setsockopt(zmq.LINGER, 10) - self.sock.connect(STATS_SOCKET) + self.sock.connect(self.stats_socket) self.pid = os.getpid() def __del__(self): @@ -60,6 +67,50 @@ class StatLog: self._send(f"{name}:{value}|{METRIC_TYPE.SAMPLE}") +class StatLogSP(StatLog): + def __init__(self, intercept=True): + """ + Initializes the class instance with an optional parameter to determine + if statistical logging should be configured or not. + + :param intercept: A boolean flag that indicates whether to initialize + the `comma_statlog`. If True, the `comma_statlog` attribute is + instantiated as a `StatLog` object. Defaults to True. + """ + super().__init__() + self.comma_statlog = StatLog() if intercept else None + self.stats_socket = f"{STATS_SOCKET}_sp" + + def connect(self) -> None: + super().connect() + if self.comma_statlog: + self.comma_statlog.connect() + + def __del__(self): + super().__del__() + if self.comma_statlog: + self.comma_statlog.__del__() + + def _send(self, metric: str) -> None: + super()._send(metric) + if self.comma_statlog: + self.comma_statlog._send(metric) + + @staticmethod + def default_converter(obj): + if isinstance(obj, (datetime, date)): + return obj.isoformat() + if isinstance(obj, set): + return list(obj) + if isinstance(obj, Decimal): + return float(obj) + return str(obj) # fallback for unknown types + + def raw(self, name: str, value: dict) -> None: + encoded_dict = base64.b64encode(json.dumps(value, default=self.default_converter).encode("utf-8")).decode("utf-8") + self._send(f"{name}:{encoded_dict}|{METRIC_TYPE.RAW}") + + def main() -> NoReturn: dongle_id = Params().get("DongleId") def get_influxdb_line(measurement: str, value: float | dict[str, float], timestamp: datetime, tags: dict) -> str: @@ -180,4 +231,4 @@ def main() -> NoReturn: if __name__ == "__main__": main() else: - statlog = StatLog() + statlog = StatLogSP(intercept=True) diff --git a/system/ubloxd/ubloxd.py b/system/ubloxd/ubloxd.py index e55cadcf78..78429a847b 100755 --- a/system/ubloxd/ubloxd.py +++ b/system/ubloxd/ubloxd.py @@ -365,7 +365,7 @@ class UbloxMsgParser: assert isinstance(s1, Glonass.String1) eph.p1 = int(s1.p1) tk = int(s1.t_k) - eph.tkDEPRECATED = tk + eph.deprecated.tk = tk eph.xVel = float(s1.x_vel) * math.pow(2, -20) eph.xAccel = float(s1.x_accel) * math.pow(2, -30) eph.x = float(s1.x) * math.pow(2, -11) diff --git a/system/ui/README.md b/system/ui/README.md index 79a4dd32ea..54697dada8 100644 --- a/system/ui/README.md +++ b/system/ui/README.md @@ -11,6 +11,7 @@ Quick start: * set `GRID=50` to show a 50-pixel alignment grid overlay * set `MAGIC_DEBUG=1` to show every dropped frames (only on device) * set `RECORD=1` to record the screen, output defaults to `output.mp4` but can be set with `RECORD_OUTPUT` +* set `SUNNYPILOT_UI=0` to run the stock UI instead of the sunnypilot UI * https://www.raylib.com/cheatsheet/cheatsheet.html * https://electronstudio.github.io/raylib-python-cffi/README.html#quickstart diff --git a/system/ui/lib/application.py b/system/ui/lib/application.py index 980410b022..a9e2982c95 100644 --- a/system/ui/lib/application.py +++ b/system/ui/lib/application.py @@ -22,6 +22,8 @@ from openpilot.system.hardware import HARDWARE, PC from openpilot.system.ui.lib.multilang import multilang from openpilot.common.realtime import Ratekeeper +from openpilot.system.ui.sunnypilot.lib.application import GuiApplicationExt + _DEFAULT_FPS = int(os.getenv("FPS", {'tizi': 20}.get(HARDWARE.get_device_type(), 60))) FPS_LOG_INTERVAL = 5 # Seconds between logging FPS drops FPS_DROP_THRESHOLD = 0.9 # FPS drop threshold for triggering a warning @@ -100,6 +102,7 @@ class FontWeight(StrEnum): BOLD = "Inter-Bold.fnt" SEMI_BOLD = "Inter-SemiBold.fnt" UNIFONT = "unifont.fnt" + AUDIOWIDE = "Audiowide-Regular.fnt" # Small UI fonts DISPLAY_REGULAR = "Inter-Regular.fnt" @@ -193,7 +196,7 @@ class MouseState: self._prev_mouse_event[slot] = ev -class GuiApplication: +class GuiApplication(GuiApplicationExt): def __init__(self, width: int | None = None, height: int | None = None): self._set_log_callback() @@ -242,6 +245,8 @@ class GuiApplication: self._render_profiler = None self._render_profile_start_time = None + GuiApplicationExt.__init__(self) + @property def frame(self): return self._frame @@ -647,6 +652,9 @@ class GuiApplication: if self._show_touches: self._draw_touch_points() + if self._show_mouse_coords: + self._draw_mouse_coordinates(gui_app.font(FontWeight.SEMI_BOLD)) + if self._grid_size > 0: self._draw_grid() diff --git a/system/ui/lib/multilang.py b/system/ui/lib/multilang.py index 3c6a6b8564..9d25427867 100644 --- a/system/ui/lib/multilang.py +++ b/system/ui/lib/multilang.py @@ -93,7 +93,8 @@ def load_translations(path) -> tuple[dict[str, str], dict[str, list[str]]]: max_idx = max(msgstr_plurals.keys()) if msgstr_plurals else 0 plurals[msgid] = [msgstr_plurals.get(i, '') for i in range(max_idx + 1)] else: - translations[msgid] = msgstr + if msgstr: + translations[msgid] = msgstr msgid = msgid_plural = msgstr = "" msgstr_plurals = {} field = None diff --git a/system/ui/lib/scroll_panel.py b/system/ui/lib/scroll_panel.py index a5b9fc70d3..6dd9ceaadc 100644 --- a/system/ui/lib/scroll_panel.py +++ b/system/ui/lib/scroll_panel.py @@ -41,8 +41,12 @@ class GuiScrollPanel: if DEBUG: rl.draw_rectangle_lines(0, 0, abs(int(self._velocity_filter_y.x)), 10, rl.RED) - # Handle mouse wheel - self._offset_filter_y.x += rl.get_mouse_wheel_move() * MOUSE_WHEEL_SCROLL_SPEED + # Handle mouse wheel only when the mouse cursor is over this panel + mouse_wheel = rl.get_mouse_wheel_move() + if mouse_wheel != 0: + mouse_pos = rl.get_mouse_position() + if rl.check_collision_point_rec(mouse_pos, bounds): + self._offset_filter_y.x += mouse_wheel * MOUSE_WHEEL_SCROLL_SPEED max_scroll_distance = max(0, content.height - bounds.height) if self._scroll_state == ScrollState.IDLE: diff --git a/system/ui/lib/wifi_manager.py b/system/ui/lib/wifi_manager.py index d3c855d9bc..b83f7bacc9 100644 --- a/system/ui/lib/wifi_manager.py +++ b/system/ui/lib/wifi_manager.py @@ -634,7 +634,7 @@ class WifiManager: 'connection': { 'type': ('s', '802-11-wireless'), 'uuid': ('s', str(uuid.uuid4())), - 'id': ('s', f'openpilot connection {ssid}'), + 'id': ('s', f'sunnypilot connection {ssid}'), 'autoconnect-retries': ('i', 0), }, '802-11-wireless': { diff --git a/system/ui/mici_setup.py b/system/ui/mici_setup.py index d55fc5e1eb..d04f08ca37 100755 --- a/system/ui/mici_setup.py +++ b/system/ui/mici_setup.py @@ -562,7 +562,7 @@ class Setup(Widget): except urllib.error.HTTPError as e: if e.code == 409: - self._download_failed_reason = "Incompatible openpilot version." + self._download_failed_reason = "Incompatible sunnypilot version." except Exception: self._download_failed_reason = "Invalid URL: " + self.download_url.replace("https://", "", 1) diff --git a/system/ui/spinner.py b/system/ui/spinner.py index 2a48b3889b..33f4543c3e 100755 --- a/system/ui/spinner.py +++ b/system/ui/spinner.py @@ -35,7 +35,7 @@ def clamp(value, min_value, max_value): class Spinner(Widget): def __init__(self): super().__init__() - self._comma_texture = gui_app.texture("images/spinner_comma.png", TEXTURE_SIZE, TEXTURE_SIZE) + self._comma_texture = gui_app.texture("../../sunnypilot/selfdrive/assets/images/spinner_sunnypilot.png", TEXTURE_SIZE, TEXTURE_SIZE) self._spinner_texture = gui_app.texture("images/spinner_track.png", TEXTURE_SIZE, TEXTURE_SIZE, alpha_premultiply=True) self._rotation = 0.0 self._progress: int | None = None diff --git a/system/ui/sunnypilot/lib/application.py b/system/ui/sunnypilot/lib/application.py new file mode 100644 index 0000000000..481886e37a --- /dev/null +++ b/system/ui/sunnypilot/lib/application.py @@ -0,0 +1,40 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import os + +import pyray as rl + +SHOW_MOUSE_COORDS = os.getenv("SHOW_MOUSE_COORDS") == "1" +SUNNYPILOT_UI = os.getenv("SUNNYPILOT_UI", "1") == "1" + + +class GuiApplicationExt: + def __init__(self): + self._show_mouse_coords = SHOW_MOUSE_COORDS + + @staticmethod + def sunnypilot_ui() -> bool: + return SUNNYPILOT_UI + + def _draw_mouse_coordinates(self, font): + coords_text = f"X:{int(rl.get_mouse_x())}, Y:{int(rl.get_mouse_y())}" + + green_color = rl.Color(0, 159, 47, 255) # Match the green color of FPS counter + + # Calculate text width to position it at the right edge; estimate width based on text length + # Each character is approximately 10-12 pixels wide at font size 20 + estimated_text_width = len(coords_text) * 11 + + # Position text at the top right corner, 10px from the top + screen_width = self._scaled_width if self._scale != 1.0 else self._width + text_pos = rl.Vector2(screen_width - estimated_text_width - 10, 6) + + # Draw the text + rl.draw_text_ex(font, coords_text, text_pos, 20, 0, green_color) + + def set_show_mouse_coords(self, show: bool): + self._show_mouse_coords = show diff --git a/system/ui/sunnypilot/lib/styles.py b/system/ui/sunnypilot/lib/styles.py new file mode 100644 index 0000000000..7a29b5bb13 --- /dev/null +++ b/system/ui/sunnypilot/lib/styles.py @@ -0,0 +1,95 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from dataclasses import dataclass + +import pyray as rl + + +@dataclass +class Base: + # Widget/Control Base Dimensions + ITEM_BASE_HEIGHT = 170 + ITEM_PADDING = 20 + ITEM_TEXT_FONT_SIZE = 50 + ITEM_DESC_FONT_SIZE = 40 + ITEM_DESC_V_OFFSET = 150 + ITEM_TEXT_VALUE_COLOR = rl.Color(170, 170, 170, 255) + CLOSE_BTN_SIZE = 160 + + TEXT_PADDING = 20 + + # Toggle Control + TOGGLE_HEIGHT = 120 + TOGGLE_WIDTH = int(TOGGLE_HEIGHT * 1.75) + TOGGLE_BG_HEIGHT = TOGGLE_HEIGHT - 20 + + # Button Control + BUTTON_ACTION_WIDTH = 300 + BUTTON_HEIGHT = 120 + + # Simple Button Control + SIMPLE_BUTTON_WIDTH = 800 + SIMPLE_BUTTON_HEIGHT = 150 + + +@dataclass +class DefaultStyleSP(Base): + # Base Colors + BASE_BG_COLOR = rl.Color(57, 57, 57, 255) # Grey + ON_BG_COLOR = rl.Color(28, 101, 186, 255) # Blue + OFF_BG_COLOR = BASE_BG_COLOR + ON_HOVER_BG_COLOR = rl.Color(17, 78, 150, 255) # Dark Blue + OFF_HOVER_BG_COLOR = rl.Color(21, 21, 21, 255) # Dark gray + DISABLED_ON_BG_COLOR = rl.Color(37, 70, 107, 255) # Dull Blue + DISABLED_OFF_BG_COLOR = rl.Color(39, 39, 39, 255) # Grey + ITEM_TEXT_COLOR = rl.WHITE + ITEM_DISABLED_TEXT_COLOR = rl.Color(88, 88, 88, 255) + ITEM_DESC_TEXT_COLOR = rl.Color(128, 128, 128, 255) + + # Toggle Control + TOGGLE_ON_COLOR = ON_BG_COLOR + TOGGLE_OFF_COLOR = OFF_BG_COLOR + TOGGLE_KNOB_COLOR = rl.WHITE + TOGGLE_DISABLED_ON_COLOR = DISABLED_ON_BG_COLOR + TOGGLE_DISABLED_OFF_COLOR = DISABLED_OFF_BG_COLOR + TOGGLE_DISABLED_KNOB_COLOR = rl.Color(88, 88, 88, 255) # Lighter Grey + + # Multi Button Control + MBC_TRANSPARENT = rl.Color(255, 255, 255, 0) + MBC_BG_CHECKED_ENABLED = rl.Color(0x69, 0x68, 0x68, 0xFF) + MBC_DISABLED = rl.Color(0xFF, 0xFF, 0xFF, 0x33) + + # Option Control + OPTION_CONTROL_CONTAINER_BG = OFF_BG_COLOR + OPTION_CONTROL_BTN_ENABLED = rl.Color(88, 88, 88, 255) + OPTION_CONTROL_BTN_PRESSED = rl.Color(0x69, 0x68, 0x68, 0xFF) + OPTION_CONTROL_BTN_DISABLED = DISABLED_OFF_BG_COLOR + OPTION_CONTROL_TEXT_ENABLED = rl.WHITE + OPTION_CONTROL_TEXT_PRESSED = rl.WHITE + OPTION_CONTROL_TEXT_DISABLED = ITEM_DISABLED_TEXT_COLOR + + # Tree Button Colors + BUTTON_PRIMARY_COLOR = rl.Color(70, 91, 234, 255) # Royal Blue + BUTTON_NEUTRAL_GRAY = rl.Color(51, 51, 51, 255) + BUTTON_DISABLED_BG_COLOR = rl.Color(30, 30, 30, 255) # Very Dark Grey + TREE_DIALOG_TRANSPARENT = rl.Color(0, 0, 0, 0) + TREE_DIALOG_SEARCH_BUTTON_PRESSED = rl.Color(0x69, 0x68, 0x68, 0xFF) + TREE_DIALOG_SEARCH_BUTTON_BORDER = rl.Color(150, 150, 150, 200) + + # Vehicle Description Colors + GREEN = rl.Color(0, 241, 0, 255) + BLUE = rl.Color(0, 134, 233, 255) + YELLOW = rl.Color(255, 213, 0, 255) + + # Button Colors + BUTTON_ENABLED_OFF = rl.Color(0x39, 0x39, 0x39, 0xFF) + BUTTON_OFF_PRESSED = rl.Color(0x4A, 0x4A, 0x4A, 0xFF) + BUTTON_DISABLED = rl.Color(0x12, 0x12, 0x12, 0xFF) + BUTTON_TEXT_DISABLED = rl.Color(0x5C, 0x5C, 0x5C, 0xFF) + + +style = DefaultStyleSP diff --git a/system/ui/sunnypilot/lib/utils.py b/system/ui/sunnypilot/lib/utils.py new file mode 100644 index 0000000000..ecaba86f4b --- /dev/null +++ b/system/ui/sunnypilot/lib/utils.py @@ -0,0 +1,36 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from openpilot.system.ui.sunnypilot.widgets.list_view import ButtonActionSP + + +class NoElideButtonAction(ButtonActionSP): + def get_width_hint(self): + return super().get_width_hint() + 1 + + +class AlertFadeAnimator: + def __init__(self, target_fps: int, duration_on: float = 0.75, rc: float = 0.05): + from openpilot.common.filter_simple import FirstOrderFilter + self._filter = FirstOrderFilter(1.0, rc, 1 / target_fps) + self._frame = 0 + self._target_fps = target_fps + self._duration_on = duration_on + + def update(self, active: bool): + if active: + self._frame += 1 + if (self._frame % self._target_fps) < (self._target_fps * self._duration_on): + self._filter.x = 1.0 + else: + self._filter.update(0.0) + else: + self._frame = 0 + self._filter.update(1.0) + + @property + def alpha(self) -> float: + return self._filter.x diff --git a/system/ui/sunnypilot/widgets/__init__.py b/system/ui/sunnypilot/widgets/__init__.py new file mode 100644 index 0000000000..96865e6042 --- /dev/null +++ b/system/ui/sunnypilot/widgets/__init__.py @@ -0,0 +1,18 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" + + +def get_highlighted_description(params, param_name: str, descriptions: list[str]) -> str: + index = int(params.get(param_name, return_default=True)) + lines = [] + for i, desc in enumerate(descriptions): + if i == index: + lines.append(f"{desc}") + else: + lines.append(f"{desc}") + + return "
".join(lines) diff --git a/system/ui/sunnypilot/widgets/helpers/__init__.py b/system/ui/sunnypilot/widgets/helpers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/system/ui/sunnypilot/widgets/helpers/fuzzy_search.py b/system/ui/sunnypilot/widgets/helpers/fuzzy_search.py new file mode 100644 index 0000000000..8d0f6bd59b --- /dev/null +++ b/system/ui/sunnypilot/widgets/helpers/fuzzy_search.py @@ -0,0 +1,40 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import re +import unicodedata + + +def normalize(text: str) -> str: + return unicodedata.normalize('NFKD', text).encode('ascii', 'ignore').decode('utf-8').lower() + + +def search_from_list(query: str, items: list[str]) -> list[str]: + if not query: + return items + + normalized_query = normalize(query) + search_terms = [re.sub(r'[^a-z0-9]', '', term) for term in normalized_query.split() if term.strip()] + + results = [] + for item in items: + normalized_item = normalize(item) + item_with_spaces = re.sub(r'[^a-z0-9\s]', ' ', normalized_item) + item_stripped = re.sub(r'[^a-z0-9]', '', normalized_item) + + all_terms_match = True + for term in search_terms: + if not term: + continue + + if term not in item_with_spaces and term not in item_stripped: + all_terms_match = False + break + + if all_terms_match: + results.append(item) + + return results diff --git a/system/ui/sunnypilot/widgets/helpers/star_icon.py b/system/ui/sunnypilot/widgets/helpers/star_icon.py new file mode 100644 index 0000000000..14666d49b6 --- /dev/null +++ b/system/ui/sunnypilot/widgets/helpers/star_icon.py @@ -0,0 +1,26 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import math + +import pyray as rl + + +def draw_star(center_x, center_y, radius, is_filled, color): + center = rl.Vector2(center_x, center_y) + points = [] + + for i in range(10): + angle = -(i * 36 + 18) * math.pi / 180 + r = radius if i % 2 == 0 else radius / 2 + x = center_x + r * math.cos(angle) + y = center_y + r * math.sin(angle) + points.append(rl.Vector2(x, y)) + + for i in range(10): + if is_filled: + rl.draw_triangle(center, points[i], points[(i + 1) % 10], color) + rl.draw_line_ex(points[i], points[(i + 1) % 10], 2, color) diff --git a/system/ui/sunnypilot/widgets/html_render.py b/system/ui/sunnypilot/widgets/html_render.py new file mode 100644 index 0000000000..a0018f5be7 --- /dev/null +++ b/system/ui/sunnypilot/widgets/html_render.py @@ -0,0 +1,27 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.widgets import DialogResult +from openpilot.system.ui.widgets.html_render import HtmlModal + + +class HtmlModalSP(HtmlModal): + def __init__(self, file_path=None, text=None, callback=None): + super().__init__(file_path=file_path, text=text) + self._callback = callback + self._dialog_result = DialogResult.NO_ACTION + self._ok_button._click_callback = self._on_ok_clicked + + def _on_ok_clicked(self): + self._dialog_result = DialogResult.CONFIRM + gui_app.pop_widget() + + if self._callback: + self._callback(self._dialog_result) + + def reset(self): + self._dialog_result = DialogResult.NO_ACTION diff --git a/system/ui/sunnypilot/widgets/input_dialog.py b/system/ui/sunnypilot/widgets/input_dialog.py new file mode 100644 index 0000000000..83b7edacf2 --- /dev/null +++ b/system/ui/sunnypilot/widgets/input_dialog.py @@ -0,0 +1,43 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from collections.abc import Callable + +from openpilot.common.params import Params +from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.widgets import DialogResult +from openpilot.system.ui.widgets.keyboard import Keyboard + + +class InputDialogSP: + def __init__(self, title: str, sub_title: str | None = None, current_text: str = "", param: str | None = None, + callback: Callable[[DialogResult, str], None] | None = None, + min_text_size: int = 0, password_mode: bool = False): + self.callback = callback + self.current_text = current_text + self.keyboard = Keyboard(max_text_size=255, min_text_size=min_text_size, password_mode=password_mode) + self.param = param + self._params = Params() + self.sub_title = sub_title + self.title = title + + def show(self): + self.keyboard.reset(min_text_size=self.keyboard._min_text_size) + if self.sub_title: + self.keyboard.set_title(self.title, self.sub_title) + else: + self.keyboard.set_title(self.title) + self.keyboard.set_text(self.current_text) + + def internal_callback(result: DialogResult): + text = self.keyboard.text if result == DialogResult.CONFIRM else "" + if result == DialogResult.CONFIRM and self.param: + self._params.put(self.param, text) + if self.callback: + self.callback(result, text) + + self.keyboard.set_callback(internal_callback) + gui_app.push_widget(self.keyboard) diff --git a/system/ui/sunnypilot/widgets/list_view.py b/system/ui/sunnypilot/widgets/list_view.py new file mode 100644 index 0000000000..1dd69c24cb --- /dev/null +++ b/system/ui/sunnypilot/widgets/list_view.py @@ -0,0 +1,411 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from collections.abc import Callable + +import pyray as rl +from openpilot.common.params import Params +from openpilot.system.ui.lib.application import gui_app, MousePos, FontWeight +from openpilot.system.ui.lib.text_measure import measure_text_cached +from openpilot.system.ui.sunnypilot.widgets.toggle import ToggleSP +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.button import Button, ButtonStyle +from openpilot.system.ui.widgets.label import gui_label +from openpilot.system.ui.widgets.list_view import ListItem, ToggleAction, ItemAction, MultipleButtonAction, ButtonAction, \ + _resolve_value, BUTTON_WIDTH, BUTTON_HEIGHT, TEXT_PADDING, DualButtonAction +from openpilot.system.ui.widgets.scroller_tici import LineSeparator, LINE_COLOR, LINE_PADDING +from openpilot.system.ui.sunnypilot.lib.styles import style +from openpilot.system.ui.sunnypilot.widgets.option_control import OptionControlSP, LABEL_WIDTH + + +class Spacer(Widget): + def __init__(self, height: int = 1): + super().__init__() + self._rect = rl.Rectangle(0, 0, 0, height) + + def set_parent_rect(self, parent_rect: rl.Rectangle) -> None: + super().set_parent_rect(parent_rect) + self._rect.width = parent_rect.width + + def _render(self, _): + rl.draw_rectangle(int(self._rect.x), int(self._rect.y), int(self._rect.x + self._rect.width), int(self._rect.y), rl.Color(0,0,0,0)) + + +class ToggleActionSP(ToggleAction): + def __init__(self, initial_state: bool = False, width: int = style.TOGGLE_WIDTH, enabled: bool | Callable[[], bool] = True, + callback: Callable[[bool], None] | None = None, param: str | None = None): + ToggleAction.__init__(self, initial_state, width, enabled, callback) + self.toggle = ToggleSP(initial_state=initial_state, callback=callback, param=param) + + +class ButtonSP(Button): + def _update_state(self): + super()._update_state() + if self.enabled: + if self.is_pressed: + self._background_color = style.BUTTON_OFF_PRESSED + else: + self._background_color = style.BUTTON_ENABLED_OFF + else: + self._background_color = style.BUTTON_DISABLED + self._label.set_text_color(style.BUTTON_TEXT_DISABLED) + + +class SimpleButtonActionSP(ItemAction): + def __init__(self, button_text: str | Callable[[], str], callback: Callable | None = None, + enabled: bool | Callable[[], bool] = True, button_width: int = style.SIMPLE_BUTTON_WIDTH): + super().__init__(width=button_width, enabled=enabled) + self.button_action = ButtonSP(button_text, click_callback=callback, button_style=ButtonStyle.NORMAL, + border_radius=20) + + def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None: + super().set_touch_valid_callback(touch_callback) + self.button_action.set_touch_valid_callback(touch_callback) + + def _render(self, rect: rl.Rectangle) -> bool | int | None: + self.button_action.set_enabled(self.enabled) + return self.button_action.render(rect) + + +class ButtonActionSP(ButtonAction): + def __init__(self, text: str | Callable[[], str], width: int = style.BUTTON_ACTION_WIDTH, enabled: bool | Callable[[], bool] = True): + super().__init__(text=text, width=width, enabled=enabled) + self._value_color: rl.Color = style.ITEM_TEXT_VALUE_COLOR + + def set_value(self, value: str | Callable[[], str], color: rl.Color = style.ITEM_TEXT_VALUE_COLOR): + self._value_source = value + self._value_color = color + + def _render(self, rect: rl.Rectangle) -> bool: + """Duplicate of ButtonAction._render, with additional value rendering""" + self._button.set_text(self.text) + self._button.set_enabled(_resolve_value(self.enabled)) + button_rect = rl.Rectangle(rect.x + rect.width - BUTTON_WIDTH, rect.y + (rect.height - BUTTON_HEIGHT) / 2, BUTTON_WIDTH, BUTTON_HEIGHT) + self._button.render(button_rect) + + value_text = self.value + if value_text: + value_rect = rl.Rectangle(rect.x, rect.y, rect.width - BUTTON_WIDTH - TEXT_PADDING, rect.height) + gui_label(value_rect, value_text, font_size=style.ITEM_TEXT_FONT_SIZE, color=self._value_color, + font_weight=FontWeight.NORMAL, alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) + + pressed = self._pressed + self._pressed = False + return pressed + + +class DualButtonActionSP(DualButtonAction): + def __init__(self, left_text: str | Callable[[], str], right_text: str | Callable[[], str], left_callback: Callable | None = None, + right_callback: Callable | None = None, enabled: bool | Callable[[], bool] = True, border_radius: int = 15): + DualButtonAction.__init__(self, left_text, right_text, left_callback, right_callback, enabled) + self.left_button._border_radius = self.right_button._border_radius = border_radius + + def _render(self, rect: rl.Rectangle): + button_spacing = 20 + button_height = 150 + button_width = (rect.width - button_spacing) / 2 + button_y = rect.y + (rect.height - button_height) / 2 + + left_rect = rl.Rectangle(rect.x, button_y, button_width, button_height) + right_rect = rl.Rectangle(rect.x + button_width + button_spacing, button_y, button_width, button_height) + + # expand one to full width if other is not visible + if not self.left_button.is_visible: + right_rect.x = rect.x + right_rect.width = rect.width + elif not self.right_button.is_visible: + left_rect.width = rect.width + + # Render buttons + self.left_button.render(left_rect) + self.right_button.render(right_rect) + + +class MultipleButtonActionSP(MultipleButtonAction): + def __init__(self, buttons: list[str | Callable[[], str]], button_width: int, selected_index: int = 0, callback: Callable | None = None, + param: str | None = None): + MultipleButtonAction.__init__(self, buttons, button_width, selected_index, callback) + self.param_key = param + self.params = Params() + if self.param_key: + self.selected_button = int(self.params.get(self.param_key, return_default=True)) + self._anim_x: float | None = None + self.enabled_buttons: set[int] | None = None + + def set_enabled_buttons(self, indices: set[int] | None): + self.enabled_buttons = indices + + def _render(self, rect: rl.Rectangle): + + button_y = rect.y + (rect.height - style.BUTTON_HEIGHT) / 2 + + total_width = len(self.buttons) * self.button_width + track_rect = rl.Rectangle(rect.x, button_y, total_width, style.BUTTON_HEIGHT) + + bg_color = style.MBC_TRANSPARENT + text_color = style.ITEM_TEXT_COLOR if self.enabled else style.MBC_DISABLED + highlight_color = style.MBC_BG_CHECKED_ENABLED if self.enabled else style.MBC_DISABLED + + # background + rl.draw_rectangle_rounded(track_rect, 0.2, 20, bg_color) + + # border + border_color = style.MBC_BG_CHECKED_ENABLED if self.enabled else style.MBC_DISABLED + rl.draw_rectangle_rounded_lines_ex(track_rect, 0.2, 20, 2, border_color) + + # highlight with animation + target_x = rect.x + self.selected_button * self.button_width + if not self._anim_x: + self._anim_x = target_x + self._anim_x += (target_x - self._anim_x) * 0.2 + + highlight_rect = rl.Rectangle(self._anim_x, button_y, self.button_width, style.BUTTON_HEIGHT) + rl.draw_rectangle_rounded(highlight_rect, 0.2, 20, highlight_color) + + # text + for i, _text in enumerate(self.buttons): + button_x = rect.x + i * self.button_width + + text = _resolve_value(_text, "") + text_size = measure_text_cached(self._font, text, 40) + text_x = button_x + (self.button_width - text_size.x) / 2 + text_y = button_y + (style.BUTTON_HEIGHT - text_size.y) / 2 + + # Check individual button enabled state + is_button_enabled = self.enabled and (self.enabled_buttons is None or i in self.enabled_buttons) + current_text_color = text_color if is_button_enabled else style.MBC_DISABLED + + rl.draw_text_ex(self._font, text, rl.Vector2(text_x, text_y), 40, 0, current_text_color) + + def _handle_mouse_release(self, mouse_pos: MousePos): + # Override parent method to check individual button enabled state + if not self.enabled: + return + + button_y = self._rect.y + (self._rect.height - style.BUTTON_HEIGHT) / 2 + for i, _ in enumerate(self.buttons): + button_x = self._rect.x + i * self.button_width + button_rect = rl.Rectangle(button_x, button_y, self.button_width, style.BUTTON_HEIGHT) + + if rl.check_collision_point_rec(mouse_pos, button_rect): + # Check if this specific button is enabled + if self.enabled_buttons is not None and i not in self.enabled_buttons: + return + + self.selected_button = i + if self.callback: + self.callback(i) + + if self.param_key: + self.params.put(self.param_key, self.selected_button) + + +class ListItemSP(ListItem): + def __init__(self, title: str | Callable[[], str] = "", icon: str | None = None, description: str | Callable[[], str] | None = None, + description_visible: bool = False, callback: Callable | None = None, + action_item: ItemAction | None = None, inline: bool = True, title_color: rl.Color = style.ITEM_TEXT_COLOR): + ListItem.__init__(self, title, icon, description, description_visible, callback, action_item) + self.title_color = title_color + self.inline = inline + if not self.inline: + self._rect.height += style.ITEM_BASE_HEIGHT/1.75 + self._right_value_source: str | Callable[[], str] | None = None + self._right_value_font = gui_app.font(FontWeight.NORMAL) + self._right_value_color: rl.Color = style.ITEM_TEXT_VALUE_COLOR + + def set_title(self, title: str | Callable[[], str] = ""): + self._title = title + + def set_right_value(self, value: str | Callable[[], str], color: rl.Color = style.ITEM_TEXT_VALUE_COLOR): + self._right_value_source = value + self._right_value_color = color + + @property + def right_value(self) -> str: + if self._right_value_source is None: + return "" + return str(_resolve_value(self._right_value_source, "")) + + def _update_state(self): + prev_desc = self._prev_description + super()._update_state() + if self.description_visible and self._prev_description != prev_desc: + content_width = int(self._rect.width - style.ITEM_PADDING * 2) + self._rect.height = self.get_item_height(self._font, content_width) + + def set_parent_rect(self, parent_rect: rl.Rectangle) -> None: + super().set_parent_rect(parent_rect) + if self.description_visible: + content_width = int(self._rect.width - style.ITEM_PADDING * 2) + self._rect.height = self.get_item_height(self._font, content_width) + + def get_item_height(self, font: rl.Font, max_width: int) -> float: + height = super().get_item_height(font, max_width) + + if self.description_visible: + height += style.ITEM_PADDING * 1.5 + + if not self.inline: + height += style.ITEM_BASE_HEIGHT / 1.75 + + return height + + def show_description(self, show: bool): + self._set_description_visible(show) + + def get_right_item_rect(self, item_rect: rl.Rectangle) -> rl.Rectangle: + if not self.action_item: + return rl.Rectangle(0, 0, 0, 0) + + if not self.inline: + text_size = measure_text_cached(self._font, self.title, style.ITEM_TEXT_FONT_SIZE) + action_y = item_rect.y + text_size.y + style.ITEM_PADDING * 3 + return rl.Rectangle(item_rect.x + style.ITEM_PADDING, action_y, item_rect.width - (style.ITEM_PADDING * 2), style.BUTTON_HEIGHT) + + right_width = self.action_item.get_width_hint() + if right_width == 0: + return rl.Rectangle(item_rect.x + style.ITEM_PADDING, item_rect.y, item_rect.width - (style.ITEM_PADDING * 2), style.ITEM_BASE_HEIGHT) + + content_width = item_rect.width - (style.ITEM_PADDING * 2) + title_width = measure_text_cached(self._font, self.title, style.ITEM_TEXT_FONT_SIZE).x + right_width = min(content_width - title_width, right_width) + if isinstance(self.action_item, ToggleAction) or isinstance(self.action_item, SimpleButtonActionSP): + action_x = item_rect.x + else: + action_x = item_rect.x + item_rect.width - right_width + action_y = item_rect.y + return rl.Rectangle(action_x, action_y, right_width, style.ITEM_BASE_HEIGHT) + + def _render(self, _): + if not self.is_visible: + return + + # Don't draw items that are not in parent's viewport + if (self._rect.y + self.rect.height) <= self._parent_rect.y or self._rect.y >= (self._parent_rect.y + self._parent_rect.height): + return + + content_x = self._rect.x + style.ITEM_PADDING + text_x = content_x + left_action_item = isinstance(self.action_item, ToggleAction) or isinstance(self.action_item, SimpleButtonActionSP) + + if left_action_item: + item_height = style.SIMPLE_BUTTON_HEIGHT if isinstance(self.action_item, SimpleButtonActionSP) else style.TOGGLE_HEIGHT + left_rect = rl.Rectangle( + content_x, + self._rect.y + (style.ITEM_BASE_HEIGHT - item_height) // 2, + self.action_item.rect.width, + item_height + ) + text_x = left_rect.x + left_rect.width + style.ITEM_PADDING * 1.5 + + # Draw title + if self.title: + self._text_size = measure_text_cached(self._font, self.title, style.ITEM_TEXT_FONT_SIZE) + item_y = self._rect.y + (style.ITEM_BASE_HEIGHT - self._text_size.y) // 2 + rl.draw_text_ex(self._font, self.title, rl.Vector2(text_x, item_y), style.ITEM_TEXT_FONT_SIZE, 0, self.title_color) + + value_text = self.right_value + if value_text: + # area from after the title to the right edge of the row + value_rect = rl.Rectangle( + text_x, # start at the beginning of the text area + self._rect.y, + self._rect.width - (text_x - self._rect.x) - style.ITEM_PADDING, + style.ITEM_BASE_HEIGHT, + ) + if value_rect.width > 0: + gui_label(value_rect, value_text, font_size=style.ITEM_TEXT_FONT_SIZE, color=self._right_value_color, font_weight=FontWeight.NORMAL, + alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) + + # Render toggle and handle callback + if self.action_item.render(left_rect) and self.action_item.enabled: + if self.callback: + self.callback() + + else: + if self.title: + # Draw main text + self._text_size = measure_text_cached(self._font, self.title, style.ITEM_TEXT_FONT_SIZE) + item_y = self._rect.y + (style.ITEM_BASE_HEIGHT - self._text_size.y) // 2 if self.inline else self._rect.y + style.ITEM_PADDING * 1.5 + rl.draw_text_ex(self._font, self.title, rl.Vector2(text_x, item_y), style.ITEM_TEXT_FONT_SIZE, 0, self.title_color) + + # Draw right item if present + if self.action_item: + right_rect = self.get_right_item_rect(self._rect) + if self.action_item.render(right_rect) and self.action_item.enabled: + # Right item was clicked/activated + if self.callback: + self.callback() + + # Draw description if visible + if self.description_visible: + content_width = int(self._rect.width - style.ITEM_PADDING * 2) + description_height = self._html_renderer.get_total_height(content_width) + + desc_y = self._rect.y + style.ITEM_DESC_V_OFFSET + if not self.inline and self.action_item: + desc_y = self.action_item.rect.y + style.ITEM_DESC_V_OFFSET - style.ITEM_PADDING * 0.5 + + description_rect = rl.Rectangle(self._rect.x + style.ITEM_PADDING, desc_y, content_width, description_height) + self._html_renderer.render(description_rect) + + +def simple_button_item_sp(button_text: str | Callable[[], str], callback: Callable | None = None, + enabled: bool | Callable[[], bool] = True, button_width: int = style.SIMPLE_BUTTON_WIDTH) -> ListItemSP: + action = SimpleButtonActionSP(button_text=button_text, enabled=enabled, callback=callback, button_width=button_width) + return ListItemSP(title="", callback=callback, description="", action_item=action) + + +def toggle_item_sp(title: str | Callable[[], str], description: str | Callable[[], str] | None = None, initial_state: bool = False, + callback: Callable | None = None, icon: str = "", enabled: bool | Callable[[], bool] = True, param: str | None = None) -> ListItemSP: + action = ToggleActionSP(initial_state=initial_state, enabled=enabled, callback=callback, param=param) + return ListItemSP(title=title, description=description, action_item=action, icon=icon, callback=callback) + + +def multiple_button_item_sp(title: str | Callable[[], str], description: str | Callable[[], str], buttons: list[str | Callable[[], str]], + selected_index: int = 0, button_width: int = style.BUTTON_ACTION_WIDTH, callback: Callable | None = None, + icon: str = "", param: str | None = None, inline: bool = False) -> ListItemSP: + action = MultipleButtonActionSP(buttons, button_width, selected_index, callback=callback, param=param) + return ListItemSP(title=title, description=description, icon=icon, action_item=action, inline=inline) + + +def option_item_sp(title: str | Callable[[], str], param: str, + min_value: int, max_value: int, description: str | Callable[[], str] | None = None, + value_change_step: int = 1, on_value_changed: Callable[[int], None] | None = None, + enabled: bool | Callable[[], bool] = True, + icon: str = "", label_width: int = LABEL_WIDTH, value_map: dict[int, int] | None = None, + use_float_scaling: bool = False, label_callback: Callable[[int], str] | None = None, inline: bool = False) -> ListItemSP: + action = OptionControlSP( + param, min_value, max_value, value_change_step, + enabled, on_value_changed, value_map, label_width, use_float_scaling, label_callback + ) + return ListItemSP(title=title, description=description, action_item=action, icon=icon, inline=inline) + + +def button_item_sp(title: str | Callable[[], str], button_text: str | Callable[[], str], description: str | Callable[[], str] | None = None, + callback: Callable | None = None, enabled: bool | Callable[[], bool] = True) -> ListItemSP: + action = ButtonActionSP(text=button_text, enabled=enabled) + return ListItemSP(title=title, description=description, action_item=action, callback=callback) + + +def dual_button_item_sp(left_text: str | Callable[[], str], right_text: str | Callable[[], str], left_callback: Callable | None = None, + right_callback: Callable | None = None, description: str | Callable[[], str] | None = None, + enabled: bool | Callable[[], bool] = True, border_radius: int = 15) -> ListItemSP: + action = DualButtonActionSP(left_text, right_text, left_callback, right_callback, enabled, border_radius) + return ListItemSP(title="", description=description, action_item=action) + + +class LineSeparatorSP(LineSeparator): + def __init__(self, height: int = 1): + super().__init__() + self._rect = rl.Rectangle(0, 0, 0, height) + + def _render(self, _): + line_y = int(self._rect.y + self._rect.height // 2) + rl.draw_line(int(self._rect.x) + LINE_PADDING, line_y, + int(self._rect.x + self._rect.width) - LINE_PADDING, line_y, + LINE_COLOR) diff --git a/system/ui/sunnypilot/widgets/option_control.py b/system/ui/sunnypilot/widgets/option_control.py new file mode 100644 index 0000000000..291d8f6ff0 --- /dev/null +++ b/system/ui/sunnypilot/widgets/option_control.py @@ -0,0 +1,166 @@ +import pyray as rl +from collections.abc import Callable +from openpilot.common.params import Params +from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos +from openpilot.system.ui.lib.text_measure import measure_text_cached +from openpilot.system.ui.sunnypilot.lib.styles import style +from openpilot.system.ui.widgets.list_view import ItemAction + +# Dimensions and styling constants +BUTTON_WIDTH = 150 +BUTTON_HEIGHT = 150 +LABEL_WIDTH = 350 +BUTTON_SPACING = 25 +VALUE_FONT_SIZE = 50 +BUTTON_FONT_SIZE = 60 +CONTAINER_PADDING = 20 + + +class OptionControlSP(ItemAction): + def __init__(self, param: str, min_value: int, max_value: int, + value_change_step: int = 1, enabled: bool | Callable[[], bool] = True, + on_value_changed: Callable[[int], None] | None = None, + value_map: dict[int, int] | None = None, + label_width: int = LABEL_WIDTH, + use_float_scaling: bool = False, label_callback: Callable[[int], str] | None = None): + + super().__init__(enabled=enabled) + self.params = Params() + self.param_key = param + self.min_value = min_value + self.max_value = max_value + self.value_change_step = value_change_step + self._minus_enabled = enabled + self._plus_enabled = enabled + self.on_value_changed = on_value_changed + self.value_map = value_map + self.label_width = label_width + self.use_float_scaling = use_float_scaling + self.current_value = min_value + self.label_callback = label_callback + if self.value_map: + for key in self.value_map: + if self.value_map[key] == self.params.get(self.param_key, return_default=True): + self.current_value = int(key) + break + else: + value = self.params.get(self.param_key, return_default=True) + self.current_value = int(float(value) * 100.0) if self.use_float_scaling else int(value) + + # Initialize font and button styles + self._font = gui_app.font(FontWeight.MEDIUM) + + # Layout rectangles for components + self.minus_btn_rect = rl.Rectangle(0, 0, 0, 0) + self.plus_btn_rect = rl.Rectangle(0, 0, 0, 0) + + def get_value(self) -> int: + """Get the current value of the control""" + return self.current_value + + def set_value(self, value: int): + """Set the control to a specific value""" + if self.min_value <= value <= self.max_value: + self.current_value = value + if self.value_map: + self.params.put(self.param_key, self.value_map[value]) + else: + if self.use_float_scaling: + self.params.put(self.param_key, value / 100.0) + else: + self.params.put(self.param_key, value) + if self.on_value_changed: + self.on_value_changed(value) + + def get_displayed_value(self) -> str: + """Get the displayed value, handling value mapping if present""" + value = self.current_value + + if callable(self.label_callback): + if self.value_map: + return self.label_callback(self.value_map[value]) + else: + return self.label_callback(value) + + if self.value_map: + # Use the value map to get the display string + if value in self.value_map: + return str(self.value_map[value]) # Return the display string + + # If using float scaling, format as float + if self.use_float_scaling: + return f"{value / 100.0:.2f}" + + return str(value) + + def _render(self, rect: rl.Rectangle): + if self._rect.width == 0 or self._rect.height == 0 or not self.is_visible: + return + + control_width = (BUTTON_WIDTH * 2) + self.label_width + (BUTTON_SPACING * 2) + total_width = control_width + (CONTAINER_PADDING * 2) + self._rect.width = total_width + + start_x = self._rect.x + self._rect.width - control_width - (CONTAINER_PADDING * 2) + component_y = rect.y + (rect.height - BUTTON_HEIGHT) / 2 + self.container_rect = rl.Rectangle(start_x, component_y, total_width, BUTTON_HEIGHT) + + # background + rl.draw_rectangle_rounded(self.container_rect, 0.2, 20, style.OPTION_CONTROL_CONTAINER_BG) + + # minus button + self.minus_btn_rect = rl.Rectangle(self.container_rect.x, component_y, BUTTON_WIDTH + CONTAINER_PADDING, + BUTTON_HEIGHT) + + # label + label_x = self.container_rect.x + CONTAINER_PADDING + BUTTON_WIDTH + BUTTON_SPACING + self.label_rect = rl.Rectangle(label_x, component_y, self.label_width, BUTTON_HEIGHT) + + # plus button + plus_x = label_x + self.label_width + BUTTON_SPACING + self.plus_btn_rect = rl.Rectangle(plus_x, component_y, BUTTON_WIDTH + CONTAINER_PADDING, BUTTON_HEIGHT) + + self._minus_enabled = self.enabled and self.current_value > self.min_value + self._plus_enabled = self.enabled and self.current_value < self.max_value + + self._render_button(self.minus_btn_rect, "-", self._minus_enabled) + self._render_value_label() + self._render_button(self.plus_btn_rect, "+", self._plus_enabled) + + def _render_button(self, rect: rl.Rectangle, text: str, enabled: bool): + mouse_pos = rl.get_mouse_position() + is_pressed = (rl.check_collision_point_rec(mouse_pos, rect) and + self._touch_valid() and rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT)) + + text_color = style.ITEM_TEXT_COLOR if enabled else style.ITEM_DISABLED_TEXT_COLOR + + # highlight + if enabled and is_pressed: + rl.draw_rectangle_rounded(rect, 0.2, 20, style.OPTION_CONTROL_BTN_PRESSED) + + # button text + text_size = measure_text_cached(self._font, text, BUTTON_FONT_SIZE) + text_x = rect.x + (rect.width - text_size.x) / 2 + text_y = rect.y + (rect.height - text_size.y) / 2 + rl.draw_text_ex(self._font, text, rl.Vector2(text_x, text_y), BUTTON_FONT_SIZE, 0, text_color) + + def _render_value_label(self): + """Render the current value label""" + text = self.get_displayed_value() + text_color = style.ITEM_TEXT_COLOR if self.enabled else style.ITEM_DISABLED_TEXT_COLOR + + text_size = measure_text_cached(self._font, text, VALUE_FONT_SIZE) + text_x = self.label_rect.x + (self.label_rect.width - text_size.x) / 2 + text_y = self.label_rect.y + (self.label_rect.height - text_size.y) / 2 + + rl.draw_text_ex(self._font, text, rl.Vector2(text_x, text_y), VALUE_FONT_SIZE, 0, text_color) + + def _handle_mouse_release(self, mouse_pos: MousePos): + if self._minus_enabled and rl.check_collision_point_rec(mouse_pos, self.minus_btn_rect): + self.current_value -= self.value_change_step + self.current_value = max(self.min_value, self.current_value) + elif self._plus_enabled and rl.check_collision_point_rec(mouse_pos, self.plus_btn_rect): + self.current_value += self.value_change_step + self.current_value = min(self.max_value, self.current_value) + + self.set_value(self.current_value) diff --git a/system/ui/sunnypilot/widgets/progress_bar.py b/system/ui/sunnypilot/widgets/progress_bar.py new file mode 100644 index 0000000000..76f4243411 --- /dev/null +++ b/system/ui/sunnypilot/widgets/progress_bar.py @@ -0,0 +1,57 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import pyray as rl +from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.text_measure import measure_text_cached +from openpilot.system.ui.widgets.list_view import ListItem, ItemAction + + +class ProgressBarAction(ItemAction): + def __init__(self, width=600): + super().__init__(width=width) + self.progress = 0.0 + self.text = "" + self.show_progress = False + self.text_color = rl.GRAY + self._font = gui_app.font(FontWeight.NORMAL) + + def update(self, progress, text, show_progress=False, text_color=rl.GRAY): + self.progress = progress + self.text = text + self.show_progress = show_progress + self.text_color = text_color + + def _render(self, rect: rl.Rectangle): + font_size = 40 + text_size = measure_text_cached(self._font, self.text, font_size) + padding = 30 + bar_width = text_size.x + 2 * padding + text_x = (bar_width - text_size.x) / 2 + + if self.show_progress and len(parts := self.text.split(' - ', 1)) == 2: + prefix = parts[0] + max_prefix_w = measure_text_cached(self._font, "100%", font_size).x + current_prefix_w = measure_text_cached(self._font, prefix, font_size).x + + bar_width = (text_size.x - current_prefix_w + max_prefix_w) + 2 * padding + text_x = padding + (max_prefix_w - current_prefix_w) + + bar_height = 60 + bar_rect = rl.Rectangle(rect.x + rect.width - bar_width, rect.y + (rect.height - bar_height) / 2, bar_width, bar_height) + + if self.show_progress: + inner_rect = rl.Rectangle(bar_rect.x + 4, bar_rect.y + 4, bar_rect.width - 8, bar_rect.height - 8) + if inner_rect.width > 0: + fill_width = max(0, min(inner_rect.width, inner_rect.width * (self.progress / 100.0))) + rl.draw_rectangle_rounded(rl.Rectangle(inner_rect.x, inner_rect.y, fill_width, inner_rect.height), 0.2, 10, rl.Color(30, 121, 232, 255)) + + rl.draw_text_ex(self._font, self.text, rl.Vector2(bar_rect.x + text_x, bar_rect.y + (bar_height - text_size.y) / 2), font_size, 0, self.text_color) + + +def progress_item(title): + action = ProgressBarAction() + return ListItem(title=title, action_item=action) diff --git a/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py b/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py new file mode 100644 index 0000000000..77e8b5fd2c --- /dev/null +++ b/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py @@ -0,0 +1,139 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import base64 + +import pyray as rl +from openpilot.common.swaglog import cloudlog +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.selfdrive.ui.widgets.pairing_dialog import PairingDialog +from openpilot.sunnypilot.sunnylink.api import SunnylinkApi, UNREGISTERED_SUNNYLINK_DONGLE_ID, API_HOST +from openpilot.system.ui.lib.application import FontWeight, gui_app +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.lib.wrap_text import wrap_text +from openpilot.system.ui.lib.text_measure import measure_text_cached + + +class SunnylinkPairingDialog(PairingDialog): + """Dialog for device pairing with QR code.""" + + QR_REFRESH_INTERVAL = 300 # 5 minutes in seconds + + def __init__(self, sponsor_pairing: bool = False): + PairingDialog.__init__(self) + self._sponsor_pairing = sponsor_pairing + self._is_paired_prev = ui_state.sunnylink_state.is_paired() + + def _get_pairing_url(self) -> str: + qr_string = "https://github.com/sponsors/sunnyhaibin" + + if self._sponsor_pairing: + try: + sl_dongle_id = self.params.get("SunnylinkDongleId") or UNREGISTERED_SUNNYLINK_DONGLE_ID + token = SunnylinkApi(sl_dongle_id).get_token() + inner_string = f"1|{sl_dongle_id}|{token}" + payload_bytes = base64.b64encode(inner_string.encode('utf-8')).decode('utf-8') + qr_string = f"{API_HOST}/sso?state={payload_bytes}" + except Exception: + cloudlog.exception("Failed to get pairing token") + + return qr_string + + def _update_state(self): + is_paired = ui_state.sunnylink_state.is_paired() + if not self._is_paired_prev and is_paired: + gui_app.pop_widget() + + def _render(self, rect: rl.Rectangle) -> int: + rl.clear_background(rl.Color(224, 224, 224, 255)) + + self._check_qr_refresh() + + margin = 70 + content_rect = rl.Rectangle(rect.x + margin, rect.y + margin, rect.width - 2 * margin, rect.height - 2 * margin) + y = content_rect.y + + # Close button + close_size = 80 + pad = 20 + close_rect = rl.Rectangle(content_rect.x - pad, y - pad, close_size + pad * 2, close_size + pad * 2) + self._close_btn.render(close_rect) + + y += close_size + 40 + + # Title + title = tr("Pair your GitHub account") if self._sponsor_pairing else tr("Early Access: Become a sunnypilot Sponsor") + title_font = gui_app.font(FontWeight.NORMAL) + left_width = int(content_rect.width * 0.5 - 15) + + title_wrapped = wrap_text(title_font, title, 75, left_width) + rl.draw_text_ex(title_font, "\n".join(title_wrapped), rl.Vector2(content_rect.x, y), 75, 0.0, rl.BLACK) + y += len(title_wrapped) * 75 + 60 + + # Two columns: instructions and QR code + remaining_height = content_rect.height - (y - content_rect.y) + right_width = content_rect.width // 2 - 20 + + # Instructions + self._render_instructions(rl.Rectangle(content_rect.x, y, left_width, remaining_height)) + + # QR code + qr_size = min(right_width, content_rect.height) - 40 + qr_x = content_rect.x + left_width + 40 + (right_width - qr_size) // 2 + qr_y = content_rect.y + self._render_qr_code(rl.Rectangle(qr_x, qr_y, qr_size, qr_size)) + + return -1 + + def _render_instructions(self, rect: rl.Rectangle) -> None: + if self._sponsor_pairing: + instructions = [ + tr("Scan the QR code to login to your GitHub account"), + tr("Follow the prompts to complete the pairing process"), + tr("Re-enter the \"sunnylink\" panel to verify sponsorship status"), + tr("If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai") + ] + else: + instructions = [ + tr("Scan the QR code to visit sunnyhaibin's GitHub Sponsors page"), + tr("Choose your sponsorship tier and confirm your support"), + tr("Join our Community Forum at https://community.sunnypilot.ai and reach out to a moderator if you have issues") + ] + + font = gui_app.font(FontWeight.BOLD) + y = rect.y + + for i, text in enumerate(instructions): + circle_radius = 25 + circle_x = rect.x + circle_radius + 15 + text_x = rect.x + circle_radius * 2 + 40 + text_width = rect.width - (circle_radius * 2 + 40) + + wrapped = wrap_text(font, text, 47, int(text_width)) + text_height = len(wrapped) * 47 + circle_y = y + text_height // 2 + + # Circle and number + rl.draw_circle(int(circle_x), int(circle_y), circle_radius, rl.Color(70, 70, 70, 255)) + number = str(i + 1) + number_size = measure_text_cached(font, number, 30) + rl.draw_text_ex(font, number, (int(circle_x - number_size.x // 2), int(circle_y - number_size.y // 2)), 30, 0, rl.WHITE) + + # Text + rl.draw_text_ex(font, "\n".join(wrapped), rl.Vector2(text_x, y), 47, 0.0, rl.BLACK) + y += text_height + 50 + + +if __name__ == "__main__": + gui_app.init_window("pairing device") + pairing = SunnylinkPairingDialog(sponsor_pairing=True) + try: + for _ in gui_app.render(): + result = pairing.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) + if result != -1: + break + finally: + del pairing diff --git a/system/ui/sunnypilot/widgets/toggle.py b/system/ui/sunnypilot/widgets/toggle.py new file mode 100644 index 0000000000..2924aec2b4 --- /dev/null +++ b/system/ui/sunnypilot/widgets/toggle.py @@ -0,0 +1,59 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from collections.abc import Callable + +import pyray as rl +from openpilot.common.params import Params +from openpilot.system.ui.lib.application import MousePos +from openpilot.system.ui.widgets.toggle import Toggle +from openpilot.system.ui.sunnypilot.lib.styles import style + +KNOB_PADDING = 5 +KNOB_RADIUS = style.TOGGLE_BG_HEIGHT / 2 - KNOB_PADDING + + +class ToggleSP(Toggle): + def __init__(self, initial_state=False, callback: Callable[[bool], None] | None = None, param: str | None = None): + self.param_key = param + self.params = Params() + if self.param_key: + initial_state = self.params.get_bool(self.param_key) + Toggle.__init__(self, initial_state, callback) + + def set_rect(self, rect: rl.Rectangle): + self._rect = rl.Rectangle(rect.x, rect.y, style.TOGGLE_WIDTH, style.TOGGLE_HEIGHT) + + def _handle_mouse_release(self, mouse_pos: MousePos): + super()._handle_mouse_release(mouse_pos) + if self._enabled and self.param_key: + self.params.put_bool(self.param_key, self._state) + + def _render(self, rect: rl.Rectangle): + self.update() + self._rect.y -= style.ITEM_PADDING / 2 + if self._enabled: + bg_color = self._blend_color(style.TOGGLE_OFF_COLOR, style.TOGGLE_ON_COLOR, self._progress) + knob_color = style.TOGGLE_KNOB_COLOR + else: + bg_color = self._blend_color(style.TOGGLE_DISABLED_OFF_COLOR, style.TOGGLE_DISABLED_ON_COLOR, self._progress) + knob_color = style.TOGGLE_DISABLED_KNOB_COLOR + + # Draw background + bg_rect = rl.Rectangle(self._rect.x, self._rect.y, style.TOGGLE_WIDTH, style.TOGGLE_BG_HEIGHT) + + # Draw actual background + rl.draw_rectangle_rounded(bg_rect, 1.0, 10, bg_color) + + left_edge = bg_rect.x + KNOB_PADDING + right_edge = bg_rect.x + bg_rect.width - KNOB_PADDING + + knob_travel_distance = right_edge - left_edge - 2 * KNOB_RADIUS + min_knob_x = left_edge + KNOB_RADIUS + knob_x = min_knob_x + knob_travel_distance * self._progress + knob_y = self._rect.y + style.TOGGLE_BG_HEIGHT / 2 + + rl.draw_circle(int(knob_x), int(knob_y), KNOB_RADIUS, knob_color) diff --git a/system/ui/sunnypilot/widgets/tree_dialog.py b/system/ui/sunnypilot/widgets/tree_dialog.py new file mode 100644 index 0000000000..c34db092e8 --- /dev/null +++ b/system/ui/sunnypilot/widgets/tree_dialog.py @@ -0,0 +1,293 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from dataclasses import dataclass, field + +import pyray as rl +from openpilot.common.params import Params +from openpilot.system.ui.lib.application import FontWeight +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.widgets import DialogResult +from openpilot.system.ui.widgets.button import Button, ButtonStyle, BUTTON_PRESSED_BACKGROUND_COLORS +from openpilot.system.ui.widgets.label import gui_label +from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog + +from openpilot.system.ui.sunnypilot.lib.styles import style +from openpilot.system.ui.sunnypilot.widgets.helpers.fuzzy_search import search_from_list +from openpilot.system.ui.sunnypilot.widgets.helpers.star_icon import draw_star +from openpilot.system.ui.sunnypilot.widgets.input_dialog import InputDialogSP + + +@dataclass +class TreeNode: + ref: str + data: dict = field(default_factory=dict) + + +@dataclass +class TreeFolder: + folder: str + nodes: list + + +class TreeItemWidget(Button): + def __init__(self, text, ref, is_folder=False, indent_level=0, click_callback=None, favorite_callback=None, is_favorite=False, is_expanded=False): + super().__init__(text, click_callback, button_style=ButtonStyle.NORMAL, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, + text_padding=20 + indent_level * 30, elide_right=True) + self.text = text + self.ref = ref + self.is_folder = is_folder + self.indent_level = indent_level + self.is_favorite = is_favorite + self.selected = False + self._favorite_callback = favorite_callback + self.text_padding = 20 + indent_level * 30 + self.border_radius = 10 + self.is_expanded = is_expanded + + def _render(self, rect): + indent = 60 * self.indent_level + self._rect = rl.Rectangle(rect.x + indent, rect.y, rect.width - indent, rect.height) + if self.is_pressed: + color = BUTTON_PRESSED_BACKGROUND_COLORS[self._button_style] + elif self.selected and self.ref != "search_bar": + color = style.BUTTON_PRIMARY_COLOR + else: + color = style.BUTTON_DISABLED_BG_COLOR + roundness = self.border_radius / (min(self._rect.width, self._rect.height) / 2) + rl.draw_rectangle_rounded(self._rect, roundness, 10, color) + text_offset = self.text_padding + 20 - 15 if self.is_expanded and not self.is_folder and self.indent_level > 0 else self.text_padding + 20 + text_rect = rl.Rectangle(self._rect.x + text_offset, self._rect.y, self._rect.width - self.text_padding - 20 - 90, self._rect.height) + self._label.render(text_rect) + + if not self.is_folder and self._favorite_callback: + draw_star(self._rect.x + self._rect.width - 90, self._rect.y + self._rect.height / 2, 40, self.is_favorite, + style.ON_BG_COLOR if self.is_favorite else rl.GRAY) + + def _handle_mouse_release(self, mouse_pos): + star_rect = rl.Rectangle(self._rect.x + self._rect.width - 90 - 40, self._rect.y + self._rect.height / 2 - 40, 80, 80) + if not self.is_folder and self._favorite_callback and rl.check_collision_point_rec(mouse_pos, star_rect): + self._favorite_callback() + return True + return super()._handle_mouse_release(mouse_pos) + + +class TreeOptionDialog(MultiOptionDialog): + @property + def on_exit(self): + return self._callback + + @on_exit.setter + def on_exit(self, value): + self._callback = value + + def __init__(self, title, folders, current_ref="", fav_param="", option_font_weight=FontWeight.MEDIUM, search_prompt=None, + get_folders_fn=None, on_exit=None, display_func=None, search_funcs=None, search_title=None, search_subtitle=None): + super().__init__(title, [], current_ref, option_font_weight) + self.folders = folders + self.selection_ref = current_ref + self.fav_param = fav_param + self.expanded = set() + self.params = Params() + val = self.params.get(fav_param) if fav_param else None + self.favorites = set(val.split(';')) if val else set() + self.query = "" + self.search_prompt = search_prompt or tr("Search") + self.get_folders_fn = get_folders_fn + self.on_exit = on_exit + self.display_func = display_func or (lambda node: node.data.get('display_name', node.ref)) + self.search_funcs = search_funcs or [lambda node: node.data.get('display_name', ''), lambda node: node.data.get('short_name', '')] + self._search_rect = None + self._search_width = 0.475 + + # Default title & overridable subtitle for InputDialogSP + self.search_title = search_title or tr("Enter search query") + self.search_subtitle = search_subtitle + self.search_dialog = None + self._search_pressed = False + + self.selection_node = None + # Try to match by ref, by display text, or fall back to "Default" when no ref is set + for folder in self.folders: + for node in folder.nodes: + display = self.display_func(node) + if ( + node.ref == current_ref or + display == current_ref or + (not current_ref and node.ref == "Default") + ): + self.selection = display + self.current = display + self.selection_node = node + break + if self.selection_node is not None: + break + + self._build_visible_items() + + def _on_search_confirm(self, result, text): + if result == DialogResult.CONFIRM: + self.query = text + self._build_visible_items() + + def _on_search_clicked(self): + self.search_dialog = InputDialogSP( + self.search_title, + self.search_subtitle, + current_text=self.query, + callback=self._on_search_confirm, + ) + self.search_dialog.show() + + def _toggle_folder(self, folder): + if folder.folder: + if folder.folder in self.expanded: + self.expanded.remove(folder.folder) + else: + self.expanded.add(folder.folder) + if folder == self.folders[-1] and folder.folder in self.expanded: + self.scroller.scroll_panel.set_offset(self.scroller.scroll_panel.offset - 200) + self._build_visible_items(reset_scroll=False) + + def _select_node(self, node): + self.selection = self.display_func(node) + self.selection_ref = node.ref + + def _toggle_favorite(self, node): + self.favorites.remove(node.ref) if node.ref in self.favorites else self.favorites.add(node.ref) + if self.fav_param: + self.params.put(self.fav_param, ';'.join(self.favorites)) + if self.get_folders_fn: + self.folders = self.get_folders_fn(self.favorites) + self._build_visible_items(reset_scroll=False) + + def _build_visible_items(self, reset_scroll=True): + self.visible_items = [] + + # Pinned selected item at the very top (if any) + if getattr(self, "selection_node", None) is not None: + node = self.selection_node + display = self.display_func(node) + self.selection = self.current = display + favorite_cb = (lambda node_ref=node: self._toggle_favorite(node_ref)) if self.fav_param and node.ref != "Default" else None + self.visible_items.append(TreeItemWidget(self.display_func(node), node.ref, False, 0, + lambda node_ref=node: self._select_node(node_ref), + favorite_cb, node.ref in self.favorites, is_expanded=True)) + + for folder in self.folders: + nodes = [node for node in folder.nodes if not self.query or search_from_list(self.query, [search_func(node) for search_func in self.search_funcs])] + if not nodes and self.query: + continue + expanded = folder.folder in self.expanded or not folder.folder or bool(self.query) + if folder.folder: + self.visible_items.append(TreeItemWidget(f"{'-' if expanded else '+'} {folder.folder}", "", True, 0, + lambda folder_ref=folder: self._toggle_folder(folder_ref))) + if expanded: + for node in nodes: + # Skip duplicate root-level item for the selected node + if self.selection_node is not None and node.ref == self.selection_node.ref and not folder.folder: + continue + + favorite_cb = (lambda node_ref=node: self._toggle_favorite(node_ref)) if self.fav_param and node.ref != "Default" else None + self.visible_items.append(TreeItemWidget(self.display_func(node), node.ref, False, 1 if folder.folder else 0, + lambda node_ref=node: self._select_node(node_ref), + favorite_cb, node.ref in self.favorites, is_expanded=expanded)) + + self.option_buttons = self.visible_items + self.options = [item.text for item in self.visible_items] + self.scroller._items = self.visible_items + if reset_scroll: + self.scroller.scroll_panel.set_offset(0) + + def _render(self, rect): + dialog_content_rect = rl.Rectangle(rect.x + 50, rect.y + 50, rect.width - 100, rect.height - 100) + rl.draw_rectangle_rounded(dialog_content_rect, 0.02, 20, rl.BLACK) + + # Title on the left + title_rect = rl.Rectangle(dialog_content_rect.x + 50, dialog_content_rect.y + 50, dialog_content_rect.width * 0.5, 70) + gui_label(title_rect, self.title, 70, font_weight=FontWeight.BOLD) + + # Search bar on the top right + search_width = dialog_content_rect.width * self._search_width + search_height = 110 + search_x = dialog_content_rect.x + dialog_content_rect.width - 50 - search_width + search_y = dialog_content_rect.y + 40 # align roughly with title + + self._search_rect = rl.Rectangle(search_x, search_y, search_width, search_height) + + # Draw search field + inset = 4 + roundness = 0.3 + input_rect = rl.Rectangle(self._search_rect.x + inset, self._search_rect.y + inset, + self._search_rect.width - inset * 2, self._search_rect.height - inset * 2) + + # Transparent fill (unpressed), white fill (pressed), border + fill_color = style.TREE_DIALOG_SEARCH_BUTTON_PRESSED if self._search_pressed else style.TREE_DIALOG_TRANSPARENT + rl.draw_rectangle_rounded(input_rect, roundness, 10, fill_color) + rl.draw_rectangle_rounded_lines_ex(input_rect, roundness, 10, 3, style.TREE_DIALOG_SEARCH_BUTTON_BORDER) + + # Magnifying glass icon + icon_color = rl.Color(180, 180, 180, 240) + cx = input_rect.x + 60 + cy = input_rect.y + input_rect.height / 2 - 5 + radius = min(input_rect.height * 0.28, 26) + + circle_thickness = 4 + for i in range(circle_thickness): + rl.draw_circle_lines(int(cx), int(cy), radius - i, icon_color) + + handle_thickness = 5 + inner_x = cx + radius * 0.65 + inner_y = cy + radius * 0.65 + outer_x = cx + radius * 1.45 + outer_y = cy + radius * 1.45 + + rl.draw_line_ex(rl.Vector2(inner_x, inner_y), rl.Vector2(outer_x, outer_y), handle_thickness, icon_color) + + # User text (query), placed after the icon if present + if self.query: + text_start_x = outer_x + 45 + text_rect = rl.Rectangle(text_start_x, input_rect.y, input_rect.x + input_rect.width - text_start_x - 10, input_rect.height) + gui_label(text_rect, self.query, 70, font_weight=FontWeight.MEDIUM) + + options_top = self._search_rect.y + self._search_rect.height + 40 + options_area_rect = rl.Rectangle(dialog_content_rect.x + 50, options_top, dialog_content_rect.width - 100, + dialog_content_rect.height - (options_top - dialog_content_rect.y) - 210) + + for index, option_text in enumerate(self.options): + self.option_buttons[index].selected = (option_text == self.selection) + self.option_buttons[index].set_button_style(ButtonStyle.PRIMARY if option_text == self.selection else ButtonStyle.NORMAL) + self.option_buttons[index].set_rect(rl.Rectangle(0, 0, options_area_rect.width, 135)) + self.scroller.render(options_area_rect) + + button_width = (dialog_content_rect.width - 150) / 2 + button_y_position = dialog_content_rect.y + dialog_content_rect.height - 160 + + cancel_rect = rl.Rectangle(dialog_content_rect.x + 50, button_y_position, button_width, 160) + self.cancel_button.render(cancel_rect) + + select_rect = rl.Rectangle(dialog_content_rect.x + 100 + button_width, button_y_position, button_width, 160) + self.select_button.set_enabled(self.selection != self.current) + self.select_button.render(select_rect) + + def _handle_mouse_press(self, mouse_pos): + if self._search_rect and rl.check_collision_point_rec(mouse_pos, self._search_rect): + self._search_pressed = True + return True + return super()._handle_mouse_press(mouse_pos) + + def _handle_mouse_release(self, mouse_pos): + clicked_search = False + if self._search_rect and rl.check_collision_point_rec(mouse_pos, self._search_rect): + clicked_search = self._search_pressed + + self._search_pressed = False + + if clicked_search: + self._on_search_clicked() + return True + + return super()._handle_mouse_release(mouse_pos) diff --git a/system/ui/widgets/network.py b/system/ui/widgets/network.py index e739eef63d..69be42f502 100644 --- a/system/ui/widgets/network.py +++ b/system/ui/widgets/network.py @@ -15,6 +15,12 @@ from openpilot.system.ui.widgets.label import gui_label from openpilot.system.ui.widgets.scroller_tici import Scroller from openpilot.system.ui.widgets.list_view import ButtonAction, ListItem, MultipleButtonAction, ToggleAction, button_item, text_item +if gui_app.sunnypilot_ui(): + from openpilot.system.ui.sunnypilot.widgets.list_view import button_item_sp as button_item + from openpilot.system.ui.sunnypilot.widgets.list_view import ListItemSP as ListItem + from openpilot.system.ui.sunnypilot.widgets.list_view import ToggleActionSP as ToggleAction + from openpilot.system.ui.sunnypilot.widgets.list_view import MultipleButtonActionSP as MultipleButtonAction + # These are only used for AdvancedNetworkSettings, standalone apps just need WifiManagerUI try: from openpilot.common.params import Params diff --git a/system/updated/updated.py b/system/updated/updated.py index ffd10e038d..c10a7097ce 100755 --- a/system/updated/updated.py +++ b/system/updated/updated.py @@ -7,7 +7,6 @@ import psutil import shutil import signal import fcntl -import time import threading from collections import defaultdict from pathlib import Path @@ -19,7 +18,7 @@ from openpilot.common.markdown import parse_markdown from openpilot.common.swaglog import cloudlog from openpilot.selfdrive.selfdrived.alertmanager import set_offroad_alert from openpilot.system.hardware import AGNOS, HARDWARE -from openpilot.system.version import get_build_metadata +from openpilot.system.version import get_build_metadata, SP_BRANCH_MIGRATIONS LOCK_FILE = os.getenv("UPDATER_LOCK_FILE", "/tmp/safe_staging_overlay.lock") STAGING_ROOT = os.getenv("UPDATER_STAGING_ROOT", "/data/safe_staging") @@ -83,7 +82,7 @@ def set_consistent_flag(consistent: bool) -> None: def parse_release_notes(basedir: str) -> bytes: try: - with open(os.path.join(basedir, "RELEASES.md"), "rb") as f: + with open(os.path.join(basedir, "CHANGELOG.md"), "rb") as f: r = f.read().split(b'\n\n', 1)[0] # Slice latest release notes try: return bytes(parse_markdown(r.decode("utf-8")), encoding="utf-8") @@ -190,15 +189,6 @@ def finalize_update() -> None: run(["git", "reset", "--hard"], FINALIZED) run(["git", "submodule", "foreach", "--recursive", "git", "reset", "--hard"], FINALIZED) - cloudlog.info("Starting git cleanup in finalized update") - t = time.monotonic() - try: - run(["git", "gc"], FINALIZED) - run(["git", "lfs", "prune"], FINALIZED) - cloudlog.event("Done git cleanup", duration=time.monotonic() - t) - except subprocess.CalledProcessError: - cloudlog.exception(f"Failed git cleanup, took {time.monotonic() - t:.3f} s") - set_consistent_flag(True) cloudlog.info("done finalizing overlay") @@ -242,9 +232,7 @@ class Updater: b: str | None = self.params.get("UpdaterTargetBranch") if b is None: b = self.get_branch(BASEDIR) - b = { - ("tizi", "release3"): "release-tizi", - }.get((HARDWARE.get_device_type(), b), b) + b = SP_BRANCH_MIGRATIONS.get((HARDWARE.get_device_type(), b), b) return b @property @@ -306,7 +294,7 @@ class Updater: try: branch = self.get_branch(basedir) commit = self.get_commit_hash(basedir)[:7] - with open(os.path.join(basedir, "common", "version.h")) as f: + with open(os.path.join(basedir, "sunnypilot", "common", "version.h")) as f: version = f.read().split('"')[1] commit_unix_ts = run(["git", "show", "-s", "--format=%ct", "HEAD"], basedir).rstrip() diff --git a/system/version.py b/system/version.py index 0cea616d23..ae6ac1b13a 100755 --- a/system/version.py +++ b/system/version.py @@ -10,23 +10,39 @@ from openpilot.common.basedir import BASEDIR from openpilot.common.swaglog import cloudlog from openpilot.common.git import get_commit, get_origin, get_branch, get_short_branch, get_commit_date +RELEASE_SP_BRANCHES = ['release-c3', 'release', 'release-tizi', 'release-tici', 'release-tizi-staging', 'release-tici-staging'] +TESTED_SP_BRANCHES = ['staging-c3', 'staging-c3-new', 'staging'] +MASTER_SP_BRANCHES = ['master'] RELEASE_BRANCHES = ['release-tizi-staging', 'release-mici-staging', 'release-tizi', 'release-mici', 'nightly'] -TESTED_BRANCHES = RELEASE_BRANCHES + ['devel-staging', 'nightly-dev'] +TESTED_BRANCHES = RELEASE_BRANCHES + ['devel-staging', 'nightly-dev'] + RELEASE_SP_BRANCHES + TESTED_SP_BRANCHES + +SP_BRANCH_MIGRATIONS = { + ("tici", "staging-c3-new"): "staging-tici", + ("tici", "dev-c3-new"): "staging-tici", + ("tici", "master"): "master-tici", + ("tici", "master-dev-c3-new"): "master-tici", + ("tizi", "staging-c3-new"): "staging", + ("tizi", "dev-c3-new"): "dev", + ("tizi", "master-dev-c3-new"): "master-dev", +} BUILD_METADATA_FILENAME = "build.json" training_version: str = "0.2.0" terms_version: str = "2" +terms_version_sp: str = "1.0" +sunnylink_consent_version: str = "1.0" +sunnylink_consent_declined: str = "-1" def get_version(path: str = BASEDIR) -> str: - with open(os.path.join(path, "common", "version.h")) as _versionf: + with open(os.path.join(path, "sunnypilot", "common", "version.h")) as _versionf: version = _versionf.read().split('"')[1] return version def get_release_notes(path: str = BASEDIR) -> str: - with open(os.path.join(path, "RELEASES.md")) as f: + with open(os.path.join(path, "CHANGELOG.md")) as f: return f.read().split('\n\n', 1)[0] @@ -81,6 +97,13 @@ class OpenpilotMetadata: # touch this to get rid of the orange startup alert. there's better ways to do that return self.git_normalized_origin == "github.com/commaai/openpilot" + @property + def sunnypilot_remote(self) -> bool: + return self.git_normalized_origin in ("github.com/sunnypilot/sunnypilot", + "github.com/sunnypilot/openpilot", + "github.com/sunnyhaibin/sunnypilot", + "github.com/sunnyhaibin/openpilot") + @property def git_normalized_origin(self) -> str: return self.git_origin \ @@ -103,6 +126,10 @@ class BuildMetadata: def release_channel(self) -> bool: return self.channel in RELEASE_BRANCHES + @property + def release_sp_channel(self) -> bool: + return self.channel in RELEASE_SP_BRANCHES + @property def canonical(self) -> str: return f"{self.openpilot.version}-{self.openpilot.git_commit}-{self.openpilot.build_style}" @@ -111,6 +138,29 @@ class BuildMetadata: def ui_description(self) -> str: return f"{self.openpilot.version} / {self.openpilot.git_commit[:6]} / {self.channel}" + @property + def master_channel(self) -> bool: + return self.channel in MASTER_SP_BRANCHES + + @property + def development_channel(self) -> bool: + return self.channel == "dev" or self.channel.startswith("dev-") or self.channel.endswith("-prebuilt") + + @property + def channel_type(self) -> str: + if self.channel.endswith("-tici"): + return "tici" + elif self.development_channel: + return "development" + elif self.tested_channel: + return "staging" + elif self.master_channel: + return "master" + elif self.release_channel or self.release_sp_channel: + return "release" + else: + return "feature" + def build_metadata_from_dict(build_metadata: dict) -> BuildMetadata: channel = build_metadata.get("channel", "unknown") diff --git a/system/webrtc/schema.py b/system/webrtc/schema.py index d80986ebf2..4876198eb0 100644 --- a/system/webrtc/schema.py +++ b/system/webrtc/schema.py @@ -16,7 +16,7 @@ def generate_type(type_walker, schema_walker) -> str | list[Any] | dict[str, Any def generate_struct(schema: capnp.lib.capnp._StructSchema) -> dict[str, Any]: - return {field: generate_field(schema.fields[field]) for field in schema.fields if not field.endswith("DEPRECATED")} + return {field: generate_field(schema.fields[field]) for field in schema.fields if not field.endswith("DEPRECATED") and field != "deprecated"} def generate_field(field: capnp.lib.capnp._StructSchemaField) -> str | list[Any] | dict[str, Any]: diff --git a/third_party/copyparty/copyparty-sfx.py b/third_party/copyparty/copyparty-sfx.py new file mode 100755 index 0000000000..2506c39a93 --- /dev/null +++ b/third_party/copyparty/copyparty-sfx.py @@ -0,0 +1,512 @@ +#!/usr/bin/env python3 +# coding: latin-1 +from __future__ import print_function, unicode_literals +import re, os, sys, time, shutil, signal, tarfile, hashlib, platform, tempfile, traceback +import subprocess as sp + + +""" +to edit this file, use HxD or "vim -b" + (there is compressed stuff at the end) + +run me with python 2.7 or 3.3+ to unpack and run copyparty + +there's zero binaries! just plaintext python scripts all the way down + so you can easily unpack the archive and inspect it for shady stuff + +the archive data is attached after the b"\n# eof\n" archive marker, + b"?0" decodes to b"\x00" + b"?n" decodes to b"\n" + b"?r" decodes to b"\r" + b"??" decodes to b"?" +""" + + +# set by make-sfx.sh +VER = "1.18.9" +SIZE = 846007 +CKSUM = "53f9b019dbba5e9acb44f1e5" +STAMP = 1754082544 + +PY2 = sys.version_info < (3,) +PY37 = sys.version_info > (3, 7) +WINDOWS = sys.platform in ["win32", "msys"] +sys.dont_write_bytecode = True +me = os.path.abspath(os.path.realpath(__file__)) + + +def eprint(*a, **ka): + ka["file"] = sys.stderr + print(*a, **ka) + + +def msg(*a, **ka): + if a: + a = ["[SFX]", a[0]] + list(a[1:]) + + eprint(*a, **ka) + + +def u8(gen): + try: + for s in gen: + yield s.decode("utf-8", "ignore") + except: + yield s + for s in gen: + yield s + + +def yieldfile(fn): + s = 64 * 1024 + with open(fn, "rb", s * 4) as f: + for block in iter(lambda: f.read(s), b""): + yield block + + +def hashfile(fn): + h = hashlib.sha1() + for block in yieldfile(fn): + h.update(block) + + return h.hexdigest()[:24] + + +def unpack(): + """unpacks the tar yielded by `data`""" + name = "pe-copyparty" + try: + name += "." + str(os.geteuid()) + except: + pass + + tag = "v" + str(STAMP) + top = tempfile.gettempdir() + opj = os.path.join + ofe = os.path.exists + final = opj(top, name) + san = opj(final, "copyparty/up2k.py") + for suf in range(0, 9001): + withpid = "%s.%d.%s" % (name, os.getpid(), suf) + mine = opj(top, withpid) + if not ofe(mine): + break + + tar = opj(mine, "tar") + + try: + if tag in os.listdir(final) and ofe(san): + msg("found early") + return final + except: + pass + + sz = 0 + os.mkdir(mine) + with open(tar, "wb") as f: + for buf in get_payload(): + sz += len(buf) + f.write(buf) + + ck = hashfile(tar) + if ck != CKSUM: + t = "\n\nexpected %s (%d byte)\nobtained %s (%d byte)\nsfx corrupt" + raise Exception(t % (CKSUM, SIZE, ck, sz)) + + with tarfile.open(tar, "r:gz") as tf: + # this is safe against traversal + try: + tf.extractall(mine, filter="tar") + except TypeError: + tf.extractall(mine) + + os.remove(tar) + + with open(opj(mine, tag), "wb") as f: + f.write(b"h\n") + + try: + if tag in os.listdir(final) and ofe(san): + msg("found late") + return final + except: + pass + + try: + if os.path.islink(final): + os.remove(final) + else: + shutil.rmtree(final) + except: + pass + + for fn in u8(os.listdir(top)): + if fn.startswith(name) and fn != withpid: + try: + old = opj(top, fn) + if time.time() - os.path.getmtime(old) > 86400: + shutil.rmtree(old) + except: + pass + + try: + os.symlink(mine, final) + except: + try: + os.rename(mine, final) + return final + except: + msg("reloc fail,", mine) + + return mine + + +def get_payload(): + """yields the binary data attached to script""" + with open(me, "rb") as f: + buf = f.read().rstrip(b"\r\n") + + ptn = b"\n# eof\n#" + a = buf.find(ptn) + if a < 0: + raise Exception("could not find archive marker") + + esc = {b"??": b"?", b"?r": b"\r", b"?n": b"\n", b"?0": b"\x00"} + buf = buf[a + len(ptn) :].replace(b"\n#", b"") + p = 0 + while buf: + a = buf.find(b"?", p) + if a < 0: + yield buf[p:] + break + elif a == p: + yield esc[buf[p : p + 2]] + p += 2 + else: + yield buf[p:a] + p = a + + +def confirm(rv): + msg() + msg("retcode", rv if rv else traceback.format_exc()) + if WINDOWS: + msg("*** hit enter to exit ***") + try: + raw_input() if PY2 else input() + except: + pass + + sys.exit(rv or 1) + + +def run(tmp, j2, ftp): + msg("jinja2:", j2 or "bundled") + msg("pyftpd:", ftp or "bundled") + msg("sfxdir:", tmp) + msg() + + sys.argv.append("--sfx-tpoke=" + tmp) + + ld = (("", ""), (j2, "j2"), (ftp, "ftp"), (not PY2, "py2"), (PY37, "py37")) + ld = [os.path.join(tmp, b) for a, b in ld if not a] + + if any([re.match(r"^-.*j[0-9]", x) for x in sys.argv]): + run_s(ld) + else: + run_i(ld) + + +def run_i(ld): + for x in ld: + sys.path.insert(0, x) + + e = os.environ + e["PRTY_NO_IMPRESO"] = "1" + + from copyparty.__main__ import main as p + + p() + + +def run_s(ld): + c = "import sys,runpy;" + "".join(['sys.path.insert(0,r"' + x.replace("\\", "/") + '");' for x in ld]) + 'runpy.run_module("copyparty",run_name="__main__")' + c = [str(x) for x in [sys.executable, "-c", c] + list(sys.argv[1:])] + msg("\n", c, "\n") + p = sp.Popen(c) + + def bye(*a): + p.send_signal(signal.SIGINT) + + signal.signal(signal.SIGTERM, bye) + p.wait() + + raise SystemExit(p.returncode) + + +def main(): + sysver = str(sys.version).replace("\n", "\n" + " " * 18) + pktime = time.strftime("%Y-%m-%d, %H:%M:%S", time.gmtime(STAMP)) + msg() + msg(" this is: copyparty", VER) + msg(" packed at:", pktime, "UTC,", STAMP) + msg("archive is:", me) + msg("python bin:", sys.executable) + msg("python ver:", platform.python_implementation(), sysver) + msg() + + arg = "" + try: + arg = sys.argv[1] + except: + pass + + tmp = os.path.realpath(unpack()) + + try: + from jinja2 import __version__ as j2 + except: + j2 = None + + try: + from pyftpdlib.__init__ import __ver__ as ftp + except: + ftp = None + + try: + run(tmp, j2, ftp) + except SystemExit as ex: + c = ex.code + if c not in [0, -15]: + confirm(ex.code) + except KeyboardInterrupt: + pass + except: + confirm(0) + + +if __name__ == "__main__": + main() + + +# eof +#,ht.7?0U8 ZV<*:DkXF6ʻ-`;?r2%dmX01O%dހA"vu5ʚAfR[8Tfj7h MIeXW|q/v=Ovjʎ❹MɯuF>;F'7W% y`}mv`JCr+޿~kÓT0(~??wܢ5%:M`Irߎ%_(6ڠ8?ruY0n-Bsi-g6)aY7q,՞x1HV͵35qw>s}2ͱVFȋ.t8 jOߋ@ F?rM_4q|?nU'ww~'(+X5A"ʑ:ǂ*6p$$~g[%Tۏa2SJ7??>Bޒ+h?ny>PYRLȌΎ=>;@+,^??9Ixw.u&%*۝I\D|}{:\}+֑;KIkȷ^03hǓa|hOfa( _' ##a`?ng86\Uo4w*O䞓U92kKYG<;bso8#n\S??k-j8tsny;.a`OG t&onhb`hdlT, G(G0̙"?0Ȁ̐R?0$}N>5ov7ވ1 y6Q:ӛ۞k)2J[ɉ.\3??RYc}>L2<Piy7xyVwzЫ<__kţjN;*&8~b'==¼0r֩vOBS+[y*S<ҚE*-tU4eO\G|D3??((͡W7442S`7722?0f?0E?0{߽߸4wNT,y{Ҟ;iliZ%H {os{EW9?0H -ۻ&GN f0(W}2i:y,;hy$aLd*ltΈ~ /_MT Ӟ׿n'U8OR =@Kiz4b UE~i(TDiz_4ٓ(\ }ֈ<%k1"v^kHl?0<<8)vy>VmfF)҈>3>]]G2~/SqĈ`o{КnNh?rF1^6yRJ⓽Ã)>iO?0nB;Vj0c;- 2?0_:u}txH|_v'W%%+Y!(?ri!MLK|[]bv\[}ISZC<~1gR~ň)yd>g$x<='[;gog8-:DLS=ql !R®9=S46mFiG0BlqA.Kd cRjZ?nƓdѬtsvVǝE%[^Pg՛`axEHZ??]YZ¢&olx =l`O74aqGz_n\.?r ]W#׭Wk,k%U&?rd-FcezQ??$b| ҩQ*(`2 SVi. ƒ!c" #[ʲp\h&X\\!T Ȑc)qlv@Xf8ciwbejmZ0Qm+PRQ8':'bbQ3iJ?? Res<[zHXxC_-Sx¥ )1Ή?0zJ8t 1J4LDD@JggC?n0TJ6 LR^5:@^sK5SC +#-Jc$|}}0涔={3tNB8c~ff]T0Zz,f?nclaWVc%i(1C&)=AL|c"}d|у<Q#T 񵆰#bl0gmG@C,JЮ1_7րD??UK$t3qRiFQ3-鶈YhD aK뚎$y@E@ 4&j?n.Ö/3B9????9o6_};C>a R)sK% y93ܗ??黽;FMՔr.a??֦#==ig5p#RO.cݝÃ@&@\1/82bՌcFmRԅ`rw?0iwi")?r }}9/rv_B+0~<DfgA2@(-kr6,dtj)(ʸc,X-H v]jwTAخױ; type??n8&^Pg{=XZٶ?0B?r"!ّ5u<u,ϡ5Hg?0CإMK!׵K){Kfqe2lv=8go߭SE^${o-i?rY#`1S֯V4O @=XDi{,% x=ퟰfy٬>]\8ȡ4(E.dY$X}YXA@'&?rdhP::*#~^nξ:/]UkT?r;Lz:3+fMoɱ@Yq,rab͕?ns{M\۹(׋г?0԰#`4TL9M{{}&.A4쿦bB?0hbn\nFx\fD'C5{?nvL_Glesk3:v7-`<??<|Px]~rySTd4f;hN4E|VUVAš/{g4U,(!e7q+taKb>?0??kW.sHw*zy-ںP!'b. uS#e?nrT/XLhSDrSndL.֏׏Ruv92cciwa:|]1EQPuu6h) ۲T ;;UP&uܤt]?0<w{xh3m֫hzq`,t {eb +F#=7a??:aW/j?n*v(+k۷"'o7dl(@'-iBO6ǦMXc*ULyA2<$\?0l.?0M+ E% gApޢ\@Ur]2eQT?nʟdo)R)yP<)ɾszBt]&ZqmåWO y]ev;?n=T&j{g+;T)J߁c+ ZWn``N3gDjZ1,kAI?0 v6CϜʚW~cUtXjD1/!|zW{Re/AݔEе{@^(3g,X@,Kʽ=ǡG[zl{պ\@9@1qF:km -4KQ_O:=g\/m3eFo nV6/O.4*ܒNɾ_2OzKbGR;du {~.KU<:`VTmw .)d hq4-Q$}.An,cPD:𼙀b# MxJؔ9諯 ?0{6Az"y̽=O#&I(xBFL)(Lܞ̱k"aαErANXb~ILh,9OD$xMX??le:rju^D)_Q_[I9*?rJm@jVl(I3SCk/<^B*_Vs:˹d;) 1 B|IJqE;զp)YDvHΕ9VccKDi~z!|B?n<}ԁ/24Ndqy!Yƨ <-5]sP"(!{[lX^FCg&,3'Pޯڀ\@B}=[(!^^X)*H ! ?n?nA/Ůt}tLN|=4n@::gc rK]?n]GZ*yږFuvjKۗ`4^Uj {*! b >?0XspKd<-_ǟ})0xen7Y"sA$/00|B0St=U~?0b2lG:*E F`aK@%Fʫ}9XB&40JBSq̩bƀQgM)V5mBӐ$?nBރ?rWrDֱv{q>lA+ڿƩ=h}A@ "R5 (@?? j,Z +#&;JbmzM6F8úC,hgd *!vp&f3M}U([8?0a?nAxX0 ).?? c`k??qH3[lƞD-wQzڌBGЃֈ6$[O&0.ڴmpR߱aMKT#iF3G ^WHՙ_}W7vJAQ+s{`o̥-\qe0?r11.쒹vY;єsܲ!DŲ/}-^.6SZ5we$/WbcH͊'HjlȅIb>l+:jO;VAj$3ks[o[}Lܡ|Q#=]yDuBZ4ڞ%|`W9aUUtfe1wp#"˗P`\kn-\k^I{"r|5Q;oQR9vYQw'^T^wy/4}t{O[Y:A!^$MAΉĻ w-' ԃД?0{*F\FݣS{v &Q* c,?0\cγz {`3,$OmZ5iliu.$0??g$(>4!1EXmG?rD?0\jVnJ^ 8emNw??.gNO&yRscˮu8q߅13.:rOs{"c\V|VaW8$1:0=Pnnrr(gE|^EAAi$P+\gFok.ͻ~fJf`*W:m̫=}}0Mtg8ErH1W\߽v{}عwIպu`uw]R*6Uw"Ypq`Ai@?0l-Iz^|j tЍPPo6`"H¦"h=?0órJ4_ti3mUbЮ3yKQHNd|W6֪4MZ|M s8+?nb&eӘxvpCqVn)(ѧ^A'ss+B7s?nEsԳ'l L7[q\feh+mtKMHCz(,6`?nrPcno([͗lL/ftAiilR7??Fܞ؉m<{!|#i>M{\ v'^0,!`hb+Tn<S4{c@:yy?0 i)uu4%{qpn}-rAdrԦV (ȺWGo[ɰ-R-4 `6l@ }VhH"-5zT[ yyM>EvPɳ.A2J밚 ?0sK!z?r߽T?n`f{7?rctFv5Ms-DZONCkD==;q(>e)Żs.Q+:Q?rR)ë95SB>u&@ kVu=DҢ&?rz #%EetP9ը'F\Bnx4(uk5t[UzLHo_hntY¤rmTBUujCY=on3/־znshH=.TH?0r*) ]];~T?r~8 Er~XʺKj|&qX,Zj#7X~Ŗ=v(xMKJ8fNi3o2 NqcԜiCxh0b_!_#߲8 ?n<ᏜM(@P* }l'fD/7vM#Ucf&6شJ?rPK?0 ap^ͼİ4} Ch5-?nUp!&"=@y\h䁚μ!m`8eDLI^{W~Y< J}zqmpoaϨpmբ~ šfo3{PxCP7ʩukօ|gY5Ģ>[3??OB]%݉+v/`ztWz{Nxrp0FLs#694 D7:UEu~@=ۮw??ë!R?rv *B[\njPa&+,TWr[*ၬ v 9\-aj.kp!׈+""ӁFCk`݂.뵓XtH<8{ѻ@䀾ṧSbYg<ЫXX6??-o񷓶ϐ4+iڰAv O?n-Y7Ax\'bW4}̸Cz?0Uy{9:<>r#Gz)!u??W04b*l=B9~0^(;rw 0A'W356622505C?0{׹8?n*]|A?raUj*[)u2}UwGPV~ӿU_ HB3ov@h"_j 6[CBBWuS?0!XVu]$L)h"xWa3Vb,?n%W;D޸u"'怨W3&X)zuڅC)Bϳ%m 얖ujrWשku+Y^g}œdյܩ:"[3PZݬf}_'Qɿ(%wVy+mp Vqy2*C>cD8+P 'j䊫_sm/g`` _?nm7mk]3`EXS+ψ&Y`+?r@ CBwG0'qh@Kp:T+ۆU)9N(<$[^f-X?rJiF#l"+:aJB,O!RuY]m`gl/|϶҇¢Ag.84S Nߴx8yX8c|WpZċ3p?n1??G.>˜'Fe>Sc ueeV*puz̫)Oy|$\֭H@<=VnVyeE5U*.JȞn\,o 3=&X[#ܚte6_)]~hY\r/6޷"n.jY8oCůvl<穖`}GqF^fKb1pylտ_n3q_޶}?nˍPEnN'9˗_^!G&(>K+#LP,HYbB6ɆagpsrVD4ԫj{ƫUDIp9I\“JJzM*agP_VǗ@Kawia8g9Ԁk~?n}{ ?rloiѳ1&ڕ-Ҧqg:=%~&쟲q2!XUؤ,|x{0h$5ϋCi.7x(٥Ͳm}'դoV˗㋧KgfXUnxje # J H{>?r-dI~L_6g)=J <B~w]/0\ϣB7YE?ryL!0>MB@ Džd2%AQpSl̢j ?nEf'X/Ëw_@zUpTZ<(T_F4W^ ν 6_̘D`Ɲ9Q?0ElQ1cf4ud5BmawlX/wiFw,3(Pr?r6q.bΉХ6_i說+SQ"zWjoCK6A6;BMkF6O{fͦ6ַ>׊$hOlɿAHlˣvd4"eiC".?r??3Tw`P?0u)|{7,@|NpˬhIMld-?0)[ッ+@s̢b oo0(a*oB X,x??z>}%??|w|77:?0m1^]D~qp????}ç{{^hn#S,3* =ݺ&N@}t!ܴ0'Ѱds/WA[lFp5עe#$?nwV{q?n:NHC??k `?n4ol492Y!Pщ,:/1; , uwhߺMX&^quʰJ*{ΦZ]E׸iavΦԅd#غimC{զ/o ~kó.DQqXl&6M]e8W]+ؼ Ni^6J -qk9<̲+v=C#泈h?r̫?rj+z?rgث]Ar\T5Ҥ./b߸+R-Mڡ\ v?r2ŕFLw,!E0{[teuO*%{M̊\c?na?nvi1Qr߳}&4R:QJ v&]ܡ{wM!x80iwZkINŻhif*WT閁kxBguo|&K\! EozE}??g ed<&V챹D PKpkZa.Ӑ-\?0\f?0#.ƝIKx%UY/_${v,ph +#4Q>@ :VHU($)@*4b'q0J4.7]Q̝9([2KGϞ\؜AtXojѼ֓?0Vq8#S.oBvb)wq7{ $8* 8 3pe6kZ2K%'|W_efSfʜ-&:󷄌6M6;&"2S!}V4y@johl=FZ ^]~2œoߝB-qskt 咕@lP7/U%!ΥƱ!-q\ A xqK?0B<:Ie#Z[?rNo.\ϰ瘚P-J@g7>̵Qo?0B{:;wcbmEYx5AXM 퍇XGT5"N[WTV5=3*T!* =r UD7'(1D:)~pjld;QFUm7D*K報?0!Z8_ՉhQ\Y8s-1`%57/.bѤWzox;)t Kȳ29dB|0B^  am0ʧ/cQUc;@Tܜ??_}UOB?rh0 {Lx#]F@1O(=mؾy?rbr$Cեq8v7y"RjFR ` L5OjXi .Zq[#dYENݶ?r '6ڒyÃu(zPλXؔw%7~qyAe>2t4t?r1-ljey>.[*0_\v,Z{hRsC[-nx 7 ׯ @ Zm 'cW6tK`*Kɩ8cnvE>J_t6kt-&7Ns#ͺ"Ix+?nG߻OEm} F8X.'s{^Il,# 􅦖9uư?0ja3?ndcK짢WgG$N`E0YS A!zeʯ^كT;qTd1Ǵˑ=G^~zmO??O??}"1z`zκ[ucG ]7^{oLk`z?ncX.Mn#MBOրF6 MskLwnqQd'c?n CX#PiN9ccýMc*4^eضӪ??%%eQ"|J;fguԧm֤$J¯Z$()wsYdB4w~|+??˸f 90󽌣eKj ·3\?rC?ng EM nԞj5pLdt?r??0pXQ4sxI˼Li^5ԱRYj%)G~>/oWOFO#UޚfAGL<}dA?0z@d[z5>]>♇?0n75-*Ztzu^kUlG!4"2.>X2`WkZe\Ќ䥐Y3ly|/M??0J4>ӔKߨqT_tJ ?n>ӹOtzqI[ !81~xUc7k #O<MS~xw*!꺐?n"1Juh?rgFW{/G{^h;Z kv+4:ѹgi* z_$}$'j%92Z?r<zT!1QPi*b|ťƪ`>n2i )I7 B3_I "SfN=](iolH[ JڮSW:׳]Y~=!;f)crڬ,DSeUBNQ#,X059=uSthƆ& u~uNqjѰ?n%c.]0 ׁ36뵌Zj[ۗ)U??tᲖTqʇ4j83&b=ܶ6qb8K?rIt?0oVp5ITßyS R?ru=a`M?0X6<#Z٪ beI(p?r1 Ϙ ÀA{.Vh! s\?0g1Мǜ\nP*c0sЫ]DGu1iN`xIj`2?rqσ݌܅??i?r č-^`xtV5?rbB e?0FÄ.[k+\tCg}h2 FLځ8w>sNxþ2=W?r!N8v Y򈦽)V?na>hJ`@89o5q:1IJ?0aVPf;\T.'3,ݢ4`y 7"uQ)Fv4LEXrLtC;բq+ #ɌjG&yuj46.GJSV%]?0??pmq"-NYH3m}4e's@99vp <,0b;Du7_vpgbIS^cl Mȍ||; {>~.ΙlX\Y  xfH!-'w߼De.&]2<;S{M+䉡azt&߲$ې&#['xgN*\,J3Α#Ary5pHm#9 x}2%9xPjnDŽts5w6T<{RE+g0aHWb\>-ղ ?nl ɛ7H|s).?0\i =_ǚIm4{[ab歧l[ADf6llKkrUuդ^y^ej}tBz)V&Qc|&CTWݴewDC!YeJ]$ku;r]ajB#m IP_r/(|n4o3б,MD7ߝukԦ/T/$ȸ2]3|!McL70կ=??IY=?0Ljj`3D+FDk'{q"z @6Wdm_'h&&"=fn=-68К6W{V*A6rɓ`!i A[g.?0q8}-33??,9:zx}5=!5E`U GwqHTYLIkԉ~>$3OI=!GGGG=pw7ϖb2h*4FHy'EtFc#ZdeCSrX3p1 ?r'd'(ڇ %ZoYGs?nG^d*i_pÃ~QNTN2FW!?nCexP+@j(jFcA= ȓ٭rPeQ1êA(es+m'6M*FGB]:4/t Z$%cY@ڒM<]^ІMWkv&_m(K͹s?nh 9=vWg*h?nvޯt4/SΐW2P |kIU qPP =tI&U)qC%<ɰv&X)Eo?rE%r DTa_>ٱk0 MVxܟmg`:v7Ǡks ǟQy︶[w_94[?0pߙ~?0>R?n5xcp_Znܪ5vʰn^L9{moElcZwVUۊ߂6P栵A[Up):tj|fTHM_-r sFk?rn>?rTl(mK0V4D}5Qe [92]D0Ƕl#~jT;jx$5?0Rȧs!§!)%Cؗe?nmL㧏$u@4oǵA0Xt^!ۡlx;h&C|O^0=EX%ƛa2A"9=齏{g/eyݛi~_7hB ~ׄ%OLeɓ=LNjH?nzEJ)UィO'$^0=G!+4"Rkj +#[d8ё{҈xJ^eebTp 3V}uT(OWf0`eG1o1mFX\$%;8M9"s N{7LRƓݨs_6U2QdϯM̵G%R;!$;=??|$[*:4Ȥ}I$D?0?nB KU?0\13`/pe=X4vu4A[l>$n$Z<9R菦ɛ|!9?rH/A(&./q]w#, h[`u*5We1 GӬ~Cő&^o-=P&*7g\r]u뙈JegZ0/P4Và*@YS~BJ5;J5U!_GɃ7O.??+_ޓIƱa}g޾Ҿ+l0隿~>q䣎-a^b̻BBxe/16xױ6M'THx??.t9C^??˻??5qF=??s,2cˏ<%mf|cy]8:#FMٓW/\*u&zvvG#y>EeX&U` CHoM.D`#]v 9$]M_ā`=|UϿ;Ow/Q@]G1)qك1Uu6EPeTr9'T{4B'j< 5ucY;`Pqg',BmUſFq6x˓W^t/uTCEVH2ggznaO T$WɰG-sh!_hMQxfx`P:s Yhܚ+MSqcCؖ:n[ḻLZ,?r5)ΌzN%8K*Z>lM=zZ:_Kk6>_nU&kٍ< <䐀gIhKPjj/_j{&ACcY|g%@jiAz"7>V|*&9~L?0v@AG!lSUN[Gơ?nw=|p4̩yg4:TwwP J+Sľ ЩQETE~~WreP^'ߎIgxTby{-(u0>oYr&L3MY(sIKs5:pC]c%xm;&u7w܅WCk įOvRϗrɤdoj,}:~P qr\N707l$iH] eGx>D^חړ6+'Fv듋X6uBNM4CːZqhGA֥@;CG)֛`(^n rB2ci)N69QF?rybZ3z,bDdZfϨNCG2s넜rC^.B?0V*9cmJ3O_-+)|!dL}Z7oo ;eɑV&7d\MT}ھ;N*MLk^D*$eVlKNA};a.i:+gqt>{T??I_jk"Y3H"70w #HGIf+ePTìjC.Z5۔Ag4 \YӈV) z1 .)'oUGFѵh$1jgu@ѳ4z<@bt_9Yqe!\u0KHtAo*v۳??]?0ժP?r Auk<%g~3VOݾc왿o؛_uP24h~rd;G[z??<Lf׊دIٶ$Qc<$Q$m"7>?n>Uf2UPNs\Jm6VP,MP"\ockmgw.}dL022o@ ¬`i8U!;j;AHLJ/,hɮ8o=+"vށ*t?n{Ffzv\z*GEс%Go^['lNFQE]2m3XCY>E:VWxKbruH;0 7yw1Atf$#wPp N2k!?nVT7'2.j4ۤŹ =kdLCqWqG#ƥ'؀z?n% τիKjZ8kx`<bJ'~ปUʁnV??.4H%\8b~6~鑝=Z8✤Ȇp6ԋgLJ/AZۢݵ?06~}ʅF?rEDbϷ{<*+Z)".?rCuRM.HN09[GѦhfSB8״N8/BnuXۺ2-LcNcѼύ?r[A˄uG ƫ`dr(N>F ɢ73ѶFK;R,O]VB??e1'>@7bL3kTY4,3ĐR Ʃ1Eiߞ5HKͨ$[O&Ne5V%xvbYA' ?0YU$vvcS!d%1b-GKF Lcv,P}$w9ix"Ѹzݽ"Q%lo9V3n#a]!#=vzǒ:tB))]כ\(J(ilր{2=XzUqf[%-6V[ܚh@6}f?r;uO|N흇[1+i9]8 )`\(M@F}p}%J%Mȝ.{Խ!!L6UaL؇t,q\aX [Mz?n-ī-*9bWLߺ$g68는qQڀڱ&J2K??jEg܏ f}YNb>լn'^7"3;#\8NL217ƵF=/]ѣ< 1y<M釩5 x$fEsĶ=n%.\1uPvLsK>?rk2I,#>y>8$F%>}E$cWXnBu 8*R,l/Lg Kl7!kf&uLV^=yO/,XP29$i&B6X֢*gT"}h"Ѐ%=l;*h'rtg#|y @,p@oׇ;K"_vjf _VTaoYbQGЎ\B?0Vᄃ hz烛Z (>g^r6VKe!օsZchî,o[]L<5ۚM"M io(G94p\ty7pu*k7 *IN2v}Brfv`hb񋋗˗=&X\Agy;nVJN?? g?r`z?07=egv?niHD??LݍۙӋˎ:B+25ġtZ\Ys9yD'Yn֥1.-#F:[(3)6?n=^5"'H(>T2A$Ťj]=NK'xwP7^w&n 9S+_=|Mu M\DsOx s-Yy>7],[f u| 5?0n*yT yV?nnRw7?r|=L$uQ}!74Pm<آnnVc)$HLm=6K”?n[S6տnkkBf5V9û`~T @D>ZENH拶M8FNWsr??YG?nOnPR]Ø^D'VFi5z# #64;k]F>h͓:qſH$5ifp|I)-OT?nE炫mT|h[3hR}u(A~¨4j?01j:3Nt|E"'W&i y}ڿnh^%ϙ_+:NvR%%()]krY#ՏxJ–Y?na&V]ᤆvw qY ??5d0*^x3Q)??RoNJq $lKDr2 {);{}(#&m&Ύ)Ԭ>.iF%DL7R>1ac6}7wh0ܸaupPx#A !J?0>x{1>*l6㏜=fRK%8iwo&;*$(aYKc rpxHbz[Oæ$e.׭s .r9%O`rU2t$T~J-4(X%Y'ޥ ro*._$Uӽ`oʪcT-Mj~{ wMǕAi^Ȇ?nwB1VGuri[m7ۓM$??7iw-4dyV`?0^fbA:S?r?n͠R%#XGEt brdD5h!~u)"TLGF(Εr'c@H3??7miJ?nlWa튑ݮdpdFu7m*`*9)s39_gϽBk.bԯ2&Fm§))P ??"p+N]պtfq!1T_VZfI{* ս3j2(rcqtt4~E/̥Eq|ˍnֹuQ l]Ϥ&>-}2/dT+ևP%$ܹI?r$RUS.]yZ4{'3*{ՙ '4\[K9v8eR)j9YZ Ϝ nSvAP|?n_ǬT~h$,,=?ng&[FcP;}?r/|,EڏSe_ '"H}AqE%?nr C,Q҅,!'ێ;w༝kX.~uTvVW25*ä,(T,k_W\%0b??7Q8w#Di&.Ӑ_??| P'xc4rc X5;u+K5??*oIUby!m(jFR%9d~pCoTHYɠ1!Q<^26?rr6º3}$??_l"s;w3-WF:ed~i~c(i`?ra&?0UmrwuS_??U7xFuS/CM#Ǒ _I4eɯ_O?rxUhr޳EfB?0L+T?njH?n\#?0Vf5U6 _qbJ$,u?0V^̈́oO͔o/+IZufMOA]}A={it) zP[k\|>[g>:N#'DKq  +#0H!O7Sm_- I2\I#f(4jsqA59=גeГCRoONPOrQ&KL%SbXPCQȳ3ꡢt3ZC 7oI=;y{q)y2;$0k͖sdC(Y5WnXDt*m&(l?rmnͭ$?n vGRbtSlq;|EH%qx~Ihlؼvvz||j_.m` J"yO;&nPw(Z}-BQ8$$??63']$r7~u|ŻDnܾo'ky!失Mk!mI3yDN=n#)'G%GH{ĿtTf":qd zv|ϴ93̊XW5_6]C7]Gs/=rƋY2O=|ѐd{U \G:`Y".bzj +#dx"_{?r"*֭,"n]ǯ^>AEy ?rAÍ^tw:(O4g$`R??|W?rO WãAKzk 5&gƒP}|MWPg{1nD9Z0(\!:L*![_y*9y#LT+޽&# ް~5UCD<S_J' kRPеzI\ SA!zk XlEm??iMk^іx?n ,F)?n9p7{V٭[W'w6'51#_/2+??~!J%?r.ۤ!]p/H;T}(,٬BmDMoo݌g&6֜zh0x+Ix֮ͮW$֟MB0GzGj~‚1W;K>)k@?n>ܵrV)ټ_,sC3pVIU$} f`@w_Xr2/،el9CFomln?nf7y11_h??lƙ1e??}Jav1x2*ɣf?ru-N?0JP)Jn-SN;s?n k NiIn9\-KWQTtv][\b>`kWU6VaßV3Z?nuD,?n;gunEoF?nȸ}"/}&"#-RmՐ#6w]lFL;>1VB,ր3FA"P)?n}#sUq}@xhcs[zߞzVq8,XoQߴug!}B*}!jl(om~K-?n--Pğ]T6/8ᒺXgx7G䥨@33ϊxoeŲD6g)Eʊ^Q?0 t2y1<x%֢)H~7wF͡{$?0MBWb RHJ"/Gi?n̍3aS-P>p9z_n:PƂ^v8p`P7Cep gPqTr3RoCSq ^Q'}G'l~?ns$(^~Z0?nps4~fG?nF^(F4\PQu)Keq@x_eqԀ* d[јl~$zxp'eT6\Z]J"SNڼjPůKT%߃.L-La}G0,FesHHjb3j"1mZ{x*BP.ld-(]9vY` umBy/D.dY[Km!2fEӳ8?0}Y^nEQl76YQ*^OJR(Rh#T|Bsfi<{1YƚxGzVspħ/ZЌ$f6 %h\AlfG!BGt?r曞]HѪ^+:yYA%m{3,+z0}F^m4w<3I +"ԧ>.ټGcrբ:$F?rm@g|x0σ7R??͛绛7ݼyꭺ??_~'}G\׿??HDK i?r>4$1[o挸 y$`9,wr8cFֿeA;eyee?rC8^hKv;6fi:Fz7پȃvezW&S)o3|<*Az-`wDz䘉lx4eu~# +#x'Jf.E.O/xܣ]zFe\wK4"Dyά;fcgq1R ٫׳NFGU*/TCc"?0Uo`i!Sekɳ'qީִN|,Q=tK*E?n*b,ȋz*bT ;"vz-"nb# X之2e߬sXrMmtK}iGo)8k/xgK L#C i=ZkF¼7ck+Vl2 H|QA5=!mp x2V1Fw|_ϸRT$uE3j,o&f%yk Զ%/L !9>cXl?? ;,Hs\s=}Ic?0Oڢ?0^}ɯ+}Hǐ&^ !2թ{Od۴c APW`8-L؜m.t 8z)R6adV6+dͻ/uK\w?0쾬3E4&?0 jo#}&Iܿ$'`?neP[R]LJFL;[i_4d'xic ?na`_Cqxh8T"mTY^(u*YD7 F?nh7$e)mͮݚreE۲|`ns~C@NAEW}|ƙ;GXqL\p^i^0Dݙɣ7@qF!z3-3kPv12Cj}g߾x^<;6g.F?0ٜgiќ35'<->4nD;s~`g7x}-.d/Rٌ"ycXHtpmQs&WqgN~.SY9\iYln-d2V4?0uhen>]RR3>!TA{{|^6T&ŋק&#g$zb 9"==f?rby?0)e8e|cS廼X:-Bd(>P Gx# @3qGYRŐ??}#EP ,{:8X3W^JEza,:~f??W 2T[%Ȑ%:TZΧ??C5קzo;)#=%iZMZ k?r8et0eQ~?r۪'\Cřa7=oP5fnC{{qH9~~Y<>(eka /fB$Ja'qsL:l?nDOwY;nP|82LdRɯ:/o7oԗw'g [ ePMͪ PM,{ݥ@D)Ceax#\nדऎ)} ~ d*n,Gdp7o騠e9˪@rڠh 𜣚&TblƓaףO?nx<ɹqɛqi2$I"t[/e.?0 miu: :WLRmhYf@ȝYFu73U8+X&ѫggG=y?n8}qz}owUKRa/}n;DvχF} y_:5*\rH]:[ e! dYu.VRV?njf@9Ҳfs}TH0P)?ri|-e ?0lrㅌ gb@P@4m??x"!S\=lv,<tfrK;@S[1_؋.yЦx0;PJ/61K73쥝aQSkZڥY^/fkPVԓu I=jm>J\GW.Xۅ>}=i ğ͚ V=(A:Z+vy5!z??F1S2qʟW >C7JܒBm95Jji'D]ȋ_̨&.;5-VIJE?n6%TNjs>lfpd/aSH_6HLLȂz?0Gf^!Y3cM8ˆ4񌶉*%,wXrيiέ;M9OFtv)gZtޜ$~uUduMQ_9Mr61nF7>5hgs^EK oQ?nݶk(,_L*:0ၐ63 P쿈8&y1Ё4Bn?r TʐƏBiK% Lq((ף^InEqY+#[3??Mr~8}Gʏ"Ggq~%YCK7FQqsM~\ϧz7o~d.b_OeK{z}C_)٭?0sIfWPzb-Z\}Z)׹SY=|YUb_f)huҶ,m12jex+a!~],-|㯜";Y~nԏiǥVZ[#\}V~ľ29O*Ũ/*T)L}qԔW//S?n+z/KG.RKՍt_'??*q*??8ڨu]lÆ?rn1B,9LThYpLmb>)c@Ovw7ZTs SyEQQ_tVȁ??oMzP=TƹA/@@*]mPmmJ.h &( qRa\={{dwڱyN /|{Rs׷R5'h^n1(nRSPfi1$;[|}OF&rJ{l6Hya3"9o}KN??N}~9L٪MyP3*pYnncðGۣ}LYS{]~5ZIM_$}$XcjQH;ڀMXɋa5VZbT??!gCP?rw[(G.J7ȑni?rZC`cBk:@Nb?r(؎/y?0]"^=RɆja)B?0 ]ܨN@>QjI?0GX]΢aZaI4yjrbg6kwC[ȡȯNPr.&(a%u"=5٬wdUl?r"?rc?r* gٖK"xH b0%$T A؎$fkr??@KёxpE.|:K9=Hݸ~oh|} #$]407:H.t҅ź/8^fi8Qt$/&_o -m{tӋ3ק B!$n:vKYQgFZ+g v< 1RE]{̯[eβ8D?02_6 }-,^c +# 12&FԯOֶ-'-^wfvJ"cT?rôiS]bpK/4gZj fj5=DلoJkz!x*b,$5+sDˠ?nwB~xUVI,7&hYp"!N0Y i!.'aI{tCMiKE\JwH?0*Fl?0_̸6ݽm&vWFrWa6"Z(h]l2A%\Q˛N[:PʆImckmN.s9e']$04QE xܿWzK??'[!/(q_d 7\q SyLԟ< rj^s1-C YN4TᤉFT7p :xnJ1~A?r^J?r y2i~ 0$*A}+--jiZ4)D4ᲣV_bEfek!d'ҳdXaQgAAV;ĉ*h6jży pJ>6XzejZ-*\8_Hw81/zRZa<̪ fTԫ۴| h>ԨRި>0,p4\)\_ B^($=3lHnfBɔGgN{ڳ"b^Zy6"_A{f:Zn2{1C`[ i HH1{Y_hYâٰ*.Nӈ >gH"i^q*?nZu̹aςO{ަgNaQsTƄşqV!i<~TGE,{Uۨb3}@͔??Kq)`Þ]ӫ%TCվ??4b1k02Z..Zd״赦ETq9c,\MM jyE +s2vQ F"̶b`Bp:eDa``r9SL A-91fr3U(/ZS2ȤsVM h6QcQ`C0i }V1e ??6lH bT +V]tho?r0lJ?0TmUuуOahpwn&fepjp&tPMR UQEkEYAF[$F=VMk$?ndtJtkR>zцy3߀@QޛC⽙A!:_.H CDk^W p#\ix4>h~~[?nN𼆂?nmC(isfn?? K) qW7_67e)Q ai&C $X|}Lׇ?n;$ڀO#$Ib9ߡE;r?0uރ6G dͶȉ]jk4''D??HAzF㶺Rq=)$r1bE d"?nS\JhG0f܅Pz̊PP?0f%NfBLƪɃۖTKTj]^Bj/dpyѦ܍(%\ΪI{#/F&d*F$̐rZyGbwgw{WxseUe$꿍(mCyOJ"%z")~2V` SasUC f@/ol<+^'GɴJ̮l hon>(ceU*0tMI8hDY}wuITZT 8jn>$ ZZRuhf) ;NlK?02vvv{ӱn+~8\N'\;Ucigޑl;8M48EP\Cͣň8* 54_yo3zYnDi1yUk{`}:['/ҏ}\#-2D&ynAKd5?r#}ƀnMVL_aX'orULQ+[vy†J@V&='̪\cVY6y#Y9/=ao/JVTW/O,]8ԫۜ!G *rMLh?rx uRNY ?0t:X{2xN_>{Tߞ|FYOJm1k=Fh:2j{=U4V?nF?rӄJf6XO_N#I^?rvSՉh Q\|PTF-]}=^&/#6|PNV4Auew=<\a_P},y 2αuHb.}BEӐū@?nJ_ybwm?0knqeESǒ@M6lM2jsU <#Y?0٧0]eҝHt ΡEh0|K#E>-og8+harhYNjQo4!Q@q1:.?n9n3k׌;꟝_\)!ZJ#iI{ !1MS?rvojm{6E΃Y_?0/3f?0N"rMBQUOUvLbZ.dcG`]?rYEH4LaO;1zNߟȓ\b)u rՀ{>n" a=P,-OOƧ^/W_ךwT^L'jsyBѸt犴Wq{;4tXMSR84@ӳjp MlFdlϚ{TDA؜J+MCM)#aO 2bF :#ax ÒA.0IN?n#m]FpɇN%7b UuzPʲB(S^ Yj14lI8(v)ҍ٢@{JKMoដDBUEY6?n^^}U?rx/æ iXG~*^ԗx#,'tN.:0=zVBt`F . Ha/t859'TiyDX BYWwp~=:&d}$ԗ?r扂-bKN*$@']|!S!9`$"p6*t6qM/=URgTapdQrJw 3۾O'+%vnoӻ;E_??#!-ɴD\]5-78@JBX2?r+h۔~K#|v^nzm?rC4jg.Qw79cT C utqPe>henVH3g{7#:׍6EPìE0KEf% 7K3#.Uer6B ??Fd?r2WIe3>8؏_mø)V9j"KH-U&y^, SKg؁lcdFqo56Ǯ~-zUI$E0TMw?rgrԳGu??y?n9<x^þ "<#,)Da8SkϗX ?n??l}$O.V勃8$;W'YPN&+V??؉TpzyQ1U*'Y|N??|~f=v7݇ed;kE߃_w??w矏 !tׯW~[4QXDctQ\N!!ȭS?0l"WHuo?r#g?rzuU'Sj+bIbHY}!0Pc-5Vm??$Υ,`Azē6GedtzH%S&FÌx~?rY.U"ēKMw6EvBi]O@"rabsեMa1q'llwXO}zUj&%9+M0J1GUZbZ[?nV،hTP7,4sG.D] ILu6dOI)y/zQZIVXēi~'u`T Օ"}A#lcFGSh~Y.lzbܶjO+p>+?0|)*sjObrt[]$%+"MQRrғRRbQJ;RCgf];)r_~Gzqtah6ϚыgAo0}/X-7vyT7la5^EJ"G[$j-f0M#tJ'{Ѱa>\?nF?rqõW[}u y߷^77??0RxaNtL6PVs}&@cXknYEh[EWK9^-aH^QуQ3 !Ŧ,W؏kߎ؇>f ݹbWۍ2,9>7k&!%\̂Y &}ʷock#_iy:?0kTȰX^Pg#va,Q+/gVɌego<5Zz l_S`2ok)god4t823VifmNj`/D/Rev}dBl|$|jjP2guqtIId&k 4։"X]^?rj[`BŠ4-|ׄ] i:*vQPv6!$g?n|I#t\&4z?rѯ~DJSW`$W=@%\D@B2??2fRjw@$ά`}??|k?r| B{~?ny1:ޣ'Mޗ70oye&1r +lCq;$7qդ[g5آUʨ'鬊LmxuY,sFZTDwcNFg)[$L0dgދa8{~Ad/knf*; 'h7?01XEE݅]įޚZxbƮZ+C+8`^1+ AΧ7Pq)t5_g陞}-9Ė}%ٕϹ1O=ŏ0,?n%Wb" H zJ)#nە??^x+\܂j4r"TQEz K7\2BUg5w??i,uimFXK9ODojӚCM??)o{B?n !}XKJ@cOZ(?0_Ga_a&'PUd,2"Vl>D;,SfkS96Xey |x-+Q+v#v<8x|,n/%m=)>0J ?0.n?r<_Rn3WNg}] _WA?nPXDDh'`zĵ33g7'sLm,?n 8,N +z-UX= GJڴ3K~Ɩ] j{ A˟5"FA,THͫ)`y $^%ld_1s'=z"?0*wFBb3s^ƶ}R^QMħ'%A*\!}HE&0㕚xڍNMEA9_Em]?0k!R'??$NzcgpV[jߵ9J]Hc Mm]k:dM|%I8äKX2tbfQN<N@\*XD w' sf 9-xL/m0zeVۿ\mIN-_ذV·(ޕ/ 0=4_K&¾Oݵ$]Hgk|T^3DB-;ar_s[dbZP+-VvB9/e1?rSiCĢtGV84qab#^Wj${f&aGN.=73aӚ:;wl6??eir ΁kؐؐF?r^82m"V??v;9-J+i"H7p&&X6tČT'4 de($^24LQ?rÓĜ&@(h7I!o58ee[v1{tzh޵C7<sWtT_$mRW4^!s=e?n*QS ς ;?r:6뛓\urA{:lnEj8ꮫB@~=_?rG"E{i*[[šeş?r!:N"8MV!@|AtHqVɻ'w5w6 &kh0Cd??+T0>n&:E-n;j#e^pKxrPb 'mڒ2?r=S"ɾ7q]sj! H-BA9d??/ũ{Vy|~???nW(&?ry:VQ9Z+~"/ṪA#x`㩗XdҶ1 xDx'7/j2Ղ#zEH7**!oZ8TcC1@^ !^ʏyh|XidY>_&`s9E)b}ZN˗&s3ľwKg"Xૈp9q5]$J9CԩiN#x+4/0F05HD8.3?rKRe?0Ѯ?nk?0| #(re0z P{E0/T@#5ѩEN$Y.+yAIWC}1ZddZ(n_M?n 'ESQV aO7Nʪ{E:U߫09W]N Θɋ["6Z$H9у6")8Rͭ)i+Z`QSw4]8ʮ|6I=9K?r{ކ+m?nkoE4r5f= w_ykb-0VkVk6TyU{F+Qcl,=pN4J<ˆG2#skSfDtR+V!@*uP Fg8K{.. D&"LoH莘Hȣ&:ǁ;* Gr^ʴy^a54?n=2@ a}m,!JʫtWz,NA ~??s- %po^F^ڧk Z<ӫ憊hVz$&ԗ[:bL?rZE,Bnr_7c<1Sol7Eqs<Zk>d]RXg»4ۅ͍b'UպguzwcH lK5tHtsu}F44ww +#?0x]#١+D(T3{m\>  7aiIF]tm}juJ5ifZ:F}F =d0!7,Cyn/8??W?0G?n%c<44Fc9"Eky~-zAX݅1Ŝ~!cۭuPiЮ'vV}+9Vc10;R=yj:b;vR!E~V 25ඹ'_$ecQ{p<^_yO< |f} JMws@i;W't&]9Z& W"7P(82SA>Yj]]<#ptpM#zYs\ǶGc.JHJez5;YRG|T/3٬H[g2+=}˖z+v)PV̩Wo+A{k_0UEմrJ= a&,# M+?n&!f?0HixQ֨m,ha*Mb[+F(*Kr??)??/b+?n]E:j¡=ܥ37A#pd4z||||]0#0%]W6M%rloTIQkXsoKNsErdɦ'ʴ<}Lէ??Eٷ mjWW!D=P}+ʇ{,DwXg/N5ؗ8rv.@mZ?nM ?rTm?n߁Eblf?n 5S5C?r+ގ4cwEA&y-AptWvRo`ӌ~[t'?0ZXɦE^?r]8О{Bt??ZkV~ :o*l?n(?0 &p|cmb>)0_?r)s3@f#Ndz)پH|H!5g9m=E^<Q  8m &3cgwmtbE\`X.+3Wo.1/ʣ¶ $Qʶ3v\J,V\]AvU.45b,OqV6.*V:3*Wi8R{?n?0$jqIuSo=|so)`_g4w!+eTkGfg@o??eGCĺw1(ChJ2'p"=G*(fBOuAM?0UkU#mgiUZ_iJ tO~p9O!C}6G>2#f'ftj^$eA{z $3fMqnާAz??̌!K$%˖Ët 7R6aHܖfng/4!E̽VIWD"]yT}Fm*VL_nǸ5c_8?0 vyZ*ĻruQO~* kud?r F'Vc?r3e7`7z<"Luqϯ<+;wB-QAc:7ی"*iud{~3jsC;z-ͿŮ>?n)3Q]h]-Vj9oУ+0܊gCf*e]c[6EZeo77t"^CY<0w2NEn$ݟKI#+kE`,P4my>i7U@T P>.PGjᑌ\*VE8! }r^;ª^thER^V"w<ZnX!}*I;hP~F|N@'ѩlJ??Ya,EvE,kd~A#,< wK7N2w 'j~͘{E5ߝ[9t />>zQcKo|"v+v@ۅr֯OڿjG0GJ~dR)& \wUiY]֡(-Q=5OJMWHN"HV'Nd͊Խv맭t뷡sæNg Nvؓb??r .989WQyltu(=ז2Emꀖ'6JQC?nD˗Z?nRI>5'I.]?rw9'5Bꞇ˩ Q,= ?0"F@lL?0f ::,$cwݴY= "wQF{[hgƍb zI-2wv}Y&l'DX^2G'P(-Ië7Y(1ςgsC!EYO XBc`r)+?nnRW[,3v/IM8}tȚTB_҃|i#-X^ɝחɛ+Tߕ1#J&VC U^b\#,R *Dַr vǡ@7BCc_d>=0I[邅l`T+M\%Ԣn}b)%߁$ݙ]IҨkl/qmvV852KcA)JJxF2k$Z)t:tG}K/V~//V#.TQ|Sj}!yR˝uo+ljMU>?0ݹ> >.+W7H6rxbA_S/G1fKSۦ4׿ـjBbhq* ?0 I8u .l}x(?n~;2xBp԰%SER7#G6Cewv'X6Gwʛm?r;o$ |'RrsXuP)0!2V̝8DnL4e$nN?0TvZFb&7 t$5X#XDFonop|InuSLP&)Q?rwIYсOn&F+uz:ukH\EFcy_lN Y-puBh/&rxe?r5S|!IԶ)r=IFJRh-i8Qvx=kvokDە;Oa(frةŴ???r)[<%nv\n OCto?r.̪t >2ý V`}8}roZmF?n,\<r^Usغ@Y%;OfTjTr.Zjn]{m\/;:FW' C CnjEO~ݐ`;Z ??H<%}5??}=;g;92?n %gqPEF%}Bh>fX#dL?n>R'CۣEuHb}7]ZDRНf׹=B[g-)҃!N 󌔫??e?rvր>m(nu@ۮshm7ݔ -W=9J/dm%9g% @X}ʘw_C`:BvYtY*c+%OXm`½TOOCyN~.fPQ9Qs[7ךҝ`R"eWZtC@dۄwtbӽZ 6 .(ۣс[AqӠ[߇rlwqҢey!WMKb\ s+ߧ(?rWVWbS%„}eu%+H[\??[]ٺ"ڵqyn +#ɛ>'[dYڦ'hx1 |tM}P>)'P]}1zބrtm;a퉂|L_Z!,qήVj͌p!7hH*]aTDuEcX%61wu8_+!T@4EZwR?ne]`;+Mb".psu ܰ1H2nt j>>N(g-&CeNyS ThByD@^ㇴˊcU45ʘ?05(Uk$!\}9>5ʉ 1C6ۛOeu?rniqһ'[8S;V?r+QĀ9'M1|?0ޫ2Ʌ $@mNḾlUu!o4TWҼf)! >Т`|958TϽ@iou 0xl}XG?r_c{=c1`h{JG1oY ^e,9G!Y0,H6K\e28Ua.>6?r??xtM?0mڅ$$ҝqZb1$ 1N'ػY3@\IXGa\XO.w>\~Wdm#f4??2VUpD].l?r lu[J?n/hVjQ?r]1wջ4]ZHyBs^Me mN|C(':x[Es,y6DB/B] &-@do{g XJ[?rξeUתL쭒EwX_E$ SK 4"|>tPӸtV=4>mc|MAY')鵮B\rmaQ?0JpM[uT][ ֓>.C*Q*yP⭶?n;Gpd"uV{m"+ty9u5M,eWzl]/X.F9&@HkΣں-uK,8Rʐ쵴/şjnY^g{79YKT"v"h̲AdӬb.??aUly nKc !*?08. i`.ݮ[\`-mR|&?0K.9v?n9EA#k09r&\V UxZ'= pچ0_ky.ֻa H탵n,Xf.GvR%+XJ'c]Um?r[}ˋs??Rr=9w۔LsUyGT3 bz??}]f5>zl7P:6[l#AߛKiDo>+d??!zGv0Hc\(r.H?0uqx_њOj:|#x,݆uyh\bpGVzcRh{)_{F@| j)/F?ntk޵J!փr )s1JFE5tܦaUՉdb/N5$ E BO9GZ.e -H۸ Յ9X?0~?nY(VM,P^0|"1KEoz"IԶ j~ɧW=~JN&ml'}BzhQ??_RN ]Ndt_P ƮV@ͼ\$4Yܽ!y??s#òoɹ(w3G#Ў3Gc6DG`͈8$@U04a D??m_ctsLaY?n첤6DȄްx*7#u6 I92ek(f7/(Wnv@^?0ue@ 6[b<$i]zG=&?r IMk+G4$UUR%e USduNw!hKM{\SOzг3F] |>gsGsAfLh=si"aWCB*U]?nu&@)U#\&oTBcկ(Fg'_?0 ~ԗc*PJE$yUOzU.8M10# iM*=@ڎ!W"~9=oיSۚjnt^ǨǠF7eHo_xc(u3VY;lKF]}&^sPmz"noiAX4nɌ\X@E7fd`a{}Mb!QEf&??W^-P;?rÚ NF?r?0I(˹Y% ϗPHN`6dAԩM2%}H/$KE$ bBPBH˾_bC.e8zA߇_ oGHnټ.ջ5uW/m[ڇ0s&hիE`*Bנ3f[}Dw#e^4gVX5=8Vtj.4J?n?r}OEX5o7Ku`Ʀď]`5_*𨹁DPqjXΖ(ʇ0:fΓ۩&1??AW~XXWPD<-A!?0w%m2˪1uk1/RlNb~KpN^Ɂ H|?nw/F4>^vMAxl9I*'J(@??>Uݦ5Ϯ-yNΨ)e—t<g:!O^gC.?0V+0hnH%Tr)"ztq?0RaT ZŶn 3SW'vq>h<愜V>uשw|q3?nxgȷY1KlxOQ{^ᇪrJF{4F˦Բ?n#{ X?0P>޻ǎBB@+8nST\qJti jF8Ub*|q.Xۈ3w??5C>GЫGLU4h:;O?0Fc76{i@+پ?n.??G0x, ^o:0bb&G=ۇ۰qc<N3RNI2PZa9M32q"Ӝd?n02׉N9N LkXUYӰb3zUR^"ᘤJ|N{2J]ZY ?0d lbj?0 Ppâ(C"36 ??0e1B\c%*5BD[8C x޵͢.Ͷ"$I7dxlAQcD[瘖m:Ji6aaw3mĩ+ZO͘days*0?ni̪=|W1}%OȅeUlMƨM)b۶%yjfvP5턺[8 G7Dv^T?r)Y XWt?n~IdQPaּOU,O?0O???rQtnep^@Q/jܚчÚSpk+OghH:#S?0Pv:Ґj<2OF` N),{7E;nJ|p  C.Tu 3j}99vL6_1??384uIK7FC(QwY(.ʹ)VVstagC(ULvuq\(gmp*F??İq%`mXZ,npRܨd*$+M8 .d;qoZd8Z߀IQ#< aD4CMdI{ѧL~OMR??KM5$-"K>YƆm2AY9d3m&]?0?0}۶??W'H;i۱7NҜV%QJLR]pq'==͊$0  #-W niA։vߕf\^5a9I6T6||q#f`ζ(#[cpKG9H]~͍PKi?0??NY;ukO0LO]oBB(޶,.Bc&@?rBvWvTl_[f('%UTp?06N& F tV<qJ9zlp9 z GW^D˱{61Q)G∠Bt24Zޠt+LK:X. n(U 2z??A=J9Nsvuw=RˢZB{::PJ}!DF'Ї'xݡCᨈUwrE3 09;#mWm))`ʜ{JSs`#!t Ibne-lhʪQ4h+@baY?0y?nt3Z@?0 !}xZ7WhkE_XeIEO/fsOY}$?r32WlL-rf2o7xv,H MAi`GK7RAkpc}'*2M#8n^"J}(j[P|YSBQ-K]6*6O!!ɤM??|h/ʺ. +EK osGȽeT~b?r LwM?0CҌ]SS?n[7{/}Be~ɷ52`*qHh{1X^*R>i$V\2 ?rxd|dxZF},]zVFJβ2v +#Lٝ@֦`[Q:"{UH>,}{C!?nV#Q>?rWQ?n?0晫r}Y(Ï>Aβ ĔZ~yh<)fAEf ?r h)Y2QRYp[Ɣ^9W}%@4H>֏;Sm,P@?nPm3?0X@X'mL?noYg doePy`!LRTu[;lBT:p1LKՆ}0@iM$bwu%b.Ι&ߩQ }g)2d`r(?0SS??hz?0%ľghE6<'7b'X鄅dKj ?0݀2]yu3~m9B'h#m댇IF79J޼yx[.\ҰJRj1& mn9K_WI(Y4[I$hm*s*rc"ݽ"]Y2ccL?n꾩t[OK?0On lŒK ]f]nc!'`&?rbeq-4JwCܺ<&??LsAėqL&zŮ<85yY)vg2L&ܰ7ܽGch_zt&/Oruܲl 4ȟ3N/.G =+S^@[6&TڼDhŒ"N#ZFYt?rG+".p8 X"Fn2=Ӥ d4[2.si~Fʼnϗއރ܄ʻ[\Gԫ2W!A# n2Cy5~?? +#:JQ߸HerشO(s)AN߼1{p"劆:]a?r"oEk>*[84=5`c0/\teKjOf9݁‚[k??7@[2w(6$ƢDn@fDvigJ傷Gr.Q>^K氢b=v_ףFѵ]EJ q<ၠ:K"Ӝp.?rګu1[<4!d/q(CUQUlJN/*}?n*6'AhĈauGN"?0a)A?nw1e??1 avгHK܇ޫ=x裏i`P.q)/7qaq[vLRpaY>q'D*_ Yt2[H^:*MO (;iÆR-Nr"?0t1î /D84߲OO=275֘Y4EqlFNR(8ޯ c!҄gh6r@Yfh&j@ߓt\ lspY}+N/c!-E\}FD=)6\!/6<ⴀc=yJ:]j}/MQnhx%-H+bO}_@wQ~ڟR%t{΢??n,pj~$2j, q cҚւۧ1$ZWqGiRkө=C$"\Nf!$?r5iP?r"@fis ŗt~!X+355E76?0Z.[֥!EQ*OKk`*jSdT[si:}A~nrewЫ"k'HP&6hhU}ˈ#k5w @]@nsz'hр֌RS"}{%=DГSCVX.)u^BkvoY:oVgN^Rɴ&QN (Y?n?r5?nmi:'*EiJHrϢR?0w(YsRrtޕUҠ!SrR!TT S&l!OJ-ꚃ>`87cVp|1M`Gs4Z5-QYlF- !~/M-\CWϋ/T:Y73hy-#wwMmd6䊅ˌ壺.dw5WɆr7hɃo?n@enm ӜM Ii]5ITq]z3hÌbe\b-^N1rBgohrj'g&_pe K?0(K89W]"VL|kq\&xeṕϬ` A}q˼Uu.˩e{N& aѣ1>{:ȋ_Jgne?ngO?0EZ_UpGA鎰[XJ¥]I^F/iKsI SC-߆쏳u~`ġJjm{;AahhӤ*w;0YN5sn!rP*ɼOs*UAbuEzMDY?rrϣ#q\~kLN.?rXkMT(bT6ĤazV^%(\c'r4{d%88DH E~KQWz̉)VWNgL]Jފ*Xfɷ` h{$,뵸nw(Z7feAElYD7EntkP?04^??opwǂ]h u=U¼DZQJgv#Ǹ*`8]zc Ե?0Cs d7G+""p$?rjIT/YDsEj##!͢oBBc31k-N_0&ab6+KϿU*k"'Ⱥn&]^񘓝恬E]C%hZMn#715`dv8j̹Ԥ]ObAi/J233 Z^XzՍ׽!oZGRg-0=@OPVDi_XLDr$"(1KQ]F+="lu,NDpݽnқ .sWEe.vݼDP FtlHe?rDx?nxE P2aWUـ:vˑ'y~uyn' zoMN@^6(}<]Ędηsm&ՃGϾ~1^=u7F EGI^Q,7ӝ?0p??<~}2:ݘxH3Q/hWfr\Hr2f)phtqs09?0iOOq??y^?0 i?r4"r/}kjku_g|WtUl4#ގϗq-CJj(ę)pb<ƠٙMNjA%|qy3%Ryz{@\_W97&[tYtDYmP7'C1k`ˇgiz?ry8m/LBs(P);2V?0¶,1gM&us`һB3mgt~;tWKx8#n:ZXԥvsm5\Mb??pŐ@+?rTWK>V*x.~E M<@S_x'vڈG?rў7*%tkHnƺ9_FEO1ӓ]=#!-Bڙטj?nM:EdxcKtCC7u yJ_!y+5CjfS+zpT}LH!e@S%'-0϶?rxזP?n,ѨLoE-$j(1P?0ҫtV3zȴ6ˏby3]W =/ޘ@c:câr}zxVCxZږBEve٬DojrG nEhC#|;?0sgrglH(sUo;N;u?nw4~EJͨYO *FcV=(@A?n8I}6??B㛀c3R6 !5Kɻ0#h` g|I:-YT^?nMQTx_SrӎXUE?r)??x;7]"V e &lgh_IdV V+87 {+jxn};ɼx`D8?? 8Ke܆A9:!&x}2X\]ެN $s?r 8g9sʥ1oA-l{5uR ' qC3yZKFvվ}?nhfyĤiH&U~⒖K??~ƓګղX~yZ# i,NAI{slZI-̵ u0?0W2??هI0ɡnż Ju(z5nz<ꃋ6͕N??}}=(_?rZ??_)䇵py^5Q-;wj!PTE,@oE{{ɣ>??NS։㖚bGha)s,ͳٱP@3t 9ZmO6G|Vcd(j!V NF@0D7<5??9\JDY8fTm6?nR:g*9?n=oXGdŪ[5W覬v`SnNVօ1K+ !Z@]q~,F7?0ď:v@DJjoqmrV-uVClLB*+͵%8WSgH\KrbZh&U]L$Mz (@ 8mD[Jw|5B$lݡ?nF_Į9lZI?0}p`f0^YHl%K؅q;Q??ŐCP}*|G+[P;,f9,NUL.HNtaiؓl[fjj/i1Dc.sMBzb?n?r|=sJyfLYx|XM N|t q9M{E?rJg1{vAb|{+H?0Q14\o3JϗG"6 A{kyh͍[nǘ̓?0b&^ LrKk݋BT]gKRciD'4t8B5: *[Mӡ^R 't?r??l,8cϝfPrm*Mᴴ  t:??V|fʏRxwSW??q)(AE4Ĭ> Bݺgyv?n "sE?r?nm-?nndubiqN[2Z4~Nf)b]c8OtV%̏KNu滐f+ ?n_Y˖"tY c$ػ+ֹ,b"Sİsz JI\){W?02*< 3R )%$%M}"]DD(dT2'HFlD/Ě'BDaxCU_Fpi%O6)&6C|穆]״e>7D:OЅ)ˮs`3,փL63Yd3eQ\٪ĸɰRwyad??-c3MȊ&Ou)T|z`۩&rX~)J 0!ʉc|3hSiֲ,\BF3 ;dB3)Tf#ytƲ9+B l?rە`Brhۋ#4*Ϲ"^?npZvY#:Q W8P??taЃOJ%Ln_^/71;t$nOZ*Pb:=>K:,|?rZ!3fq(?0 O+nQ'E +#[Gϸr|j/ѤvQ汐h?r9qEw՚HWMRZ 0=0#.F:4:o޲zØjN`ع==q'i??Åx<\|:O_~vpgã????@damtOo;h==kxJV&,b*' ̘ң1x!_5:'Y>iQc07{{O{4g?n^,v)}ns{/xPR g,>@*s##6^πԙ~xE˔9Qop`[:JLu2)L+!??ƈHy(muG&ٱJoʻPTMXF?rʾQ 9WLsn'pzv#Y1D(>|dۓߑuTuKW4ond??Q-'ՂhN/W02fzldoo{"KQh~VK}Dp=0=T??V&ğ?r^?0֫(I8O0ܶFS%VjT6M{<]ѴT΀37Q3$L㞛"lԮpQȒI:lDc/hi㎠r|j9K3x&iZlj?rdŃVu~Azu'EB97&.>WGc- /iiqgȞSZ}*nZ~21\4i*!&}:PNg)-ZU_??dE*i8Dkh>P~V?0J?n`J7 }M??yV 8ЌPaS:J]*[J?0$([DL-Wgm??fUvDu4q?n)*][H"??J^3#觗&q[!iJ`Y"t#S1BGſr͛(,Q%hu^Xx:FgbYlJq]⽽o@ ׯ^=F;J6q&fm_|sX6"?0l7N`x(\`8~KI ?ng:N_E"ǻa@}Z٣)"U6aS+ZvN W0~ 622l̓:_8թmbل,<VTgtNP?0lS W1Ec+E 턒5L:^/=FH {^xߝ4+j|k9[D.+Lh9)PRA79e@ݣ??C@XL??;p%@RXF&7+E-eiBldj}i0Fs]4}u,Y ??|Sʗt^oB}X*P0h=p|x7T 1vi/gtGlQfl +#\= a+ ]<Jc.7%]1ga顔7JY:??JĂѺ8DZa`1u02xoW}#T0Cy-K*[:0 FV4\M,wrT&i+|G ~,y_UJ?nBUDy]ݹ[r}Wx\?r?n4^c 20?0dW&^Im"?0åm47Hg !^BEv< EڝVQi4`ۿ4C+_V;r4"h:{?0JEbi-{$E,o6 ZN` 6"C!1$ϼ/.T(niVLJqj?nN3+1պL^ЮM]۫09-+uS:/Y+yZ$rV.9s]䜟2!ay,%ܖ: Oc, JC5lI!(C|+w8\H*d@B]+$8JH}MGD"P+}?n=޺R^3Xe uuu3UO#|d1;1l|P~& Km.|w!}Auۧ) bʒLC>e`R6c74Y-I!\Jk ̖J+KR8ZN"sVeF7 ADτt+X/HL( =t-6"eR꬏\eOEM,2!\x^|{[6I7hQt$6G;7??ٟ~eaI>͟/72+Y{e]Xf޲ oId%S/g߅PJr孼^mw;$y!HWm2D,)cD.DV(OYj 㷖t}KݽuMh?0HƧ3A'NfP5ҽ?0I&Ηbhႈp?0_䋓't(_HvdGUm̢|ZR>5ڷq~ݨ_բe[pK:?rh:J0> o|ηiH3g?0gCNjXx[*G5|'OySʹۨSPV/ǗRHpYC^Ouf"'35s_$L:s_3ՅC*ʖ}88'31fQfGI]Ek1zze{I?0swK&U_.svBxaa $0.XG„Z7{(+5;G??Ch5.Mc)GI\6Nl˞,q5Fn]?0rRt{?0*MQ?0ġdV>< ?0RЀ#ݕagq2#*!BEܗ +D=0z q8@C"1<Ѐ`?n!aQs}۰rn?0f2 ),xj(Dmq|j-M㜺iDKB<&Z1Yy)IҠj#&A:/ƒ*)'E|yJ;tъbo`_̑ep#DI<Ɗ_cbszclH{#G??A$Hed_2?rC)wy}9?n]}??||:ejYO5Xl 8LAWݕIۛS`*DQP $TJΥ?nUEcdR>v*=5C,2_/E@g$+0 ?0rSwڒ^r$c,I$ִ\eOt.噭Ve]a;kҎEh쏽w}@˛A??qzDZw^J>~FK1דy-t}ّ9m\eB4hq{6% ?0)҂z@5 ʞX2w)-wRe4CV)؏LδڬWG[LPZ,.5?n?01lO 3i!]8t/`?n. k3u`d_ FP FK4vףgpwӭw0JRp̂Fk[ܰ,EZ蘎E??]T?r`AG۹i̚$&.0+m[Y<-]j0?n^.-ldQ\EQB*O㯜b^9T=ϢȃĂg혳iaV7I" @45NOZOq%ÃYz mFҧ˖2yQB&#scqK-5рOP 3Xa]YC7[ (?r;[!&Y?r/ V*h,#s6>8,K5́JZ4fm7S ЄY0{7Pޡ/izdžU񎋐/}'!X$iH\Se5-+2Q6]FEhGލE/CsZ6+[V-,2Jյ3?0g iTo*Ȁv77ԏ?0[Ńi_ J^Xd Ai: ͧi5?r,EEQ >ՖfH`Z??!6?0<:QvceJϜҠ4:t׊;8tbeë?r?0]sr\8 ]P%0Ӵ@mH/ʅgLz{lcF4w~.ݤ P4XEl0[g$^q54ϛ'r|R=ݾ!916Jf,u&/e8תQ)"gĢc?0De"[/Gr$ZyRv8d5q x'aEѵ+ܱJũY9$/Y7;)/<65H?r?0a2OZ7|Ycp, y4yl@ddy|'γpI$,@|3v?nW^;`easr=D}?rp8Ǭp;r J'/O9j3z('t dŃn%Q|hڈ0A|4߼^Q+?nS*L?nJ>d)aEt?nlhifYA,-Jn"s%TuZTxq3^H5`݃wN?r?0N9mᣅRVzqմM3g=mlGa-t , UۚuK?0ѥz?r )$uNa6 F5OA.@%hs3rvWR;"HGJ~ WNQ/"4k6a{?rr(* 4( Nx_A`o}A-o (~?0,[A $P;ȇ??]u8~ynܥWҭA?0Iox7Ho!?0bB 'urNs9sl莹!GTd{t/U3$X0ᓼ.M PEJz>D+(,׭d-E545솩c&p8G!8>gxNu*5$1zeop_rh{L6#|KF MۺiR1v4>?0ݲ%_7/۫,"fiʽ~a5 s;gu Y>B4%^̼΂R{-jp;:YX˅V,Jja4X JS)s~>,qQ?nÑG#ܱf#ɮ/uHUX.G-ET(t1qde%(?0Eʞ>Fd1 gaf*Ip?r!E5{"CvVטCa.`Өު|_"Qxe|Na{?nNf*H6oxŮxLKfc d=w?r!әM/ 5OˤPև@d=2?nPtu8#Qk;-;^u0x.?rEĜIB*+`ӛ27qt%t[ Q" uxvna>E)/.c b?n pʣԄ0im`6 ȟ=NA:8ȐQ)jұy"^ƅ%p1i阂YPr!À &QbW??4T2??'HAfKڭ X\ػ5V[]MQgj8jZ&X% =Qi1+BL+YT*kX~o/\~vmJ %1mX ?rR)0.%}'zW??wtOo/'Jo1(,"G[׻ɿh#${?08??yReR+ju#^?00Bۇŧ ;M8ؘyq;^/`ŝ1ȕr0irVFٳ,f4[bPL^)o?riϮw?0B{#ԻRFpgLKC:v<6{?nW{gJ 9lm{ۧC?0b­@KZXYO+ԋ bTGo㜯^ gm+o=JEŷ|'>I?n2Ǹ=h55My9heo0џO.CuYkB2>fЩ#BgZiհ,eЍ*`hL{ua?n*)yP$MiQvAe &E_>z!Wy3t/[ǯB'.'@k˽/ە/e߼3(НO^<|`?0h??*ͭ}V0R/zap0zG[?0), ގ~|}yI> k]>?0΁X^ϴ'qtWo0JI+Ip;jr)C4!J?nZ^I:3ܗYU 5Z% d?nʔ:2%*͢-NL e֡x.eb޿⬖s6Fr湄~??;Wt{G7^Tu>xՎ{:IsIG%]1CI=Ah,-DFS"?nZo2/x#s,f'@I+2"DX@Ziy6Epn4$??gC g6K;w+QWK,sfjK|==Lka3ہWۍ/,~@u$?0')aIj[BSfX޳F8GVpT1qvgq@oqqƷka7u?nxLdBz;N=tcϔS d:׳e=$D%%<}m ԖKqS8g`1@l,q,eVrZkGB̕ kTD+L>>DmTF>Y#,{o6HRyLy`39ϒzx*cQ aI28E1hT~mX܍;6懨Ka_" ?rZWzTVmm0lo l> 9;4''Y60(Y{?0-QfFTA>{cu_A:4̩%>_",-%Ko?rDo!p-Lq!W&]9=D" ~:?n77z%6$Y%d= Q]p .py$}p" t` CAIzXiOఘCGaDy-dg%??DS C72[עdV~clJ9N,ZhQ}G"-2.tX;,ʄ&Fp7o>2OyRp3F@LL9څEri\?0 ~ 7x3kZƮ?nT^#/I馪A<7 NMX&Ɓ6*ݨAZ`NTB V0)o0'+DK.jL9]) *l|t"[8vmoL+Лv{蛤"B &Nr}$sS2=,*!UhNS( Aw'O{ٿ>wHx9?rbV7p'N?rLf+۞I"8ݭ|ISm4^yE1. 'ZIycaC:V x`cDo#i{m#J?n4!5RK;ke3P@qFchҷg^Ë!??ޙv.NܑI??++^a>.?rZ>Kq>>'KR@]G!s&C6ְw^լNEl')1w3(^kPPvN0-WKcT},Rde@i@{yLnӑwH9~-l25^pv/e7=wp l01r;jK S:&=Mv?0J߆Ak T?nP-p6_^=} HN`H*6h„'N 7_[{U[Yj29\BNU1;M}fu؏Ưx%p8n~ۿW??Kmn7;4!x#S?rX/)Lld 6GaĬu=v|[*fonosU>n Q4(9{=zF+q+~}_}fglDDqOD E;Y!&F&vo,??Z`A EWh=J/W#}VS>jIGume#oidm65.|Gٍ`FE~?0#t֍18#mjգ/{]F5@w,#8qzi`jtK)Ji;͆+IӠ$]1'BG#ei`WuiTE ucj,!"W%G*+u7K|4՝hZuLDs-\s;+)Ӻ"??MDX=źkr`Ԫf?n.ie:;k/.MsȝQQ5iYlC8>!vzS?rkջ,WK4}l[/GDi:MEw宝m`,Jb1}F6jb**\nF$uєjܕS..:\ %'H'*zQelRV:cVzAcW?n12‘y<{Nw[hR"`\__D0.c'??{Uaڱh6̮8k%k#kq^zY/i2Z ;6@gS /TOG8-z씍*qb"E_u'\>Ygzikj??PI#H4{r .QB"֔4 ?r߮ɼ‘ЀC/uW?nV4^ r J } NE :!t'??2B6!"{HP9|?0pu2*Fb9 +}Fj(BƂqu Vq} :xr3^DzVГnbBmJy[qǎ4Y{d­uZ9eqqe{l(!yG5QlTmg;8T.-%5Uu?r EDG.&f0Zm{?0$=#AB?rl+Q0;0ׁ#D:e?0#1+t;UjN{GL^J%2wA$UL#[!?n>l?0^Fw Q4^0̅#%$ c?r2߮#RbFQ |qb-O~P<)K=)i4pcϫ2mVXx?r%??.~_?0.D-{vrA<$I{0Akd>4+rXEo *!szeڜSJD1*oFF8Hk[ףK{gna.VA8nJG{3eⱌC> ႇ/f2棕#΂x*+Rzл?n"{??p[D]?0-|Tqt??~R}`W 8E.rs&a*eӥpB)2\q4lyMg| E0!܌8v$N~8{iq|,*첲A4QBfCiZ/_4~|俞>nЈ$x Wev-UͿH;I6/BߎR{5ϟnIyn??*??=yKP߿~߄,,3Eŀ y>ɢ#4/nrMb0QVxZ6|~o:m(aù 9Ilj| =㔔qY9_ʗ52^JF div> +#30bϢ>i*_ |+??akz_pNJg+J2|町HN/YH~1]"yl5^&mU>0n|i͚ dN-UVXC9:X]ŵ|^׌e%O돋\,C0ҮB%bd0#MJT HlkޒrG ө['91M[zH( nˡ_ol 7 'D.ՔG^<ĉΣ<<ӵ8f/XAe\,{_qP?r:xȺ''`o>$><~wO^Awyjv}!7]37Km 5(֑q%A 8#u3z;6ON|p1?0X30!YG~}Igߝxp.V KއOj{cM7d%lЄp!>Gv/Oٝ/_Q>g/eާZ濟3&m-o_6r9{z83ֳO ??,&~僧1D,z8QdZ٘`̀{~e{(g|hy?0 {m>cQX?0JǏ>ZLW7O.y{Mܔx%)2IGϫfQJؠxRMɠsaT鱆AW&d ^GBrMEP[i:G$7sdW7/c~u WY">e]%^DQ5?r68>lIE~?rv??.xڑi)CwX$W\?rxDohvH'I}NBh6]<vjOZXn= &#:Z^k)8);Z" cԉq^hCGZՕ:$iJo*!??j)ͽ}qViVj^pz|tV)>;ˀrG-t`3ӑ̜s9੆~ *&$ |K-7myqDظ}??n)4H]m/. uDtє|k;{S>>}t>HrWVe?rAF*w2b蒥2HU^Yw%B>9Jcy+{ʧ4] C[yt 3N͇iMf6A}Wd A|g9˾Vapu9ב)a Xf7.b+|A_wbډV^{<.x)8d$LÅ{n0 Y`zJ073 uY n*74/ `ga !!6`\]9`& wi>??>zZ8A hE$i Jjv}VZ%S6Qb*I(tD=:ZL<윘J@ZuGҏ-@%NJ9x`MV}Tt,t}/^,sb$6>὏0Qn!*&pa҃#!5&RmpNMd0>( nB}J}[,nGCD+?0̅.אpC5UN53%eϝ<kx-YDɣL2!]bPL96כL%'fAf.$t Ej.KmБY6ߞ}ww.M/ED* $V?0VK݊u,ww?r` P:co#lN+:r.["#UJRV??(U4,zzWfq(Y.,BfQi#oZV1fmF+ 5-AQQ{gXj⭾4|^8x?nQK: ;'Y+˶d}~8-{~@}ukt:@z?rAj}EgU se Cco%,| z6zX{WX@WˈTTqXWSTs)mj{?nS5/nU5eL]fmkL*F-*Bke' ?0 YxeuT E"4H_+,3bXJP?0Dm!ՈĽ[d&~-ȯtQ#zC>;ҬVД/FA nx9;<۪|~ ݤ1MJ]~}J@\NPRvB/CEᥚz>6"?0nA?nܪŤF"K)?r.,%eT?0&t8xΣj|1^5>6'J)@\<[Q#n:J 5~h,RvPgݭgT ?0F/?n,ꘈAxfh^oɍc>åHbDd{vhjY>ID~UWWSY5bPHu-lo KMWey??B&d3?nj~n"EpkGuFix\5]4ѳUNKRL*,S9)2A;.ξcDE3 c!e"Ԋ)]戁=fMV~dK!e-oX,`\a>|_=~}*ȯRQ?rˬC[řKzЎz0]:5¤N{L [窵1Ұw*Y^iyscIFgI_ nDUM᝖!L+jeu ]x%^`W(ZN]|K}??dzjGos>fKb`cgG!i1Wxrk.qM@Dn,fpys ~y,7,>]59{„.<670:ܴt_0XTseo9FuIB??]Yw?rioʙФ"|??"yRh1u⊂+F??F.rڱrLO>8?rh=#AjYv;A1It/vHy`.-nWf -%XzFO#kYƏgc0Kj0п~!M̋tE_*_Nj~QSߟ:y_<_^CZb7~Qc|SX;EM:⋛޼?nke~A*}$NΛ_5L-痞&@tepE烛㛉i|xr^:&bwjeKD1CxVU??8~t2eb7w(?nPJb퓣5cv-1h8ۇ*#ExZ)֟N̠o p{%+>z={Q`AlZۜnuGxv2|?nmW.~fޭnz{D YzB9blY[ƿ7Bb,UMڏR&4jqFձ`cԄU$6xT\$!?r3봬gk=`^d4#|!e{%N2J) YL})}ifyW%O_$$FM-lH׈\obהW*A?rέ<vx"JtA9)53gxcW]9n߳T0޲a|ETVpY!1?r{mx7(G}w++d(b\.ŘR̮҈S?n24Ͻ?rUɾ.2rfAk.*rTE/1^؈U5#*¥3\vZh]%C%PH.ԑ tO$fa??OXIS [Kr`,ʕ 8/j?nr9S"/b?0\ZѺA9?rht׺[ T?r#*J).,3O騽܃<竑-XCb`RQ6<]T `8>,gkB))?n9ד6h6R+Krv/-`whIM [;!*k;Q!;ys"ڽ͆>j&jk?0(HT?rmjan/huyTߵ0Uq*#?r}7HSj vXZd{蓫QCw&(rEj @ owP e^eR?rFUw%CD9a{QITL,S ?0uj/w0 %n?0(p_`>6S\d1dPzNT,ꢻ|sd%[ڴڤ:^DD!T`33ņ7͠moGI~]|KCB1RB??o%DǴuIC_'sT - sECl5M4RmvÎAu[ +{%9!9XZw'KHQl'g&cUhS`%*B͎ϝD+|2))f%glAwۘܗoXiy K%-??@nWEUj7P?r&CWjc6@2iTi͈2kaTe=R6qIˈy5mLn%=pmmB,Po@iy?n&]A5NAʲ9iDtBĪ^ H`1R4zϒ:5u1*FIذ bڸo ?r}c(Wƅ=p@=5rcrU{B?r??If"@3AεxgNKJaIMJ΀sI(Yyq2%6(iVm ,RL}"?? LԆuVZ*Pm+?0( .6d* `Xȶ\ joqFpM•;8Υ :Zh;GRȶ)Hr%F1ɣ#ϽzHF,F\]JLcIφݟǔ$??>BF:/ajRǐW pIK/4HCc׾3*NPvcCUU> =e +#?0??!/RP?r玓wIAtVTIF]PX*:_EZխ3@qqNMˠ҅iI] CJ}icު\"UlEuz~"!ǀRhӘpґOIEMmuO9+2"$/_??[̘[[n~B~.$PN/(N%h.k]κ#w]7Q :MtUGZHvi,k/wXW M4SfTࢅfԘ%wDwt濺КƁkUwA߹VV+J"kJXWFE?0'PM˜^K8μo)(frI??ٙ@LX͑.Ɯe@0Td4p!=Hv6 =csQ{S 0a6T_+??5?nj߉w^ @p̎S֗<>vj Q3۞W@v?nb>M>Z-O7_ǚbSP2KE1^nݠ(=Uĺp`t0%c̄;xLSϗ֙EY^K*a"ݶD\Vim+8r^^T%ػa%! cx?rsT`򂐬*OB€G1mx~SF3RHD "< u*۽pΤ:C>>m% VRs2,Rus\;NgN^SӘ.ڇS۲`L1?nF*??|`DpB36Glmn6t쩠[y"qH)Z-n[҈㥄eJMd4pո!>oz{ʏ*$r l8[R9Z|4^g1&dAlCHX_ؗ/RrpnD/xOn2Ff"V=F?ny7EV1m0E.ḨW3֫!p|N9!E n1]L~l9c%" 14YCc4^c?0VCɽYΤs$?r?0s)7?0KqYzĒkp@qc>v7ڳv8J|ln: Jɖck"Nj12S}N@r&;Hp|@@Oh?rJ9[O*ƹjxKz0LS\V0,5^zugXUSU)y^֡CVݡQ֩[jl5/ 4(0xfN=zKWVZu%]Ԕ*h 2icطi1YzIj[_;F>EQ>qdH]][/d#( !`_H=2'(Q=hZ+4e24[ CJS'n} YEoDk.ykuMKbJY}]O}sT>ʋxByq$M*x4!q' &Bs=w[Yd=/8\CC@7VhiO΃ݽ g]eԜ/??^[:1θfpLMb$!t9C6F;rU'k*w=4̹!M, >_#ؤ25^!ZB?r!-&lNҢ./= "{!c`R?0:iE)tۦl{?rf@_P/WbUY^1MwaUVVah\j1hFrRyy`HP*5܀yKUd[0qgmn bouȭJlO2N~Q+8_¾}gqܙҰ# nq$vq5 #A=d_@w&Pʰzj^OV{RpZ"mS_0bGâR'Ԭm.iMԧ}*O5 _gs;B??{fX6FwkG%y%M"+?0!Gwg3'OU??ۯhT2_Y Vپ}7ƸUC??i3!Bdy4Fd0gxq63ZxVc9s 继`)_'~25tx!{?0Q2HIgEQP@f㨵T-`C8(m|y~"$`Β-?nݭPWǮc4·j<'mAH,hbjF˽Ԙq0CQP7Xk:t.V$Hz غzM^Uq p nuaP2ogՁǺְ<4o+.)T?09op+Lilcx  g0Qx!8\6NU :@_,722$}IL#?n25pO@8?0`??c}:[,g`wyG,/tHpFo}Efyqk84q*jmum,s=*97UAwxC@;tE*J=kWtj?n r:d-ƆrxBWD-$-ƚPfHalg9;??I/+۳M#R[:y1nMrHCcf-X6P?0f1^*;V ހ"0; nD(7]?rFp wDɍxWC:“p2$@_Ps[,jl29S??6dy'Р̗Ć%LqP%}+cHڪcykjSEG濤/H?0ʉ2ƣPU#ϔpI{#^O4AwlNQ7i1N BV&u%~?r`8.f!p]W^& xwtT}g@πhIӀ"i%]#TK5b_G 0^87ˇ??0,]3Y>If*܄q$A>o"{O%}Ta*\??,~*;<tɫpy,CD,;(> .~cWcz6)5uP 06XA?n%//^{-)_9[_aabL.^nX#Rr* R=U{2&F:gqszML劸!fp,n?0 ={:/CKM}TƵfFN1N<у7hI$}pHotHQ:^'U7J$;MF}.A߰K 7._T;9m*&Y<Ǖd<8KlX͠ Ŷ[Ƕt$I}8unz`GݽJ46!ªw`2OM]6a +#ߦ3͸?0[9->Q݃??}AT>j*g2.IT |4$4vN׵dPRoӍkQ&3}KIE]3{KVZ!Pmlzlx R1ZPROim<3OZ1 n,  9h%Ҭp'?ry[-\^r"ѐ?0jseC[!aQ S6=Gh8~$HwD3BYm@`d^BVDقJH]Az^`kQ_Iw~9<ޯ5۵nүaSZ6-)VoWӬé}I3~r6^8 v5VNQP_BB)6u٬na45ĭ&"ԇR0H~E54 %ћ1.(|@224j @زmo@>}O3BP94#TbeaKzIMLATK?r!վsKy$vT8VEz' O jcЄ;mBgg$MR3]g Hj:e{O ~??yK#;}z* ?nh|ڟã??Fߐj@kƅY=k(Q$+>dԘ=ħӍnLIAZ7&d,M=@\|bR^|7]"no 7U~cҤG*0Ay3uqڰ|A>wљ:lN[c߂whvufʑWn&^xd֦1xҍ4gWݩ#O)}{#̫Gfȿ\bȭ0 p_z"=JaIeczrLb nr[2z9QD](oW?0ͤ"0*f䡎<{MƇ{b@jn6/HTɻ[%kRCbogO#c>1_au&\2ѣÃZĘS} I+.8#ZСj[r{OQI$e ӓ6Fu0p)Bn~cAՈCC+ZG6jմ?0TL93)aYlF7w]?n5(JA%;ok8L'3@M=ezBV̕;=6޶S?rj]~ cAs> &?ne޴GL)}c{5Rr\]5 ^5@-սDz/ۊ{hN#clAid^il 3'{CyGk?rAY詮KEeʟ[r-SD{ݣ8۟KS*掮Y4MHJ?n?nMXIHHI 9zH8?0py18m??H)8F"и;y:}-)2M+y7`C\a"q+r.NT%uXBĥ?n\&?n&-7=R@ 1+wpK_#rob=.^W9OZtP$Mihr[oY1+"vOjz]?nf%[@'Q@UWR*j΀?0oEb[uq-&:/rJc[@Pcuz"_I)g>e$׫ ʋOu/!+r% ]+t>NUʝJ OʎP8W-",[gd+f??dt4K`&)+:A>%#'9tq3^end8j??5lXpyӀiYrHڻw$sM SJ*b _*j1N#eega f`5`+Φh]8%"̙Io<]X#-rd|r⹝m,?nq\!3ۜP?rwc0Юh a擖q#R)[/`k<Ë2~ժbn1yv??ʫ3'}4'dﻢ'?r myxbr[H *!*­{\o/\3L?n`??T_\_%ʸφ|hGe`?rT(7mNTT3Sz5䌿P/٬ Byyg}GdhGT]^0җ@9'a{+Xցi?0P;*[ČQh*LH6QLΜRMERUӅ ˆ?r(-: /PSClӟ]$cL(prA_$מ~h|Hĉz-{XO=:jETz`ִ -{{O&72o z [B8gPsh#'OlPE}Ĩ P(Kt:vMǶZ?r-)@h6 /*U|9{ݛ-}EwGqvXfIjVZh Ogsf:r(MUQkOK?n.1 BnqL^yތ!p.Us{4PDQw9`?0~AOrI1!AQnĺ?0@=Sb0{CApz-.LD0?r7᭡ɈT:_U w&eļkǙ}тSpz.hFGK7)#& rq]w8\g]6z飑Je$ǖ_Bt;P9 QFLYE0eѩá_{K>%WC?r(6xS,꽹5(_wQR-wQ\TsQČEkյC&NQo.=o +#vftrZ/DRR|j5 Dlyq ^?n%ԓ7MƎ; $%at8imE^U}]x ?n#|MHo~}ww'%6H(''$TȎ-`Y8܂ ?rْz?0uˉ5V1J7|m/09O?nm C+@c*?n׃_HIA״+r^v*6-Q/Tm?nwtK*hQݼO@~zIv@mV˭?0 2&Qs?0Rb'M֦fʊ4řZo8zᔸH?0ؿ#*ќwh l`5.:J\1bg%ԬU!'0'I{g>kzbZ߯9?0&qXJ*"}lw(TC=%G( ?0X^rCpwo  ׽"-J\g4𾢏KKGZ.uɝlF`U3k?n\`KGbc]쁏7w[H?0vR!??Tr V^7`L\(Ni6?0JCt5b0Gkk„.-Yz:t_sg%>#$}L[krO??lS3МGJ %$ue.=#P]c\#G[TwT# r*]I7L1T'8SԷۢ>MT'RQ!9{ȁ'G[3G{&PBSB??pvWű/1,tcXm?0tmbW GR՛GTجu]fz .y1*6 [Q6tݡeG#' >5?0V.:9SؠZGD}H!{ޛ% 4)R4?0n h*8ƑA?nhE?nOߪ?0T$A?0F"w]+'ޥ{????h:@>=Bx^ naD˅Zi9?0e\:F&|SÐ48<6JY )LMd?n?nBȶ"?0+*n^rz_8e=\y?rzL, P?r=ckiӭ=dn^?0ADGg[ӽU;(>*OU~!8 5^,qb`cÀ ??Av/Q5%NGZ?0iV,O\]'`wo^ǾTB8Bxk?0OcAK'%ڒ7mNchpX8;(T]J$Eu5cj?nkLtO"Lǻ+39E%vUaCckw??4>HI$!3UWjVP6?r})l6eL-^&B8/+#!޾iJ:VdUqJ1kq{Uݟ}wa8൝,-t?rA$NeC٣R:׍_^KTY%C1moq[ū\p$ɽ{lZ?n/{'/䝪೭.ϳԈFF\bh+8߽WѺ"g 0oB|Ea#!IswŤw]nuj7%19CSR89S1mS[:vHær?0>dHKбΆh1OL]"QP]}`I9il<Q@\GvE2!8Cht>m;w< ?rbǨT&>+J0pP,ֺzeZ ]RLjpPvap:v7X=;*x1,U~@.Gna+?? W????yB /3"~\b~=x4`??@K2؂u?0pݛeC*Mܟ?nWLu]q߁A;\#"jz/?r(աMrkL;q??RKjb,:ρ2;R6K/-{1>2`.1"1vmuSF @3&tޒKʹ(ƍ$yaMn?0-ƻGJm2/tE͝RG;gK|ᛐ.Ws:ϒ-ދf#`!h9QLvFC0j,@oIEq^B&FPcS/Tް\i:\|Щ_?rHעeFRB*=63u?0FOwG^ZD߲,vi p:EbFD?rאg%qm)"("3⼴p`.55X52hؘ~Oj/5rNߍ@Ž?nAbO<oyc7xߛ(}X7z a!5qHaC^(vA^W&:2Jw=˴q[,b[MMy/5??HkKT9PՂoxv4"miS=7 4S?r:2.ќ!:#Eq_)q鬒0p!C"tN@'pl~pzoAzF`EIQnoۮ<5?rSR|}|QdHob<'Xf#?0XX7fTp?r yWmNwz>e2BTR;t=T|BI1_ n13-F|J D_#xꕳI0{M *3+lSlOB'`|@$Jz#yASq8AvAoDLBSvdT| +#ɨU 4İ;DۺnG}Xϳ2'_ЌXQ;3LF *7oDKy&?0MF/Yfqh4NVY=qoqԚi&U&Y$V>rg`a*S]!cjyPI;`wD(?rΝ[̾ڳqB2u ??FE5 Oy4I@n1؝cjyۤ9?r=XO^/f&J;o[i5┚j?rU3_ZL$=*tgn.H?0:jaAZ9J+C00B6^ -ѕ42'%}Z5>~wP*oDxfnԒ`k,ESS_N{mݨ`פ団2XFͲ\)9yޯ/rTN7N6q%ؽnP??S??eu,>ũv( +/!ٗz7X4?nv7rg5#DO71VVٟY?rU@=T.LT~|M~1RkJȧzbV9??'s 1`%y"SF$N7R0lrZyQ:M`{C go.[]X>l191rI?0A!'`l yoD;K??7??lN+2~E\:vIo??+f$jIh=-+>6Mt1iIrK=Kл8(e?n*05٤U|ҁexjjgly^>+kp†IZhЇij>MhW#S??D0~x*Yobult|_m{o~rrtN8V 8aJi=x6P}لRRfTp<|&NIcc;6[q PFAx~wBђ^/kp B?rJ{ZޭCbH5wֲޔ|kO`dTFUጚ??f;n)R,|:&KN7NTO8 {SY< lqkC?0dh:5Zw[Sn4zo7= 6$65̀5z\ i 5ґ VmP|y!_eOY1Z?0K?ruK^b8?rq]I۫IO.'q-ViyBJ~9@cIif/K_Y, .4ܓcQ,sQ|NVSҲӰוWi7m5pgeDM9jpj@Wgw?nk)?0tR{u)_.鸏KdS0`NcCneP ]0g\F;ΊN +#DˮU?n$\c[%z\rƬg}DI&[s6b)VizX[}5-b#w6.V偅X|y94yƠeb4"t6Uvⶸq !IaTIj۷oƶ*͖g+1&#J|@J7#O1.s9\?rf\Dlz+dFCQMe[ȱ4J?0r)پ-y^MO)0u[G97!/<-&7 xC |M/j??JsgToD3;$ІjhhыcDHQO䂈la7\n^]DlAq"5^Ͱ+xN-7瑃\?0RH߉ǹ?0Zҭ?r9hV#w5gN{>뷲vd-2 cvh51v:W!wr5 ъ1rC!p~AYLCdAoiZ_?r[[ekG~SQ}5ϧxG'[R냪:r;2upy:^!26ogkḡvÙg ӢI-,XrGȓ^y]nP·iP@+fN;O`}1B"zڲ9=ukv!;ψK+FfԂ'8[GeZE3m6nTs@?r-ضf5ae:+2uE_Rqb=b3Hv;͟R4=.:rCU5O;c־MV\R__=fN:#rTjf̵o˴iNMp>m$??Ouxa(6-^*:%|7QGf;Ha IsGY/"~|7x O F*C??EܫW6"=8ْH6Di84b(K'&oA۫G杦J?rƁ۠ K!WC8E:jm~sWg0(;(,e23~DWmSO.Oә};oS:]w垙n ګ[eތ. ]ZDSavM,H^FOv!x+HwGQhf]_ݢZVtM649xisZ)QN8lPOYIM?ni\=V :)Ld|ԁ^Ċ2nƖxCD?0<[RFmU[O{G;eg`<=6_O;?0[QLhPbBg?0?r?n͆Sjya;uqg{}s*eCl>+ Ru??~Z0db(G\c+V^{S(Y.qk|텫X m7 4rU_[YdZ-^[U؛!֦5^G<LH-Yte7k!.9(V7 {hvyN:0'$Ű w?nA64e^_Qk\:7bH߈y aM6*:lPQ!jSLM-Ѻ_SHMQS{RD>Vj2kkI&{A#s8m 6v:Ʃ߾yi`G$m bl:^P56_oj-o[xۯݕnsT6(=7V2 8F$mj4NVHW֚gn:隗q?0R^Mw 3@˶If:M]{?0v_??;۶}0QH{AS:3\YtSq:EbKIiޢ6 ث:s=J(M$Gm: ?n":ؔJAKE&?n P_y1D2߽ᤎTz?nNFj4#+m׵GN,%?r}p5:7?0dѤ+?05?0tx6h*YX9}#:ZjF;2 1G40Wyo$/ C!\­*GTq7XaBܸ[X[a.4ƺ&L)#4X1ڄɭ~|] uyIYlUJ@tOTA-?0^B#11a]*OTпXEBrk长?rB@u#??)y-2\`CA&н70ı&?n\dQ1/E2.R׫QƳyb;h{YuO?r0t_^J&ilJgi0|0&Oqtͯ18_#OcT\@6선[U%*Lg&} SWiJE;Qm_Qsj;iOm3T˨(OoMk[XDyO+/  \ss\‘-j;;Wn Ԋ'7] !b(In»?r0 ۑjZ d06B6?r8Hk@^:V b~{Dfa(0c;0rFg>,2HLӊ*Q3w# t؍BNk>*Y{bC޿=;NT\~ݦKs +#JiN5(n.`hkBҙ,-cAq݆2C1Ӵ7ѣGs ?nI?n`olJ̙484jCT7pd'%d‡sBļe_eOZy&WY+sĞcu5YS+⹑?0KԱgUE%Y!! h_R_b,s`REǽlTNQMFXfҁj[ND$gehkXb\-6L a4|EM]|5-:t63FH4gF`%WNd@ Xݕ+j7x6M|"ǟ~_?rbroꑬ7XH-Gy,NK>tu8Wip3ŞH"a )Mci@?r D(ZߠNN֋QT$KfrBtójO08Q^ljSQzz ]c]ul?0%Ƚ?nҝw<:/?0Bpdk WV0#jT{kgQuow^tdxq/nb(\Q#qU}}P^QgDZaq$z^&2цP~<'yc4lcVn6lg&mz zdztn?0=ZFBmAvS>mR0Am6uǯ*& V֏hz=FuO?0t c;.Kw9]IҺcM{Hj][KVa1u?0*_K#~vHjpUp:X~hwo%[i;%NZa`7}ӦZ>?0OrvPS<5#j?rHx?r:0c{%nkW4aJ6EQD!SSKOw' 1 1$ԀZJ}X(?rAE՚lRwfn3r@X 3][aτZ"~<|tjbqMqO2w̬P?n@;Mu_C^D Bܙ$V:8K1hëPz; hr򨆧uӮz\OnKÕ-Bw΄'v;&b%&g}u|nl[ntXxB+D6TW슂YitA) (> aDX-g.7|Cd:$T69ә1XUX&*}; G]{H;Ϧ =O$Hyܙ??A}ޠINAMK 㛐>??ݖgx 4vt˹6;M3[??%;8v&ȊT2eb2-eRoz\:+oe>ֳe\O)=\J%!E®6?nq#gU7HE7"?nhP?0[-x]qwn6:*lLL9??|ϺDuj*ug&rʶŪ?nCwgh!|4HfW.I4XЈn+,_jvKώg .PO)q?n\4Ԇ'˿[1A|B|@[o͠%͠1+R+iYhbC?riYP쩋X rmDž1[6 02[鲒?n'u3??>Hgk{uDgaiwq-(E=O3;3<:8w}Q m(㪅CE?06??OB9PrROd4*$c;VwKQVo^g;/b"DlV9yË$wF Sij2N[(cF{|Y hp%&F^`qⓃԗRQ*?rzf{fb/*<5]qO2?rKiiy5=k{5_'Rq k?nd:^dj?0հ iUqXjp ?r2Rp;c&ٺohNe]IWyِm"ټhJڈDRL}5=8?? 5$U]QFs0!?nHBo)1^/U'8|PqPtMI8z]4!*#rf&>5?0?0۞F78z?n4X(VKݴ(jYn׶> UE`*?0U%pyۻ}y&?0 hv̌??WL~*) WAԸF(0h+MھZ7 iWӍk~$K%bsbH:k%/!=S\+׫Z< uT^32)(4+0*p0 cBwM!e驇oU l!RaͶ(DՂ RN!۲I.B|yOaQ$}Q۶pE?rvK4m'VwT1 ]99ǵ 3EU=W0"jZ-p) %??:cdK᠁63Mv&ߍW\XërG}/Q9ăfE)r"t(;.աSݚ{W_Gy=RtG3,\PR,֫22-=1XEos)I9*=%Ɵ6jtA~ ݻ:?0R?r-5*g68%l35yzČtN +#^Clk{??[- X?rrʶaGFuy 3ӣZOвn_"̯'e<\;o,,{6,mAcVܶS55{R4ĺ2?ruK)?n>F{&_)w6#חRXQ\ A50"yݓTTw+>=Qw.+w┉XxŀŽ1v,KYct p?03ٓS%urtnL~GAG#^Fbjhr["bS:KQ>U)\/(xicXü9u8zRc-]"ů0v67V\T.KfmM2 R|FHnqi*!^oº[VJfql. ?n[f&S[L_HS!uŻ7ZgQg(ЊKCs(B–ȎVtAwHܥݿfVA=<1y*z$JVz./#$OTC_??hg"U|`X] WR[(b[z+G,:ieU1ܪM37J\%Μܖ*p?0 m[:)~P[eZDA&_iou8 ?n'D=W.;D͚MnQ)+LcK>;z~k{=Z}=_L حhyuiVlBSW6H%:m ̐2:"tGBi'Tu{'##ej1\rԅNga>t0Ӫ p͏a0xgU"3U#xi()Ǐ5Fd5 n%DJ*JUwçŎA `5:#4ue\ӆRd|UDAXJpB$IP?r.Nn,.ZTVRҶ?nxpGmSeHj*P̲|RQ,lS3= Fۑ `DNp Nim@o?nfr5$$Z#*xz 2G|ObQدszZ;Ih[%JG7ABUv᪚O;c#bF>L2n:ei \UՌpM"hO5]?0RcUq2d#~81uP\_~w@J ?0bGa.?0M7P f0Ȉ,%ҩH֍V3ڞűb>rz]` ?0472???0J%VͽtE! k ?0BRczbeZA2 Э_М0|=yA7phP\Kק- Me+nG HQ\?nHwQbu;UŽ$N7_Ji@}/n0r4]FyLa6ذN`B(qc{Bc|cs!}  MCپ!i:Cq@@8ZeX]/ npttL[6Ml qa &*;n]'4xY?r#@r߬"L,LF-!65lɺÏ R~Z!߼B9.hpC]ڻv7J㒭E2ܢӣ/t<^?0Dt6NB=tF_kمB%Ns(9Vu0GKHGZ(AYh!p3;w䮊tGnpvY\` h.?05G??֌yy`$1z +XizWFKs(fT)a]كd h|CX( YWn"/MxH=ˊ7OR-%o)]:l r~ZAf+Ob*I.g|?0c̓1]N*)'^^?nobӻ?0OBt LjFe1eb]4Ɩ@>Uì‚кO?rTP C:YB?nJsµ2 =w/%z3U}ސO=+w78m.dsI+":hRB]Šq |%D;$8Wu#u=wI{`bg+/?n0]͠i?rT<Î&l?r5RM˛Qb"r/BK7THYp9Zų;]S?n>mFl}??R{x߮}qݶrtj(T{k2y9Oio+/*n{TgӞq1i4*z7pVV}uŐ^Ga:Cѐͣ￟>??~EKһh-ϪTC|D{SFotbi٨5~_35|΃űc;Ry-m|_ǿ??ǿ/@b??T/zB {įP έ ܢ8IY8rૅ<$T??8>8Li (IդIH-SN9= {??yc??\ᇷ[TYUfY0\v 4 O??$xH?rB<CP'U47s^ڶf٬O^@ ɛ0 O)חkxbbb2:9ГBXr-;gA?nKETg-H>e?rB.-SZ9??ѺXj8lZ@O=|cMYjۦ'gm Uȼ_e ܵW[ZK3 ΌW?rp?0?n>grv/-ϖs?nq4t?rp|4/h%yrC^}yU\72y$xHrJDž//ӅQ>KVT:H?nɍHAU0vj??H!9Kƽ(p=/C+0lUr}*,X}[utK[ZEvc4U0?r8{)ݾáލXs<i`?0x}hFJymxN?rM/ @@nEQ+Ai hpNSܶwEy!=wt="q} '5Ny[F Ӡq+]??_!i . [hY?r%Kp#8dةmeLH.6(Ta(2NВnWn]>XKRɜMJTp| P>AmۻTPyR~']U`㓘&!=/r|,I[9nbqj" MZ<.sBS hO&+ynڐ LIgM)82opHS1F8.q:װ{nsL+i2YcbWB@@ڜcÒ;QI0<Փ6+L5?nX8j(1/߽Y(xJ)Wm|1=䉪'A8BJv)laڀ,-FiҤ [/fm"Čݡ~BЬWWԒ3/a]%i9I+$A-15u+?nƙ'z'n???0k?nIm)O>.E?r\˱۸ry'Zjg#W)gWvEݗ] fU|N(LZuҮ;WZwƆ#6еƵr?0?0v?0Wxm'[wP\]ۉ(HJCMˬÁ毩Gu!fCWµ?0Xc7+ӜhS*i*`=ZHiFBQK]F2(/IsqW;vbS"U=Cޱ;6^;4")rOLؓT=S.־̔.Ѩl!Fa$P$Y>vlTʤj\שa\BCJm;o~EP"Ғmv??QHOp($|RNX%4ɋAx_{[sp9 gFݪvpkBcC(4")ijE9?nh/Uߪ\ &u_?0 6rFye>'l.?ryNIő$7sZXUTL8hX>='3黯7=V/=(?0Xzhu4h_W;??QGvz^޼ox?n^񦖗VeFC-y@G%!s),Zi7?n{,{K\?r?n:xĈ umEw1QjebNڡ?n8UJ+4x]:DF9 *`NuHC!7 7Xo{2V׫FC4fV͛wɲFY[fckT~rMrK?r(QfQ` ǂ?nγdEb߅ZI_#)WTE=pAl9XY*yU>.XFl Uot\ѩ:C7b˅1??&6=qJ/FbTQ*3U#{gu'}ՑWeWg 5ģH|fbb:-}ܰ}~Ah:W2O]S8RYmOui^ph%Oxatf'14wǬ[%&#㖪[B~ ўexF 5(s"ܶ/gثʮ2 ?rh:st.6ý{>tseMw9ySRf "=i3>ۦ†O"O x@>Ù6[yDGyX5)oKɠL鵝$ (mQ@y\poэnZ#U0BwRL&ڍ4o6(Y&,7?r/8"O?r}}#J?n9PmqBD9Jd;3yD8z$꒴'xKǁXh{J6ٜlUU:XQ>mc؇̉ cY>XvC$_OzGѐ/ f?r#7eIk5Y6T(R;_2??mX(DGqSfׂ0F@[o{wh Oc2^7K^AtA⢢Q-g7nB3!h""jEk -lȇ/ŮkӰ[Vc(ݖ*V9GBʪEB˰K??/eu<!&'SϕsN;}Xhdny]y?nem^=DIyظiw4D0n ͶfR)`\,TV@Gqj(ƶZ"u0մ&cCAmaXg%.P: [녷mjM?rek?n``r!5FTs|a1LD0Hll#6^խx;ݬ奢wnq~+HT.YEMiҏ#?0Zq됡:b؍1)5c-u k^à5?0*H@@L~d (alo_rT jpֵ @sL)EbHy܏5AǶEDU} sj`z Qcm[Uk?nxjJ-9iGU 5\5W+af;ӃSKU\Ʋ?rx-ṿTNi#N'??5_'V_B;K;OR YnI;M%)h+rݒ#_¨^ bk~?0biruJ=ZڨElvB@?n0-89;092OQ)<]B??ia3\hGLCG:W)%LӤӝ5tԠńj#H 9??Tf;P!=a9^* +#3s1">?0@IqԩI,g9&@Ah;?0C-w]?0k?0MՒ)UogzylWXjwk L* 6&u_uhfUԝmv5`WFAw`^?0,3AI郎D~B?n=Gk ߶$_gi??J6ZӮrw:t|Ek\da|DP/o^q t7;(f)a?rh4HS0lG<|QQ%{EPF?num:+G -bXt`ף.,zDhLfpr4hC["5jo&xk;PsS y0vOuO NF懓 J_"oϊ_m{RvKm?rxh8N;edD.&[be-Mo`,"A]Ż^Pzl8Dr8BʻuP颊JgWfTha 1d뫖WQ^J??G??{cVVTobVzzՈI.{EJя/ F{A#݄6Ԛ_lYuZnn%G. ݁w\Oۗ B*Lo%E]rK@5DuJG,/hYfTVc!6$6t@Qцr(ɀL@΃vXR)`7䪬YX3z?n=k--i??Nrb&nI*. AR,7CRt^`~7x`@u.X^gXTihȵB:c JAmwR6[qd.͎F=k d>lWMwڋ(/aLk[wu%bTO#nt*uwn;TFagh-E3ZsA0MQEl~N9ԮddJM}aPZdu~:xK&w^+rH/OߺGy}ÄSϧtg:5MᴵL r9m)??.7)S$a%3"lST,CʭY0o9ӧAn\$dힷq WBԧEګPOA"1I(XZV+?rK0KA,G}A\R›|fQ1Te)4X5?n&D5-- pp)v׸Ʋ[C$K 2??̱^ 9K)0,hRm@yGshgվ^PSp?ngC# ??>: E?0pBCJe{-̉7_vjv'λ+ViZ7}[Lz\TfmTjp[$(}t/8}4,mHq\:3ϔ"β01};P*BcV 8{܉N Դ4UNo؃|}rk2v.ǧ ?ncxj_2q??})mxxɩA>9{1b.(?nJP@;O&ʼnѝ\P_͛$Ų$?r+•1tdz^;(\;%wiP5?nL9E^^F6ڀ|SC{ON_7YA%wuh>t_l/D6fQ]셖mn,nM?rw çP?rzl?0Hmtkk(~bot6lۤ.!7[-kWz)H өvw:8X\o!pV9,(_F5v {f5r?04TLC;㤯HSՃt+{ gy9tp{]T[ԣ0]ș_nqcLQ}+-c׋?n4$)W_&PF[+7|sѢ09x?nWxYb oqaXiYN@lҟkgOt^A|. fx$`ùx`3oc]/@/z`b+܍g؃yF4t7oOS#g?ny@3Wá\e7SyهѬv7'uE6=6f\rz5 h}[]nXkfL{D7GKi\Ԑ}״ks=5>5** +#cY_-ZXnmnnA u )?nո{#L.-0/9GG9l"v 8]${j,{?r5>Hhn7+b3|-h(,rJ* j%\0д0eQZ ?n{_UQ޶7??Ӆ'MM>4?nN+I٫lͧ-cerU>۝}@Y8_]{u5 uH ,_gS`xډd[>dvѽw;nwZo,X⨔u}ƈ'#s[_m;kc ëwPLm|1Z\mN։wgϻ*w9ũ=H~o4W_,|d)*[8P*{SidT,gB ڪd:`|>?n2OW.RQe^2X@q_$1Hf&_=l#PմaqB@b cEқix֏bѩ`INHСDR䇳̿\շpnغ_-0/Nu'2בSVUK{A&ݬ+%o3U\cO|]|_;VL7??(:>޳>MʗŘ|S9\Pp;T8"_<%)IW}78JX/ȴtwP5Xn3dMWI˭W1?nَ?rX~EWC?n=,72\su8P,fEgzp.SZskfC.(M,h=x]4ڻm^ǭWftә??[Dgb=>~y1sMP;XݿC-VK( E͇8ET0xQȼ Rڣɞw(goўKЀF!I1y<}S=#?n{?0+.CE3=)=wWwAt?n6AV`/-?nD]XXHehg0ejg{DTG+3VU*#lP4ss=i#|'&= a"~̓wSDT5b#;y4wɠMO_+??F)7S]Tש&^hFGY_QҞhaw0??+oǶlj); u(A%l,LI#92޲;ܨst$N5/_A?0a],?0ܑ,Gp8bk){^Qٗs ջfQ,Tݫw_ߟ޵^^y4!炚3n M.PKrtrj4 o|S??ecc`)5"v|~CchݓRa|=uMTNjS5s)~bnFx[;^0!{9ݲv\Dx<x4כdOh۩OKM??rG{<'|~4ώ }6??YGDYĢt-ek:>Z*M(~Jg=kG`?0%q_/INK'9˶i9Xd ъ}%O;˿N8} ̎VTַduLI?0՗?? .%L~2k:⨨z_=>??~E4ϠKp]٦TKpq2*39YR&38=I?rK>dB??}E B1O .??9)/bZhUtOtȭ[^>~3-ΒU-a;yOv v\q0'9{ɻU\T- ]mfѧ!q:M"1NG3TMcUa3US>n5sCuiVKG30Hz"?nn.Տf6\RWu㗨y?0ll2Rْ׫uTV,?r.-iFkǭ)|pβ3y8Or̈́L`>YLREfQ 0#'?rLdܯ'|\Ix@gxsjGޒ _f (׮*`u[ňROW(??Fk1}y2j`\!tGSǣJQԩ1,S0\\Z< jUq*6EaA۳[4eH&?0ƹb>{rr>xSL EEY躥H0VrX2yY?nB2:;_.mm,+1%?nD^UEu??Εzkv*Gi'E=b$^_#ϕ@;06=s3rL5<+żxE G뢴{ȉdbQ9c!V;Xgd 7Z\$^6rKvje/HumMeǯY3??FDjBΆk6<9|2)&^Yi&'ᆂ>??[o?nwV(/,:cH!:K<<'O_N[ׄIMgaF~$$2bfPM?r;6aO9~;f1qo[~⨳Wz]Fھy_lKǏR#XmIl%v{:SDݣ?0rZ/3??mHyWZOa}I:UwhA&!3٭G.V!aN<3::-w??nE[S#M`?n9t??~]Mev{6u>uUHJדшlAKkF{]%(ٍWe?r$&|b$yD=vj)~N/x2IJAt&h[o{??($aWW~fg*ƉIc7=~|㟟*8!qݰ˙|ab,%5~Uf{q |͓:c&,kcVj$,3?0bVgH)gCRK7HSW+^Uolj^>_px9et??.n~_=BA|9z??yA70yY??'NH2| +#୞ ;Eʮp0YMYL-2l[)d""5BSzI򩧠ON?0;[$P@'~i(7ӂKƧ)Jt tp̪9fA x)v`P?n2Z-+U{˶L7?r^=*2HDg{u\Wjp>Bsù}Vi_DD~O@_%%Ĺå[ lW߀x`xƽqI3,hs>H}G-.~c1^Yp:퇫$gUg37ם:Z|+NY2$:%Jc7]t]7elWM;iyA@"~rw^;??}󢖩/J"AA/^|,C࿺̏q>R_tCIRD§<,u;*p L}<>i#\Fp.#Y0rL䜵 >DJ-#Y^ AH@BT܁hunL!2+Fo֡8Pg̓p{˙RhBhq%5'񬇽pwDiL5۔,.9MC7lEVGF+O03.V[~i)˸&8G9Y.HjhwnnHuN []6%qfYWbg"t`n1g!VSn6 ?nCg`c&w9ϚױTkW!=cP2%?nR?n)a+)??I:EJSSbjnfuH1Q^Up^9ޒ1N13,.a*kڣnG(gsm0a6ފLÌ m&ek]Yg ӞᚍD1l]T\L??~|KWQ_ˬ57dJo{J˳G/k}??Z??=U??ߓ&_~tK&cߊΓDÌuPQ o4yBz??̝{͂x/\'򱫉},sEa݁y]9߸l8SԢ!`- >Z1wF6Z;Y~+opW@2 S79??-KvO eӤ+)Ep6쀘d["f5trw/Lu3q0mv.?0MZjP0^F?0kc@!iM:K_Ek2cL!ؒ_xd4{,\!n9ۭE1QmNY IKj'M??I4_??ɱ)^=~OqH*3VOZU9x>5?r;?rAQz09:W˰C2 @BנsPp;τhY%KWǃ#6\)lYeV*>?rp@j ㈺iKxZj7Oxs@3[yN1?rXҐ9X6ieSyQ9 }5Xieq›z6Q*yZqVrȆЁǞO]Jk9 nA`rL}uk4?n4aX^kEJ^4M*rOש5Ͼ=mbB?r.G,uƲ\e#[2I<vLJu&rwZ*uG + Bmahpl^PgGuY?r~shY-IKQb{Tu}Hc!ZoF+Df%.2tzpӬGς RG+zP+.X wփvh?rCy%Aŕu5OhY??jw}3Y$rsr~ 4A]X0TF"]C#<{P7FR2!je.0C?0Xx) o^86"nwj6ূ'eW>|@3Rmf?0>HX0HN(2]BP(?n2}À~RR7k d$ fǟ@E5?r7礛՘(XzSZ??un+"و>fJ^qe 6;@C!09RK2LYS(+-:yεKhNFwyG"Fgj,>~>bp2 Cr#?n??8gD("6q> ?nԄ$?00/ES_^9)cT*'d p;wGk$5ZYEV4JO+uhXFG$^='ϵ0Kp>߽_װfJ'[ vOvqhdBܛŒfSLZFz߽dCO~w:??>SRҎҨh+ή`\*t<-Q0 ҳeÇa:^ e09ORrxP)B'GO`6(f%3_dqoG7ܵF\_F(+*$Oˢi?0S;pβ0 xohD?rX7KHx6sg1Lƍk|ov0z+WQ9"B)=LX&v65^?r ?0V')mLz:<]Al,=5=lo??ho@7g*?njN.oLh veqSo`?0zcwtxGnP ʩVYG1qMGqSG/[LIȏJ 8ID[0m,x;>l/%R2{iC+`ƇGa/bEn8CCpӝi>o#>)5dɫPsN`mdcJp=Ԫ`*{u^ueuA-?0"=1Ȧ= |mzNHÕ>H5CxVImW`}6_`r!v~)ˆ8P+tP~R/D3Wzw~wj|"?0pcu5Hnu6R]\Sf(H?0?r$uT1za_I;.=2#MMH?rQmK,X;kӀzJ>s7 " L{\Qt q$LX2/ڐ,YN0ʙsW=x`SeKN*&?reo;1X^cݗ1Ͽ"=&RRs{ &HJ>'Sj'']UÂͦx!% +#??翁%2e]^|*'PJݏ>^+'?rf h+!f;}-/8C1Jj,5l50l85n5$\q7Kp0't2ݫs)G$zJճc,ʹH$|vpD|gÛC/@v˗,+Y2!^YRj*kv u֔E>LG+ujfQ]^å& x??w"Cy6du&Qmޙ/\K^pݦFQ2aTP>6By̿l9lmg@S-To)ffJK 0c\558t=i. ߳wm?0izA ",rLaѤ 5fT ppC^@B'shs{q">d! ,<|^ٳOvws G͸RK1b8W O [:Ԓ%?? L)r-Xˌ 0F5-$V 'K*V[D 3ΥIۋUdmѾ[.ZOu{IߺYYMý[Z-MئWhZ\??Ğz"5cD}da߆"dq>V7У t9$$. gpMeo"*9+P8ע>CFz؏_Fq8WQ,CIƉuN뵚3*ĉ/1Dݽ䘼^ҏe@p??{Շ˯v?nhuh, ` @S"bV,.=L<-<(q9$T(Ƭ+:?0"'] $]M1/>ўu\03,*z Nq#WA{tr|OO#=[81B]wV Rgym=v~/?07WzYD3??/dP@`Ak@KN /P׏w9ڣ'sg//ˤKoC8$j4Nxl!1h[ ݣTuFi6E݊ f֭{I0?r??;U}GCzpfo78z| *œ_qPq_QZ F?rZ4˴&J8XYsM#`Hg!4:ʋҎ ȎkF̢+ŕ;?rntCEv- F"S;=8 ޮ]\M:&ؽ?n쾡[]_ߕAgUN)[UP橊)Jg'ޖxt6&t {E=$(>ĨKb:6??w͒5KGlIϨ3F)K3;Rs]Jo#zd,֯73%˚aF4f+>7t%>xʯ7A?rَ$겑)Yz?nSd ?ngrxt*;XyfTk`HI,"͏ΰ:hi(߮~opu6v {d}qq7QO ޽>x$a+2277gOM ??飺m6ةANrskZ?0:c(c&VÌjuv?0ep!??o*:**a[y7:*;Ј.DS f?rv,R=:wjZ5}}DyjM$"ɍX-E҈nn i.)oF*T%7z!?0YxEf?r{EJ:V# }™dj)Q<RDʐG9l51XSf\;9tCր|+T]@yS{F쀜eSu ?0v|3G}s9&žw-\>ES-?rWRfoHaLm:#:"̐~S?nk:[#*vS]U1ͦO??3]xUrCyi8g4pZ@Ҟ<F`y.ܯ?r/ ,|{UݍShYHqȃ<Lvh _a`R]{n0HhY!%)awzSÉ'ML*q7 ޹5?0h}¡i|"|e}:A7#f5pq.jp O.f.\#k\G)^&wRCk>xi?0%,O^?r?0^Azq7N_?rZ$?rnQP䊝OllRЭi=#jO8߽LAꩂI96u,J8V2]PiF?no)yh?roD1V|ǥ[}ɻL h7IeccGod7֡4-Y` ^< u6.$Fƻm&xևS~6@WIyOw씞a~.-,)wbx4e3%~ɯ2?rb`IZ!_qbih/_cR\ <:w9v ~*Ͼ^zoz `칚8&YXYXԲw&)o]Y mء&N0z#Bp\~s??F̰5V2}ͨǯmj⍘a?0>lOquHҎWuΪj&cgrI=`!I:З20^1^Y=*0??u$Ld?0I #uxLֳ@$'~^\Ϗ??w'Ϻ:KBOʱZx9c7A?r/h]qilxj,eٮjݠ$>uB[_r5jJ2/))?rZ?n\W-P|[mN[ڡ,_9x!\H5I#y9-%;kMΒlsv?0 GG!g&s, H{GU}Hz[C~Eޞ= \;D)xN]siIoU4mPKOb^ _?0YJq`fxH<`CƁ\X%RKPP*:G>HsH:6-U2&p(s,D".EvݷuߑylY.ɅsѨNb_@.N4޲q)u<넣#XvLbZW~N%2!p7@)E{w*7)|P O͒mb]kYszXhQx5/UZ1"_?rԣYٶMǙ.`RŒ'jnܚP.Tô?r/D?nzӯAQ.5Թ;2I PJ⁀kDLheyl[0?0a`$C^Z:*·@6ޅaZf[-RYfPrjIx<]z|җc?0\,kmPW>-!dllV׽˟(r(0KYgjjW)-Jz;V[lN# +#V{*xvu)Sv[6$<T1ϰ|rV+t*~??U?0E*?n":|~`iRd Fls;X3`tG` 4*\{*N <孩E4J8-iןl𦱘{>fv$"Rk\wcl &CM-Pt«YoXM2MiFnpz؁pÌ@BD:5B@ي.ܩE[y|6C3D ,N5ݿ2KK.%Sr{ :ZyiK{p&%\dNY컟×<ٍ&L=xHh$\i&*4()'g@*Dh.}0 kdnFI[xKŵL{F1, Z}Xq,rt)^Z4dן$#5 VfR5nυ>Gx2 W{^{2g}%?0UH$c0v%Wm鱄]?nώz =ub?reJX3U$K8W7(TXo /+u" 3n_ Vۯ.aKXJaS=rW%eG{i :M.iFTCb˂A踷IyJ'a{ `w^?ncD[DY1ܵ>{%1)=}/Z"GA^[o{܊~?rܥh "Uj?r`?r~oIe)&c5ڢuw_~Ȣ\??mã{?nZDV+}" _eH(&QAZR8??Tݥw!%.fuꐴ!yh#uKG g/bѤ3b;zy (>-/̡$Җ+S?0Zc  T虃5(0 OU'j܌ȴ^şX>nT۾H?nI??l)Rl%U*iUƅ[}b6EPR7"|?r.v6B)ri~zKnGm*m>syئQ1f,T+H}N4[B ${ b==˘#*#;VG0_BuWѸH2hSZ1a]WJs'oUuRCJ?r+zdrxG:晴rD*o:?rPz-#R.9= ۝C.Rԭ&ZRe9+NZ{霶p%aFЅcᅒa uIMo+(FI5͝+q%J Lu~mHNs[98X )Nw(UvkEkAe;?r_S??~?rP?? $xEgR A Cğr?n1P_RrAxE'^F(,ke:jw) \Eiq ](8t?n. T, N񈡢-F-9ui h#fw T 7zHj^??,Qu៿iՋDYPNAK\G1\ +m#?r4IDxЅ KM俋@EjiQχ(PNJoB:D28ˈK!n#0z"γָԔ8= γX͸!+\i*Lo:3I}bjW}@h??2@81k#f>`NkkxLTAiilՙ D#i㋻HācN+XHZ$NYݳj1%11)-tcaL'=1qeOxFm:ex[M7,d?rB^hGZʼ4z֚%͗yVKs?nSN ىIdkd}„;^dr$ug7+H٬o?nخNUrZJ*&JN})zsg(.x $1C[Iv?0z_D:gzsȥ=8fhZYNaf~ G88Ul%«p.BknʖR121Rߘyc~|0fD~r,Zr:wQUK?n8J(_lB׸bY{b?rr־REM2+p~O\JKSuGHWŁ Mk]jgpY|B@G}E-H zcE6-?r=>h5#¥޸V~*-qP4);q~.12$,|I֏Or~(`/&a'?r9g?ryoKmi@J~tj4 :@/q"KkbI\wB( o 5Ċ*c-w*6W?0?0~8/ Up!;"Ɏ;ޓۙr{%*Xcg&s>\u%O?0@qzd-,?n@P(?nO7-g6)[|x<+r,ƩݿF8ugGȋ/؟Φ9'ңpO3w5={`ޫLqU \|?0$k'>E??n҉zd_cBB7KZϵd).1lZg3p7%)u -wOy'q?r:Dj swb[:9pGSG Yd.*A.}QPx&?ne@ʒk^}bf)ʨ_0ʡֵ@85{0[c|c(ßrYo "RlxnXI 4FmߋCS/f^:ܧ&}Rm_xwPm@K~'t<ÌVK cW~vf kոѻqX.M(3[5nAk&iY G6pہZ% ;O)h/OQbu$]R5 AP6&hMIH nE}6F?nۄ6Mpr3[f2,n2zƢuӺxsI@ ͯfi@q=w?rӹv˞ #?nfu?n ZVKQ"B=m6t.;s^mܫH0+nC +#bCпq3%w"?nm+)ulP-v:e!̋ X<|\X}ˁ\hD@F JϦ3:.sl8ʝ6f<ѥ~ʦ ?0눂hbYB΁{rk\mdPr-FGaT͇Bϊ,{r?n܅PeVR)X-VvN|?rEM??@NSߢ27^t t:vژ R˟i9eqBsJ?rǺuOt2yM wE3ߚa~x~ܪ}YgRVş*Uémhn7QpwũMM7?rkگC'g/|X=<`"Si?r~p6q642jÌ.9 U:_ho+0<Z3݈:ۙ&^tq 'rnQquTc3/8Dt#_y:D/D!ymzvI|soȁk6=G(ugHzaQq`xo??ZpQ^d& X.`'4*nBuk ap(y{ԣ(Ng3ͷp=ǣ5/V?rfW(0C ̨(*ІۯAeH`V( xtv9&KID_.[et;i<2::gŷh|LA[mdh78S&g%dR䯻MU|:KDly:%h)RICKOn퍎*;\yin8Vޮg4h|T#d {1Q43z Di=J^4df?nk?0uk W3 k%Mgbow`NfNJD6VLK${cɍ4z/~d8d&ϑN6#xF,U{l6|?nX:ɵK8Xi~վLe9?0NSAtx_yx%?0 Z2?r":6T`wGxy,??sMWdR' :?n`RHB.u2Ck1h dZk?nsР@!=[Y 3zkR@!,[?n??ƃ6%=|v[hQGuVceK,KIo"0B" zȈf{O,̃.@wg$sN?rg&>?rQEǖJuICN$+E)SQ?nQ*TׅG?nAǺz"0FhQX9f6*0`BҋQ/ ˜`Ă[d]Q4Q-P$|Z{$)U2@矑.Us6('/›6?0}L&N<9vt kLbKX[rRue{?nza(E 4n>sh|ԩ$Lw17rK=yϘ  OK㣔jv1NZ2mt-x\5uww]8yXz3&3cp?rmH,1c{|D⦧81$rv_1b'fժ^T!zEB8XU?0?rl nle?nmRLؼaW?rӀ> 5. Y7@F" ]]Ί.^J2lxTEF8fH/h 8D3a4WNBU;^Gc Kfp ;nPTOkΉ\Ptn*?r-B PT{^?0J?0W,7?0qP)|P_-Y+PU6'38l15B}v9+inmyFW[|J?r?0ljU!Smjc[ /ivHؔ6V{t(Zy1NT L zLYEz/*ͯgr.ԭIzsKomrJ\0A^</7^rkBֿd`f[jPxk( du'C  Oo B,yB?0?n`Vil ޗ??^mbrR^an*2_$Mw%0yo~qGmXNڴ/+"K>(~,TuA[9uA?0գ#GߋktD$RKtKYi>n +#??#~`~'׷"WD EmGEŷN³?r}Ht>N2RA{ʉUI_7?nu %EyKZ?nYL1T؍eR`ҋlXש rEJ%wJ^![?rF_Nh4exiQƪO:!x6ÙKGCʫ*?r^n !Ljh'AȻqo_-+y\m!ې֢bӽсmWcOTյm,oi2aM*?n)Ķ-8ke1;5\51 E^??M }09ڽsY9;FcUJ^A6h[x!Sαٗ.z"n(s lCPGllhITuZqE^mxKD/~{S*,5:v6Fo ),y'FZacaKJmW_95 A6t+g)?r1F^kai jLlgThn0T&E-Op~7%IuTӆ7 mJ ?r^(\㽧Gǽgw?ny:Ŏ,G6"՞w|0cBV.*y6JEav2(Kߗ) GI2 ͎BOI Kt] mQ2#?nS>(x΀Iݜ;OwKWU8t2'g#t-09ܧãB[~"ٻLXmxb[7o#@bK[UC[tDhpm)84⸟δȒL/(*ePbqkq:=Sޥdf‹ay嶱zOng z+1tPz/78k_/˳ "{Z?nހ"V-iϝjI/ qF5 _ԛ;/0 RIӣb)%owz ]S{dZ,ɩFY,KQ!Fd%^vy hxlbVns=AVZBaIY|oeS7 #F陸^\~N3.Hp]s}N[Tj:p g%ŷZxn?n??mgjq9xVudFygC=ţE DD1N'2Bb:Q(??. Stm?rB_݊?nǣ6xg0fȴB7Þ"qѭ{?nYI?rWq~u2RWlf6Eh.GîR.'lNZEiA5P, 1Ia(u̐8!<4Slz[l+QQzaQ?n34lW.f)wF,U&dѬqSө7N^RuS*~L%~BQ }>BN+^s#QW#t S3,n{XoSa,s6$;b}?0 yx67?nb dqJn4,f]4b qpwS†5*vCm>3p^mC(@&Z?0QsKo~Dg[fhw4?0=fj@3z8%lMdTsq2F(E+Y\_yq7JYmR/*˓~!{ WܜyY ͹4&[*{TȢܣB^=4$1"ɓc&אZi].*}隑n)6n47`1fZe E>?rҮ̍: kDlC??t`9d/&"I5{sRty+Gd3Rԥۣt3eeL[6 ;M,ƹ%*yjQR_C.hFuz⡩e+Kcߡꞧso ;??E(zq?r*T.zn쒦t[8iwhOqtďnѥIU;[v:?0oF!_Fle??g߿HSIz[ 2gdK%emFtoԖsI6B AOw \*{/yye,<Ϯ.Ov??>Bhl(c T#E݋=|:Y#8>}H|mܚc>>Avi;qBPE2pxV&ыWA%Z9>*FU?r(紺NqeUB?ra"׷~J0CX;?? S)Օ61guÅf.g`Pa(ĵ svu(R&b?rlɄ2_.mN1^Fc(v}2ǻ O2 EUK"SC&ACpj & 8V(dppK6[o'wD?r$tuUh~Yhwj>xV";k5Ӆ <"~UG"*9Va6Ԝ [(21[@%?rAotLR42$b?r~-A`F[|KFyK ^7nӳ= 3?n Eug=`p` H)A9zwg)ǗzqOQr$5;JJ q:bp"&S?0no7N)0jE!L*'mS<ن B ?r_[gS`3A}ʀi??Tz4`GYta2fw&&)Ig)ASn^r{Z%,>yP;kwA#,i+ׁhaN@M/gh~k'#Ϟ)JL%]mq&})蜊 m§0OF7l6DRu4??K-_ ?rR +#feEvNuV"^W('d.@)Ay%dKDKu~5TG ^5LxNߊ'??ZksdFNyzc48 `^w7=J$z-Ku?nJc),c;_QQ<ԷH a*S\ x^:+r;PU1P瓢.|^Ş4}yԆS%HtiQ@'v"X8*hFA͊2.4QWU?0dy??RN L'R1cy%,??80ʱ{2f:9E'Bܻ jUl_.,?r36=LŏuW|3뎹??R)hڧo$ɣ[-1J$7h`QT??EښqanPl16S50/A~ư:⦏4ȧ=Bt}??RJ\ޅRF_/Z2wҳ\2Cc5E|* 4T}!&M}/Wv??֋rcmqXR-K3%9GcYM?0W~%6d=ux4#y;;1oglQߊ鼜Fy&hG!a?n~]L¢vn#q:~iv8MIQt /??zN_Ltvxt< QЃ{9JƈVJ*I*F\kPN0$dFDW1UofPefjБ+{{zx%oTt\W\Dr`91ůssbLԙ>zˡχlo;/f/VY~0 Y`$St@UP.&lDsކ[TW513&Zm͇ViUUOYDiXY쬊jhXٚzZ#sFf??2*rA\ ] M ƅBn 2J\8}]'e8\J5/g ,LD]EOj%f#o %?n+mRvrusر (dкJ|hq+SBeզuXT*?n0~X&9H$fTqq\v9W}/?0k;LjgPOَy\#X<&C#4"0dsn u??,h W:jJ {kpP┡s&ƧDitfPIey5s,[g* QDTd?rή;.\,K_z@J=0w!oV)gex\BvRLV.dmNOoKPܘKw.K%7Z/1r;h.xI8_qfVNc 4W+\†??l5n] /* B?0Vc`AӠUDX|lvJD 怢YbeoHR)%5բ꡹γ󩾚yp'8b˺#:)sDK˾h:4|Q7bɲ_N5#&򢛑"xu<&SBp2jHМ [d??RvvQ7>#CeJ×0#xFv pz'r$Mj4Pq?rE>73ZWݏ_@t[Lt?r\D4+rZNlpch+&i3??yW1pv9Tq˽{)9![UO%ֿ %Ec"KW7"$ Eްdu=?r21hN֕KЈ$L슞/֎vLbۂ(OK里9?n\^!x Rck*?r v4Iq}ZO"t-RIDl}K +#q7vrxtxo喇??VmZw[Vcmoy~YX 5?0ZydmGTvWj.j:zv^4in5~*%?rWEЌj;pg OdTL*G%ox!LN4xw??aC =^fʬJS3r50Y|Ij/>kW,XjbZ./m]&xi#nQvu[ލ=gI<*f y$9YdfCO|E(ܐ7T'=S{7UԦr,qFd05g@ SAl`d*],]kD| hi]V[Lx[Ǥ.zNǸoMkpzu&??'\p|U;ӹd$#Ub?nSd??ErV`<?nC6jIq޳v{)gue???r Y'޵־??4E?0kH)Tx/uګ;$ 6ęGURM-r{\Q_ 2-:5'n7Dx8GV&8ۃ'.hOh68b>㭜|;^?0?0W)u&'??pYQQP\@mb{۫t?nvm8Ս#G4֞hJJ3&4<>Bvd Ƽ)|S> J}="=r-RIRaJ=s$De#bBqn gX6Kh@.\6yܑ2P:yC?rj88QԻ0?r !J֌S?ns7I>J|_xi}aQn2;n<%s/致nykyė#N_d1Op4ϒAIH(=25I__K:~g8q/ ;M9;E] Cۏ$,f ));ՙlfBCnYzv9OT10'??X(>OM0ȲbqJp\d WZ`diiϻ픇HͻJY(LũtTCrĕRy\؋?n?0v\Ĥ0y C/fb?0g?0 ?rh/X9-(Ic3hxۛG-0FtrB#\_P6 .ƩՀh ef*nWY twaP/EVד9f/{!}_k8(y{<"Nq8dn.8Pt*MghXGa"'Q?n'ODSvWr/.CQkL]jp -76ֿ8里.s+^K.mIWgY|0,Qd8NᲫX41>oz~ڮED5%s\r:jhR1^ɀ^N}hAH-*RqO?nZBbɠŻ_`cǮM*hDEyr]s̘MʎS7vmnpIB5s7"QR7fFQvu?n1.gf>^i"NlAǩW.uRnۖ=|jKF,éUGJ/*DC7~??q!h QNe#iWK4U|!a76TT68ΧQι4_Faq4g^]D aԈg03(>K#?r9>isq}_R-(;؈fL%_0wAYHAߵzh.TPGm0$7 Ki#rjkXFʴTR)Ho=3tB"?nsŧa"8u 'Z֩J֩#(q[aUZ?0mwxczeQi"—}.P_ҭTN<{n%_v_lSL-yq`%>T4??{uc/J><<=RdW??hU/:KtE5+4p$H˺)m{3"- YMYU4-]=D#uC`~X\%.Iq2??,]pǵu:li+f?njyznB4R16e2M_HZJ5Q7O.ؕcV^Ec6g0VwlU>x?0<'Px|6->+a&<`F~恆~Vi?raYQCdPWg?nW'|Vc/(??t7nEjL#Sy5xIn6??~:u%4D\|x~P")ZKbb?n O~VRio#Y+jl_Xp߱jU\-(-]W:͘/OGRU e-GL6`R??= Zu9 \;g=3N,)'_s4?n*7o +?r:ÎQ8uU:AUlP+\者zǵW׫mL13sJfq~(t"2H]BF?nk"ntɼ)Hg4CW㓵ЩUդ%UF/+qT!hDdd߼/(&QQ8܉ǂbd_6g(AR|7x$)H`|?n`.(\No?n}$]aw7r/H`|``.d(3wӭe6%UbdJsyu ʫȦxySƑ+1 W҆X7\f?0nT/k6_w_;]AAoZ?n7/Qb𾤌MrjL#r&R7q*kB}&&$PC74Ee?0iʀFgRrքmN&M'9[kSg>o?n1%k(, m pI)?rHAst9sxX/JfWXLCt\|GәpZG 52ĹٞhMR8>P`z,ӝE’߈!αWSBpVH/P|J+,qP=Z-Q3Kf,QBzt[EMԲeȖ,34~'fjPe{Q)yVz'U?rN >/S[ߵh"ԟu"*-U%q-_މa٧7fkrt/jkx??ӹpsˆ[ŒS??:[hb-wW`U)dzEY!p/PPHwM.+/ZL殺`/SA[j```B띈0D->41i=(S[/ej2?0?nQd'7 u?nx38V4}ͧSo4cu:?nšD.$+fQ#2!Y% yi+O?0},m-aP8W}oz#t_f|Uk,rJ'sMI3+un?01w?ryHAW?rҸeMBP/]mqSJ>SeFV7rߝ ҫt6 qoaR0b*l?ntpN ڛޚL{XsfZMw袞:.hd87GTRڿ̲dp~qRUTBOSy9B۶7p R2.RZJJ{CC @Б%yD?rU;' eW[D{`rUA1xW4xAQ4A?0G59<ŁУ&][48~8]Y f[dڛ_uiƖw_R,bO_nkEuYIUcIYaj߲X%(*m?n[q D[mVԣkMm??V8jM+"R??[SBQ34fY:B0M '\|M"O^d3QAB%:N5!>3;QwyQs]ǎ\bWסb`[Ϗe?0fȭ;~iRf+,MjxZ#NUrRvpL??h Z W?n CG텛ϼY<ŒT+-|ՙ|^cMO&3g?rj_:ihor( SOhpae0EV%TNa;T:й>2WSD^E|g ~+ >xL?rϩ#/1)Ԯډwj/Z RaDH2j׵N'[~?0 FEwtމ>"6YR1i+U&e3D|r\ǧ??<?nA_m)h]rա]PL1^I&LI7],\)oAxNI(*{ژ*WBԝsxO T:zGGo_vb/rSZip߷YQ)kXW눫$]}8nխ'F 0\DwU*E:/^g[D;w[-Vnۍ!H^Gr:*fq2RL0F;2|Z_&O=Fny4=b._{渉 Yv9Cm?06TZ GI_}&1X=eᝓ{oEM/'?nfݹ7S-UOro$/IUڻ7~{N$9j@wH2tt"20n)#?n;##VS?0ϡnuiV<3 =*Js=ܖQ1Xg}sͅA/ς"x3<>irگj'v$Cv 83~yNVSU7Aϭj>Ijs)cd8 !?0f@W)Mɱ$4ݥM 9Xהr?nK?retkIܳu53l9??iNd,?nc 3zJQW{uUQ?0u;Knԟ+jVyXy8nDѐx??DƱqȢ2,/& K_=G,?0F'o*UIN-֙!yk8gFg@ D/`rقG5s1ꟻݺs9V|EF3߁ ɱ|o*ayxvݵ6?nw:NJQeEp0{}$g3y.Fe?rj`kIهW_%vh7hSנaBF/ܝ??R????lm>Zo<*¹u+q4>cFdk8W:uf$y??%=:Q_|+]mN-J(w+8c3}v< Et=xLp:C,9ai'?njˆ!U%4t2B@6Rrf!»"???rbKRhKJya!(u'0V%EI%)Z\}u%dt&܉Ծ2Sr;#_gޡ<8he$[-}0Ϝ2(?r@A+7ٽFrL5U[K/sdJI2QLb;i }y#[d6ĉ+zGҮ"tӤj!ߚ c3qBP4/x(^II{@ ʞ+IEٞ BWM d\1??7K߆%g_ot:kc﫬xa@\&:pɹY/g{d/x01gڝ%D7+w}z#]>Yoy??u֞KZoC\bd0xOދ˄4Y|>[&yyA6]t(+YNbKKn(ȜLRZ=9ColzWC_k?0=jUL@;6嬳i^s$gnHx913dߟᰰ?npϓ1,&Ea3f?0Rӯ+_dڔ7$7/n~ʧ8zȞ}IL?08[Zk>΃tN#BT7^W$-3??<= LaYpLY De\R w<]&?n?0d>Zhf#ٯ??a:&םS[ZD.'G~qyKl2b7p[;'a'Ku]U?r(SA6-0 u+&Kn6y~S?nL0£+_ 풓 |!b#j??$Tٝ??uz,(HqQvO!jRKd`'Fƒ;IຟPaOUz&2BX7pu(G~b?0ocbXx;9tuo:ٻ[դvJ؂3i|4Y F2R:~ط(?n&71lxuQiҭԐa 5'Qp[^EԜ?r@ %ST,eJ,@RhWA(AKI?r2!b,~L)u95:,b TV`$?r6Vj=@lTvk je2 a h1 O9#;d R-?rUh]9qƧ=&CYP'a$<ldQrg7]p>|l!to]_;^jQ??M e?nw7(Z=D?0~;\BK^BWiTDbTFek`Ԋbv?rrTK'YSUA<̀jH<zeA\qyB.8M¢pEo;H!n#{wtLKs=*A 2D@sy{qez[ܴM^fVaǥJ?0w}@Zz?n%6S EPۇYL6se7?rE;qF6]a>2$¦* SGgϥq 8ׂŁP ?r UJUI'=LVE\pB mH[ C ^¯wUZ??7~ӲJ}$IV~`C T_)kVY?0MnW!%[Nt|FpYo4]mi??灍l>m8 &lYZoPb\ʩL?0ہ~ k9Y[;RCl~`CHt1B {dD)ItW#hQhR MoM8{)E$q"t5q2g0:8kGQ6۵(}l;}l43 {z??zlgL_v_z; D>C!_vq 8GGqDGOAZfvJAq*'??A){?n6mR2rpŨ?n{O$U]ļz&3z۟wҹ^M;/Zt@ NM*ݏ.vV Q`,Kj>OZ6)VwW,x ^HLdr;sMT<\{J(4{p6=zA2yMA?r0K{TFQ%r󩧖|J9EI$JDYC-)PmIӕAH&fY(ąGW78xG63S hA(@ץ?rGE5" z^zQ$#jxny{??\$\??fGk0BٱŅT䵊fvvF{??UfAB. X" IC!YUrQưsj} py$Zq[ĸl/Q=~S?rwsKxA_Ė%UjRG"Wb!P+8Lps(-pPOmû_dw[nBlGB`GeIS@Y皛dYcD(MC#A1M8ak"qeH[k#MT<@yvF1.Y}YO7p`P$DI g4#v>]"\??t%5pLD3Fֆ݀wj6,/D򋖲&5ܒ[4Eq=!FmR#5 #h$/uYdUp2(cʷT4-k/ oɽƅ+%AUfY<ш( SRpzV?r[ snҍd\o-=O՜-ҫyFJB??ևq~"pv\Xf;5Ŷһ'#?rp,=3 .HD(;&AQook,FInbX-|UGiʾa*Ո.b(Gtf4H?rr@?r<$-)X(KgMU'IaW{R]FYRH$FO}lTbch;׭Ųt:#FiPc̨ +# 2ż/pyׁ&0 1Z??Z]_/(Mk+aamkVU1L:+N 7%krrsJ,,L2?rvr}?nhD|^C:HR??ruQ=J,<+ZmL˶V?n*,D??wV+'K78CiHQ('-y27HP 6#/](2m9eC?n qd=g&5oy.e(}=!4J2\}25KBVȋb D#%R;x.b>{QT$[GwWL!-ggVRX^npѵU8h?r1r 'I64y!퐬ޠ??eQ#S7^+eJei9 vݑ7?0]cՒ?0eP?n:Y-eݬRG>re}V#jޠArMnkǵTҪݻTХz??.z'jNچrv0m=?n:04E|=+_[26/!]զ^F ,Ӻ_/kf Xe]뚧#ǍoK[]Y\򬂹vׄk9aCø%ډ/@cfz?r#%8gy0&\=܄5'ZΫwjkKUYRWRm.NLvEh8sӞR,8`D2mL$f7λ#X }$#CqwAD*ni?0?0"c`sDYwMFI=Ws YPnhq[kӥ[U^ PIYKD7ר,qL*<2EW=`2"[jZX?rފ{)77_kg?0b8/g_{rLK$;N{68O&IlǫCIͶDIJ<\S??!,RT^Im(?nP(?n{77p5EiP;44o;mCm4[ciCBbZxڤ[}NmF&Yoc6y RZrzPIZ &Vq_d][g'O֋{3jH^?rvVdwvXK:<ECKvWnuWCrXE?0M{h~ ^$g6b4$p?r[=윆78$r G}rxPiTX7E{ eu!N??w窱`kI??a~e(<8<< ZY*m{uTj?r'Ҧ+&s3]meOQfx W޵q0tʝJԕA#hs%DQ #g&⤟?0jd~]PrDoQ4J?nYa/rfE4}_0h-,ZFL,?nTh2Yz-MN=I™m14}ha4v?nR- ^??xWaic#XY?0~~0*ObszY&??v=ޡˀqiu+77qy>.:~5rr!0DML(+)j+m#NlnhăTmYd?0^d(rM3 fT\m"~ Ϳ?r/i>66Q'mWveY8;a;I-6!gH@Y!0f\O%?np?01D+oi]75s;7{D{žat2pl`ʈb-?r|-lTP-a2?0S)e)RV+n3s6{ 6gUIrZ{f<2DS()mPvP;Æ\)k:MZ +#9*/_]$O$Lr&+PtxBNP'o`_dZ')Gm. IS_&HwAgϣ"]'1!O2eEx͈n#y6 8~_,'A??'U"p{roVo4 Rg{x1oo^gaۗtn<++G(?nBn'?0STNc'dyBStseSY.f'@^F&k)6Mv\e@_Do?r-uix^qՍ\0 g.j$\UP4JUEE?rIqRfǪ?r1_rbv*+QՓzQae/[/fԼa@Q&}Jd(LqJoĘDx?r"bcQY 'SP2C{av]3"tDj&Ru?rXUAN Vj#0:}@rfUvs>vL hH8-]L༊lre_$[M٩^gJKɠ⦃ȸڄ(aJ r;s!Ɩ&Yttyvx;L9f3hj)`=S oH?rHy;]*7>y/,MؘjFƮI[?r >KL> Jc+6']Z>t62S4>51uqPM?? I,Y@7S%XCF(9H':5??Z߶ZT.??*F1+2ϧ?0ԮU.b>Otxlxإ=񟔍RO ?rن;==~N&IHq&"u:G9=} a.^^yIw~*JDXrZd^a*M0wG*'F"_s%"W-0Z)X>Ob]o2;}OY8oY4 $`-x?n"?0a#<$Dkװ]pk2d̷ownwqgI]Ɏ*7uht/(ηa Q|ɣ~sхN4ɱF2Wm?n6Ͷ}2NFb< Gf58eLp;Y>)VՃ 3>~ΗW$SX6G+_Na'UA0HF}<6?0Se4y9Ox6S/#osQ:]3p 2C:pzջi1!nLůD! t3v~@l4ܕZ"KPLJSzesz.{Y>{^Wng夌piONH7L.%jLZ5U?ngA"G+uxtڦ;0?nΒ=~Uje@k%B*|frwfffWܱ?0uruX ɪj:%g))/MU)(qe-'qtud_^绉xYDgRGya 'n$Myԍi>5?097*uYӓ'N3b!eRu4M6]-j$ي@*PU=*;187wŀ,q;z-/ *OMLDt+m?r:QS~wZ[lEN^|Q2^ͨTҳn:f?0k`?rUZSBX穪"MYw(]mckoAj?r(stq??`hډq>ݚnuQGַn9T}ojd|¥#?0r`ߠťƹFhk̺UpicߚB_zۮJů}bi=>elR?r-LuSFƒR"nӖF*S&SR(uErH9ooF,wcf\ 5؊(XMĹ?n!V??z9Z[ny~ѥ<]e<2sbto8s~~ v_Esv}D!8~+ۙjL.WFG刜zzD,Ф]|GDtl?rik졒6U@!A.ODMKԴU;[191trQ8>~ÕQӳjCAƆhhJ)&NYhksg])[6Uъ9?095˸5Vn en@`;K;ڹQ?? .Q'!&'p6CuP6m?r~Xϓj_Kf xPhȶXqŚ(m\w^MǞKP_/Qҁ:[F8KiDnB4PE8:;=*]SC?rd_D^q-oo-a@ $?0 C,??+X9ai5f^aϑl?nOu0(y0~1)̠1a^Zrc8RE'(??f,K h?0B2I6ŇcP7YC®Vu^\dv+o8٫]gM%My1i1uc-%e4R4fGGӭK}W0]bVKJ=CW7RWo?n|LĊuxXmZTua0˜9/hh66%C!R=,fG]G _f}妼Իf`%󉷱0fF!g|hgP]c8V#_)'?0{M)9?riC9eSmm6ZmW\Af475?niGARd,'XoR"z #s0?ri-c ?r:>'g|Q?rcWiiZ5Gc@MS3-^GDz:KYmPL&rNuv;zpu?0kut ϱ8 +#{zqx,>.tYFK'aV)U@2ߤf~^:s[*ې9+Dx?rI&TtWwD3\ZC@㵖t$9/'M9[ 㤫2xC˒4Nڡ[,ivg։{ܩU ?rZ;t՗VI\X,$??z??/^c4P,iHM.o+L꧷3c$}$1.A\kyyv9(JսrdO6UW4502)b(/ÆjwLyJ|zB:~+ڤG2&%QC1GoLBB \\S/?ri"[.0/yP%1nIv%8??nD]"fq.הW;x8Q0%YNc9TSӂ;Yzvc Ш?rzK5t!`v`^6nsjmᅋ`nlk?r??ح?n.L9 MJ4mbL9/NDi"nrӅT1ZOQLl$ ްέ2_ls:8:m݀>_ܰפeMD]e2{pq]P˾PfdV62Sf2FPoKvZV+9;'n]єG^>%F (O:SL9.H0'<9?n]"m4}x #9)w%/vf8,V?r"9-M<~a%Qneu"0.4j*9Kkj??px`Q؂ldZ*ׯKYp㹓q%\ jᰍj'W'??b"ێR5pi Uqm8<Ӆ%SW+^\O̯~|wu,7`4?0p{7|+[}0š­]0>%rs_T&??rkq0yNI¦28I"*iJ]fC2ĺα}??I ZH,K!cFL7OPW{ÎIw?0M}ms!Q*ER?n͠իM7*;O@;K"2W{Ԏ:YLmc!P|mU?r]O.`.P4>L*H#_`pVʅ27De5%}EA?nH()8ۍlE1ٷ+nG.YDڦ簬U-ZO?rNKR"LO.-˩aT5Xcz,`-RAUywO‰nJaTG:e_?0>e173S@7.DE?0vQC_Ǹe қP^ãx&!!W nԑw,8ڀНYݺڪ$\-LQ)BPjLoȈw^Td>[U]YYp7htreColH-@??f˚`ybTƘwå7`) -ml?nG:U3q[:dK|i +#;;^ly/^1to{Yj'ɣlj0֨>8J6m/d--$%2# p~[sU"?rE??ns;7ѿu+}#\aT=ӣLYذ62kdgW@yIµjxө5dAbc кXpVX ݆ʅvhUUN"ҔF\RO[ɛ֒)cƱ#9pWJ=u?r{6f݁ ?0.!zDGԟ_VaNIjw;$3Rs\@47ϛbIZH*,P?0Lo5* 7kz?03ܶ-|Wj0qxsAw~&d{PYE0&M9}WH!"c'ٯ/4LBw6ye\9XͦlB?nxLӥDxj4aS׳??]8oӝVmzȝ,R-K EdۭRk}kd`R4M "XT#YC}H9|Q}9>?n8śU@i2ϕ.:r`Kܣ>(ؚĞE??mdpfܿ:i[VH"O!ڔEv=.)%ݝKp&5 Xa-bK/=XdY`+ !bguuZ\+Z5EsN}GlhBS Elcm"71Urr cRLdj@>??u^'?0NwB0xxtr1"ܭc#Lx?0hͰ9M(F>WA6I4φglNpYXN !GYWt ~_|vDžE??R5v(?r T; rl {n;hZnR5Тt65w>ދu{?rd{?r7b-mʑ5mj5 [[ZXi$]+c>I m{b9/`}la;ǥ+H%60?n3(欢"e5>+0ZA?rP4(W% UExPEXT]}v`,N|B?r9";EE#[W5]܁?n=v#ËB5@e{g??~?nE1j|qp~] Jaw^f)p4SuREGL툉ИiXfy>qs_Λ~}+AyF+"{<¤Aid.x&Z!5 @x[΃WD[};?0 Z2p]&SEQϞTVT[qcT\Tጒd.pwC3\F`L7+gB7Qm^1h8҇=Dkҋ'?0tfc|Ee % TMm5kii,vՋjhDpi4,bDцqGkM>AKUo=?0h8G0|~GVjʟ:L\tT%T(KI2E}pC7Ɩ0;^KuoBqp_ӟ^WH?rI9z3q&Uw.ufTNL簋@*bJ xSg_=/?0ъAo:I2/??XZpjڻ'}SG33"sKxt5, a`aLiTҋQ&g"Y̘JhFRR̽?ncY> Bv]Ew韭;CU/yo1UM_?rI8!F8̆?n^)=Z-ҭ@??M.ԛ_0#O;@Խ%g$t!cRs?0w[%yhi)ھh}&euiT^FTb9}\ZmOQo}0|y.#n^U-n:}bv}Bn#|ypcr!??+l="=hzZGVG?0:C 8+ g}ŦA6 dqD@ k\U m"Χ_[$3wGmN<{Y/zcLڛd훁m ,??ѩ;dlzmb5:)ŴD$?0I?ro(Xafjg$Π0a<#|TnHph@;gLO#/| ~m9V%eUQ1]B°ģ[Fdf9kAաE[dٙpKi3Hfv7:b>׌y/52:s a&^I:tҊ uLXnPę'uvɪI9{$?rO* &Fetaڤ#jrI>qY U$ḼRK&Å Fs,ir8?r9a[耢 _ myND>UyiHX8=zH?n2K׎=Ջ^9??9y7nUM;+V ??ƚjnV\l$N:] |o79!eC޽IZoTC%zpCkOU4siQ.U[5I薨Fd"d;QweB}k/|<Ib*4"_BWD,SG%\I[nL7ruQd}j)KKO 1bM2i:hǽΩ_fE o]hT@1Zpk2???0u98=ϸgsⳑQF)Z~9i3^6t!('yc+FJ'#,N`LW)Swb (Fلˬi3GRs9Ek%!8To`zʢnƢ>LbjSiL<0\H3 4js3졍c5v:q{(.lM Xhm۬3p^ꐘz7avho(e@D_ˉ&iس4DGk^+2_ HQCzZzC:[V"8Q-;69ݛ&??}nEw+Gd1$XJ|m"??>r`AvƇAza`uݜ&adܱ7$f?0zmmfeİw.hthC4@Mθs\pN'(Is"s\}uŹqX)2uKh藞˞ _[h&Zzڧ-Q:b86L_!| ̄1 -F?n7gl5-h2P W_q*PcAf?nA&!F!Fd}GnݨsA??o??YD/T;MCdHRv-"&Q^髷z7G-X=N^??{}ȝ)d7]hW'0-vЯۗS:0FSv6XWWc-"FIţǯn$.$sUCg =.rxjm.IBo4 ҉pIjUbDB2eIb~82h5M2;D|??ouDqHx$6O4O/Nz!^USN}/F Q~_Xp Y-/3xD6P|Q??t2TQձ`7+c6MV?n[kA^RZSMҖ*V @ƈn(${4~2( i[VN?0,Z~цLDYƳK]D)<3HʒPDI U7?0-v2?0Tl3&/7 0?r$=!m# 9X)0#6w4% TAF+$S..X`wLF?nӡg<7QoN>bi^lq.I]+٨ZBAFLB??oryBaB7[}kI-C(R Y?0EѱBv2,Pe&0p^1PhYrL1@㷞 ??Kxŗ?0X'ņf8{]%~JCN?nnC¦D>Oq^2$'??<])ȉ u.x/N4%i??]|??>yߞ7iso¼>t9B#gw$:s雓|"IB7oG&Kb$Jp#eOQep̼{¼ѐro0gNMlʱ??Hu)o3i(cJ?n֤RtLɥ)ʨђR??y! ё[n2CpKܳZ)$YV|YB;n1D-mlZ\/&08!rB:-(|4)ۗ#o.F0v#kעD]etUh=E{͈Im6o1piQW8?nY~F[k5gk瓌{c??Ǣ[ZE^IvJ,d 3F2&o9?r7Y@zV<<`xO{TmƻY. $k??2Et5p!uxGnK-)^j&0W gvlL%P1GmQ0QN<{VXy䬙zb [5k.cNB9\%Rį-i`˒IgC4Z?0uI.!,̈[ӎtѲ[HКtHOm|}99tW2ĵ15ḡ} HL3d- +.^mZ`E{$6F{J-&9(JG#:wع1sAjuoT'fM~<:Cab~ʼno`;LIjvhnR$w-?n?0&"mod\4q s,Di6Z)lCw,"~Wmܢu;?rU};w}??\ތP`!53LJ2:{7vT_+fvfd}a\d*{/[ASq6[g]l%g5Av7?n7,]&.HՓ7o^9r ?0QtLcژA$9 Plhu_?rBB<5$??tuM$HoH; ' T47/Giw?? vw'?0&c(KDqIu;[LX1?rZjH.Lz8)x}m?rtc/~-KlP Y?n?n[۩8wNdj.f܎K+???rD)&"MCF;˗SoKzگSd2#n-)*$1%\w: +#~!{N0 rŷlJee,zK0\4"nh`K6$]6] 3 U^: \ z@ńZ$fԦ߈*<'&{uK۷NS[OlJ>?nPf0ULBi@7uIulk\։tGFB~S,{p>%wLO:?n fGTëOf: pb|"~wTAi 3d&y$ŀz˵bŀYX8ޗb xsF+C|n h40B,2ҰzOl(ۛE:wzz}SI7Ӵѣ6qXM.6:D:>Z~#m^%|WIlN$ %[~>0Tx0tilf^xsͻ3OrsFb! VpuVF_Nj:}3W8??O2DnV&w :"@V}a;M+/2.p FB6R"X  2: òZk2PD *'XXu=?rg>O* 9GrZ%dPFOhDIRqOn@Lt2p,[ecY=tP絸4rM8ှn1ܯ{t5j;<hOϚ?0@8vYxƕƆU]H%I2[ގ~}V(5in(Cũ&xbJ-ʔo??uBT7/)%?0Me0Я HKt{/;戎^< @zo 1@) hRU)ϴzc!bu0_#5Jw; mg\֣&?r93WzPAU3n7@͉|X>dZ?nXGy!\#߷sD19ESR>?0m|H%Glh=rev3&].5جD6JtXlPvQ?? vl+ui"&ɢ$D 9lOQ7DEU??FD;Uбt<_ldWd`~=`sCq"Ö]a$w4y-p$W7_j`>3扃Ã>8߆nU>a~g.ӥb e~vc rNVKo>ۗ偵_v?n"_D+ ɟfAf2QUw|8fE (3:}JPDL|WP<?rp53X~o;DPO7??wWo3* 2tw$]==)"?rbikv6N\uhWwBztB\pNU-dup?nbJo9:%o8a6ɀS;,;8sZd͗(}L$~~f0*; ]lO]i8f)dɿk/OnMFe|rt&wW$)_=4aBDXE$__`\D)#??a,)C/-o~+ 艁AY$Yt`>p/R0/F2w:D9}z.[a2pM"on".H,9?n/XDd(^iUXEo,=$8nBy's\K@P] BHl(Mh:TM JLFm<8 $DzJRwSFDnj??XG'D/ù;~^ŽW˧Mk?n%X,\kKگ}MāL{3pT/%*$X'˹G2w4j??leLj 5W0g;;,va>5{>cV>F{,7ϭxHnαx_qXocɵ5??\)ՓW qS=kUY3 | t$n wNO3&cBqpϱ 8WZ-2[%Zl9o;NEʯ???rio??;j??daL/:aZ)4O _VnjlQcΡw 0eTn2Th%1>T|O?n36^=x%&b?ra5L?r[&4`[WV;t}y]~ (?07+o?0Ղ8T<6iLZΌ/>&Xq .G Oeh*-Lo'Ya ]bۧ2xu&wI mr@:@ǵo44Ql]ơjhqisLAlm% ~Wn.keZ個lل_%85P}HyhCgB<:o7qWAprq \'s?nr"o5ju;l+to:8}EUއ(i"YQ^Y?n^Wf4x̣u(x8![ dsg{0 do ٽ/q҈F|<>VۥykDI->),s/{;jn=H,;1:l-?n#R=ݚr?rh%1OpI47Agw;^E$?nN&XQZ7v:R0%\N]t>bD*N?0ڇ[pL?0{(Zey ͩ^ *K/8*ec[}9{x:M&fQ)jr޿^r?nE?r:M14&uz)]  ݑ=xV7հW03 ;Hm>P쎈sږZrK*^`"-gR"$Qqъ6X6d:{cb+QmGmzhn,Q=iP_uVglǞ:;.gBH4biz|[cЉ o,M\UŮ̫ X*[83-m1ggV"l1?0wCrV8^[ǰl\]Rl?07_ٴ.cV!xez Cy ft`^?rJ??dz"\-kO3`w]%36gN1bif[iVȿ:3-wi)JEc4iLHV[В>YH:T&8)b8@eT ߓ;43ժ_WG@Gon]w̖,Ou1tӤF]ugjUwpG6q +#rsTnk?r}.RI*0l _j2Ƭtח$_DioŠB 3-ńsDKc.-̫)( Ǐ]#ݏG~Y['h_Qvm6?rX:;[*o2̕(P \q4p@>Tc *k1W??q߁םbqz?nH4fE WVrJ 2daf7?nn;..9r`P?raKiEQp'+,.t}t>b#^T$A_M|rx)A҆L41Xo\uo>twwH~y~{~[??߀€L9Tv9ҝbGkŵt`@(m(f$(7h?0eB`0\y6 - Z am)5Je-(_mj.d+_(z[ 68uUԭЧ%oc/**UDȬ/Rxbbo+ ?rˋ??1;ZT?0;_|oN~f]:yVۢA%\FNqo*4!j{4hvA6X r=vl f3?nw$\^Zxm,Z liia!IgnvX:^,/wrj?nն*庻UP6B5[%߯*EYJf1䴦Kن"b`A.828fޑjl,`~ŀ%MLG1i'CnIXs,'m[@-%@"oKr3[?0=_[mhm[?0`:Jv곛vjp+MYC,,čX-p]`aZl4Za0S@z_c}>3d|Nxp??N:w18?0=7??*hU* ڰƲ/U7$u1#Tc)N/)2As/][7%(!ќ593TCW:/ԍ3$]o`4  ˕MY<%bN- 4Yf?ri|JxjҢ:8z:wZpQ;𨷒څP +6ui,ӂO%9NBemyMY^ghljױ5ep3hObe0YTSZ*$߯??d%"FrQ7P^>JײaKbb}^:C4bO̓bO-bKqd|~Kw iQC0/wwM6!yBndK3V- T ~~ws>-?r/ead1gLsLrX!X%",Z4 DwD}$΋W:4i NSM\ @ ` <Je5 Mbq‹ϤVuϥWxC\B`zhGx)>q̽Rk5H7a440u6 @4]Z;f~zb^O`s{:b&>α0(G?nj(#ܡ!25ã3̤ǽeF\\gV"U?0a>J)^*<;Ss+5DNeh&4+&fk3xFf?0UY)}_fvn]"_1L,?r ?ny|ȸ"m{\ݢ?0؍&cUvc]֬V1@Ii8m>DJ8kYڅ-Fj>gKDL'{/n#"!]Y0YT$^kq*1/0EH?nTRIXi?0"?r0`h"Yȋg吃_~Oԯm-R)$H̗e⡹l5b!-\@Uu(9c,.aTuf€=alh+iO</!5*T+mB3SH%3?r7$cYBX??v#d`h@'$LkF ^ogZF˻pmџNXkmk ˑP / , E0i,lf>:Lֿӓѽ~V fiV U?0޽+!m\TfY`p*V vpȴO{u?r"#>_@Hq7xz^evW@A1ӿt9WU'Y=lΖ R/#ߪm +dE3 +#mZ+rS֎2#MtAWčDf( ¨!A%:ڴ5 AX̨=/}^hƴ2]h,KDBˣH0wXy儡Ry%kƤ:ZKq>ZKGc*JTydi6?0B$N ^X  'AqNqay.&P_>b)@{Cjy DCq79??F??8o *ǥ*]Bӥ(S>68ؓ(;"-yZe쩿Zxk+CDtfL f2??ȆHiEeXQ-0'1btk5Jhѻ:%kE??U65c*( 7>??IKpW[&f<6 _xz9ku곱sFPڦ4Ѹs+yfc)sY?0R3_T5`l$5?nx cFi_v=S.}2YOn?n??]}b׸+kpәݵu\9={nS*v%f<үRn=f^>_n9{xؗ]G=mٸVw nUp1ųDߡ3$HM7ˏW;=H|ܧ{OYY!]tigK\DSU#l7DQ{DWԳ.m&R摒^.e=ތhHvY>3#_/?rlhz8tqH]7J҈ˮ⍏RhInx ;?rNY`F](.2'k,,|uMF]`NXoAv*qLX°ċhAE?rntu#*VDi`5(&T+3yiR Ae:q {f_(k?n?r$VQur<ݪ9>Gs=ۙ>Ѻܒ,)+14G*&(mIJ@U9½A}@+;)dfKV'&!2 3j{14U\S []@H]ж$-Ot?rŢg~=h&$??dZǤo_<1=Dr1#P}G{Pņj~D|f٬u~J_rt*y,ܰu*m|;[M7r0é^~~aLwoU9"E(WYS: ((ZP[0KkJ2?rÄ/kCY(i+"D>sdʃw/2K5"fܩ{\Ϩ9eؐ#9%}.!W>\:znk^ !NS<U|3Br1eFS??5Ԁ?nQgwGY1Op̓X[0??;>?r??|v3U_cc\}3/| G##'Jf0'ts.hҟCrl1/(t$+ Q\n?0Ru⪸&d IԤhLKXY#&Aŗ߈]Y,#}Wk_7W|?? 2R?0Zȼk'MB뀤LMOf;9p#9_#f+~&e,4xf_ۺɧ sp-ۮ?r{,dW,}#UZn*H=)'Rg0KT-b1En2FYZtd4t,֩1\H_9?0_A*ߗ=O)IicV@ 1~R "@ԏ0 cYMpN~"T&Dӆ D Pf',>M0fc?n&f]#dmQOLZNYp(.mj@Fy?0w?n>a.b$RĦToeF)Koޗ1yT?r_??}Nz, m7~x_]^;G/ɣߛ COSzR aI?04Ō IT89J y^g_D'E~Z0VprgcZ&`ԱbBMxlvJX\7W +#bOwU??szbz`~9߿?n1?0 <:BLWiՍDf2Y& .{Cĕ'`LX{ZDXrD88HLqC@-3".uq;aYWͦl o;;C$jBRjFA?r$o])ף\)8T 4085N]|LjŎi.O/W['&Is9.M3_LpN?0} ~FB*??o(-a!>MAz/dvETN1ݍzތCyo0o_köPTK&w<гk!ijRQw{#1 ֓^lQ3:?0V?n7#~Q% z I`%5ʟp %I WlȆH Bc892[ۘYC"1M>]?rě?0.DH6 g)?n;R;ю:44JIiD%/*  ޿=>TN|!Yr~$(zcdu~ @,O]r(,7GEϋIvO(fg'3܍lXٜ3LwnbS8;,\??x_:7??4y1??Z=wrj*x7hU%|~Ջ̒YC(͏ qf1yz` n\.뜛?rɨGHO)4nK|Ys5O=?0Wp??@ߜ~۩?0(odВ:^rv%=Ɓf()yDQ6+x9\櫙?n mRґE+81^F2dru5ލ}uM+DȚ݌ܗjP!f(`KLd+UFt6FsDOuཛྷ1??_/aO2, ĘJ}}D'MS^MPW}hZnA#OOO~'d*Ya $X^< 8^s&Τҙ"rMx׹nGpM#1)^zGjx]MciVMтmdjQJ|}ŷx]v@)9O׌W^?07)AvҰsݿ*S9O{쬣#겴^&V߱yJ&D!]{zUwk ;_{o&$Slܥ>Ë鰡tgX89$|X&]_H8Jr??Ѩ7*[.WQyӴg:8 Mɼ+2V-kRmVĠ%**d$"<1䰫L&BBٴ;1>NT^\Ok Do57m`yp `G[0^4)t6Ǔ:\qu+O VsV!>U`*''x>_%ד\CE2??4t_>=I//$Ꝟ|WgO藯_[uE+Yʄ%5v+[MITT8e.[b|`tPWBB?r,);%E}۠f{b ?nS xaN??^+G}Nz]T|ry rip*#RAvZu1}Z??yvKQB2Ux+ ?rŨHi60Υ$n^ڲz.`oj?? v߽\39>k)#lMETM^Cwe1HܺB8ڷk #6jK3+eeC??I}/3.?0탧1$]}_it21h26V[ŏstqeާ=Jl}FYobjH6e>0s|Uf)BJwɋqF)D>_77Պ~ډmrw^7œȵ&lT|h?nW1AƜ?rEV`nNѴYH=Dձ)enF?0#Av]Dn!@:)ThM1@ň;?rWaH#_iS>8E~yEZ QR,w𒖘8b^6ey_c%5?n?rMX1:bk2+[%ټ ʔB|V:?nX97pӀjq,5edP0M7W?r*E[r;Pm#hLNcmsy}flf(H\d??yZLxsaNEi{W]^?r?r`WzrEF &DȒ֯Dr[H??V:t}.aƳE<ȚЭy<FTw6LݩvG0zY:bbQ??zWo'ӷ%w}K??oJ=Z6;W~)EIe b~W/M.*o'rA1-??aqQ $wpd$'5T5%ؕ>X-?nx~Lֿi}d5Khƅ='wϞa߃:35Se]y2we=ة0 01^9vϭ瀉fI#=*"ڠ1vtVۺ{ŤY>޳_el>3h5g96wƗg`8llsKL* 3+?0J?rƐ[@ D1vM TIs&7B~A^֥B瞋p+c9q42w`^ Q&9Iy;dy=p7͘zva.3,su1Ӽ5ќP語Dl{pl=G?nmاs7f`VUQ/ԇƵzX)z3MGE/_#ao!%7$`Q`O&k{^M.M+7S'-j\qǤ?noRP5nv:yxXYmp*9E8ta"6,V|pcʢˤ-;H=u<ջ,WFZ)BR5]ceç m?n%nҴ ?r׋b MFd WԃnF??kxl4]2Ge??L8/G2v+FaFI_7ۿ9֘yk].FL/2grqg݉D:[#^Z@XVWN!QltrH2.[~3Z\??Erq5M];4"6?0]-Y ΨQ?n@C5pƘ2AȒܜAW?nZOHF- *WM/?n.fs 6dK?ne~kwcX~> ݲh_ȘM$1ZxXb,U[X@Az O/p?nʯ&{|}jV9?0:ȣ&&ؒf1)*.88@@A%C,m2t]]}?r&RO@V$%^Y9itRMI¬gQVc:o!`x3S!>!QOyl$N:+6h"\b{>VZ╈3EiXJyBxXPJ.F/?0?n{C{.LInڨ*$#";????lGG{P?rɃ?nv8\C\$'?rЮ"Q0aFrS@f(L=Ҽ+@C 6{^k`{H}#~vӨjZ?r9F*A\ry#Mpw|t bmH%姻z\6Qi$5?0Q lj Շ#Lrm,)S4L~`?rUɶG8C1#sa1sRKcdLlq4-4xZ ƧolG/z{Z[z0ԤaflGgODž]&Ty3%&/IEW0k G$W=8b)@8zPU*wc XXN[S'#Tٜ66v֎?r$_=<'n 2&dpd] ?0}W slezO3`b ?07Bz9 >$[kc6G|f4z9Y>5hiMI,L'HK)-%Cgq|j)lW7INF%z89.(Ǻq1~)='A8k4_wd:= :Y?r{*S<=E1~$'DYĪ&Z7;Gi8Ld0T10&%S`>d>s:w847uWɫR6}V#deSX=}ҽ Dk5g.M@58ҳDT,k1l63W!}h?0fV(1omۮ %b?ryRDT.F&moz kӸS*Vlt!@unֽ]Pvț;ʷa_[T~ pä<޼|:E4"mh!σ1?r/N#7؋R, n4 dFoxoSPlbz|nlZEUSĺG hbF_F,u:IBMߵ&{pl|IM{I3Ұ,vdV5JX ?nT)4jj؂z#LuG7l2epcbo5X"#}± c?0_=Ndt?rKEx3NVEM^㑆r)S:҉,bhgjckqxۑ?r`3+s[!5we mW$L?r2#^^3R%aބ% +#}h??e;2ךq*$پ#A?0` &]4p\/5F!`fR$[^D{yM/{u$ƓC+¡!׊|XZRHXq*h&?rMD5iQ?0Ggs.'&>^)u`ܬt* m(l|=ed76ǐrv 3̾$~x2'Rq4+3xSaXGtQ:SUdYNFQo@we>N?nj Uq%}/#c"3I$792z`8Y?r7?rf{hYsU|u5>{;/glgS17ㄐ=OQ` f(=/RUsex#bkqC/Yxald0[0_PehK,a`+bX;m/z{ۆݮŹ㮇gDOO!8A^3e0Yf$- m%?n׉ M0k.Y|T1}4??<<ۦUKYP0Ǘ|l™BsJ3׊c?r*/ksh)'FA|Oؤ[ãrSlR`@jOMV/ :y:%GTlܓlֳ.VF?0j`˳@~gA!--Kl1(\VXb=@;ϚVp4LYBET5Jz{.qU'Q;6㠇ibhdz XTË0"ÇotQy776-#x7GXFaD!qY_+RX[fKJ??l@V%X5äG![~8sBb!N^HtᒆҀ[Rֹkg4[a90??8!?rFBpgdĂ9rcS=**hra,8@iFNUh5<"$5-"+U2eB ;Q/#!rKDж2Tw0wHnMYxvaUb؈ƚlhw.Ws*WQ!H~E1YtYOAE͙r@R>DM7>72g[6+Or(|VUB%Sj?rA%uO??>h\zu̗K7vԑ~]Jh`B_bW=??@bwAEKbXr1uûiPYƋ@TcMkӫ3mObFMY?nbqC?rlxy{ |Ж*>ctCټ&V;Vx(֭-N8m{16F=De\yX I[ЏHbgO6<]&+*?rl٭zgJz"+K?ru)jat?nџuKjAM#CgnVmmltgOY;0!)jHIKcp2v4Qu1$p$;x,ۃ;PGvhϯyP!9Z0 P4(q`̓`rQDx t6cuC@??&]Y=\yl˝ZZZ4ل^cߦ]NT%Yմ4"֤VP=B]$@cÀ]J-fAÉ2T]#%rEFlq9;368" o{k#?rT&݋Üg.pos?rP_?naj`FRwĘY]C$m!o;;+B#??ҩSiFOݓ~Xpd^Y=Cu[0|8g:5zLpdlӎo+}7Q nNۉZUJhA=bò +#5?nx-Fx̮gy6JU0="≩y=' P~j9Si}C Zo&n9CBH9IMqc!ViԇqHHJׇOί?0wu3'j?rkv:M/kQEų߻;KƐ˥ۢfu (~+H9֜zT.܏r_ S?nxTR)ٓO6@O &96??|W??}; jS\Qc.E2Q; $NpF|RL!`E53Dr&H3YRY\;ti^g%Cφ?n[~QXrR۷o_|^??Y. 1.W5Dop |-GuZLB,ȮD(l8-Ο??zֲa5Lumpq1]6.WK??&;ib6(uoq^sPbɱF9X9or[IQ]hπPsz+tү9ځb[wN;s??F6-Qm.jא;Q.B,0uu(j6n(jm^]BԾ#hdcxGc vpc/??ܸ(6=??4R&{lʏ`%z8agtj)A$ 3P?06M3meƞ?0 ,IChA{ZOr?ru9<Ыޝ܃vqr" b:hL: )̘;Ƞ^Rxo(H+ƑE; #R.?nTةݮIpE[vP֬E xa+Nb?03pk0w!#!vF s}Ͱp/;D]6 GMq FϷEarO”0LIQva9wU9vRح '\&JvWn~O64__ߜG]GwSﵗ12 oKٰvEcx\qm_ف+?rKlGPQ sD ;wXUӵ2]n5^z ~ޢ77?rcQ1粒@A xﲅ<(r/׳.9FSnٍ&7'ZЧ\%b%#~R??GE,=mS-+t3z#]™??Fxcv7۹&s{JiJ3yz:}M24f(·o*K%їK6mM #Y?0'M%2as79*Z<pl7Fi'p??oW*Yv셽B`J!X-/??=}xmRh; J96ë!xq[Iפn.datq3zeb?0JEµXav>iH*s`;0F2;e XN*eHN1舆=?rHkE`J7eXزs!ome wkh5WX&Gboͷ`iv$9cQ unm+YΑ%,k?n??{2@G7|ь8!$š$À0{σCKJ"v'PmR 9֥S`Bpu^7^QgO.A٘1vA@6x^-n?rW$YRXW"9@$b9\pYɸsIT{Bq)-9Xt :|$"eN}[Ž?rN`QoYڬ і%Y|LS ]ZyzֹO֒RL-Kd94×]åf ) ڀ,]/4}] =?n ث礦_fq?ny5x5إ3#c,L׮n0,[[ox+Ge2BDcQp [~&.侘djCM?r0xi66<?0FjIf<|W`B z1KprӟeݵX5+Q$Q⃂@'JeD4AW-X#"nE Ww+z~ HkItnT詝7=/&(0- z?n Xee45R l:C.)6~XLQCw?0/ 5`\GSrǟ̯UGV|>~ÖƠ͜OYDϒ1(??66ms,,'Vyʭ4QQ?0Qɯ?0)+:[v_1 q"'كM"zLRh|e9Į#--jqAYK#7L9-k@s!BwR= ir?r2n*8;?0#?0 oi^T4R?rT{(?0?nXe_~!Q4??kU gmbQ5C `4r&nCW>PeJ 3f;dD#8Bܐz+D2̷ns 0oF 1Ye8ܢ]=C) |<3??9;ז9[I|ok& ¶[&GI=2!ݣtZ:g5IУm[݊x]J|aYD7?r[~"mwas??h:#L?0~*K1G\oKRth. TmR%@ ;),@I&̓E׋cGCqtu8l;&~ +#דif,>jLE=;iఞI7;=h_-ya?0n-h> PTY-jNN'k4(2"s֐Y(fOx??{v*>0h:aYtKa?0b S]Ap*AF+ZbN"oTIt/0Sܠh6A֨$X#P_!š`}S2#?0d5.;NZ=O/9#yF餪Zc#ç:Vo{w8X|KOQM vo﫰??NBQQ]kF*|{)w-К//V>|䆗??6?n[2"h>M"Y61Ś}ZxBl!5[e$󔴖 m[`dWv9n<$[1wM*~=8cm:"41E'_[?0>Po[́n+D,6\=`6! BQZ^rVFӵljt??]HA656vw;U?rf6ިoOw?0a,}vd-tNo+ 1(N?r/T#*nC=2gd\Iu["4;fAVlۣT!{mOwtr,CF5omG!هDb6OMQ>YX^<D7ie]GSljI/h[U18=EbuMlBh]:#-XI՗Mq!ǰ:oֆ;e~SOkJO1_/K!4j}r"U;!4?nW"ݝe]>`g (sî~4%0y{L3MƮpgl.sbMjx<]Lvlbx4+4i'Ze5sP9^Y9,0V~F 1$~-niTn-+fIq0vNKh9DxPzV zy)VJm^32DνA{>?nD%p}75O_#46h+ԮZIx#[}PPKuKI G7 w+鑲B,)6E16byy#Wfp]*w sŪnK{5i-Z%/yzhu$ (bYNhM^Za-j>voafZ&?0ڦ)oʳ~R`z̓P,N?nZqŔ8+6=;#fvAlF҇ٺv=B42)Ȧ2RB`x Ҫ%Q˥Dq)hS&M/qp????M3:pV߆y<¬zE?nSs(jU_{ *0F\͔0tҸl8" y)ةԺ'Գ!z"v%LԼ8-l@T\^ \$Ο[QޚKIK C& a%JGp׳sI$/?? rEz]xUKw$Fѐ/n5JnH>@~V-_bf);:*Y!~i d,м!4$RXQ>8=vzdW+h詐%9p(r18i ͼ??ǻO)"fJ,/6eaITD~Ň5΢urqld2{ic/q(ŷ9#`W*3??LQO7|6ݚ!gh;l&+V@eiXj9g>**:?0O/!7G+sp;$m.W|! I9vqr% G/$5Ike@ɒĮ$ISsU(H\(:vedHb"o_gҏKgqta{Rec~4-#{|MeEKS:فtAJ0 ͇ EG g& ̥ TIJz?0C r8Z,??(cٻJܣM04zlў5J$a1UXJ%|}?n>FA l#g2=}^4wgxM6pR<oo<sBJL¾ 1|?rA1`:ʭXipPoFX+AB??jWIV]-g͡kk B#8+#b2kDZ^Pٖ?re/G|@:ŗntmM7BPJǦ~ +#?nd_1_|Cn dVuA*x?0.m㾊c{A[&)4QM<_\>?n-CY0:ټWכf-ҡ(TZ -[< 1(E3Z+@`A9ҷOPlnU_òa0l{=ilaʪZ;Z>)5ͻshK {dL1:dQM&]Jvۉ`{)/?n{UJ&z92?rn3E߱KfWBԺvnR?rE7 J?rC.DJ)wܠZ'ݨLŐ:ASc!xlw>??<??U??piS] .դ -ÂcsEu>CjR?n«T?n餠5ETͦ9 fydS&@ے*q;^X5qZ%qwMzepӣU۲&$^"aJ:nAVF8/Q.:^0ZRbHua`[f-YY̍0)ƃsF &WsBFr@ HA??c-"V?0<M#~p6&a"QnAΆ:uuh\`rV0D?n71~LS1C'?r?0(|k*. X7BJOqG9E̠]BR4ܣNڒDLB?rQ^"GUf2_?0z>o(boqISۘ`D0\b)Pj??@AnY <:BX;2h*Q?n,۲>X>kN{??9a_blk/{%Wȑ :TBwEh*A.bT:N}uk%;JwmxFuKk.GEA3> i6iEY.tDOΐ@祓a$+ceShn*T P`foH|S!"9]*DLIJ(ޜp2F{ 8 ٱEբ@ճYDNiyje#=æB<&n`GAUձ%# kgL_]Nvx͟g.}ODܡVHEO?0>(U{R[OFO Ǵv$CpL$>ow(ywDK% ՖbFO?05ْ`PîQѠU3 ѱT =B?n-|l 9]꘸Jpaet/ӣ}4xrQO@{㊯7҈pۻKs;j79G6nY眶,`\-m@?0R)٣{m$ܶmy N|KYM???rNӽۨ&>N"FslŸ/|r$N-?n\`q!Sx> k)<Ȧ[ J>zL?n^Iѫpѥ]E∩ )+4Bya,6vM?0/??u1 *-.=,iP]7rfjS}k{r_|:O}1%sNLfѫ΋rFTjUdٙފN^3@ y^ڎvxkf>/r6Ye&8ꍚs ,-Eo8ç/H%os?nizh0&4kc]fTKդz]v;Z,-Rx<㎜&㰆UQu3U,I  TԤ:88h$Y?r;Mv˜UR;xu1)`i栭C5oWz_"6PMl[þR=YjGvҼg8]WZ=`bm zDCS~~5>=|F/__'u~>q8.hW?rOuM6a̰*/#aH`?0ѼCLWr7> K=|2'pEq+BbQ57)c7#[mɬi*&L5wِzυrvD'?n~e4r01UtxuZ7t7+vKy&?n褣4t@֨4%(UJ *q}jl2L&_O=?n'vl~?nJjY??*VlP:XZԣ;jLf$i -(PB$#S ?rKQ!C"W8%.FZ(x ɗ[>΂y0H ddžL8II1Xŧ?n7),LԱe/O73?nvyq^EU CdW],H(zdv]&6[p^I{^E1A~i`A LQg':NO{,ֆoT-tzdMV#yĄm̻QKᇗye P7ͨ9R:^=u&?r?n3+}AkK9@T۠5Kz@ث,ŘS\f,g[., Qvu.aE.dĊMBn](In_9ze†ۖ?nwP7:JL4/)fs983Ck>za#zt2=:޻jDz8aߓ̐nI{z|tfPQ3 Isls$@?0C>"]K *&mVyPSâ7??a;CL򆊧]nZQ:qU7ҍyo+/sBms4Rq7xsɆiNUlL>87%"}A^4wB'ퟅ ĩ??)d"ٻSv1f% ɲPHܷntc.t؀Z#ngP^+ta]&d$WB#jqBrjZlʄ>>[ƁhowX-?nLfha?0>j6r ,l[?? g7K?0g-^}/_5f63=tƅVN-;[hJ26\BN7px#& ?0q66N7Q& u ??}H@ւʎOJG4GZxD???nr"O9=տeTCpЅ?0 M@l/ +#?na?r #i.~+zM8??{ǰ(B1E=WJ*Px6+4T*j?n?0*6U9r[j\RΆ=X8JDe3.?n>T_3}oZc]Lbծ8JOͧ-K4/K Wh"eF%REX(\R#PA½v[E+,fu>aH|;C(Xk1.BF8xPhJNKjm`j07WV<1-r8 euq1H>Ɯ&>b5ݣe7raH7<7RHm֡l^?nBOo~+>մaʗ+ǧYB_8elu{[࢜-V,Uݧi?0 {8dXn8Rsс>hn(ppk\B?0qH\4B24PoyO|OpVO1`ql/J,\VP ~ec'9߈bpb"AiAA9?rKiA>0@Ja|h1n*.?n( L*o_`{y^g>?0gvj>c0,x۸a[ƪ[b_ٕ=qKB* ^?0wPop*,Z쨰^pcqV)`=E+va- ?r{?rV  Z+FwW +ݺ-e0RXL^bQ?r8 w% ??Z8;Ixd EQ_Z)8vQ>1?rolGQu*<aNzjw@QNap9 ^'('#÷Wq8B>\ËyW9p*dr?n|HV\a6PK0nPג4xhp)6G&L~l2C0DG;^>T4n{N.guQzbB-Jq81:2AY?rYF&aK"$E 旜m6H>-IBHQ_OuTF|{VٶZhHވ[TސJDH}q`گ?r__ּFb`34qa4VfWfgaF5$ ‚y%,ڽEc??*MDD?n ۱i&iTRaEL_䷃V"6pnw`_91(2laUvq Y1C͏mis@źsȋG}hW2z.& ||]'/HC_P_?r-OJ)GVhZ*g͏x2gj]GUi%Y۠%&1ph$E?0SR9d9\piI:u!>X@}uId/Eͺ޻<5-@{ah,(5#]zENMR/aqꘘijbV˴ZrHvY@Mn}hQh:`?rKu@_FV) M֫:8iK- %\T>&qL8:52u)_c,WCX20SUֱJǣE^31S$ԕ_a.7C5萾6tQj(Pq 4rTmوyd+11n&s)GuMI˚7 .ĎztJJ,R,l(8&gek9sMWӐELnWoxULk#K ox_C^Nң@GCWxO{m][wrshz$?0# tƬ?0?r)‘<].ICehD ^<0kDp(Z}ZȄ8@["K. oYᅢqM4jzQaQaQ;ލr4v+FgZ`d-6^`葤8Dۮ?rݖws"?nב.$ό ͦx1hK zi8HMeoI]玥Jh똚ٹy"?r~w Vk!C).(XG@ëH ??\eFp3>HNEC!ZOZɅb?r(S[R9r0̱$[94:Q=I1{nдٵwG0TJ}̏(pb$/w)(@xf@og"}#y5paû?nl3),%cB8@y\re$T'5L3ٙ+7U Bh}r! ?rEgE].GPnpțjϳ".9U34N .+NU}G#ezX50optB蚷ͺw?? /-0k|IdR8Ԗ˹]9@8wԫ} fJ i؛shyؒöڞi`@){|m]Fmh0MhL?nN$x3UC ߚ-0eVO9͊a/-Szc|y$O.19=?? ';b{hq,F0?nrEPψ y2/a+Tc{UjKjS`ae84??d޻T?r;ZT#+ }JI.+'yt`DӅKǼ4!?rAuUi2hN(#/R߶yڍ(_MF%l.+9?0ܒ ơ#0ȋ@߿›a'ʹxȂ::p]{~lzL~<>ǩNDc{-`D 2khǷ/#7ecj[% GA:$7JݭGW` '15j"gQO=26G3tbZ7#IU<8`z饓kHϟK/A!.z94uGW x!8 jukx{@?0?n\%(JD6jDnȬS?r Φ^w OKg[V,#.6ᠴJe#LG,3»Y]k1"ރ9A-gʉ1/݁vZ??2pܲ ?rlܶ%v9\IX<$[Jrue,[4<:|ޤτ{ʷS1}F[m%Be&h},XlJzV=(S<;BftMC꒛7zMwG64R@4(e@O?0\dωd}{/"#Z7ٸQVG%*yl8o_}c!#<0u@^Ɔb9&F,@TH:y%&qΈ|5s62K.j6N[܎ش梵z>jn[7j\rRtu]Mm6]?r P7 z V>pۢ hQ#HjZT1Z _q& DI ׈;?ny!X5c]m^kTw|l+p|~haOvpOۭ\ayQqΚ0 _ 9j*BӰ1h y{(=63{@/(4Kud y&`??9ј*@hP62k<=/BE-WAN?rמjł nK~o^p,өiveTZ?rP0`=%]FE(*jaK7zgABFp$/vpp!%Mh=ϜHQ3j\sh6aл~UL-i(aZA F-xSy.PF+ %Y-??byVM SףwH6 47ɝo{޸]Yi5?r5|BX+?n4 yQbYP)LA?rѳq9:.@\~IFR%„j^v%wܮ΃ dn|hGG-DOe`_}ʣ?n@鿘?nw/F`LJW(]Qf,h@"j%ğf|ߊS;NOH?n{'f/Em|S6mfj5iT2K4S\)KwZr9rt Nߦڐ|O@Z 7ӑ]cϭr4#[(|ɥg?r$ýЙc7 &w)nu +q?r ERHb)r?0 ֟&?n҅[C>N?0)ayǘQM;0$F4G/ȼ IܝS'q"}OsVՅxńELh[q=ŘM@2t/^W\ՉT?0ֹU/6Pw1N0ե" ;8EW>lzAъ\Λi)]v镍%I`ۛU=MI| ??-ȝ}gd&tP?rߩ?0wH?04~D9#>{~ڹ&)6۬CvAv(??Ïgkm-߷eb|w&s!kHk}6DP,B*d)2$LP"I˔H=:FP%QŒZS$J$pPjN7]{c!`۟'Q{ GIlsfYGǫpu6E깖Tev}OJenIW8hi[VOn䔃h>i'Nd,/t[b%]]\hA~lf9ꗗSWJi)4s:&]unZI[^@*/GBUJ6q1Lc v5i[/6,/8>?0VF6m#Ymj| m#Zme2xUXeJ'XyCs}FE’+CIj\de9j9T5YD[64[YP;mol|=]6˾ F??ox_GgF~M9]u=_{Y/x????///gZOp?0!;>p۸Mk'wa$U4yщጼ[vƘ'<#s21,C&>zE }??|ty.2T,qCG_1\: DErGH?r~tѼdکLx'nttхgԬg/|G姙nvOg׏]m?n[+Juc0L8>'$t}P7} ĴѽI`'.&>=ngo.ţboS=9aQ: ./+m=o7 I`4(WtvVNsd c08۟Wq\ۢCUC6>vUNڜQZBv[,4:?ns]kr|!pHQ YCW@   k r`?n4RE+vEPM-ꦺiEp`RT^^f~,BVbb;Uуt)S`p+n\cKKZ-?0R"jevA\̆J+:{G{%}W׮M+0gu(RG?0װB;#@u_:њ  m("2W`r&:~d (s&L睐??uhpdhqKI!~J"yyYibOPa D߀H`Az"{ 3S_驠ה$il5MYr!^fª=8c^NB?ntDiVIz* rI8ʰtUZhN /(Ѭ'Tp@z(5bdm7i҉.7ުUH"W?0':"b -{o GQY!U8r JXoD9tfz)HkJ7JcrBy_Dp")?00d Olz|@lۣ:)5>^j$M\S<k5Ab+~08OˈPIZL=kBSVɶ͂u:fX(%~) ] ,Iii.Шvuv۾6Vd3À ];md`~g@[~Q/YudYe$ Ecttk*.Dz[eSe%K1C!D=R FA2[{Ǥ ??ki˚"/Vz\$G83Ac&oIC1̅mUݜI`C奍4قe?n$8y!y۬ {,{kwl};T'뭾'x?n./??Gmқ/..O'}9mWݬn[t?rgTu"''iT ?rUA9hCGPfT}Ȓi/(I"&.]6@e؛KҤKi Х!~XdσH7jxLr>CӼ}:g /%rJGv6G7T/ؐ&yU5V=+-\,غQTma%'XF_1jil]3.~0ΨZ?0ޠaZ݄c`9jWhoVpsT@q??4(Ç(0馅W[%5p^U"*^NDM$:r#pS=1gsS?r0*YO04?r4_н~QY*_=i;09MSJ<03R_+n2"=DW>Z嗟cRhf*&aҮf8 =QlCk,y;~?n7Kaau ۠1еksW2  1  o9S?nvđwv@pwmgB5EMjϱÇijXRΒ\nCmùŜwSl[Ⅸr|'??Vm^/9(O^E_Ң_R֣/s9r#^0X/V1N[V%dqCv³3E0(0W=kYQl #HXnhii.%c?r?nqn[7F1T%brD??en˥ޒpA"/'c"/O4秸 Hcぜ5E\^)Ys~%AJwWIBD?rUܖ]rY;6kٴ=l?r#i xfE7x;:@wp?ni??^ȕK^}POa|_[U/r&5ɡga xNTڸ.EUĂgb??MV.a?0C/+򙃆csg/_gp­ӝydŌh}0 fS7-cߝ4XN:YfkrRW%v&_PjlT_@<וeLMWJq?r*[-?0Qc[ց1VxGб;9d8O`pg[vqC4C/8I[,hF_vR;q/GaŲC>D $1.(vں1N_ji2RxW9#P@@U }2Y'̏wƀ??ƶ9?rXqx-6q|qb 9{ $>?n%SC?rEWuc-shoY jtXXQ"=R[&u{[6`iאF 5*(W5@(^B*D`s.,'ܓFY_/Y%_ ,??17>,ۮb]CZd1{?nO=ʾBghY y@ѢK)W:ZPp ӷs$q#U&N`dɃ?0}OH^ Tԋ gLcDl 938A׌0t*]\?r8p̓+fF,R:zd$)Yr`E<:t![ͨ?r۫Υ"W!8*Yڸ`A-a1&?rmy=$K~:?0qPm.RLMni1Q}X΀6cI?ni ?0 8xY4IW5|ƊǁF4+P??W-NcgUqdݴXG,cQOS~/Xk$G= ~ͷQ=g[,eeN??lHς3H9A)Zu5O1D7+x RT-z_;B[ghKp˷,I^l<.1:|_6qjXr?n+?r .:$e;w2mI׷4])ZPZ Nek}{=PY?0zRtH W/<$8FF"[N8a/]M>gv|7TLBès:JR <3p)8#Xa^k(Fd@v;xS)VGbi/"SnH׀#pZjnu??Wng$Sw27n;[,ѶnFrrHe^ɱ &-k@ 3BWKLntvˏ\Qrpr[?nﶁNl/Wc%qp*Mi( 9iVI1`ߪ-E{~?nN%A좰"sq*C2 LG]'QUR~WqSNs%-P:qdGa|O9A19jЀd˃Tc}CMmoª?nݒ7-$8U?0S -[5x??_FC m8-mE:! x2jݿpYWb1MsvFD47hM,$9r:r9 ]eU<p;Rrh.f X15*cGҋ=56b{d̵?njdWd -D0,{1xO25-XrX!e1VZXK"ؿ]eGsXwKlRPp ٽ_{-@G}:6"?n(0XƋnɇไH l,Ȫ4'2Fcd6^RTv#yղ0ͨ,S˲0W4:4c$2]M4w~^2--ݸ[VKvx= YڒS7UJ9J1//=<5q K(/fE-{V*Or9mIG+aBQ&UvYmyb`ޅ磥z}]RkNƷ,"~=_hDqfjc }ƆC~ aC$??*+G6 6}?nUI'QX,}7r{jd_2VQ唍fgS3l0":z|a{`Eu9wհ<֯fET!\)^ qR@JF9dPṚA}} :LTCIqdlt9S{񔒉5YBj\ʫ??'4\ ^^Y2oonPџ-셅}dD\FڒLKj#] ?0EӥmM!4)5jLgb~9=F<L &??G??B8g:8hڷAڇ1h%2+xsH[P(VKD??q!Xb@UcqXBatPĉn,rD$yBl'l0POPz]J [1z'Nch2%jG\hy?0;Q5tՑEce;@&!:>Ž#,D! գ^I|͓?nО5}l> *r| @ȝ@݅($S%<")<>DXQ:??cC(p}KS?nwvcv9=(sŶs?r\4jb~1cU2ͼBKc٘>h,p?nx>*B -p.4=s6QD]}>?rσꕧM麀}7q[YK*7N¬0q.͠+;.F{zTn+IJԥzD>< CA`NP?r'ͯNqi=]kgtMWaX6a;g;C+qiq#[?0 'I6" ?rPoMRw%oNJ>gj!?0At.HmC ?0ق7_JB![?06g?r8.xYpJ05vGcT0n5'r<|H |m@e8xI( G&1Չ-lEW,7UcюTR^>%@.!Hߠa.hv׽9>;>{*'N?n8x] dF v?0H*Խ1ޅGnP r_jC:d~ĸ^J\gE^?r?0$fx7Ai4鱨H;LlǏ??W_ M$R??~Ӡ)~>?n3M\vv,f"'v=@F?n?0'9o$0O($7]`ml K?nۙ7K܀J[G81GB[?nkYp7yRh-<ᇹ$?0o`N\<~]|3pE./>4?n"nӅRI?nC ?n?0x#B#mh?0*ԝ'p Qp+ r8kDʇJ?nnu(;*gy\pg B/dgb,v7x%irX бKS1 ჱyR-TuF_ckћߗhU??m~MçSZC0 B#K{ؒ?rlbi?nu0X_НM??;_3 I Ufˏg>v tL"_?0 5ߍ=4џdI,wG~̱7eWӾ旑f"?0鳳v*32"223/2Ho+2FMͬGC(AS%?r?0h}f"h'bW :MXFqGŲ*l/Jpmk_ъ]HJREXK }CC:뢵L7Q|n{cY?r(w7)!;{>]p[ )6{++0AOH9}nnGEndkCRie o??az1qZ#~RW4Ы?0}keXwͳ?0~9IV쮟nY˺Yum@va0Ugc. `|M޿_QW-Cb|?0Sn>,M 3DIWy{ R$>HnEb2#9 sY j_jempنftAJO?n}Vnl:6܆jp%Э3i, Y-f?n <Oi'Ue`߾Nf~x?0< k:??Gьm(8??'p0&KU84%NyE;,~c`f>Q/ǿW|6BnU-lu??yq?0{9xy&\)K9>5<*{+FV2.W0<]y6܊`LAW=??նZMy'P76O2 hp$w7n(|Dη''z_D f}*2Uc_G7_{sI0)ST*K侧f"ZTIJ2_'R{4qOW+oh/$./"ZQD75qZ慏z7:.ҒHU9G&xo W'IVyAROP>1t's_/~L`ĸgWAwGG`O_~+^DUn nR,{֝)x%kKe?nT*XM(Q+?rv2R!Z'RhS+tSZEWIr:idIA.H']kᜪm`6 h rUaka*\;7JRp8),SK4>߿*Bk??.Uz`"".q0/T~p6Z|="V28lZ?n%i]CGeɒkBV%b9YK޽W?0eЅYNYe^{9U?0$SOPjdǮ'PZj2f LSIy?r%m]qF$fy6?ncɂX*ɶ;IM౉WM= igxf$K}y58ǩ΋2!nK08:* SpO??\ȵ<<G4֮ =P(XѼy`o>q,8*~SBZLi6XsGO\huq?? Õ^R+0U??mEN$^Y,:z$??UE{q_?0_FwIȦ-e! Qu;OԎ!.)$j,3zyj5|{h=tm:iGzQkmP?0Q7\ꞛ@IQ]$ʞ[e??8zaavq!j`6)ptWN>ӧA'ˤE%jaaNp##2H=DBl4rKʡ,n[nY}!7b#)y"/)Bv2uo~zUpv7!gXlѿ_״'7n??|MU:DOΤMw{^meLTY_l'VH9FuA&$R'?n< :R 7-ja8WCn=#2?? jh ??yC7HlPKaF1a`8jJkXI^'B_ʫlQ??'1ƲH?n-.OrFۿUU캏\zwAt}vɩ|!nKwp2s@ؓ0 G?nݝ?rIHܦ= (yɵW!e o8j??]7JXTj$)Hn#/t9q M{D'ZNt`ueZwFzKw^1/%7L)?0ѸM+ג*rC,pVq$WԚ)+> :Tǥ0nb!ShNLp1 !ַ<Μǩ#LAbÿ[(#ñ5.?neC"*{VylK[nV5h95w$F+agPb4*}輥AX@k.[Rq2Jﻫ"@hOoPe `?0~ Z,yϵ ۷ Jy8BtyX{xh RYAv$LY&āH,[ԢxFa9T_#-Niiy1mlptlU̒ ns8mkCVmV{f4֏a$m `%aAUsx[Vj,QE8I)|GF?0UOH?r_k-B IT, T_" ;zUJ^9ﶪ̸ cDVmc.+"??OPYؓpWչ?r-5?nqc Moa^kZrߧɝáѽS IjȆ4m5E&vw<[0f++S'6*=LN(g\WR9dX]u_H*֣.Lfq0͸rJⵋ`m\iPp--޶DUaǻquFਣˈuyve??0#wVrGv6Q74T&כU't|2:C8gm!#7f}fv3G%'֧?nE({TCnVYӣ|qXÙ?0q{fev-/#cL>!&N??N>y_&_EHIE,fz]V z*CC!,Es??i*ޤ5(/{ZiƮ5܌oQ$`r;wS??JSFpu>j_bU}*uҋRz}4Wf BDh* G c]tEG֟_]hdVkp6l>J!#6m[pZ[0=jwݯ~yW9E]zTt*TZ喾fc{_\njL {㒾/tR:Ps݋лKFrtj5WỚiƒ`zw?n[.Oߘ{KM?njA-5Lw `p?0 _=#(] f$,}6lU(MtPQR}ejnlɬNduRDU&(Tj. jrV??ưR\#W#@E<ŸDD>F:Hfn?nc/͸6wDm:79ӢG?rAS//?rq#._p;hevLAă **k6"B6d_?rufzoQ UC;=Am囧c5?nu+=Ճ?nnzex?n=>ȋn ZXi Lύ u~^}̃{R$՜A,Y o!B|P]9)=;oٍ=q9>=;#?0۝<0<zmG7M*sTJvYo.&͢fJDY}X2! hi$XqջO&VlFQ?09ȤdNW)"N|v{%:peft򗛤@6?0ͧ̍*G?0kwj˶Us@Ũ!!`tĨ݌N[⡯;K]A}YK:"ZyFO"}"ఠ.x'`ˁSUz|a*a+>F0YؙvujϘNE|рid7ͽݑ봬u.x?r|42_,1pUACq9B,5`x\ӊKAG)pdHh<^dFđEiNђ% ֺ2f3XBo](DQYU+r> {?nVZZMJaRYn/ њ_Ԓ7`<^%ΒGK8R8H42Fzn4??׋itQ8/N.w~𝎬S=\n|&r7??>}/_|Ǎk7??6>ހ^`hS.lR;??@;epeZuBBvk^49^L0 Ȼ!#%ᣞyYΰrkSakg4Q'S!ts1OߎK<1Ee]U)^amS}_bf:\uQxG?nLTL75Ci>[}/J?0D7?0Oj)?r-ٳOx J\6,'{dcSav?rI/et7R ӎt_[D-nr?nC.-_uYw];:}kdI*_EAzӰ3W1'jgRE^,n?r#~V.2oGw0!={x?n8<.W*ۡ+6ueV'\%jЛ+f89È=L콪Zg QS_u$R:/L#KPҠM= qc r9/;?0p6L +&xz.6}"(`&SirMlŸ7DmB;; Q^1:>ԟ#/=lI~Z5??DQʃ@IUAsZI3_:s]~t"ki9- rܨ)Ewc|?n(6yY¸]>C$s/Kk7znSMr'K. =I?n(o5MD>Ĺ5Dl/3g! R5NO;ҭOVϮR҃"I&(Κ%yL?0m??n}1Dݾyɱ\=[Gm۶h%t?ngLz#c__DED!hv ?ns$'KuUZ&NyFU:92-l'owW EUW^BG*)Ht7G_UXf_I/+>(ov\f1ki?nCEXf5-P~f\bFWr&v{rAaeU{<{wwe#_XwI5 _Rh׀r; 0QqE rV>tWFh޶\DݼQL_~=o]95~y`X@"N<0uX`{yӨ8чb3(v_ӷBL -_)`v=)r|V)wQ$625.Šf Ý=6?0s?0#=0@|m*??wwk0<}xq; Ǽ??EoOBbk?rZ U)Ͽ{o9y8T3gˆsA@G%#޷`V\x*?rNK W-˵7Uy`?n<'Tݐ:^s$󣃽KnmbR%+ºRqEZ eL{czcC?r^{Ca蠳(MwwbNiԸ&?rYTxlD)N}%pF(2>aUu?nVeJ5jC%kgu/Z= C&nvKvOn)PpolpҰ aQ&DFRb#2ZUhhG/_̐#'jxFs5BϠ#2KS__ren@Py)3{,BT.6ڂ^'oɲ\S >#/}OETͰdJ+{,G6,P5x̑f% n +'4~fX[6fCIoG{˯I[ƽdY"0QK:Vg纽:0OX6ꚶy6y<|n;m]tV -T7@@1}qx=[ݿb=~6::' V-Xűzϊ;*|fb ~|Q=K-T})o}}'R8;Iأxш4VbNc}-E/wp8C/=c'/l绵⎪3@$BcN׹w(y=U7{\Nˇ+գ&wz R񿇪JToKXS&{nY6_??|6YlG!MA R =ב?0M(CfjH??H_P}ӏ߼8/Og??xٛ(wg?n?r6K~q>u2~ze"Տ + &ڠZ)O,j!њ !uO§* @S]yykzH 9Hv_us~ʍ͖D[e-ovyx;(eaV\Gs??ÿ9)ʜ1w Yo2UAֿˬ_unaTɜ[_׹??A#`+c"\s[PĦп5~eS;؄o?rN)Soj'^~f8\BNu?rg;m~av\<&oNp?r aiDjqy5(68z{}JËS~X)?r].TZ_L߾~7!и]|MCԻޝeWL2NGMfkN<\a﶑}ݍ8Լ.~-ꯔ'Jk/H|K'+؇T?r]mdmOř߰7t=c_zyjGFʄ?n%(n}^oSxV${3 ?r15po> xE"6@z4ŸxlG!v!U~r"[OኼDMKW_9g&SCwK;|Gr=G2("'Q Ukjěw;/"_U[t„6LEidX[]H]T^.j\ ZXo~avy$6,ǣS??be+)$e.mVF +#w6NNtF??o7EUՒ[ EMN]UNcueB/PØ?0q|k xfD-݇=|?nDQI͵BVs-9KSkͥZd$9 mnI32dm͈TK.<epI%'ۤNQ("t͢f;3rULT(I0F:n4pP2<:ݡvi=M0HFf[*z.}hMN݈*t{"Yt2Pp$`v/z.nw֥«`ۯȘ?rpc+nI kHWp:@Xtj[\pH^ 9 -J0V"??`$qJ>?nz㮦u'+&LLFzapKhH?rCA@Qst}=jqd藭U!>f26[cՊK_]V {[/Ѱf@zAfZP–d[Il6ow{Ul(|ŷ/<ilx?0Ru8ҵa-5 _+fG{a1ue!&A?n:?rYj0[L??e㤿l-0?nIz]f =k'-h}"EM:LŶJ罷(%RIlG0 h!E׸`9eD9%X}$Ebw{8\f{g Fz!)p'Exi^wy\Oe UycJ_%P8J3yX^t\S`:Q^ߟV ?n.(fF&_*Cu׻_&18s_iAXkjjk[?r,*]v72JWpS?r2aO~TYCxZ>O 'gKEbGyUȦ zage/Mc>ᓗ#rkd+z[Yy|*zWzV9&w@ofyֻY|NTj {R襬oUB|Sf-PD!ʤAXҊnimU]"<(V0GW4P+ S ="ffKώwL_"(|KW&U0J #P~rd %Y?rP_Y]".iSc4EǥxExE?rJ7К?r~ѽyJ]6ͧA:.'fHb ֪u=&+ވ҇V{[nMS?n;Z_uLrrP 5KКz=sEz+!=N&{O_.]Z2!-X$[[Ikxx6T禟=RL1)&@qVjwIJ?0uTAcnYJ汎j ]8!84q^{@O6?rY|mUYLJeȩȟ;! fli5dž?0Iښ\{WL3eSwp*wrwPC~xQAzp1; Ky[]%ހ8{+(̆x9س)YnvO]\GbT^J8-pͣs|vňOm?ndž3&>?nyb?0(:e`ը?rl Sopڀ*Z0P>jxY;~TOb|??K(=lgn t:"1x?0|?0k]C($_:}vR$5.gB{EMXAA?rqE=_'׶u[!??~sL<(ḁ"VCUZ oSuDo_CX~[[YBǛԑQA!:+qRS|c7A!q (84 ⵀˤ2 1z3@6"G=[ДTAtwOh+ +;D{?r2Oskcm4j{eZr& ?0ߦ1Th3X𤤒bV5??+3??=??!ޥF`¯,ϐ|tۢᨥ`ބz [:fs물Tx˚n3".WQ,ZtU }*)SJ6p\۪KltwGGó{l ?0@ ^2Vާ ͽW_ބ&4gxF_TLΖ~-?0cG7~*G&!l'Ɩ[Iqlln?0YD<[)补8Yտ_YSeMsӧAUfi>Q~@eQeIƴJ5ʉTGYM|,ڌK#F\7Ǘ hהß=bz.*k`!22{wn& +#?r)߲:,cb#w\f/:5K^H3mZJL&r/g^6Vx7$.NoY[VhQ28݇Ij?0_??Ot+dRNr+{D,ѝF˕2!\5]..!hgoMyیE7otS1Mus42ZEu]̕+KXJ.P^Ӕ5YJTdRڕE0lH/M3Itގs29~5XX\*-[U_Uae2tPl nZhnK6GK.7W`) KEpWUТR!yLER??sl5܏~x~&$;?rCEBUpZX)kh "<`FdOkd9-Kk1M?0n{AOw ,EF?r~$f*Ҽw>?nCyȅzKf%f0jCK Xhf!IIa(\jllb0pcr#P6Ja^\4qTK3V`3:Zf(,>,:??†I7??=??bB)68t`)3J)Јxlͅ:\o،2ЙCFcy;{[g/#}ڵ*&#sDwH 屆W26/g4>tg&WLJTg-?0 N (,a܇U?n館R@3YʹH:t%siZw 8’QvIz9 m ˡjSkKW*%;+eTP>iDfrJ@mQGض^^Sj4PDԨ;a;9Wi*5>AG?00tzK]n-QH3-3V\n-xK5}%E< Km=QNf86?0NvC:M32u \`Y#;r7_"ޮ>?n:ru"8 rLN5Aқ?r"d/kKS` bJm3|oY?r[2`LJsbW`zp1۔9JYobnM GfTi=d[ p(r5\Pnt|s˅cBbDKܳrR86H4"ȈA=Q;fs.sڅ ]/?ru3GlbyYRuX8/&(D#Lf;9G2Eeg-g<Mv"F_~*>-∵C+AdC\U~ʓĈ*` `<y/yd9—vbOshC 2[ָy@$hft+ H)kܠʽk6C9nV+/}LbUWuNs!*;U3FٺsVicB@pFV\fXhRbn$f"woR=_,l7xy;_tfG?nR"9xËK?n u3})?rc3Nh,*wКl&sm8"QBW̿C&4! T??y^F%~T¾s(r1)<"i^m}ˌ"8Pv}?r*7Xr$~%0%?nHvZF8`J)P9??|N$EEy:gy(^ (`O!?rO7Gwi$iv=|瑻]4_. ˯Pt4kU.w=自?n TQzU 4l EIԴFa T!)jQlЉa'w.Byv xyзDٶfEx/ZCXP)rUruUrK tZad=$68̯xJl1q(KER?rVGROz=i\{7 hv;;Ұ3?rį&7QuT"؆x!>Ma"԰\""n?r1.[/$P¹j*7E 1?nMa0Hj'e ٌ7:_>;!CWř_Wֿ#lS1ߴ$o߶ejNߡY_lHpT>affbTk8jםῖaGo1J%t y[Icw`.[ė^tiG>ńMsI/[7w҄-u60f3!t|0E[Ntu[!2b(dxgPqBoYӈ!w݇NߨC?r_rlXvdw+?0c)ݗ,u+d1yu'j@JJL>R`cnQeȎ\j)?r(!yD~sĻb@sfb0f3hrIQz"9qMxHʝ#RZgl1􁡿4o?rar{q1Y'>-NI'P:_"2'|??n8c?n HePmX/#^ܔ׿&q@C"اsQIQ(UFlO>EBxM%nJJ;q) nQ\뫖&ZZ)B|a-h6%)xD^w̺&W,g٥e28ƨk0u@ZDzi'q@hYk鳣{W䇿/[a+զ׽>BWB=??l%xm?0>cyDf>giTR*OP0Isbu_'iC,Iƛ)S:h-oZr4DFyoozmo+@?0eR)YzfȪ ϋDP0}@ $?r{'_9+ OZ͊~i~ᢶ޲ $JEf,e_'qS쫳"itӭ'??~>ij~Zz\v?0apScqԔ&#Ge3!H~ FUmu*d7pA+uDLm-7=]D>.xc?0??>bdw?08i©Cg??msXK#u~{Y;v6@v}х}C* G(-4H2??$a(GdK2dZEHnF$u)e[[RNjo&˖֞kkiFWM79dZ@QrCFL$n??}_'D:ǏՅ֗-ڀsLu5.5/5 b_r0ۯi{BI3Y%^mN,r’# sk)|fs+HBhPp?nVQ~kZ+K?nIvߟ9zr|ct˿IKһ}D *qm&YOݶq(ǐJ*3o<Szv??N!yI(=(.:õ,ޫ'&JL?naRUnX|G& brQ(O^@^<?nSR8<,(7"Lk_{/_}ÀZRvXf6F^\=}ɋ@3q4(H#8L84yj_9:~fW?0̩o%b?05$Gh|j|?0TTZOۙXӹio{R"+PZ=c̩ RBU6)m*:H]Rs&+?rMZѺ\(]K"VvZrmu%4]@Fv5)T~?r9 8?0;*ǥ/PWO]W4&LLp_0R%pj4p1@g=/*#h҈E֧';**ተΠ?0.00IpA?rg̅g@(͍AF9Y$ q6Ќ)Ⴅn?r5/ya1!Vsbה9$ishh~{rgG]`??MKU6V𽜁jQyͣ 1?0@bR4f&KqZ 3rGV}Mv*ߍ&4T&!:3\W#|$CoIegVm}izH42?0h8Dudn!%=cb₸0*>??^}Wt__VḪ~7O_'?rO^??~??ysB)唴qq~؝ yx{AtGT$ O@IQ]i9]/^D^ɼwE߽Z}u`.C}z߃+ܮ&ld'`{*{(}p;5^^̦ZO0Q -Qϳ1HFKj?? !#0.?0RT}\[2iX=Fc2>~%J7[ |zN\mv[ɭXc]pF~??D\o/'0-5. AT}k)O% $*8%Ǣ(w}M/)c*Z[δHfj)5A%,OxZ"J359Ï&W#z,EYjtP3ËJ??Q<)J)ofWQvmh;tL cO4wZ`8yb=#RC|aTOfd5Ƴ="蓺O??M SDl9?nMjθj⌠}~TU~1$GW~rw9+%W"|F&jQk-??R_lݔcqʊ!S7&% 0&g b9B~IEHݩݕ7\)ZDx?r{񰤐E*ED_K)B[\?nA5CY0KN8ĺ\[| {d(@bh~xû_>pgx>v?rR( >KgT49'WצcTvyVwrGȼ#v%!{_J!{.H$\NVN}={KGKRuе2~xRWqY[%;^zg2P{ (Ǟ9?rAul{GW#儕?rJog!|Q??zFSuB+TT:s8ڎG£Z7$43! ˽+'FpZ$-ΟWsOEOqI9E)~My:7y/|CsV xG~F'hF,7APPT(d+AIpW\w_ Ɣk}Aܦ2_ %|YAڽ|ͧV5-~N;g#S4-Iۙh,UK 9(.iJS1_̭qQ~LwW[ 6eL=UOz\cE2{;W|zxt5[[֩JR*t5(L4P?06G7}|B7N^wqt(~i8?n,Ȕϙ&<_ctqIL&9M4-:e޼| *‡?rdo?n8BeMH-> l^xk=W10t.E9Ŷ tD``&S6==ţ(cPe 5J?nQM'ӭh8[ЊOӑ??B]t,g!xcSOl.jDA*Haйp/F4@WR͂^@ X 8IVƀo# ˘^nzmRVnmv`/-P>MQg?rvcT;|;Ԁm?r?r>Xx5R1DXsF<]X$BLtsc-Fn.#{PQM|)w`pFKhVCV*BQw;96l7{s.h+2EӖXMU%\\)*HI1lW9"÷iq&RG:GW?nJH؟z2Ζߜpg0Qjmw^⁷mXb`*1àⅵ~s:o_<ōu{}2`N{'ïI6??yV$mQt"aɋW/OHBў`(79b2VLպ'8&dzLt9\hTxSJs?rO<XK"niw]3y~){ۡ}@twC_&NP=B݄iV 1(@ kxI,x&`A4݄ɩ(rH'0N: M0!xBN.Z~rҠ~xq!Kj2 W6*u\9ɷqgM67{Ͽ~o:[{e`}3<8+w&V+śmlsqHrʄ^"}?nwg_8| [rϫ)=3 rT3ȻgKt Nz0?0t<pV/|j"Qnk5a=D#!,ٻ*S#ǦecbOA.aކeː0>#`w!GynڡZ7L C0y[mu#.>+X]jC O:pRǁ[Ƕ9ިǂ}:1}ʌخE+gS5,֟].eBJpY6SȖZVY??s9CoUo1heɔJ^/ߪ{Ip'WA«9_z؄^i-h0H60- JzHgg肤(4̛I6ܫRv„+8j|ʬ:4+)?n60N-J J4r-??%8E ZBD@?0ml\C^@أ~[y??RT]5yוPl`"a1?rDi$b3cKql9/[(@}NLg)pHj/6%D>ϥC ft2HXN9 'u'.yΌnn[XO4i]*W $2'_U?nX4ioIaR6COS*Kߌn#Q-$PZ*݄?nߦ)]oq~GCÁ#װ2lHjRw#<fʹ?rSJ2ASB__jҊ"B} Y\E6a'3KttӤvٺW[PʩB$k/=D- ]RF4'DrUx:-$$n5e}6LL 08Ug9sL ,zl($<ږZFԊw IHz-dQv`atABqOE4a2<ީB*?0C[eFzXa1, "8$C*O~NQim{P3N Uʊ0Ji:(zg~%T`c}H(?nFY)*o]Ct Զ_d)(<v?nP/o3&g/1Jy(KWa(QtU[VQI7e>c0?r_r5fvP\kdnߎ膑1d$??M[O%??S>xax?02AnǙ]&5V@ *uٛk(G06:Gpu*=||Y+0~qUǂS ش^'%<>??*QBxXTp(0'j< f2"ɖTS_;MN7ŝd#ȆR#zPB3/yvUʶN$bVT"S`?r6V>?n;iP 9W 3Y;,KAdr +#l:j2(,[1q0۸^[ v3TYʵh&YL#mkD akЬV|R1'`SQA?r üyS`e{&jMw$uxe9"#rf/cTI#9d+h3nn7 Fy9OV@=cOhO{8.~I_U޲tj6bA??yCwm/w1ػQZ㒡1YІa~-Ɵٍ7oyˤ׍3>f,Е#YH?06?nT (xZf :It&OI ?n#R.nbv~ѶF"@Hdy Y??`j%\R`?r[WP{a]{9*U~_1}5lCrX$=ZcsY,0LxܰGl7^K??uI3Od3î"sJ'ǔ?? U=6\۪;k?n1^ms ʃ}ۉYӶݤhl%sj~.ƣeuVo!g| {vs۱ͦ?n.l$4Núr(-7J=ᆐN T?nb@:b?nJ:|+pwiAg3{Tnl )ծ[bb@͸6!%=TNcAQ'x$|FKAn6Z ;-Dq?0 8wZ|)8Ǿ 0j?r,ﰼHM52w.rN3!E0[_ܔJgIUxē`836T/}LaA5DǓj :?noxorB`?0$,?????? ƢKQYz7,,[ׯoY%#?riO;%m:a;vy ƅZ˪Gl"%"")7N9ÍI}%ϰ焪yէi69.[dX5eo{*PFx::9Ԛ|hQ•iEI??I4j={ƽUCِggΗ|:om$,Oj|L bd3W]α5k>؇XYsnB~Zs(s?nդ/VA4}69sAd=vMdfBk%mOZd~uX*<̮m$tQ_QxeV%4?0NeaHq(4WjSVRHF¤K9r3t2bHKoYs,kC#I.R١GЉ8ST._Lk`Ƃr_r2+^i܋j9 ;X_.Qcx,??&~3ǹրy4n%⎮+V8(NC^/n<8=@HE|y32?0.)n2~7prP5(pkvPD]G Wcov]0Y&ZZ'Q}`RgA{{_ܯUX^Hc[R)AP$?0Pi7=1XٹtiLA<?ry{9#eGj>Ķ,ޙXZ=0nJC|(ΐ~Q4_$nM|D?0?0bbD.Ik4rHNHc&)CWZ<&ϳĈt~6Z(L|'#䒳@`(u7~V,L ZniLWVd6I؛@BIh5V4HS-ip '(p (M;t8vE_|ax\۩rR7/CYb>$R񑷻n5; x2aQKmW\Frv\M?r?0&@Ȅ!Ft佑ZYZH1Mi#g<Alf`ꇀ,'S)Jkongt f '`|3PeXψb?rŶ9BA^X Qlm!mcA~< S/d{ZaʠX5g|"q\˙ȝ=J$_L .[՜)2튌 |Eu; 6SC2Ȯn6m*$Ç )NSN#y0*Ew@S ?nV՚1!ܥ;u)iVv0P>]?n~ Tz^ҮWge󸩍mzt@AnnŁA <6?0?n@7EHK93t3c?n_tb(m*\yŤ}ݰPz(^|6@(*Z@e1HY@vXB2%)c"icZz'P}@5 Gp辌9e=5> D:D_B"P3$P,fi@??zlVz44?n].NaE_9F]2nV_e?rޘ<{]l??PLe_˴ NJkk=l%ngZm;&05]v[,<`&*ڌziYzI4>,an1yQQҶ9Fm????eQ?06]>6=E! .{PEչ11Դ|SR?rӚoLat+=g7>]B/MrLsT +#hr[ snqd85A{*MrNFbjLS9?n~qG {[?r3ю4[l~sX*VޘF{EN(2*U"t؛YU߁k@3adNv !$;5R=UHx*C1y'0KLm߁*b'Ty33jIυ4??hu@<5MX8 Pq!:h-!_i3aCٕLz#KV2$/!ɪhbA2s{0N<ѫϮЍ ƠFzЖUvdB*SF9_H]A= Q$sRv=#Q >%$878a˗nB@r;ǴKH1f~a}Pw\uQ먲FzJt˗Cz/FW̽6ܶ?nݯzRLZ+ 3?0,dn-?n+4-[uf??I]* (y'aRXC b8 /8C r$bVҗbƤpwkLr|0]NB`9YnM4Wyvg=?n0(͓篞2??IťFm#, J!f~²Cw>ZpkR_<{_Ia·fA3bwL9`%!&dW/Sn#$bf{!˞F+]OGB5?r~ u=Ho cZQ[`P~2i^(ȝ7i;/;ir??;DSw9tL%?r?rλdXPZ4t襖m;Tx2Ƀ*Z޺*U&eV8K Ӥ_[Jb;5gFGsvnH\)55_x >=D.`壜߼iX¦׉@fk m'0 k?n.y/`[0V6fUoU&n{JRa;]NB|`}Rd,JM2!!Ey87|67ٝ@@5Y%s4C-Uu婶z-$C;?0op(rLkeFg<#/uJTo.&~v۶J-~zcObU܋wMdY4 ՟efŭ ؒ?0?n"XPw9T lviWOm>͕?0/->rwW{/) REPp]bT)CjǼ{|msSv'/^ǭ)fk~E5j'a2MN-}?0~P"9Q'xfQ**?r(1bm>`nCar%+a2tCyAS9͎#'),&I&h]Dt(f?n!P^zH{YX&$ 9K<t¡tlӢ+P"Wݦ,PBB2$UEJ*eBoE$~ؑ*bGN="b, rT@Q,!;]@Å mL %!h`5|Z xGۣa}ū  x43W2J"7*|4̘Bd<,,wSTf3ϝgg*̂ \/u::Cpy@v9tދU<\ssk Ց`oOڱU7x:LC2gspB*UlfnA$p\ڰ)1<9ű?n*kIi'8썒,&^JzrAy0H v- ~,؆7((h??&#\o`ǡznЎPj\2>^ٕZ?ni^dgYl#?r#YK&󢿌`lZ$[YFءϖ!<6,~g<\:SC VmA~yir4.ĭAOk4E)nW21yoH*??ɖxc{r b+*6!zx3d`XB; 2u5B-(BaVoQm]|U~4]6mZ &4&̪_o~r`bjpks,\5č;W]Ȋ¹?r+j??GثEyD!^GN$biMӍ2@]][y:FyH,A򞧉NДϩxx!^^h ,ET?rGaÒg|Q#nsec?nc=4)$f?n!W!@nJ=j> Ao4fV۬^UJo&&)a!v =??&mrA| T!_Ś&sv4eħC;3#Xͻ:R~' /Z?rc??M5??C聈 2tF˘ް?nΆI m=wmN}T<)rHSC tp2`JEH1=KL1Xg݁0+߯y>x?n7Mec>\bŏ 0&zyy w-{`~?ns0AVQ$\̭6'GXO\?r/s-\8,iض#zV tCȽ\f,nnmv}5kX+}dec+"t"(2nnyH`| XK}ٍ 2 10V[n5bf4("-v[-* 90ώ?nZ\ 2(?n;(ž/ELgy~7V@AV$;ؿ0gyNrۧ[OW6i/!Wla4rܼ??Y;7#wMgVd-fZԹܦ~̈́?n(j ԃzU x!heԀv??{CmxvJ_E9FL"@cHvg7^?r43c-n(ݎ2!含`7Xsކ.n=NüAwmJΗDf8d5x0%1ú`]X֊#Ɛw"Ï?rt??{ bdȎEJ(%8Rp??'1#[1kO*qnTpr+C/!+T&bS XC Pt73K?r:۾alR?rk1$ԊEJϋMPiEUԫaC{<2PL)\URBZmPE\yLVsWM_L꺑 g%>1K3'[??S[A~&Z"t]:Sm&f%~S].$^nt*؋d"h7{/to[[2C??vkf匂^މ oi Czz9:M"EGɍ`+=#·Ξ;9﯆ӟw8Ά?0mM7ǁsTtck 0lR!!Ui??ԭ*s?n(O^=!kĎ׮ѭs*:R!㞔)X^6zVf݅(??: S/un\F$@a(,?nWk޵л/{|0ϔ^л cs Ej;;,>h65=sE-LZGI\R [\;k eV~=)ܕYA\;nZ5R.j,$u?rT8K2٪eYq ?nR!8H!naYW{_бdH)բ˸_WTsJtFuSu{?0M(.{P'?0@~_5F?n?n:3$ltOhظ%渐Bn-YIAJM署f `E*I?0s2Q+*&^O^3N-'uL$'=V!uG:%bȅ}p~x*@gnnɭ^-#}IEm*a|WE.Bv Rv5&5 nZ`ͩw>v[|rP-FlYݸހR:XKO Pzh-$2sia.a諦^=?rK?? p; QF~zQ;ux[,wÕj$Hx{5O"ź9x^&hFAD.S5_U!P>a6Gq䆌Uߎ?r>F LͼyQ#/旼H"4:sBߝ!W^,=/ҙh j}EV{qlƚMz67r"@b])wOMGz?rz#]c]^_Q ; 7]|SC%>0L}SJ>H\6N +?0(v[b8n-D nOvu??,y5,eb?0ר'igh9??,G|C$QuAr&c*S:??4J?0jZaHva?0XLnbf!"dZՑ85bs9/Oy- hK7|61Y+!(XZ./Ry,q6E9;XVwմ!p}a?rnA930$o,Dy)/B$%+*??Ugv" nV+:Va~D')??@b HKiNTVɱ > lwvr+ H6|CocnPD$FZkFʷJ|F?? -l(pWNG*-()̑?0bPV{͍p-p/v@1?0;M5H՜?r??Rdr@%#[-d̢0r ?n?rX}$4)+ŋqf-%#Pp?r4ZFLS?nWR ðfSW>/}aHHMr6 8M?n????]zi=Hel,IQÜÜ*hOU#hD>1\nGx00+$TѱE?neilmOIO1M)bCtMۓ ik?r vBV(G(~nϿk/"byr|[ys* ZڂMe={lHW^ ~<]k74Ir咄k_2j[%/2o??J}שx%K!HzQR*t/AF&zv\W(N%]?nW+{Zv6Q,~+3aZ/6c = ._$bǗqNǸ[Slૼx̋W 6}w4 7!S8@wFN^̛zkgEc'GG^W??uEKI-7hDù5>?0"o],?0S]Y$Z rk~>PӡtqU;{Cu}^'SzbBiw<>?r紷;E 23qotDŽ?r/.GCyί&g@q1~8ο0|e]TB&3b[] ѝw)3g)h!1ԛc |%.ƃ7O?0 ՝gDsr5υ4ac0H.xd&NB2޳+9Ydi`*K\k(}HJesx0$I%%|1LΆEa$|Z;QxSܔV\OhȄ$fȂn*3gxr V{y: GȬ)mѝvوHI@ǂFx 83ޙ4?? >C`OULf-?0Wc_/=<+H"A9OJ0Y(a+pJ'hh|ϓVWP`|/-~&Gj^8.EqN??zL+`d=!k;_.GloUG+d9l+ac#)~yU<0rHryьF̨-}w!>Ⰿ3<UDJaawI9jym*E$,Wh3J)._.Ju#@n;-U+lB4e0`44 :R.w嬽zQ]??~w&i^8Z+_ì Gv(ۢ{-ì^VsnZF:1OV˞_V{s5Ҹ{RX#vk%[ٹke_goWP3t_5ABQf??ȏfPXn4cx޹;?r??Zdи1kʼfoV)p?r/dN/8os@P%Um޷&bc4H?rU᳻dY|O{nosṄm Jg Um'i% .°4"<mHaso^Vcrjp +#q?nWuUeuCF??qk6w %>xX&!n-1+m.)ʙ;q?nX՝(9N6/V1 Ei_S}xSrL#|lz!#\N'6ugQV ˟xpn[GEuz?r]~VktbyU C}N bY'z7L(??R.fC7cqu=-lDnzb!|Uw.wֱL?0!D #H`\B`qB۰տ9oSDt(oǦ\ќXCk84H xX}fc[y2a3_GIsMGmm|ێ$Qcٔ"GPp^&!v`7KTl/l>0_E>Ite .ݚ_y!γ?0"zi+\.VGYt|b/:3S?nPq&H}:V1 J< ͗+6n>62$nEs~qL307{-wQ+KJ5$RYK,@*UY??njs_C@ α #WwM_TR+EG{g[V:>6j4>Gtl{A.p?rsDf\r y{hr?0,P“g<D\tw:'2:eRTi1U/Z;]M ´R ৚:M#2ev=׏6BuyíY.n;{fO. ̜agc[*JRTYA?nӒ3y?0.&3\mn__?n)~k2Ez5"_p` {Ge* +,oX4-qp?no0ȋsܼdyTKt-~҄4k+%F4&A\a'B3g?rFL0LhضZem,bC?nLHۼ$-YbkyX% D?0Ÿh\y= ?n"̟{i90p3Zm3iބp"PgUb^BWl>g\يW6:zfW4IP\k<[^te+ѓb+[Wy倶rSnбWA>%xVpꡃSmr?rH S kt!WĄgci_NFۑ7L4\Y 1/{^mٹd1卅cGa=k fY:sUY}Y \bse-ŏ/kVf̙eTA8|)?n63\eF&Yo@)7]vk$=h.=.Tͳ9l T7Jඳpľon*uw" y(,Ӊf}54h{xl^Ix;3W𢽂N?nͬVc>Z#ƕ(lYeX_\ Skj!3!J Nԛ?0JW⧱N$??@?0D,) tãE?r}|H[O(+hţZ~m-耚Of0\Rb,'P3[@R2IN ?n2Iőb~oW1,K.?0{J稤;XgP+ʓ0lvܵp#AЌR?ng#[R-?nl=Cg3p~a6`3T#Ҟ*P B4)ml߅Wؚ!C/C&h%켦S#NFFԉ*hns-I.s0 8|i*+{ j#MPEc sRbF0YD?rLBo`>}:35y4??ܙ}A~{ϟog[[[6H$Eѫn6AV}azڛ&??臫~+srd֩/;kR3M80#}aWf"~tq=85إQ6)Beo]?0MjћWNӒv*zg-I $fgG9n6mZ7a>f;O^P 7_ 7oWRX<|b4mnn|mNu~Szut??;,qmvWߵq':]G*ch,pT"]:'0]=ӃWFf(t0׸Hu'dx2Kq-NEAV6w03f9ߟ':hw$b{ًd1}?r2m{񰍟l +#iF2?n-w*c()ĦH?rkjj4P>&e^N6ϘIrvÓ{^ڥƵfb76bqa!L1?n6PƇ9 «ܷ P8ϯv΅qY/3S7ї]eKf7\4xs7O3Z 8@??10KЌa#F'X^rc?n<[gN`Gͦtu_ ȲKOUo|r%NKkP& ?n{YԢ#V?0,EgYKn>3Q+00BQnX]?n.mpx+JLWWQc??~ެa+<1*]3>o>9lF}91O|C٨ḟV530_eDT?n}c??ŪߕXMN??%d:Kۤ7O`=R[jx~]r=%뉺+Tf=ҨQzFQbf*[ctIKԼM_c=]iI'm>zY:TnMs0A˖`=fS֝RVwqVraV>`+G/m-"!=덯F{Ym{M_NKac@~gkk8:V`/dIrPԃOj bՋ{j.RRc)M?r؞[Mn>??VZGP)c I+ǛlĢzq^eM?nEa0B*q+-Pф6+Kَm.t?0^$ (&*21Jso=+¢2Z,!,Trx04\2QMIQ3]~xjZ'&otw7ʕEkP6"1k!G*((+\?0|8Mp#Fԣȵ^C`t 4{.U9 A|????zy/I[|qoWhOO7{??9|׊Cb5M `g&=:w^D6mq]}Y2:W4}nhymsԎM`4zhp0*.3Ml?n4&*EY#Zwr˔`_ӻ%sSp1{'ɘ?nryU0;ަU~26p*ܥ55߮q`?0iWm 1v:-ܻ4??ysKrC9|`>0LӃWqgK.=aܺ@QAIr5, :5-OSnb4FgC x"IN.W#}rSOvm?nF@3e alKP <=\Y&jB?n{`wwF3dTD-/dSqtwof-IC)uu lWGVѳğ>:d٭/:f *@%]F} (CNL"UZB%#HnGɔ[Q.Nc9nGHY2" SWvFNͶauS+8o$ ?0@a_jv'\~M*}X,ec-g~F);ƓY&WQXwL ψUĻRH80I?0"(gPbR1A\WTɈAz8g;?01O{1995׷}FW;1 $KB4tcO87<3^Z_hIrH'w%Bf)>+95hMs]&A/??1cYw'Wjh\!KQ%SN"/s=;`:>c(|0Fv1x؍˔&:LI??I& *MZBA!24:ג7tO5fdQGXڼ669]gr{nd.Q[哑?nA`2x%dLP~u{?r[( 9ꔯ\&ԛLYVSl-2,]2l(\CUuQΑvÙW*o%_??IwU0L*bSz'Z1k3+?nEdx=2xAp){J ~f,#pH".B$WѼn-g?rfm\\UKpl,ˡMd- K0.iT[fwUWdsiN4?0Ch҇zP1Q7*B`41e`٭T?ntU41>iG̿(ֽmrgĮȅ`We0oZOݺ6[}H%W1kYF /Wn7'݄,3Z9I..bk4#_)>$ uJl܎_ȃ1FPm4SkioȣJ, kE~#{ϱ%<x[8zsAptbg̝+'?? g??$L)RTⴧr$M٨xeg;6g͹BY\jo2tB',+·y17V(yK[i+ "V[˶εHYM F{J>|!i*$?rzhf^ʉfЯbH)xTkjYu!Kubleoۮ,~c??;Յ\n={LmdQwK d ?0@?r4?n=Bv>??s}v$]} eb1J5f$#%a8)u*ZݶE"}|6XV]<?n?06؉V ~ u/h{nYkWF׏r_7m??/~}[gJE!XgT¥0fG\2%+??${ji0&y3v.c*`,*Z)H^g 3) 6??TΥ lMX~1]d5Qڲ#?r2w5$4WSkb?n܋VsR-`9RYԡ`>;Hѓg 2ţh!'UDCJ=OU-???rg1m(=k3w~G ?0$V_78D_w+ф"jիvNj4N7;n4aw@fo]o:/»(0*3ngi28$/8UM>c{FxeCSYQNԐPN|8zjifO7_lGg,ur1[ÑwbӪێ,1v?r>t7wPÊ,3f(iedK-ZL&&ŧ=A2a?0)g)IQL!C¹E>AX֖CcL7)7+.W>]x1t4 3m<??7Ό`gy $K"x(:xEyllFyMnOd:e;Iڏ.qt[3:$W?nJazRJIAHGsJkv::$aչxf<l&8D(T5EH%[?nIak1ݪfgU>7?00kخ/Y{:wab)8liƲ?r-˦F^P്,5JkXz#k|oޞ6d[e 1f?r֢{)ilʷq,n&ɇ5I*q:U+7ubλL&4Y̵#'ueT(Ϣs!T6`A(&m&Y+Q7(fIͻafmx.~lAhA`MØLGJǚ/m9۶q3><;6+r83"K|Lwްb]Ɯ˘W??b=g9Σ.^H=Z,Jfn:<(G\|_Y"XV.b6?rN v}Yfq7;X¸??,A0~䍙qZ]y1?0c2N Z=ԧ{0r<玲U< #H( [>ԨeiМv¨|lq= @H{??#WpE3!<`j-NtfӐ,ӧ*t\,ϋD=;AZdƊ܀?nՆK*Pa)~n3??r0Au)Zj^X0Y6CBPV.?0]˅KPRjh?rUve7fïb; [WE&%MuCO;6gdS1>^~2chTc)]5QF$'miMGykYv ԲBl8V 9?r Nn/So--D%h-)ԢB4IqoRjDѢODrށo~Y??Km~ vϿ_:3ndtr+c8Q(|?rzYH>eF2PPyga6Uo@)45mK\f|,^OoL-uzA_o{3|ǀ濶;P9-_'~mn'y79{WamtmƮ??5{/Au6!++ [J|fQ׫e~+PõiFLOȽ dގ.R!#ke;f4n^y?nEy  l;u6)K`?0GL5@AXrURp?nWGIcX 84B}ȷٙU%pQHG(4oھl]1,ƭT&h?r }ڡIQi?0?ndӳ->+@mLVELC0ʀL?n5Ⱦ|?nZB~"U*$qܹne%0TASoVcce?0lL01g?0d"znཐDbpR@(X{"M4O#j&fxijJcG7ĺR4)l ӄ gs?0!34{SN)e"1T&ǻ#,|fLjM+K".Q??&ϔ#Ů7ׂt{5QNh06yFL-)kyyI9}{;ҜߘjrAmJNS?roԫ?n?0ٓK(a dْ[<٩?nyѴI?0L$??>oz8mϮ0p@G>yff,e`(m}<"IGh~I֎bZ;(["skWD$2H%?rKƄj??8???n*Y"- #*)灅{Aq'#/cHDl??2]c'4>?0B?n\nn&wbO 曦[ק?0&ᅄ@éq:k0kǠj6YL_of?0PJJ`-4N ,> 'vgHYE*)???rd.:?n&\(*kb??Bg< 7;bye۪q1گ+h4Q.99{O'<ӆxņ)/[}%kq&ko1$\b*Yzu*Ѹe{Q{)nf925&i1 Ϲ??a?nݹaҏ>}&p[G)CVirx_͚!"c7gR9L,5@vA+1Ƿ"WT\jv[\Ôg?rAVQy6;hص&zy.x??uazr#-k^]Yܖ0V ƴQ%^|߬q?rEH{vs~eʈALUU_qp;/5I6QUbӞS>3cjV>jM!2cV,Z!op a:&i~ ME%lQv[g3c ^h; +#!)6wǽM|*ppm71mIO<xO?nV|5cr_V3]>Lnۊc|bG|֕5gM(ܷrzf4x~bW^b-1rA7V;GAmoC߭-n!F0BPB'u??8N,ʉqҹo\ZT~(νu YyDJ׻!%`j E免}XU^R>E-G C%pt?0eyb*n&3\ 맣tV#3@;Z~#W_1 He$i'[4?0ϒa_# L#ip?0$ЏM4H{F=lu6~s?nI?0e3 I?rgj#ωEfd;}Pr ;QzU%Қ4osYt]VL%Gg"^L'"fpVAoNYTMG# z4?0cT?06a1o%]{a,Tn0dfga[I_zz!b?0Jǽ{d)Co.):<%<[m:?0d$ё ٧{@2º?rh"^f5\Iz5$i:=|?0~-gTvrp HlGZ6OhZª-4N3v/sZ'֪Ooj0m޶+hg5B֮HUXbIj-Ё?0홋V13%6,Ζ?njd(8=?r*hyhldbq'wً(0%*h;~H3@n/H%|)_N?0p'7u@$Z3cnkt@F5+yXJ-A0iNf;R|Ѽ?0mLl韣@?? H[!T0chbޮ<ʺ2fZޟOz\)8J%2༷P&K&$.;tΠuBi!jB˾?r*l*V0R)%>ա=nS'a/gCd6X[ۿ,C%ZpvmQ ,/f'+oDSnۛI``SzEm?r?r t1< =]tFC?n֩Qhv IpN]?rM @??q;E?n=kAeH& ] 7Jr}@^,~ [?n/e/iuazJO%;X9Mh9j0:H0GDI2Y95Et7 *,0$z"# #0\|mD5Zv3SJlaAQk$%詿!iiny,eyA,hlY6- qu'ҺQ. ]3G-**¢C#_(llVea%S8duKl`O>1+ON]֌Sdk'=UK/×ӓ"2e=?0aΥu+IA??/gD]B(]{T볬ZLUtKȢhUo>>6Y#&P3k= 5L()Σ>$cm#֗t*0xnRcKolzh%c8RMe%ʑdS),bEi5o^ZkV;zڕkgJwjrmTW muIHDqǦϐvw5=%VULdlw9xY~5?0:%u?ryg??f"J-3 ſ-a=.9ߍRБמ\_;h7??[٥[&j: \cN??&ш^$kq.FC38D.6ϐjbk"ܨqxXp}]1No 5'LlSVU-55)3ދ^j??F[x=$+_?rZIk R1x)oHt%% {ڑGWc?0[A4_jN.L[n}atZj!jdt[wo ?0漨[ggVĬښTF??[$뇊[M$wd47#YʵE˼r/ %wFDRM(ҁ[4K?n ??JUĕ^xac[Zr!Rm%`筦uID@'QDt]A*xSvi;=&U?rI.TVA-%UT{;" Me!g]t6f6|_mm[Զ,FlTw}A毾;+Ťy˷lֻ+ofج%* #}s.1 sSf?n&r/9hm<[=-SsZ&9z]?0IJg΋2iC]+} ߼waWy#m-}KKHJyDV|mI'{T7dqKI@4uHx9??u}f lt198?0L~n|2;r/ePsE4/^eK,7)¹,r㔩; Lʗ&xr\~ԝQFw?n??6vei,caU?09SXٸ_ as-bWy]K8?0?r77FCC?nI:K2&=C0{& jh}FA|)P<+'aa5/X6jIh24MgOig<$ocO]GˬKMw8Q.thKm?r l"ß?rA\Mjk~QO`޽1f+ʂ<eמ,bUg2K٢&=rEmR/ś`k6 {^nᏞpXbVYYB/B }ixFvO}>6Sh@˒xŒeE`YH܏HܶL)G7lXR4K6 01:꩏??iQ[<d`p5AzOVKP|@A*?r!zb?rI;rOc2?n9>):k qxfYl /,~f> 4=?nؽi'cEa>[eWUo̙Ymkkn0)6l?n?0z?rX52Nx##t21ȟu^*Yomc^??uݢdj=+r: +#8OW+ 9`Wm>L[x*EC6'`6;ir@&vr2.~tHIq\A<`mk+u+x>5>4wLS#C%|𧘔xA?0\?ne7iYlkGM-JFi+H,;N졈~yY`Vwb]ܙ~ɦG1RB:kR3BŀtTX@FL{'=ddQ?n-yy;eU Z6Mihrao]?r/UcVL[1u.`Ax9ew9=sֶm9ՁG1Yrp??pLjϘ.§p 46?rSlt! >??toOإߺ.%{w,~IJ`MBn[┋~–mϢd<{s#,p 19ˆ4WP3EY4Ù#ӅnngD^uJUS0nv>\I?rEA+pxq@i 13XixzL<818f:5՗0EW]-C.#J$C'8Eٳg筪{ʒ@5ma鉆皒7Ϭ&3y`.}X)ѭbG5Dq!fdi^p乨wơj>TA2XٔI6kӒ$諦v)*Hв)md:BDAJ7:"I|)ԍ6 0p)PU?r/_lTc%^"WG,?nKzQvI'N +ipE[/Z(??47$Ž\7U:&KN'VWʙn5CЏ'{6y>z\?nv/U|c%8AQ+b0r?nIX.xI'#dWW#ޔ'FKm 3l26`MEɑ{?rJBoIU&Lфf?0jvp&'/ \+82U}Ë,Inw6gpҝgڿ t =mӎۍ$I>ܜ֏ppՃfTK\\~}q_?n~\~sq]*l9DP?n\ʛ ^neഘ<9^`e婀ʁ=qVǐ042aM~yAAϱ-cwsNQC(\|xIQ}lgP!]V×,5FI_m" 20 }Fqpil%ȑޡ0aD]Ww~.< .=xohiԻA ۰Ltf<ߡ24=7 W|?ns(|TDac%R2^6*t@/H_`-un o'#ӎ^sf!gW澽pW^; 4jjCKДaUqa90??n0E50*m>+28ڠ]W%!q '2j$8\eIi(ˌ&5PVS&#A]3לjG?0D%?nRק|W+Ln2.(?0m*zT:i,}rCTЅVz=BKnǶ_Vcw$,5V#Gmv/Fwxz! NbUJG3or!R9uPrM~w{kj!aŬL#cNnʊ>9]j~w([\~X=9)mRU^@} 3Ĵ2E`x!<*;]R8ǟIʁ?n#k?nt-'MۓF_uc3xe6k,P${A'2ۣ?r8vO%1.ΒЫ] F2EF?0KAVaŻvEt*J̞hX͠IVtIy+vdM,u??um{tjvb.(d;Xk~o]50V:)`~"x^W??.܂?nV8BX̓?r~:ۥj?r?n5RߪYjZ!!CCϩ|6+[X*ƀ,ClJ;Sm#4gB7??dB9흆?r{d=lyWl2PSRba7#w+.$wWk"8 l ҘmR!nQ:1FC bBk&SgPvuPp{Vt `~Pf/e i20nK OCXyKG%d7B_ʑ4@I#{?0Q+?nf~|',aNc:ho/F!M"y:?0x)bIZՕ6.v; Ⱥ&3!F?n%$J mS&˾aJ9QܼmUD%z6#Ė 7??RQNcj#ZpK)~tbcҧarӨʟ]k:?r=է*]7iͳg&Z>Gw$<0{%bjVՑޣYi8Yd23xecP}25 (jc+ӑR%dwCzL\j+Vi^-sY6XA(srF[)d>q4~Acu%r<]1kаKɽ$(.x%0?0V &\ù//Aӣ(FJ5[6hkj05S?rD*8֠H;#ҏDX6LΖ?rHѶ&S wi ×YH]{'_Ќ\>fojK0cC-"%Q^G5|er@Z<Kr8ýM;ehEVV}j+g \7Ex)KP_V&=NoQ)0_`P{(^Rb0˙Q?0?0qw67SpWThciug؏ۛ#KtYԒTbm#nX?0 g[jn~Cܺ?n"k  ׫v<ֺ9Mh24m7zwnҥ%zR:ݢҒ!yWV}AyYxq8{+}CocVrfS0ϰ;gX ZAMdnq(ΒNWd͉̾T'(_SRo??(ώ@.̓;^Ǖc`eB``__㏻LRܲ&N*!>#N`L_KqzktyhA?nՇ,8vN+dPt6ɱ.w?0!:+tu|}Myw΄q֍D1.,U|=;8xٳ.l K2ߚ0n"w]=#sä,E8k@?081g½lLwfwtlT UnH V4˜?0Jjj>yAY??}[B RΔh,lԮ>7j9ꮹ\,'~RϺ͞;?nA_WYּ$r"vw߿Qbv{׆Ok`0߈0Nkz'0SjI?0^Jl$yD_.⦙;m>?rL:?r" ^c8-vi"|YAk^-\–W3(UX0M3kGe\;8#L6LiV/~<==lv"N~-P#xD{hoګ/ݿoeM2-N(Ԫ>U~lf?nOE_݉BUbZ9?nz,?ri8{3c~lC铕wm $?nЩ~ ӝS3<r Ǎx?n|NL_O ,B9>9mkmf!e^U@*Ì;|C.1}Ь_6Ez?0DT&iቨOW1xCQ;07ج}}#OE|Do4:'͍&`.ŗ$.KVzvVOIuZzXo`ʉo1C5 ՛l.;`?n++O;ּ,p@QPLw՜1Ƞ@x9 vW0OwMMngU ԨȳjJ ,8d?ng;fK\|5+2nu ׫?nZKUY'o֑&DugwPtsĺsMʗwhT]Eޝ7NyFe??w^Rܪe~Vv)"^ ʓý5]C,ttP \u.rA/?n@4nA})""$jkrũ;q3 AoZim(]0B^^S;}QꁵZf}pYohT2Y0u,V%O,XEif'r[p-hI1KCh{:]Xy*7}sŖZ] |Stxn^1S'˳*EEXYof5!t~}g$ǼӕuVS9zA 3COQF`Q4*w0D?0z2IUFdא/ݰh`08VJ٩BHu/2cv?0ַƬ?0z(4Ā J[!UcjrqONN=3N59'8լd3??qJ/ÚwH&Nɇcc64Qi3eEO,R= E5B+:w_᫗7dy#gmxz"(PyUeQgfe5dtSkCScF7!נBݿT앭6ֽNet;ьIIeY:-..b L6?r\%NЧJ}-VS'MC%m9D??_uؙ?0b  őzhyLD6(/DsպW* 'ΐ5&r}a\e??xи?0-l')H_+0})??}̐*zB?n!b L/Y98֒( KY~8?0)!{?rtN(;{2"dcq툄χ^~;mC('tSڇmz6_N`o,]rSD}r狷M.Ysְ($-]n%4N[ׁ{7(ٲaڅ%aChA Y#mީhBcQuSD?n@X.öE_kktXvGc`ΌrBY}ysh?0Gm0ݴ2K$hض2Sh,3?0SISl)޼'-gNq1! ҩriMUE9#0=X$Ya! WzHLe(KWQU٘f0Ϭ$IAkbx̏j㔚R}C_9w0ꃖp_<6%lSی1XgQvܪqR#GB'XP8$ U7\%??N»ͮ?r&´2-g9,0AK5'?rVT/K6q 2^媄g{9>?n?0C֦SHhj˺I )_B??SUN8٩@ubUYqx-<!9T#t6gw%TZBCVI-%H}^"Sn0eQvzTNQq0S.y\A!#nzpMT!8KT0'Q=璵jU5G/gڂ^9'3??Fw:cNu~v{ɄԨR_rb!jZK',Igu1M%C".Y~g~"ԣ[ E_eqQ$,_\f{镊`[bS33 ݹGbsm#>LmzP%Жó'j4cr{,CGJvioG9"Aq' HUYlh)\2t߽dR~?nm%o$s2*ŊPgKY |fn.g<];JYjg5=Ы:x疜_]4טԥqڄ0rM{⬑U ':VQViO?r +#Ȅ({l kdgP*?nOVD}x??}qqpe\t*k5+Mm IG3E#Bq׊$f%o}y??}eD>NCa]nDVY>ɰR~cunuނ(ٍUx\>hx w@̹m\lHa AabO$,7z9㜋f^>Ř00{Q/wj!lTwvUU\@9|{)T%gY5PuүA L +v?n6ѽ{2hUNR½-ǠgR9w?rdbg?r Ѻ1ۧg'vj3̷#IH$c&K5d 4C?0{B<Ӓ(ׄͿw;gOOZ5Ԟ%ɩyY'򠥄X4Լ????:y~D,jܶE@р3ʭE &%(݆]Bt^\<dT "@?0ܔD= jvZ ovP|w~ s!Cg34b aЛ9}|1\-ĕA8_Eb,*NL[kJjN_-|r@?0/??PM?0^3ЕY??F6^~ 9?0c#i?0vʱL]^q9.D*9p_??]M:&b]9:) @Kx\%X)5U-o%@b)4۸Fc,hqb]k6˒w.bt*MWp&YQ\ 4²|> a)&oO-* 6P>%V !nsX{EOk/=-PSGU_/uNjήvˆW"88׾w;0Mֵ%+7>'w]+ImWwHpA6IKX7`Ӎ?0 k;Z&'HA* S#>ε_Ѷa+PEw]TV>:?r??vn(?n9.9ii^w٨>t6ez.j%MظU,W[;QlQG9h[6tNl?rva:36b-ʈx_k`]ɈO7XO46;ۖqɛ}HӮkOeXU@|̴??$.npW_77m<1QGW!MO1|f7Ew3 gC3tN~kKyo QxAγd_25#6xvta(R! 2[2Cx>????5'טWt4ofh!Xe0~ZL eiaHMQ\׿hipRt ^ϯox0̝z8ʅʻIW?rge=yUD,oe0z\c}7 bhG>f1{k!14@ɵ5 FٹNNQG!(??c}n* vmm [#EO_ tiTjHQt+;;yC1J}??KgNj@lbRsol/f?0{#=КL nW%,b8[]5n {(X]|w2HmnR&Wj֯ ]YWHp8y\??K!mbSAY-tZL־~MԆiƽ,RFwſk4<';|<<-|?0U" ?0y۱r0` c?006$̼|rDqBq%FHѕ]n+L¶Ȭen9=1K"Ţ"lj:QuJH0{π-;~AX=?0jȑ+ZK.rw*m:j ؽ]/l.XQSHu7ݑXUH,WcZ?n[`)>R 8x[,֟n:vvXo381ۼWITq1aHXG;5ͥ٭D06nƽz9H:y_cE]l.DUc:?r\b&S޼re wū*3J#T.LjnFr1/^tu@eSh3WWwQylHAz+{jɋitsF&W[jÛnf0 ?rɹp_}ǁ{J4EmIФ;նr+^/]\F9+o=_3\J̗Wc?rF-2UɔNh}e**ǜaNia{|+OW`!T?n3fUBќ?0?nKx"4ۋ,N/l_c]՚%޲T\;w& +BPq?n|'F5!jpƯtS^g`??K3sB(/: ݑauew1혢EofI$9!v;mPB;UOJDVez5UwMnpqcxzkws_6"1NHh7a\,h??ḮgfRp4M[jYjeZJu'wV4˙zM_6=|ř=DUOL:P(hwc@Ghjd:fó^6a`_SLutlAˆߪ f6rTJ1ϹU  hڊ'+JmDa??؂PJ-RH+չ:!/z??=QҤB/a﮼2E(I#;.*y)No|W\Lp`%?0DV&}(+\NK!q5vȥ>7㘃R"+mǔQsu?nI vaڃFNɆͧjӖG_?r~eskEVܬ_vV¸U*IݞzUE郤DB UN 1`q4&\z, 4X5L17;k?n^??l6!??mV*؏CJU(?rqe=k]wۊ>!}!ǧ,WqIvd6ff<q@r帱`J%;J*P)n>j?0&a&i*?no6ΨɎ>Q2KݕR *ON^/8zFr]X1*p$'+h [p +#6$yQ`Aܲ Rꦀz?n{x~~z]udGHn:P_4?rK%><{_qHxj?0<Y)ȵ-_F\agw|vpL ;??8*|އ~Ѣq_Gՠ8l 1J?rΔsPW˕ip@nFxb|8X+G&1^k=[ږkWS >??g7vR0YA( 5&hoE[`BU>iag1 gZY^,ʨq:I3h6qX{?nD _oUk,![RHE(C<¶%&owGfQʛR%_VcGϟNäv^n#'vl|.:%d̄{֖??1کcZ=99{wM֏m[vۼI5q.͍Y?r+7x~8?nG癵009Q?r7mׄQ=!tGa^da۠^yeV]ä 9 Qh "뻪qc/??fN%_uz:2^\;as??iCG}ȶ]d5\??+bPO^YAdu+'if+D:8p~<32 p]ch5MH~M>}A[J qDxJcXsy=7`tڒye$M}P?ncE%ʳH.!n 맔N'rTekKE?n(UE3sQ_`Fv89X(??ǘ4SnNIhoc9x_|-??{C~'ÉfWxסsDC)v.k#.yMeC\.J,!c]E*_hFoqhiA!P|nv_81t؅o]NV4n; nM\H5bǒG4{+0fտ/:p1Z1Jƴ{fJ%Bl7 @V~ՒEV(CY > zMJoZ'ڌ (Z{&j1HCm x]8Iӑ $3ˠěN኶beeٺ9@@ 0>ML= rTlX:U54"wJ;z/ 4}???rU9/ekvĮkbttK(vac]Wx=uc>߻b#cJ:u娷e5?n7e}Ba.5ݦy&͒7Tݚ-Vityl`kt5A&Xɍm6˙sG\[1|:I"uLbh nf?nvVE% lMt^4YݾN=rV0)F Ls?rZi"?rt2vNVY]5Ϗ(u5fZˬ?r@ D(Wd?rQ]t) ɕ?r*c7zҙF4ͬ!V`;aWfZbK6cz^RuR??J9B;(IndVTmh5?rbu7 2Voo\\YP➉;%+J=$=`n/legŀ*6h̷t譣dCyC1qKe2+7 `ћܻ,.KfpK2V惷 bZMʣ~t||&|T@d?0DWh2zݢV3W h-`xe:y< %j`Q=p9xE 1Y+?rZV`"՘ 'opu??6 aэC|3?n %&S)|}]UqQdp6&o>8}q/һ].fo|xp7>)|*B0a t_Q,s ȱ|=(cMx{i^zXўNj/FCws(?n9D:H`añE&Oi\^+S{VɈ c$$S\ZA;큹%aϋ,2{^$S&0Fћ8%$4?rLt[F|c?rEJS qM ;^eჯ??nfOXn0M5l-$e߰n 2WR1.s;&Gϖ' ڳmTdin;."()ndx}97MxC{&\ɐ}!j= +#\tN,`(3sPWwmb8#O: *$帰ߵ?n 2%f%u."ΏNzm {>сӂ&֐^`ť8hqVEl4..sȲ?0L7U)/Hת ~}MH??J\|GHw1$LF40)4D6?0x~m(}Cq1M_嗩j\pJV?rqCm 8b @NC|tj8̴rx2,87Ga7 9-j-ȫ4HSG@ V_/QBŶ[6imȴ+s./ Nu|?0hW/3(r'yઙ0Hx(VGS8\n v0.$-\C";quUq1+f<ΥeB,ٛ[Fb/LXo?0P̀"@M}@)bav!agiwOB4d\g޿iGȎ.!W \d5C]?rQۺ`x9jAR@qweq#EEQ;8g)0?0@BqJ?nB]"t[d~,p;c&kKIHJ죾E7cjf?0o?r}/Oz>.>Ýy ??_o????Zq17qƸZ>-oFSWOqe愡Σm&Feb.ΥM1tvNͩvz6|j؋0^ww: 6<;`{g{Mw??~%X:4{}:)SÐkIcNh8?r$O`K>5}OIV:þ]ڭOn ==߯ ~T?0 ]?0[?0$S6s?riAIPlmϵ3x) f*@ZeF8i9e瀎"fxJ,"b:ٖmqNOQ!{%F?rnx2M]f ӖP]91{t(Jez3^,6 îֽt1/7w)a^@u#d4CE%TH8?0?nSFl|Y?0w[/V9?nyI$)*QnV#tKً1䠠,=}X8.jrW>_<ڇS2fM,EHhJT$۩R {mvZkQqM)Ka?r!Ry<\ ޮrxR:4Y3ͲVz_Mҡ3JԽRCw}BC{f2T[Aa5`"o?0k"4HRs(cFO+OAЃ+۠F"< ڛf}~4mr1$Y'/Y1bC3e\,F3oŽ6·͠eY|"G&5ݴrجzpD#\cP?0f.T?nKS_HS3xk.efwХ3/jX?0㿱/\vut&P[p@؉U1X뙴<#TI#H4H?n_z-c(QBMh??jS'PX|,Ṳx=u dJ9aB hDה5fS(dIJP^AmapJd0 h$"}OBGˍ]zXQ|)=mF Fe~i.dz`_Dv6o j6Sѓpœ6zTuW6uns9?0?0n_ύI M w r+FA1`uQڎ`u q F-]CX_B1?n1Ge&"Em.0us"a?0|-eL6."*t_Z@ƶe_mEV8Bp/!ܖCˍ?07Qŧ:)2@`cB%3L!U-J4җj*A{z[:E+j W39X^W,`-2v3)a[h4 ?0U8U=iigMEx~Cz?r~rBcP_z??x!##=d%M{0;hoűRE%bryUe{N_㥥h{.b*(4:Qp\n48DP:8rxjkΩoS%N[0{Aly*hL}n U 4#Ƴ]m??oﱹv(,+|E̙}xUAhXk<^]xNGul#ʂ]p,ITSҫHC`mn(k}.ɲU0XGD+46Hu EZc(r3ocaF~R^WE·wv#nVJr\`3:qٷ`4`"<(RVkn2??dn41@[Wqc8pdL":dsN9=.M AXX_sq0%nGahWȫe75dKğ9aC)(iap1aۮlCIuSnj1^m*#19wtwlv#B?0[Bк0#rR (M'ï?0ɣ޶5w/??ݼ}r1.&BEp@Jۡ}3GG7 <ц]n"C6c;5]*)G$'?r}D*vVKGœOR2u%ΘvLD?n.Cew25OR?nD -2_W_5a9yӌ.SRu?rRExNɖpiipRl ?n0ba0]"2_֨[)~#oQ-1fqɿds?n3,.ep4R/K^h7k0tɂ+Ih&sIKmp Xe!9?n-J"d;OKoUT+|b~Ök6r>8R4$Rsf qؕxMH_{gzh|GZe"x$t2EC!o?n*`D7 9hCῙ=|m ,֔EDF-ȖH~Y XI秴Zn7\.ҹ5.٤x[l՚<ױk[-3)۹ XL'&4N'Rbu.$^gziE|eT&k"Nԛ+s<1Sc3}E$aFr0]`zlV㽪T=TU?n#t8l`Y̬'o4u^m\H':m#6*ocZx+2wp~j=?n?rj+OO??EXfe(??!t?niEkkOР'{\??I/g7ڜ)1-C~ZV(\Y0ĕijKERţ~??^N&ʵ9qRXiΨI\K, uK7MG޻4}*ykzK\\AD+LJjVњ%=tUEl&ZK?r[a F!-a„U#TSX4`-?nht~%;Qdgґ˒4v{>ۅl~aT^ɖ ui\ j+.NW\#%q=?rNn1yMڧb #3I77t~FKeTrbKeo**"^W/3NjdMΉskwmt"SsnRD'ʋi%/??ncʹ qS`t8c̺6f;m{C.v[LM+Z9 7ٍPY?n e(K l:QfؼI%%޹x":5OFFa Q8GAJ)\բKfAWvQYWA*‹FnSЈi?ns:`SKe<0YImږ{2WRY*aeBi3ĈZFz}Y7)p\#@öz`$N@ 6P_eP/~UDㆍϣk~[A1A~e_^D2{;FI!c'-2qU?rC{3Ǝa^m:#L_B/{ mlsXk'䦱I/˾^ta`4XC~5W6?rx|Lܨ[J n]f'_cp|)k^*oyK+'޷^ؙM UՊ҉d۴JH@Dţ|Dbo{A ?0B жG_{O4Ĵ8󝤨`o/"XhqRj\!_OX"Phxʣ5Y@I?0Ӯ2V^@%$i4[;LSyB[KS:*/YېվRS??cLr_DPKXGNJ"t"T3`&fp0d )??n??-?n_hi:Oy?n??6&סE#T}ͧ-/UB^-#5bSCz>|J-9`yCSXp2PP3lZa7btԐ1m3Jt`Kõ?n2jd?nn5+*,4d ]nGp_B>!üzzFcy;S@P)اPfbR,*rB)jrKU_i%]z0Jp|[K顩B~k#XQ C/COiTTXZQBҸAqPcGEH{;p囤ĪW,"%?0aZjrF}D6R fio U^xR`Ф`L64:i}܈},\[|:O&&Zy[^F`oF*~Ͻ8 ǀ[T@$%+f)䛿X??[&T*ԼA][Qt'o#XL??M ֪azy>GSGorcR{W%]bQ??Icիm]]uB床€'bVOc#s$zOo^PY]fLGJ2ɲڈ}KT)Մ\T >%U/id: 12L0|ݟ/)JBK2*Nl?r34~w׻Q_:ߠfh-8St?rMhC{L ϱM0q;/z<+w4ox(d Q֮Lw6$mJ>jZ;r>a˹{-ܬ]:??uǵz c?0۾@WlҗEk6fY]}iǧm[+k2#rV +#i+mڟ@-uo??r:\t XK?0:mJ$Kfk_ 6KC)?0=-7ė++# 3\>?nL7[ۍ?0$'iVwC??`~3TicKK6eOOkܘŇv@,TXD'R;<>5fӅX Smjjee ?nYАN;n*=B(҉f*4sۍ>w]ҙZd~64 S3ޖB~S>w&_K?rCvRb2ykaWMvW_MM˔S֑;0#RdrmR?r??®Bz6*-` 7}sPI@m?rP: ]*C1uFDp[K=iԪN FZܗyxV IyƊQ4|U`$ [d"1T[S:,фqζjD "]bEm/B??_tu1 '#\?nxoYmSN?n/7Jq?n߁HY-{2%Y`\1E쮸Aj$<@]AF??eT ga2ai)0&RQ+Tc=,^9#lއ?nJIi2ܨ׈J=ne/lmY?n,I~2:#sy??&6'Sp8GkRJeKjGeI+s+pXR9$SR~F BTqZgW-?n\YDCCT.FQT*JE2M‹;b)áxmMf٬B_??a}1L"~%A$OۺQ?0?0n0φ[QB>/ϪZ=^b}/f[~Kk}6TU= 6=*hWHdÔ=Xs5qP-?rUP~ 9O &y]>Gqy?n"ؐ/&#I_ ??DC?r\@DT3M<'Gl 5U?r~S57Sܷ\<@J??R7":1*p1n# iuJPSzKpD_/z-']5u6I?r4m֥~0{1!k?r8>x!d!LA( ,wocߐ;j#4q[ezŇ_Z>_܍F,R2E@Fk$ˌ2,n"oeRP,&+Tf]IShu˻];}????{wӻw??ߙû.o$ B$??mj^Q??>/׃f/];>l#7?0lnv4x"QG km3f8c7ј TebwP)V5})ᦆu 81ser0TO??|CeRH%#mfІɻ3eޟSh9ëg!.x۴0IY/kT@XꚳʦXNi6"eخv^\}e?ntUD&3+b=D,TI\WZv-(ԝ g cPLGok `2bDiz4Էpҗ$@z_ud??:Eձ*Aï)n9(Wۡف!)'X<OWF%A/O+TlE*~#oƥ3 'ڧRޓ?nH?rsO 1?0PYr&7a5z?nPWs,:9GHtBHTڰ2"o*T|՛9D"i3eϓ#Lh!X(H??InqPK^][ѧ>y]kne(X$U'ޚq*QfOn{ĽDKg)K#U!Ѵ .ve<ݫ`SU:VJFzG'J4>S5O:Aam3?n[ ŷH0_=ڄ|U5^9GOx>go}o??Ɓ_??:GQ2O??zbo%[c[ҋ# ѷU^6pu!}a|AH??թ?n9_??=~pd2e9ųMwE:8yl<z(sdbB3ӧm4>/J}ȩ ?r埄O=|蹰+iΗˢ@涱^3jsu]o*ik0Ƌmyi2??gz=]l/6m?nAU?0ddgL[ضgw~vZlǚ]۩ǛzB??֭s+??V:wS6ݲ\?r6b;KDz$ܖgg%c$ TNupA YUiu^0$e-h0;IةvD @<1Uk}?n9[0^rbpMCu'71&l`?0Fo?nlMO?rݷv*/lnǒUa ޹+|QFuQ}91k?nS|1Z/ +#+j9?ne*T0sP1#"yU¤]zWzΦn&EQ??q5p??u!'?rd ;džȉ4o+B:k80idLdLWemT-?0?r0o*Qm@TMNޫ0ԗ@W/s/Ңg`+c_b ?0GL 6|]2{?rZȃ{{+j)>"?0Cw/]b}Pa!:h0Wǻ5)Nw+;mEĮb*LŊ~GNptc{=e$7lN^ b^:-,rGE5! wUnH"53GlJ+i#=E6Fa'?0crtіk#pOmz%SeVB=L(H]C8?riI2m%UR&L.??ԄD?n׾ z ӯ23hPfzK;"4J("E0Bw4fO?n i.I#@Umg¡V7 LZuUm`\ayg :rIGg.3l7[Nt(!KdyI%QvcQٝ;>E`@եWkH{:27X5IA!RQT8x*95Rpl`z?0 Nh83Ηƣ;?0ǜI>Bh+N3]>FoD> B"޾̴je?0"wJ g)_?nj n7=:(;oʅSw]X??iv}MvKTƢysg Ԫ2ջM5xudWe+A%~0\!"8X^TP!J=Z酀NLe]&IgLe]ΑiYVInIT??0"z3yhR{g8N!ɤ%MZ5u@\6-\T(4TkRIQl?0PN!u|spp0nUg|s@ߢyPFCeE(KP3Rԍi&5osLOi 1MFh{="h%nlw1I|xUzl=]~MWxhh`duۢik*/Ne^niu}Sώ-x ՋpDk3M$+磺67#GC#TLL@Jp~E:>d%ՀLv~iF??q0ʥ?nقm,nf!.)2+<%Ld_$"8QJƷpφkѹHh$%F|fb shVj=F.Bj:6%fHś,.'G*\}ÀF# \_W\?nW*4!c\Cu5X>BW<>,S x'9**΅>-v6b?n!. i2ƙ1\mCyhk Gj=7݁:$pX?rj޽HOIiג1GJ^]?rtyj`!# eS*U51{+;w~{ xa=jjF[??jꖸW~U_w]vsTPzbP*>l#0gAdbG9k:>̳*8>:*AM\K3."Tƨ9t_;kتtAM& ';/%+B:O/",d|bDɸ}`D FO?nٔoFw ,^-epTYŒeu?0ǯD*??C3zX'Kޯ2ld_i!msxieh4@o!ޕ(%WWz!B^/R?r?01dmJ\Cs1,96~A{˂?0\-h)W=26%Nc^u0#ĉ?nD!BޘB@kTsI/\RuS_攞km3g⤦+Z]L3ЊD6?rdn0)7ؑjŸP~p# 1?nnۄ+Hi␜(c DZ!jCPwѩM+CV/]! uQmЪyhVPn??䢪?r1my^ͶfIs;͵:FҮIlZd;MqcX/*R7Z#RR Y7\z-$x&)ϟ?0 "s?0LNXy3\@WQ|' Rp-<]O|g@:lrr!;"u֢ɪ@C?n$yrw5Sw5"PR""5DGqIfS.?0nSq?n bې#I۲oE^D^rLc/Gto\Ie@\= aZ£BT LGh %:KK#Fpl2VoO*yS2ǩJT&B8Zl%'34mvlEս2HOʓvՋ\T3u<ڞtvzrbH;+*Q0W⟪?rZ^lMmd&H"$#\jbINn%δXK`UѡG8&'ɇcA@iv)̀9p<_4fd%\lzWrBj}SS??'VU@?nx՝hhgկ0kaQ^S`%CQg7VbݿuiU}u5GڇZ (:*briD;r{jg˅T VSrJd hMfێѢPC\q1Q'0bW]h$?? *'txC9lq-{6)y:5ދ_j8_~vpY0z01իb%CLI:vWi)\_xs1??sۜ%13ٺaV@u'qM]9e)N_$#^;F=XyCc;򣛀mOM6_J6M<+ +#"rr8uIKf*;Op53$'y~ψX]:!bj 6f`.z֘!0??nLҷlؔ:'kNH+M-N1*7qhM(9bBu- SȘ4oSP kZ 6ǓL-=)?r?0?n3|NdlՏ*48KaU⟁&XwoW }A*Gbދ$Sl tUbNյVU6 uƙ]\\^苝pu3 Ba De2-w~/d+gπYh¨-ܵipqǑI?0ynqm?n;ϗoujpex 5?rn=>l\¤P?resV]dLB FyеW'|5r(?r?0Ȍ'U\`3p0V; {XAlG??RT3jST%]????·J[hFoa0k5L˾ ZCZY/>9v upcW,n''3C3\7zBVa7JS:FYOQ#hΰJ/.ZםOow EH~a1Q2eУ^#{X8d?0jB־Ƚ;k\q????>U[_K,5}C5.0FJ;T*Y^=loa#EYGX?nJ CT6j;zL??xcG1\o\oۢ &}|6G'Ϫ)2 mوbswZU[p'\2wֈEr鸅T]ddqú]yNO9;V3izxPu<@x=G?r掐gGQKd2q?0vZK?r0?n??qq?rv}(:XoyYȆfvnSͻ,j)r[LGe;9ԉ6Ny6[#`D{Iv[6+?rN$^&?r$Џzrg1lpwݓY}:I> u9˨sӽ4{2wxb\5贄۾幛gvǾZmw;-sHۂsD%wK_1;Z7NS=vחn+QV-}jɟB^B[aQĨQ$ ?rJ-tCOUC T%=}9z] ;p~/c̲ytSFaeB턙rE߳Fp ,#<(|vw;XfNgЁUL p?r[LmՅo~\~>=_oe7zQ_] 2P[6CnE W[)^Wǫr;ornfPn[ulzH,Oq{뿼㡫A.tmץhz]-ZO¡,66+5/KYJ'HO9{|^)~پ^'rEN^L^5~k?nq;tg$BA?nuh2I \ {ĺC;\|i} o{pZ kۘBH?n#tN{/#\s=2(1S.#VN.,UZqY@ Ln/^.Wˡ+؎7c$=KLR-w =4c.,.:MC%M cY'cd?r-8A$7vB??{LtޙCX&3[}׀)h*2L-%T*x n6..pI}Z˥}Y~7]幑^rH?09xI_E]v9y]^%Wav"ݶK 3YFkIFQ?r)`\,Pٝ1?n+Iɇ-FkOz9(%m$Tğ0 dɃawyNae#v?0j1C3hk^UQ0YrщL*qn |ʌI*8@0??з,|P7 Ja,}0WxXJV8;F0+lvuvN}᭜*wJ6vy 8.Y.V?n-~8 f6M~b ?0Xf8?n` "T #5R't9׈LÞ+"&K: -;yl ݲ[?rHuW构)E)Nv"2b;??Ż76^Oq8-MO/gt·7@\YXM.@@"|Vas2AM@"IpQ t6SNj#T؁s>MOR~64 Flǭ@}wRU??tl{bS1L{??j7j/0ͅad<J-Dq[Ѫ)$K3+f<.hZ.v x89>S\,ƗM?r4 :MA<[6mva46̅%;힮f:)ϛܾ!$0ږMM>Kz\ꀳc4LL[#:_4ɎI@[ܚ8Z,e)|Y֫\m?08y%)0T*WW|P&_!eNCH4OW>E-rڼDا'EL%}c(4#=9OѢ]@`%P,+:\cщy&H,䀷ց?n=y~oU9҅rVra u6bg#L +*FV-)-kY,/e+^Us+3=TiUFi[3]š_yeqΈl<\"ժw4;d>]2t^'U9e`O1QqxgIݘ払]Yt9fFtzuO03 +#01w0/0Sy: s0G002 ;^&l+IV&_ܔ } %(Aӱ\PkZkInLGU pzxZڌ(9sWE!@,f^9./5F7Kvڴ뭏TbXru}VF]mLT+z٥8ԫ|iGZh3Z`'D0{]P㓝qjeOk)g9SljwR&3=Q%qT}6ϒ=r8i2(9g5U"쌍qlBNQ*ׯ)??-v c^*ġIR1j䓼a{J}:p9Tm׻@NLӉMe#GbϷ1Ww%'rȤeMj'%t9NX%:Po6֌a`d%:g&R)iM躃^cfMvPmfRP7aITGDGKU81ȊlӦOR㕈Pgd0)7فV??4З}YEop EV>\z]ܼ _8 4)Q?nTZ/k4Iѕ<`Ema}wo0o:a?0MwNš/oSS=Cli顾ݝVȉuH)g |ʑbTװ4͛J81n!i=ok8Ũ>^G݀Zjj0TK.ןt !uo"%dpS4%uefqvú?rۨqبH7*K&`Kզ/lś=pವZPBo5g_Ek(&HĆSŦj- ^$(?rN&YZmypxD~LԌ:3>Rc6-:s)<@wIfhc[{EJ[|50M0"9e7ŒEƮ!QDueChW3Stj(OH7-/p?rf;FQFʼnu-Or*1Su???0vȀNR-WkI3-=mM& Ha$R_6z[[5#?nΥ[|;Wx^Hq@({pe k#S<=R'>:s|V?r?n[zSy{/AAsB)tQmDZU{}e/YC:A;|b#?n'q+37gw>)%&VÜ,Ҁ[i$C+*zwa@E.jyzq>M;NuQم*s5Rԁ"G̍q'(HȜ/mpG^b{vMMN+Fbos$.-f==U`?nQf/ȇ=Q"*3wݓ7Sމ^T%$707]3H[Gq `Hd]&5K6)T65DʶGRdK^G}liM|1z6rˇʰEg%T5?nDJޅ6Tben)%;لlH!jVZ.y鴄eHȔmhwjW.~G/=MS47xHOAJ??dyFy?0H27g@ڻ>eAPT̢͉bG_ ~ȮNlZON^Ռfr3%A@D^C9eoΐTN=,W*ʼnV1uj[?n\{d,`h~pm~1Ի>(~dJ3 e7/β΢΂>[GhS2AfՒO&(6)lL{Yy|'c'go/-7}IJ''7(?rPQ͆??6QSwӊmOnѐ\|fʣfNLC'??W\k#77;??m77v?rg??6C()=M f&8gGϟ<#d ,7&Ho!Λh{yqZZrmTS]I2T7iWo'̃L]}X Y?0\]ʚ SAq/11dHҕ -og?rK4wʕ1AE;?n~fSX??)ru+M5#~|{VR؂Jajᅢ{VHWōEJ),NfD֩v< O}L<p}zyN!xt("(,)YܼipNJYe7*?0&0Y"Ԅ/%=L Lёt.IǺ&GG1|U˫I.Z?0>k v;q#Mg,v-lR6c0[|=6Ď7fzU_۴<}9o\)Psv_ћrG^p_ ~.׶ozqM'e#̚.]h.(WE5Ri<=yS56Goo+*Zgg)H?r r󐝘IP??.B**Qp?0"J_ ;VCV N:??@A Zi|3)#- IE[omǔ++j]5˹^?0ŢI's5;?n^zo0RD]BL-9y1ZDD?rXm;\EVx01n[s8??I[|ޢɀbct-8Z8H!ߥlh\Q{ypˉo\SY)1(Y>KλӁ@?0W=Ƈf!?0A ECך&_\b6XG>MZ:|ɱcgv6ML2m(`[:/ zC؉h<\?0L襙SdN@0 +# Q<'5V!Ûѫm ]?0b+P&C]۟t^3tÕ9~ґ4hήxb~Uj#|ŝu/~-??l~a'Ǖw1qwA[o X ٓ"p?n[ -Pp?0&{7[rE֞=\w%L?r7|ATv&NB[Ty//yӢ$;O^<˗#&'IMvf}vbYy~}ϫ&V47X!FR??~7/ymn_GP&S %dhCi|<]]&El-8ASlzUp??N6Ƿ^>W&ޱk#zB_Eդ)%D~q3'1)xw˯8ӣg_NdS'y=j-d7eEah??{d(!0Lg/4-0ء=?rh2l>JcG򭗿*ŅԼ(<戱Be,]cKbH9ahp'Eٕhx}wrzYd[gx,2[d:M(6xiG'DoayO4U'HO=F'g~Fg'&bh7y6CzVi#i+$'͜R NI?0r"o&p!T^ωb3ѧON%~`Sr<_*l'[KsǼN:.?0wRڢ7~;nqE胝98-G%6ȵ?ry*zZ]ll~U*S$H)Egj*93X @QV%!k[,?n"P=&%>Lr[h|ɺ}<szojAZ1k*mSWcfLUۖpjgcp?nenYmen]F]]kՒ QWi].3 =o꾸oM7}:x#Gkh˙["yFb9O9ql8h~Z)K LzC`>zӢ0; 0q*tk{U\_5>SU\!ULB5ǾYrtJcF3I\B|..0ݡka3~j-(5vW?rW}~ĥ|۸/|!ƪV.{yl?ntH%"n^\5[Ga?nhm_UdFWݬᏟA"?0Ԣ Syc&pxjIG S@) dfB, [#1^USەlJU ŨAH(HyFB?nb<ԫEcO%@>YAJu'JY?rp=+&8#T1(`|c ##*(ӍJhсPjStf`'lM]wvA't-??0Ɂ˗??RxŃ~6eDi[54@#("2jiB=(a4ܜ??o[䘡[V6ha?01%we/wf_h_MlK7Z/W]HW%#ɣ'[;Ffg\@&˵wpCе3IҺOp?0gH1c#??H7Q||K_ky~B1ɴőMhdn·c%/ʯUnYX)ԕ$#<ݒ=eewpͽ|M]>hǿzq;^{l^38F )޿W-ן[?nꏷjNITu#nN R1n%'}:NwB<,1f,0RW_U'GFtݭFM i<2!.l6xm?0fpBZV" w{CDi%(UE2i*м+E)\arݓ$<}|߼j#1Eƣ8[YGA6>S뛞lUѩ_XB??B='?r?0qYak4/">_Ke+/%oX?r”\ueRThz)F.6vn>??>DT|{JTz\Da3UپxVEE}|}8<<&xn*TnMGBa(n#=/]F7cC=θr!b(z*]E"҂R{UhNOIRe+IZhH'"hL>pf0S,_6t`̜TE W$B bӦ*_?nx ]"P!X 1]63c{9cQx9?0%Э$ Jй8#NTK0wZNUC vM"*O?0RCWmcaæ:c[#"O/d $6u ꖄcV"⧺0p>v!zT)UOsHŔ~%(ؘChaHY8Ia s+]{wOӹ;}>W ?r鱁ciN˓{'S +#`-(=C %!VKl+9ؘ2oqp=(86 Y`T͒VV6Xo/}kFޜd{᝷hP.0b5hstK1w]MF*>F-ם,eK# 9F&tVbą /e93= a?rՖ(' ̸Ɖ@E0%/YAR߈l7hs:ԭġwOi%K8b 'cJO8 ㋛.T\,bɍ pW]*M2kM?rGoh}V6u*O>`)wWFsn&qfPeV & *<Ҝܵ#2v@#(͆[ AjH?r5l}#͡l~/׽?nﶰJߏkz1n ۨWio;12^vOD3+W(LǐC-}~mGu¥Mm2єჰ &{?n{?nuG8Vz2؞i4WJ{b_o\TM?0;~%_|kҦ:#wjy0ث2qpK^IPg-H^l2^z^h|\<_o5[]????-PԻͲW\`aׇ4cO}{W3m?r0- /,D?0| Rٌ7eX6;<SP+]I#`~A>U;b]+s(X{uV0?r<҃_?rfkY7uɈ{E1TzM?npo*j%>XL@ZGwX"1Lr$W65kAZ~c*Qy#NWڎU}ZژUiO?03 ^r&VӮvՈ;nGu_QeWQz8 nlᔰWǴ{Ч\&[JU]*76S31X0xhVڂH<,, b/@^l??P$NS痭)d0c?0Z>KZ":jUCHuC k!l/p~$oqU+A?0k|7>نEi Oi|]}:{&RGo52NmT~c%3ɴgփ)n4miS9gKCgtB1^E0e ɣOP@!ؤ!'b-Nt7dh5 hv+KrX,\;W&L~VC?ni,p)TFcP)hƈ"2ژ'YT2O"V&+SlM> 6(FZ:߻KJymAʼn=A=-z/2^- ??#+lkҷaw.UX#Ar3,Q r5u_$[ەsW3??4[_满tYS^0vNWNMisB٢pXp%[5Q6{BmD3٫TaZ8RVAaa"QjxjF?rTgU ى+S0d"~@v;=zU$OP)k=_m}G?02^[VU1/`VT-?r*S<[4?n,\,>iX۾!9bR~#Aև*Ƽg˦Qtf*;'Wusi??rM|m~NΠ/U_]uU(C@^vmm;+8_&C?0xO Iy~(tmw9Ɍqiwn);*Kvuj4d't1/F?ni܇qߔ];jmW&iT?nע|A/a~AA?rgzʜz+a4"aIGOH֋Ͳ9WG7u`ۆ '1BzW"( E"v@Ir7gmX5n)uhv:Gm go̵\Hf}u۲.EQn]w[-%AbQ䳽^r LgU0&##˔S2j3?013Rݑ[z2Йh͖P/Hr=y?r^lT)%-n6n!.Z(2jJ%Q|DO,gdU% WAen@#p'6%;ElZiUEqh#<ۏ/Q.t{17T Jo:V@HwZwAѥdYu!BNF4 7blz~ /գkh??j,Oܧ1ޑgŖM1i뢴UPqYk>ilL<jdQcNOfCcRڱ}\/NwʛI$PI}SpOkcс!qƘPv*(Zes5TϑmHc3;_V=TUEg),S촋3#l,89U;Gd2Jb؞1YFi[?0HZIѼbC4ԫ|Y)Om.xOOxeay?0+!z<'jʲzٮx??_YV07NcK)-|u4|g>g-cǵu! $p8qg1exQ2k]tz#>LZw iɵX5s>jPCɡܜgܶǶɮ6,,x=-MF?n!QGջD[H?rY%Iͽ)UHuc&0L?rV_FoxF@UpnVU?0H2N'|P]~s2{wudghPmY$`6T4QP+5#ts%b̿)ѭwA\тZkM%hޡRMUv&c?0r=$.ޣ*X@<3$Gy{u #;$<*hY?0o]]tj.GZ*h/[mʋ~E?0Czt`pҴQ<.5_2 俫:l??{F6^BfgϨP`(]]Iw4CX^g.(ޥљO񾾲}qWooX:зDlV$gzc??wm#Զ8/j{y9jq:azX%5,Szx ܼWp?0՛lk.ՓzpVTpg}^|m_Ƕ/xxv/~oFs{DR垱SaհWM j[Dա&ofU^ _"aVl;Gū"D+6v9++3,$-zN8m(zrb?nv"fnad1b}w6+;;Vqlw}^iVGDwd˕L q3='HAA`o2G~=w8uCYf6Ŷ]F5a.?r(:?r0 ??B#G>@lО X.s G s~b4es1+*4'B#}7܌TT@4ׯC n9]2.?n?0??&m~??O˻_6އ0qc <^>V:?nkZz+~C9):KSm[ա;zHg\_pEM@gM5s6pīnn@VO@~; B{1&iT~! ao_L/1M]c?0@Xҳ^~5DlCa*R8\7CCdM?n9dp+^bD#C۴я0??iTs??qތ‡A?r(ϰk(v2;̽yKJR.j%w9+S:`?r?nCbfy$;O<^zc8 v,ʿp\ė8R)iŘ-WP!jfF{uؘ.W#@H]s-~V82vM!^l-(Jj<`$8.=352?0)Q ;'_?nF4c?0]!u{xMRWprSp4$?rc([WD[m'-0haS"Gt۱Y/CY Ȏ''5xS8-[lKΤi.:mj_'hTv0ZBDž)Mle\L"|'1 DC"`gՆh[޵DK-ܻ]S}\4d @@z]>P)\b.W!HjM9rHu!pM87aQ7dY1j~xӻfIwNF[C( kG A0??[0 (`Z` ]#iz YhW?0.:8(lVE"'$4iv~Ɔ!h*3Vb#E(D y'pZ0{9?rtdH k,l7J+K05j4\2.)N_o 8~q]EE5Ua(\SG"Ґʖ%?0ƙTID4hNaY ]! r1 ]T 7lv!m4CH Dl(k) gq70e|"dz153-{Wk1H7Xվc+yGpTYWS+ʄY"PaC͘W!N}|Dj|x"?nN2ZX1:"zK''D\af`_ pܢIa(y?0C&nfX>gb?0wyA'$n=w=??xY ?rhƥc6ʰ^fy|p}W^v 1͍86t`!|it&U;WO\q14,?0Di-< 6?njMW. hS ^Bs?nĞw8[1I 2yX-E,#TzD Ϧ^@^f?0%^cqZ06wbxsDbţ8dQG<5d^Kr q> a@ӃZ;8Kv4ikUY (4jUކք3gFx}6{}"b/v6ʼ%G `E6&JC?0LNh}UU@:9H{p±4s!m@Gs&:3bR7O7:bPS0ůmGn 2EJBŢ*3#."!ǡ??85w7Ez +#X׶`o'`Fwǜ| oWؐ46otpY{`-ENYYG b`ē(y]mG8s^LiyGaQf'd}Y8<;%1f@}Pb @G!T?r viڗHW擁eY' z%酥g2Eb8")!=j+xk(uWV!Rp-FxV{y/=idnbdPAWi'G9w9لvͰCtܗ-+2.Dҳ)OEz񸐻Xw:s [Ƿ0*?rJMR*óXS޶zo4xm&* y/cǚ1E1]~84|P\($7Y??`z=B)1X}m6B1ߤNB~C&v͒C *6:NdSnLrCtiT7wdhߏﵹ  hpI Ak;LRoTTWH7l2qU"*?nyݼε=D1t4pȁܭu x2Sj]v/d7",ڽFܩ#{X@.?r6N9>֓M|1ygd#NP˧ⅆ5e-iņ6݌\٣Z"f\BF#`FQc$L@ $s{U-Ym_#%6y.Պm;tc(֧˳߯4ήF nM#/??#?0:RY0W\DXcΈ:?rbpxpϻM1Kh5cqdG<{9M6<{H÷5qmoRg~ؗ?roKmYe9[&Қ&,5<) H`a!xU=ȇmcp$4Hh<:T.zlm$ukУ>x5 |ސWF3bUAm)GA ]dϸDQhN"#J(klI 5I0&cMZXI._ш}]"BEYO̴Tq6ӳQ(29U߁xr`_bK #nwGgCcX"YbUE;2+?r %Y{^Yo䫾Ӡ txTF6.$?r/$0+cث ojl#fІcfOhk;z73F=< w<eCVlvAF"0??2CL^ln8Ry=D!F%JfG|)OA:l!$-EN턺.rM?0}xyK: 5~]Q?0}O:zN޽Row(LWՎ"̃%}Hw愹yߵZrhRܶ^w~[.a~\OK9?ncewaHc\??3?nE!g=n„}U߮ʈ/}RzyU+̬ex(O=,e"FDB>w62~Z5jTa"y^szE_'O(8,mǔ??twJE ?0~d!cz<(%=ӆi~x64quyNܕ?nO1OS D# #-C\t?rnS,tZ3͆Zm0v$@d}өr侹^wĦ?rbQv/ os+EVNUU_ N8N0uaE?0vhM7=Up?n{Ap#t/2=4ckn7$ȯ\#׈nwp-}cyw.S& )J;sv C6 ƍ&z?rX,ҧr\sL??Q?n6?0SgIR'xϞɓs]h@LÊEt~4u_ң͛eKFˢU`E(kKCDϐQxں667>в?0yoS# !*9vj" 4a2F%e?n7XtJ]˻2ouvARC\ >rO]P/<׹ ~i24U(RS,ل4N>>utW{)+g)˖tp=`ˀ'iy]Y2U/* X) a?rGhN <mԀi7`?03Vm]ogDk,4zihÖo% HkQ%$=yUccTt">콩??ۮc@AjZZ^m2@%jY[z?r9"DtJ5M/geL p22I:3$3|s@DsbZ:j#^mVpD?r ErI^ ꮉQtԷx9GDs-륑Try2@!?0@XaRHflIy,_Yk89޲*(!WMFU7T=yxA쟄n[Ra0]dݰD\Z e%QUQV>?rPD`FdI6Eoh1+l2˥~iԜ*[ݥdF3L4vI;e,<`M3U% Wv?riQgs7|a.;?rk+"#w>TI, H9SFTMY|Ɠ~ O -hT2tc9?rx4JUw^ ȋFކ'_*.*??> ^Uw!UefunUxsdq" z6UbBWiS*yµz0H-Ԍ|OY.7ƞf, lI\`'G ^,Ds(x'Tf0(GSQ#XdZ Hwo H??hE] )"eЭ8"!I(3MEgk?rŢ̩>G>p˦8]6= \oykQAɗ9VQ?r,8/w|i0/E;TwRy+ɶbNsȎ y75g67??p=0xp =tbppyINTrqD3H~X626il7 °?rG#ji<4N-ˢ"RIu RMvZRNR$sbu ev,Q?n9@e0??MPso5s/1]#[+w#$Y z@u"ژ,DXب-T(:l?r >S sn\7??cW>3@ЫJ| cg#U7Q^;+HzOBzx IcU CZC?r?r_cU5w?0<^d6HJF,X6HT w+uML:`TZ* K3h*`zG>Wf]Lǫo960I)xt32a5 ̎2E q%a[|l2b0Ll ,5bu|l"I0)P(bU#v axӟMiʅA?rWĺMu߀7dtr-|kvL?0[=<}DG.__^?rIs޴"ԦD:]{oDz{xgCL^o+䐠!]FK,WX O-By-A༐r'yv.^嫌hc7O|~z$+,7EEyx F|^?0~*be04eMg_|z\;44[z7?ng|/Lv"??`6KQ'NI0m鸼C3xB ?0A~$6`!SrT mt)]㒊:?n\??WD>?rqT5wV)z_1qg^Xh CbAmb!݃9w123Gu3ܰ1%}F{5??P??acujMC{d !s#gDUX3KatQ*]?0i&_',ϔDT?nd)oLύ3I`jP3 F|P|P?n> SϢHgӐlIlqNQ&~`k4|~ZVIN"p~ 4??`a@lU0s7 '7 OpbC`gnj5.?rf~`j('4y??t6u{ݿ~׭_tփ{4Ԓ_$FkƾP`|5p?r?r3QRf.2Tש^WePF+:Zq/;TGo?rkG-&??t%MUl.WEr{fd"␳R:HXp,&d s0bfNƻ>2]{:zAW??Sֈ?? x<.3?0%d1nc&$( bkH?0j{ίnmKH*U4X^v$&;%U͝)V[ VQ?rEhdg('1Vg4&zXX%XG+zЫ7Y`vWT%|:C»]iF?0Hk} rsMEfcoM)yNcG=p=j\ݖmN?0]QLy޺kz=/[(11l6A^=4-?r;cgTUkv O9)y\.S:]Kiʭ@-2= G??X{oFQ\ߞ;-p6E +#F5u(,vwwGF.̘g#~5phHBqA#D/??c1鑿aaMI^_]~Y83LuL3qv%0?0f KD:ͽ4~*11 #lH5,9;G 2DAa$ɸ2$`@nBGתXAАժ2UBW)>)d ge~Jhu;,F:̯zކD?02m~ߧbnU ?nnh&a{gErݨxV3{~A9^A3??dIrCizh0 W便=3OtzvLdo߿??xOd'~m3uK2Zˋd}cLա4.$c_o_,g,KmPk{ZJ`>; i "FWIr8 `{0)Y)CH,BC?n7} s_`~1pT|˲odRz@8?0YѬREMk'p %̮Rk0851nqS( =ȗ&XFM[L7آ\233F]@`!Y_' taN9^E.p`ȃ)oS|Y"C5n,s@.Q $!w'\DTL{;tYp'nω'X/6"- X;QD qz+߳h8>_cWRϟ^@x"?rS<7'x8C0s3OkGEqC?0z|g c⹉V`??&9^`cu=hzd7h*_dsw8Cc(g6D+P΁ٸ??vƖ &F5@/0K)ᇷ|j1dOeUAtI?nBT<{R^>1xш`w{kkҼ(T  -tƠXpoO/LB;]tQ F 3yUxϔ?nW`rG ;;=Bc؉g&y̢Bq5J1ʐ\ 5r_ҏ!s4Btc_CPz}Kಚ??Ve3lTَn,?? l9?rBR`#Sqeʩ5 }"{#??9pA>w@T'aȧ=% =t3 9o%b]?nkh2AŴrVq,?n!=x1= zh9B[ ń=gYk Q9Iy?0[h,x}Wۇtxv=yV3 ;oՊ2cKbN!:,%+@OZ0"U392"LSj?n5[rDۓ1?n;;РlԴs(k<#։6idT 0MpbLUjV{]"iv6$RLy!7ミ(sz[T luGB4" "St[^'qaqlQp-(Dxϙ"9?0U/M'v`Lo2-Ž(݇4Eoi]V7zSE%Lߚc_z:l,n S;hra^h@+@g??{lϞ}>lew(??~Ezhi|0LXj{o8uE?n?rYv);R (%)(*t@ kH{XSX܍3 1_!#;Gdfs9HN7UnEjŌ&?rr8)}8nR輙[Q wǕ_MG B55Eu Y9Ph8q\|cXUP|PPLxCu9x>iOcs1WH D;w!&'^>(҃W5域]̒OЎ~0pf==5OYp/n??:Cs$8B9 ާI*Tgi7w ||MhVhDT:4td9֨BK9|iMTr:˦0a0=ϣ)aM$ tf$z|kW9|r:<%zL/Ccgv3)2q! qZ9IHO?0d6HˮPnX2?nap ۾>p d-U5?0*:Viq-+-As]VU:z9 9bfc]++꤇V?0n+yPɌ`CW2?rN6ĀZUD.2j>?r{y~he8{^ۆ!Me~JZ-u I:_9i&i%-lX|7=$Ę@&3LGuw)/ s][pՋ?0G;[*;HUac?r>u]T??5Ae݊%4>a v.S,A k/_ee>$Pf}9Ur @%9HL&O\0Uyr]1&3<5mI~<)*!w"7c 8\ rYAZɤZw-;K֌?n8| z,b2-Ģ【ԑJJZ{xocxæ0 +#?0EŕD`&Q1`ؙyi0T 1( jWꀫ 'P HN^Frt4mPj@č~p%?0.PC?02?0a?nPb?np8$/XT!q';/BL?0?0z244B_olhf>0U?0?0Xo?0|Aݙ{m)f"YՏ?raϋ>dwM*|\{" :ۙAʝ h\/&Y[6.1bX'̟mTBA5R @>LsGX]+aLwh Җw2ʺrDuN6AM->Q=jX  D?rD"?n4ϴEGj_?0Mj K|_sC4 mzgA;*??^s\xo:)Hϙbahw͒W"|<e'ƽ|~"b@e%P&*ׁ??Cd uICk@z}Gѩ,S:{ qv][Rı־Ю5ɬ(Ǭ,?r b}8e?0VƑ;9VP?nGrD,QVv?0zƏh߷(yA2 }ٮ5=8tҭt F|ߞGG?ri:vaᄮ.-A{F,b??+reD~qJiC:ȹ;@|)PqY6lySj(+lͬ`/Xe1 ,5Fqy@dX@EnYytЩbṜLNtuP.D ڏMbD~{nqދ\gAAz<2D3qw -h{=Qt?0s Ԃ%VoTk?nm* 6%0N5[i!WTFqSPEϲŒ嘻 ioL#tJb-dq6/TMEn8$J@P/RHpV/2 ?r ur1@꯷PA4Ac-ЊWb nI(܂4??).h2@y"2B`-]_`rgI"<+?r4}0u nӵCo y~ 2J-|VXpdF{VOr H2BPA??>\(& Ìʛ~uEہ?rk{bhcZ{ Ua۸_cBђL+)zFD[}sQweXp%yQMN#v.W߮ťG^,>/v q؀Ի{m3Ccv:ɔVjtxhCv}??Ż(Ɔq?nɶ.,ἀGje?n%vO:@—dw:Ym_Y?n?rJJbI\l;FO0*Y-l><[݅5I????qn {d_@L]$lևᴣҬ??>24_4};a>57#@?0ٿG _~/+D :e?01U?rE^o[|VC!<'W?0p#փmOëP, 5s)Et>UۄUW'٣v!??A2XVXC'G??tDOR{VToߝyA3!y㳣?r.9s׽2NAk~J M;?n_&C|Ѵ 2;ԭ8לg_nUME{s z8r5j沁d"8)GE:91e1v_QA6k'nJw~UnVS}B]O >GURmqDpI ׈ U٩"6g0Q+^@e@{٫dOqѮ}`ղZm&"G.?r &:4 l?r-Q(oO5ejD ,?0B?r4Q@Ppԣ'{b"M٬c|M*qJc4ui<vLRl8@?r0 :L,Eѣqvdt3fIh2SEG]GXB?0s Հu/F U5ɕ?ruT̂G,ԕO PW!s0Y%,C`8FJ2ݘ]2Sꇄ3.ߤ7wwT'1@Êƪ#!OO;H!A.19|2iy]e*V<)F%|??4?ruP?rl{J; 3,gֆӄ rX\6)yu@n3Bz&6BnD.VR=Nxz՝`+HΛ:%(Dښu,蓾`)G!8)#Y죰2L.`H8/ ,љc^0Ԥ[?r?rjw?r|??/̥48ӔαD:ΫٚM }d u̵}<ՑEqd1#d~Z^`Mk+`7l+A7KƤ?n6gۥA`F$1ˊ yLl62^u!j+qs^ &(bQ['lŅ_0 hR][$*yn.R+?rNϕ?r9Q1HuE-;_A m_H$t{??ߕ p\=D1&BĤFh8vlBv?nıB+d!??'Wk8&>/midmƷNnlYaj"n4,r`U u lB; yKrC+-Z;~J䳿}stv6JAzsJ=???0n)=\EKD?0F~6RerKvOz8;KคuQ?n'6J"!brJQzv='o7?rY????=y{m(^pn%j"[[;:>8:5V?0?0߿ẼCuc )omGի}8 qqPy|ϚByمxpeqG}AnIe%-o>.)AdG]0 |Z=7W>[y,to"@kL$*?0ՎL6盪4ÙDy؛ӎj-~N"ԘbaKb+`fSZszg>1 %mG[shu??+d҂W R\Gl˔qfOЌRLTȦzTPSm?0ǮSq^MV7o\ai"8̪ʍHȪTj߸$P&8>j?ng1-+֚1EqpUk_\{SJ>~Oӛ ?0G05F~??+Ev~"$G'lO}Yi~:F'Ju_t1n\fUFP3TN_Ĝ*DJl t)ys}C Du MaQ1%,YDDh -nQIPTΖBT1X*dgAlv`!C\צwDOqRYgقQϺ;p_z~\K^YW @8R<;J'iji7UYЧ9峬j{:BF_L_i9j[WQa{=OdG=B,lO^HZyfcwX?0Vnt;1@:O3?0uR!LuM B3]?0192C@TiV%R+jq62@I!ޖ8?nJ}j<Ȳvz-f%!Ix=k|fXjJ3I\ '}= ڃXNvn*nK &?0ը"T>f&\,om=0&[| PY$|gGߝuT6UhFI zf=pGEqNJ[|ѰʇYJ2."i?na0Dd("OH?r0nz~Y^9D!`k?0W5R~&6[4=b?r仗iU&up,NqW$K''DLd|ң^2 r05Jgp%1??Eg'֪n7&DH@~. :[?reS4@ᚍJ|6$U7P<-ɧV{??Q?ră l6 ܒY(B&LB0\Wan1 :vJzlPa|)U+uռ"QP/wjs1^HtB"y$wYj*?n@IAX3MC-̟/զ!Z3h *Ĕ2$4&t]`dlk,ԴN Q]@8GA ]z촶)LbBCTh{W۰H3ӏ]jCBYz ԯ@ 5'"֟(ֆPOcO׉ʟMײyV['l@H8)q=ƱH@ov&aR]?rn/BdUTtY/Z!:!Q4s??g܏&4Yx:.4P>z\ud*BCжA=}>wQiRw!L&SGz$E&!LxFѱWZ 7h@?na_&Xš!԰ kagk/o dMDQ:R! =t$~ Y4K?0^Ms~ΠӨKIH,U@g)4/C)Cbv֥ЏaICh!.E$ΒBkR!8ޯ>36 pb\6 .y?n/o%YuQ\_\(m3k@p?0`9'oj8r`3\ӬɱB"DzW([4 ڢĝlF%U92Lbk&0rKjS!l$VTk=R?0rr?06PARٙľb)48%a۟*n3JfJs%wdܵj뺍+CͶy`%&RTL$`eTfP6?r^'v|R T%q@m?nV;FiQOjG͐(Y_YVi㺌'?0j?nI̘^maKJY/`3/E>/* zY ^(rWBau7_qpvnX^Jm83IڼB_bfJIͨGtlxFԏUkKB.޳ RO-sώ#'^u;l􉦨#xAD,D=\o?0lot??knіǷt]k=ik-6Ioh/r[[SwEZ%k9,c>fA_f d^$V"߭l31/j-ߙEޠ;tOr=8^Ãx!?nz?r3cȩ XQ:n9Tü^l{yeͲI7&EhBu,P$0lr}f7Uz:7!ǹooϯ~/zk~+C>)tvx??ס/|9<(З>T_Kȡ>e'1l~B'g$pW?r|C?ri0B٨ҳd ??r??w. ViiO´e;w8IT%ucu qY(*.*#֙#?0xѣg1$1N??X l,??w{;ubO?0ةϣs@4>^&L??u:U;jeUZW҃X'"??}mItކۯk\=׺.cpŧвd}WM䶄h.)k%;cn(A?0]'rssEFR:z%$nܗm"=V¾ZaDDjs?nRp-Yݚ 1KOuɺxy1)/W9=8bcp9i+dB O{vlk-mRαj# \8QV%;oNTӎIr[d.!K33N{5Pyɬ=lUɠ>.78U/#VO&pMA[[y ̲]0wl ȸuHTT$rb>u:??Öǖ4|??H\wbѼ[Eڠjv6>Jq 1*D1;0Ҭ"2;&#Phb 2(&@Zk!.RvLh%_^v?0#VH(kUVQ?0]1\Pǁa)d%Fƒ*gf8Ub; +#>a~mE]%nyڋvq^ھh w¢'ͮE[EɳW6t,Fx$mY ?0yh8|Ԭ4ݲSIwdWl^F?rI`ً3F;¦!:ڀ*?n5+ۜVMޣG|5eZ15H˫%[n׳.)BY/&YXliҥ.,hU$GFP/V}cXN9#+Lfaf|%y^D3Jdwh?0ڄ0EDXH#xN?n2&46Ol3f?rz EUclP&q5yljql7eRnǀ b,F䗮}rk'8AliK'qҹgݦkY{:Aªʫh܎)chڹPO?rXQ[9joXY8qC{R`:cqn?06Cшz(J NR!vg_~64oO*؅'D6*ސɻ()ᰥdzC]-+I9ԴV:<&G/Y$z[^T*Ľ<F?0h|LsW?r.K&ZsMNy&* I_F[tR\vD8{7vr6V2)jo?rgYrWiTOYo??ҊԬeJ6 Tx\G???0)N⤥}P?0 ֭SuChuWUt8obk:[:[\NBWze=IC/&KnLXfg))d`XtbZӣWi3tzlH6z?r IA}%ߊc--ʇ\o?nQ|` z~$YWo( ~ϧ^6IOTzet`-WԣLwj~ 46O<.khyp:saRM%Oy(,]5@5J E}qw6q%5"ɎYޛ6floO^C[tkY4޽>?0Q?0CAaD `0TiqZ+V?r` Wtĵ d ??YUfane:rТ<=(sw_s??wYIi|B_Ր"M|o^揶~yo/X4U%n.VnPM؎7B˫JÊhQ= FĮ84쐫K__ nFHnUf*ZڨS??~J(QN,kmpa"n&Ж{+XX^m)fJx[tn0~՛Ϛݸq??Rn_gJ̈E v'{gu;~jKJQ_!UEʆ??{ˆ-=ߪd:!؍b!Q%NB,^5TdB7Vĕrŋ +*Y,.oAurzmOqYhm")#1tf'he јMhQ6!yj ^\'@H h>)Th$M?n5?rs"|y7yx (P(0hA2;[Ҵ{/萐U:s#wg}0s,n*tw-V??y|FFy=Ռd!7ҙ3=ۧvˍ<#R׹/idrccʡDSt,1-ʫDNᑳFUvPWQ2ۚ'T?nL$'XZוW?rR]y#A.!Wn??L$!-BĔOazi48F"$,K0y@f:{'YeƘά0??V>;ISܩzIy~-W_(l 䟵Wp2tǔUnB rhcd?nݸE8y/6ZTp]ﯥbRK!m1r!y e٠k&ag=V#[ąv';nLU$+-:nbl}!0JJǵ¹(KgZB5iP ßIS5źV?rYDEuUם%2-pDnSRͩ?r6&!?0<1LKLSVXdvRIf{hFdGfm./bx ( $oc)"跿=|uo40'U)ё9]XvC2eP@rh~h,x Sv?n6@tT熒.N^tț^lU&&2 i~+0Y.Эk.mLMH??SF'yZy<4[}HYVA~_؟ƺY@ҫɑ_]g>J9WRzvq")[ݐ×1)A˧9&E$"7Vd0)w?0AyD%WH4F&86~4T9N.x}fn=M?0x^P|[G m,Y@*iCݱ4`wIz]ip&9EGAn`:x /*=#EF(v6-(.uU3FGk&$Z@&'iO蘐 b1טsYV+|*s'AU1wd26z79W yr?rɪi.׷+s:Q buCk?0u®X+)v4BɬBQȅnnXzRVmI2ŷ4!O|n,?0ܟf0eq̬J n('N.:Np^&o=r*A5כֿmO"Ϡۧoi/=^M"v53c +#%* 2[y%4,kHsRL/5,@ْ5`|_|GZB̲ ×Z4A@??RG^zo|Q%鞍 :u)5ҺCaMчMvPY de2ayiͩxOleehD^ItJdbUD%y~c̼iIa|l1/u,tej =DW[3&sc.k8L %pbj~ryS#*NO6Cg~I&3m[-":a ˢpIvi7m#fq=8+0@?0]JcopQp]m7ptc^j5[pfר#h[[9]i!=t\D*\ 28- qzO OS5 tW&v¨nm|F?r8#ʋ@DbN?rS1˰w *i]M=%#("xaڄF2`n̨djU?0-:vbH8H 53E:3:bH-b'J!j* - KeUm3Bnȁs9bʁТ//ŀPo<А.+?n9MJ0;q&DEdU}kgQV5)(V~Q H/ThP`fNLjR̤*=YшdW'VL?nˢ'Y{Bђ y)4le{M @\ʆ?0Yd덵ӣ8!\(]OVWtVgCM M%e<;5eC6t߫"Y9?n=+/Ȍb[$2SvJ!j,=RH?r݇n}..]E8>$6.S2epTu;?0LFPʩ^4YseAe#T 0?rը]/v ej,̠:`O4Ntu_^)DhV7\ksu頢w܁&j2|tܕvj$42]@+,8,XIZFətOz_6eVԜsA ɢ4P0< YzM 3R59vޛifJձ'O;??nWkh3QD۫R4A ^ t1.Oj^\FN--`nH (@* ?nŎv5I2G)GcUXҞ!K8o [{ t?0ݞ=x˅W?0pjDžfAK},| I97S~VQ+ŬH?ni,ݷK??TgoWW7?0_6n_݈;g=7a:K"p΁J{3U{]Ubdw]y92mpF,z_4կe?0pntk*&j@ GQuj2=7?rȇqx#Ղf9O8fwMjVmeZd,K &<$967?0 gL.wۓN8!̸0@GuiLSoa?r.8%Q??h'WwF\Dh,OA}tu#0WtqCulTע@a{cu?n7X{3 gS$1 V`k#8;Ss^ LWkNh0?naTҤ?rnt{4!&&ot~sww??HqgOOaw`_vo:]EI2tt9ɿkp|n(W^qBaxXc]hO????Nd@ou, [%'s[Ic~tճ<Ϳ\$"Y??˛$|ǎOJwGt~H_,հiNO+zų^$0ZHQE/ El|zvMi:@gBnK??'tT"]9#-BvV2jܰ<~P04>j,` ?ndl|aS09~2)NNeAuJ?? ]h>m}MN;qQi]5ai&axTO[@/`+"l鳫6̉ }^q}1Qs?0t7ȧJ[:Sqxw=D,%#fԘ(/)'KrDU~P}V7XQW ?n_CZL>CIKY?r\.ǬpIoKS~8ރ&aw ^Kx^C0wwHV=L2Rv9iixNOzH{cb$wϤWq1ti`S%6ܣff٫h?rzF.IRs:ګox:QɊȵ72XLL&ÿ%a<Φ23kRFJo$LubMq䚷d˸–JRC^~Bs.o*›{!=[>Ko+{* %esD?r/1mv`;A>5ӎ??PƩ!oŌ~b7??P+PW_3M/r}q"M2G|;tЉjm9}mqc!Z-Mqk]&(xYI 8ʯh[U-dnk-NED~Y^35 ^K T-_YAfB7N76\ìvorEیܲ#rhϫA2ɭv/?ryԻ#v[5 vAPrس;ԝm*QYϧ42Xs:3IGYn=vdH]hz4ؿ.] yh:Y6zFO5?nhؗהii^|mMoT:(Ofg?r ]9Of-vrGmS¼#M.0ډxn?r7/{j"͂b[4b'::mvcc$G1y7kq<VsAT*YjrH_Eu_?r:0Xv}u-;MidpFo_(=&s:JwDNiwؼ!MzA$#d+67UGLr5ڣEl`ӚŻDQGih{賗}l6W#W7>:Yϡ8?rQXZyQ3j$_H+Spg]3[je \;/Z}-j:fέ0hW4KLxD>R:+P4$<{6~u)f˕NyV8J^|*]QUL$GbPD<9?rTN"M<(P2 EwQ=yYrS+Q̎$bXި?0m&#e:V=f?0~~"&gĸ4G;wf2eV]-X9Z?0='yk̝q/d^ZxxnD??}Qzcgi֎\CB4= %s??& mE@ @rl+uiy"#0H=6rčT-4CsJ'N1{ZQ ۺ%+SL5a||'sDI#{̠ȵ ~7d 4b]$́vŹnx_E?rtoN≔}EZq%!BJ!Ԏѥ"EL$S](ד,`??Ckgg"EU6ϒ$h+N,R1">$S,bt}Pbg]jeig( h;?0IU,u!;?nӅ U:yꫝ뭛#gUՃ-n6JHÚCO=QꋑXC|KujL@(a6knqq6O0eMVÝWnGgoTUTeW3B#PEâd +#tylɷκe!Qٶ^G(tN =Ngw LM(ܦMC[T̅MUfy]\jt:?r=uiO@JәL"L[ w{L%zm0[`mS u8e<=-X7Yܾ`6p]ZpGp섍w(6~PsWYmm׮O$9???rȕ}:"uYd$EX1U]??@cC${ 7i,YUhY@o\v>WD<*G@&bPbX^q/,"ULar\AYyEA6x6=8p9:48 {AHXܚL<3HBWPق;e!\8L$}Vv+p- y (4v;ws\DblR>eN3~>K?rE+nF6&zLHvÞ 0e HCv6^m:-D'C鬧vbשreu࣍[r!EZz@-a 3ڮϴ{;U!#1 ._"Bw5Sh\ &306@V4]nM @ EUCrH>D˸?0zUizD<#5`g]քs\0]g5'M?n !Vwga7}{4B8^B& *^ֈ+ʞ/ƳGF̌w6ҳe̼ov7=~6Z?? ;?0o$mT:] tA]h^T饵[gsuԪ:e7N&k% .)%s)F^HPuMfx[ҧhe^vG=A-VibJ+r6ܘ9`teKvH kN++rODM]#gU%,s;NoںZ1~BPͱ[??0imq6??!3o~qUgmWϫh&dpk!Vlq+uv+xTWiT"ZDa%32z{:E#ZW?0T~W/Lh iG9 Xn!O c))f o&]EZLX-ll{AuiUrY䌐/".d_ǚ4|3Z(EWn$K?rJ`wTʳа 2U6iگ6&^A "Go4Z" ,%EyԒ'#x%#p@yH<+ߝ+l5-qtOGb?nPXa{lA9&ghR*lO?raP?0V]ш5邁lhl$`?r,a?r-b)EQ_zQm|kEIJ;ƶ~p[G`?n_??eY^ϧ!݊Z;A=@?0RP",Q*?nf%V=|کFkCoĐ)1.n]KaQ04)VG Ap"#Z_vl7 Tm$Z,A$FRǦdjB]4oͻBM矘"pNH5*]މ*\@mg?rl|e'k.njO3¯?0Fh8V??e?0*/DNHTmZݐ)Ƶxfw??tԖXDjbgN9GjI,X*7+զ'v?nbƗz38[hYo,7K"pղgUz 9cSF=9\MsKMj$]w֠A I8kPt??*`e.gpx;FʹҠC,~-MA>m_:= +#u&r Þ%?nJ$HܫUݐ`2](tGĜKLkxj;\lT1e4X4' Ju,xAy;N(nM?n uԊׁ?nGtZH^zVg捺}wVUwG26M[=I(%;Lnڮu!zj۪x) GĎj_FRI<{DkB5ݵb벷1A9??wrX&N:P$c6b4V :P~[B~0h5ca <'FA o&`&&DL+A\svbM$!fTa ^+OriE+V+K:saE9ta"VЊ0,]9O\_h:Q ˪>{+-5lAGҾ珀EiRP$pE5(1;$"_3nQA@r$:f4Ǥ\ald:$Au& ɷE)ɏE~bD?0-1n.P 6o?rv]zv{~ğ_1'6 8X0عy,??lRӦ7ZmNԆ@$$!&.@ZѾ>¾ "eV+qC!Wo^}1P=BVT%I̾_<;qUFٿvqrP&;,#3?rȍrN߱͘} Q!Kn35O64)HZ#rW(gѵ1.)xK%ݏ(|"v3נ[K`YnH$8c]V}%++mV6^V+cu_]o?r;vp;㘱(h}??++e:Q(ziG )U4wc4qȱh݂q vOQw-󸤮~j\O24ikBrf-lHVB5EF˕T9^;Jdǚ^d?rDe*+] ]nPi٭7ݒA`AB??1?nP$ݺov}-ڹ- &aH2Y¸BH[q(t)]p@]7q! .]Zqc??pQ.kV YGXtHoi`GU"r2oUdq|.Up.`:C%E89A.#}1YTWVZ3ufg>G;ۂe7O< n첛r-"5{L0???r0_$lAoӛ"*&o5Jd{绁bz0Ϳd??śׯT??xy_ zĜǨ)#Y-UMYǃj<zxP7%9M??",= 'SLAz-dn)jQ?r3Zi̜t:;WY2HKdWn蝢~?nvbWxڡ5K dR1_H='4/$YE^tEꁎD2=֋.0@;0/_ O6ed'2qɂ;>uʔL0EK?n\I??.n8)z=s??9Gb|o8FTpޗmۆ<'HE.=+RuA@ZvE)WW%ILR y\\j>Xuʒ]ȏ@&R[qdBBWqEI<Rȯj؎vK6> Ύۅ[¾Q| |f䧹rS*FqݡTUFaIVXKoH\?0x<a$DI&wTNExI~6i*gZV*a= ePWq:6.dv9&NRi3(}c8$H0څ[ Gb#NH9kw 8[ZQHLF|`G NHYnAj-NAIeRBZ>ir?niF%vrff mMO(f^r c}ss4 Ff#L?nd-RHʵF&Z1he(364~nd~@^UJJfxR !3q$_,[t?r 葆7 hKyHO!!UlMu&`ٯSІ;?n՛b_HZnAu@p{ޏdޅ`Dŏ_<>\\n(` /[x$l,"d> ?rn8Mg!+]*t Fa ^ãŗ Sy B}E8DY`V#;x ??U?rXpcW r=2b*p|kR"C{`"M?rAG聗b>Qc@.p" 4R0Ԥ#H7qP2?n1ȊI y7m?0~`a?n"ĢYrAՎHńs#֎FP;bo?0a^aarVx'M\;[I^i:LIHBM]"}dK2s /pьmYdLΰl?rp~%&S{`hơpt@@c7:qtkfnTh{>v!`;ui0#?0gKɌ_?04QHHO75C|s/" W?0'd<1Z[ 4"P40xD <a}`L"nk ] .j2mLqx(A1j_:d1LOfѻvsBhm!^p:*O#i7n0w#Lyj+O*?rw4]2B-ThH.KFlT{ [Ab{8LIaΈzM??gToCXLa/G??|~f=m0j]hV,DY& eY{eBQ=DԞQkM8V+h0Є Pb08N@xWzjqPZ^ޔCnx1H7TjyB}\07iϐ6XxfAqmS'/c΁NAdO ~Af02,gO.lyl@΂?r::?r hβi|KVjCQ\ȇ46=4u"eHfTh7r 飼EY`7SMmxU+fNuG@?nqT2m,EwAnNu5ng@`Vp" ''b X19gFI}zvmS}e`\VNdA`>j.hF[tJ$W2uNh4e(yAkJxgK[ ȌA-tX0<?rk+H?n$]>?nZN&#UkCBɵy%" pYcm@<&a:rc}:VQ(C&)DAGwإS:_V_?nή9V4kd"}:8in@Y,[YnPZ=sfh`4خT0^37.>%h"%X>ݦʩ)FS@\ZPD?r&Y 4ڸ|@*峪goQ9`Uip![d iiô-9z/;6Mk;jKӐiy-usJmN;Μ˜98C`0?nb4Θ-r6ذ#2?0fPs6^s#J{Jfl+ rNmҵ x@wfLGFbbj1!q1s$%W,۵W-5{՞f8L}[)z?02Qze'/Kk?r;OМlG%l0-#??zY '5V 즃q},TWGa^`6u@*njBru~/mrBr.xoE[Z1כW???0w<{7m۶m۶m۶mƹ;ߧi;;ji$B߿ʪ<8+;l53#Zzg - X-ɧϏڜ2:>p39:߸#^C9*דF{Crùtȍ1|%Kҕ!#?r7LG kca+|-ugkuPV4`ԱťdE6Ҽb9q]>iB`>-7{R4:UrtR]lZz_>&TI~L({ZzCe#YZPgvH8Z)'MydtOwm٦7?n?0p03C4Փ?0FӀ=)?0?0@?0 I J`g MPPJs0Z07ap9?0X$\?0MlD[&de8 ?0.#"Q?0s\І91(9] ~?0?0x\w???06>J9P0`@ X??&F{?rqk(`xvN !fb=1HXt*=l`Bb(hXK 8څɃi9pʳ}w6xۅ{ "ѡv;#Mc##5F_C?0?r?0J rdŀĀ{Yd74 CWYm??kYCg9?0wT\ȹTY~/2H5 MnSShNvF0~E@z;inwR` k<јeBT?0X)sL?0??$u?0?0->_??O, +N9'c ,c7cdc??"?0ҿ??}!?00D=,rlD p=$T#$܍>+0 H?0L?0?0?0,H?0V;J8$ED~GA`[_?n'X ("z A1V(;Ҽ+n_^N4vw>>Mu9vB飹!"(}j?r@E㓖Sy[ͳvK7H?rG5AJsU]YSִϳ`!IdN_)eqxTʲ1T3!an?0P[3jnvv{ٱT^O 91??*c m[x‹){۾HGN\lP`dD`x@(IE:BEΧ;vg9q& +#5g &"=->v}\ę%heD{CNBx&??i:&iF??)F"|(-1vIʺWYEe[?0A=}ŗo

G_??^xE 1#2r̼4wUYր=!`~jt(RݟoƴPSnpL+޵e!2(8(Jv$T2a $Hd{gZZKxifT-?n8K4X?nCo0-RD#vY):Hf̮kĮ׫%莋,~9?0aud~ T7PPk9\%beY w"v=ɯlXk@H(*~^ [L $ D_rD˩=NESd'Y8=?rN &ey8n:N qUú΃m?n+EyKb1 bź(e}՚ș5{U|[Uz(KY*7 !q?r%1`RjGqIKR- )X,:YВG BH,V})ndΈQQ:+ptØxF9 v;sdw5aw„ Oe"zTs{KMbky/-@e'[VZ.!CṘx:Dø(Bv 4oƿ~}GD_^6 +Y(g2@k6+\fEAPeJïnjCT~!T6~Lu䓆2z1i:)dxEYL-uI!ge,p b}\,?r4+4TJ7kAЮ̀6&]2 ՕOhh5Mm27F j5I~7;̉yb8%!\_LU?0ʽ'Uʻ $|D8UaDڙ6P>W3ZjwwHGYa)Cʕqލm*5ץOffʽQ96V D0C%]>7Ф#ikYԈqMeoKkI?rAҙwz ov?0 n+$t-M !0I[`p8/;ӱcBΑER8ط%" 8Y{SӺ Iw%N8jK\!G{6u~Z@\{iKȵӯcsA>nyi}iUʖAAqQCTGy93%ǧÉEBY4?rK2êJ(Q@~$|Qe?0cqo,gAe[6fV:ܻtYށt5oywlyفq:Dp*'뚊v=i-S麉noLHCoL{0C}^!I:ȃ\dz(L4KgadLm'/r)} #Xa-=DɒVMi|F`z6dwd'_MuQfbt??hdgBܫ011(ADH@yp >SU׀aJ`q\E[]E3KbZy~Dc+b%~#̔_ߴ|oӨzY 9lP`?nV73/r]aFauQ:݄y%ܫ e:R((?nt$$jWM} t s.)_?r>@jJ:+{n0d⓸ֵz6XP# vd*u??=7 #j4oU]{.JbOKiɪLHN/N*&Kk'W&'I,䋐2 HuRh^Ӕ ڌ,d_??&75r$Wyo??ɗY6VXpz.F}#ƒI6ɼ8 Qc.T5|-$fBV !" fUb&k}R&)0fJ es4Z J"})7씮Sb`Xޞ!1DH^V9}GBl1>4iٵ+1%4ju{ u@,!q! +#ǹ a^ l'jڍ"8cDz YA{2GC#g8i[]ptv~?ra2)|jpnlY}+:ѣ퇅2L>[GWWR_yOLJ?n+)[]Y]oBvFby:FѾ]XOQi@\^lY7)'Y??1MFI;]Ճu.=s]ᗘ ԁ6Ҙ5jj?r\&׳S ku??og ??p?rq\LcB0hZ2v^ӥ5|~lh [8ogQ.,|.j?naLsi}yiHrb`(D.U0lZ!Z:ϫ5)??,X2N42}gW>wíFiLoNRrΖ:iJLs9X&P)T8g??L|,:`ܨ2GkZXbQl֧5dlJKhlS£(iUcXCN:+OM%FwѳҥlVJz);f\3%ݬ[2xfӿj=<;"(ΜPc)lyv<5i˓Y&_D0Eغ7'aW3s5JNI@nB0Tǜ`JdPrXiL$Wr)먄SwtV2B5b⸢315sʜ0V]E&(BB)GЭ*?nl !-tbA5210,d#,@թ?nɦ^O_܈cP冑q15",M3|ؖqމB$}KozjPM$v}Х,(i# oTt8BEv'??ޔӜm-~ezXaT-2;.Oz\ ,o=ޜTDXQ';Sж؟$>z7KySYs֮МHGvHU'4|"TcgMHmW !Kȥ#'?0{M5dK%ĥ !(ۭ'Tš+hOQ7O}j%~ZZ}jV$t|OzI_Iߥ8@tT t7 ?n- ?rpC瘯??]Sb͗\?0ՄN[b‹\ZND5mBWAܗ.))6dz_0i(t(;dVи?06&.&6gB#BG]?r?r '.?r:bj<o?n36퓰Hbev@#l@7#QΣq&v,੉\7H̀Gj"9z9롟!EPF'- NvKj=ɄF /΋'+FY==K/|%S\K7e@9@ýv*?n˕Nd:o=55-eX@9Bc [K& %:>$.D ol;^ eI@^[&T(L?n#ef !x?rѝ ILrv??9H~A!;oN>Y#:Amx??IR?0"鈝G#!'*Tx@fh}Rc)x*&W[4??pz\`bbaoiUPdBC {"ɽѪDĈ:`0c9U0 +r1 'GWƞ C6&4d+I+M^x?00hZZZWg7¯s^hWg#$2f[}HH,dNcT5O>܀7>0%DaCb0@ZVYKY^̿ɫ,IV; lܒŚ*% :@LWVD17F0^< +d-{;r>]>ANJkRj!ߚ8]>0mS_cexfv9< 8`<2(qC|nQHPyyս>?nP^pܞc;R^~ƭ[&W?n3 ؁2?0ꗕX}fG}?n8zk%!U%2@A̵giMǜw<Ghw?0بlY_e)W<Q<5a?0Bu!kN#់ݎac&“ G8>qtRu($koA7&YRl9Sxswy{ Lbi_*IqIxB`^bd嬭4!.)+3>hme#5o2LW60Mo ɛc/bIߜ-Kл<_]N2Ǖ (~6j|oBℍvLM*뭃g"ĥFO?nN5؇8PgJg{z1a#&$'/q)!@??}dX]ꀼ~Tд_?0|ClgF`{??wρD"X3TB W ČIEŦACWk-4%6Fe'?nQ qFZ5F6*??wϟrt-X.q4%R*&s7ye-'3ۂ)c3;-uט,?0pY 7r6J;IGFGdž,#*qjVnd~3.*Ҿ}lg73=sJ|9I"sEcvg02?0.b(???rS`t?rG8q$YV2QQج5Mij??2ra +#(: ??NˈfdQt2q1Q56ᒸpVC0v`dcfsHк(C9|k!H֚p9>[ Ͻ{p+T1;w?rIńh?rybw??)889Z8Қz8 #J,=( ie `ӄCrҳv$,[G0Lr4uk^ma^|冟pb1'_-q5ԾP;#ޙ/XW UQlX@b8캆%qwAAu%u# 8玜dAtt,1z> ۍkX?0,yOb{ɓ,Mtm|;!}GTq m??+ ?0ecڈؙ@-\*~kUn97zN)qq%C. nYai>Se2!>Gl駺Ǹ:Нඏ`KRHh#11|ִcږ 0<-lsx]Y6١$r%citF?rtfMÊ>u=Qٍ &v_ j4{FI~ 06??.,Lo7Su}i3kVpVJwk9 MJ)lv`-Ŋc&>*1]T%[%?r@!5PJ[pCLxa˷13{/zFv:{$;_ܬ~????0Z`גS6Jz{IJHX]?nm/:M]S xV!i!_)^'k^7Y}^p+Y"bP|O$(``fj Ygx4.HF!"ZN?n KS22bGpMw e)a!4?0y1@.)Pzjq6%2??'RqޒAiK.'-[9H-efW[q!7mш0ԓFO-?0dXӿVV?0vq=oocB$8ŨsCOX΄.??]oʍ.A%x)ɳR,K 7E wSfqՓ??pH?rOr2kR%+8qD`tJ+nd_8?n[+Ò__kw}v>+\ 3_,y+6?r>~~:K*-ݿE;ްEFVTd֗4t@SA1A }??ҜaOɚwHНkPFTK Pauc8Z`-Tg(_= `+H_J%L<"W19*A$`ICgJ*ŬHN3e*lA_q*h*l Ah;p"g/Sg>ay9BjZ +{>i$8?? 뛋yĥL3?0v*Dʞޛlb@̼)LP-؏ńnG :حU`宦}Ǵx0۔/5@c4}?ryz*Pn/9ZX9^478E#lzuɠdB+od 4w Gvݎac0: HfHJ:<+"o)#I93mxG[if&9PDР7`?rSxao 5o r@ !W;??>;gB(Hd. z6C8:^[c#G,oF08N`ߴr l˞+"2C_noÄҲz,:@ڈ %g_#LVW~!=}맻ё͘^+mj]QӜ>rb{V$5DBQw 5Tgy4SY1sO0=d}8ѸyV/u5:.5t3yPmyиY?r9'o_dLouc>џ1cycgO_ޯsfY7h l\ BfO?na9QOlb f6n/ U+yh0¼ *ENE=FeIhM?0]_n\l{]Rťh̔?nGU2+Y1ˣ%VپCfgͿK1?n\ D$f+5D^.cORԲHMtlfh~;pgT$˞iXGo/\z#t0c@FrAYer1T0AG ,VM-OcjㄷQi@A+9L!$w~M!w9~T306Z?0czgjm!jH1l8\/ X{Tnڿaی<|:oQØcmG'F"YZ ,Z{.sjRkGu:2P zMVOfdx7V,,N̾T,Ը=uk.ή:KC_}GK&AVIU)Iտx0VQn.|ϸ޳uږK]Kh91W[дy{Sx +#4֘ ;}4M}]rzYO#/AJ+??&Sԍ!2OY3N'b)uG8\?n7k8}ІDVѴΙ]u{-1JO]EmM}T[H+JM2ymi[:%Ǚṽ@2c|eO }^{BQuQY-NTG{sgAY6\w>iaڕEH{kr^OCs_G:!??Ő53>N,Σo2AO=%zrvf31I#&A"Gêz%kIx =]~(h ]hn/uG":0טY2qT%4_dOomu8c9{>,*"ˆwU1Z%ɧ?nmN8SwIqg0qnk[]wΗa(h"K^O|r$m7Lfd?nfʷ/s??Fy??s<0Bww19h?r??cr[u70,/YkUՍIU#m2a*d.P#[??ZOmBH@x")h?0c7(rρJEKq?n< <'\='e!^:Z1F7rdI>ƄjрUR*@Qo;b;!F -2"p=_/=MtFS?0??-X]qjCUW-n1c^(# a[|G@7I‚ǯZ@0\'"u~Cr`]>ouII?n{iQINj~X@˼F۠5ٲ3]9V/jP9*Hʬ0$Z ??uut]ϼ |]g郴`O"2QsT)ߤ}7 ^[l9Ԯ86ɺ75ej`Z?rN=.4v?rft[A*ߑ*׍h֭ݼ6@JN\pTK.|s(a7Ev8UN*Pn*gn|UPrtՊյA<x:r_xŬ;jMni%a[_ܼcyxu; C}rՔڊ2;|TUG??Vrw+^A:7ve.5Txy/() t~k]i^:bC >p,]N3 橡F̪" 4_Ea[=[3{-̀9g`* DogԇALP^^i8r=u" p--;VebA, V-sGp(?rkD6gOΓ}_dh˖y!Sc^VwJIx3B"=Jw!}Ivr?rnzK>$(;hiqe"2Rrb.ѮRSd.1x91YKz<%n73g 2ce4%ҲV |(R#9Q^?rk eG!ȭFkJFb_߸aėZN}o[ԙ.``J𚋆Yo/][+Sg%y~m2L`Ֆ&ɹ`yY"0?n2f%Π=i <6Шn 5GdX&N0H㌲Kl4;!݊<LJ$@uK>"1Pd`|n ?0cKfOΉ_F;hASEkChf/0>i jK}dx뙶/ bt9 W`+a_5~RVDY ]y+S3!{znnw!/~r,X'rYb_(!,)tECv݈eð~bItE4q͹/uXx??ꨌo/ 1S[E e LL|d6ξx\VV1veh+2k],nү"$O,;?n陃kp??9f.ѓMP`3+/S3uSކ%=t?nwduW1=-P䱐 )Ba,#zIǒDYz8J7pd+iU;}~aDZJtX^LyFϮ~s?0@׻2?00u:Oc*N4'[`>M?0*'8+7)/M8/ VYm"9+.~ikX`?? 3?rZZ.E"Aof~e^}}sƛݯPhTIwT?r;gv)QFFMC0$p݆3*6nv:О'vF7hʝxS5;LݔfP y f#raSI1q5gXmRqr.I~PW,hԁm?n3eVk^M4v;uOucU + pOZr0@ʁtI)rlf!󂠿\ZFIj--ɠfcZ=!lK߂)1YWr????v?rzETy3=w4OY;*6UWPPwg֠a}yߘ֯ʣ?r 7Ū/g<zOC m5]=y~4i7z ?r86\??f=t__58jMo7:Dk?rӣELdd*&CA$bfS3%xFK&(tj~ _/I ~n9 r:dM{6~`t%$II^?n>RkN7~9bJ%,fE?0O}a9YIpv\Ʊ>0x,Elq9\M<{%,ĩǀ #*v-Ƶ0Fgd>o-o/=V0IҌw +#x%_U5:A}h{bYA1eD@nV@&h65Ӵ%`ْ?n4T%Cc0Uz?nw)TT:,U;N>޸}npOD(RP3+$ImԸf[?rL S$(4lvaWůuBô?02v?rHgǫ_o%CI8/@1ۥ[=)S{?0*qUd2Q&iYNm"eXC\LՙjXI&;KYkhr#&F8[_2vY2[/ US\4sowUpr?npE"i+3)>p/j[e^yz?n8`yf5&%aAxqZLpBN,` }4|Rw咹?neԺ+}?rtd1u8rx3l_{(W+V*Rw}@fY~wd[;!ѣPyĒ3;e5\F^Pp8?nrM;2+`h #7d_jhW7y̸~.I+y5va:Fd9P5Dk'=Z\ ޒԫrhI18,On`[dL8dbF~:oVdS0"ńşdfg܉xЙX9L\Q)(M~TߊZyuSK*[hLDn@4qJD/1)h^{!1_C"kD WQY͈=kTi!StWn[Wp]~R?ry/A?nPew$rW*#X Yk|s@@mb$R,q1?rel/!}G=:uPx<3v0ɠ}r\o@x*cZihbyFتFX8[JD3t5"^͏(oVRHmc83g9Ө¼ۃM0'W΅b'V445bpSR(X`?rd;hQQcPOmyŴ[kAN/?n)PΐDbf\7 Cn=:mqUV?n}kbi\3?0x%*f%:GTPr<-ԏ u\mq>ɕmW51w= -?n>vCZ= ?nl  y箑&&*8/ҶI#MH[[#H\0" gybCQ!d 0>u؀3:x1.w×D։2D*R˱.5++ynHr$HC>TK qSQƔM4tvA}s+ݲa [Ctʪ|+1҇2J7;36H P'fVkz y);ʾkzߖ%N)8;qRJ<,F([Fp=)3ypڃ)TQMq҈+??8C8 oç]l@-cuNK-9[c-xDuQxeoT>nlCCip?n'?r?r˿eFr]MlX@I_ ;G8N|4ꅿXv nn6k)䷒Y?nv#iԠLt0Uߍ]_JR9``Pӈ01O9;_㿎4E??0f7[3*\"O5?ncbdpK܌^2`ꮼJF!|ے[0SmB<"a2ua<}ҏ#D{l&Ĺm>̒H@7QZ9K`CiRdw *)!_~of>74#^??!JϡLYڽȶ/PIq -bܐ(3:{2>/1??nJU1(( ޴1Š2{u0~>$aät( K4 jd2 ??,|W1\C;'ilaX1u܊z2Bm94)rM]$Ϡ__~ksvsnP*":eӤ,5.Bȝ+S@8hM5SBP 1рcOX{r10?rBEc/x?r:#??HxW ֻj%t_q?rn`Z k2[똴L9eYkȔQuy??*\Z?re !G̕ĝ3C^Bɾ0[Cq6sm~'lypF{Y15_(GQ iհGKj}8 NyxHs.uV0F Tb2x%I:TAmNW^0uhK&D<\R\JZe#D@BJqJKL, Tw*7N%E{Ԃx-]PG:W{'\M pC8vSs\tKUJ4xܔM_j]lurC@VV[0*# .NNRPT?r"5 y?0W򅇹l8~Mz?r1/??<]'{bFM?0DOLdJn9u'tlyy&=nu?rMӱKKwZz)Kǹf.?0hj̓ 00Jg߱`&4PSa?0iš?rrї=/Y44&2grƌG!h=j?n7ŕxf zjhOhiCLSWtij Lb +# %V%IR ,hzo< O?n\4X*^bGTq1Ae]`YIN 5=i_YՐ|K\ 1B?0JZ^>??l\hf_Vkp?rɮoJ 7p|xo҃RLz1X}u嵉s]b85-(c?ny??Y <E0N "ncDAۇ&s`zmo D+wV)q$?rt~;xG{wiz<]>PM;CWKbU?r{38o5~=oHjNF;?0Kd$z]W]|LBo꤇g~km?05:@:vk{?n](7a??9;Z!?0cY@L#8/AUVs*{sK.1>to6 ֪hd|6Kb,UJ E/܂/(uw:Cw>DH"A~r-atL?0gU!A?0R&a%4f2xJ¤+2<3E9; d#pb]8+A':[}pJ'eWd|ʝD+8B ҨWkm2 4d.?r(~f!C+?nԡu9"dwaҟ?nmwnj8X1;O{c3v?ru(td}g&\<U!jqv83 Rm8F1AҫչiHv).t1xX-$'VҤwe?0?nLb},{N=x*B(/5ԇln5Qj [;߂P̗'xf?rV7<MA^|s:BkvCCQ"N?nA8?r]⿨ҍ2[WKNxo/6Fz1W ~ v?rue[3tn匇 )8#2p^H*|u?nca}"msZ[5X1aE[!o$n^61z:m|ѹ)??>\ʿJ CMRf9Yrq mZ6Dnuzg6Jꆥբi_eDęg (#y s9}?rc(D-WU$k27O??s̟.⃏AZ6)UC414{_COkm6Ks ??zC ̌ {7+gh{(RQ8bc}v-m/3Df%6St}tD㈒KB6B!.uF3=~Fߠ]]B^8ey';E5O#PF!6m GcP%v`g.M݊\conX$iWy^΃Xd\QF(؄VFD\B6TG=GڌY@%pDS]O:5(qa gpbh876^p-cmc ?0<[]a9r:tA}R͌U/Z-O+쇊?rQBjX A1QTShƔ2("TtR+xڪ׊Ǧ,fgE̳^?03'6#ȱ_HY$!W!ZIeƳ+M+es.@}ziHM+QPYMT#@Z?no0H p3U犠wC1?0ֶm۶m۶m۶m۶8gIsΈz) ޢ jW9?n}֩@v%؝ -pC=#`[1ma˹vs^J6Ee\>8CW]Vyy/jx2QӳP B@./2u1t3??lߙzN2bvq-!UȅJq"h{byj`&z3堲VI?09ѩz WtbUEOzk|zm42¼?nĨu;yc8J\}ZToK'Rvk7J[&bv?nHx9P/'Ođ}Q??˲L6nt&E&))֢B9u`VWPj7B¥V`]^و 5+%?n8 p&OJcsڗ|о^V?03uȯJJf9Vc!! =2,b{탤G+=dM.Q.3ɘԻF5PG8}b jCu\$lN(chA+UO''cgA@ TxBߏ‹(5bU _,6Fg6dt0 }")urO 4YKPM$ 7eܐ{Eͼ`?nlB8Fi)0Y=fyu /9mFr#{P D]c=W|PŻNnV=^h(슧QJuk s,I$F"U2^p.Gbf7%L^;I]/iMo?nm3Bә]/\U?nXtHsVy1Xg=m.^V#L7V;Q\*ʗNߖ_eA>?neî5.&X߆hHf,D$ )TI(FdvR.x]ēp" onጓͼ$|&=pk,l4!To?? e1 Ci8#B'WePz#ID50o0wT?nIF5WD9eL^/(ƈ_ʧ(ծB' Vp{[Gk[f iYK-x=A0pG?r<D!x$a4q?nǞitΑBDRh91PŦcoe=gg@ T-a,twC(Dz~~\xvʩt+O.Sat Y?nVlDP% 0 ;\'9BchP{C?nɐ> ĩi-n`kվpSƤiF/"ۆSxk#)T0!ng ߱%Q!2j "Ws산!g?nxp(K-l/+ KrQh\"1P?rϭ\Tu 싿a`vkaw @ICj۟}}28F.(uUV2P_z'tll/#a*>F:W؀q/~{aWU@c2.dP5Ӄ!m ,qHQ?02YP<4`'EiNy9Nhw }I6yx"@3,mU CO&,ADCf"Ndb'|h%FSk1gDÆv=H8??(|Y3]rxxH@~DE$T-N'h[Oe?0K/QC LMc|Lx R=dwo\H^v}`זϨwP+ /Sq{E4Xju5fs#QJUL"5}p+1X*gSn'1{0AX-"S ŊvSl58r??c"y V-hݚh|h/jk.Q+\JfXS+kg"ny[R 0xՖvX4-d޳3[)&%Re|/iBIum_ˡ_xůޔoTR?nzji1fꕩC)I%PFtA?rb@ v@(dROP,eܦ6\M&V8 +=b7׹Q/e?nㄙ8.޲9?nMȾw,ѤM"mDGȨ9uIN/@ϑ+CKmT-mTlVRk5r'T«2qF&1;1z/а0~ϘsIQx1l_e!Q0L>|="#D\Ú>YK{W5"+?r|4>9#\1N3 +#)dzŐM'ޮTX-ΛGB` L*J?n&q%.[@Ӻ)q{Ic?0SBQ9Tf F*GA:}yqUS0qKXNrY: uǝA"-U`x$DK.&+&1],1="=Ojsia$MGJ0d -hN?rx52/ Y UEOmz??A ^f?rKJw<)R൭QiI])),NGKkדlͶyC0g0qߛZ=IbcFغly8Yl9i?07ߋ1&f='ZjcO_ 6;D4,ݔ)x51؟Ԡ>mRt@яi.sftQhOT~ⱂoFE>؏??j͆M{͖W?n3{LJu#E,?rKlQ[ ([nv#k:jmYr/ӵ~ANd*Q/@1ǂ_^J8##oYyX3:97{I߬W??\K0W=qļ\_:FqHpx _8*[ek`brjHKr$ 6T&$HxIq ?nA.RYzlL$f!M_ ΁(d#?0iL`Q_ < ijW K:ϸA=$DʅдNVB9͑<<'CAK굒IBR==jQL %O?nyVLwk?rJa}1R8AfcZ)yrYޟA_foq}kT%G1ƃ$]zS#~$w_j^KYٝ\??g^xa[}L2$iI[jbb! ۞pQsl8Y?re27PD?r#[Nr8-&+n$)C#Pt?nbNxnSNDE8GȎ|G= /%t?0P4qݷDĢ,1Bn[VHh?nH{Dqim2A@;¿ )҇W*`LulOf)kv~)kaafl 4 \=" F"E4yt|EBg68'XGM6n=_[g"X%oE:е:\9cܪy9Py?n g??lD#!??竨UꙐ^,EǻwqB??Btt!Ob]?n{NJay}W)wR|adsv)t]9GAZ{;C2 u7@4l7,Xr/at }k/b?r2r&8q AX;8YA?r"XsD?0o?0/FVYO;bW-?0U<|eeRݦn-@bIv SCEAxRߴ.{{|_GOcBxLM??M<*h`;s&߰L*p+mBJZfDXwZh:r;<0bWj:hwV$H?nD [?0𫟁ŒVŏuŠWN6d?nҠ}FȆAMO%fuqb7FgPz>0rE$.cBEfRx%R:wBMQD)(|GDHd1:Z'/=Zb^?n%OSlx;r-@zc%/$%6ƞYbmI("[0k`̀Ӧ0|TdlnzldX3h-Q)疒_ٻ%We`pezUI„$DG"jC!X5Re鎊(͖]GVlWPRʡ6kY*pf JnRM$<-̞4D]ܔMGW.jRHJ1011=?ru=)/+Jh4 BVQ>9"@ij|\D_|?rҬ li _G_X[j d;xV.1ya'"q*nbW;X#?0[7xs҅䵏).$XO{BTTQ }~)` ]+w+n|(ISENe3Q O (}ZlVɸ᠟Ad_lUa?nה'nLjޑ2vJMvfPF0ث.|u9??dv??hpE 0FjA\S)2E2/k6dI{ZL']qy-v|9b:>@ƫC5XGiESm(goX׋_%r2fDqkY榁4O͗E?0.9zhceS*?0N0z7hrE=[txPK!.צcV_a-OsZxօ]`-Ccr/Fs\^ہ\Q8 F,ե+PlAhx*V@\~#{,*8y ???0'qilI1]SOsU3r?n;xY(lJ`1-6&񥘝v>X?nӣsXJ+ǜ^{Df3̌8Bu"s:McFYF`޷3?rzUE^\d:Җ??/8a>ɮ;Y׺yJcyt ZfE-g'@ڦH[$bض`mE?ny^yv=\ zNu򻳼IKD+'fdd93|;:?rЮFOdgdd\"\Ls"xl΃?nРxO1.?n"9G?0gHXڝ cI衁v'?rV?rn~> +#gsOҀꆄB?n<{%,io=ыYEޛڕq+kV6vɮH~RJ#VgGX~V^'Ȥ)Z!w!mhԠy-S}:pGaN7IQLJ j{E`,E&.@; )&Gʅ_s"|~OyU :Z5sK»2nJM?rR'?r!}N(Px4??^+>}o߮fMZIѷKT*Phs_fQ#KNwUSūcſ(wVWK@}-<ɳH{2' {}o@{A6GyG*5VVdpo`5Ӱd2es {~>}ޒͅ#؈YФ ZO"1eo??)$=OK*֝[f0Qެ aݜ[OqP{ >z`-(V˶o>>I(A`ƴr\#aw0<7YU`wCj9/1bJLus@}D3u=N?0jRжf׍N0ƹ?nLi?rsa_]h %3,1&rGDR,C5z)8>"B5n2T~sK'wj7s*,([3?rXf02Kٰț @rhq* 5Q}gDm1'gLo Hl#o$ـW(6œ(7֒62|g*1S٢tTl>??8=ghc?nIID#e+9MBp0??&{FŘw>P, ;9Ѓ5>﷔^`VϜ׺`fIswTOE=3Nb*4x^2+I??CDa"9#UOh?nZI:瘝Q! qQ᩷/fͥaCN_bkR?rXivbLTiXgR?rc9i 5Z3 T*۶}]ĂRi<؏*?nQt2g!x!\Bږc5_/juN1c>Rq]/d PL{vG/SF3>$.] ҖSkWGtG~\\WCo4jn??俛F'Tr\B/?nLE7M@3I&̡R1%??+MYL(GMrB/h:!W8N Ӄ\[+fZ&aD1plkvΊ-&3;rSz> 7wĜ>:R??2?0uf4}`/GlSq.BePzm^??U<?0e`% TJ8eʔ]T&XUֺMJ'RO.ULgr~F*;0f߳Bڔ +@\LTkVvLTwvֲm WX&Mh:H@5CSX7xm)y`+b_v% xK'[TvB6I'1lPt&XQSEVf7磆s &LΞa~_?0F!J] ώ=Lu;I?0EoIgT-8ՑEsmjU\fudy uJ:F7CgH8,MgEj= ozEtq~kM=2X܊ì9?r?0-J{8XDڲɹwϝkq3_b8@oO61u_TSmLxs;a@a~:!؃fFdvd".~(G;>?r/H@ >}5!iC2ukN({@-zy"Q,ցM'[J\:'SMtlSP}6V%~'sSV}9x9IF%4)s-@1.qDx& Wm䗜Z+#14??Y??ſӸM#,㴸@|JmaQk8x c?rv(?0n1ia}?r}˭&葯KlF;U [?nwOvbIR52;??@Û_ǿ{xb̈́mG<WQ%;F/?r^/mE(ğeQ*[m 8dZ ߇90:Cw񑦸QM)VK~r74??6 mhGRKM_OMl!"(??uPw?nP:,JnUnW?rj}L7d)K'&ͷ3~(emK ^1 9Z uʋqtl<4*q'S܃e;)[4BL.Z.soyE)MݒZ*{w)N&|Y]??T9 +6[k,ֆLQTZLnr??]U^6!9t??RτFx]kepH2\Vs^88)o![|Wf%GuGO$PzN[JU ZE??0]H)"Q$ɀ[蕟X+4OFQ8sYMyS@8er%@D +#[uNb@lKuF\gܦ5Ic|VN -ڢ2]OX>?n??״V@:g&e0b6C zS$yU;(B>X%Alj?nhdMKIICZ??9"@N2FۈJ]^Y1"4t?0d:dNsTU?nѦ[g`Ú.-Bg_VqGβ{]r= Lap^,ϫd:f&Ud :;AE#{OsMnx۳Db$t"țRn7f+D8RC7N 4!olY sZN:3Ep `6X>oO+@F8+2nc$$#?0 a*li7'V1ʻUUTMs!vO=WxDѠG8BE_BFQi1\!?0ɒe?nyGlM+CKGrnFx6m-ޮGclN?ng*Ȭmтih4q]QZbNV Mm,'F0OTOʁ9Znd8C7.&WU(&M\E-M<_9ߒm¶=[?0a,C.@Cܺ ,콺Єִ-ՒTF??/TҶo~rs8u=[@?n|*>BŤ?0/ x7n~??Xʍ3O|{aȯp?rꮕ*!O]Z!U;#?n8mtvqu5Բ%<-Cxhd&?n^"(,MҼP$с,.{- [??%5Kb ٓ+ΘycpLZRbџNEuAj8\{ဟ9څfM!'ICOIHq˟hl(ݩ0Gج?? f"{Zɜ/_76hd;SJRqei8B+!9d??NH2(/*TvK=H0beq.t/N?0X,k&D*Q0ѺpZ 9kpL75wG6eGkI#N 㪿l,Aǁ `2"[&軴z7 P1b*/#/5?nᠪc쩈:>LQ/Oqa㑩)u0?r[`ab%zLC#}*KQzhpRq:퀚i<6\kRk&6uf")%''E??t:#<E81a-Oh%w38AأoFf!s`utqkb~[H憚L嘍{a'jX>{0sF]H;Σڀ>tqOK[7kn2մ qgSg3[0-|guW6e ^\a-ܕ '6@68H1d>o;f@#xJTo(@A>e~{;IWjkA<;דn#ۂSꖡ543Fk",X +;*w* + Zc._!J$9* /Ol7xF2T,UΚpRϏ3#F@cXWeJA[aN+#@M>\wLQBvam"jhRŹ#A0ఃqX%lHț Y-Wq"L)k,;>Oz޺˅>(C??p/G,a-5.B]A2S7 n69e4q4Hj=27Eݳ!?rnbA3ӒO;G2l*1 ~:|?r`?0?09d|bpQ+nS}*[k'?ngAh>Oϖ +ΞGASSkJX;%]<ᨵ)5J5LPallT^6S[w7k܄15)m(Vsc,* B/ۘ!Za\cev_6s:p @e4v&y7u$}Y)utS/M?ria y}rÍ)%^R>@~tO7u#|6F?r$!Y`?nVawf$ZT ?nև,0?r?0YFyqYI$jӀBˉ˔lB0XES_4=&q/8Ch#W3_G\ q/{ː|An(k{k9,uu,v]c1kUsaޗVI5Ow-rh 0 FU~1Е*y.=6-%{յd ХeIBg7S^gT(js WF7@CF&lOLV^@)K*5dPn4㧋Ѥ%zQJyL~?n`_ye8w?n&)U#ԕ[4,Q׽Y?r&Gd*|W8/)!Y%cLj?0J.[>4.*#rm1pYSjC%R1R_?nF<Ř5QRLiҸẳ3w"HnPwKvs??!Cr±$_پ?nB!myŕO.??#rGƃJzUYQ\Ls^`>-?rB{T=ʙ|+P=$4Yܐ?n/pa4M~l!D. tZl2/9 2Mi5Wu~?0؁HoR`ۓt?r-Ch&x7m^+&񟗽GQ$>Ƌ)e!W!/?nJ??&;860ͧ-[0O<@̾wHr#1Im=|+{v0Q ΤIS|^ ?0`>ΈNї0/Wy*;o|+ms6vձA[3,?n''ԃW!Fͣ&e-:.R}(WaŜM6l{0ZW$V67R5ӽRYd_?rtȔ}[D<_?rwPp,=y5DcOjk/Zj+ЏzxuKߕGLvq۵G(mT$(M\obvT>x?r˸g"'J]CF<5?0k2عġNV#=>??w~ڎ??l1\k-IQjng.?rHK(@*4}k?? Tlx:|f>l Y +#[CY|TOy-*[8D|%E^HݷIО{1yvhu+[})~n`n\wQc̼OR?nMc?? D@Epֻ{{t|ZNn??*E 4k+Bh6 3|(Yd7?rQ_*׭6N\='OhX*nTkw'tvcQ/ dj $xqr㨲??{K}ᓈP d>i0kƱkծz>Ĭzg)9Gu^<cv .l<~Ԇ6XW,Qa.|f(_q.[PmnΗb[ HppW/BXhGR@&>{;}~%=?rllBqo?nZ??d=S4fEKPa٫p\yi5&RƦҞҝS?nOs?r*y$u;//Fڹ6B4I'ַ/* }X7 v)Ruv \9ۦő_D22w!>d#IpngL[qnD3UiU2\pNVVI0Mr$/?rl=NfQFO],p)=:L>^btzK\;L]BlbBx2.ňz6Y\d?nDG`ubbH3QhTғ$⽑;~pw'Uب*9uSz)(tG5 &AX2 V-?0xGǧ%w?nq=JZbzk1tAqic.-r:-ac*زOAeĆ4> Z` u۽&9^T#-CCĈɍ~ٕm5=&ƠOһщNsӥaQwHk!_51|iNK.We?nAY_ŧ =cqmJzBبK?0jfRܼ)tv} Qmh]Bzpԝ?0MN4bf@HC&v ,'ڝ214w|z/i: ZA&#kŚ[!O%/Oiw(yqL{$dv7j^(ov,Ai~ iè]l^Q&S\vp/'C1Tҩ19Y 3(B 8wSQ -J?0(F * I=+Uw?n"g´v DW˽\)9[J3#ܺbc;ƩP!P/?0?0<́@IHc?nqCv khq:2͍c,0hfal=n?r$=?n;,0 5UN?nV?rd_MSI(l)\BOK?nL ]( g?0j!SSR#bypbSݤawOֆߍBc~X4|LoD|={ݑ}= `){FqMc* Uλ0eu@Z ]Z"kdeĂ1^c9/hf]Dsel,x??ldB/r2y@??JOqdADžpw|߂8$9= /N珂XZ= Q {k[o+.r*6U6md.X[tmء @VbsRqV.f\v[l?0Y n$/\z*J*9rF 9?rr!X1~~R'6?0]`E],@ӹӑd}?r>)2sL+ق_dJ7ڕFnt9D#I }j}gi\Yeoh:n4\}DɗyuV?rS#\2 +8B.zN 8g4tgxՔNp=AW;R@輦Q1$)Ww,ـW.rf,S&u}n0CmMn@D+ڮ_R_Y/QNZ P磈B{z1?na1as??Bi.ہC/9OoN!9mh!UMj9?0Dا w3gScͺήHlfN}“m*$Pv<-:W[uERJ/ ӶTW0*-ZKAO`5Tk뛛摵yc.9$jJ[` 5nnʄ3sBʐΔ@;Ѵaay@ѷ dL,X_gm)$;c|jJOE>cP%Md"?0'țh!Dә5&F_,WOiP#-pFAyo{Ex966`8-fߢA2?rPoN)H:ž?0mZI7wjm/v)0-)BOm ?r>ov1OY,8'GmOO?rXpl!Zv˵ :X?r}lw0?nnV6i_SxenCw]~$?0뵀#,Xz$h n;3vr0)ν:7ᛀGyȿ7;0sd#;??TWNFӖ MS)8bZ"P+y:,緱;L>];wHŠ%gT.///|,߹<2kHH%rr~W;Ԍ'F't]meC)R#G{?0瘟{F?nk5յ4uSܗ8ūc B?nI"|8}}mF57hTAydksA{= Q<3U>vSW6knq01pxp :1^_7][\~??vk??&>,|,D~涴 +#`ꍍ3y<^nJ4F0ل˷ڤ*4cZ fWv|tչ+m~?nr8R~OT#BT&0:$_sά2 P>MU}&bؖ@E!nD m`Frf!D9LR5{_'t;xN/"g:<~vUpqgе67Oo6?r5?r sGSܳ4OZ3Yj֖rA>g?0}vHgSB0{25x4>릲,}pP"PY91dBL0kUp$֦h@L54˸*ba7d7H27oܲʩ}8?0+дo0]DVPB5SB.A3 ƯTӅ80mD2-NWZSs%VUL_@5#F^,|fh\AC "+ $|cm=5A_ ֏h̓$/ M[_5ZA-I?raL&2ڟ7qɌGU'E-"8-:̡32`{8ynX+!jZC_6mʧ\T⋲>8Aev.n0`ژR w/֙UIPp%?rǗrzBl67͏????u C^&\;*\ t ނ/` ޭ{1UX3¹ckW(9#seHL;:gL\-;@ְmW{TH2?r+h$m"C0y4uTHтAߍn.=ފE5[pwZMٯj7|옱DrIX!fAC&J2mM?0w"ZX֤4~0ˬ?rT.-#ڹMy[=;1;f rke?n,v?r7rpƮ7  C?r-?0r?r?0̕fVyaƾ%.Iy)&`l-jw0I tuX  P>f/s'ҖeQ'/7Hb*Uoh+blydX}8(T56'oD_:TUƬDkt/QĄcn>AmV㴥rNrQ(cr5e̠Vj<է v:a=6mk$~(:]~wWqc??A'$:#.aiҁn<~Vͅ7{?rP???nvxb6P>5y,Ex'?0:|NJë)13#!lB)ny>3[z/iV{.;#?r$=?0mGm>ĐU\-&@F -uywg%[:f?r P j]yvWrUu.-1`?r$4EIfYvxEIݱlL8V;/M??tr]#Fqaq"o!CP Sf)5+1L"&W$⦔\UBw~m<8ii|${dI;0L83Ogi8$m^q;%<;][Ud$#PcD퉆UyR욂DF XFH=C5C$MC}^TI%UsKgIJ6%kJ9e+gDj V($Q; ??t>WDcrLjGΫ6-_??5ߓ# K"$ܜ2kN7Y>W?r<#xBQo!̇귙 K&4$h=tF,DS_W= jSmcG`myUH??J J+9j-/n4TQ< 7Q4?nBL5[?0Rv #Qe-Q<38&0J"륫ISq$rg((.6F#Y^?0pu ߕ :22Ͼ?n֙4lȩn `2Qk~gڎ??p2}.`x'LDK??@U,W*~2:SZ&&) 9ʎuWEuPl'GEDױ??ߵXUՖ?r8?nezT+:U$k4vѦu?rʔLDSHGYU@pqQ&whJ?n:(՛MkCu$HW".Z??I_24EUq.T.":VJΠi Hr?nPUTD5#S:i=*7M:FTB6ͽK>imiF$28Mékt,4M\T]> <!2h\s'{ot:@-_9~\',,NYgSW@TM{N+*Ra GܛnF>`)7XAi$:V(BC84Ky[K,~W:(\!(\jd-lE:<>*>LqQ?ryWM[iEe s%~i݇}`?na/H*??Q`S܍㍿u(Ɣ#CQuwH.6 +#u2J¬4L< ɑ|C—ʉ^u9<2?n4IXLtV:㴱1Kc/-.bëhFԛ\WB\(05 VTkFr*FP9[lɆvB݉N8hY#d$4&8# _&97^Y<<ⴰ#4%"38]4t+Ύ)?nqK5Ù8A5b5pVחjNpi%+=??8uw#P?rѩVdX?rL7wT|!U?nޢJ}Hsu,?nĂ(%sYBྉeì]&P{0殲B?0\@IZ~KJ Q{4F&Td> :pآzLk##!z4wIEvKRX(^IJ2DҙVȐS݄쪭?nww!f"մ ^Kyho]cDz܌t,gqH02_{yQ6-9ttTEhPqY0nKnq*%o! fcR)qr6(sQ<'??8RŘCZulSt2 |F,'6LKϩ?rҡm,Db~lPCBMSjNviO.nbIJD\w0{#>V׏5‘DΜ*fUD('5GlHp9SR&aPB +#;>th4Ho32ROf"!iH|S?r ͑lіNGR]'|C\~BNVj?r_|?n?nVE;'(7nrSG^#Ts&$?0pDZa|vcpR T@;ՂC/?rnٓlq'=@;/FڏE$5JM}JvB'>DEkKȊNAZHgվf?0&Z)P՛??e$'+f|.Ab+ ҭ .u L?0rB6FoH [`.!JM_mCHvPs}?rrf,7ۺm4g[Ob{jV7澷O)ՔP|XOjbtU^??:^jaY$I֙9\]L7IŦѮ˖UțYNF9vmeM4TEA=A &I4l=n0R0r(*NJ,2{'W&?0@T5͔`.D R*pP8b X. NcSRZ_)O6j ح5.Rۺ]8{s~:):"PDOބa^~b|y:F4,Zu ַQƃGjh+d5C-[0߅򷢀*({{B{+(U8>x7Q -~V]IᬄmM_|9Ago z]1D3Kj)&|HD8?r"#S)lLڮ^fZ~IU[0Tr3sc-Ny2i4)?n-kpK47Av;uUEIF命H??|98}SÉAF?0㈘!XK5,|}oSL ^oObmMk=֡PgZ1A.91E݌7YяFUkWWuGXLlI:64^uѾNBH[uɈe1S.DMY_)ٝ1;0Cb3CjDwQA'%P\0\s~H#SXRy?rbGѯTT}'ZʸMbs ;( w*= a>FJE%.pM*b*Sb[KO ʕ2@{ '7}RO?0?nw(!G_8;@OEsW%bǘhN3J*2U6Uf 6ScSu&|]^}g~ϔ+t4;o˝7HopVѭm|M_J?n\+l9pe[gZTm28dF1H|EmD-WjQ'?n0ibQڦk5:l1NHWzE%H(*]σ_,(|k9kξDw)emG]C;j=~?0IlݼsV]+Uif3Q,݂Zۡ)0M9!,V?ri&hPt{j?nbAǞ3@i YFQ|E\Áz=6" ۩uvHvY;rOvy 9's9|~zu zLQ+e+^6zSͧoQB;%3Ndߖbs*Se?rFӿ-̻~?n)R|؞w烹߉]X$xa JOdzREs)ma+:Ұ /**_ؿGi$`i.`rے$v|?r\q?r??wkbCS>Nb#??'9r \%S]7BK*,~άj:?0bDO*þS?r!b~:K_/<ҚGGRruAQf-_ؒLہO8I\P:rAQ*[rJn OdRWD(m=sEE&\iJER}t)}HeGOѰͬ7Bh=a[#*yzd<>3Z"._GQ72*5ϥHZ2qN֔W.7,RJl^n1Wn}Ȭpy@&Wy``\?0ź:?ni\??nȞ虗ُF_<'\a ^^nQB/;3i{<|w^ċn0wz fG(eۋ(oThNXǙݽSfMb*V m@٥PxZE$*f?n>ڎfCd-΅\|G晦CqǝIɣ%).ɘޖ?03bAjM"?0ǿ5eQ/<8Atqb6k%{sj4Vt*ϳI-θj|ޤ׏R@(@<%;WW<P"74q3q)‰Q|RGJFY?rAGMkC??̡pBLBov߼p8FRi!.JG@ä&:ThҶxƒEU\Մ# w77 K??}]ᶄ\5VZ7":E4ITB.B[@k?rŹxS]\ciVp`X$[tHHABFp_qS]"eĉV{]IZM]pڈQ51'1??t5>d4lpuCWuclb,;TZb!gd|8fχtaR\ ST3Οm0_^[|=.>|u\e HPO;7?rfj913p@Xlp.(^aٮ"9QobE:;nLQ[4*H!jSD316Uhė蝑ōby??>_G.>qpL=. Fx?rSorȪTV3]! Ikc")m4̏.6:mj \!'M@FW02pVįG¸ł09??i`ٹúfP "j`r!wu~8-|؝GTѵWk铑wWE3AtXosa)0j7T-4 l|k50ॖ)c>rRgYGngZ#k`E6UxQgTy8i7`ֶ:Ҩ5?02q|Q?02iTzX5.6DPW@Lrw88L@3uS+DP(+/Jt!`j!XRRɓ+he}6eL@&wx46QQZr{9cw *"D .^D/kR(?r-̋Znoy"FgduV?0q5nH-#Hj 2QnoBgElU:%t.Ǐorꤲdr??JkHn"{o,ks vw+??oSpxDJVMjceO??/憛եO6S ~u8G`Pg%wNg-"ee{ K?rqEetURF\}p8lJ|%&xՐWj1bMڍ~`ͣ9ogy/{[gAUDGWS%6:nB,IBX(v9)i-#n#t{ɘEJ?n>Z$s#*4Jjz8UzphI+rPFoaGRO*wGLD$AW~b9?0 acy|6Hؽ7GIGDW͓eDu4ognY/N%_??`qE;>%U̬s0|x??j&0_>4jYM~NzZv6|15|֯vC$ó !s|)u_f,H|~Y*̘#i]j_0edzq㐦4:׻v,P$%YrcfKm<}pĄD"vWP cFwgbƎmqv3w[=&W&,?n?0e8Ŋe:~VQISSIDIwWlpؗ"""}0VH}$QGUHu}6/Pz]GtH?r' A.$}ɯ:9P:t:nCGw`F(hS iBF3;Okk5fRӉQ +#0_4R`/(jvM/_IB!!{',͓Iqs[DdW\/'- Γ'rL!!@?n4|RS@n:,6ql[Mn2k*8IϾ wY2p`Axnɨj7G7n`0R߁\ڙZ}-'A[Oil3G`sZ;G惹@Z/faIJ\ )}}{=>`P@uEgLQٕ0JqZ i<QV^a;~Z_xq ]ngMI'q@d־O'f'c3$3mvt>\N#\R9BG݃ސz1.^il5fγ8Q!!!صᅝGf{բB4_nRa{}ugOO"2kCݑέ,Nv 7AP#=HX5?n(sA0Gj9]ݠVk;m?r-0 4{Ԟo)qj0B ̨ϔoX?0Zy;`.ʑ |H ??F\b#6-??+>?n1"rn׵>k??l&_PFeSj^OxjyՠfZ-3M߭EE:ǯ}pZv=pJ?0䀻H?n9֛Ta(5PC@[;$y&ۿZ\jӂ/BZU*|<62j?noͣ?r++Π3Z}{|' z?n\ ZIPE #SЋRK;,QE@qRlֻ5I{V &*!cWScPE+ZM &J]rـZs}k?r2=WV}i bnBw3ӱQROƳW U{Q#xj;eЬP$6Y]͗"7Ak氫} t!E'b=lzAv0S|=uT`Swj<,Q ?nxD*[ށ9 If[?rզ̃9rHm(8 \ʛDI800G,Eb._^X?0! `c!"fb4Ft'#t#ӵz,QU-sJ(75-{߻{Arȇ?nm>voQU͌)qNV #Tіj_=t| ^,Lju])?n^x!ĊgpjB;š':H)J?rb\AT:wU9BX)5/<0L`|GX.$Tc0!CZ!L!QmmBc񴔡UOGb|8#{Kr0N(kWW,[v?0PwW W?0. U('(?0۴|VG 5JX'neQ0G-V'z;n?nYj?0Y;މPjBؔ̕L rbyU ??/cxTuYm݆){?0 6!oF)^FYvSD0cܺ&.O (uTkYgj#2vaM4Do3h'aK&T2X))f4V{#7>V,]%@0lg"Ly;P:G|!:Fr)4\i?nw[ᱍ??(6!Ŀ&-_KHS}^HUkXΐStWEẂ3@HkEBcrxhx7Sn~ڻ#ݦ#)J;HK'狦f8?0c@]#P޼?0,QUO8Wݪ~~hG|ܻP1mmZsp\>NRZ}ޯf]~:0\)/rn\4^K3@=NRvrn"9~ L@ڳda,zN u6?rQӮ bd#y9iñZcAtMg/nܶrՕ W^.lEKDU!/Bw|r$h rÔ" :e X=p{$F}4p+-a).븾&&FsNm`'U1.˦AMbӀ#ۭh+nOgA@,ML5oAl1l9Џv*lқܐ]xN*,{v[Fy*2FvW''DEon7ڛ4 {|w78˃';`˕??rX\\?rњ2)( 6upMQLr"`6 Y7?0v$ A@??-ƥK)|+F:plIH|dt`$,ѝ_2u[+`N_/i7Ma:0s[톧ҧƜH64l: DsC 8s?08IsIQ5=ɗA ^^K%=x@_ C%yu|nIL-^8QE_DWP}kW* o7 vK"R[t rrZ,qeH6O%`)㜞s'?r$dD$H3"Do,þvZԣ!?0c'NW{o6_VH0L]s"NH4t=A{"??@Xl$RrGȐ8ɍV1Xウр?0&gbe-3 G :ޣtYW̬}gj?n##??$VWij}\=(+PZi#]SˠF@~XsWw"@RiOY~0 =Fٽs9T'R'kܚ$??ȃo؜???0Otlw'ם8,'s޳A#`:܏ynސB +#Ʒ1?r??^VSc4g5,Oly<(d="MTMÈ~v8a@҄ ]vfΚO=y":??6F9mȋ"(?n:VXRyj(r:?n^T#ўBPVf_ߠxٶ3e`Fx[\ahj}jx% a!b0A?nݗ% }>>4{C^˾\*!R,jTH-ܪPxăױk~d|x67Q ?0[6(UM/~gC&;:qUa?rܚ[8ksX.7UeM*n(=mb0xP c@0أIMmhW29)c8NϺ@Sg^Wȧp!1I!?rQ Z"p޽"2?n!H,2`f?0H'ؚ$T~=.j)Z:5҄r^챈 m$v}I[@Ye3/7ݐF*)b 3wn "8,s`.3&s%ã496U밮SL] M݌̥(op4=(9)$aNL4 hs d=W0p"?0!x8ȧgoHy3lĢm$E" _7K!8v={*f3 fȉ?? yS3"La+Z>nad,~U<eA`8JL')t@KXOڟc W/A ?0k"dv1dPH~7A9FQ)p)n??hA13?nGIZXe+TKlTg:a3?rJ(xh5凷L8InE' D.󥾯 t-a_$m[|?n\ܬޟ?0|*7gf??^@Zrhmѵv-d0?nőG_(j9Cy$1qXEĜ³`O.%φ꓀Ϻ',{R~}u3b??Hҙg)UdT߿y{e_;]R?n2Pb?nb`jN5t[FqrNDXXgq"{ph"ΑHvs6~FXw2Zgp7ղ!f->iKy,pyCAv?0F^=6&~$`>!۷8&ʫfEYyՁ??[_OŚY2'iRf:lGm~!3ړC27WoKfv Zy|y̑td6??9NqDV'[!ϡ=dlܞ֚jJBW ?0&(2 Tq(Sq^z*m:su+3uQlryI(~3ZCV+j[t??Ĉ,< XB.V;kkdqp`\erP_ 36(Pb?01C$vY#6ېP ]MJ^T{r7F듦7,䤱zA!d\Pt ֕[Fk̗y DW˞j?rU<;%Zl!>[ѲO|RP CC-?n|8˕2АOhDёԘ-;:-]eRuT2y,$nVd{HRXۺi8G(vcOsnUQjr*Pĥ/x؝ nTuj"dCsI2 -qp5a'l&?rA (1~ w:BorP&7?0ZU6{;Q?0,:?rnE%^Kf"ѬЧN3VG??BRG:;i"]Zc<2@g*:f~eVTf2>w\. \nrOP 1SDrmpcC^Z8|s)y??ׁ!y}?0U'?04=@'??0ߗAc]PWGxNʆAY+Ri경ÞDHKрc9Z9;g 4WZ^b,z:vNb)M`8a0?0Xa> A뎝 P`k[șqm)SV4Yd[8kzw򘄠M[_??Hr|g/ p=5Pp}6ZtHEfMݷ]#E^̄?r@[/A/p}SDUjp( ѢgpZ78D[ѣAU%1T altJ;TI-n+ Q"eR]-|ZXbv"p%b΋Ԋ'q_Ƨ?r?nQs[@1C*,M&9?riot5|G79?r}:t%N5_!VĤz\TӚ^7_w}ijF~!2b^&DĴ4qC 8P9ϤRJ&Lm Ѐ"jL"w4'saH)ɠQ?rK,ƣ,*C!E'I{UHQ n EN|-k[ J$isє$>רFz4x[ʈKAkgwG'??a?rocbN~S>gV#{r(?0-yֽ_ 7“o^V"C[A0r=5>nAUi]W-a끽Xwべ2FH)>hf*bܮ.ctƠUuV 2LT*|/EQwPWZ3dųQ=Z^#5 4YwSud#8*WغBo~Mӱh~v~et0^SPHZmaUV7S6H&>6LvK0hDk,MȊvY?nv:FRN]* ?0\x*eVoxcv`:6t|TksUhz<}bvl 'S *bJ*4Ҋ3q${lqp?06 ݺdq|+96%pED/.E^EtxAh)J?0gOl FI1'Mr^; B%Nu4O.wU?r8NͶ!O(؀1V*_X*x[В.B>Xm?rH,*D 8j'TUH1_Gdw2{VF+Ί(,ۧ ??R+ɩ` ?n.nk?0 +JZ{F,BO7]Hi6]g`eZ%f.;͟JA.W0o|Ĝ"iZ!'vv~S5* Ţ{YAPf?r0_W ]݂x????,LLv~e???nQ#*64??c^'!`Qk?r rT@qlXЖqjt;gaD_"F#[+k2??Qʴ6^A̜>;iQXRĜ$lY[a嶂UDz>t3BISdDmG'JJWauRW=2]P\Ak!YtDQ hn&n\?rҌLN1 txV,YC뻱*x-_[X^F75Lr)ſRr&Wu8m)L$8)QN*͋8pGÊ,5pV؃Ub` 3B@.;^?? u%81f\]!BUdz9[Y:?nl3/7#HqoKWt2f+plUrSWR҈EDx9dGS^ˬ\SQ1)FP҇#xط:HuPeo=+IB R*t2<.8w $2$hT]ä,ڵQCit vCZBy>O%~0zVΤAfWm *Y)8# ç6|,%.€Ad?n^=1תhJˣӕx ?nx'jXA#,"zbNnK~5mxnA `Jwo*Dg|bJhT!4MniRcuuʆ(/e}v ̽qG̗fG̭2*e!&[tr;d%9q$oMm{(EO{J+, Ԋ2Ed=Vq}#Yq"[ΜJLT\J?n8Z/nx?0bw 3-Xc뼄ھ??ړ1K3wR9Sl5 -"UD׽mg5uhĵfwp$شUt{x)xm/к#,۪5]'NRwph]{ V8!\5zYJ@!:j`SG}u;y|v]"j~(.gh+`A-%{&`NgZBL݊˾ƀ6o,).XW3\-Yʦ",^]S+,@j e u?r*-KSİ 8P(-0\8E={@hc]]u;T& ~S*n|B6&`0@͔i9Mf_wEx`7I\T >|ج6!jR߃1Factd%T_h:l$btP"֑*ݮ,*kTys3(J22mF-FjX;y] ^V/nw7=Z|v&1yRC_fNJ勓7Z %hSX8 &Ofv\SetlNY??LH;AS[bL?n@n<I?n}fOupG-~4cVtFx$ XnQꑾR)tҟqlѠ>Z{4Le@}S+kQ,Z[WȤ}8H[sݷ4?nN)绥%M@fbӄi'c?n)pjІ +#0g YeiW.'#@CSyIS-mn,##A|mK \Ѽti{уІKMBih>[e'@>biU*G !&KKOg>/?nI3?0[J*SRrZRi[ ESs x?r$e1TmC!!QqO^|MȳN?r7ܥ$=.Oiuj.M9^3G Vk'|AkRU?nfLũ֍8 H*Sd<4[WSR4fZ$a#KLM?0Wa6'#BYr{=?n;xIfb$FRa%OFP??}s5Z\㓸uZl]y]z;x[J^h/ b~uGsrZ#w'׮xrdQ|D?rSO"%peq%gLv&ng9n(jydA5yg&ŗ7Aqlj{M̵k8#e HO\ǣ/ &S-I[D?0Tw 2GK\]səGDŽOdG[?nKKz#E¿C}F71[7L+ڼjn??K C~;m'C5`?nimSYu4yνf=W&~4_U@S@k$NlҦ|kR&c3ׁvƺ\HuֲyaTG6[ ??!JXp.GN3JJN?r?0g-jo$Qصu2(̩bT4tws~+nvIEi#d1rx1ze!Vi^%`O|0v8|؍VʆU u1kyȪY1(]=??Lޕ1iPQ%{'xL6DlP kd\nMLcn}|6GWb8}/haW8-)"X@WBFSeDu}-Tu.CSy-gT`=d?n&Ep^pY[l@ ^>c\5fVdO0-?0+j8`/FQo?rD1;xR=X!aI-%,0'؋y{Zz6,ͺ аFȋgN[c$ښ?0[aj6}G(@=%`C0F\BsU?0*I*]Tv=맷;wXGXm[.BeR0(j$FTg?n])r :֕/7;?04!̪U<^pGA% uj{H9$\;>i*Ѳk[wPb$&x,ֈ :mBy\zuJTQqJ^0w x ]mY< v\YGvz]YÆPwSFdY`rp5w[-?rvA!r\iC'U??4{&XEaMx}*mm?nJ6~ܻ^NaWuywYDɛqG_@ Pd 9'SAtwĠR-_ ބ-.R^FF[?nڂFK7y2^[GrSE+ >dȞxxu{lxUˏ]:W1<2`\z5j>?0f&N{2}Ւ$!/u0TY ??M!B"];0$$e%YT\13G誄#/w}H٦wM!l]-;dD*oMʐ3_\4tslk"}oaaZۍ_ly6*Hw{w.qi~jNb]BQi L~͡07V!4E=', mnQ6g\Iu73I{?04 8QpO?0dZ0^|Mb'17I=ّߟֵۼ|i 8ӌumT&?0UL!Tu7*0ϒݓ9#cj1 h4$hwzz Z8cBDB"0ȉqR??#B,µSq??ks4UcI[(?rhh$}<]cB??*!\Ll?r{@ےvCѾa5 1b]N{ETܝq. v&Il3*Nc8T`\cr?r_crknjۄd-Qp̍5aAOd??Pl]L$u_ʗhuן::N^;Ï44$볏[>P4 F/1; Ե $7aPnBHҀ'02]GvmnG5BjꔀݕS~I.>k#9\#*sK?n+ 6<Pf}aE?0cf7 Ċm*:n08Dzt^+??^rz$lߜ-K{+ +#Jg t~ۢ1.6t2:\q-`P_{8ZrԀӕB&ždYRі_j|6 1w{J.bEgҎpJC>yJwAP !+o8(hvq=QScM?rcc|A=+B]RaáZMa(ܖʠt-40tZi}&HLM#L?r&sՈIG!O}òw kڮ]~?nNjf:=1밑qB& U Dfb1؏Ã] pu_h)IQxeKr3Ru%&!;XCTr9x^yLẰ7,ȡ%57zFN3涏sP*q I9o5v4 ?nҠaڢϩ6CH"Tv1??(C,f4dUT vs\M)~SRӁ4#Ĉ%.7JzD=0b)L%gG*$lm!BP5M=e𷜾ߖY 7X0a$FN=͈d\Lo*!g;؁Fj:œQ~Dpi.>䃫V]鐢nvh vP^`J5cU>2}hּ{PE:'apRژ-TJ,0Y??*hQrKÏHR`mǗF+\"bZp$:?r=Jg] :K1)6^ ,][t2TfuIאfB58D +ztl{ ?05F)aa*vylP!4~AhY0Cl4&y!I+'f??-aǷ/TI a-K?0vSc"9uWeU^D)?nev5-]K_YhWSTB3OxJg8EM"R"\";D3JV8?rJr+CJtH/ROQ~ΙM?n8JԢ̪Pȳzf\??b[JJFRꪂ?r=ңT roW%U#: G_ܸ`afLfu Qɠ4h?0 kϬb??&Hhm@,{9M:Ӑ?0W@qkrBxjp, G(Ɓ9UAY56iNܱ ???0%Հ1^Iyߓ^0ɝe5t8lDb"QC?njmERj|.qry=}DcЯ׵^7Y#!R+uBm5#qEJip`wRd0'A&Zw.NÂUYF찥$ϙ/B}C lŬv +"ĥVуopJ4./zĦ[fpߠBg?raa' ["Y~/UmݡcHyMYccN|??Q-pzP'fBws0@;yY ht*}Cot<fؼpdu?rKߧ3ee=3Bnb2!VX/ǡEPQuAŮޟMp {!|%"G{Lf Opw5Y|?0k ؄!yOzIaս2coT.-cmj9+AU{Fn+bzM\n,= x`^7ӔǞ}ux$ +#i`pov,Y URP(<m}Ҕ u 5dX\dgN4{9ߨ6U'%|Sg??א>DKPʁLA>o?0}/<YBZh'K"3w2$ZZB \+k^VO@`Aq-/ ,yFߥy{??K?r뢃: NP'wV'V/AՋ],R~(%7 ɂF2|]s{ keㅂ?nQI??׋ 4LΊ%@2,r^k9U7,&G2?n)i (OӚݒ-ZܳShL}NN0??px7M{̨g/6Xj6]?n%MCc=oDPX{u~%&MS!;pRC?0m,;!{EKb/ p,]oyx;??r^Ba7tl=G:zPS):~d(M ٷx/n`ǩArVU[f/E&8!l٘1RLK,Mhn}m*u ;iT dbdErV\ʔcC[m8ž6փSnedh~$?nxev?npt3KչtL4T 0"\ k (B\(\ԔΟ$.'~4A-Կ;lG?nKF~C}DD*X3 !cHcvXi2k'"!?r::w(lO'|!>HoFqfEh`46%&179-5%=[fItEi%at1fjZSZғ%Xdh~lѭNuX?0Iդ(Xeo US./V_X1< ݶ?n}[l6HV/V*NIo_AxK'l_T\9׷/ZulDGOYkZFqE)VLLksT&&rPO@䱡߃se DRB>h|GWx5pgU="=+G^o}JŒkP ub5w?0D- #ZCvtwKbn|~o 55d^6ЉTtT>1P|,';fj&TSy߰['Mu\q< H4HUJDpM*ae#Q{_kRTKXoIF#YKB4n6װŨԄ,YrZel$2p J?nMI4E6NT?rM傆+'Z_[>fZX ǩ-!E9pK'D{K !z #XxHNx4SLQĬ0z%^WWw3:0 ~U3VT79A9VB-8_(9w3}ζ٩h_X[Rk4+B#&g Ӧ-AnRkk29%?nd#OvoQXnvr!L>}^q`8mB!b!c]57gb97ϼ9aY0$Ŋ>^2[JR|˻TQ0?n(gL7KQOD"UM>͇3F9T7%N[529lC.UOBܙ`Y|2Y]q(B,OG*2??x- Ï]+??ɰ=#/S׵[?0JaE0ړNlWƙ,4rSNpZ %]B?0ctH&F7?rDQv;<V_!%\&ٓF[M_J뺋kEl+D"ʼ 覂.'d߷BT}\??FHAf߁;'[+h6l]l84e+M??RySTA ҋ ?r`9+yqϔlRGih>T ?rMl p&UigSVvm'X܆ˍjцS5?0~toHOgQhʌP??|H1?reF>sOd\Y&:h*MalI^jgE}<#٥({U^M{ Cvh3>?n??ܦo*kYu ESœO=H{??RǦ;XcĝE@\.&Z8ݵE*?0vz?nq ,`MqͳrIXi qt¢;qR+I1ɸFζL||$*؍{n̤3 I9 ]?ru䤋~$cEᎲG^Io|џCڳZX>esޞ/U?0c]kGjd -=4"U2DõKZ71##u^)L?r^zgFgf??c,&ʅ@yw><;H3|Y(0s)5%t'Z)IWZs ??=6]81 *D$B?nbgw]+rs#TG"/s?nr(*|5|?nZ%Gv?r%"L(lle m`.Mpa8m:?0-MS (P{$7Ŏʪ>wbfVt)Ih9`??K-eŚ?rbgr&(Wex6c}#BHϡs%֙jc鐏̒*\Is}NUMCTxȅ؎v=58,azw&I(f2hm*&rKSb$#L+t,59'tM.QrBs]T=ł\Su7f"lF+#s??0Gg{F_q#_?n]IuO3%4߇*d}^Vu+6G VW$ lgy\ʨ7&0Yҗ+ b*DgƼnht2d#{"ؔ>+5k-P6,_8B?rV"c}Mcg&U\ߜv7xڸԵNܢ'@-8lޣѽSz 3:%4h5NI"<6I8Sѡf3`.ݳy{:c$qFx%mVzQ%q/]?nԶ#W*<>BNКflOmn;n.Z[qs_C;7ʆB@-75ow2MCL_ݽk?n(MY„;d[tΙS~!D0w>~>rxIuu|Xo P"ެƒCzho{ҏwFPy¼p-l.7i87t^(7rr۷X"J {b@ «5XQ6<+ض+l5f5<8JNi.M?rVZ]V4̐5"ҠPZ .5$Kn ԟ.2υ_9`&H>ܨT=w*e?0psĚxo0*dV{γ??_J^,W(j{Kȫ@?0wkȢd3"}+Xl`g98MZtb=~J&T3{;5aVpDkLg m͹0Xm.?n8 x_2\Iyi74,]..:O#`hɀc??H[O`ْ]}45#;Bgl%pvUeb!S}?nR^aʙOH]*,kP˹4vvN@[}MU"2QcQml[ c<-;zj#~*?nD͍*IcrH/U;!KJ}cs/C^iPvT.8?rU[/c[c7mX/)K5;﷡dTIRx57A7T>??1+ՠUۿռiVMsV*uƗe4GS6?0"M+){kh"2[H?0D9uJ7_\j20{"LND\89Sʧd:z26/]Ĵ lA!~ PrW<>ϣإS0jsN| X6=Ф4:xSZbOǼc:q>G-[Ư Kw,)^8Iʕ˱:\?rA{vW%]=;7$(Js.~Fg;TO~5۽ڪHѬd?n5λZ{7% *]~n쌴ب&MHv@1??}?n8ণ^)Jg~Ч$QvdpC9lo仠2)$EĨԫ M^䶟 u3Ѭaiirddz:dA5&67ѪV]} HSEwbXwio{O*?0;S+J3Cei???n&v@1B!YQ']V+ e|]0Y7R?r:A?n'ÂN_{&Hĕr??OU?0 ?nҠ\?rхcht=Js-Hҹ͆^ tJ'sиwwo*qpQU=04#')U#kGN̮/1c/>7VRbfrB Z[ZU*^c#XU~87k8]Dm\?n%}% :ݮs]ϬEizɭÞydƞ8[Rn?n|p?n+6uu^s,*uS7g'AX!6s%F D+e +;G@+mg-ѹt|cmp<7TeكO&:/fc??W?0QRgR}t瑅T28-/#rgwztpA^?r튤Sj|2ɋI]mNפsF8ħB{%|ŞRSኁLXbVr3nɸ+@_p!-њK?rҙXiFw?ny&׵??9)~䦋[)r!V|f?r9-_ƹ-%k}/Ԩ\&LJ߀z@55rU ө.?0'_kX۳ mH+0P˸N~EpJMO-ULy=5TJmĘ5J ǷG(?0dsQ9?nAO`60x(˔NDSZ='BKI+A&K@ 0'}L 5/VFheUΥb##¾عSW05iɴr??IL )?0rJ/Rii~%ezoR{9gfƩ|KN]#)DKs┬4-{Լ.z=%ʦҐhG2*RxS/Z?0Bq9,a ]39Sk]oFL1dm42cƪ> +#/v3ܙ|zR#*2I2{d‘G3N]Zgġ15ۢ~0^?n7j'Ju=< :h|D(/#4SuY/9I9xi9FI9gŏmd2I?r̶IQ\jڱTXr%O{uq6SE#SA7r@Rd;ғ骲6tR F̎9Lpct~e"$a+봠`yjNS8l[&xtW$ tz@nILo3im:tLF 7c彣x$W!Roׁ quϐ% +dZ|>WM?0 (NyOuȧv)K6_C򠟜_kJ+J'gZ?rBv!-jUî5䩐w17zMT:)?rB B+ Y0y.lVaR^:[bPamc 5Ntc'~IxPomYK4b6vAOj l췇tdebL yoh@:&0_@:B/zM):O:c՟Y=(g0??V%د{(I4eRZZ1(غS~(dUo`Z"\?ry1m\2|Վ:7ZZ{ paq#pF|}-?nbAl}n]B0dBAK?r]Mj8jǧ}X%?nc;r{óڢ??;<\#8s9^-ei99*MB^gv,ӥC]88{r嫝P3O`Ay*[sb d0g~uWKl낉.4Xy*| [v[fY?0刻CdQ?0Ot#pߩor6Hj_huWx@|.]1͊E?0&Yp\IJ2G>boxWҹv,5v,#pRȊvq]'g+dȲ '=o/, ANqvET8sƻvҳ|Enf ;d@mD$0xtOePGBq ynaw=`bD>Y??x+&a\_C&߃ٯ ^չ7Xv:I-4D*Y[^G9(׬}oܶ³EZ`[Ofܝϫ??(~@'KBǑSh(4:q5 d);햂?ne"61_X|4Y*ōH1Cy5+ɼ DCkXPx]ٔY=M uàE6$]]]6­+ɩڍA c=JB#8QΨ%Kay[??C rg-@%׋<?0|hiM\fSSP1aqkCh!)!ׄ?rfSF"y!7 ˲eh n&MoΡ/Đ0ex*=aK b3.i߫b*.qdL1(cj;Ŕ$眅EeafUlжMO؀R`fI(lbS`-hh??Aq\?rT 2.{5i$)P/LąhP+M ZJB,x j[Kl8k=ӁF;?r@?np L(**%{PPh݃#լʡ-~TLj$?n hl&n%ɇZ}ȁu5蜶OPw|Z|iUj9HE+ׁBL4_Nie7p?n%hxM00/SotjeHH*yJ?rivqՖ-wm:mƽ&iB~.pЮ4HQfEI UaR2r%0"3ZbA`?0_:Co?rfVz WuЁn+8<٭۝ް؈/ U>??TnJl|#j ASiva2>Fu)3*%/nttsɪ$G?r^9R;Nc̊M b2:=5e*̃,8Qfy2|ˆ@]?n8\˜s2,L?nT?? 玡W~?rjv>(ޏ7{$PC.*R^.VHK`m5.O6v* YєiJpo'M\??Z\̼A~.|%Da[9o$„FLJ{"L2) 2 A?0wGZo^hop-\Ϡ6XUNc߭$&>.ye2GJv`q@'B?nu svҏ{QkwfvqHծ/c;^!#Qu?0ώ\2h>z$v _gVB޷\Sj/Q<>tVQ??᠆?? XPUqigvC*m:f/ǯJϊRmcek?0v5*nl<lmRtf cpo$Y@a/K)v?0kexTY9|5H{KL_ wћsJp܊vyŘĐSWBΛm3%9x$6ųzEE,S2k-Z+CaL+, ܘ?0F6?r"N*3*3~?0Dپ(OV{ks;`%v`&K4^'fM]ϑQTM_3o,o>-WЃ?0ag.\\a^ @jQΫ_:p `-,|o/_).{9=t'mTu˓DQL0kxE3vI':x5ĭiD\~X ???0Et濑طV-pey%?0er9?r,,h/u??XFa;hf^Oj8DkVr&msEx\g6+Be5UJbйq?0<1?n2pS3 D).B8@$<(p%^ο'b)Ϭ5uMSLж/_7nQl'ZAK'C5ᖽǴeo.~'?rS乖l-):$OJJ!rvÿM_1e ie;`RW 05T289 Ƌ$=@-YR\8?r/{]&y;??z-eRv.!&??pt/mIxzSw$壆jTC!)O{bDϪap?nlیJ1ķ_禍[p:oLSN fGK?nGưo$m$Pa.,ƈV9d[|ɜ\>a Қۙ+R6zQ]]bg9I}+teGS4oV̰m,-ĉ@N@F 6f6˓9t-ENJBnl/N0Mon&؂)vvD;=Kɰ]ڧˀdv?rK3W # SHbitBe*ktCjl펨Q̟D?n N}$̈??X~XF'XGF`?0uDZ7&`R(V??{42^y"OPEQL'0??sګZD /x?0! o|'Ő̙,k}#w~;|Da \D."!FbxXx]% D[09I+eRZ??s6aI#+}J=Ϧ4X@}K NoUszC^-??G?0ݟdt[@G15Myhhektq ß%g0.Qv|#_jВ ٕ6<>BSHEmosrD/c`qF?0wa{'om,6L(`tZLV`$d*Qct=5G ߚN678+-ߦ1n8?r1_20O:QW'UQ|eC֕0KA?rQ*?0dX&U>U6G>;!郛FGiYp!jrsR!QBp09ZY=|r{?r"*XXhoeYHFWгpbPlk!mO1LIZՒQw1kͩa=&jkbѺT8 >W3 _TDk?0yb@[co dv$Ʌb&?n=R2GPA~+gZߘ +#YvZ`p9!ROx4/禑*vE1c64&_xxiWij|),.>~:?nr %S ˔6ohX)?rAa](SZ '+&~O鶨,ld%&mI#)QBO ?r.o!??ni-Y!Z wז3t.CѠ^IeOo&5x8^Kx⧷I)L5^r-J=+;),=5٬tvFif^YNs0++b/m?0FiOKl6 p?r\áD)N9-|wc???n(ngBL?nRfK}h`?r7Q!!8ҞaPTFk3etqj"]KMw#6yV=]ʹω1WbLyIxQrhnj52HS0A_~̦C^%*NB>TT*>uoWvKTk{ L鞹s+"{gq8~X6X܉bO~F'0Ey1e^~L~"p?0BV.dll@N0pH10o"噶%UL"Z?rPf/X3r3{@;Nn1d w]EIqZ2=jP2M/?n?r"^5.?r--Pc0Tf='\$ȿ-OLuQK3:<:?n eWP3?n-#l.D` ]_̈́H&V4+'T|ŕVOaRM7}6_qbi~ddZ8]Iτ ?rdMMKD"V p*ߴ$.zvTP"}ѝyPeVpzWS#K?nj(B)q g5(ZZvz&YMƢ¥gb{hk%bQNpyTr22Jcgmԩ0N.]r?nIJˁ㳗*K?0PBqzc{oD>⊯QFc5{_mo h?06~P$(vOx?re??̐p~Etx}aPZJ(SN1:T`{n$0{ήt9G~.??Ĭ׮}V`3ep36&#g%|@&!ܓپL3|_=`ڻ jUiQcƴ#2J2Snxy|vGO?r?nЙ*HXvJ!8W3-eyh 2‚*r??(HL!EX_n18U$klPt@3?rB-`mFJ((Cob]) Ĥ#̈́ƺmqNZفK|0?nDޓ9w}vgSo=Hq4NS,[ 9B8q%ϡT3WO6 /0_^unuIj>v{xTpݟە|CY2/r>(6v녫ݭE| ȍ2ۤJ??ALdd(mp)ES9(t{#q|7X}'Q[H: ><@;Mo?rC$;IYoAlPđer]\;b\FT̛zW^(he+%5vؒF??aTn(V=0sCq?0{=LU!z*3K*Ajy^ʐYSS%aKR vI&DI[JHn1Hu3pZ ‡WfaK!f9"${R kr#'(/j;w G&1DwjҞ?r"Ayd2>ij}m6# aE[FB24Ęi79p?0Щ#7 :FdS#l 4 >(HfcȭR1";@2ٚF3?rfg9.>@7>oWQQ&uE~Tg%T'.;EWzq?r'%8`"! X?n6ʎ}s{`=Ʌ*U +@HI`݄MTiֺܹuOr ]9 wY=ہmDCuau†Qa!c~Yµ^Ct6OpQG2 nPF7#G.VC/Lv?n9b`;<]:3jdO{XWkddf??oGqkL??Q|RYj$j4?0/ aHW}~`6 t$=m(䦬vi%4ZmiūqZ<}-o ZrLi$_AoPmm=z]sr8v!N||X\'}[Wo .S|؂nc']_\Nga7pNc].]C6S 4Y٤Z^?ncJVOgR_)[5dl&9K:QϑlT<'K8vI@\/:Lh>02LHGQnZ~;2uVGiv6qpjB(?n]pj_2 y?nl<'տ~~vO^sy{Bع;T<uoEoig>[|BgIx~P"K0Gg'M^&aޝ&BL%YuIX7'H3gzJ=zS?nb|]S??[ղ߉ed_1z]DdHV$@_&-w.DajBm76z}:0p)s|UʫT2Le|@I1TDW*\W\ߣRJvƣ?r+iKVw5{MPyX{AV߹Ew]Kgnk#\2Qh5Y̑hc1B}դ 1ۯ!z-y+rҮ՜+-p"Kz?ruH`A41] *+OeͥB>=I98}pAsut!Pge bl_4t1~?0ё!#b;op?0]{$1;&=C|O0l~#e/٨գ23"%Yؓ2ZX,gg4VMЗ,؂oBR44TYPe 9=DX6$?0I "uSa+fTf2s*v4vI(HG!O" 4G!?n7K|?0XGp8RsZ^刺0dۮۜx_ J-^*bfzzx5?0ɬ("xG%Gf͗Jdb?nZr%.x-J=mH̾.±@RLcML$R~2Gŧ:v́2˝ i}mQ 9bdXڻ^m$O~@}W`\ gjBƃӁXE%+:NJ2Kns%]|l.Οai,Х:th^ %D]<+gsdؖV Ճ ts(p/ځ wIF)Nh=kOnH3q"n_1bDWET5]3b!QbGh2.0FݵI.Ά[C!3gr0'XK(+f@d'3r160+=3|/;??T51BGw#FE?nh5Q73223222,B.(uTڳJE), w`b(8kԃ&<hJ03z0Kpb)ki[yz9 ,ltE.̓M[6myI򫗁q:|9ZՅaфqbm$MDֳR}Y o(2:y}؇wiBMlAq'c,81}WhZī=~kv'>O@@ @f [| +F)Nw{~sa:۫{.$qn?04zyTv#V`WXfSڤZDtY?0oe Z~c6& `p?0D1s??#j.ѬV8Lx1x)gR)b锘}N3좠),)Qh&aDqX71*u>L' ) Q`o?nQJDk?0jNBbL(_,$ umȍCX Oߑ(?r| >w=L7Y:wL&&M*^h  d0-t?r-ʨX#7r 0 @Ư0`UW1 ljsnPgx^[H[J??9oy4OI {:݂!04jBU +#Qkb?r2C?rt1[P(E?0r1*lc06hЅR7=T2tn(x?r M_Sۉi'K!ntKCpZPMIYxqpȢ!!sǼCfE&<$Ӧ뀾=FʡI+~Q g܄j[hw[π,ǜu:iC+QzU1!'MJͲf s"1x:4(sBvL,Ԝ,NZJqŽ?n޻Ž75')EDɁROq"oG,Uj E^sc<G1Xr]GRJl@|>At=lK??;y8kifqPthM0"TzGEi9.6PBZ١߅+z??GCpKp?0'|ۂ??u&=zsZg۲`Akb] U8jkdBB#k??"X ~CYhe랥c~[]f$j%7C[ZmUNvРD4Dq^t.54er8(wu-(4pr)n=uGkx,Dd+*t?0H;,-yxd޶jR[joLi:`^Bl n?0I|Ÿ8&۽??6;dR}8B,??ɤ.9Un>&-$fR3􎯧AVZ)Ƿ7dNVM?0Jz|(}B\~ol3iQ0RÐlI&f?ndc gVe-6-m*;Z7@c.o(%`/r8Ux,;UA$=/(9D d鼄 :}s ~P 7xX^ɳiYkwh_O8n&O̍ fLluPPO?nLz}4kB,(R* G[?n`ogW@LB)qLy@?0ʳ_Gpn\.p;\yW??O|&'hۃg*Mwvha&qJ2sOML5O<'-F~aXi0G$zdJo4f82W>}{-?n|.p>~ 3БvNn1)q9_Tz3ŀ.?rلj $!1dgp^Po%݋ [%(gU4U9E?rbQߧ??kG SU%AQ&L~UfFMK%~ t15ieJj{|{WzϿn@U3'5ДpΓ?01!K4 Zl渼hW?0&ev#< GS~L:@ϙq?r!$ۮ!|6?ndΥqZ-F4 A9yu&]ˢB7gZҕP\0DxCp*ӦS_sjT,pX9u&In<4̹4nWM],,a5'k2ϖIo1q5\ 3XKAzkR3r;Y!?0M??FTw?n;KLFV'Ek l: xWt՞SPKbԵ259LQedDƈ6j3j7ZgN1dWd*A˟Sy)nk*~Vym2УT7É"סx"+zA=hK@e󠷾f mf$}RB2q?r;\Ŏ` #SҏdT 'aJ?n?r,s=x ??pf8?0&縂a[yl??j\װ$-,]ůNKKx$Pd?0d-?0Uo.}?nEͶIC?n~P/䃤?r!sWLL{2 :9>^la}Pۘ.)p p;"QS .= spw*삱"<غ?n5B'Jt߽aM|xQRrۼ!f{&{^?rѻo73\cMskzq` C)ϮbwB|d\pgI\i f, o'7%뢛3!NԳKwN,0_:[P@@ofYW]yD]`??]Z3pPe^bF?njMqw4<"]KׅJ?r[T__ӫ qF5gDI|ly,(08/u9Š9K|םWѦo .V?0tǀ.XzIeMBVg<YeT}cHiwBŖ>fb݌ dH!,DŽ)@ctz ""O-?0%; QD^X7<8cv$ ,`;ҫmƸ\{b?0EmPLK3̶5HjVp3PW덓=67֖: -ԨHx{V~F¬ڧM?rnU65f.mԳ]ӏa>= JAùS;h_#h7_/.2(vled`VPɛKcHS+q~.X*퉿I<66!ثy&L>,0܁I5o,[˩R.*ԤoXz8t!ڧτfR/%?rTMŋ5n %pі#p9xY1 ["\09!4F@Qf}Uڿ??1S;R[X2o\g}"3 >Q/KR%?rf1Au#! .T$`Hir6wʎPc5˒@ozP;Sl5?rp)ͨ1mC̘%e;`h?0}??$FCI`TV;;[^7^??2pkB%6)<Đ1)U U-%P-m.3f H̋)].?0ʻ,&ӃH0hg5lt??2 XGo?nSLҸh&k NW޲Tj+tmK??(>og;5i t!8nnOC?0ߥ}_6CR |"ghv6PXr!cJ*5e.MB8?r١3S”htX"lۦX9~*&5S9Xgx??>CCIݚ)(@ҎH IqkTdw|bCX[&)sG??Z"CRn(x,)Ð?rǠX.G>,%=I8 uT[jzzkl4wTX3?n )DmOFC+O`?r9L@;~{Ӛ?0#4ՠ UpY F1Qxpم(muj&HZ_XX/ꉁF[qG22,zAҕfcQHǖFwio[;-=&=z"S7q馲Ua9L+ywZ2Ґ=!0SXEWNv(WGj'!HY++;ӓqHWhZD"??4Yuw\!P6RSs\-Dq?0㥶kc8U!!Fu)'θJb~uimuuᴲ']ܸ[-iԭ"tn{V›M=R0>J5Ы1O1;N w8xk; 2j-r4wr`VGܽWUb$tWgV+;4H%\Z]~?0\-$8nӠE*!*2`,GT5&oV|wS'I0|VzX[0ըFmGt9!bAQa@8P<4YG~M%ߩe/Vˑ*Ip&Wh??mГ$l3H>0YH.C!:<@՗~CjI:?rm0#"h\x|Ą8,??"rA|dt"tF8f?nC <,AR;V gǧ"ې#)r7/ Te|(\;r'`YRo&Ż4#5 ?nqt7(y<%8$0ݭ\U4qd@} ;I>ZxG&Kfd26q?r#MZ sQC FW|Uѭat7vgtJ=|L.罛zfsU/>g;Y?rs؍,-T<)ZyMڼQ[mtC5/zA"DZ3ݧ19--;@/vTs/ʢ3fJL<t=A Pez)kOv nF}hvwLc2Ӑ$|ݺ?r!O G&S;ɑkKRwA74q8}q1-6BͯIUT#MJ?rZe_9.2ipxse1d)u`Ujh\ncv19{RSMTX$"0z?rEAxv Y\:֥~I?nO:d,LCe5-W]_;b2x!PZ *f:"oG]\)\Tt:,8 FT,i.fYTU-hX;1?0+y\zPi;>YS 0+%tp3xl"# m .aznP|d6~ hN\f(17 '`)_{o+ԣ"QR>}ßH0uzvҠ^_(Q#᝺;͹Ȑa[d^&K;&架a ʤBNW+XCqY9v.39Ν;nJ2տe$$I7j$M+ nįZVXӎ7߲ߛ9)Gpt@N3f G?n6t>;Hq@sQmb>=Ne'@~4t^D*kS߆%nTb,1;O,3`ȪIC1m'X.1GxXZ:(9!mhpơлҲ\}*r@q9>t/JڧVفV">/?nF P]vgFm51PX!h*2,c9ryer Eu9"M%xd?r3-3@OJ_'8alcqds(HSe!y^Rc1bfM{WKX^,Z 8rK=<^]6Iޑ-;a<ƙsgSzV?n7-C7G¯ OӋY6Xy(]L\&#Z"?rl -*h 4:k1Ңn_Ŗ-^ v{ Z"&V\!?r`ՠC4?0LK?030$j|ČkW حriOAfEG6Ht "Y7uq<`PV??@gU݃E'*:|c -'xAyqq-|_wH[Fwe=b|6Sw)8$3"vm??P>+T|F\K=CW0.{!UZ˔!K^|}&}Iw"nњvW=N/W;n2pS)HcvJx\S)ӷ ez))q(=^HlNj4;PP>M?0M[pj_wHǖn~4.X c䃥4wD?rFoJ8pT=I-?rTX']TM9]S[Dj$'jN@}lbŘNV,8EE%Qx6$Xe!ROQEqbyokX/Gck*ma[6f@ ꞙ=m:2Z8 *^1蓞Lސ_e!gQ sLNᕓ{ЈHZӂprs&#}aY݃ҷw\hkip?rxL*(6bb+BJ6o"?0}-YhF@[g?n$a뭩jyK5JM5|b1 KZԝ z2Y?rqCة)O/T]#!t.҄@1J喐)2:`":9Ky0Q<¼N&5!ßk/+x$wnL<3:0ѴDD,.Pe Q2)-KҌ}P<ћYgFz-p%TZ00BBpAs;^=P[IUƺ1h?rAz-8?r;<-Z|'KXʩάWVͨ]E{ijǍvҹ< 7]>O/uX٣ݜ6z9%>W\(+tS*9j~ut(%b+Qq6zU*ID|7[K6JK >)}Is}WAs`Q reKE]\Tͻ8Vu:z~2ԑxf4fp~ػ簟-}ܰ: PE<zss\+QND)fUC1n:fRJ(g͑2L$>u&%{F/u S>|17t)12i3ߕ&Ckp UnL&^(?npѓYG5e:}ÃYRmM V_辮2Llơz,?rX?rS> -xRwNk7+hWwUQ`l-]Mq182m|\#XLO&u|KGyIUAV֞xhS-t?n=;BxA5]=2Y& uaIu&# Է?r׈=Y6}c*!s^ܰAv;Z4{σ[mJqI??[K wTZ!>Lsˇ-zKϡ95BKOW d|(?r7*;SɯcUX1;&oAUh<<;/">j@:Fc.yjڔ\;\_𓜩dy*$KCpBKV7sPԠBHt],ol矞[8sa/?r̷h$g犔\(rJy$l2Q|ȵ*Q$̳5Dh:s5e$f-Zb.xZ.y,(5O!6\&y{??g[>K8tYFe@\ܪ$1o`7jb{&av_|GX' BԌ2Ju}1P{TZ}\?rۂm}??1v6:]3<#06Q(d??ẕ7׽[ q^ rT!dX$.ԴjQ.ȉ5@Z[@BWyky-Yhea]P"e"-[-}|1??3sXwbykʊqónK6kSL>FAchztƔhgۍ37h6]F5W+]<`J*ɊAI uh|0lH*W̑L+-)~њyӹLq?0ً?n АY4at5d8O}Fz\Mv/wmdz3ލq#q!Kb d p+6RM7˗j}Nޕܥ'Dق_kZJX 1h]?0ؿT'S"3IO)O/U(.)IV??>3sp%Lu=Q!&T² r[7A?05 (%ܻB*xkKT?0] {o4E]J88*jѕ>}|vllĨXW`[$_۝¢{bUr??}͢=v[ݮҊޫ T??!2$7E|>ty|ړ2[>`R8mD0tDv96P ?reP|sĈ'ϺCvt#v{ü~<(?rIx-b&?rȿg h[EbӝmTEױAIGM)"KR%C FM!p&"@[Sj05Ѩf vac5J$qHP&#`L7bf9;y1J/Ժ[*zW^c@?nWL#l?nWB}ΝM !,rV 6w}>S'K\~[bioQvIKYVQ;Zq-+rb?n͡ڗy[E pR D$%??kэP׻ks#m\lqPN1꼗$u$IΙGV£YZB=,O?nȆmv/<۹sld..lӞ\x9wUaYUBLrըzt?r蛜Q3ƞ9Y9{vA܃eӃ%4c|YGν0fа"_j.>fTU{^+`ڰ?rڳy|I]FMU¶ScIٟ4n,^py-TfDŝU?rn}'Nr\v;&iy,];V5b7Z+VH*27FbW{޳bbIeRY4i Nk0C !&. +(O.u.\FnA=tM),`i_NBaHRpز]}+uԙ2Jon G?rOzn,tHoQxzҢ+]m(䭓+%ΰ(?r5feMz׼5;BE8H&=ӭU&T{?rӄ*#:bIUAi9h)/5JU ?0CZ3㓝J)zOyTXʅ*lUUMwʦg]ec]{bu)i,?rsEPlÎĘ^XZZ6؍$/_0eĠ«hkRFͅU?r񸚳uw;_NnY?nHR +#D :J$4`~jᓄC%;I=*?rkfB bl"n2G+?? +dqx:D7-\59n9"CmۄGMq4s0o|7~H,ƣ%6lYl6@tҸ(Qor#gzKp8=a594,qޖ,fApɱQq`ۺoЧ XY\&>L^`cCx_*؛Ʀ`ԟ +#V cw9Έȃqd>1}jzcփ3nu᧐T! ?rzxҗX`$-?r\ܫ&B~ɺ\I3%0AkH[Kʄ86]5=dF??t$R`d?rpNv`@>A@EG㦳jr/d7?0i<) U**h(ʦֶ݁5?n>{<7}rb]cڞ,%CHY?n7KEx(MvަkrtL$/AB쌘O\n}Y!!CkYpJŬlWby~逈zN*FxH#,zRzMy8a՘0aOu5Jj6RRn~fcc ew'Q3\oh\Ez]һ~m-SK fw@dl7oJ< BϢ"{Xlyapg *Ѵ'y=W܇MyHinB6Ŀ|pC6Fd ;|;G>T sjBHytK)kPcA km@!|sG;, xKץN$g2uF9%?0L)}ܙ?r To?07aCTn,vgdIԈ81LJ0vd\xs/}-;z{}@T[_Z^x(:j&/ٿK.PJ-$Qܡ77 ~$//VwvKtҦv"o |p}I?r\Qӟ!OIG߼m$1,M ?r6]"${X̱j9đ!-ys9so2}_URt<^@?n]7i6f|CF఩hD£AH{< @۱toQ-)|Ogȱ6keP6`ِBH߅Fr0ic&:IJ{bI:RtзlnPR: ':\)΋&i홢ڀUhV25??2dtvcal-B`:9{lu@{]2?r6 ݊5irD=[MK],ꘐ;@ v޳BO;eG雈7_ӛ'ӗZ뢗=F4%3<-ɱj6'_B<@aF041bAr|-2 ò]нsA/smh]xު7"%H?rKWUDZp7MTxNDԋsz?n?nDڣ]gOؙ;~lm_Pk]t~W[ӣG5=yT.^Te,/Bt8Tg\?nI""ȹ)LG>jxԂFoE{W_AxLj=eQ9ԮbS[Ǵft<={M2- j{CuԒ10bv?n*L>vy/z*h!-ܽ,**U]Ķ i-0B5$jCuNhC)K4;(GF#nH'g `sV: 4pT+ML]Y,?r~/g_W$Ƶ!Tx??>hL,$Q58= y`ӣt`=l͸u0W<2u*_R[9:WcT2 7N_k$:CQIK);BGm={6"nƭ9+P?rtH^ŰkOrt)?rêپ ?rDVbV+f!)Dc*۩Nݠwm.}z[(`Py\YPvB\ʧpt֝wwVSJbI(BLؖ|Ȫԯ@2Y?n s֮*-Bf(1<"?r킾cWtB'~.ԀuTc(L Ł`nS8Ɣ:硬!?n)i(g'j!&+U?0;}g1稠?r@ !X>0 ؤU8EW뢉L8Z4=G6^UgqVܸW?rĘ?rv?0ӰitS@a& _1@cUxmY`XFV}O[x82}ϘZ`rC|3uR+^f?n@11L72\lTl[pa`sWSx ׊UtOH^l`ZIhp-4[E-!EQ?0$˹A` D`B"ѡ,fO͞;`r`[7e.vY~}j+trAΥk֭YuPCbK[?rBo+]tĄO0#9#"ywwؑ9H9վzS&}-ᄠ&?nIqG7.{!^*cU>?r*4V.-IvǼ_?r8,c oy U|%nE!7BvN?ntcʼnU۬.Ek3 ^{:6Qr [?r1?0y($(;۱+]uҝEzeG{??5rRqRccUԨe\ūXo,VWJͥ<м`VZ] .LiɇTe}U5Vn8f]IKd%Xӑ\?rM`/ +#uҾvժī j(K9; 2'(~zlB7>;ŅԨVwVl/2P_s#gK-51)5J?0 F1&,^m^%]s,LI??]KDŽe*T6 Q!ˆmVj76:7Z0C&/9Ρ|Jz|NBFYUUb@U$eɺAX$l3rA;?rcuZZ.a%i\`7Esmel)^Sѣ &́N\/O M+\#r?09/3/* hcx<2tg͑.!K'97Ll(Cd?0 &̴@3L?r -aD`(e?nXرcj]X coѥ>Z|B:s.JM[`ZomIǎ>6ax dS"Bאh RC],0/gA7w#U9IqQNƵ:,ݱhLB4iʋH9*| +>+hn|2˦$kmVrl@{?n* S^[$֤*҂9E(d`ԏ%>ӧ,@[v^YhO- ܘ̌}[x-/ H?0Qa-3'+lc@U}%u܃pSRWОA0qTs4V_-sJКm%!RP)n*$ (웺jmX(pe?r7Cl(ƒ"hv~/'Z?nR~CJ<"sѺmٮ" ?r0 2/͋LG2iQ:fL$Lg55j(?00 iyXO]:J\vUE2 5Xp>R}gzwYM=O@`x>1PD>Wשd[FP?nSU?rŎXZ)b*%g_X[33%r `ٿib3"5;mՓi3X@[nesܡ^1 PAF|l0łg;yD8,;,f37(t|mj>꤃o#æ U^T΢?n5?0Q30_8lJ|"3}" ",??hKJ "̦xzb9')DkZi+C|E$KZ2?n ldtIAD^9C`f?n}h3\x^)??_x35/+kP-Whh=wX}⬃Kۇ,mF:d-O] `կ `ހN.y p nmD쳝EFm[ZOv&I*3}L`ThZ(2{Ȅ/KBFDFqqHv 8j͉Q(0n ^s7DGV,f?n[qhˢl$j_cjy bALhْbsI@[aZf 6n]5=$;Xq\F9QVCaDMSbSWCtK͋'"n3y0t?nƇ;ק&{ZvРuxtl_|"Kz,^e??dh*d?0EjKoWu.O]~*49r|a%Őq^>oK5wb{KjFHd|uh pX+eڝv-]>/'9O@?nqu;KB|h",oxOyttۯ@_yGH_!i7 '3?0BݎTe0Zdos-݋-7l"̐-R|1F/ fH|΄tiY??X*,iUCiܻy{l"e\C<^`PV Nf.^Wܿ1cf3ܴ~$֣˺˒h]MX5g<\m2Mz "Z9Ҩڮ0 * 5>]I"Zfi'$z?nc:,DVP=Obfclb3t?r6tϢIn޲t`]xCU_DIƘQqbj,1wx7oj3v\/EH 85Lyzz??dh)_\Keh[m$Rl}+Հ ;y\OMܑ m5s`ǚB:|~rp|r?n|־p"'\FM3HZ `;[m ?0GMEIq?nb(_p2-]lܰ r=/ݪg;:FK|]@kZ5}ӢKm"r`D4$nB=妓wȜڌL}X^"(k-Y6YQE 8:zj">?r5<8lJKWqeפЃQWDEMTH'$C!iDHz񭣧nɒR?n"ݾݕ"*J[9ˑxO-󟙚+0A^ᛓjWc Z"r],{P疣a7x׆hL?n5M_6vl;!U2 tJ|`,M,[r1Y&o`r`1oIdzm&2Wᨫmmm.??MMׇuy=4G̻fl9WMOIpS>k]zZ>n?06C}Wi?nDž3uR??F8-I:j?0;)#3wVm":.ʓ8rS??`~lHeW8^D1i9{~ġ=-EJ*[r~a!3{?rp ?ra (\C\1}xև۶'=-Rޮ4M{Zp$pYcijLM)??#_xA`jtqf`p#P??[|%ÄIZ;?0m?0}—q{$>уnydNnP!*>[RJJՌ{j ?n͟0Eś! /͌lzWм ?r[M<{;n]z>sꝷ?0sk Kf`!C#&h)/kh$8D]/V!gL#֕olS`d5FUӖz*ل6X$&elۚF;^>^˯u\ 0ǃTW*Z@e[ *?0y" +#l;11V}; : phi7X"A`L7n:+67zk:U%.ʬC.۷>*/R,$K;(?r"ktR65eݙXNǫPvUlhk^N2-BG0ywz Moh8X]ླJ /]Y?rXWvkFϲi*jb tBe1=U-[x>f'2)bx7У5uϱThSeԡ&k ]ְMJv|Gb?r fsW:҉9/O&}jmq0\o :/6Ukj}s3|mj8BT&4.8!]o_9-C]^FlF^A领vST؛x ɂ3儲l =e-*qh^k39H,{4}O\ϹS(ҵ,90TTvo6%ߡh?r_[s` m>/ߤZXi4??)C&:x7N.0cR~[3S-zt ܴl>"+v?n^+PK/^XtjA2?0g?0m?0\gvjQa; ۲@kTRS6/Bs9c.Pu$VUK-*uC{ͳ3n꘱.iu{*񸖊=S&$LQչdSTo?0a w8YJ!nEycpŮZfC!?nm>vL!e?nYF;LzYUBIk%֖L!]kOֺ9+~kP$K"QVtrž?r,@܆pAK4sW2(h3e??1vyZ^#q#*ؤqgӁ/wZ`Z3f6ֵ???n _v{e+dHzL#l,(?0?rrf??rr($ӷJO_$|2;,}ƫM{ZIIq~h\?0(rOIk9N>@xk\.(#p}}kM?0$0uZѝ;KwF?0\Zdp7O;ZB8l$OJ@?05Rl"?0UM AǴ,e5JԘ춴1M]#K.bNKk{tfQ_PcZ_X\3h/^FA9Ԭ[96;m)__۵$䟨ʺ;aixt];9LѴfiQ{R%`kV*vtγ:+|~p2z_;'.u>OtCuge&/??_r 3嚃ò%OJS9 sw&OZ!|N05;??uqb'f_\#|VP_!4-Aa&?n!ӊDzť:0{g!RM.ۚ_w2V7+|Y郐i%ٱ&Xс(Ve1Wsaxv5`VJyDFr0kYCnL$7"eiM>C¬3p1?rAQRP–VO3dЩoԡ{L(rLp34V?rYBI6T]\˸TV>9lh zR+j] l遾'ICǘ!N% WJV[4RK~4m\?n6I3]}SU-PKZZ$<O8H[}eMҚZ,.6 3}:_7f]cś$vΞOjl~\Քc0ҦfK+`.@KJjYa?0Wåx6nߣ(.[M]6MdABlԄ`ڎa+KxN6l^9W`$e=Bu@7^^F4o6PowzygDcnN;T`??mQ6s|>wHQ֗NgAהF3=zhFNFX!UR[ܪVԹxn=-} ŷ_e7}OA)xs]&`ǔ,mu'u[ `ݔb/>QٔrrF/EԹۺ֡TC~ST?0 gBP|6q8I{$ ) 37 ݉~?0V4kc&/Ts=\PC(ҮxHf?n>*\+z71??ẖݰ"08ɔ4Dу{OME,)<'[Zl@ /p̏~M } Ct%#d*I@phpKkèŀ9W{ݒW p,#sw^ ŧݓL5UvVA?r.f'??-o싫O<R! b$cCH,0??F [I}diD0vt\Kdd6+n,1jiv#*.e Z̥! >e}5SS?r+q3ԥ1D*pR3}6~]qF@r?rJ n&ٰ޺H*1<;Xܡ;ZW LMTB0<Ƴxk*ZKM]oF8OCRR2OoZ_]̧tHz> ?r۲჉C|kݭ@]qfȌlD)}OFy˦I# sąOPO]%|`A1 t"KZ4q/ƗUy#T3T܏Ͳ@pKj<;$FM$vcmo6-eڥ/<("rz)T'?r;'N5/K9?rS/~6/nRv+G׻eEk??|YєH& :B@R{eyY;ÿMUе6¯wRNAqy~6G-y6rj?nh994gW;ƥ 3᭫S!YmZ}ŊkBaO+M?r\|7s'kĤ0QڎK3_mpٍ[??vɣYMH˸v*<1a bSpYEY1(GQJd"??uf'V>7~~̜W0X,??N_I\ftY(+Zj޾ͤڎIZTbejkc+s5~.))j|gN27?r-=)V!U#NZrhTc"6G_t֊܆LvVe?n&w#DMUfTe 5e#rZFĿ/ӄEHЏ??\#lY~i?rj1ɉ]Ȑ=~{˚kwJ{HW}3H|{G>!)ݍKt/zlONR/??*`~ 07d W\JUr ?0$h$7$@-\w#.Yt2?0)V9l-tsh`@ܧ\ҩdAlN&Nn E ~5p&SWLҀ*<2)?n'3(0z?nZ|OLLRS?? '$[%}Xҗ'@h?r5)8Pv`)D'FA?no|^9>T}C{%?n&s?nӡnN)g>ŐFFk ~$[*id(λpd/#IӛpGA+[#jغìB넖S\}jU+Ps|&W,,o;??P?r {r ?nis7Yf?0,#_eVq,JìE +Hjg´b%{vN X犔Xb)'ȄcTB#0iX&)ԙECZ$K?rfQ[ՌLw:_͗svϹ84^T't??l3*&UEѥ!JQMڙ??؃#"J%9kerh]?nK5K5i-Nś@S>ẙAm2h!zqE=uJQůRY7<6FO{\(^-+(q9?nbT\*~[o?0^xL7vt6g (# 1=G_qAj0xBz`{Bp-Ѫq޿CNE7rHɻ6o &ˬ^QDLpbA?rP\"tϢB: LSjP \$2esFp]\O?rOFDp`* 8C]ԤEw23Fb7rfȱr^ihsl$mzE/fN59owo-f8Owq$iJ c?0cdЀ';FޜFw#L/tVʬP^\o΂4"<;pWQQU?rps(+MdfddkA>yX62˵^AөPgEM?r4;ec|)4ƙ##7A›x} oY!t\;4r8T2Ia=ܙ,+''?0S.OG׹"S'5zm6d7@dZɎ'@ 3HJP^??+piF?0tmچK?rp*0d&RMtvIy~<27?n] S7Κ\H(?n581hE',9Cz7T)J{A"xFIGx!}HSV+S&UT%)Nr:eZ3LAA&C jwq۫Ɵ"yZ )Д2Δ-R?0d}8?n=Ű;"J_VC"GרKNètyQ>3@#^??l?rW 8#"9tl JQD??:Džo}z[}>??oijsD6aV }ʋ6ǟ_SHNkkcHmT!Bq%#6kxpj74p7#J wWŨқ7& r7廦 /%Qz0tuO('̷7gxAW)n~e0tDH!l{vI_?rG;zuVgi&?rOLN=sv?0^,`FTU5|"=?n&Cxv5$VUhķdv?0ĚAt;!k XС )Jg&?rT"a(+?0Ⱦ@7|md2lۨ C-_B0x?0űMd鶵ͷ?rѡٙ\ -9-t$L͸vpp;YMtt5l~$bيTq` _3AGpPN!59??Ǯ$`͚GhtwwPUuVp[??B~&` ;eq^ۣz Bգ!?n#~ůb׀ldP '=<%#Fz~k@SPtϜ7#_"M!zOSשVs2;Apsp/%y`/?0< `z澤PĻw&O_5k4۶IE&PDDb?0.d?n/>0OQ]u?rz@UEeLH2o`Nt?ra06/ksw{ &&g$n1rv#<#so&-0ټp48vE;緓J N.@I0@|H\//9'Sfj ) 0oScsR۵)Ha*";؅q+ĂkO/w{+є->\Ɓ$D=LX8C-D`77J^ѤA(-L))eHPH.M0SjcaRX\́ݍNP^OGqRP?0~ kWTypBXT:ewt¢%LD4OG;Xi)傹^Q$u 6 xb$|Ң#fWf|~kpiJjbrcSvMi?r%3Be?0b0@vLud%U$'6 \W8uTqqD4PcN9?0 E`?n@gtb/Ifc}iK/hcd!5>޵_U;|(pa8#Fhh' T?0>!JOl+>%x&,3sCJp~:C|cCQ T:Ѫ{$~$8rx2ЊԴD/5{gGI֦(??P敵< M<1'?r?n9oo|&ov6((2 hҼ?nY ɗVcP;Y}y??ZYM޶ce?0 a$_J%Fc?r #?r! PSf٘KHeD,}s@Gт^zw))ބ|,t^4|q |L¦TKR5Uɇ>cѬ*nG2w#R"%`\}uk%a+2(tQ:9?r/^) ;Ŏd$w mɳf52H6BP֟GyP祧)rg+h>YgϦH6^zJC7TNX.?npoȊj=7=&\Dg(fTb?0mqҩ@2JqjzfQ]ՅHK;zUGh88SM~2i֭}jq sۜΜZ㛕ڞsE,"-8JAO3c??ϔ@#Ab ;p301YCq$]"ssfl?nG+0}/Tjk* y?n:p)=T#[J|wÙQ4V Ӝhq/Ngg?0p&DZ6 ]I }:|dw_yYzNK=LDe"a&E=&Lul?0 lq(@0yvxT>q??Q)r MCGAֈl2ҢXS?? ?rGGW+iJ<͓Ǟv [~ݹHUX+`.`4SY0tj\ں[L7[`SÒ95[f8mFTX(K?roş#u'DbyM8׌*v8ȹz-,x&n6)W8#zCQ2??*3}fxQ,S'0n*hgGG6ҿz7bI2h3m)eTr8(n҃0 >ƘG]Et6Y&t4bϩ +#ՋfI6kʹ= d{j⭲rMϤMvfӦL֍uV>D\BMM`/ &0RWǢ4Q(H?rF=,>y@9.Ɠ" - 8?0tofp?0gS55ѱ5TnWħӶNH3 eBP/gfUzX\ztQ;adg0$̑QL22C X h B :8hVi1n7@63oNny4 b#.GI*i >J؍wQo_q_̨f0kwI,%H Lrme۹|ҌPjG,2U@ s41|/i?rf n UyˢuJ+S@Uez.jEd8?? ܚٯŮ(+pϺ$*ՓLܥiLN.5nnł??InralwcUwxZB^T$mT:L27Y]:ƹ.+N2OjkԠEڳhXB?rh)k-oN2R c.84f:)QF"^Ѽd+4QOCK|?nfMharZ\)U?0)M~G1ES0<x*e^@LPsp{2tV˯?0@snp"TDt-w{>܏zaհRZ䩼εQ2"??!ARCTf|ВxXe!K{m1o; ڞq(S6}4xrB4ӵO8wn??C>ǟ t e)|W.e$ddB?repɩn! 2/Tqϳ؊ḁ4X6SX)l7Or0j=@TX1?r{0w<2U@CHwd] wLݷ=šse2 &ܨ45#\uG(9f=1c!*Y5z?n^`SFʄH5T*f?0\K0 4yA8rZ|)kA_-H-qp?rJ?0LSr6CK>p-?rKHMɏߌ)[:e FA"c2?0gPy)Y0tRMjEXX0lUV0k&n3Qz#?nMgg2rv{fԧxcTDL*}1ZܑXnjAu4bH{O[_j fŵ\Jd?r"= tUb!|=rP68CHf|T<chs4\ͺwmijwFS=tlc*1SJ K`M%}. ow݃0_5׭L~  VŁa}R%_Ő+0cċ8ƹ[iyT4;*aQ 3r] j nq>mוZ*0Wơ>6*_ʹ?0՜v:RE}u-[|ߨ#_%S#+4|}0S ?ng-3a00uu OE~4F[,9(`rAիZ?n`Ȣ_ǼԆr#OsK$6>Au4%иs||O\5,:U0;WScz+\>.ޖcީ,6b38=u}׬/Za;ȹ,3/wĆRxAb4p'Nf)DOGSЗ lBrxJLtWx WHbM驎?ni55;Vgjj7=4AF2z JaD%Ͷi_SHU^V5pP[+chH.i2.&SիfNqeސNhsh7Q]ؚ28i`ڲlW,%_qˎ];g LQn!pJac4[DI4%ypQS(51`0֠J3ɏ{RsAtd5 ~>៧(hn9F?r>{,z5479ou)-NT *W?rS7:ӧ('?? #s{%BwD ?n@$v[%tF<3o'.A£GBE6Ghz^]qfAU:f??lz/+kqi".sAHuz] PkybzR7Fw&`ڰΔٖ}7hn΢odgB5?rMjZE:16mGΗf?0{4[m1:M&e'jp)5hW+vB`*7z붻˜yP)3I LLS¯,/6׈/>`0-pYQG N(v$UcN?r#=k;x·́ O6L_Fm8xƊ;n4z-pᆋV[SeJU,n;nkҀZ6?rk @?nkb'Vjm2NR'?nJ"*%txMP{_{a\2Vj.i};k??3@v{sgܼ??Y)0C>')sb0h:;?r9*.cgϱ떟Gw~KcĚ"8hi˧}#dLifvC"3TC6{E(|P6.sdv;R@6??|y`Z??lקoKj(?nkJ p?0}Eyҳ%Oo>n9?00;IfnҺtmQMQ=L%JE!E?0Tko˺!\u'fV4X&|ȃem;eSUmex)SD49QNoF^JD'SI''no(Mj"bQ n.D{ LʾzkH2n!-bs|F5ƟxʼnnIN p0CGrJls7ĉv m?0|Ι: yWVQ!\J X).uΗ}-١a;aZsWr>%s#x?rX??ov[1x1(_Xh,Ch/MAk?r,z\A]6Q}+!kT9Zz1rJ'vDieD9J+y1ߔyN7 #dz.I@>&.Qd,+1N4v0vGi7DblZ軻:H%n3,ԡzСH-M܋ˆ(u%Hu6Ewr@D}Z[tJ#e},,{yľI:4G30g=L7-eJTbs"㋥+ܞabhmy_~3.zõU'R#ƫ1Pb諠L?0W上춿NuKQK?np/$0?rˏ?? F zU*Uti^K LӤDd=A?r~Hnx2бnj&T z".~%q^nJ-'"lׄ.e˘ !/YN›=D=ILa9Pp4?05= W$h6>͈ 2 hlTg*h.r=A$~USAtAmf^WjД4e7HWfVCC{l 4@ Pn@CxhލˀP;{ MPҿOW lJ46ǝxtÈ6,`dMq*輴>$sEU#F„) GJVĊ[hWY-h\6a9#Oh3Xl^??fˮ0dyx`[M.bf1ٜ#:Ek${ *_hy_߯_t?nL^3e :% >CQ`&4mm,)~)_r[EUJ,s1VZ¸_]VtKD4rj!|a9&HI+_o@7"*Zz. qdT!uD6M$*֢ߏ9|͝ᘨ-_+>IO~A*6d/qOiGk x)zb b63WO1C^2CK݀͘{?rRˬbt|xKnPS>mmi)Vո1jaC8P`Gq1߆Ф!ȉ5?0:Q~ynjDOpit uԋ6Ym]ZXK5{ƩC[n=~!M3@ gk5Wۓ5G 2ؓ8Ư%ߓ}mpƨzrzbm??4L̏m$Ɯw/Ws}X䌅} mM-.%]JpR!Zv]ѕA3\۝!Ϻ Nn;q#:K,u[hގ +xZ|:+?n|h?ru^VsOp@EN64b{C'Ԥ<6U.Wl0)i&EY`^ux"$FlY}K,}5Kc$ #f7gʭ v[8QX.Z* őŨe%ȷq:*XF3Yȉwi17ʼu;[]ɘKVAku[T(3@@Y-mMFUА!D{IJDe[@3h+[=w2j]2/K0zl c6y¡}\?n0N(LmsoharY<"6# S͘U f??ěJ9ŴŁrh]dum6-4M*;8ucQZة2qe9X@2:c.DyKsAڱDe 4LFmbHGS0ȟ #s-o&?r[(|ф)_v5]`*1ӕܛEhH?0Ǹxn/RO\g?0 ]\W{n(>= R+#1.vyxo˗V{ OOd0ZuҟB<ৡ?r<5?0*??.fK@G@{2)e,Ӭ7cwz]@hRTv2ʩ1󐘒͞/586k#4R P()rxrô#Nsc tʆ?r,yO+rE+Fo+" Iw9H7u(B5M#'S D}⒪D|9d Z'-;Ǣl88V3Mt<nzoxOĵIkfW#)z[dnj|)u[<])t*\&ho`PGm!+nL|bO MYv [~Ҫ,*$k;bnRR KX6`^ligZ"pY\\=*jV!!Zqk }awcwQ,خ#4>x'HAV*;*\ 2!i9M!&H*ǝ}1?0qqӫ΅Pਘ"Ҿ6ۃ.s!nKKn/9WB#/jH"Rq>n^oxIB?nΪQ4j{@iPtd??~ޔZt=9@4jE/ @\X7T,']?rOJǎx)/n)MMi&\Q\*,sRR]މT`삋L1,,ō]?r{KübHr-ؕ3':f&0sGYg=Og<``h]X^M+DOKhZqSLTTv.tʤrSVlǔ????YV7ѻ}`^o|Qၢ)O6g;~>m??Y(WV@GW3/[Uժr'ҹe]Abv +#.u}5]\p$n.`wB߿o "u._єXbV?nnn_?rH_)AD%ƐxVemhbCXBԶ~S ’WL9^g+}DJ%BiMaS{(CN+_PoV@v*m?0N-ՇbFtΚT `3;I!Y[rK~81D>C d~B?????n2?n ??y1bL쩚aˀY9) ?nMba4K8}F4i&ܸ[׏A%,S(9)>?0(44^s6e,Nef{lqz{{bm;8q 1T[m7\gMLtKKw@uĴ09Bg.,F&fgg 4p1 pJYL,W aWe]3,S۲^&Dc+"SVl|j;a͈몼bJ6:acwOr҇~D??CjG["U8FjRܬe4y[&"(Ôo G&\ gbXiF/f æ?r;Aigs̥6R%I)J6VԪ8C_8FCjOIu7@c3x{/?nLob8oԸs8ڧ'#!=%nX{S3Eqz͹N3ޤiM&l9sku5?nEs$Uuuuuh(WI{*??F xz?r_ѓnZFT=.xжd<y?0*a uT; ^STÍb.6'zMye3hDeg(gU}˟zMC7H_2WWG)U2WZ] Ϗ8NyǏܕ??"yVx>)dNbfgD;:#~yynQ1wQ}5GE%h it0Ȼ@ǏDP銊讨]OjG9#Ne{3:&֯fL.җB.Ya+ˬz1(I JQ ${t|q.Υ9Ź&ѣ K,֓8[^eD|9[%?rm^gے>),U\c[C#b]F*|$z!W5xehlSŢ֚Z[3K]SV\Ǿŕ?n3ӡIeL,Y^G(Mup2񣍷f^Pu꒦<&{3(T+/҃*-}:G 6͂-?nY1R5V!7&I-x:t01l"Q*?r\wGTtXZ%;DmI8;rBR.ZpocEn0?nZ`y?r6y[7ܷ`AP{g/<"=Xy6v0Z;?rHj0~qFqc…6[ڻ`]lYEy ߒPbr:r2땙E_XTu'ՇU贃Egֿ*UHm$8??( 2Q?0t՟a <~7R^vU"1#9Bjor֑,h SX?nc"ˢ !D麃.&;nwI;x'Nƻ.ͣ"ZZ!ZfM .ɪOmJRA rTV?0 գ?0??j_Eh QQ;!w;sȎHİ^]!n3S$oϭR U$EJתd=,N>h-uXؓ0붜3UYy'l5t^7JBO??q\JZtjNɢ*aҝÚ"spUt; ~kUӉ)?r1Yp9m[ykjD9lVr5K"ѥY7WqϠhAQX;94?r݅U?r6#s??L؋QdW9tC(1V.Xr]U^ϚYb5ӱGEkԍmEAL_~<24 ~3L֫0U$"?n:N:?nC[Gd_ ̮v䎂 uOvϮ'ܽ GG3Kü[:L:?r;x}1cY{JQl@*gOxgdxkf:&vipH}tUTHʿ %8)`QKojfjv9LpGQ맃KOzNb Mm*3ؚ#8sfTuSl_X8BqK[W{ዽ.^-8᜿ܥCzvT*²vV+S]c@17Zj㤋.N`EpuMkv$i?n '}{ZRQSE-Ge-jU)U_&حߝī&Wt+/&W/:l0fY1Q [W8Z,x2uEDǁ*yr$/??*3RM'iCban tey4U_SfՐד\:5_fx v (aa?r(SܱYx em *'+=n#A錔nh;1jXZ$u^&n1xNАPji(5Q4h,r!O>ŶS 堅e(zrl'o[RK[FxmT1!)u ˞%]j3f*8LZnm>Rfo胱_fju `Ѣ?r\vw:*)\tDٗOѸ- "&[7M{A@v 1|nTTB8~^l-K aUǞYŭ4' 7&l=hAGM\1+6A<7f+53TlI̟f4^d +#;Ll9r>162o,h,d_m^^=ʮnytN<""=0q',Wa 1Ϣeh9G ytzD"ܖ~?n\[Yr8i?0eT!?0 p?n8T7 ɵpvÏf;ȩD"+E7*j$בt]rW9?rG,s;ӗ\L(GዕQ-dNMGljf߱39Wc?n폘"1(;}0%N/iYy}pYT4)2uFALJO>}S<4?rXr~as@5?0T(vɣ5b &*k/dbmn8-c|?rMFm4۝cpZtW osQyI>vOP]5rV(xpRYRן}o?n[/p駯^5Y3jOΏ.\?rK'NO~Ϙ>^yxq; nt:D'AY6_)C9Q~}gKMdYg&Ob=7GÝ1F4*?nI$%@w?0\2lZ4! )rd xp@aM4z/%7K ?r;&* cGU+=C BPkyޡőZJWgZhD#si}T.$'\tA?r[o8Y(b0>U ,2 O#lg3U{eO~7~wϏ/??{O??~O6pթ.MVaՖʙ-_}&5Q?n*Ku+؉1Mv䦻wZfݖU]ʓO(H_ra.pTRgkf7SVi)=(+mCyyw>W_ 灟(k;rdr4VI^HFCcJ"z,^!Mfђȇ~|pu[n* ;?r]/˱b:O]G%68Xub= /TUwxDS.Iad)xo??K. i"AtUS!<|?nUF<# ~lFȇw`8?r{VI~(2PJ hwg*E6o,?0g ]GhFR{efyte}sH]IفuH߅=]Hik9Vll'={fM'V}b[v7/5?r` JnY5L4  J?n1G"I;|7uևct1D$wi]GfP`RHb q&Cx DvVuh?n-JSȒ7t3':cę_` 5VxIhܘm1"Nĺ?nQW?r&ffm M`v!0+B]#^?rth묩5yL'??x2=|h5rԪ??at}񑰦H`-SRy fY?0pPP馓bH2݌C'qh<@O*[^&"se}Ź@8mkḝHD|X,;#Ed.r؍\xZd b0lnhCk,̶Dv:M,{??BIu+Z-2Ϸq+1\PSY9P\e'VrӦҪx%:[QL52GMmv`"CĻP??k}Gu:`U,6ۅD8Z$Ū\N+ rbg](?0?rr?09pNń@nQx0@W>x2|T] .)?n2\s=i|ؐ;ȯX}{ٱ=k$`qT/|P'kʏ)+H)fgz_R>d˳g?0&=gP {³oau" ღ8"|KU7',Y??;S|l,{dDgBb吂`)=Jp??.F4.yy;14>lTANM`k5y/>\W8LSxjʊKerXgT\T "?noF[2؄M~na>N~>cVђ텳 )*oW [,:hCޕ?0ҙ$k\@% ʇcy3NѮ蟸S ddףܽ?r/Uƺni6=sAV{qX;DFİZ?0{*x bNR??89)KޯCޤeTHi`ã@q[1??ETrne-ïݣI<vrD%H,jӳI^[FMz-3{Cvr11s;Cn 7&VؾJ!S&=RlW-Υ؄6D +#C6!MtG{x'n[!Uj]U5w*Zgn+JH+cPC:?n[FDzFu:"Xc[Vm؞P"5WhgjUi>nURܪ\ߪm8ϯД (>qòB$5>??{B!ip-??&+ȐB\.Hn' P7F]nm ^%j}{1^'"U%dkҋ/??= qb??MI_ }2MB"u6DkA̵F`4^+rTi#yrJRw^5Z?rq\09]a­9Dzd#TN%`|{ռ r_'G=v_,~r퀧9)dϣ#<:1ŎMX2O޼V/UglC7{&bYYYe_h<%HFbو̲u;W+<5&?nGn9^J#vK='Kt*7<5^E??ï7W4rR?rA̦ÿᙌ۫:YU2:+m#@?0?n!;ihe C2ܟQ ?nJ?rONwFs6x/kG_pD]῵ORd?0^w,KNUdsj7eu.?0JY*)!D%b7G˶r\awZJY_os-zbgYK>KWɌjF&'Y;mrFݞ\Qzp%u":7vrMqm9[`'HxO(n!MPwWďaTpvc#]"kqL!/UmmK )?r48O_~=SCnW&X,`#|HxpKY4~2YSQ{-#Aa9b=n7Tib0sg; Hl~YS׾WEˏ @tYc3thG0B]gKmjUH tr}1wc`5"v,Zecu8A{Ŗ&ji5`Ȍ0{/ᏧؒK{H.K^{pd&XZt4aFK>FB~|8^(cJȱԩ_ZWg)~E>ۺvͳE/6QBhGI`,rDSy1thă-a9&8^,qr[=ryFRc&ga$i\"0 ]@qQ)6~|R=U\l͡ y]'u٘TYNE@ǾKA;0??FlݞqsM%66Z١P.ErުhFw;gH>ttݟL>26iUif[V6 \N#=+Ht xc9{d`9SAS!DZ?n'A^:/c8~%p~aQ^o)Cp)L͙ɋ,\nQ oF̳8&U*'w5Zu<29 zRI̲󎆉y5΅6\X֗4BD<ՂH5ى-}3 WܜjA?rG]>ϮtB#5vVŞzFD[xSNQV\g;3Pbr,쵪}k?0[v 3&?0_ҿhWݐ-;䐁mha_MK(-ߺN>~IJy?0{?n[bSptd7=*YҊk?rQ(BZET!4a#gNx6Lh7ǒZ9r?nHsw8ΗSr?nLqS{ݥ7jBLU:l~FMJ0Xoafߞc3%oG?0k@&OzAo^A'WMЕXCNkFG[L6(ٕ=ef@pV\xno;H&->&5`Mզ,kzI.̿s!VvH ?0j%%YuRYgX|?0 &D"}lfg.?r%k[,<" H={Vw777ͬP)],#^g:fCHOwvrS9h"NMk5BÔיŻ.uυo"E7uW]BTomuzޜ認v}@%䷨1GZXF̠XVUi`;:N9,z>+|@pPҫ<)?rY7;_(WU!T[&H]]d\0L: ?0W *.!=ܢd#Τ?r1rJDV$*B,>wl??UԷT3z?0UZ?0xPJU"??ʛf|{1jˇ;R|Bp[o0&r;!@mqϚ>K+6A/w*h.U<Սhr?0i/J +Th KJ..`[=,9ޗ̿Guüpt??LFeYG XPd'VA?r` Q ?nĄ&%}D(Znlm## #.?r03k+-J^w>b+`Kd51jژ*SdrC?nwtrbS-EYjI;XNrAO0F+fk\Bb:"Z}.3L@jq,<,D0'C+0à|HpQ'   r?nI?n A:ZO?rCUz?r?nkaud'}Kd(ƾ'bFƌ^$Rܫu1mJT[R%]q9,=7(/DV .7NЀ2J=G,Ne|7$mI+ߨ3EkVRuxT]cӍi2ƣo!`،S5 7#nG] @*XmRK@0-}5j Q?0;hd3/I:ڪWhG&e; ϧTFI.<4hfެVke@Yj`.ֺ@;ѻ_Hz9>ϟ a*??Uǫ|tUM ,Bʓm#KMWqGj??g?r[1J?rf˃&rWVh)<҉^j^ ]Ό|G7ZEZ$??en|gqQ6#2KeţYFXU=Q&_bPsHVYp;idː5)T(2oK+̡ȧwEe g58IJpo??`.'x*`y%5y=Ez>t ^%rP8fڏkp㆝024ӛ( nywP,KϞ)XR3RLmQQ Qy煮J}1|3!4w?n}= "{2>@,~Xv8Gmx,ݺ>6oiE;vq(l-9"2I7c &$AH 5eu?r~|I(SBNQIVg?0 (PfQfn>- 7-"J4%BzghIpbF(eBKJPʜ&˭o@f0e$n$4`u?0ñ^n`?0?0%EyY0]E?n)hrz0feWU HGw0 dv{L2 oTP D;N$65/dҭd!׆-Ǿ. q?0i, xZ+oY%$m,* Ndy9<fS˜mJ7nFir,zѬUg+ 1J19mʓĕ4?0t@"!OIq5$?nA#:TѨ%(ܲGJ4)-wf_hsg-K[۫;JBJ֖_iu3kVqs2 d9Om?r{M!?rFT?07,[LJPPgl:X5ݎs-A2ևTp^<39Ze7S Qi{|A?0?r/^F9Rį*ݞږ¯IBoß??v`1_SfÎ{(Fa% ƒN}70@Ҿ87uZ7R1o9,(QrRL63 g 9@:%[ Hc|jYvX_Fi9pua;Sz! #e^QntVDа@TWW%h7McߞӺ%-k%4M2rHGN`y}p)e[??O5%bb9F%/!g/6#rᆭ !"rm]GnTa3rɦZ=%ZI(E" iX\w7Zk>Ç"cIxcl\`.Fԕ:*ۋrk8*/9A[o9(YZJ;Q^^N#[^*8@(?nO6jM|-KZK([Evd74?nq/Tϸ$=O?n??E-&|Yz]6o?0Kw LRE^WEn.-n]J`/p0ej`ے~=?0<]Djge}lMc߯w<2Pd5 &\=>{xx8ϑoT,J;&=.oОf}Ėځ[G~$ }LԡPDY}kgqy.fHRXPwhVUQh,7YN|zhQm @`pը/oHsP"Pǔ•^TIb<+ظaF3߲Rlðbyh/]uă[Ja&נgΣǑ5KaYEZwR5LZ F3^L"4쁯GZD2}͚YV.])bNz^U'FY5+oME@>)h*hs|@'S45Xªnb,#OOAX@s!r>ӕoҞ&f&_}#5Puǧn @]=XOk>4Gn7we9_FTT|J'?0Ww>??\p[ӣDX!e=!NZM4ގ{3{pW N9;{~k?r|xA>0 ~Ή?nHq1yNĭ@V?0 W?0 h"@ Aw׋H~;G??fx6,u3LpR/fIG pPg}pʁ>4C"hŬM_ﱥ,G8Pf;`/ hJ")f$ʄ[(C(SW1rܻ"BҌt!_`!Vr0 M8b\Y\btȮ./1  (2,I25U5=Y~XDR]~';}^cx5}5@XVPWfӶxz;%X{:d|3=Hiɰe8JWXFMҴr k@b%S{VA:Or/O -9F*x8Q 7|W7֑%iW`LDʙ5yOx?nK= +UKuF@=??ϟ(tdd5/rE71XQMrXa-Ҳv*"{c:HEK;OH_aqX~xh?0>Q@WYde5 WEVF狇dm5[f<^b,ٕ@ kKb&G +# CQ0ϐy]F@v(ɇhHBiٰZovF8|k6jzyfmtDF)o]ͬ]6TTiU,c/t ,6<]Pdꧪ)I^#(qM@^?nxǎI|leW=([K!* ^){?0"oJ0vX]Xz0~XܵABqnw搘'#kmY@X2ΚyՏ3N*?roۉ;pݱᒽ;`a?0lNApeKMzHN~8n8k[е\F+4:DG @-}/U2)e} V;1Xܒ $H҇{L3r1%^]`t%G pP$[xR,>"laY)_\-er.tAjh.hК'@+gDI9 RWuRlGEA~ +@07;Tc1?nf?nZ]@7þWŮעK$)_ٺbo}=c=Q%?0gW݊mm9oS,7GO1Oi2?01Tba)&Epm_^ C;ĐQV4"HRczMQNjDI帰t`H^M?n(┞x|Zm){5F|ʤFw?0ff8AJBs|z < jK9be7F y)ŜQ??((BkE!v@$ <ߞHÚ}fmxMxZKƯo} H5sFY[uGnie .ro辜8!-%صgKV: gIYA _@2Nؘz]}lsnݷooe+F41{dce!gV'9p\w4[?0WZl|ף{cƑP?n-9ӾI=NR?nIx5kUYX??)9m7,ℜ/2Q8}oҪxh/ͩHxqcvu$[wG<9s3uRFgq"9&`:L'wZN`[!E %ba"ے$j?nmou;??0>W9AP?n |S 2 rWPߗ z NhP"C ?r)R;zRY=ϗ{x" MǵN҂{IT'1?rG7Eى181|/{2VC¡>yvy ^OkNƉ=\o'UBq06?nJ &rv?nЋW?0n.?0?0ѓhG30+./nqjяFFMo?rl_1:@Tg@WB! s/' ~UݏPDDRa;??IMD?rV.ܙ+JUO*λTD”7??"ˀv=٬%C-:pfzbVYY|B,Ҍpd~Iy1燦\p >Hށn5QnJχ#6 QD8l^p(??5<+={_/6ʜXY(T"dK9w??5%?0ŏvZ+Ӊ.b,,_<}5kb'Wp)_=O518?rgق;MP6OS:)K'|r5@]tUū=U:l"f@oMJ't>kPfm9/tfٵCPh }fTP1*w0Pl5Kdn:\5S72>>d ,£}kʲVmΈ%4Xy+F+7{=%eTQ"ǣoY#P/YNY=ϑ.67_Y?0{z8񪧀-:8Oޕ|/g#vwObʄQ~e^ٜ"(e!063Sк?04V`Xhlx.ctD&%V:=9 M:-k+m{A!;>(lA HoԫۼZA}':,=m9u'|Vjsf3^F!7'+g(Tr>L5Isfؗ"\:x4Pak>I#6U&+"`_םv@?r <,85=1=iY0~f)E;)'|nۉ&݇VgpK`>h^ԛH[]oxY0"EasS6tIaTH"ҝMݔ)-Jn C!~.Nj~Ï??}F`a8G|OdZL30D^GE…Ǹ~ӥ7yt1=0}bu+oDu$pMTf|`Kl^\,4S}`W65·b ͷ_ͷ>8^??tC3Ҷi7˿??귚o^i>NA.LAA{ٰz VJ:2ʺzjxB8YV͂A9yXlU,)tE,,~#_{?rVj1/mX0^Afܣ#÷emf&=xIuX[Q Q?r,(`#??-yٚ!@wÏ9O鄯urs$_ ¡~\YG?0eBo&UC塶)fKBtGu݉kK>0?0Rci;+]ǯn_o`7o7,8 .͛ݹuف:N7:[4{pCԣl^zgM9'_fPn7 "hw)VxDx%ϻh{d6hBZCk& ՈuG#!"M0zyˊa4nQN}nP?n^a!ΎV|Ǭ.?0m[Qğ~ǫVs|p9x??l_^ 5δէt@,TwcI]>){9)d??<o[gW_(s(nds"MK&r1^OIdiĤXn¥œQ?rp`?0лU.zp^Pr{OIt*fwpuN KD%!9Pqbpg޳4B=)R_¢tdU -I961Qc儖?0"%ϯ`m* W$S`Z_shDw?0r\ŝ8)kN`~Pp&YOLetz:_x᭙)"s,{Xze(P2Aƹη+ܟ?r.1sɎǼ1obQgu0Xok!Z:`W7 ˻6D1]z^KC[z!G#cu4;<9)kh??1D@oml+ė??nWo_)/\0g>_ S^n&9ݬf_>ZJ'6왵t^{Wɾܥa88WYXZݮ竃lyp4{L:}'^,ᫎa$U y&Zп ʫ +#?nM {dLG7#ThG4ZzF&EZvηt5nHOַP?r:?r{fD s3lf8G#Rx8t1xW|!ܼ//!XW%*߉{l8v@wnJ-W?0LJC3K8Mme:Ocߌe4"{+`sX)l]A9jr>h||\r3|,J5?rj\t#CC}a"Dˀ=D;t\Ѽ?0Ԇ}aEʔLZ?r,+R"@+[%5tH#B-B5_8~!9Z~ZE.ζ7T{fu`ix֋{ru!s&{(۽iWIOsȇTԿnC,p =zR_bKmn"Yל-w\1(W󞆋O@~$nnv??iJl{ 5>DZ>5߅ޞcv׃=c߉my@Zd/} y9>X*S ?rdش UIÚc],ߏ8??W?r$-34Th*s66"R+cYXwi'HZƟ"lJ*$j?0OT8Y9w @[p(ݜT2ѦY4hХ%_} #܍NS˕uZq<nNQz8Yz *?rAɂVWI`Pҙ|RW?niԍ,^Nu{[Vd&?r}[E.J0:DP0¬姝-[|B[,^>k|?r& ȝk,9^]'{ř#II.-̓q=YR1㹸y]`8A(=ܗF*Uq5E%fWP&rc/ ?0$6O4 rFT9h7㴎'skgǙ\ZLY/vl'Ydij"oCs m(AZhkו$@oZ4F)4]R -,Q"" : ?nᖒ&3İlf䋲c=1z-?nϥW u )㚖??9ŗ̎n2vسlqa}Oa=mܭK;^??l >>?? ‘Ѱ} +ٕ٪??+K)sp4qVk/'RE>j3$|=`p)ˊϡ?0]~dOVmȡY/P\62ˆo!j&R6y%b+BYD^15&YU[ 4#TJFw3r&Fvgp7X|kMfcWo]X|kl:Y[,Clˊ4n_ȷUޚF<6&]%5V0{DQOȃ aj҆#$RD5?nL29e!"??NXS #S*Z*[|$YKchzWR5#ا>#BOjX%|9P̃?n &EG4>b[f(n ;*5*u?n,#Ԁhde`[8&{C?0 `4Z-=R7]'D,?r7i4 ݤ!4|*uih<*)0HTQiUdZ"B%Z,KDxްDA]?0QI7Xڛmno848cCE';:URA3ᙥ`)tv;6#^?0b%g oqmkvUfU4`"3YFj6vOMrX$}›}/pQx)N9NSI!?r21 U%R]f: '/Ϻ?0)`dq09˭Ÿ7tKË~~xΡuᣑ?rG^01āA(5DPݛL! O~0EUQ;_RㄙPXz*f~/;lQ"R[74!5Z QgE2^ke{(k4ay`n5ܘZ|76%#PƄǪ'^AlU@AG65^;QXH\.g(?no: yDc'lC?0ɵK;䢰eZVeuYG䓣M1E^GFRA>B˽$1+4(),uGnW6ýaz7ܭx@?0;x, 0EwZy^$?nEnp"0Ma1?ra?rU^,GwndSIsԞ3֤ZZoM|9h4>QؕLb,DmS?0oN??Yp|j5?0^zf16adkWKZh!haͲT?r[2RCP1]B??)O,~Xw^kbLzK6Wmqg<+Zo#Cv#bJⵏ{d0"cV@MLbXhcSjSī !숤!FHEo!Xm@mҩ&?0@{Ph8h)^gmS$`?r9v'tl2wh`n#zM"\hBe??[N~@iN5Rv f7kC hWY[eڮ R.η%ߤ8%mFu}Si+ҕy} cSZم4kmoؠ21Ʈe,k*daKn1}cq!Jhdu0s5$hCt/5XZ9yy->wrwt?nM4*>]ǒK?0lE<ܿ%HK E8|0_ZT7n?rf  u9/?nPhB$)Fb1Ce*+ZwT"~^k??s҄'c}'LtQЮo`}j3@y/0[I?nO.ZL'R\^Bӯ/`65E}=?04< ZBNW?nRyy)Hs2MW!Vu'gy_{%{?rWE~þAlaIgx=1vj !?ru=Aw|<=s Km=q~]!֭*!虛?nnvr<; 5el~x?? ;|y T??4 G3MwМybr@WBskh@LMi˖۸K$||"oM+?0:As,:X_??XSǡh}$fksˆ,?n;l'wG#kĝ&EvU%#0LކUP i$[#;T#S?rQuzf@ !??rYo8̼4\F!qk1;w +#??\x:Dw??y `xv}.,??2zy܃א. cC&1"<[L3rq@-?r F8g0.#Qu)bXCw{a9KuUXZ``ȸ)*n9<;-bdX_ά/K)9uB]f Q.jp`4L|w&um|?rgc[f@ȵq)sִaf"Q|9yްlPc ƷuTe"* z4LdElj[ʄB$&!8`g?0!C_?0b,UFSAQ?nQڦ!|=X:cކiGSsQ >c59:#,DPD1˟=H' I;0t]{7ϝ: QŞNȶ_zc=V M,UUYZM _G!aJkizNAA=ſ\.4XP@]qT%iL<+ I\私OjhtTn%3cpp R[/W,@7d9F-Pt\n#ġ;mz= CsnoPE*J,@?nVn3n*VkΚz*ϱ nz_IHZAƈKk&B#=܌`+u56Gܘ`f,lNmcOsr2#.TS$?ngdTɨVKpA5Q0<0x6vP =b?0IE\ME4/-e>h]|c6.Yr??1`{d*@b"{0(^8n%)5?n;3U-;?nZ'wNm1;z2s+9Cf ɭ'o[u`XM!x7s<>E6T4ה|??& mְxFazZ U͂7.>i.`Ț;<Ƽ泚[UabTWhͨvBEYSY@HIE$z+3@%W;(Fq6hՈ71fп?r]ơU>_[0kWvOToW.A˧ݽ>??Blmn5MjkSsc}c%{GfNaaDӖ# %+K?0qFwFiaOjmR=6g#:L:`/nkNPAʳ!SGouJ[^~??Hgb#DaoH"Ur}ek\;/La5-0~N_?0l~2Jb膂 ~Zh+תvgaj)W|SO]0R+(2SWˏl t2]Ʈw6OvS]µ699t"PI3l,ǔ3PQ?r_{ҙ"U<ߌdRÌ˛A?n;9 ]amhF|ޓS%_G(6k.}09?rgu;krT?rgJck]P}ޯ̉M\fGVnlu70wma񈏪غ%08ǝb)B2LXDtӤlx.#S9s ZoL??+W{ FSŲ>m z䂝W?0qklQ?rM]-"!OXlr1fFPl~ByIXY"2&V7b%UK ܯ$kP k2V18 {'WPt_p(?0L:Yb),vh?nk3YuBf#Qj]'D;͔,V,TbE?n.LWHMqg*0o { faQiC9jOnP|)u[dHi2FA[l`xen%h D(Fc ֑e?0e\dΞp8P`(dmd/ܶv|f=L_R̋|-66hk`?0Q{7t[r޺u2UwQEjylꮦxIEj Tǥ'ĊY>Hʕ2~:Bs??\d,/Z?nWK w7mo˴D(듮Z?rnגGXlm[t/wzyNBdEwEkLm3Tܸ36S8JÐK_XўF{J:+ٷ=Uz l"?ncFuzWnOLGN`sPc;?nKIg.kMi(h{#86QI0>$e??U|1ည}`ʭ̰syVe,Q2?rK&~`XCQ._*vOQēNA?n%;:du&2%_-M*Q41HM"46j($,o/j)EElU@b?n#/D#,ѫC 4qh;{;x)9TX8۵x55a5[}CM3Q"kը˻;DRF njĈ?0n7^$j4jBy?0%?nw%1x*&^9Y./ +#\®&XI qv%f%?0h6yMŒѣuf7.F3qĦ&$vey #F)QeQ+N`svo>&nP\#b!cBnpף <$h(`1)72"Et Z??39$1iH&B)A$8]L_..4!5Hf#de!!3`>?0|~N#JaY^%;?0A-Q"NCJ-r:bSb6l-5,`W?r"yg~}~yAI~M#\tq֛x) ={FM̛'zl:E.WBuI(?rXOӹ#hL%kI>>Fo8 pMP}ib&5_YLTgÖZyP[~J,^H9Q^Qk̟n\,/VҊMtVc4vѨn5$?0M*RbvpJ™po ^ *;>O0#GoM`믺2\3D?n)0&=`^U\XH?nsDfGxx94x\=upԬ}Nj6r r@Ѭ?0^?rX> .j }`I!ƜL؂}eͤd<O?nM=ߧ=#i??Per*ayXu׋.u!dC^wսYmJVY]`~f/*#,c8_ ZҫVn+q[1L1~-ǁ0҅P /?r??[T8?0@vsmY3(PFX>O_a&;eȏ8y6NSψѠtk?0w@tX֒{76MKi,>5Z,Ě^ x?0K#^-[?nCC+m@u`(QAbF??RJmHZjYF=}5q??c<ٸ+å1v] m?n2e&fq#JS=.FD-R>V^"nS:F, @E#"(7 y\-,oۓ0xs^Q&1N0%X*;,byKgEhXAW@j=Wȡ_yBuϿF -Rqɷd=1Pzh׎֏}+tmg꫽Ot,紻^\/p2U)ter4Fl$9uYD Vhm"筒yQ^b>~?n?ry񺇩c6?ryI2@LQь^\y6ݨk_V2?rs!h09JmaToa3Mf1ш!"l*E3U\HS>14<YAښ:뿿K4KH7M,Z5/ip:fV9gf&jע,9m4?ra.Wn]gD`f\Mxc!oJF3!2~|>Ʀȯ@'v$صWMee?rdHXDuE(W0pJ??,]F?r.D?0 z8|s9S'tO>u :Dkl|Tӯ ^Bn7MރOV`$ijQ0?nӰ,vyNtI64R.LBIuEM|Ȩ2)3:sXO :#!i h#j[Drd1g\VL{Wr7:`YRBx~Q39`q3!)l$W1#w͵bp&sڬN'I++q`UF46@߭KbCƖyԭKaMnsxsEGnRQرd寒'2njIJ1ƍ?np\A%ZK#Klj\!AJri2.YA8Iy#ia uVYc54)] 6ʦ*Ĕf.ѯKhK:52dx=>J=tϟ&gzO$/y>%?r??N%1=<&O=}GG#ƣ/w 0ҵ5I<}In>m=K+e&ɸ>;J,vjl@@d??5[6 +ftXGc%%{Ó,$Y S|W^S.UL.ӳW@K'  <,'NYðZvvR4{E9;1e)"_:`iF_@G6?08ŀ7ϟv"< -a8C?rTDG?r1˺W6`9VJG8@6߉bJ&㐳s12> ۙ y.2yL/RZz:/# B"]\<Ƽ9xG1ٱ$VQ߇͝LQzPinzIYljusͦjATƿ[b42q)V 2YTCFx=?0rk7|+]kw5̠=J )Akưuu2^W|8'~,יEvlyòыIaJK GvJ_SϠޅbP.|GE.ը~FyeFCP,t-7v=MHK6+?0`w??Z,kL_X8*Q?n<ΐ?r8?nAĶ@Dyd?0>HutP=J#Sv?rnbEvz+]jDW#jh|zd~Q!=*Ȟ\9nQK8Q<S^C!ǻ} .Dco33!ef??]v2U> X$,0D*H'\,A'aQ!aFwē`\:c\ȰL^ r$she`/ [Vh>\ڃȫ2xyh4/#- {lڜw.>5_跕?0[Ou&Ǻʫ/jK:N(?nXW~kV˩_1?0⫯$eݹj%Uno-C+K6±!hNseI@=gdYߛyu@b)o ?0@"B[;sdgM:juרr5:wDJg}!]Yzʭ4X+sƼm/ Wb*D9>dwD}]Fτ~ q˽n''|}>VvD5x;}(D^NH qv%뎄l%SDԒ g഍Ȓw@gҏ@cgp%Sp+Cm{L5t!+mfT3gAaɖaE<Šmg ,G?0?0;MSh9`yY!ZJ~US=忯N~ɳik6{W'0iom΢Ed?0g +#7?roh3),;f GѨ6<v:52Hym2Ͼ"K"?nw`dG=n5769?n=N&͖wNս]\V5Uyi?0U,v VmL*$\IeUVzBWoKѢNk5σL֐4)WV)Ey3a_mKŔY}+!g;\械#)# BZlίmYy?n){(KaGlvnSݧ֭M=<MXȗ]TXiԒn(+SϽ 9?nIW6K}2D{]G7H]?0B%Zb̛Y5Sl!^Dny[v Fs1;Ϯ39y ~o2 a[6y~ {UeV3P]IM$v}l*6r_2}eZ5B8Oy#w CM9o&`4°1K8lg"#*A"S $B C3`L?nπ,]VRxŅޱ(&P(NyoG?r:ƌߵLIѲm\0H6t FU+B&q=1#C(ѽ簭i@˅7]tbE-TxK&@glٰh.;B?0Ɂ,?rq ?rC,ddzuV ykGog)M(;&fOg?ryͧ3U?0j̪Eil}nAExq1n8(b0[4 _d٣WK&D&Y%?0k\}h:z97?rSP7JX1_M|&6"???0,P^ڲF`ќ)dµV][tTfEoFY6N5cҹZwŦLSl76K?0Hekۭ*m_##*3wYTĭW3Q$T* +y6}m:Zdٌђm7mү@wI\"Ksaˍ>jGx8v߸zǣt@|1 LeQ\OʢՖkR}0Fx-ax*9)H˭kPM[ JK(y8ው:_8C}/F-RاSJZ%hY&qI6 F7AdTJ?nE`(\\X3b$3DZVJRbh;!šRW'ȟuX(Ebhr3n:">7SM"h7dgfoh""))),Fqa&(_#.4q?rXv"Jἀ&E "&] ?nmmD+?rVL;z{]c;Ty??yBZ U;׹yĬ_FbIِ??HUxUݱi#??GU,8f##}C?r 'ofи]#іi{3P;[=${-72h e'Сg'-x?n^?r18JԌT---ل%BwWl.Kڿ*?rQƥH!??9+tdQ "sM63lpGȏrlU9 }*]+ybE9xjЗmj=Z*E}0Uw*(3^IjF%r(T|y)7{!m]1($6^앞ط%?r6/J\uV1m3k\Ѽ(Z/QXd9̒J(*7MP|?0(eK83gѢNK׻OҚp?0Ԭl\7"\G0?0E{G1XۣkO[>m:lPN`}^e4(XЪ< Yh99xlO¼A\F83̬LMZ8b|[>hbFR"2e9b塭LSD]sM,Wˠ~^ 3l_-0*W(Z7uIwUqe-ߖIM2EK'L/騀umj$"Xº`9zz&O7BzzE583y8c#dIZb1ya0(??KS.L:4PsQ҄kFwN?0?n\~&-khg"^s.S ?0Pnc"8X~Pax'3i?r~ₔ3Y/֘!L?n#01#DA9VBmYntyF-g%s"EA+n|8M(s:S8Ox;CGy9_S1CsR#}6MΙeaUO4ٰXVYn=jYRf9-EC=]. [:FXsYjf*Y",CyAJN"!MGݮdrS?0V($',:ebPeDK9F "S=[&1biWxB8QI 坤.t#~&vڜv,_9aL}rfK L$}E32Żkѭ$%QiNVtXO~HJY\; +#/`t;77{l6f9DH cks}O+Feh^9Wy ;vSOT,mݢXp"G[ۓA)&CR=4Là?rbI"?nH?0<`P.>%l K6lhܩ֧ƲtUt/<؇et4TcX2sD6~hkף&ɘ[dזF.KJJ%սa(Cq[Y+s}]gmzؔo\'}S167V"́{&0'N#ᕎFL?0JZVPma?032|ft??.g-UwX\??7hWd70??8%a76[{#Gmqkӝ%f[%UIw?rYn߹M?rnг @$*{m$,qYQNjH\y;WˎgQv,g_|v1ɮC۞Ϣk5½ 2e0ٻ'1>ʾO|5 Q3SD&ߺ\aX@Z&|UÍ_m.J:2]8NΝ?rb??zguwQv8D,$2E%h!GGAgRwnȤlo??.b$~nr]sp?0#140"n>x??t;-S2^ש(w=bCiRQGbC&Ap<|ԫ5^(.Y1$2mK2Ȟ5;0?0/64]L?0lr=}*Ie7I'+0݄NrumA79UL9@`vMXy?nzgQFoh\VzR!O [ix;]Nr|U¹?nqu-`!,kr}Fּ3S)~?0vU?0I_fa"7]µMW?0$hy+R?nNX)& ;x{3Z+ܾE ޼Mh~zpfW|@o"]?0|T&) I#.F](pnˡaT1NS=<%Fc-f?rVN"ax??wkG؋xCЈ'vU\*c?0o kL}ff!N[WTq|yFƛ?nn4l5Zfkmw{ͻU?rr,D[wަQFš!CweCxfrX|2??&"ޘF9 n~ʧ\XͯCA/ P:4L{CKPZٳaRǘ.ZV7߼B??+\TZɰ(8S+lţj*7o)G#`cBJn?rZ֒;F+|??UyMt׀F;׼3jYaY4U|ULqN^,Fc=7g/8u(_t$Y%\,s{>Bh6\7!E1O{?rB0a??+F0fd]fSh#vAaW~֑271轪&.jAɏ1i;fI2Ɨ?rk4NJ|"J D=HjDXtKtg f:WuQ[xG\+or`iOӶ31~#8򯤰Fɏ#)?r?rp?0tCAS:G(gQۛY~E9v;S6w_=6;y+NgIp(=N Ca)S׌똗;>rB$1D![??t21KD7Uen`mʔ6tmKk?0,:FV B,c-=y?nYܜ-Iղ#>bv?r a=ٶKHSVJˉ-{P"?n\ii%[gD3GZ'[Uc 52) 0FuM䝥Sc,dz6(?r?rdKsf/-|VmŦj=8ML˔qr+cSyu0epevUcb?0>I4&m`hqMY-I9k:-y6~vs $[l=lA 3Lhd0{)GZ#3kyߝ Iၘb{ }P^8nE\㲖έnD"~5b},pO9FR@L'Sä+A?rvjmn4C }bΛ9G(U#vo:C|ŸPW,^OH/4&&C?nS_+6:2CN_f&evATeЋ3wx?nK7")\?n;Zndw#F01_?n`V{YnxnP&&IÆDT(29 9GCt.cȏ} @]rŪ h׌2hUU4%yQ~2K\;6-Kaʇu6扽$W;mBĵ.Lܕ#ObRέ%wArSE18(Wz&F"/YSni;[6mh6]<'+#ֺ?r`fÇBbŚM9G{Zzx9vl*;ۈZo(Ͷi.1=%q@ /KV5Sorw=N/0vאYPg!Z,f׮c)*KFcV&&ڍ0~{ʗQu@G??혂6/"*LcQO4_׿JP??PH&믱vx͘k }bp#֌ZX+MS^R$kplu6t%DְltZLJת3wٽMdka?rP(N?n??KuśŞ.WJsy;J؃B{f CdDLRbUͨ_e ??[Iwx[t?0[+keIh 64sb킽?0{NZwAY=\uK-%e{;u0aN?0a:HVm𿴷p~C@y qV,", 4br]iJ Kֲ*QZJϴpik-|ڛCy9@T++[Il/ʩRvq2;7 Ѐx8R7s\"Le{ (ʟJ\բrՋfX{:I)#{|dX,ɑ܏2m쵹0)G,oV~;_Ez??jA)qpϜiF'֭(Y?roCH?0X??d.м[vjlٵ Up7fe7?r|olmSpMw,r|^KK64Q/eeiz2"2KdĶZ$h??1Jʧ%'RGd??i_` rӜLhu9r#U2gyɪȂ'BXV*٥LpMyYCThDJ*{vN_hl2<;`un~hmpNe&GZ(<u~d#+Ok8GMz^hols9]Y?0QA9*YhRH>נ)1R6fPཪ='I"HK)ЋRvNL$iU/ Q{[Cy8iP1 ǽPۄB BZYb_m?nl4Jt`cqNňLM\cTBjWKX(=Oi1^%Dy@ ,W?rz,ZV02ɍh/.69칒;-.\U^0[v u}`Hv@|u\$لF,ֽ+q4fCUK)'u??soY??`0*|煞r~7oom=ԿMM ĊHXzpdHa["9)DJR>|`Y 4œDW6+}p]g0IQwD Ͻo,|-خs0yaz~eҒӍ[o('^zlq8""x\-")irXl#T=2|&U`JJ1v9E7KZK,K g@Jnc;P4D&ڟK?r$Řpw{?0tWXdrP ݟ}c^`xʖGk?rX*} sqo]+e$x#APB,_+8)^5ߌc-:ZBf_SXQOljrC[''Wv Y6ϒ81`  l,՜8 _ʹvB`^U;hC%v.ͮ06ضm۶m۶m۶mw:ɃTw%TҪt5$1pcCގ$+l?npSLiɝaY x~w=X✛Z*_MPu0?nf8F{*"~73w^IJb:.ln.'k9J=WK'i)ߥP+S7LQ64ECSt7 RK z%,_Lk[ˀ3MpiHO'iƠ 3~m60QI[PcwـJβH_0R7%$;#'Ms8P?rtnGqNSFk8ġS$a\Ff??HOLR??2P{9l|Bj?nTYl߭@R,f-S[@ O/. ioFҹbsDؘCleAmE0;Df9oc홴,gҲ??U.2Vg_>hP_?04W:?066?0*~FYfEr_!?retSkIifo@j8tn&jae>G|ih"meO>PV3o^?0ܱV&{n \z#V -]{y#F2Ӕi&4Ρ*JN;Pm}R^z?0erafu&W\T|Gŗa2jAiy,eŠqѓ?rNaZmAb?0v3R0Glf?n̂ȼY 2,D֌#Y IuA(!ɴ9cZo]SF4%EHL3 9[=+ywL7emmk /fU_`XFN=C5?055$n,8<10=FLwzT*D"eD(En/!ax ʸcF;ӟCO (J/N/c裙AFѡw>"lZ̹Txj1QcU2˱"$G*(;S,1k% pdR!ly0\<8@yY cM~RPHKBtF5hFؓ7g?0A8~]㕝Ǻ.]3/5㵉e02QAk'*̰xTykϴ]&5oun1)mN:R }kHtQT?0&VC"ӾM`#ʒ.-H|jj7oBMScr3%s67 ?n֟W,+A協0ם2_/˳!py %LJI+'V`J?0|T% +#{̥ݎLdHZЫ5+z̧냿[J"jh;Fڡi_v<7FBo(5izW0^?0*RbZga-K5??ֺ&C AEhųGTM}=b0]N68`.\ RRJĈ$NI@Jy+1Uxl\V daK4\- cښ(1x+D="O养pKs58s܁?nQyW:ҿ|;"-5Z?07p(3lۥϣTGqprT)Y!C??:nE9QCf!,F˳74] QXнzj&QGje_ 9]SK'%ޫf!e&Pʍw?rN?0(G''y! [ThOLmڲ`R rß,pP¿STގY#fe6-El ;Pxnm\UvpVrzɮ#juQMҺơdbLxZh/>Bϰf^5??jX# c f%B}hV PaUWѕiµhZ+#Ĝk,eӰ8+TZɴdF??ƘcNpaJi`tmՎhXUj(4?rJg=9D.@*sJų:ڭhl{j?0T8qw8o#IE82 csb!lN p@ #zj?nH{BQ(|DYx; -FeF:0%eGRjU.Nڨ0C'Rl%txS_t\bF͸MzǦףF?nY*%Mm%,,El|ڋ 2`?nMׇZvQI,~Bp~?0 6bCЙ&,ҁ=BU?0ZE]R{|-.C*?r^Q?? iNKfp}4~dyƾM ˾37J<]ӛKt@kjDPlg]AefVx_`n<?0FZ|M>~qI^A jCҺDkdGVsrj+&hC]EPDWd. `_k~/v?r+By~+DP2X _ a l 7s%?nJ-mE暵BC[j qL"ۭ|1_{6,cgIQAѱ2A} PI(P~Qe5քGoXR~@@uíZ=xOf9Cp"޺kQ8?rяn8T%XȊ`5?0e%Qʹ3V%Ba~x3Y)SU݃zgLn?0^4q8ABN!ާ0$SA"0!?0n-j~Nm?r}vO?rMH1$C#!K2??٬53~L`hl6ي'vl*H~ijyEf?0?r[S> Z9k%2 z~T:r,$L'c!:h1wd/[/f4dUYחNcۭYۖ4[V6w>Iӟ?0N h-z2&%]i}M<- l?n$?0reort%6YM^e DX"qc7)E ƈ6af 0`p?nohO@Cl{!Wӫd^_#u-m.T P-'^?r1;삟GhLor+?r83?r$Ң8?0s!3@#B<34MSk\ť.7b|ԭSȦ9. 8oշSڪjXm1XƒpWC*Z#EUtFt?rF8ӒsMc??8mGh:Q#qնqTR-?nI3r>nݧS &Qְ2] ?ns&]?ri<+铸~XFxY}kj.}s5gϋ!2W\'[`6jk aSu}"W@ѽ"rC z 2 Weaΰ(RPX9> ep!8'+zd0Ti̻hRy-^oWg/9G13 s?n3bHֈn4(uxEdSIZSrzӥ??TRhlZHeȍ\?0;GZ9.#dFr5[ԞZ!!7wp'i =QnCR`2t WiRKAa~MDlXXy̎bxu|2?n`I"\U~l *pM-\(VdpQFt鞕*IŚ rP>-~"6 TK-y_ctXܧl(bG;K3nU?0u?0G};(]`3ςGX[%(Cmj\7xm]AGijcF ]R&{ c l/ެ??p@;f)QKbߴ{qu0ĽT1ڥ.=<3l`e۵ (9=}Yw?0xdT>CLVZ>;M)4Oe,h~ߺE8, >*3q覷ƬbA\-.74,ӑ'z|Е|YV 5j8z'˽gӽ{όٹ{fK+b;K[/2z9?0eȲfVaSQ,WTۛ F)|8~??j?nTUN@{3Q_נ9?rps79l#E]_&T l3ƘɯV?0 ,B$+5&FUÜ?r{aZBi]yެ?ng`R)@=49(?0eX2>΢ v6)3,q Ziz +#9p]Jk2%{zy5E8wEg/hBx%@|??7Eܾ)=d6O yd"?rz*n?rl#ɳ gBRZ!;|mV=ަΚk4Afc9O?0fZOɺ*X^@=qUiMhhwQ)[Y7Y}6Ǣs=qԅCXn-$49@IJnc;m5%Սz0Oɯ1,t崆hS>3~&>D,!^rn\Tl&q^H̓jȊ1u= ,= E9v CgW#^_@m@n5Wc3}I͑k㰃rVDd_+"ȉW.Ed<Oprm?0>$9̅6/2o4ego%}嫫vSޙobTmk賂N ^u?n]%0t;(.-Q)b&Zgy8op9⮽+"m7gzJd6lkTkM,]zO~Y,fy\OYԔKChl7"p9>Kj%+:~;Er>Vs\ `ۦ/|nwtd(Vj0>u8G/u`iB,Q7vcs+7i{:rK?rp 2F`iUGFU)Aעsk`E˝SU% !BNIq%x֡VaK Y#AOg@+0X\JC`8-1cxyXyKxEkqeO?nҡ3>"0]%jw]584kBÀ46S:gQ\^ZmF|h /԰4*1c@k@b8 UŖSU2zSγ`joc c'?n"CV> ??drx6ϼd& q!4??$PF[D/pM>LJG,k(Â!L:twJhk}b4tf'etJƏT=L%q®>bi3cr?n!HHXq'e}[9:@8#^uL0xy֢?rmX4*y3O.'+2:P+򎓎Du9:΀8ɰo[NA_v2-Usܵ|ŏ+13=` K1> #3`ͭfẽ/ | u~T'W19ݾh.E?0FֳjJr!D;35s.ւmq∊֔i~XqE,\$tuY)j!IuhɿF>7m9;\6s!$_hT?n Ҥ},s)DaE&Ӎb:d9qfJQH??xē-]k+Qd圻JB]ao+#[te&?ry1?0\X\aI'Q9܌؍yq)k(,,l9 Uvۍ?nɎeΏ=aBotU0/rr:]xikgI\RވD?r>{V(CDKDT' :P v;z?no`{L`}/:/4,a|/0OāV?n$)`JW@1'??UeoDYnCM??eϨqzAF+VM)>X??y<#y\OQȗ?r#7iV?0>?rM3mDo(??#OGHasذx"Qz[IZ,Zqtɠ zIG;)[kK$ +#!S6iQy) Mq2"ln}cc'@ƥK{1ʍuhk<~[7ۙãq]*p8TC^DBϛBfEŇ5@hxd_𺲺Z{/<n?0'8RRD}qx8|g 猺B[ivT·d=ɟ\C_Kvz8;'r'F]%ҹꖴnRxZ le𹀨rm{c((1ih$,)f =YaES:N_q"׍Lcu݄;OE,̓@O(lequk٘2n~2gn(y/A d&ɥ{ByDj@j=6d4߫ghyfrc aDd?0YJLyM?nT>#H5W$D "ACDTj?ntܐ3@VtJT%w+J}CO_"rϱ:~yx3%xsz r6!foֻJ_FV:KݏKYi-z/x4' 2QgC!i'obnEOi{+֫Vj@v ׏LK5:Q0Tat Axhvq{@ mqEgc*B1U: T,JIBxx>͛3XQ<?0,cG?rj)вD(>17jޫ]q9A< BN+aAU_ٕQE@_XnB;K$cB(uW?0_ S>}_OM1WDG鵫V??;a -F zEoѶeN0%>[תn6SI`VT&;cP.;_V,>Fg|HcŤ4*Py"a,lX]޽?n}7?rp7ui2p D{??t:6#??>A.f1D/1vsPiY̫)OA }L~k*:sлuK??1'a.JÜhmٱ(oK\sv`ܴ$Ֆu[\7 j]H#ˁЇ&ýF0t;O~Uiu7@?rUrgJCj,S6 #iv{.;]˓?nJxإe&#Cu>UDW{k;L:g}4޿"k  6/-?nMqմ#do{[lwN0v7LSb!E`87_#'a xo5CvH?r^ءEф^bN<Y?nq(,2V WQ2s۔>9\> cv,YY?0 $GwK y?0`RoB=mv,.[D??$ [Ȳb+x^XH@aJg^젳НXlIA:&S78Rͅ9WlrSeFr??׾Ţ'??ix7= i{^:\U3&3u|*w\?rDg?0,eLfҢF,>TtA׮߾yUf3.i̞3n)1tͿ eSZOV1|GƐk^|s3w^ aTY6hN@l@ӕZ5x.S'4Hg]7Q%t@20WZ?r~^ZDP\nRJ7Ej;~rwUˉDO-Jb8$|^ Ǝ?01-_EQЎQW)r "3P{M(`>TnpdeWʹ'q]Nz.7'Vw.9%}3D_,r_kHƎ4913$1X˫E]SG*JFNxex&G̨ERs6ڙrbS"Q$,]e)[~5h/@٤>??PJ;\a&_jV3Onnz^R U~J:u'[S}9(#ނ^3(耡&4$묓hp?nެmEUzmT%t|$nC??VFz?rM(1B'zVm!x+cvrZvđ=kjԘֶO2Ϊ)?0 $^ڴQGFaWd(: # +#lb?0CPG?0S3.nIښ9crmB{-Wr?? lK B mZ^?nKcdDx㣦7 Ӳtk5`!޴??BթJ[bEWLAJye5SL^4 թ-=RQ܁F6o@iغcɚ!)Bx*|,O]*p@ 鰧.;??(%i¡vYDjĶ@A^Hg%,?n6*L]ch}9pկ~!PϷ(J@1\ކm?0f*L0$N9qf(xFPΪo𦯮^ܚIvB1+F܌U<kBlw {{qͽQ~dT%P؋;i*TSt +0h/L?n!x ?0C[;w;}'wZWcZG3}cGCGGSg??zVf?0+ +?0=+??+11?03202IO/H=i!>OچXFg`692caT.Kt'LQ蛌c!x>3??hV]nnqQ=x+J"Hh9 M#-DjX iJ,Q_7?nw.%+$q Zш{[aLqIlߔT'N5UαAm$??ѨBbd3BtC29\+vU١ S Qt\}fWҞ֩4C ʭ1-˛fK?njO?? lV$3wMXV=;Þa[ $FKޠHMh"T¬T%E0QIj[\?rMحHFTNM8d\X>vQ'",9Ee!KjvU$tP7\捛von+ήwQ}5!IR J?rPTrw P/>/Pw|<%cT f =??\K\y>P.`՜Y1Q??UC ROf6l`:8=sG5GƕCФ4;??sOrf\5G̩6E3cڥn"hV~NO8"QalM>Z$p.ܡ$"L}Dd3WѣC_ KJ*a|.0:\$8R\D7 X o20C3"4^XlRSE Z.(^~g{.fJ`sC_7 >S8!.wZ&5odLܬ mb8S0R.a}y2t|?rCoF{z a;BշٿW"nRq-?n۱G52U8s*'C|a cZ?rhy,Da[c,+(Ѹi-L1'}Yim{7l?red 1+???0 1V&*`_@aL`D4j燆fifYiFhbJ2߽D:%ܽ/I1o.bd˟F7etQdyPi>k?n4ؐ}w#.7/܌AURg'v߮/k˭+%KzϺ~aec&̤n]GbHͤۗN?rxNۧe2Fi];}ԞgrDQ7Wv=~t?r*GQO7}f}77`OKQ|M@RIK6DN}zeC:o N.I0,M???n&4jVQ.YFv$]`GmX4B_$:(P,( oh8 *QdP\&j]XT>4|m"{s8ue~~ڔ urjqU3Nx罖~D8=`Vzk/ )4*OaMie1\cҿ$-9I?0^vqPj\8F|Saƀ!Y b[Z= |à%zP.&3&*1Ԉ??IjR-5yuT??I&3*;A;\=='bX'Uaڙ(UWg' 1[?rLQe,׈gﺊ!['m?0}NQ6#`:Ӷ⅐K")!NrYo%C DOUPaZ^ss.Pf :a!x֋J>RN?re?0pЌ5T)"yb`~;YEA[6K46( PI  &O0SŅP"a\РiiH@+ǐ5?n *̰ٻr)r''p<C5^F'È,- d&&9ٕ-"4|[?n\ aejqD,コsBg?r:[HCHшR2E~^[1(I5jʹ:+7c>mcm}??F=v$,"68ɘd\X!j{~6?r`]o$/$}V{ *aM:ŬZ!_IFܞKb?nn&wJ[v$6da =HmF:XgY@?r6׹OUqbo4Kv k[3 eg̵u?r ?r&f%[njPө+7[mҪdjD}Z{c&\E&`(888*_q$(Ј ]Z~ٸh 4Po??ݳǞPPydC s L*fB`i|EO׬ }3Ypve"}DwRL`]! G{??4|N]~}NDWi݉+km[wQJ>gd]Q[]*.x`0 &;fD?n;Orb_TiCa7aYޒYȈ^E#jmN1S{.$"3Of` f!rۡϿ=":V ;5'v؎*8#]1CȉcYjSYE4}yOƺT[gֲT*@@FV_B2q&:UDU-8B5LVdY̧+!~>nv_(.+{8.|6`?r@A~>?0SR[_?0W- 2aB5{c\{ĩôB)CP5K]8xoh&R嫫s59Uj{}meOk,jvHYQ?rM R1>~~DP~Y .ֈ ,KQWӎgonx;Oou5X %  }Ʒ2*Vo/TodH2s@%XB janO'I1EdBzYw[,Vw+?0|g=6_@>|?0 <Syۜ޷ Y[&;)F=?0Z:l ~Riӽ0\0@(Y!a4oqTD$F$ ]໿nȆ>Sy!DF??c燯tH.0DT,H3@D&/nΖ>Y:gXVDuί N#K"^|CBWna4.?0Oخ gCb(Uf̉pa$#4;j.]DϢx-j%KlC??u n rfg=vN0Oھ\s]Zd6p _??m;T,Ė鴸z)k+2Xb\I9tҿߟ*Wl??heo:a*:aPG>?n۲Ǽ7*m(# HZ(h4[l{면\7Ɯo&ԛHe怡?rĚ4lћrz9ߤnFٗ23Y6 pHh,%pX;?rjy"JO ~Wy/??}0@ =wO"!xƒ]!3@5?r:7R!}?rzO/}N2h oz8myAs^n3T^,f~Or9 2_y(b|q*h5J@iCY*M.k֟0*qߙt㏞'??}ke{mLT,m kס??*92-Ƞh\ɕɴt_׿M:`lݝr T',GS?0Xơm6F;إҏY}@0!TR5Kk_ +ASWΡ"_VHAH?n*"[LS X,-Vݵы%ђ*cuSQ^??&* kGVY.Ơ,ژXq@41xen(f ɐS ]$!D9}O)-??[] 5JR?rތ}}jd #mKC9f=#b ԕmsqM,NEyպ$em%[S ?ron;5M""XXiLjARƄ7W*cU$25K+a"Ŏ$Wүʸœu +# m8Cdŝhjͻ2fƊ:]3Rl$h&#~3͘qdK}9c0]?0WK٧Z9(PΥ1_oW3v 0G@Map\?nNuQ<q&sH$˸]oe9P:5"A~?0\n"j?nE}*]uE0rglq?r?rYW U-nIDe{-3g?0:5vϻ1'\Bggں6AZ5s?rÔήՌ-.>;E.UA ȫ0%]keĝsw40ԩOY%]"|CGx9 ;Cf`cmXÚ?n[ \dev\au{Rq{~}ASʧH/9??<Qc@^f0NM0R@ Ux avY:@apă;DM*8L,+i.q?0k S*.P|N>]2yխ8pඎQS8VNNqՖ?0ڦpLyq'-X(?n#gZE[лTR߾!`_(cHEʖk"MX3!N7}&]V3e^xh%=Ǎ{(Y, d9ڀH#4j~vjXKI ?0I|= f?rMu<0 Dm؍۠i }_/?np"-Y^kfe&G%dD˲x?0VfH&Wj7kfVٯ n??-YbKA5A;, `aKZX|D58 DR)ܜ*??lS!ʅB˺-lUS i=}3c@h=~g5 H#ytSp?n-m JUwM9}g.pF3 "[mpqC#j.[I?0vJԲk|M} x||UVH΁KYCq#QܬP,}}C:]>hE5rtfߕD;࿝φwoGgowɒNSd}CT]ޙ~ekҿ-FmkOZ+ȋ"3vi -hf{tOep"1bEBb|R~zRfmnhn7~Y[ۻ!>AؽP'r@,im,Vu0W9`<)c}A#)lM#V/2V%]zlwʷzl_~Pn6?r/-A?0c]R!)]6EAĂ~~,\Y3;1TG3C_,['h#~irG.\Uh8Bm:ȭZۃu  3KAz(ɭ Y |GE8b9B8M#A_rܩtR,I/şN㱛te_,_<6Vc)}7*iTC?0MezcǃB_yٿHsǡyU@grRT[-(.<p#}UʸYވ~dÆ9bp GuIc *`?0E(pLj!,o 6+R3c&Yܿo܁3c/4Kł '6SZnV[tᛥX8z ,)@Y?n.UmHC4I%l:З.0ީkpnVck$IasBSv `#K$B-қ]z_"ӶGevbܮ7l?nzd.n'|12V ۄk! eV6-(E_y뢕k(BY7 dpU!V?r0ǬkJ1;??3eϴ>A@-'??}͹0R^^XA4u!0GFe??IJ>¨qFl'DL)κPJe??:-#H "` -0d6:wutWNd^V=lBYH>!Ն U]" EYPuK[%LMKL#(/;`'챊kE9PV`d:F&GP6غ cx4,Ѧ!o-"/;|8NVoJ??R%tisQD^ڕLt ?0t:̽lXQR_hHVApu{NkF?0GؿmI i:*eTYHƍ|Z=:ڇXt@s|s`N޴YNS*}#扄nlV?r*Cf~ M*h"mY85uf$myIнC6eYj)==̠:?0Gb?r 5}[Nw:kI78kƭB.O@|}sk„N?r'b^T!A#?rR[ t-$>tvC]yU^@N[4Xz{\RN>ycpeŌ 턳OtcY?n'Ɓl"g حbm?0`ՕWW RdD'KGU?r2kiyU@sAjvN.O]J3?rӸ΁`R?0?n=,?0C!?nPLn{zdY^$/s~JREN5(7ޱI;~a5e\ɢ h?n{/wp>1d_ sr0ت9npZVƔGsOLBƙ€N3ڃa3*=hQz#<+q+u%X%`B&Rrv9psJU܄&{8āͪ;`Zac/b\ \N&vZ7dނVZU~eM2y_N9WH$ +#e?0{zHr> !( K"㟩0XU)ِ^LI! ;,0}[J+2t~3h?nj\T& ;+iu>y$`($} 0#t]bM0$', ٪`?0-n5n:þIPnYf} W©ǝ\[ZIpCD9_4Z,a X,ӃRB8=K??q$4AWdmtz]RѽF&6<*Bw-F?n A ..Xz^=.[=́p4֝1,?r=Cl G1??A2YͲ(˶ɾ8x*cR.3⁝IyD~c[s`gOĽYF@PzP[-?n3NpefaT{]B`AzmYt pu=Í]G(SRb{7d^LjŞnd78RxjGcw&A92|9I$ F+ۈ|U7ONןVV9yF^piZ5?rޣW?r9j?nrÆ ~Ż14ݧPh;t甿QpՋ5/xB*e[1BPb\Q~:jȰ5F?0;C *TǔSf=h.i{۸E,Ȗٵi)?0#qƵXC=gP\%)htYvM[Ͼ.Wweԫ8ܩΨz 4}4 Ӯ# 4F0a|k4 7J$_@VD A/U?nfJ ExϞ*tzDP|q˾Rr?n mAA Z"O)ry7cdzNR =xDb;c9f^Ev_)^(J> =€OAgfi+!!#|GE tN\.) ,-.n k]?rZH<~Q^;D?ns QFg+r+Ra6z/$w?0)Hʜg;j#VK#t_PVAqXU ŝ馽g_1O".SwH+.5a@Ӊg}~t@N#<^ d "]9 3wkPasƝ^GI}MS3)REHr jDA=*,1izõ$[GrNrn&;;Qxꍾ>&ܸ{I5B-tVǂ/LWiF|9a̠L#Gk?n&npkN`sZ~?0j)1ʾm,]ia/^^Pg,Nr (|u٣e!<u%Ҟvw.2 .'~]-~Q^M؎fE>ƩиPtÑklD*RjX_OŲȠ???0O7 $SD 04tchC?0?0H>mr~H7{Pm"uBO]#%'XP~^^T4)wiP c;f%B0H96Q&[mINwnϤ,;-~*gN_]0k(*v0]\Gc?0ḳh,++tqՔG t#=hDm/T%fN5vQD+?0Nљ?07 ?00Ѥy` 5"5(_1իi:& (PFES]}ǐ8zc??c \y,[~u?0wjCNSZ?nфayeM=0:jۖǗM=ƈMS{)>PźYX\ZWaIft瑝 ul^J??-?nJ2[M7kK XU*"m0;}ܚz:)@Ft8Ա^zUSE _=^ ȶĭc%"b-9؇|i"i]`G()7`͆/fG#OU|nl'z<{[qjEL43O k?rffU8W8a]ưtd=k?0??גǯPIJo[X??m'tM7ˈ7~)26+?r$ٜ4Î;VqHo?r(P1/Kbcg‚m72w5BPj!NS.Gwˠi Y+r\U3T_S]DۏKq͋eH2Aajqe/51gD7?0Mgs@|K|*R oa?0Hd2nS[1zUolqs]?0q_\FK⡫!JX)3(EV{.)eGFQBM{??`z!f%~Pƭ*D Naۥ]ݦvIۇXP޵N߯_߄aa?0g?r:CYXC7SR?rtk5*/"o nMc/]~Hu:| Nh u{J0@̼ZbT!YawEY7;^46+ƧD<~NÓB]y"׮\?0/X4#_?n=ջ{it/nW%&[[H\ƨ&ΥV˖47Z$Ιܠ'/8?nd cl3buǺ @h6Щ5̤'Ǭ| Hyzcpl3fd;fٞd??v@"f|`uY1$4_d*??cJI,DF?03!Mceר3,(q=6c/?0dM~ QNF:iO8C@IoͿysy y<c-2`GV_P3hNlgz~_)P?r_@JlhXK97vdDD7E?n$C"ZU+Z!j??y+d s]r:!505VOi`h!"S$k?02z#gs~po\P Ld33eidLIpد6 ?n ]55{@'\H/{t6r;l"NSl3cCߟ#6пId2E@?02{{[VZB$^"Dwf*%:wXg^?r)Ȕ(?rб3%.뙄gq-o/[fРM/'|o1gٕ$bpy̳X*Ң]*VG#Ġ1K?0?r&cg7?0_??=33+=?0==#?????0tLt[ # (EC??]O?r!&!'ɯLIoX??o?n{$^l%NߙߚOXʾmDݚpZN z+V>$MWpǟVѯ-٫1n8vxX򤌐3ZAq3?nUblPE8~oU{ep];k /Wz}>նfCvV/?n[xN4ᑽE>Y* }zm3q: OqmSHp6$ryge%2۸Q0eW{[H:9AMY_jhpbY5<W*r&ߟn';[V7jXAl4`]AοHʪR????՟ aֿJ0韚梨%%AU?rdyNFk@ lqDo6s| +ǂy9Gڶ@blT|ſC<ϴSe7=LCA@?0??ȵkQ5-ezGC HL~-Kn>$^m/8:??ݎ;J-V-ދ,4VNj&.p-?rӁ9Vzu?? ``MӍ6 ,AV, VHM?0B?nA1Fu63-I-W[f:-v$6=1=lGglCܺ7vA?0[@?0}mߥ;E`!h3LSoվ1(Ǿ1*t@忸eo}a䯥֓oZ{r*Ao\gpDε]zQ# Eil,[#Z\u%&:d󬂍'쓨";ڻ8xIѨ[Gm6?0N\-X&U7m+D^e{'[a"2e7] +#rv> zRGCf<$ H)2r.ĸۺo_5#-Ɉq_I9wJmwNm :ƣuUBħs{R|R"fI{ƫ]Qz~P"\Snq?n*n?rP5sO^2xvE.zD4XCjPJBѫtG .0WMH:dw??YqDI]oNiB!颭Pmuu?r9 b7%Yw1|qC)~4%_9$$I")4NDyVa @> َ̩ .N烨cd=Ĺet!?nt[Dg?n?ruη#{g-RLyx8?n,6W|!?0Ed"ձ5Z=jR,5h?0& xc}C .@,yL'WEGOHu./9HJ Ao("7ik?r_Gc?r?r@ G1J8KNp2)c=1~.)ks^g R.??U`WŮ8no/M`V@DYZۯF_G߳R`V;`Vs}g1Q~_xQtk1dV4b5_LT<H|^dCr*KH_Nx>r 5ϣ}C-JܣG\*8YT;ɉRvHփD1yiy]1UB6"_j]Yd^*,d^9,|G^RInSy&K?0!J=C&k@h??" '3[ BX>.86m#RJ?0V_t^??hsz\p'"?noU@,ǸKDhhxl0OU ,qa3ɶ4kw6?rZ{_ڪ|GYrEDN\]!^{QkfEETQ#K< ~Bǿ]Ft$!Y?nw/4l#6[ǽ/T HuʇD1*35=jyC8*fʛ)??.9*w)E)qҡ~Q`b4~/lu.F|w[1 O-D}:\Eή;~%yr 2@T{,7Ǎj1ٌf{h)!_rؔ`aZ/(Q68:~xs F8?ruzǑC%#q_jͩ(8{..|Q$??dF $DxPΖ??x&txQygg|uu\3X݈xW<2.d5PgI/3@ ϋ=рEijeэ&ҋMѠFG0D+3!vG0DO.??# _@dK)pB!KcfBƧT#3H/bX$N_xM"S)*4EA S1O]GğiAF&O] 6gk8gV^c??6+dC1^{NĬ" (_b~*¬˸ql嵚?0t‘f?n9=*e~u ʕR,?nwP7O% %[4Aj*ZKC[5A$0U )+A63úLK׺dgvUh15` 훣8v\ RM]ESѿ҆@j)wԘm%S׵9~1ۛK&*ʩh??DÞ;e"HY??8X?0r7[D~# ??^ܮ??0Ҽ/E~--F$Һdl&ŰârE/?ndZ$?nPk@Y>[Z+J\aٓh!Oߓ%-<UuͻW˭A_??3= K^w<d 8߯ga' bo95u}^_L ؾ0huaW?nEcCd08 ϢCf0:%Ch0: "J ~H|p u<8?0N̐qT?0Bs`)q3dCQJbBAF !]$0WRj@kƁSW?0%7,1%МeP'-SM}pV־!IaU??wPh 57nh?n@;ȇBSnYWtUQK_Iv{5 6AkslNxC۹UR0K3SZJ?0.h غLnޘ{g4X.ؑ9Ëӣu_AX-ˮu$g XόQO?0mY$Qs$ LtuGH*l u{'a9b;?r4OfU"V*LBzu 7A"K++*Ե5!7pY[BS?nSśߊlIo=X=YA27y`C7oiDbkzlhdc?0fSqu+-C/&UZIr-Q)-OVN;vO/xҽopCkB;CumRzͱXK9-T +#NWao v~=)!??5/xx`?riM}7'gS09#rҲϝ|{?nvjOZ0R,=K.Ǭ [VQ uIg>5|̈́竬Wk/wQ10tT:8{K$1U2UlrIbFm-NKv sZ8)StKg~3&Ari 2me&\ZQJ ~W,?0Ѭm9/1VlKӾf :l}x q%?rF9??8soJUA~qĚ_cryGT ^>F:ˎFLrq{pQzRڶgZ!]dhYso\D[z5nL,HHށ$Ys:;Igq>zP?nPT)7ʅYݭ.`(A*?rL;Vϣ\A(D0lY?r-7]isXť̌,L^l?? [U?rSI(oe:QT)l> Rtm8#K΂ 8z$ j&U 9C͟WBW>C'hـwȣZЖTJLKϣk!m7Hs~]?ru9X-C8ZKEm}U6DsTzNOl1fu:6q@X6입*F*NV?nXQ;!:%ol2ـ!|vdDÓpJP 5AN_Yb0qts'?ņSb^{P朋?0ps|6d?rqPBx?n?n/*p%3( xỲQ'ɆwϛC5KK2ɤ9Y4ϐ +P7;c6jLz1??B(lha d,֑O@ixomH??8&,16,w#u?rqOJD3?n*qU׳}eaDoU'AiҶ!u:^('P$& ?nFc9?n˨3 Bkz j&cwM7J{Ʀ\ 1c-F NE :G$ -D*+bEtsUxH !?rF[bnZd*H[o`ueln,_sFGt[OVƓ.KRfs_M*$v}?0lXs\Pv}J m{ġ*bec)N%+I# tԝ)\Y/'U/K,&vAĠ$^3i']w+ˆ|*< A_֫y w3OIUS;zfD+]C*b݅&UܗW챿3'b3*wX8*FH?0Ġ|ݕ[]y`1's2#>_ #2uNgb ³!KJyCg8Ϸ5|6d릅3[y)dE]1jtR3&fG4- ^>s{K:E"b>|6vb0Ԉߐ);ؕ?0UYFG.E{^?0~Jzc&Kk*1wWCVClD=?nhRp 87RÃutcږRkĞ΁ 0oH>:ҵ*f(\0^dfC2w:_1$+F"?0]V寇!TqQˆvcDDC±aH㧙:L#2QlG-YSѫ~ui _L&W٠NoilLaCJ hEëevpwy0|@^/Y/kf$&YMQ`'k}S 8N,s|2l8au=ĭCR ¨\?ro=dLu_W;Ca0]r{~)|PgĪkIT}-1IC݄`FRsmR-BBΖҤ1(\ݻG$.~_Οe:hj7еs^D2{JI/pCu~! טkw/=L̿X㛁 CwR-:u?0< %uƒ"]449Qԋ#~;hgg ;дn/Ȇ\UWw{gDjMv5\%~5>sF??ڸkDL tL??׸Lf?rq>ۀ^0;PwַfM*$.hD-MZ3w %RUx(\z*:n#1w%@[89X$SjlEsvO˜P&?nYɺ_00GY3`HjX7rdJji@ࡷe`\8A>\?0$( 5kJ?r(ي'}&QFIXLչw//),,oRo>????Q Pd) t&ɭ$6b&i-&§=`w=Iㄖ8 d?0;e&?0ĩw/M4TiMȜ֕Scq:-G.;vj(7G [5p,JP0ڋܙ1;k;v=m;C֑jF..[CR-C甋ϖ`f+{va-ܤ֠l 7lfmc+UWCM<^Α#[OBG1hjef&u4~4L;10.9Ҟ)1Aށ>?0ל5:&c/g៞!>173Y>e2mYw?n}{Ư2' 87+K?rAo_,RAFaye[3Λ7d^fYBE߽#PQbCnMb<v+bzJ~FԶVs`v&wn<>< kp,/QɌ9#p??¾TӒ)BY#BڻWQ.i.QW6U>Or^B.kBo.^MVd$>ֿd?rU|=u(I'zMG!S(E/1tM!u< GkA*+%_?0[˿˽cTO4O<[%n!y=i|'RQYyS|jVzqhlMK(dgd^Db˒.8}\2Td(誝bˤ>P&KO]U_>z$Oc^~I??|9|~(s>|6.S+s2eڊXbNeh=p&P,8&hO( ȸQRq<>Uv|Jo:ޚe mcjF`{媌gsCʄ휋'ʴgP͗m 73%foe:nGeEֳ}o5ϻ2q7:;l5]on{lwk/c.ʾGy?nmwqss 7lVb̎ ]S?riL S/gow$ؤT9ҧJ9YTb>b%mKMZ&\;)MgE\:N finfχO:mwκ3?n}@A#`לRjŕQprO??v|png'喝eAekq o3\Ě6xa)gR%.ljI_6E{M6pR?0:[cۧwed**inJ%$P,Wǽw)gofBE7>.:(?n^{?n1)2->ajiaLಧ"ssrj+S1[r*{'>78<æO4O(y^ޞ7D0t;UhVB CH;P?nBVW*h{Om>c%ş=k&g6h[5bp~??UgJ9 ͕`z'OJ2:pc>6e[*??TA>WfNlnyA_Ke7_5=cKv7w vTUڃBװ.jn8fno64+7~> A5ƈ| w>QU!DY8Tt=0f=ekKP;mk0R .uyS^JOyiAV"fSkŽpf,!R3yt<7hWT1=;~Bx{ Rd,A\ijU8 PӁX9NϘiryE¬ttF7k<<"GZ zEߔbNO,h~ ۉo?r+ĜBrAĦiJj?r-2bo[#??d6U2^mnt5i>jC0fV߮{oOʆU>eebC5$7#Y:6qնcj $PuZ璯 =о[ ү(]P&OX{7*ԾZKH{CvcDKhIJߚ.A$\X?rZ6N@5Z%jO?nRkD׺ߖЗ2+9>6#[ʒ }PY7A3ÞvvXJs6MشaSBJe?ra<=bl C@6;B}t7_j@|uh=RF+V܅; ނ,&)_a wziε/~c "ݩɜ~*_K\(,;dЧ^Ė+ER[>渽ib;g]R=2L¯z-R)tS𶶿u޾NR^fkc|u?rZ^{Pv?r/0f8:p٥w[T&cc9 Pa1Pu*||lN?0/;|/+cْ|ဝ{0+iC!ﵵ|9_gE!x??|p#*}xkzyL'ɬӖaZs*-c#HF &SKc?nV#J3Hf=b%3+oJgqf?rS'B֧F{1Zoiq~}XlmA$sdb:籬7-bjt5y!?0;p??8LOĀ??PXd|H ԙHPlja|cYOv.M$:[e#-l[XK8bvNBڸ@G]ShȮ Ay{;#_f*^)E(]tHV;Q'sJ]c[?0ʟɔeLeDY—!y)> f}?r1}^fL?nUN+:ޮ=AZi<*1،%8vuK{:Kmpx=kDqɽ\-dVT_-pxz'YIrq֤ n|5x:_??^ k??!=)h*N7}+ػ2.b=%8i?n5˲MȆ2S7bZfI?0XJdR͆5A[??FB2~ ?nnӱ?r܃x_JՆȢiZӄ=B$J +#a|IgyIfmQem3(-M2휫؛v 2qqlQUT]nΩxV!fyHg?? {e/b↙ z%6%`UcwmںB3e(&B/T)eVx'9$5i_8wu*cP`@.omL`N(qI4^Cghgq7 P YR6U_>4imvK0;4)*쟖?n*1T^6fc\`Ǵ~+L#ݗLb6C)?nfq?r*gQcXj4 ۪R,^Rʾ1ؐלUg9{xĻ5zyČWOhh:yȳqhOMk$ˆT0 {J![Yp)9Ie??sH{RJJ%-ƄAJ?r1N\9ltf~tCy Zz˰R0v50`f%jPVC@203 I9}^ ?? ÖYYi9?rm 4A;2H|' ljjj"=ֈ=%''X9p90 Dw\xVr_'=Bf6F]#ȉ~!&?n&(:mȂ)#eG , o&Rqc:x´㮣?r=!iݭBS?0=À1$!O:{EHvWWA&^gGexlz0 e/S5Z??~׮{vB^]f.ˢ-9i]>к1⵫mvh6N%д.~$?n8v\p[^+M{oT/ؚ,%[ŻV>iL5K}hsUu7lgtvBQ?nhDIdf5?nbR%C1EgtW´KpB%qGwFC)~2fs@$IoI1t5F6 E;q:P$as7e2?rC߸@Dʯ|^"gcKȎk}uyzzz)E?0517$ѡkc޲1m:V,2?r[3Y,mBHOB47QwrT642&%ZI??s6ۀJo?rCԁ% 2%F:zFdSgepZ'yܚ[?r[=A{Χ @1p<prȷOg@3]$?0i(-ܥWޮb)IT|%V@NTp8)wkŸpx>??A 1/AO^:I(j"%Œxd_wk\E@ ,pwY@k?0TFCQ gTab.f%ڛy?nIȦXn P/Dz$>GII(UbރU4big }HC!94Z˟V9c"%Cy5L aͦ-4#!~,I 1:0?r@WK8}I5R'3Sd}??qLv.GpYrK[ge}ʯ8/dN6(`3_SuldIuKamڧ3?04&+?0ѝ9ȺN;i KjO"/=0ۂăꍌ1`?n.y2JYߑDEP\wPcti#wZ>?0BB^6(6'?r}%]!jKc-Y,@ck_G]b:ZP'i@#qb558(5UKicۖsCKˆ { Ԥ9}5eS'nzjJZlA??U?r\wB3?0?0"&$@ř69]l.nrO OSR\?n5L1B|X9E*ZJ;ߩOIєA(xh|f韖堔h[dMyV%'-|T?r0]g['e^@)gFcՄJOԊQ⾹\L:<6Q*W֋wirSTR2n?0Zh-<{ f lF22(&?0lwЖԧD3232` -WѐUM1d̼\-5G6):tV֊|V?r~RtKm$dX@5֮Xn92'?0@ u3kR l;0n0ڒ! ' .WʚJ?nÐ9bʔ` :L.emK8Lc0Ohch??h" f't9:UMnugz|/X>`ض[0b4 9Bh*;^1Z{!T9qոܧ욟!Nf7\QÔv˝?0BO6A2Jn,R~U5' :REq9韰'$\>j?nXsus8Ǘ)g T|HbSI<-ZS@﹜A??oMOdt:fve;;o$3ӳ{73 = F??j_0w,xPP ??$?n~Ȅ?rZz%eynָK`p+nj LaBW?n ~ܲ#o7˾:nGtb׸mb%OowH6*baԪiq+w(nP/N]+g\L|G4&?0b#{.t˼5l8?0H?0iÏ}.Zet"#nKP@[ȜEiFZ0+a?rO#U܃ņVF !]#ؠ?rlD:;D+J&__q;𨯻w>yWoUGHWN?nf<;aq:&֛ÈpsRՉD xnNE@X"H̵:dX8<_1 x:Tͱ")7$xݔ*^dv94'(KK??BIưE|V .;XBs! }~?0pn'JeAj1  a8A!K "bN;b0zb̢YehhڍZ̬8?nF˾qm*t~?rwNrj5 {P`3I?n}Xp?0CGS\i)#-㉴4. $ ZՋTjBLqxWz˸qWJ?rwh1=oaY3݋roo,cn)Ow3f+&C :uw%%7SAwm>bNDž_YhyI M9tԙN?n,a{fKV[yOsFJB7#C6??>']11.DD4# E:+[3?ruƢc5n&_bv7<=/\Nroy4R+hv%pϮ0?n"-z-)spSs-ZL, ۬P~~enX4DR)7q;RM4˿E)|qσyR;񱛞,aPF-$=-<aSA{Yn??!H(ڠlt?0HVS['(54Uw0P[]p?0pC_xkJ?r"AirE1˝nϵrk]J'8娣*4$|HgTyk'*Ʌf}Pazbyzy1P86G4©1x 68]?rl?0^@Brei0Yļi=zӮ}M)q*`oe6rd-̂o23Yb+C':NyƱUNR>+eCSq sD1Ug`RnE?nJ@JX_DV,~z%S06f#po2b?n~JɉzOpQTǃb]+9TXJjvU*.Y6G2P*""xL??6kF5zP)gDk?n?00}3#GJ?r}#ϣM)ߧ؁?n@,(;|pF0a\d#Ⳕ|\?0shɈ|pl+(f-Cúm-Ч9 L)%sSX/eK}r(v9@w'sBkq})\v9% 3{sv]L??\V),(vSYZ׿79Ns|ЄGoEx!ivJn.P瓏3b^g<>Kߍ};#CXL}ڲvp/C2QOe{0WZ@T?n.@%/ #2 ????οpu=hC awo6kBfFðOE4|n'l?01K_aR04ʥ,ew?0~J?nSs?0͸{(P@˶IEc%K2y[ԍxs+UіDT FLW}wI);z; xøEn ߆??i?rpޚ䔞ڭ!z*㇫iFԦ||ЖPY j#BISM|J|XR?0C8`8PJ۸)lS-H:K>CBLY?0߬Yx校H=Mq\$!k.޲>OR7ٸMހ@ߝ52sf;bl{j%E@p~ ` pkXd[[wrAb,ʾ?0E$7GJ:.T>GҍO$5#r^!`lr??!#>> +#fX?rVE?0ͰQZ![5 ]sA`6-%XƃVw`9\jmeԏ\?0+;_x#1Xv`J?r()#(c"ȦRY7=<{o/Ooztѻ?rC5 m=HAqp3[JO?n6zWd?r6ryW(809s?rOP a,@S{~~b;jU {%]g|㛥zߟ}?neGo.ɑ7R({%TX+`pT4m058{Vhz.{nm-z^:##LdoO]\d8d+*!^Px afK]iӌҐčTU(HN.Hm tzFE_NOY#iΔѥEbۉkūWj:Ҵkkަu9^]?0%n$8rPlRp GSlxc`&U2icP [V?0]??m Q&JXUroh5Mm Mn$R.L@a jMֿwE2$,2@B::2I')}y!PjB:`}47+cLU4kd;#H[eà./0贿'6HT(nZąڲ88:eBWhcr"*pn[oՏ({w+m μe5-f+7l٪EI1LX{YΨԧA6e.`60Z_"}:'%(M!a>Q ?0x su_νv0$-w*tV<zl=:hנ`(h[&#y%즤s=VYީOp(l?nӭ[^u%!sK H?0ThY f~vS+%'ߤ{NaC,d_Lc'7П_(|&UFc+CFvUZ.=4`b-T>VroyhjG ?nR5W0F ]3e ̙^m- /f vƞBú$Ր{!8d@fxZzCٞY?03TOOy<>^/8Fd??E׀;tDяx񵷋L@Jb ! 㪔 2jtSc+9xkά\:1*E?0r}Gmʉ Y)2@ˏ.=ǥis9sa ?n_JF)}&bRY~#|N9G`vOp|녃c{z_z?r?0?rMBu@ݙwsae_PռB ˼sP>1O.ziryַ7 7WS.gwW??cOG?nZ;\vͿQW- ~OOcG=z.9ti?0n8B֯:؇R-dn=$}/AGgǣCH3`(=frLV_-.[?r.񮟖V^#4z{̜?rOtlFk\Yns+̵n07tus)-w8͎ >v'Gz?n1F9:uq5gz]\xfctu]gƲ'25n,BC dJEy9m*T<6gPCS h-dO?0nM>w(qV;[Pg|9e?00SGV-QAsG^~C{Oyrn+Ce-[`^bf(gvv?r)wѴU쯝h8)P%:=2NJPF+aRטc??'HqMQ:Q_1P%eӘ$@ ݀09~v ㎀D:ћUp[pG;\NL 'awjB8CPjwzd\Z[7zDܷ`k13kڰ6??7ɖ_e'RiڔV@ƚw): :up0GFlӮfKxZO63dSKL QPIx?0dԀ)n)Ǧͯ?0ف>kEIXYKx;g6|KS$3kϠ?r~]6?nM˨'ޒ;}AXq'V'1SӶcw Ƿ`ڔ?0C>Ih<ڧ1] yd ayzq;4g+ns{?nfRW p̢iCt4X ?n4_a9ӰR4"Nq]8 3v)T݃S,v)hUIskmInD6ӹ0tҟ-x- %Dݼ 2>BêEcpdl (o%͵?0O(s>{G]V^8ZL[ڐAt V $K'+ftMQlg.O_/_17D_Y_ s_F{0<]ӎ/)u$3;:jJ՗hwѠy5ݣ՟òϕK3 0V=O_^SǪY^z-F7].,P-TY[Fl εZ?nBsSP1ZWKvWګ#??"r C?0?0cX+W# ʃ6Ѓ|t>nQbV$ZW܋=l8*ʀTD( Gn9mk'QB?0DGU_cnjktロVYu@f73R-dXf;DWɅ~ g9\K#L?rF>-%i2udks.*04!qև/epPA?04.$>t )e~#%И b$NPM+45VӤb!)j7tv%Eh/M+ۭ.t$2Cm -)$bs:^/:=-fbdZ"JRz~R$\ d\jbj ZJu)J~sѹ' CjafWPC*$,6"3CfF;R+iZ0YHGOщc1cY t~!iXvq2bKc8]Q?nI6Yn9~c%[l;c%ٟ, I T͵ىx?0J?n!ze(6nlqs l/2mEYNh /E5q`AZԋ2.X5`.#Ifim)u6:V'lC6秙.Af'eI_l){$]%'1Ci?rQw|NXuMP\$?0 Jշ`iP f?0Zhz$SH7]ү9,gf?nvZ}妦3^WȈ+/U7gkSed_m"@3Ћ嚧6&~Q#j(,(Iv2eEGL?n5[ԍ S%4 UOȡ??ӊGP22\hQ'h,ћ3GF BXYV$ͮW*#8g{:b4:Y%g[y8&tbOMȴMWC>^|3J>z5*7Z,h Y/z_Tsz_y{ODpT25u_U.11k͘'=W^eIgsHJ;^hm98)0 =V^"(gxZNϱ>KgJbUa6b]:~O^tFLb|]]5[ =?rl6b4z%likBE[ȶl[at _7_ݤ{`?nwGh\֩>NRK/cV@3 7݅{F'WkKQH-_4'@Sfe9xm!E{r=d+ۤ`+B}v)iu>SnBGG/c_D>Yd!eǀ;yȤXm#ٞP-ʆ$eH[@8;1#'*-:u<{_aw6r 0?r]ps\>`#ɟni[IGkʺVg?r׺J֊zAO[-|pA!KqHǜ=%T(j;/^D0}^}`9R gzTaB-audp]<&.h)MtwNF"jGkf LOeAZ۠@0'u?nW7 r 3JFILiT:}ܝ?n??F%Ѧ-@BW,mZU~'4Ma5Q tu6poI2K?n#bRg_vq32ruZo{_`G8]uۋhWo"2}i/ӂC[=V""JūOь0I" !"?r2nS +9)A\NS@$Ki'sځ}DOȲىl>?rU =qKjC3ti)=)fu[_jcIayl$?0\XS#&o^C<ۯQ]WSL5*s6j?n,-T;ۇ$BpQ$bQ֭?n(ˣGkc{Ɔ@?0L7pyń;ٚ`BeŸ(^('gQ \0JIc@ ϟ)ʊO6viWºpٸ;m .8oԥL:Ԍvu5V9?0<]1s0Ⱥ}Crl(isȐjڟ˝qvZ6d=Y61|d EcS.ɰښ=Yȝ5z\^ NNҼA(Xd0 ?0weLZcR +# $ER>tO吵 -'cNjdtF=V_0=6ME;X{???rcloK~j?0\W*5!-jNyֽcFL~+?r<TWa]#ʈ6_ц29 T S3:lMe҅COT)A@)!p.gm/R#bHU'M&+&,WR|&2ċk*J^ȹEڀ(2)&s@#"L8QDZ ˧ #.$v@%]p{wl .j7?rgҞ$ 2X}@IF?niZWtE#i&T㦢b[j:ygASi3dX٩,Ay j>\VH{:eσBah9XRg,HP^H8{"r?0{^WnB<M NHé|C+ECSk^coUfET^N~!8gPH3 ډ-;B{ .y҈P{ c+ ܈ҿ&XkGpv&>Wdf3 +yGá皩7k@ps>Cן_&.9O3:}^P-1q Clzj둻bTfwfH7eA6K;- V`swEX~p2rɯ!o3q3$<h)Ow]Dl7BV@H^_>E=O`iBewh4G 6 >CO[)K2",&3{&  Ȩ(#:pje`"ty@$ L(????tKs+!6s@BJv|OdLdjm,;ddu+)EC$iPHWG6*MK`*8o&Qw0+a} Z]P=:?rl~3ٕ)QWCU ]aGڪ/-/&pQG'ݩgm_Ê~g^dF1`/??D n6;bۦtƺ+S)u)ƺ=5|(@|@fYS\3`Bn:,a׾Yhe)Lt9G#BdӝKduBReK~ ?nO8l=wRy;pugL骢3"#ER5Vd-u&IAarA %Am%?02Z6}! ڌ15ay9DB{=?r|ዄO??,Ao]YsrX"trJZC#r}D-X|O>%dڶ[qbȠC#CMU$G??2)l&oN?0v=z{opӟC3StkղT>bA#٪!r =1Z'M?rB!E$!5)립AK9EV`}徳=;Ow*pYʞ %(h[鶼K?0MtY72VjFU[ӿ2fϢ?n?r8WcG?n'di^U~qiKO<*2Eu1D\2;g=L//u|A_N/3;%WܺY=e=4|Zlak%͐]Tn(M'M+{ Sh1Axȗh'3s3\<LaA?r셧787,4H,X{J+!AZf{=NnA|~xr?nO֕pɠ]*$oRd6 bAn[Hgi~n&KmpX(Lj2,Pf@Q)m \??кt2)8ÎAvh(HZJ$CTQ0(*,^?n]>_ ?0 ]5YYBjY[?0M8F⸆S%7ݭ??Z4?row@V?ny#&_z}D5qz|,Nž 5+ˇWݢw%UF]|~ix1~;O1[Fy?nzlSX.w/NNTE _'K:x;K1 .Q8ͼӹ:@Gz,&<.$By)`?rq.t["Iz>ÄF*F?0Bʤ/a`WDo 3;(Ji GT*4[W[3)J~M5V]-2e}ϏA@EQI(fM(F?n=zZND55G֑>"(_ڣ?nBYO jmKJ}"qNlBmJfp4g1g%M <u;?0ЋIh^a"?0AD.d)ؘsbEDvpz5U!A#1J\3&eq/%^[QG9b {ak'ͽ^{zךP ?rN8ΡZ#3gd;qCz1uHoS@R%Y3Eef!M[D=@,(  ,a 2aah$ԷKry~,:>CEGFjj)x{yOp @SSQ_q<\c[Bh8PavZS#gydrn|0Ny8q{NȥSGKg_bs C?nRA D??CKf oC-%q XO1Hu@0ڕgnZֳ9$W|\<Š]3 qRDvLlFB~HZ}xO?r6~m;$!P)mOY`z?rnh135WQ~gR;A`zzC|u?0nEX'ҾɔUng׺"?nɥἤ ??ؑbd:g4|^u&-BQ"&?0qW?n*2@ֆYRM32=HD h|sl\U$c"+zo'{&yPv~N/12oˎrm `%n3W16"M¹y??b>HY2=ݮL`)cΖ͉4N|"<61\Q#E[$NY1GN1"qAplMgVq_d`gA,۱>VnI;K]nqM7ezC.L 8.xRhF>A MYȬ~?0}E..y^~@t?rL]_#Y)@.<5JʇMIh3?0=(S>I{[!D_7꫃YCm?rD@mœW??ڎ}h6MJR: O/%^тV+W &XX].P0QgG2;& ]B??FP)RM@0*{"sVV'J9Vx'@|~_ LW#5LYFM!ES9ŁK"*O˫(_Ѧsb ӤUwÆcVá%ڤy8Gh:1z??XBc´P[Bl#V8+r_T}8PzWjFAfaJv;ovҕp??|:olF:}Mj˛u,'_發+9%´q}yegr&,0r0"c ⶥwlM>mh?nKOůHՈmXw7]ftdqAz,I?nƋ4bUΛcutʈAbmLrh K.O]yR4E]u^{X7zU'VvGsszf*Q6fq.so#ZeyO$aU2A6u/>ZnmONe>3a6-͵Ne.W+{4Gj!Q!6k>U輵Х^6Ț~~0ی3,A+[a=z;UB~ev.o|ousBO?nGc,Gw'շkkd[o9~ FeILذj/f)/ynYzgKcRyWL&sO["e`h2 D?0l*dgϖ,=v=!@wznF%3w]LdMF*߻QB"SsOKZ9lF#C ي˄;=Q-B 5v}UEGMn׍x?r;@i j&բU~ S)8z(æ]xkӲ3]EӼbe7iux`wu}f4{U.Ny#Qo+ϯG%udɨD7I`[˧|`ߵ=_ORRYtYR\s͔ޛhjƪ&`?0!+Irp ʢđa!M~?0e"7-ws&OwiDҸH;ݲx˭B&Xmi?n龜X;[=cp:H1m|vEl<=M,drmAVNCE8ԇ;r4x"J>(h2xex9n?0 Md?0SL{o8 |L)S3M{Nm[N/.Agݤ ae`~VUٕqsqDg֭u[e);thY2M?ng]Sr0- 83ղ2I+^Dׁ] -k8k+`wZ@A4Dyѽz㵊۠iuM)!K&"%re??f&#,wcBYMҎީ3+V$0pnt;vdQh(=ی*t(j#\ DHUMkQBt]GʉM?nLUL7(cplH .JF&8PVe Kf@wAsmstInD?n9)SQ*w -/]/zM\y\gF|k<ƗbavVXr_%]Ryg7_.̓tꌫ3U3e?n7@Nb6r$tpl%&S_H٥|}jQIPw]vB/+ L>Y뇥g#ke@E??4a:or?09>??ڱ%. riͰ;H[Oۗ?rbQnr#}?r.ӎ"DqXvjZ6gvbJ|P_[L' a:mGVKSzAݸbtjGZ'?n/0#j8-oxvB@C,hn;2ޘE??֏x:kDD%\^e]R>NI,?n{cȬݵ)4X O{?n4#`)^ˍMV܁KeU=^OSsGpֹ'g%k3nZ߷cnW|}ƓxykdUfpİӡvQrK,#W^/]`9YBf~ѲPjH3W!Pe[JszBb-Wc{Q!O"K;)IR\&(0:l67,[=y\KPTzUCdoNnSa a{$aq`\oG+EiVWUwQ*U4ѫrxg G ϽiA3L#Da^QGt??P6/ľi|7cJlNp'Dy ugH-RY`+8_AGr7uǮ3Q:7sx(>`\$]}٥?r⽲ι"1euU9wx=3Z)m`0' 쁯N05/M܈+Ry3$ ھ@$5{ ZM=?0{>J7AOMX$$ qU/Ք!JmQԣǴ@/l/srvvn D!?r@M_GQU/ ??lsOЅQ&*HK//͢;- '_&6^~ g5݉ŧ,w??G0u:O?0o$gvNF mUӹfW@Roa2bP?ni3HMY8#`d! x]dk=?rs;7Y&\ LȬK[[a9GRv4g~^=ӕ(= .,;]6]??̒Aso߻B^tyQׄZRmK;OjvBm,\*{ÍOV7?0vKaJ%wUcG'k:.0rW??E)znnLc#Ȋ[fkAWMܶ@=\w oUIk&s<M-q/X??njn/߷i]SǽprL 0t&[ԅ"L2g(*1G $hQlpro??/QH-A92\DCSL1 Ht\h<72Lһ/FCSDUl;o2v#)z\@0ׯ֎Krʝ*'l//i&ڎ'_-im{MXN3')ɞQsTyнuعGp[d7rI+ę͉O0NǙW('ꚥ|7B^>;] MŘ"WvUҥn߀$ċ 7rvX܂+ :zA t u|ro=Ԁ >??K?0F񓁌M/FxH7S-]%JPt1EOlk7S5.vLC6?nd +gg-zON HL~Oe|dHjj0hFt):Jv|7litdԒf^i-L>ɳoSsu{?r4%ȉ[.iZwbVnrF\{J:;h6 LY_ /tIVhٝЭAFUQ0HqscBFhV[CV0{v2'knhg_?r:*j,glp"B0€/5L_F.=aV"-|1+вIrTܱYBn?ryW.]j!B)^'vhjW%%UGq;FHf#?rߥtSΆ;at?r)GQvL?rA^U&|\ >Ҋy7)6~&.]&WB??"DhTKv6H{ꌄqFLO9k$"(ך"EHrY"  >=WAGZT t +#U=(tVjFf;u0lfҎ[a)q39?0,~m۔)9?01?n $1e?0p $/_\̺?rNsԋȑ+fG[)O98&b?0eZVNr2| 'EC"?n'遳iZ$Ѡ$I$^6Q9Xۢ5|L}se=8jS`𾘾p6'{581gFAh0] 2\")իH??鉶eq!MB'x<T#N%03<9_ɥxL Lr_Ǽ->!*b>hkUuUy< 5Vӎ.ƙU==Ҍ?? f 2+uWЂZ2dgFe.!< ذ vWz+M1J,W9<AW('T%FXyX>yy8|CQY!Ӌ??`Ӆ!idPZҦ=M)T!7fBc:f줈p)=euF[ְ?n#?0Z*.~Gu~;|kg">/ g#w4!}pBSgH*_`!ʦP <ӔN-[ѢY / >ŧI{mP??^(kI1?0tC=ɴL_BQ??H[7)Ln%QP^4^ 8e/QZgaF2_ֽOU14#hGm%/ǭJSݡx`LT9CG2?r?rЦP^"=[RlD=3:%ێ)QS??Mi(;J4KN 䨋A5@<.}ʁ,Mx_k 67=??յN?0G|?0kE}P>dXƺTkF tЊ8CVdsb4B:S;:q??A0_ނ, ;ׂva''̝[?0amdLcot #7*/0/,m!;'G5' f}zHRGS)*wYV]ko1a̺g6>:;R901tܡS g*gr@lMCMF̙"Yz+v"(??옡V-! aQ1Yܡ6v-Њ~ڊ(X2u(xwo|DYxz}dƹyuoǎ|WaŠ#qNe 5E3E|CRc땑D>fu>0fQUFޛ2[ o#fd#6ڮ^|)VKß$F%P!I'@Es?0#^y aaz:#$)0*,TIU#XfУ^4.k{u9UL,jߏ$z?r ?rbqdأ)yK#{APnKh΀B_3f,dtL+ƿ<}^}8HUO$X)l4 GYutO߮)a0ś+愘vW \y:?r;$1lX%,t}6B$k-S[F`{P&v-#jU,lllj]N?0n2\6ܨ ְxit|+?n0wВ7mJ|[>KJL[.Mv)<_hN]_z8tPȣF*J|q@Rۦ!o?08G@^(]qw|_[-Ȼ?0?nehr;*a$Q:嶛L=Yn[ԷV3OU.1\$.iVMyYȸl4Jvh5LHΏe?n6@>}oc_{nK,fW\%AÙ9?0Gb>~1ݨ1ݼMU=Q?n OY d.iH&+(}sCuVZus}?0I^??J48Mr|y UTQȞ#]??}Vkv7z9j)Qgc}E)fW=fzoV{??FF҉4`wF},m[,{x=Ğ_"&#f˦T0=U0iy4҂_ ԠTTCh<' b p'7k*kR-2(ITitSPɁu\F+::ùcX¾qQ@Y/(ږYpQwyI5hQ`Z.ߋUuvX ~$ޥ:ohnO갳_J7U7^B~&')S??>i:OIca $?n/V'>+,zcqgp?r]̮גt_ZM-+W${a `a%g`lê5,baKJhm[ȈzYJ#\D1"Q֗/̉nJ2| +#¹r"@?nz:DȄZ'(,^ߟ+$Bbv[`͵-> fΈm^=nyN U;?r5{>PZ`a]~j??Oʀ͞>2l#i<| nּ͢IoɋA٧-?rͅ}URw^,|2,tڃ%kGљ{rSzJҟ ~}RQƹ_0!rA3?n&}d?0eiK_$aej!nL90b<mp(` ۱J??{?nB yt$H>)+~I^\z{ErkS-|UTk¿Nd&?rVfH_WU\k_2,HuF0:ItV7߉<{+Gw}L.Ad' gr^qXr&a ~(?nl`il_f&c``de`gWgV>O??fR2udtaX<~JhJCzы`s?0iB,??=;;Nt(YM,5sknV,9#++++;vg׼`t>zYk案S%V‡ygCM]+A)M@+lXUMAAi9P*1/x{؈rK0{q"mB?r`X38rS3b?rEͲ@-(PH??_灻uWfBBX5N9f+USFMc꽌AJ[ޔ4?r&&kݐ=bUMfC*v`!u/' r`I%r?r??.o2&xeeLueP1>𭮮3Iٺx]Lrz=tX-/wNi 9e&`@KA_f?rm 藑%sv__8*P۝ѝ̿F8":Yr{t96??ǙWK^QZ^!xJINg?0BrKu껋 '}*E\]'#/&Plg??w)yW>s E06RNp$A<ʝk+':??7?0:DJQ]Vy{݉|]sA#1vl H}@x`x=N/EIJ·96RJs*B)*49"o'֊C5c?n8-7*$E5B9hw& DJuCsCne9v"jB3B(L-k./ZHmsWX/0vj7=Pyݝχ9x5'5–;\]C^Y6\u&R31f?0DC378?0{e^NBP9/vBD+*:Q|G0cc%œ%gN 4n)9%ֲFR,$%ףH춿vl4XΎL5&C$I8)ULdBzej(WZ6e=2jP?nX?0oTKyWOⷦγl?n7stÆɴ@n9Zf[ {j'iEa kk6/›*pJ#D1drigMP/AWINiW ҮDҼ[h֣GJp?rlʽ*ٽt9e_y4]ANhu@#g]L!0oMQʑB-Ho)5vթI?n$D%Yfr #"RLҹ^C/??*+Sxw:4!6 $0oejpi??cV, ~?rh{DӨ9h>\R&.+.K5NVݱQ??eOT4r5ǾRYDžYy"{}b޶ikz]L͘λΚq%`,U_a_(iȦ5]c??2:rndNݼeE*rLX/ aBD$u?rpʿe4 M*gM^(m#9a#7k{G6aHfiLԹca)uRDcٵ!^FUGzp v[ԗW8I^d2ōO[ Y N<nﱹȠygH%b*Iq(#+N~R,-R["oi\FO8ou(v/GS,$Ԟ'e 18ѐq%i?n;.%lgyP݈xa,c4d)%@KNЫtt83kja?0ɾ)\WX<q]?0R^{O-6^?nKu\{Md>>>nQ|TPM)/I{\%%%?nnpg)-nܪn_y_!n"Aҋ#`s+Ye[ ٍK+(r!a0BRJtΫOF TyTh1 ɂNR'Dw$y_u)-??+jުp`4קDO0qOuY?r ZR4˯ǝ<֭Fpokab6Lӹm>ʯɑ7€C=\`Z85.3^ 4)Vӯ :t\2zB qӴ>#ګ^:h=l9$kv lq{}f@0~8(q)2r,.+n;DoQhbIZlՌUed]5Ym8VpU9QP9"'ퟶ_ĵXk`(s2x7}z8g]Uy?ra tz}UpzQmp.V !|Ԡ,np@BZ??Htk9(2=m}įTPYv&#t3iq'4Vjꄖ.Qx1d;mw,d_]=m1oyp&aa: _fYY2I+cpjVrޮ.!ps1j\O,ȍnKϤ|C>-ygOV$vWT^#ȴ?0i%l E5ɳ95rd:6zݠx߾V4Z׻،?nc w ӊ6 V,iؓ&% 8uN329P\`rY;jd?0Z Q[{-)r vHO{8??1SaP ̎JSU52_2kN4%QHia~:*e+Nw\dn^e:-{bJ[7ăLX?0u6KB+WdcjKeޭ?rt#7%9c?nZ4Y7kG`X Vϭ0dS?0RRŸr6PYdRgʡL0-L¿ &?nXeEn2t`Iqi@5z:É0h6ζwQSrmھeW blZ׃)W/Ub*"t*u 8LGR>V9vYZ3>??6%r2sƀ!w"E[ޖRgG܏uX~HzeXݏ:tɚu+Z( +# cJ>8٨%]~Q+QKƘe?nJOnm4??NZ[ Xۊ\S2V:ۀ-EEd?n_$'/لN㤍^?rZF}A"v8vVy:iJ[9Dk "iH #XgIء1z~:y2srW?n -iONb6ЀGl@̧Ld?rLلA_0>:ܶ^ek[s XBgB75̛CbnȫVqX],~Ʊ#J' m!v+RTFM?n` V$~a8l=< 3UiɩExV%"L??ZpaZmN:% aV8*%,2mFldCQ]uQMIڷ&(୘8 0MM?n-gy% ؂@`Xtܠz۔Ue6VeglTa0?nzQĦ/?0c~֮K1unrMy_bU?ra_Mq׬`I?0]0=xW'!wn?n ձ7V߿;ij}Q8Zf~4͋@1_t轢-A`4ÂAuSȼb(n?0͋O [im|?r5f?rfs Nj9ރ?n0( p3Rz3O8|Z "?nUc(ͤ ۝}DHذ2}٬?0xwWPOqo ϔsd[ڣ޷tjG{܃1 DVmDև-i#~fRn1.uAlti]ZJ%MYn@ v'f!,mx\[@ :KN z?08&Ǎ+4zJ%eJM+=˗|rFDقJhN+>{?nk]A (*{QR_hXHPHCE}X<>k$:#/AҰ(ntdFjeU_8o*?r8ۭ2sVmAA$)|guEHߴ)(M8:SfDBMSh}}=Ą:2&W]Vz݁ Ԕ!{˟`>x!9݄r.Sh~F*C oA^0b,\і0,:D lAXPV{JWBBzT_N:1dR'Ҹ(2F9n; nK ?rS9veebUfL!7FDHS er=#(7EgRN.O^;,7åZNݰmGB?rSsr(??g`[K:Z% ŭ7pA$??E??ZN0”5uGw}oT.f u.A4 s=Y.(wih•;0g=t^r)?r .zFS縚a:MbZzBF'3۸|s°!;?r؁spe!`ڽ%b?n P7ܺս'Sp'~쏞{fq??ce0(1'?n4=QQ#Dd:r*$2s}% a<ZrOJDqT,dڲ;0j!P`O]Ѝ7 e??=HE9-ܿkc@TWOuJ.Gb"]l&E{CVH≯RK]XJB5LړcYskp'm0&D>u?05TT\O |+ ?0?0x4P-?0ƶm۶m۶m۶m۶yomjNvVέ B@̴RmM]g@l9Y.hq<1S??EdO UlZgD Z'mx↲/wm@toQ/7ZPr'c0m8`DZ:Clpk$6oH-1v`厺όf4 ]ЪG(B:Cd7IctDĉ8= 6ʍДzYo>\@vD6c9-dD'ÿy(#c~l,B́QPe*1??&Qge)r~4#/iA!ӋݱD4&!6ǞdowJ~?rW*h!wfϡ-ŹuYyO>8?n=q?0Ѭw\hxDl8YN{`V˾U8N뿦Pr ;aD: 8?n?r`]dtЊX3bYUH~l??mUlp=R^˱̈??N}x2%?r2:5$f[ߣ8n+?r8\DfzǦGl}uEdޫUk/;FQ!KOG~p#,UoNW|V9"9}:^Φj.^?rk";"/q9nag옮q{:7zl2\#??"z+4VUv]5"WpOid;]?0H3N8.SQ  Db[)|D"lι\}.9sc&TطIB]D>G+ޥ8U}@??ɾPf k*E֔1l4-%N.٬w??i+??Ȧ`H@&?0=`^h??u֯]Bs7uHjeiq?ri4DꀅcxiWDA183ͦo\bW^ z<l9)VX -,Oc??4@GSH?nZ%?0?nTP6 J^v}K x<s?n?nţb#N>'?n$(8}ũ3РU+^<Z9A2m Ze RJZ9bM )f@}*05R0*|姑ivJz{BDm$+|5ٍ/]O+& Q=A^!ȿ#STϮihY@wZ^)=`\ɷS+;ttvӍS[)AiN(P%0byl.U::ZʷS8 9˷v/%}SĘ $Mc+ꚙ"E#unm`$@&Z[\ruCPEMa20GC YBNʗ9[s7Ա߇Z"d̹.x{Ư9 FI1g[E7Qc c?r]ŠoFT>!˭ 7̢)?nr?r&]A-??7v F7M2OK̖(37š^dP$?0jP:?rR?0 ^X% rb`ӻn6?n-23?n U'_f;6g-uia>)of?rv逥.ow ?0,Z w)uʬ?0re1J^;1|r7Jf^D[:ԣÝd!I%,kJ[\e{֟x_Zfv|o!# Qub??tZӵ3j?07bx JtVn,Fe qB6O ?nX^쟨uBGnQ x ' ۩|38J{7 \†x7q?r4@D| #Z?0$i%\ oc#lAfU??@HZĖ8~ewLMl |aJ܀ $ShbAfف. Sfቿ(a@+a$P6ť1hISl|X4rjTJ$gIN6(mzl{C]zl*ٍs?r|=@ckJMšd=bЦ"?0E?rM.&eTC3C#;bv)Z0;Wp{69$3nC0(MSr8+0AEZaǥ+vD,xars[V9x6?0Q$׭GF9G??)`蠬M`z㙌Q*1˱n!>\M(.(onY>A|*l5\!75AtKwWt3Bqysy:u_RQK!Ӷ@)[?0n p`B=;L70ȿ[-{UNJdhqud擑DRmQU%:yS4pu.j ._ZB'kw?0Ō\žUҬgC/6`:,Ik'"Q3yìOKQpeN\gGRJ%mvؒ?nz|'Ezy `?rJt0?rwu/xs5 {D(3,/*R央'Dį)I@|Y:i@?n>5α{{^"`L ^ֆ^.[\U3k_! ܈sůɴB͏'g+9%XK_9=Zgͅ+FKrZh$N`?r1ǝ?r1.ƽ]\RBKyvs?nl#@Ӑ5R?n L0CdSataYQ 2T(M9:ΛH恉2XCMɇcebӍ@O8/S\V۞H>9 II!^eWRTC?nXpbP {r`=L_[na0vڝ#|ol-mK˾'K^Jݢ/9ʗ9=ҟE2?nJи^|GzgJ|I$H觰KsIo֡ 둂d;'1^ z%A8?ra0_ƼCbtaJTw$|YwԖm$mNuZs?r+I[5ԴB)mؗ81TufUEJuF{ղej2(@O=s\GeJo@o)kjSμ?r1̔<>w]X%ep1_4WkjBʾ}??n}:ne&W 공¶9T&}Guy[6眧ni_vnPs7""BJXa$;GՉF?0PҼyEe ) 38?0DzJV??p㈟"#WOʤ*=Iv%qK#2!?rR,Fx* ;'W~ce@I~Q{jL|3ƴ' a|lkZkzΊr?? 𯃡\c}%ݨ2" eÝ"r+w >*t5|d#1:^jEjXGbo*>Z S#b\]цGid v\:0땚r7JSl0p!P2}H8_+B(vP7eݹkZe\sA8j%6?0My#8P=<t榯dמUeDY+D*N5 ?r}{:wmZ6^~V=s![i סwf()@Wc8XIF *[מݱFQ{oi_ q}?0 QkZIC9gW?0`[O ahᚕ\D +#$1xa:BkX-gsG1"s/ٸo\Ul3^+|Qu%yCW:V$n|?n݀N+3:aNf?rӟ??|>5.{%ФZ=VҴ?r!it;f̯RXv_RIhsLvŐW}[W|?n8?? &#rZɹWÎlr7*1Ъ.^<ъʩ4+eϼۧ/l ax4t.b| }P -԰;}O#M=2yAE@ł56+b98R8R6}oF@Vij47Z?n&<;fAc$"",Q2!1C[sWǎrk+3|=?02#O8VVdzOd[C0y0(%M;Eh`.ɍKk 9h|S7XnKBwp6¨ٗ o*I)fR#?? d319/F404cJߏ??l(е<Aj:c??[2MZmNߧ:g}Wp=m { zzY%nfknw`@;|hޑ0??5<.nT_Kij{־S('HoWI~*³4=7YyF~D@Lf=q?n#S]L<>.,Ɍes61.]=wX/B<61; 9p|4BZ\%S,u&1* ]6} "o '?nrzZ:>׎BOqҩOVMsGa%oX_' 1=fN5+;-!iL4ѳң&4XTwx@w_õ$dQr+8ϊRfFarЬ&}lW=9F|ˎ??g??nbq(?0&?0 <!"{- Jhh wRGlg5-Dcj$_F?n!@O*{ 1??qO)l ]LQ.?04#u!D_Bʕ a??v-khm=>i{P>+%M;rk1eVJʆ{`Щ^ũCpy?? P+1#nܐ?04V'9+4uw[QIEe8f5~3IC!s9R[R~kɈ F]{gghj2܈QDD(CE{||(',Q㿿A̬1EZJC3 t{!?ro5sPk'T??V7ظVcVY;0;mʊ7Vunq'duU$LfQ A+Q4`|Ck.)m:<2 Cki;?nO :[NYkYzaYkWXAYl̩v}H`V@hGdu\OfDȶX?n%ޏNri)錻?n|CB̅,?0ǢCR*R6Ao>L95 I")7Rj%VR̗Ux5θϛl4 ?nPstnLU &2f}$ei>WO,;܎?rco?n3?0{?r-9e^vҴ#j~j@ˊ?0q=~pRjb?03h,bT  oXLVZW_f4Iq9KR'"`ħJ)Y˟(?n`#LcdkŊn/@AcsQfNDzdL_@Hѕ4k/|«eSPޑHgu)+,IR$KE5ףiϛhG1 `ȲcW4! (K >%¦T`rErH)yFB V\lN81"WU?rg:[2]cH?n1|[2PacҔKhf[W@EзE=d/mة 8a`j}V]+l@!%e]t7KBsy; ߯??:;ЬJ:}bzjݍsv60ҋ$=&gy$`.vSd֟3dZEJSyN??bBkxTĚr?nbQ Cj(ո&"UؚbJ;j4Co3Ho ?0߭sQ6%WҐ'r;-_PPhK lR ܻ>Z\dX)Uͩ'nGc˪er(ͳqd>--:;:"Ly91\PShsFCdNo7&t$R;m92??vZ~:5a)7wauP{Qv8?0 KXXL<8x:xfynq??0T0:6EF~$QTv_ơ 3,ۦ1qUa"5݆[?r)T),/k&q@%szoaЊ8"u_\0m7{h$1["UTk2rqYֿ֗YlyְLcAuAIi2*CDIn܅i~"J= ]j*=h7BHX[ P|8P TI>'&2-T2P8Fm Leq2y03}- :(dCj0d*g(q*{ vQc1V%2 @ ^>A%w$l?r?01B5tKqȈX#ֹҳ]r;}h~GwK"G}.}RF;cUn7+~OeYp`B&V9wӐk};{h}N7Ili:_Bg3wz߾(9% 3]Rekoc:ŋ p,r_!R?0OzfLǨX?rAG!y`wSbM47YX[causrr{z9Ct{i?rL[ izaܮNRQ@lXIJ=$Pa%T3nSˠ pL]fɭS8b֮7Hg}qR??(Ϊ?ryڒ]Hw{ɤiM1Iz&BjObvO1Q]ao5rwx?0eNNu%u?0{?0֏aʑ%#D?n6NSlJhށ1|ԁZͼ(V&<`kWO=8c?n+ o>|\_(m6??yPTJvQMW-[uHG״o=$yG8*ea !i5ò`w<™~l S,z9/myQijWyyQb)a~LB7`z8G8qnW*T\,AG׍ A_j˛~?0kp>&!@"#G`s%>t?0{CQ͵wgBe6t-ĽfJ:Cv[F1T(fRt+5|?neOtUEtނDncg& SLg93)= P#>Rg}߮RMBJ-qvX;᎐OV% C*tmk]ߡF/ `~TTj0iRi]w,ӥ0)|mqbe75<??(6&N??[H%M3[ O>K ̝ەEԂG3zEHB1zBNU0yQm(ԎLZ>=[,]kc^ xhmFG|X]&#%La5[êi nuF'Zƻ?rmw7{um6@FA4DRM7@_wĝ4]6r??s?r|w$bmZcP(ޡoG9U`¿?r^pVȗ>(.=ľ^:gTRqfj|\չտgS7>-:_-aխOr>I>riꓗ4-7?? dn[lYYipZt_VxvS9yO-N:)d`,YITY_'.27akpupӨg/@vɋ.?01 %i*.<э2"Z *l}L1?0 n!^Ԫs3#Y@2L3F`Z5-6 ?r yCUCkF wQE^A+?nc?0W}݅ZcKctО"ѠȕIg#g_W&??KoL .5*KϽn8qtR:$;̏YQhuE?rC&xHwǂP={)EZ2gK%Z$ J3I;dž%&bc막{~~0g);=\@Jb|ݞe`̕ie .ߔH#ٚ%:R+B1ñ !'G>ў  E`G!,<$ڣs,?n+:}pW&7l5/\Sn%fJݎ03FU{y{?0???r$*1,Ԋe;#y9'nB!@ODLK״=fCF't}Wz Kϡn+A]s_0h]bhE)ZBjxç0A&Q'q pinXg$Ʀ͇?r$3=EVG.4Fz;?r,xC(5e#n[j0"{1料pJ|v=@ǟ[HN{}hr6~20w-X/$)-?0ǁ'8DhC.Lu֦f}"F}Fc|IFA|ۿr`.2bt-\+BD;eIzx:hRuuE1tBjSұ??y)FϿO8Fwme9d7W;5h\<>._!??ݞZy߉K#E?rlw^04<`^AV`XԐ[wJ?0" 7yS&<HZB[\VҦ$3o*,h{9.;]vga]g;?0?0PP&Orӑ??npP :|;=ݓ_RmYՇ0(s+C}[Jaѷڝ݁Wty#7dfzR&M454d>DXX E4"C.5 ze l)0@X~i@l!ݠ!(E?rI>6äΫ|DLU~\ GH4UqHo;LQn?0S"*J"`<7BxN']S{|r^~=OVhaNӻ.€w!>KRHc>C:?n ]X9fif؈&ڵ2FsSˌ$p?n;?r6w1$Ys4㸝 W2c.7(npy#v<5$dKaĦ2U,oSXm)ⶵ 7uaYTpLʰ??p 8?nPF~_!N_4zgKZjwjSh@W bnqe\!qJd`к5f|QGA>b6v¾M'%%}{f#?n)݂*eC(TUeaV/jlΩBW4LUw#E拚h8/ɿ%(w@ B,O|wʉSZsh6%VS')iyK:U 񧿋Jhv!c0!U.J)Ka5A9?r&UQqޣo bx)!4pok#IԽ:EIt ?n`)E̗e4i@_tZ"d-FQ!LB S;E˜:p/SEM sV?rkнhI۵cV<£tdOo??2]l>.= ~XzŤ_VMinn+*<pa:4,GFX_8FLz<|CL9^jK.HA\ˁHRZ ŧGey\Q*OlHH":@>WKeImW1cɢ%s+(k0cx砩3{kBzݒW2V] u($Yy??oЗtA-f9bIR+,r?0Ǒx\ګhy蠹^cā s"iC\w3& ^!nJ\.ʠ,xf|c+7UȈ?0yIWIrM?0y0.õ>dh"?0cƻϚr[+2Z|`a T0H>o$8b?nP[ۻlsӍKTh?rzyqPG0I3{'#)WxKzI-/IF⬢ 5I{LDmD g]fScO&D9&_Ƅ eC?nV@~0,e %sK5N&H%G侸\U?no\AY(F݃i)0ҷ Vl^?n׳S*-T&m,6Kw XdٗR3EԮԏl$hz$A UnjhJ~j=(A*4qabgeubͶ0X 72] d)NOzUa89cӥ OE5M(Tާ)O{s5JVd0k)[$u4{`~|l_8??010032V&_{4=@ʊԀ]??R|͂G gV;E?rn-)J6PdwXz0&?r"2$vbܛm9P;Al Ǖaٹ= E9P5[-CY#zI>h>sXS??`(lkWUdqVZ9tMf#&AnOC??OP ;RUDsfR$$M??ł}$(fXW0`jVC{?n]n}"TBTL.?rPUvp|5??TeRũy5N"VTa5q8E͂IrJ5 1;9\YYz?nKIZEeM0FdO??eW6ٷ(S%ֈ@_o0<԰IW4ZBfF&ozfAH?n?rza2iѰ[uo-h??q??6PgL,˚+O~)P`33ڦ.6Fow7qBvy9|whhnN^E.PO$n%Fj+̽n "+e g U7k"wS؛>_곷ً”kYҚi,X9;P)SӻvOɕKIu~?005ta`CzS Օc6߽: ֚LΜ}m??8Te* +#/,Ê軞^^Y1U[=]~݉gn6j'щ }?0+\3P*rA?r3u̡\vjbosM}EJW6́!ԦM2^$/)L-qCfi@qDqj,y@$iJ"VJ-/,2dt k?r v_ڒJrF؇?nsjRe \lvQ|͝{8]zqy"??PIٹ?rpa,XXt|·p.\s3}ʁn?0%IX,0}濜Ft#"JX@aqp?0N32~ h'Eɤrs||[/R&ZG?r4}% 0ӑI. Ă?rhpR-í{w $=tEt5c{s_縔c X\KE1 \!6MepRWط _țf$L$VNx@JVValZ’?r8xqjnd2=hCQԐ<ҩ4tN'#$ =J]c?n^jn6Y:>hl'~@+{{ܨ77pE]Zrfୡ5TvL⏍">I^Oq'7GV5RTIGER;"DF]][גkY'^q<7v2 y?nh15͡&941J+lRۯ SׯUz`??q|J.aȜsj --rhɄ\w'??C bi5|~0ȷ[:!۠U0@؊Tm1Ú1ᕃ2Ikc6QMwֻp/lE^¿"ɲ&8$kM^XXzQIdwro.jΎ]P?0&?rܿ?n,A ȳ8KNbopY E]FJȘz-T9@~K?nBŮ˻gػ5ta竐,lB#&oBE^D?rQ?nʸEgXr毁R'E៨.b\t/b;RIU$D]77*WH( mHe=eNaLZPNm؛<{Jij<36>{w[wB?n"^`I'䊗'.ύ8ٓ^Q &kyD1L2LPZh?r|JdfD—ܔUg6[T.ᢳk%??'r(cX8.vPXYv~ZFnuynRK}A=ʰUG^XڹC܏ۥH2@?n%J4ܪoQuI5 {ö?0]]U m!6EYb??]hJVۣ]ۻP8e?0ܷ?r ϛF9OƘ.H9<̳ACFT[*P.%^+)C͒4~CFO6̳ N)Jq5p%Iq4s6Yq5 y~AiV1|pv#3G3h`zbΨm:Sإf2N,e*3D6i 4ZXQVKа>KG!_yJoXv_+^{tMD>_ Um>Oz 8@BNFW+QZDX5} ,/q>w4p޾9o zu3K.2JvG"g"tg`]'$񟑡??&F1????vB~@(hL9jNN88'5\w'J&17?riLJ8D[1|m/`BԂZ`U,JU^Cau?nIO~6sJڄB1Cx_w};e3Ir%Vvjfd>Q@ݒva潳cbS3k]F8gLGa홂t<Is~??;f;^BWk?0U?n">3l+VFkb^DCp75ٔ Cl`JSVv֥PgŘWH%oTZ]FAg?nUOԆ?rqoBw211iŞm+ܡ?rl-,Z R觯Y$V,^hJۭnfRu=S4ܳWǛǗVjmQyPO#'OK[VOoFEeY*F^?r-e+gk^?rM7)r)4X)^?nshV1gVdw/ݚ[ލ.Վe )^0/)-O&xVOd&h2!2u*S==㓫vP2?0!n~?rJSFߊ ¡*ibjltM+UQr%>Nj7|jfN}xOʂ(nLpɕ-(Ӥi`7DsWLa;P?0\_`??VF ,F1Đ&7x-8=q!(JЫH9X&h I/,vJbfHӞ??hkQ8fߦkZwԃɆKm0TNJ)%P@p>]soxJp3p>M:8O_Ï*X ux%k-Ĥ(Xl<&k('Z?0ǘ!:)1l:NX?r5aLfAxANVb+8)SScVɼ6fr|/42GΠX!2WJ2b mc@% )'Wpy@Pg=e?rL]l7EbPlf$묖+0+!n`nnN{A`VNZAƤ͙#w$qL_~D?r?r+`:HpK}E??[m9VJf'JD#qdJesS@@L>6ПPh`-?r4څol1vAN . TKaZAy82sCLK.ԹjЩ??(prv8 WqA`F@?r9?nԱ2ZY2v'jZTIY3+$N&a #T8A'y܎g'.\ y??SkmK`W\=[njbOeQi?0&WdPZUq&;?0<(~2x?rd!L??+~DG0YvMph>=|YܤmM: @âIOnʟmI<9.6LdB ~sg?0'h7.Bx/ۀO,dc]y˼xLK F +т0-yoLsFGm;͓U\U;j6/ ?n|>8sK{ &z_8n]p&ջj;???rcBFg/rq[2pW dNƭf??oFU}]LjdX1)ǎ&<Cn=3E?08-p`{gPDqHQ[JM~\꿖%(SDj.Ʊ c`46aiYM?ne)mgpୃlB`e -q1 xzq׍jL0?n?r}UthVf5 ??IƼKFSMqIzIZG$0R=f?nh k|H.PY`kP@#fIŤć^waNdF4^8cn ^8@=Z-K`Y`^Ss,-; "wwmhU^VN??DJ:DKw(?0*?rO,?0uϞ%캚V i6Poeua{j~8^RK:rnS$hR'ĵT???0?r{`,I^p,Hlߙ~Bm#Y88?nx} ƾ9hyF?rZeXӟcWwX{rnu(̳7nR&a7q/"b-6=N^זwL[1B)HloY9*򺻹]mZVҮz>h8faa Ug494yC/2h:#t.4zW_$o|U*&y "dFw;MtXxo"@!k. E,+=̳SVzD(j0^֮B2Nc*:Muߢ6ekݣ/ +Z9UN?nAV*>2'472-1PA{A ??9$R^x'd㥁ondxBإyw]8C*;v1!ܭ}??.ixqqIsu,"<6]f4Mn1 -TA.|Af7 e$u1_GF,`cyd$h9̡ `߼:NA͏0n't4uyt뙲殶N:AʞcH:}}. 8/uzA^DS3ՄWV ֡˅kޛG$uc_4dYr%lbGhQ?0+WRŤg4_;?nmg#qm1GeveߚX~(+/L3cO%(a!&W@OD+"U !m0B^??7T=$$dk(6 x6UbqK,%YaP%:j${47ٌ?ruvh,YDیS8n^g29uwUEf~t!_{kǘԒzs=ڭu??f4zu;SBu.{^$@T hEio,Th~v * !Q]ayo1(!M[?n1=>xNu`E py!&$jEGѹq] Ecъ<֥'M44 Ԩ7ӳN edT뵗ܪWSϨ)Ħv w9ScG,Si]޸6ɓ z#$[ =*cTZ%Z_GfF]vdb)P^uk[qx- ;+D:gOnKSqvf^ŏH<@rh!)*[!IJ!z>PEIn:WOv5++.D`f2!K9V~q+u-e få{W8չ5])\k{v=> to4ئځTaש4+rUR`9%dw&uHM̟n~#ϦǴ)>JY5H0P??W&Й'P6}ta/һoݴn\6Рѽ?n??D7#|m{sfYPnژJϳ{':jy?rH]xn멇OadQ"/ 0-k}JgkC??'K@{e\F_`C?rE2vaS?0pV;%?r羄@>h]>amAZDum?rn9)^Q젻,W[Ƶ$$KgK|_WtD|Xz.*P3??XMŁ??HRQmRyDž%?nT??#i-gV{{w(0=QNoܞJQƟ:|ÅIIΎȹfӦ?n??DLMޝRo8f?rƵY&Iw>Q."B%R.`eeuUr[a#)B E{G@]j?091}VMXu2Pwm,z*·e;C4g_7|U u^-eU"SeJ(." d٦+CvWH W`'L??"Iƭ/tBX)]%^ava On'!*}5WܲdN7>????t!"!Ötwrr9픽MԘ}Ɉtvlz\"?r<}XV㾥yu tgvO؞JAޥ=0Rܣp矀\Aگ t`}o_M40צ.Y4r0cTJ(a'jk=s] Mt\8I8wÜDޤ>)Īo%7@Q}a/!+ؠddQ?0>`K?n-ѐ_r\вctOpM3ݙMkrםM!B~nW?nω2̢nbV`;Dž{(afĄaTӳyg4'?rAL1_,dxFf mJ5L&vG.dO _Ā7i0;y?rX7\&y|+[g2'h~.hb 8x#?n/sR6??s+{TaLnx; )XA,Šs?rqdu4@9sM?rjC^ GѢ?0 1213?000231120??ht@+hDv`vH'TvRQ陖?01]W'/ҫw/L??jÉPϦL44 e>k "0@T! ?rYU/@qr$xH?rߦOgi)뗴 jt!* aMѵ4AsִRqCzC͗C7{F$ ~f- ,LY>GoYg^/W QpI7CND~@el*֚w:gNL??սd'lPu6@HVȓKb1ɜseS+ۜq 1Fh];̳ W0x!!ЬL5LQڅ$g"H +#3"#2gTł-m(Ȣx (+AVR5%*Y9YATN:sh2-rj!5HLiqZY%)+Y6CHĐ8T-B^J0(m*Fk2B(xFvi'J'öx7?rg?0!d$79Zk8E%BG3sQYItY`v?rLč+?rFǾG7cPi25= yASK_ e%Ѐr%xZ$ U񎺢HYVhXMe 4֌Vs3#+ܰ@-zz%I@|vp0?rwֱ~?0k{m6ymh[xD+QiMgzz9xC@&7³74?r5ʂpEҢibW-r3=m{)Bk3'y^})Ɔ_v%/?0;<ȺF&Gz=k׌:<\8j)jD[$?0v#?0iTx5n®O$X!a$JߩRt/M9ڑ]<}`]*1B&4H&<6 Hݨ̼b&V1/+-0tWdZ˞Qu~^"h2m誷xN`iM1}Q8ʽe?nHwkI`bJoIk!,y4/}:S 2HGCˊ~wߧs$?nD{Md.lRs4j?rd@2wT0߈獏x),˱=5SNceeeL "v({i}Yqj'cFdbʦBUsw&Ee14Z<ԅG?nȣ|G~E/LV^/W*͘jʼn)5ЂGsA&=fMSQp85_k_iE|0?nyV-@ȳmc6,Q=^??! %\Aӓ9uwWޥ́DTi-}AtPcPNB1yͤEqoNYa=!w_kU׾P ^j"츘+ې.tVאۇRANuk;vz]< UЯ;/D=9U?0cJZ.e 0>G>,=Y-E3њKd=7dsvc Ob9ǝ=3ZDZ>mה`㎲wRxeoE7OgH%yW^νw ??;Sr =5+?n ++F bOws?n!+tDbxOS/ g~:IARS&SoښR?078b֓,UM^Eb~??8-ԶASvĎR.|LuЍԥN.+w?n ()cyKBnl$$1,gig`+ȁbInÖ}3wζ!K;)a@o}ϲ7rSe!o$1LQgJzwH65 m'(3)xЪ[IjnXG^7FԉM)ۍ"e>=)Ԏ0sV^^y?r/ S_/󼕱gUX|My>ENU%sK hƃEFL!!SUfEﳨW$1##RkBhZW]^|OOR6æz~pI]򮐜y9Gt8?ryª8dOp?n JZn  FY-?0$)_&޴=!9#I2YٳltQT'Rjr`Љ8 5-^L5(]@DŽBkyHbhQB2C8nVVUsuunlLk7g }f*MBGnׁ9}PR'@1'V(o9ZjȽk Pz|j8hr eAUySX֠f9v3Qvi=,?n018,6٤>/HwfzyՔ&n\Szv?009LQ\d??FچAU/OX^xu6*ȍWEP&g~6s*?rR-P4)Pzyn Ɉ6lnA-dw)"DvCo)?0,n~H◘2mp%Ӟ+)SR~z*XQUX.kϥēx.+y;;Š.~TĢ,>wONr$"?nf|)KbD;&Ȟ͙Ĺudgeê7_-jUHN??&c[Soف]&Q^N'^ڴfG oנ_+ 2)HlI%UmI]YQ KTkt%e_>vjY1kmZK?rw'Smy_Xd p TE%:XmG׿`@.nWwrock6KqLۦ?n|8k:( X2qRnUVDon\Uv"[̈zV.O ƻ6vXFoUGQI]`g;Lxf3k +#qd?nhck4Ln}5@n/9T=V8{TIDS= tip24B-XfXzfIl$yO쀡[7^[3ZZɔ> zYemmvkKA ^WDU2Z%dBl$IV,GɌx,Wz&{ؿL'֭)lC#"ͣgTYqZTKlqG+g}Ggz/'q?r`!laL)5??"?nV&<{ s_z:^ дQ꯷ovN??QA2 6R.a`c̬`ZnO/  AزX\6y3ӫ5)e@BۛAW}S31ץ;?0=aY%?nOuQg ֺtN9*etoh{xןJjJ)i}J+PY>mԦ^X7SlUSHt֏V}#s![5V?n0eޚm0M$YcC"z':OXXF_|/#>џGG>7"X4AC Wz_] rt|2ETP^}>>cs(', _J(# r??B}D}>z&{\(0ݻuk첡D/heTV :PJ!&RTC&|c Cb숺xO)yV,ɣ(M=]復OMSIV-LBoxҡlgaBkT6"i* :͎!?n77'&AYF_WW@Noz^vz Ύ؁#=#-#_(~.@4@\T ).u8dUYѹ@=s@@.?0j> UCѫ2q8Ҙ'/NqcTz ?0wYEAe!_rnC|~N,CXg!a)sP}ŏ<%B6,?0UJ%)XBe&-^W+V 6C?n47 M̮n3/@Н+{8SѢ{gms?nA֢ Cfev6|G΢:"??b.Q 1??NVWxH=RShQ'w!B¬BO|&kGOk_վ??j~߿rBoү1Q/Ф+X,[!b;ӆafѼs׻U~sAk' K'eK-f?nm=1k'p(e]Q|D0oK[I|-.#ͺȃB^Wy?nK?0!ϰ\?n1ZTz??1FY GC2- ' SjS! g$cW??0??f2RG\??BFKOg=}l˝G7Ĭ!`Y :~8+qk%Fp¯"Cp.)lJZmAV E\/T uśNJL{N~:|$eeog"Um.x6xwWc|9?n7T#qkA1O2:2=+Rv\{֣'Wj!ָp/cw!C^*jO8.ߴC}0R<ڻR& ;??K5ly!77_I"Ӳ(e rڸm$nZ@u Uzndz=Tr6Sˤ=e{b[=ȇz hrOA $ȝ1!":ios "ľ{|Y?nNW]ΆRs1b6]F!DS<Gݡ"͈V0 'b0)̰6qt[Z>&?n'P~!b;h9 |ޜЭ!$Ҵm93q???r*UC\%b`юP)81%p#eXpaT9<)]A@.v[??!w}#'9yWA BXl˓+EmbI sVhB4\G](9Nǟ|Z8i|u$"7=G%*^ZYlR>y~5Hŋ5zn43&.OG`ͻQ}n4t08Da6VchmmѲ\Lj>J̄j{g??}:5jĔXsafb*1s'H2}z0[K PmQ+z*\9M|Ҹ䇻g Z@I??E:wJZD́3;|9U#`iV˜H%V5H#,fK`+Yb3 =I&gy`7cmIZHCx= G[k-0ٮOQSMg gP(2j#;JfVas N[)}dfVI5؞F||r%P&@CRJ#Vz7odg{R[<9dA+M?nT[|2}qJ K)W'ֺS(ks~mY~RS?r\Tf4o+h)D3AߟN[P.vuMڽ8J;C~S6E[]?0&XIhF؜$89H("3c!{QiQ/j*#_I*& `=bhMCUX.'i9{dibԝt{ˏ+۾Y5͝sHH|Ӷ,J~zT?r-bHQw˛FdSLkCnN挭ѼJ8.?r{|$EudIKoK ˡB{ٴvWA7oCWug6-ns%mz/m>}Nr 4ڝ[ xG 4ڝ^??;M??g<E-BN&GR JH \74}D9Se;b| [B,TE)9V3/uY?n0.:cp!]6qQC'LE[ >و%xݖpC H=Ln;j)Eߧ?rTO +#ʙ5o)'XTbQSJEllMQ@FjbxI+ͪGRGk9'@%M"Ъ_L9fT{ޣf?nENJ7lZiU e>h"m/7oKh0[hl#ZI^eOPV-7?0DA|}d |[^4\>ū2ۤY4FGǂ}-Xf T62,Ѭ!aB*'>W0 Zn["򒻜»Z(#,]޺žkUل6J]큁g1Ќ?0^Cr?0}??h?np |MrJh{\LlH񟠁'Sˆҳ\e,$97lNg¯c GF^wD4nDRC2< a!2GIsDU59KHD*-q??Z +#Ɓg1>)gh8Z~Jy_Z35FS]cUv:Z&B3JX2}V[#sbl'ģDhLO&.'üLdlFhbLfܗ%%W}%ee( ?n0P?0\]9lYȊS??;6XƃJfVv%ivDaK(`ȒQb骉shD l^ N_A|@^𼣱{:\A3S5xW3F?0,ЪR>j!\bTC[74sN(Z<6$@`z}ҟ/#8巃ĠB٥4YxP?rv6a[z7G仴?0}\;(0뢩Q?0qڶ ǖi-?0LOy4Tzkζ=R>@ Qɒ 4U~ie{01lvWQgH?0O"3 ;WW~t>T(uGG7թ۠oBN`Z}?0u6%Gj䑿^G1x8+Ns+(&ˢj%Hh?0漮o͟7OG﷽?0YFs}l':Le^ݛ\p?0(@Cr ,dL܎ A/E fA %dZ.kQ??c:wjbڨ$aASѽ(T?nlc]QcFAdL??Dݪa\Ã[ka}R$9,;Cm?r8(&.;c;'7=+_óucPw~=8È8|㏬HF\gKBw=5\q3c -Id[hM2Rs)Ca9Գ=/[yynZqV1St?rt=Oh(G\]?nQXl/hLBQ& #;fQF!YZk**eoiȦ*`euB2tĢtiDMKczd%;dMGf-Nv%( omY??_VSHЏa0"S[2|2R=xCqOO9?0Fj;<ڎTå:6tf/E8l;(Y"oL20>|v,ǒt'LDCsWG5 )("1rӇY{@.!w:p.Bo7{ngT,{Ehp_pyO'~D]8wRD'x6,+3ܶڥy:fh.G_xN|p>CUNwxQI~Ugq]G`ss{f-_BKz۬ovNѝґn6{+uuO?0RAՂ\}1WA#{8F SR?rߊ?r~崅v`zυ;p?0{No}Kp?ncРCdQ*F=MňFjK~IWqspZN?rV8d6\;%^{Jв|O$T1M@^l535%N~<ہVP*wk{;6eKao,I??kǧDZVX٧' i1KcC(nʴ:/c:~ tbu;g??|f?r`m>%F~.xYKJ}'@@|wTG<}#]͵KZh2(rY1, 8QwhjX4W3`z9K٠?rG"ru8תYԺMԖ7eQʕI̙oЭާ.Ҧ(*"G˼a: f&A]-N??wy+vS>IDx%/".3':6ú2Cܔ>J2G{Aԑ_mfW݊qHIq/SS6];c8e-3c0eV66-[s村WBn?n5'C- ۙs?rSCX\??̹yso;m9-N_k6 ?n׼_?nW$ݶcՙǸp}C??|OPO2FS3biN^ve_+trS~{w@ MRDw~M/8‰Q{80e;нSxY6ڏ Z&.?0&M=BM F0X\$v[O#ō,<;dTN2Jo\@ ' -C+KiĊw?0xaȆA|U}#M't;d~ =쒙{"уm9P?r舓߄@e!M0Lk?rKa2ANԼ8^iZzFE~sGR,u[;S/en-s{B޲G?njf>^U34%gAH+xIwvEԶ"}jw6jzk x3fvuS(3De'q|(Ҟ=kYm^O8vg%KirSHVf-"o* =%7#@nUw/v8ƭa S(|x6?rg,8{w>554%s$%O}JQ3[!ȏw5f}bKBREo\7\vw N%x$0V?n?n?0A?n@?01?rB{Dy2w&;R nwǴ?r<+4NlX,i)N#x*QZ{7Ln"`!T^͐{KQ ^+SO?nŘAK/i%B(z9*Rq!p 0v'r,LZcr2s`G.'bhOCơWSFOp5r24cY-V@b 1M_p pyñ]sb($Ç=b4R,qJ㿭c52GDd1=$.'ޞr/F'.c*A??b?0:s9^1ko?r8Wa˨aLsn7  U??h bM$n{23|:~B$چ<%8 hǸ?noA1Z#̸(B?n b 1e7r ]lD0(_"3941TOd-׎$A%ƍ`ll[X*?0l)?0ZPwɱq̵Jr3Ƚgcљ~Ef3<]p4p}_e1c9}|rxqK?rK@E(p;>x%ԄF_U\F~ #ˋ@7(0]SÎY=xtԎ?n@MfG W,?0OlNS ?0$G?rwUHMVa{ 2";[~jSL??n:UP_݉{ªZ򢃷N JY}O`з8DK'a(w$_!֏?n^Av:S W?nSE}Kc0Ж>>'*`$Fo6 ^ % &c]+Fwy*'n=RQ?01K0kBibxm}Tp~}xyꖚN1R)M925|&c̍{PoNgF= ȍܒ~nGrv3>Ew WU?0W$F2$2L-4֑ց,lwr[?nW?0Gc7es((ٓ{ x f嵀'/sF]Oɝ5G@|r4RysO}"yAN`U=x+dԔ;'.JĪL'Z/?0:iˤV Iz76ܪ&ݴe??1Y%NT*˶mW%iKqmjVV"D;`ƣ~I~Ag'da\#8^!\KC3Hl`?r[¨)* (8o9*~a?ngvlC^+T!y09RZJ7P̀`tmD!wI @Ѿ.넖e(Ul׆m%6M);DcЛ %_!ߺv}eֺ969JMByޏTߍi~|͸=})߷ll[++(FҦ1sNbXðSЕ']B7N"DQ?0`=VAeRO PR*E޼qR/777.GY{r+7.hxE/bǩr嘆q.bѸ}R?r)z}vZS4e0P?rH?0$ݒ8GڄhGDxȂ2͗`f8QJW'_IVhE$SkXR[(a`W[:F#Viz]޲IGLFW='z -%K?0J<{8;U--Ȋ,,/3^i ˫]Sw91p\4ё1{u̒?n[D)c_t!/B^%y*JnCa op*KlK<{??=0^8ڿx捨ʴGDDKtVଖC~ju2bϹݏנS Ұ7B;a,21#L'Ʀ?0U~HA؊,S`w-\ jeSw3G?nh}CFEnʖVt13dDFEFVnH^ fFx1L"}Ga5bG2'{V.Ewj-JJ?r.4aR `?rf )Ҧ,a }|1͓¹" X*VxQ[?n*۲lfXkEz^{bCiN(z,1'??UY 0xk,&x|O@v w(ekg|5e[Өv΅>Zc1{UbZW3/ԘWL/gW}??'V?r`V!$MJ{wi== VF6@'p`pL ^m} `M=ϤyfJ??ڥhǑǓa F0=$I/#@ .%U%#k,VR<-ai?ns`d#>i-D_wԟYLyɹ+J!nI|5,nd\M$[PQu(, Щ2xJAzCHC<5T,5,f tT%)?0.,"ۢT{*eO:sTybOy'F x5Mr΂qTƛ?n,J?nyΊz +#9)Ň hɑ-?r+F;8#>*al%ïRl)??F{bY >9ob>LUةeՅAx| xHP3SNP[j]_K {??UOPEp޵N,ȯH?ruUTf5 9O$0jQ!4:xe1?rJ?0C4c` F=pVq>ch,d??+6Ob7L7)C#{ .Ժb{FG`#J/H!U]hO68ּXk$6F!`Һ\}l"yGHk@:4Y־{SKuU {ʕ T͊J_2 ;}`l*&fɣ2Pk ?np/$EBKcېS ׆Z\^J}6I|J!M?0';Ζ2հ>,7g 1:S-$*G=oa^ij+3#1L~4ro{w>-⧯#ky$&2 N&nWn@4^Ov=|-}!GX8߄Yn wa@j2 Da7L|!8ohĭYri ''Q9х8'@\70?nE^Wdj=Lxj4.lSW$i "qM+|EcZOiu+/=;K4CWCqG\^7M[IƷS ꑊ Of?n| Wu b̸yMz}{LXVP-*`w. k??weYhvc?nnSMȲ ~8rЂLp` 9$Ic|?0p?r8>1þHk;xZ-A,o}+^Mz}co?nq4Ͼ8A`Zi/Uk3)j&O4i>$6 8Ӳ8)?n&S5l՘=eJ)w%ܾ{-%U Cʚ1w +oWN*JmG[o/x眚Ucl5^I "$ekxR3SȭF7Y{}[1ΩօCGhBny1G??D3#UgO;A)hPO($g$@X8OJF)P8eBh,m#x1>ϼEI.oXGרE_ֲ|⃘q2>??3{y5oVxQPS~;]pf\CS "k@;>#H졡8j:a!|W롾N8SI!j܃DZMVbma,qOyZfcD5[rlbI?r^ ~f$0u盶ߘ3$׀> q-]ay%\S'Ja׺fPE K-F4ڽbA65hSヺOdPVp߁l#29@v'dIF-Z,(>{3UxcT2B y_ xgsLPo3;:bMq4>ߌYaE]"xv{ށF¾CS{{n;=4`W<>SsG"A C@">Nvl%:?r7l+sS%~z6ΖR/>a"@8 ::F+QQf+Jm?00at KZnO1~NROL%Op,?nк V!g!??k^#+;॓ ?0sY!'Ŕ*)XpH worL3!E]>X[c|RL\%.2hZh`u|[stHy0.eih7Y;[j, e n!Km:bkcI:ιdW*y~+7 -G[aW{[}` TD?nV_v.;;vPq-H'ņT[`½PXsö-Su A+!>[6ɜgf,M~>@ya?nxlQ*]ӂK/ojf}{9?0W?rlYa+Hk?r6\G.!9U^DT?nhBEjQ]%g4+m Xנ+w7ԔW7\?0,kV 1-5NL pZ.ѫ隣uYg%<6N̎f9;CieÛ|S `/StQJiͱ) 8PYFz2~Y .o#0S} yDh7D p"I#_4c13vPG['yr;vF [uot2Vxs/H???r(ς>cb|F:ʥ~eFb4wu=?0dECͭ,r#zTo_ >o^_óX;+aZ8WAx>zv>DZWᏠH\jLC[iL5?nKCڈg%5C[!C!O;i0 /[iؽ[>+Q??([ *vMmZM9|ᖵ5t%Eui^ԪhY+2yŻr0c<b}?r*r.}rDS%^Le?rI/^2X-O`M^;<:x=pT9Egg8p[tc4ȓ3[ҕJ=ҩ>VrYޒfmn7)??"18-@0;D$01kM]n6r ?r4)&r 9I8m87+O`DiWG]NWbUd?rW?r2^?r?rޖO0CW?r\nc?rZLK3wbJ%cXE?0;GgZXq%.ΟެMs@iٯ?r.8g>!m_v_z͐wp&~r r.uHc?rPjc??P?r7,qݽGs :i[Q9jUp?0DtǙ e~)4?0Z"(}/!4Q.FM3+kYMNBGZHy.UX\e͑*_r#SO>a9O%=Z5iJֱ~~R7^tX2AWտ*F)MMsjgmHl3z8B\n=τБM?rIV}!Qڏ#Kmtc,xhli +#[}iǘctvr;=5­*|vGsO/fθ&|M=?0>v/gPH#œJPB?n?080?0}3?n4sՄ9!` )g6O]VW4Wi㱑O~?rIնx|v ޮ#9%5XKOi4S3ɃҽMNOw d#,K^_@"cN: Bg]L"1:#]8Ď&&\9.[RD.vl!bPl&PwVp?0H&+peI0R.0rÜe`,Y4"!$Laկ8u\>,oe*jQ`Dbv Z҇Yf?0[YACq'|p֨L3g3‘5ZIDO(;?0И`/ѽ ՎnVc.*<+b?07;u݊ |сjp6`~4 cKi+ve|Ϙw?rRzqR\/6E4/?nL腄 ^(/c13wx̞;c|yTd#*M$BVѢإo[&oK.?r5?0zw@\F^&?0 iv&v2Ta/wC++}LB[:?rW0'쁰xvp-}tfΪ~nkʹV-׳ܘYL AW2/5O&s:sA㺚۠*K-8^pF5?reUL|!og؋smg\PQ-vu5ah~ק>H|QZNe<,jV@X6O5hVˎGR, m4PO(j_q%* A0Wg,pͭۑ➶Wh̟6=DhiʹX$xI?r{ܓpM{s-1ϫ6^),y}Ԏf>Emn0ྥx2Ջ$2"|B1%4#YN! E87H b 95"w|vTWv\{& qǫ?n)ݿּ[==tKȫ 3Zw#6I3~`=nX| Toኧ?rQ@ftPc-Uh8W2,R\yڨOYXz0NE!<'TNq=GYSN2rnjL5ݜu}M VTɒAy0pU*>iC`,LMD&Cп48àL#Ngx쳩z5~~ ?n 7C яc_É/s>VJ5O+ޅA&z?nBL½1ȶa'ΗY_ n_Yfѻ'񖮰iЉN{???rq?0otyi9 '8Ve%y%j@ҹ( <2D #bW /yIaAxc&`n'?0q;,g2Sq/ɖ_XęWw6woP'> AWĠ}dk>oT*Lr#ui.};M֞pnc죲_?rTGpc CQ,%=*p|!K`MR2hds4f[?n9ث+ӷEsL(Qo]qVB݈A!~eA}g>D}Ժ6͉գtm^] :`7p!>uJae9I2=LRmDӾNR NqwV.F\U)PiqzܑHk Y]uǟ?0q)<hxu*%yekҠmBW "H??LʋO8v@PA(>c› ~€3c]%J6n=P=wMi3/;"j0d,w.K??(FUyYIqx73Ԫ?r[ڰzxfH:]XSR$AKꇦ`dΪ:PykR-eWNjS_29R m LS-P40l'c{i$Jqm.э?r_ 凎"dcO/66)Cs 8SO'8g[R\N?nl Ll|+ :PVK .[fsEt^M$m&'[QTUx!yokPʾ/w?nh!,>ltqvKLŧbTGvc??3il-l8bI@kD~i";-r?ru􉕆[Nne曏7??R;̙1+}U[st\&Ct}ؘe0++}İBQQV5-" xCm6bᆠ|֓$pVYڃz,?0pIS6`2M`r]%D8X~2nt_ uGe<9x!SPBu3W=Elc8F6^_]){ҝ8g窰o+yˠ@iDV N"Zh!@/L}ڼ ɡTA#S8ꊬ%Z&Cײ4#jնodF- Єut똥;/n6:\0Y&WLz*HA4TA٪A2}h ,4 I-mp('&)bqw1Toݢ??8 >DJÝx?nZ LN\?ny|@5|?nvibU|3nl&Or早g$$ֈ әwzBtU??늖?nWޥ+FFO㎊U*nWKc`p0ހnϋ{,,P*v3LgWH=sJ7px7FO<5;qƟEW?n/*D/"n @|ۙ;u?rh*YmKglمUF1V}{[eĕ#??5}c^P&(kRSD^Y=/Pv07o֚)Ff֙?? j*zϼӧ#3?r:G鹢v8ӷ+bdfS &mRT\IqЩ)pj&b潥JyC\`l?n |WsĪC c +#4}G@ 4r7*U fu??gj.؄ 7kXwn>Bn1>viugMgdI+&ɦt TQ{Pmi4ciߑs'`U@cY?nj_/fOnPY HZ4n@033HtN";ǏPhZ}.ᤶPXىoLm㐔dEHM?rg}o4k\M,W$14?rzǼ ѩc}knE2N f44#ꗻº R\.rՅiQ‰p[cui, ,)"H4?nЖ0l2v?09 ^>c)(MitEo 6&եxuؓ/f:qZrҐov5atEA= r\go{̑]Y?rc Rϭqe-@Tw.mG-q`wx}E34b5.ցZKuk:5P.z 5t/znVԨ[Lz/-gAVO8kO?rKh(+NfBnKY 0$<7m4l_$!@?0<73 Cq?rX@Iv6AnP%2׈W&fa?rJo']" 9H%S!HJZARVN6uA;ZR6#6Odֲ Xj!N Fqt~܏$`V}gEGe=-+# -0H2ጛj6È s96Kv"1V[]_= 8͛roRA=0I"^¤b?rN뻓??/aҡ*R5gB|G'C&iפV%p0wEhp4`WϊB&i_M!" g9 5W5#ڇ0G &^ȁ3놛*~w\;tz??SEuQ?n#`"1(BL!fb=ʧq/)"(ڏ|t4gژq'=+{!\h6b=?n'*:EӒ x?rsQMF<`]M.x?0-b_k{??=+1 1 25+$ 5u~b_ CGqEE,|_g)(??\S0ϑ!O߿L]HO!}tbNYt@v"0X2YshPt*!_IqQM%\#9\Y 1y*Op=C}j.ֺDH #H s!5*DO)(G*ܲ(6@&ƠH~A(?nn"??ÆU{'@+U ru񌀶H'?nz]_8uR.b(da?r :rE3`N1tF3mBbK1Y~!; YJ=rG%]n`čxz%޳,,7^E}< IN1_6VLJSxq bE8Za{} M :6 2?n Ea/UJF؇3sn&*;+-cq{dcK6q.ń>y3 ΝUҟ!e]!CI5֘@bܘWظorM̑$ kOM>[~y:P?r"??86x8V[>?0 k??`ib8'?nPkt=e5?nOC>ɳaЅoԣ Y#F | h}uhTttzJM?0D86$hjԬI\!f$Ja4̍X?n%M4_Ƅ;yTK=isCzZFq$)~>cG&8& ؙ*c%*NbucɄ^vTqҬ@q??EP6 VΘ3il<+)O} ȥƬFֿ`N#:x }^ś]Aw`*|cuA,h.4E ^IOw 0>웡{F CM)z/37Λ9;h`aە??m~{ ,4IAab_W|q-_z^X.kJߚ7?0덲y"nMK-`KT㟾$#*/a$x=30Q[ι :nJ| B͕0ö]ɖp`ZzUc yz3UrR̳m\m'fI~-hA&A2?0e ?rKh:.TH|lF||/??8~7 K_2%n ?rNʴ qͳjX8ې7{F%H Kb?n.8v)ﭜF)P+䨳Qe'o8v ~Do|9QZ3ciJ>hޫYo*b 7j?rnݨ%=mл#^jdN#mlʬpW QbFi @+?n?n,.wk,??w9_YV}tƋT(T@[DҪiY:98 t?nsr߇3iA^Ro\jo-ƴ󍩠~b5ox[E+̰m.)J [ay>g@&byxw2ƒls`"-wd7%;"Q頽TS5??YaAnnx׍c'z,`My}r-!ֆt} wiQwY+HJ2gFJU*vgBc^b~d%rY/kW8e pq񝷔e]>}ru :opN_C BY6mHP8v}YInQ?0|݊:4 _5}| IڑD *h. (ڷӺTn!(쪩,I|PQ;gfwHۉfCܦj^ߊz;"ywoar շqb9w]j9C= 3pQ`[{pV^ YXL˱L734Y|IBވ=4ØX19/V2dr.OMhc;mV(XdfuYa^+!-7.<>l?n}U|'d8ha-KRWxR+0 BE)Ghh¨})b(=RLw}A26e+Y2k&;',V_J |2g:R9 + l)abizd렋'*?r/yOQ5E\Xcڬ(aE/hܥS#PZ BF֘Zk:ؔi-\q! ,g4onz)t*m$$w&&c)*(}:9VTGiM X݈1䝂j]rݞEpܕfuc??z 8OWy??H?0y'vZ+_}z^#8g9J?n ӍFs$G<vغyEc@2D:dZ{ܨa.eACA#vC10 4qdU53 5eޣKm>bk0nӝCYwjJ$DHecSt)?r#^XԨf$-gXmIݶxB= 첱FeS=:fJ?nɐ{t&CZ|=>_p}Z(:Kp[?ruR:,CF&z)Ś۵¼0/Qq{r?r ^H??2E/'P00PDv3J{+ ΋^͆uԣKW*Ob=#'靮}Jidk:I G=2o (&8>XIr7׻ϱry"W[[=_!pĚwvfv>9+y& ^T+YVyԏm\0sn B]85ߛz襑~;??{ӉYkrwutKЎRXqSDzd $Au>TղrͣoATXP *@3v/}}/z)-P}bS8Lbfvtr펷$ЮiN]Q}$(oJj5pq2`(WSl.Q:(va:~HJ)V.Dԃ6)/??n87;nK~$(zƇLp~[AvAP#oM f?n`c?nlsM޼cYWltdCq\9iX_1dFdkE2ƾ1$QeoZ_ǵ~QiafJrZ݆uonöTdd *P Zcs?rrrIc`^ck\?0=FLn zFyDI??rj{&?0c9Ul4XSqzsèqA`xp;I˝$GJq.")F nGu0" kR/@@ŶXe"x`$vJow0BnMz??vuVLUVB}??pg?0 FFѕעP?0MF0!W,x{i^Ie&͌|γ7Y+a$o*ʭؽ3ܥ@IriPjbmX4BY03vMDnC9ü-3׬u##tSKM&AHd/tp!0h.krYFNr ????*j d$[]?rnX7F/^VӁȏ)<Qi}%ަV! q O. B氏6%%#rtD:j40S8GJAI|KAP5ϓ-*JRHRӘȁ+w#ݖ}j}gMy IBH+&?rGz3Pܝ5㧎O<]5\9}Em2??\۔]=au>uimIjQf*#yG'6+(~f.]4>^TеG?0F??USag 9A+I'f¤GX5-m8q@_bU8g--@zW;Lnf?nT??WAe$K4:v9BJ<%łnt%F5Q"|tO^n^'uB&0kiX:FI|4,>adm2#e< ڞ\bc)e$DەEH :Sߤ8$VwB#t3_za+(gdUML:XAfţߠK㯂W֙ne6Km"JIDh bz>w0S[5Hg`X6!~QR*Y?0uyeV3״&-Gql?n_wB±\#`19ۡyVdJ3TQL"z?r^-SXMp+<ީ?n˶\xz??q`. /jVoOqU/?0pzԈMlq"Y (d~GI!JB+?npqj+Oe!b+Vthj Y"b5GQ%d+n DžT9H,,wRK@_hR%Mժ\CV h!CEKPFMB۹و@,n{¹':Ȇb(w??[:H=yN:/*[(A*ʺpXiڧWm> |ŝ-W-DaˮsТ1E){I㹦x=?0LkjtK?0%Բ(X$A+[X0U35 !w"6obv"y h wӘ:SS_\Y>8A^_1i.B5וJUnFߺ3n.ãVP}=OM4&г*??)eѕШLoWýGҴɣ"jmGwwѱPl@'qFY|hs4ԭsDōK_K*$4,4'j]YkFةN>25(Y; .PbhT͑|5/`  X3l<??h|Yb6H.]Ph8!?0p,[{; ?r-?nAe+Nǀ!W=7wR#U@R5140?nga??S1y'h?02}D |u%owAg#Bz)hvJau|1Ss,SDi\.!?0PXf(r~!t bBHHxIâX!BhsY6mضKmC,"Y`sJba& n=`^ ?0,'3|?0p{QwuJ0hʣsl&a?09(rxک?0Cٝȹx`=tu????B+m7Q`xxۨ7\BY'umzڂx[fCZ "TM/+Q9U~*w{??\*d'?rOsΗD> )<)O t e0.ԬMZTG=xlt~FV(??Qs[2=C^qLp|7VՓuuÛ)8[!ixKՑ;};B:?r3; |Wp+[/D?rdTMв#t rFWǼ8 Ҭ?0k?0͍n&.E (I5,I)\yzaօy;G"l/ُJ!5dl4{eGYY%`R ͺОk I먤6 LTt⫂ph+L=1R@:3f3?00^Onc?06Tyw"KV]_Gbޓ!gAUP Dϝǚ ޾P7yE݋ӞheQaʯoP׬/RNH\k$~{w&#ݿ{Ll~8$͹lbx?r/p}ti Q)??yZz`sUi2fN"ܓ0ʡԧ?rx+35H%TVu$sϙzT e&>#W-mS=rX-HSzOt*}r'ޤZg_\?r{ە??0l??Յ+$dM'_@,Bz7ֆC5b |DECyKne=?raPNhFT^#މn}zT-?rgb)c??{T_8WQq})E@S9|<[\z m=J!/b?r&˂Ze>|\k{~zEz@؍~+mwWGuYGE7p&?0h}tmfN?rBeDDyV%%6pǂ=qsi{<+pS#=@VI`/k Ãƭoao przX-?0"}՜&RdBcrj&Pz^;c~#\o|nȡo M-@ύAB,^1pQf,ɆvFB0M1׸JA9hYbw`$K2v(uS"%ώdsKK8ClR+i] &~(r514wz/kzj~?07m5BܓqEdCCm4vp +#??.[vcIIOW6i. iki'&8x bn3+գWQ($nKn?0thp9/%b8C LI͚>ߡǛY(CmIU8e4لdL21e7`]"6Qt ԜAxL9y3b*R}P Y˚+]O,(..ąk${XvZ[]<@yJp2 u^Yтo_E>N+k>m-~?n^Tk锢R dTe??9wJ:b4 !2MZlN|vY7Nv?r:,!5H|NOk??|~OzRCNoŭӀvM틵O67)h2"6汙pX4?rz֞n=feCq\'ᨋ7&R)) UҹxQD;yn_מbe}=𬌢1d"9pnIn> }v7]8%&+&Ugwf!g5@.14?n*;hpiQڞBnn6/?0mP{;uf3VRT0jx)mML=3\\yp8ӾTcX{P™$ÌR=M~{k;2이+c??=s:LjJ7\?ryC;‹%|m:SM9Fd{6nIDmYq&eMX:RڞB?0#JOLR="ِM){ROho,jR'z׬ɿ{$)GӮnxNԦ6vAs+??A,2fxSTgvt`Ӫ kD ~I^ .B_I'[}IJ VxaZX> Ee~LN}:FXGA{Q&s'E-/wTsܛ?r*6Q<(LNKGO2Q{}Q\wk7<̆[+Zo/[蛮vQyuɏXC=-v`^[=%~pȎLF87|Cjm_GC^Ŭ`\BSj\șI&.P)ѕ.U硷cxfb\9MrgNM3S=_k@J&2v {BR:y(:d"1z&4zJAwb.I u9?n3sPiW^_X(7ӾYT'0 [J2Y6[ltIi_U|2"YPsu_3PWFM|M>zK׳hg6Y_)^O ҫl`D7JaU_ClEMfGA|6FzRo.Ӄɸ|_};C{8^@ ]l.I]hjDwQqjŴ=~%[9dBJ5µ ꖓG$B`ο=>R[=yn-9d?0L +#U?0q0QJr?0FϋT:[&pz.1vF K?rIn*b hu  bCSGpj=2dlqoh ;Qao2kh(o/dWyU?nG!3#c^/e1C>;bO=J5 dXJWQu>IeElہ~jTYst$Y?0F vE"JInL{ؖ1n,vY??]R??/.=??PArSGn*;Dǧ!YAq6*pHv(i@OUG;T[y:৬J?npjˎVHF+52l'@›1fAP<|yOk$M&aivu??rWGwe8i&MQ~9)i3#y񛹒K7?nVv?nB,Ke?nUwfiF_-R< r%yi:q?0Pke ?rg<*u1Ht wRq³1֨~6"ȴq/GI]5KC~9ZB-5Xsw变t]^x*X4W-WwO`M?n8aO`veXyA`QqkfʣPh \D_'=(zJt?nZJ Mw/|yO #ee#~> g^l~}`vC]??K{J.P]g,s'j/[%~,͊M+jʿl`('>ڞ>=-gCsUdUJNkᏫob4zkrrJhdt4t")}má:3( K ЃnaBUkw$}T'+Ͳ<YTPol#~Kx??\^A]Dٜm83dZxZ`d3@H?n6`7{i ?reŵSN1J/֗"U0h+#,^S]d1*?nS?na+Zr"iBOHҴz"jx0ʯWd_}$͏PJC)8Jsͬ*#Z]?rMViʥܫTSlLOŃ<M?nP)/E-,qnhMиY%mNJj.l+~6 BbVL5f[w5JTR}JVƜd5b;rM?0x~ @Vњj7-Ztyoň lC&y|޹ެ@nteh8&ЄBW)DvLHj#; Y7lAI[?rF 0\ȾN{jIGܕ6xG]yB0q K {- 7(}"Y]7M X/){s{[)b:ʩɓhpWZVh(ܰ|MɱzHU:_X;%gyFyb2ZưTJ\ӊݬ?n8ݧՆ29U[L9ryen'9$/]'!Cw~ >Ry7?r2_8̀Ii,Ea}>q?0{]ΐ,!|PE;0Ω-;`&L2Q?r`-y:?n=ZϨɱ7J,(BXٝ?0hgFhD={\ 51N?nBFX49-QJVz;/$d='Zse쫭RNJ\gHM5.z8Q5@daJTn[2r @4@FPy.Ρo`x,!2.rHtff0A[ ?n7!^^eHOҦfuAIn_N{o/#(J<"gӌ/ +#`CKo;nuQ2dv#*r4fu ZZioڈl% e!D]nض(b(Nq?n媑 pq@9FgY &vvjtJ~҂Ua ri˸Ub"*p+<osy 8-7Br?n_ĉ.!Y5g4y>Fq\%EIIԬ ;s>pi":o&oy y#R}'.Ѯa׷?r5lˬk/kyWqt տU6mge5)`7gmc_ޫgq2rP6PQG ٰ*Qtmvu\Lqx#O] ͝O&-b&՘˪3*DYQF7\3à N]%BRe [t#  ɲv5FP>g{V jQK7"f]kR߮?00L l~]&~E8~DnuNq[HZnE%fqd{NO0w&ˑrӐ,K'߸z??I^9ձ?n=D Iɤ9@lP+e4MD0o(YC)?rǮfGK<j $q?0qM>3;dU<;MO>fތ ` u{"w<@|ZB W{˫^9Qlb6A6#???0KYدY8ƀ4YW;{7O{??$JŏΩPzdG)-&|Xoo}@6??m<0FnA8J\w52-Q7{i1})-&fױp%)(dn) 6P㟹79nąVTK~Vʰ^(q`&XGJ^ܹpA|I{ͻ"|b a_7Z(}W]͊GEߦЖNn[Ԏ??-$>$%vmOcD8Fg8|}1.?r&w/iVxk8x0?03js1+O?0M([J+`y-ҚGjqo:?nLsTp p?rϷ݌p,HeDF9 5C@ ;a3u1G]t@;g?n:}M+H$O㳾.hKy4{D!0ԡ_A?0_Pe`af߯{?0Wմa9jjNYcH{aw1]*)1"c?0L?06ϵP!$wIvb#hQ2(:ld?rYdZ$HFzƾ\f o7xZQ7^]+ccc Wle؇??h/?rcS?n⽬;O~UkkkA%U8,B?0KJixek b/3aw^l7W2F??\ xÉ!`#9XȔ*=8Jh4W&A|  ʤ؜J zzzzk4 GRu0hѵTOP1Q%?r:qҔ69WFZf_VnV~Vx]c.},gh:/ Vs^8%1x3I%_G# Hr0'Xg%6,PhELQ>?07JTRQ?r\eAf(.˺+b_GqV)8N?n=l3:pYOpaU1 NvW"H5UODcM_NB=wQBn{ϮXteԥft2:v2:tʠU֥(x8a,`fQ| &K]|a:ܳ9 )Vck?rw0IF.n#n~!@Q򀳵|zrj/y_I Oko=i2LM=G^~徺6~U_~.?0?rTAV4mD]+"`IÖdR??'c6edDgŠ.EҚ. [jbbؽf_z7W!ےk*|ESG%<.#W+.^7{A3M Oa%0B7QQ^t~:1~i,S-ߤL;A?0X UNA.egeij/R4%"Npte뛕cawƒgcoEz>'Y9L¹v#છ?r/wi6W]f#\=E!4i1o#&O.aĄ|(*⫒ɞn19 ?0NI;k?n0B,oSv*m8eI\?rxW\T:d7낂8X.S<Rr$vZ֊{|ł$f|@҈gTvh`J}w-nߺu΅=M3Ek39q^E_to >K$5oO Ur[k_06!*jT_iQ()8 ioяٺ!ޏ~Ȃ V7N[?nۙ/*0wCnysK:{4> `Wi떼=C&|?0µ|ԾD4 '‡afq^h-ƄܩJn??cKOFnԪP+F[5)U@MQvZwJb}?r(t%PӢ3ڲOPMթ7T-_?0edǺđhavhyQSYMI#5YFxĺz@b^?0 ̺ i_k ١!)z jM6?n19' &A՝ܨ1N7ջӠ@z:#a:Hx8[g杶1tCHNZJ}T "!ov/6(z0$H5jn??$ގ??ژ`PGUw5L7*Hzp0]!{|_VNs#[h*18$_w_M05h[4W6ˊy8*R???rBYm%©lʀ5xyge/J&q:*ސ<(F:C=e|!vY?r*w\=X.CZGo,!4Z>1_7Ç{=RgFeY±uǮ{iH<楚%?0 "屗F{,ʔ88?rVdK@¸q7dt=_{6![mY!m'2A>j#cf髊C0.P6C -ujxp;/cRњM b9b2Xv %G#=uS٬0"첢b2YwL(?rn~hߟW??/wHz_@":3Wo*?n9gbzc~`U \=NЊPrgzTiQAѴ0L`_xp{z"v fޚfO %g=Lԓ'z=1yjD[Q*"@D4b0lW!{?r" 18>?nb)mLHA&#O;oB9$xJшCyp:8r.Iu’q  ?nx=cRLf5t߈LS=ğSZۣ]6k3m- %spK??tm^ ڶeIb|6Ѐ#\ gQys6]q<`z[[oh|@`KUpOJ84?0饁HwRDц?n߬} ]^Qmn-F)Kl'&Ǧ|ں*$ݦR?n?r G UjOR' 2+tK(b5IۓuJ ̛Ww:^c@UCwѨOc+(NT92ȷmG[rAG70AtAI_YdϿdl$k wrW46iyj[3(s =[-qTTD=UْF^dV=s+# C|1(WF8s2;Ռ?0P*^ҵqAp!?0=&4lzW݇S?rM޽K-I,$b?rh/W&ȩVUTծ"Q*oՑo5H:k\9Pk'Qp-JNcH$ȭJvo"'cN&Լt46f>M|Yu>꜃>*r9~}??Ue(=JȜO{b2>N-O1"E3)D[v 8z?0JSF\ża".m5D8d0}Hlkȫǩ]u ?0ţhL*ݒ)C߂ z3')ARiXӢ"s _6A5y` Yˢ2+`?rM]4ʱj/hFc|[paPSSB܁Pq,{0:#.y@)h|-"9пPV΃'bɅ|4怸Ld[5Bg|uA yh\Ԋ?ne5QgANOcbn9w#dPMFᗐkb-<˷~`TQ7;me*ND|y,|dRG0@Fw6v8SKsYblM%S-G]QY_IU"WrD S1!AP?0~p1s +#4C(&;r\%Q6cN d@6bAXe-Z,PuبG1*eR|i¨ ; 7luRyu?? nX9ɈZw6/y 4<ͭҧ'Tt:yePXP6n3ԗJ[6G;ه?r8,n??|&y}P#f~zظigk&N?nߴQ{ˁB6*@x֓,}R??Ԇ;,GbT@v_J5J m?0?0UUJ`SLeQd:R`yR/\5xD)kRx:ʟWq}4G^f<˦I?nǚ4pd">J!zmgޯ6mjn)yHӡx^ w¦ޖl]?rt1L\J{ȯpѳ/:čQnyJX'?0G 6/@ IRS])LL[B7Wyh͢׺xBu6{2WD,bQP UhUCN%'iHX1&܌eӏF>LTeLNvѐer&#LɖϤ jQ.~//ɃYp3Nj<%?n1u??1z)L&W7Y$ H"倹!~"w3=^?r\8dv6ּXv~龪Fɒ5Mt|t>z2foOuY<ޘŀ ??^]Oˑ=)OIeұ9xiejv C=Q[RҘlOG1??_bt9c׾Eb{ʊ??2Zqno^ełѾm>D_v_egtBtNz~!>wݔt+XGdt+ =jޡ??BbnVɜ+?nmӬ>1&6ťt>3I"\dwz?08l}Ϋ{&:^>ytؘP&k^zz&"#s'/'C𴍓_m+y@/|51W[߃{N<|@X5!hOK=-vkۄq>~mcs A?nFFm~A#kW)Tt%H7^kuCfUZ%~'[2ݓ~MLl5eJ#3N"z^~ze[n8qd>?nMϡ9K?nѻx(SȦ_h Y~Z^Q~oc~,H,ʉǕs<6ɏ Jjhyl'j]O99B`݇|JC^,v[R w]1(jnWM41+T L?n|2t$I']N_@5z}"o?n?r}nW:IE,EP=] Ax!i㒁&#?0]kHqA>>b>x=VPy7tڂa퍋_ng㑙 ˒1rUr5 '`?n͸/?nZ[\Z4s;%MU4*ʁc -o['b':PpQ*BoE_>k*?nٮD `eeηYbԨD"vrOm@%5 2h1n;^zyG(pUNRdU,[*[*X>妄vw1nL0'L0b(|_NY_%e`^ԉЁ"М.-7ޫYf>iw;O_3LA.N_DJJh??WSоϽtrr ZzLRJW{ŐNؒ6gp9J 2=|??! [b/&vf_12?0002101??NY[_4_*f@wZ NRrOlmipO izKiypJQ[ br#~m{bTy!lQ'Ȉ1ZD* n/LߐZjo3y?r>[ vѵjzb=;U?rʀ 5hgy@KGMJI?0ׂKp(!wM'Ǥ^%ćU?0L" -D5i8ebW2與!tޢGybk\"*E7N>DaR;??Oy]?r]e#dz?n)58f 'c tp+-+P񉈃QxeWɲp'a,$K®F*j??[/6i+Il 23|f09"\MB@7N{o?r??(lhw&1y7o%)Q9z?0ڌ͇ aIHRwh(յLd.}<5q=iu=Pqbl?rmjH8`N"çyykBk֢r_\<(OC8G cRJ , MFۙSvjOtDd~*l*6(tohzz/;z`<n\\ f[T d?r¥D7UUK"¤\8q40L"?nF$WCĥai1hb¸+]s״#/%tM*ѫnC>WSϡI®cKS?nAO??[,"?ntOCꨆAN'L0- =?0I1`KW6BJt6$V!oE=*to7^ۃu.?n4m&ɝTa酞fr~˓!sȷԟߏəQdR 6Mxn8A573ౡ?nr8UP^fWطL*vDzۗ)ON*W`yC5v\k"RXGdIVpQpxO??=7~O/En^ΐwvZoz43r|YYƹ7,;V\]ݎМM8uJkؗ/l >FQ4cYv&Ғ˕Yϲ$!:祉'˞K?0h u4PN7:/ǤA[ʇeh鰰Qs[-g_p0ʓyf(MG#?0$^~~)q%?0u9!Q0?0Ƃ?r'Ő?r%$<@7qx^?r@njVu}g(}kUovTn+F?nK-YO?rо+yjStmedC*(\l?rz "UB5?n''n^EkQ؀ĆL??Vm;Gjy9GQ#E9?0$٣kPeB[ky2MIe%ݝ8O??`ʂkp#?0T?r~?npX8V#.7]d!0a`8(8Gd&[SUȷ2u#Bs˂è]6L0<T?n-wFtaKz CSᘣ6 #4f*"3^= ' rx{?r'@GT3~LyEJ?np> Nf??g(52N{K \(i?0; +|ӱcC|ĉ\zL9ʇgjbW2V???rJB?0XC2r1$><\=]>INwբrEB˔\b[|ct?rklRD83nyh2X?nzHb 5+VymW`9k`ſg_ɘ$Tet˖H?0 +v;x/1}iϳ̗`Ӿ%k^% %֘0 :J\w4HI~|yE=*oZ|=7anoH¸*&EJ~{Ypi[Țh7s~a}1`l.ӟf~ŝ@ɅhWhK؀xVe~]%3U?r5?n;}.tik B x˾?0#'?r|Hɚ[߀sz1n("i,5貆?r.+1j 9We8Iu0Bo&ێڡ8:Y ?rfnwO#4Z98Y(K2i?0 2`f+twll +?0޽G䜁^y+ӓd2&}0{?0w}'EOh?r~ $ֿ3Εage{?0v>/SubChKt??_Vtl.j=unK|+Vyy͹馡9l8#,"̞*i("ǞC/C^EfFXw:9k>-] `m_*ZP̤"AnUS;@"PwTeOlؼ4>$>E2v1ih!TB7LJS:Z''a0PaԤ%ܘ |#eWP |LW@d/M!)?0O臀+uɷ8,<؇T:^j[R3 +uQs=m?n;j7}Xе??4O;$;,UU7s3刕T_瑰rChl҈RH?rn$N_gNr:aLtCIsSk@$,鸂&s>œ_-tZZ眊&싅_rv̓ƙ׳a:6#|Mx$GKf3VT@ۢQ@YTd\Z Ne5i=af*pu!Ԩ0p(t;4M/BEi?rq{f^M \I+61ߗ>cP-*cyNfkȜyT9_O,M}FC:^)UQݲ-[ն&F8Y\R¬M+Imuu:7l{'gЛVdշ&Y6T_0&L-(Z`mU*ryvg kes%Pvٹhu:??,=':R;;NOtW3{*~v6>kתѧ{ܣ~{d ЄN\ٴB^ sg V__zS2ZyO֔=+Ԧ]խ?r:_Y:;V$Vz69n9OͯIn.ou՞??F y3f|+??tq#2qӭHak5Yz1)փt'``Nw!7?rdFg幜+M5˒I'T́Kr ]`P3beuF ?rBGdƧ^)"li幄?r??I5?n0V-t$Noض+0cȀX"^-]ђ$9بi0 3G4]d#_ocXԻ?rn*Ih8nBÛPl~j=>Pwf}2sWc 5?0A F|`?0)PG9" Buf%[v ,}?0\~lpѦ7܆{;J> -C+{("BHj<mlU{H4Xif^y59?r<2q9n*gJ&/laK阸*v2HK6rI+eVTYLcHQ^yphq^'m??v8U xb&V^A+Ґi Td7U4kf1?ri5 k+Z(.JoRρ#Wqgx~R\?n-&?r*hՏ#?nY}U `ϚUW=nkd{c-PRFYw;ⓓxDI&Y7|9=1qm=7.i2d6:F00Ic<7:/ tc>?0To`E+`8A=h)t&QaU OC~0+OpxraG <gPxE+#]Z?rm~?nB:pF̀`e%_8xz Pg'"D:0&AQ1yҨs@5鶮}&SfuNEKO٤<~G y?0Ny\gD| ry^6Ǭz⫖Xֱ?0hl. fȚ8V;f na +#RDmT)ݮo}ov;Da?r>LI꜔TcZKs!&hb<({\|3qRb]"bғ `DZE!hs s|*T| L4vNxcXxogV͂E+:7`y5X%Xd8 w7ec#??$Nr\&tLof,̣bJ6t&I$1{̗,eI$!h$7|C|V8/?n `ƺODL1g|OHE}؎3݈UfNL??]qi rag@k[-F/^|?0]f7Z@j1˯O-hCiDL흴??]+3&`^5C??W$VPuSF???n \ E?n??}Y]9O:9I kWxCK6zݖ|ku[+A,z=`(o~Y3ad[ѥ..0|{V_:H/6= x Q2y4pwIBxwˁ]gLz$!R\4M/ć9} Mb{_cwr粈X65.!bmO=]S9H1PQ8!`zwGYٶ⸧J%duD/p ESdIp~AҐ$??Kd엀r .͟kTkߤC0;+KFA`;!0!!)^ʟ5.4],{a=V@9!QW7?0?r?0bJ;]1~ S-8_Jx&hxPxya"v3بQt3IlN,KXy(006?0ۈ  f< f|o%#`б3,~id־KnU8B 5zL!9ާTYF62yd:ɝ0`lɍdYzewyq v3r=%ݔX.b|9R32q1"c5q9n$d޲$#H-0ˑxNF?ny!%a`ǁȑ5ʑ0r'#;<$Ƌ,H#k.[`#r;wfdȑb-0˵;#O&o`~|eIF?nYlY~򄑂?r ,ȕ+a12q{9/?r2W8eOFn`.8kKQ8d0q^YN4=)rVlx䔹~ϞH90j`=0ՍNSN0~9Ív2N#K?r+c`5eF6~n$\%d[,c0Rq|ئ?r0;?nu{w???? ??i7ƞ#ͮ|ky <?n52 á6'bЁq3^$bnC\JxA/hhb-T 07E=G?04I;v\[E36J^i|HZdK:?r,k׳SK4HsxY=_hzo^-]ÔW?0asHD9uOit JtL0T|HrV/1y@QI\ܾ֫+aW%7;ǀ;?0だ4tp_PnX7@a?n;Ğ`nf1\$P8O8Ĺg??ylcz;:&Lx"U{?n?rPm귃RӸ|;e?0U^.<\MUs(C\va"X!x8Q;{9+ F@30???06Rm錩9n4}a_?n"lrjpd?0eFݤ%mƁ_;-*GO:У)M?0l\z*ߔҨH@AWdG ``DYpew8pgYm7*Vg,'./tn6psݸ8$?nC,s;c+в?n,߅hȠ eW7t}N\IMiy^{;98D|wې]<+bi(|11m m43,yy3Ziy9gez‰`1 qM?n 8jsQ_5xb!'Ptwzġ$Q8blrv߽ڱ25ピfYү9| |+N\IFF:"% #qDhTwUOX]ۡqEBI7hAv+ ̦i??@A,eBԃ⃝`AM5*f9w(UM,I7c|l[AqGo`hh8һ-i"޽}['ħe?r RAhl]ؿn̔J]*Dh]o;#AUxؒG C@t6=S⛕zQdvkmi!cv}hRXXJT?nDo$$ӏecs:#AP)~8q=WP$ySEs/< EV 䘸PU2\k,G";H/@=tk‡m 8X<9I34!\(k*/%M??&:@?rlv?0K}Sq?0T  Q_z>,, \U0mB ޗun$J*<.??ȏ|/0nN\^nZ̏XM(NҶe;}ۃ64jϺl[Vhe !?n+`T)[Eg񴊢@e(|[OO,P 92, ծqs^}1*+6,nECp,ܛ s?r z,7\ zཌྷ)1U-Z?r;ݶs O_>Sb.|,&?rޏ307HM (3NPJToY*)cu(=JTe??!}^@=90km[?rUx0/#Eq4p԰.ڔ{W_JAw?0eJӋ\ǁ$1R:ߧ?rŋ^]ɳD׻B#eS1Qaޛ5ʴwm^SL9ېĐ8|oD6X󙗶17XQ(d3BYt)PWhA௰[XS7>C?0/`JVRY D&˓"Uuxi#wޘ%cWaml>K} tC^0ep]ŭuOٶukmdVřnK9ӝfYJQ),.mh.+'e3C >ms~~q@8&V h^RiBTfuw??DmXN 캰s(]??P\ǣ>RD?nSA)E\9f?? p+$?r/k?rЧ?rɴ'Ex`[b_(wɕW?nXU(?0f 볥C8a:MNm0LNT mo4{ͮcQW1&L-x ?n;6y: +d`Z}wf_6S7Đjw譬C^?0{!@rh…|n?n]t֢^H?rھ]5Z%P/þrm8*?rhQeLWr(/Nf&Y?r4^Fc x(Sjak.!V;M?nmFWP 8V[clK8_c?rToX,Q֊4xb{Q#f^3NZ'9u?riARCMb#Tfdw &m[zOOE*f>^aby?0jz?0awHlQѹX:4}Ȋs.ؕ<#[w6??x &}eӡwkٜu8?n{^?0k :>lerlnhDɭnb0ٍ4=qP??321/[Nw+@F!2FR|>X8I[LB5ǒ??۰M.D4c-ra&gx/C/DBxtW?0-|e _[3Fn/^kx:jBھ⩩Ķסs PpT??:,Y(kvϡ?0/yE_0|??ڐ<%Pvx?r,)f]'??߲3Nbc0ӭ?n,+eAI?0TpPo(ÿ@qե`+?nW0``l>&Gx_xremC%I"Hf:b}U#_ߢmwGr| FCG$~]QO4??BamNnXy`dK50R$U ??*f[c\7BG˗lu`^tuBߐ_R.Qٔ.Zm?0@~?0}ہ*s#PAC彐 nK}~Z*[zM2uAѣ+Q{~3pbJbŚUA+Wsݦg*<ѢӢ6RMẻ?nf(hGeۥCԽpɺ,wp~J~2٠?0z.f5d֪Z8J`#Y&J.N2_Cԉn6zF.>O1| 7ῑ[!M(߼90P#3v3-+:-%>=:??@VF[ۙ +#3z w,slȤ&͈HL0̤Mlv>*3QHJ}*#>>lDk<6{[7!|4)zn"?nq#3;Ňf,]x*8j[to9@$܇으mW@JAv^֍Ki_mf<^֔ncYH%gF)df{ ߿U-;A&`I^)O${/"}?n΀KMqt .7s{\m@^ߴeuHhHkG^_]t[T?0N֟Β)2~0PzjHeݵ쳕pEV_a6{B<8T$=UqO4ug,4OuQb%w?n6qcE T0ב'YVcžGCݬ,b'hanvt!(o\-q"M%O%) (f=8$ZЊak>Fd8Ľ`dO8E=0joK^S~Y nu~Z!={[V`@Ww/8'?ngT5h_=5tHo7Rf4*Lm?0L2wD% cI`/-+2.өM%z )^"ۉFz!6?0g?0sj^*M`m\Uņ_ ȬyL(1G7OesWU90P7""y^PĈU;^؋J 9rl]kXyyml7gKNmfIE`#ue[Fp_!xn_n R@Uk^̎s0+C +2hf#$xs.{egsUX/,IG/b /R#[,6ڕ=?nP?0+KE`ʂNp㾰+XkkHN]fxp;N3`yG/t8IoS?04~0;NkD?nq`";ښfJvh^Y ݪpՠ^1rJ=?rl+')]5~qA\?nԂ??>7oiRn]V%SN#U_f;{W̋\ݶr_19Td!-8Y 5jxc #`Vb???raJs}??ϳaQ/??ݚË8ƶ5(w*2bR8 @BY=jD$??w-|K@&FD???nX.ZP?r,4(%w ϿҵXfG.S_rv?r?rL'~'ً[g7s؃lw$=8/ڇ^ ˀebWdXT뤅 "^%+da;ڟula.oOI-u>up?rUS5Xpp~GfSwg:`(;"HGrj,rWƖ2Dd+MUz~+TM8xc7F_t/`i&t(luJ90HLs̛~C lSRmh@Q=|+\γDNI=D3$qmDXxk H?r!-`) P俽ǰsA7ߟ޽[H\l$}_ٰ彷5G&r,?0C>yi. K?n``QX#NU1s4#󞴅fD7g>@j\C~CP^)[at"1 w0XMD$mq#m&6zxElt3B?0n?0{Q׮XlmG !^Ck.U ,ߞ6C8RrqɊ8Ѐ??AC@UZ2g+ Q,Ͳ?rl"bN0aaVZ(=lgNٻzhc"68O+.9!.J| ߹:S?ng`cm]g5mqV0j&iCFDW%P>j53N(mYC7b!gVLsʝ75˒ezXM,uPI%O.ACѭ vj|U |^KzTnWn5.`rG܄ؕE߹gjlk}DcbuuA-e-=_>k8>R?0g.ӊ$PJVn(PXAƂ2\ԠIq?r.56kT˳V\@ģ?0COFy&cv* WV쒸vMmN6wRbc"i*ɻ1ڻ OXrȉ״_#=r'+_[mrbJIz0XfnNe31w+a4VV#.Iq6lҳf^k*U_Щ.튆Auy: M K=F\JqB~$ svU[Sh8woصd+Rs=P\`|4óBGIYj/%X3_u6`;muزi`[[_(#8$}6soٌ/d=ڤqzagx??UIP=42r,,+j+d~Eq C[{??;Nڎfz$Z!\5/1`sS\BW@/]8?r~>';c6\O۷b6 Tn͝B{m⫾a?0(ȍ ܴ59D1]/-W-`\>aXH%ψ+"kV?0Vpd剠ݛ72lG3V_P C}kA+Jz[BpW3񓷚GTÀp%ic6u,??T3u(+TJupˇHC+e 8nSndd&,Yբ?n$?0Ѓ=*p=mXծNuvPo,NVӰ-z2󇿏**yd$wپR;8\;!ɭn\Jm;b;ώ%yHRb3/qufwvRz띺A y,SΗ{${wPe}W3!_ne^wĤ@7Uo+|J ݤgX E* p_p7.SSRp֮E1HvL~k'; m_h:H^s7!%??!0ӹ}sLlݫ6 ^Zf׏,FAuE*$5nH#ٙ2q cogi/8s4º\^hJs|h~e ; n;bRf5n/wć.KխiSW @FEͼmfzN^V m*tg 6Έ𳛯 %G6mxg"$GIhoD{[ٝN7sz–sZStM-0.+Κ()6=.3sjZ訆23 &WwcgI?n*u 8vӋx泐8ova,4L5qU`?r3xhsF7YR5db9^HtEj??€\ iGQ栄鸙iV$N ,SӲ_lgB[/\A8˗/E}pvC1mmT[r -v9u޾Xw.jo[-A>%U8J tmMAVڛ[6(/1#7'Ѳ.- > 覥#W'G??4:?n%wc@`'n?rlyۯw^[ $A[3%x rp{n+-FᆧwT~M(Yy6JF^‡-[T%QRh2» +#h(*rALaKhU4oYoh(w{?n6m c|wV.>LwF\σl !?rrM[z'(_I6qXThqƑD%*AJz>+MW )(>8ETTr7L5BI6)?r}h*WR˶j(hb>vspUǕ?n;\nhXj?n??-fǶ|nV?nO(QGt ( 7?rf{J/+ӣgP?0#vPbDTi \كڷwGo<]9Z!EQV@ o-Tpd??KyV3?nAiathԾEUpT,^U䑽ϰc\c bp!A~oI6t3ܮrB`ǘ:^֘"]Ť` kdj;t:B~FCn?nA9}LH:)/K sGLj턈JEhC9L"2!'7-GT5;.??Yg1 ahxFպX+lnVB4Ugߨm6Ѩ\L~^8c;(mcM4n"Z1wc롊dCPӧDz(RAO>u[L¥b9R?0m{Vib`??f0 l2?n#jw(),]m㠰=f+<:jiPl3Z#"P+5JB*VlT--r??`'3rܥÅ_jlPQSWzIKDko'z %e^bqtAU'_s$"_chw7oS63,2@aSȟ,-QG1h(9IF߁lL?n}&yҡMFmT/YUh(.:Ż?rOe %?r3GGn˔qw?0/m|kX#F7QwPǴ_.փ~of~p 'Ý??AlK??Y|w7 ^CA =\$?n{H8 r`ݥC3XAc???n??|8U?rw15Xw?rD)^pbaƾk4vvXh0Ѫ} ǥ,H{fN-Vᲆ}/˫PClm_ÐC&I\I{kȏCQ(2$O4Gd(0~/{Do7K6=[ 6"y&2#?0????#)a>Oᢉ|lJEs$.KJM;h]c<,0(5Bgsj r=k;&n*؃+N+@?0?n0E.Ou2JM?nX߽p?rCŌeGтf1$3n.fH``ҩ3Ƕ|]h\-mY-vpz5IԪۏЩZKO%4Ł??5٣s??os!kf??O"ʥn@YK'kM,yj7hUij"talxlߞIHELvϤMgZ1?reSېMaEuF9e#~0Bhp@+dµHNCMx;$;c#.V >ё:%~NYur'Y@JЗV^׭.5h^ĄY;P2hI@IҍeuO_t,.O1}Kzc Fmj_?r* f,?0DS!1o4^ڈ ??uPstdz,H3#Q X \Ȣ{ ٶ~{A8PFS\ ;X@B[V{vYt:4s>GvlWr?nv{^p])u^y,A+Ltj&N ɱ}jKrREdU mi?0?rȤ 'e|gLMu??O2)JJ8N2yuX *D.D 3i6v^T:)x.-Yj #U]09n9$XB2%/???r \-_=c "zbl)s!Rڊ+2Χ8_ў^vvor+5*OvXGI۝I}]*ħ3MxS]mw ш??q+?0pR~/;es9삄>80y(տ)Lخh#*8i70]gD|6QSط,kokyr ,6_3``df}wO<ǧQ&?nbFe#UP"dd \oh+^Ot??l=PO`Hփ͆ ^jb=1Se9¯1S;"^jj#W>gYlc1,Zt-K??{l1>??a@Kݎ@l׉ⰷڜ=#ƾMۿHCׂ(EtJH_m[ג5t }loN?0{\2K5tzRW.X-Y CTh@Ccլ%"nFd}ѯ??$V*(U;gߧ=|%~jNoj/OR>/7DmZE .5k"Cϝd\~tS+ u= ̎\o֬)?n-#>=YZ9YTW8o+Gnf,5Btw [08o.TM$q{`FS]٠yU`8s͂#o,A΋!?nؐaC166b@ ֩`l~i޶ *.a26ο[Y?nc(v n;?ro&%bGbNҿ!RF2ѩpC,JĮ҂[Dd ?nY:NQMOD_Pɩ~7ԿI.XGO*"IFm!Ž_1aq-¡ X-dgd?n7x=@CX*%0f/Ch Zvv[㹪~C8Ҽ$x`FY>P mL߿)@1?0Fu`QtЍ6A(('cУzi0[*JXygZ&aT??` ٝp lm='Nid gPl4s4}liHyfk4&[$ VlF߀o$Lxmتя.~_bw@KU C2,{V(9ߠ@+_kPEXA)ᄋQ͗w'RJ^oI:+/] ^EfNE,a@vŏN~o?r|QmxUm믞>~ ??]ʉx??gCe-r=2+5B,^ޭPe|UpMMskѫ)E\R,E 7ۨ!??z5ߩ6;Hoг:Htk jI?rYh>2iF6zuP]bq#H6 o]U.6Jښ2kI_e݆;[\K(2 X[/Nz2Hީ\^MjUb]*/jY@ ~d&?0F_a,l`(uА}50^^64[0~T^n:Fv'*Zտ% IBV,{,Pȴ9GZP`o1O1Gty[^)Q?0??.G ߗtq"Z|Wl[%[ӄ~NAIGqa;| '{.6LĘ)n{6!Z!"-ZzVt`}ɞb}KX{+g-^3??~f`nó"r{zoeg.ٕ?0cڵXҹo$aC enk>o}sg^)mQL˜1 ̂.kJ1y*k~_4/:̊W`[O9+HlAi UIڎŁkdԹ3T>8ΪِCi&vnahR2W߀q n5W毐/ϢS^7;dzDҰmyӹ3µNE|4 ̹+/exװ [fWʈN7O.?0Mk@co Qȼ^96vuD,aS]͒-& 5t8u2;?0pFwgG8h^H:6[& ]zsr+ 񵾏t{iFZ'_Y04 !ZqeM'&w"?n/7N\n𺸽_a,>+Aް?r6s1Gs%3!>^46W|\x!y?0\iKR{eѿrduc:} EOjՉg{5:~3 3b5XQ'x|zQy(ʏ??(J$%ToJeU'e8I,-\$4Jv0MɏU}mY|79mYiR2;g-./a)K%aEޭc(+B 62 lQy}-'}9|.ZVk|6v9#_m9WCzT}eٴA{V @2񋥷Y ;g??kSu6STk(/6cNVeߥ-XO8PV?rs%Q$N7eզm?0R>D$eN?nߋ~pXPe׵9 ]4KqT$_.jv)ݫԚO_i413+Z)4hK5v?nԲ";eg.Ns%ƚ 3kxpAD+_W.#+GFUZ/GQ1yδɱg7"k7ái_lK!pN|[Ҥ vZ;`A~R藾.ENg,ɦy$P?nNpa)([#"U?0OwlUF߭.I,'i?0$#g 1ձ.Z*Nژ>RmP+FL"*n7ѹXf͹s?r d3)L$???0B۳FN].?rѫ0*֖`P\XtOth7U&y#$@m8 MTbf?n v9IRn ۮb5$MW !kx-n{y߸Ji@??N$}r9~Jn[ dȾr\-XDFra6u&Sq*kNp:`8Q8}82J5fSʮ;F6t'0fEvIXAW'G!{[ň1,&?n"p.Ł\," S!N(dC&( 2s,$U>וo\.wn&]t,9C=?rutIt\Il^B\#k$~NXz,/3]$I\7ZUE''L?0G??alj1o}*RZNX^W3EkPYsC>^ﳦ|ɪJݿeZ j'%Ÿ׾1"-[\ P?rlò 7l=[ɎHu$lhx!Gͦ0?0I^dU60!:ITekR.ؘ(ݣyĤL=^Өbƥ#ۑz{e?rt%D{7o0I O6;;W[=˵S%3,5BQ?rSlj\4ESUYm4֢XjlZ,*2UD#ŦfC}wi[55#7$9%.Ϧgv}0 H^+8(& ??I hWmD P QW_mP%lr-Z_ ʋk2M;-z;xg8BSFݸ)ʛQ۵l~7Am:/$\E}v0e\NZcIDy1lFz=m.>d%1]6 :z|5 ai7^tU1H,\`kۓ,cOתoNTH*W'!bf+G>vBIWEAF^[)_C+NQhHc???n,F`d:x1\lbf̋cU#(]Q/:tol&.tпy^wnMꈢyh]&B?n_J6XZ=KJ.<PGl t2p,[-|V*&+`OPm P4DI>iTO?r$!B "KנF_995A`ѵvy4<cYV5.*؀qBY`ʶe,hnmquCAs̸85hƁ'(3 0f ??GtN;??<;W^]".ioz0N_aR0dOe,^:H#[#Wi󸘈&+v "za6?nnpg;Lq83-],:*x1C@)kJ`BvYU0'OzfȟlMuBLުt ΝY1 L)y x9oc{@| je]<}f^R=q>LT75u|.@8|?n$U8#WF@Ե[fm,\]ָ3̠%lTHE"E}fSӕ' >b?07,OJ񟙫r$+S0P&D:uM.fr3cB6 .`}Gu8jB\%>٩ЉJUAa. Iy?r͆Ȓ{(`>ych{mYu;3ռek@vJZ5<`pR 65UKgUȻGwNۨ֯!*{ΞvBykQY,0-|;_kyK+m0VL~F|~1^~uʺqD3ع&MfrnٟoT,B-E*]cp?0R!e 4Mn>YKN!pYdc\3el *ouu2+vWgjUO[th5(VSɚF-2_?r:& %4u&1hʸkn^c?rhY~"g]mA0+,P}kM記WYy4ɧ-l3јh'ذ?rFݢÂ??aXt7DWMExЍtSDOZlhB=C"??4YޤG?r#e䖔tl:_'&DlS} ]Eh`>|xpipvC0?0f-Q6Y}a>=b?0ֵ`TMfrfaOqzsq/O#ģyd\Q[D|*LWTT[$+sqjS MTY̖{rToޤӓx '6s@=-k$GƊ6Բ%L-dhZ|/4Coԑƶ45?0v{/9n4[!\!?nu,x"P2gKgɢdulϕ k۠Uˤ1o!PW)?0XR򄐔|oSi9UlZ/P""?0[4_*-it`u2)C??1DTAZZb0lXV?r-'qGEJ#n5yۧjfFm"xUF5+uɬr.x??e*9K ⭿AULTk QlK?r.;#C`Os[jo_s{dX^m W5LܤϛV԰BmA/f1?n !5?rUb*6yJ 2U"m#dΏ3?r@Z4.N^o/ݶroheݴ弤]*9]k?rC02?0ϼQ%yԳ7F*S2CϨ.Mj= r y7X?0(dHU8+Y6Y kc(w"x 9XTs =uaL;r)˘Q |?r'??x(NAR6 mGޫ.<OJIIđ&zeق3uB,}O^i)J -\LNA!^CUxqNEt_*Z/}ح:"__ku51-V7=\ 6_y[@-,U̚1V?roȡnƀ?0?n&"OBIN=Y,̚x&Xh9;JUe;CU'#UQqS +#b'vCz-߫-ZEܧ藯?nX:|RygW }Xds|gLF2kw(߿0hq3@z7۾()05:|fS'lƚ;?0;jS!9H`g^iG{"-X/&"9R7dC?reHBIcyV^i@WCWŦ ?n??h$ƮP-3թt`丝=Pڭ9Jmkr_,;ipGv|݄sdD+Z6HCU|I˫tPXn4,?0V/GZc>Z0@sbS焹Fcm`G+Lֆ"iDHCe7rs =ǩ*<ԩV?n%{zpNHEf=Hm\?n'~V>QXF\RapX\5y-낀S~/Eq7-mmpK1*!xlp_|àNuPIhR~K6Z;1y3t oGZe`ZOU`ERɇGE~>z?rdp?rچULxmԠ+ЄtmN6j'C6]uBܒyd2d%`e6f=ud`+?nGk>MeZ8ouȊ\n1{*t:hѴf;*&8*FDfyㆲ%gvOc:CPe:z??^]ݏn:e7gDAS??I 8S8XZQ(RPzĒ_2grn4g"9qEOWO-0kO7rvӇ?r?r}R?rheg|=\x-kM*l?rZ?nVM?rMV0yc+??!<~ϻI~^B\eLvڸ#;lg*CktQ<朱hPNWA \+h?0Ϧ3jJ)alcl.ЫjR~hPI-"b%3RȯbǦ-s}5H·=G y"??c,|é]`b ??ls SQz8g`Wt<9Szz|}@iPdQ8M=EER*GWGNڿb&VYzZ5O@]CŨ?0+ޡ?r`a&) ܐ> tVGSZ{|ʆG!Sض\7;3&!n9nbO|1vqJVpY+ߵ*!s#&-;`FzBujͫeu(ni?01 żSH~ CT~ދ}kΉ ~魾qmx!_e֠( v f7ؐhYauҷ8bU??N?0'lA֮|bܭݚ[SNڏ{&AAb \N?0P?? Nb9hBmwnX J[J:)>dWÒ6 ]n H?r׾l'W\ZͿZ8bf'‰zǴV׬&pXJGe̋N3KNH"Z/@(A N3P"K»ԌSXT{uu~ ٱd刞?n*0V:I01S#I0S"*\['{DckRŨ'J0~GJ.xMYɳ띄1h%*BD/d^iqvbȂaK^\Iy$qzla5 zY8thԀBu9FAIzE f8]S0̱klAɰQɇv1GeL _fim>kG.ʨ[?0l|JvKJx.-]?n%s]!#C1B4#sR(O?rY?0,?r6x}!ݸ k#]?0ƵlgrU1Zƃ`bY`Ǚ6^AffB@Z))d.N|af| u(L|<oQ#& հߡIT~{|jCyxuؒپ\K[=wU-3zm#Y*džeXm?nZ6"kFɀQlhc` G?0vq:P&f65CkkKUCp#qgGBUϘhKGO&O2stfhmSsZqv\sEoХSFJrrleam2%S׍1w!yOS'l)&{s|XpJB W>6 =Dп1i%0o&-l%ֹ??d |AN; 8cetq89CB1E/`n8"٬X`QXU#@[c[oH?0]J2]=ܘ)4|0ĄʫYESz 0sHQFgvY뭸}?r_]uoBo 72"~%y3vk?nZwUQغFOmd}ͽ^k{Z},7t+8](OpA2B[$#mWY^ƨSCW=@eC^um=a{\:EImCxY(q\%T8ad/ez?rhl0VR h|fpyQD7k6\ڀBNBUu_}@%1!$lDӢ?n_ګ+>y3Ո>Agn R U6Co{'x* d9hI%G.[zPzƽ`%=CB")_9[T|ĭ7I}?nܘ_aA;]WK:ݾZ oJea!2+jCV{+rQ^'Ӊ&*y}&hP*Gs=%nQ\㢞??8q'"v MS\i~voރw #2 _+٧N.5ӽMOeML.KμDu+a՟#-.>&/bx:WXhx??tQžRmJf?0.\'̘+T\>*Xl qڶ 65!wـ vĕeMPQ?rЬ#?nU\{?0㟛b~6 GIqt?0(a[KMQ?0I6-y6,zݤ%:zbװ)F1Jv~~ut4 / -|?r00ɻƐ?r &]VQ¡tmq xk ( o7a᚝ϲ?rG$+fj0>EdA\ž)6BoØ%l,WKMdЛ{Ee5wuuUw`^"tj.$~BsmC@㜭E9j{=`I%Wl0Jh/^i7x0@|jƎv_mxS'.3ޅQ͊L{ +#+<F: '?r4KO=W>5"PEE:T'(Y]\kH 6\I'Ik>hAWܔ|}#|M>O?? IpjOS{ |C >Ov_P"A.}Ѭ/(v5܇ jD~Լׇ>5?0x;/h@[`5pڪ Ɵ?0]`=p[C??SÄ???rC_h_9{%JӀ9p???r?0 ݷ7?0K?0?0!*/?n<ÿP??>U ~`aO4`egl⃣9cß9'dƞ}t_5.jN_We:?0+Uq}/3ǟ4gnt!.3 xE󃬽`r;>®9kWג$G4;RwLCO=b֪Blxf'Xʮm]Aͯd2>Z=8˵F?n@^>PHlyK{BVCA#Ǝ{λ_&,7zDTHl9hi6q n㓧H6Q/SfB?nSA<lBs%Fxd'ퟭHRΣ{ώz^ǀjĜ!,xN0ԷQENWïR3Z ?0߽~tw.|?n=m~Vbok]*`ԜZ[4I^xgJ6%Vbk* w#{dѼ?rw\Wor<޾/vЏs.umʽc"A2Tɪpoˢ????y,+ߓǂD}6??f_G_?rom@W?n <VTIk4uNPI]]Zҵ.-r\CnY9f4:+bBB[~;NTt??SPY5*00:jj5o$ ]kO4#DV-Z&W}#A F;oI(J@zާk~B@8K6\*ҭ5Uu:Nv,QM[ɼ1L'x6]@oQB[_ߪب^coN`7g>sٖvז4?rUH?n?n5,Զ/kN6tj`iDx@A%n߰  ԓ*dH?0ܶ7x+}x Z"'!7%Xe??f0-i2wWCļ$>V  BU+Ġ7WC"/?0 qgp8BfdLŌrϦ|9Xt':vLAp`qx&"!5I/I'Z he$-J&a!Zɫ]왉EgFl7Of퀳0iI4^NUcD'A04QMYT}jW??&/W؞TNN>}‚0r0Y@JNP}Y;E&M+jPjEɡu |?rL?nkjDr¥|j<5 zamugy"áȅ>/n5?r آ{i-uؾuI/Y$ßb_wCqxqk,w=uWQ Cusyn< 5b*Q{)-E6)뜲01??]h-pYp8MMhhlr*d/ϋ!>BY駻ojU{|eu0òvSɳ=LV}/!AbJC??[ѤYjqfofuah%Q?nKn?raqWhE="1*_g}ǣ+??d+ q5apb"NMd5)'Ww՝}uӼPt~ "jD$1?0@K AXH6ZL2 VT$1ʁVN,.pmwYbISpܮjXCgWAX09;qNV˙r9YM*˂W\tH~羁B.[],/g'~7>`N؊mZw eع(HksGgP_xF3U;mبddl]{(|lI*hΑZ. _{ko]%VY2ow.#NICxmlc%nƴBkjRVjh}E l6BHEnE[*v2P.ƴ!ك۳o~) T~TkkK>Uj!>gEr>?n{>h9 G ?n3;u/WhؙŕNCDUZ›?0miݑPZ `KO[6?reEЧzI<'xoue3:F @cIHUipx[/:fEZ?r'tjmaM!o:Ϻ+?nXu-M@ =PtnsDa6A3xqGTf5yטrp̀Ѥ\haqN??{V)S^dΞ՝[Ws*RwhRN駧H™]!OGL)nx!>`uXA(ܔ@?n٭vK^O| )]L ;o+gj%&.luYщV?rpbtĒcqZ?08?nAb=r]5MtƏzwBT Ǚ{hr+I0v"Z?nBRy nfmg}=VY/2;>twsA?rBFtxyE?rrLd8ݴ>i<(`X k1=C-9SKHnnZǽqx?n]M `v:6t*{(\ uK ʘJ=)gʗSHUKLJNյiK3P7E:{Tz4Һiyuҿ??Ϟ??(5׸k&:c1~a;v\4aI-砡3q, _?nNQT!77??X5?n5h9etdx>??#R/8/]s?07#8/R,D(᱃6&CktAبxo[&??yrzz\dmk?rΞi%?0ÙԟN`97pK8<*6iYT)xi O>$'Ųv'O,)y*uyөm.E|Ѡ&zRGnk2Gˊ2?0{+kѪGJ)5:C]T[Lh*.FߚWjK9$ŅQ?0F/9 M҃~$;t%i%5#/WW1_Df{g;D_*D#7|qa|?r@:O1sSȟ ISw׀͢`(5?narmB9@D۩8k)vCfkqvr:V鋂VJ[MrF#4@L3ے-xZ ??j,8/?nl8?nەiOsLȬF*仦y"Os1HNДyJ}|yIZIɝ"IM &U#"ǹ:%'??%ר |R(ŬAe!U):q@B\b* a_7Ԍ3Y]ċ/H[(r^HC;WZV *ςZОf*zm̕ɼYn/L\1IBf{s@k6⽡zkPT"y87Nc}hid9`M¬eOzPH&C &ƴ/kۓƁE9eB6bi%MBFAmSm"65ry4;'PO|[SQtG<;r}קd1;8?0,H =ZDžD@=IB+ZّkM[T9ĨC:\^u~4&iz8RZ'0|oJ;Q=Ê|n4Dx^Êd` *U8bVn,w#Jd0|??^^S$-R}k?0. t~BSAd?n;qUԟ%!䬶U.=sY]&J'w){p씱# ,e 'vc&ɽLNςWڣm wQAT?r~ <{5k8u铹l91z]&TGW-m9]yr(7~ۯTZdtH Xrb䙠]Ӕ(Hd#rg[N[}d5јoX2MF\R3\DҨ 3OpE`K! I?rYjid.8*zb;:'SeT:l,92<3z?rž/OI!hϮd)V~nyt7_j|@TzaƦ.jFAlњ:{ȃ<~|ȒF}WAxqG(?r#5O*7UDpCif=V??uFypV5Md>ћ&d m\f+@N2p6kUQ72`tHt`6_tL+u4$LO<`(^"M‹a~p2Q6Ke"46ܿ6KtRo*vB+eSeCC;CwCD|..{G`TR}D^]>Xuj5jVs&RYVғsMp~F[,3#%-ln\@W6umгشBStFQ؉LT m%nEł+Ty -d?nByuPCTƜK?n_LYV?ndڡfJqh[e`ΚQ-:" 3'ֈxao'Bt(/B4qy(UӦVxJ|,s!o)5ntGz٩[)I:tC*?nʡrR8IV_aIwsyKJ`m7#]2yL\KV:sR/ѓxo[Ye(П4oTJo/v+J^ 춊T?rB?n6ba?n2U;t3鰐lJH%??W ͦ&V6\Tѧ>G{P%?0d.*264SN_?n-?r3B݂??0 Rqޜo1^33VV\R; Mb,=* vd[mD7:5?nag5حblC+ 9s`?rlݕf1 c@ƐɈ'gQId@θ^>6::/u`i9ojXOO>nvUӺYN5ƭ1?nWFr^2g(0!h1yB[>hFQ>39B_ls)%W9mv,>zq`UC~x!ZѵÉ߲#`6*k 7QﴊL}*0Q:quR?0%l b?0`$N a59NJ?n"n(F6; 8gh K $[\p?rğ=?0{ߖM>\J.137^??PbZx{L2؋0x"?r)uJ:ށS.o唉!Ȣ[}]!AC÷C]zm#8hd>u(%cv'x;2rUcCj\?0W?0 &Im qliSt 9R%ĢvuTnɜZDF NJ?nUClğXp 'Z?r)ɧ\1n3d g%%Z9M6m8ɞ aʭ0% ׎iRі_Nj7BX֠3ݽT[Jţt7)zo-WJoD;lLcUZCs>im `&wZ̫%:M7aJn^kT,Qs)qF;j`>GBqr{UW|^.£M)8mc_>nh yv(IUx_ޜiVkO O|?r)<8;??e8~|ϑzOhxԙU/sFf&;NyϻM9Ԑcy<3IVB$?rfPo7?rʡaD/_@?0ݝҿBVy2gc!m;#m6ܚv&dx=B芿4kN:?? \O/]A[?n:а-z+p/Rfӿ&1vl&Kzr)u1??k7(>Etnh4;kXK$>W^Lȍqoѳ_\bx>v1DxI]ɰ"h8/* [/.UXe|ļ0W@M.^ΑD;EW>wG7ԭrAS-KDINZkl#C&Õ] 40t0J/.vA?rY՟xD ||FDLMc|k*xM_^¹tYoB;ZE؆)-T/8h5{,3UV yT˃f}T89e:awq^R$H3bx/l}?nAhɑdP\ Aʗ^lqӽnPNMM,6ʻ5([m@E 7qU@"ΡXAvvI}`!evzr_??ink(=rM\4^P&@0nq\cIFnbw轏\hqwWNEiף\PQCe.|}jnJ?n<47]}?0Szd}Q@9,axLǸ s8x!pQVrĖYO1[5Բ??TWæ}֚' /lH@D6f1w֏Z9۝'>oƴQ O@P.C)'HKd7lgv>Jv٭>,V˽]?n>,RmpyT|Qla"-\#-8x+𛦆S@#??yX$6GZDںv4=۽:D>YpłHܚ??p4Ezi͡)Ij"hgꉓD?rSZ7߭8gQ[ \`7_o5=/Q??0AX4vc3{Jž/BƶqQ-,/ 9apie k૨ 0"1G:^.Ѣ:y(ZL??b|E/3ߪmel_Z|coQ|}Gm@G"o^wls3Rm}XLdezV}T۳<1CM{CA%3e>YoI4[fױ9-^!yRTWnT=v)/JF7>YjwklcA?0q&uǶlx$\]K3 ??tnyX:qIٲgor??):q/Z8L?0͞%"hd#j'5c-..Whx1"6 n`0*kSxï||_hjPccx]_:E??Bۋ&8oUS+J!PL?n|VM\vuI4VE.5'"I6+KpA_Q"P0$CliK+}Bڀ*,t@!u2r*s?rsCl\7_ŔE>xyЩOmzSGBmjA7שC;x׺9epEb{~Wf\EMCd΍̲`51+jH>mX@#+vZkg37|L=Pnս)lp 4[97ß5~//~8Y-WW GK_zMg-!~ bm]ljq;B)Wx?r5ʱn U1 5?nq~c7,V>tab1QK=iIpiGZ)_Ń#GEыSJ`EQkGT50tvr"w o{ջ<5T/(.S%~m_#/e˫J??jadPN'qxq@ "k-2"-6|y枠{N7OH'و@rŸ6fǔ i@uٵ3}]Q~NSImLǖ0ڏs7 [|[/}Xi9:Oet.q~yռ!Reb3U;+jf,vAb0,!=CyٔB+ryCX#IoWHA RST1G及Upcm|X9Sk@7e͍}#%Իp2ߔ4??VNUS;/qpT=w)_!4KZWؚ"Ng1s))peAUϦ8\Ya PC՛1_m\n6@'#Ίr;{Q^"qiP5Gdְ(1!SScsշG>r,|pfٞy7`>dL{xp'=uL[҄C:U,2?rI\!Ds2`|(h}5aX"l_\SVKr-DFiT&?r 1E{I+ﯚ}JÉĠsw?n깘,4MiiۄX(.65q%EK~Ի*(|^êp*n*<><0'w>8|Wԋ>{'<[ %(FG?0SIGq~/p?0?0믻U"N|ZX?0<1Okxamlh˰"znY;{≭8I%HKآʎ9_)ǀI)]-BV$vQJP9Sn^Ǡ6t 8]#^{??&1HmtY0AW~Za߅a3{.i'I3ec0Nc2˛h29Ux+:Ƽh);?rU6Wo\`ݰjw4PﭛFiDH}acA_=ysΤ93[C??ƐkrRYf/ yd8A51\W59V}I r8ЙP'#%}pJgҊ?nki?rq!P:UDm.A5`H݃X/hdZ9޸Ml ,^^h `ο%o8E}a8C7[{1Q؆Eeyw5]eVIγuYM۬ʫ"1ߚ$P?0$ q~{M՜mYg[S÷7l*i|?nVr*WςƄCT<9|nՖ ̽Em\$-@ʤŧSƚBZ[￘vz`#-vLҚi4 ZudTƋFwXxڬm4Э'Ɯ7^+?0D~se,O3M 07=. +#G?nh1 Y(X9qKՠ9w@͊+@iaAd8iC}??)6#y*p܇A'_\wm1[)/p>S1$'fU'MuD ᩹S~~wE[_.U1"\ZTJvxM[-n*t-ѣk҄SJZb9u3qB#%g. $㧋,hFa|k{ ( ӧY^S#o泐K y|mbfA=sҢvQ?07dJs!K5OjUx׭Ln-66t6i;SK|J`'u҃&t`DƐ"ԇ2id.H\06{$H]¥[c??|(>Mܥh-"XV4=K3XꗆD,wFNiN{yߍ=d֫Y؛怙7XI5e?n?0NCp$$߹F?n='NݩQ:a Jҗ>sAԟdɗyτ1 {S :W-pKZ^5ss*ͼaKY^y^y ަZlI O9ߐ/-]'!p"a+Y4"??IGiCQΘˍMlaѨ BA mUTyc!XjN o2rR9{S ?rI-sRZ<7d@q*dg%vZV@ 66u/W vg3M[_!;3??8D??t$ZZzqGB.YrgxM U6=eD&MDCI8>.٬H^Ӵl>i0WM }.x}6\D(O\st6aI@+Rrc$${)l6Ux?rh._B+C$*19ooA 38Eǐ?nX06R5*k\|5wf\΄=FM-d?n*r3t<#czݤ뵡U㊤>:fOe}WsgAy%L5^??O_X鱝K7m#[d(")LpcluBֆ5&b(9.N𕊷p?0!@ڠ??;*:lx-w`Їѵ?0m똔>??1x^‚}}6Иp0tVY9-)G0ݽ58e~Tg i??)ӟ̉c!묛hhWr_ǭթt(|#!#Ǝ`*=?r~[ 4'8ZY\rBnn"ɠUf}aS 7ؤJ{E/loCB[9lv{pZ[K"_7X q oO#.&MMLym6ֈx2TϵTٳ|tF ??uL\Llx#Rw[w[NgbIE@[lʎҘ??vgcDC4:ϥao{H'ͣj^jh|!lpYVCKHVꚽti򗹚#68#"~YLl+]c"a_GE.ȿre. _j~""3撝p'eM]t-2GՋ>?nI:N]Zu.,ӯ="pA 0 !SMïv^-~ w$\YݥI#ݻraYsP aEx _ލ?ry8j*벽kb@>EiU{a/V_Ii0}P.] `@5 >૰lβrpQyTq²+jJ?0mbu.ܵ~ L+8]|he^YhJ۪/uE֬TM]xQK Wjq>!)J9ã} =ٳ/e5z6~VZBuv??~UAl̮vk6!q~3o 2??EN罿oUm^: UNf?r ngCuO"7ڗ>ZD-%#8ܶachQ^[[QKJ+Z L& ?0nz~?0"(=(Fbf)N?0ϙx)ͣ!'8sժF#7VbD.J/d>=]A 92kȚx)$LEv8TmNèViE^prt &y\]\r6Li*=q]e|q|1]E Z72;9??wjBoN}:S3N 7e??S^TD@+aI6yГ #k+LL6uMkggbq qK`_v+9OV,dYo2)m:fx5Kok꡴\\\̛⣣U?nO>,X^jȂ&.x'AaT, r/g*&Qa9|%ڥ˷]Y(V9v-\*U0'Lhɰ.v@{As=/^~?nW/X!{`?r+Kढ़K6]xУ/NAnuE$+L.r t;6e?0+_4;{AI6M=?n !E7!@ZXMEycs ƴ hQ3Ҩh$袱@ˤ1vȖXAvT`W!9s7SOL%/t{Cw$w33"Ô'Odiᔘ+;TQ=2,0NgÃJ®ߔk??r.j7?nG Ō?0?nCFW'OYk +nnAR`8:Y<taK@4"Ki790??j^.ܮiHy U!'I%Bnn*dEm -=Wl-B!Bb`uWzhR!?r$4#ܓ£BzxG4Dfe(+CM_dy\|n!dƆkx5!Α%2GxB>4vuDfe(ȪIH!f.u/bmz0/OHwG/BC mYx=IBnxpZhn~hK*dYꇅ%8pvMU#´py ha +#xտ=WUڴof v& s?naTHs-O!)P8iBn7|l8HQ!9kx7n*!7\w8CDnw_IB27Q?n)iZhúޟgCn:D'JqW.1fwn96cfy{nǎěnn??A\s#HA@׿:S@- 61诩)g3[]M06 lMY G2˗QS24)"oV[_(NV q 9űvw5:n:(F"c4ya^f??PtUfǂ>F1}l})!a& 8E?rQO M?r{?0iiIe;\*{MFuE~Fv5*K21J/y!B?nt֏ͽ˸5=;cΎ?r_v_??¾^-5XTۏ?n4UEfoџuB8Bnqvz4ĕ99)f^teQ.RFSg^d$bÙD8AYmHré.R~K)W7/W)SQRQ?ni80rED 體ܵ Lle>=vMkՕwiK^Cx5;"Y\U""xR\vgo)M)?0xk绣 7Rm&e-Y,J$ґ??#hE<*OI&l'wCƨ,RH5xh&[:-gt=1ֻ8Gi4A@x߉өU.(+#\t"+n55%I^۩V?nȨ|ĩ?0vY#sCg2~\U~x(",؈|y\U7_d4~;ĬO{>ȏ.5QH Vg.5& e'o~D({*4\s2qnpĂ~M$xPq?r~ž6pp~ RE>cF Q%\OpG-?r6} zt3*u{`J9q^E@í-?0-X\?rh)U^T4h!Uh"`}TK؜X-`VXj*]+D=\hnTӍgTͅ?nZ=eC`rnśBGڦ h_ʁщsLK݄'?rGV k4Pē?nqr0Z`iTU[ew_ųyL3%SVdb].PtYmp[" %~uXN'o2z%16T^%8ڨcMXh u&QHɫ$|":ߒ zJK1VKp2 aEJi:0cR?0^im5p'Pz,w|xAJK$q{m2,I7?0e,'YVKaZ&{tkଏ$Ԏ$̥I:pESQ9$98۔A{JVu'??IjTظ|o>xB{k#̝ u%)p`'&bNÀ4e?0~H.I'U`?0`?0!o`m/1KқuvL]esu:5a]HW#CcV#|8ˢDr"(eh"ܦ?r2g2LR/8; M>edIpn\uN@|HSo.MuvJize`v6'hDzq OL?0}=񭈊V&tgE18Ah`0gp(-?n4w*J#.O5EꏃۑYk\u<`zqHťIxoEb rݍưQ/)d!XnEu&v6 +L݃t8??~~l=24Rv+b'[y;ML]4o%Ԇv5(*2xppRp??d$BΚ`{e4lr@fw5_y>g'Na9y_Mu??T!#޺^/2ft)]&[},F bv86Q="qıVUۼg/sb'Z?n `B@.e CLVw%6UE\\xdt壪B%}KCR *$h[f*^:OI"%?0?0%C:3&~=*Xd.@WU@YKb"qěc}AapF9Az$W>" 餀X{=hN= tUSNĠIV?nł]qe <8ʚ4g 5hYmٳZЂ&n}Մ??qq*$jIw(Zv[v{z/߲l)g\s(iOI6b\?n=f??Y>x&T]d)>MĩnJ/gŌYw8VtVf}jOKD#&l0~eFM})'$xrƝ)??O^)7A4brCRYd_??L慅k LX?rҁqU5H7=1f w7w&C^DEI!9d,x^343ԙ37=x8ѳ6D{5gšdS??wĶCHf 2& $i(m>Ud괗l̸߄uC󃐍VLuņ ˠ?n;|5llŬ"!ivJU3.*=PgYtR-4@w .,(u?nXE }ExrWɐ^9oZqzopLcl+nIk@D9[:c7.t?r}&t5gY5)Ux ?na`pT/``( (*= 9p ]nEgX˕O6^R3#YsL$;r2?nw/FlEp҃dx;Ѣhᝠe-`PGЬ٬Sdl--0-??&W?08?rz '$gKe+?rHՒre1Պ"YQ)GR4zp({K+1dg$LJ4,ަibO3mISvZfIjNw~$5.\.*x6!AkoERj w@%SYU"}5^hO&'wɒt_}^Qef-fgt di{xKBUẁowgkwQ R)kXl' 'ݛexVe2ia$/pXځ0R|Ku&s9Q5A-,n$SX?nf/KU%t0?0jcyaش#zFiVf#҈n̸B觤}+ŖTl=)^]Kd2ɹ?0Lx88LkhUӈM4ÖZ?r ?0?n>C{VE̿K2$egSIޡgq$lL&a#I؈a_&<O܄447/tX<aLq%Ex:bjSOr??a\BcuN?nko@텕ƘpBH?0p;,H8-ܯ4-ނei EmߨQs}<:JР:v?nJX'{r9a4,\DO9kL87 NOYO'N(9??A:o=OG1WeEΊ)k: "?numSֳZb߅^ `|uVYT9Kڅ(5vФ߶"y 7|IAMatvh,!#9B ~1SeOw(K}}AP5 ލ&.PIF)xD>p)!m#=芠Co]ƸITbM8CLޒ&ikR=a??hEJ_W%֘d)Q1")31':C=[q&<[?r;"H4zpׯe<-an̈́]/2?0:j7g6Sbo íF`$82(XpOK"ݜK7!;} Cj|7猆`5ݴ+Ymu*h늩nA\^Sfs[n'/^BTU 뎙u:Nn,qt݃?0Eq_wO;jks׬.H F\KQb|yܩ:]Z}bv <|9#Df٫tncM V=R8[{bU_L6?rfc}+.MjZ;ʝi#N!R6!Aՙ IQ 7.VY)K!SaCӁ&w#'.~{9^s9K^ePY l{m!Qx QC^UaHL$.L6c.?n?ni4?rUJ^Mt9m5oEU-rD2Jy>*DiWVBli1&QhQlr^RZJq7+>'&a{?rR](?0-C12b`\?nKҡ}$P+(Bu^_beRҶrގBU l"g8C~0 &=BqNpSxr㺏`fz;x.3enAkNz?nxd ;ɂN\<}1Ƹ})b\Et9XQ:Y?n'3hίo6:??-4.UyԽqгf6r1??,`Q^NeV+&|ݖ|m= VJ߬D9-]+Шk";Ž}Rl(Hc*UhYvmֶ((~'ڪ$۹Be ~3ϕ??M~tg8ͩp]dV (3Es嫨iԭdbr˫уlj\F3Q?03?n̯Ɍ~zZIy</^=yXϬu{F>,N6`-=v!?r9U-Q=̜l+d/^P4y&em*mMnQ:)CFR9 eLˤ0IT#-S&DRh[u_p*%3ژS6Nk ;"w[LRq;ZXwS^(WCrJqyL[֊bVe d~Zm%[QػQ/Q6Sx 5VpÅErܞf=>)trK^+eߖbW펅n+ۛ!rV(+?r_83m L L L L ̥\;yK:ϻ+q4|ȏxNR޽p_6% IN"BA*I=0?0L]8\8g1N6Ѳo7aq7iq7eqS7mq7cq3`OV 1QOGs!1Syi?0/?ns?na{B8&̞8ZHـhXA4*^bD\$SC!:3]VʘOmVK%H67f3O!лBj.C@|C??)=6'_6A;)7[<[%D8{9.X[+.w&{[doٖ[bv =\;Uӣpdk?nӢGʋ?nbLs ??}Ԍl??>K0165`ԍbSwbpn\TԳaDv߮k.8{Ðqj>Zyr\kejSPPZPJȲrb H3$?n*匞xnIJ@%ibDlP0#UU_-z+iSwb Qŋ( /GI1cO'NY5Uk%A^ea0`V0 4b`h;H8C9"/D<]a_(/_?rB5lMCj m`80M0H0w9$ N]SHQXN"EE"Q4~*€=(6]IQ@W$ݡSZT|c>3V?rM 7nh=AV`/vChh3)c0nZb|r'+@;?rV Xj#䇒~??54WׂZHHɑ:?n%x{\C`,L@'xyʈ-֊d#sS棑Ј]/m!BhZ"(7l|l_(pxGG8lTE6°)p[4qR0/廛] fmb`̐?ne%bv??J*pvߡù;zN)ƌ;R+{W>c~bU]"$7#f[u1\Ye jrb)[aCmS??":\I(`$QYrE "NZmHX"3T]c)`=z9eDPluG6{ʂ'_QXIc}]t5܂ͮ|Z\Na95+؜*Ʋzla^PSegX^qW3n"dI֗'sYZlvӤxbO//OU"̱ߤ;Q(PG47 =IV(8!Mkb@@! LR7.?0# _Y* r.7T.Q"b|s8s9@n 3ϜQXFkGriBN21=y S@29̣ +DF :6LLIQ4Yq6.@g$V|-b;Di*,TAQIџe0..܃˫-1EoM +#*~ɴf՚9ڬtꚪ\M_?nOZ5Y28 C>$p]1 J"BX6Cʐ cp^ Pʸ3Ye)nc@ҤY[YM6 v^HKEkm1樜#Keې5vݥعPְ#S|4$% C^YBsjBQ~3oDgREmQϙjࢡ檔߂h xflv 0,.ޯmx$⢝6xR#i7ډэvծF=FA{؍3R-L } y^Ds].t鐤C)(6z8Xq6Y!!ΐpC{!8<`jXee"^u+%B'<ܕ$7TVͽ^,\D)t7$n-f)IP@Max0˘!ZLr xo_1NUKf}iQ:)b"QQNs3r7D/]UN7?n??l_:QWw؋IM3v⌸kå>SF*>}Xpߛ{&(Rj!$O[" QY_c"`pSi;L_KYwflfmL8+^jO@&uyW4Z{lI-&,m^K^G"}wx^s.&M?r߇:9+~F\M!9-8Bͪ-:R,&EJ^~\jsaȶhk>?nً! xGjxn |+$<$6Y?r~< ceBty3siJ??G&5gd{9T;7xudǎwн~~Lwix} x 1>O2g?rq-Rf0x^H**hf2|@/HD]V?nռBat~yޘ޽ZRK3 r.@ 7Y??k⼹aږ䤵CIyu%(?nay``\%?0X}Դ}ZCQ̅U, $ a1$dRB$_Ik9r?nԎ[  |,ZH1V^9 #f~Gh#i>SP[*GZ??SU9mCO?03wH-gzl>b hG?rff?0Ye"6+JbXkF4I}<Yd?r#H-ΥoEϓlݕ=:Z2Xo?nQgN7 Ѯk:%ݳ~Y=W#*A.s4??R'4hKK3#Сȋ:/oJ\G;4AXNf%g6)Z*&?ncJ Ka_7Q'Q>w}ѰC+ŤK*MN[C6MKoy?rx?r88/k1cLcnc~)vK#`:&tTGd;P%?0+ ^{5杇3q<Í!ttBGYJIS#HPqgۇlOZƃ+^@  3,`zfN B-',NfM'KЗVo6S$2&-zs,}O*`n~Ȝ.3z~q䕤՝;-l`+wj3nnOlb)<c{mD[QNEYE{+}я%R"iDQ~@xPH<-a=CH[4Q O O`ЖH/NasJ~Pc aD]X f'|m\Q\0)쯔b)ĔVVox byc:jVR@b ?0p7x1|r3ÿFs}|`y}1??{wQ +#|zyp{C<룩Tm6ҷr|2=ȣ1rWAL_4kd1GD??~П2-Á5s ݒUP(b??aUTNܞԈ6)_nMx64#vb<1~ v ?n hYњ0(0:w#lvoa0Uu#?0(?0 _'6U VҸC6 I;E;Qb?rlE<琹Ñ-TY/ۨ(|0Q OX3O?? 1JO<"Q"< Մ=B&"=|bc&}pNz2]{9Ŧb6%۳5@u\lNl3ŲZV϶ۘc701mx i??xi)JG xc&2M\7e[TQ=Emך[k^OszJ!nf朓 3zg|#G@o`ta]p |^xQu?05l&01?nԾQ\2DZ|z&ń]gm|:U9A?rtL+U7inLЇ;v5mjc`]^8VU!j^Y~< ߹6FYӽ;_$YKa!uJ[}~N߳6|(?nԥe>|0,<0/"kG«!5;qQ:K_Zmc.k/jmJ؂{Rr_G*4ӟӛc:F~ ΀?? zs}0[dp6*-@p^y kN1ْ"0#Sǟ.u>'E|Bٽ,`}'ed|O{y9dddaYz:,pn.|dޑ૰(sgWyγ0_ Bq'iZFP'TWf=ӋdD*1=,Hb8$d\7 (#({_ ?n5R?nR؞E^@H~Aci.lݻZeX??8P,d疇l?03zs\ AHM<$to0Ǟ?nSYփn/,rs?n2! ԏ?r"dӮ ?nZ}3R +P8旒/,?rrn6hGӲO`x!* ƲH4Yʬ )̞^FQY(YI]J̓?r]ж<g_ i3M3qTBck7 S%RÉ,o }'XD9zbG&/- 7@?r|.Bg<PiR+̍Cџ`4P `VBPD?nOA4?n BY?0mSGEF[pF9,r 3 ?nC}E$?07w?rom, 1px xj]1AUy,#̍ĢT82??b;E5nH[)|-,?nSca sD9YX-6͙C]ǞaUӌ݅[FE??T^tnHl/m!6HVsܐ*i-mkwUa hM2ʇtcZЊU7gFK]_`@:݆A~Zf8߬f_rbH:2?nDq޹QE]?0w͜lmxDUaB;_\M8ߜx#tHU'#^6}se++@?rYD^Rjw%v(b;MZǧIw7m!(cT?0rی祐+!i]KL??#%]o#9"%~C5t{8taPG-5:nO|>d-ZLѦ]Fos'?rYe8/MtoTr9 fN~>r5:rީTwqX6>`U?r2?rkF7h :v"w\?0tcz@LdE;I"NJ*. #E-,Mv+X18'm :ڲ?nJnI=g$->eJWQҩN p^'Vq\~>??]v훌N糇0H}q׷:5=6b);CŮ- cNA2p݊f]9F4}eD^?0[B2Ud?rPP<,ƙ9 Dˠu]3<>Ygf5)W!!6ww".eѣtmF̴6>XRd([?nx"?rQE⍞g}"?0Bqgs ɉNU(Iho#Z`uMFhׅp`^pӼwXvũ<7??KJ㦗G-ls<8r]- 7ĀmFӒ-.`v}xl)qŖ u)\hESwsrsGGT???nr`5`o \zo (U:9'[''cZ0JIŝ9̺z?rEJ7??H+?rLϝl e :Wqn%c(Rf._zf3ӧZ]?0( ?0 t̅94Qb?r#kר%h^v߅+IAv8hh@F4|x?nð5CalS>وl35s?0I㸖S&7%旼Jk?r?r3ptA&W&%p7ݠY) u]?nЛ:]`7kh .?n*>Un0'3VU_Vݚ9T3XIuغpөxBڮ!!4TH % 3ƀ>SA?0/V!^GP1lP +#]ޯ :X-[2LVzlS"SeHīoв]f0?r/>],!LyNȜ+ 0(+2zkQNZW![^jI r=$D~uRSSĜzt'kylU8yAZn>*փĘ#>-C,3|&&Q_V>صLޝ=>$Q<t~m:/"ne.PҸlP&/PA[:֏9?r+סW4#x"fݨKY%p&#zs3z]!d-LAFDϏ`TL?n[ IRmbN wVā%㥢qB {y M Ⱥ`P}1Hf#%K?rһE\[|Lfda1it=K BȣR:giUfKVޡ0&+:}?n4DJ&@T.D%A~fN:2e1Җ #T.T^IZHD4tIN?rB??P㘖6}h&Dss\{](xHs7CQv`AeDn%v&$䍆N1use Q-;]j4xUXT_BH#`YJ|qFgTCR֑Βݲ?0O;ɜrp^1tNxR!npaUƇE\&zb󸼆cCNnb׫NUEyx n?ndWr*hWAbw+n92M.!O?n;܄lU.VA,'BKy+js5-7 7FvFQU76NUOԾlHWYV?r)BlꈞF9΅,}UAb"lxn w.A7ǀt<(?0.ިE瑺a/D9qkb4FǬTuVtPc12fV?r=1max!7H

comma body

- -
-
-
-
-
- - - -
-

body

+
+
+
+
+
W
-
-
- - -
-

you

+
+
A
+
S
+
D
-
+
+
+
@@ -53,43 +47,6 @@
- -
-
-
-
-
-
W
-
0,0x,y
-
-
-
A
-
S
-
D
-
-
-
- - - - -
-
-
-
-
-
-

Play Sounds

-
-
- - - -
-
diff --git a/tools/bodyteleop/static/js/controls.js b/tools/bodyteleop/static/js/controls.js index b1e0e7ee70..3a11f78b9e 100644 --- a/tools/bodyteleop/static/js/controls.js +++ b/tools/bodyteleop/static/js/controls.js @@ -18,37 +18,3 @@ export const handleKeyX = (key, setValue) => { $("#pos-vals").text(x+","+y); } }; - -export async function executePlan() { - let plan = $("#plan-text").val(); - const planList = []; - plan.split("\n").forEach(function(e){ - let line = e.split(",").map(k=>parseInt(k)); - if (line.length != 5 || line.slice(0, 4).map(e=>[1, 0].includes(e)).includes(false) || line[4] < 0 || line[4] > 10){ - console.log("invalid plan"); - } - else{ - planList.push(line) - } - }); - - async function execute() { - for (var i = 0; i < planList.length; i++) { - let [w, a, s, d, t] = planList[i]; - while(t > 0){ - console.log(w, a, s, d, t); - if(w==1){$("#key-w").mousedown();} - if(a==1){$("#key-a").mousedown();} - if(s==1){$("#key-s").mousedown();} - if(d==1){$("#key-d").mousedown();} - await sleep(50); - $("#key-w").mouseup(); - $("#key-a").mouseup(); - $("#key-s").mouseup(); - $("#key-d").mouseup(); - t = t - 0.05; - } - } - } - execute(); -} \ No newline at end of file diff --git a/tools/bodyteleop/static/js/jsmain.js b/tools/bodyteleop/static/js/jsmain.js index 83205a876b..0db1dcd9b3 100644 --- a/tools/bodyteleop/static/js/jsmain.js +++ b/tools/bodyteleop/static/js/jsmain.js @@ -1,5 +1,5 @@ -import { handleKeyX, executePlan } from "./controls.js"; -import { start, stop, lastChannelMessageTime, playSoundRequest } from "./webrtc.js"; +import { handleKeyX } from "./controls.js"; +import { start, stop, lastChannelMessageTime } from "./webrtc.js"; export var pc = null; export var dc = null; @@ -8,12 +8,6 @@ document.addEventListener('keydown', (e)=>(handleKeyX(e.key.toLowerCase(), 1))); document.addEventListener('keyup', (e)=>(handleKeyX(e.key.toLowerCase(), 0))); $(".keys").bind("mousedown touchstart", (e)=>handleKeyX($(e.target).attr('id').replace('key-', ''), 1)); $(".keys").bind("mouseup touchend", (e)=>handleKeyX($(e.target).attr('id').replace('key-', ''), 0)); -$("#plan-button").click(executePlan); -$(".sound").click((e)=>{ - const sound = $(e.target).attr('id').replace('sound-', '') - return playSoundRequest(sound); -}); - setInterval( () => { const dt = new Date().getTime(); if ((dt - lastChannelMessageTime) > 1000) { diff --git a/tools/bodyteleop/static/js/webrtc.js b/tools/bodyteleop/static/js/webrtc.js index 165a2ce6c4..28bea238e6 100644 --- a/tools/bodyteleop/static/js/webrtc.js +++ b/tools/bodyteleop/static/js/webrtc.js @@ -15,15 +15,6 @@ export function offerRtcRequest(sdp, type) { } -export function playSoundRequest(sound) { - return fetch('/sound', { - body: JSON.stringify({sound}), - headers: {'Content-Type': 'application/json'}, - method: 'POST' - }); -} - - export function pingHeadRequest() { return fetch('/', { method: 'HEAD' @@ -38,20 +29,18 @@ export function createPeerConnection(pc) { pc = new RTCPeerConnection(config); - // connect audio / video + // connect video pc.addEventListener('track', function(evt) { console.log("Adding Tracks!") if (evt.track.kind == 'video') document.getElementById('video').srcObject = evt.streams[0]; - else - document.getElementById('audio').srcObject = evt.streams[0]; }); return pc; } export function negotiate(pc) { - return pc.createOffer({offerToReceiveAudio:true, offerToReceiveVideo:true}).then(function(offer) { + return pc.createOffer({offerToReceiveVideo:true}).then(function(offer) { return pc.setLocalDescription(offer); }).then(function() { return new Promise(function(resolve) { @@ -90,14 +79,6 @@ function isMobile() { export const constraints = { - audio: { - autoGainControl: false, - sampleRate: 48000, - sampleSize: 16, - echoCancellation: true, - noiseSuppression: true, - channelCount: 1 - }, video: isMobile() }; @@ -105,23 +86,8 @@ export const constraints = { export function start(pc, dc) { pc = createPeerConnection(pc); - // add audio track - navigator.mediaDevices.enumerateDevices() - .then(function(devices) { - const hasAudioInput = devices.find((device) => device.kind === "audioinput"); - var modifiedConstraints = {}; - modifiedConstraints.video = constraints.video; - modifiedConstraints.audio = hasAudioInput ? constraints.audio : false; - - return Promise.resolve(modifiedConstraints); - }) - .then(function(constraints) { - if (constraints.audio || constraints.video) { - return navigator.mediaDevices.getUserMedia(constraints); - } else{ - return Promise.resolve(null); - } - }) + // add a local video track on mobile + (constraints.video ? navigator.mediaDevices.getUserMedia(constraints) : Promise.resolve(null)) .then(function(stream) { if (stream) { stream.getTracks().forEach(function(track) { diff --git a/tools/bodyteleop/static/main.css b/tools/bodyteleop/static/main.css index 1bfb5982b4..79fe8052ff 100644 --- a/tools/bodyteleop/static/main.css +++ b/tools/bodyteleop/static/main.css @@ -172,13 +172,6 @@ video { display: none; } -.plan-form { - display: flex; - flex-direction: column; - justify-content: space-between; - align-items: center; -} - .details { display: flex; padding: 0px 10px 0px 10px; diff --git a/tools/cabana/panda.cc b/tools/cabana/panda.cc index 0612d67746..cf5354a507 100644 --- a/tools/cabana/panda.cc +++ b/tools/cabana/panda.cc @@ -106,8 +106,8 @@ cereal::PandaState::PandaType Panda::get_hw_type() { -void Panda::send_heartbeat(bool engaged) { - control_write(0xf3, engaged, 0); +void Panda::send_heartbeat(bool engaged, bool engaged_mads) { + control_write(0xf3, engaged, engaged_mads); } void Panda::set_can_speed_kbps(uint16_t bus, uint16_t speed) { diff --git a/tools/cabana/panda.h b/tools/cabana/panda.h index d318c33f4d..8b861a2476 100644 --- a/tools/cabana/panda.h +++ b/tools/cabana/panda.h @@ -64,7 +64,7 @@ public: // Panda functionality cereal::PandaState::PandaType get_hw_type(); void set_safety_model(cereal::CarParams::SafetyModel safety_model, uint16_t safety_param=0U); - void send_heartbeat(bool engaged); + void send_heartbeat(bool engaged, bool engaged_mads = false); void set_can_speed_kbps(uint16_t bus, uint16_t speed); void set_data_speed_kbps(uint16_t bus, uint16_t speed); bool can_receive(std::vector& out_vec); diff --git a/tools/jotpluggler/browser.cc b/tools/jotpluggler/browser.cc index 0d1b5a2c1b..27378b4b6b 100644 --- a/tools/jotpluggler/browser.cc +++ b/tools/jotpluggler/browser.cc @@ -73,7 +73,7 @@ std::vector build_browser_tree(const std::vector &path } bool is_deprecated_browser_path(const std::string &path) { - return path.find("DEPRECATED") != std::string::npos; + return path.find("DEPRECATED") != std::string::npos || path.find("/deprecated/") != std::string::npos; } std::vector visible_browser_paths(const RouteData &route_data, bool show_deprecated_fields) { diff --git a/tools/jotpluggler/sketch_layout.cc b/tools/jotpluggler/sketch_layout.cc index cd0bf51015..bc110b534f 100644 --- a/tools/jotpluggler/sketch_layout.cc +++ b/tools/jotpluggler/sketch_layout.cc @@ -1304,7 +1304,7 @@ void append_event_fast(cereal::Event::Which which, append_can_frame(can_service, static_cast(msg.getSrc()), msg.getAddress(), - msg.getBusTimeDEPRECATED(), + msg.getDeprecated().getBusTime(), msg.getDat(), tm, series); @@ -1316,7 +1316,7 @@ void append_event_fast(cereal::Event::Which which, append_can_frame(can_service, static_cast(msg.getSrc()), msg.getAddress(), - msg.getBusTimeDEPRECATED(), + msg.getDeprecated().getBusTime(), msg.getDat(), tm, series); diff --git a/tools/lib/api.py b/tools/lib/api.py index c6e2d98914..f84fe75869 100644 --- a/tools/lib/api.py +++ b/tools/lib/api.py @@ -1,5 +1,6 @@ import os import requests +from requests.adapters import HTTPAdapter, Retry API_HOST = os.getenv('API_HOST', 'https://api.commadotai.com') # TODO: this should be merged into common.api @@ -11,6 +12,9 @@ class CommaApi: if token: self.session.headers['Authorization'] = 'JWT ' + token + retries = Retry(total=5, backoff_factor=1, status_forcelist=[500, 502, 503, 504]) + self.session.mount('https://', HTTPAdapter(max_retries=retries)) + def request(self, method, endpoint, **kwargs): with self.session.request(method, API_HOST + '/' + endpoint, **kwargs) as resp: resp_json = resp.json() diff --git a/tools/longitudinal_maneuvers/maneuversd.py b/tools/longitudinal_maneuvers/maneuversd.py index f8dc6787cc..48e7384cd3 100755 --- a/tools/longitudinal_maneuvers/maneuversd.py +++ b/tools/longitudinal_maneuvers/maneuversd.py @@ -142,7 +142,7 @@ def main(): CP = messaging.log_from_bytes(params.get("CarParams", block=True), car.CarParams) sm = messaging.SubMaster(['carState', 'carControl', 'controlsState', 'selfdriveState', 'modelV2'], poll='modelV2') - pm = messaging.PubMaster(['longitudinalPlan', 'driverAssistance', 'alertDebug']) + pm = messaging.PubMaster(['longitudinalPlan', 'longitudinalPlanSP', 'driverAssistance', 'alertDebug']) maneuvers = iter(MANEUVERS) maneuver = None @@ -187,6 +187,10 @@ def main(): pm.send('longitudinalPlan', plan_send) + plan_sp_send = messaging.new_message('longitudinalPlanSP') + plan_sp_send.valid = True + pm.send('longitudinalPlanSP', plan_sp_send) + assistance_send = messaging.new_message('driverAssistance') assistance_send.valid = True pm.send('driverAssistance', assistance_send) diff --git a/tools/op.sh b/tools/op.sh index f21a285d17..3b02602619 100755 --- a/tools/op.sh +++ b/tools/op.sh @@ -383,6 +383,9 @@ function op_switch() { git submodule update --init --recursive git submodule foreach git reset --hard git submodule foreach git clean -df + + # remove openpilot update flag if present + rm -f .overlay_init } function op_start() { diff --git a/tools/replay/consoleui.cc b/tools/replay/consoleui.cc index 2d21b4efc0..eeb385f8a4 100644 --- a/tools/replay/consoleui.cc +++ b/tools/replay/consoleui.cc @@ -11,6 +11,8 @@ #include "common/version.h" #include "tools/replay/py_downloader.h" +#include "sunnypilot/common/version.h" + namespace { const int BORDER_SIZE = 3; @@ -125,7 +127,7 @@ void ConsoleUI::initWindows() { // set the title bar wbkgd(w[Win::Title], A_REVERSE); - mvwprintw(w[Win::Title], 0, 3, "openpilot replay %s", COMMA_VERSION); + mvwprintw(w[Win::Title], 0, 3, "sunnypilot replay %s", SUNNYPILOT_VERSION); // show windows on the real screen refresh(); diff --git a/tools/replay/logreader.cc b/tools/replay/logreader.cc index aba67bcdf8..54b69dc168 100644 --- a/tools/replay/logreader.cc +++ b/tools/replay/logreader.cc @@ -142,18 +142,19 @@ void LogReader::migrateOldEvents() { new_evt.setLogMonoTime(old_evt.getLogMonoTime()); auto new_state = new_evt.initSelfdriveState(); - new_state.setActive(old_state.getActiveDEPRECATED()); - new_state.setAlertSize(old_state.getAlertSizeDEPRECATED()); - new_state.setAlertSound(old_state.getAlertSound2DEPRECATED()); - new_state.setAlertStatus(old_state.getAlertStatusDEPRECATED()); - new_state.setAlertText1(old_state.getAlertText1DEPRECATED()); - new_state.setAlertText2(old_state.getAlertText2DEPRECATED()); - new_state.setAlertType(old_state.getAlertTypeDEPRECATED()); - new_state.setEnabled(old_state.getEnabledDEPRECATED()); - new_state.setEngageable(old_state.getEngageableDEPRECATED()); - new_state.setExperimentalMode(old_state.getExperimentalModeDEPRECATED()); - new_state.setPersonality(old_state.getPersonalityDEPRECATED()); - new_state.setState(old_state.getStateDEPRECATED()); + auto old_dep = old_state.getDeprecated(); + new_state.setActive(old_dep.getActive()); + new_state.setAlertSize(old_dep.getAlertSize()); + new_state.setAlertSound(old_dep.getAlertSound2()); + new_state.setAlertStatus(old_dep.getAlertStatus()); + new_state.setAlertText1(old_dep.getAlertText1()); + new_state.setAlertText2(old_dep.getAlertText2()); + new_state.setAlertType(old_dep.getAlertType()); + new_state.setEnabled(old_dep.getEnabled()); + new_state.setEngageable(old_dep.getEngageable()); + new_state.setExperimentalMode(old_dep.getExperimentalMode()); + new_state.setPersonality(old_dep.getPersonality()); + new_state.setState(old_dep.getState()); // Serialize the new event to the buffer auto buf_size = msg.getSerializedSize(); diff --git a/tools/sim/launch_openpilot.sh b/tools/sim/launch_openpilot.sh index 392f365d03..ea3c4cb8f1 100755 --- a/tools/sim/launch_openpilot.sh +++ b/tools/sim/launch_openpilot.sh @@ -6,7 +6,7 @@ export SIMULATION="1" export SKIP_FW_QUERY="1" export FINGERPRINT="HONDA_CIVIC_2022" -export BLOCK="${BLOCK},camerad,loggerd,encoderd,micd,logmessaged,manage_athenad" +export BLOCK="${BLOCK},camerad,loggerd,encoderd,micd,logmessaged,manage_athenad,manage_sunnylinkd" if [[ "$CI" ]]; then # TODO: offscreen UI should work export BLOCK="${BLOCK},ui" diff --git a/tools/sim/lib/simulated_car.py b/tools/sim/lib/simulated_car.py index 68ff3050db..1567c20ef9 100644 --- a/tools/sim/lib/simulated_car.py +++ b/tools/sim/lib/simulated_car.py @@ -92,6 +92,8 @@ class SimulatedCar: 'ignitionLine': simulator_state.ignition, 'pandaType': "blackPanda", 'controlsAllowed': True, + 'controlsAllowedLateral': True, + 'controlsAllowedLongitudinal': True, 'safetyModel': 'hondaBosch', 'alternativeExperience': self.sm["carParams"].alternativeExperience, 'safetyParam': HondaSafetyFlags.RADARLESS.value | HondaSafetyFlags.BOSCH_LONG.value, diff --git a/uv.lock b/uv.lock index 272421934c..7ecb51f5d1 100644 --- a/uv.lock +++ b/uv.lock @@ -13,7 +13,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.13.3" +version = "3.13.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -24,25 +24,25 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } +sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" }, - { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" }, - { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" }, - { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" }, - { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" }, - { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" }, - { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" }, - { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" }, - { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" }, - { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" }, - { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" }, - { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" }, - { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" }, + { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" }, + { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" }, + { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" }, + { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" }, + { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" }, + { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" }, + { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" }, + { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" }, + { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" }, + { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" }, + { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" }, + { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" }, + { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" }, + { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" }, ] [[package]] @@ -116,12 +116,12 @@ wheels = [ [[package]] name = "bzip2" version = "1.0.8" -source = { git = "https://github.com/commaai/dependencies.git?subdirectory=bzip2&rev=release-bzip2#90b7fefbe37fc2ca26597e6e9e0035dd386effa1" } +source = { git = "https://github.com/commaai/dependencies.git?subdirectory=bzip2&rev=release-bzip2#1ddfd3eb7b9e30a957c263930e1b0660e5dce6d1" } [[package]] name = "capnproto" version = "1.0.1" -source = { git = "https://github.com/commaai/dependencies.git?subdirectory=capnproto&rev=release-capnproto#05582563f2fdf6638a550fef61b129a2fb288d05" } +source = { git = "https://github.com/commaai/dependencies.git?subdirectory=capnproto&rev=release-capnproto#6e99db11a1dc5dfa74be40d1e0666ebe10c8e0d7" } [[package]] name = "casadi" @@ -174,39 +174,39 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.6" +version = "3.4.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" }, - { url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" }, - { url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" }, - { url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" }, - { url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" }, - { url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" }, - { url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" }, - { url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" }, - { url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" }, - { url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" }, - { url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" }, - { url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" }, - { url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" }, - { url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" }, - { url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" }, - { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] [[package]] name = "click" -version = "8.3.1" +version = "8.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" }, ] [[package]] @@ -291,41 +291,41 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.5" +version = "46.0.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, - { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, - { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, - { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, - { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, - { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, - { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, - { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, - { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, - { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, - { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, - { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, - { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, - { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, - { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, - { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, - { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, - { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, - { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, - { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, - { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, - { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, - { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, + { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, + { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, + { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, ] [[package]] @@ -371,7 +371,7 @@ wheels = [ [[package]] name = "eigen" version = "3.4.0" -source = { git = "https://github.com/commaai/dependencies.git?subdirectory=eigen&rev=release-eigen#40e5d76de1b33a86c5181b63db6782d8f06da1da" } +source = { git = "https://github.com/commaai/dependencies.git?subdirectory=eigen&rev=release-eigen#891c42d8029b2a633f3aca7f60cc7aa4b5305405" } [[package]] name = "execnet" @@ -385,7 +385,7 @@ wheels = [ [[package]] name = "ffmpeg" version = "7.1.0" -source = { git = "https://github.com/commaai/dependencies.git?subdirectory=ffmpeg&rev=release-ffmpeg#b9732165bcf5a3fab83b05994187802a0d115b6e" } +source = { git = "https://github.com/commaai/dependencies.git?subdirectory=ffmpeg&rev=release-ffmpeg#8261317427e81a0fa1f53a7ef77f15004ec78889" } [[package]] name = "fonttools" @@ -432,7 +432,7 @@ wheels = [ [[package]] name = "gcc-arm-none-eabi" version = "13.2.1" -source = { git = "https://github.com/commaai/dependencies.git?subdirectory=gcc-arm-none-eabi&rev=release-gcc-arm-none-eabi#15a616d4f08f6b8ecaa9b2390c75d2fe0c0fffb8" } +source = { git = "https://github.com/commaai/dependencies.git?subdirectory=gcc-arm-none-eabi&rev=release-gcc-arm-none-eabi#fd995de677db114e2862cf4ed245ca9a17536668" } [[package]] name = "ghp-import" @@ -449,7 +449,7 @@ wheels = [ [[package]] name = "git-lfs" version = "3.6.1" -source = { git = "https://github.com/commaai/dependencies.git?subdirectory=git-lfs&rev=release-git-lfs#f77417aad13a05b03bb2696a0b5a124f339d117b" } +source = { git = "https://github.com/commaai/dependencies.git?subdirectory=git-lfs&rev=release-git-lfs#9fdbe7eb0257d7a13851ed4baa52fbccbe7e2e9d" } [[package]] name = "google-crc32c" @@ -498,7 +498,7 @@ wheels = [ [[package]] name = "imgui" version = "1.92.7" -source = { git = "https://github.com/commaai/dependencies.git?subdirectory=imgui&rev=release-imgui#c5c108b23a2e0346480d7f4c4981bf6ec7ba9054" } +source = { git = "https://github.com/commaai/dependencies.git?subdirectory=imgui&rev=release-imgui#f3d874be2f3aa44869ffd4775e0957e986a30a68" } [[package]] name = "iniconfig" @@ -578,12 +578,12 @@ wheels = [ [[package]] name = "libjpeg" version = "3.1.0" -source = { git = "https://github.com/commaai/dependencies.git?subdirectory=libjpeg&rev=release-libjpeg#2d69723fe445dadc68ceb9072510a505111b64a7" } +source = { git = "https://github.com/commaai/dependencies.git?subdirectory=libjpeg&rev=release-libjpeg#d90bc630661092de49428bfc3a82a371ee35a889" } [[package]] name = "libusb" version = "1.0.29" -source = { git = "https://github.com/commaai/dependencies.git?subdirectory=libusb&rev=release-libusb#8daf8079f98809ef4674177bca915a0a81eac52f" } +source = { git = "https://github.com/commaai/dependencies.git?subdirectory=libusb&rev=release-libusb#6562b0138726a380368d68a6ac5f6e36d6aea2da" } [[package]] name = "libusb1" @@ -599,7 +599,7 @@ wheels = [ [[package]] name = "libyuv" version = "1922.0" -source = { git = "https://github.com/commaai/dependencies.git?subdirectory=libyuv&rev=release-libyuv#28c3c2a2444232aeeaf989c33fd333ce74e6fc90" } +source = { git = "https://github.com/commaai/dependencies.git?subdirectory=libyuv&rev=release-libyuv#22b976c39a3f2607ef5458056b1a10558da0e85f" } [[package]] name = "markdown" @@ -751,25 +751,25 @@ wheels = [ [[package]] name = "ncurses" version = "6.5" -source = { git = "https://github.com/commaai/dependencies.git?subdirectory=ncurses&rev=release-ncurses#e33e7f648009ad97638b1a0a373a06a05526c040" } +source = { git = "https://github.com/commaai/dependencies.git?subdirectory=ncurses&rev=release-ncurses#b733e08a93873e8d8ac47caabc2eb64a425f7146" } [[package]] name = "numpy" -version = "2.4.3" +version = "2.4.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/10/8b/c265f4823726ab832de836cdd184d0986dcf94480f81e8739692a7ac7af2/numpy-2.4.3.tar.gz", hash = "sha256:483a201202b73495f00dbc83796c6ae63137a9bdade074f7648b3e32613412dd", size = 20727743, upload-time = "2026-03-09T07:58:53.426Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/ed/6388632536f9788cea23a3a1b629f25b43eaacd7d7377e5d6bc7b9deb69b/numpy-2.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61b0cbabbb6126c8df63b9a3a0c4b1f44ebca5e12ff6997b80fcf267fb3150ef", size = 16669628, upload-time = "2026-03-09T07:56:24.252Z" }, - { url = "https://files.pythonhosted.org/packages/74/1b/ee2abfc68e1ce728b2958b6ba831d65c62e1b13ce3017c13943f8f9b5b2e/numpy-2.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7395e69ff32526710748f92cd8c9849b361830968ea3e24a676f272653e8983e", size = 14696872, upload-time = "2026-03-09T07:56:26.991Z" }, - { url = "https://files.pythonhosted.org/packages/ba/d1/780400e915ff5638166f11ca9dc2c5815189f3d7cf6f8759a1685e586413/numpy-2.4.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:abdce0f71dcb4a00e4e77f3faf05e4616ceccfe72ccaa07f47ee79cda3b7b0f4", size = 5203489, upload-time = "2026-03-09T07:56:29.414Z" }, - { url = "https://files.pythonhosted.org/packages/0b/bb/baffa907e9da4cc34a6e556d6d90e032f6d7a75ea47968ea92b4858826c4/numpy-2.4.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:48da3a4ee1336454b07497ff7ec83903efa5505792c4e6d9bf83d99dc07a1e18", size = 6550814, upload-time = "2026-03-09T07:56:32.225Z" }, - { url = "https://files.pythonhosted.org/packages/7b/12/8c9f0c6c95f76aeb20fc4a699c33e9f827fa0d0f857747c73bb7b17af945/numpy-2.4.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32e3bef222ad6b052280311d1d60db8e259e4947052c3ae7dd6817451fc8a4c5", size = 15666601, upload-time = "2026-03-09T07:56:34.461Z" }, - { url = "https://files.pythonhosted.org/packages/bd/79/cc665495e4d57d0aa6fbcc0aa57aa82671dfc78fbf95fe733ed86d98f52a/numpy-2.4.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7dd01a46700b1967487141a66ac1a3cf0dd8ebf1f08db37d46389401512ca97", size = 16621358, upload-time = "2026-03-09T07:56:36.852Z" }, - { url = "https://files.pythonhosted.org/packages/a8/40/b4ecb7224af1065c3539f5ecfff879d090de09608ad1008f02c05c770cb3/numpy-2.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:76f0f283506c28b12bba319c0fab98217e9f9b54e6160e9c79e9f7348ba32e9c", size = 17016135, upload-time = "2026-03-09T07:56:39.337Z" }, - { url = "https://files.pythonhosted.org/packages/f7/b1/6a88e888052eed951afed7a142dcdf3b149a030ca59b4c71eef085858e43/numpy-2.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:737f630a337364665aba3b5a77e56a68cc42d350edd010c345d65a3efa3addcc", size = 18345816, upload-time = "2026-03-09T07:56:42.31Z" }, - { url = "https://files.pythonhosted.org/packages/f3/8f/103a60c5f8c3d7fc678c19cd7b2476110da689ccb80bc18050efbaeae183/numpy-2.4.3-cp312-cp312-win32.whl", hash = "sha256:26952e18d82a1dbbc2f008d402021baa8d6fc8e84347a2072a25e08b46d698b9", size = 5960132, upload-time = "2026-03-09T07:56:44.851Z" }, - { url = "https://files.pythonhosted.org/packages/d7/7c/f5ee1bf6ed888494978046a809df2882aad35d414b622893322df7286879/numpy-2.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:65f3c2455188f09678355f5cae1f959a06b778bc66d535da07bf2ef20cd319d5", size = 12316144, upload-time = "2026-03-09T07:56:47.057Z" }, - { url = "https://files.pythonhosted.org/packages/71/46/8d1cb3f7a00f2fb6394140e7e6623696e54c6318a9d9691bb4904672cf42/numpy-2.4.3-cp312-cp312-win_arm64.whl", hash = "sha256:2abad5c7fef172b3377502bde47892439bae394a71bc329f31df0fd829b41a9e", size = 10220364, upload-time = "2026-03-09T07:56:49.849Z" }, + { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" }, + { url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" }, + { url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" }, + { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" }, + { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" }, + { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" }, ] [[package]] @@ -997,21 +997,21 @@ wheels = [ [[package]] name = "pillow" -version = "12.1.1" +version = "12.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, - { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, - { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, - { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, - { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, - { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, - { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, - { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, - { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, - { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, - { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, ] [[package]] @@ -1148,11 +1148,11 @@ wheels = [ [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] @@ -1219,7 +1219,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.2" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -1228,9 +1228,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] @@ -1387,7 +1387,7 @@ wheels = [ [[package]] name = "requests" -version = "2.32.5" +version = "2.33.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -1395,9 +1395,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, ] [[package]] @@ -1411,27 +1411,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.7" +version = "0.15.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277, upload-time = "2026-03-19T16:26:22.605Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/97/e9f1ca355108ef7194e38c812ef40ba98c7208f47b13ad78d023caa583da/ruff-0.15.9.tar.gz", hash = "sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2", size = 4617361, upload-time = "2026-04-02T18:17:20.829Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037, upload-time = "2026-03-19T16:26:32.47Z" }, - { url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433, upload-time = "2026-03-19T16:27:00.205Z" }, - { url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302, upload-time = "2026-03-19T16:26:26.183Z" }, - { url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625, upload-time = "2026-03-19T16:27:03.263Z" }, - { url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743, upload-time = "2026-03-19T16:27:09.791Z" }, - { url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536, upload-time = "2026-03-19T16:27:06.101Z" }, - { url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292, upload-time = "2026-03-19T16:26:48.718Z" }, - { url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981, upload-time = "2026-03-19T16:26:54.513Z" }, - { url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422, upload-time = "2026-03-19T16:26:29.277Z" }, - { url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158, upload-time = "2026-03-19T16:26:42.321Z" }, - { url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861, upload-time = "2026-03-19T16:26:57.459Z" }, - { url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310, upload-time = "2026-03-19T16:26:35.909Z" }, - { url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752, upload-time = "2026-03-19T16:26:45.723Z" }, - { url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961, upload-time = "2026-03-19T16:26:39.076Z" }, - { url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538, upload-time = "2026-03-19T16:26:15.992Z" }, - { url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839, upload-time = "2026-03-19T16:26:19.897Z" }, - { url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1f/9cdfd0ac4b9d1e5a6cf09bedabdf0b56306ab5e333c85c87281273e7b041/ruff-0.15.9-py3-none-linux_armv6l.whl", hash = "sha256:6efbe303983441c51975c243e26dff328aca11f94b70992f35b093c2e71801e1", size = 10511206, upload-time = "2026-04-02T18:16:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f6/32bfe3e9c136b35f02e489778d94384118bb80fd92c6d92e7ccd97db12ce/ruff-0.15.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4965bac6ac9ea86772f4e23587746f0b7a395eccabb823eb8bfacc3fa06069f7", size = 10923307, upload-time = "2026-04-02T18:17:08.645Z" }, + { url = "https://files.pythonhosted.org/packages/ca/25/de55f52ab5535d12e7aaba1de37a84be6179fb20bddcbe71ec091b4a3243/ruff-0.15.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf05aad70ca5b5a0a4b0e080df3a6b699803916d88f006efd1f5b46302daab8", size = 10316722, upload-time = "2026-04-02T18:16:44.206Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/690d75f3fd6278fe55fff7c9eb429c92d207e14b25d1cae4064a32677029/ruff-0.15.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9439a342adb8725f32f92732e2bafb6d5246bd7a5021101166b223d312e8fc59", size = 10623674, upload-time = "2026-04-02T18:16:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ec/176f6987be248fc5404199255522f57af1b4a5a1b57727e942479fec98ad/ruff-0.15.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c5e6faf9d97c8edc43877c3f406f47446fc48c40e1442d58cfcdaba2acea745", size = 10351516, upload-time = "2026-04-02T18:16:57.206Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fc/51cffbd2b3f240accc380171d51446a32aa2ea43a40d4a45ada67368fbd2/ruff-0.15.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b34a9766aeec27a222373d0b055722900fbc0582b24f39661aa96f3fe6ad901", size = 11150202, upload-time = "2026-04-02T18:17:06.452Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d4/25292a6dfc125f6b6528fe6af31f5e996e19bf73ca8e3ce6eb7fa5b95885/ruff-0.15.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89dd695bc72ae76ff484ae54b7e8b0f6b50f49046e198355e44ea656e521fef9", size = 11988891, upload-time = "2026-04-02T18:17:18.575Z" }, + { url = "https://files.pythonhosted.org/packages/13/e1/1eebcb885c10e19f969dcb93d8413dfee8172578709d7ee933640f5e7147/ruff-0.15.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce187224ef1de1bd225bc9a152ac7102a6171107f026e81f317e4257052916d5", size = 11480576, upload-time = "2026-04-02T18:16:52.986Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6", size = 11254525, upload-time = "2026-04-02T18:17:02.041Z" }, + { url = "https://files.pythonhosted.org/packages/42/aa/4bb3af8e61acd9b1281db2ab77e8b2c3c5e5599bf2a29d4a942f1c62b8d6/ruff-0.15.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:55cc15eee27dc0eebdfcb0d185a6153420efbedc15eb1d38fe5e685657b0f840", size = 11204072, upload-time = "2026-04-02T18:17:13.581Z" }, + { url = "https://files.pythonhosted.org/packages/69/48/d550dc2aa6e423ea0bcc1d0ff0699325ffe8a811e2dba156bd80750b86dc/ruff-0.15.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6537f6eed5cda688c81073d46ffdfb962a5f29ecb6f7e770b2dc920598997ed", size = 10594998, upload-time = "2026-04-02T18:16:46.369Z" }, + { url = "https://files.pythonhosted.org/packages/63/47/321167e17f5344ed5ec6b0aa2cff64efef5f9e985af8f5622cfa6536043f/ruff-0.15.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6d3fcbca7388b066139c523bda744c822258ebdcfbba7d24410c3f454cc9af71", size = 10359769, upload-time = "2026-04-02T18:17:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/67/5e/074f00b9785d1d2c6f8c22a21e023d0c2c1817838cfca4c8243200a1fa87/ruff-0.15.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:058d8e99e1bfe79d8a0def0b481c56059ee6716214f7e425d8e737e412d69677", size = 10850236, upload-time = "2026-04-02T18:16:48.749Z" }, + { url = "https://files.pythonhosted.org/packages/76/37/804c4135a2a2caf042925d30d5f68181bdbd4461fd0d7739da28305df593/ruff-0.15.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8e1ddb11dbd61d5983fa2d7d6370ef3eb210951e443cace19594c01c72abab4c", size = 11358343, upload-time = "2026-04-02T18:16:55.068Z" }, + { url = "https://files.pythonhosted.org/packages/88/3d/1364fcde8656962782aa9ea93c92d98682b1ecec2f184e625a965ad3b4a6/ruff-0.15.9-py3-none-win32.whl", hash = "sha256:bde6ff36eaf72b700f32b7196088970bf8fdb2b917b7accd8c371bfc0fd573ec", size = 10583382, upload-time = "2026-04-02T18:17:04.261Z" }, + { url = "https://files.pythonhosted.org/packages/4c/56/5c7084299bd2cacaa07ae63a91c6f4ba66edc08bf28f356b24f6b717c799/ruff-0.15.9-py3-none-win_amd64.whl", hash = "sha256:45a70921b80e1c10cf0b734ef09421f71b5aa11d27404edc89d7e8a69505e43d", size = 11744969, upload-time = "2026-04-02T18:16:59.611Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870, upload-time = "2026-04-02T18:17:15.773Z" }, ] [[package]] @@ -1445,15 +1445,15 @@ wheels = [ [[package]] name = "sentry-sdk" -version = "2.55.0" +version = "2.57.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e9/b8/285293dc60fc198fffc3fcdbc7c6d4e646e0f74e61461c355d40faa64ceb/sentry_sdk-2.55.0.tar.gz", hash = "sha256:3774c4d8820720ca4101548131b9c162f4c9426eb7f4d24aca453012a7470f69", size = 424505, upload-time = "2026-03-17T14:15:51.707Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/87/46c0406d8b5ddd026f73adaf5ab75ce144219c41a4830b52df4b9ab55f7f/sentry_sdk-2.57.0.tar.gz", hash = "sha256:4be8d1e71c32fb27f79c577a337ac8912137bba4bcbc64a4ec1da4d6d8dc5199", size = 435288, upload-time = "2026-03-31T09:39:29.264Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/66/20465097782d7e1e742d846407ea7262d338c6e876ddddad38ca8907b38f/sentry_sdk-2.55.0-py2.py3-none-any.whl", hash = "sha256:97026981cb15699394474a196b88503a393cbc58d182ece0d3abe12b9bd978d4", size = 449284, upload-time = "2026-03-17T14:15:49.604Z" }, + { url = "https://files.pythonhosted.org/packages/c9/64/982e07b93219cb52e1cca5d272cb579e2f3eb001956c9e7a9a6d106c9473/sentry_sdk-2.57.0-py2.py3-none-any.whl", hash = "sha256:812c8bf5ff3d2f0e89c82f5ce80ab3a6423e102729c4706af7413fd1eb480585", size = 456489, upload-time = "2026-03-31T09:39:27.524Z" }, ] [[package]] @@ -1549,26 +1549,26 @@ wheels = [ [[package]] name = "ty" -version = "0.0.24" +version = "0.0.29" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7a/96/652a425030f95dc2c9548d9019e52502e17079e1daeefbc4036f1c0905b4/ty-0.0.24.tar.gz", hash = "sha256:9fe42f6b98207bdaef51f71487d6d087f2cb02555ee3939884d779b2b3cc8bfc", size = 5354286, upload-time = "2026-03-19T16:55:57.035Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/d5/853561de49fae38c519e905b2d8da9c531219608f1fccc47a0fc2c896980/ty-0.0.29.tar.gz", hash = "sha256:e7936cca2f691eeda631876c92809688dbbab68687c3473f526cd83b6a9228d8", size = 5469221, upload-time = "2026-04-05T15:01:21.328Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/e5/34457ee11708e734ba81ad65723af83030e484f961e281d57d1eecf08951/ty-0.0.24-py3-none-linux_armv6l.whl", hash = "sha256:1ab4f1f61334d533a3fdf5d9772b51b1300ac5da4f3cdb0be9657a3ccb2ce3e7", size = 10394877, upload-time = "2026-03-19T16:55:54.246Z" }, - { url = "https://files.pythonhosted.org/packages/44/81/bc9a1b1a87f43db15ab64ad781a4f999734ec3b470ad042624fa875b20e6/ty-0.0.24-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:facbf2c4aaa6985229e08f8f9bf152215eb078212f22b5c2411f35386688ab42", size = 10211109, upload-time = "2026-03-19T16:55:28.554Z" }, - { url = "https://files.pythonhosted.org/packages/e4/63/cfc805adeaa61d63ba3ea71127efa7d97c40ba36d97ee7bd957341d05107/ty-0.0.24-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b6d2a3b6d4470c483552a31e9b368c86f154dcc964bccb5406159dc9cd362246", size = 9694769, upload-time = "2026-03-19T16:55:34.309Z" }, - { url = "https://files.pythonhosted.org/packages/33/09/edc220726b6ec44a58900401f6b27140997ef15026b791e26b69a6e69eb5/ty-0.0.24-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c94c25d0500939fd5f8f16ce41cbed5b20528702c1d649bf80300253813f0a2", size = 10176287, upload-time = "2026-03-19T16:55:37.17Z" }, - { url = "https://files.pythonhosted.org/packages/f8/bf/cbe2227be711e65017655d8ee4d050f4c92b113fb4dc4c3bd6a19d3a86d8/ty-0.0.24-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:89cbe7bc7df0fab02dbd8cda79b737df83f1ef7fb573b08c0ee043dc68cffb08", size = 10214832, upload-time = "2026-03-19T16:56:08.518Z" }, - { url = "https://files.pythonhosted.org/packages/af/1d/d15803ee47e9143d10e10bd81ccc14761d08758082bda402950685f0ddfe/ty-0.0.24-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db2c5d269bcc9b764850c99f457b5018a79b3ef40ecfbc03344e65effd6cf743", size = 10709892, upload-time = "2026-03-19T16:56:05.727Z" }, - { url = "https://files.pythonhosted.org/packages/36/12/6db0d86c477147f67b9052de209421d76c3e855197b000c25fcbbe86b3a2/ty-0.0.24-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba44512db5b97c3bbd59d93e11296e8548d0c9a3bdd1280de36d7ff22d351896", size = 11280872, upload-time = "2026-03-19T16:56:02.899Z" }, - { url = "https://files.pythonhosted.org/packages/1b/fc/155fe83a97c06d33ccc9e0f428258b32df2e08a428300c715d34757f0111/ty-0.0.24-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a52b7f589c3205512a9c50ba5b2b1e8c0698b72e51b8b9285c90420c06f1cae8", size = 11060520, upload-time = "2026-03-19T16:55:59.956Z" }, - { url = "https://files.pythonhosted.org/packages/ac/f1/32c05a1c4c3c2a95c5b7361dee03a9bf1231d4ad096b161c838b45bce5a0/ty-0.0.24-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7981df5c709c054da4ac5d7c93f8feb8f45e69e829e4461df4d5f0988fe67d04", size = 10791455, upload-time = "2026-03-19T16:55:25.728Z" }, - { url = "https://files.pythonhosted.org/packages/17/2c/53c1ea6bedfa4d4ab64d4de262d8f5e405ecbffefd364459c628c0310d33/ty-0.0.24-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b2860151ad95a00d0f0280b8fef79900d08dcd63276b57e6e5774f2c055979c5", size = 10156708, upload-time = "2026-03-19T16:55:45.563Z" }, - { url = "https://files.pythonhosted.org/packages/45/39/7d2919cf194707169474d80720a5f3d793e983416f25e7ffcf80504c9df2/ty-0.0.24-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5674a1146d927ab77ff198a88e0c4505134ced342a0e7d1beb4a076a728b7496", size = 10236263, upload-time = "2026-03-19T16:55:31.474Z" }, - { url = "https://files.pythonhosted.org/packages/cf/7f/48eac722f2fd12a5b7aae0effdcb75c46053f94b783d989e3ef0d7380082/ty-0.0.24-py3-none-musllinux_1_2_i686.whl", hash = "sha256:438ecbf1608a9b16dd84502f3f1b23ef2ef32bbd0ab3e0ca5a82f0e0d1cd41ea", size = 10402559, upload-time = "2026-03-19T16:55:39.602Z" }, - { url = "https://files.pythonhosted.org/packages/75/e0/8cf868b9749ce1e5166462759545964e95b02353243594062b927d8bff2a/ty-0.0.24-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ddeed3098dd92a83964e7aa7b41e509ba3530eb539fc4cd8322ff64a09daf1f5", size = 10893684, upload-time = "2026-03-19T16:55:51.439Z" }, - { url = "https://files.pythonhosted.org/packages/17/9f/f54bf3be01d2c2ed731d10a5afa3324dc66f987a6ae0a4a6cbfa2323d080/ty-0.0.24-py3-none-win32.whl", hash = "sha256:83013fb3a4764a8f8bcc6ca11ff8bdfd8c5f719fc249241cb2b8916e80778eb1", size = 9781542, upload-time = "2026-03-19T16:56:11.588Z" }, - { url = "https://files.pythonhosted.org/packages/fb/49/c004c5cc258b10b3a145666e9a9c28ae7678bc958c8926e8078d5d769081/ty-0.0.24-py3-none-win_amd64.whl", hash = "sha256:748a60eb6912d1cf27aaab105ffadb6f4d2e458a3fcadfbd3cf26db0d8062eeb", size = 10764801, upload-time = "2026-03-19T16:55:42.752Z" }, - { url = "https://files.pythonhosted.org/packages/e2/59/006a074e185bfccf5e4c026015245ab4fcd2362b13a8d24cf37a277909a9/ty-0.0.24-py3-none-win_arm64.whl", hash = "sha256:280a3d31e86d0721947238f17030c33f0911cae851d108ea9f4e3ab12a5ed01f", size = 10194093, upload-time = "2026-03-19T16:55:48.303Z" }, + { url = "https://files.pythonhosted.org/packages/03/b7/911f9962115acfa24e3b2ec9d4992dd994c38e8769e1b1d7680bb4d28a51/ty-0.0.29-py3-none-linux_armv6l.whl", hash = "sha256:b8a40955f7660d3eaceb0d964affc81b790c0765e7052921a5f861ff8a471c30", size = 10568206, upload-time = "2026-04-05T15:01:19.165Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c3/fcae2167d4c77a97269f92f11d1b43b03617f81de1283d5d05b43432110c/ty-0.0.29-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6b6849adae15b00bbe2d3c5b078967dcb62eba37d38936b8eeb4c81a82d2e3b8", size = 10442530, upload-time = "2026-04-05T15:01:28.471Z" }, + { url = "https://files.pythonhosted.org/packages/97/33/5a6bfa240cfcb9c36046ae2459fa9ea23238d20130d8656ff5ac4d6c012a/ty-0.0.29-py3-none-macosx_11_0_arm64.whl", hash = "sha256:dcdd9b17209788152f7b7ea815eda07989152325052fe690013537cc7904ce49", size = 9915735, upload-time = "2026-04-05T15:01:10.365Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1e/318f45fae232118e81a6306c30f50de42c509c412128d5bd231eab699ffb/ty-0.0.29-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d8ed4789bae78ffaf94462c0d25589a734cab0366b86f2bbcb1bb90e1a7a169", size = 10419748, upload-time = "2026-04-05T15:01:32.375Z" }, + { url = "https://files.pythonhosted.org/packages/a9/a8/5687872e2ab5a0f7dd4fd8456eac31e9381ad4dc74961f6f29965ad4dd91/ty-0.0.29-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:91ec374b8565e0ad0900011c24641ebbef2da51adbd4fb69ff3280c8a7eceb02", size = 10394738, upload-time = "2026-04-05T15:01:06.473Z" }, + { url = "https://files.pythonhosted.org/packages/de/68/015d118097eeb95e6a44c4abce4c0a28b7b9dfb3085b7f0ee48e4f099633/ty-0.0.29-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:298a8d5faa2502d3810bbbb47a030b9455495b9921594206043c785dd61548cf", size = 10910613, upload-time = "2026-04-05T15:01:17.17Z" }, + { url = "https://files.pythonhosted.org/packages/1c/01/47ce3c6c53e0670eadbe80756b167bf80ed6681d1ba57cfde2e8065a13d1/ty-0.0.29-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c8fba1a3524c6109d1e020d92301c79d41bf442fa8d335b9fa366239339cb70", size = 11475750, upload-time = "2026-04-05T15:01:30.461Z" }, + { url = "https://files.pythonhosted.org/packages/c4/cf/e361845b1081c9264ad5b7c963231bab03f2666865a9f2a115c4233f2137/ty-0.0.29-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4c48adf88a70d264128c39ee922ed14a947817fced1e93c08c1a89c9244edcde", size = 11190055, upload-time = "2026-04-05T15:01:12.369Z" }, + { url = "https://files.pythonhosted.org/packages/79/12/0fb0857e9a62cb11586e9a712103877bbf717f5fb570d16634408cfdefee/ty-0.0.29-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ce0a7a0e96bc7b42518cd3a1a6a6298ef64ff40ca4614355c1aa807059b5c6f", size = 11020539, upload-time = "2026-04-05T15:01:37.022Z" }, + { url = "https://files.pythonhosted.org/packages/20/36/5a26753802083f80cd125db6c4348ad42b3c982ec36e718e0bf4c18f75e5/ty-0.0.29-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6ac86a05b4a3731d45365ab97780acc7b8146fa62fccb3cbe94fe6546c67a97", size = 10396399, upload-time = "2026-04-05T15:01:26.167Z" }, + { url = "https://files.pythonhosted.org/packages/00/e6/b4e75b5752239ab3ab400f19faef4dbef81d05aab5d3419fda0c062a3765/ty-0.0.29-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6bbbf53141af0f3150bf288d716263f1a3550054e4b3551ca866d38192ba9891", size = 10421461, upload-time = "2026-04-05T15:01:08.367Z" }, + { url = "https://files.pythonhosted.org/packages/c0/21/1084b5b609f9abed62070ec0b31c283a403832a6310c8bbc208bd45ee1e6/ty-0.0.29-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1c9e06b770c1d0ff5efc51e34312390db31d53fcf3088163f413030b42b74f84", size = 10599187, upload-time = "2026-04-05T15:01:23.52Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a1/ce19a2ca717bbcc1ee11378aba52ef70b6ce5b87245162a729d9fdc2360f/ty-0.0.29-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0307fe37e3f000ef1a4ae230bbaf511508a78d24a5e51b40902a21b09d5e6037", size = 11121198, upload-time = "2026-04-05T15:01:15.22Z" }, + { url = "https://files.pythonhosted.org/packages/6b/6b/f1430b279af704321566ce7ec2725d3d8258c2f815ebd93e474c64cd4543/ty-0.0.29-py3-none-win32.whl", hash = "sha256:7a2a898217960a825f8bc0087e1fdbaf379606175e98f9807187221d53a4a8ed", size = 9995331, upload-time = "2026-04-05T15:01:01.32Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ef/3ef01c17785ff9a69378465c7d0faccd48a07b163554db0995e5d65a5a23/ty-0.0.29-py3-none-win_amd64.whl", hash = "sha256:fc1294200226b91615acbf34e0a9ad81caf98c081e9c6a912a31b0a7b603bc3f", size = 11023644, upload-time = "2026-04-05T15:01:04.432Z" }, + { url = "https://files.pythonhosted.org/packages/2c/55/87280a994d6a2d2647c65e12abbc997ed49835794366153c04c4d9304d76/ty-0.0.29-py3-none-win_arm64.whl", hash = "sha256:f9794bbd1bb3ce13f78c191d0c89ae4c63f52c12b6daa0c6fe220b90d019d12c", size = 10428165, upload-time = "2026-04-05T15:01:34.665Z" }, ] [[package]] @@ -1672,7 +1672,7 @@ wheels = [ [[package]] name = "zeromq" version = "4.3.5" -source = { git = "https://github.com/commaai/dependencies.git?subdirectory=zeromq&rev=release-zeromq#0f7d2b9121cc30c0e377717fc1db52205a8e4c80" } +source = { git = "https://github.com/commaai/dependencies.git?subdirectory=zeromq&rev=release-zeromq#250faf500a3d101b91f4c85a4618fe1882c9cf61" } [[package]] name = "zstandard" @@ -1702,4 +1702,4 @@ wheels = [ [[package]] name = "zstd" version = "1.5.6" -source = { git = "https://github.com/commaai/dependencies.git?subdirectory=zstd&rev=release-zstd#b2b10636beba0384eada30979651b4ca7cf919ff" } +source = { git = "https://github.com/commaai/dependencies.git?subdirectory=zstd&rev=release-zstd#6896f3e5ea22d632c5ea3bc6e5f3b773c144f43b" }