diff --git a/.github/workflows/auto_pr_review.yaml b/.github/workflows/auto_pr_review.yaml index b9664b9066..cedeee1741 100644 --- a/.github/workflows/auto_pr_review.yaml +++ b/.github/workflows/auto_pr_review.yaml @@ -40,10 +40,10 @@ jobs: 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-c3' + PR_LABEL: 'dev' TRUST_FORK_PR_LABEL: 'trust-fork-pr' steps: - - name: Check if PR has dev-c3 label + - name: Check if PR has dev label id: check-labels uses: actions/github-script@v7 with: @@ -62,11 +62,11 @@ jobs: 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-c3', hasDevC3Label ? 'true' : 'false'); + 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-c3 == 'true' && steps.check-labels.outputs.has-trust == 'true' + 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 }} diff --git a/.github/workflows/repo-maintenance.yaml b/.github/workflows/repo-maintenance.yaml index 14b4d904e3..49dae93747 100644 --- a/.github/workflows/repo-maintenance.yaml +++ b/.github/workflows/repo-maintenance.yaml @@ -43,6 +43,7 @@ jobs: with: submodules: true - name: uv lock + if: github.repository == 'commaai/openpilot' run: | python3 -m ensurepip --upgrade pip3 install uv diff --git a/.github/workflows/selfdrive_tests.yaml b/.github/workflows/selfdrive_tests.yaml index 640ef3d01a..41d8156ca8 100644 --- a/.github/workflows/selfdrive_tests.yaml +++ b/.github/workflows/selfdrive_tests.yaml @@ -107,7 +107,6 @@ jobs: build_mac: name: build macOS - if: false # temp disable since homebrew install is getting stuck runs-on: ${{ ((github.repository == 'commaai/openpilot') && ((github.event_name != 'pull_request') || (github.event.pull_request.head.repo.full_name == 'commaai/openpilot'))) && 'namespace-profile-macos-8x14' || 'macos-latest' }} steps: - uses: actions/checkout@v4 @@ -125,8 +124,8 @@ jobs: - name: Install dependencies run: ./tools/mac_setup.sh env: - # package install has DeprecationWarnings - PYTHONWARNINGS: default + PYTHONWARNINGS: default # package install has DeprecationWarnings + HOMEBREW_DISPLAY_INSTALL_TIMES: 1 - name: Save Homebrew cache uses: actions/cache/save@v4 if: github.ref == 'refs/heads/master' diff --git a/.github/workflows/sunnypilot-build-prebuilt.yaml b/.github/workflows/sunnypilot-build-prebuilt.yaml index 4f52e7fa29..00ae1e28bf 100644 --- a/.github/workflows/sunnypilot-build-prebuilt.yaml +++ b/.github/workflows/sunnypilot-build-prebuilt.yaml @@ -8,21 +8,15 @@ env: PUBLIC_REPO_URL: "https://github.com/sunnypilot/sunnypilot" # Branch configurations - STAGING_C3_SOURCE_BRANCH: ${{ vars.STAGING_C3_SOURCE_BRANCH || 'master' }} # vars are set on repo settings. - DEV_C3_SOURCE_BRANCH: ${{ vars.DEV_C3_SOURCE_BRANCH || 'master-dev-c3-new' }} # vars are set on repo settings. - - # Target branch configurations - STAGING_TARGET_BRANCH: ${{ vars.STAGING_TARGET_BRANCH || 'staging-c3-new' }} # vars are set on repo settings. - DEV_TARGET_BRANCH: ${{ vars.DEV_TARGET_BRANCH || 'dev-c3-new' }} # vars are set on repo settings. - RELEASE_TARGET_BRANCH: ${{ vars.RELEASE_TARGET_BRANCH || 'release-c3-new' }} # vars are set on repo settings. + STAGING_SOURCE_BRANCH: 'master' # Runtime configuration SOURCE_BRANCH: "${{ github.head_ref || github.ref_name }}" on: push: - branches: [ master, master-dev-c3-new ] - tags: [ '*' ] + branches: [ master, master-dev ] + tags: [ 'release/*' ] pull_request_target: types: [ labeled ] workflow_dispatch: @@ -34,9 +28,72 @@ on: 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 common/version.h | grep COMMA_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 - if: ((github.event_name == 'workflow_dispatch' && inputs.wait_for_tests) || contains(github.event_name, 'pull_request') && (github.event.action == 'labeled' && github.event.label.name == 'prebuilt')) + 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 @@ -44,19 +101,26 @@ jobs: with: workflow: selfdrive_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 ] + 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: ${{ steps.set-env.outputs.new_branch }} - version: ${{ steps.set-env.outputs.version }} - extra_version_identifier: ${{ steps.set-env.outputs.extra_version_identifier }} - commit_sha: ${{ steps.set-env.outputs.commit_sha }} - if: ${{ (always() && !failure() && !cancelled()) && (!contains(github.event_name, 'pull_request') || (github.event.action == 'labeled' && github.event.label.name == 'prebuilt')) }} + 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: @@ -74,64 +138,15 @@ jobs: # 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_C3_SOURCE_BRANCH }} + scons-${{ runner.os }}-${{ runner.arch }}-${{ env.STAGING_SOURCE_BRANCH }} scons-${{ runner.os }}-${{ runner.arch }} - - name: Set Feature Branch Prebuilt Configuration - id: set_feature_configuration - if: ( - !(env.SOURCE_BRANCH == env.DEV_C3_SOURCE_BRANCH) && - !(env.SOURCE_BRANCH == env.STAGING_C3_SOURCE_BRANCH) && - !(startsWith(github.ref, 'refs/tags/')) - ) - run: | - echo "NEW_BRANCH=${{ env.SOURCE_BRANCH }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }}-prebuilt" >> $GITHUB_ENV - echo "VERSION=$(date '+%Y.%m.%d')-${{ github.run_number }}" >> $GITHUB_ENV - - - name: Set dev-c3-new prebuilt Configuration - id: set_dev_configuration - if: ( - steps.set_feature_configuration.outcome == 'skipped' && - env.SOURCE_BRANCH == env.DEV_C3_SOURCE_BRANCH - ) - run: | - echo "NEW_BRANCH=${{ env.DEV_TARGET_BRANCH }}" >> $GITHUB_ENV - echo "VERSION=$(date '+%Y.%m.%d')-${{ github.run_number }}" >> $GITHUB_ENV - echo "EXTRA_VERSION_IDENTIFIER=${{ github.run_number }}" >> $GITHUB_ENV - - - name: Set staging-c3-new prebuilt Configuration - id: set_staging_configuration - if: ( - steps.set_feature_configuration.outcome == 'skipped' && - !contains(github.event_name, 'pull_request') && - steps.set_dev_configuration.outcome == 'skipped' && - (env.SOURCE_BRANCH == env.STAGING_C3_SOURCE_BRANCH) - ) - run: | - echo "NEW_BRANCH=${{ env.STAGING_TARGET_BRANCH }}" >> $GITHUB_ENV - echo "EXTRA_VERSION_IDENTIFIER=staging" >> $GITHUB_ENV - echo "VERSION=$(cat common/version.h | grep COMMA_VERSION | sed -e 's/[^0-9|.]//g')-staging" >> $GITHUB_ENV - - - name: Set release-c3-new prebuilt Configuration - id: set_tag_configuration - if: ( - steps.set_feature_configuration.outcome == 'skipped' && - !contains(github.event_name, 'pull_request') && - steps.set_staging_configuration.outcome == 'skipped' && - startsWith(github.ref, 'refs/tags/') - ) - run: | - echo "NEW_BRANCH=${{ env.RELEASE_TARGET_BRANCH }}" >> $GITHUB_ENV - echo "EXTRA_VERSION_IDENTIFIER=release" >> $GITHUB_ENV - echo "VERSION=$(cat common/version.h | grep COMMA_VERSION | sed -e 's/[^0-9|.]//g')-release" >> $GITHUB_ENV - - name: Set environment variables id: set-env run: | - # Write to GITHUB_OUTPUT from environment variables - echo "new_branch=$NEW_BRANCH" >> $GITHUB_OUTPUT - [[ ! -z "$EXTRA_VERSION_IDENTIFIER" ]] && echo "extra_version_identifier=$EXTRA_VERSION_IDENTIFIER" >> $GITHUB_OUTPUT - [[ ! -z "$VERSION" ]] && echo "version=$VERSION" >> $GITHUB_OUTPUT + 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 @@ -226,15 +241,19 @@ jobs: if: always() run: | PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --enable - + + publish: concurrency: - group: publish-${{ github.head_ref || github.ref_name }} - cancel-in-progress: true - if: ${{ (always() && !failure() && !cancelled()) && (!contains(github.event_name, 'pull_request') || (github.event.action == 'labeled' && github.event.label.name == 'prebuilt')) }} - needs: [ build ] + # 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: ${{ (contains(fromJSON(vars.AUTO_DEPLOY_PREBUILT_BRANCHES), github.head_ref || github.ref_name) || contains(github.event.pull_request.labels.*.name, 'prebuilt')) && 'auto-deploy' || 'feature-branch' }} + environment: ${{ needs.prepare_strategy.outputs.environment }} steps: - uses: actions/checkout@v4 @@ -266,7 +285,7 @@ jobs: "${{ 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 }}" + "${{ needs.build.outputs.extra_version_identifier }}" echo "" echo "---- ℹ️ To update the list of branches that auto deploy prebuilts -----" @@ -274,11 +293,18 @@ jobs: 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: [ build, publish ] runs-on: ubuntu-24.04 - if: ${{ (always() && !failure() && !cancelled()) && (!contains(github.event_name, 'pull_request') || (github.event.action == 'labeled' && github.event.label.name == 'prebuilt')) }} + if: ${{ (always() && !cancelled() && !failure()) && needs.publish.result == 'success' && !failure() && (!contains(github.event_name, 'pull_request') || (github.event.action == 'labeled' && github.event.label.name == 'prebuilt')) }} steps: - uses: actions/checkout@v4 - name: Setup Alpine Linux environment diff --git a/.github/workflows/sunnypilot-master-dev-c3-prep.yaml b/.github/workflows/sunnypilot-master-dev-prep.yaml similarity index 95% rename from .github/workflows/sunnypilot-master-dev-c3-prep.yaml rename to .github/workflows/sunnypilot-master-dev-prep.yaml index d4c201824e..122755f2c4 100644 --- a/.github/workflows/sunnypilot-master-dev-c3-prep.yaml +++ b/.github/workflows/sunnypilot-master-dev-prep.yaml @@ -1,9 +1,9 @@ -name: Build dev-c3-new +name: Build dev env: DEFAULT_SOURCE_BRANCH: "master" - DEFAULT_TARGET_BRANCH: "master-dev-c3-new" - PR_LABEL: "dev-c3" + DEFAULT_TARGET_BRANCH: "master-dev" + PR_LABEL: "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' @@ -25,7 +25,7 @@ on: target_branch: description: 'Target branch to reset and squash into' required: true - default: 'master-dev-c3-new' + default: 'master-dev' type: string cancel_in_progress: description: 'Cancel any in-progress runs of this workflow' @@ -43,7 +43,7 @@ jobs: 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 == 'dev-c3' || github.event.label.name == 'trust-fork-pr') && contains(github.event.pull_request.labels.*.name, 'dev-c3')))) + || (contains(github.event_name, 'pull_request') && ((github.event.action == 'labeled' && (github.event.label.name == 'dev' || github.event.label.name == 'trust-fork-pr') && contains(github.event.pull_request.labels.*.name, 'dev')))) ) steps: - uses: actions/checkout@v4 @@ -55,7 +55,7 @@ jobs: 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 == 'dev-c3' || github.event.label.name == 'trust-fork-pr') && contains(github.event.pull_request.labels.*.name, 'dev-c3')))) + || (contains(github.event_name, 'pull_request') && ((github.event.action == 'labeled' && (github.event.label.name == 'dev' || github.event.label.name == 'trust-fork-pr') && contains(github.event.pull_request.labels.*.name, 'dev')))) ) with: workflow: selfdrive_tests.yaml # The workflow file to monitor diff --git a/.gitignore b/.gitignore index 3c0dad521d..00a0533d86 100644 --- a/.gitignore +++ b/.gitignore @@ -50,7 +50,6 @@ cereal/services.h cereal/gen cereal/messaging/bridge selfdrive/mapd/default_speeds_by_region.json -system/proclogd/proclogd selfdrive/ui/translations/tmp selfdrive/test/longitudinal_maneuvers/out selfdrive/car/tests/cars_dump diff --git a/.vscode/launch.json b/.vscode/launch.json index a6b341d9ea..f090061c42 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -23,6 +23,11 @@ "id": "args", "description": "Arguments to pass to the process", "type": "promptString" + }, + { + "id": "replayArg", + "type": "promptString", + "description": "Enter route or segment to replay." } ], "configurations": [ @@ -40,7 +45,41 @@ "type": "cppdbg", "request": "launch", "program": "${workspaceFolder}/${input:cpp_process}", - "cwd": "${workspaceFolder}", + "cwd": "${workspaceFolder}" + }, + { + "name": "Attach LLDB to Replay drive", + "type": "lldb", + "request": "attach", + "pid": "${command:pickMyProcess}", + "initCommands": [ + "script import time; time.sleep(3)" + ] + }, + { + "name": "Replay drive", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/opendbc/safety/tests/safety_replay/replay_drive.py", + "args": [ + "${input:replayArg}" + ], + "console": "integratedTerminal", + "justMyCode": false, + "env": { + "PYTHONPATH": "${workspaceFolder}" + }, + "subProcess": true, + "stopOnEntry": false + } + ], + "compounds": [ + { + "name": "Replay drive + Safety LLDB", + "configurations": [ + "Replay drive", + "Attach LLDB to Replay drive" + ] } ] } \ No newline at end of file diff --git a/Jenkinsfile b/Jenkinsfile index 0905abd6da..f3a63d3dec 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -178,7 +178,7 @@ node { try { if (env.BRANCH_NAME == 'devel-staging') { - deviceStage("build release3-staging", "tici-needs-can", [], [ + deviceStage("build release3-staging", "tizi-needs-can", [], [ step("build release3-staging", "RELEASE_BRANCH=release3-staging $SOURCE_DIR/release/build_release.sh"), ]) } @@ -186,12 +186,12 @@ node { if (env.BRANCH_NAME == '__nightly') { parallel ( 'nightly': { - deviceStage("build nightly", "tici-needs-can", [], [ + deviceStage("build nightly", "tizi-needs-can", [], [ step("build nightly", "RELEASE_BRANCH=nightly $SOURCE_DIR/release/build_release.sh"), ]) }, 'nightly-dev': { - deviceStage("build nightly-dev", "tici-needs-can", [], [ + deviceStage("build nightly-dev", "tizi-needs-can", [], [ step("build nightly-dev", "PANDA_DEBUG_BUILD=1 RELEASE_BRANCH=nightly-dev $SOURCE_DIR/release/build_release.sh"), ]) }, @@ -200,39 +200,30 @@ node { if (!env.BRANCH_NAME.matches(excludeRegex)) { parallel ( - // tici tests 'onroad tests': { - deviceStage("onroad", "tici-needs-can", ["UNSAFE=1"], [ + deviceStage("onroad", "tizi-needs-can", ["UNSAFE=1"], [ step("build openpilot", "cd system/manager && ./build.py"), step("check dirty", "release/check-dirty.sh"), step("onroad tests", "pytest selfdrive/test/test_onroad.py -s", [timeout: 60]), ]) }, 'HW + Unit Tests': { - deviceStage("tici-hardware", "tici-common", ["UNSAFE=1"], [ + deviceStage("tizi-hardware", "tizi-common", ["UNSAFE=1"], [ step("build", "cd system/manager && ./build.py"), step("test pandad", "pytest selfdrive/pandad/tests/test_pandad.py", [diffPaths: ["panda", "selfdrive/pandad/"]]), step("test power draw", "pytest -s system/hardware/tici/tests/test_power_draw.py"), step("test encoder", "LD_LIBRARY_PATH=/usr/local/lib pytest system/loggerd/tests/test_encoder.py", [diffPaths: ["system/loggerd/"]]), - step("test pigeond", "pytest system/ubloxd/tests/test_pigeond.py", [diffPaths: ["system/ubloxd/"]]), step("test manager", "pytest system/manager/test/test_manager.py"), ]) }, 'loopback': { - deviceStage("loopback", "tici-loopback", ["UNSAFE=1"], [ + deviceStage("loopback", "tizi-loopback", ["UNSAFE=1"], [ step("build openpilot", "cd system/manager && ./build.py"), step("test pandad loopback", "pytest selfdrive/pandad/tests/test_pandad_loopback.py"), ]) }, - 'camerad AR0231': { - deviceStage("AR0231", "tici-ar0231", ["UNSAFE=1"], [ - step("build", "cd system/manager && ./build.py"), - step("test camerad", "pytest system/camerad/test/test_camerad.py", [timeout: 60]), - step("test exposure", "pytest system/camerad/test/test_exposure.py"), - ]) - }, 'camerad OX03C10': { - deviceStage("OX03C10", "tici-ox03c10", ["UNSAFE=1"], [ + deviceStage("OX03C10", "tizi-ox03c10", ["UNSAFE=1"], [ step("build", "cd system/manager && ./build.py"), step("test camerad", "pytest system/camerad/test/test_camerad.py", [timeout: 60]), step("test exposure", "pytest system/camerad/test/test_exposure.py"), @@ -246,17 +237,13 @@ node { ]) }, 'sensord': { - deviceStage("LSM + MMC", "tici-lsmc", ["UNSAFE=1"], [ - step("build", "cd system/manager && ./build.py"), - step("test sensord", "pytest system/sensord/tests/test_sensord.py"), - ]) - deviceStage("BMX + LSM", "tici-bmx-lsm", ["UNSAFE=1"], [ + deviceStage("LSM + MMC", "tizi-lsmc", ["UNSAFE=1"], [ step("build", "cd system/manager && ./build.py"), step("test sensord", "pytest system/sensord/tests/test_sensord.py"), ]) }, 'replay': { - deviceStage("model-replay", "tici-replay", ["UNSAFE=1"], [ + deviceStage("model-replay", "tizi-replay", ["UNSAFE=1"], [ step("build", "cd system/manager && ./build.py", [diffPaths: ["selfdrive/modeld/", "tinygrad_repo", "selfdrive/test/process_replay/model_replay.py"]]), step("model replay", "selfdrive/test/process_replay/model_replay.py", [diffPaths: ["selfdrive/modeld/", "tinygrad_repo", "selfdrive/test/process_replay/model_replay.py"]]), ]) @@ -266,7 +253,6 @@ node { step("build openpilot", "cd system/manager && ./build.py"), step("test pandad loopback", "SINGLE_PANDA=1 pytest selfdrive/pandad/tests/test_pandad_loopback.py"), step("test pandad spi", "pytest selfdrive/pandad/tests/test_pandad_spi.py"), - step("test pandad", "pytest selfdrive/pandad/tests/test_pandad.py", [diffPaths: ["panda", "selfdrive/pandad/"]]), step("test amp", "pytest system/hardware/tici/tests/test_amplifier.py"), // TODO: enable once new AGNOS is available // step("test esim", "pytest system/hardware/tici/tests/test_esim.py"), diff --git a/README.md b/README.md index 805c4b73d8..7e686cef5c 100644 --- a/README.md +++ b/README.md @@ -22,34 +22,24 @@ https://docs.sunnypilot.ai/ is your one stop shop for everything from features t Detailed instructions for [how to mount the device in a car](https://comma.ai/setup). ## Installation -Please refer to [Recommended Branches](#-recommended-branches) to find your preferred/supported branch. This guide will assume you want to install the latest `release-c3` branch. +Please refer to [Recommended Branches](#-recommended-branches) to find your preferred/supported branch. This guide will assume you want to install the latest `staging-c3-new` branch. + +### If you want to use our newest branches (our rewrite) +> [!TIP] +>You can see the rewrite state on our [rewrite project board](https://github.com/orgs/sunnypilot/projects/2), and to install the new branches, you can use the following links * sunnypilot not installed or you installed a version before 0.8.17? 1. [Factory reset/uninstall](https://github.com/commaai/openpilot/wiki/FAQ#how-can-i-reset-the-device) the previous software if you have another software/fork installed. 2. After factory reset/uninstall and upon reboot, select `Custom Software` when given the option. - 3. Input the installation URL per [Recommended Branches](#-recommended-branches). Example: ```release-c3.sunnypilot.ai```. + 3. Input the installation URL per [Recommended Branches](#-recommended-branches). Example: ```https://staging-c3-new.sunnypilot.ai```. 4. Complete the rest of the installation following the onscreen instructions. * sunnypilot already installed and you installed a version after 0.8.17? 1. On the comma three, go to `Settings` ▶️ `Software`. 2. At the `Download` option, press `CHECK`. This will fetch the list of latest branches from sunnypilot. 3. At the `Target Branch` option, press `SELECT` to open the Target Branch selector. - 4. Scroll to select the desired branch per Recommended Branches (see below). Example: `release-c3` + 4. Scroll to select the desired branch per Recommended Branches (see below). Example: `staging-c3-new` -| Branch | Installation URL | -|:------------:|:--------------------------------:| -| `release-c3` | https://release-c3.sunnypilot.ai | -| `staging-c3` | https://staging-c3.sunnypilot.ai | -| `dev-c3` | https://dev-c3.sunnypilot.ai | - -### If you want to use our newest branches (our rewrite) -> [!TIP] ->You can see the rewrite state on our [rewrite project board](https://github.com/orgs/sunnypilot/projects/2), and to install the new branches, you can use the following links - - -> [!IMPORTANT] -> It is recommended to [re-flash AGNOS](https://flash.comma.ai/) if you intend to downgrade from the new branches. -> You can still restore the latest sunnylink backup made on the old branches. | Branch | Installation URL | |:----------------:|:---------------------------------------------:| @@ -59,8 +49,31 @@ Please refer to [Recommended Branches](#-recommended-branches) to find your pref | `release-c3-new` | **Not yet available**. | > [!TIP] +> You can use sunnypilot/targetbranch as an install URL. Example: 'sunnypilot/staging-c3-new'. + +> [!NOTE] > Do you require further assistance with software installation? Join the [sunnypilot Discord server](https://discord.sunnypilot.com) and message us in the `#installation-help` channel. + +
+ +Older legacy branches + +### If you want to use our older legacy branches (*not recommended*) + +> [**IMPORTANT**] +> It is recommended to [re-flash AGNOS](https://flash.comma.ai/) if you intend to downgrade from the new branches. +> You can still restore the latest sunnylink backup made on the old branches. + +| Branch | Installation URL | +|:------------:|:--------------------------------:| +| `release-c3` | https://release-c3.sunnypilot.ai | +| `staging-c3` | https://staging-c3.sunnypilot.ai | +| `dev-c3` | https://dev-c3.sunnypilot.ai | + +
+ + ## 🎆 Pull Requests We welcome both pull requests and issues on GitHub. Bug fixes are encouraged. diff --git a/RELEASES.md b/RELEASES.md index dacf0eaa17..189aa7ad54 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -1,7 +1,13 @@ Version 0.10.1 (2025-09-08) ======================== -* Record driving feedback using LKAS button -* Honda City 2023 support thanks to drFritz! +* 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! Version 0.10.0 (2025-08-05) ======================== diff --git a/SConstruct b/SConstruct index 192f83a0f6..12d879b350 100644 --- a/SConstruct +++ b/SConstruct @@ -359,11 +359,6 @@ SConscript([ 'system/ubloxd/SConscript', 'system/loggerd/SConscript', ]) -if arch != "Darwin": - SConscript([ - 'system/logcatd/SConscript', - 'system/proclogd/SConscript', - ]) if arch == "larch64": SConscript(['system/camerad/SConscript']) diff --git a/cereal/custom.capnp b/cereal/custom.capnp index 52c648f388..bbb3b1f1d8 100644 --- a/cereal/custom.capnp +++ b/cereal/custom.capnp @@ -25,6 +25,26 @@ struct ModularAssistiveDrivingSystem { } } +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; + } +} + # Same struct as Log.RadarState.LeadData struct LeadData { dRel @0 :Float32; @@ -48,6 +68,7 @@ struct LeadData { struct SelfdriveStateSP @0x81c2f05a394cf4af { mads @0 :ModularAssistiveDrivingSystem; + intelligentCruiseButtonManagement @1 :IntelligentCruiseButtonManagement; } struct ModelManagerSP @0xaedffd8f31e7b55d { @@ -122,9 +143,10 @@ struct ModelManagerSP @0xaedffd8f31e7b55d { struct LongitudinalPlanSP @0xf35cc4560bbf6ec2 { dec @0 :DynamicExperimentalControl; - - events @1 :List(OnroadEventSP.Event); - slc @2 :SpeedLimitControl; + longitudinalPlanSource @1 :LongitudinalPlanSource; + smartCruiseControl @2 :SmartCruiseControl; + speedLimitAssist @3 :SpeedLimitAssist; + events @4 :List(OnroadEventSP.Event); struct DynamicExperimentalControl { state @0 :DynamicExperimentalControlState; @@ -137,21 +159,58 @@ struct LongitudinalPlanSP @0xf35cc4560bbf6ec2 { } } - struct SpeedLimitControl { - state @0 :SpeedLimitControlState; + struct SmartCruiseControl { + vision @0 :Vision; + + struct Vision { + state @0 :VisionState; + vTarget @1 :Float32; + aTarget @2 :Float32; + currentLateralAccel @3 :Float32; + maxPredictedLateralAccel @4 :Float32; + enabled @5 :Bool; + active @6 :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. + } + } + + struct SpeedLimitAssist { + state @0 :SpeedLimitAssistState; enabled @1 :Bool; active @2 :Bool; speedLimit @3 :Float32; speedLimitOffset @4 :Float32; distToSpeedLimit @5 :Float32; + source @6 :SpeedLimitSource; } - enum SpeedLimitControlState { - inactive @0; # No speed limit set or not enabled by parameter. - tempInactive @1; # User wants to ignore speed limit until it changes. + enum LongitudinalPlanSource { + cruise @0; + sccVision @1; + speedLimitAssist @2; + } + + enum SpeedLimitAssistState { + disabled @0; + inactive @1; # No speed limit set or not enabled by parameter. preActive @2; - adapting @3; # Reducing speed to match new speed limit. - active @4; # Cruising at speed limit. + pending @3; # Awaiting new speed limit. + adapting @4; # Reducing speed to match new speed limit. + active @5; # Cruising at speed limit. + } + + enum SpeedLimitSource { + none @0; + car @1; + map @2; } } @@ -192,16 +251,20 @@ struct OnroadEventSP @0xda96579883444c35 { experimentalModeSwitched @14; wrongCarModeAlertOnly @15; pedalPressedAlertOnly @16; - speedLimitPreActive @17; - speedLimitActive @18; - speedLimitConfirmed @19; - speedLimitValueChange @20; + laneTurnLeft @17; + laneTurnRight @18; + speedLimitPreActive @19; + speedLimitActive @20; + speedLimitConfirmed @21; + speedLimitChanged @22; } } 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 = true; + intelligentCruiseButtonManagementAvailable @4 :Bool; neuralNetworkLateralControl @2 :NeuralNetworkLateralControl; @@ -221,10 +284,24 @@ struct CarControlSP @0xa5cd762cd951a455 { params @1 :List(Param); leadOne @2 :LeadData; leadTwo @3 :LeadData; + intelligentCruiseButtonManagement @4 :IntelligentCruiseButtonManagement; struct Param { key @0 :Text; - value @1 :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; } } @@ -283,9 +360,16 @@ struct LiveMapDataSP @0xf416ec09499d9d19 { roadName @5 :Text; } -struct CustomReserved9 @0xa1680744031fdb2d { +struct ModelDataV2SP @0xa1680744031fdb2d { + laneTurnDirection @0 :TurnDirection; } + enum TurnDirection { + none @0; + turnLeft @1; + turnRight @2; + } + struct CustomReserved10 @0xcb9fd56c7057593a { } diff --git a/cereal/log.capnp b/cereal/log.capnp index 15795f8c38..7babd4d8b5 100644 --- a/cereal/log.capnp +++ b/cereal/log.capnp @@ -585,7 +585,6 @@ struct PandaState @0xa7649e2575e4591e { heartbeatLost @22 :Bool; interruptLoad @25 :Float32; fanPower @28 :UInt8; - fanStallCount @34 :UInt8; spiErrorCount @33 :UInt16; @@ -714,6 +713,7 @@ struct PandaState @0xa7649e2575e4591e { usbPowerModeDEPRECATED @12 :PeripheralState.UsbPowerModeDEPRECATED; safetyParamDEPRECATED @20 :Int16; safetyParam2DEPRECATED @26 :UInt32; + fanStallCountDEPRECATED @34 :UInt8; } struct PeripheralState { @@ -2631,7 +2631,7 @@ struct Event { backupManagerSP @113 :Custom.BackupManagerSP; carStateSP @114 :Custom.CarStateSP; liveMapDataSP @115 :Custom.LiveMapDataSP; - customReserved9 @116 :Custom.CustomReserved9; + modelDataV2SP @116 :Custom.ModelDataV2SP; customReserved10 @136 :Custom.CustomReserved10; customReserved11 @137 :Custom.CustomReserved11; customReserved12 @138 :Custom.CustomReserved12; diff --git a/cereal/messaging/socketmaster.cc b/cereal/messaging/socketmaster.cc index 1a7a48980e..7f7e2795c4 100644 --- a/cereal/messaging/socketmaster.cc +++ b/cereal/messaging/socketmaster.cc @@ -33,7 +33,7 @@ MessageContext message_context; struct SubMaster::SubMessage { std::string name; SubSocket *socket = nullptr; - int freq = 0; + float freq = 0.0f; bool updated = false, alive = false, valid = false, ignore_alive; uint64_t rcv_time = 0, rcv_frame = 0; void *allocated_msg_reader = nullptr; diff --git a/cereal/services.py b/cereal/services.py index 373e865e34..17ac2e926d 100755 --- a/cereal/services.py +++ b/cereal/services.py @@ -88,6 +88,7 @@ _services: dict[str, tuple] = { "carControlSP": (True, 100., 10), "carStateSP": (True, 100., 10), "liveMapDataSP": (True, 1., 1), + "modelDataV2SP": (True, 20.), # debug "uiDebug": (True, 0., 1), @@ -120,12 +121,12 @@ def build_header(): h += "#include \n" h += "#include \n" - h += "struct service { std::string name; bool should_log; int frequency; int decimation; };\n" + h += "struct service { std::string name; bool should_log; float frequency; int decimation; };\n" h += "static std::map services = {\n" for k, v in SERVICE_LIST.items(): should_log = "true" if v.should_log else "false" decimation = -1 if v.decimation is None else v.decimation - h += ' { "%s", {"%s", %s, %d, %d}},\n' % \ + h += ' { "%s", {"%s", %s, %f, %d}},\n' % \ (k, k, should_log, v.frequency, decimation) h += "};\n" diff --git a/common/model.h b/common/model.h index a984f55e8d..a1e034c06f 100644 --- a/common/model.h +++ b/common/model.h @@ -1 +1 @@ -#define DEFAULT_MODEL "Steam Powered (Default)" +#define DEFAULT_MODEL "Firehose (Default)" diff --git a/common/params_keys.h b/common/params_keys.h index c35a3220ac..3d1dc6cb9a 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -94,7 +94,6 @@ inline static std::unordered_map keys = { {"Offroad_NeosUpdate", {CLEAR_ON_MANAGER_START, JSON}}, {"Offroad_NoFirmware", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}}, {"Offroad_Recalibration", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}}, - {"Offroad_StorageMissing", {CLEAR_ON_MANAGER_START, JSON}}, {"Offroad_TemperatureTooHigh", {CLEAR_ON_MANAGER_START, JSON}}, {"Offroad_UnregisteredHardware", {CLEAR_ON_MANAGER_START, JSON}}, {"Offroad_UpdateFailed", {CLEAR_ON_MANAGER_START, JSON}}, @@ -146,16 +145,23 @@ inline static std::unordered_map keys = { {"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}}, {"GithubRunnerSufficientVoltage", {CLEAR_ON_MANAGER_START , BOOL}}, + {"IntelligentCruiseButtonManagement", {PERSISTENT | BACKUP , BOOL}}, {"InteractivityTimeout", {PERSISTENT | BACKUP, INT, "0"}}, {"IsDevelopmentBranch", {CLEAR_ON_MANAGER_START, BOOL}}, {"MaxTimeOffroad", {PERSISTENT | BACKUP, INT, "1800"}}, {"ModelRunnerTypeCache", {CLEAR_ON_ONROAD_TRANSITION, INT}}, {"OffroadMode", {CLEAR_ON_MANAGER_START, BOOL}}, + {"Offroad_TiciSupport", {CLEAR_ON_MANAGER_START, JSON}}, {"QuickBootToggle", {PERSISTENT | BACKUP, BOOL, "0"}}, {"QuietMode", {PERSISTENT | BACKUP, BOOL, "0"}}, + {"RainbowMode", {PERSISTENT | BACKUP, BOOL, "0"}}, {"ShowAdvancedControls", {PERSISTENT | BACKUP, BOOL, "0"}}, + {"SmartCruiseControlVision", {PERSISTENT | BACKUP, BOOL, "0"}}, + {"StandstillTimer", {PERSISTENT | BACKUP, BOOL, "0"}}, // MADS params {"Mads", {PERSISTENT | BACKUP, BOOL, "1"}}, @@ -167,6 +173,7 @@ inline static std::unordered_map keys = { {"ModelManager_ActiveBundle", {PERSISTENT, JSON}}, {"ModelManager_ClearCache", {CLEAR_ON_MANAGER_START, BOOL}}, {"ModelManager_DownloadIndex", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, INT, "0"}}, + {"ModelManager_Favs", {PERSISTENT | BACKUP, STRING}}, {"ModelManager_LastSyncTime", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, INT, "0"}}, {"ModelManager_ModelsCache", {PERSISTENT | BACKUP, JSON}}, @@ -192,10 +199,12 @@ inline static std::unordered_map keys = { {"DynamicExperimentalControl", {PERSISTENT | BACKUP, BOOL, "0"}}, {"BlindSpot", {PERSISTENT | BACKUP, BOOL, "0"}}, - // model panel params + // sunnypilot model params {"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"}}, // mapd {"MapAdvisorySpeedLimit", {CLEAR_ON_ONROAD_TRANSITION, FLOAT}}, @@ -218,8 +227,8 @@ inline static std::unordered_map keys = { {"RoadName", {CLEAR_ON_ONROAD_TRANSITION, STRING}}, // Speed Limit Control - {"SpeedLimitControl", {PERSISTENT | BACKUP, BOOL, "0"}}, - {"SpeedLimitControlPolicy", {PERSISTENT | BACKUP, INT, "3"}}, + {"SpeedLimitAssist", {PERSISTENT | BACKUP, BOOL, "0"}}, + {"SpeedLimitAssistPolicy", {PERSISTENT | BACKUP, INT, "3"}}, {"SpeedLimitEngageType", {PERSISTENT | BACKUP, INT, "0"}}, {"SpeedLimitOffsetType", {PERSISTENT | BACKUP, INT, "0"}}, {"SpeedLimitValueOffset", {PERSISTENT | BACKUP, INT, "0"}}, diff --git a/docs/CARS.md b/docs/CARS.md index e10dc8ae77..dbc1dbcd74 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. -# 326 Supported Cars +# 334 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 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
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 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| +|Acura|MDX 2025|All except Type S|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch C connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
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 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
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 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| |Audi|A3 2014-19|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| @@ -72,12 +73,15 @@ A supported vehicle is one that just works when you install a comma device. All |Genesis|GV80 2023[6](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai M connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| |GMC|Sierra 1500 2020-21|Driver Alert Package II|openpilot available[1](#footnotes)|0 mph|6 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 GM connector
- 1 comma 3X
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| |Honda|Accord 2018-22|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 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Honda|Accord 2023|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch C connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| +|Honda|Accord 2023-25|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch C connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| |Honda|Accord Hybrid 2018-22|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 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| +|Honda|Accord Hybrid 2023-25|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch C connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| +|Honda|City (Brazil only) 2023|All|openpilot available[1](#footnotes)|0 mph|14 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| |Honda|Civic 2016-18|Honda Sensing|openpilot|0 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| |Honda|Civic 2019-21|All|openpilot available[1](#footnotes)|0 mph|2 mph[5](#footnotes)|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| |Honda|Civic 2022-24|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Honda|Civic Hatchback 2017-21|Honda Sensing|openpilot available[1](#footnotes)|0 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| +|Honda|Civic Hatchback 2017-18|Honda Sensing|openpilot available[1](#footnotes)|0 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| +|Honda|Civic Hatchback 2019-21|All|openpilot available[1](#footnotes)|0 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| |Honda|Civic Hatchback 2022-24|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| |Honda|Civic Hatchback Hybrid 2025|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| |Honda|Civic Hatchback Hybrid (Europe only) 2023|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| @@ -85,7 +89,9 @@ A supported vehicle is one that just works when you install a comma device. All |Honda|Clarity 2018-21|Honda Sensing|openpilot|0 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Nidec connector + Honda Clarity Proxy Board
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| |Honda|CR-V 2015-16|Touring Trim|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| |Honda|CR-V 2017-22|Honda Sensing|openpilot available[1](#footnotes)|0 mph|15 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| +|Honda|CR-V 2023-25|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch C connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| |Honda|CR-V Hybrid 2017-22|Honda Sensing|openpilot available[1](#footnotes)|0 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| +|Honda|CR-V Hybrid 2023-25|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch C connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| |Honda|e 2020|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 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| |Honda|Fit 2018-20|Honda Sensing|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| |Honda|Freed 2020|Honda Sensing|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| @@ -96,6 +102,7 @@ A supported vehicle is one that just works when you install a comma device. All |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 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
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 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| |Honda|Pilot 2016-22|Honda Sensing|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| +|Honda|Pilot 2023-25|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch C connector
- 1 angled mount (8 degrees)
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| |Honda|Ridgeline 2017-25|Honda Sensing|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| |Hyundai|Azera 2022|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai K connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| |Hyundai|Azera Hybrid 2019|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai C connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| @@ -120,7 +127,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 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
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 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
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 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Kona 2022|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 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
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 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
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 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
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 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| |Hyundai|Kona Electric (with HDA II, Korea only) 2023[6](#footnotes)|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 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| @@ -298,6 +305,7 @@ A supported vehicle is one that just works when you install a comma device. All |Toyota|RAV4 Hybrid 2022|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| |Toyota|RAV4 Hybrid 2023-25|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| |Toyota|Sienna 2018-20|All|openpilot available[2](#footnotes)|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| +|Toyota|Wildlander PHEV 2021|All|openpilot|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| |Volkswagen|Arteon 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| |Volkswagen|Arteon eHybrid 2020-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| |Volkswagen|Arteon R 2020-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 154734b7fc..7583095eaf 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -39,7 +39,7 @@ All of these are examples of good PRs: ### First contribution [Projects / openpilot bounties](https://github.com/orgs/commaai/projects/26/views/1?pane=info) is the best place to get started and goes in-depth on what's expected when working on a bounty. -There's lot of bounties that don't require a comma 3/3X or a car. +There's lot of bounties that don't require a comma 3X or a car. ## Pull Requests diff --git a/docs/DEBUGGING_SAFETY.md b/docs/DEBUGGING_SAFETY.md new file mode 100644 index 0000000000..cd0a46b446 --- /dev/null +++ b/docs/DEBUGGING_SAFETY.md @@ -0,0 +1,30 @@ +# Debugging Panda Safety with Replay Drive + LLDB + +## 1. Start the debugger in VS Code + +* Select **Replay drive + Safety LLDB**. +* Enter the route or segment when prompted. +[](https://github.com/user-attachments/assets/b0cc320a-083e-46a7-a9f8-ca775bbe5604) + +## 2. Attach LLDB + +* When prompted, pick the running **`replay_drive` process**. +* ⚠️ Attach quickly, or `replay_drive` will start consuming messages. + +> [!TIP] +> Add a Python breakpoint at the start of `replay_drive.py` to pause execution and give yourself time to attach LLDB. + +## 3. Set breakpoints in VS Code +Breakpoints can be set directly in `modes/xxx.h` (or any C file). +No extra LLDB commands are required — just place breakpoints in the editor. + +## 4. Resume execution +Once attached, you can step through both Python (on the replay) and C safety code as CAN logs are replayed. + +> [!NOTE] +> * Use short routes for quicker iteration. +> * Pause `replay_drive` early to avoid wasting log messages. + +## Video + +View a demo of this workflow on the PR that added it: https://github.com/commaai/openpilot/pull/36055#issue-3352911578 \ No newline at end of file diff --git a/docs/SAFETY.md b/docs/SAFETY.md index 18a450a395..25815e3372 100644 --- a/docs/SAFETY.md +++ b/docs/SAFETY.md @@ -16,7 +16,7 @@ industry standards of safety for Level 2 Driver Assistance Systems. In particula ISO26262 guidelines, including those from [pertinent documents](https://www.nhtsa.gov/sites/nhtsa.dot.gov/files/documents/13498a_812_573_alcsystemreport.pdf) released by NHTSA. In addition, we impose strict coding guidelines (like [MISRA C : 2012](https://www.misra.org.uk/what-is-misra/)) on parts of openpilot that are safety relevant. We also perform software-in-the-loop, -hardware-in-the-loop and in-vehicle tests before each software release. +hardware-in-the-loop, and in-vehicle tests before each software release. Following Hazard and Risk Analysis and FMEA, at a very high level, we have designed openpilot ensuring two main safety requirements. @@ -29,8 +29,18 @@ ensuring two main safety requirements. For additional safety implementation details, refer to [panda safety model](https://github.com/commaai/panda#safety-model). For vehicle specific implementation of the safety concept, refer to [opendbc/safety/safety](https://github.com/commaai/opendbc/tree/master/opendbc/safety/safety). -**Extra note**: comma.ai strongly discourages the use of openpilot forks with safety code either missing or - not fully meeting the above requirements. +[^1]: For these actuator limits we observe ISO11270 and ISO15622. Lateral limits described there translate to 0.9 seconds of maximum actuation to achieve a 1m lateral deviation. -[^1]: For these actuator limits we observe ISO11270 and ISO15622. Lateral limits described there translate to 0.9 seconds of maximum actuation to achieve a 1m lateral deviation. +--- +### Forks of openpilot + +* Do not disable or nerf [driver monitoring](https://github.com/commaai/openpilot/tree/master/selfdrive/monitoring) +* Do not disable or nerf [excessive actuation checks](https://github.com/commaai/openpilot/tree/master/selfdrive/selfdrived/helpers.py) +* If your fork modifies any of the code in `opendbc/safety/`: + * your fork cannot use the openpilot trademark + * your fork must preserve the full [safety test suite](https://github.com/commaai/opendbc/tree/master/opendbc/safety/tests) and all tests must pass, including any new coverage required by the fork's changes + +Failure to comply with these standards will get you and your users banned from comma.ai servers. + +**comma.ai strongly discourages the use of openpilot forks with safety code either missing or not fully meeting the above requirements.** diff --git a/docs/how-to/connect-to-comma.md b/docs/how-to/connect-to-comma.md index cbaccaae6a..5f02e11599 100644 --- a/docs/how-to/connect-to-comma.md +++ b/docs/how-to/connect-to-comma.md @@ -1,11 +1,11 @@ -# connect to a comma 3/3X +# connect to a comma 3X -A comma 3/3X is a normal [Linux](https://github.com/commaai/agnos-builder) computer that exposes [SSH](https://wiki.archlinux.org/title/Secure_Shell) and a [serial console](https://wiki.archlinux.org/title/Working_with_the_serial_console). +A comma 3X is a normal [Linux](https://github.com/commaai/agnos-builder) computer that exposes [SSH](https://wiki.archlinux.org/title/Secure_Shell) and a [serial console](https://wiki.archlinux.org/title/Working_with_the_serial_console). ## Serial Console On both the comma three and 3X, the serial console is accessible from the main OBD-C port. -Connect the comma 3/3X to your computer with a normal USB C cable, or use a [comma serial](https://comma.ai/shop/comma-serial) for steady 12V power. +Connect the comma 3X to your computer with a normal USB C cable, or use a [comma serial](https://comma.ai/shop/comma-serial) for steady 12V power. On the comma three, the serial console is exposed through a UART-to-USB chip, and `tools/scripts/serial.sh` can be used to connect. @@ -45,7 +45,7 @@ In order to use ADB on your device, you'll need to perform the following steps u * Here's an example command for connecting to your device using its tethered connection: `adb connect 192.168.43.1:5555` > [!NOTE] -> The default port for ADB is 5555 on the comma 3/3X. +> The default port for ADB is 5555 on the comma 3X. For more info on ADB, see the [Android Debug Bridge (ADB) documentation](https://developer.android.com/tools/adb). diff --git a/docs/how-to/replay-a-drive.md b/docs/how-to/replay-a-drive.md index 084b6bf825..b0db36a46f 100644 --- a/docs/how-to/replay-a-drive.md +++ b/docs/how-to/replay-a-drive.md @@ -8,7 +8,7 @@ Replaying is a critical tool for openpilot development and debugging. Just run `tools/replay/replay --demo`. ## Replaying CAN data -*Hardware required: jungle and comma 3/3X* +*Hardware required: jungle and comma 3X* 1. Connect your PC to a jungle. 2. diff --git a/docs/how-to/turn-the-speed-blue.md b/docs/how-to/turn-the-speed-blue.md index 13b3b03e80..eb6e75afa2 100644 --- a/docs/how-to/turn-the-speed-blue.md +++ b/docs/how-to/turn-the-speed-blue.md @@ -3,7 +3,7 @@ In 30 minutes, we'll get an openpilot development environment set up on your computer and make some changes to openpilot's UI. -And if you have a comma 3/3X, we'll deploy the change to your device for testing. +And if you have a comma 3X, we'll deploy the change to your device for testing. ## 1. Set up your development environment diff --git a/launch_env.sh b/launch_env.sh index 4c011c6ac0..67dd5ee795 100755 --- a/launch_env.sh +++ b/launch_env.sh @@ -7,7 +7,7 @@ export OPENBLAS_NUM_THREADS=1 export VECLIB_MAXIMUM_THREADS=1 if [ -z "$AGNOS_VERSION" ]; then - export AGNOS_VERSION="12.8" + export AGNOS_VERSION="13.1" fi export STAGING_ROOT="/data/safe_staging" 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/mkdocs.yml b/mkdocs.yml index a66d1c76d4..550f807aca 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -21,7 +21,7 @@ nav: - What is openpilot?: getting-started/what-is-openpilot.md - How-to: - Turn the speed blue: how-to/turn-the-speed-blue.md - - Connect to a comma 3/3X: how-to/connect-to-comma.md + - Connect to a comma 3X: how-to/connect-to-comma.md # - Make your first pull request: how-to/make-first-pr.md #- Replay a drive: how-to/replay-a-drive.md - Concepts: diff --git a/opendbc_repo b/opendbc_repo index aa0aa1b7aa..f54d85c7d9 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit aa0aa1b7aacc15e5e9228a91d7f7043d8b39f9e2 +Subproject commit f54d85c7d99d45ada6f6978621b10c8b421b1923 diff --git a/panda b/panda index f10ddc6a89..69ab12ee2a 160000 --- a/panda +++ b/panda @@ -1 +1 @@ -Subproject commit f10ddc6a89953440a15deec6352fff1d406a627a +Subproject commit 69ab12ee2a2958bb9825bd772ff03be6714b6c0e diff --git a/pyproject.toml b/pyproject.toml index 0968cd4ed3..2d4505c744 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,9 @@ dependencies = [ "pyopenssl < 24.3.0", "pyaudio", + # ubloxd (TODO: just use struct) + "kaitaistruct", + # panda "libusb1", "spidev; platform_system == 'Linux'", @@ -101,8 +104,9 @@ dev = [ "av", "azure-identity", "azure-storage-blob", - "dbus-next", + "dbus-next", # TODO: remove once we moved everything to jeepney "dictdiffer", + "jeepney", "matplotlib", "opencv-python-headless", "parameterized >=0.8, <0.9", @@ -120,6 +124,7 @@ dev = [ tools = [ "metadrive-simulator @ https://github.com/commaai/metadrive/releases/download/MetaDrive-minimal-0.4.2.4/metadrive_simulator-0.4.2.4-py3-none-any.whl ; (platform_machine != 'aarch64')", + "dearpygui>=2.1.0", ] [project.urls] @@ -157,7 +162,6 @@ testpaths = [ "system/camerad", "system/hardware", "system/loggerd", - "system/proclogd", "system/tests", "system/ubloxd", "system/webrtc", @@ -173,7 +177,7 @@ quiet-level = 3 # if you've got a short variable name that's getting flagged, add it here ignore-words-list = "bu,ro,te,ue,alo,hda,ois,nam,nams,ned,som,parm,setts,inout,warmup,bumb,nd,sie,preints,whit,indexIn,ws,uint,grey,deque,stdio,amin,BA,LITE,atEnd,UIs,errorString,arange,FocusIn,od,tim,relA,hist,copyable,jupyter,thead,TGE,abl,lite" builtin = "clear,rare,informal,code,names,en-GB_to_en-US" -skip = "./third_party/*, ./tinygrad/*, ./tinygrad_repo/*, ./msgq/*, ./panda/*, ./opendbc/*, ./opendbc_repo/*, ./rednose/*, ./rednose_repo/*, ./teleoprtc/*, ./teleoprtc_repo/*, *.ts, uv.lock, *.onnx, ./cereal/gen/*, */c_generated_code/*, docs/assets/*" +skip = "./third_party/*, ./tinygrad/*, ./tinygrad_repo/*, ./msgq/*, ./panda/*, ./opendbc/*, ./opendbc_repo/*, ./rednose/*, ./rednose_repo/*, ./teleoprtc/*, ./teleoprtc_repo/*, *.ts, uv.lock, *.onnx, ./cereal/gen/*, */c_generated_code/*, docs/assets/*, tools/plotjuggler/layouts/*" [tool.mypy] python_version = "3.11" @@ -247,6 +251,7 @@ exclude = [ "teleoprtc_repo", "third_party", "*.ipynb", + "generated", ] lint.flake8-implicit-str-concat.allow-multiline = false diff --git a/release/ci/publish.sh b/release/ci/publish.sh index d723782934..4aecbacc4a 100755 --- a/release/ci/publish.sh +++ b/release/ci/publish.sh @@ -51,26 +51,19 @@ git fetch origin $DEV_BRANCH || (git checkout -b $DEV_BRANCH && git commit --all echo "[-] committing version $VERSION T=$SECONDS" git add -f . -git commit -a -m "sunnypilot v$VERSION release" -git branch --set-upstream-to=origin/$DEV_BRANCH # 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=$(cat $SOURCE_DIR/common/version.h | awk -F\" '{print $2}') +SP_VERSION=$(awk -F\" '{print $2}' $SOURCE_DIR/common/version.h) -# Add built files to git -git add -f . -if [ "$EXTRA_VERSION_IDENTIFIER" = "-release" ] || [ "$EXTRA_VERSION_IDENTIFIER" = "-staging" ]; then - export VERSION=${VERSION%"$EXTRA_VERSION_IDENTIFIER"} - git commit --amend -m "sunnypilot v$VERSION" -else - git commit --amend -m "sunnypilot v$VERSION - version: sunnypilot v$SP_VERSION release - date: $DATETIME - master commit: $GIT_HASH - " -fi +# 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! diff --git a/release/ci/squash_and_merge_prs.py b/release/ci/squash_and_merge_prs.py index 0f20f4f900..24922288be 100755 --- a/release/ci/squash_and_merge_prs.py +++ b/release/ci/squash_and_merge_prs.py @@ -14,7 +14,7 @@ def setup_argument_parser(): 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-c3-new-test', + 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') diff --git a/selfdrive/car/card.py b/selfdrive/car/card.py index 820c68e895..2e52c00827 100755 --- a/selfdrive/car/card.py +++ b/selfdrive/car/card.py @@ -179,7 +179,7 @@ class Car: self.params.put_nonblocking("CarParamsSPPersistent", cp_sp_bytes) self.mock_carstate = MockCarState() - self.v_cruise_helper = VCruiseHelper(self.CP) + 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") diff --git a/selfdrive/car/cruise.py b/selfdrive/car/cruise.py index 5ffb43f9e5..c82287d2b1 100644 --- a/selfdrive/car/cruise.py +++ b/selfdrive/car/cruise.py @@ -30,8 +30,8 @@ CRUISE_INTERVAL_SIGN = { class VCruiseHelper(VCruiseHelperSP): - def __init__(self, CP): - VCruiseHelperSP.__init__(self) + 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 @@ -46,10 +46,13 @@ class VCruiseHelper(VCruiseHelperSP): 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) + if CS.cruiseState.available: - if not self.CP.pcmCruise: + _enabled = self.update_enabled_state(CS, enabled) + 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.v_cruise_cluster_kph = self.v_cruise_kph self.update_button_timers(CS, enabled) else: @@ -111,7 +114,7 @@ class VCruiseHelper(VCruiseHelperSP): 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 diff --git a/selfdrive/car/helpers.py b/selfdrive/car/helpers.py index 275c84479f..a7abc1976c 100644 --- a/selfdrive/car/helpers.py +++ b/selfdrive/car/helpers.py @@ -60,5 +60,8 @@ def convert_carControlSP(struct: capnp.lib.capnp._DynamicStructReader) -> struct 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 7d136b824c..7c7a563003 100644 --- a/selfdrive/car/tests/test_car_interfaces.py +++ b/selfdrive/car/tests/test_car_interfaces.py @@ -1,15 +1,12 @@ import os -import math import hypothesis.strategies as st from hypothesis import Phase, given, settings from parameterized import parameterized from cereal import car, custom from opendbc.car import DT_CTRL -from opendbc.car.car_helpers import interfaces from opendbc.car.structs import CarParams -from opendbc.car.tests.test_car_interfaces import get_fuzzy_car_interface_args -from opendbc.car.fw_versions import FW_VERSIONS, FW_QUERY_CONFIGS +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 @@ -21,11 +18,6 @@ from openpilot.selfdrive.test.fuzzy_generation import FuzzyGenerator from openpilot.sunnypilot.selfdrive.car import interfaces as sunnypilot_interfaces -ALL_ECUS = {ecu for ecus in FW_VERSIONS.values() for ecu in ecus.keys()} -ALL_ECUS |= {ecu for config in FW_QUERY_CONFIGS.values() for ecu in config.extra_ecus} - -ALL_REQUESTS = {tuple(r.request) for config in FW_QUERY_CONFIGS.values() for r in config.requests} - MAX_EXAMPLES = int(os.environ.get('MAX_EXAMPLES', '60')) @@ -37,43 +29,10 @@ class TestCarInterfaces: phases=(Phase.reuse, Phase.generate, Phase.shrink)) @given(data=st.data()) def test_car_interfaces(self, car_name, data): - CarInterface = interfaces[car_name] - - args = get_fuzzy_car_interface_args(data.draw) - - car_params = CarInterface.get_params(car_name, args['fingerprints'], args['car_fw'], - alpha_long=args['alpha_long'], is_release=False, docs=False) - car_params_sp = CarInterface.get_params_sp(car_params, car_name, args['fingerprints'], args['car_fw'], - alpha_long=args['alpha_long'], docs=False) - car_params = car_params.as_reader() - car_interface = CarInterface(car_params, car_params_sp) + 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) - assert car_params - assert car_params_sp - assert car_interface - - assert car_params.mass > 1 - assert car_params.wheelbase > 0 - # centerToFront is center of gravity to front wheels, assert a reasonable range - assert car_params.wheelbase * 0.3 < car_params.centerToFront < car_params.wheelbase * 0.7 - assert car_params.maxLateralAccel > 0 - - # Longitudinal sanity checks - assert len(car_params.longitudinalTuning.kpV) == len(car_params.longitudinalTuning.kpBP) - assert len(car_params.longitudinalTuning.kiV) == len(car_params.longitudinalTuning.kiBP) - - # Lateral sanity checks - if car_params.steerControlType != CarParams.SteerControlType.angle: - tune = car_params.lateralTuning - if tune.which() == 'pid': - if car_name != MOCK.MOCK: - assert not math.isnan(tune.pid.kf) and tune.pid.kf > 0 - assert len(tune.pid.kpV) > 0 and len(tune.pid.kpV) == len(tune.pid.kpBP) - assert len(tune.pid.kiV) > 0 and len(tune.pid.kiV) == len(tune.pid.kiBP) - - elif tune.which() == 'torque': - assert not math.isnan(tune.torque.kf) and tune.torque.kf > 0 - assert not math.isnan(tune.torque.friction) and tune.torque.friction > 0 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) diff --git a/selfdrive/car/tests/test_cruise_speed.py b/selfdrive/car/tests/test_cruise_speed.py index 4f9444d4bb..8512651de3 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 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 @@ -49,7 +49,8 @@ class TestCruiseSpeed: 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() + self.v_cruise_helper = VCruiseHelper(self.CP, self.CP_SP) self.reset_cruise_speed_state() def reset_cruise_speed_state(self): diff --git a/selfdrive/controls/controlsd.py b/selfdrive/controls/controlsd.py index 56a3258a5c..3751efc87a 100755 --- a/selfdrive/controls/controlsd.py +++ b/selfdrive/controls/controlsd.py @@ -95,6 +95,8 @@ class Controls(ControlsExt, ModelStateBase): 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.lat_delay = get_lat_delay(self.params, self.sm["liveDelay"].lateralDelay) @@ -114,7 +116,8 @@ class Controls(ControlsExt, ModelStateBase): 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 @@ -166,7 +169,7 @@ class Controls(ControlsExt, ModelStateBase): 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 diff --git a/selfdrive/controls/lib/desire_helper.py b/selfdrive/controls/lib/desire_helper.py index 2adfa65f6d..e72d464d06 100644 --- a/selfdrive/controls/lib/desire_helper.py +++ b/selfdrive/controls/lib/desire_helper.py @@ -1,7 +1,8 @@ -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 @@ -30,6 +31,12 @@ DESIRES = { }, } +TURN_DESIRES = { + custom.TurnDirection.none: log.Desire.none, + custom.TurnDirection.turnLeft: log.Desire.turnLeft, + custom.TurnDirection.turnRight: log.Desire.turnRight, +} + class DesireHelper: def __init__(self): @@ -41,13 +48,25 @@ class DesireHelper: self.prev_one_blinker = False self.desire = log.Desire.none self.alc = AutoLaneChangeController(self) + self.lane_turn_controller = LaneTurnController(self) + self.lane_turn_direction = custom.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 + # 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 @@ -56,12 +75,13 @@ class DesireHelper: if self.lane_change_state == LaneChangeState.off and one_blinker and not self.prev_one_blinker and not below_lane_change_speed: self.lane_change_state = LaneChangeState.preLaneChange self.lane_change_ll_prob = 1.0 + # Initialize lane change direction to prevent UI alert flicker + self.lane_change_direction = self.get_lane_change_direction(carstate) # LaneChangeState.preLaneChange elif self.lane_change_state == LaneChangeState.preLaneChange: - # Set lane change direction - self.lane_change_direction = LaneChangeDirection.left if \ - carstate.leftBlinker else LaneChangeDirection.right + # Update lane change direction + self.lane_change_direction = self.get_lane_change_direction(carstate) torque_applied = carstate.steeringPressed and \ ((carstate.steeringTorque > 0 and self.lane_change_direction == LaneChangeDirection.left) or @@ -106,7 +126,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 != custom.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): diff --git a/selfdrive/controls/lib/latcontrol_torque.py b/selfdrive/controls/lib/latcontrol_torque.py index 7e4ef56023..ca9c3fde66 100644 --- a/selfdrive/controls/lib/latcontrol_torque.py +++ b/selfdrive/controls/lib/latcontrol_torque.py @@ -35,7 +35,7 @@ class LatControlTorque(LatControl): self.update_limits() self.steering_angle_deadzone_deg = self.torque_params.steeringAngleDeadzoneDeg - self.extension = LatControlTorqueExt(self, CP, CP_SP) + self.extension = LatControlTorqueExt(self, CP, CP_SP, CI) def update_live_torque_params(self, latAccelFactor, latAccelOffset, friction): self.torque_params.latAccelFactor = latAccelFactor @@ -56,10 +56,8 @@ class LatControlTorque(LatControl): actual_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)) - desired_lateral_accel = desired_curvature * CS.vEgo ** 2 - # desired rate is the desired rate of change in the setpoint, not the absolute desired curvature - # desired_lateral_jerk = desired_curvature_rate * CS.vEgo ** 2 + desired_lateral_accel = desired_curvature * CS.vEgo ** 2 actual_lateral_accel = actual_curvature * CS.vEgo ** 2 lateral_accel_deadzone = curvature_deadzone * CS.vEgo ** 2 @@ -71,14 +69,10 @@ class LatControlTorque(LatControl): # do error correction in lateral acceleration space, convert at end to handle non-linear torque responses correctly pid_log.error = float(setpoint - measurement) ff = gravity_adjusted_lateral_accel + # latAccelOffset corrects roll compensation bias from device roll misalignment relative to car roll + ff -= self.torque_params.latAccelOffset ff += get_friction(desired_lateral_accel - actual_lateral_accel, lateral_accel_deadzone, FRICTION_THRESHOLD, self.torque_params) - # Lateral acceleration torque controller extension updates - # Overrides stock ff and pid_log.error - ff, pid_log = self.extension.update(CS, VM, 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) - freeze_integrator = steer_limited_by_safety or CS.steeringPressed or CS.vEgo < 5 output_lataccel = self.pid.update(pid_log.error, feedforward=ff, @@ -86,6 +80,12 @@ class LatControlTorque(LatControl): 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, + desired_lateral_accel, actual_lateral_accel, lateral_accel_deadzone, gravity_adjusted_lateral_accel, + desired_curvature, actual_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/longitudinal_planner.py b/selfdrive/controls/lib/longitudinal_planner.py index df4f07ccd7..c8b6d94364 100755 --- a/selfdrive/controls/lib/longitudinal_planner.py +++ b/selfdrive/controls/lib/longitudinal_planner.py @@ -146,8 +146,8 @@ class LongitudinalPlanner(LongitudinalPlannerSP): 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 from Speed Limit Control - v_cruise = LongitudinalPlannerSP.update_v_cruise(self, sm, self.v_desired_filter.x, self.a_desired, v_cruise) + # Get new v_cruise and a_desired from Smart Cruise Control and Speed Limit Control + 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 diff --git a/selfdrive/controls/tests/test_torqued_lat_accel_offset.py b/selfdrive/controls/tests/test_torqued_lat_accel_offset.py new file mode 100644 index 0000000000..84389856b6 --- /dev/null +++ b/selfdrive/controls/tests/test_torqued_lat_accel_offset.py @@ -0,0 +1,70 @@ +import numpy as np +from cereal import car, messaging +from opendbc.car import ACCELERATION_DUE_TO_GRAVITY +from opendbc.car import structs +from opendbc.car.lateral import get_friction, FRICTION_THRESHOLD +from openpilot.common.realtime import DT_MDL +from openpilot.selfdrive.locationd.torqued import TorqueEstimator, MIN_BUCKET_POINTS, POINTS_PER_BUCKET, STEER_BUCKET_BOUNDS + +np.random.seed(0) + +LA_ERR_STD = 1.0 +INPUT_NOISE_STD = 0.08 +V_EGO = 30.0 + +WARMUP_BUCKET_POINTS = (1.5*MIN_BUCKET_POINTS).astype(int) +STRAIGHT_ROAD_LA_BOUNDS = (0.02, 0.03) + +ROLL_BIAS_DEG = 2.0 +ROLL_COMPENSATION_BIAS = ACCELERATION_DUE_TO_GRAVITY*float(np.sin(np.deg2rad(ROLL_BIAS_DEG))) +TORQUE_TUNE = structs.CarParams.LateralTorqueTuning(latAccelFactor=2.0, latAccelOffset=0.0, friction=0.2) +TORQUE_TUNE_BIASED = structs.CarParams.LateralTorqueTuning(latAccelFactor=2.0, latAccelOffset=-ROLL_COMPENSATION_BIAS, friction=0.2) + +def generate_inputs(torque_tune, la_err_std, input_noise_std=None): + rng = np.random.default_rng(0) + steer_torques = np.concat([rng.uniform(bnd[0], bnd[1], pts) for bnd, pts in zip(STEER_BUCKET_BOUNDS, WARMUP_BUCKET_POINTS, strict=True)]) + la_errs = rng.normal(scale=la_err_std, size=steer_torques.size) + frictions = np.array([get_friction(la_err, 0.0, FRICTION_THRESHOLD, torque_tune) for la_err in la_errs]) + lat_accels = torque_tune.latAccelFactor*steer_torques + torque_tune.latAccelOffset + frictions + if input_noise_std is not None: + steer_torques += rng.normal(scale=input_noise_std, size=steer_torques.size) + lat_accels += rng.normal(scale=input_noise_std, size=steer_torques.size) + return steer_torques, lat_accels + +def get_warmed_up_estimator(steer_torques, lat_accels): + est = TorqueEstimator(car.CarParams()) + for steer_torque, lat_accel in zip(steer_torques, lat_accels, strict=True): + est.filtered_points.add_point(steer_torque, lat_accel) + return est + +def simulate_straight_road_msgs(est): + carControl = messaging.new_message('carControl').carControl + carOutput = messaging.new_message('carOutput').carOutput + carState = messaging.new_message('carState').carState + livePose = messaging.new_message('livePose').livePose + carControl.latActive = True + carState.vEgo = V_EGO + carState.steeringPressed = False + ts = DT_MDL*np.arange(2*POINTS_PER_BUCKET) + steer_torques = np.concat((np.linspace(-0.03, -0.02, POINTS_PER_BUCKET), np.linspace(0.02, 0.03, POINTS_PER_BUCKET))) + lat_accels = TORQUE_TUNE.latAccelFactor * steer_torques + for t, steer_torque, lat_accel in zip(ts, steer_torques, lat_accels, strict=True): + carOutput.actuatorsOutput.torque = float(-steer_torque) + livePose.orientationNED.x = float(np.deg2rad(ROLL_BIAS_DEG)) + livePose.angularVelocityDevice.z = float(lat_accel / V_EGO) + for which, msg in (('carControl', carControl), ('carOutput', carOutput), ('carState', carState), ('livePose', livePose)): + est.handle_log(t, which, msg) + +def test_estimated_offset(): + steer_torques, lat_accels = generate_inputs(TORQUE_TUNE_BIASED, la_err_std=LA_ERR_STD, input_noise_std=INPUT_NOISE_STD) + est = get_warmed_up_estimator(steer_torques, lat_accels) + msg = est.get_msg() + # TODO add lataccelfactor and friction check when we have more accurate estimates + assert abs(msg.liveTorqueParameters.latAccelOffsetRaw - TORQUE_TUNE_BIASED.latAccelOffset) < 0.1 + +def test_straight_road_roll_bias(): + steer_torques, lat_accels = generate_inputs(TORQUE_TUNE, la_err_std=LA_ERR_STD, input_noise_std=INPUT_NOISE_STD) + est = get_warmed_up_estimator(steer_torques, lat_accels) + simulate_straight_road_msgs(est) + msg = est.get_msg() + assert (msg.liveTorqueParameters.latAccelOffsetRaw < -0.05) and np.isfinite(msg.liveTorqueParameters.latAccelOffsetRaw) diff --git a/selfdrive/modeld/constants.py b/selfdrive/modeld/constants.py index 5ca0a86bc8..ff7e1d8600 100644 --- a/selfdrive/modeld/constants.py +++ b/selfdrive/modeld/constants.py @@ -13,12 +13,9 @@ class ModelConstants: 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 + N_FRAMES = 2 + MODEL_RUN_FREQ = 20 + MODEL_CONTEXT_FREQ = 5 # "model_trained_fps" FEATURE_LEN = 512 diff --git a/selfdrive/modeld/fill_model_msg.py b/selfdrive/modeld/fill_model_msg.py index a2b54b420e..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) @@ -149,7 +150,7 @@ def fill_model_msg(base_msg: capnp._DynamicStructBuilder, extended_msg: capnp._D meta.hardBrakePredicted = hard_brake_predicted.item() # confidence - if vipc_frame_id % (2*ModelConstants.MODEL_FREQ) == 0: + if vipc_frame_id % (2*ModelConstants.MODEL_RUN_FREQ) == 0: # any disengage prob brake_disengage_probs = net_output_data['meta'][0,Meta.BRAKE_DISENGAGE] gas_disengage_probs = net_output_data['meta'][0,Meta.GAS_DISENGAGE] diff --git a/selfdrive/modeld/modeld.py b/selfdrive/modeld/modeld.py index caf342e88b..00b49afaba 100755 --- a/selfdrive/modeld/modeld.py +++ b/selfdrive/modeld/modeld.py @@ -80,6 +80,64 @@ class FrameMeta: if vipc is not None: self.frame_id, self.timestamp_sof, self.timestamp_eof = vipc.frame_id, vipc.timestamp_sof, vipc.timestamp_eof +class InputQueues: + def __init__ (self, model_fps, env_fps, n_frames_input): + assert env_fps % model_fps == 0 + assert env_fps >= model_fps + self.model_fps = model_fps + self.env_fps = env_fps + self.n_frames_input = n_frames_input + + self.dtypes = {} + self.shapes = {} + self.q = {} + + def update_dtypes_and_shapes(self, input_dtypes, input_shapes) -> None: + self.dtypes.update(input_dtypes) + if self.env_fps == self.model_fps: + self.shapes.update(input_shapes) + else: + for k in input_shapes: + shape = list(input_shapes[k]) + if 'img' in k: + n_channels = shape[1] // self.n_frames_input + shape[1] = (self.env_fps // self.model_fps + (self.n_frames_input - 1)) * n_channels + else: + shape[1] = (self.env_fps // self.model_fps) * shape[1] + self.shapes[k] = tuple(shape) + + def reset(self) -> None: + self.q = {k: np.zeros(self.shapes[k], dtype=self.dtypes[k]) for k in self.dtypes.keys()} + + def enqueue(self, inputs:dict[str, np.ndarray]) -> None: + for k in inputs.keys(): + if inputs[k].dtype != self.dtypes[k]: + raise ValueError(f'supplied input <{k}({inputs[k].dtype})> has wrong dtype, expected {self.dtypes[k]}') + input_shape = list(self.shapes[k]) + input_shape[1] = -1 + single_input = inputs[k].reshape(tuple(input_shape)) + sz = single_input.shape[1] + self.q[k][:,:-sz] = self.q[k][:,sz:] + self.q[k][:,-sz:] = single_input + + def get(self, *names) -> dict[str, np.ndarray]: + if self.env_fps == self.model_fps: + return {k: self.q[k] for k in names} + else: + out = {} + for k in names: + shape = self.shapes[k] + if 'img' in k: + n_channels = shape[1] // (self.env_fps // self.model_fps + (self.n_frames_input - 1)) + out[k] = np.concatenate([self.q[k][:, s:s+n_channels] for s in np.linspace(0, shape[1] - n_channels, self.n_frames_input, dtype=int)], axis=1) + elif 'pulse' in k: + # any pulse within interval counts + out[k] = self.q[k].reshape((shape[0], shape[1] * self.model_fps // self.env_fps, self.env_fps // self.model_fps, -1)).max(axis=2) + else: + idxs = np.arange(-1, -shape[1], -self.env_fps // self.model_fps)[::-1] + out[k] = self.q[k][:, idxs] + return out + class ModelState(ModelStateBase): frames: dict[str, DrivingModelFrame] inputs: dict[str, np.ndarray] @@ -102,19 +160,15 @@ class ModelState(ModelStateBase): self.policy_output_slices = policy_metadata['output_slices'] policy_output_size = policy_metadata['output_shapes']['outputs'][1] - self.frames = {name: DrivingModelFrame(context, ModelConstants.TEMPORAL_SKIP) for name in self.vision_input_names} + self.frames = {name: DrivingModelFrame(context, ModelConstants.MODEL_RUN_FREQ//ModelConstants.MODEL_CONTEXT_FREQ) for name in self.vision_input_names} self.prev_desire = np.zeros(ModelConstants.DESIRE_LEN, dtype=np.float32) - self.full_features_buffer = np.zeros((1, ModelConstants.FULL_HISTORY_BUFFER_LEN, ModelConstants.FEATURE_LEN), dtype=np.float32) - self.full_desire = np.zeros((1, ModelConstants.FULL_HISTORY_BUFFER_LEN, ModelConstants.DESIRE_LEN), dtype=np.float32) - self.temporal_idxs = slice(-1-(ModelConstants.TEMPORAL_SKIP*(ModelConstants.INPUT_HISTORY_BUFFER_LEN-1)), None, ModelConstants.TEMPORAL_SKIP) - # policy inputs - self.numpy_inputs = { - 'desire': np.zeros((1, ModelConstants.INPUT_HISTORY_BUFFER_LEN, ModelConstants.DESIRE_LEN), dtype=np.float32), - 'traffic_convention': np.zeros((1, ModelConstants.TRAFFIC_CONVENTION_LEN), dtype=np.float32), - 'features_buffer': np.zeros((1, ModelConstants.INPUT_HISTORY_BUFFER_LEN, ModelConstants.FEATURE_LEN), dtype=np.float32), - } + self.numpy_inputs = {k: np.zeros(self.policy_input_shapes[k], dtype=np.float32) for k in self.policy_input_shapes} + self.full_input_queues = InputQueues(ModelConstants.MODEL_CONTEXT_FREQ, ModelConstants.MODEL_RUN_FREQ, ModelConstants.N_FRAMES) + for k in ['desire_pulse', 'features_buffer']: + self.full_input_queues.update_dtypes_and_shapes({k: self.numpy_inputs[k].dtype}, {k: self.numpy_inputs[k].shape}) + self.full_input_queues.reset() # img buffers are managed in openCL transform code self.vision_inputs: dict[str, Tensor] = {} @@ -136,15 +190,10 @@ class ModelState(ModelStateBase): 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['desire'][0] = 0 - new_desire = np.where(inputs['desire'] - self.prev_desire > .99, inputs['desire'], 0) - self.prev_desire[:] = inputs['desire'] + inputs['desire_pulse'][0] = 0 + new_desire = np.where(inputs['desire_pulse'] - self.prev_desire > .99, inputs['desire_pulse'], 0) + self.prev_desire[:] = inputs['desire_pulse'] - self.full_desire[0,:-1] = self.full_desire[0,1:] - self.full_desire[0,-1] = new_desire - self.numpy_inputs['desire'][:] = self.full_desire.reshape((1,ModelConstants.INPUT_HISTORY_BUFFER_LEN,ModelConstants.TEMPORAL_SKIP,-1)).max(axis=2) - - self.numpy_inputs['traffic_convention'][:] = inputs['traffic_convention'] imgs_cl = {name: self.frames[name].prepare(bufs[name], transforms[name].flatten()) for name in self.vision_input_names} if TICI and not USBGPU: @@ -163,9 +212,10 @@ class ModelState(ModelStateBase): self.vision_output = self.vision_run(**self.vision_inputs).contiguous().realize().uop.base.buffer.numpy() vision_outputs_dict = self.parser.parse_vision_outputs(self.slice_outputs(self.vision_output, self.vision_output_slices)) - self.full_features_buffer[0,:-1] = self.full_features_buffer[0,1:] - self.full_features_buffer[0,-1] = vision_outputs_dict['hidden_state'][0, :] - self.numpy_inputs['features_buffer'][:] = self.full_features_buffer[0, self.temporal_idxs] + self.full_input_queues.enqueue({'features_buffer': vision_outputs_dict['hidden_state'], 'desire_pulse': new_desire}) + for k in ['desire_pulse', 'features_buffer']: + self.numpy_inputs[k][:] = self.full_input_queues.get(k)[k] + self.numpy_inputs['traffic_convention'][:] = inputs['traffic_convention'] self.policy_output = self.policy_run(**self.policy_inputs).contiguous().realize().uop.base.buffer.numpy() policy_outputs_dict = self.parser.parse_policy_outputs(self.slice_outputs(self.policy_output, self.policy_output_slices)) @@ -216,14 +266,14 @@ 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() params = Params() # setup filter to track dropped frames - frame_dropped_filter = FirstOrderFilter(0., 10., 1. / ModelConstants.MODEL_FREQ) + frame_dropped_filter = FirstOrderFilter(0., 10., 1. / ModelConstants.MODEL_RUN_FREQ) frame_id = 0 last_vipc_frame_id = 0 run_count = 0 @@ -320,7 +370,7 @@ def main(demo=False): bufs = {name: buf_extra if 'big' in name else buf_main for name in model.vision_input_names} transforms = {name: model_transform_extra if 'big' in name else model_transform_main for name in model.vision_input_names} inputs:dict[str, np.ndarray] = { - 'desire': vec_desire, + 'desire_pulse': vec_desire, 'traffic_convention': traffic_convention, } @@ -333,6 +383,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') action = get_action_from_model(model_output, prev_action, lat_delay + DT_MDL, long_delay + DT_MDL, v_ego) prev_action = action @@ -347,6 +398,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 @@ -354,6 +406,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/modeld/models/driving_policy.onnx b/selfdrive/modeld/models/driving_policy.onnx index 867a0d3b9b..7b87846748 100644 --- a/selfdrive/modeld/models/driving_policy.onnx +++ b/selfdrive/modeld/models/driving_policy.onnx @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:04b763fb71efe57a8a4c4168a8043ecd58939015026ded0dc755ded6905ac251 -size 12343523 +oid sha256:ebb38a934d6472c061cc6010f46d9720ca132d631a47e585a893bdd41ade2419 +size 12343535 diff --git a/selfdrive/modeld/models/driving_vision.onnx b/selfdrive/modeld/models/driving_vision.onnx index ce0dc927e7..4b4fa05df8 100644 --- a/selfdrive/modeld/models/driving_vision.onnx +++ b/selfdrive/modeld/models/driving_vision.onnx @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e66bb8d53eced3786ed71a59b55ffc6810944cb217f0518621cc76303260a1ef +oid sha256:befac016a247b7ad5dc5b55d339d127774ed7bd2b848f1583f72aa4caee37781 size 46271991 diff --git a/selfdrive/pandad/pandad.cc b/selfdrive/pandad/pandad.cc index 26f42add6c..bcb39391c4 100644 --- a/selfdrive/pandad/pandad.cc +++ b/selfdrive/pandad/pandad.cc @@ -84,12 +84,10 @@ Panda *connect(std::string serial="", uint32_t index=0) { panda->set_can_fd_auto(i, true); } - bool is_deprecated_panda = std::find(DEPRECATED_PANDA_TYPES.begin(), - DEPRECATED_PANDA_TYPES.end(), - panda->hw_type) != DEPRECATED_PANDA_TYPES.end(); + bool is_supported_panda = std::find(SUPPORTED_PANDA_TYPES.begin(), SUPPORTED_PANDA_TYPES.end(), panda->hw_type) != SUPPORTED_PANDA_TYPES.end(); - if (is_deprecated_panda) { - LOGW("panda %s is deprecated (hw_type: %i), skipping firmware check...", panda->hw_serial().c_str(), static_cast(panda->hw_type)); + 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(); } @@ -175,7 +173,6 @@ void fill_panda_state(cereal::PandaState::Builder &ps, cereal::PandaState::Panda ps.setHarnessStatus(cereal::PandaState::HarnessStatus(health.car_harness_status_pkt)); ps.setInterruptLoad(health.interrupt_load_pkt); ps.setFanPower(health.fan_power); - ps.setFanStallCount(health.fan_stall_count); ps.setSafetyRxChecksInvalid((bool)(health.safety_rx_checks_invalid_pkt)); ps.setSpiErrorCount(health.spi_error_count_pkt); ps.setSbu1Voltage(health.sbu1_voltage_mV / 1000.0f); diff --git a/selfdrive/pandad/pandad.h b/selfdrive/pandad/pandad.h index cdacdc894b..954851b613 100644 --- a/selfdrive/pandad/pandad.h +++ b/selfdrive/pandad/pandad.h @@ -9,13 +9,10 @@ void pandad_main_thread(std::vector serials); // deprecated devices -static const std::vector DEPRECATED_PANDA_TYPES = { - cereal::PandaState::PandaType::WHITE_PANDA, - cereal::PandaState::PandaType::GREY_PANDA, - cereal::PandaState::PandaType::BLACK_PANDA, - cereal::PandaState::PandaType::PEDAL, - cereal::PandaState::PandaType::UNO, - cereal::PandaState::PandaType::RED_PANDA_V2 +static const std::vector SUPPORTED_PANDA_TYPES = { + cereal::PandaState::PandaType::RED_PANDA, + cereal::PandaState::PandaType::TRES, + cereal::PandaState::PandaType::CUATRO, }; diff --git a/selfdrive/pandad/pandad.py b/selfdrive/pandad/pandad.py index 265f673628..f4064ddcd4 100755 --- a/selfdrive/pandad/pandad.py +++ b/selfdrive/pandad/pandad.py @@ -29,6 +29,12 @@ def flash_panda(panda_serial: str) -> Panda: HARDWARE.recover_internal_panda() raise + # skip flashing if the detected panda is not supported + supported_panda = check_panda_support(panda) + if not supported_panda: + cloudlog.warning(f"Panda {panda_serial} is not supported (hw_type: {panda.get_type()}), skipping flash...") + return panda + fw_signature = get_expected_signature(panda) internal_panda = panda.is_internal() @@ -36,12 +42,6 @@ def flash_panda(panda_serial: str) -> Panda: panda_signature = b"" if panda.bootstub else panda.get_signature() cloudlog.warning(f"Panda {panda_serial} connected, version: {panda_version}, signature {panda_signature.hex()[:16]}, expected {fw_signature.hex()[:16]}") - # skip flashing if the detected device is deprecated from upstream - hw_type = panda.get_type() - if hw_type in Panda.DEPRECATED_DEVICES: - cloudlog.warning(f"Panda {panda_serial} is deprecated (hw_type: {hw_type}), skipping flash...") - return panda - if panda.bootstub or panda_signature != fw_signature: cloudlog.info("Panda firmware out of date, update required") panda.flash() @@ -67,6 +67,14 @@ def flash_panda(panda_serial: str) -> Panda: return panda +def check_panda_support(panda) -> bool: + hw_type = panda.get_type() + if hw_type in Panda.SUPPORTED_DEVICES: + return True + + return False + + def main() -> None: # signal pandad to close the relay and exit def signal_handler(signum, frame): @@ -91,11 +99,6 @@ def main() -> None: cloudlog.event("pandad.flash_and_connect", count=count) params.remove("PandaSignatures") - # TODO: remove this in the next AGNOS - # wait until USB is up before counting - if time.monotonic() < 60.: - no_internal_panda_count = 0 - # Handle missing internal panda if no_internal_panda_count > 0: if no_internal_panda_count == 3: @@ -145,6 +148,12 @@ def main() -> None: params.put("PandaSignatures", b','.join(p.get_signature() for p in pandas)) for panda in pandas: + # skip health check if the detected panda is not supported + supported_panda = check_panda_support(panda) + if not supported_panda: + cloudlog.warning(f"Panda {panda.get_usb_serial()} is not supported (hw_type: {panda.get_type()}), skipping health check...") + continue + # check health for lost heartbeat health = panda.health() if health["heartbeat_lost"]: diff --git a/selfdrive/pandad/tests/bootstub.panda.bin b/selfdrive/pandad/tests/bootstub.panda.bin deleted file mode 100755 index 43db537061..0000000000 Binary files a/selfdrive/pandad/tests/bootstub.panda.bin and /dev/null differ diff --git a/selfdrive/pandad/tests/test_pandad.py b/selfdrive/pandad/tests/test_pandad.py index 87f30e4793..6a7359fd85 100644 --- a/selfdrive/pandad/tests/test_pandad.py +++ b/selfdrive/pandad/tests/test_pandad.py @@ -22,8 +22,6 @@ class TestPandad: if len(Panda.list()) == 0: self._run_test(60) - self.spi = HARDWARE.get_device_type() != 'tici' - def teardown_method(self): managed_processes['pandad'].stop() @@ -106,11 +104,9 @@ class TestPandad: # - 0.2s pandad -> pandad # - plus some buffer print("startup times", ts, sum(ts) / len(ts)) - assert 0.1 < (sum(ts)/len(ts)) < (0.7 if self.spi else 5.0) + assert 0.1 < (sum(ts)/len(ts)) < 0.7 def test_protocol_version_check(self): - if not self.spi: - pytest.skip("SPI test") # flash old fw fn = os.path.join(HERE, "bootstub.panda_h7_spiv0.bin") self._flash_bootstub_and_test(fn, expect_mismatch=True) diff --git a/selfdrive/pandad/tests/test_pandad_spi.py b/selfdrive/pandad/tests/test_pandad_spi.py index 9f7cc3b029..da4b181993 100644 --- a/selfdrive/pandad/tests/test_pandad_spi.py +++ b/selfdrive/pandad/tests/test_pandad_spi.py @@ -6,7 +6,6 @@ import random import cereal.messaging as messaging from cereal.services import SERVICE_LIST -from openpilot.system.hardware import HARDWARE from openpilot.selfdrive.test.helpers import with_processes from openpilot.selfdrive.pandad.tests.test_pandad_loopback import setup_pandad, send_random_can_messages @@ -16,8 +15,6 @@ JUNGLE_SPAM = "JUNGLE_SPAM" in os.environ class TestBoarddSpi: @classmethod def setup_class(cls): - if HARDWARE.get_device_type() == 'tici': - pytest.skip("only for spi pandas") os.environ['STARTED'] = '1' os.environ['SPI_ERR_PROB'] = '0.001' if not JUNGLE_SPAM: diff --git a/selfdrive/selfdrived/alerts_offroad.json b/selfdrive/selfdrived/alerts_offroad.json index 183c1f8547..6bae034766 100644 --- a/selfdrive/selfdrived/alerts_offroad.json +++ b/selfdrive/selfdrived/alerts_offroad.json @@ -29,10 +29,6 @@ "text": "Device failed to register with the comma.ai backend. It will not connect or upload to comma.ai servers, and receives no support from comma.ai. If this is a device purchased at comma.ai/shop, open a ticket at https://comma.ai/support.", "severity": 1 }, - "Offroad_StorageMissing": { - "text": "NVMe drive not mounted.", - "severity": 1 - }, "Offroad_CarUnrecognized": { "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 @@ -49,5 +45,10 @@ "text": "openpilot detected excessive %1 actuation 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 591e54918e..b03a953a65 100755 --- a/selfdrive/selfdrived/events.py +++ b/selfdrive/selfdrived/events.py @@ -83,7 +83,7 @@ def below_steer_speed_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.S f"Steer Unavailable Below {get_display_speed(CP.minSteerSpeed, metric)}", "", AlertStatus.userPrompt, AlertSize.small, - Priority.LOW, VisualAlert.steerRequired, AudibleAlert.prompt, 0.4) + Priority.LOW, VisualAlert.none, AudibleAlert.prompt, 0.4) def calibration_incomplete_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality) -> Alert: diff --git a/selfdrive/selfdrived/selfdrived.py b/selfdrive/selfdrived/selfdrived.py index 0e35c1e724..f4f71f4e92 100755 --- a/selfdrive/selfdrived/selfdrived.py +++ b/selfdrive/selfdrived/selfdrived.py @@ -21,12 +21,12 @@ from openpilot.selfdrive.selfdrived.helpers import ExcessiveActuationCheck from openpilot.selfdrive.selfdrived.state import StateMachine from openpilot.selfdrive.selfdrived.alertmanager import AlertManager, set_offroad_alert -from openpilot.system.hardware import HARDWARE from openpilot.system.version import get_build_metadata from openpilot.sunnypilot.mads.mads import ModularAssistiveDrivingSystem 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 @@ -86,7 +86,7 @@ class SelfdriveD(CruiseHelper): # 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'] + ignore = self.sensor_packets + self.gps_packets + ['alertDebug'] + ['modelDataV2SP'] if SIMULATION: ignore += ['driverCameraState', 'managerState'] if REPLAY: @@ -96,7 +96,7 @@ class SelfdriveD(CruiseHelper): 'carOutput', 'driverMonitoringState', 'longitudinalPlan', 'livePose', 'liveDelay', 'managerState', 'liveParameters', 'radarState', 'liveTorqueParameters', 'controlsState', 'carControl', 'driverAssistance', 'alertDebug', 'userBookmark', 'audioFeedback'] + \ - self.camera_packets + self.sensor_packets + self.gps_packets + ["longitudinalPlanSP"], + self.camera_packets + self.sensor_packets + self.gps_packets + ['modelDataV2SP', 'longitudinalPlanSP'], ignore_alive=ignore, ignore_avg_freq=ignore, ignore_valid=ignore, frequency=int(1/DT_CTRL)) @@ -134,13 +134,7 @@ class SelfdriveD(CruiseHelper): self.state_machine = StateMachine() self.rk = Ratekeeper(100, print_delay_threshold=None) - # some comma three with NVMe experience NVMe dropouts mid-drive that - # cause loggerd to crash on write, so ignore it only on that platform - self.ignored_processes = set() - nvme_expected = os.path.exists('/dev/nvme0n1') or (not os.path.isfile("/persist/comma/living-in-the-moment")) - if HARDWARE.get_device_type() == 'tici' and nvme_expected: - self.ignored_processes = {'loggerd', } - self.ignored_processes.update({'mapd'}) + self.ignored_processes = {'mapd', } # Determine startup event is_remote = build_metadata.openpilot.comma_remote or build_metadata.openpilot.sunnypilot_remote @@ -162,6 +156,7 @@ class SelfdriveD(CruiseHelper): self.events_sp_prev = [] self.mads = ModularAssistiveDrivingSystem(self) + self.icbm = IntelligentCruiseButtonManagement(self.CP, self.CP_SP) self.car_events_sp = CarSpecificEventsSP(self.CP, self.params) @@ -301,6 +296,13 @@ class SelfdriveD(CruiseHelper): LaneChangeState.laneChangeFinishing): self.events.add(EventName.laneChange) + # Handle lane turn + lane_turn_direction = self.sm['modelDataV2SP'].laneTurnDirection + if lane_turn_direction == custom.TurnDirection.turnLeft: + self.events_sp.add(custom.OnroadEventSP.EventName.laneTurnLeft) + elif lane_turn_direction == custom.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): @@ -442,6 +444,8 @@ class SelfdriveD(CruiseHelper): self.events.add(EventName.personalityChanged) self.experimental_mode_switched = False + self.icbm.run(CS, self.sm['carControl'], self.is_metric) + def data_sample(self): _car_state = messaging.recv_one(self.car_state_sock) CS = _car_state.carState if _car_state else self.CS_prev @@ -546,6 +550,11 @@ class SelfdriveD(CruiseHelper): 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 diff --git a/selfdrive/test/process_replay/process_replay.py b/selfdrive/test/process_replay/process_replay.py index b1190c25e9..895204538f 100755 --- a/selfdrive/test/process_replay/process_replay.py +++ b/selfdrive/test/process_replay/process_replay.py @@ -595,7 +595,7 @@ def get_custom_params_from_lr(lr: LogIterable, initial_state: str = "first") -> if len(live_calibration) > 0: custom_params["CalibrationParams"] = live_calibration[msg_index].as_builder().to_bytes() if len(live_parameters) > 0: - custom_params["LiveParameters"] = live_parameters[msg_index].as_builder().to_bytes() + custom_params["LiveParametersV2"] = live_parameters[msg_index].as_builder().to_bytes() if len(live_torque_parameters) > 0: custom_params["LiveTorqueParameters"] = live_torque_parameters[msg_index].as_builder().to_bytes() diff --git a/selfdrive/test/process_replay/ref_commit b/selfdrive/test/process_replay/ref_commit index a4297096c0..a833fadb94 100644 --- a/selfdrive/test/process_replay/ref_commit +++ b/selfdrive/test/process_replay/ref_commit @@ -1 +1 @@ -6d3219bca9f66a229b38a5382d301a92b0147edb \ No newline at end of file +afcab1abb62b9d5678342956cced4712f44e909e \ No newline at end of file diff --git a/selfdrive/test/test_onroad.py b/selfdrive/test/test_onroad.py index 0149653c84..b4b9b9dbbe 100644 --- a/selfdrive/test/test_onroad.py +++ b/selfdrive/test/test_onroad.py @@ -18,7 +18,6 @@ from openpilot.common.timeout import Timeout from openpilot.common.params import Params from openpilot.selfdrive.selfdrived.events import EVENTS, ET from openpilot.selfdrive.test.helpers import set_params_enabled, release_only -from openpilot.system.hardware import HARDWARE from openpilot.system.hardware.hw import Paths from openpilot.tools.lib.logreader import LogReader from openpilot.tools.lib.log_time_series import msgs_to_time_series @@ -33,7 +32,7 @@ CPU usage budget TEST_DURATION = 25 LOG_OFFSET = 8 -MAX_TOTAL_CPU = 280. # total for all 8 cores +MAX_TOTAL_CPU = 300. # total for all 8 cores PROCS = { # Baseline CPU usage by process "selfdrive.controls.controlsd": 16.0, @@ -57,30 +56,20 @@ PROCS = { "selfdrive.ui.soundd": 3.0, "selfdrive.ui.feedback.feedbackd": 1.0, "selfdrive.monitoring.dmonitoringd": 4.0, - "./proclogd": 2.0, + "system.proclogd": 3.0, "system.logmessaged": 1.0, "system.tombstoned": 0, - "./logcatd": 1.0, + "system.journald": 1.0, "system.micd": 5.0, "system.timed": 0, "selfdrive.pandad.pandad": 0, "system.statsd": 1.0, "system.loggerd.uploader": 15.0, "system.loggerd.deleter": 1.0, + "./pandad": 19.0, + "system.qcomgpsd.qcomgpsd": 1.0, } -PROCS.update({ - "tici": { - "./pandad": 5.0, - "./ubloxd": 1.0, - "system.ubloxd.pigeond": 6.0, - }, - "tizi": { - "./pandad": 19.0, - "system.qcomgpsd.qcomgpsd": 1.0, - } -}.get(HARDWARE.get_device_type(), {})) - TIMINGS = { # rtols: max/min, rsd "can": [2.5, 0.35], diff --git a/selfdrive/ui/layouts/main.py b/selfdrive/ui/layouts/main.py index 3ab8525d33..777d2f4c3f 100644 --- a/selfdrive/ui/layouts/main.py +++ b/selfdrive/ui/layouts/main.py @@ -63,14 +63,20 @@ class MainLayout(Widget): # Don't hide sidebar from interactive timeout if self._current_mode != MainState.ONROAD: self._sidebar.set_visible(False) - self._current_mode = MainState.ONROAD + self._set_current_layout(MainState.ONROAD) else: - self._current_mode = MainState.HOME + self._set_current_layout(MainState.HOME) self._sidebar.set_visible(True) + def _set_current_layout(self, layout: MainState): + if layout != self._current_mode: + self._layouts[self._current_mode].hide_event() + self._current_mode = layout + self._layouts[self._current_mode].show_event() + def open_settings(self, panel_type: PanelType): self._layouts[MainState.SETTINGS].set_current_panel(panel_type) - self._current_mode = MainState.SETTINGS + self._set_current_layout(MainState.SETTINGS) self._sidebar.set_visible(False) def _on_settings_clicked(self): diff --git a/selfdrive/ui/layouts/network.py b/selfdrive/ui/layouts/network.py deleted file mode 100644 index 856be26e97..0000000000 --- a/selfdrive/ui/layouts/network.py +++ /dev/null @@ -1,17 +0,0 @@ -import pyray as rl -from openpilot.system.ui.lib.wifi_manager import WifiManagerWrapper -from openpilot.system.ui.widgets import Widget -from openpilot.system.ui.widgets.network import WifiManagerUI - - -class NetworkLayout(Widget): - def __init__(self): - super().__init__() - self.wifi_manager = WifiManagerWrapper() - self.wifi_ui = WifiManagerUI(self.wifi_manager) - - def _render(self, rect: rl.Rectangle): - self.wifi_ui.render(rect) - - def shutdown(self): - self.wifi_manager.shutdown() diff --git a/selfdrive/ui/layouts/settings/firehose.py b/selfdrive/ui/layouts/settings/firehose.py index 74a8f317d5..b3db1fa5f0 100644 --- a/selfdrive/ui/layouts/settings/firehose.py +++ b/selfdrive/ui/layouts/settings/firehose.py @@ -2,7 +2,7 @@ import pyray as rl import time import threading -from openpilot.common.api import Api, api_get +from openpilot.common.api import api_get from openpilot.common.params import Params from openpilot.common.swaglog import cloudlog from openpilot.selfdrive.ui.ui_state import ui_state @@ -11,7 +11,7 @@ from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel from openpilot.system.ui.lib.wrap_text import wrap_text from openpilot.system.ui.widgets import Widget - +from openpilot.selfdrive.ui.lib.api_helpers import get_token TITLE = "Firehose Mode" DESCRIPTION = ( @@ -163,7 +163,7 @@ class FirehoseLayout(Widget): dongle_id = self.params.get("DongleId") if not dongle_id or dongle_id == UNREGISTERED_DONGLE_ID: return - identity_token = Api(dongle_id).get_token() + identity_token = get_token(dongle_id) response = api_get(f"v1/devices/{dongle_id}/firehose_stats", access_token=identity_token) if response.status_code == 200: data = response.json() diff --git a/selfdrive/ui/layouts/settings/settings.py b/selfdrive/ui/layouts/settings/settings.py index 674e5005f4..a731a9158c 100644 --- a/selfdrive/ui/layouts/settings/settings.py +++ b/selfdrive/ui/layouts/settings/settings.py @@ -2,7 +2,6 @@ import pyray as rl from dataclasses import dataclass from enum import IntEnum from collections.abc import Callable -from openpilot.selfdrive.ui.layouts.network import NetworkLayout from openpilot.selfdrive.ui.layouts.settings.developer import DeveloperLayout from openpilot.selfdrive.ui.layouts.settings.device import DeviceLayout from openpilot.selfdrive.ui.layouts.settings.firehose import FirehoseLayout @@ -10,7 +9,9 @@ from openpilot.selfdrive.ui.layouts.settings.software import SoftwareLayout from openpilot.selfdrive.ui.layouts.settings.toggles import TogglesLayout 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.lib.wifi_manager import WifiManager from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.network import WifiManagerUI # Settings close button SETTINGS_CLOSE_TEXT = "×" @@ -43,7 +44,7 @@ class PanelType(IntEnum): @dataclass class PanelInfo: name: str - instance: object + instance: Widget button_rect: rl.Rectangle = rl.Rectangle(0, 0, 0, 0) @@ -53,9 +54,12 @@ class SettingsLayout(Widget): self._current_panel = PanelType.DEVICE # Panel configuration + wifi_manager = WifiManager() + wifi_manager.set_active(False) + self._panels = { PanelType.DEVICE: PanelInfo("Device", DeviceLayout()), - PanelType.NETWORK: PanelInfo("Network", NetworkLayout()), + PanelType.NETWORK: PanelInfo("Network", WifiManagerUI(wifi_manager)), PanelType.TOGGLES: PanelInfo("Toggles", TogglesLayout()), PanelType.SOFTWARE: PanelInfo("Software", SoftwareLayout()), PanelType.FIREHOSE: PanelInfo("Firehose", FirehoseLayout()), @@ -149,8 +153,14 @@ class SettingsLayout(Widget): def set_current_panel(self, panel_type: PanelType): if panel_type != self._current_panel: + self._panels[self._current_panel].instance.hide_event() self._current_panel = panel_type + self._panels[self._current_panel].instance.show_event() - def close_settings(self): - if self._close_callback: - self._close_callback() + def show_event(self): + super().show_event() + self._panels[self._current_panel].instance.show_event() + + def hide_event(self): + super().hide_event() + self._panels[self._current_panel].instance.hide_event() diff --git a/selfdrive/ui/lib/api_helpers.py b/selfdrive/ui/lib/api_helpers.py new file mode 100644 index 0000000000..b83efedb60 --- /dev/null +++ b/selfdrive/ui/lib/api_helpers.py @@ -0,0 +1,14 @@ +import time +from functools import lru_cache +from openpilot.common.api import Api + +TOKEN_EXPIRY_HOURS = 2 + + +@lru_cache(maxsize=1) +def _get_token(dongle_id: str, t: int): + return Api(dongle_id).get_token(expiry_hours=TOKEN_EXPIRY_HOURS) + + +def get_token(dongle_id: str): + return _get_token(dongle_id, int(time.monotonic() / (TOKEN_EXPIRY_HOURS / 2 * 60 * 60))) diff --git a/selfdrive/ui/lib/prime_state.py b/selfdrive/ui/lib/prime_state.py index da2ff899dd..be2132c1b7 100644 --- a/selfdrive/ui/lib/prime_state.py +++ b/selfdrive/ui/lib/prime_state.py @@ -2,14 +2,12 @@ from enum import IntEnum import os import threading import time -from functools import lru_cache -from openpilot.common.api import Api, api_get +from openpilot.common.api import api_get from openpilot.common.params import Params from openpilot.common.swaglog import cloudlog from openpilot.system.athena.registration import UNREGISTERED_DONGLE_ID - -TOKEN_EXPIRY_HOURS = 2 +from openpilot.selfdrive.ui.lib.api_helpers import get_token class PrimeType(IntEnum): @@ -23,12 +21,6 @@ class PrimeType(IntEnum): PURPLE = 5, -@lru_cache(maxsize=1) -def get_token(dongle_id: str, t: int): - print('getting token') - return Api(dongle_id).get_token(expiry_hours=TOKEN_EXPIRY_HOURS) - - class PrimeState: FETCH_INTERVAL = 5.0 # seconds between API calls API_TIMEOUT = 10.0 # seconds for API requests @@ -58,15 +50,13 @@ class PrimeState: return try: - identity_token = get_token(dongle_id, int(time.monotonic() / (TOKEN_EXPIRY_HOURS / 2 * 60 * 60))) + identity_token = get_token(dongle_id) response = api_get(f"v1.1/devices/{dongle_id}", timeout=self.API_TIMEOUT, access_token=identity_token) if response.status_code == 200: data = response.json() is_paired = data.get("is_paired", False) prime_type = data.get("prime_type", 0) self.set_type(PrimeType(prime_type) if is_paired else PrimeType.UNPAIRED) - elif response.status_code == 401: - get_token.cache_clear() except Exception as e: cloudlog.error(f"Failed to fetch prime status: {e}") diff --git a/selfdrive/ui/qt/onroad/alerts.cc b/selfdrive/ui/qt/onroad/alerts.cc index d6829c6b08..2e8f3612eb 100644 --- a/selfdrive/ui/qt/onroad/alerts.cc +++ b/selfdrive/ui/qt/onroad/alerts.cc @@ -4,6 +4,9 @@ #include #include "selfdrive/ui/qt/util.h" +#ifdef SUNNYPILOT +#include "selfdrive/ui/sunnypilot/ui.h" +#endif void OnroadAlerts::updateState(const UIState &s) { Alert a = getAlert(*(s.sm), s.scene.started_frame); @@ -73,6 +76,12 @@ void OnroadAlerts::paintEvent(QPaintEvent *event) { } QRect r = QRect(0 + margin, height() - h + margin, width() - margin*2, h - margin*2); +#ifdef SUNNYPILOT + const int dev_ui_info = uiStateSP()->scene.dev_ui_info; + const int adjustment = dev_ui_info > 1 && alert.size != cereal::SelfdriveState::AlertSize::FULL ? 30 : 0; + r = QRect(0 + margin, height() - h + margin - adjustment, width() - margin*2, h - margin*2); +#endif + QPainter p(this); // draw background + gradient diff --git a/selfdrive/ui/qt/onroad/annotated_camera.h b/selfdrive/ui/qt/onroad/annotated_camera.h index e3ca837907..5d9d21ab6b 100644 --- a/selfdrive/ui/qt/onroad/annotated_camera.h +++ b/selfdrive/ui/qt/onroad/annotated_camera.h @@ -12,6 +12,7 @@ #include "selfdrive/ui/sunnypilot/qt/onroad/model.h" #define ExperimentalButton ExperimentalButtonSP #define ModelRenderer ModelRendererSP +#define HudRenderer HudRendererSP #else #include "selfdrive/ui/qt/onroad/buttons.h" #include "selfdrive/ui/qt/onroad/hud.h" diff --git a/selfdrive/ui/qt/onroad/driver_monitoring.cc b/selfdrive/ui/qt/onroad/driver_monitoring.cc index 49f2c950b4..e67c483047 100644 --- a/selfdrive/ui/qt/onroad/driver_monitoring.cc +++ b/selfdrive/ui/qt/onroad/driver_monitoring.cc @@ -73,6 +73,11 @@ void DriverMonitorRenderer::draw(QPainter &painter, const QRect &surface_rect) { float y = surface_rect.height() - offset; float opacity = is_active ? 0.65f : 0.2f; +#ifdef SUNNYPILOT + const int dev_ui_info = uiStateSP()->scene.dev_ui_info; + y -= dev_ui_info > 1 ? 50 : 0; +#endif + drawIcon(painter, QPoint(x, y), dm_img, QColor(0, 0, 0, 70), opacity); QPointF keypoints[std::size(DEFAULT_FACE_KPTS_3D)]; diff --git a/selfdrive/ui/qt/widgets/input.cc b/selfdrive/ui/qt/widgets/input.cc index 4f330dca8d..efd330587a 100644 --- a/selfdrive/ui/qt/widgets/input.cc +++ b/selfdrive/ui/qt/widgets/input.cc @@ -336,8 +336,8 @@ QString MultiOptionDialog::getSelection(const QString &prompt_text, const QStrin return ""; } -TreeOptionDialog::TreeOptionDialog(const QString &prompt_text, const QList> &items, - const QString ¤t, QWidget *parent) : DialogBase(parent) { +TreeOptionDialog::TreeOptionDialog(const QString &prompt_text, const QList &items, + const QString ¤t, const QString &favParam, QWidget *parent) : DialogBase(parent) { QFrame *container = new QFrame(this); container->setStyleSheet(R"( QFrame { background-color: #1B1B1B; } @@ -375,6 +375,9 @@ TreeOptionDialog::TreeOptionDialog(const QString &prompt_text, const QListaddWidget(title, 0, Qt::AlignLeft | Qt::AlignTop); main_layout->addSpacing(25); + iconBlank = QIcon("../../sunnypilot/selfdrive/assets/icons/star-empty.png"); + iconFilled = QIcon ("../../sunnypilot/selfdrive/assets/icons/star-filled.png"); + treeWidget = new QTreeWidget(this); treeWidget->setHeaderHidden(true); treeWidget->setIndentation(50); @@ -396,34 +399,49 @@ TreeOptionDialog::TreeOptionDialog(const QString &prompt_text, const QListviewport(), QScroller::LeftMouseButtonGesture); + // Create initial list of favorites from param + const QString favs = QString::fromStdString(params.get(favParam.toStdString())); + mapFavs = new QMap>(); + favRefs = new QStringList(favs.split(";")); + for (const QString &item : *favRefs) + { + mapFavs->insert( item, {}); + } + // Populate tree - QListIterator> iter(items); + QListIterator iter(items); while (iter.hasNext()) { - QPair currItem = iter.next(); - if (currItem.first.isEmpty()) { - for (const QString &item : currItem.second) { + TreeFolder currItem = iter.next(); + QString prevFolder; + QString currentFolder; + if (currItem.folder.isEmpty()) { + for (const TreeNode &item : currItem.items) { QTreeWidgetItem *topLevel = new QTreeWidgetItem(); - topLevel->setText(0, item); + topLevel->setText(0, item.displayName); + topLevel->setData(0, Qt::UserRole, item.ref); topLevel->setFlags(topLevel->flags() | Qt::ItemIsSelectable); treeWidget->addTopLevelItem(topLevel); - if (item == current) { + if (item.ref == current) { topLevel->setSelected(true); } } } else { - QTreeWidgetItem *folderItem = new QTreeWidgetItem(treeWidget); + QList folders = treeWidget->findItems(currItem.folder, Qt::MatchExactly, 0); + QTreeWidgetItem *folderItem = nullptr; + if (folders.isEmpty()) { + folderItem = new QTreeWidgetItem(treeWidget); + } else { + folderItem = folders.first(); + } folderItem->setIcon(0, QIcon(QPixmap("../assets/icons/menu.png"))); - folderItem->setText(0, " " + currItem.first); + folderItem->setText(0, " " + currItem.folder); folderItem->setFlags(folderItem->flags() | Qt::ItemIsAutoTristate); folderItem->setFlags(folderItem->flags() & ~Qt::ItemIsSelectable); - for (const QString &item : currItem.second) + for (const TreeNode item : currItem.items) { - QTreeWidgetItem *childItem = new QTreeWidgetItem(folderItem); - childItem->setText(0, item); - childItem->setFlags(childItem->flags() | Qt::ItemIsSelectable); - - if (item == current) { + QTreeWidgetItem *childItem = addChildItem(item.displayName, item.ref, folderItem); + if (item.ref == current) { childItem->setSelected(true); folderItem->setExpanded(true); } @@ -431,6 +449,39 @@ TreeOptionDialog::TreeOptionDialog(const QString &prompt_text, const QListsetIcon(0, QIcon(QPixmap("../assets/icons/menu.png"))); + favorites->setText(0, " " + tr("Favorites")); + favorites->setFlags(favorites->flags() | Qt::ItemIsAutoTristate); + favorites->setFlags(favorites->flags() & ~Qt::ItemIsSelectable); + treeWidget->insertTopLevelItem(1, favorites); + + // Create favorite nodes + for (int i = favRefs->size() - 1; i >= 0; --i) { + QString item = favRefs->at(i); + if (item.isEmpty()) continue; + + QTreeWidgetItemIterator treeIt(treeWidget); + QTreeWidgetItem *nodeItem = nullptr; + while (*treeIt) { + if (item == (*treeIt)->data(0, Qt::UserRole).toString()) { + nodeItem = (*treeIt); + break; + } + ++treeIt; + } + if (nodeItem == nullptr) continue; + + QTreeWidgetItem *childItem = addChildItem(nodeItem->text(0), + nodeItem->data(0, Qt::UserRole).toString(), favorites); + if (item == current) { + treeWidget->collapseAll(); + childItem->setSelected(true); + favorites->setExpanded(true); + } + } + confirm_btn = new QPushButton(tr("Select")); confirm_btn->setObjectName("confirm_btn"); confirm_btn->setEnabled(false); @@ -438,7 +489,7 @@ TreeOptionDialog::TreeOptionDialog(const QString &prompt_text, const QList selectedItems = treeWidget->selectedItems(); if (!selectedItems.isEmpty()) { - selection = selectedItems.first()->text(0); + selection = selectedItems.first()->data(0, Qt::UserRole).toString(); confirm_btn->setEnabled(selection != current); } }); @@ -465,11 +516,91 @@ TreeOptionDialog::TreeOptionDialog(const QString &prompt_text, const QListaddWidget(container); } -QString TreeOptionDialog::getSelection(const QString &prompt_text, const QList> &items, - const QString ¤t, QWidget *parent) { - TreeOptionDialog d(prompt_text, items, current, parent); +QString TreeOptionDialog::getSelection(const QString &prompt_text, const QList &items, + const QString ¤t, const QString &favParam, QWidget *parent) { + TreeOptionDialog d(prompt_text, items, current, favParam, parent); if (d.exec()) { return d.selection; } return ""; } + +/** + * Handles the addition or removal of items from the "favorites" list based on the provided reference identifier. + * + * @param displayName The text label associated with the item to be added or removed in the favorites. + * @param ref A unique reference key identifying the item. + * @param btn A pointer to the QPushButton associated with the item. The button's icon is updated to indicate + * whether the item is currently favorited or not. + * + * If the item is already in the favorites, it is removed from the list, its associated buttons have their + * icons reset, and the favorites tree is updated accordingly. If the item is not in the favorites, it is + * added to the list, a new associated button is created, and the favorites tree is updated. The current + * state of the favorites is stored in the Params object as a semicolon-separated string. + */ +void TreeOptionDialog::handleFavorites(const QString &displayName, const QString &ref, QPushButton *btn) { + if (mapFavs->keys().contains(ref)) { // Remove from favorites + for (auto *itemBtn:mapFavs->value(ref)) + { + itemBtn->setIcon(iconBlank); + } + mapFavs->remove(ref); + favRefs->removeAll(ref); + for (int i = 0; i < favorites->childCount(); ++i) { + QTreeWidgetItem* child = favorites->child(i); + if (child && child->data(0, Qt::UserRole).toString() == ref) { + favorites->removeChild(child); + } + } + } else { // Add to favorites + QPushButton *favBtn = new QPushButton(); + btn->setIcon(iconFilled); + mapFavs->insert(ref, {btn, favBtn}); + favRefs->append(ref); + addChildItem(displayName, ref, favorites, favBtn, true); + } + + const QString favs =favRefs->join(";"); + params.put("ModelManager_Favs", favs.toStdString()); +} + +/** + * Adds a child item to a given folder item within the QTreeWidget. + * + * @param displayName The text to display for the child item. + * @param ref A reference string that uniquely identifies the child item. + * @param folderItem The parent folder item to which the child item will be added. + * @param btn A pointer to a QPushButton associated with the child item. If nullptr, a new button will be created. + * @param addAtTop If true, the child item is added as the first child of the folder item; otherwise, it is appended to the end. + * @return A pointer to the created QTreeWidgetItem representing the child item. + */ +QTreeWidgetItem* TreeOptionDialog::addChildItem(const QString &displayName, const QString &ref, QTreeWidgetItem *folderItem, QPushButton *btn, bool addAtTop) { + QTreeWidgetItem *childItem = new QTreeWidgetItem(); + if (btn == nullptr) { + btn = new QPushButton(); + } + if (mapFavs->keys().contains(ref)) { + btn->setIcon(iconFilled); + (*mapFavs)[ref].append(btn); + } else { + btn->setIcon(iconBlank); + } + btn->setIconSize(QSize(100, 100)); + QWidget *buttonContainer = new QWidget(); + QHBoxLayout *layout = new QHBoxLayout(buttonContainer); + layout->addWidget(btn, 0, Qt::AlignRight); + childItem->setText(0, displayName); + childItem->setData(0, Qt::UserRole, ref); + childItem->setFlags(childItem->flags() | Qt::ItemIsSelectable); + if (addAtTop) { + folderItem->insertChild(0, childItem); + } else { + folderItem->addChild(childItem); + } + treeWidget->setItemWidget(childItem, 0, buttonContainer); + + connect(btn, &QPushButton::clicked, btn, [=]() { + handleFavorites(displayName, ref, btn); + }); + return childItem; +} diff --git a/selfdrive/ui/qt/widgets/input.h b/selfdrive/ui/qt/widgets/input.h index 76f87bf32f..3fb1ebfe1a 100644 --- a/selfdrive/ui/qt/widgets/input.h +++ b/selfdrive/ui/qt/widgets/input.h @@ -8,9 +8,22 @@ #include #include +#include "common/params.h" #include "selfdrive/ui/qt/widgets/keyboard.h" +struct TreeNode { + QString folder; + QString displayName; + QString ref; + int index; +}; + +struct TreeFolder { + QString folder; + QList items; +}; + class DialogBase : public QDialog { Q_OBJECT @@ -75,11 +88,20 @@ class TreeOptionDialog : public DialogBase { Q_OBJECT public: - explicit TreeOptionDialog(const QString &prompt_text, const QList> &items, const QString ¤t, QWidget *parent = nullptr); - static QString getSelection(const QString &prompt_text, const QList> &items, const QString ¤t, QWidget *parent = nullptr); + explicit TreeOptionDialog(const QString &prompt_text, const QList &items, const QString ¤t, const QString &favParam, QWidget *parent = nullptr); + static QString getSelection(const QString &prompt_text, const QList &items, const QString ¤t, const QString &favParam, QWidget *parent = nullptr); + void handleFavorites(const QString &displayName, const QString &ref, QPushButton* btn); + QTreeWidgetItem* addChildItem(const QString &displayName, const QString &ref, QTreeWidgetItem* folderItem, QPushButton* btn = nullptr, bool addAtTop = false); QString selection; private: QTreeWidget *treeWidget; QPushButton *confirm_btn; + Params params; + QMap> *mapFavs; + QStringList *favRefs; + QTreeWidgetItem *favorites; + + QIcon iconBlank; + QIcon iconFilled; }; diff --git a/selfdrive/ui/sunnypilot/SConscript b/selfdrive/ui/sunnypilot/SConscript index cb085d93df..24206ddf7e 100644 --- a/selfdrive/ui/sunnypilot/SConscript +++ b/selfdrive/ui/sunnypilot/SConscript @@ -4,6 +4,7 @@ widgets_src = [ "sunnypilot/qt/widgets/controls.cc", "sunnypilot/qt/widgets/drive_stats.cc", "sunnypilot/qt/widgets/expandable_row.cc", + "sunnypilot/qt/widgets/external_storage.cc", "sunnypilot/qt/widgets/prime.cc", "sunnypilot/qt/widgets/scrollview.cc", "sunnypilot/qt/network/networking.cc", @@ -38,6 +39,7 @@ qt_src = [ "sunnypilot/qt/offroad/settings/visuals_panel.cc", "sunnypilot/qt/onroad/annotated_camera.cc", "sunnypilot/qt/onroad/buttons.cc", + "sunnypilot/qt/onroad/developer_ui/developer_ui.cc", "sunnypilot/qt/onroad/hud.cc", "sunnypilot/qt/onroad/model.cc", "sunnypilot/qt/onroad/onroad_home.cc", diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/developer_panel.cc b/selfdrive/ui/sunnypilot/qt/offroad/settings/developer_panel.cc index 58193f9fe8..9c36097f1b 100644 --- a/selfdrive/ui/sunnypilot/qt/offroad/settings/developer_panel.cc +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/developer_panel.cc @@ -5,9 +5,14 @@ * See the LICENSE.md file in the root directory for more details. */ #include "selfdrive/ui/sunnypilot/qt/offroad/settings/developer_panel.h" +#include "selfdrive/ui/sunnypilot/qt/widgets/external_storage.h" DeveloperPanelSP::DeveloperPanelSP(SettingsWindow *parent) : DeveloperPanel(parent) { + #ifndef __APPLE__ + addItem(new ExternalStorageControl()); + #endif + // Advanced Controls Toggle showAdvancedControls = new ParamControlSP("ShowAdvancedControls", tr("Show Advanced Controls"), tr("Toggle visibility of advanced sunnypilot controls.\nThis only toggles the visibility of the controls; it does not toggle the actual control enabled/disabled state."), ""); addItem(showAdvancedControls); @@ -22,6 +27,10 @@ DeveloperPanelSP::DeveloperPanelSP(SettingsWindow *parent) : DeveloperPanel(pare enableGithubRunner = new ParamControlSP("EnableGithubRunner", tr("Enable GitHub runner service"), tr("Enables or disables the github runner service."), "", this, true); addItem(enableGithubRunner); + // Copyparty Toggle + enableCopyparty = new ParamControlSP("EnableCopyparty", tr("Enable 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 it's IP."), "", this, false); + addItem(enableCopyparty); + // Quickboot Mode Toggle prebuiltToggle = new ParamControlSP("QuickBootToggle", tr("Enable Quickboot Mode"), tr(""), "", this, true); addItem(prebuiltToggle); diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/developer_panel.h b/selfdrive/ui/sunnypilot/qt/offroad/settings/developer_panel.h index 42b3bd83b8..7f67512b5d 100644 --- a/selfdrive/ui/sunnypilot/qt/offroad/settings/developer_panel.h +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/developer_panel.h @@ -16,6 +16,7 @@ public: explicit DeveloperPanelSP(SettingsWindow *parent); private: + ParamControlSP *enableCopyparty; ParamControlSP *enableGithubRunner; ButtonControlSP *errorLogBtn; ParamControlSP *prebuiltToggle; diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/longitudinal_panel.cc b/selfdrive/ui/sunnypilot/qt/offroad/settings/longitudinal_panel.cc index ba86676f4e..27be7874c0 100644 --- a/selfdrive/ui/sunnypilot/qt/offroad/settings/longitudinal_panel.cc +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/longitudinal_panel.cc @@ -33,6 +33,23 @@ LongitudinalPanel::LongitudinalPanel(QWidget *parent) : QWidget(parent) { cruisePanelScroller = new ScrollViewSP(list, this); vlayout->addWidget(cruisePanelScroller); + intelligentCruiseButtonManagement = new ParamControlSP( + "IntelligentCruiseButtonManagement", + tr("Intelligent Cruise Button Management (ICBM) (Alpha)"), + tr("When enabled, sunnypilot will attempt to manage the built-in cruise control buttons by emulating button presses for limited longitudinal control."), + "", + this + ); + intelligentCruiseButtonManagement->setConfirmation(true, false); + list->addItem(intelligentCruiseButtonManagement); + + SmartCruiseControlVision = new ParamControl( + "SmartCruiseControlVision", + tr("Smart Cruise Control - Vision"), + tr("Use vision path predictions to estimate the appropriate speed to drive through turns ahead."), + ""); + list->addItem(SmartCruiseControlVision); + customAccIncrement = new CustomAccIncrement("CustomAccIncrementsEnabled", tr("Custom ACC Speed Increments"), "", "", this); list->addItem(customAccIncrement); @@ -71,16 +88,22 @@ void LongitudinalPanel::showEvent(QShowEvent *event) { void LongitudinalPanel::refresh(bool _offroad) { auto cp_bytes = params.get("CarParamsPersistent"); - if (!cp_bytes.empty()) { + auto cp_sp_bytes = params.get("CarParamsSPPersistent"); + if (!cp_bytes.empty() && !cp_sp_bytes.empty()) { AlignedBuffer aligned_buf; + AlignedBuffer aligned_buf_sp; capnp::FlatArrayMessageReader cmsg(aligned_buf.align(cp_bytes.data(), cp_bytes.size())); + capnp::FlatArrayMessageReader cmsg_sp(aligned_buf_sp.align(cp_sp_bytes.data(), cp_sp_bytes.size())); cereal::CarParams::Reader CP = cmsg.getRoot(); + cereal::CarParamsSP::Reader CP_SP = cmsg_sp.getRoot(); has_longitudinal_control = hasLongitudinalControl(CP); is_pcm_cruise = CP.getPcmCruise(); + intelligent_cruise_button_management_available = CP_SP.getIntelligentCruiseButtonManagementAvailable(); } else { has_longitudinal_control = false; is_pcm_cruise = false; + intelligent_cruise_button_management_available = false; } QString accEnabledDescription = tr("Enable custom Short & Long press increments for cruise speed increase/decrease."); @@ -92,7 +115,7 @@ void LongitudinalPanel::refresh(bool _offroad) { customAccIncrement->setDescription(onroadOnlyDescription); customAccIncrement->showDescription(); } else { - if (has_longitudinal_control) { + if (has_longitudinal_control || intelligent_cruise_button_management_available) { if (is_pcm_cruise) { customAccIncrement->setDescription(accPcmCruiseDisabledDescription); customAccIncrement->showDescription(); @@ -104,12 +127,20 @@ void LongitudinalPanel::refresh(bool _offroad) { customAccIncrement->toggleFlipped(false); customAccIncrement->setDescription(accNoLongDescription); customAccIncrement->showDescription(); + params.remove("IntelligentCruiseButtonManagement"); + intelligentCruiseButtonManagement->toggleFlipped(false); } } + bool icbm_allowed = intelligent_cruise_button_management_available && !has_longitudinal_control; + intelligentCruiseButtonManagement->setEnabled(icbm_allowed && offroad); + // enable toggle when long is available and is not PCM cruise - customAccIncrement->setEnabled(has_longitudinal_control && !is_pcm_cruise && !offroad); + bool cai_allowed = (has_longitudinal_control && !is_pcm_cruise) || icbm_allowed; + customAccIncrement->setEnabled(cai_allowed && !offroad); customAccIncrement->refresh(); + SmartCruiseControlVision->setEnabled(has_longitudinal_control || icbm_allowed); + offroad = _offroad; } diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/longitudinal_panel.h b/selfdrive/ui/sunnypilot/qt/offroad/settings/longitudinal_panel.h index 57e05bcbc9..dcd0a3094b 100644 --- a/selfdrive/ui/sunnypilot/qt/offroad/settings/longitudinal_panel.h +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/longitudinal_panel.h @@ -26,12 +26,15 @@ private: Params params; bool has_longitudinal_control = false; bool is_pcm_cruise = false; + bool intelligent_cruise_button_management_available = false;; bool offroad = false; QStackedLayout *main_layout = nullptr; ScrollViewSP *cruisePanelScroller = nullptr; QWidget *cruisePanelScreen = nullptr; CustomAccIncrement *customAccIncrement = nullptr; + ParamControl *SmartCruiseControlVision; + ParamControl *intelligentCruiseButtonManagement = nullptr; SpeedLimitControlSubpanel *slcScreen; SpeedLimitControl *slcControl; }; diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/models_panel.cc b/selfdrive/ui/sunnypilot/qt/offroad/settings/models_panel.cc index 93e5ac80a8..c3f795e18c 100644 --- a/selfdrive/ui/sunnypilot/qt/offroad/settings/models_panel.cc +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/models_panel.cc @@ -101,32 +101,42 @@ ModelsPanel::ModelsPanel(QWidget *parent) : QWidget(parent) { QString policyType = tr("Policy Model"); policyFrame = createModelDetailFrame(this, policyType, policyProgressBar); list->addItem(policyFrame); - list->addItem(horizontal_line()); + // Lane Turn Desire toggle + lane_turn_desire_toggle = new ParamControlSP("LaneTurnDesire", tr("Use Lane Turn Desires"), + "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.", + "../assets/offroad/icon_shell.png"); + list->addItem(lane_turn_desire_toggle); + + // Lane Turn Value control + int max_value_mph = 20; + bool is_metric_initial = params.getBool("IsMetric"); + const float K = 1.609344f; + int per_value_change_scaled = is_metric_initial ? static_cast(std::round((1.0f / K) * 100.0f)) : 100; // 100 -> 1 mph + lane_turn_value_control = new OptionControlSP("LaneTurnValue", tr("Adjust Lane Turn Speed"), + tr("Set the maximum speed for lane turn desires. Default is 19 %1.").arg(is_metric_initial ? "km/h" : "mph"), + "", {5 * 100, max_value_mph * 100}, per_value_change_scaled, false, nullptr, true, true); + lane_turn_value_control->showDescription(); + list->addItem(lane_turn_value_control); + + // Show based on toggle + refreshLaneTurnValueControl(); + connect(lane_turn_desire_toggle, &ParamControlSP::toggleFlipped, this, &ModelsPanel::refreshLaneTurnValueControl); + connect(lane_turn_value_control, &OptionControlSP::updateLabels, this, &ModelsPanel::refreshLaneTurnValueControl); + // LiveDelay toggle lagd_toggle_control = new ParamControlSP("LagdToggle", tr("Live Learning Steer Delay"), "", "../assets/offroad/icon_shell.png"); lagd_toggle_control->showDescription(); list->addItem(lagd_toggle_control); // Software delay control - int liveDelayMaxInt = 30; - std::string liveDelayBytes = params.get("LiveDelay"); - if (!liveDelayBytes.empty()) { - capnp::FlatArrayMessageReader msg(kj::ArrayPtr( - reinterpret_cast(liveDelayBytes.data()), - liveDelayBytes.size() / sizeof(capnp::word))); - auto event = msg.getRoot(); - if (event.hasLiveDelay()) { - auto liveDelay = event.getLiveDelay(); - float lateralDelay = liveDelay.getLateralDelay(); - liveDelayMaxInt = static_cast(lateralDelay * 100.0f) + 20; - } - } delay_control = new OptionControlSP("LagdToggleDelay", tr("Adjust Software Delay"), - tr("Adjust the software delay when Live Learning Steer Delay is toggled off." - "\nThe default software delay value is 0.2"), - "", {5, liveDelayMaxInt}, 1, false, nullptr, true, true); + tr("Adjust the software delay when Live Learning Steer Delay is toggled off." + "\nThe default software delay value is 0.2"), + "", {5, 50}, 1, false, nullptr, true, true); connect(delay_control, &OptionControlSP::updateLabels, [=]() { float value = QString::fromStdString(params.get("LagdToggleDelay")).toFloat(); @@ -159,6 +169,19 @@ QFrame* ModelsPanel::createModelDetailFrame(QWidget *parent, QString &typeName, return frame; } +void ModelsPanel::refreshLaneTurnValueControl() { + if (!lane_turn_value_control) return; + float stored_mph = QString::fromStdString(params.get("LaneTurnValue")).toFloat(); + bool is_metric = params.getBool("IsMetric"); + QString unit = is_metric ? "km/h" : "mph"; + float display_value = stored_mph; + if (is_metric) { + display_value = stored_mph * 1.609344f; + } + lane_turn_value_control->setLabel(QString::number(static_cast(std::round(display_value))) + " " + unit); + lane_turn_value_control->setVisible(params.getBool("LaneTurnDesire")); +} + /** * @brief Updates the UI with bundle download progress information * Reads status from modelManagerSP cereal message and displays status for all models @@ -259,6 +282,18 @@ QString ModelsPanel::GetActiveModelInternalName() { return DEFAULT_MODEL; } +/** + * @brief Gets the ref of the currently selected model bundle + * @return ref of the selected bundle or default model name + */ +QString ModelsPanel::GetActiveModelRef() { + if (model_manager.hasActiveBundle()) { + return QString::fromStdString(model_manager.getActiveBundle().getRef()); + } + + return DEFAULT_MODEL; +} + void ModelsPanel::updateModelManagerState() { const SubMaster &sm = *(uiStateSP()->sm); model_manager = sm["modelManagerSP"].getModelManagerSP(); @@ -272,34 +307,31 @@ void ModelsPanel::handleCurrentModelLblBtnClicked() { currentModelLblBtn->setEnabled(false); currentModelLblBtn->setValue(tr("Fetching models...")); - struct ModelEntry { - QString folder; - QString displayName; - int index; - }; - QList sortedModels; + QList sortedModels; QSet modelFolders; + QRegularExpression re("\\(([^)]*)\\)[^(]*$"); const auto bundles = model_manager.getAvailableBundles(); for (const auto &bundle : bundles) { auto overrides = bundle.getOverrides(); - QString gen; + QString folder; for (const auto &override : overrides) { if (override.getKey() == "folder") { - gen = QString::fromStdString(override.getValue().cStr()); + folder = QString::fromStdString(override.getValue().cStr()); } } - modelFolders.insert(gen); - sortedModels.append(ModelEntry{ - gen, + modelFolders.insert(folder); + sortedModels.append(TreeNode{ + folder, QString::fromStdString(bundle.getDisplayName()), + QString::fromStdString(bundle.getRef()), static_cast(bundle.getIndex()) }); } std::sort(sortedModels.begin(), sortedModels.end(), - [](const ModelEntry &a, const ModelEntry &b) { + [](const TreeNode &a, const TreeNode &b) { return a.index > b.index; }); @@ -322,37 +354,46 @@ void ModelsPanel::handleCurrentModelLblBtnClicked() { }); // Create the final items list using sorted folders - QList> items; + QList items; for (const auto &folderPair : folderMaxIndices) { - QStringList folderModels; + QList folderModels; + QString folder = folderPair.first; for (const auto &model : sortedModels) { if (model.folder == folderPair.first) { - folderModels.append(model.displayName); + if (model.index == folderPair.second) { + QRegularExpressionMatch match = re.match(model.displayName); + if (match.hasMatch()) { + folder.append(" - (Updated: ").append(match.captured(1)).append(")"); + } + } + folderModels.append(model); } } - items.append(qMakePair(folderPair.first, folderModels)); + items.append(TreeFolder{folder, folderModels}); } - items.insert(0, qMakePair(QString(""), QStringList{DEFAULT_MODEL})); + items.insert(0, TreeFolder{"", { + TreeNode{"", DEFAULT_MODEL, DEFAULT_MODEL, -1} + }}); currentModelLblBtn->setValue(GetActiveModelInternalName()); - const QString selectedBundleName = TreeOptionDialog::getSelection( - tr("Select a Model"), items, GetActiveModelName(), this); + const QString selectedBundleRef = TreeOptionDialog::getSelection( + tr("Select a Model"), items, GetActiveModelRef(), QString("ModelManager_Favs"), this); - if (selectedBundleName.isEmpty() || !canContinueOnMeteredDialog()) { + if (selectedBundleRef.isEmpty() || !canContinueOnMeteredDialog()) { return; } // Handle "Stock" selection differently - if (selectedBundleName == DEFAULT_MODEL) { + if (selectedBundleRef == DEFAULT_MODEL) { params.remove("ModelManager_ActiveBundle"); currentModelLblBtn->setValue(tr("Default")); showResetParamsDialog(); } else { // Find selected bundle and initiate download for (const auto &bundle: bundles) { - if (QString::fromStdString(bundle.getDisplayName()) == selectedBundleName) { + if (QString::fromStdString(bundle.getRef()) == selectedBundleRef) { params.put("ModelManager_DownloadIndex", std::to_string(bundle.getIndex())); if (bundle.getGeneration() != model_manager.getActiveBundle().getGeneration()) { showResetParamsDialog(); @@ -383,34 +424,28 @@ void ModelsPanel::updateLabels() { "Disable to use a fixed steering response time. Keeping this on provides the stock openpilot experience."); bool lagdEnabled = params.getBool("LagdToggle"); if (lagdEnabled) { - std::string liveDelayBytes = params.get("LiveDelay"); + auto liveDelayBytes = params.get("LiveDelay"); if (!liveDelayBytes.empty()) { - capnp::FlatArrayMessageReader msg(kj::ArrayPtr( - reinterpret_cast(liveDelayBytes.data()), - liveDelayBytes.size() / sizeof(capnp::word))); - auto event = msg.getRoot(); - if (event.hasLiveDelay()) { - auto liveDelay = event.getLiveDelay(); - float lateralDelay = liveDelay.getLateralDelay(); - desc += QString("

%1 %2 s") - .arg(tr("Live Steer Delay:")).arg(QString::number(lateralDelay, 'f', 3)); - } + auto LD = loadCerealEvent(params, "LiveDelay"); + float lateralDelay = LD->getLiveDelay().getLateralDelay(); + desc += QString("

%1 %2 s") + .arg(tr("Live Steer Delay:")).arg(QString::number(lateralDelay, 'f', 3)); } } else { - std::string carParamsBytes = params.get("CarParamsPersistent"); + auto carParamsBytes = params.get("CarParamsPersistent"); if (!carParamsBytes.empty()) { - capnp::FlatArrayMessageReader msg(kj::ArrayPtr( - reinterpret_cast(carParamsBytes.data()), - carParamsBytes.size() / sizeof(capnp::word))); - auto carParams = msg.getRoot(); - float steerDelay = carParams.getSteerActuatorDelay(); + AlignedBuffer aligned_buf_cp; + capnp::FlatArrayMessageReader cmsg(aligned_buf_cp.align(carParamsBytes.data(), carParamsBytes.size())); + cereal::CarParams::Reader CP = cmsg.getRoot(); + + float steerDelay = CP.getSteerActuatorDelay(); float softwareDelay = QString::fromStdString(params.get("LagdToggleDelay")).toFloat(); float totalLag = steerDelay + softwareDelay; desc += QString("

" "%1 %2 s + %3 %4 s = %5 %6 s") - .arg(tr("Actuator Delay:"), QString::number(steerDelay, 'f', 2), - tr("Software Delay:"), QString::number(softwareDelay, 'f', 2), - tr("Total Delay:"), QString::number(totalLag, 'f', 2)); + .arg(tr("Actuator Delay:"), QString::number(steerDelay, 'f', 2), + tr("Software Delay:"), QString::number(softwareDelay, 'f', 2), + tr("Total Delay:"), QString::number(totalLag, 'f', 2)); } } lagd_toggle_control->setDescription(desc); @@ -421,6 +456,9 @@ void ModelsPanel::updateLabels() { delay_control->setLabel(QString::number(value, 'f', 2) + "s"); } + // Update lane turn desire label and visibility + refreshLaneTurnValueControl(); + clearModelCacheBtn->setValue(QString::number(calculateCacheSize(), 'f', 2) + " MB"); } diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/models_panel.h b/selfdrive/ui/sunnypilot/qt/offroad/settings/models_panel.h index 8586d862bd..1a39800dde 100644 --- a/selfdrive/ui/sunnypilot/qt/offroad/settings/models_panel.h +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/models_panel.h @@ -9,6 +9,7 @@ #include +#include "selfdrive/ui/sunnypilot/qt/util.h" #include "selfdrive/ui/sunnypilot/qt/offroad/settings/settings.h" class ModelsPanel : public QWidget { @@ -20,6 +21,7 @@ public: private: QString GetActiveModelName(); QString GetActiveModelInternalName(); + QString GetActiveModelRef(); void updateModelManagerState(); void showEvent(QShowEvent *event) override; @@ -36,6 +38,7 @@ private: void updateLabels(); void handleCurrentModelLblBtnClicked(); void handleBundleDownloadProgress(); + void refreshLaneTurnValueControl(); void showResetParamsDialog(); QProgressBar* createProgressBar(QWidget *parent); QFrame* createModelDetailFrame(QWidget *parent, QString &typeName, QProgressBar *progressBar); @@ -80,5 +83,6 @@ private: Params params; ButtonControlSP *clearModelCacheBtn; ButtonControlSP *refreshAvailableModelsBtn; - + ParamControlSP *lane_turn_desire_toggle; + OptionControlSP *lane_turn_value_control; }; diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/software_panel.cc b/selfdrive/ui/sunnypilot/qt/offroad/settings/software_panel.cc index a1961cb1ea..8bdb7703f2 100644 --- a/selfdrive/ui/sunnypilot/qt/offroad/settings/software_panel.cc +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/software_panel.cc @@ -11,12 +11,39 @@ SoftwarePanelSP::SoftwarePanelSP(QWidget *parent) : SoftwarePanel(parent) { // branch selector QObject::disconnect(targetBranchBtn, nullptr, nullptr, nullptr); connect(targetBranchBtn, &ButtonControlSP::clicked, [=]() { - InputDialog d(tr("Search Branch"), this, tr("Enter search keywords, or leave blank to list all branches."), false); + if (Hardware::get_device_type() == cereal::InitData::DeviceType::TICI) { + auto current = params.get("GitBranch"); + QStringList allBranches = QString::fromStdString(params.get("UpdaterAvailableBranches")).split(","); + QStringList branches; + for (const QString &b : allBranches) { + if (b.endsWith("-tici")) { + branches.append(b); + } + } + + for (QString b : {current.c_str(), "master-tici", "staging-tici", "release-tici"}) { + auto i = branches.indexOf(b); + if (i >= 0) { + branches.removeAt(i); + branches.insert(0, b); + } + } + + QString cur = QString::fromStdString(params.get("UpdaterTargetBranch")); + QString selection = MultiOptionDialog::getSelection(tr("Select a branch"), branches, cur, this); + if (!selection.isEmpty()) { + params.put("UpdaterTargetBranch", selection.toStdString()); + targetBranchBtn->setValue(QString::fromStdString(params.get("UpdaterTargetBranch"))); + checkForUpdates(); + } + } else { + InputDialog d(tr("Search Branch"), this, tr("Enter search keywords, or leave blank to list all branches."), false); d.setMinLength(0); const int ret = d.exec(); if (ret) { searchBranches(d.text()); } + } }); // Disable Updates toggle diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink_panel.cc b/selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink_panel.cc index 3d4e070963..96d945ab53 100644 --- a/selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink_panel.cc +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink_panel.cc @@ -34,6 +34,27 @@ SunnylinkPanel::SunnylinkPanel(QWidget *parent) : QFrame(parent) { vlayout->setContentsMargins(50, 20, 50, 20); auto *list = new ListWidget(this, false); + + QVBoxLayout *titleLayout = new QVBoxLayout; + QLabel *title = new QLabel(tr("🚀 sunnylink 🚀")); + title->setStyleSheet("font-size: 90px; font-weight: 500; font-family: 'Noto Color Emoji';"); + titleLayout->addWidget(title, 0, Qt::AlignCenter); + + QLabel *sunnylinkDesc = new QLabel("
"+ + tr("For secure backup, restore, and remote configuration")+ "
"); + + QLabel *sponsorMsg = new QLabel("
"+ + tr("Sponsorship isn't required for basic backup/restore") + "
" + + tr("Click the sponsor button for more details")+ "
"); + + sunnylinkDesc->setStyleSheet("font-size: 40px; font-weight: 100; font-family: 'Noto';"); + sponsorMsg->setStyleSheet("font-size: 35px; font-weight: 100; font-family: 'Noto';"); + + titleLayout->addWidget(sunnylinkDesc, 0, Qt::AlignCenter); + titleLayout->addWidget(sponsorMsg, 0, Qt::AlignCenter); + + list->addItem(titleLayout); + QString sunnylinkEnabledBtnDesc = tr("This is the master switch, it will allow you to cutoff any sunnylink requests should you want to do that."); sunnylinkEnabledBtn = new ParamControl( "SunnylinkEnabled", diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/visuals_panel.cc b/selfdrive/ui/sunnypilot/qt/offroad/settings/visuals_panel.cc index 818da9b75e..e19760f2e1 100644 --- a/selfdrive/ui/sunnypilot/qt/offroad/settings/visuals_panel.cc +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/visuals_panel.cc @@ -28,6 +28,20 @@ VisualsPanel::VisualsPanel(QWidget *parent) : QWidget(parent) { "../assets/offroad/icon_monitoring.png", false, }, + { + "RainbowMode", + tr("Enable Tesla Rainbow Mode"), + RainbowizeWords(tr("A beautiful rainbow effect on the path the model wants to take.")) + "
" + tr("It")+ " " + tr("does not") + " " + tr("affect driving in any way.") + "", + "../assets/offroad/icon_monitoring.png", + false, + }, + { + "StandstillTimer", + tr("Enable Standstill Timer"), + tr("Show a timer on the HUD when the car is at a standstill."), + "../assets/offroad/icon_monitoring.png", + false, + }, }; // Add regular toggles first @@ -65,6 +79,15 @@ VisualsPanel::VisualsPanel(QWidget *parent) : QWidget(parent) { list->addItem(chevron_info_settings); param_watcher->addParam("ChevronInfo"); + // Visuals: Developer UI Info (Dev UI) + std::vector dev_ui_settings_texts{tr("Off"), tr("Right"), tr("Right &&\nBottom")}; + dev_ui_settings = new ButtonParamControlSP( + "DevUIInfo", tr("Developer UI"), tr("Display real-time parameters and metrics from various sources."), + "", + dev_ui_settings_texts, + 380); + list->addItem(dev_ui_settings); + sunnypilotScroller = new ScrollViewSP(list, this); vlayout->addWidget(sunnypilotScroller); @@ -83,4 +106,7 @@ void VisualsPanel::paramsRefresh() { if (chevron_info_settings) { chevron_info_settings->refresh(); } + if (dev_ui_settings) { + dev_ui_settings->refresh(); + } } diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/visuals_panel.h b/selfdrive/ui/sunnypilot/qt/offroad/settings/visuals_panel.h index f342662c22..30ff31c301 100644 --- a/selfdrive/ui/sunnypilot/qt/offroad/settings/visuals_panel.h +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/visuals_panel.h @@ -28,4 +28,5 @@ protected: std::map toggles; ParamWatcher * param_watcher; ButtonParamControlSP *chevron_info_settings; + ButtonParamControlSP *dev_ui_settings; }; diff --git a/selfdrive/ui/sunnypilot/qt/onroad/annotated_camera.cc b/selfdrive/ui/sunnypilot/qt/onroad/annotated_camera.cc index 3721a3d198..1d5567161a 100644 --- a/selfdrive/ui/sunnypilot/qt/onroad/annotated_camera.cc +++ b/selfdrive/ui/sunnypilot/qt/onroad/annotated_camera.cc @@ -14,3 +14,8 @@ AnnotatedCameraWidgetSP::AnnotatedCameraWidgetSP(VisionStreamType type, QWidget void AnnotatedCameraWidgetSP::updateState(const UIState &s) { AnnotatedCameraWidget::updateState(s); } + +void AnnotatedCameraWidgetSP::showEvent(QShowEvent *event) { + AnnotatedCameraWidget::showEvent(event); + ui_update_params_sp(uiState()); +} diff --git a/selfdrive/ui/sunnypilot/qt/onroad/annotated_camera.h b/selfdrive/ui/sunnypilot/qt/onroad/annotated_camera.h index 46ce7d4be3..8c0a385657 100644 --- a/selfdrive/ui/sunnypilot/qt/onroad/annotated_camera.h +++ b/selfdrive/ui/sunnypilot/qt/onroad/annotated_camera.h @@ -15,4 +15,7 @@ class AnnotatedCameraWidgetSP : public AnnotatedCameraWidget { public: explicit AnnotatedCameraWidgetSP(VisionStreamType type, QWidget *parent = nullptr); void updateState(const UIState &s) override; + +protected: + void showEvent(QShowEvent *event) override; }; diff --git a/selfdrive/ui/sunnypilot/qt/onroad/developer_ui/developer_ui.cc b/selfdrive/ui/sunnypilot/qt/onroad/developer_ui/developer_ui.cc new file mode 100644 index 0000000000..292ba6f7bb --- /dev/null +++ b/selfdrive/ui/sunnypilot/qt/onroad/developer_ui/developer_ui.cc @@ -0,0 +1,227 @@ +/** + * 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. + */ +#include + +#include "common/util.h" +#include "selfdrive/ui/sunnypilot/qt/onroad/developer_ui/developer_ui.h" + + +// Add Relative Distance to Primary Lead Car +// Unit: Meters +UiElement DeveloperUi::getDRel(bool lead_status, float lead_d_rel) { + QString value = lead_status ? QString::number(lead_d_rel, 'f', 0) : "-"; + QColor color = QColor(255, 255, 255, 255); + + if (lead_status) { + // Orange if close, Red if very close + if (lead_d_rel < 5) { + color = QColor(255, 0, 0, 255); + } else if (lead_d_rel < 15) { + color = QColor(255, 188, 0, 255); + } + } + + return UiElement(value, "REL DIST", "m", color); +} + +// Add Relative Velocity vs Primary Lead Car +// Unit: kph if metric, else mph +UiElement DeveloperUi::getVRel(bool lead_status, float lead_v_rel, bool is_metric, const QString &speed_unit) { + QString value = lead_status ? QString::number(lead_v_rel * (is_metric ? MS_TO_KPH : MS_TO_MPH), 'f', 0) : "-"; + QColor color = QColor(255, 255, 255, 255); + + if (lead_status) { + // Red if approaching faster than 10mph + // Orange if approaching (negative) + if (lead_v_rel < -4.4704) { + color = QColor(255, 0, 0, 255); + } else if (lead_v_rel < 0) { + color = QColor(255, 188, 0, 255); + } + } + + return UiElement(value, "REL SPEED", speed_unit, color); +} + +// Add Real Steering Angle +// Unit: Degrees +UiElement DeveloperUi::getSteeringAngleDeg(float angle_steers, bool lat_active, bool steer_override) { + QString value = QString("%1%2%3").arg(QString::number(angle_steers, 'f', 1)).arg("°").arg(""); + QColor color = lat_active ? (steer_override ? QColor(0x91, 0x9b, 0x95, 0xff) : QColor(0, 255, 0, 255)) : QColor(255, 255, 255, 255); + + // Red if large steering angle + // Orange if moderate steering angle + if (std::fabs(angle_steers) > 180) { + color = QColor(255, 0, 0, 255); + } else if (std::fabs(angle_steers) > 90) { + color = QColor(255, 188, 0, 255); + } + + return UiElement(value, "REAL STEER", "", color); +} + +// Add Actual Lateral Acceleration (roll compensated) when using Torque +// Unit: m/s² +UiElement DeveloperUi::getActualLateralAccel(float curvature, float v_ego, float roll, bool lat_active, bool steer_override) { + double actualLateralAccel = (curvature * pow(v_ego, 2)) - (roll * 9.81); + + QString value = QString::number(actualLateralAccel, 'f', 2); + QColor color = lat_active ? (steer_override ? QColor(0x91, 0x9b, 0x95, 0xff) : QColor(0, 255, 0, 255)) : QColor(255, 255, 255, 255); + + return UiElement(value, "ACTUAL L.A.", "m/s²", color); +} + +// Add Desired Steering Angle when using PID +// Unit: Degrees +UiElement DeveloperUi::getSteeringAngleDesiredDeg(bool lat_active, float steer_angle_desired, float angle_steers) { + QString value = lat_active ? QString("%1%2%3").arg(QString::number(steer_angle_desired, 'f', 1)).arg("°").arg("") : "-"; + QColor color = QColor(255, 255, 255, 255); + + if (lat_active) { + // Red if large steering angle + // Orange if moderate steering angle + if (std::fabs(angle_steers) > 180) { + color = QColor(255, 0, 0, 255); + } else if (std::fabs(angle_steers) > 90) { + color = QColor(255, 188, 0, 255); + } else { + color = QColor(0, 255, 0, 255); + } + } + + return UiElement(value, "DESIRED STEER", "", color); +} + +// Add Device Memory (RAM) Usage +// Unit: Percent +UiElement DeveloperUi::getMemoryUsagePercent(int memory_usage_percent) { + QString value = QString("%1%2").arg(QString::number(memory_usage_percent, 'd', 0)).arg("%"); + QColor color = (memory_usage_percent > 85) ? QColor(255, 188, 0, 255) : QColor(255, 255, 255, 255); + + return UiElement(value, "RAM", "", color); +} + +// Add Vehicle Current Acceleration +// Unit: m/s² +UiElement DeveloperUi::getAEgo(float a_ego) { + QString value = QString::number(a_ego, 'f', 1); + QColor color = QColor(255, 255, 255, 255); + + return UiElement(value, "ACC.", "m/s²", color); +} + +// Add Relative Velocity to Primary Lead Car +// Unit: kph if metric, else mph +UiElement DeveloperUi::getVEgoLead(bool lead_status, float lead_v_rel, float v_ego, bool is_metric, const QString &speed_unit) { + QString value = lead_status ? QString::number((lead_v_rel + v_ego) * (is_metric ? MS_TO_KPH : MS_TO_MPH), 'f', 0) : "-"; + QColor color = QColor(255, 255, 255, 255); + + if (lead_status) { + // Red if approaching faster than 10mph + // Orange if approaching (negative) + if (lead_v_rel < -4.4704) { + color = QColor(255, 0, 0, 255); + } else if (lead_v_rel < 0) { + color = QColor(255, 188, 0, 255); + } + } + + return UiElement(value, "L.S.", speed_unit, color); +} + +// Add Friction Coefficient Raw from torqued +// Unit: None +UiElement DeveloperUi::getFrictionCoefficientFiltered(float friction_coefficient_filtered, bool live_valid) { + QString value = QString::number(friction_coefficient_filtered, 'f', 3); + QColor color = live_valid ? QColor(0, 255, 0, 255) : QColor(255, 255, 255, 255); + + return UiElement(value, "FRIC.", "", color); +} + +// Add Lateral Acceleration Factor Raw from torqued +// Unit: m/s² +UiElement DeveloperUi::getLatAccelFactorFiltered(float lat_accel_factor_filtered, bool live_valid) { + QString value = QString::number(lat_accel_factor_filtered, 'f', 3); + QColor color = live_valid ? QColor(0, 255, 0, 255) : QColor(255, 255, 255, 255); + + return UiElement(value, "L.A.", "m/s²", color); +} + +// Add Steering Torque from Car EPS +// Unit: Newton Meters +UiElement DeveloperUi::getSteeringTorqueEps(float steering_torque_eps) { + QString value = QString::number(std::fabs(steering_torque_eps), 'f', 1); + QColor color = QColor(255, 255, 255, 255); + + return UiElement(value, "E.T.", "N·dm", color); +} + +// Add Bearing Degree and Direction from Car (Compass) +// Unit: Meters +UiElement DeveloperUi::getBearingDeg(float bearing_accuracy_deg, float bearing_deg) { + QString value = (bearing_accuracy_deg != 180.00) ? QString("%1%2%3").arg(QString::number(bearing_deg, 'd', 0)).arg("°").arg("") : "-"; + QColor color = QColor(255, 255, 255, 255); + QString dir_value; + + if (bearing_accuracy_deg != 180.00) { + if (((bearing_deg >= 337.5) && (bearing_deg <= 360)) || ((bearing_deg >= 0) && (bearing_deg <= 22.5))) { + dir_value = "N"; + } else if ((bearing_deg > 22.5) && (bearing_deg < 67.5)) { + dir_value = "NE"; + } else if ((bearing_deg >= 67.5) && (bearing_deg <= 112.5)) { + dir_value = "E"; + } else if ((bearing_deg > 112.5) && (bearing_deg < 157.5)) { + dir_value = "SE"; + } else if ((bearing_deg >= 157.5) && (bearing_deg <= 202.5)) { + dir_value = "S"; + } else if ((bearing_deg > 202.5) && (bearing_deg < 247.5)) { + dir_value = "SW"; + } else if ((bearing_deg >= 247.5) && (bearing_deg <= 292.5)) { + dir_value = "W"; + } else if ((bearing_deg > 292.5) && (bearing_deg < 337.5)) { + dir_value = "NW"; + } + } else { + dir_value = "OFF"; + } + + return UiElement(QString("%1 | %2").arg(dir_value).arg(value), "B.D.", "", color); +} + +// Add Altitude of Current Location +// Unit: Meters +UiElement DeveloperUi::getAltitude(float gps_accuracy, float altitude) { + QString value = (gps_accuracy != 0.00) ? QString::number(altitude, 'f', 1) : "-"; + QColor color = QColor(255, 255, 255, 255); + + return UiElement(value, "ALT.", "m", color); +} + +// Add Actuators Output +// Unit: Degree (angle) or m/s² (torque) +UiElement DeveloperUi::getActuatorsOutputLateral(cereal::CarParams::SteerControlType steerControlType, + cereal::CarControl::Actuators::Reader &actuators, + float desiredCurvature, float v_ego, float roll, bool lat_active, bool steer_override) { + QString label; + QString value; + QString unit; + + if (steerControlType == cereal::CarParams::SteerControlType::ANGLE) { + label = "DESIRED STEER"; + value = QString("%1%2%3").arg(QString::number(actuators.getSteeringAngleDeg(), 'f', 1)).arg("°").arg(""); + } else { + label = "DESIRED L.A."; + double desiredLateralAccel = (desiredCurvature * pow(v_ego, 2)) - (roll * 9.81); + value = QString::number(desiredLateralAccel, 'f', 2); + unit = "m/s²"; + } + + value = lat_active ? value : "-"; + QColor color = lat_active ? (steer_override ? QColor(0x91, 0x9b, 0x95, 0xff) : QColor(0, 255, 0, 255)) : QColor(255, 255, 255, 255); + + return UiElement(value, label, unit, color); +} diff --git a/selfdrive/ui/sunnypilot/qt/onroad/developer_ui/developer_ui.h b/selfdrive/ui/sunnypilot/qt/onroad/developer_ui/developer_ui.h new file mode 100644 index 0000000000..0c5c472209 --- /dev/null +++ b/selfdrive/ui/sunnypilot/qt/onroad/developer_ui/developer_ui.h @@ -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. + */ +#pragma once + +#include "selfdrive/ui/qt/util.h" +#include "selfdrive/ui/sunnypilot/qt/onroad/developer_ui/ui_elements.h" + +class DeveloperUi { + +public: + static UiElement getDRel(bool lead_status, float lead_d_rel); + static UiElement getVRel(bool lead_status, float lead_v_rel, bool is_metric, const QString &speed_unit); + static UiElement getSteeringAngleDeg(float angle_steers, bool lat_active, bool steer_override); + static UiElement getActualLateralAccel(float curvature, float v_ego, float roll, bool lat_active, bool steer_override); + static UiElement getSteeringAngleDesiredDeg(bool lat_active, float steer_angle_desired, float angle_steers); + static UiElement getMemoryUsagePercent(int memory_usage_percent); + static UiElement getAEgo(float a_ego); + static UiElement getVEgoLead(bool lead_status, float lead_v_rel, float v_ego, bool is_metric, const QString &speed_unit); + static UiElement getFrictionCoefficientFiltered(float friction_coefficient_filtered, bool live_valid); + static UiElement getLatAccelFactorFiltered(float lat_accel_factor_filtered, bool live_valid); + static UiElement getSteeringTorqueEps(float steering_torque_eps); + static UiElement getBearingDeg(float bearing_accuracy_deg, float bearing_deg); + static UiElement getAltitude(float gps_accuracy, float altitude); + static UiElement getActuatorsOutputLateral(cereal::CarParams::SteerControlType steerControlType, + cereal::CarControl::Actuators::Reader &actuators, + float desiredCurvature, float v_ego, float roll, bool lat_active, bool steer_override); +}; diff --git a/selfdrive/ui/sunnypilot/qt/onroad/developer_ui/ui_elements.h b/selfdrive/ui/sunnypilot/qt/onroad/developer_ui/ui_elements.h new file mode 100644 index 0000000000..3711e5ac05 --- /dev/null +++ b/selfdrive/ui/sunnypilot/qt/onroad/developer_ui/ui_elements.h @@ -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. + */ +#pragma once + +#include + +struct UiElement { + QString value{}; + QString label{}; + QString units{}; + QColor color{}; + + explicit UiElement(const QString &value = "", const QString &label = "", const QString &units = "", const QColor &color = QColor(255, 255, 255, 255)) + : value(value), label(label), units(units), color(color) {} +}; diff --git a/selfdrive/ui/sunnypilot/qt/onroad/hud.cc b/selfdrive/ui/sunnypilot/qt/onroad/hud.cc index 233ca59f98..fb2a69c24b 100644 --- a/selfdrive/ui/sunnypilot/qt/onroad/hud.cc +++ b/selfdrive/ui/sunnypilot/qt/onroad/hud.cc @@ -4,15 +4,310 @@ * 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. */ +#include #include "selfdrive/ui/sunnypilot/qt/onroad/hud.h" +#include "selfdrive/ui/qt/util.h" + + HudRendererSP::HudRendererSP() {} void HudRendererSP::updateState(const UIState &s) { HudRenderer::updateState(s); + + const SubMaster &sm = *(s.sm); + const bool cs_alive = sm.alive("controlsState"); + const auto cs = sm["controlsState"].getControlsState(); + const auto car_state = sm["carState"].getCarState(); + const auto car_control = sm["carControl"].getCarControl(); + const auto radar_state = sm["radarState"].getRadarState(); + const auto is_gps_location_external = sm.rcv_frame("gpsLocationExternal") > 1; + const auto gpsLocation = is_gps_location_external ? sm["gpsLocationExternal"].getGpsLocationExternal() : sm["gpsLocation"].getGpsLocation(); + const auto ltp = sm["liveTorqueParameters"].getLiveTorqueParameters(); + const auto car_params = sm["carParams"].getCarParams(); + const auto lp_sp = sm["longitudinalPlanSP"].getLongitudinalPlanSP(); + + static int reverse_delay = 0; + bool reverse_allowed = false; + if (int(car_state.getGearShifter()) != 4) { + reverse_delay = 0; + reverse_allowed = false; + } else { + reverse_delay += 50; + if (reverse_delay >= 1000) { + reverse_allowed = true; + } + } + + reversing = reverse_allowed; + is_metric = s.scene.is_metric; + + // Handle older routes where vEgoCluster is not set + v_ego_cluster_seen = v_ego_cluster_seen || car_state.getVEgoCluster() != 0.0; + float v_ego = v_ego_cluster_seen ? car_state.getVEgoCluster() : car_state.getVEgo(); + speed = cs_alive ? std::max(0.0, v_ego) : 0.0; + speed *= is_metric ? MS_TO_KPH : MS_TO_MPH; + + latActive = car_control.getLatActive(); + steerOverride = car_state.getSteeringPressed(); + + devUiInfo = s.scene.dev_ui_info; + + speedUnit = is_metric ? tr("km/h") : tr("mph"); + lead_d_rel = radar_state.getLeadOne().getDRel(); + lead_v_rel = radar_state.getLeadOne().getVRel(); + lead_status = radar_state.getLeadOne().getStatus(); + steerControlType = car_params.getSteerControlType(); + actuators = car_control.getActuators(); + torqueLateral = steerControlType == cereal::CarParams::SteerControlType::TORQUE; + angleSteers = car_state.getSteeringAngleDeg(); + desiredCurvature = cs.getDesiredCurvature(); + curvature = cs.getCurvature(); + roll = sm["liveParameters"].getLiveParameters().getRoll(); + memoryUsagePercent = sm["deviceState"].getDeviceState().getMemoryUsagePercent(); + gpsAccuracy = is_gps_location_external ? gpsLocation.getHorizontalAccuracy() : 1.0; // External reports accuracy, internal does not. + altitude = gpsLocation.getAltitude(); + vEgo = car_state.getVEgo(); + aEgo = car_state.getAEgo(); + steeringTorqueEps = car_state.getSteeringTorqueEps(); + bearingAccuracyDeg = gpsLocation.getBearingAccuracyDeg(); + bearingDeg = gpsLocation.getBearingDeg(); + torquedUseParams = ltp.getUseParams(); + latAccelFactorFiltered = ltp.getLatAccelFactorFiltered(); + frictionCoefficientFiltered = ltp.getFrictionCoefficientFiltered(); + liveValid = ltp.getLiveValid(); + + standstillTimer = s.scene.standstill_timer; + isStandstill = car_state.getStandstill(); + longOverride = car_control.getCruiseControl().getOverride(); + smartCruiseControlVisionEnabled = lp_sp.getSmartCruiseControl().getVision().getEnabled(); + smartCruiseControlVisionActive = lp_sp.getSmartCruiseControl().getVision().getActive(); } void HudRendererSP::draw(QPainter &p, const QRect &surface_rect) { HudRenderer::draw(p, surface_rect); + if (!reversing) { + // Smart Cruise Control + int x_offset = -260; + int y1_offset = -80; + // int y2_offset = -140; // reserved for 2 icons + + bool scc_vision_active_pulse = pulseElement(smartCruiseControlVisionFrame); + if ((smartCruiseControlVisionEnabled && !smartCruiseControlVisionActive) || (smartCruiseControlVisionActive && scc_vision_active_pulse)) { + drawSmartCruiseControlOnroadIcon(p, surface_rect, x_offset, y1_offset, "SCC-V"); + } + + if (smartCruiseControlVisionActive) { + smartCruiseControlVisionFrame++; + } else { + smartCruiseControlVisionFrame = 0; + } + + // Bottom Dev UI + if (devUiInfo == 2) { + QRect rect_bottom(surface_rect.left(), surface_rect.bottom() - 60, surface_rect.width(), 61); + p.setPen(Qt::NoPen); + p.setBrush(QColor(0, 0, 0, 100)); + p.drawRect(rect_bottom); + drawBottomDevUI(p, rect_bottom.left(), rect_bottom.center().y()); + } + + // Right Dev UI + if (devUiInfo != 0) { + QRect rect_right(surface_rect.right() - (UI_BORDER_SIZE * 2), UI_BORDER_SIZE * 1.5, 184, 170); + drawRightDevUI(p, surface_rect.right() - 184 - UI_BORDER_SIZE * 2, UI_BORDER_SIZE * 2 + rect_right.height()); + } + + // Standstill Timer + if (standstillTimer) { + drawStandstillTimer(p, surface_rect.right() / 12 * 10, surface_rect.bottom() / 12 * 1.53); + } + } +} + +void HudRendererSP::drawText(QPainter &p, int x, int y, const QString &text, QColor color) { + QRect real_rect = p.fontMetrics().boundingRect(text); + real_rect.moveCenter({x, y - real_rect.height() / 2}); + p.setPen(color); + p.drawText(real_rect.x(), real_rect.bottom(), text); +} + +bool HudRendererSP::pulseElement(int frame) { + if (frame % UI_FREQ < (UI_FREQ / 2.5)) { + return false; + } + + return true; +} + +void HudRendererSP::drawSmartCruiseControlOnroadIcon(QPainter &p, const QRect &surface_rect, int x_offset, int y_offset, std::string name) { + int x = surface_rect.center().x(); + int y = surface_rect.height() / 4; + + QString text = QString::fromStdString(name); + QFont font = InterFont(36, QFont::Bold); + p.setFont(font); + + QFontMetrics fm(font); + + int padding_v = 5; + int box_width = 160; + int box_height = fm.height() + padding_v * 2; + + QRectF bg_rect(x - (box_width / 2) + x_offset, + y - (box_height / 2) + y_offset, + box_width, box_height); + + QPainterPath boxPath; + boxPath.addRoundedRect(bg_rect, 10, 10); + + int text_w = fm.horizontalAdvance(text); + qreal baseline_y = bg_rect.top() + padding_v + fm.ascent(); + qreal text_x = bg_rect.center().x() - (text_w / 2.0); + + QPainterPath textPath; + textPath.addText(QPointF(text_x, baseline_y), font, text); + boxPath = boxPath.subtracted(textPath); + + p.setPen(Qt::NoPen); + p.setBrush(longOverride ? QColor(0x91, 0x9b, 0x95, 0xf1) : QColor(0, 0xff, 0, 0xff)); + p.drawPath(boxPath); +} + +int HudRendererSP::drawRightDevUIElement(QPainter &p, int x, int y, const QString &value, const QString &label, const QString &units, QColor &color) { + + p.setFont(InterFont(28, QFont::Bold)); + x += 92; + y += 80; + drawText(p, x, y, label); + + p.setFont(InterFont(30 * 2, QFont::Bold)); + y += 65; + drawText(p, x, y, value, color); + + p.setFont(InterFont(28, QFont::Bold)); + + if (units.length() > 0) { + p.save(); + x += 120; + y -= 25; + p.translate(x, y); + p.rotate(-90); + drawText(p, 0, 0, units); + p.restore(); + } + + return 130; +} + +void HudRendererSP::drawRightDevUI(QPainter &p, int x, int y) { + int rh = 5; + int ry = y; + + UiElement dRelElement = DeveloperUi::getDRel(lead_status, lead_d_rel); + rh += drawRightDevUIElement(p, x, ry, dRelElement.value, dRelElement.label, dRelElement.units, dRelElement.color); + ry = y + rh; + + UiElement vRelElement = DeveloperUi::getVRel(lead_status, lead_v_rel, is_metric, speedUnit); + rh += drawRightDevUIElement(p, x, ry, vRelElement.value, vRelElement.label, vRelElement.units, vRelElement.color); + ry = y + rh; + + UiElement steeringAngleDegElement = DeveloperUi::getSteeringAngleDeg(angleSteers, latActive, steerOverride); + rh += drawRightDevUIElement(p, x, ry, steeringAngleDegElement.value, steeringAngleDegElement.label, steeringAngleDegElement.units, steeringAngleDegElement.color); + ry = y + rh; + + UiElement actuatorsOutputLateralElement = DeveloperUi::getActuatorsOutputLateral(steerControlType, actuators, desiredCurvature, vEgo, roll, latActive, steerOverride); + rh += drawRightDevUIElement(p, x, ry, actuatorsOutputLateralElement.value, actuatorsOutputLateralElement.label, actuatorsOutputLateralElement.units, actuatorsOutputLateralElement.color); + ry = y + rh; + + UiElement actualLateralAccelElement = DeveloperUi::getActualLateralAccel(curvature, vEgo, roll, latActive, steerOverride); + rh += drawRightDevUIElement(p, x, ry, actualLateralAccelElement.value, actualLateralAccelElement.label, actualLateralAccelElement.units, actualLateralAccelElement.color); +} + +int HudRendererSP::drawBottomDevUIElement(QPainter &p, int x, int y, const QString &value, const QString &label, const QString &units, QColor &color) { + p.setFont(InterFont(38, QFont::Bold)); + QFontMetrics fm(p.font()); + QRect init_rect = fm.boundingRect(label + " "); + QRect real_rect = fm.boundingRect(init_rect, 0, label + " "); + real_rect.moveCenter({x, y}); + + QRect init_rect2 = fm.boundingRect(value); + QRect real_rect2 = fm.boundingRect(init_rect2, 0, value); + real_rect2.moveTop(real_rect.top()); + real_rect2.moveLeft(real_rect.right() + 10); + + QRect init_rect3 = fm.boundingRect(units); + QRect real_rect3 = fm.boundingRect(init_rect3, 0, units); + real_rect3.moveTop(real_rect.top()); + real_rect3.moveLeft(real_rect2.right() + 10); + + p.setPen(Qt::white); + p.drawText(real_rect, Qt::AlignLeft | Qt::AlignVCenter, label); + + p.setPen(color); + p.drawText(real_rect2, Qt::AlignRight | Qt::AlignVCenter, value); + p.drawText(real_rect3, Qt::AlignLeft | Qt::AlignVCenter, units); + return 430; +} + +void HudRendererSP::drawBottomDevUI(QPainter &p, int x, int y) { + int rw = 90; + + UiElement aEgoElement = DeveloperUi::getAEgo(aEgo); + rw += drawBottomDevUIElement(p, rw, y, aEgoElement.value, aEgoElement.label, aEgoElement.units, aEgoElement.color); + + UiElement vEgoLeadElement = DeveloperUi::getVEgoLead(lead_status, lead_v_rel, vEgo, is_metric, speedUnit); + rw += drawBottomDevUIElement(p, rw, y, vEgoLeadElement.value, vEgoLeadElement.label, vEgoLeadElement.units, vEgoLeadElement.color); + + if (torqueLateral && torquedUseParams) { + UiElement frictionCoefficientFilteredElement = DeveloperUi::getFrictionCoefficientFiltered(frictionCoefficientFiltered, liveValid); + rw += drawBottomDevUIElement(p, rw, y, frictionCoefficientFilteredElement.value, frictionCoefficientFilteredElement.label, frictionCoefficientFilteredElement.units, frictionCoefficientFilteredElement.color); + + UiElement latAccelFactorFilteredElement = DeveloperUi::getLatAccelFactorFiltered(latAccelFactorFiltered, liveValid); + rw += drawBottomDevUIElement(p, rw, y, latAccelFactorFilteredElement.value, latAccelFactorFilteredElement.label, latAccelFactorFilteredElement.units, latAccelFactorFilteredElement.color); + } else { + UiElement steeringTorqueEpsElement = DeveloperUi::getSteeringTorqueEps(steeringTorqueEps); + rw += drawBottomDevUIElement(p, rw, y, steeringTorqueEpsElement.value, steeringTorqueEpsElement.label, steeringTorqueEpsElement.units, steeringTorqueEpsElement.color); + + UiElement bearingDegElement = DeveloperUi::getBearingDeg(bearingAccuracyDeg, bearingDeg); + rw += drawBottomDevUIElement(p, rw, y, bearingDegElement.value, bearingDegElement.label, bearingDegElement.units, bearingDegElement.color); + } + + UiElement altitudeElement = DeveloperUi::getAltitude(gpsAccuracy, altitude); + rw += drawBottomDevUIElement(p, rw, y, altitudeElement.value, altitudeElement.label, altitudeElement.units, altitudeElement.color); +} + +void HudRendererSP::drawStandstillTimer(QPainter &p, int x, int y) { + if (isStandstill) { + standstillElapsedTime += 1.0 / UI_FREQ; + + int minute = static_cast(standstillElapsedTime / 60); + int second = static_cast(standstillElapsedTime - (minute * 60)); + + // stop sign for standstill timer + const int size = 190; // size + const float angle = M_PI / 8.0; + + QPolygon octagon; + for (int i = 0; i < 8; i++) { + float curr_angle = angle + i * M_PI / 4.0; + int point_x = x + size / 2 * cos(curr_angle); + int point_y = y + size / 2 * sin(curr_angle); + octagon << QPoint(point_x, point_y); + } + + p.setPen(QPen(Qt::white, 6)); + p.setBrush(QColor(255, 90, 81, 200)); // red pastel + p.drawPolygon(octagon); + + QString time_str = QString("%1:%2").arg(minute, 1, 10, QChar('0')).arg(second, 2, 10, QChar('0')); + p.setFont(InterFont(55, QFont::Bold)); + p.setPen(Qt::white); + QRect timerTextRect = p.fontMetrics().boundingRect(QString(time_str)); + timerTextRect.moveCenter({x, y}); + p.drawText(timerTextRect, Qt::AlignCenter, QString(time_str)); + } else { + standstillElapsedTime = 0.0; + } } diff --git a/selfdrive/ui/sunnypilot/qt/onroad/hud.h b/selfdrive/ui/sunnypilot/qt/onroad/hud.h index 1e98cd3a52..4c92835957 100644 --- a/selfdrive/ui/sunnypilot/qt/onroad/hud.h +++ b/selfdrive/ui/sunnypilot/qt/onroad/hud.h @@ -7,9 +7,8 @@ #pragma once -#include - #include "selfdrive/ui/qt/onroad/hud.h" +#include "selfdrive/ui/sunnypilot/qt/onroad/developer_ui/developer_ui.h" class HudRendererSP : public HudRenderer { Q_OBJECT @@ -18,4 +17,50 @@ public: HudRendererSP(); void updateState(const UIState &s) override; void draw(QPainter &p, const QRect &surface_rect) override; + +private: + Params params; + void drawText(QPainter &p, int x, int y, const QString &text, QColor color = Qt::white); + void drawRightDevUI(QPainter &p, int x, int y); + int drawRightDevUIElement(QPainter &p, int x, int y, const QString &value, const QString &label, const QString &units, QColor &color); + int drawBottomDevUIElement(QPainter &p, int x, int y, const QString &value, const QString &label, const QString &units, QColor &color); + void drawBottomDevUI(QPainter &p, int x, int y); + void drawStandstillTimer(QPainter &p, int x, int y); + bool pulseElement(int frame); + void drawSmartCruiseControlOnroadIcon(QPainter &p, const QRect &surface_rect, int x_offset, int y_offset, std::string name); + + bool lead_status; + float lead_d_rel; + float lead_v_rel; + bool torqueLateral; + float angleSteers; + float desiredCurvature; + float curvature; + float roll; + int memoryUsagePercent; + int devUiInfo; + float gpsAccuracy; + float altitude; + float vEgo; + float aEgo; + float steeringTorqueEps; + float bearingAccuracyDeg; + float bearingDeg; + bool torquedUseParams; + float latAccelFactorFiltered; + float frictionCoefficientFiltered; + bool liveValid; + QString speedUnit; + bool latActive; + bool steerOverride; + bool reversing; + cereal::CarParams::SteerControlType steerControlType; + cereal::CarControl::Actuators::Reader actuators; + bool standstillTimer; + bool isStandstill; + float standstillElapsedTime; + bool longOverride; + bool smartCruiseControlVisionEnabled; + bool smartCruiseControlVisionActive; + int smartCruiseControlVisionFrame; }; diff --git a/selfdrive/ui/sunnypilot/qt/onroad/model.cc b/selfdrive/ui/sunnypilot/qt/onroad/model.cc index af0177c344..5d92838f8a 100644 --- a/selfdrive/ui/sunnypilot/qt/onroad/model.cc +++ b/selfdrive/ui/sunnypilot/qt/onroad/model.cc @@ -48,5 +48,43 @@ void ModelRendererSP::drawPath(QPainter &painter, const cereal::ModelDataV2::Rea painter.drawPolygon(right_blindspot_vertices); } } - ModelRenderer::drawPath(painter, model, surface_rect.height()); + + bool rainbow = Params().getBool("RainbowMode"); + //float v_ego = sm["carState"].getCarState().getVEgo(); + + if (rainbow) { + // Simple time-based animation + float time_offset = std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count() / 1000.0f; + + // simple linear gradient from bottom to top + QLinearGradient bg(0, surface_rect.height(), 0, 0); + + // evenly spaced colors across the spectrum + // The animation shifts the entire spectrum smoothly + float animation_speed = 40.0f; // speed vroom vroom + float hue_offset = fmod(time_offset * animation_speed, 360.0f); + + // 6-8 color stops for smooth transitions more color makes it laggy + const int num_stops = 7; + for (int i = 0; i < num_stops; i++) { + float position = static_cast(i) / (num_stops - 1); + + float hue = fmod(hue_offset + position * 360.0f, 360.0f); + float saturation = 0.9f; + float lightness = 0.6f; + + // Alpha fades out towards the far end of the path + float alpha = 0.8f * (1.0f - position * 0.3f); + + QColor color = QColor::fromHslF(hue / 360.0f, saturation, lightness, alpha); + bg.setColorAt(position, color); + } + + painter.setBrush(bg); + painter.drawPolygon(track_vertices); + } else { + // Normal path rendering + ModelRenderer::drawPath(painter, model, surface_rect.height()); + } } diff --git a/selfdrive/ui/sunnypilot/qt/util.cc b/selfdrive/ui/sunnypilot/qt/util.cc index ca85935d0b..2e066e88b5 100644 --- a/selfdrive/ui/sunnypilot/qt/util.cc +++ b/selfdrive/ui/sunnypilot/qt/util.cc @@ -110,3 +110,16 @@ QStringList searchFromList(const QString &query, const QStringList &list) { } return search_results; } + +std::optional loadCerealEvent(Params& params, const std::string& _param) { + std::string bytes = params.get(_param); + + try { + AlignedBuffer aligned_buf; + capnp::FlatArrayMessageReader cmsg(aligned_buf.align(bytes.data(), bytes.size())); + return cmsg.getRoot(); + } catch (kj::Exception& e) { + qInfo() << "invalid " << QString::fromStdString(_param) << ":" << e.getDescription().cStr(); + return std::nullopt; + } +} diff --git a/selfdrive/ui/sunnypilot/qt/util.h b/selfdrive/ui/sunnypilot/qt/util.h index 089b5370cc..4b9d615ce5 100644 --- a/selfdrive/ui/sunnypilot/qt/util.h +++ b/selfdrive/ui/sunnypilot/qt/util.h @@ -15,8 +15,11 @@ #include #include +#include "selfdrive/ui/sunnypilot/ui.h" + QString getUserAgent(bool sunnylink = false); std::optional getSunnylinkDongleId(); std::optional getParamIgnoringDefault(const std::string ¶m_name, const std::string &default_value); QMap loadPlatformList(); QStringList searchFromList(const QString &query, const QStringList &list); +std::optional loadCerealEvent(Params& params, const std::string& _param); diff --git a/selfdrive/ui/sunnypilot/qt/widgets/controls.h b/selfdrive/ui/sunnypilot/qt/widgets/controls.h index a840febfe7..03cb461385 100644 --- a/selfdrive/ui/sunnypilot/qt/widgets/controls.h +++ b/selfdrive/ui/sunnypilot/qt/widgets/controls.h @@ -750,3 +750,24 @@ public: setFixedSize(400, 100); } }; + +inline QString RainbowizeWords(const QString &text) { + const QStringList colors = { + "#FF6F61", // soft coral red + "#FFA177", // warm peach + "#FFD966", // soft golden yellow + "#88D498", // mint green + "#6EC6FF", // sky blue + "#A78BFA", // soft lavender + "#F78FB3" // rose pink + }; + + QString result; + QStringList words = text.split(' '); + + for (int i = 0; i < words.size(); ++i) { + result += QString("%2 ").arg(colors[i % colors.size()]).arg(words[i].toHtmlEscaped()); + } + + return result.trimmed(); + } diff --git a/selfdrive/ui/sunnypilot/qt/widgets/external_storage.cc b/selfdrive/ui/sunnypilot/qt/widgets/external_storage.cc new file mode 100644 index 0000000000..f951187511 --- /dev/null +++ b/selfdrive/ui/sunnypilot/qt/widgets/external_storage.cc @@ -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. + */ + +#include "selfdrive/ui/sunnypilot/qt/widgets/external_storage.h" + +#include +#include +#include +#include +#include + +#include "common/params.h" +#include "selfdrive/ui/qt/api.h" +#include "selfdrive/ui/qt/widgets/input.h" +#include "selfdrive/ui/sunnypilot/ui.h" + +ExternalStorageControl::ExternalStorageControl() : + ButtonControl(tr("External Storage"), "", tr("Extend your comma device's storage by inserting a USB drive into the aux port.")) { + + QObject::connect(this, &ButtonControl::clicked, [=]() { + if (text() == tr("CHECK") || text() == tr("MOUNT")) { + mountStorage(); + } else if (text() == tr("UNMOUNT")) { + unmountStorage(); + } else if (text() == tr("FORMAT")) { + if (ConfirmationDialog::confirm(tr("Are you sure you want to format this drive? This will erase all data."), tr("Format"), this)) { + formatStorage(); + } + } + }); + + QObject::connect(uiState(), &UIState::offroadTransition, this, &ExternalStorageControl::updateState); + updateState(!uiState()->scene.started); + + refresh(); +} + +void ExternalStorageControl::updateState(bool offroad) { + setEnabled(offroad); +} + +void ExternalStorageControl::debouncedRefresh() { + if (refreshPending) return; + refreshPending = true; + + QTimer::singleShot(250, this, [=]() { + refreshPending = false; + refresh(); + }); +} + +void ExternalStorageControl::refresh() { + QtConcurrent::run([=]() { + auto run = [](const QString &cmd) { + QProcess p; + p.start("sh", QStringList() << "-c" << cmd); + p.waitForFinished(); + return p.exitCode() == 0; + }; + + bool isMounted = run("findmnt -n /mnt/external_realdata"); + bool hasDrive = run("lsblk -f /dev/sdg"); + bool hasFs = run("lsblk -f /dev/sdg1 | grep -q ext4"); + bool hasLabel = run("sudo blkid /dev/sdg1 | grep -q 'LABEL=\"openpilot\"'"); + + QString info; + if (isMounted && hasLabel) { + QProcess df; + df.start("sh", QStringList() << "-c" << "df -h /mnt/external_realdata | awk 'NR==2 {print $3 \"/\" $2}'"); + df.waitForFinished(); + info = df.readAllStandardOutput().trimmed(); + } + + QMetaObject::invokeMethod(this, [=]() { + if (formatting) { + setValue(tr("formatting")); + setText(tr("FORMAT")); + setEnabled(false); + } else { + if (!hasDrive) { + setValue(tr("insert drive")); + setText(tr("CHECK")); + } else if (!hasFs || !hasLabel) { + setValue(tr("needs format")); + setText(tr("FORMAT")); + } else if (isMounted) { + setValue(info); + setText(tr("UNMOUNT")); + } else { + setValue("drive detected"); + setText(tr("MOUNT")); + } + updateState(!uiState()->scene.started); + } + }, Qt::QueuedConnection); + }); +} + +void ExternalStorageControl::mountStorage() { + setValue(tr("mounting")); + setEnabled(false); + + QtConcurrent::run([=]() { + QProcess process; + process.start("sh", QStringList() << "-c" << + "sudo mount -o remount,rw / && " + "sudo mkdir -p /mnt/external_realdata && " + "grep -q '/dev/sdg1 /mnt/external_realdata' /etc/fstab || " + "echo '/dev/sdg1 /mnt/external_realdata ext4 defaults,nofail 0 2' | sudo tee -a /etc/fstab && " + "sudo systemctl daemon-reexec && " + "sudo mount /mnt/external_realdata && " + "sudo chown -R comma:comma /mnt/external_realdata && " + "sudo chmod -R 775 /mnt/external_realdata && " + "sudo mount -o remount,ro /"); + process.waitForFinished(); + + QMetaObject::invokeMethod(this, [=]() { + debouncedRefresh(); + }, Qt::QueuedConnection); + }); +} + +void ExternalStorageControl::unmountStorage() { + setValue(tr("unmounting")); + setEnabled(false); + + QtConcurrent::run([=]() { + QProcess process; + process.start("sh", QStringList() << "-c" << "sudo umount /mnt/external_realdata"); + process.waitForFinished(); + + QMetaObject::invokeMethod(this, [=]() { + debouncedRefresh(); + }, Qt::QueuedConnection); + }); +} + +void ExternalStorageControl::formatStorage() { + unmountStorage(); + formatting = true; + setValue(tr("formatting")); + setEnabled(false); + + QProcess *process = new QProcess(this); + connect(process, static_cast(&QProcess::finished), + this, [=](int exitCode, QProcess::ExitStatus status) { + process->deleteLater(); + formatting = false; + if (exitCode == 0 && status == QProcess::NormalExit) { + mountStorage(); + } else { + setValue(tr("needs format")); + updateState(!uiState()->scene.started); + } + }); + process->start("sh", QStringList() << "-c" << + "sudo wipefs -a /dev/sdg && " + "sudo parted -s /dev/sdg mklabel gpt mkpart primary ext4 0% 100% && " + "sudo mkfs.ext4 -F -L openpilot /dev/sdg1" + ); +} + +void ExternalStorageControl::showEvent(QShowEvent *event) { + ButtonControl::showEvent(event); + QTimer::singleShot(100, this, &ExternalStorageControl::debouncedRefresh); +} diff --git a/selfdrive/ui/sunnypilot/qt/widgets/external_storage.h b/selfdrive/ui/sunnypilot/qt/widgets/external_storage.h new file mode 100644 index 0000000000..d26eefd18c --- /dev/null +++ b/selfdrive/ui/sunnypilot/qt/widgets/external_storage.h @@ -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. + */ + +#pragma once + +#include "system/hardware/hw.h" +#include "selfdrive/ui/sunnypilot/qt/widgets/controls.h" +#define ButtonControl ButtonControlSP + +class ExternalStorageControl : public ButtonControl { + Q_OBJECT + +public: + ExternalStorageControl(); + +protected: + void showEvent(QShowEvent *event) override; + +private: + Params params; + + bool refreshPending = false; + bool formatting = false; + void updateState(bool offroad); + void refresh(); + void debouncedRefresh(); + void mountStorage(); + void unmountStorage(); + void formatStorage(); +}; diff --git a/selfdrive/ui/sunnypilot/ui.cc b/selfdrive/ui/sunnypilot/ui.cc index b2701356cc..7b10929bc6 100644 --- a/selfdrive/ui/sunnypilot/ui.cc +++ b/selfdrive/ui/sunnypilot/ui.cc @@ -18,13 +18,23 @@ UIStateSP::UIStateSP(QObject *parent) : UIState(parent) { "modelV2", "controlsState", "liveCalibration", "radarState", "deviceState", "pandaStates", "carParams", "driverMonitoringState", "carState", "driverStateV2", "wideRoadCameraState", "managerState", "selfdriveState", "longitudinalPlan", - "modelManagerSP", "selfdriveStateSP", "longitudinalPlanSP", "backupManagerSP" + "modelManagerSP", "selfdriveStateSP", "longitudinalPlanSP", "backupManagerSP", + "carControl", "gpsLocationExternal", "gpsLocation", "liveTorqueParameters", + "carStateSP", "liveParameters" }); // update timer timer = new QTimer(this); QObject::connect(timer, &QTimer::timeout, this, &UIStateSP::update); timer->start(1000 / UI_FREQ); + + // Param watcher for UIScene param updates + param_watcher = new ParamWatcher(this); + connect(param_watcher, &ParamWatcher::paramChanged, [=](const QString ¶m_name, const QString ¶m_value) { + ui_update_params_sp(this); + }); + param_watcher->addParam("DevUIInfo"); + param_watcher->addParam("StandstillTimer"); } // This method overrides completely the update method from the parent class intentionally. @@ -39,6 +49,12 @@ void UIStateSP::update() { emit uiUpdate(*this); } +void ui_update_params_sp(UIStateSP *s) { + auto params = Params(); + s->scene.dev_ui_info = std::atoi(params.get("DevUIInfo").c_str()); + s->scene.standstill_timer = params.getBool("StandstillTimer"); +} + DeviceSP::DeviceSP(QObject *parent) : Device(parent) { QObject::connect(uiStateSP(), &UIStateSP::uiUpdate, this, &DeviceSP::update); QObject::connect(this, &Device::displayPowerChanged, this, &DeviceSP::handleDisplayPowerChanged); diff --git a/selfdrive/ui/sunnypilot/ui.h b/selfdrive/ui/sunnypilot/ui.h index cf8de1c4bb..393f997cbd 100644 --- a/selfdrive/ui/sunnypilot/ui.h +++ b/selfdrive/ui/sunnypilot/ui.h @@ -13,6 +13,7 @@ #include "selfdrive/ui/sunnypilot/qt/network/sunnylink/models/role_model.h" #include "selfdrive/ui/sunnypilot/qt/network/sunnylink/models/sponsor_role_model.h" #include "selfdrive/ui/ui.h" +#include "selfdrive/ui/qt/util.h" class UIStateSP : public UIState { Q_OBJECT @@ -73,6 +74,7 @@ private slots: private: std::vector sunnylinkRoles = {}; std::vector sunnylinkUsers = {}; + ParamWatcher *param_watcher; }; UIStateSP *uiStateSP(); @@ -92,3 +94,5 @@ private: DeviceSP *deviceSP(); inline DeviceSP *device() { return deviceSP(); } + +void ui_update_params_sp(UIStateSP *s); diff --git a/selfdrive/ui/sunnypilot/ui_scene.h b/selfdrive/ui/sunnypilot/ui_scene.h new file mode 100644 index 0000000000..c941be675c --- /dev/null +++ b/selfdrive/ui/sunnypilot/ui_scene.h @@ -0,0 +1,13 @@ +/** + * 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. + */ + +#pragma once + +typedef struct UISceneSP : UIScene { + int dev_ui_info = 0; + bool standstill_timer = false; +} UISceneSP; diff --git a/selfdrive/ui/tests/test_ui/run.py b/selfdrive/ui/tests/test_ui/run.py index 653395ba1d..68d1f0c185 100755 --- a/selfdrive/ui/tests/test_ui/run.py +++ b/selfdrive/ui/tests/test_ui/run.py @@ -29,7 +29,7 @@ UI_DELAY = 0.5 # may be slower on CI? TEST_ROUTE = "a2a0ccea32023010|2023-07-27--13-01-19" STREAMS: list[tuple[VisionStreamType, CameraConfig, bytes]] = [] -OFFROAD_ALERTS = ['Offroad_StorageMissing', 'Offroad_IsTakingSnapshot'] +OFFROAD_ALERTS = ['Offroad_IsTakingSnapshot', ] DATA: dict[str, capnp.lib.capnp._DynamicStructBuilder] = dict.fromkeys( ["carParams", "deviceState", "pandaStates", "controlsState", "selfdriveState", "liveCalibration", "modelV2", "radarState", "driverMonitoringState", "carState", diff --git a/selfdrive/ui/translations/main_ar.ts b/selfdrive/ui/translations/main_ar.ts index f72ea8ee83..ef7110e72e 100644 --- a/selfdrive/ui/translations/main_ar.ts +++ b/selfdrive/ui/translations/main_ar.ts @@ -2103,6 +2103,22 @@ Warning: You are on a metered connection! [Don't use] Enable sunnylink uploader + + 🚀 sunnylink 🚀 + + + + For secure backup, restore, and remote configuration + + + + Sponsorship isn't required for basic backup/restore + + + + Click the sponsor button for more details + + SunnylinkSponsorPopup diff --git a/selfdrive/ui/translations/main_de.ts b/selfdrive/ui/translations/main_de.ts index 193e2fd895..dde9241460 100644 --- a/selfdrive/ui/translations/main_de.ts +++ b/selfdrive/ui/translations/main_de.ts @@ -2085,6 +2085,22 @@ Warning: You are on a metered connection! [Don't use] Enable sunnylink uploader + + 🚀 sunnylink 🚀 + + + + For secure backup, restore, and remote configuration + + + + Sponsorship isn't required for basic backup/restore + + + + Click the sponsor button for more details + + SunnylinkSponsorPopup diff --git a/selfdrive/ui/translations/main_es.ts b/selfdrive/ui/translations/main_es.ts index 1d1723cde7..3b53e7f944 100644 --- a/selfdrive/ui/translations/main_es.ts +++ b/selfdrive/ui/translations/main_es.ts @@ -2087,6 +2087,22 @@ Warning: You are on a metered connection! [Don't use] Enable sunnylink uploader + + 🚀 sunnylink 🚀 + + + + For secure backup, restore, and remote configuration + + + + Sponsorship isn't required for basic backup/restore + + + + Click the sponsor button for more details + + SunnylinkSponsorPopup diff --git a/selfdrive/ui/translations/main_fr.ts b/selfdrive/ui/translations/main_fr.ts index 959321bb4b..45705defd9 100644 --- a/selfdrive/ui/translations/main_fr.ts +++ b/selfdrive/ui/translations/main_fr.ts @@ -2083,6 +2083,22 @@ Warning: You are on a metered connection! [Don't use] Enable sunnylink uploader + + 🚀 sunnylink 🚀 + + + + For secure backup, restore, and remote configuration + + + + Sponsorship isn't required for basic backup/restore + + + + Click the sponsor button for more details + + SunnylinkSponsorPopup diff --git a/selfdrive/ui/translations/main_ja.ts b/selfdrive/ui/translations/main_ja.ts index 28b5b00e51..3faa183954 100644 --- a/selfdrive/ui/translations/main_ja.ts +++ b/selfdrive/ui/translations/main_ja.ts @@ -2082,6 +2082,22 @@ Warning: You are on a metered connection! [Don't use] Enable sunnylink uploader + + 🚀 sunnylink 🚀 + + + + For secure backup, restore, and remote configuration + + + + Sponsorship isn't required for basic backup/restore + + + + Click the sponsor button for more details + + SunnylinkSponsorPopup diff --git a/selfdrive/ui/translations/main_ko.ts b/selfdrive/ui/translations/main_ko.ts index d69fda88b0..756cad6045 100644 --- a/selfdrive/ui/translations/main_ko.ts +++ b/selfdrive/ui/translations/main_ko.ts @@ -2096,6 +2096,22 @@ Warning: You are on a metered connection! [Don't use] Enable sunnylink uploader + + 🚀 sunnylink 🚀 + + + + For secure backup, restore, and remote configuration + + + + Sponsorship isn't required for basic backup/restore + + + + Click the sponsor button for more details + + SunnylinkSponsorPopup diff --git a/selfdrive/ui/translations/main_pt-BR.ts b/selfdrive/ui/translations/main_pt-BR.ts index c5171b19f7..0a886d435c 100644 --- a/selfdrive/ui/translations/main_pt-BR.ts +++ b/selfdrive/ui/translations/main_pt-BR.ts @@ -2087,6 +2087,22 @@ Warning: You are on a metered connection! [Don't use] Enable sunnylink uploader + + 🚀 sunnylink 🚀 + + + + For secure backup, restore, and remote configuration + + + + Sponsorship isn't required for basic backup/restore + + + + Click the sponsor button for more details + + SunnylinkSponsorPopup diff --git a/selfdrive/ui/translations/main_th.ts b/selfdrive/ui/translations/main_th.ts index 3bd2d84a92..7b89f2a694 100644 --- a/selfdrive/ui/translations/main_th.ts +++ b/selfdrive/ui/translations/main_th.ts @@ -2078,6 +2078,22 @@ Warning: You are on a metered connection! [Don't use] Enable sunnylink uploader + + 🚀 sunnylink 🚀 + + + + For secure backup, restore, and remote configuration + + + + Sponsorship isn't required for basic backup/restore + + + + Click the sponsor button for more details + + SunnylinkSponsorPopup diff --git a/selfdrive/ui/translations/main_tr.ts b/selfdrive/ui/translations/main_tr.ts index db9c15580a..0598d08364 100644 --- a/selfdrive/ui/translations/main_tr.ts +++ b/selfdrive/ui/translations/main_tr.ts @@ -2077,6 +2077,22 @@ Warning: You are on a metered connection! [Don't use] Enable sunnylink uploader + + 🚀 sunnylink 🚀 + + + + For secure backup, restore, and remote configuration + + + + Sponsorship isn't required for basic backup/restore + + + + Click the sponsor button for more details + + SunnylinkSponsorPopup diff --git a/selfdrive/ui/translations/main_zh-CHS.ts b/selfdrive/ui/translations/main_zh-CHS.ts index 2316360891..1f4db2d6d0 100644 --- a/selfdrive/ui/translations/main_zh-CHS.ts +++ b/selfdrive/ui/translations/main_zh-CHS.ts @@ -2082,6 +2082,22 @@ Warning: You are on a metered connection! [Don't use] Enable sunnylink uploader + + 🚀 sunnylink 🚀 + + + + For secure backup, restore, and remote configuration + + + + Sponsorship isn't required for basic backup/restore + + + + Click the sponsor button for more details + + SunnylinkSponsorPopup diff --git a/selfdrive/ui/translations/main_zh-CHT.ts b/selfdrive/ui/translations/main_zh-CHT.ts index 16bb393dea..1a8784846c 100644 --- a/selfdrive/ui/translations/main_zh-CHT.ts +++ b/selfdrive/ui/translations/main_zh-CHT.ts @@ -2082,6 +2082,22 @@ Warning: You are on a metered connection! [Don't use] Enable sunnylink uploader + + 🚀 sunnylink 🚀 + + + + For secure backup, restore, and remote configuration + + + + Sponsorship isn't required for basic backup/restore + + + + Click the sponsor button for more details + + SunnylinkSponsorPopup diff --git a/selfdrive/ui/ui.cc b/selfdrive/ui/ui.cc index 4b8f79785b..4cad883f7d 100644 --- a/selfdrive/ui/ui.cc +++ b/selfdrive/ui/ui.cc @@ -55,8 +55,7 @@ void update_state(UIState *s) { } if (sm.updated("wideRoadCameraState")) { auto cam_state = sm["wideRoadCameraState"].getWideRoadCameraState(); - float scale = (cam_state.getSensor() == cereal::FrameData::ImageSensor::AR0231) ? 6.0f : 1.0f; - scene.light_sensor = std::max(100.0f - scale * cam_state.getExposureValPercent(), 0.0f); + scene.light_sensor = std::max(100.0f - cam_state.getExposureValPercent(), 0.0f); } else if (!sm.allAliveAndValid({"wideRoadCameraState"})) { scene.light_sensor = -1; } diff --git a/selfdrive/ui/ui.h b/selfdrive/ui/ui.h index e78b573b66..5b3872b3d4 100644 --- a/selfdrive/ui/ui.h +++ b/selfdrive/ui/ui.h @@ -66,6 +66,11 @@ typedef struct UIScene { uint64_t started_frame; } UIScene; +#ifdef SUNNYPILOT +#include "sunnypilot/ui_scene.h" +#define UIScene UISceneSP +#endif + class UIState : public QObject { Q_OBJECT diff --git a/selfdrive/ui/ui_state.py b/selfdrive/ui/ui_state.py index c2bb5f77a4..c4a2c0ca11 100644 --- a/selfdrive/ui/ui_state.py +++ b/selfdrive/ui/ui_state.py @@ -105,10 +105,7 @@ class UIState: # Handle wide road camera state updates if self.sm.updated["wideRoadCameraState"]: cam_state = self.sm["wideRoadCameraState"] - - # Scale factor based on sensor type - scale = 6.0 if cam_state.sensor == 'ar0231' else 1.0 - self.light_sensor = max(100.0 - scale * cam_state.exposureValPercent, 0.0) + self.light_sensor = max(100.0 - cam_state.exposureValPercent, 0.0) elif not self.sm.alive["wideRoadCameraState"] or not self.sm.valid["wideRoadCameraState"]: self.light_sensor = -1 diff --git a/sunnypilot/__init__.py b/sunnypilot/__init__.py index e69de29bb2..ab5441aa71 100644 --- a/sunnypilot/__init__.py +++ b/sunnypilot/__init__.py @@ -0,0 +1,7 @@ +""" +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. +""" +PARAMS_UPDATE_PERIOD = 3 # seconds diff --git a/sunnypilot/mapd/live_map_data/debug.py b/sunnypilot/mapd/live_map_data/debug.py index da9f4d7771..794f2ef8ff 100644 --- a/sunnypilot/mapd/live_map_data/debug.py +++ b/sunnypilot/mapd/live_map_data/debug.py @@ -37,15 +37,14 @@ def live_map_data_sp_thread(): def live_map_data_sp_thread_debug(gps_location_service): - _sub_master = messaging.SubMaster(['carState', 'livePose', 'liveMapDataSP', 'longitudinalPlanSP', gps_location_service]) + _sub_master = messaging.SubMaster(['carState', 'livePose', 'liveMapDataSP', 'longitudinalPlanSP', 'carStateSP', gps_location_service]) _sub_master.update() v_ego = _sub_master['carState'].vEgo - long_spl = _sub_master['longitudinalPlanSP'].speedLimit - _policy = Policy.car_state_priority - _resolver = SpeedLimitResolver(_policy) - _speed_limit, _distance, _source = _resolver.resolve(v_ego, long_spl, _sub_master) - print(_speed_limit, _distance, _source, " <-> ", long_spl) + _resolver = SpeedLimitResolver() + _resolver.policy = Policy.car_state_priority + _resolver.update(v_ego, _sub_master) + print(_resolver.speed_limit, _resolver.distance, _resolver.source) def main(): diff --git a/sunnypilot/mapd/mapd_manager.py b/sunnypilot/mapd/mapd_manager.py index 9ebf07a7f4..1211c1ecc6 100755 --- a/sunnypilot/mapd/mapd_manager.py +++ b/sunnypilot/mapd/mapd_manager.py @@ -6,11 +6,11 @@ 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 time 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 @@ -56,7 +56,7 @@ def cleanup_old_osm_data(files_to_remove: list[str]) -> None: def request_refresh_osm_location_data(nations: list[str], states: list[str] = None) -> None: - params.put("OsmDownloadedDate", str(time.monotonic())) + params.put("OsmDownloadedDate", str(datetime.now().timestamp())) params.put_bool("OsmDbUpdatesCheck", False) osm_download_locations = { diff --git a/sunnypilot/modeld/fill_model_msg.py b/sunnypilot/modeld/fill_model_msg.py index dadffc8433..a62c451efd 100644 --- a/sunnypilot/modeld/fill_model_msg.py +++ b/sunnypilot/modeld/fill_model_msg.py @@ -3,6 +3,7 @@ import capnp import numpy as np from cereal import log from openpilot.sunnypilot.modeld.constants import ModelConstants, Plan +from openpilot.sunnypilot.models.helpers import plan_x_idxs_helper from openpilot.sunnypilot.selfdrive.controls.lib.drive_helpers import CONTROL_N, get_lag_adjusted_curvature, MIN_SPEED SEND_RAW_PRED = os.getenv('SEND_RAW_PRED') @@ -120,23 +121,7 @@ def fill_model_msg(base_msg: capnp._DynamicStructBuilder, extended_msg: capnp._D modelV2_action.desiredCurvature = desired_curvature # times at X_IDXS according to model plan - PLAN_T_IDXS = [np.nan] * ModelConstants.IDX_N - PLAN_T_IDXS[0] = 0.0 - plan_x = net_output_data['plan'][0,:,Plan.POSITION][:,0].tolist() - for xidx in range(1, ModelConstants.IDX_N): - tidx = 0 - # increment tidx until we find an element that's further away than the current xidx - while tidx < ModelConstants.IDX_N - 1 and plan_x[tidx+1] < ModelConstants.X_IDXS[xidx]: - tidx += 1 - if tidx == ModelConstants.IDX_N - 1: - # if the Plan doesn't extend far enough, set plan_t to the max value (10s), then break - PLAN_T_IDXS[xidx] = ModelConstants.T_IDXS[ModelConstants.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 = (ModelConstants.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') - PLAN_T_IDXS[xidx] = p * ModelConstants.T_IDXS[tidx+1] + (1 - p) * ModelConstants.T_IDXS[tidx] + PLAN_T_IDXS: list[float] = plan_x_idxs_helper(ModelConstants, Plan, net_output_data) # lane lines modelV2.init('laneLines', 4) diff --git a/sunnypilot/modeld/modeld.py b/sunnypilot/modeld/modeld.py index b94e166bc6..3d11ed23f4 100755 --- a/sunnypilot/modeld/modeld.py +++ b/sunnypilot/modeld/modeld.py @@ -177,7 +177,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() @@ -304,6 +304,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') action = model.get_action_from_model(model_output, prev_action, long_delay + DT_MDL) 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, @@ -316,6 +317,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 @@ -323,6 +325,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/sunnypilot/modeld_v2/fill_model_msg.py b/sunnypilot/modeld_v2/fill_model_msg.py index c7de698f61..ee0eb48684 100644 --- a/sunnypilot/modeld_v2/fill_model_msg.py +++ b/sunnypilot/modeld_v2/fill_model_msg.py @@ -3,6 +3,7 @@ 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') @@ -118,8 +119,8 @@ def fill_model_msg(base_msg: capnp._DynamicStructBuilder, extended_msg: capnp._D # action (includes lateral planning now) 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/sunnypilot/modeld_v2/modeld.py b/sunnypilot/modeld_v2/modeld.py index 1a420e6f1a..0fd45940d8 100755 --- a/sunnypilot/modeld_v2/modeld.py +++ b/sunnypilot/modeld_v2/modeld.py @@ -27,7 +27,7 @@ from openpilot.sunnypilot.modeld.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" +PROCESS_NAME = "selfdrive.modeld.modeld_tinygrad" class FrameMeta: @@ -77,42 +77,47 @@ class ModelState(ModelStateBase): 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 = max(100, (shape[1] * 4 if shape[1] < 100 else shape[1])) # Allow for higher history buffers in the future + 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] - self.temporal_buffers[key] = np.zeros((1, buffer_history_len, feature_len), dtype=np.float32) 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] == buffer_history_len: # non20hz - self.temporal_idxs_map[key] = np.arange(buffer_history_len) + 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['desire'][0] = 0 - new_desire = np.where(inputs['desire'] - self.prev_desire > .99, inputs['desire'], 0) - self.prev_desire[:] = inputs['desire'] - self.temporal_buffers['desire'][0,:-1] = self.temporal_buffers['desire'][0,1:] - self.temporal_buffers['desire'][0,-1] = new_desire + 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['desire'].shape[1] > self.numpy_inputs['desire'].shape[1]: - skip = self.temporal_buffers['desire'].shape[1] // self.numpy_inputs['desire'].shape[1] - self.numpy_inputs['desire'][:] = ( - self.temporal_buffers['desire'][0].reshape(self.numpy_inputs['desire'].shape[0], self.numpy_inputs['desire'].shape[1], skip, -1).max(axis=2)) + 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['desire'][:] = self.temporal_buffers['desire'][0, self.temporal_idxs_map['desire']] + 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 ['desire']: + if key in inputs and key not in [self.desire_key]: self.numpy_inputs[key][:] = inputs[key] imgs_cl = {name: self.frames[name].prepare(bufs[name], transforms[name].flatten()) for name in self.model_runner.vision_input_names} @@ -156,10 +161,11 @@ class ModelState(ModelStateBase): desired_accel = smooth_value(desired_accel, prev_action.desiredAcceleration, self.LONG_SMOOTH_SECONDS) desired_curvature = get_curvature_from_output(model_output, v_ego, lat_action_t, self.mlsim) - 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 + 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)) @@ -202,7 +208,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() @@ -306,7 +312,7 @@ def main(demo=False): 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] = { - 'desire': vec_desire, + model.desire_key: vec_desire, 'traffic_convention': traffic_convention, } @@ -322,6 +328,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') action = model.get_action_from_model(model_output, prev_action, lat_delay + DT_MDL, long_delay + DT_MDL, v_ego) prev_action = action @@ -336,6 +343,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 @@ -343,6 +351,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/sunnypilot/modeld_v2/tests/test_buffer_logic_inspect.py b/sunnypilot/modeld_v2/tests/test_buffer_logic_inspect.py index 8a0cfd97c8..f664db31b3 100644 --- a/sunnypilot/modeld_v2/tests/test_buffer_logic_inspect.py +++ b/sunnypilot/modeld_v2/tests/test_buffer_logic_inspect.py @@ -8,12 +8,16 @@ 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, 25, 8), 'features_buffer': (1, 25, 512), 'prev_desired_curv': (1, 25, 1)}, 'split'), - ({'desire': (1, 25, 8), 'features_buffer': (1, 24, 512), 'prev_desired_curv': (1, 25, 1)}, '20hz'), - ({'desire': (1, 100, 8), 'features_buffer': (1, 99, 512), 'prev_desired_curv': (1, 100, 1)}, 'non20hz'), + ({'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 ] @@ -95,9 +99,7 @@ def get_expected_indices(shape, constants, mode, key=None): idxs = np.arange(step_size, step_size * (num_elements + 1), step_size)[::-1] return idxs elif mode == 'non20hz': - if key and shape[1] == constants.FULL_HISTORY_BUFFER_LEN: - return np.arange(constants.FULL_HISTORY_BUFFER_LEN) - return None + return np.arange(shape[1]) return None @@ -108,6 +110,8 @@ def test_buffer_shapes_and_indices(shapes, mode, apply_patches): 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]) @@ -116,10 +120,7 @@ def test_buffer_shapes_and_indices(shapes, mode, apply_patches): expected_shape = (1, constants.FULL_HISTORY_BUFFER_LEN, shapes[key][2]) expected_idxs = get_expected_indices(shapes[key], constants, '20hz', key) elif mode == 'non20hz': - if key == 'features_buffer': - expected_shape = (1, shapes[key][1]*4, shapes[key][2]) - else: - expected_shape = (1, shapes[key][1], shapes[key][2]) + 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" @@ -130,10 +131,10 @@ def test_buffer_shapes_and_indices(shapes, mode, apply_patches): 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): +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': + 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) @@ -173,15 +174,22 @@ def legacy_buffer_update(buf, new_val, mode, key, constants, idxs): return legacy_buf[idxs] elif mode == 'non20hz': if key == 'desire': - length = new_val.shape[0] - buf[0,:-1,:length] = buf[0,1:,:length] - buf[0,-1,:length] = new_val[:length] + 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': - feature_len = new_val.shape[0] - buf[0,:-1,:feature_len] = buf[0,1:,:feature_len] - buf[0,-1,:feature_len] = new_val[:feature_len] - return buf[0] + 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] @@ -191,32 +199,18 @@ def legacy_buffer_update(buf, new_val, mode, key, constants, idxs): def dynamic_buffer_update(state, key, new_val, mode): - if key == 'desire': - state.temporal_buffers['desire'][0,:-1] = state.temporal_buffers['desire'][0,1:] - state.temporal_buffers['desire'][0,-1] = new_val - if state.temporal_buffers['desire'].shape[1] > state.numpy_inputs['desire'].shape[1]: - skip = state.temporal_buffers['desire'].shape[1] // state.numpy_inputs['desire'].shape[1] - return state.temporal_buffers['desire'][0].reshape( - state.numpy_inputs['desire'].shape[0], state.numpy_inputs['desire'].shape[1], skip, -1 - ).max(axis=2) - else: - return state.temporal_buffers['desire'][0, state.temporal_idxs_map['desire']] - - inputs = {'desire': np.zeros((1, state.constants.DESIRE_LEN), dtype=np.float32)} - for k, tb in state.temporal_buffers.items(): - if k in state.temporal_idxs_map: - continue - buf_len = tb.shape[1] - if k in state.numpy_inputs: - out_len = state.numpy_inputs[k].shape[1] - if out_len <= buf_len: - state.temporal_idxs_map[k] = np.arange(buf_len)[-out_len:] - else: - state.temporal_idxs_map[k] = np.arange(buf_len) - else: - state.temporal_idxs_map[k] = np.arange(buf_len) + 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), @@ -226,6 +220,8 @@ def dynamic_buffer_update(state, key, new_val, mode): 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), @@ -241,16 +237,27 @@ def dynamic_buffer_update(state, key, new_val, mode): @pytest.mark.parametrize("key", ["desire", "features_buffer", "prev_desired_curv"]) def test_buffer_update_equivalence(shapes, mode, key, apply_patches): state = ModelState(None) + 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(key, None) - idxs = state.temporal_idxs_map.get(key, None) - input_shape = shapes[key] + 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, key, constants, idxs) - actual = dynamic_buffer_update(state, key, new_val, mode) - # Model returns the reduced numpy_inputs history, compare the last n entries so the test is checking the same slices. + 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} {key}: dynamic buffer update does not match legacy logic" + assert np.allclose(actual, expected), f"{mode} {actual_key}: dynamic buffer update does not match legacy logic" diff --git a/sunnypilot/models/fetcher.py b/sunnypilot/models/fetcher.py index 60a222a36c..3be6e0b46c 100644 --- a/sunnypilot/models/fetcher.py +++ b/sunnypilot/models/fetcher.py @@ -8,6 +8,7 @@ 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 sunnypilot.models.helpers import is_bundle_version_compatible @@ -66,6 +67,7 @@ class ModelParser: 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 @@ -121,19 +123,36 @@ class ModelFetcher: self.model_cache = ModelCache(params) self.model_parser = ModelParser() - def _fetch_and_cache_models(self) -> list[custom.ModelManagerSP.ModelBundle]: - """Fetches fresh model data from remote and updates cache""" + 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) - response.raise_for_status() - json_data = response.json() + # 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 Exception: - cloudlog.exception("Error fetching models") - raise + + 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""" @@ -143,12 +162,12 @@ class ModelFetcher: cloudlog.debug("Using valid cached models data") return self.model_parser.parse_models(cached_data) - try: - return self._fetch_and_cache_models() - except Exception: - if not cached_data: - cloudlog.exception("Failed to fetch fresh data and no cache available") - raise + 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) diff --git a/sunnypilot/models/helpers.py b/sunnypilot/models/helpers.py index 79241cd831..20b94fb611 100644 --- a/sunnypilot/models/helpers.py +++ b/sunnypilot/models/helpers.py @@ -19,7 +19,7 @@ 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 = 9 +CURRENT_SELECTOR_VERSION = 10 REQUIRED_MIN_SELECTOR_VERSION = 9 USE_ONNX = os.getenv('USE_ONNX', PC) @@ -185,3 +185,27 @@ def load_meta_constants(model_metadata): 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/tests/model_hash b/sunnypilot/models/tests/model_hash index f66baa7e71..33d7d86e28 100644 --- a/sunnypilot/models/tests/model_hash +++ b/sunnypilot/models/tests/model_hash @@ -1 +1 @@ -2ff2f49176a13bc7f856645d785b3b838a5c7ecf7f6cb37699fa0459ebf12453 \ No newline at end of file +70406ab4dd66d0e384734a8a56632ae4a62bc9670c2e630a0f71588c4e212cd8 \ No newline at end of file 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/car/cruise_ext.py b/sunnypilot/selfdrive/car/cruise_ext.py index f443aeee0e..716e3e1c93 100644 --- a/sunnypilot/selfdrive/car/cruise_ext.py +++ b/sunnypilot/selfdrive/car/cruise_ext.py @@ -7,19 +7,46 @@ See the LICENSE.md file in the root directory for more details. import numpy as np from cereal import car +from opendbc.car import structs from openpilot.common.params import Params +from openpilot.sunnypilot.selfdrive.car.intelligent_cruise_button_management.helpers import get_minimum_set_speed ButtonType = car.CarState.ButtonEvent.Type +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 + + +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) -> None: + def __init__(self, CP: structs.CarParams, CP_SP: structs.CarParamsSP) -> None: + self.CP = CP + self.CP_SP = CP_SP 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 + 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) @@ -39,3 +66,26 @@ class VCruiseHelperSP: 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 diff --git a/sunnypilot/selfdrive/controls/lib/speed_limit_controller/tests/__init__.py b/sunnypilot/selfdrive/car/intelligent_cruise_button_management/__init__.py similarity index 100% rename from sunnypilot/selfdrive/controls/lib/speed_limit_controller/tests/__init__.py rename to sunnypilot/selfdrive/car/intelligent_cruise_button_management/__init__.py 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..0b0957b828 --- /dev/null +++ b/sunnypilot/selfdrive/car/intelligent_cruise_button_management/controller.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 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.75 +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) -> 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 + v_cruise_ms = CS.vCruise * CV.KPH_TO_MS + + # all targets in m/s + v_targets = { + LongitudinalPlanSource.cruise: v_cruise_ms + } + source = min(v_targets, key=lambda k: v_targets[k]) + v_target_ms = v_targets[source] + + self.v_target_ms_last = apply_hysteresis(v_target_ms, 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) + + allowed_speed = CS.vEgo > ALLOWED_SPEED_THRESHOLD + ready = CS.cruiseState.enabled and allowed_speed 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, is_metric: bool) -> None: + if self.CP_SP.pcmCruiseSpeed: + return + + self.is_metric = is_metric + + self.update_calculations(CS) + 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 index cd7a24b63b..55244caa57 100644 --- a/sunnypilot/selfdrive/car/interfaces.py +++ b/sunnypilot/selfdrive/car/interfaces.py @@ -43,11 +43,21 @@ def _initialize_neural_network_lateral_control(CI: CarInterfaceBase, CP: structs CP_SP.neuralNetworkLateralControl.fuzzyFingerprint = not exact_match +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 setup_interfaces(CI: CarInterfaceBase, params: Params = None) -> None: CP = CI.CP CP_SP = CI.CP_SP _initialize_neural_network_lateral_control(CI, CP, CP_SP, params) + _initialize_intelligent_cruise_button_management(CP, CP_SP, params) def initialize_params(params) -> list[dict[str, Any]]: diff --git a/sunnypilot/selfdrive/controls/controlsd_ext.py b/sunnypilot/selfdrive/controls/controlsd_ext.py index 7e06ac77c1..e0f9326bf8 100644 --- a/sunnypilot/selfdrive/controls/controlsd_ext.py +++ b/sunnypilot/selfdrive/controls/controlsd_ext.py @@ -73,7 +73,9 @@ class ControlsExt: # MADS state CC_SP.mads = sm['selfdriveStateSP'].mads - CC_SP.params = self.param_store.publish() + CC_SP.params = self.param_store.param_list + + CC_SP.intelligentCruiseButtonManagement = sm['selfdriveStateSP'].intelligentCruiseButtonManagement return CC_SP 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..00ce026abb --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/lane_turn_desire.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 cereal import custom + +from openpilot.common.constants import CV +from openpilot.common.params import Params + +LANE_CHANGE_SPEED_MIN = 20 * CV.MPH_TO_MS + + +class LaneTurnController: + def __init__(self, desire_helper): + self.DH = desire_helper + self.turn_direction = custom.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 = custom.TurnDirection.turnLeft + elif right_blinker and not left_blinker and v_ego < self.lane_turn_value and not blindspot_right: + self.turn_direction = custom.TurnDirection.turnRight + else: + self.turn_direction = custom.TurnDirection.none + + def get_turn_direction(self): + if not self.enabled: + return custom.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 index fe54f46ca7..6e134276e5 100644 --- a/sunnypilot/selfdrive/controls/lib/latcontrol_torque_ext.py +++ b/sunnypilot/selfdrive/controls/lib/latcontrol_torque_ext.py @@ -9,23 +9,28 @@ from openpilot.sunnypilot.selfdrive.controls.lib.nnlc.nnlc import NeuralNetworkL class LatControlTorqueExt(NeuralNetworkLateralControl): - def __init__(self, lac_torque, CP, CP_SP): - super().__init__(lac_torque, CP, CP_SP) + def __init__(self, lac_torque, CP, CP_SP, CI): + super().__init__(lac_torque, CP, CP_SP, CI) - def update(self, CS, VM, params, ff, pid_log, setpoint, measurement, calibrated_pose, roll_compensation, + 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): + 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._ff, self._pid_log + 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 index 644f28573a..1965d50b51 100644 --- a/sunnypilot/selfdrive/controls/lib/latcontrol_torque_ext_base.py +++ b/sunnypilot/selfdrive/controls/lib/latcontrol_torque_ext_base.py @@ -7,6 +7,7 @@ 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 @@ -43,9 +44,10 @@ def get_lookahead_value(future_vals, current_val): class LatControlTorqueExtBase: - def __init__(self, lac_torque, CP, CP_SP): + def __init__(self, lac_torque, CP, CP_SP, CI): self.model_v2 = None self.model_valid = False + self.lac_torque = lac_torque self.torque_params = lac_torque.torque_params self.actual_lateral_jerk: float = 0.0 @@ -53,17 +55,22 @@ class LatControlTorqueExtBase: self.lateral_jerk_measurement: float = 0.0 self.lookahead_lateral_jerk: float = 0.0 - self.torque_from_lateral_accel = lac_torque.torque_from_lateral_accel + self.torque_from_lateral_accel_in_torque_space = CI.torque_from_lateral_accel_in_torque_space() self._ff = 0.0 + self._pid = PIDController(0.0, 0.0, k_f=0.0) 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, diff --git a/sunnypilot/selfdrive/controls/lib/longitudinal_planner.py b/sunnypilot/selfdrive/controls/lib/longitudinal_planner.py index e3dee73912..e20e80f99c 100644 --- a/sunnypilot/selfdrive/controls/lib/longitudinal_planner.py +++ b/sunnypilot/selfdrive/controls/lib/longitudinal_planner.py @@ -7,22 +7,28 @@ See the LICENSE.md file in the root directory for more details. from cereal import messaging, custom from opendbc.car import structs -from openpilot.selfdrive.car.cruise import V_CRUISE_UNSET from openpilot.sunnypilot.selfdrive.controls.lib.dec.dec import DynamicExperimentalController -from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit_controller.speed_limit_controller import SpeedLimitController +from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control.smart_cruise_control import SmartCruiseControl +from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit_assist.speed_limit_assist import SpeedLimitAssist +from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit_assist.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 +Source = custom.LongitudinalPlanSP.LongitudinalPlanSource class LongitudinalPlannerSP: def __init__(self, CP: structs.CarParams, mpc): self.events_sp = EventsSP() + self.resolver = SpeedLimitResolver() + self.dec = DynamicExperimentalController(CP, mpc) - self.generation = int(model_bundle.generation) if (model_bundle := get_active_bundle()) else None + self.scc = SmartCruiseControl() self.slc = SpeedLimitController(CP) + self.generation = int(model_bundle.generation) if (model_bundle := get_active_bundle()) else None + self.source = Source.cruise @property def mlsim(self) -> bool: @@ -35,16 +41,26 @@ class LongitudinalPlannerSP: return self.dec.mode() - def update_v_cruise(self, sm: messaging.SubMaster, v_ego: float, a_ego: float, v_cruise: float) -> float: + def update_targets(self, sm: messaging.SubMaster, v_ego: float, a_ego: float, v_cruise: float) -> tuple[float, float]: self.events_sp.clear() - self.slc.update(sm, v_ego, a_ego, v_cruise, self.events_sp) + self.scc.update(sm, v_ego, a_ego, v_cruise) - v_cruise_slc = self.slc.speed_limit_offseted if self.slc.is_active else V_CRUISE_UNSET + # Speed Limit Control + self.resolver.update(v_ego, sm) + v_cruise_slc = self.slc.update(sm['carControl'].longActive, v_ego, a_ego, sm['carState'].vCruiseCluster, + self.resolver.speed_limit, self.resolver.distance, self.resolver.source, self.events_sp) - v_cruise_final = min(v_cruise, v_cruise_slc) + targets = { + Source.cruise: (v_cruise, a_ego), + Source.sccVision: (self.scc.vision.output_v_target, self.scc.vision.output_a_target), + Source.sla: (v_cruise_slc, a_ego), + } - return v_cruise_final + self.source = min(targets, key=lambda k: targets[k][0]) + v_target, a_target = targets[self.source] + + return v_target, a_target def update(self, sm: messaging.SubMaster) -> None: self.dec.update(sm) @@ -55,6 +71,7 @@ class LongitudinalPlannerSP: plan_sp_send.valid = sm.all_checks(service_list=['carState', 'controlsState']) longitudinalPlanSP = plan_sp_send.longitudinalPlanSP + longitudinalPlanSP.longitudinalPlanSource = self.source longitudinalPlanSP.events = self.events_sp.to_msg() # Dynamic Experimental Control @@ -63,13 +80,26 @@ class LongitudinalPlannerSP: dec.enabled = self.dec.enabled() dec.active = self.dec.active() + # Smart Cruise Control + smartCruiseControl = longitudinalPlanSP.smartCruiseControl + # Vision Turn Speed 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 + # Speed Limit Control slc = longitudinalPlanSP.slc slc.state = self.slc.state slc.enabled = self.slc.is_enabled slc.active = self.slc.is_active - slc.speedLimit = float(self.slc.speed_limit) + slc.speedLimit = float(self.resolver.speed_limit) slc.speedLimitOffset = float(self.slc.speed_limit_offset) - slc.distToSpeedLimit = float(self.slc.distance) + slc.distToSpeedLimit = float(self.resolver.distance) + slc.source = self.resolver.source pm.send('longitudinalPlanSP', plan_sp_send) diff --git a/sunnypilot/selfdrive/controls/lib/nnlc/nnlc.py b/sunnypilot/selfdrive/controls/lib/nnlc/nnlc.py index 77d46ace34..1738a11e49 100644 --- a/sunnypilot/selfdrive/controls/lib/nnlc/nnlc.py +++ b/sunnypilot/selfdrive/controls/lib/nnlc/nnlc.py @@ -9,6 +9,8 @@ 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 @@ -30,8 +32,8 @@ def roll_pitch_adjust(roll, pitch): class NeuralNetworkLateralControl(LatControlTorqueExtBase): - def __init__(self, lac_torque, CP, CP_SP): - super().__init__(lac_torque, CP, CP_SP) + 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 @@ -57,14 +59,44 @@ class NeuralNetworkLateralControl(LatControlTorqueExtBase): 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.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.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.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.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.enabled or not self.model_valid or not self.has_nn_model: + 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 @@ -128,3 +160,5 @@ class NeuralNetworkLateralControl(LatControlTorqueExtBase): # 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.torque_params) + + self.update_output_torque(CS) diff --git a/sunnypilot/selfdrive/controls/lib/nnlc/tests/test_nnlc.py b/sunnypilot/selfdrive/controls/lib/nnlc/tests/test_nnlc.py index 01ddec68ab..009e3d96af 100644 --- a/sunnypilot/selfdrive/controls/lib/nnlc/tests/test_nnlc.py +++ b/sunnypilot/selfdrive/controls/lib/nnlc/tests/test_nnlc.py @@ -3,6 +3,7 @@ from parameterized import parameterized 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 @@ -41,7 +42,7 @@ def generate_modelV2(): class TestNeuralNetworkLateralControl: - @parameterized.expand([HONDA.HONDA_CIVIC, TOYOTA.TOYOTA_RAV4, HYUNDAI.HYUNDAI_SANTA_CRUZ_1ST_GEN]) + @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) @@ -57,6 +58,7 @@ class TestNeuralNetworkLateralControl: VM = VehicleModel(CP) controller = LatControlTorque(CP.as_reader(), CP_SP.as_reader(), CI) + torque_params = CP.lateralTuning.torque CS = car.CarState.new_message() CS.vEgo = 30 @@ -77,17 +79,23 @@ class TestNeuralNetworkLateralControl: 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) 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) 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) assert lac_log.saturated diff --git a/sunnypilot/selfdrive/controls/lib/param_store.py b/sunnypilot/selfdrive/controls/lib/param_store.py index 2ef3473187..65a0175340 100644 --- a/sunnypilot/selfdrive/controls/lib/param_store.py +++ b/sunnypilot/selfdrive/controls/lib/param_store.py @@ -4,39 +4,41 @@ 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 capnp - from cereal import custom - from opendbc.car import structs from openpilot.common.params import Params +from sunnypilot.sunnylink.utils import get_param_as_byte + class ParamStore: keys: list[str] - values: dict[str, str] + _params: dict[str, custom.CarControlSP.Param] def __init__(self, CP: structs.CarParams): universal_params: list[str] = [] brand_params: list[str] = [] self.keys = universal_params + brand_params - self.values = {} - self.cached_params_list: list[capnp.lib.capnp._DynamicStructBuilder] | None = None + self._params = {} self.frame = 0 def update(self, params: Params) -> None: - if self.frame % 300 == 0: - old_values = dict(self.values) - self.values = {k: params.get(k) or "0" for k in self.keys} - if old_values != self.values: - self.cached_params_list = None - self.frame += 1 + if self.frame % 300 != 0: + return - def publish(self) -> list[capnp.lib.capnp._DynamicStructBuilder]: - if self.cached_params_list is None: - # TODO-SP: Why are we doing a list instead of a dictionary here? - self.cached_params_list = [custom.CarControlSP.Param(key=k, value=self.values[k]) for k in self.keys] - return self.cached_params_list + for key in self.keys: + param_type = params.get_type(key).name.lower() # Using string instead of number because its "loose" dependency, and could change by OP at anytime. + + # Over engineering opportunity: It's possible this conversion is slow, we may check the value as params returns it for cache purposes. Not today. + param_value = get_param_as_byte(key, params) + if (existing_param := self._params.get(key)) is not None and existing_param.value == param_value: + continue + + self._params[key] = custom.CarControlSP.Param(key=key, value=param_value, type=param_type) + + @property + def param_list(self) -> list[custom.CarControlSP.Param]: + return [v for k,v in self._params.items()] 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..e69de29bb2 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..c66c2c392a --- /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 + + +class SmartCruiseControl: + def __init__(self): + self.vision = SmartCruiseControlVision() + + def update(self, sm: messaging.SubMaster, v_ego: float, a_ego: float, v_cruise: float) -> None: + long_enabled = sm['carControl'].enabled + long_override = sm['carControl'].cruiseControl.override + + 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_vision_controller.py b/sunnypilot/selfdrive/controls/lib/smart_cruise_control/tests/test_vision_controller.py new file mode 100644 index 0000000000..9f6efffb55 --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/smart_cruise_control/tests/test_vision_controller.py @@ -0,0 +1,104 @@ +""" +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, 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.vision_controller import SmartCruiseControlVision + +VisionState = custom.LongitudinalPlanSP.SmartCruiseControl.VisionState + + +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 + + # 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..f12a00f23e --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/smart_cruise_control/vision_controller.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. +""" +import numpy as np + +import cereal.messaging as messaging +from cereal import custom +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 + +VisionState = custom.LongitudinalPlanSP.SmartCruiseControl.VisionState + +ACTIVE_STATES = (VisionState.entering, VisionState.turning, VisionState.leaving) +ENABLED_STATES = (VisionState.enabled, VisionState.overriding, *ACTIVE_STATES) + +_MIN_V = 20 * CV.KPH_TO_MS # Do not operate under 20 km/h + +_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.amax(predicted_lat_accels) + + # 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 + 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 + 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_controller/__init__.py b/sunnypilot/selfdrive/controls/lib/speed_limit_assist/__init__.py similarity index 54% rename from sunnypilot/selfdrive/controls/lib/speed_limit_controller/__init__.py rename to sunnypilot/selfdrive/controls/lib/speed_limit_assist/__init__.py index b0a6675491..2ac5b6a6f0 100644 --- a/sunnypilot/selfdrive/controls/lib/speed_limit_controller/__init__.py +++ b/sunnypilot/selfdrive/controls/lib/speed_limit_assist/__init__.py @@ -1,12 +1,10 @@ from cereal import custom -DEBUG = True -PARAMS_UPDATE_PERIOD = 2. # secs. Time between parameter updates. -TEMP_INACTIVE_GUARD_PERIOD = 1. # secs. Time to wait after activation before considering temp deactivation signal. +SpeedLimitAssistState = custom.LongitudinalPlanSP.SpeedLimitAssistState -# Lookup table for speed limit percent offset depending on speed. -LIMIT_PERC_OFFSET_V = [0.1, 0.05, 0.038] # 55, 105, 135 km/h -LIMIT_PERC_OFFSET_BP = [13.9, 27.8, 36.1] # 50, 100, 130 km/h +PARAMS_UPDATE_PERIOD = 3. # secs. Time between parameter updates. +DISABLED_GUARD_PERIOD = 2 # secs. +PRE_ACTIVE_GUARD_PERIOD = 5 # secs. Time to wait after activation before considering temp deactivation signal. # Constants for Limit controllers. LIMIT_ADAPT_ACC = -1. # m/s^2 Ideal acceleration for the adapting (braking) phase when approaching speed limits. @@ -16,4 +14,7 @@ LIMIT_MIN_SPEED = 8.33 # m/s, Minimum speed limit to provide as solution on lim LIMIT_SPEED_OFFSET_TH = -1. # m/s Maximum offset between speed limit and current speed for adapting state. LIMIT_MAX_MAP_DATA_AGE = 10. # s Maximum time to hold to map data, then consider it invalid inside limits controllers. -SpeedLimitControlState = custom.LongitudinalPlanSP.SpeedLimitControlState +# Speed Limit Assist Auto mode constants +REQUIRED_INITIAL_MAX_SET_SPEED = 35.7632 # m/s 80 MPH # TODO-SP: customizable with params +CRUISE_SPEED_TOLERANCE = 0.44704 # m/s ±1 MPH tolerance # TODO-SP: metric vs imperial +FALLBACK_CRUISE_SPEED = 255.0 # m/s fallback when no speed limit available diff --git a/sunnypilot/selfdrive/controls/lib/speed_limit_controller/common.py b/sunnypilot/selfdrive/controls/lib/speed_limit_assist/common.py similarity index 64% rename from sunnypilot/selfdrive/controls/lib/speed_limit_controller/common.py rename to sunnypilot/selfdrive/controls/lib/speed_limit_assist/common.py index 4a460b3335..15e8ab8ad2 100644 --- a/sunnypilot/selfdrive/controls/lib/speed_limit_controller/common.py +++ b/sunnypilot/selfdrive/controls/lib/speed_limit_assist/common.py @@ -1,12 +1,6 @@ from enum import IntEnum -class Source(IntEnum): - none = 0 - car_state = 1 - map_data = 2 - - class Policy(IntEnum): map_data_only = 0 car_state_only = 1 @@ -15,11 +9,7 @@ class Policy(IntEnum): combined = 4 -class Engage(IntEnum): - auto = 0 - - class OffsetType(IntEnum): - default = 0 + off = 0 fixed = 1 percentage = 2 diff --git a/sunnypilot/selfdrive/controls/lib/speed_limit_assist/speed_limit_assist.py b/sunnypilot/selfdrive/controls/lib/speed_limit_assist/speed_limit_assist.py new file mode 100644 index 0000000000..de9ebecaac --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/speed_limit_assist/speed_limit_assist.py @@ -0,0 +1,255 @@ +""" +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 custom +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.selfdrive.controls.lib.speed_limit_assist import PARAMS_UPDATE_PERIOD, LIMIT_SPEED_OFFSET_TH, \ + SpeedLimitAssistState, PRE_ACTIVE_GUARD_PERIOD, REQUIRED_INITIAL_MAX_SET_SPEED, CRUISE_SPEED_TOLERANCE, DISABLED_GUARD_PERIOD +from openpilot.selfdrive.controls.lib.drive_helpers import CONTROL_N +from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit_assist.common import OffsetType +from openpilot.sunnypilot.selfdrive.selfdrived.events import EventsSP +from openpilot.selfdrive.modeld.constants import ModelConstants + +EventNameSP = custom.OnroadEventSP.EventName +SpeedLimitSource = custom.LongitudinalPlanSP.SpeedLimitSource + +ACTIVE_STATES = (SpeedLimitAssistState.active, SpeedLimitAssistState.adapting) +ENABLED_STATES = (SpeedLimitAssistState.preActive, SpeedLimitAssistState.pending, *ACTIVE_STATES) + + +class SpeedLimitAssist: + _speed_limit: float + _distance: float + _source: custom.LongitudinalPlanSP.SpeedLimitSource + v_ego: float + a_ego: float + v_offset: float + last_valid_speed_limit_final: float + + def __init__(self, CP): + self.params = Params() + self.CP = CP + self.frame = -1 + self.long_engaged_timer = 0 + self.pre_active_timer = 0 + self.is_metric = self.params.get_bool("IsMetric") + self.enabled = self.params.get_bool("SpeedLimitAssist") + self.op_engaged = False + self.op_engaged_prev = False + self.is_enabled = False + self.is_active = False + self.v_ego = 0. + self.a_ego = 0. + self.v_offset = 0. + self.v_cruise_setpoint = 0. + self.v_cruise_setpoint_prev = 0. + self.initial_max_set = False + self._speed_limit = 0. + self.speed_limit_prev = 0. + self.last_valid_speed_limit_final = 0. + self._distance = 0. + self._source = SpeedLimitSource.none + self.state = SpeedLimitAssistState.disabled + self._state_prev = SpeedLimitAssistState.disabled + self.pcm_cruise_op_long = CP.openpilotLongitudinalControl and CP.pcmCruise + + self.offset_type = OffsetType(self.params.get("SpeedLimitOffsetType", return_default=True)) + self.offset_value = self.params.get("SpeedLimitValueOffset", return_default=True) + + # 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_final(self) -> float: + return self._speed_limit + self.speed_limit_offset + + @property + def speed_limit_changed(self) -> bool: + return bool(self._speed_limit != self.speed_limit_prev) + + @property + def speed_limit_offset(self) -> float: + return self.get_offset(self.offset_type, self.offset_value) + + @property + def v_cruise_setpoint_changed(self) -> bool: + return bool(self.v_cruise_setpoint != self.v_cruise_setpoint_prev) + + def get_v_target_from_control(self) -> float: + if self.is_enabled: + # If we have a current valid speed limit, use it + if self._speed_limit > 0: + self.last_valid_speed_limit_final = self.speed_limit_final + return self.speed_limit_final + + # If no current speed limit but we have a last valid one, use that + if self.last_valid_speed_limit_final > 0: + return self.last_valid_speed_limit_final + + # Fallback + return V_CRUISE_UNSET + + def get_offset(self, offset_type: OffsetType, offset_value: int) -> float: + if offset_type == OffsetType.off: + return 0 + elif offset_type == OffsetType.fixed: + return offset_value * (CV.KPH_TO_MS if self.is_metric else CV.MPH_TO_MS) + elif offset_type == OffsetType.percentage: + return offset_value * 0.01 * self._speed_limit + else: + raise NotImplementedError("Offset not supported") + + def update_params(self) -> None: + if self.frame % int(PARAMS_UPDATE_PERIOD / DT_MDL) == 0: + self.enabled = self.params.get_bool("SpeedLimitAssist") + self.offset_type = OffsetType(self.params.get("SpeedLimitOffsetType", return_default=True)) + self.offset_value = self.params.get("SpeedLimitValueOffset", return_default=True) + self.is_metric = self.params.get_bool("IsMetric") + + def initial_max_set_confirmed(self) -> bool: + return bool(abs(self.v_cruise_setpoint - REQUIRED_INITIAL_MAX_SET_SPEED) <= CRUISE_SPEED_TOLERANCE) + + def detect_manual_cruise_change(self) -> bool: + # If cruise speed changed and it's not what SLC would set + if self.v_cruise_setpoint_changed: + expected_cruise = self.speed_limit_final + return bool(abs(self.v_cruise_setpoint - expected_cruise) > CRUISE_SPEED_TOLERANCE) + + return False + + def update_calculations(self, v_cruise_setpoint: float) -> None: + self.v_cruise_setpoint = v_cruise_setpoint if not np.isnan(v_cruise_setpoint) else 0.0 + + # Update current velocity offset (error) + self.v_offset = self.speed_limit_final - self.v_ego + + 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 ** 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_state_machine(self): + self._state_prev = self.state + + 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.op_engaged or not self.enabled: + self.state = SpeedLimitAssistState.disabled + self.initial_max_set = False + + else: + # ACTIVE + if self.state == SpeedLimitAssistState.active: + if self.detect_manual_cruise_change(): + self.state = SpeedLimitAssistState.inactive + elif self._speed_limit > 0 and self.v_offset < LIMIT_SPEED_OFFSET_TH: + self.state = SpeedLimitAssistState.adapting + + # ADAPTING + elif self.state == SpeedLimitAssistState.adapting: + if self.detect_manual_cruise_change(): + self.state = SpeedLimitAssistState.inactive + elif self.v_offset >= LIMIT_SPEED_OFFSET_TH: + self.state = SpeedLimitAssistState.active + + # PENDING + elif self.state == SpeedLimitAssistState.pending: + if self._speed_limit > 0: + if self.v_offset < LIMIT_SPEED_OFFSET_TH: + self.state = SpeedLimitAssistState.adapting + else: + self.state = SpeedLimitAssistState.active + + # PRE_ACTIVE + elif self.state == SpeedLimitAssistState.preActive: + if self.initial_max_set_confirmed(): + self.initial_max_set = True + if self._speed_limit > 0: + if self.v_offset < LIMIT_SPEED_OFFSET_TH: + self.state = SpeedLimitAssistState.adapting + else: + self.state = SpeedLimitAssistState.active + else: + self.state = SpeedLimitAssistState.pending + elif self.pre_active_timer <= PRE_ACTIVE_GUARD_PERIOD: + # Timeout - session ended + self.state = SpeedLimitAssistState.inactive + + # INACTIVE + elif self.state == SpeedLimitAssistState.inactive: + pass + + # DISABLED + elif self.state == SpeedLimitAssistState.disabled: + if self.op_engaged and self.enabled: + if not self.op_engaged_prev: + self.pre_active_timer = int(DISABLED_GUARD_PERIOD / DT_MDL) + + elif self.pre_active_timer <= 0: + self.state = SpeedLimitAssistState.preActive + self.pre_active_timer = int(PRE_ACTIVE_GUARD_PERIOD / DT_MDL) + self.initial_max_set = False + + 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 and self._state_prev != SpeedLimitAssistState.preActive: + events_sp.add(EventNameSP.speedLimitPreActive) + elif self.is_active: + if self._state_prev not in ACTIVE_STATES: + events_sp.add(EventNameSP.speedLimitActive) + elif self.speed_limit_changed: + events_sp.add(EventNameSP.speedLimitChanged) + + def update(self, long_active: bool, v_ego: float, a_ego: float, v_cruise_setpoint: float, + speed_limit: float, distance: float, source: custom.LongitudinalPlanSP.SpeedLimitSource, events_sp: EventsSP) -> float: + self.op_engaged = long_active + self.v_ego = v_ego + self.a_ego = a_ego + + self._speed_limit = speed_limit + self._distance = distance + self._source = source + + self.update_params() + self.update_calculations(v_cruise_setpoint) + self.is_enabled, self.is_active = self.update_state_machine() + self.update_events(events_sp) + + # Update change tracking variables + self.speed_limit_prev = self._speed_limit + self.v_cruise_setpoint_prev = self.v_cruise_setpoint + self.op_engaged_prev = self.op_engaged + self.frame += 1 + + v_target = self.get_v_target_from_control() + + return v_target diff --git a/sunnypilot/selfdrive/controls/lib/speed_limit_assist/speed_limit_resolver.py b/sunnypilot/selfdrive/controls/lib/speed_limit_assist/speed_limit_resolver.py new file mode 100644 index 0000000000..ddc3252992 --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/speed_limit_assist/speed_limit_resolver.py @@ -0,0 +1,132 @@ +import time +import numpy as np + +import cereal.messaging as messaging +from cereal import custom +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.selfdrive.controls.lib.speed_limit_assist import LIMIT_MAX_MAP_DATA_AGE, LIMIT_ADAPT_ACC, PARAMS_UPDATE_PERIOD +from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit_assist.common import Policy + +SpeedLimitSource = custom.LongitudinalPlanSP.SpeedLimitSource + +ALL_SOURCES = tuple(SpeedLimitSource.schema.enumerants.values()) + + +class SpeedLimitResolver: + _limit_solutions: dict[custom.LongitudinalPlanSP.SpeedLimitSource, float] + _distance_solutions: dict[custom.LongitudinalPlanSP.SpeedLimitSource, float] + _v_ego: float + speed_limit: float + distance: float + source: custom.LongitudinalPlanSP.SpeedLimitSource + + 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("SpeedLimitAssistPolicy", return_default=True) + self._policy_to_sources_map = { + Policy.car_state_only: [SpeedLimitSource.car], + Policy.car_state_priority: [SpeedLimitSource.car, SpeedLimitSource.map], + Policy.map_data_priority: [SpeedLimitSource.map, SpeedLimitSource.car], + Policy.map_data_only: [SpeedLimitSource.map], + Policy.combined: [SpeedLimitSource.car, SpeedLimitSource.map], + } + for source in ALL_SOURCES: + self._reset_limit_sources(source) + + def update_params(self): + if self.frame % int(PARAMS_UPDATE_PERIOD / DT_MDL) == 0: + self.policy = Policy(self.params.get("SpeedLimitAssistPolicy", return_default=True)) + self.change_policy(self.policy) + + def change_policy(self, policy: Policy) -> None: + self.policy = policy + + def _reset_limit_sources(self, source: custom.LongitudinalPlanSP.SpeedLimitSource) -> None: + self._limit_solutions[source] = 0. + self._distance_solutions[source] = 0. + + def _resolve_limit_sources(self, sm: messaging.SubMaster) -> None: + """Get limit solutions from each data source""" + self._get_from_car_state(sm) + self._get_from_map_data(sm) + + 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. + + 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 _consolidate(self) -> tuple[float, float, custom.LongitudinalPlanSP.SpeedLimitSource]: + 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 _get_source_solution_according_to_policy(self) -> custom.LongitudinalPlanSP.SpeedLimitSource: + 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: + return source if self._limit_solutions[source] > 0. else SpeedLimitSource.none + + limits = np.array([self._limit_solutions[source] for source in sources_for_policy], dtype=float) + sources = np.array(sources_for_policy, dtype=int) + + if len(limits) > 0: + min_idx = np.argmin(limits) + return sources[min_idx] + + return SpeedLimitSource.none + + def update(self, v_ego: float, sm: messaging.SubMaster) -> None: + self._v_ego = v_ego + self.update_params() + self._resolve_limit_sources(sm) + + self.speed_limit, self.distance, self.source = self._consolidate() + + self.frame += 1 diff --git a/sunnypilot/selfdrive/controls/lib/speed_limit_assist/tests/__init__.py b/sunnypilot/selfdrive/controls/lib/speed_limit_assist/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sunnypilot/selfdrive/controls/lib/speed_limit_assist/tests/test_speed_limit_assist.py b/sunnypilot/selfdrive/controls/lib/speed_limit_assist/tests/test_speed_limit_assist.py new file mode 100644 index 0000000000..fa17494d2e --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/speed_limit_assist/tests/test_speed_limit_assist.py @@ -0,0 +1,247 @@ +""" +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.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.selfdrive.car import interfaces as sunnypilot_interfaces +from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit_assist.common import OffsetType +from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit_assist import SpeedLimitAssistState, REQUIRED_INITIAL_MAX_SET_SPEED, \ + PRE_ACTIVE_GUARD_PERIOD +from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit_assist.speed_limit_assist import SpeedLimitAssist, ACTIVE_STATES +from openpilot.sunnypilot.selfdrive.selfdrived.events import EventsSP + +SpeedLimitSource = custom.LongitudinalPlanSP.SpeedLimitSource + +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 +} + + +class TestSpeedLimitAssist: + + def setup_method(self): + self.params = Params() + self.reset_custom_params() + self.events_sp = EventsSP() + CI = self._setup_platform(TOYOTA.TOYOTA_RAV4_TSS2_2022) + self.sla = SpeedLimitAssist(CI.CP) + self.sla.pre_active_timer = int(PRE_ACTIVE_GUARD_PERIOD / DT_MDL) + + 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) + sunnypilot_interfaces.setup_interfaces(CI, self.params) + return CI + + def reset_custom_params(self): + self.params.put_bool("SpeedLimitAssist", True) + 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.initial_max_set = 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.sla._source = SpeedLimitSource.none + self.sla.v_cruise_setpoint = 0. + self.sla.v_cruise_setpoint_prev = 0. + self.events_sp.clear() + + def initialize_active_state(self, v_cruise_setpoint): + self.sla.state = SpeedLimitAssistState.active + self.sla.v_cruise_setpoint = v_cruise_setpoint + self.sla.v_cruise_setpoint_prev = v_cruise_setpoint + + 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() + + def test_disabled(self): + self.params.put_bool("SpeedLimitAssist", False) + for _ in range(int(10. / DT_MDL)): + _ = self.sla.update(True, SPEED_LIMITS['city'], 0, SPEED_LIMITS['highway'], SPEED_LIMITS['city'], 0, SpeedLimitSource.car, 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, SPEED_LIMITS['city'], 0, SPEED_LIMITS['highway'], SPEED_LIMITS['city'], 0, SpeedLimitSource.car, self.events_sp) + assert self.sla.state == SpeedLimitAssistState.preActive + 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 + v_cruise_sla = self.sla.update(True, SPEED_LIMITS['city'], 0, REQUIRED_INITIAL_MAX_SET_SPEED, SPEED_LIMITS['city'], 0, SpeedLimitSource.car, self.events_sp) + assert self.sla.state == SpeedLimitAssistState.active + assert self.sla.is_enabled and self.sla.is_active + assert v_cruise_sla == SPEED_LIMITS['city'] + + def test_preactive_timeout_to_inactive(self): + self.sla.state = SpeedLimitAssistState.preActive + _ = self.sla.update(True, SPEED_LIMITS['city'], 0, SPEED_LIMITS['highway'], SPEED_LIMITS['city'], 0, SpeedLimitSource.car, self.events_sp) + + for _ in range(int(PRE_ACTIVE_GUARD_PERIOD / DT_MDL)): + _ = self.sla.update(True, SPEED_LIMITS['city'], 0, SPEED_LIMITS['highway'], SPEED_LIMITS['city'], 0, SpeedLimitSource.car, 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, SPEED_LIMITS['city'], 0, REQUIRED_INITIAL_MAX_SET_SPEED, 0, 0, SpeedLimitSource.none, 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.update(True, SPEED_LIMITS['city'], 0, REQUIRED_INITIAL_MAX_SET_SPEED, SPEED_LIMITS['city'], 0, SpeedLimitSource.car, 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.update(True, SPEED_LIMITS['city'] + 5, 0, REQUIRED_INITIAL_MAX_SET_SPEED, SPEED_LIMITS['city'], 0, SpeedLimitSource.car, 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(REQUIRED_INITIAL_MAX_SET_SPEED) + + _ = self.sla.update(True, SPEED_LIMITS['city'] + 2, 0, REQUIRED_INITIAL_MAX_SET_SPEED, SPEED_LIMITS['city'], 0, SpeedLimitSource.car, 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_setpoint_prev = REQUIRED_INITIAL_MAX_SET_SPEED + + _ = self.sla.update(True, SPEED_LIMITS['city'], 0, REQUIRED_INITIAL_MAX_SET_SPEED, SPEED_LIMITS['city'], 0, SpeedLimitSource.car, 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_setpoint_prev = expected_cruise + + different_cruise = SPEED_LIMITS['highway'] + 5 + _ = self.sla.update(True, SPEED_LIMITS['city'], 0, different_cruise, SPEED_LIMITS['city'], 0, SpeedLimitSource.car, self.events_sp) + assert self.sla.state == SpeedLimitAssistState.inactive + + @pytest.mark.parametrize("offset_type, offset_value, speed_limit, expected_offset", [ + (OffsetType.fixed, 5, SPEED_LIMITS['city'], 5 * CV.MPH_TO_MS), # 5 MPH fixed offset + (OffsetType.percentage, 10, SPEED_LIMITS['city'], 0.1 * SPEED_LIMITS['city']), # 10% offset + (OffsetType.off, 0, SPEED_LIMITS['city'], 0), # Off + (OffsetType.fixed, 10, SPEED_LIMITS['highway'], 10 * CV.MPH_TO_MS), # Different speed, fixed offset + (OffsetType.percentage, 5, SPEED_LIMITS['highway'], 0.05 * SPEED_LIMITS['highway']), # Different speed, percentage + ]) + def test_offset_calculations(self, offset_type, offset_value, speed_limit, expected_offset): + self.sla._speed_limit = speed_limit + actual_offset = self.sla.get_offset(offset_type, offset_value) + assert actual_offset == pytest.approx(expected_offset, rel=0.01) + + def test_rapid_speed_limit_changes(self): + self.initialize_active_state(REQUIRED_INITIAL_MAX_SET_SPEED) + speed_limits = [SPEED_LIMITS['city'], SPEED_LIMITS['highway'], SPEED_LIMITS['residential']] + + for _, speed_limit in enumerate(speed_limits): + _ = self.sla.update(True, speed_limit, 0, REQUIRED_INITIAL_MAX_SET_SPEED, speed_limit, 0, SpeedLimitSource.car, self.events_sp) + assert self.sla.state in ACTIVE_STATES + + def test_invalid_speed_limits_handling(self): + self.initialize_active_state(REQUIRED_INITIAL_MAX_SET_SPEED) + self.sla.last_valid_speed_limit_final = SPEED_LIMITS['city'] + + invalid_limits = [-10, 0, 200 * CV.MPH_TO_MS] + + for invalid_limit in invalid_limits: + v_cruise_sla = self.sla.update(True, SPEED_LIMITS['city'], 0, REQUIRED_INITIAL_MAX_SET_SPEED, invalid_limit, 0, SpeedLimitSource.car, self.events_sp) + assert isinstance(v_cruise_sla, (int, float)) + assert v_cruise_sla == V_CRUISE_UNSET or v_cruise_sla > 0 + + def test_stale_data_handling(self): + self.initialize_active_state(REQUIRED_INITIAL_MAX_SET_SPEED) + old_speed_limit = SPEED_LIMITS['city'] + self.sla.last_valid_speed_limit_final = old_speed_limit + + v_cruise_sla = self.sla.update(True, SPEED_LIMITS['city'], 0, REQUIRED_INITIAL_MAX_SET_SPEED, 0, 0, SpeedLimitSource.car, self.events_sp) + assert self.sla.state in ACTIVE_STATES + assert v_cruise_sla == old_speed_limit + + def test_different_speed_limit_sources(self): + self.initialize_active_state(REQUIRED_INITIAL_MAX_SET_SPEED) + + for source in (SpeedLimitSource.car, SpeedLimitSource.map): + v_cruise_sla = self.sla.update(True, SPEED_LIMITS['city'], 0, REQUIRED_INITIAL_MAX_SET_SPEED, SPEED_LIMITS['city'], 0, source, self.events_sp) + assert v_cruise_sla != V_CRUISE_UNSET + + def test_distance_based_adapting(self): + self.sla.state = SpeedLimitAssistState.adapting + self.sla.v_cruise_setpoint_prev = REQUIRED_INITIAL_MAX_SET_SPEED + + distance = 100.0 + current_speed = SPEED_LIMITS['highway'] + target_speed = SPEED_LIMITS['city'] + + v_cruise_sla = self.sla.update(True, current_speed, 0, REQUIRED_INITIAL_MAX_SET_SPEED, target_speed, distance, SpeedLimitSource.map, self.events_sp) + assert self.sla.state == SpeedLimitAssistState.adapting + assert v_cruise_sla == target_speed # TODO-SP: assert expected accel, need to enable self.acceleration_solutions + + def test_long_disengaged_to_disabled(self): + self.initialize_active_state(REQUIRED_INITIAL_MAX_SET_SPEED) + + v_cruise_sla = self.sla.update(False, SPEED_LIMITS['city'], 0, REQUIRED_INITIAL_MAX_SET_SPEED, SPEED_LIMITS['city'], + 0, SpeedLimitSource.car, self.events_sp) + assert self.sla.state == SpeedLimitAssistState.disabled + assert v_cruise_sla == 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 + if state in [SpeedLimitAssistState.pending, SpeedLimitAssistState.active, SpeedLimitAssistState.adapting]: + self.sla.initial_max_set = True + + initial_state = state + + _ = self.sla.update(True, SPEED_LIMITS['city'], 0, REQUIRED_INITIAL_MAX_SET_SPEED,SPEED_LIMITS['city'], 0, SpeedLimitSource.car, 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_controller/tests/test_speed_limit_resolver.py b/sunnypilot/selfdrive/controls/lib/speed_limit_assist/tests/test_speed_limit_resolver.py similarity index 59% rename from sunnypilot/selfdrive/controls/lib/speed_limit_controller/tests/test_speed_limit_resolver.py rename to sunnypilot/selfdrive/controls/lib/speed_limit_assist/tests/test_speed_limit_resolver.py index 62a9329958..14433dc293 100644 --- a/sunnypilot/selfdrive/controls/lib/speed_limit_controller/tests/test_speed_limit_resolver.py +++ b/sunnypilot/selfdrive/controls/lib/speed_limit_assist/tests/test_speed_limit_resolver.py @@ -4,11 +4,13 @@ import time import pytest from pytest_mock import MockerFixture -from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit_controller import LIMIT_MAX_MAP_DATA_AGE +from cereal import custom +from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit_assist import LIMIT_MAX_MAP_DATA_AGE -# from selfdrive.controls.lib.speed_limit_controller_tbd import SpeedLimitResolver as OriginalSpeedLimitResolver -from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit_controller.speed_limit_resolver import SpeedLimitResolver as RefactoredSpeedLimitResolver -from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit_controller.common import Source, Policy +from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit_assist.speed_limit_resolver import SpeedLimitResolver, ALL_SOURCES +from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit_assist.common import Policy + +SpeedLimitSource = custom.LongitudinalPlanSP.SpeedLimitSource def create_mock(properties, mocker: MockerFixture): @@ -52,79 +54,85 @@ def setup_sm_mock(mocker: MockerFixture): parametrized_policies = pytest.mark.parametrize( "policy, sm_key, function_key", [ - (Policy.car_state_only, 'carStateSP', 'car_state'), - (Policy.car_state_priority, 'carStateSP', 'car_state'), - (Policy.map_data_only, 'liveMapDataSP', 'map_data'), - (Policy.map_data_priority, 'liveMapDataSP', 'map_data'), + (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", [RefactoredSpeedLimitResolver], ids=["Refactored"]) +@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(policy) - for source in Source: + 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(policy) + resolver = resolver_class() + resolver.policy = policy sm_mock = setup_sm_mock(mocker) source_speed_limit = sm_mock[sm_key].speedLimit # Assert the resolver - speed_limit, _, source = resolver.resolve(source_speed_limit, 0, sm_mock) - assert speed_limit == source_speed_limit - assert source == Source[function_key] + 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(Policy.combined) + resolver = resolver_class() + resolver.policy = Policy.combined sm_mock = setup_sm_mock(mocker) - socket_to_source = {'carStateSP': Source.car_state, 'liveMapDataSP': Source.map_data} + 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 - speed_limit, _, source = resolver.resolve(minimum_speed_limit, 0, sm_mock) - assert speed_limit == minimum_speed_limit - assert source == socket_to_source[minimum_key] + 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(policy) + resolver = resolver_class() + resolver.policy = policy sm_mock = setup_sm_mock(mocker) source_speed_limit = sm_mock[sm_key].speedLimit # Assert the parsing - speed_limit, _, source = resolver.resolve(source_speed_limit, 0, sm_mock) - assert resolver._limit_solutions[Source[function_key]] == source_speed_limit - assert resolver._distance_solutions[Source[function_key]] == 0. + 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(policy) + resolver = resolver_class() + resolver.policy = policy sm_mock = setup_sm_mock(mocker) - _speed_limit, _distance, _source = resolver.resolve(v_ego, 0, sm_mock) + resolver.update(v_ego, sm_mock) # After resolution - assert _speed_limit is not None - assert _distance is not None - assert _source is not None + 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(policy) + 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[Source.map_data] == 0. - assert resolver._distance_solutions[Source.map_data] == 0. + assert resolver._limit_solutions[SpeedLimitSource.map] == 0. + assert resolver._distance_solutions[SpeedLimitSource.map] == 0. diff --git a/sunnypilot/selfdrive/controls/lib/speed_limit_controller/helpers.py b/sunnypilot/selfdrive/controls/lib/speed_limit_controller/helpers.py deleted file mode 100644 index b15c63bf05..0000000000 --- a/sunnypilot/selfdrive/controls/lib/speed_limit_controller/helpers.py +++ /dev/null @@ -1,19 +0,0 @@ -from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit_controller import DEBUG, SpeedLimitControlState -from openpilot.common.swaglog import cloudlog - - -def debug(msg): - if not DEBUG: - return - cloudlog.debug(msg) - - -def description_for_state(speed_limit_control_state): - if speed_limit_control_state == SpeedLimitControlState.inactive: - return 'INACTIVE' - if speed_limit_control_state == SpeedLimitControlState.tempInactive: - return 'TEMP_INACTIVE' - if speed_limit_control_state == SpeedLimitControlState.adapting: - return 'ADAPTING' - if speed_limit_control_state == SpeedLimitControlState.active: - return 'ACTIVE' diff --git a/sunnypilot/selfdrive/controls/lib/speed_limit_controller/speed_limit_controller.py b/sunnypilot/selfdrive/controls/lib/speed_limit_controller/speed_limit_controller.py deleted file mode 100644 index ab94e64ca2..0000000000 --- a/sunnypilot/selfdrive/controls/lib/speed_limit_controller/speed_limit_controller.py +++ /dev/null @@ -1,284 +0,0 @@ -import numpy as np -import time - -from cereal import messaging, custom -from openpilot.common.constants import CV -from openpilot.common.params import Params -from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit_controller import LIMIT_PERC_OFFSET_BP, LIMIT_PERC_OFFSET_V, \ - PARAMS_UPDATE_PERIOD, TEMP_INACTIVE_GUARD_PERIOD, LIMIT_SPEED_OFFSET_TH, SpeedLimitControlState -from openpilot.selfdrive.controls.lib.drive_helpers import CONTROL_N -from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit_controller.common import Source, Policy, Engage, OffsetType -from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit_controller.helpers import description_for_state, debug -from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit_controller.speed_limit_resolver import SpeedLimitResolver -from openpilot.sunnypilot.selfdrive.selfdrived.events import EventsSP -from openpilot.selfdrive.modeld.constants import ModelConstants - -EventNameSP = custom.OnroadEventSP.EventName - -ACTIVE_STATES = (SpeedLimitControlState.active, SpeedLimitControlState.adapting) -ENABLED_STATES = (SpeedLimitControlState.preActive, SpeedLimitControlState.tempInactive, *ACTIVE_STATES) - - -class SpeedLimitController: - _speed_limit: float - _distance: float - _source: Source - _v_ego: float - _a_ego: float - _v_offset: float - - def __init__(self, CP): - self.params = Params() - self._CP = CP - self._policy = self.params.get("SpeedLimitControlPolicy", return_default=True) - self._resolver = SpeedLimitResolver(self._policy) - self._last_params_update = 0.0 - self._last_op_engaged_time = 0.0 - self._is_metric = self.params.get_bool("IsMetric") - self._enabled = self.params.get_bool("SpeedLimitControl") - self._op_engaged = False - self._op_engaged_prev = False - self._v_ego = 0. - self._a_ego = 0. - self._v_offset = 0. - self._v_cruise_setpoint = 0. - self._v_cruise_setpoint_prev = 0. - self._v_cruise_setpoint_changed = False - self._speed_limit = 0. - self._speed_limit_prev = 0. - self._speed_limit_changed = False - self._distance = 0. - self._source = Source.none - self._state = SpeedLimitControlState.inactive - self._state_prev = SpeedLimitControlState.inactive - self._gas_pressed = False - self._pcm_cruise_op_long = CP.openpilotLongitudinalControl and CP.pcmCruise - - self._offset_type = OffsetType(self.params.get("SpeedLimitWarningValueOffset", return_default=True)) - self._offset_value = self.params.get("SpeedLimitValueOffset", return_default=True) - self._warning_type = self.params.get("SpeedLimitWarningType", return_default=True) - self._warning_offset_type = OffsetType(self.params.get("SpeedLimitWarningOffsetType", return_default=True)) - self._warning_offset_value = self.params.get("SpeedLimitWarningValueOffset", return_default=True) - self._engage_type = self._read_engage_type_param() - self._current_time = 0. - self._v_cruise_rounded = 0. - self._v_cruise_prev_rounded = 0. - self._speed_limit_offsetted_rounded = 0. - self._speed_limit_warning_offsetted_rounded = 0. - self._speed_factor = CV.MS_TO_KPH if self._is_metric else CV.MS_TO_MPH - - # Mapping functions to state transitions - self.state_transition_strategy = { - # Transition functions for each state - SpeedLimitControlState.inactive: self.transition_state_from_inactive, - SpeedLimitControlState.tempInactive: self.transition_state_from_temp_inactive, - SpeedLimitControlState.adapting: self.transition_state_from_adapting, - SpeedLimitControlState.active: self.transition_state_from_active, - SpeedLimitControlState.preActive: self.transition_state_from_pre_active, - } - - # FIXME-SP: unused? - # Solution functions mapped to respective states - self.acceleration_solutions = { - # Solution functions for each state - SpeedLimitControlState.tempInactive: self.get_current_acceleration_as_target, - SpeedLimitControlState.inactive: self.get_current_acceleration_as_target, - SpeedLimitControlState.adapting: self.get_adapting_state_target_acceleration, - SpeedLimitControlState.active: self.get_active_state_target_acceleration, - SpeedLimitControlState.preActive: self.get_current_acceleration_as_target, - } - - @property - def state(self) -> SpeedLimitControlState: - return self._state - - @state.setter - def state(self, value) -> None: - if value != self._state: - debug(f'Speed Limit Controller state: {description_for_state(value)}') - - if value == SpeedLimitControlState.tempInactive: - # Reset previous speed limit to current value as to prevent going out of tempInactive in - # a single cycle when the speed limit changes at the same time the user has temporarily deactivated it. - self._speed_limit_prev = self._speed_limit - - self._state = value - - @property - def is_enabled(self) -> bool: - return self.state in ENABLED_STATES and self._enabled - - @property - def is_active(self) -> bool: - return self.state in ACTIVE_STATES and self._enabled - - @property - def speed_limit_offseted(self) -> float: - return self._speed_limit + self.speed_limit_offset - - @property - def speed_limit_offset(self) -> float: - return self._get_offset(self._offset_type, self._offset_value) - - @property - def speed_limit_warning_offset(self) -> float: - return self._get_offset(self._warning_offset_type, self._warning_offset_value) - - @property - def speed_limit(self) -> float: - return self._speed_limit - - @property - def distance(self) -> float: - return self._distance - - @property - def source(self) -> Source: - return self._source - - def _get_offset(self, offset_type: OffsetType, offset_value: int) -> float: - if offset_type == OffsetType.default: - return float(np.interp(self._speed_limit, LIMIT_PERC_OFFSET_BP, LIMIT_PERC_OFFSET_V) * self._speed_limit) - elif offset_type == OffsetType.fixed: - return offset_value * (CV.KPH_TO_MS if self._is_metric else CV.MPH_TO_MS) - elif offset_type == OffsetType.percentage: - return offset_value * 0.01 * self._speed_limit - else: - raise NotImplementedError("Offset not supported") - - def _update_v_cruise_setpoint_prev(self) -> None: - self._v_cruise_setpoint_prev = self._v_cruise_setpoint - - def _update_params(self) -> None: - if self._current_time > self._last_params_update + PARAMS_UPDATE_PERIOD: - self._enabled = self.params.get_bool("SpeedLimitControl") - self._offset_type = OffsetType(self.params.get("SpeedLimitWarningValueOffset", return_default=True)) - self._offset_value = self.params.get("SpeedLimitValueOffset", return_default=True) - self._warning_type = self.params.get("SpeedLimitWarningType", return_default=True) - self._warning_offset_type = OffsetType(self.params.get("SpeedLimitWarningOffsetType", return_default=True)) - self._warning_offset_value = self.params.get("SpeedLimitWarningValueOffset", return_default=True) - self._policy = Policy(self.params.get("SpeedLimitControlPolicy", return_default=True)) - self._is_metric = self.params.get_bool("IsMetric") - self._speed_factor = CV.MS_TO_KPH if self._is_metric else CV.MS_TO_MPH - self._resolver.change_policy(self._policy) - self._engage_type = self._read_engage_type_param() - - self._last_params_update = self._current_time - - def _read_engage_type_param(self) -> Engage: - if self._pcm_cruise_op_long: - return Engage.auto - - return Engage.auto - - def _update_calculations(self) -> None: - # Update current velocity offset (error) - self._v_offset = self.speed_limit_offseted - self._v_ego - - # Track the time op becomes active to prevent going to tempInactive right away after - # op enabling since controlsd will change the cruise speed every time on enabling and this will - # cause a temp inactive transition if the controller is updated before controlsd sets actual cruise - # speed. - if not self._op_engaged_prev and self._op_engaged: - self._last_op_engaged_time = self._current_time - - # Update change tracking variables - self._speed_limit_changed = self._speed_limit != self._speed_limit_prev - self._v_cruise_setpoint_changed = self._v_cruise_setpoint != self._v_cruise_setpoint_prev - self._speed_limit_prev = self._speed_limit - self._update_v_cruise_setpoint_prev() # always for Engage.auto - self._op_engaged_prev = self._op_engaged - - self._v_cruise_rounded = int(round(self._v_cruise_setpoint * self._speed_factor)) - self._v_cruise_prev_rounded = int(round(self._v_cruise_setpoint_prev * self._speed_factor)) - self._speed_limit_offsetted_rounded = 0 if self._speed_limit == 0 else int(round((self._speed_limit + self.speed_limit_offset) * self._speed_factor)) - self._speed_limit_warning_offsetted_rounded = 0 if self._speed_limit == 0 else \ - int(round((self._speed_limit + self.speed_limit_warning_offset) * self._speed_factor)) - - def transition_state_from_inactive(self) -> None: - """ Make state transition from inactive state """ - if self._engage_type == Engage.auto: - if self._v_offset < LIMIT_SPEED_OFFSET_TH: - self.state = SpeedLimitControlState.adapting - else: - self.state = SpeedLimitControlState.active - - def transition_state_from_temp_inactive(self) -> None: - """ Make state transition from temporary inactive state """ - if self._speed_limit_changed: - if self._engage_type == Engage.auto: - self.state = SpeedLimitControlState.inactive - - def transition_state_from_pre_active(self) -> None: - """ Make state transition from preActive state """ - - def transition_state_from_adapting(self) -> None: - """ Make state transition from adapting state """ - if self._v_offset >= LIMIT_SPEED_OFFSET_TH: - self.state = SpeedLimitControlState.active - - def transition_state_from_active(self) -> None: - """ Make state transition from active state """ - if self._engage_type == Engage.auto: - if self._v_offset < LIMIT_SPEED_OFFSET_TH: - self.state = SpeedLimitControlState.adapting - - def _state_transition(self) -> None: - self._state_prev = self._state - - # In any case, if op is disabled, or speed limit control is disabled or no valid speed limit - # or gas is pressed, deactivate. - if not self._op_engaged or not self._enabled or self._speed_limit == 0: - self.state = SpeedLimitControlState.inactive - return - - # In any case, we deactivate the speed limit controller temporarily if the user changes the cruise speed. - # Ignore if a minimum amount of time has not passed since activation. This is to prevent temp inactivations - # due to controlsd logic changing cruise setpoint when going active. - if self._engage_type == Engage.auto and self._v_cruise_setpoint_changed and \ - self._current_time > (self._last_op_engaged_time + TEMP_INACTIVE_GUARD_PERIOD): - self.state = SpeedLimitControlState.tempInactive - return - - self.state_transition_strategy[self.state]() - - self._update_v_cruise_setpoint_prev() # always for Engage.auto - - def get_current_acceleration_as_target(self) -> float: - """ When state is inactive or tempInactive, preserve current acceleration """ - return self._a_ego - - def get_adapting_state_target_acceleration(self) -> float: - """ In adapting state, calculate target acceleration based on speed limit and current velocity """ - if self.distance > 0: - return (self.speed_limit_offseted ** 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: - """ In active state, aim to keep speed constant around control time horizon """ - return self._v_offset / float(ModelConstants.T_IDXS[CONTROL_N]) - - def _update_events(self, events_sp: EventsSP) -> None: - if self.is_active: - if self._engage_type == Engage.auto: - if self._state_prev not in ACTIVE_STATES: - events_sp.add(EventNameSP.speedLimitActive) - elif self._speed_limit_changed != 0: - events_sp.add(EventNameSP.speedLimitValueChange) - - def update(self, sm: messaging.SubMaster, v_ego: float, a_ego: float, v_cruise_setpoint: float, events_sp: EventsSP) -> None: - _car_state = sm['carState'] - self._op_engaged = sm['carControl'].longActive - self._v_ego = v_ego - self._a_ego = a_ego - self._v_cruise_setpoint = v_cruise_setpoint if not np.isnan(v_cruise_setpoint) else 0.0 - self._gas_pressed = _car_state.gasPressed - self._current_time = time.monotonic() - - self._speed_limit, self._distance, self._source = self._resolver.resolve(v_ego, self.speed_limit, sm) - - self._update_params() - self._update_calculations() - self._state_transition() - self._update_events(events_sp) diff --git a/sunnypilot/selfdrive/controls/lib/speed_limit_controller/speed_limit_resolver.py b/sunnypilot/selfdrive/controls/lib/speed_limit_controller/speed_limit_resolver.py deleted file mode 100644 index 43f78087b2..0000000000 --- a/sunnypilot/selfdrive/controls/lib/speed_limit_controller/speed_limit_resolver.py +++ /dev/null @@ -1,119 +0,0 @@ -import time -import numpy as np - -from cereal import messaging -from openpilot.common.gps import get_gps_location_service -from openpilot.common.params import Params -from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit_controller import LIMIT_MAX_MAP_DATA_AGE, LIMIT_ADAPT_ACC -from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit_controller.common import Source, Policy -from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit_controller.helpers import debug - - -class SpeedLimitResolver: - _limit_solutions: dict[Source, float] # Store for speed limit solutions from different sources - _distance_solutions: dict[Source, float] # Store for distance to current speed limit start for different sources - _v_ego: float - _current_speed_limit: float - - def __init__(self, policy: Policy): - self._gps_location_service = get_gps_location_service(Params()) - self._limit_solutions = {} - self._distance_solutions = {} - - self._policy = policy - self._policy_to_sources_map = { - Policy.car_state_only: [Source.car_state], - Policy.car_state_priority: [Source.car_state, Source.map_data], - Policy.map_data_priority: [Source.map_data, Source.car_state], - Policy.map_data_only: [Source.map_data], - Policy.combined: [Source.car_state, Source.map_data], - } - for source in Source: - self._reset_limit_sources(source) - - def change_policy(self, policy: Policy) -> None: - self._policy = policy - - def _reset_limit_sources(self, source: Source) -> None: - self._limit_solutions[source] = 0. - self._distance_solutions[source] = 0. - - def resolve(self, v_ego: float, current_speed_limit: float, sm: messaging.SubMaster) -> tuple[float, float, Source]: - self._v_ego = v_ego - self._current_speed_limit = current_speed_limit - - self._resolve_limit_sources(sm) - return self._consolidate() - - def _resolve_limit_sources(self, sm: messaging.SubMaster) -> None: - """Get limit solutions from each data source""" - self._get_from_car_state(sm) - self._get_from_map_data(sm) - - def _get_from_car_state(self, sm: messaging.SubMaster) -> None: - self._reset_limit_sources(Source.car_state) - self._limit_solutions[Source.car_state] = sm['carStateSP'].speedLimit - self._distance_solutions[Source.car_state] = 0. - - def _get_from_map_data(self, sm: messaging.SubMaster) -> None: - self._reset_limit_sources(Source.map_data) - 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: - debug(f'SL: Ignoring map data as is too old. Age: {gps_fix_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[Source.map_data] = speed_limit - self._distance_solutions[Source.map_data] = 0. - - 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[Source.map_data] = next_speed_limit - self._distance_solutions[Source.map_data] = distance_to_speed_limit_ahead - - def _consolidate(self) -> tuple[float, float, Source]: - source = self._get_source_solution_according_to_policy() - self.speed_limit = self._limit_solutions[source] if source else 0. - self.distance = self._distance_solutions[source] if source else 0. - self.source = source or Source.none - - debug(f'SL: *** Speed Limit set: {self.speed_limit}, distance: {self.distance}, source: {self.source}') - return self.speed_limit, self.distance, self.source - - def _get_source_solution_according_to_policy(self) -> Source | None: - 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(source) - - limits = np.array([self._limit_solutions[source] for source in sources_for_policy], dtype=float) - sources = np.array([source.value for source in sources_for_policy], dtype=int) - - if len(limits) > 0: - min_idx = np.argmin(limits) - return Source(sources[min_idx]) - - return None diff --git a/sunnypilot/selfdrive/controls/lib/speed_limit_controller/state.py b/sunnypilot/selfdrive/controls/lib/speed_limit_controller/state.py deleted file mode 100644 index 0f1d489ee8..0000000000 --- a/sunnypilot/selfdrive/controls/lib/speed_limit_controller/state.py +++ /dev/null @@ -1,58 +0,0 @@ -""" -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.sunnypilot.selfdrive.selfdrived.events import EventsSP - -EventNameSP = custom.OnroadEventSP.EventName -State = custom.LongitudinalPlanSP.SpeedLimitControlState - -ACTIVE_STATES = (State.active, State.adapting) -ENABLED_STATES = (State.preActive, State.tempInactive, *ACTIVE_STATES) - - -class StateMachine: - def __init__(self): - self.state = State.inactive - - def update(self, events_sp: EventsSP) -> tuple[bool, bool]: - # INACTIVE - if self.state == State.inactive: - if events_sp.has(EventNameSP.speedLimitAdapting): - self.state = State.adapting - elif events_sp.has(EventNameSP.speedLimitActive): - self.state = State.active - - # ACTIVE - elif self.state == State.active: - if events_sp.has(EventNameSP.speedLimitDisable): - self.state = State.inactive - elif events_sp.has(EventNameSP.speedLimitUserCancel): - self.state = State.tempInactive - elif events_sp.has(EventNameSP.speedLimitAdapting): - self.state = State.adapting - - # ADAPTING - elif self.state == State.adapting: - if events_sp.has(EventNameSP.speedLimitDisable): - self.state = State.inactive - elif events_sp.has(EventNameSP.speedLimitUserCancel): - self.state = State.tempInactive - elif events_sp.has(EventNameSP.speedLimitReached): - self.state = State.active - - # TEMP INACTIVE - elif self.state == State.tempInactive: - if events_sp.has(EventNameSP.speedLimitDisable): - self.state = State.inactive - elif events_sp.has(EventNameSP.speedLimitValueChange): - # When speed limit changes, reactivate - self.state = State.inactive - - enabled = self.state in ENABLED_STATES - active = self.state in ACTIVE_STATES - - return enabled, active 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..5633ed6efc --- /dev/null +++ b/sunnypilot/selfdrive/controls/lib/tests/test_lane_turn_desire.py @@ -0,0 +1,113 @@ +import pytest +from cereal import log +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 + + +class TurnDirection: + none = 0 + turnLeft = 1 + turnRight = 2 + + +@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/selfdrived/events.py b/sunnypilot/selfdrive/selfdrived/events.py index f39fafefdb..035c5f13ee 100644 --- a/sunnypilot/selfdrive/selfdrived/events.py +++ b/sunnypilot/selfdrive/selfdrived/events.py @@ -17,7 +17,7 @@ EVENT_NAME_SP = {v: k for k, v in EventNameSP.schema.enumerants.items()} 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'].slc.speedLimit + speedLimit = sm['longitudinalPlanSP'].sla.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( @@ -147,16 +147,43 @@ EVENTS_SP: dict[int, dict[str, Alert | AlertCallbackType]] = { ET.WARNING: NoEntryAlert("Pedal Pressed") }, - EventNameSP.speedLimitActive: { + EventNameSP.laneTurnLeft: { ET.WARNING: Alert( - "Set speed changed to match posted speed limit", + "Turning Left", "", AlertStatus.normal, AlertSize.small, - Priority.LOW, VisualAlert.none, AudibleAlert.none, 3.), + Priority.LOW, VisualAlert.none, AudibleAlert.none, 1.), }, - EventNameSP.speedLimitValueChange: { - ET.WARNING: speed_limit_adjust_alert, + EventNameSP.laneTurnRight: { + ET.WARNING: Alert( + "Turning Right", + "", + AlertStatus.normal, AlertSize.small, + Priority.LOW, VisualAlert.none, AudibleAlert.none, 1.), }, + EventNameSP.speedLimitActive: { + ET.WARNING: Alert( + "Automatically adjusting", + "to the posted speed limit", + AlertStatus.normal, AlertSize.small, + Priority.LOW, VisualAlert.none, AudibleAlert.none, 5.), + }, + + EventNameSP.speedLimitChanged: { + ET.WARNING: Alert( + "Set speed changed", + "", + AlertStatus.normal, AlertSize.small, + Priority.LOW, VisualAlert.none, AudibleAlert.none, 5.), + }, + + EventNameSP.speedLimitPreActive: { + ET.WARNING: Alert( + "Auto Speed Limit Control: Activation Required", + "Manually change set speed to 80 MPH to activate", + AlertStatus.normal, AlertSize.mid, + Priority.LOW, VisualAlert.none, AudibleAlert.none, 5.), + }, } diff --git a/sunnypilot/sunnylink/athena/sunnylinkd.py b/sunnypilot/sunnylink/athena/sunnylinkd.py index 90eae1dfe8..25a77c367b 100755 --- a/sunnypilot/sunnylink/athena/sunnylinkd.py +++ b/sunnypilot/sunnylink/athena/sunnylinkd.py @@ -3,7 +3,9 @@ from __future__ import annotations import base64 +import errno import gzip +import json import os import ssl import threading @@ -17,11 +19,11 @@ from openpilot.common.swaglog import cloudlog 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 from websocket import (ABNF, WebSocket, WebSocketException, WebSocketTimeoutException, - create_connection) + create_connection, WebSocketConnectionClosedException) import cereal.messaging as messaging from sunnypilot.sunnylink.api import SunnylinkApi -from sunnypilot.sunnylink.utils import sunnylink_need_register, sunnylink_ready +from 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")) @@ -107,10 +109,13 @@ def ws_recv(ws: WebSocket, end_event: threading.Event) -> None: except WebSocketTimeoutException: ns_since_last_ping = int(time.monotonic() * 1e9) - last_ping if ns_since_last_ping > SUNNYLINK_RECONNECT_TIMEOUT_S * 1e9: - cloudlog.exception("sunnylinkd.ws_recv.timeout") + cloudlog.warning("sunnylinkd.ws_recv.timeout") end_event.set() - except Exception: - cloudlog.exception("sunnylinkd.ws_recv.exception") + 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() @@ -137,11 +142,15 @@ def ws_queue(end_event: threading.Event) -> None: sunnylink_api.resume_queued(timeout=29) resume_requested = True tries = 0 - except Exception: - cloudlog.exception("sunnylinkd.ws_queue.resume_queued.exception") + 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)) # Wait for the backoff time before the next attempt + time.sleep(backoff(tries)) if end_event.is_set(): cloudlog.debug("end_event is set, exiting ws_queue thread") @@ -171,16 +180,26 @@ def getParamsAllKeys() -> list[str]: @dispatcher.add_method def getParams(params_keys: list[str], compression: bool = False) -> str | dict[str, str]: + params = Params() + try: - params = Params() - params_dict: dict[str, bytes] = {key: params.get(key) or b'' for key in params_keys} + param_keys_validated = [key for key in params_keys if key in getParamsAllKeys()] + 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 - # Compress the values before encoding to base64 as output from params.get is bytes and same for compression - if compression: - params_dict = {key: gzip.compress(value) for key, value in params_dict.items()} + 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 + }) - # Last step is to encode the values to base64 and decode to utf-8 for JSON serialization - return {key: base64.b64encode(value).decode('utf-8') for key, value in params_dict.items()} + 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) @@ -189,15 +208,9 @@ def getParams(params_keys: list[str], compression: bool = False) -> str | dict[s @dispatcher.add_method def saveParams(params_to_update: dict[str, str], compression: bool = False) -> None: - params = Params() - params_dict = {key: base64.b64decode(value) for key, value in params_to_update.items()} - - if compression: - params_dict = {key: gzip.decompress(value) for key, value in params_dict.items()} - - for key, value in params_dict.items(): + for key, value in params_to_update.items(): try: - params.put(key, value) + save_param_from_base64_encoded_string(key, value, compression) except Exception as e: cloudlog.error(f"sunnylinkd.saveParams.exception {e}") @@ -252,14 +265,19 @@ def main(exit_event: threading.Event = None): handle_long_poll(ws, exit_event) except (KeyboardInterrupt, SystemExit): break - except (ConnectionError, TimeoutError, WebSocketException): + except Exception as e: conn_retries += 1 params.remove("LastSunnylinkPingTime") - except Exception: - cloudlog.exception("sunnylinkd.main.exception") - 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)) diff --git a/sunnypilot/sunnylink/backups/manager.py b/sunnypilot/sunnylink/backups/manager.py index f98088a1fb..e52b547afe 100644 --- a/sunnypilot/sunnylink/backups/manager.py +++ b/sunnypilot/sunnylink/backups/manager.py @@ -12,7 +12,7 @@ from enum import Enum from typing import Any from openpilot.common.git import get_branch -from openpilot.common.params import Params, ParamKeyType, ParamKeyFlag +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 @@ -20,6 +20,7 @@ from openpilot.system.version import get_version from cereal import messaging, custom from sunnypilot.sunnylink.api import SunnylinkApi from sunnypilot.sunnylink.backups.utils import decrypt_compressed_data, encrypt_compress_data, SnakeCaseEncoder +from sunnypilot.sunnylink.utils import get_param_as_byte, save_param_from_base64_encoded_string class OperationType(Enum): @@ -74,7 +75,7 @@ class BackupManagerSP: 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 = str(self.params.get(param)).encode('utf-8') + value = get_param_as_byte(param) if value is not None: config_data[param] = base64.b64encode(value).decode('utf-8') return config_data @@ -113,6 +114,7 @@ class BackupManagerSP: 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}", @@ -124,9 +126,11 @@ class BackupManagerSP: 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) @@ -169,8 +173,7 @@ class BackupManagerSP: self._update_progress(75.0, OperationType.RESTORE) # Apply configuration - all_values_encoded = self._get_metadata_value(backup_metadata, "all_values_encoded", "false") - self._apply_config(config_data, str(all_values_encoded).lower() == "true") + self._apply_config(config_data) self.restore_status = custom.BackupManagerSP.Status.completed self._update_progress(100.0, OperationType.RESTORE) @@ -183,7 +186,7 @@ class BackupManagerSP: self._report_status() return False - def _apply_config(self, config_data: dict[str, str], all_values_encoded: bool = False) -> None: + 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} @@ -195,26 +198,8 @@ class BackupManagerSP: 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()) - param_type = self.params.get_type(real_param) try: - value = base64.b64decode(encoded_value) if all_values_encoded else encoded_value - - if param_type != ParamKeyType.BYTES: - value = value.decode('utf-8') # type: ignore - - if param_type == ParamKeyType.STRING: - value = value - elif param_type == ParamKeyType.BOOL: - value = value.lower() in ('true', '1', 'yes') # type: ignore - elif param_type == ParamKeyType.INT: - value = int(value) # type: ignore - elif param_type == ParamKeyType.FLOAT: - value = float(value) # type: ignore - elif param_type == ParamKeyType.TIME: - value = str(value) - elif param_type == ParamKeyType.JSON: - value = json.loads(value) - self.params.put(real_param, value) + 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)}") @@ -264,8 +249,8 @@ class BackupManagerSP: # Check for backup command if self.params.get_bool("BackupManager_CreateBackup"): try: - await self.create_backup() - reset_progress = True + if await self.create_backup(): + reset_progress = True finally: self.params.remove("BackupManager_CreateBackup") diff --git a/sunnypilot/sunnylink/utils.py b/sunnypilot/sunnylink/utils.py index 35714eafe3..91c0788795 100644 --- a/sunnypilot/sunnylink/utils.py +++ b/sunnypilot/sunnylink/utils.py @@ -1,5 +1,8 @@ +import base64 +import gzip +import json from sunnypilot.sunnylink.api import SunnylinkApi, UNREGISTERED_SUNNYLINK_DONGLE_ID -from openpilot.common.params import Params +from openpilot.common.params import Params, ParamKeyType from openpilot.system.version import is_prebuilt @@ -55,3 +58,59 @@ def get_api_token(): 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) -> bytes | None: + """Get a parameter as bytes. Returns None if the parameter does not exist.""" + params = params or Params() # Use existing Params instance if provided + param = params.get(param_name) + if param is None: + return None + + param_type = params.get_type(param_name) + 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') # type: ignore + + if param_type == ParamKeyType.STRING: + value = value + elif param_type == ParamKeyType.BOOL: + value = value.lower() in ('true', '1', 'yes') # type: ignore + elif param_type == ParamKeyType.INT: + value = int(value) # type: ignore + elif param_type == ParamKeyType.FLOAT: + value = float(value) # type: ignore + elif param_type == ParamKeyType.TIME: + value = str(value) # type: ignore + elif param_type == ParamKeyType.JSON: + value = json.loads(value) + + return value 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/system/athena/athenad.py b/system/athena/athenad.py index f97b8e55bb..42c9cf8a1c 100755 --- a/system/athena/athenad.py +++ b/system/athena/athenad.py @@ -381,20 +381,22 @@ def setNavDestination(latitude: int = 0, longitude: int = 0, place_name: str = N return {"success": 1} -def scan_dir(path: str, prefix: str) -> list[str]: +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) @@ -402,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 @@ -427,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 diff --git a/system/camerad/SConscript b/system/camerad/SConscript index fe5cf87b78..734f748a2a 100644 --- a/system/camerad/SConscript +++ b/system/camerad/SConscript @@ -4,7 +4,7 @@ libs = [common, 'OpenCL', messaging, visionipc, gpucommon] if arch != "Darwin": camera_obj = env.Object(['cameras/camera_qcom2.cc', 'cameras/camera_common.cc', 'cameras/spectra.cc', - 'cameras/cdm.cc', 'sensors/ar0231.cc', 'sensors/ox03c10.cc', 'sensors/os04c10.cc']) + 'cameras/cdm.cc', 'sensors/ox03c10.cc', 'sensors/os04c10.cc']) env.Program('camerad', ['main.cc', camera_obj], LIBS=libs) if GetOption("extras") and arch == "x86_64": diff --git a/system/camerad/cameras/hw.h b/system/camerad/cameras/hw.h index d299627ce9..f20a1b3ade 100644 --- a/system/camerad/cameras/hw.h +++ b/system/camerad/cameras/hw.h @@ -13,7 +13,7 @@ typedef enum { ISP_BPS_PROCESSED, // fully processed image through the BPS } SpectraOutputType; -// For the comma 3/3X three camera platform +// For the comma 3X three camera platform struct CameraConfig { int camera_num; diff --git a/system/camerad/cameras/spectra.cc b/system/camerad/cameras/spectra.cc index 47ae9061f4..caf7871573 100644 --- a/system/camerad/cameras/spectra.cc +++ b/system/camerad/cameras/spectra.cc @@ -1004,8 +1004,7 @@ bool SpectraCamera::openSensor() { }; // Figure out which sensor we have - if (!init_sensor_lambda(new AR0231) && - !init_sensor_lambda(new OX03C10) && + if (!init_sensor_lambda(new OX03C10) && !init_sensor_lambda(new OS04C10)) { LOGE("** sensor %d FAILED bringup, disabling", cc.camera_num); enabled = false; diff --git a/system/camerad/sensors/ar0231.cc b/system/camerad/sensors/ar0231.cc deleted file mode 100644 index e4ae29f079..0000000000 --- a/system/camerad/sensors/ar0231.cc +++ /dev/null @@ -1,136 +0,0 @@ -#include -#include - -#include "system/camerad/sensors/sensor.h" - -namespace { - -const size_t AR0231_REGISTERS_HEIGHT = 2; -// TODO: this extra height is universal and doesn't apply per camera -const size_t AR0231_STATS_HEIGHT = 2 + 8; - -const float sensor_analog_gains_AR0231[] = { - 1.0 / 8.0, 2.0 / 8.0, 2.0 / 7.0, 3.0 / 7.0, // 0, 1, 2, 3 - 3.0 / 6.0, 4.0 / 6.0, 4.0 / 5.0, 5.0 / 5.0, // 4, 5, 6, 7 - 5.0 / 4.0, 6.0 / 4.0, 6.0 / 3.0, 7.0 / 3.0, // 8, 9, 10, 11 - 7.0 / 2.0, 8.0 / 2.0, 8.0 / 1.0}; // 12, 13, 14, 15 = bypass - -} // namespace - -AR0231::AR0231() { - image_sensor = cereal::FrameData::ImageSensor::AR0231; - bayer_pattern = CAM_ISP_PATTERN_BAYER_GRGRGR; - pixel_size_mm = 0.003; - data_word = true; - frame_width = 1928; - frame_height = 1208; - frame_stride = (frame_width * 12 / 8) + 4; - extra_height = AR0231_REGISTERS_HEIGHT + AR0231_STATS_HEIGHT; - - registers_offset = 0; - frame_offset = AR0231_REGISTERS_HEIGHT; - stats_offset = AR0231_REGISTERS_HEIGHT + frame_height; - - start_reg_array.assign(std::begin(start_reg_array_ar0231), std::end(start_reg_array_ar0231)); - init_reg_array.assign(std::begin(init_array_ar0231), std::end(init_array_ar0231)); - probe_reg_addr = 0x3000; - probe_expected_data = 0x354; - bits_per_pixel = 12; - mipi_format = CAM_FORMAT_MIPI_RAW_12; - frame_data_type = 0x12; // Changing stats to 0x2C doesn't work, so change pixels to 0x12 instead - mclk_frequency = 19200000; //Hz - - readout_time_ns = 22850000; - - dc_gain_factor = 2.5; - dc_gain_min_weight = 0; - dc_gain_max_weight = 1; - dc_gain_on_grey = 0.2; - dc_gain_off_grey = 0.3; - exposure_time_min = 2; // with HDR, fastest ss - exposure_time_max = 0x0855; // with HDR, slowest ss, 40ms - analog_gain_min_idx = 0x1; // 0.25x - analog_gain_rec_idx = 0x6; // 0.8x - analog_gain_max_idx = 0xD; // 4.0x - analog_gain_cost_delta = 0; - analog_gain_cost_low = 0.1; - analog_gain_cost_high = 5.0; - for (int i = 0; i <= analog_gain_max_idx; i++) { - sensor_analog_gains[i] = sensor_analog_gains_AR0231[i]; - } - min_ev = exposure_time_min * sensor_analog_gains[analog_gain_min_idx]; - max_ev = exposure_time_max * dc_gain_factor * sensor_analog_gains[analog_gain_max_idx]; - target_grey_factor = 1.0; - - black_level = 168; - color_correct_matrix = { - 0x000000af, 0x00000ff9, 0x00000fd8, - 0x00000fbc, 0x000000bb, 0x00000009, - 0x00000fb6, 0x00000fe0, 0x000000ea, - }; - for (int i = 0; i < 65; i++) { - float fx = i / 64.0; - const float gamma_k = 0.75; - const float gamma_b = 0.125; - const float mp = 0.01; // ideally midpoint should be adaptive - const float rk = 9 - 100*mp; - // poly approximation for s curve - fx = (fx > mp) ? - ((rk * (fx-mp) * (1-(gamma_k*mp+gamma_b)) * (1+1/(rk*(1-mp))) / (1+rk*(fx-mp))) + gamma_k*mp + gamma_b) : - ((rk * (fx-mp) * (gamma_k*mp+gamma_b) * (1+1/(rk*mp)) / (1-rk*(fx-mp))) + gamma_k*mp + gamma_b); - gamma_lut_rgb.push_back((uint32_t)(fx*1023.0 + 0.5)); - } - prepare_gamma_lut(); - linearization_lut = { - 0x02000000, 0x02000000, 0x02000000, 0x02000000, - 0x020007ff, 0x020007ff, 0x020007ff, 0x020007ff, - 0x02000bff, 0x02000bff, 0x02000bff, 0x02000bff, - 0x020017ff, 0x020017ff, 0x020017ff, 0x020017ff, - 0x02001bff, 0x02001bff, 0x02001bff, 0x02001bff, - 0x020023ff, 0x020023ff, 0x020023ff, 0x020023ff, - 0x00003fff, 0x00003fff, 0x00003fff, 0x00003fff, - 0x00003fff, 0x00003fff, 0x00003fff, 0x00003fff, - 0x00003fff, 0x00003fff, 0x00003fff, 0x00003fff, - }; - linearization_pts = {0x07ff0bff, 0x17ff1bff, 0x23ff3fff, 0x3fff3fff}; - vignetting_lut = { - 0x00eaa755, 0x00cf2679, 0x00bc05e0, 0x00acc566, 0x00a1450a, 0x009984cc, 0x0095a4ad, 0x009584ac, 0x009944ca, 0x00a0c506, 0x00ac0560, 0x00bb25d9, 0x00ce2671, 0x00e90748, 0x01112889, 0x014a2a51, 0x01984cc2, - 0x00db06d8, 0x00c30618, 0x00afe57f, 0x00a0a505, 0x009524a9, 0x008d646b, 0x0089844c, 0x0089644b, 0x008d2469, 0x0094a4a5, 0x009fe4ff, 0x00af0578, 0x00c20610, 0x00d986cc, 0x00fda7ed, 0x01320990, 0x017aebd7, - 0x00d1868c, 0x00baa5d5, 0x00a7853c, 0x009844c2, 0x008cc466, 0x0085a42d, 0x0083641b, 0x0083641b, 0x0085842c, 0x008c4462, 0x0097a4bd, 0x00a6c536, 0x00b9a5cd, 0x00d06683, 0x00f1678b, 0x01226913, 0x0167ab3d, - 0x00cd0668, 0x00b625b1, 0x00a30518, 0x0093c49e, 0x00884442, 0x00830418, 0x0080e407, 0x0080c406, 0x0082e417, 0x0087c43e, 0x00932499, 0x00a22511, 0x00b525a9, 0x00cbe65f, 0x00eb0758, 0x011a68d3, 0x015daaed, - 0x00cc4662, 0x00b565ab, 0x00a24512, 0x00930498, 0x0087843c, 0x0082a415, 0x00806403, 0x00806403, 0x00828414, 0x00870438, 0x00926493, 0x00a1850c, 0x00b465a3, 0x00cb2659, 0x00ea2751, 0x011928c9, 0x015c2ae1, - 0x00cf667b, 0x00b885c4, 0x00a5652b, 0x009624b1, 0x008aa455, 0x00846423, 0x00822411, 0x00822411, 0x00844422, 0x008a2451, 0x009564ab, 0x00a48524, 0x00b785bc, 0x00ce4672, 0x00ee6773, 0x011e88f4, 0x0162eb17, - 0x00d6c6b6, 0x00bf65fb, 0x00ac4562, 0x009d04e8, 0x0091848c, 0x0089c44e, 0x00862431, 0x00860430, 0x0089844c, 0x00910488, 0x009c64e3, 0x00ab655b, 0x00be65f3, 0x00d566ab, 0x00f847c2, 0x012b2959, 0x01726b93, - 0x00e3e71f, 0x00ca0650, 0x00b705b8, 0x00a7a53d, 0x009c24e1, 0x009484a4, 0x00908484, 0x00908484, 0x009424a1, 0x009bc4de, 0x00a70538, 0x00b625b1, 0x00c90648, 0x00e26713, 0x0108e847, 0x013fe9ff, 0x018bcc5e, - 0x00f807c0, 0x00d966cb, 0x00c5862c, 0x00b625b1, 0x00aaa555, 0x00a30518, 0x009f04f8, 0x009f04f8, 0x00a2a515, 0x00aa2551, 0x00b585ac, 0x00c4a625, 0x00d846c2, 0x00f647b2, 0x0121a90d, 0x015e4af2, 0x01b8cdc6, - 0x011548aa, 0x00f1678b, 0x00d886c4, 0x00c86643, 0x00bce5e7, 0x00b545aa, 0x00b1658b, 0x00b1458a, 0x00b505a8, 0x00bc85e4, 0x00c7c63e, 0x00d786bc, 0x00efe77f, 0x0113489a, 0x0144ea27, 0x01888c44, 0x01fdcfee, - 0x013e49f2, 0x0113e89f, 0x00f5a7ad, 0x00e0c706, 0x00d30698, 0x00cb665b, 0x00c7663b, 0x00c7663b, 0x00cb0658, 0x00d2a695, 0x00dfe6ff, 0x00f467a3, 0x01122891, 0x013be9df, 0x01750ba8, 0x01cfae7d, 0x025912c8, - 0x01766bb3, 0x01446a23, 0x011fc8fe, 0x0105e82f, 0x00f467a3, 0x00e9874c, 0x00e46723, 0x00e44722, 0x00e92749, 0x00f3a79d, 0x0104c826, 0x011e48f2, 0x01424a12, 0x01738b9c, 0x01bf6dfb, 0x023611b0, 0x02ced676, - 0x01cf8e7c, 0x01866c33, 0x015aaad5, 0x013ae9d7, 0x01250928, 0x011768bb, 0x0110a885, 0x01108884, 0x0116e8b7, 0x01242921, 0x0139a9cd, 0x0158eac7, 0x01840c20, 0x01cb0e58, 0x0233719b, 0x02b9d5ce, 0x03645b22, - }; -} - -std::vector AR0231::getExposureRegisters(int exposure_time, int new_exp_g, bool dc_gain_enabled) const { - uint16_t analog_gain_reg = 0xFF00 | (new_exp_g << 4) | new_exp_g; - return { - {0x3366, analog_gain_reg}, - {0x3362, (uint16_t)(dc_gain_enabled ? 0x1 : 0x0)}, - {0x3012, (uint16_t)exposure_time}, - }; -} - -int AR0231::getSlaveAddress(int port) const { - assert(port >= 0 && port <= 2); - return (int[]){0x20, 0x30, 0x20}[port]; -} - -float AR0231::getExposureScore(float desired_ev, int exp_t, int exp_g_idx, float exp_gain, int gain_idx) const { - // Cost of ev diff - float score = std::abs(desired_ev - (exp_t * exp_gain)) * 10; - // Cost of absolute gain - float m = exp_g_idx > analog_gain_rec_idx ? analog_gain_cost_high : analog_gain_cost_low; - score += std::abs(exp_g_idx - (int)analog_gain_rec_idx) * m; - // Cost of changing gain - score += std::abs(exp_g_idx - gain_idx) * (score + 1.0) / 10.0; - return score; -} diff --git a/system/camerad/sensors/ar0231_registers.h b/system/camerad/sensors/ar0231_registers.h deleted file mode 100644 index e0872a673a..0000000000 --- a/system/camerad/sensors/ar0231_registers.h +++ /dev/null @@ -1,121 +0,0 @@ -#pragma once - -const struct i2c_random_wr_payload start_reg_array_ar0231[] = {{0x301A, 0x91C}}; -const struct i2c_random_wr_payload stop_reg_array_ar0231[] = {{0x301A, 0x918}}; - -const struct i2c_random_wr_payload init_array_ar0231[] = { - {0x301A, 0x0018}, // RESET_REGISTER - - // **NOTE**: if this is changed, readout_time_ns must be updated in the Sensor config - - // CLOCK Settings - // input clock is 19.2 / 2 * 0x37 = 528 MHz - // pixclk is 528 / 6 = 88 MHz - // full roll time is 1000/(PIXCLK/(LINE_LENGTH_PCK*FRAME_LENGTH_LINES)) = 39.99 ms - // img roll time is 1000/(PIXCLK/(LINE_LENGTH_PCK*Y_OUTPUT_CONTROL)) = 22.85 ms - {0x302A, 0x0006}, // VT_PIX_CLK_DIV - {0x302C, 0x0001}, // VT_SYS_CLK_DIV - {0x302E, 0x0002}, // PRE_PLL_CLK_DIV - {0x3030, 0x0037}, // PLL_MULTIPLIER - {0x3036, 0x000C}, // OP_PIX_CLK_DIV - {0x3038, 0x0001}, // OP_SYS_CLK_DIV - - // FORMAT - {0x3040, 0xC000}, // READ_MODE - {0x3004, 0x0000}, // X_ADDR_START_ - {0x3008, 0x0787}, // X_ADDR_END_ - {0x3002, 0x0000}, // Y_ADDR_START_ - {0x3006, 0x04B7}, // Y_ADDR_END_ - {0x3032, 0x0000}, // SCALING_MODE - {0x30A2, 0x0001}, // X_ODD_INC_ - {0x30A6, 0x0001}, // Y_ODD_INC_ - {0x3402, 0x0788}, // X_OUTPUT_CONTROL - {0x3404, 0x04B8}, // Y_OUTPUT_CONTROL - {0x3064, 0x1982}, // SMIA_TEST - {0x30BA, 0x11F2}, // DIGITAL_CTRL - - // Enable external trigger and disable GPIO outputs - {0x30CE, 0x0120}, // SLAVE_SH_SYNC_MODE | FRAME_START_MODE - {0x340A, 0xE0}, // GPIO3_INPUT_DISABLE | GPIO2_INPUT_DISABLE | GPIO1_INPUT_DISABLE - {0x340C, 0x802}, // GPIO_HIDRV_EN | GPIO0_ISEL=2 - - // Readout timing - {0x300C, 0x0672}, // LINE_LENGTH_PCK (valid for 3-exposure HDR) - {0x300A, 0x0855}, // FRAME_LENGTH_LINES - {0x3042, 0x0000}, // EXTRA_DELAY - - // Readout Settings - {0x31AE, 0x0204}, // SERIAL_FORMAT, 4-lane MIPI - {0x31AC, 0x0C0C}, // DATA_FORMAT_BITS, 12 -> 12 - {0x3342, 0x1212}, // MIPI_F1_PDT_EDT - {0x3346, 0x1212}, // MIPI_F2_PDT_EDT - {0x334A, 0x1212}, // MIPI_F3_PDT_EDT - {0x334E, 0x1212}, // MIPI_F4_PDT_EDT - {0x3344, 0x0011}, // MIPI_F1_VDT_VC - {0x3348, 0x0111}, // MIPI_F2_VDT_VC - {0x334C, 0x0211}, // MIPI_F3_VDT_VC - {0x3350, 0x0311}, // MIPI_F4_VDT_VC - {0x31B0, 0x0053}, // FRAME_PREAMBLE - {0x31B2, 0x003B}, // LINE_PREAMBLE - {0x301A, 0x001C}, // RESET_REGISTER - - // Noise Corrections - {0x3092, 0x0C24}, // ROW_NOISE_CONTROL - {0x337A, 0x0C80}, // DBLC_SCALE0 - {0x3370, 0x03B1}, // DBLC - {0x3044, 0x0400}, // DARK_CONTROL - - // Enable temperature sensor - {0x30B4, 0x0007}, // TEMPSENS0_CTRL_REG - {0x30B8, 0x0007}, // TEMPSENS1_CTRL_REG - - // Enable dead pixel correction using - // the 1D line correction scheme - {0x31E0, 0x0003}, - - // HDR Settings - {0x3082, 0x0004}, // OPERATION_MODE_CTRL - {0x3238, 0x0444}, // EXPOSURE_RATIO - - {0x1008, 0x0361}, // FINE_INTEGRATION_TIME_MIN - {0x100C, 0x0589}, // FINE_INTEGRATION_TIME2_MIN - {0x100E, 0x07B1}, // FINE_INTEGRATION_TIME3_MIN - {0x1010, 0x0139}, // FINE_INTEGRATION_TIME4_MIN - - // TODO: do these have to be lower than LINE_LENGTH_PCK? - {0x3014, 0x08CB}, // FINE_INTEGRATION_TIME_ - {0x321E, 0x0894}, // FINE_INTEGRATION_TIME2 - - {0x31D0, 0x0000}, // COMPANDING, no good in 10 bit? - {0x33DA, 0x0000}, // COMPANDING - {0x318E, 0x0200}, // PRE_HDR_GAIN_EN - - // DLO Settings - {0x3100, 0x4000}, // DLO_CONTROL0 - {0x3280, 0x0CCC}, // T1 G1 - {0x3282, 0x0CCC}, // T1 R - {0x3284, 0x0CCC}, // T1 B - {0x3286, 0x0CCC}, // T1 G2 - {0x3288, 0x0FA0}, // T2 G1 - {0x328A, 0x0FA0}, // T2 R - {0x328C, 0x0FA0}, // T2 B - {0x328E, 0x0FA0}, // T2 G2 - - // Initial Gains - {0x3022, 0x0001}, // GROUPED_PARAMETER_HOLD_ - {0x3366, 0xFF77}, // ANALOG_GAIN (1x) - - {0x3060, 0x3333}, // ANALOG_COLOR_GAIN - - {0x3362, 0x0000}, // DC GAIN - - {0x305A, 0x00F8}, // red gain - {0x3058, 0x0122}, // blue gain - {0x3056, 0x009A}, // g1 gain - {0x305C, 0x009A}, // g2 gain - - {0x3022, 0x0000}, // GROUPED_PARAMETER_HOLD_ - - // Initial Integration Time - {0x3012, 0x0005}, -}; diff --git a/system/camerad/sensors/sensor.h b/system/camerad/sensors/sensor.h index d4be3cf036..96aa8b604f 100644 --- a/system/camerad/sensors/sensor.h +++ b/system/camerad/sensors/sensor.h @@ -10,7 +10,6 @@ #include "media/cam_sensor.h" #include "cereal/gen/cpp/log.capnp.h" -#include "system/camerad/sensors/ar0231_registers.h" #include "system/camerad/sensors/ox03c10_registers.h" #include "system/camerad/sensors/os04c10_registers.h" @@ -88,17 +87,6 @@ public: }; }; -class AR0231 : public SensorInfo { -public: - AR0231(); - std::vector getExposureRegisters(int exposure_time, int new_exp_g, bool dc_gain_enabled) const override; - float getExposureScore(float desired_ev, int exp_t, int exp_g_idx, float exp_gain, int gain_idx) const override; - int getSlaveAddress(int port) const override; - -private: - mutable std::map> ar0231_register_lut; -}; - class OX03C10 : public SensorInfo { public: OX03C10(); diff --git a/system/hardware/base.py b/system/hardware/base.py index b457ea4e17..ce97bf294d 100644 --- a/system/hardware/base.py +++ b/system/hardware/base.py @@ -65,6 +65,10 @@ class ThermalConfig: return ret class LPABase(ABC): + @abstractmethod + def bootstrap(self) -> None: + pass + @abstractmethod def list_profiles(self) -> list[Profile]: pass @@ -89,6 +93,9 @@ class LPABase(ABC): def switch_profile(self, iccid: str) -> None: pass + def is_comma_profile(self, iccid: str) -> bool: + return any(iccid.startswith(prefix) for prefix in ('8985235',)) + class HardwareBase(ABC): @staticmethod def get_cmdline() -> dict[str, str]: diff --git a/system/hardware/esim.py b/system/hardware/esim.py index 58ead6593f..909ad41e03 100755 --- a/system/hardware/esim.py +++ b/system/hardware/esim.py @@ -3,10 +3,32 @@ import argparse import time from openpilot.system.hardware import HARDWARE +from openpilot.system.hardware.base import LPABase + + +def bootstrap(lpa: LPABase) -> None: + print('┌──────────────────────────────────────────────────────────────────────────────┐') + print('│ WARNING, PLEASE READ BEFORE PROCEEDING │') + print('│ │') + print('│ this is an irreversible operation that will remove the comma-provisioned │') + print('│ profile. │') + print('│ │') + print('│ after this operation, you must purchase a new eSIM from comma in order to │') + print('│ use the comma prime subscription again. │') + print('└──────────────────────────────────────────────────────────────────────────────┘') + print() + for severity in ('sure', '100% sure'): + print(f'are you {severity} you want to proceed? (y/N) ', end='') + confirm = input() + if confirm != 'y': + print('aborting') + exit(0) + lpa.bootstrap() if __name__ == '__main__': parser = argparse.ArgumentParser(prog='esim.py', description='manage eSIM profiles on your comma device', epilog='comma.ai') + parser.add_argument('--bootstrap', action='store_true', help='bootstrap the eUICC (required before downloading profiles)') parser.add_argument('--backend', choices=['qmi', 'at'], default='qmi', help='use the specified backend, defaults to qmi') parser.add_argument('--switch', metavar='iccid', help='switch to profile') parser.add_argument('--delete', metavar='iccid', help='delete profile (warning: this cannot be undone)') @@ -16,7 +38,10 @@ if __name__ == '__main__': mutated = False lpa = HARDWARE.get_sim_lpa() - if args.switch: + if args.bootstrap: + bootstrap(lpa) + mutated = True + elif args.switch: lpa.switch_profile(args.switch) mutated = True elif args.delete: diff --git a/system/hardware/hardwared.py b/system/hardware/hardwared.py index d702334fa8..0144a9df03 100755 --- a/system/hardware/hardwared.py +++ b/system/hardware/hardwared.py @@ -6,7 +6,6 @@ import struct import threading import time from collections import OrderedDict, namedtuple -from pathlib import Path import psutil @@ -24,7 +23,7 @@ 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 TiciFanController -from openpilot.system.version import terms_version, training_version +from openpilot.system.version import terms_version, training_version, get_build_metadata ThermalStatus = log.DeviceState.ThermalStatus NetworkType = log.DeviceState.NetworkType @@ -326,18 +325,22 @@ def hardware_thread(end_event, hw_queue) -> None: 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) - # TODO: this should move to TICI.initialize_hardware, but we currently can't import params there - if TICI and HARDWARE.get_device_type() == "tici": - if not os.path.isfile("/persist/comma/living-in-the-moment"): - if not Path("/data/media").is_mount(): - set_offroad_alert_if_changed("Offroad_StorageMissing", True) - # Handle offroad/onroad transition should_start = all(onroad_conditions.values()) if started_ts is None: diff --git a/system/hardware/hw.py b/system/hardware/hw.py index 5e40fff136..d24857e8bd 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: diff --git a/system/hardware/tici/agnos.json b/system/hardware/tici/agnos.json index 941a4956bf..d93963cf2c 100644 --- a/system/hardware/tici/agnos.json +++ b/system/hardware/tici/agnos.json @@ -23,14 +23,14 @@ }, { "name": "abl", - "url": "https://commadist.azureedge.net/agnosupdate/abl-32a2174b5f764e95dfc54cf358ba01752943b1b3b90e626149c3da7d5f1830b6.img.xz", - "hash": "32a2174b5f764e95dfc54cf358ba01752943b1b3b90e626149c3da7d5f1830b6", - "hash_raw": "32a2174b5f764e95dfc54cf358ba01752943b1b3b90e626149c3da7d5f1830b6", + "url": "https://commadist.azureedge.net/agnosupdate/abl-556bbb4ed1c671402b217bd2f3c07edce4f88b0bbd64e92241b82e396aa9ebee.img.xz", + "hash": "556bbb4ed1c671402b217bd2f3c07edce4f88b0bbd64e92241b82e396aa9ebee", + "hash_raw": "556bbb4ed1c671402b217bd2f3c07edce4f88b0bbd64e92241b82e396aa9ebee", "size": 274432, "sparse": false, "full_check": true, "has_ab": true, - "ondevice_hash": "32a2174b5f764e95dfc54cf358ba01752943b1b3b90e626149c3da7d5f1830b6" + "ondevice_hash": "556bbb4ed1c671402b217bd2f3c07edce4f88b0bbd64e92241b82e396aa9ebee" }, { "name": "aop", @@ -56,29 +56,29 @@ }, { "name": "boot", - "url": "https://commadist.azureedge.net/agnosupdate/boot-0191529aa97d90d1fa04b472d80230b777606459e1e1e9e2323c9519839827b4.img.xz", - "hash": "0191529aa97d90d1fa04b472d80230b777606459e1e1e9e2323c9519839827b4", - "hash_raw": "0191529aa97d90d1fa04b472d80230b777606459e1e1e9e2323c9519839827b4", - "size": 18515968, + "url": "https://commadist.azureedge.net/agnosupdate/boot-b96882012ab6cddda04f440009c798a6cff65977f984b12072e89afa592d86cb.img.xz", + "hash": "b96882012ab6cddda04f440009c798a6cff65977f984b12072e89afa592d86cb", + "hash_raw": "b96882012ab6cddda04f440009c798a6cff65977f984b12072e89afa592d86cb", + "size": 17442816, "sparse": false, "full_check": true, "has_ab": true, - "ondevice_hash": "492ae27f569e8db457c79d0e358a7a6297d1a1c685c2b1ae6deba7315d3a6cb0" + "ondevice_hash": "8ed6c2796be5c5b29d64e6413b8e878d5bd1a3981d15216d2b5e84140cc4ea2a" }, { "name": "system", - "url": "https://commadist.azureedge.net/agnosupdate/system-e0007afa5d1026671c1943d44bb7f7ad26259f673392dd00a03073a2870df087.img.xz", - "hash": "1468d50b7ad0fda0f04074755d21e786e3b1b6ca5dd5b17eb2608202025e6126", - "hash_raw": "e0007afa5d1026671c1943d44bb7f7ad26259f673392dd00a03073a2870df087", - "size": 5368709120, + "url": "https://commadist.azureedge.net/agnosupdate/system-2b1bb223bf2100376ad5d543bfa4a483f33327b3478ec20ab36048388472c4bc.img.xz", + "hash": "325414e5c9f7516b2bf0fedb6abe6682f717897a6d84ab70d5afe91a59f244e9", + "hash_raw": "2b1bb223bf2100376ad5d543bfa4a483f33327b3478ec20ab36048388472c4bc", + "size": 4718592000, "sparse": true, "full_check": false, "has_ab": true, - "ondevice_hash": "242aa5adad1c04e1398e00e2440d1babf962022eb12b89adf2e60ee3068946e7", + "ondevice_hash": "79f4f6d0b5b4a416f0f31261b430943a78e37c26d0e226e0ef412fe0eae3c727", "alt": { - "hash": "e0007afa5d1026671c1943d44bb7f7ad26259f673392dd00a03073a2870df087", - "url": "https://commadist.azureedge.net/agnosupdate/system-e0007afa5d1026671c1943d44bb7f7ad26259f673392dd00a03073a2870df087.img", - "size": 5368709120 + "hash": "2b1bb223bf2100376ad5d543bfa4a483f33327b3478ec20ab36048388472c4bc", + "url": "https://commadist.azureedge.net/agnosupdate/system-2b1bb223bf2100376ad5d543bfa4a483f33327b3478ec20ab36048388472c4bc.img", + "size": 4718592000 } } ] \ No newline at end of file diff --git a/system/hardware/tici/all-partitions.json b/system/hardware/tici/all-partitions.json index 5891e2748a..ebffc01dfd 100644 --- a/system/hardware/tici/all-partitions.json +++ b/system/hardware/tici/all-partitions.json @@ -152,14 +152,14 @@ }, { "name": "abl", - "url": "https://commadist.azureedge.net/agnosupdate/abl-32a2174b5f764e95dfc54cf358ba01752943b1b3b90e626149c3da7d5f1830b6.img.xz", - "hash": "32a2174b5f764e95dfc54cf358ba01752943b1b3b90e626149c3da7d5f1830b6", - "hash_raw": "32a2174b5f764e95dfc54cf358ba01752943b1b3b90e626149c3da7d5f1830b6", + "url": "https://commadist.azureedge.net/agnosupdate/abl-556bbb4ed1c671402b217bd2f3c07edce4f88b0bbd64e92241b82e396aa9ebee.img.xz", + "hash": "556bbb4ed1c671402b217bd2f3c07edce4f88b0bbd64e92241b82e396aa9ebee", + "hash_raw": "556bbb4ed1c671402b217bd2f3c07edce4f88b0bbd64e92241b82e396aa9ebee", "size": 274432, "sparse": false, "full_check": true, "has_ab": true, - "ondevice_hash": "32a2174b5f764e95dfc54cf358ba01752943b1b3b90e626149c3da7d5f1830b6" + "ondevice_hash": "556bbb4ed1c671402b217bd2f3c07edce4f88b0bbd64e92241b82e396aa9ebee" }, { "name": "aop", @@ -339,62 +339,62 @@ }, { "name": "boot", - "url": "https://commadist.azureedge.net/agnosupdate/boot-0191529aa97d90d1fa04b472d80230b777606459e1e1e9e2323c9519839827b4.img.xz", - "hash": "0191529aa97d90d1fa04b472d80230b777606459e1e1e9e2323c9519839827b4", - "hash_raw": "0191529aa97d90d1fa04b472d80230b777606459e1e1e9e2323c9519839827b4", - "size": 18515968, + "url": "https://commadist.azureedge.net/agnosupdate/boot-b96882012ab6cddda04f440009c798a6cff65977f984b12072e89afa592d86cb.img.xz", + "hash": "b96882012ab6cddda04f440009c798a6cff65977f984b12072e89afa592d86cb", + "hash_raw": "b96882012ab6cddda04f440009c798a6cff65977f984b12072e89afa592d86cb", + "size": 17442816, "sparse": false, "full_check": true, "has_ab": true, - "ondevice_hash": "492ae27f569e8db457c79d0e358a7a6297d1a1c685c2b1ae6deba7315d3a6cb0" + "ondevice_hash": "8ed6c2796be5c5b29d64e6413b8e878d5bd1a3981d15216d2b5e84140cc4ea2a" }, { "name": "system", - "url": "https://commadist.azureedge.net/agnosupdate/system-e0007afa5d1026671c1943d44bb7f7ad26259f673392dd00a03073a2870df087.img.xz", - "hash": "1468d50b7ad0fda0f04074755d21e786e3b1b6ca5dd5b17eb2608202025e6126", - "hash_raw": "e0007afa5d1026671c1943d44bb7f7ad26259f673392dd00a03073a2870df087", - "size": 5368709120, + "url": "https://commadist.azureedge.net/agnosupdate/system-2b1bb223bf2100376ad5d543bfa4a483f33327b3478ec20ab36048388472c4bc.img.xz", + "hash": "325414e5c9f7516b2bf0fedb6abe6682f717897a6d84ab70d5afe91a59f244e9", + "hash_raw": "2b1bb223bf2100376ad5d543bfa4a483f33327b3478ec20ab36048388472c4bc", + "size": 4718592000, "sparse": true, "full_check": false, "has_ab": true, - "ondevice_hash": "242aa5adad1c04e1398e00e2440d1babf962022eb12b89adf2e60ee3068946e7", + "ondevice_hash": "79f4f6d0b5b4a416f0f31261b430943a78e37c26d0e226e0ef412fe0eae3c727", "alt": { - "hash": "e0007afa5d1026671c1943d44bb7f7ad26259f673392dd00a03073a2870df087", - "url": "https://commadist.azureedge.net/agnosupdate/system-e0007afa5d1026671c1943d44bb7f7ad26259f673392dd00a03073a2870df087.img", - "size": 5368709120 + "hash": "2b1bb223bf2100376ad5d543bfa4a483f33327b3478ec20ab36048388472c4bc", + "url": "https://commadist.azureedge.net/agnosupdate/system-2b1bb223bf2100376ad5d543bfa4a483f33327b3478ec20ab36048388472c4bc.img", + "size": 4718592000 } }, { "name": "userdata_90", - "url": "https://commadist.azureedge.net/agnosupdate/userdata_90-602d5103cba97e1b07f76508d5febb47cfc4463a7e31bd20e461b55c801feb0a.img.xz", - "hash": "6a11d448bac50467791809339051eed2894aae971c37bf6284b3b972a99ba3ac", - "hash_raw": "602d5103cba97e1b07f76508d5febb47cfc4463a7e31bd20e461b55c801feb0a", + "url": "https://commadist.azureedge.net/agnosupdate/userdata_90-b3112984d2a8534a83d2ce43d35efdd10c7d163d9699f611f0f72ad9e9cb5af9.img.xz", + "hash": "bea163e6fb6ac6224c7f32619affb5afb834cd859971b0cab6d8297dd0098f0a", + "hash_raw": "b3112984d2a8534a83d2ce43d35efdd10c7d163d9699f611f0f72ad9e9cb5af9", "size": 96636764160, "sparse": true, "full_check": true, "has_ab": false, - "ondevice_hash": "e014d92940a696bf8582807259820ab73948b950656ed83a45da738f26083705" + "ondevice_hash": "f4841c6ae3207197886e5efbd50f44cc24822680d7b785fa2d2743c657f23287" }, { "name": "userdata_89", - "url": "https://commadist.azureedge.net/agnosupdate/userdata_89-4d7f6d12a5557eb6e3cbff9a4cd595677456fdfddcc879eddcea96a43a9d8b48.img.xz", - "hash": "748e31a5fc01fc256c012e359c3382d1f98cce98feafe8ecc0fca3e47caef116", - "hash_raw": "4d7f6d12a5557eb6e3cbff9a4cd595677456fdfddcc879eddcea96a43a9d8b48", + "url": "https://commadist.azureedge.net/agnosupdate/userdata_89-3e63f670e4270474cec96f4da9250ee4e87e3106b0b043b7e82371e1c761e167.img.xz", + "hash": "b5458a29dd7d4a4c9b7ad77b8baa5f804142ac78d97c6668839bf2a650e32518", + "hash_raw": "3e63f670e4270474cec96f4da9250ee4e87e3106b0b043b7e82371e1c761e167", "size": 95563022336, "sparse": true, "full_check": true, "has_ab": false, - "ondevice_hash": "c181b93050787adcfef730c086bcb780f28508d84e6376d9b80d37e5dc02b55e" + "ondevice_hash": "1dc10c542d3b019258fc08dc7dfdb49d9abad065e46d030b89bc1a2e0197f526" }, { "name": "userdata_30", - "url": "https://commadist.azureedge.net/agnosupdate/userdata_30-80a76c8e56bbd7536fd5e87e8daa12984e2960db4edeb1f83229b2baeecc4668.img.xz", - "hash": "09ff390e639e4373d772e1688d05a5ac77a573463ed1deeff86390686fa686f9", - "hash_raw": "80a76c8e56bbd7536fd5e87e8daa12984e2960db4edeb1f83229b2baeecc4668", + "url": "https://commadist.azureedge.net/agnosupdate/userdata_30-1d3885d4370974e55f0c6f567fd0344fc5ee10db067aa5810fbaf402eadb032c.img.xz", + "hash": "687d178cfc91be5d7e8aa1333405b610fdce01775b8333bd0985b81642b94eea", + "hash_raw": "1d3885d4370974e55f0c6f567fd0344fc5ee10db067aa5810fbaf402eadb032c", "size": 32212254720, "sparse": true, "full_check": true, "has_ab": false, - "ondevice_hash": "2c01ab470c02121c721ff6afc25582437e821686207f3afef659387afb69c507" + "ondevice_hash": "9ddbd1dae6ee7dc919f018364cf2f29dad138c9203c5a49aea0cbb9bf2e137e5" } ] \ No newline at end of file diff --git a/system/hardware/tici/amplifier.py b/system/hardware/tici/amplifier.py index f6b29ec0ce..bfdcc6ddaf 100755 --- a/system/hardware/tici/amplifier.py +++ b/system/hardware/tici/amplifier.py @@ -61,18 +61,6 @@ BASE_CONFIG = [ ] CONFIGS = { - "tici": [ - AmpConfig("Right speaker output from right DAC", 0b1, 0x2C, 0, 0b11111111), - AmpConfig("Right Speaker Mixer Gain", 0b00, 0x2D, 2, 0b00001100), - AmpConfig("Right speaker output volume", 0x1c, 0x3E, 0, 0b00011111), - AmpConfig("DAI2 EQ enable", 0b1, 0x49, 1, 0b00000010), - - *configs_from_eq_params(0x84, EQParams(0x274F, 0xC0FF, 0x3BF9, 0x0B3C, 0x1656)), - *configs_from_eq_params(0x8E, EQParams(0x1009, 0xC6BF, 0x2952, 0x1C97, 0x30DF)), - *configs_from_eq_params(0x98, EQParams(0x0F75, 0xCBE5, 0x0ED2, 0x2528, 0x3E42)), - *configs_from_eq_params(0xA2, EQParams(0x091F, 0x3D4C, 0xCE11, 0x1266, 0x2807)), - *configs_from_eq_params(0xAC, EQParams(0x0A9E, 0x3F20, 0xE573, 0x0A8B, 0x3A3B)), - ], "tizi": [ AmpConfig("Left speaker output from left DAC", 0b1, 0x2B, 0, 0b11111111), AmpConfig("Right speaker output from right DAC", 0b1, 0x2C, 0, 0b11111111), diff --git a/system/hardware/tici/esim.py b/system/hardware/tici/esim.py index b489286f50..391ba45531 100644 --- a/system/hardware/tici/esim.py +++ b/system/hardware/tici/esim.py @@ -40,6 +40,7 @@ class TiciLPA(LPABase): self._process_notifications() def download_profile(self, qr: str, nickname: str | None = None) -> None: + self._check_bootstrapped() msgs = self._invoke('profile', 'download', '-a', qr) self._validate_successful(msgs) new_profile = next((m for m in msgs if m['payload']['message'] == 'es8p_meatadata_parse'), None) @@ -54,6 +55,7 @@ class TiciLPA(LPABase): self._validate_successful(self._invoke('profile', 'nickname', iccid, nickname)) def switch_profile(self, iccid: str) -> None: + self._check_bootstrapped() self._validate_profile_exists(iccid) latest = self.get_active_profile() if latest and latest.iccid == iccid: @@ -61,6 +63,33 @@ class TiciLPA(LPABase): self._validate_successful(self._invoke('profile', 'enable', iccid)) self._process_notifications() + def bootstrap(self) -> None: + """ + find all comma-provisioned profiles and delete them. they conflict with user-provisioned profiles + and must be deleted. + + **note**: this is a **very** destructive operation. you **must** purchase a new comma SIM in order + to use comma prime again. + """ + if self._is_bootstrapped(): + return + + for p in self.list_profiles(): + if self.is_comma_profile(p.iccid): + self._disable_profile(p.iccid) + self.delete_profile(p.iccid) + + def _disable_profile(self, iccid: str) -> None: + self._validate_successful(self._invoke('profile', 'disable', iccid)) + self._process_notifications() + + def _check_bootstrapped(self) -> None: + assert self._is_bootstrapped(), 'eUICC is not bootstrapped, please bootstrap before performing this operation' + + def _is_bootstrapped(self) -> bool: + """ check if any comma provisioned profiles are on the eUICC """ + return not any(self.is_comma_profile(iccid) for iccid in (p.iccid for p in self.list_profiles())) + def _invoke(self, *cmd: str): proc = subprocess.Popen(['sudo', '-E', 'lpac'] + list(cmd), stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=self.env) try: diff --git a/system/hardware/tici/hardware.py b/system/hardware/tici/hardware.py index 35f9916c31..0f50acdc38 100644 --- a/system/hardware/tici/hardware.py +++ b/system/hardware/tici/hardware.py @@ -425,9 +425,6 @@ class Tici(HardwareBase): # pandad core affine_irq(3, "spi_geni") # SPI - if "tici" in self.get_device_type(): - affine_irq(3, "xhci-hcd:usb3") # aux panda USB (or potentially anything else on USB) - affine_irq(3, "xhci-hcd:usb1") # internal panda USB (also modem) try: pid = subprocess.check_output(["pgrep", "-f", "spi0"], encoding='utf8').strip() subprocess.call(["sudo", "chrt", "-f", "-p", "1", pid]) @@ -446,22 +443,20 @@ class Tici(HardwareBase): cmds = [] - if self.get_device_type() in ("tici", "tizi"): + if self.get_device_type() in ("tizi", ): # clear out old blue prime initial APN os.system('mmcli -m any --3gpp-set-initial-eps-bearer-settings="apn="') cmds += [ + # SIM hot swap + 'AT+QSIMDET=1,0', + 'AT+QSIMSTAT=1', + # configure modem as data-centric 'AT+QNVW=5280,0,"0102000000000000"', 'AT+QNVFW="/nv/item_files/ims/IMS_enable",00', 'AT+QNVFW="/nv/item_files/modem/mmode/ue_usage_setting",01', ] - if self.get_device_type() == "tizi": - # SIM hot swap, not routed on tici - cmds += [ - 'AT+QSIMDET=1,0', - 'AT+QSIMSTAT=1', - ] elif manufacturer == 'Cavli Inc.': cmds += [ 'AT^SIMSWAP=1', # use SIM slot, instead of internal eSIM @@ -492,7 +487,7 @@ class Tici(HardwareBase): # eSIM prime dest = "/etc/NetworkManager/system-connections/esim.nmconnection" - if sim_id.startswith('8985235') and not os.path.exists(dest): + if self.get_sim_lpa().is_comma_profile(sim_id) and not os.path.exists(dest): with open(Path(__file__).parent/'esim.nmconnection') as f, tempfile.NamedTemporaryFile(mode='w') as tf: dat = f.read() dat = dat.replace("sim-id=", f"sim-id={sim_id}") @@ -503,6 +498,14 @@ class Tici(HardwareBase): os.system(f"sudo cp {tf.name} {dest}") os.system(f"sudo nmcli con load {dest}") + def reboot_modem(self): + modem = self.get_modem() + for state in (0, 1): + try: + modem.Command(f'AT+CFUN={state}', math.ceil(TIMEOUT), dbus_interface=MM_MODEM, timeout=TIMEOUT) + except Exception: + pass + def get_networks(self): r = {} diff --git a/system/hardware/tici/pins.py b/system/hardware/tici/pins.py index 747278d1ec..bdbea591fb 100644 --- a/system/hardware/tici/pins.py +++ b/system/hardware/tici/pins.py @@ -1,5 +1,3 @@ -# TODO: these are also defined in a header - # GPIO pin definitions class GPIO: # both GPIO_STM_RST_N and GPIO_LTE_RST_N are misnamed, they are high to reset @@ -26,7 +24,4 @@ class GPIO: CAM2_RSTN = 12 # Sensor interrupts - BMX055_ACCEL_INT = 21 - BMX055_GYRO_INT = 23 - BMX055_MAGN_INT = 87 LSM_INT = 84 diff --git a/system/hardware/tici/tests/test_power_draw.py b/system/hardware/tici/tests/test_power_draw.py index db0fab884c..4fbde81673 100644 --- a/system/hardware/tici/tests/test_power_draw.py +++ b/system/hardware/tici/tests/test_power_draw.py @@ -31,9 +31,9 @@ class Proc: PROCS = [ - Proc(['camerad'], 1.75, msgs=['roadCameraState', 'wideRoadCameraState', 'driverCameraState']), - Proc(['modeld'], 1.12, atol=0.2, msgs=['modelV2']), - Proc(['dmonitoringmodeld'], 0.6, msgs=['driverStateV2']), + Proc(['camerad'], 1.65, atol=0.4, msgs=['roadCameraState', 'wideRoadCameraState', 'driverCameraState']), + Proc(['modeld'], 1.24, atol=0.2, msgs=['modelV2']), + Proc(['dmonitoringmodeld'], 0.65, atol=0.35, msgs=['driverStateV2']), Proc(['encoderd'], 0.23, msgs=[]), ] diff --git a/system/journald.py b/system/journald.py new file mode 100755 index 0000000000..37158b9251 --- /dev/null +++ b/system/journald.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +import json +import subprocess + +import cereal.messaging as messaging +from openpilot.common.swaglog import cloudlog + + +def main(): + pm = messaging.PubMaster(['androidLog']) + cmd = ['journalctl', '-f', '-o', 'json'] + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, text=True) + assert proc.stdout is not None + try: + for line in proc.stdout: + line = line.strip() + if not line: + continue + try: + kv = json.loads(line) + except json.JSONDecodeError: + cloudlog.exception("failed to parse journalctl output") + continue + + msg = messaging.new_message('androidLog') + entry = msg.androidLog + entry.ts = int(kv.get('__REALTIME_TIMESTAMP', 0)) + entry.message = json.dumps(kv) + if '_PID' in kv: + entry.pid = int(kv['_PID']) + if 'PRIORITY' in kv: + entry.priority = int(kv['PRIORITY']) + if 'SYSLOG_IDENTIFIER' in kv: + entry.tag = kv['SYSLOG_IDENTIFIER'] + + pm.send('androidLog', msg) + finally: + proc.terminate() + proc.wait() + + +if __name__ == '__main__': + main() diff --git a/system/logcatd/.gitignore b/system/logcatd/.gitignore deleted file mode 100644 index c66f7622d9..0000000000 --- a/system/logcatd/.gitignore +++ /dev/null @@ -1 +0,0 @@ -logcatd diff --git a/system/logcatd/SConscript b/system/logcatd/SConscript deleted file mode 100644 index 39c45d1093..0000000000 --- a/system/logcatd/SConscript +++ /dev/null @@ -1,3 +0,0 @@ -Import('env', 'messaging', 'common') - -env.Program('logcatd', 'logcatd_systemd.cc', LIBS=[messaging, common, 'systemd']) diff --git a/system/logcatd/logcatd_systemd.cc b/system/logcatd/logcatd_systemd.cc deleted file mode 100644 index 54b3782132..0000000000 --- a/system/logcatd/logcatd_systemd.cc +++ /dev/null @@ -1,75 +0,0 @@ -#include - -#include -#include -#include -#include - -#include "third_party/json11/json11.hpp" - -#include "cereal/messaging/messaging.h" -#include "common/timing.h" -#include "common/util.h" - -ExitHandler do_exit; -int main(int argc, char *argv[]) { - - PubMaster pm({"androidLog"}); - - sd_journal *journal; - int err = sd_journal_open(&journal, 0); - assert(err >= 0); - err = sd_journal_get_fd(journal); // needed so sd_journal_wait() works properly if files rotate - assert(err >= 0); - err = sd_journal_seek_tail(journal); - assert(err >= 0); - - // workaround for bug https://github.com/systemd/systemd/issues/9934 - // call sd_journal_previous_skip after sd_journal_seek_tail (like journalctl -f does) to makes things work. - sd_journal_previous_skip(journal, 1); - - while (!do_exit) { - err = sd_journal_next(journal); - assert(err >= 0); - - // Wait for new message if we didn't receive anything - if (err == 0) { - err = sd_journal_wait(journal, 1000 * 1000); - assert(err >= 0); - continue; // Try again - } - - uint64_t timestamp = 0; - err = sd_journal_get_realtime_usec(journal, ×tamp); - assert(err >= 0); - - const void *data; - size_t length; - std::map kv; - - SD_JOURNAL_FOREACH_DATA(journal, data, length) { - std::string str((char*)data, length); - - // Split "KEY=VALUE"" on "=" and put in map - std::size_t found = str.find("="); - if (found != std::string::npos) { - kv[str.substr(0, found)] = str.substr(found + 1, std::string::npos); - } - } - - MessageBuilder msg; - - // Build message - auto androidEntry = msg.initEvent().initAndroidLog(); - androidEntry.setTs(timestamp); - androidEntry.setMessage(json11::Json(kv).dump()); - if (kv.count("_PID")) androidEntry.setPid(std::atoi(kv["_PID"].c_str())); - if (kv.count("PRIORITY")) androidEntry.setPriority(std::atoi(kv["PRIORITY"].c_str())); - if (kv.count("SYSLOG_IDENTIFIER")) androidEntry.setTag(kv["SYSLOG_IDENTIFIER"]); - - pm.send("androidLog", msg); - } - - sd_journal_close(journal); - return 0; -} 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/tests/vidc_debug.sh b/system/loggerd/tests/vidc_debug.sh new file mode 100755 index 0000000000..7471f2ab08 --- /dev/null +++ b/system/loggerd/tests/vidc_debug.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -e + +cd /sys/kernel/debug/tracing +echo "" > trace +echo 1 > tracing_on +echo 1 > /sys/kernel/debug/tracing/events/msm_vidc/enable + +echo 0xff > /sys/module/videobuf2_core/parameters/debug +echo 0x7fffffff > /sys/kernel/debug/msm_vidc/debug_level +echo 0xff > /sys/devices/platform/soc/aa00000.qcom,vidc/video4linux/video33/dev_debug + +cat /sys/kernel/debug/tracing/trace_pipe diff --git a/system/manager/process_config.py b/system/manager/process_config.py index 67a77caa24..92d5a4ef8c 100644 --- a/system/manager/process_config.py +++ b/system/manager/process_config.py @@ -65,6 +65,9 @@ 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) @@ -108,8 +111,8 @@ procs = [ NativeProcess("camerad", "system/camerad", ["./camerad"], driverview, enabled=not WEBCAM), PythonProcess("webcamerad", "tools.webcam.camerad", driverview, enabled=WEBCAM), - NativeProcess("logcatd", "system/logcatd", ["./logcatd"], only_onroad, platform.system() != "Darwin"), - NativeProcess("proclogd", "system/proclogd", ["./proclogd"], only_onroad, platform.system() != "Darwin"), + PythonProcess("proclogd", "system.proclogd", only_onroad, enabled=platform.system() != "Darwin"), + PythonProcess("journald", "system.journald", only_onroad, platform.system() != "Darwin"), PythonProcess("micd", "system.micd", iscar), PythonProcess("timed", "system.timed", always_run, enabled=not PC), @@ -134,7 +137,7 @@ procs = [ PythonProcess("pandad", "selfdrive.pandad.pandad", always_run), PythonProcess("paramsd", "selfdrive.locationd.paramsd", only_onroad), PythonProcess("lagd", "selfdrive.locationd.lagd", only_onroad), - NativeProcess("ubloxd", "system/ubloxd", ["./ubloxd"], ublox, enabled=TICI), + PythonProcess("ubloxd", "system.ubloxd.ubloxd", ublox, enabled=TICI), PythonProcess("pigeond", "system.ubloxd.pigeond", ublox, enabled=TICI), PythonProcess("plannerd", "selfdrive.controls.plannerd", not_long_maneuver), PythonProcess("maneuversd", "tools.longitudinal_maneuvers.maneuversd", long_maneuver), @@ -178,4 +181,15 @@ if os.path.exists("./github_runner.sh"): 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/proclogd.py b/system/proclogd.py new file mode 100755 index 0000000000..3279425b7b --- /dev/null +++ b/system/proclogd.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +import os +from typing import NoReturn, TypedDict + +from cereal import messaging +from openpilot.common.realtime import Ratekeeper +from openpilot.common.swaglog import cloudlog + +JIFFY = os.sysconf(os.sysconf_names['SC_CLK_TCK']) +PAGE_SIZE = os.sysconf(os.sysconf_names['SC_PAGE_SIZE']) + + +def _cpu_times() -> list[dict[str, float]]: + cpu_times: list[dict[str, float]] = [] + try: + with open('/proc/stat') as f: + lines = f.readlines()[1:] + for line in lines: + if not line.startswith('cpu') or len(line) < 4 or not line[3].isdigit(): + break + parts = line.split() + cpu_times.append({ + 'cpuNum': int(parts[0][3:]), + 'user': float(parts[1]) / JIFFY, + 'nice': float(parts[2]) / JIFFY, + 'system': float(parts[3]) / JIFFY, + 'idle': float(parts[4]) / JIFFY, + 'iowait': float(parts[5]) / JIFFY, + 'irq': float(parts[6]) / JIFFY, + 'softirq': float(parts[7]) / JIFFY, + }) + except Exception: + cloudlog.exception("failed to read /proc/stat") + return cpu_times + + +def _mem_info() -> dict[str, int]: + keys = ["MemTotal:", "MemFree:", "MemAvailable:", "Buffers:", "Cached:", "Active:", "Inactive:", "Shmem:"] + info: dict[str, int] = dict.fromkeys(keys, 0) + try: + with open('/proc/meminfo') as f: + for line in f: + parts = line.split() + if parts and parts[0] in info: + info[parts[0]] = int(parts[1]) * 1024 + except Exception: + cloudlog.exception("failed to read /proc/meminfo") + return info + + +_STAT_POS = { + 'pid': 1, + 'state': 3, + 'ppid': 4, + 'utime': 14, + 'stime': 15, + 'cutime': 16, + 'cstime': 17, + 'priority': 18, + 'nice': 19, + 'num_threads': 20, + 'starttime': 22, + 'vsize': 23, + 'rss': 24, + 'processor': 39, +} + +class ProcStat(TypedDict): + name: str + pid: int + state: str + ppid: int + utime: int + stime: int + cutime: int + cstime: int + priority: int + nice: int + num_threads: int + starttime: int + vms: int + rss: int + processor: int + + +def _parse_proc_stat(stat: str) -> ProcStat | None: + open_paren = stat.find('(') + close_paren = stat.rfind(')') + if open_paren == -1 or close_paren == -1 or open_paren > close_paren: + return None + name = stat[open_paren + 1:close_paren] + stat = stat[:open_paren] + stat[open_paren:close_paren].replace(' ', '_') + stat[close_paren:] + parts = stat.split() + if len(parts) < 52: + return None + try: + return { + 'name': name, + 'pid': int(parts[_STAT_POS['pid'] - 1]), + 'state': parts[_STAT_POS['state'] - 1][0], + 'ppid': int(parts[_STAT_POS['ppid'] - 1]), + 'utime': int(parts[_STAT_POS['utime'] - 1]), + 'stime': int(parts[_STAT_POS['stime'] - 1]), + 'cutime': int(parts[_STAT_POS['cutime'] - 1]), + 'cstime': int(parts[_STAT_POS['cstime'] - 1]), + 'priority': int(parts[_STAT_POS['priority'] - 1]), + 'nice': int(parts[_STAT_POS['nice'] - 1]), + 'num_threads': int(parts[_STAT_POS['num_threads'] - 1]), + 'starttime': int(parts[_STAT_POS['starttime'] - 1]), + 'vms': int(parts[_STAT_POS['vsize'] - 1]), + 'rss': int(parts[_STAT_POS['rss'] - 1]), + 'processor': int(parts[_STAT_POS['processor'] - 1]), + } + except Exception: + cloudlog.exception("failed to parse /proc//stat") + return None + +class ProcExtra(TypedDict): + pid: int + name: str + exe: str + cmdline: list[str] + + +_proc_cache: dict[int, ProcExtra] = {} + + +def _get_proc_extra(pid: int, name: str) -> ProcExtra: + cache: ProcExtra | None = _proc_cache.get(pid) + if cache is None or cache.get('name') != name: + exe = '' + cmdline: list[str] = [] + try: + exe = os.readlink(f'/proc/{pid}/exe') + except OSError: + pass + try: + with open(f'/proc/{pid}/cmdline', 'rb') as f: + cmdline = [c.decode('utf-8', errors='replace') for c in f.read().split(b'\0') if c] + except OSError: + pass + cache = {'pid': pid, 'name': name, 'exe': exe, 'cmdline': cmdline} + _proc_cache[pid] = cache + return cache + + +def _procs() -> list[ProcStat]: + stats: list[ProcStat] = [] + for pid_str in os.listdir('/proc'): + if not pid_str.isdigit(): + continue + try: + with open(f'/proc/{pid_str}/stat') as f: + stat = f.read() + parsed = _parse_proc_stat(stat) + if parsed is not None: + stats.append(parsed) + except OSError: + continue + return stats + + +def build_proc_log_message(msg) -> None: + pl = msg.procLog + + procs = _procs() + l = pl.init('procs', len(procs)) + for i, r in enumerate(procs): + proc = l[i] + proc.pid = r['pid'] + proc.state = ord(r['state'][0]) + proc.ppid = r['ppid'] + proc.cpuUser = r['utime'] / JIFFY + proc.cpuSystem = r['stime'] / JIFFY + proc.cpuChildrenUser = r['cutime'] / JIFFY + proc.cpuChildrenSystem = r['cstime'] / JIFFY + proc.priority = r['priority'] + proc.nice = r['nice'] + proc.numThreads = r['num_threads'] + proc.startTime = r['starttime'] / JIFFY + proc.memVms = r['vms'] + proc.memRss = r['rss'] * PAGE_SIZE + proc.processor = r['processor'] + proc.name = r['name'] + + extra = _get_proc_extra(r['pid'], r['name']) + proc.exe = extra['exe'] + cmdline = proc.init('cmdline', len(extra['cmdline'])) + for j, arg in enumerate(extra['cmdline']): + cmdline[j] = arg + + cpu_times = _cpu_times() + cpu_list = pl.init('cpuTimes', len(cpu_times)) + for i, ct in enumerate(cpu_times): + cpu = cpu_list[i] + cpu.cpuNum = ct['cpuNum'] + cpu.user = ct['user'] + cpu.nice = ct['nice'] + cpu.system = ct['system'] + cpu.idle = ct['idle'] + cpu.iowait = ct['iowait'] + cpu.irq = ct['irq'] + cpu.softirq = ct['softirq'] + + mem_info = _mem_info() + pl.mem.total = mem_info["MemTotal:"] + pl.mem.free = mem_info["MemFree:"] + pl.mem.available = mem_info["MemAvailable:"] + pl.mem.buffers = mem_info["Buffers:"] + pl.mem.cached = mem_info["Cached:"] + pl.mem.active = mem_info["Active:"] + pl.mem.inactive = mem_info["Inactive:"] + pl.mem.shared = mem_info["Shmem:"] + + +def main() -> NoReturn: + pm = messaging.PubMaster(['procLog']) + rk = Ratekeeper(0.5) + while True: + msg = messaging.new_message('procLog', valid=True) + build_proc_log_message(msg) + pm.send('procLog', msg) + rk.keep_time() + + +if __name__ == '__main__': + main() diff --git a/system/proclogd/SConscript b/system/proclogd/SConscript deleted file mode 100644 index 08814d5ccb..0000000000 --- a/system/proclogd/SConscript +++ /dev/null @@ -1,6 +0,0 @@ -Import('env', 'messaging', 'common') -libs = [messaging, 'pthread', common] -env.Program('proclogd', ['main.cc', 'proclog.cc'], LIBS=libs) - -if GetOption('extras'): - env.Program('tests/test_proclog', ['tests/test_proclog.cc', 'proclog.cc'], LIBS=libs) diff --git a/system/proclogd/main.cc b/system/proclogd/main.cc deleted file mode 100644 index 3f8a889eea..0000000000 --- a/system/proclogd/main.cc +++ /dev/null @@ -1,25 +0,0 @@ - -#include - -#include "common/ratekeeper.h" -#include "common/util.h" -#include "system/proclogd/proclog.h" - -ExitHandler do_exit; - -int main(int argc, char **argv) { - setpriority(PRIO_PROCESS, 0, -15); - - RateKeeper rk("proclogd", 0.5); - PubMaster publisher({"procLog"}); - - while (!do_exit) { - MessageBuilder msg; - buildProcLogMessage(msg); - publisher.send("procLog", msg); - - rk.keepTime(); - } - - return 0; -} diff --git a/system/proclogd/proclog.cc b/system/proclogd/proclog.cc deleted file mode 100644 index 09ab4f559e..0000000000 --- a/system/proclogd/proclog.cc +++ /dev/null @@ -1,239 +0,0 @@ -#include "system/proclogd/proclog.h" - -#include - -#include -#include -#include -#include - -#include "common/swaglog.h" -#include "common/util.h" - -namespace Parser { - -// parse /proc/stat -std::vector cpuTimes(std::istream &stream) { - std::vector cpu_times; - std::string line; - // skip the first line for cpu total - std::getline(stream, line); - while (std::getline(stream, line)) { - if (line.compare(0, 3, "cpu") != 0) break; - - CPUTime t = {}; - std::istringstream iss(line); - if (iss.ignore(3) >> t.id >> t.utime >> t.ntime >> t.stime >> t.itime >> t.iowtime >> t.irqtime >> t.sirqtime) - cpu_times.push_back(t); - } - return cpu_times; -} - -// parse /proc/meminfo -std::unordered_map memInfo(std::istream &stream) { - std::unordered_map mem_info; - std::string line, key; - while (std::getline(stream, line)) { - uint64_t val = 0; - std::istringstream iss(line); - if (iss >> key >> val) { - mem_info[key] = val * 1024; - } - } - return mem_info; -} - -// field position (https://man7.org/linux/man-pages/man5/proc.5.html) -enum StatPos { - pid = 1, - state = 3, - ppid = 4, - utime = 14, - stime = 15, - cutime = 16, - cstime = 17, - priority = 18, - nice = 19, - num_threads = 20, - starttime = 22, - vsize = 23, - rss = 24, - processor = 39, - MAX_FIELD = 52, -}; - -// parse /proc/pid/stat -std::optional procStat(std::string stat) { - // To avoid being fooled by names containing a closing paren, scan backwards. - auto open_paren = stat.find('('); - auto close_paren = stat.rfind(')'); - if (open_paren == std::string::npos || close_paren == std::string::npos || open_paren > close_paren) { - return std::nullopt; - } - - std::string name = stat.substr(open_paren + 1, close_paren - open_paren - 1); - // replace space in name with _ - std::replace(&stat[open_paren], &stat[close_paren], ' ', '_'); - std::istringstream iss(stat); - std::vector v{std::istream_iterator(iss), - std::istream_iterator()}; - try { - if (v.size() != StatPos::MAX_FIELD) { - throw std::invalid_argument("stat"); - } - ProcStat p = { - .name = name, - .pid = stoi(v[StatPos::pid - 1]), - .state = v[StatPos::state - 1][0], - .ppid = stoi(v[StatPos::ppid - 1]), - .utime = stoul(v[StatPos::utime - 1]), - .stime = stoul(v[StatPos::stime - 1]), - .cutime = stol(v[StatPos::cutime - 1]), - .cstime = stol(v[StatPos::cstime - 1]), - .priority = stol(v[StatPos::priority - 1]), - .nice = stol(v[StatPos::nice - 1]), - .num_threads = stol(v[StatPos::num_threads - 1]), - .starttime = stoull(v[StatPos::starttime - 1]), - .vms = stoul(v[StatPos::vsize - 1]), - .rss = stol(v[StatPos::rss - 1]), - .processor = stoi(v[StatPos::processor - 1]), - }; - return p; - } catch (const std::invalid_argument &e) { - LOGE("failed to parse procStat (%s) :%s", e.what(), stat.c_str()); - } catch (const std::out_of_range &e) { - LOGE("failed to parse procStat (%s) :%s", e.what(), stat.c_str()); - } - return std::nullopt; -} - -// return list of PIDs from /proc -std::vector pids() { - std::vector ids; - DIR *d = opendir("/proc"); - assert(d); - char *p_end; - struct dirent *de = NULL; - while ((de = readdir(d))) { - if (de->d_type == DT_DIR) { - int pid = strtol(de->d_name, &p_end, 10); - if (p_end == (de->d_name + strlen(de->d_name))) { - ids.push_back(pid); - } - } - } - closedir(d); - return ids; -} - -// null-delimited cmdline arguments to vector -std::vector cmdline(std::istream &stream) { - std::vector ret; - std::string line; - while (std::getline(stream, line, '\0')) { - if (!line.empty()) { - ret.push_back(line); - } - } - return ret; -} - -const ProcCache &getProcExtraInfo(int pid, const std::string &name) { - static std::unordered_map proc_cache; - ProcCache &cache = proc_cache[pid]; - if (cache.pid != pid || cache.name != name) { - cache.pid = pid; - cache.name = name; - std::string proc_path = "/proc/" + std::to_string(pid); - cache.exe = util::readlink(proc_path + "/exe"); - std::ifstream stream(proc_path + "/cmdline"); - cache.cmdline = cmdline(stream); - } - return cache; -} - -} // namespace Parser - -const double jiffy = sysconf(_SC_CLK_TCK); -const size_t page_size = sysconf(_SC_PAGE_SIZE); - -void buildCPUTimes(cereal::ProcLog::Builder &builder) { - std::ifstream stream("/proc/stat"); - std::vector stats = Parser::cpuTimes(stream); - - auto log_cpu_times = builder.initCpuTimes(stats.size()); - for (int i = 0; i < stats.size(); ++i) { - auto l = log_cpu_times[i]; - const CPUTime &r = stats[i]; - l.setCpuNum(r.id); - l.setUser(r.utime / jiffy); - l.setNice(r.ntime / jiffy); - l.setSystem(r.stime / jiffy); - l.setIdle(r.itime / jiffy); - l.setIowait(r.iowtime / jiffy); - l.setIrq(r.irqtime / jiffy); - l.setSoftirq(r.sirqtime / jiffy); - } -} - -void buildMemInfo(cereal::ProcLog::Builder &builder) { - std::ifstream stream("/proc/meminfo"); - auto mem_info = Parser::memInfo(stream); - - auto mem = builder.initMem(); - mem.setTotal(mem_info["MemTotal:"]); - mem.setFree(mem_info["MemFree:"]); - mem.setAvailable(mem_info["MemAvailable:"]); - mem.setBuffers(mem_info["Buffers:"]); - mem.setCached(mem_info["Cached:"]); - mem.setActive(mem_info["Active:"]); - mem.setInactive(mem_info["Inactive:"]); - mem.setShared(mem_info["Shmem:"]); -} - -void buildProcs(cereal::ProcLog::Builder &builder) { - auto pids = Parser::pids(); - std::vector proc_stats; - proc_stats.reserve(pids.size()); - for (int pid : pids) { - std::string path = "/proc/" + std::to_string(pid) + "/stat"; - if (auto stat = Parser::procStat(util::read_file(path))) { - proc_stats.push_back(*stat); - } - } - - auto procs = builder.initProcs(proc_stats.size()); - for (size_t i = 0; i < proc_stats.size(); i++) { - auto l = procs[i]; - const ProcStat &r = proc_stats[i]; - l.setPid(r.pid); - l.setState(r.state); - l.setPpid(r.ppid); - l.setCpuUser(r.utime / jiffy); - l.setCpuSystem(r.stime / jiffy); - l.setCpuChildrenUser(r.cutime / jiffy); - l.setCpuChildrenSystem(r.cstime / jiffy); - l.setPriority(r.priority); - l.setNice(r.nice); - l.setNumThreads(r.num_threads); - l.setStartTime(r.starttime / jiffy); - l.setMemVms(r.vms); - l.setMemRss((uint64_t)r.rss * page_size); - l.setProcessor(r.processor); - l.setName(r.name); - - const ProcCache &extra_info = Parser::getProcExtraInfo(r.pid, r.name); - l.setExe(extra_info.exe); - auto lcmdline = l.initCmdline(extra_info.cmdline.size()); - for (size_t j = 0; j < lcmdline.size(); j++) { - lcmdline.set(j, extra_info.cmdline[j]); - } - } -} - -void buildProcLogMessage(MessageBuilder &msg) { - auto procLog = msg.initEvent().initProcLog(); - buildProcs(procLog); - buildCPUTimes(procLog); - buildMemInfo(procLog); -} diff --git a/system/proclogd/proclog.h b/system/proclogd/proclog.h deleted file mode 100644 index 49f97cdd36..0000000000 --- a/system/proclogd/proclog.h +++ /dev/null @@ -1,40 +0,0 @@ -#include -#include -#include -#include - -#include "cereal/messaging/messaging.h" - -struct CPUTime { - int id; - unsigned long utime, ntime, stime, itime; - unsigned long iowtime, irqtime, sirqtime; -}; - -struct ProcCache { - int pid; - std::string name, exe; - std::vector cmdline; -}; - -struct ProcStat { - int pid, ppid, processor; - char state; - long cutime, cstime, priority, nice, num_threads, rss; - unsigned long utime, stime, vms; - unsigned long long starttime; - std::string name; -}; - -namespace Parser { - -std::vector pids(); -std::optional procStat(std::string stat); -std::vector cmdline(std::istream &stream); -std::vector cpuTimes(std::istream &stream); -std::unordered_map memInfo(std::istream &stream); -const ProcCache &getProcExtraInfo(int pid, const std::string &name); - -}; // namespace Parser - -void buildProcLogMessage(MessageBuilder &msg); diff --git a/system/proclogd/tests/.gitignore b/system/proclogd/tests/.gitignore deleted file mode 100644 index 5230b1598d..0000000000 --- a/system/proclogd/tests/.gitignore +++ /dev/null @@ -1 +0,0 @@ -test_proclog diff --git a/system/proclogd/tests/test_proclog.cc b/system/proclogd/tests/test_proclog.cc deleted file mode 100644 index b86229a499..0000000000 --- a/system/proclogd/tests/test_proclog.cc +++ /dev/null @@ -1,142 +0,0 @@ -#define CATCH_CONFIG_MAIN -#include "catch2/catch.hpp" -#include "common/util.h" -#include "system/proclogd/proclog.h" - -const std::string allowed_states = "RSDTZtWXxKWPI"; - -TEST_CASE("Parser::procStat") { - SECTION("from string") { - const std::string stat_str = - "33012 (code )) S 32978 6620 6620 0 -1 4194368 2042377 0 144 0 24510 11627 0 " - "0 20 0 39 0 53077 830029824 62214 18446744073709551615 94257242783744 94257366235808 " - "140735738643248 0 0 0 0 4098 1073808632 0 0 0 17 2 0 0 2 0 0 94257370858656 94257371248232 " - "94257404952576 140735738648768 140735738648823 140735738648823 140735738650595 0"; - auto stat = Parser::procStat(stat_str); - REQUIRE(stat); - REQUIRE(stat->pid == 33012); - REQUIRE(stat->name == "code )"); - REQUIRE(stat->state == 'S'); - REQUIRE(stat->ppid == 32978); - REQUIRE(stat->utime == 24510); - REQUIRE(stat->stime == 11627); - REQUIRE(stat->cutime == 0); - REQUIRE(stat->cstime == 0); - REQUIRE(stat->priority == 20); - REQUIRE(stat->nice == 0); - REQUIRE(stat->num_threads == 39); - REQUIRE(stat->starttime == 53077); - REQUIRE(stat->vms == 830029824); - REQUIRE(stat->rss == 62214); - REQUIRE(stat->processor == 2); - } - SECTION("all processes") { - std::vector pids = Parser::pids(); - REQUIRE(pids.size() > 1); - for (int pid : pids) { - std::string stat_path = "/proc/" + std::to_string(pid) + "/stat"; - INFO(stat_path); - if (auto stat = Parser::procStat(util::read_file(stat_path))) { - REQUIRE(stat->pid == pid); - REQUIRE(allowed_states.find(stat->state) != std::string::npos); - } else { - REQUIRE(util::file_exists(stat_path) == false); - } - } - } -} - -TEST_CASE("Parser::cpuTimes") { - SECTION("from string") { - std::string stat = - "cpu 0 0 0 0 0 0 0 0 0 0\n" - "cpu0 1 2 3 4 5 6 7 8 9 10\n" - "cpu1 1 2 3 4 5 6 7 8 9 10\n"; - std::istringstream stream(stat); - auto stats = Parser::cpuTimes(stream); - REQUIRE(stats.size() == 2); - for (int i = 0; i < stats.size(); ++i) { - REQUIRE(stats[i].id == i); - REQUIRE(stats[i].utime == 1); - REQUIRE(stats[i].ntime ==2); - REQUIRE(stats[i].stime == 3); - REQUIRE(stats[i].itime == 4); - REQUIRE(stats[i].iowtime == 5); - REQUIRE(stats[i].irqtime == 6); - REQUIRE(stats[i].sirqtime == 7); - } - } - SECTION("all cpus") { - std::istringstream stream(util::read_file("/proc/stat")); - auto stats = Parser::cpuTimes(stream); - REQUIRE(stats.size() == sysconf(_SC_NPROCESSORS_ONLN)); - for (int i = 0; i < stats.size(); ++i) { - REQUIRE(stats[i].id == i); - } - } -} - -TEST_CASE("Parser::memInfo") { - SECTION("from string") { - std::istringstream stream("MemTotal: 1024 kb\nMemFree: 2048 kb\n"); - auto meminfo = Parser::memInfo(stream); - REQUIRE(meminfo["MemTotal:"] == 1024 * 1024); - REQUIRE(meminfo["MemFree:"] == 2048 * 1024); - } - SECTION("from /proc/meminfo") { - std::string require_keys[] = {"MemTotal:", "MemFree:", "MemAvailable:", "Buffers:", "Cached:", "Active:", "Inactive:", "Shmem:"}; - std::istringstream stream(util::read_file("/proc/meminfo")); - auto meminfo = Parser::memInfo(stream); - for (auto &key : require_keys) { - REQUIRE(meminfo.find(key) != meminfo.end()); - REQUIRE(meminfo[key] > 0); - } - } -} - -void test_cmdline(std::string cmdline, const std::vector requires) { - std::stringstream ss; - ss.write(&cmdline[0], cmdline.size()); - auto cmds = Parser::cmdline(ss); - REQUIRE(cmds.size() == requires.size()); - for (int i = 0; i < requires.size(); ++i) { - REQUIRE(cmds[i] == requires[i]); - } -} -TEST_CASE("Parser::cmdline") { - test_cmdline(std::string("a\0b\0c\0", 7), {"a", "b", "c"}); - test_cmdline(std::string("a\0\0c\0", 6), {"a", "c"}); - test_cmdline(std::string("a\0b\0c\0\0\0", 9), {"a", "b", "c"}); -} - -TEST_CASE("buildProcLoggerMessage") { - MessageBuilder msg; - buildProcLogMessage(msg); - - kj::Array buf = capnp::messageToFlatArray(msg); - capnp::FlatArrayMessageReader reader(buf); - auto log = reader.getRoot().getProcLog(); - REQUIRE(log.totalSize().wordCount > 0); - - // test cereal::ProcLog::CPUTimes - auto cpu_times = log.getCpuTimes(); - REQUIRE(cpu_times.size() == sysconf(_SC_NPROCESSORS_ONLN)); - REQUIRE(cpu_times[cpu_times.size() - 1].getCpuNum() == cpu_times.size() - 1); - - // test cereal::ProcLog::Mem - auto mem = log.getMem(); - REQUIRE(mem.getTotal() > 0); - REQUIRE(mem.getShared() > 0); - - // test cereal::ProcLog::Process - auto procs = log.getProcs(); - for (auto p : procs) { - REQUIRE(allowed_states.find(p.getState()) != std::string::npos); - if (p.getPid() == ::getpid()) { - REQUIRE(p.getName() == "test_proclog"); - REQUIRE(p.getState() == 'R'); - REQUIRE_THAT(p.getExe().cStr(), Catch::Matchers::Contains("test_proclog")); - REQUIRE_THAT(p.getCmdline()[0], Catch::Matchers::Contains("test_proclog")); - } - } -} diff --git a/system/ubloxd/.gitignore b/system/ubloxd/.gitignore deleted file mode 100644 index 05263ff67c..0000000000 --- a/system/ubloxd/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -ubloxd -tests/test_glonass_runner diff --git a/system/ubloxd/SConscript b/system/ubloxd/SConscript index ce09e235e6..9eb50760ba 100644 --- a/system/ubloxd/SConscript +++ b/system/ubloxd/SConscript @@ -1,20 +1,11 @@ -Import('env', 'common', 'messaging') - -loc_libs = [messaging, common, 'kaitai', 'pthread'] +Import('env') if GetOption('kaitai'): - generated = Dir('generated').srcnode().abspath - cmd = f"kaitai-struct-compiler --target cpp_stl --outdir {generated} $SOURCES" - env.Command(['generated/ubx.cpp', 'generated/ubx.h'], 'ubx.ksy', cmd) - env.Command(['generated/gps.cpp', 'generated/gps.h'], 'gps.ksy', cmd) - glonass = env.Command(['generated/glonass.cpp', 'generated/glonass.h'], 'glonass.ksy', cmd) - + current_dir = Dir('./generated/').srcnode().abspath + python_cmd = f"kaitai-struct-compiler --target python --outdir {current_dir} $SOURCES" + env.Command(File('./generated/ubx.py'), 'ubx.ksy', python_cmd) + env.Command(File('./generated/gps.py'), 'gps.ksy', python_cmd) + env.Command(File('./generated/glonass.py'), 'glonass.ksy', python_cmd) # kaitai issue: https://github.com/kaitai-io/kaitai_struct/issues/910 - patch = env.Command(None, 'glonass_fix.patch', 'git apply $SOURCES') - env.Depends(patch, glonass) - -glonass_obj = env.Object('generated/glonass.cpp') -env.Program("ubloxd", ["ubloxd.cc", "ublox_msg.cc", "generated/ubx.cpp", "generated/gps.cpp", glonass_obj], LIBS=loc_libs) - -if GetOption('extras'): - env.Program("tests/test_glonass_runner", ['tests/test_glonass_runner.cc', 'tests/test_glonass_kaitai.cc', glonass_obj], LIBS=[loc_libs]) \ No newline at end of file + py_glonass_fix = env.Command(None, File('./generated/glonass.py'), "sed -i 's/self._io.align_to_byte()/# self._io.align_to_byte()/' $SOURCES") + env.Depends(py_glonass_fix, File('./generated/glonass.py')) diff --git a/system/ubloxd/generated/glonass.cpp b/system/ubloxd/generated/glonass.cpp deleted file mode 100644 index cd0f96ab68..0000000000 --- a/system/ubloxd/generated/glonass.cpp +++ /dev/null @@ -1,353 +0,0 @@ -// This is a generated file! Please edit source .ksy file and use kaitai-struct-compiler to rebuild - -#include "glonass.h" - -glonass_t::glonass_t(kaitai::kstream* p__io, kaitai::kstruct* p__parent, glonass_t* p__root) : kaitai::kstruct(p__io) { - m__parent = p__parent; - m__root = this; - - try { - _read(); - } catch(...) { - _clean_up(); - throw; - } -} - -void glonass_t::_read() { - m_idle_chip = m__io->read_bits_int_be(1); - m_string_number = m__io->read_bits_int_be(4); - //m__io->align_to_byte(); - switch (string_number()) { - case 4: { - m_data = new string_4_t(m__io, this, m__root); - break; - } - case 1: { - m_data = new string_1_t(m__io, this, m__root); - break; - } - case 3: { - m_data = new string_3_t(m__io, this, m__root); - break; - } - case 5: { - m_data = new string_5_t(m__io, this, m__root); - break; - } - case 2: { - m_data = new string_2_t(m__io, this, m__root); - break; - } - default: { - m_data = new string_non_immediate_t(m__io, this, m__root); - break; - } - } - m_hamming_code = m__io->read_bits_int_be(8); - m_pad_1 = m__io->read_bits_int_be(11); - m_superframe_number = m__io->read_bits_int_be(16); - m_pad_2 = m__io->read_bits_int_be(8); - m_frame_number = m__io->read_bits_int_be(8); -} - -glonass_t::~glonass_t() { - _clean_up(); -} - -void glonass_t::_clean_up() { - if (m_data) { - delete m_data; m_data = 0; - } -} - -glonass_t::string_4_t::string_4_t(kaitai::kstream* p__io, glonass_t* p__parent, glonass_t* p__root) : kaitai::kstruct(p__io) { - m__parent = p__parent; - m__root = p__root; - f_tau_n = false; - f_delta_tau_n = false; - - try { - _read(); - } catch(...) { - _clean_up(); - throw; - } -} - -void glonass_t::string_4_t::_read() { - m_tau_n_sign = m__io->read_bits_int_be(1); - m_tau_n_value = m__io->read_bits_int_be(21); - m_delta_tau_n_sign = m__io->read_bits_int_be(1); - m_delta_tau_n_value = m__io->read_bits_int_be(4); - m_e_n = m__io->read_bits_int_be(5); - m_not_used_1 = m__io->read_bits_int_be(14); - m_p4 = m__io->read_bits_int_be(1); - m_f_t = m__io->read_bits_int_be(4); - m_not_used_2 = m__io->read_bits_int_be(3); - m_n_t = m__io->read_bits_int_be(11); - m_n = m__io->read_bits_int_be(5); - m_m = m__io->read_bits_int_be(2); -} - -glonass_t::string_4_t::~string_4_t() { - _clean_up(); -} - -void glonass_t::string_4_t::_clean_up() { -} - -int32_t glonass_t::string_4_t::tau_n() { - if (f_tau_n) - return m_tau_n; - m_tau_n = ((tau_n_sign()) ? ((tau_n_value() * -1)) : (tau_n_value())); - f_tau_n = true; - return m_tau_n; -} - -int32_t glonass_t::string_4_t::delta_tau_n() { - if (f_delta_tau_n) - return m_delta_tau_n; - m_delta_tau_n = ((delta_tau_n_sign()) ? ((delta_tau_n_value() * -1)) : (delta_tau_n_value())); - f_delta_tau_n = true; - return m_delta_tau_n; -} - -glonass_t::string_non_immediate_t::string_non_immediate_t(kaitai::kstream* p__io, glonass_t* p__parent, glonass_t* p__root) : kaitai::kstruct(p__io) { - m__parent = p__parent; - m__root = p__root; - - try { - _read(); - } catch(...) { - _clean_up(); - throw; - } -} - -void glonass_t::string_non_immediate_t::_read() { - m_data_1 = m__io->read_bits_int_be(64); - m_data_2 = m__io->read_bits_int_be(8); -} - -glonass_t::string_non_immediate_t::~string_non_immediate_t() { - _clean_up(); -} - -void glonass_t::string_non_immediate_t::_clean_up() { -} - -glonass_t::string_5_t::string_5_t(kaitai::kstream* p__io, glonass_t* p__parent, glonass_t* p__root) : kaitai::kstruct(p__io) { - m__parent = p__parent; - m__root = p__root; - - try { - _read(); - } catch(...) { - _clean_up(); - throw; - } -} - -void glonass_t::string_5_t::_read() { - m_n_a = m__io->read_bits_int_be(11); - m_tau_c = m__io->read_bits_int_be(32); - m_not_used = m__io->read_bits_int_be(1); - m_n_4 = m__io->read_bits_int_be(5); - m_tau_gps = m__io->read_bits_int_be(22); - m_l_n = m__io->read_bits_int_be(1); -} - -glonass_t::string_5_t::~string_5_t() { - _clean_up(); -} - -void glonass_t::string_5_t::_clean_up() { -} - -glonass_t::string_1_t::string_1_t(kaitai::kstream* p__io, glonass_t* p__parent, glonass_t* p__root) : kaitai::kstruct(p__io) { - m__parent = p__parent; - m__root = p__root; - f_x_vel = false; - f_x_accel = false; - f_x = false; - - try { - _read(); - } catch(...) { - _clean_up(); - throw; - } -} - -void glonass_t::string_1_t::_read() { - m_not_used = m__io->read_bits_int_be(2); - m_p1 = m__io->read_bits_int_be(2); - m_t_k = m__io->read_bits_int_be(12); - m_x_vel_sign = m__io->read_bits_int_be(1); - m_x_vel_value = m__io->read_bits_int_be(23); - m_x_accel_sign = m__io->read_bits_int_be(1); - m_x_accel_value = m__io->read_bits_int_be(4); - m_x_sign = m__io->read_bits_int_be(1); - m_x_value = m__io->read_bits_int_be(26); -} - -glonass_t::string_1_t::~string_1_t() { - _clean_up(); -} - -void glonass_t::string_1_t::_clean_up() { -} - -int32_t glonass_t::string_1_t::x_vel() { - if (f_x_vel) - return m_x_vel; - m_x_vel = ((x_vel_sign()) ? ((x_vel_value() * -1)) : (x_vel_value())); - f_x_vel = true; - return m_x_vel; -} - -int32_t glonass_t::string_1_t::x_accel() { - if (f_x_accel) - return m_x_accel; - m_x_accel = ((x_accel_sign()) ? ((x_accel_value() * -1)) : (x_accel_value())); - f_x_accel = true; - return m_x_accel; -} - -int32_t glonass_t::string_1_t::x() { - if (f_x) - return m_x; - m_x = ((x_sign()) ? ((x_value() * -1)) : (x_value())); - f_x = true; - return m_x; -} - -glonass_t::string_2_t::string_2_t(kaitai::kstream* p__io, glonass_t* p__parent, glonass_t* p__root) : kaitai::kstruct(p__io) { - m__parent = p__parent; - m__root = p__root; - f_y_vel = false; - f_y_accel = false; - f_y = false; - - try { - _read(); - } catch(...) { - _clean_up(); - throw; - } -} - -void glonass_t::string_2_t::_read() { - m_b_n = m__io->read_bits_int_be(3); - m_p2 = m__io->read_bits_int_be(1); - m_t_b = m__io->read_bits_int_be(7); - m_not_used = m__io->read_bits_int_be(5); - m_y_vel_sign = m__io->read_bits_int_be(1); - m_y_vel_value = m__io->read_bits_int_be(23); - m_y_accel_sign = m__io->read_bits_int_be(1); - m_y_accel_value = m__io->read_bits_int_be(4); - m_y_sign = m__io->read_bits_int_be(1); - m_y_value = m__io->read_bits_int_be(26); -} - -glonass_t::string_2_t::~string_2_t() { - _clean_up(); -} - -void glonass_t::string_2_t::_clean_up() { -} - -int32_t glonass_t::string_2_t::y_vel() { - if (f_y_vel) - return m_y_vel; - m_y_vel = ((y_vel_sign()) ? ((y_vel_value() * -1)) : (y_vel_value())); - f_y_vel = true; - return m_y_vel; -} - -int32_t glonass_t::string_2_t::y_accel() { - if (f_y_accel) - return m_y_accel; - m_y_accel = ((y_accel_sign()) ? ((y_accel_value() * -1)) : (y_accel_value())); - f_y_accel = true; - return m_y_accel; -} - -int32_t glonass_t::string_2_t::y() { - if (f_y) - return m_y; - m_y = ((y_sign()) ? ((y_value() * -1)) : (y_value())); - f_y = true; - return m_y; -} - -glonass_t::string_3_t::string_3_t(kaitai::kstream* p__io, glonass_t* p__parent, glonass_t* p__root) : kaitai::kstruct(p__io) { - m__parent = p__parent; - m__root = p__root; - f_gamma_n = false; - f_z_vel = false; - f_z_accel = false; - f_z = false; - - try { - _read(); - } catch(...) { - _clean_up(); - throw; - } -} - -void glonass_t::string_3_t::_read() { - m_p3 = m__io->read_bits_int_be(1); - m_gamma_n_sign = m__io->read_bits_int_be(1); - m_gamma_n_value = m__io->read_bits_int_be(10); - m_not_used = m__io->read_bits_int_be(1); - m_p = m__io->read_bits_int_be(2); - m_l_n = m__io->read_bits_int_be(1); - m_z_vel_sign = m__io->read_bits_int_be(1); - m_z_vel_value = m__io->read_bits_int_be(23); - m_z_accel_sign = m__io->read_bits_int_be(1); - m_z_accel_value = m__io->read_bits_int_be(4); - m_z_sign = m__io->read_bits_int_be(1); - m_z_value = m__io->read_bits_int_be(26); -} - -glonass_t::string_3_t::~string_3_t() { - _clean_up(); -} - -void glonass_t::string_3_t::_clean_up() { -} - -int32_t glonass_t::string_3_t::gamma_n() { - if (f_gamma_n) - return m_gamma_n; - m_gamma_n = ((gamma_n_sign()) ? ((gamma_n_value() * -1)) : (gamma_n_value())); - f_gamma_n = true; - return m_gamma_n; -} - -int32_t glonass_t::string_3_t::z_vel() { - if (f_z_vel) - return m_z_vel; - m_z_vel = ((z_vel_sign()) ? ((z_vel_value() * -1)) : (z_vel_value())); - f_z_vel = true; - return m_z_vel; -} - -int32_t glonass_t::string_3_t::z_accel() { - if (f_z_accel) - return m_z_accel; - m_z_accel = ((z_accel_sign()) ? ((z_accel_value() * -1)) : (z_accel_value())); - f_z_accel = true; - return m_z_accel; -} - -int32_t glonass_t::string_3_t::z() { - if (f_z) - return m_z; - m_z = ((z_sign()) ? ((z_value() * -1)) : (z_value())); - f_z = true; - return m_z; -} diff --git a/system/ubloxd/generated/glonass.h b/system/ubloxd/generated/glonass.h deleted file mode 100644 index 19867ba22b..0000000000 --- a/system/ubloxd/generated/glonass.h +++ /dev/null @@ -1,375 +0,0 @@ -#ifndef GLONASS_H_ -#define GLONASS_H_ - -// This is a generated file! Please edit source .ksy file and use kaitai-struct-compiler to rebuild - -#include "kaitai/kaitaistruct.h" -#include - -#if KAITAI_STRUCT_VERSION < 9000L -#error "Incompatible Kaitai Struct C++/STL API: version 0.9 or later is required" -#endif - -class glonass_t : public kaitai::kstruct { - -public: - class string_4_t; - class string_non_immediate_t; - class string_5_t; - class string_1_t; - class string_2_t; - class string_3_t; - - glonass_t(kaitai::kstream* p__io, kaitai::kstruct* p__parent = 0, glonass_t* p__root = 0); - -private: - void _read(); - void _clean_up(); - -public: - ~glonass_t(); - - class string_4_t : public kaitai::kstruct { - - public: - - string_4_t(kaitai::kstream* p__io, glonass_t* p__parent = 0, glonass_t* p__root = 0); - - private: - void _read(); - void _clean_up(); - - public: - ~string_4_t(); - - private: - bool f_tau_n; - int32_t m_tau_n; - - public: - int32_t tau_n(); - - private: - bool f_delta_tau_n; - int32_t m_delta_tau_n; - - public: - int32_t delta_tau_n(); - - private: - bool m_tau_n_sign; - uint64_t m_tau_n_value; - bool m_delta_tau_n_sign; - uint64_t m_delta_tau_n_value; - uint64_t m_e_n; - uint64_t m_not_used_1; - bool m_p4; - uint64_t m_f_t; - uint64_t m_not_used_2; - uint64_t m_n_t; - uint64_t m_n; - uint64_t m_m; - glonass_t* m__root; - glonass_t* m__parent; - - public: - bool tau_n_sign() const { return m_tau_n_sign; } - uint64_t tau_n_value() const { return m_tau_n_value; } - bool delta_tau_n_sign() const { return m_delta_tau_n_sign; } - uint64_t delta_tau_n_value() const { return m_delta_tau_n_value; } - uint64_t e_n() const { return m_e_n; } - uint64_t not_used_1() const { return m_not_used_1; } - bool p4() const { return m_p4; } - uint64_t f_t() const { return m_f_t; } - uint64_t not_used_2() const { return m_not_used_2; } - uint64_t n_t() const { return m_n_t; } - uint64_t n() const { return m_n; } - uint64_t m() const { return m_m; } - glonass_t* _root() const { return m__root; } - glonass_t* _parent() const { return m__parent; } - }; - - class string_non_immediate_t : public kaitai::kstruct { - - public: - - string_non_immediate_t(kaitai::kstream* p__io, glonass_t* p__parent = 0, glonass_t* p__root = 0); - - private: - void _read(); - void _clean_up(); - - public: - ~string_non_immediate_t(); - - private: - uint64_t m_data_1; - uint64_t m_data_2; - glonass_t* m__root; - glonass_t* m__parent; - - public: - uint64_t data_1() const { return m_data_1; } - uint64_t data_2() const { return m_data_2; } - glonass_t* _root() const { return m__root; } - glonass_t* _parent() const { return m__parent; } - }; - - class string_5_t : public kaitai::kstruct { - - public: - - string_5_t(kaitai::kstream* p__io, glonass_t* p__parent = 0, glonass_t* p__root = 0); - - private: - void _read(); - void _clean_up(); - - public: - ~string_5_t(); - - private: - uint64_t m_n_a; - uint64_t m_tau_c; - bool m_not_used; - uint64_t m_n_4; - uint64_t m_tau_gps; - bool m_l_n; - glonass_t* m__root; - glonass_t* m__parent; - - public: - uint64_t n_a() const { return m_n_a; } - uint64_t tau_c() const { return m_tau_c; } - bool not_used() const { return m_not_used; } - uint64_t n_4() const { return m_n_4; } - uint64_t tau_gps() const { return m_tau_gps; } - bool l_n() const { return m_l_n; } - glonass_t* _root() const { return m__root; } - glonass_t* _parent() const { return m__parent; } - }; - - class string_1_t : public kaitai::kstruct { - - public: - - string_1_t(kaitai::kstream* p__io, glonass_t* p__parent = 0, glonass_t* p__root = 0); - - private: - void _read(); - void _clean_up(); - - public: - ~string_1_t(); - - private: - bool f_x_vel; - int32_t m_x_vel; - - public: - int32_t x_vel(); - - private: - bool f_x_accel; - int32_t m_x_accel; - - public: - int32_t x_accel(); - - private: - bool f_x; - int32_t m_x; - - public: - int32_t x(); - - private: - uint64_t m_not_used; - uint64_t m_p1; - uint64_t m_t_k; - bool m_x_vel_sign; - uint64_t m_x_vel_value; - bool m_x_accel_sign; - uint64_t m_x_accel_value; - bool m_x_sign; - uint64_t m_x_value; - glonass_t* m__root; - glonass_t* m__parent; - - public: - uint64_t not_used() const { return m_not_used; } - uint64_t p1() const { return m_p1; } - uint64_t t_k() const { return m_t_k; } - bool x_vel_sign() const { return m_x_vel_sign; } - uint64_t x_vel_value() const { return m_x_vel_value; } - bool x_accel_sign() const { return m_x_accel_sign; } - uint64_t x_accel_value() const { return m_x_accel_value; } - bool x_sign() const { return m_x_sign; } - uint64_t x_value() const { return m_x_value; } - glonass_t* _root() const { return m__root; } - glonass_t* _parent() const { return m__parent; } - }; - - class string_2_t : public kaitai::kstruct { - - public: - - string_2_t(kaitai::kstream* p__io, glonass_t* p__parent = 0, glonass_t* p__root = 0); - - private: - void _read(); - void _clean_up(); - - public: - ~string_2_t(); - - private: - bool f_y_vel; - int32_t m_y_vel; - - public: - int32_t y_vel(); - - private: - bool f_y_accel; - int32_t m_y_accel; - - public: - int32_t y_accel(); - - private: - bool f_y; - int32_t m_y; - - public: - int32_t y(); - - private: - uint64_t m_b_n; - bool m_p2; - uint64_t m_t_b; - uint64_t m_not_used; - bool m_y_vel_sign; - uint64_t m_y_vel_value; - bool m_y_accel_sign; - uint64_t m_y_accel_value; - bool m_y_sign; - uint64_t m_y_value; - glonass_t* m__root; - glonass_t* m__parent; - - public: - uint64_t b_n() const { return m_b_n; } - bool p2() const { return m_p2; } - uint64_t t_b() const { return m_t_b; } - uint64_t not_used() const { return m_not_used; } - bool y_vel_sign() const { return m_y_vel_sign; } - uint64_t y_vel_value() const { return m_y_vel_value; } - bool y_accel_sign() const { return m_y_accel_sign; } - uint64_t y_accel_value() const { return m_y_accel_value; } - bool y_sign() const { return m_y_sign; } - uint64_t y_value() const { return m_y_value; } - glonass_t* _root() const { return m__root; } - glonass_t* _parent() const { return m__parent; } - }; - - class string_3_t : public kaitai::kstruct { - - public: - - string_3_t(kaitai::kstream* p__io, glonass_t* p__parent = 0, glonass_t* p__root = 0); - - private: - void _read(); - void _clean_up(); - - public: - ~string_3_t(); - - private: - bool f_gamma_n; - int32_t m_gamma_n; - - public: - int32_t gamma_n(); - - private: - bool f_z_vel; - int32_t m_z_vel; - - public: - int32_t z_vel(); - - private: - bool f_z_accel; - int32_t m_z_accel; - - public: - int32_t z_accel(); - - private: - bool f_z; - int32_t m_z; - - public: - int32_t z(); - - private: - bool m_p3; - bool m_gamma_n_sign; - uint64_t m_gamma_n_value; - bool m_not_used; - uint64_t m_p; - bool m_l_n; - bool m_z_vel_sign; - uint64_t m_z_vel_value; - bool m_z_accel_sign; - uint64_t m_z_accel_value; - bool m_z_sign; - uint64_t m_z_value; - glonass_t* m__root; - glonass_t* m__parent; - - public: - bool p3() const { return m_p3; } - bool gamma_n_sign() const { return m_gamma_n_sign; } - uint64_t gamma_n_value() const { return m_gamma_n_value; } - bool not_used() const { return m_not_used; } - uint64_t p() const { return m_p; } - bool l_n() const { return m_l_n; } - bool z_vel_sign() const { return m_z_vel_sign; } - uint64_t z_vel_value() const { return m_z_vel_value; } - bool z_accel_sign() const { return m_z_accel_sign; } - uint64_t z_accel_value() const { return m_z_accel_value; } - bool z_sign() const { return m_z_sign; } - uint64_t z_value() const { return m_z_value; } - glonass_t* _root() const { return m__root; } - glonass_t* _parent() const { return m__parent; } - }; - -private: - bool m_idle_chip; - uint64_t m_string_number; - kaitai::kstruct* m_data; - uint64_t m_hamming_code; - uint64_t m_pad_1; - uint64_t m_superframe_number; - uint64_t m_pad_2; - uint64_t m_frame_number; - glonass_t* m__root; - kaitai::kstruct* m__parent; - -public: - bool idle_chip() const { return m_idle_chip; } - uint64_t string_number() const { return m_string_number; } - kaitai::kstruct* data() const { return m_data; } - uint64_t hamming_code() const { return m_hamming_code; } - uint64_t pad_1() const { return m_pad_1; } - uint64_t superframe_number() const { return m_superframe_number; } - uint64_t pad_2() const { return m_pad_2; } - uint64_t frame_number() const { return m_frame_number; } - glonass_t* _root() const { return m__root; } - kaitai::kstruct* _parent() const { return m__parent; } -}; - -#endif // GLONASS_H_ diff --git a/system/ubloxd/generated/glonass.py b/system/ubloxd/generated/glonass.py new file mode 100644 index 0000000000..40aa16bb6f --- /dev/null +++ b/system/ubloxd/generated/glonass.py @@ -0,0 +1,247 @@ +# This is a generated file! Please edit source .ksy file and use kaitai-struct-compiler to rebuild + +import kaitaistruct +from kaitaistruct import KaitaiStruct, KaitaiStream, BytesIO + + +if getattr(kaitaistruct, 'API_VERSION', (0, 9)) < (0, 9): + raise Exception("Incompatible Kaitai Struct Python API: 0.9 or later is required, but you have %s" % (kaitaistruct.__version__)) + +class Glonass(KaitaiStruct): + def __init__(self, _io, _parent=None, _root=None): + self._io = _io + self._parent = _parent + self._root = _root if _root else self + self._read() + + def _read(self): + self.idle_chip = self._io.read_bits_int_be(1) != 0 + self.string_number = self._io.read_bits_int_be(4) + # workaround for kaitai bit alignment issue (see glonass_fix.patch for C++) + # self._io.align_to_byte() + _on = self.string_number + if _on == 4: + self.data = Glonass.String4(self._io, self, self._root) + elif _on == 1: + self.data = Glonass.String1(self._io, self, self._root) + elif _on == 3: + self.data = Glonass.String3(self._io, self, self._root) + elif _on == 5: + self.data = Glonass.String5(self._io, self, self._root) + elif _on == 2: + self.data = Glonass.String2(self._io, self, self._root) + else: + self.data = Glonass.StringNonImmediate(self._io, self, self._root) + self.hamming_code = self._io.read_bits_int_be(8) + self.pad_1 = self._io.read_bits_int_be(11) + self.superframe_number = self._io.read_bits_int_be(16) + self.pad_2 = self._io.read_bits_int_be(8) + self.frame_number = self._io.read_bits_int_be(8) + + class String4(KaitaiStruct): + def __init__(self, _io, _parent=None, _root=None): + self._io = _io + self._parent = _parent + self._root = _root if _root else self + self._read() + + def _read(self): + self.tau_n_sign = self._io.read_bits_int_be(1) != 0 + self.tau_n_value = self._io.read_bits_int_be(21) + self.delta_tau_n_sign = self._io.read_bits_int_be(1) != 0 + self.delta_tau_n_value = self._io.read_bits_int_be(4) + self.e_n = self._io.read_bits_int_be(5) + self.not_used_1 = self._io.read_bits_int_be(14) + self.p4 = self._io.read_bits_int_be(1) != 0 + self.f_t = self._io.read_bits_int_be(4) + self.not_used_2 = self._io.read_bits_int_be(3) + self.n_t = self._io.read_bits_int_be(11) + self.n = self._io.read_bits_int_be(5) + self.m = self._io.read_bits_int_be(2) + + @property + def tau_n(self): + if hasattr(self, '_m_tau_n'): + return self._m_tau_n + + self._m_tau_n = ((self.tau_n_value * -1) if self.tau_n_sign else self.tau_n_value) + return getattr(self, '_m_tau_n', None) + + @property + def delta_tau_n(self): + if hasattr(self, '_m_delta_tau_n'): + return self._m_delta_tau_n + + self._m_delta_tau_n = ((self.delta_tau_n_value * -1) if self.delta_tau_n_sign else self.delta_tau_n_value) + return getattr(self, '_m_delta_tau_n', None) + + + class StringNonImmediate(KaitaiStruct): + def __init__(self, _io, _parent=None, _root=None): + self._io = _io + self._parent = _parent + self._root = _root if _root else self + self._read() + + def _read(self): + self.data_1 = self._io.read_bits_int_be(64) + self.data_2 = self._io.read_bits_int_be(8) + + + class String5(KaitaiStruct): + def __init__(self, _io, _parent=None, _root=None): + self._io = _io + self._parent = _parent + self._root = _root if _root else self + self._read() + + def _read(self): + self.n_a = self._io.read_bits_int_be(11) + self.tau_c = self._io.read_bits_int_be(32) + self.not_used = self._io.read_bits_int_be(1) != 0 + self.n_4 = self._io.read_bits_int_be(5) + self.tau_gps = self._io.read_bits_int_be(22) + self.l_n = self._io.read_bits_int_be(1) != 0 + + + class String1(KaitaiStruct): + def __init__(self, _io, _parent=None, _root=None): + self._io = _io + self._parent = _parent + self._root = _root if _root else self + self._read() + + def _read(self): + self.not_used = self._io.read_bits_int_be(2) + self.p1 = self._io.read_bits_int_be(2) + self.t_k = self._io.read_bits_int_be(12) + self.x_vel_sign = self._io.read_bits_int_be(1) != 0 + self.x_vel_value = self._io.read_bits_int_be(23) + self.x_accel_sign = self._io.read_bits_int_be(1) != 0 + self.x_accel_value = self._io.read_bits_int_be(4) + self.x_sign = self._io.read_bits_int_be(1) != 0 + self.x_value = self._io.read_bits_int_be(26) + + @property + def x_vel(self): + if hasattr(self, '_m_x_vel'): + return self._m_x_vel + + self._m_x_vel = ((self.x_vel_value * -1) if self.x_vel_sign else self.x_vel_value) + return getattr(self, '_m_x_vel', None) + + @property + def x_accel(self): + if hasattr(self, '_m_x_accel'): + return self._m_x_accel + + self._m_x_accel = ((self.x_accel_value * -1) if self.x_accel_sign else self.x_accel_value) + return getattr(self, '_m_x_accel', None) + + @property + def x(self): + if hasattr(self, '_m_x'): + return self._m_x + + self._m_x = ((self.x_value * -1) if self.x_sign else self.x_value) + return getattr(self, '_m_x', None) + + + class String2(KaitaiStruct): + def __init__(self, _io, _parent=None, _root=None): + self._io = _io + self._parent = _parent + self._root = _root if _root else self + self._read() + + def _read(self): + self.b_n = self._io.read_bits_int_be(3) + self.p2 = self._io.read_bits_int_be(1) != 0 + self.t_b = self._io.read_bits_int_be(7) + self.not_used = self._io.read_bits_int_be(5) + self.y_vel_sign = self._io.read_bits_int_be(1) != 0 + self.y_vel_value = self._io.read_bits_int_be(23) + self.y_accel_sign = self._io.read_bits_int_be(1) != 0 + self.y_accel_value = self._io.read_bits_int_be(4) + self.y_sign = self._io.read_bits_int_be(1) != 0 + self.y_value = self._io.read_bits_int_be(26) + + @property + def y_vel(self): + if hasattr(self, '_m_y_vel'): + return self._m_y_vel + + self._m_y_vel = ((self.y_vel_value * -1) if self.y_vel_sign else self.y_vel_value) + return getattr(self, '_m_y_vel', None) + + @property + def y_accel(self): + if hasattr(self, '_m_y_accel'): + return self._m_y_accel + + self._m_y_accel = ((self.y_accel_value * -1) if self.y_accel_sign else self.y_accel_value) + return getattr(self, '_m_y_accel', None) + + @property + def y(self): + if hasattr(self, '_m_y'): + return self._m_y + + self._m_y = ((self.y_value * -1) if self.y_sign else self.y_value) + return getattr(self, '_m_y', None) + + + class String3(KaitaiStruct): + def __init__(self, _io, _parent=None, _root=None): + self._io = _io + self._parent = _parent + self._root = _root if _root else self + self._read() + + def _read(self): + self.p3 = self._io.read_bits_int_be(1) != 0 + self.gamma_n_sign = self._io.read_bits_int_be(1) != 0 + self.gamma_n_value = self._io.read_bits_int_be(10) + self.not_used = self._io.read_bits_int_be(1) != 0 + self.p = self._io.read_bits_int_be(2) + self.l_n = self._io.read_bits_int_be(1) != 0 + self.z_vel_sign = self._io.read_bits_int_be(1) != 0 + self.z_vel_value = self._io.read_bits_int_be(23) + self.z_accel_sign = self._io.read_bits_int_be(1) != 0 + self.z_accel_value = self._io.read_bits_int_be(4) + self.z_sign = self._io.read_bits_int_be(1) != 0 + self.z_value = self._io.read_bits_int_be(26) + + @property + def gamma_n(self): + if hasattr(self, '_m_gamma_n'): + return self._m_gamma_n + + self._m_gamma_n = ((self.gamma_n_value * -1) if self.gamma_n_sign else self.gamma_n_value) + return getattr(self, '_m_gamma_n', None) + + @property + def z_vel(self): + if hasattr(self, '_m_z_vel'): + return self._m_z_vel + + self._m_z_vel = ((self.z_vel_value * -1) if self.z_vel_sign else self.z_vel_value) + return getattr(self, '_m_z_vel', None) + + @property + def z_accel(self): + if hasattr(self, '_m_z_accel'): + return self._m_z_accel + + self._m_z_accel = ((self.z_accel_value * -1) if self.z_accel_sign else self.z_accel_value) + return getattr(self, '_m_z_accel', None) + + @property + def z(self): + if hasattr(self, '_m_z'): + return self._m_z + + self._m_z = ((self.z_value * -1) if self.z_sign else self.z_value) + return getattr(self, '_m_z', None) + + diff --git a/system/ubloxd/generated/gps.cpp b/system/ubloxd/generated/gps.cpp deleted file mode 100644 index 8e1cb85b95..0000000000 --- a/system/ubloxd/generated/gps.cpp +++ /dev/null @@ -1,325 +0,0 @@ -// This is a generated file! Please edit source .ksy file and use kaitai-struct-compiler to rebuild - -#include "gps.h" -#include "kaitai/exceptions.h" - -gps_t::gps_t(kaitai::kstream* p__io, kaitai::kstruct* p__parent, gps_t* p__root) : kaitai::kstruct(p__io) { - m__parent = p__parent; - m__root = this; - m_tlm = 0; - m_how = 0; - - try { - _read(); - } catch(...) { - _clean_up(); - throw; - } -} - -void gps_t::_read() { - m_tlm = new tlm_t(m__io, this, m__root); - m_how = new how_t(m__io, this, m__root); - n_body = true; - switch (how()->subframe_id()) { - case 1: { - n_body = false; - m_body = new subframe_1_t(m__io, this, m__root); - break; - } - case 2: { - n_body = false; - m_body = new subframe_2_t(m__io, this, m__root); - break; - } - case 3: { - n_body = false; - m_body = new subframe_3_t(m__io, this, m__root); - break; - } - case 4: { - n_body = false; - m_body = new subframe_4_t(m__io, this, m__root); - break; - } - } -} - -gps_t::~gps_t() { - _clean_up(); -} - -void gps_t::_clean_up() { - if (m_tlm) { - delete m_tlm; m_tlm = 0; - } - if (m_how) { - delete m_how; m_how = 0; - } - if (!n_body) { - if (m_body) { - delete m_body; m_body = 0; - } - } -} - -gps_t::subframe_1_t::subframe_1_t(kaitai::kstream* p__io, gps_t* p__parent, gps_t* p__root) : kaitai::kstruct(p__io) { - m__parent = p__parent; - m__root = p__root; - f_af_0 = false; - - try { - _read(); - } catch(...) { - _clean_up(); - throw; - } -} - -void gps_t::subframe_1_t::_read() { - m_week_no = m__io->read_bits_int_be(10); - m_code = m__io->read_bits_int_be(2); - m_sv_accuracy = m__io->read_bits_int_be(4); - m_sv_health = m__io->read_bits_int_be(6); - m_iodc_msb = m__io->read_bits_int_be(2); - m_l2_p_data_flag = m__io->read_bits_int_be(1); - m_reserved1 = m__io->read_bits_int_be(23); - m_reserved2 = m__io->read_bits_int_be(24); - m_reserved3 = m__io->read_bits_int_be(24); - m_reserved4 = m__io->read_bits_int_be(16); - m__io->align_to_byte(); - m_t_gd = m__io->read_s1(); - m_iodc_lsb = m__io->read_u1(); - m_t_oc = m__io->read_u2be(); - m_af_2 = m__io->read_s1(); - m_af_1 = m__io->read_s2be(); - m_af_0_sign = m__io->read_bits_int_be(1); - m_af_0_value = m__io->read_bits_int_be(21); - m_reserved5 = m__io->read_bits_int_be(2); -} - -gps_t::subframe_1_t::~subframe_1_t() { - _clean_up(); -} - -void gps_t::subframe_1_t::_clean_up() { -} - -int32_t gps_t::subframe_1_t::af_0() { - if (f_af_0) - return m_af_0; - m_af_0 = ((af_0_sign()) ? ((af_0_value() - (1 << 21))) : (af_0_value())); - f_af_0 = true; - return m_af_0; -} - -gps_t::subframe_3_t::subframe_3_t(kaitai::kstream* p__io, gps_t* p__parent, gps_t* p__root) : kaitai::kstruct(p__io) { - m__parent = p__parent; - m__root = p__root; - f_omega_dot = false; - f_idot = false; - - try { - _read(); - } catch(...) { - _clean_up(); - throw; - } -} - -void gps_t::subframe_3_t::_read() { - m_c_ic = m__io->read_s2be(); - m_omega_0 = m__io->read_s4be(); - m_c_is = m__io->read_s2be(); - m_i_0 = m__io->read_s4be(); - m_c_rc = m__io->read_s2be(); - m_omega = m__io->read_s4be(); - m_omega_dot_sign = m__io->read_bits_int_be(1); - m_omega_dot_value = m__io->read_bits_int_be(23); - m__io->align_to_byte(); - m_iode = m__io->read_u1(); - m_idot_sign = m__io->read_bits_int_be(1); - m_idot_value = m__io->read_bits_int_be(13); - m_reserved = m__io->read_bits_int_be(2); -} - -gps_t::subframe_3_t::~subframe_3_t() { - _clean_up(); -} - -void gps_t::subframe_3_t::_clean_up() { -} - -int32_t gps_t::subframe_3_t::omega_dot() { - if (f_omega_dot) - return m_omega_dot; - m_omega_dot = ((omega_dot_sign()) ? ((omega_dot_value() - (1 << 23))) : (omega_dot_value())); - f_omega_dot = true; - return m_omega_dot; -} - -int32_t gps_t::subframe_3_t::idot() { - if (f_idot) - return m_idot; - m_idot = ((idot_sign()) ? ((idot_value() - (1 << 13))) : (idot_value())); - f_idot = true; - return m_idot; -} - -gps_t::subframe_4_t::subframe_4_t(kaitai::kstream* p__io, gps_t* p__parent, gps_t* p__root) : kaitai::kstruct(p__io) { - m__parent = p__parent; - m__root = p__root; - - try { - _read(); - } catch(...) { - _clean_up(); - throw; - } -} - -void gps_t::subframe_4_t::_read() { - m_data_id = m__io->read_bits_int_be(2); - m_page_id = m__io->read_bits_int_be(6); - m__io->align_to_byte(); - n_body = true; - switch (page_id()) { - case 56: { - n_body = false; - m_body = new ionosphere_data_t(m__io, this, m__root); - break; - } - } -} - -gps_t::subframe_4_t::~subframe_4_t() { - _clean_up(); -} - -void gps_t::subframe_4_t::_clean_up() { - if (!n_body) { - if (m_body) { - delete m_body; m_body = 0; - } - } -} - -gps_t::subframe_4_t::ionosphere_data_t::ionosphere_data_t(kaitai::kstream* p__io, gps_t::subframe_4_t* p__parent, gps_t* p__root) : kaitai::kstruct(p__io) { - m__parent = p__parent; - m__root = p__root; - - try { - _read(); - } catch(...) { - _clean_up(); - throw; - } -} - -void gps_t::subframe_4_t::ionosphere_data_t::_read() { - m_a0 = m__io->read_s1(); - m_a1 = m__io->read_s1(); - m_a2 = m__io->read_s1(); - m_a3 = m__io->read_s1(); - m_b0 = m__io->read_s1(); - m_b1 = m__io->read_s1(); - m_b2 = m__io->read_s1(); - m_b3 = m__io->read_s1(); -} - -gps_t::subframe_4_t::ionosphere_data_t::~ionosphere_data_t() { - _clean_up(); -} - -void gps_t::subframe_4_t::ionosphere_data_t::_clean_up() { -} - -gps_t::how_t::how_t(kaitai::kstream* p__io, gps_t* p__parent, gps_t* p__root) : kaitai::kstruct(p__io) { - m__parent = p__parent; - m__root = p__root; - - try { - _read(); - } catch(...) { - _clean_up(); - throw; - } -} - -void gps_t::how_t::_read() { - m_tow_count = m__io->read_bits_int_be(17); - m_alert = m__io->read_bits_int_be(1); - m_anti_spoof = m__io->read_bits_int_be(1); - m_subframe_id = m__io->read_bits_int_be(3); - m_reserved = m__io->read_bits_int_be(2); -} - -gps_t::how_t::~how_t() { - _clean_up(); -} - -void gps_t::how_t::_clean_up() { -} - -gps_t::tlm_t::tlm_t(kaitai::kstream* p__io, gps_t* p__parent, gps_t* p__root) : kaitai::kstruct(p__io) { - m__parent = p__parent; - m__root = p__root; - - try { - _read(); - } catch(...) { - _clean_up(); - throw; - } -} - -void gps_t::tlm_t::_read() { - m_preamble = m__io->read_bytes(1); - if (!(preamble() == std::string("\x8B", 1))) { - throw kaitai::validation_not_equal_error(std::string("\x8B", 1), preamble(), _io(), std::string("/types/tlm/seq/0")); - } - m_tlm = m__io->read_bits_int_be(14); - m_integrity_status = m__io->read_bits_int_be(1); - m_reserved = m__io->read_bits_int_be(1); -} - -gps_t::tlm_t::~tlm_t() { - _clean_up(); -} - -void gps_t::tlm_t::_clean_up() { -} - -gps_t::subframe_2_t::subframe_2_t(kaitai::kstream* p__io, gps_t* p__parent, gps_t* p__root) : kaitai::kstruct(p__io) { - m__parent = p__parent; - m__root = p__root; - - try { - _read(); - } catch(...) { - _clean_up(); - throw; - } -} - -void gps_t::subframe_2_t::_read() { - m_iode = m__io->read_u1(); - m_c_rs = m__io->read_s2be(); - m_delta_n = m__io->read_s2be(); - m_m_0 = m__io->read_s4be(); - m_c_uc = m__io->read_s2be(); - m_e = m__io->read_s4be(); - m_c_us = m__io->read_s2be(); - m_sqrt_a = m__io->read_u4be(); - m_t_oe = m__io->read_u2be(); - m_fit_interval_flag = m__io->read_bits_int_be(1); - m_aoda = m__io->read_bits_int_be(5); - m_reserved = m__io->read_bits_int_be(2); -} - -gps_t::subframe_2_t::~subframe_2_t() { - _clean_up(); -} - -void gps_t::subframe_2_t::_clean_up() { -} diff --git a/system/ubloxd/generated/gps.h b/system/ubloxd/generated/gps.h deleted file mode 100644 index 9dfc5031f5..0000000000 --- a/system/ubloxd/generated/gps.h +++ /dev/null @@ -1,359 +0,0 @@ -#ifndef GPS_H_ -#define GPS_H_ - -// This is a generated file! Please edit source .ksy file and use kaitai-struct-compiler to rebuild - -#include "kaitai/kaitaistruct.h" -#include - -#if KAITAI_STRUCT_VERSION < 9000L -#error "Incompatible Kaitai Struct C++/STL API: version 0.9 or later is required" -#endif - -class gps_t : public kaitai::kstruct { - -public: - class subframe_1_t; - class subframe_3_t; - class subframe_4_t; - class how_t; - class tlm_t; - class subframe_2_t; - - gps_t(kaitai::kstream* p__io, kaitai::kstruct* p__parent = 0, gps_t* p__root = 0); - -private: - void _read(); - void _clean_up(); - -public: - ~gps_t(); - - class subframe_1_t : public kaitai::kstruct { - - public: - - subframe_1_t(kaitai::kstream* p__io, gps_t* p__parent = 0, gps_t* p__root = 0); - - private: - void _read(); - void _clean_up(); - - public: - ~subframe_1_t(); - - private: - bool f_af_0; - int32_t m_af_0; - - public: - int32_t af_0(); - - private: - uint64_t m_week_no; - uint64_t m_code; - uint64_t m_sv_accuracy; - uint64_t m_sv_health; - uint64_t m_iodc_msb; - bool m_l2_p_data_flag; - uint64_t m_reserved1; - uint64_t m_reserved2; - uint64_t m_reserved3; - uint64_t m_reserved4; - int8_t m_t_gd; - uint8_t m_iodc_lsb; - uint16_t m_t_oc; - int8_t m_af_2; - int16_t m_af_1; - bool m_af_0_sign; - uint64_t m_af_0_value; - uint64_t m_reserved5; - gps_t* m__root; - gps_t* m__parent; - - public: - uint64_t week_no() const { return m_week_no; } - uint64_t code() const { return m_code; } - uint64_t sv_accuracy() const { return m_sv_accuracy; } - uint64_t sv_health() const { return m_sv_health; } - uint64_t iodc_msb() const { return m_iodc_msb; } - bool l2_p_data_flag() const { return m_l2_p_data_flag; } - uint64_t reserved1() const { return m_reserved1; } - uint64_t reserved2() const { return m_reserved2; } - uint64_t reserved3() const { return m_reserved3; } - uint64_t reserved4() const { return m_reserved4; } - int8_t t_gd() const { return m_t_gd; } - uint8_t iodc_lsb() const { return m_iodc_lsb; } - uint16_t t_oc() const { return m_t_oc; } - int8_t af_2() const { return m_af_2; } - int16_t af_1() const { return m_af_1; } - bool af_0_sign() const { return m_af_0_sign; } - uint64_t af_0_value() const { return m_af_0_value; } - uint64_t reserved5() const { return m_reserved5; } - gps_t* _root() const { return m__root; } - gps_t* _parent() const { return m__parent; } - }; - - class subframe_3_t : public kaitai::kstruct { - - public: - - subframe_3_t(kaitai::kstream* p__io, gps_t* p__parent = 0, gps_t* p__root = 0); - - private: - void _read(); - void _clean_up(); - - public: - ~subframe_3_t(); - - private: - bool f_omega_dot; - int32_t m_omega_dot; - - public: - int32_t omega_dot(); - - private: - bool f_idot; - int32_t m_idot; - - public: - int32_t idot(); - - private: - int16_t m_c_ic; - int32_t m_omega_0; - int16_t m_c_is; - int32_t m_i_0; - int16_t m_c_rc; - int32_t m_omega; - bool m_omega_dot_sign; - uint64_t m_omega_dot_value; - uint8_t m_iode; - bool m_idot_sign; - uint64_t m_idot_value; - uint64_t m_reserved; - gps_t* m__root; - gps_t* m__parent; - - public: - int16_t c_ic() const { return m_c_ic; } - int32_t omega_0() const { return m_omega_0; } - int16_t c_is() const { return m_c_is; } - int32_t i_0() const { return m_i_0; } - int16_t c_rc() const { return m_c_rc; } - int32_t omega() const { return m_omega; } - bool omega_dot_sign() const { return m_omega_dot_sign; } - uint64_t omega_dot_value() const { return m_omega_dot_value; } - uint8_t iode() const { return m_iode; } - bool idot_sign() const { return m_idot_sign; } - uint64_t idot_value() const { return m_idot_value; } - uint64_t reserved() const { return m_reserved; } - gps_t* _root() const { return m__root; } - gps_t* _parent() const { return m__parent; } - }; - - class subframe_4_t : public kaitai::kstruct { - - public: - class ionosphere_data_t; - - subframe_4_t(kaitai::kstream* p__io, gps_t* p__parent = 0, gps_t* p__root = 0); - - private: - void _read(); - void _clean_up(); - - public: - ~subframe_4_t(); - - class ionosphere_data_t : public kaitai::kstruct { - - public: - - ionosphere_data_t(kaitai::kstream* p__io, gps_t::subframe_4_t* p__parent = 0, gps_t* p__root = 0); - - private: - void _read(); - void _clean_up(); - - public: - ~ionosphere_data_t(); - - private: - int8_t m_a0; - int8_t m_a1; - int8_t m_a2; - int8_t m_a3; - int8_t m_b0; - int8_t m_b1; - int8_t m_b2; - int8_t m_b3; - gps_t* m__root; - gps_t::subframe_4_t* m__parent; - - public: - int8_t a0() const { return m_a0; } - int8_t a1() const { return m_a1; } - int8_t a2() const { return m_a2; } - int8_t a3() const { return m_a3; } - int8_t b0() const { return m_b0; } - int8_t b1() const { return m_b1; } - int8_t b2() const { return m_b2; } - int8_t b3() const { return m_b3; } - gps_t* _root() const { return m__root; } - gps_t::subframe_4_t* _parent() const { return m__parent; } - }; - - private: - uint64_t m_data_id; - uint64_t m_page_id; - ionosphere_data_t* m_body; - bool n_body; - - public: - bool _is_null_body() { body(); return n_body; }; - - private: - gps_t* m__root; - gps_t* m__parent; - - public: - uint64_t data_id() const { return m_data_id; } - uint64_t page_id() const { return m_page_id; } - ionosphere_data_t* body() const { return m_body; } - gps_t* _root() const { return m__root; } - gps_t* _parent() const { return m__parent; } - }; - - class how_t : public kaitai::kstruct { - - public: - - how_t(kaitai::kstream* p__io, gps_t* p__parent = 0, gps_t* p__root = 0); - - private: - void _read(); - void _clean_up(); - - public: - ~how_t(); - - private: - uint64_t m_tow_count; - bool m_alert; - bool m_anti_spoof; - uint64_t m_subframe_id; - uint64_t m_reserved; - gps_t* m__root; - gps_t* m__parent; - - public: - uint64_t tow_count() const { return m_tow_count; } - bool alert() const { return m_alert; } - bool anti_spoof() const { return m_anti_spoof; } - uint64_t subframe_id() const { return m_subframe_id; } - uint64_t reserved() const { return m_reserved; } - gps_t* _root() const { return m__root; } - gps_t* _parent() const { return m__parent; } - }; - - class tlm_t : public kaitai::kstruct { - - public: - - tlm_t(kaitai::kstream* p__io, gps_t* p__parent = 0, gps_t* p__root = 0); - - private: - void _read(); - void _clean_up(); - - public: - ~tlm_t(); - - private: - std::string m_preamble; - uint64_t m_tlm; - bool m_integrity_status; - bool m_reserved; - gps_t* m__root; - gps_t* m__parent; - - public: - std::string preamble() const { return m_preamble; } - uint64_t tlm() const { return m_tlm; } - bool integrity_status() const { return m_integrity_status; } - bool reserved() const { return m_reserved; } - gps_t* _root() const { return m__root; } - gps_t* _parent() const { return m__parent; } - }; - - class subframe_2_t : public kaitai::kstruct { - - public: - - subframe_2_t(kaitai::kstream* p__io, gps_t* p__parent = 0, gps_t* p__root = 0); - - private: - void _read(); - void _clean_up(); - - public: - ~subframe_2_t(); - - private: - uint8_t m_iode; - int16_t m_c_rs; - int16_t m_delta_n; - int32_t m_m_0; - int16_t m_c_uc; - int32_t m_e; - int16_t m_c_us; - uint32_t m_sqrt_a; - uint16_t m_t_oe; - bool m_fit_interval_flag; - uint64_t m_aoda; - uint64_t m_reserved; - gps_t* m__root; - gps_t* m__parent; - - public: - uint8_t iode() const { return m_iode; } - int16_t c_rs() const { return m_c_rs; } - int16_t delta_n() const { return m_delta_n; } - int32_t m_0() const { return m_m_0; } - int16_t c_uc() const { return m_c_uc; } - int32_t e() const { return m_e; } - int16_t c_us() const { return m_c_us; } - uint32_t sqrt_a() const { return m_sqrt_a; } - uint16_t t_oe() const { return m_t_oe; } - bool fit_interval_flag() const { return m_fit_interval_flag; } - uint64_t aoda() const { return m_aoda; } - uint64_t reserved() const { return m_reserved; } - gps_t* _root() const { return m__root; } - gps_t* _parent() const { return m__parent; } - }; - -private: - tlm_t* m_tlm; - how_t* m_how; - kaitai::kstruct* m_body; - bool n_body; - -public: - bool _is_null_body() { body(); return n_body; }; - -private: - gps_t* m__root; - kaitai::kstruct* m__parent; - -public: - tlm_t* tlm() const { return m_tlm; } - how_t* how() const { return m_how; } - kaitai::kstruct* body() const { return m_body; } - gps_t* _root() const { return m__root; } - kaitai::kstruct* _parent() const { return m__parent; } -}; - -#endif // GPS_H_ diff --git a/system/ubloxd/generated/gps.py b/system/ubloxd/generated/gps.py new file mode 100644 index 0000000000..a999016f3e --- /dev/null +++ b/system/ubloxd/generated/gps.py @@ -0,0 +1,193 @@ +# This is a generated file! Please edit source .ksy file and use kaitai-struct-compiler to rebuild + +import kaitaistruct +from kaitaistruct import KaitaiStruct, KaitaiStream, BytesIO + + +if getattr(kaitaistruct, 'API_VERSION', (0, 9)) < (0, 9): + raise Exception("Incompatible Kaitai Struct Python API: 0.9 or later is required, but you have %s" % (kaitaistruct.__version__)) + +class Gps(KaitaiStruct): + def __init__(self, _io, _parent=None, _root=None): + self._io = _io + self._parent = _parent + self._root = _root if _root else self + self._read() + + def _read(self): + self.tlm = Gps.Tlm(self._io, self, self._root) + self.how = Gps.How(self._io, self, self._root) + _on = self.how.subframe_id + if _on == 1: + self.body = Gps.Subframe1(self._io, self, self._root) + elif _on == 2: + self.body = Gps.Subframe2(self._io, self, self._root) + elif _on == 3: + self.body = Gps.Subframe3(self._io, self, self._root) + elif _on == 4: + self.body = Gps.Subframe4(self._io, self, self._root) + + class Subframe1(KaitaiStruct): + def __init__(self, _io, _parent=None, _root=None): + self._io = _io + self._parent = _parent + self._root = _root if _root else self + self._read() + + def _read(self): + self.week_no = self._io.read_bits_int_be(10) + self.code = self._io.read_bits_int_be(2) + self.sv_accuracy = self._io.read_bits_int_be(4) + self.sv_health = self._io.read_bits_int_be(6) + self.iodc_msb = self._io.read_bits_int_be(2) + self.l2_p_data_flag = self._io.read_bits_int_be(1) != 0 + self.reserved1 = self._io.read_bits_int_be(23) + self.reserved2 = self._io.read_bits_int_be(24) + self.reserved3 = self._io.read_bits_int_be(24) + self.reserved4 = self._io.read_bits_int_be(16) + self._io.align_to_byte() + self.t_gd = self._io.read_s1() + self.iodc_lsb = self._io.read_u1() + self.t_oc = self._io.read_u2be() + self.af_2 = self._io.read_s1() + self.af_1 = self._io.read_s2be() + self.af_0_sign = self._io.read_bits_int_be(1) != 0 + self.af_0_value = self._io.read_bits_int_be(21) + self.reserved5 = self._io.read_bits_int_be(2) + + @property + def af_0(self): + if hasattr(self, '_m_af_0'): + return self._m_af_0 + + self._m_af_0 = ((self.af_0_value - (1 << 21)) if self.af_0_sign else self.af_0_value) + return getattr(self, '_m_af_0', None) + + + class Subframe3(KaitaiStruct): + def __init__(self, _io, _parent=None, _root=None): + self._io = _io + self._parent = _parent + self._root = _root if _root else self + self._read() + + def _read(self): + self.c_ic = self._io.read_s2be() + self.omega_0 = self._io.read_s4be() + self.c_is = self._io.read_s2be() + self.i_0 = self._io.read_s4be() + self.c_rc = self._io.read_s2be() + self.omega = self._io.read_s4be() + self.omega_dot_sign = self._io.read_bits_int_be(1) != 0 + self.omega_dot_value = self._io.read_bits_int_be(23) + self._io.align_to_byte() + self.iode = self._io.read_u1() + self.idot_sign = self._io.read_bits_int_be(1) != 0 + self.idot_value = self._io.read_bits_int_be(13) + self.reserved = self._io.read_bits_int_be(2) + + @property + def omega_dot(self): + if hasattr(self, '_m_omega_dot'): + return self._m_omega_dot + + self._m_omega_dot = ((self.omega_dot_value - (1 << 23)) if self.omega_dot_sign else self.omega_dot_value) + return getattr(self, '_m_omega_dot', None) + + @property + def idot(self): + if hasattr(self, '_m_idot'): + return self._m_idot + + self._m_idot = ((self.idot_value - (1 << 13)) if self.idot_sign else self.idot_value) + return getattr(self, '_m_idot', None) + + + class Subframe4(KaitaiStruct): + def __init__(self, _io, _parent=None, _root=None): + self._io = _io + self._parent = _parent + self._root = _root if _root else self + self._read() + + def _read(self): + self.data_id = self._io.read_bits_int_be(2) + self.page_id = self._io.read_bits_int_be(6) + self._io.align_to_byte() + _on = self.page_id + if _on == 56: + self.body = Gps.Subframe4.IonosphereData(self._io, self, self._root) + + class IonosphereData(KaitaiStruct): + def __init__(self, _io, _parent=None, _root=None): + self._io = _io + self._parent = _parent + self._root = _root if _root else self + self._read() + + def _read(self): + self.a0 = self._io.read_s1() + self.a1 = self._io.read_s1() + self.a2 = self._io.read_s1() + self.a3 = self._io.read_s1() + self.b0 = self._io.read_s1() + self.b1 = self._io.read_s1() + self.b2 = self._io.read_s1() + self.b3 = self._io.read_s1() + + + + class How(KaitaiStruct): + def __init__(self, _io, _parent=None, _root=None): + self._io = _io + self._parent = _parent + self._root = _root if _root else self + self._read() + + def _read(self): + self.tow_count = self._io.read_bits_int_be(17) + self.alert = self._io.read_bits_int_be(1) != 0 + self.anti_spoof = self._io.read_bits_int_be(1) != 0 + self.subframe_id = self._io.read_bits_int_be(3) + self.reserved = self._io.read_bits_int_be(2) + + + class Tlm(KaitaiStruct): + def __init__(self, _io, _parent=None, _root=None): + self._io = _io + self._parent = _parent + self._root = _root if _root else self + self._read() + + def _read(self): + self.preamble = self._io.read_bytes(1) + if not self.preamble == b"\x8B": + raise kaitaistruct.ValidationNotEqualError(b"\x8B", self.preamble, self._io, u"/types/tlm/seq/0") + self.tlm = self._io.read_bits_int_be(14) + self.integrity_status = self._io.read_bits_int_be(1) != 0 + self.reserved = self._io.read_bits_int_be(1) != 0 + + + class Subframe2(KaitaiStruct): + def __init__(self, _io, _parent=None, _root=None): + self._io = _io + self._parent = _parent + self._root = _root if _root else self + self._read() + + def _read(self): + self.iode = self._io.read_u1() + self.c_rs = self._io.read_s2be() + self.delta_n = self._io.read_s2be() + self.m_0 = self._io.read_s4be() + self.c_uc = self._io.read_s2be() + self.e = self._io.read_s4be() + self.c_us = self._io.read_s2be() + self.sqrt_a = self._io.read_u4be() + self.t_oe = self._io.read_u2be() + self.fit_interval_flag = self._io.read_bits_int_be(1) != 0 + self.aoda = self._io.read_bits_int_be(5) + self.reserved = self._io.read_bits_int_be(2) + + + diff --git a/system/ubloxd/generated/ubx.cpp b/system/ubloxd/generated/ubx.cpp deleted file mode 100644 index 81b82ccafc..0000000000 --- a/system/ubloxd/generated/ubx.cpp +++ /dev/null @@ -1,424 +0,0 @@ -// This is a generated file! Please edit source .ksy file and use kaitai-struct-compiler to rebuild - -#include "ubx.h" -#include "kaitai/exceptions.h" - -ubx_t::ubx_t(kaitai::kstream* p__io, kaitai::kstruct* p__parent, ubx_t* p__root) : kaitai::kstruct(p__io) { - m__parent = p__parent; - m__root = this; - f_checksum = false; - - try { - _read(); - } catch(...) { - _clean_up(); - throw; - } -} - -void ubx_t::_read() { - m_magic = m__io->read_bytes(2); - if (!(magic() == std::string("\xB5\x62", 2))) { - throw kaitai::validation_not_equal_error(std::string("\xB5\x62", 2), magic(), _io(), std::string("/seq/0")); - } - m_msg_type = m__io->read_u2be(); - m_length = m__io->read_u2le(); - n_body = true; - switch (msg_type()) { - case 2569: { - n_body = false; - m_body = new mon_hw_t(m__io, this, m__root); - break; - } - case 533: { - n_body = false; - m_body = new rxm_rawx_t(m__io, this, m__root); - break; - } - case 531: { - n_body = false; - m_body = new rxm_sfrbx_t(m__io, this, m__root); - break; - } - case 309: { - n_body = false; - m_body = new nav_sat_t(m__io, this, m__root); - break; - } - case 2571: { - n_body = false; - m_body = new mon_hw2_t(m__io, this, m__root); - break; - } - case 263: { - n_body = false; - m_body = new nav_pvt_t(m__io, this, m__root); - break; - } - } -} - -ubx_t::~ubx_t() { - _clean_up(); -} - -void ubx_t::_clean_up() { - if (!n_body) { - if (m_body) { - delete m_body; m_body = 0; - } - } - if (f_checksum) { - } -} - -ubx_t::rxm_rawx_t::rxm_rawx_t(kaitai::kstream* p__io, ubx_t* p__parent, ubx_t* p__root) : kaitai::kstruct(p__io) { - m__parent = p__parent; - m__root = p__root; - m_meas = 0; - m__raw_meas = 0; - m__io__raw_meas = 0; - - try { - _read(); - } catch(...) { - _clean_up(); - throw; - } -} - -void ubx_t::rxm_rawx_t::_read() { - m_rcv_tow = m__io->read_f8le(); - m_week = m__io->read_u2le(); - m_leap_s = m__io->read_s1(); - m_num_meas = m__io->read_u1(); - m_rec_stat = m__io->read_u1(); - m_reserved1 = m__io->read_bytes(3); - m__raw_meas = new std::vector(); - m__io__raw_meas = new std::vector(); - m_meas = new std::vector(); - const int l_meas = num_meas(); - for (int i = 0; i < l_meas; i++) { - m__raw_meas->push_back(m__io->read_bytes(32)); - kaitai::kstream* io__raw_meas = new kaitai::kstream(m__raw_meas->at(m__raw_meas->size() - 1)); - m__io__raw_meas->push_back(io__raw_meas); - m_meas->push_back(new measurement_t(io__raw_meas, this, m__root)); - } -} - -ubx_t::rxm_rawx_t::~rxm_rawx_t() { - _clean_up(); -} - -void ubx_t::rxm_rawx_t::_clean_up() { - if (m__raw_meas) { - delete m__raw_meas; m__raw_meas = 0; - } - if (m__io__raw_meas) { - for (std::vector::iterator it = m__io__raw_meas->begin(); it != m__io__raw_meas->end(); ++it) { - delete *it; - } - delete m__io__raw_meas; m__io__raw_meas = 0; - } - if (m_meas) { - for (std::vector::iterator it = m_meas->begin(); it != m_meas->end(); ++it) { - delete *it; - } - delete m_meas; m_meas = 0; - } -} - -ubx_t::rxm_rawx_t::measurement_t::measurement_t(kaitai::kstream* p__io, ubx_t::rxm_rawx_t* p__parent, ubx_t* p__root) : kaitai::kstruct(p__io) { - m__parent = p__parent; - m__root = p__root; - - try { - _read(); - } catch(...) { - _clean_up(); - throw; - } -} - -void ubx_t::rxm_rawx_t::measurement_t::_read() { - m_pr_mes = m__io->read_f8le(); - m_cp_mes = m__io->read_f8le(); - m_do_mes = m__io->read_f4le(); - m_gnss_id = static_cast(m__io->read_u1()); - m_sv_id = m__io->read_u1(); - m_reserved2 = m__io->read_bytes(1); - m_freq_id = m__io->read_u1(); - m_lock_time = m__io->read_u2le(); - m_cno = m__io->read_u1(); - m_pr_stdev = m__io->read_u1(); - m_cp_stdev = m__io->read_u1(); - m_do_stdev = m__io->read_u1(); - m_trk_stat = m__io->read_u1(); - m_reserved3 = m__io->read_bytes(1); -} - -ubx_t::rxm_rawx_t::measurement_t::~measurement_t() { - _clean_up(); -} - -void ubx_t::rxm_rawx_t::measurement_t::_clean_up() { -} - -ubx_t::rxm_sfrbx_t::rxm_sfrbx_t(kaitai::kstream* p__io, ubx_t* p__parent, ubx_t* p__root) : kaitai::kstruct(p__io) { - m__parent = p__parent; - m__root = p__root; - m_body = 0; - - try { - _read(); - } catch(...) { - _clean_up(); - throw; - } -} - -void ubx_t::rxm_sfrbx_t::_read() { - m_gnss_id = static_cast(m__io->read_u1()); - m_sv_id = m__io->read_u1(); - m_reserved1 = m__io->read_bytes(1); - m_freq_id = m__io->read_u1(); - m_num_words = m__io->read_u1(); - m_reserved2 = m__io->read_bytes(1); - m_version = m__io->read_u1(); - m_reserved3 = m__io->read_bytes(1); - m_body = new std::vector(); - const int l_body = num_words(); - for (int i = 0; i < l_body; i++) { - m_body->push_back(m__io->read_u4le()); - } -} - -ubx_t::rxm_sfrbx_t::~rxm_sfrbx_t() { - _clean_up(); -} - -void ubx_t::rxm_sfrbx_t::_clean_up() { - if (m_body) { - delete m_body; m_body = 0; - } -} - -ubx_t::nav_sat_t::nav_sat_t(kaitai::kstream* p__io, ubx_t* p__parent, ubx_t* p__root) : kaitai::kstruct(p__io) { - m__parent = p__parent; - m__root = p__root; - m_svs = 0; - m__raw_svs = 0; - m__io__raw_svs = 0; - - try { - _read(); - } catch(...) { - _clean_up(); - throw; - } -} - -void ubx_t::nav_sat_t::_read() { - m_itow = m__io->read_u4le(); - m_version = m__io->read_u1(); - m_num_svs = m__io->read_u1(); - m_reserved = m__io->read_bytes(2); - m__raw_svs = new std::vector(); - m__io__raw_svs = new std::vector(); - m_svs = new std::vector(); - const int l_svs = num_svs(); - for (int i = 0; i < l_svs; i++) { - m__raw_svs->push_back(m__io->read_bytes(12)); - kaitai::kstream* io__raw_svs = new kaitai::kstream(m__raw_svs->at(m__raw_svs->size() - 1)); - m__io__raw_svs->push_back(io__raw_svs); - m_svs->push_back(new nav_t(io__raw_svs, this, m__root)); - } -} - -ubx_t::nav_sat_t::~nav_sat_t() { - _clean_up(); -} - -void ubx_t::nav_sat_t::_clean_up() { - if (m__raw_svs) { - delete m__raw_svs; m__raw_svs = 0; - } - if (m__io__raw_svs) { - for (std::vector::iterator it = m__io__raw_svs->begin(); it != m__io__raw_svs->end(); ++it) { - delete *it; - } - delete m__io__raw_svs; m__io__raw_svs = 0; - } - if (m_svs) { - for (std::vector::iterator it = m_svs->begin(); it != m_svs->end(); ++it) { - delete *it; - } - delete m_svs; m_svs = 0; - } -} - -ubx_t::nav_sat_t::nav_t::nav_t(kaitai::kstream* p__io, ubx_t::nav_sat_t* p__parent, ubx_t* p__root) : kaitai::kstruct(p__io) { - m__parent = p__parent; - m__root = p__root; - - try { - _read(); - } catch(...) { - _clean_up(); - throw; - } -} - -void ubx_t::nav_sat_t::nav_t::_read() { - m_gnss_id = static_cast(m__io->read_u1()); - m_sv_id = m__io->read_u1(); - m_cno = m__io->read_u1(); - m_elev = m__io->read_s1(); - m_azim = m__io->read_s2le(); - m_pr_res = m__io->read_s2le(); - m_flags = m__io->read_u4le(); -} - -ubx_t::nav_sat_t::nav_t::~nav_t() { - _clean_up(); -} - -void ubx_t::nav_sat_t::nav_t::_clean_up() { -} - -ubx_t::nav_pvt_t::nav_pvt_t(kaitai::kstream* p__io, ubx_t* p__parent, ubx_t* p__root) : kaitai::kstruct(p__io) { - m__parent = p__parent; - m__root = p__root; - - try { - _read(); - } catch(...) { - _clean_up(); - throw; - } -} - -void ubx_t::nav_pvt_t::_read() { - m_i_tow = m__io->read_u4le(); - m_year = m__io->read_u2le(); - m_month = m__io->read_u1(); - m_day = m__io->read_u1(); - m_hour = m__io->read_u1(); - m_min = m__io->read_u1(); - m_sec = m__io->read_u1(); - m_valid = m__io->read_u1(); - m_t_acc = m__io->read_u4le(); - m_nano = m__io->read_s4le(); - m_fix_type = m__io->read_u1(); - m_flags = m__io->read_u1(); - m_flags2 = m__io->read_u1(); - m_num_sv = m__io->read_u1(); - m_lon = m__io->read_s4le(); - m_lat = m__io->read_s4le(); - m_height = m__io->read_s4le(); - m_h_msl = m__io->read_s4le(); - m_h_acc = m__io->read_u4le(); - m_v_acc = m__io->read_u4le(); - m_vel_n = m__io->read_s4le(); - m_vel_e = m__io->read_s4le(); - m_vel_d = m__io->read_s4le(); - m_g_speed = m__io->read_s4le(); - m_head_mot = m__io->read_s4le(); - m_s_acc = m__io->read_s4le(); - m_head_acc = m__io->read_u4le(); - m_p_dop = m__io->read_u2le(); - m_flags3 = m__io->read_u1(); - m_reserved1 = m__io->read_bytes(5); - m_head_veh = m__io->read_s4le(); - m_mag_dec = m__io->read_s2le(); - m_mag_acc = m__io->read_u2le(); -} - -ubx_t::nav_pvt_t::~nav_pvt_t() { - _clean_up(); -} - -void ubx_t::nav_pvt_t::_clean_up() { -} - -ubx_t::mon_hw2_t::mon_hw2_t(kaitai::kstream* p__io, ubx_t* p__parent, ubx_t* p__root) : kaitai::kstruct(p__io) { - m__parent = p__parent; - m__root = p__root; - - try { - _read(); - } catch(...) { - _clean_up(); - throw; - } -} - -void ubx_t::mon_hw2_t::_read() { - m_ofs_i = m__io->read_s1(); - m_mag_i = m__io->read_u1(); - m_ofs_q = m__io->read_s1(); - m_mag_q = m__io->read_u1(); - m_cfg_source = static_cast(m__io->read_u1()); - m_reserved1 = m__io->read_bytes(3); - m_low_lev_cfg = m__io->read_u4le(); - m_reserved2 = m__io->read_bytes(8); - m_post_status = m__io->read_u4le(); - m_reserved3 = m__io->read_bytes(4); -} - -ubx_t::mon_hw2_t::~mon_hw2_t() { - _clean_up(); -} - -void ubx_t::mon_hw2_t::_clean_up() { -} - -ubx_t::mon_hw_t::mon_hw_t(kaitai::kstream* p__io, ubx_t* p__parent, ubx_t* p__root) : kaitai::kstruct(p__io) { - m__parent = p__parent; - m__root = p__root; - - try { - _read(); - } catch(...) { - _clean_up(); - throw; - } -} - -void ubx_t::mon_hw_t::_read() { - m_pin_sel = m__io->read_u4le(); - m_pin_bank = m__io->read_u4le(); - m_pin_dir = m__io->read_u4le(); - m_pin_val = m__io->read_u4le(); - m_noise_per_ms = m__io->read_u2le(); - m_agc_cnt = m__io->read_u2le(); - m_a_status = static_cast(m__io->read_u1()); - m_a_power = static_cast(m__io->read_u1()); - m_flags = m__io->read_u1(); - m_reserved1 = m__io->read_bytes(1); - m_used_mask = m__io->read_u4le(); - m_vp = m__io->read_bytes(17); - m_jam_ind = m__io->read_u1(); - m_reserved2 = m__io->read_bytes(2); - m_pin_irq = m__io->read_u4le(); - m_pull_h = m__io->read_u4le(); - m_pull_l = m__io->read_u4le(); -} - -ubx_t::mon_hw_t::~mon_hw_t() { - _clean_up(); -} - -void ubx_t::mon_hw_t::_clean_up() { -} - -uint16_t ubx_t::checksum() { - if (f_checksum) - return m_checksum; - std::streampos _pos = m__io->pos(); - m__io->seek((length() + 6)); - m_checksum = m__io->read_u2le(); - m__io->seek(_pos); - f_checksum = true; - return m_checksum; -} diff --git a/system/ubloxd/generated/ubx.h b/system/ubloxd/generated/ubx.h deleted file mode 100644 index 022108489f..0000000000 --- a/system/ubloxd/generated/ubx.h +++ /dev/null @@ -1,484 +0,0 @@ -#ifndef UBX_H_ -#define UBX_H_ - -// This is a generated file! Please edit source .ksy file and use kaitai-struct-compiler to rebuild - -#include "kaitai/kaitaistruct.h" -#include -#include - -#if KAITAI_STRUCT_VERSION < 9000L -#error "Incompatible Kaitai Struct C++/STL API: version 0.9 or later is required" -#endif - -class ubx_t : public kaitai::kstruct { - -public: - class rxm_rawx_t; - class rxm_sfrbx_t; - class nav_sat_t; - class nav_pvt_t; - class mon_hw2_t; - class mon_hw_t; - - enum gnss_type_t { - GNSS_TYPE_GPS = 0, - GNSS_TYPE_SBAS = 1, - GNSS_TYPE_GALILEO = 2, - GNSS_TYPE_BEIDOU = 3, - GNSS_TYPE_IMES = 4, - GNSS_TYPE_QZSS = 5, - GNSS_TYPE_GLONASS = 6 - }; - - ubx_t(kaitai::kstream* p__io, kaitai::kstruct* p__parent = 0, ubx_t* p__root = 0); - -private: - void _read(); - void _clean_up(); - -public: - ~ubx_t(); - - class rxm_rawx_t : public kaitai::kstruct { - - public: - class measurement_t; - - rxm_rawx_t(kaitai::kstream* p__io, ubx_t* p__parent = 0, ubx_t* p__root = 0); - - private: - void _read(); - void _clean_up(); - - public: - ~rxm_rawx_t(); - - class measurement_t : public kaitai::kstruct { - - public: - - measurement_t(kaitai::kstream* p__io, ubx_t::rxm_rawx_t* p__parent = 0, ubx_t* p__root = 0); - - private: - void _read(); - void _clean_up(); - - public: - ~measurement_t(); - - private: - double m_pr_mes; - double m_cp_mes; - float m_do_mes; - gnss_type_t m_gnss_id; - uint8_t m_sv_id; - std::string m_reserved2; - uint8_t m_freq_id; - uint16_t m_lock_time; - uint8_t m_cno; - uint8_t m_pr_stdev; - uint8_t m_cp_stdev; - uint8_t m_do_stdev; - uint8_t m_trk_stat; - std::string m_reserved3; - ubx_t* m__root; - ubx_t::rxm_rawx_t* m__parent; - - public: - double pr_mes() const { return m_pr_mes; } - double cp_mes() const { return m_cp_mes; } - float do_mes() const { return m_do_mes; } - gnss_type_t gnss_id() const { return m_gnss_id; } - uint8_t sv_id() const { return m_sv_id; } - std::string reserved2() const { return m_reserved2; } - uint8_t freq_id() const { return m_freq_id; } - uint16_t lock_time() const { return m_lock_time; } - uint8_t cno() const { return m_cno; } - uint8_t pr_stdev() const { return m_pr_stdev; } - uint8_t cp_stdev() const { return m_cp_stdev; } - uint8_t do_stdev() const { return m_do_stdev; } - uint8_t trk_stat() const { return m_trk_stat; } - std::string reserved3() const { return m_reserved3; } - ubx_t* _root() const { return m__root; } - ubx_t::rxm_rawx_t* _parent() const { return m__parent; } - }; - - private: - double m_rcv_tow; - uint16_t m_week; - int8_t m_leap_s; - uint8_t m_num_meas; - uint8_t m_rec_stat; - std::string m_reserved1; - std::vector* m_meas; - ubx_t* m__root; - ubx_t* m__parent; - std::vector* m__raw_meas; - std::vector* m__io__raw_meas; - - public: - double rcv_tow() const { return m_rcv_tow; } - uint16_t week() const { return m_week; } - int8_t leap_s() const { return m_leap_s; } - uint8_t num_meas() const { return m_num_meas; } - uint8_t rec_stat() const { return m_rec_stat; } - std::string reserved1() const { return m_reserved1; } - std::vector* meas() const { return m_meas; } - ubx_t* _root() const { return m__root; } - ubx_t* _parent() const { return m__parent; } - std::vector* _raw_meas() const { return m__raw_meas; } - std::vector* _io__raw_meas() const { return m__io__raw_meas; } - }; - - class rxm_sfrbx_t : public kaitai::kstruct { - - public: - - rxm_sfrbx_t(kaitai::kstream* p__io, ubx_t* p__parent = 0, ubx_t* p__root = 0); - - private: - void _read(); - void _clean_up(); - - public: - ~rxm_sfrbx_t(); - - private: - gnss_type_t m_gnss_id; - uint8_t m_sv_id; - std::string m_reserved1; - uint8_t m_freq_id; - uint8_t m_num_words; - std::string m_reserved2; - uint8_t m_version; - std::string m_reserved3; - std::vector* m_body; - ubx_t* m__root; - ubx_t* m__parent; - - public: - gnss_type_t gnss_id() const { return m_gnss_id; } - uint8_t sv_id() const { return m_sv_id; } - std::string reserved1() const { return m_reserved1; } - uint8_t freq_id() const { return m_freq_id; } - uint8_t num_words() const { return m_num_words; } - std::string reserved2() const { return m_reserved2; } - uint8_t version() const { return m_version; } - std::string reserved3() const { return m_reserved3; } - std::vector* body() const { return m_body; } - ubx_t* _root() const { return m__root; } - ubx_t* _parent() const { return m__parent; } - }; - - class nav_sat_t : public kaitai::kstruct { - - public: - class nav_t; - - nav_sat_t(kaitai::kstream* p__io, ubx_t* p__parent = 0, ubx_t* p__root = 0); - - private: - void _read(); - void _clean_up(); - - public: - ~nav_sat_t(); - - class nav_t : public kaitai::kstruct { - - public: - - nav_t(kaitai::kstream* p__io, ubx_t::nav_sat_t* p__parent = 0, ubx_t* p__root = 0); - - private: - void _read(); - void _clean_up(); - - public: - ~nav_t(); - - private: - gnss_type_t m_gnss_id; - uint8_t m_sv_id; - uint8_t m_cno; - int8_t m_elev; - int16_t m_azim; - int16_t m_pr_res; - uint32_t m_flags; - ubx_t* m__root; - ubx_t::nav_sat_t* m__parent; - - public: - gnss_type_t gnss_id() const { return m_gnss_id; } - uint8_t sv_id() const { return m_sv_id; } - uint8_t cno() const { return m_cno; } - int8_t elev() const { return m_elev; } - int16_t azim() const { return m_azim; } - int16_t pr_res() const { return m_pr_res; } - uint32_t flags() const { return m_flags; } - ubx_t* _root() const { return m__root; } - ubx_t::nav_sat_t* _parent() const { return m__parent; } - }; - - private: - uint32_t m_itow; - uint8_t m_version; - uint8_t m_num_svs; - std::string m_reserved; - std::vector* m_svs; - ubx_t* m__root; - ubx_t* m__parent; - std::vector* m__raw_svs; - std::vector* m__io__raw_svs; - - public: - uint32_t itow() const { return m_itow; } - uint8_t version() const { return m_version; } - uint8_t num_svs() const { return m_num_svs; } - std::string reserved() const { return m_reserved; } - std::vector* svs() const { return m_svs; } - ubx_t* _root() const { return m__root; } - ubx_t* _parent() const { return m__parent; } - std::vector* _raw_svs() const { return m__raw_svs; } - std::vector* _io__raw_svs() const { return m__io__raw_svs; } - }; - - class nav_pvt_t : public kaitai::kstruct { - - public: - - nav_pvt_t(kaitai::kstream* p__io, ubx_t* p__parent = 0, ubx_t* p__root = 0); - - private: - void _read(); - void _clean_up(); - - public: - ~nav_pvt_t(); - - private: - uint32_t m_i_tow; - uint16_t m_year; - uint8_t m_month; - uint8_t m_day; - uint8_t m_hour; - uint8_t m_min; - uint8_t m_sec; - uint8_t m_valid; - uint32_t m_t_acc; - int32_t m_nano; - uint8_t m_fix_type; - uint8_t m_flags; - uint8_t m_flags2; - uint8_t m_num_sv; - int32_t m_lon; - int32_t m_lat; - int32_t m_height; - int32_t m_h_msl; - uint32_t m_h_acc; - uint32_t m_v_acc; - int32_t m_vel_n; - int32_t m_vel_e; - int32_t m_vel_d; - int32_t m_g_speed; - int32_t m_head_mot; - int32_t m_s_acc; - uint32_t m_head_acc; - uint16_t m_p_dop; - uint8_t m_flags3; - std::string m_reserved1; - int32_t m_head_veh; - int16_t m_mag_dec; - uint16_t m_mag_acc; - ubx_t* m__root; - ubx_t* m__parent; - - public: - uint32_t i_tow() const { return m_i_tow; } - uint16_t year() const { return m_year; } - uint8_t month() const { return m_month; } - uint8_t day() const { return m_day; } - uint8_t hour() const { return m_hour; } - uint8_t min() const { return m_min; } - uint8_t sec() const { return m_sec; } - uint8_t valid() const { return m_valid; } - uint32_t t_acc() const { return m_t_acc; } - int32_t nano() const { return m_nano; } - uint8_t fix_type() const { return m_fix_type; } - uint8_t flags() const { return m_flags; } - uint8_t flags2() const { return m_flags2; } - uint8_t num_sv() const { return m_num_sv; } - int32_t lon() const { return m_lon; } - int32_t lat() const { return m_lat; } - int32_t height() const { return m_height; } - int32_t h_msl() const { return m_h_msl; } - uint32_t h_acc() const { return m_h_acc; } - uint32_t v_acc() const { return m_v_acc; } - int32_t vel_n() const { return m_vel_n; } - int32_t vel_e() const { return m_vel_e; } - int32_t vel_d() const { return m_vel_d; } - int32_t g_speed() const { return m_g_speed; } - int32_t head_mot() const { return m_head_mot; } - int32_t s_acc() const { return m_s_acc; } - uint32_t head_acc() const { return m_head_acc; } - uint16_t p_dop() const { return m_p_dop; } - uint8_t flags3() const { return m_flags3; } - std::string reserved1() const { return m_reserved1; } - int32_t head_veh() const { return m_head_veh; } - int16_t mag_dec() const { return m_mag_dec; } - uint16_t mag_acc() const { return m_mag_acc; } - ubx_t* _root() const { return m__root; } - ubx_t* _parent() const { return m__parent; } - }; - - class mon_hw2_t : public kaitai::kstruct { - - public: - - enum config_source_t { - CONFIG_SOURCE_FLASH = 102, - CONFIG_SOURCE_OTP = 111, - CONFIG_SOURCE_CONFIG_PINS = 112, - CONFIG_SOURCE_ROM = 113 - }; - - mon_hw2_t(kaitai::kstream* p__io, ubx_t* p__parent = 0, ubx_t* p__root = 0); - - private: - void _read(); - void _clean_up(); - - public: - ~mon_hw2_t(); - - private: - int8_t m_ofs_i; - uint8_t m_mag_i; - int8_t m_ofs_q; - uint8_t m_mag_q; - config_source_t m_cfg_source; - std::string m_reserved1; - uint32_t m_low_lev_cfg; - std::string m_reserved2; - uint32_t m_post_status; - std::string m_reserved3; - ubx_t* m__root; - ubx_t* m__parent; - - public: - int8_t ofs_i() const { return m_ofs_i; } - uint8_t mag_i() const { return m_mag_i; } - int8_t ofs_q() const { return m_ofs_q; } - uint8_t mag_q() const { return m_mag_q; } - config_source_t cfg_source() const { return m_cfg_source; } - std::string reserved1() const { return m_reserved1; } - uint32_t low_lev_cfg() const { return m_low_lev_cfg; } - std::string reserved2() const { return m_reserved2; } - uint32_t post_status() const { return m_post_status; } - std::string reserved3() const { return m_reserved3; } - ubx_t* _root() const { return m__root; } - ubx_t* _parent() const { return m__parent; } - }; - - class mon_hw_t : public kaitai::kstruct { - - public: - - enum antenna_status_t { - ANTENNA_STATUS_INIT = 0, - ANTENNA_STATUS_DONTKNOW = 1, - ANTENNA_STATUS_OK = 2, - ANTENNA_STATUS_SHORT = 3, - ANTENNA_STATUS_OPEN = 4 - }; - - enum antenna_power_t { - ANTENNA_POWER_FALSE = 0, - ANTENNA_POWER_TRUE = 1, - ANTENNA_POWER_DONTKNOW = 2 - }; - - mon_hw_t(kaitai::kstream* p__io, ubx_t* p__parent = 0, ubx_t* p__root = 0); - - private: - void _read(); - void _clean_up(); - - public: - ~mon_hw_t(); - - private: - uint32_t m_pin_sel; - uint32_t m_pin_bank; - uint32_t m_pin_dir; - uint32_t m_pin_val; - uint16_t m_noise_per_ms; - uint16_t m_agc_cnt; - antenna_status_t m_a_status; - antenna_power_t m_a_power; - uint8_t m_flags; - std::string m_reserved1; - uint32_t m_used_mask; - std::string m_vp; - uint8_t m_jam_ind; - std::string m_reserved2; - uint32_t m_pin_irq; - uint32_t m_pull_h; - uint32_t m_pull_l; - ubx_t* m__root; - ubx_t* m__parent; - - public: - uint32_t pin_sel() const { return m_pin_sel; } - uint32_t pin_bank() const { return m_pin_bank; } - uint32_t pin_dir() const { return m_pin_dir; } - uint32_t pin_val() const { return m_pin_val; } - uint16_t noise_per_ms() const { return m_noise_per_ms; } - uint16_t agc_cnt() const { return m_agc_cnt; } - antenna_status_t a_status() const { return m_a_status; } - antenna_power_t a_power() const { return m_a_power; } - uint8_t flags() const { return m_flags; } - std::string reserved1() const { return m_reserved1; } - uint32_t used_mask() const { return m_used_mask; } - std::string vp() const { return m_vp; } - uint8_t jam_ind() const { return m_jam_ind; } - std::string reserved2() const { return m_reserved2; } - uint32_t pin_irq() const { return m_pin_irq; } - uint32_t pull_h() const { return m_pull_h; } - uint32_t pull_l() const { return m_pull_l; } - ubx_t* _root() const { return m__root; } - ubx_t* _parent() const { return m__parent; } - }; - -private: - bool f_checksum; - uint16_t m_checksum; - -public: - uint16_t checksum(); - -private: - std::string m_magic; - uint16_t m_msg_type; - uint16_t m_length; - kaitai::kstruct* m_body; - bool n_body; - -public: - bool _is_null_body() { body(); return n_body; }; - -private: - ubx_t* m__root; - kaitai::kstruct* m__parent; - -public: - std::string magic() const { return m_magic; } - uint16_t msg_type() const { return m_msg_type; } - uint16_t length() const { return m_length; } - kaitai::kstruct* body() const { return m_body; } - ubx_t* _root() const { return m__root; } - kaitai::kstruct* _parent() const { return m__parent; } -}; - -#endif // UBX_H_ diff --git a/system/ubloxd/generated/ubx.py b/system/ubloxd/generated/ubx.py new file mode 100644 index 0000000000..9946584388 --- /dev/null +++ b/system/ubloxd/generated/ubx.py @@ -0,0 +1,273 @@ +# This is a generated file! Please edit source .ksy file and use kaitai-struct-compiler to rebuild + +import kaitaistruct +from kaitaistruct import KaitaiStruct, KaitaiStream, BytesIO +from enum import Enum + + +if getattr(kaitaistruct, 'API_VERSION', (0, 9)) < (0, 9): + raise Exception("Incompatible Kaitai Struct Python API: 0.9 or later is required, but you have %s" % (kaitaistruct.__version__)) + +class Ubx(KaitaiStruct): + + class GnssType(Enum): + gps = 0 + sbas = 1 + galileo = 2 + beidou = 3 + imes = 4 + qzss = 5 + glonass = 6 + def __init__(self, _io, _parent=None, _root=None): + self._io = _io + self._parent = _parent + self._root = _root if _root else self + self._read() + + def _read(self): + self.magic = self._io.read_bytes(2) + if not self.magic == b"\xB5\x62": + raise kaitaistruct.ValidationNotEqualError(b"\xB5\x62", self.magic, self._io, u"/seq/0") + self.msg_type = self._io.read_u2be() + self.length = self._io.read_u2le() + _on = self.msg_type + if _on == 2569: + self.body = Ubx.MonHw(self._io, self, self._root) + elif _on == 533: + self.body = Ubx.RxmRawx(self._io, self, self._root) + elif _on == 531: + self.body = Ubx.RxmSfrbx(self._io, self, self._root) + elif _on == 309: + self.body = Ubx.NavSat(self._io, self, self._root) + elif _on == 2571: + self.body = Ubx.MonHw2(self._io, self, self._root) + elif _on == 263: + self.body = Ubx.NavPvt(self._io, self, self._root) + + class RxmRawx(KaitaiStruct): + def __init__(self, _io, _parent=None, _root=None): + self._io = _io + self._parent = _parent + self._root = _root if _root else self + self._read() + + def _read(self): + self.rcv_tow = self._io.read_f8le() + self.week = self._io.read_u2le() + self.leap_s = self._io.read_s1() + self.num_meas = self._io.read_u1() + self.rec_stat = self._io.read_u1() + self.reserved1 = self._io.read_bytes(3) + self._raw_meas = [] + self.meas = [] + for i in range(self.num_meas): + self._raw_meas.append(self._io.read_bytes(32)) + _io__raw_meas = KaitaiStream(BytesIO(self._raw_meas[i])) + self.meas.append(Ubx.RxmRawx.Measurement(_io__raw_meas, self, self._root)) + + + class Measurement(KaitaiStruct): + def __init__(self, _io, _parent=None, _root=None): + self._io = _io + self._parent = _parent + self._root = _root if _root else self + self._read() + + def _read(self): + self.pr_mes = self._io.read_f8le() + self.cp_mes = self._io.read_f8le() + self.do_mes = self._io.read_f4le() + self.gnss_id = KaitaiStream.resolve_enum(Ubx.GnssType, self._io.read_u1()) + self.sv_id = self._io.read_u1() + self.reserved2 = self._io.read_bytes(1) + self.freq_id = self._io.read_u1() + self.lock_time = self._io.read_u2le() + self.cno = self._io.read_u1() + self.pr_stdev = self._io.read_u1() + self.cp_stdev = self._io.read_u1() + self.do_stdev = self._io.read_u1() + self.trk_stat = self._io.read_u1() + self.reserved3 = self._io.read_bytes(1) + + + + class RxmSfrbx(KaitaiStruct): + def __init__(self, _io, _parent=None, _root=None): + self._io = _io + self._parent = _parent + self._root = _root if _root else self + self._read() + + def _read(self): + self.gnss_id = KaitaiStream.resolve_enum(Ubx.GnssType, self._io.read_u1()) + self.sv_id = self._io.read_u1() + self.reserved1 = self._io.read_bytes(1) + self.freq_id = self._io.read_u1() + self.num_words = self._io.read_u1() + self.reserved2 = self._io.read_bytes(1) + self.version = self._io.read_u1() + self.reserved3 = self._io.read_bytes(1) + self.body = [] + for i in range(self.num_words): + self.body.append(self._io.read_u4le()) + + + + class NavSat(KaitaiStruct): + def __init__(self, _io, _parent=None, _root=None): + self._io = _io + self._parent = _parent + self._root = _root if _root else self + self._read() + + def _read(self): + self.itow = self._io.read_u4le() + self.version = self._io.read_u1() + self.num_svs = self._io.read_u1() + self.reserved = self._io.read_bytes(2) + self._raw_svs = [] + self.svs = [] + for i in range(self.num_svs): + self._raw_svs.append(self._io.read_bytes(12)) + _io__raw_svs = KaitaiStream(BytesIO(self._raw_svs[i])) + self.svs.append(Ubx.NavSat.Nav(_io__raw_svs, self, self._root)) + + + class Nav(KaitaiStruct): + def __init__(self, _io, _parent=None, _root=None): + self._io = _io + self._parent = _parent + self._root = _root if _root else self + self._read() + + def _read(self): + self.gnss_id = KaitaiStream.resolve_enum(Ubx.GnssType, self._io.read_u1()) + self.sv_id = self._io.read_u1() + self.cno = self._io.read_u1() + self.elev = self._io.read_s1() + self.azim = self._io.read_s2le() + self.pr_res = self._io.read_s2le() + self.flags = self._io.read_u4le() + + + + class NavPvt(KaitaiStruct): + def __init__(self, _io, _parent=None, _root=None): + self._io = _io + self._parent = _parent + self._root = _root if _root else self + self._read() + + def _read(self): + self.i_tow = self._io.read_u4le() + self.year = self._io.read_u2le() + self.month = self._io.read_u1() + self.day = self._io.read_u1() + self.hour = self._io.read_u1() + self.min = self._io.read_u1() + self.sec = self._io.read_u1() + self.valid = self._io.read_u1() + self.t_acc = self._io.read_u4le() + self.nano = self._io.read_s4le() + self.fix_type = self._io.read_u1() + self.flags = self._io.read_u1() + self.flags2 = self._io.read_u1() + self.num_sv = self._io.read_u1() + self.lon = self._io.read_s4le() + self.lat = self._io.read_s4le() + self.height = self._io.read_s4le() + self.h_msl = self._io.read_s4le() + self.h_acc = self._io.read_u4le() + self.v_acc = self._io.read_u4le() + self.vel_n = self._io.read_s4le() + self.vel_e = self._io.read_s4le() + self.vel_d = self._io.read_s4le() + self.g_speed = self._io.read_s4le() + self.head_mot = self._io.read_s4le() + self.s_acc = self._io.read_s4le() + self.head_acc = self._io.read_u4le() + self.p_dop = self._io.read_u2le() + self.flags3 = self._io.read_u1() + self.reserved1 = self._io.read_bytes(5) + self.head_veh = self._io.read_s4le() + self.mag_dec = self._io.read_s2le() + self.mag_acc = self._io.read_u2le() + + + class MonHw2(KaitaiStruct): + + class ConfigSource(Enum): + flash = 102 + otp = 111 + config_pins = 112 + rom = 113 + def __init__(self, _io, _parent=None, _root=None): + self._io = _io + self._parent = _parent + self._root = _root if _root else self + self._read() + + def _read(self): + self.ofs_i = self._io.read_s1() + self.mag_i = self._io.read_u1() + self.ofs_q = self._io.read_s1() + self.mag_q = self._io.read_u1() + self.cfg_source = KaitaiStream.resolve_enum(Ubx.MonHw2.ConfigSource, self._io.read_u1()) + self.reserved1 = self._io.read_bytes(3) + self.low_lev_cfg = self._io.read_u4le() + self.reserved2 = self._io.read_bytes(8) + self.post_status = self._io.read_u4le() + self.reserved3 = self._io.read_bytes(4) + + + class MonHw(KaitaiStruct): + + class AntennaStatus(Enum): + init = 0 + dontknow = 1 + ok = 2 + short = 3 + open = 4 + + class AntennaPower(Enum): + false = 0 + true = 1 + dontknow = 2 + def __init__(self, _io, _parent=None, _root=None): + self._io = _io + self._parent = _parent + self._root = _root if _root else self + self._read() + + def _read(self): + self.pin_sel = self._io.read_u4le() + self.pin_bank = self._io.read_u4le() + self.pin_dir = self._io.read_u4le() + self.pin_val = self._io.read_u4le() + self.noise_per_ms = self._io.read_u2le() + self.agc_cnt = self._io.read_u2le() + self.a_status = KaitaiStream.resolve_enum(Ubx.MonHw.AntennaStatus, self._io.read_u1()) + self.a_power = KaitaiStream.resolve_enum(Ubx.MonHw.AntennaPower, self._io.read_u1()) + self.flags = self._io.read_u1() + self.reserved1 = self._io.read_bytes(1) + self.used_mask = self._io.read_u4le() + self.vp = self._io.read_bytes(17) + self.jam_ind = self._io.read_u1() + self.reserved2 = self._io.read_bytes(2) + self.pin_irq = self._io.read_u4le() + self.pull_h = self._io.read_u4le() + self.pull_l = self._io.read_u4le() + + + @property + def checksum(self): + if hasattr(self, '_m_checksum'): + return self._m_checksum + + _pos = self._io.pos() + self._io.seek((self.length + 6)) + self._m_checksum = self._io.read_u2le() + self._io.seek(_pos) + return getattr(self, '_m_checksum', None) + + diff --git a/system/ubloxd/glonass_fix.patch b/system/ubloxd/glonass_fix.patch deleted file mode 100644 index 7eb973a348..0000000000 --- a/system/ubloxd/glonass_fix.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff --git a/system/ubloxd/generated/glonass.cpp b/system/ubloxd/generated/glonass.cpp -index 5b17bc327..b5c6aa610 100644 ---- a/system/ubloxd/generated/glonass.cpp -+++ b/system/ubloxd/generated/glonass.cpp -@@ -17,7 +17,7 @@ glonass_t::glonass_t(kaitai::kstream* p__io, kaitai::kstruct* p__parent, glonass - void glonass_t::_read() { - m_idle_chip = m__io->read_bits_int_be(1); - m_string_number = m__io->read_bits_int_be(4); -- m__io->align_to_byte(); -+ //m__io->align_to_byte(); - switch (string_number()) { - case 4: { - m_data = new string_4_t(m__io, this, m__root); diff --git a/system/ubloxd/tests/print_gps_stats.py b/system/ubloxd/tests/print_gps_stats.py deleted file mode 100755 index 8d190f9ec1..0000000000 --- a/system/ubloxd/tests/print_gps_stats.py +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env python3 -import time -import cereal.messaging as messaging - -if __name__ == "__main__": - sm = messaging.SubMaster(['ubloxGnss', 'gpsLocationExternal']) - - while 1: - ug = sm['ubloxGnss'] - gle = sm['gpsLocationExternal'] - - try: - cnos = [] - for m in ug.measurementReport.measurements: - cnos.append(m.cno) - print(f"Sats: {ug.measurementReport.numMeas} Accuracy: {gle.horizontalAccuracy:.2f} m cnos", sorted(cnos)) - except Exception: - pass - sm.update() - time.sleep(0.1) diff --git a/system/ubloxd/tests/test_glonass_kaitai.cc b/system/ubloxd/tests/test_glonass_kaitai.cc deleted file mode 100644 index 96f43742b4..0000000000 --- a/system/ubloxd/tests/test_glonass_kaitai.cc +++ /dev/null @@ -1,360 +0,0 @@ -#include -#include -#include -#include -#include -#include - -#include "catch2/catch.hpp" -#include "system/ubloxd/generated/glonass.h" - -typedef std::vector> string_data; - -#define IDLE_CHIP_IDX 0 -#define STRING_NUMBER_IDX 1 -// string data 1-5 -#define HC_IDX 0 -#define PAD1_IDX 1 -#define SUPERFRAME_IDX 2 -#define PAD2_IDX 3 -#define FRAME_IDX 4 - -// Indexes for string number 1 -#define ST1_NU_IDX 2 -#define ST1_P1_IDX 3 -#define ST1_T_K_IDX 4 -#define ST1_X_VEL_S_IDX 5 -#define ST1_X_VEL_V_IDX 6 -#define ST1_X_ACCEL_S_IDX 7 -#define ST1_X_ACCEL_V_IDX 8 -#define ST1_X_S_IDX 9 -#define ST1_X_V_IDX 10 -#define ST1_HC_OFF 11 - -// Indexes for string number 2 -#define ST2_BN_IDX 2 -#define ST2_P2_IDX 3 -#define ST2_TB_IDX 4 -#define ST2_NU_IDX 5 -#define ST2_Y_VEL_S_IDX 6 -#define ST2_Y_VEL_V_IDX 7 -#define ST2_Y_ACCEL_S_IDX 8 -#define ST2_Y_ACCEL_V_IDX 9 -#define ST2_Y_S_IDX 10 -#define ST2_Y_V_IDX 11 -#define ST2_HC_OFF 12 - -// Indexes for string number 3 -#define ST3_P3_IDX 2 -#define ST3_GAMMA_N_S_IDX 3 -#define ST3_GAMMA_N_V_IDX 4 -#define ST3_NU_1_IDX 5 -#define ST3_P_IDX 6 -#define ST3_L_N_IDX 7 -#define ST3_Z_VEL_S_IDX 8 -#define ST3_Z_VEL_V_IDX 9 -#define ST3_Z_ACCEL_S_IDX 10 -#define ST3_Z_ACCEL_V_IDX 11 -#define ST3_Z_S_IDX 12 -#define ST3_Z_V_IDX 13 -#define ST3_HC_OFF 14 - -// Indexes for string number 4 -#define ST4_TAU_N_S_IDX 2 -#define ST4_TAU_N_V_IDX 3 -#define ST4_DELTA_TAU_N_S_IDX 4 -#define ST4_DELTA_TAU_N_V_IDX 5 -#define ST4_E_N_IDX 6 -#define ST4_NU_1_IDX 7 -#define ST4_P4_IDX 8 -#define ST4_F_T_IDX 9 -#define ST4_NU_2_IDX 10 -#define ST4_N_T_IDX 11 -#define ST4_N_IDX 12 -#define ST4_M_IDX 13 -#define ST4_HC_OFF 14 - -// Indexes for string number 5 -#define ST5_N_A_IDX 2 -#define ST5_TAU_C_IDX 3 -#define ST5_NU_IDX 4 -#define ST5_N_4_IDX 5 -#define ST5_TAU_GPS_IDX 6 -#define ST5_L_N_IDX 7 -#define ST5_HC_OFF 8 - -// Indexes for non immediate -#define ST6_DATA_1_IDX 2 -#define ST6_DATA_2_IDX 3 -#define ST6_HC_OFF 4 - - -std::string generate_inp_data(string_data& data) { - std::string inp_data = ""; - for (auto& [b, v] : data) { - std::string tmp = std::bitset<64>(v).to_string(); - inp_data += tmp.substr(64-b, b); - } - assert(inp_data.size() == 128); - - std::string string_data; - string_data.reserve(16); - for (int i = 0; i < 128; i+=8) { - std::string substr = inp_data.substr(i, 8); - string_data.push_back((uint8_t)std::stoi(substr.c_str(), 0, 2)); - } - - return string_data; -} - -string_data generate_string_data(uint8_t string_number) { - - srand((unsigned)time(0)); - string_data data; // - data.push_back({1, 0}); // idle chip - data.push_back({4, string_number}); // string number - - if (string_number == 1) { - data.push_back({2, 3}); // not_used - data.push_back({2, 1}); // p1 - data.push_back({12, 113}); // t_k - data.push_back({1, rand() & 1}); // x_vel_sign - data.push_back({23, 7122}); // x_vel_value - data.push_back({1, rand() & 1}); // x_accel_sign - data.push_back({4, 3}); // x_accel_value - data.push_back({1, rand() & 1}); // x_sign - data.push_back({26, 33554431}); // x_value - } else if (string_number == 2) { - data.push_back({3, 3}); // b_n - data.push_back({1, 1}); // p2 - data.push_back({7, 123}); // t_b - data.push_back({5, 31}); // not_used - data.push_back({1, rand() & 1}); // y_vel_sign - data.push_back({23, 7422}); // y_vel_value - data.push_back({1, rand() & 1}); // y_accel_sign - data.push_back({4, 3}); // y_accel_value - data.push_back({1, rand() & 1}); // y_sign - data.push_back({26, 67108863}); // y_value - } else if (string_number == 3) { - data.push_back({1, 0}); // p3 - data.push_back({1, 1}); // gamma_n_sign - data.push_back({10, 123}); // gamma_n_value - data.push_back({1, 0}); // not_used - data.push_back({2, 2}); // p - data.push_back({1, 1}); // l_n - data.push_back({1, rand() & 1}); // z_vel_sign - data.push_back({23, 1337}); // z_vel_value - data.push_back({1, rand() & 1}); // z_accel_sign - data.push_back({4, 9}); // z_accel_value - data.push_back({1, rand() & 1}); // z_sign - data.push_back({26, 100023}); // z_value - } else if (string_number == 4) { - data.push_back({1, rand() & 1}); // tau_n_sign - data.push_back({21, 197152}); // tau_n_value - data.push_back({1, rand() & 1}); // delta_tau_n_sign - data.push_back({4, 4}); // delta_tau_n_value - data.push_back({5, 0}); // e_n - data.push_back({14, 2}); // not_used_1 - data.push_back({1, 1}); // p4 - data.push_back({4, 9}); // f_t - data.push_back({3, 3}); // not_used_2 - data.push_back({11, 2047}); // n_t - data.push_back({5, 2}); // n - data.push_back({2, 1}); // m - } else if (string_number == 5) { - data.push_back({11, 2047}); // n_a - data.push_back({32, 4294767295}); // tau_c - data.push_back({1, 0}); // not_used_1 - data.push_back({5, 2}); // n_4 - data.push_back({22, 4114304}); // tau_gps - data.push_back({1, 0}); // l_n - } else { // non-immediate data is not parsed - data.push_back({64, rand()}); // data_1 - data.push_back({8, 6}); // data_2 - } - - data.push_back({8, rand() & 0xFF}); // hamming code - data.push_back({11, rand() & 0x7FF}); // pad - data.push_back({16, rand() & 0xFFFF}); // superframe - data.push_back({8, rand() & 0xFF}); // pad - data.push_back({8, rand() & 0xFF}); // frame - return data; -} - -TEST_CASE("parse_string_number_1"){ - string_data data = generate_string_data(1); - std::string inp_data = generate_inp_data(data); - - kaitai::kstream stream(inp_data); - glonass_t gl_string(&stream); - - REQUIRE(gl_string.idle_chip() == data[IDLE_CHIP_IDX].second); - REQUIRE(gl_string.string_number() == data[STRING_NUMBER_IDX].second); - REQUIRE(gl_string.hamming_code() == data[ST1_HC_OFF + HC_IDX].second); - REQUIRE(gl_string.pad_1() == data[ST1_HC_OFF + PAD1_IDX].second); - REQUIRE(gl_string.superframe_number() == data[ST1_HC_OFF + SUPERFRAME_IDX].second); - REQUIRE(gl_string.pad_2() == data[ST1_HC_OFF + PAD2_IDX].second); - REQUIRE(gl_string.frame_number() == data[ST1_HC_OFF + FRAME_IDX].second); - - kaitai::kstream str1(inp_data); - glonass_t str1_data(&str1); - glonass_t::string_1_t* s1 = static_cast(str1_data.data()); - - REQUIRE(s1->not_used() == data[ST1_NU_IDX].second); - REQUIRE(s1->p1() == data[ST1_P1_IDX].second); - REQUIRE(s1->t_k() == data[ST1_T_K_IDX].second); - - int mul = s1->x_vel_sign() ? (-1) : 1; - REQUIRE(s1->x_vel() == (data[ST1_X_VEL_V_IDX].second * mul)); - mul = s1->x_accel_sign() ? (-1) : 1; - REQUIRE(s1->x_accel() == (data[ST1_X_ACCEL_V_IDX].second * mul)); - mul = s1->x_sign() ? (-1) : 1; - REQUIRE(s1->x() == (data[ST1_X_V_IDX].second * mul)); -} - -TEST_CASE("parse_string_number_2"){ - string_data data = generate_string_data(2); - std::string inp_data = generate_inp_data(data); - - kaitai::kstream stream(inp_data); - glonass_t gl_string(&stream); - - REQUIRE(gl_string.idle_chip() == data[IDLE_CHIP_IDX].second); - REQUIRE(gl_string.string_number() == data[STRING_NUMBER_IDX].second); - REQUIRE(gl_string.hamming_code() == data[ST2_HC_OFF + HC_IDX].second); - REQUIRE(gl_string.pad_1() == data[ST2_HC_OFF + PAD1_IDX].second); - REQUIRE(gl_string.superframe_number() == data[ST2_HC_OFF + SUPERFRAME_IDX].second); - REQUIRE(gl_string.pad_2() == data[ST2_HC_OFF + PAD2_IDX].second); - REQUIRE(gl_string.frame_number() == data[ST2_HC_OFF + FRAME_IDX].second); - - kaitai::kstream str2(inp_data); - glonass_t str2_data(&str2); - glonass_t::string_2_t* s2 = static_cast(str2_data.data()); - - REQUIRE(s2->b_n() == data[ST2_BN_IDX].second); - REQUIRE(s2->not_used() == data[ST2_NU_IDX].second); - REQUIRE(s2->p2() == data[ST2_P2_IDX].second); - REQUIRE(s2->t_b() == data[ST2_TB_IDX].second); - int mul = s2->y_vel_sign() ? (-1) : 1; - REQUIRE(s2->y_vel() == (data[ST2_Y_VEL_V_IDX].second * mul)); - mul = s2->y_accel_sign() ? (-1) : 1; - REQUIRE(s2->y_accel() == (data[ST2_Y_ACCEL_V_IDX].second * mul)); - mul = s2->y_sign() ? (-1) : 1; - REQUIRE(s2->y() == (data[ST2_Y_V_IDX].second * mul)); -} - -TEST_CASE("parse_string_number_3"){ - string_data data = generate_string_data(3); - std::string inp_data = generate_inp_data(data); - - kaitai::kstream stream(inp_data); - glonass_t gl_string(&stream); - - REQUIRE(gl_string.idle_chip() == data[IDLE_CHIP_IDX].second); - REQUIRE(gl_string.string_number() == data[STRING_NUMBER_IDX].second); - REQUIRE(gl_string.hamming_code() == data[ST3_HC_OFF + HC_IDX].second); - REQUIRE(gl_string.pad_1() == data[ST3_HC_OFF + PAD1_IDX].second); - REQUIRE(gl_string.superframe_number() == data[ST3_HC_OFF + SUPERFRAME_IDX].second); - REQUIRE(gl_string.pad_2() == data[ST3_HC_OFF + PAD2_IDX].second); - REQUIRE(gl_string.frame_number() == data[ST3_HC_OFF + FRAME_IDX].second); - - kaitai::kstream str3(inp_data); - glonass_t str3_data(&str3); - glonass_t::string_3_t* s3 = static_cast(str3_data.data()); - - REQUIRE(s3->p3() == data[ST3_P3_IDX].second); - int mul = s3->gamma_n_sign() ? (-1) : 1; - REQUIRE(s3->gamma_n() == (data[ST3_GAMMA_N_V_IDX].second * mul)); - REQUIRE(s3->not_used() == data[ST3_NU_1_IDX].second); - REQUIRE(s3->p() == data[ST3_P_IDX].second); - REQUIRE(s3->l_n() == data[ST3_L_N_IDX].second); - mul = s3->z_vel_sign() ? (-1) : 1; - REQUIRE(s3->z_vel() == (data[ST3_Z_VEL_V_IDX].second * mul)); - mul = s3->z_accel_sign() ? (-1) : 1; - REQUIRE(s3->z_accel() == (data[ST3_Z_ACCEL_V_IDX].second * mul)); - mul = s3->z_sign() ? (-1) : 1; - REQUIRE(s3->z() == (data[ST3_Z_V_IDX].second * mul)); -} - -TEST_CASE("parse_string_number_4"){ - string_data data = generate_string_data(4); - std::string inp_data = generate_inp_data(data); - - kaitai::kstream stream(inp_data); - glonass_t gl_string(&stream); - - REQUIRE(gl_string.idle_chip() == data[IDLE_CHIP_IDX].second); - REQUIRE(gl_string.string_number() == data[STRING_NUMBER_IDX].second); - REQUIRE(gl_string.hamming_code() == data[ST4_HC_OFF + HC_IDX].second); - REQUIRE(gl_string.pad_1() == data[ST4_HC_OFF + PAD1_IDX].second); - REQUIRE(gl_string.superframe_number() == data[ST4_HC_OFF + SUPERFRAME_IDX].second); - REQUIRE(gl_string.pad_2() == data[ST4_HC_OFF + PAD2_IDX].second); - REQUIRE(gl_string.frame_number() == data[ST4_HC_OFF + FRAME_IDX].second); - - kaitai::kstream str4(inp_data); - glonass_t str4_data(&str4); - glonass_t::string_4_t* s4 = static_cast(str4_data.data()); - - int mul = s4->tau_n_sign() ? (-1) : 1; - REQUIRE(s4->tau_n() == (data[ST4_TAU_N_V_IDX].second * mul)); - mul = s4->delta_tau_n_sign() ? (-1) : 1; - REQUIRE(s4->delta_tau_n() == (data[ST4_DELTA_TAU_N_V_IDX].second * mul)); - REQUIRE(s4->e_n() == data[ST4_E_N_IDX].second); - REQUIRE(s4->not_used_1() == data[ST4_NU_1_IDX].second); - REQUIRE(s4->p4() == data[ST4_P4_IDX].second); - REQUIRE(s4->f_t() == data[ST4_F_T_IDX].second); - REQUIRE(s4->not_used_2() == data[ST4_NU_2_IDX].second); - REQUIRE(s4->n_t() == data[ST4_N_T_IDX].second); - REQUIRE(s4->n() == data[ST4_N_IDX].second); - REQUIRE(s4->m() == data[ST4_M_IDX].second); -} - -TEST_CASE("parse_string_number_5"){ - string_data data = generate_string_data(5); - std::string inp_data = generate_inp_data(data); - - kaitai::kstream stream(inp_data); - glonass_t gl_string(&stream); - - REQUIRE(gl_string.idle_chip() == data[IDLE_CHIP_IDX].second); - REQUIRE(gl_string.string_number() == data[STRING_NUMBER_IDX].second); - REQUIRE(gl_string.hamming_code() == data[ST5_HC_OFF + HC_IDX].second); - REQUIRE(gl_string.pad_1() == data[ST5_HC_OFF + PAD1_IDX].second); - REQUIRE(gl_string.superframe_number() == data[ST5_HC_OFF + SUPERFRAME_IDX].second); - REQUIRE(gl_string.pad_2() == data[ST5_HC_OFF + PAD2_IDX].second); - REQUIRE(gl_string.frame_number() == data[ST5_HC_OFF + FRAME_IDX].second); - - kaitai::kstream str5(inp_data); - glonass_t str5_data(&str5); - glonass_t::string_5_t* s5 = static_cast(str5_data.data()); - - REQUIRE(s5->n_a() == data[ST5_N_A_IDX].second); - REQUIRE(s5->tau_c() == data[ST5_TAU_C_IDX].second); - REQUIRE(s5->not_used() == data[ST5_NU_IDX].second); - REQUIRE(s5->n_4() == data[ST5_N_4_IDX].second); - REQUIRE(s5->tau_gps() == data[ST5_TAU_GPS_IDX].second); - REQUIRE(s5->l_n() == data[ST5_L_N_IDX].second); -} - -TEST_CASE("parse_string_number_NI"){ - string_data data = generate_string_data((rand() % 10) + 6); - std::string inp_data = generate_inp_data(data); - - kaitai::kstream stream(inp_data); - glonass_t gl_string(&stream); - - REQUIRE(gl_string.idle_chip() == data[IDLE_CHIP_IDX].second); - REQUIRE(gl_string.string_number() == data[STRING_NUMBER_IDX].second); - REQUIRE(gl_string.hamming_code() == data[ST6_HC_OFF + HC_IDX].second); - REQUIRE(gl_string.pad_1() == data[ST6_HC_OFF + PAD1_IDX].second); - REQUIRE(gl_string.superframe_number() == data[ST6_HC_OFF + SUPERFRAME_IDX].second); - REQUIRE(gl_string.pad_2() == data[ST6_HC_OFF + PAD2_IDX].second); - REQUIRE(gl_string.frame_number() == data[ST6_HC_OFF + FRAME_IDX].second); - - kaitai::kstream strni(inp_data); - glonass_t strni_data(&strni); - glonass_t::string_non_immediate_t* sni = static_cast(strni_data.data()); - - REQUIRE(sni->data_1() == data[ST6_DATA_1_IDX].second); - REQUIRE(sni->data_2() == data[ST6_DATA_2_IDX].second); -} diff --git a/system/ubloxd/tests/test_glonass_runner.cc b/system/ubloxd/tests/test_glonass_runner.cc deleted file mode 100644 index 62bf7476a1..0000000000 --- a/system/ubloxd/tests/test_glonass_runner.cc +++ /dev/null @@ -1,2 +0,0 @@ -#define CATCH_CONFIG_MAIN -#include "catch2/catch.hpp" diff --git a/system/ubloxd/tests/ubloxd.py b/system/ubloxd/tests/ubloxd.py deleted file mode 100755 index c17387114f..0000000000 --- a/system/ubloxd/tests/ubloxd.py +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env python3 -# type: ignore - -from openpilot.selfdrive.locationd.test import ublox -import struct - -baudrate = 460800 -rate = 100 # send new data every 100ms - - -def configure_ublox(dev): - # configure ports and solution parameters and rate - dev.configure_port(port=ublox.PORT_USB, inMask=1, outMask=1) # enable only UBX on USB - dev.configure_port(port=0, inMask=0, outMask=0) # disable DDC - - payload = struct.pack(' - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "common/swaglog.h" - -const double gpsPi = 3.1415926535898; -#define UBLOX_MSG_SIZE(hdr) (*(uint16_t *)&hdr[4]) - -inline static bool bit_to_bool(uint8_t val, int shifts) { - return (bool)(val & (1 << shifts)); -} - -inline int UbloxMsgParser::needed_bytes() { - // Msg header incomplete? - if (bytes_in_parse_buf < ublox::UBLOX_HEADER_SIZE) - return ublox::UBLOX_HEADER_SIZE + ublox::UBLOX_CHECKSUM_SIZE - bytes_in_parse_buf; - uint16_t needed = UBLOX_MSG_SIZE(msg_parse_buf) + ublox::UBLOX_HEADER_SIZE + ublox::UBLOX_CHECKSUM_SIZE; - // too much data - if (needed < (uint16_t)bytes_in_parse_buf) - return -1; - return needed - (uint16_t)bytes_in_parse_buf; -} - -inline bool UbloxMsgParser::valid_cheksum() { - uint8_t ck_a = 0, ck_b = 0; - for (int i = 2; i < bytes_in_parse_buf - ublox::UBLOX_CHECKSUM_SIZE; i++) { - ck_a = (ck_a + msg_parse_buf[i]) & 0xFF; - ck_b = (ck_b + ck_a) & 0xFF; - } - if (ck_a != msg_parse_buf[bytes_in_parse_buf - 2]) { - LOGD("Checksum a mismatch: %02X, %02X", ck_a, msg_parse_buf[6]); - return false; - } - if (ck_b != msg_parse_buf[bytes_in_parse_buf - 1]) { - LOGD("Checksum b mismatch: %02X, %02X", ck_b, msg_parse_buf[7]); - return false; - } - return true; -} - -inline bool UbloxMsgParser::valid() { - return bytes_in_parse_buf >= ublox::UBLOX_HEADER_SIZE + ublox::UBLOX_CHECKSUM_SIZE && - needed_bytes() == 0 && valid_cheksum(); -} - -inline bool UbloxMsgParser::valid_so_far() { - if (bytes_in_parse_buf > 0 && msg_parse_buf[0] != ublox::PREAMBLE1) { - return false; - } - if (bytes_in_parse_buf > 1 && msg_parse_buf[1] != ublox::PREAMBLE2) { - return false; - } - if (needed_bytes() == 0 && !valid()) { - return false; - } - return true; -} - -bool UbloxMsgParser::add_data(float log_time, const uint8_t *incoming_data, uint32_t incoming_data_len, size_t &bytes_consumed) { - last_log_time = log_time; - int needed = needed_bytes(); - if (needed > 0) { - bytes_consumed = std::min((uint32_t)needed, incoming_data_len); - // Add data to buffer - memcpy(msg_parse_buf + bytes_in_parse_buf, incoming_data, bytes_consumed); - bytes_in_parse_buf += bytes_consumed; - } else { - bytes_consumed = incoming_data_len; - } - - // Validate msg format, detect invalid header and invalid checksum. - while (!valid_so_far() && bytes_in_parse_buf != 0) { - // Corrupted msg, drop a byte. - bytes_in_parse_buf -= 1; - if (bytes_in_parse_buf > 0) - memmove(&msg_parse_buf[0], &msg_parse_buf[1], bytes_in_parse_buf); - } - - // There is redundant data at the end of buffer, reset the buffer. - if (needed_bytes() == -1) { - bytes_in_parse_buf = 0; - } - return valid(); -} - - -std::pair> UbloxMsgParser::gen_msg() { - std::string dat = data(); - kaitai::kstream stream(dat); - - ubx_t ubx_message(&stream); - auto body = ubx_message.body(); - - switch (ubx_message.msg_type()) { - case 0x0107: - return {"gpsLocationExternal", gen_nav_pvt(static_cast(body))}; - case 0x0213: // UBX-RXM-SFRB (Broadcast Navigation Data Subframe) - return {"ubloxGnss", gen_rxm_sfrbx(static_cast(body))}; - case 0x0215: // UBX-RXM-RAW (Multi-GNSS Raw Measurement Data) - return {"ubloxGnss", gen_rxm_rawx(static_cast(body))}; - case 0x0a09: - return {"ubloxGnss", gen_mon_hw(static_cast(body))}; - case 0x0a0b: - return {"ubloxGnss", gen_mon_hw2(static_cast(body))}; - case 0x0135: - return {"ubloxGnss", gen_nav_sat(static_cast(body))}; - default: - LOGE("Unknown message type %x", ubx_message.msg_type()); - return {"ubloxGnss", kj::Array()}; - } -} - - -kj::Array UbloxMsgParser::gen_nav_pvt(ubx_t::nav_pvt_t *msg) { - MessageBuilder msg_builder; - auto gpsLoc = msg_builder.initEvent().initGpsLocationExternal(); - gpsLoc.setSource(cereal::GpsLocationData::SensorSource::UBLOX); - gpsLoc.setFlags(msg->flags()); - gpsLoc.setHasFix((msg->flags() % 2) == 1); - gpsLoc.setLatitude(msg->lat() * 1e-07); - gpsLoc.setLongitude(msg->lon() * 1e-07); - gpsLoc.setAltitude(msg->height() * 1e-03); - gpsLoc.setSpeed(msg->g_speed() * 1e-03); - gpsLoc.setBearingDeg(msg->head_mot() * 1e-5); - gpsLoc.setHorizontalAccuracy(msg->h_acc() * 1e-03); - gpsLoc.setSatelliteCount(msg->num_sv()); - std::tm timeinfo = std::tm(); - timeinfo.tm_year = msg->year() - 1900; - timeinfo.tm_mon = msg->month() - 1; - timeinfo.tm_mday = msg->day(); - timeinfo.tm_hour = msg->hour(); - timeinfo.tm_min = msg->min(); - timeinfo.tm_sec = msg->sec(); - - std::time_t utc_tt = timegm(&timeinfo); - gpsLoc.setUnixTimestampMillis(utc_tt * 1e+03 + msg->nano() * 1e-06); - float f[] = { msg->vel_n() * 1e-03f, msg->vel_e() * 1e-03f, msg->vel_d() * 1e-03f }; - gpsLoc.setVNED(f); - gpsLoc.setVerticalAccuracy(msg->v_acc() * 1e-03); - gpsLoc.setSpeedAccuracy(msg->s_acc() * 1e-03); - gpsLoc.setBearingAccuracyDeg(msg->head_acc() * 1e-05); - return capnp::messageToFlatArray(msg_builder); -} - -kj::Array UbloxMsgParser::parse_gps_ephemeris(ubx_t::rxm_sfrbx_t *msg) { - // GPS subframes are packed into 10x 4 bytes, each containing 3 actual bytes - // We will first need to separate the data from the padding and parity - auto body = *msg->body(); - assert(body.size() == 10); - - std::string subframe_data; - subframe_data.reserve(30); - for (uint32_t word : body) { - word = word >> 6; // TODO: Verify parity - subframe_data.push_back(word >> 16); - subframe_data.push_back(word >> 8); - subframe_data.push_back(word >> 0); - } - - // Collect subframes in map and parse when we have all the parts - { - kaitai::kstream stream(subframe_data); - gps_t subframe(&stream); - - int subframe_id = subframe.how()->subframe_id(); - if (subframe_id > 3 || subframe_id < 1) { - // don't parse almanac subframes - return kj::Array(); - } - gps_subframes[msg->sv_id()][subframe_id] = subframe_data; - } - - // publish if subframes 1-3 have been collected - if (gps_subframes[msg->sv_id()].size() == 3) { - MessageBuilder msg_builder; - auto eph = msg_builder.initEvent().initUbloxGnss().initEphemeris(); - eph.setSvId(msg->sv_id()); - - int iode_s2 = 0; - int iode_s3 = 0; - int iodc_lsb = 0; - int week; - - // Subframe 1 - { - kaitai::kstream stream(gps_subframes[msg->sv_id()][1]); - gps_t subframe(&stream); - gps_t::subframe_1_t* subframe_1 = static_cast(subframe.body()); - - // Each message is incremented to be greater or equal than week 1877 (2015-12-27). - // To skip this use the current_time argument - week = subframe_1->week_no(); - week += 1024; - if (week < 1877) { - week += 1024; - } - //eph.setGpsWeek(subframe_1->week_no()); - eph.setTgd(subframe_1->t_gd() * pow(2, -31)); - eph.setToc(subframe_1->t_oc() * pow(2, 4)); - eph.setAf2(subframe_1->af_2() * pow(2, -55)); - eph.setAf1(subframe_1->af_1() * pow(2, -43)); - eph.setAf0(subframe_1->af_0() * pow(2, -31)); - eph.setSvHealth(subframe_1->sv_health()); - eph.setTowCount(subframe.how()->tow_count()); - iodc_lsb = subframe_1->iodc_lsb(); - } - - // Subframe 2 - { - kaitai::kstream stream(gps_subframes[msg->sv_id()][2]); - gps_t subframe(&stream); - gps_t::subframe_2_t* subframe_2 = static_cast(subframe.body()); - - // GPS week refers to current week, the ephemeris can be valid for the next - // if toe equals 0, this can be verified by the TOW count if it is within the - // last 2 hours of the week (gps ephemeris valid for 4hours) - if (subframe_2->t_oe() == 0 and subframe.how()->tow_count()*6 >= (SECS_IN_WEEK - 2*SECS_IN_HR)){ - week += 1; - } - eph.setCrs(subframe_2->c_rs() * pow(2, -5)); - eph.setDeltaN(subframe_2->delta_n() * pow(2, -43) * gpsPi); - eph.setM0(subframe_2->m_0() * pow(2, -31) * gpsPi); - eph.setCuc(subframe_2->c_uc() * pow(2, -29)); - eph.setEcc(subframe_2->e() * pow(2, -33)); - eph.setCus(subframe_2->c_us() * pow(2, -29)); - eph.setA(pow(subframe_2->sqrt_a() * pow(2, -19), 2.0)); - eph.setToe(subframe_2->t_oe() * pow(2, 4)); - iode_s2 = subframe_2->iode(); - } - - // Subframe 3 - { - kaitai::kstream stream(gps_subframes[msg->sv_id()][3]); - gps_t subframe(&stream); - gps_t::subframe_3_t* subframe_3 = static_cast(subframe.body()); - - eph.setCic(subframe_3->c_ic() * pow(2, -29)); - eph.setOmega0(subframe_3->omega_0() * pow(2, -31) * gpsPi); - eph.setCis(subframe_3->c_is() * pow(2, -29)); - eph.setI0(subframe_3->i_0() * pow(2, -31) * gpsPi); - eph.setCrc(subframe_3->c_rc() * pow(2, -5)); - eph.setOmega(subframe_3->omega() * pow(2, -31) * gpsPi); - eph.setOmegaDot(subframe_3->omega_dot() * pow(2, -43) * gpsPi); - eph.setIode(subframe_3->iode()); - eph.setIDot(subframe_3->idot() * pow(2, -43) * gpsPi); - iode_s3 = subframe_3->iode(); - } - - eph.setToeWeek(week); - eph.setTocWeek(week); - - gps_subframes[msg->sv_id()].clear(); - if (iodc_lsb != iode_s2 || iodc_lsb != iode_s3) { - // data set cutover, reject ephemeris - return kj::Array(); - } - return capnp::messageToFlatArray(msg_builder); - } - return kj::Array(); -} - -kj::Array UbloxMsgParser::parse_glonass_ephemeris(ubx_t::rxm_sfrbx_t *msg) { - // This parser assumes that no 2 satellites of the same frequency - // can be in view at the same time - auto body = *msg->body(); - assert(body.size() == 4); - { - std::string string_data; - string_data.reserve(16); - for (uint32_t word : body) { - for (int i = 3; i >= 0; i--) - string_data.push_back(word >> 8*i); - } - - kaitai::kstream stream(string_data); - glonass_t gl_string(&stream); - int string_number = gl_string.string_number(); - if (string_number < 1 || string_number > 5 || gl_string.idle_chip()) { - // don't parse non immediate data, idle_chip == 0 - return kj::Array(); - } - - // Check if new string either has same superframe_id or log transmission times make sense - bool superframe_unknown = false; - bool needs_clear = false; - for (int i = 1; i <= 5; i++) { - if (glonass_strings[msg->freq_id()].find(i) == glonass_strings[msg->freq_id()].end()) - continue; - if (glonass_string_superframes[msg->freq_id()][i] == 0 || gl_string.superframe_number() == 0) { - superframe_unknown = true; - } else if (glonass_string_superframes[msg->freq_id()][i] != gl_string.superframe_number()) { - needs_clear = true; - } - // Check if string times add up to being from the same frame - // If superframe is known this is redundant - // Strings are sent 2s apart and frames are 30s apart - if (superframe_unknown && - std::abs((glonass_string_times[msg->freq_id()][i] - 2.0 * i) - (last_log_time - 2.0 * string_number)) > 10) - needs_clear = true; - } - if (needs_clear) { - glonass_strings[msg->freq_id()].clear(); - glonass_string_superframes[msg->freq_id()].clear(); - glonass_string_times[msg->freq_id()].clear(); - } - glonass_strings[msg->freq_id()][string_number] = string_data; - glonass_string_superframes[msg->freq_id()][string_number] = gl_string.superframe_number(); - glonass_string_times[msg->freq_id()][string_number] = last_log_time; - } - if (msg->sv_id() == 255) { - // data can be decoded before identifying the SV number, in this case 255 - // is returned, which means "unknown" (ublox p32) - return kj::Array(); - } - - // publish if strings 1-5 have been collected - if (glonass_strings[msg->freq_id()].size() != 5) { - return kj::Array(); - } - - MessageBuilder msg_builder; - auto eph = msg_builder.initEvent().initUbloxGnss().initGlonassEphemeris(); - eph.setSvId(msg->sv_id()); - eph.setFreqNum(msg->freq_id() - 7); - - uint16_t current_day = 0; - uint16_t tk = 0; - - // string number 1 - { - kaitai::kstream stream(glonass_strings[msg->freq_id()][1]); - glonass_t gl_stream(&stream); - glonass_t::string_1_t* data = static_cast(gl_stream.data()); - - eph.setP1(data->p1()); - tk = data->t_k(); - eph.setTkDEPRECATED(tk); - eph.setXVel(data->x_vel() * pow(2, -20)); - eph.setXAccel(data->x_accel() * pow(2, -30)); - eph.setX(data->x() * pow(2, -11)); - } - - // string number 2 - { - kaitai::kstream stream(glonass_strings[msg->freq_id()][2]); - glonass_t gl_stream(&stream); - glonass_t::string_2_t* data = static_cast(gl_stream.data()); - - eph.setSvHealth(data->b_n()>>2); // MSB indicates health - eph.setP2(data->p2()); - eph.setTb(data->t_b()); - eph.setYVel(data->y_vel() * pow(2, -20)); - eph.setYAccel(data->y_accel() * pow(2, -30)); - eph.setY(data->y() * pow(2, -11)); - } - - // string number 3 - { - kaitai::kstream stream(glonass_strings[msg->freq_id()][3]); - glonass_t gl_stream(&stream); - glonass_t::string_3_t* data = static_cast(gl_stream.data()); - - eph.setP3(data->p3()); - eph.setGammaN(data->gamma_n() * pow(2, -40)); - eph.setSvHealth(eph.getSvHealth() | data->l_n()); - eph.setZVel(data->z_vel() * pow(2, -20)); - eph.setZAccel(data->z_accel() * pow(2, -30)); - eph.setZ(data->z() * pow(2, -11)); - } - - // string number 4 - { - kaitai::kstream stream(glonass_strings[msg->freq_id()][4]); - glonass_t gl_stream(&stream); - glonass_t::string_4_t* data = static_cast(gl_stream.data()); - - current_day = data->n_t(); - eph.setNt(current_day); - eph.setTauN(data->tau_n() * pow(2, -30)); - eph.setDeltaTauN(data->delta_tau_n() * pow(2, -30)); - eph.setAge(data->e_n()); - eph.setP4(data->p4()); - eph.setSvURA(glonass_URA_lookup.at(data->f_t())); - if (msg->sv_id() != data->n()) { - LOGE("SV_ID != SLOT_NUMBER: %d %" PRIu64, msg->sv_id(), data->n()); - } - eph.setSvType(data->m()); - } - - // string number 5 - { - kaitai::kstream stream(glonass_strings[msg->freq_id()][5]); - glonass_t gl_stream(&stream); - glonass_t::string_5_t* data = static_cast(gl_stream.data()); - - // string5 parsing is only needed to get the year, this can be removed and - // the year can be fetched later in laika (note rollovers and leap year) - eph.setN4(data->n_4()); - int tk_seconds = SECS_IN_HR * ((tk>>7) & 0x1F) + SECS_IN_MIN * ((tk>>1) & 0x3F) + (tk & 0x1) * 30; - eph.setTkSeconds(tk_seconds); - } - - glonass_strings[msg->freq_id()].clear(); - return capnp::messageToFlatArray(msg_builder); -} - - -kj::Array UbloxMsgParser::gen_rxm_sfrbx(ubx_t::rxm_sfrbx_t *msg) { - switch (msg->gnss_id()) { - case ubx_t::gnss_type_t::GNSS_TYPE_GPS: - return parse_gps_ephemeris(msg); - case ubx_t::gnss_type_t::GNSS_TYPE_GLONASS: - return parse_glonass_ephemeris(msg); - default: - return kj::Array(); - } -} - -kj::Array UbloxMsgParser::gen_rxm_rawx(ubx_t::rxm_rawx_t *msg) { - MessageBuilder msg_builder; - auto mr = msg_builder.initEvent().initUbloxGnss().initMeasurementReport(); - mr.setRcvTow(msg->rcv_tow()); - mr.setGpsWeek(msg->week()); - mr.setLeapSeconds(msg->leap_s()); - mr.setGpsWeek(msg->week()); - - auto mb = mr.initMeasurements(msg->num_meas()); - auto measurements = *msg->meas(); - for (int8_t i = 0; i < msg->num_meas(); i++) { - mb[i].setSvId(measurements[i]->sv_id()); - mb[i].setPseudorange(measurements[i]->pr_mes()); - mb[i].setCarrierCycles(measurements[i]->cp_mes()); - mb[i].setDoppler(measurements[i]->do_mes()); - mb[i].setGnssId(measurements[i]->gnss_id()); - mb[i].setGlonassFrequencyIndex(measurements[i]->freq_id()); - mb[i].setLocktime(measurements[i]->lock_time()); - mb[i].setCno(measurements[i]->cno()); - mb[i].setPseudorangeStdev(0.01 * (pow(2, (measurements[i]->pr_stdev() & 15)))); // weird scaling, might be wrong - mb[i].setCarrierPhaseStdev(0.004 * (measurements[i]->cp_stdev() & 15)); - mb[i].setDopplerStdev(0.002 * (pow(2, (measurements[i]->do_stdev() & 15)))); // weird scaling, might be wrong - - auto ts = mb[i].initTrackingStatus(); - auto trk_stat = measurements[i]->trk_stat(); - ts.setPseudorangeValid(bit_to_bool(trk_stat, 0)); - ts.setCarrierPhaseValid(bit_to_bool(trk_stat, 1)); - ts.setHalfCycleValid(bit_to_bool(trk_stat, 2)); - ts.setHalfCycleSubtracted(bit_to_bool(trk_stat, 3)); - } - - mr.setNumMeas(msg->num_meas()); - auto rs = mr.initReceiverStatus(); - rs.setLeapSecValid(bit_to_bool(msg->rec_stat(), 0)); - rs.setClkReset(bit_to_bool(msg->rec_stat(), 2)); - return capnp::messageToFlatArray(msg_builder); -} - -kj::Array UbloxMsgParser::gen_nav_sat(ubx_t::nav_sat_t *msg) { - MessageBuilder msg_builder; - auto sr = msg_builder.initEvent().initUbloxGnss().initSatReport(); - sr.setITow(msg->itow()); - - auto svs = sr.initSvs(msg->num_svs()); - auto svs_data = *msg->svs(); - for (int8_t i = 0; i < msg->num_svs(); i++) { - svs[i].setSvId(svs_data[i]->sv_id()); - svs[i].setGnssId(svs_data[i]->gnss_id()); - svs[i].setFlagsBitfield(svs_data[i]->flags()); - svs[i].setCno(svs_data[i]->cno()); - svs[i].setElevationDeg(svs_data[i]->elev()); - svs[i].setAzimuthDeg(svs_data[i]->azim()); - svs[i].setPseudorangeResidual(svs_data[i]->pr_res() * 0.1); - } - - return capnp::messageToFlatArray(msg_builder); -} - -kj::Array UbloxMsgParser::gen_mon_hw(ubx_t::mon_hw_t *msg) { - MessageBuilder msg_builder; - auto hwStatus = msg_builder.initEvent().initUbloxGnss().initHwStatus(); - hwStatus.setNoisePerMS(msg->noise_per_ms()); - hwStatus.setFlags(msg->flags()); - hwStatus.setAgcCnt(msg->agc_cnt()); - hwStatus.setAStatus((cereal::UbloxGnss::HwStatus::AntennaSupervisorState) msg->a_status()); - hwStatus.setAPower((cereal::UbloxGnss::HwStatus::AntennaPowerStatus) msg->a_power()); - hwStatus.setJamInd(msg->jam_ind()); - return capnp::messageToFlatArray(msg_builder); -} - -kj::Array UbloxMsgParser::gen_mon_hw2(ubx_t::mon_hw2_t *msg) { - MessageBuilder msg_builder; - auto hwStatus = msg_builder.initEvent().initUbloxGnss().initHwStatus2(); - hwStatus.setOfsI(msg->ofs_i()); - hwStatus.setMagI(msg->mag_i()); - hwStatus.setOfsQ(msg->ofs_q()); - hwStatus.setMagQ(msg->mag_q()); - - switch (msg->cfg_source()) { - case ubx_t::mon_hw2_t::config_source_t::CONFIG_SOURCE_ROM: - hwStatus.setCfgSource(cereal::UbloxGnss::HwStatus2::ConfigSource::ROM); - break; - case ubx_t::mon_hw2_t::config_source_t::CONFIG_SOURCE_OTP: - hwStatus.setCfgSource(cereal::UbloxGnss::HwStatus2::ConfigSource::OTP); - break; - case ubx_t::mon_hw2_t::config_source_t::CONFIG_SOURCE_CONFIG_PINS: - hwStatus.setCfgSource(cereal::UbloxGnss::HwStatus2::ConfigSource::CONFIGPINS); - break; - case ubx_t::mon_hw2_t::config_source_t::CONFIG_SOURCE_FLASH: - hwStatus.setCfgSource(cereal::UbloxGnss::HwStatus2::ConfigSource::FLASH); - break; - default: - hwStatus.setCfgSource(cereal::UbloxGnss::HwStatus2::ConfigSource::UNDEFINED); - break; - } - - hwStatus.setLowLevCfg(msg->low_lev_cfg()); - hwStatus.setPostStatus(msg->post_status()); - - return capnp::messageToFlatArray(msg_builder); -} diff --git a/system/ubloxd/ublox_msg.h b/system/ubloxd/ublox_msg.h deleted file mode 100644 index d21760edc2..0000000000 --- a/system/ubloxd/ublox_msg.h +++ /dev/null @@ -1,131 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include -#include - -#include "cereal/messaging/messaging.h" -#include "common/util.h" -#include "system/ubloxd/generated/gps.h" -#include "system/ubloxd/generated/glonass.h" -#include "system/ubloxd/generated/ubx.h" - -using namespace std::string_literals; - -const int SECS_IN_MIN = 60; -const int SECS_IN_HR = 60 * SECS_IN_MIN; -const int SECS_IN_DAY = 24 * SECS_IN_HR; -const int SECS_IN_WEEK = 7 * SECS_IN_DAY; - -// protocol constants -namespace ublox { - const uint8_t PREAMBLE1 = 0xb5; - const uint8_t PREAMBLE2 = 0x62; - - const int UBLOX_HEADER_SIZE = 6; - const int UBLOX_CHECKSUM_SIZE = 2; - const int UBLOX_MAX_MSG_SIZE = 65536; - - struct ubx_mga_ini_time_utc_t { - uint8_t type; - uint8_t version; - uint8_t ref; - int8_t leapSecs; - uint16_t year; - uint8_t month; - uint8_t day; - uint8_t hour; - uint8_t minute; - uint8_t second; - uint8_t reserved1; - uint32_t ns; - uint16_t tAccS; - uint16_t reserved2; - uint32_t tAccNs; - } __attribute__((packed)); - - inline std::string ubx_add_checksum(const std::string &msg) { - assert(msg.size() > 2); - - uint8_t ck_a = 0, ck_b = 0; - for (int i = 2; i < msg.size(); i++) { - ck_a = (ck_a + msg[i]) & 0xFF; - ck_b = (ck_b + ck_a) & 0xFF; - } - - std::string r = msg; - r.push_back(ck_a); - r.push_back(ck_b); - return r; - } - - inline std::string build_ubx_mga_ini_time_utc(struct tm time) { - ublox::ubx_mga_ini_time_utc_t payload = { - .type = 0x10, - .version = 0x0, - .ref = 0x0, - .leapSecs = -128, // Unknown - .year = (uint16_t)(1900 + time.tm_year), - .month = (uint8_t)(1 + time.tm_mon), - .day = (uint8_t)time.tm_mday, - .hour = (uint8_t)time.tm_hour, - .minute = (uint8_t)time.tm_min, - .second = (uint8_t)time.tm_sec, - .reserved1 = 0x0, - .ns = 0, - .tAccS = 30, - .reserved2 = 0x0, - .tAccNs = 0, - }; - assert(sizeof(payload) == 24); - - std::string msg = "\xb5\x62\x13\x40\x18\x00"s; - msg += std::string((char*)&payload, sizeof(payload)); - - return ubx_add_checksum(msg); - } -} - -class UbloxMsgParser { - public: - bool add_data(float log_time, const uint8_t *incoming_data, uint32_t incoming_data_len, size_t &bytes_consumed); - inline void reset() {bytes_in_parse_buf = 0;} - inline int needed_bytes(); - inline std::string data() {return std::string((const char*)msg_parse_buf, bytes_in_parse_buf);} - - std::pair> gen_msg(); - kj::Array gen_nav_pvt(ubx_t::nav_pvt_t *msg); - kj::Array gen_rxm_sfrbx(ubx_t::rxm_sfrbx_t *msg); - kj::Array gen_rxm_rawx(ubx_t::rxm_rawx_t *msg); - kj::Array gen_mon_hw(ubx_t::mon_hw_t *msg); - kj::Array gen_mon_hw2(ubx_t::mon_hw2_t *msg); - kj::Array gen_nav_sat(ubx_t::nav_sat_t *msg); - - private: - inline bool valid_cheksum(); - inline bool valid(); - inline bool valid_so_far(); - - kj::Array parse_gps_ephemeris(ubx_t::rxm_sfrbx_t *msg); - kj::Array parse_glonass_ephemeris(ubx_t::rxm_sfrbx_t *msg); - - std::unordered_map> gps_subframes; - - float last_log_time = 0.0; - size_t bytes_in_parse_buf = 0; - uint8_t msg_parse_buf[ublox::UBLOX_HEADER_SIZE + ublox::UBLOX_MAX_MSG_SIZE]; - - // user range accuracy in meters - const std::unordered_map glonass_URA_lookup = - {{ 0, 1}, { 1, 2}, { 2, 2.5}, { 3, 4}, { 4, 5}, {5, 7}, - { 6, 10}, { 7, 12}, { 8, 14}, { 9, 16}, {10, 32}, - {11, 64}, {12, 128}, {13, 256}, {14, 512}, {15, 1024}}; - - std::unordered_map> glonass_strings; - std::unordered_map> glonass_string_times; - std::unordered_map> glonass_string_superframes; -}; diff --git a/system/ubloxd/ubloxd.cc b/system/ubloxd/ubloxd.cc deleted file mode 100644 index 4e7e91f830..0000000000 --- a/system/ubloxd/ubloxd.cc +++ /dev/null @@ -1,62 +0,0 @@ -#include - -#include - -#include "cereal/messaging/messaging.h" -#include "common/swaglog.h" -#include "common/util.h" -#include "system/ubloxd/ublox_msg.h" - -ExitHandler do_exit; -using namespace ublox; - -int main() { - LOGW("starting ubloxd"); - AlignedBuffer aligned_buf; - UbloxMsgParser parser; - - PubMaster pm({"ubloxGnss", "gpsLocationExternal"}); - - std::unique_ptr context(Context::create()); - std::unique_ptr subscriber(SubSocket::create(context.get(), "ubloxRaw")); - assert(subscriber != NULL); - subscriber->setTimeout(100); - - - while (!do_exit) { - std::unique_ptr msg(subscriber->receive()); - if (!msg) { - continue; - } - - capnp::FlatArrayMessageReader cmsg(aligned_buf.align(msg.get())); - cereal::Event::Reader event = cmsg.getRoot(); - auto ubloxRaw = event.getUbloxRaw(); - float log_time = 1e-9 * event.getLogMonoTime(); - - const uint8_t *data = ubloxRaw.begin(); - size_t len = ubloxRaw.size(); - size_t bytes_consumed = 0; - - while (bytes_consumed < len && !do_exit) { - size_t bytes_consumed_this_time = 0U; - if (parser.add_data(log_time, data + bytes_consumed, (uint32_t)(len - bytes_consumed), bytes_consumed_this_time)) { - - try { - auto ublox_msg = parser.gen_msg(); - if (ublox_msg.second.size() > 0) { - auto bytes = ublox_msg.second.asBytes(); - pm.send(ublox_msg.first.c_str(), bytes.begin(), bytes.size()); - } - } catch (const std::exception& e) { - LOGE("Error parsing ublox message %s", e.what()); - } - - parser.reset(); - } - bytes_consumed += bytes_consumed_this_time; - } - } - - return 0; -} diff --git a/system/ubloxd/ubloxd.py b/system/ubloxd/ubloxd.py new file mode 100755 index 0000000000..84a926dd78 --- /dev/null +++ b/system/ubloxd/ubloxd.py @@ -0,0 +1,519 @@ +#!/usr/bin/env python3 +import math +import capnp +import calendar +import numpy as np +from collections import defaultdict +from dataclasses import dataclass + +from cereal import log +from cereal import messaging +from openpilot.system.ubloxd.generated.ubx import Ubx +from openpilot.system.ubloxd.generated.gps import Gps +from openpilot.system.ubloxd.generated.glonass import Glonass + + +SECS_IN_MIN = 60 +SECS_IN_HR = 60 * SECS_IN_MIN +SECS_IN_DAY = 24 * SECS_IN_HR +SECS_IN_WEEK = 7 * SECS_IN_DAY + + +class UbxFramer: + PREAMBLE1 = 0xB5 + PREAMBLE2 = 0x62 + HEADER_SIZE = 6 + CHECKSUM_SIZE = 2 + + def __init__(self) -> None: + self.buf = bytearray() + self.last_log_time = 0.0 + + def reset(self) -> None: + self.buf.clear() + + @staticmethod + def _checksum_ok(frame: bytes) -> bool: + ck_a = 0 + ck_b = 0 + for b in frame[2:-2]: + ck_a = (ck_a + b) & 0xFF + ck_b = (ck_b + ck_a) & 0xFF + return ck_a == frame[-2] and ck_b == frame[-1] + + def add_data(self, log_time: float, incoming: bytes) -> list[bytes]: + self.last_log_time = log_time + out: list[bytes] = [] + if not incoming: + return out + self.buf += incoming + + while True: + # find preamble + if len(self.buf) < 2: + break + start = self.buf.find(b"\xB5\x62") + if start < 0: + # no preamble in buffer + self.buf.clear() + break + if start > 0: + # drop garbage before preamble + self.buf = self.buf[start:] + + if len(self.buf) < self.HEADER_SIZE: + break + + length_le = int.from_bytes(self.buf[4:6], 'little', signed=False) + total_len = self.HEADER_SIZE + length_le + self.CHECKSUM_SIZE + if len(self.buf) < total_len: + break + + candidate = bytes(self.buf[:total_len]) + if self._checksum_ok(candidate): + out.append(candidate) + # consume this frame + self.buf = self.buf[total_len:] + else: + # drop first byte and retry + self.buf = self.buf[1:] + + return out + + +def _bit(b: int, shift: int) -> bool: + return (b & (1 << shift)) != 0 + + +@dataclass +class EphemerisCaches: + gps_subframes: defaultdict[int, dict[int, bytes]] + glonass_strings: defaultdict[int, dict[int, bytes]] + glonass_string_times: defaultdict[int, dict[int, float]] + glonass_string_superframes: defaultdict[int, dict[int, int]] + + +class UbloxMsgParser: + gpsPi = 3.1415926535898 + + # user range accuracy in meters + glonass_URA_lookup: dict[int, float] = { + 0: 1, 1: 2, 2: 2.5, 3: 4, 4: 5, 5: 7, + 6: 10, 7: 12, 8: 14, 9: 16, 10: 32, + 11: 64, 12: 128, 13: 256, 14: 512, 15: 1024, + } + + def __init__(self) -> None: + self.framer = UbxFramer() + self.caches = EphemerisCaches( + gps_subframes=defaultdict(dict), + glonass_strings=defaultdict(dict), + glonass_string_times=defaultdict(dict), + glonass_string_superframes=defaultdict(dict), + ) + + # Message generation entry point + def parse_frame(self, frame: bytes) -> tuple[str, capnp.lib.capnp._DynamicStructBuilder] | None: + # Quick header parse + msg_type = int.from_bytes(frame[2:4], 'big') + payload = frame[6:-2] + if msg_type == 0x0107: + body = Ubx.NavPvt.from_bytes(payload) + return self._gen_nav_pvt(body) + if msg_type == 0x0213: + # Manually parse RXM-SFRBX to avoid Kaitai EOF on some frames + if len(payload) < 8: + return None + gnss_id = payload[0] + sv_id = payload[1] + freq_id = payload[3] + num_words = payload[4] + exp = 8 + 4 * num_words + if exp != len(payload): + return None + words: list[int] = [] + off = 8 + for _ in range(num_words): + words.append(int.from_bytes(payload[off:off+4], 'little')) + off += 4 + + class _SfrbxView: + def __init__(self, gid: int, sid: int, fid: int, body: list[int]): + self.gnss_id = Ubx.GnssType(gid) + self.sv_id = sid + self.freq_id = fid + self.body = body + view = _SfrbxView(gnss_id, sv_id, freq_id, words) + return self._gen_rxm_sfrbx(view) + if msg_type == 0x0215: + body = Ubx.RxmRawx.from_bytes(payload) + return self._gen_rxm_rawx(body) + if msg_type == 0x0A09: + body = Ubx.MonHw.from_bytes(payload) + return self._gen_mon_hw(body) + if msg_type == 0x0A0B: + body = Ubx.MonHw2.from_bytes(payload) + return self._gen_mon_hw2(body) + if msg_type == 0x0135: + body = Ubx.NavSat.from_bytes(payload) + return self._gen_nav_sat(body) + return None + + # NAV-PVT -> gpsLocationExternal + def _gen_nav_pvt(self, msg: Ubx.NavPvt) -> tuple[str, capnp.lib.capnp._DynamicStructBuilder]: + dat = messaging.new_message('gpsLocationExternal', valid=True) + gps = dat.gpsLocationExternal + gps.source = log.GpsLocationData.SensorSource.ublox + gps.flags = msg.flags + gps.hasFix = (msg.flags % 2) == 1 + gps.latitude = msg.lat * 1e-07 + gps.longitude = msg.lon * 1e-07 + gps.altitude = msg.height * 1e-03 + gps.speed = msg.g_speed * 1e-03 + gps.bearingDeg = msg.head_mot * 1e-5 + gps.horizontalAccuracy = msg.h_acc * 1e-03 + gps.satelliteCount = msg.num_sv + + # build UTC timestamp millis (NAV-PVT is in UTC) + # tolerate invalid or unset date values like C++ timegm + try: + utc_tt = calendar.timegm((msg.year, msg.month, msg.day, msg.hour, msg.min, msg.sec, 0, 0, 0)) + except Exception: + utc_tt = 0 + gps.unixTimestampMillis = int(utc_tt * 1e3 + (msg.nano * 1e-6)) + + # match C++ float32 rounding semantics exactly + gps.vNED = [ + float(np.float32(msg.vel_n) * np.float32(1e-03)), + float(np.float32(msg.vel_e) * np.float32(1e-03)), + float(np.float32(msg.vel_d) * np.float32(1e-03)), + ] + gps.verticalAccuracy = msg.v_acc * 1e-03 + gps.speedAccuracy = msg.s_acc * 1e-03 + gps.bearingAccuracyDeg = msg.head_acc * 1e-05 + return ('gpsLocationExternal', dat) + + # RXM-SFRBX dispatch to GPS or GLONASS ephemeris + def _gen_rxm_sfrbx(self, msg) -> tuple[str, capnp.lib.capnp._DynamicStructBuilder] | None: + if msg.gnss_id == Ubx.GnssType.gps: + return self._parse_gps_ephemeris(msg) + if msg.gnss_id == Ubx.GnssType.glonass: + return self._parse_glonass_ephemeris(msg) + return None + + def _parse_gps_ephemeris(self, msg: Ubx.RxmSfrbx) -> tuple[str, capnp.lib.capnp._DynamicStructBuilder] | None: + # body is list of 10 words; convert to 30-byte subframe (strip parity/padding) + body = msg.body + if len(body) != 10: + return None + subframe_data = bytearray() + for word in body: + word >>= 6 + subframe_data.append((word >> 16) & 0xFF) + subframe_data.append((word >> 8) & 0xFF) + subframe_data.append(word & 0xFF) + + sf = Gps.from_bytes(bytes(subframe_data)) + subframe_id = sf.how.subframe_id + if subframe_id < 1 or subframe_id > 3: + return None + self.caches.gps_subframes[msg.sv_id][subframe_id] = bytes(subframe_data) + + if len(self.caches.gps_subframes[msg.sv_id]) != 3: + return None + + dat = messaging.new_message('ubloxGnss', valid=True) + eph = dat.ubloxGnss.init('ephemeris') + eph.svId = msg.sv_id + + iode_s2 = 0 + iode_s3 = 0 + iodc_lsb = 0 + week = 0 + + # Subframe 1 + sf1 = Gps.from_bytes(self.caches.gps_subframes[msg.sv_id][1]) + s1 = sf1.body + assert isinstance(s1, Gps.Subframe1) + week = s1.week_no + week += 1024 + if week < 1877: + week += 1024 + eph.tgd = s1.t_gd * math.pow(2, -31) + eph.toc = s1.t_oc * math.pow(2, 4) + eph.af2 = s1.af_2 * math.pow(2, -55) + eph.af1 = s1.af_1 * math.pow(2, -43) + eph.af0 = s1.af_0 * math.pow(2, -31) + eph.svHealth = s1.sv_health + eph.towCount = sf1.how.tow_count + iodc_lsb = s1.iodc_lsb + + # Subframe 2 + sf2 = Gps.from_bytes(self.caches.gps_subframes[msg.sv_id][2]) + s2 = sf2.body + assert isinstance(s2, Gps.Subframe2) + if s2.t_oe == 0 and sf2.how.tow_count * 6 >= (SECS_IN_WEEK - 2 * SECS_IN_HR): + week += 1 + eph.crs = s2.c_rs * math.pow(2, -5) + eph.deltaN = s2.delta_n * math.pow(2, -43) * self.gpsPi + eph.m0 = s2.m_0 * math.pow(2, -31) * self.gpsPi + eph.cuc = s2.c_uc * math.pow(2, -29) + eph.ecc = s2.e * math.pow(2, -33) + eph.cus = s2.c_us * math.pow(2, -29) + eph.a = math.pow(s2.sqrt_a * math.pow(2, -19), 2.0) + eph.toe = s2.t_oe * math.pow(2, 4) + iode_s2 = s2.iode + + # Subframe 3 + sf3 = Gps.from_bytes(self.caches.gps_subframes[msg.sv_id][3]) + s3 = sf3.body + assert isinstance(s3, Gps.Subframe3) + eph.cic = s3.c_ic * math.pow(2, -29) + eph.omega0 = s3.omega_0 * math.pow(2, -31) * self.gpsPi + eph.cis = s3.c_is * math.pow(2, -29) + eph.i0 = s3.i_0 * math.pow(2, -31) * self.gpsPi + eph.crc = s3.c_rc * math.pow(2, -5) + eph.omega = s3.omega * math.pow(2, -31) * self.gpsPi + eph.omegaDot = s3.omega_dot * math.pow(2, -43) * self.gpsPi + eph.iode = s3.iode + eph.iDot = s3.idot * math.pow(2, -43) * self.gpsPi + iode_s3 = s3.iode + + eph.toeWeek = week + eph.tocWeek = week + + # clear cache for this SV + self.caches.gps_subframes[msg.sv_id].clear() + if not (iodc_lsb == iode_s2 == iode_s3): + return None + return ('ubloxGnss', dat) + + def _parse_glonass_ephemeris(self, msg: Ubx.RxmSfrbx) -> tuple[str, capnp.lib.capnp._DynamicStructBuilder] | None: + # words are 4 bytes each; Glonass parser expects 16 bytes (string) + body = msg.body + if len(body) != 4: + return None + string_bytes = bytearray() + for word in body: + for i in (3, 2, 1, 0): + string_bytes.append((word >> (8 * i)) & 0xFF) + + gl = Glonass.from_bytes(bytes(string_bytes)) + string_number = gl.string_number + if string_number < 1 or string_number > 5 or gl.idle_chip: + return None + + # correlate by superframe and timing, similar to C++ logic + freq_id = msg.freq_id + superframe_unknown = False + needs_clear = False + for i in range(1, 6): + if i not in self.caches.glonass_strings[freq_id]: + continue + sf_prev = self.caches.glonass_string_superframes[freq_id].get(i, 0) + if sf_prev == 0 or gl.superframe_number == 0: + superframe_unknown = True + elif sf_prev != gl.superframe_number: + needs_clear = True + if superframe_unknown: + prev_time = self.caches.glonass_string_times[freq_id].get(i, 0.0) + if abs((prev_time - 2.0 * i) - (self.framer.last_log_time - 2.0 * string_number)) > 10: + needs_clear = True + + if needs_clear: + self.caches.glonass_strings[freq_id].clear() + self.caches.glonass_string_superframes[freq_id].clear() + self.caches.glonass_string_times[freq_id].clear() + + self.caches.glonass_strings[freq_id][string_number] = bytes(string_bytes) + self.caches.glonass_string_superframes[freq_id][string_number] = gl.superframe_number + self.caches.glonass_string_times[freq_id][string_number] = self.framer.last_log_time + + if msg.sv_id == 255: + # unknown SV id + return None + if len(self.caches.glonass_strings[freq_id]) != 5: + return None + + dat = messaging.new_message('ubloxGnss', valid=True) + eph = dat.ubloxGnss.init('glonassEphemeris') + eph.svId = msg.sv_id + eph.freqNum = msg.freq_id - 7 + + current_day = 0 + tk = 0 + + # string 1 + try: + s1 = Glonass.from_bytes(self.caches.glonass_strings[freq_id][1]).data + except Exception: + return None + assert isinstance(s1, Glonass.String1) + eph.p1 = int(s1.p1) + tk = int(s1.t_k) + eph.tkDEPRECATED = 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) + + # string 2 + try: + s2 = Glonass.from_bytes(self.caches.glonass_strings[freq_id][2]).data + except Exception: + return None + assert isinstance(s2, Glonass.String2) + eph.svHealth = int(s2.b_n >> 2) + eph.p2 = int(s2.p2) + eph.tb = int(s2.t_b) + eph.yVel = float(s2.y_vel) * math.pow(2, -20) + eph.yAccel = float(s2.y_accel) * math.pow(2, -30) + eph.y = float(s2.y) * math.pow(2, -11) + + # string 3 + try: + s3 = Glonass.from_bytes(self.caches.glonass_strings[freq_id][3]).data + except Exception: + return None + assert isinstance(s3, Glonass.String3) + eph.p3 = int(s3.p3) + eph.gammaN = float(s3.gamma_n) * math.pow(2, -40) + eph.svHealth = int(eph.svHealth | (1 if s3.l_n else 0)) + eph.zVel = float(s3.z_vel) * math.pow(2, -20) + eph.zAccel = float(s3.z_accel) * math.pow(2, -30) + eph.z = float(s3.z) * math.pow(2, -11) + + # string 4 + try: + s4 = Glonass.from_bytes(self.caches.glonass_strings[freq_id][4]).data + except Exception: + return None + assert isinstance(s4, Glonass.String4) + current_day = int(s4.n_t) + eph.nt = current_day + eph.tauN = float(s4.tau_n) * math.pow(2, -30) + eph.deltaTauN = float(s4.delta_tau_n) * math.pow(2, -30) + eph.age = int(s4.e_n) + eph.p4 = int(s4.p4) + eph.svURA = float(self.glonass_URA_lookup.get(int(s4.f_t), 0.0)) + # consistency check: SV slot number + # if it doesn't match, keep going but note mismatch (no logging here) + eph.svType = int(s4.m) + + # string 5 + try: + s5 = Glonass.from_bytes(self.caches.glonass_strings[freq_id][5]).data + except Exception: + return None + assert isinstance(s5, Glonass.String5) + eph.n4 = int(s5.n_4) + tk_seconds = int(SECS_IN_HR * ((tk >> 7) & 0x1F) + SECS_IN_MIN * ((tk >> 1) & 0x3F) + (tk & 0x1) * 30) + eph.tkSeconds = tk_seconds + + self.caches.glonass_strings[freq_id].clear() + return ('ubloxGnss', dat) + + def _gen_rxm_rawx(self, msg: Ubx.RxmRawx) -> tuple[str, capnp.lib.capnp._DynamicStructBuilder]: + dat = messaging.new_message('ubloxGnss', valid=True) + mr = dat.ubloxGnss.init('measurementReport') + mr.rcvTow = msg.rcv_tow + mr.gpsWeek = msg.week + mr.leapSeconds = msg.leap_s + + mb = mr.init('measurements', msg.num_meas) + for i, m in enumerate(msg.meas): + mb[i].svId = m.sv_id + mb[i].pseudorange = m.pr_mes + mb[i].carrierCycles = m.cp_mes + mb[i].doppler = m.do_mes + mb[i].gnssId = int(m.gnss_id.value) + mb[i].glonassFrequencyIndex = m.freq_id + mb[i].locktime = m.lock_time + mb[i].cno = m.cno + mb[i].pseudorangeStdev = 0.01 * (math.pow(2, (m.pr_stdev & 15))) + mb[i].carrierPhaseStdev = 0.004 * (m.cp_stdev & 15) + mb[i].dopplerStdev = 0.002 * (math.pow(2, (m.do_stdev & 15))) + + ts = mb[i].init('trackingStatus') + trk = m.trk_stat + ts.pseudorangeValid = _bit(trk, 0) + ts.carrierPhaseValid = _bit(trk, 1) + ts.halfCycleValid = _bit(trk, 2) + ts.halfCycleSubtracted = _bit(trk, 3) + + mr.numMeas = msg.num_meas + rs = mr.init('receiverStatus') + rs.leapSecValid = _bit(msg.rec_stat, 0) + rs.clkReset = _bit(msg.rec_stat, 2) + return ('ubloxGnss', dat) + + def _gen_nav_sat(self, msg: Ubx.NavSat) -> tuple[str, capnp.lib.capnp._DynamicStructBuilder]: + dat = messaging.new_message('ubloxGnss', valid=True) + sr = dat.ubloxGnss.init('satReport') + sr.iTow = msg.itow + svs = sr.init('svs', msg.num_svs) + for i, s in enumerate(msg.svs): + svs[i].svId = s.sv_id + svs[i].gnssId = int(s.gnss_id.value) + svs[i].flagsBitfield = s.flags + svs[i].cno = s.cno + svs[i].elevationDeg = s.elev + svs[i].azimuthDeg = s.azim + svs[i].pseudorangeResidual = s.pr_res * 0.1 + return ('ubloxGnss', dat) + + def _gen_mon_hw(self, msg: Ubx.MonHw) -> tuple[str, capnp.lib.capnp._DynamicStructBuilder]: + dat = messaging.new_message('ubloxGnss', valid=True) + hw = dat.ubloxGnss.init('hwStatus') + hw.noisePerMS = msg.noise_per_ms + hw.flags = msg.flags + hw.agcCnt = msg.agc_cnt + hw.aStatus = int(msg.a_status.value) + hw.aPower = int(msg.a_power.value) + hw.jamInd = msg.jam_ind + return ('ubloxGnss', dat) + + def _gen_mon_hw2(self, msg: Ubx.MonHw2) -> tuple[str, capnp.lib.capnp._DynamicStructBuilder]: + dat = messaging.new_message('ubloxGnss', valid=True) + hw = dat.ubloxGnss.init('hwStatus2') + hw.ofsI = msg.ofs_i + hw.magI = msg.mag_i + hw.ofsQ = msg.ofs_q + hw.magQ = msg.mag_q + # Map Ubx enum to cereal enum {undefined=0, rom=1, otp=2, configpins=3, flash=4} + cfg_map = { + Ubx.MonHw2.ConfigSource.rom: 1, + Ubx.MonHw2.ConfigSource.otp: 2, + Ubx.MonHw2.ConfigSource.config_pins: 3, + Ubx.MonHw2.ConfigSource.flash: 4, + } + hw.cfgSource = cfg_map.get(msg.cfg_source, 0) + hw.lowLevCfg = msg.low_lev_cfg + hw.postStatus = msg.post_status + return ('ubloxGnss', dat) + + +def main(): + parser = UbloxMsgParser() + pm = messaging.PubMaster(['ubloxGnss', 'gpsLocationExternal']) + sock = messaging.sub_sock('ubloxRaw', timeout=100, conflate=False) + + while True: + msg = messaging.recv_one_or_none(sock) + if msg is None: + continue + + data = bytes(msg.ubloxRaw) + log_time = msg.logMonoTime * 1e-9 + frames = parser.framer.add_data(log_time, data) + for frame in frames: + try: + res = parser.parse_frame(frame) + except Exception: + continue + if not res: + continue + service, dat = res + pm.send(service, dat) + +if __name__ == '__main__': + main() diff --git a/system/ui/lib/networkmanager.py b/system/ui/lib/networkmanager.py new file mode 100644 index 0000000000..07b5d42f4b --- /dev/null +++ b/system/ui/lib/networkmanager.py @@ -0,0 +1,44 @@ +from enum import IntEnum + + +# NetworkManager device states +class NMDeviceState(IntEnum): + UNKNOWN = 0 + DISCONNECTED = 30 + PREPARE = 40 + STATE_CONFIG = 50 + NEED_AUTH = 60 + IP_CONFIG = 70 + ACTIVATED = 100 + DEACTIVATING = 110 + + +# NetworkManager constants +NM = "org.freedesktop.NetworkManager" +NM_PATH = '/org/freedesktop/NetworkManager' +NM_IFACE = 'org.freedesktop.NetworkManager' +NM_ACCESS_POINT_IFACE = 'org.freedesktop.NetworkManager.AccessPoint' +NM_SETTINGS_PATH = '/org/freedesktop/NetworkManager/Settings' +NM_SETTINGS_IFACE = 'org.freedesktop.NetworkManager.Settings' +NM_CONNECTION_IFACE = 'org.freedesktop.NetworkManager.Settings.Connection' +NM_WIRELESS_IFACE = 'org.freedesktop.NetworkManager.Device.Wireless' +NM_PROPERTIES_IFACE = 'org.freedesktop.DBus.Properties' +NM_DEVICE_IFACE = "org.freedesktop.NetworkManager.Device" + +NM_DEVICE_TYPE_WIFI = 2 +NM_DEVICE_TYPE_MODEM = 8 +NM_DEVICE_STATE_REASON_SUPPLICANT_DISCONNECT = 8 +NM_DEVICE_STATE_REASON_NEW_ACTIVATION = 60 + +# https://developer.gnome.org/NetworkManager/1.26/nm-dbus-types.html#NM80211ApFlags +NM_802_11_AP_FLAGS_NONE = 0x0 +NM_802_11_AP_FLAGS_PRIVACY = 0x1 +NM_802_11_AP_FLAGS_WPS = 0x2 + +# https://developer.gnome.org/NetworkManager/1.26/nm-dbus-types.html#NM80211ApSecurityFlags +NM_802_11_AP_SEC_PAIR_WEP40 = 0x00000001 +NM_802_11_AP_SEC_PAIR_WEP104 = 0x00000002 +NM_802_11_AP_SEC_GROUP_WEP40 = 0x00000010 +NM_802_11_AP_SEC_GROUP_WEP104 = 0x00000020 +NM_802_11_AP_SEC_KEY_MGMT_PSK = 0x00000100 +NM_802_11_AP_SEC_KEY_MGMT_802_1X = 0x00000200 diff --git a/system/ui/lib/wifi_manager.py b/system/ui/lib/wifi_manager.py index 4cb741bc95..af9ae943ea 100644 --- a/system/ui/lib/wifi_manager.py +++ b/system/ui/lib/wifi_manager.py @@ -1,52 +1,35 @@ -import asyncio -import concurrent.futures -import copy +import atexit import threading import time import uuid from collections.abc import Callable from dataclasses import dataclass from enum import IntEnum -from typing import TypeVar +from typing import Any -from dbus_next.aio import MessageBus -from dbus_next import BusType, Variant, Message -from dbus_next.errors import DBusError -from dbus_next.constants import MessageType +from jeepney import DBusAddress, new_method_call +from jeepney.bus_messages import MatchRule, message_bus +from jeepney.io.blocking import open_dbus_connection as open_dbus_connection_blocking +from jeepney.io.threading import DBusRouter, open_dbus_connection as open_dbus_connection_threading +from jeepney.low_level import MessageType +from jeepney.wrappers import Properties -try: - from openpilot.common.params import Params -except ImportError: - # Params/Cythonized modules are not available in zipapp - Params = None from openpilot.common.swaglog import cloudlog - -T = TypeVar("T") - -# NetworkManager constants -NM = "org.freedesktop.NetworkManager" -NM_PATH = '/org/freedesktop/NetworkManager' -NM_IFACE = 'org.freedesktop.NetworkManager' -NM_SETTINGS_PATH = '/org/freedesktop/NetworkManager/Settings' -NM_SETTINGS_IFACE = 'org.freedesktop.NetworkManager.Settings' -NM_CONNECTION_IFACE = 'org.freedesktop.NetworkManager.Settings.Connection' -NM_WIRELESS_IFACE = 'org.freedesktop.NetworkManager.Device.Wireless' -NM_PROPERTIES_IFACE = 'org.freedesktop.DBus.Properties' -NM_DEVICE_IFACE = "org.freedesktop.NetworkManager.Device" - -NM_DEVICE_STATE_REASON_SUPPLICANT_DISCONNECT = 8 +from openpilot.system.ui.lib.networkmanager import (NM, NM_WIRELESS_IFACE, NM_802_11_AP_SEC_PAIR_WEP40, + NM_802_11_AP_SEC_PAIR_WEP104, NM_802_11_AP_SEC_GROUP_WEP40, + NM_802_11_AP_SEC_GROUP_WEP104, NM_802_11_AP_SEC_KEY_MGMT_PSK, + NM_802_11_AP_SEC_KEY_MGMT_802_1X, NM_802_11_AP_FLAGS_NONE, + NM_802_11_AP_FLAGS_PRIVACY, NM_802_11_AP_FLAGS_WPS, + NM_PATH, NM_IFACE, NM_ACCESS_POINT_IFACE, NM_SETTINGS_PATH, + NM_SETTINGS_IFACE, NM_CONNECTION_IFACE, NM_DEVICE_IFACE, + NM_DEVICE_TYPE_WIFI, NM_DEVICE_STATE_REASON_SUPPLICANT_DISCONNECT, + NM_DEVICE_STATE_REASON_NEW_ACTIVATION, + NMDeviceState) TETHERING_IP_ADDRESS = "192.168.43.1" DEFAULT_TETHERING_PASSWORD = "swagswagcomma" - - -# NetworkManager device states -class NMDeviceState(IntEnum): - DISCONNECTED = 30 - PREPARE = 40 - NEED_AUTH = 60 - IP_CONFIG = 70 - ACTIVATED = 100 +SIGNAL_QUEUE_SIZE = 10 +SCAN_PERIOD_SECONDS = 10 class SecurityType(IntEnum): @@ -57,673 +40,387 @@ class SecurityType(IntEnum): UNSUPPORTED = 4 -@dataclass -class NetworkInfo: +def get_security_type(flags: int, wpa_flags: int, rsn_flags: int) -> SecurityType: + wpa_props = wpa_flags | rsn_flags + + # obtained by looking at flags of networks in the office as reported by an Android phone + supports_wpa = (NM_802_11_AP_SEC_PAIR_WEP40 | NM_802_11_AP_SEC_PAIR_WEP104 | NM_802_11_AP_SEC_GROUP_WEP40 | + NM_802_11_AP_SEC_GROUP_WEP104 | NM_802_11_AP_SEC_KEY_MGMT_PSK) + + if (flags == NM_802_11_AP_FLAGS_NONE) or ((flags & NM_802_11_AP_FLAGS_WPS) and not (wpa_props & supports_wpa)): + return SecurityType.OPEN + elif (flags & NM_802_11_AP_FLAGS_PRIVACY) and (wpa_props & supports_wpa) and not (wpa_props & NM_802_11_AP_SEC_KEY_MGMT_802_1X): + return SecurityType.WPA + else: + cloudlog.warning(f"Unsupported network! flags: {flags}, wpa_flags: {wpa_flags}, rsn_flags: {rsn_flags}") + return SecurityType.UNSUPPORTED + + +@dataclass(frozen=True) +class Network: ssid: str strength: int is_connected: bool security_type: SecurityType - path: str + is_saved: bool + + @classmethod + def from_dbus(cls, ssid: str, aps: list["AccessPoint"], is_saved: bool) -> "Network": + # we only want to show the strongest AP for each Network/SSID + strongest_ap = max(aps, key=lambda ap: ap.strength) + is_connected = any(ap.is_connected for ap in aps) + security_type = get_security_type(strongest_ap.flags, strongest_ap.wpa_flags, strongest_ap.rsn_flags) + + return cls( + ssid=ssid, + strength=strongest_ap.strength, + is_connected=is_connected and is_saved, + security_type=security_type, + is_saved=is_saved, + ) + + +@dataclass(frozen=True) +class AccessPoint: + ssid: str bssid: str - is_saved: bool = False - # saved_path: str + strength: int + is_connected: bool + flags: int + wpa_flags: int + rsn_flags: int + ap_path: str + @classmethod + def from_dbus(cls, ap_props: dict[str, tuple[str, Any]], ap_path: str, active_ap_path: str) -> "AccessPoint": + ssid = bytes(ap_props['Ssid'][1]).decode("utf-8", "replace") + bssid = str(ap_props['HwAddress'][1]) + strength = int(ap_props['Strength'][1]) + flags = int(ap_props['Flags'][1]) + wpa_flags = int(ap_props['WpaFlags'][1]) + rsn_flags = int(ap_props['RsnFlags'][1]) -@dataclass -class WifiManagerCallbacks: - need_auth: Callable[[str], None] | None = None - activated: Callable[[], None] | None = None - forgotten: Callable[[str], None] | None = None - networks_updated: Callable[[list[NetworkInfo]], None] | None = None - connection_failed: Callable[[str, str], None] | None = None # Added for error feedback + return cls( + ssid=ssid, + bssid=bssid, + strength=strength, + is_connected=ap_path == active_ap_path, + flags=flags, + wpa_flags=wpa_flags, + rsn_flags=rsn_flags, + ap_path=ap_path, + ) class WifiManager: - def __init__(self, callbacks): - self.callbacks: WifiManagerCallbacks = callbacks - self.networks: list[NetworkInfo] = [] - self.bus: MessageBus = None - self.device_path: str = "" - self.device_proxy = None - self.saved_connections: dict[str, str] = {} - self.active_ap_path: str = "" - self.scan_task: asyncio.Task | None = None - # Set tethering ssid as "weedle" + first 4 characters of a dongle id - self._tethering_ssid = "weedle" - if Params is not None: - dongle_id = Params().get("DongleId") - if dongle_id: - self._tethering_ssid += "-" + dongle_id[:4] - self.running: bool = True - self._current_connection_ssid: str | None = None + def __init__(self): + self._networks = [] # a network can be comprised of multiple APs + self._active = True # used to not run when not in settings + self._exit = False - async def connect(self) -> None: - """Connect to the DBus system bus.""" + # DBus connections try: - self.bus = await MessageBus(bus_type=BusType.SYSTEM).connect() - while not await self._find_wifi_device(): - await asyncio.sleep(1) + self._router_main = DBusRouter(open_dbus_connection_threading(bus="SYSTEM")) # used by scanner / general method calls + self._conn_monitor = open_dbus_connection_blocking(bus="SYSTEM") # used by state monitor thread + self._nm = DBusAddress(NM_PATH, bus_name=NM, interface=NM_IFACE) + except FileNotFoundError: + cloudlog.exception("Failed to connect to system D-Bus") + self._exit = True - await self._setup_signals(self.device_path) - self.active_ap_path = await self.get_active_access_point() - await self.add_tethering_connection(self._tethering_ssid, DEFAULT_TETHERING_PASSWORD) - self.saved_connections = await self._get_saved_connections() - self.scan_task = asyncio.create_task(self._periodic_scan()) - except DBusError as e: - cloudlog.error(f"Failed to connect to DBus: {e}") - raise - except Exception as e: - cloudlog.error(f"Unexpected error during connect: {e}") - raise + # Store wifi device path + self._wifi_device: str | None = None - async def shutdown(self) -> None: - self.running = False - if self.scan_task: - self.scan_task.cancel() - try: - await self.scan_task - except asyncio.CancelledError: - pass - if self.bus: - self.bus.disconnect() + # State + self._connecting_to_ssid: str = "" + self._last_network_update: float = 0.0 + self._callback_queue: list[Callable] = [] - async def _request_scan(self) -> None: - try: - interface = self.device_proxy.get_interface(NM_WIRELESS_IFACE) - await interface.call_request_scan({}) - except DBusError as e: - cloudlog.warning(f"Scan request failed: {str(e)}") + # Callbacks + self._need_auth: Callable[[str], None] | None = None + self._activated: Callable[[], None] | None = None + self._forgotten: Callable[[], None] | None = None + self._networks_updated: Callable[[list[Network]], None] | None = None + self._disconnected: Callable[[], None] | None = None - async def get_active_access_point(self): - try: - props_iface = self.device_proxy.get_interface(NM_PROPERTIES_IFACE) - ap_path = await props_iface.call_get(NM_WIRELESS_IFACE, 'ActiveAccessPoint') - return ap_path.value - except DBusError as e: - cloudlog.error(f"Error fetching active access point: {str(e)}") - return '' + self._lock = threading.Lock() - async def forget_connection(self, ssid: str) -> bool: - path = self.saved_connections.get(ssid) - if not path: - return False + self._scan_thread = threading.Thread(target=self._network_scanner, daemon=True) + self._scan_thread.start() - try: - nm_iface = await self._get_interface(NM, path, NM_CONNECTION_IFACE) - await nm_iface.call_delete() + self._state_thread = threading.Thread(target=self._monitor_state, daemon=True) + self._state_thread.start() - if self._current_connection_ssid == ssid: - self._current_connection_ssid = None + atexit.register(self.stop) - if ssid in self.saved_connections: - del self.saved_connections[ssid] + def set_callbacks(self, need_auth: Callable[[str], None], + activated: Callable[[], None] | None, + forgotten: Callable[[], None], + networks_updated: Callable[[list[Network]], None], + disconnected: Callable[[], None]): + self._need_auth = need_auth + self._activated = activated + self._forgotten = forgotten + self._networks_updated = networks_updated + self._disconnected = disconnected - for network in self.networks: - if network.ssid == ssid: - network.is_saved = False - network.is_connected = False + def _enqueue_callback(self, cb: Callable, *args): + self._callback_queue.append(lambda: cb(*args)) + + def process_callbacks(self): + # Call from UI thread to run any pending callbacks + to_run, self._callback_queue = self._callback_queue, [] + for cb in to_run: + cb() + + def set_active(self, active: bool): + self._active = active + + # Scan immediately if we haven't scanned in a while + if active and time.monotonic() - self._last_network_update > SCAN_PERIOD_SECONDS / 2: + self._last_network_update = 0.0 + + def _monitor_state(self): + device_path = self._wait_for_wifi_device() + if device_path is None: + return + + rule = MatchRule( + type="signal", + interface=NM_DEVICE_IFACE, + member="StateChanged", + path=device_path, + ) + + # Filter for StateChanged signal + self._conn_monitor.send_and_get_reply(message_bus.AddMatch(rule)) + + with self._conn_monitor.filter(rule, bufsize=SIGNAL_QUEUE_SIZE) as q: + while not self._exit: + if not self._active: + time.sleep(1) + continue + + # Block until a matching signal arrives + try: + msg = self._conn_monitor.recv_until_filtered(q, timeout=1) + except TimeoutError: + continue + + new_state, previous_state, change_reason = msg.body + + # BAD PASSWORD + if new_state == NMDeviceState.NEED_AUTH and change_reason == NM_DEVICE_STATE_REASON_SUPPLICANT_DISCONNECT and len(self._connecting_to_ssid): + self.forget_connection(self._connecting_to_ssid, block=True) + if self._need_auth is not None: + self._enqueue_callback(self._need_auth, self._connecting_to_ssid) + self._connecting_to_ssid = "" + + elif new_state == NMDeviceState.ACTIVATED: + if self._activated is not None: + self._update_networks() + self._enqueue_callback(self._activated) + self._connecting_to_ssid = "" + + elif new_state == NMDeviceState.DISCONNECTED and change_reason != NM_DEVICE_STATE_REASON_NEW_ACTIVATION: + self._connecting_to_ssid = "" + if self._disconnected is not None: + self._enqueue_callback(self._disconnected) + + def _network_scanner(self): + self._wait_for_wifi_device() + + while not self._exit: + if self._active: + if time.monotonic() - self._last_network_update > SCAN_PERIOD_SECONDS: + # Scan for networks every 10 seconds + # TODO: should update when scan is complete (PropertiesChanged), but this is more than good enough for now + self._update_networks() + self._request_scan() + self._last_network_update = time.monotonic() + time.sleep(1 / 2.) + + def _wait_for_wifi_device(self) -> str | None: + with self._lock: + device_path: str | None = None + while not self._exit: + device_path = self._get_wifi_device() + if device_path is not None: break + time.sleep(1) + return device_path - # Notify UI of forgotten connection - if self.callbacks.networks_updated: - self.callbacks.networks_updated(copy.deepcopy(self.networks)) + def _get_wifi_device(self) -> str | None: + if self._wifi_device is not None: + return self._wifi_device - return True - except DBusError as e: - cloudlog.error(f"Failed to delete connection for SSID: {ssid}. Error: {e}") - return False + device_paths = self._router_main.send_and_get_reply(new_method_call(self._nm, 'GetDevices')).body[0] + for device_path in device_paths: + dev_addr = DBusAddress(device_path, bus_name=NM, interface=NM_DEVICE_IFACE) + dev_type = self._router_main.send_and_get_reply(Properties(dev_addr).get('DeviceType')).body[0][1] - async def activate_connection(self, ssid: str) -> bool: - connection_path = self.saved_connections.get(ssid) - if not connection_path: - return False - try: - nm_iface = await self._get_interface(NM, NM_PATH, NM_IFACE) - await nm_iface.call_activate_connection(connection_path, self.device_path, "/") - return True - except DBusError as e: - cloudlog.error(f"Failed to activate connection {ssid}: {str(e)}") - return False + if dev_type == NM_DEVICE_TYPE_WIFI: + self._wifi_device = device_path + break - async def connect_to_network(self, ssid: str, password: str = None, bssid: str = None, is_hidden: bool = False) -> None: - """Connect to a selected Wi-Fi network.""" - try: - self._current_connection_ssid = ssid + return self._wifi_device - if ssid in self.saved_connections: - # Forget old connection if new password provided - if password: - await self.forget_connection(ssid) - await asyncio.sleep(0.2) # NetworkManager delay - else: - # Just activate existing connection - await self.activate_connection(ssid) - return + def _get_connections(self) -> dict[str, str]: + settings_addr = DBusAddress(NM_SETTINGS_PATH, bus_name=NM, interface=NM_SETTINGS_IFACE) + known_connections = self._router_main.send_and_get_reply(new_method_call(settings_addr, 'ListConnections')).body[0] + + conns: dict[str, str] = {} + for conn_path in known_connections: + conn_addr = DBusAddress(conn_path, bus_name=NM, interface=NM_CONNECTION_IFACE) + reply = self._router_main.send_and_get_reply(new_method_call(conn_addr, "GetSettings")) + + # ignore connections removed during iteration (need auth, etc.) + if reply.header.message_type == MessageType.error: + cloudlog.warning(f"Failed to get connection properties for {conn_path}") + continue + + settings = reply.body[0] + if "802-11-wireless" in settings: + ssid = settings['802-11-wireless']['ssid'][1].decode("utf-8", "replace") + if ssid != "": + conns[ssid] = conn_path + return conns + + def connect_to_network(self, ssid: str, password: str): + def worker(): + # Clear all connections that may already exist to the network we are connecting to + self._connecting_to_ssid = ssid + self.forget_connection(ssid, block=True) + + is_hidden = False connection = { 'connection': { - 'type': Variant('s', '802-11-wireless'), - 'uuid': Variant('s', str(uuid.uuid4())), - 'id': Variant('s', f'openpilot connection {ssid}'), - 'autoconnect-retries': Variant('i', 0), + 'type': ('s', '802-11-wireless'), + 'uuid': ('s', str(uuid.uuid4())), + 'id': ('s', f'openpilot connection {ssid}'), + 'autoconnect-retries': ('i', 0), }, '802-11-wireless': { - 'ssid': Variant('ay', ssid.encode('utf-8')), - 'hidden': Variant('b', is_hidden), - 'mode': Variant('s', 'infrastructure'), + 'ssid': ('ay', ssid.encode("utf-8")), + 'hidden': ('b', is_hidden), + 'mode': ('s', 'infrastructure'), }, 'ipv4': { - 'method': Variant('s', 'auto'), - 'dns-priority': Variant('i', 600), + 'method': ('s', 'auto'), + 'dns-priority': ('i', 600), }, - 'ipv6': {'method': Variant('s', 'ignore')}, + 'ipv6': {'method': ('s', 'ignore')}, } - if bssid: - connection['802-11-wireless']['bssid'] = Variant('ay', bssid.encode('utf-8')) - if password: connection['802-11-wireless-security'] = { - 'key-mgmt': Variant('s', 'wpa-psk'), - 'auth-alg': Variant('s', 'open'), - 'psk': Variant('s', password), + 'key-mgmt': ('s', 'wpa-psk'), + 'auth-alg': ('s', 'open'), + 'psk': ('s', password), } - nm_iface = await self._get_interface(NM, NM_PATH, NM_IFACE) - await nm_iface.call_add_and_activate_connection(connection, self.device_path, "/") - except Exception as e: - self._current_connection_ssid = None - cloudlog.error(f"Error connecting to network: {e}") - # Notify UI of failure - if self.callbacks.connection_failed: - self.callbacks.connection_failed(ssid, str(e)) + settings_addr = DBusAddress(NM_SETTINGS_PATH, bus_name=NM, interface=NM_SETTINGS_IFACE) + self._router_main.send_and_get_reply(new_method_call(settings_addr, 'AddConnection', 'a{sa{sv}}', (connection,))) + self.activate_connection(ssid, block=True) - def is_saved(self, ssid: str) -> bool: - return ssid in self.saved_connections + threading.Thread(target=worker, daemon=True).start() - async def _find_wifi_device(self) -> bool: - nm_iface = await self._get_interface(NM, NM_PATH, NM_IFACE) - devices = await nm_iface.get_devices() + def forget_connection(self, ssid: str, block: bool = False): + def worker(): + conn_path = self._get_connections().get(ssid, None) + if conn_path is not None: + conn_addr = DBusAddress(conn_path, bus_name=NM, interface=NM_CONNECTION_IFACE) + self._router_main.send_and_get_reply(new_method_call(conn_addr, 'Delete')) - for device_path in devices: - device = await self.bus.introspect(NM, device_path) - device_proxy = self.bus.get_proxy_object(NM, device_path, device) - device_interface = device_proxy.get_interface(NM_DEVICE_IFACE) - device_type = await device_interface.get_device_type() # type: ignore[attr-defined] - if device_type == 2: # Wi-Fi device - self.device_path = device_path - self.device_proxy = device_proxy - return True + if self._forgotten is not None: + self._update_networks() + self._enqueue_callback(self._forgotten) - return False + if block: + worker() + else: + threading.Thread(target=worker, daemon=True).start() - async def add_tethering_connection(self, ssid: str, password: str = "12345678") -> bool: - """Create a WiFi tethering connection.""" - if len(password) < 8: - print("Tethering password must be at least 8 characters") - return False + def activate_connection(self, ssid: str, block: bool = False): + def worker(): + conn_path = self._get_connections().get(ssid, None) + if conn_path is not None: + if self._wifi_device is None: + cloudlog.warning("No WiFi device found") + return - try: - # First, check if a hotspot connection already exists - settings_iface = await self._get_interface(NM, NM_SETTINGS_PATH, NM_SETTINGS_IFACE) - connection_paths = await settings_iface.call_list_connections() + self._connecting_to_ssid = ssid + self._router_main.send(new_method_call(self._nm, 'ActivateConnection', 'ooo', + (conn_path, self._wifi_device, "/"))) + + if block: + worker() + else: + threading.Thread(target=worker, daemon=True).start() + + def _request_scan(self): + if self._wifi_device is None: + cloudlog.warning("No WiFi device found") + return + + wifi_addr = DBusAddress(self._wifi_device, bus_name=NM, interface=NM_WIRELESS_IFACE) + reply = self._router_main.send_and_get_reply(new_method_call(wifi_addr, 'RequestScan', 'a{sv}', ({},))) + + if reply.header.message_type == MessageType.error: + cloudlog.warning(f"Failed to request scan: {reply}") + + def _update_networks(self): + with self._lock: + if self._wifi_device is None: + cloudlog.warning("No WiFi device found") + return + + # returns '/' if no active AP + wifi_addr = DBusAddress(self._wifi_device, NM, interface=NM_WIRELESS_IFACE) + active_ap_path = self._router_main.send_and_get_reply(Properties(wifi_addr).get('ActiveAccessPoint')).body[0][1] + ap_paths = self._router_main.send_and_get_reply(new_method_call(wifi_addr, 'GetAllAccessPoints')).body[0] + + aps: dict[str, list[AccessPoint]] = {} + + for ap_path in ap_paths: + ap_addr = DBusAddress(ap_path, NM, interface=NM_ACCESS_POINT_IFACE) + ap_props = self._router_main.send_and_get_reply(Properties(ap_addr).get_all()) + + # some APs have been seen dropping off during iteration + if ap_props.header.message_type == MessageType.error: + cloudlog.warning(f"Failed to get AP properties for {ap_path}") + continue - # Look for an existing hotspot connection - for path in connection_paths: try: - settings = await self._get_connection_settings(path) - conn_type = settings.get('connection', {}).get('type', Variant('s', '')).value - wifi_mode = settings.get('802-11-wireless', {}).get('mode', Variant('s', '')).value + ap = AccessPoint.from_dbus(ap_props.body[0], ap_path, active_ap_path) + if ap.ssid == "": + continue - if conn_type == '802-11-wireless' and wifi_mode == 'ap': - # Extract the SSID to check - connection_ssid = self._extract_ssid(settings) - if connection_ssid == ssid: - return True - except DBusError: - continue + if ap.ssid not in aps: + aps[ap.ssid] = [] - connection = { - 'connection': { - 'id': Variant('s', 'Hotspot'), - 'uuid': Variant('s', str(uuid.uuid4())), - 'type': Variant('s', '802-11-wireless'), - 'interface-name': Variant('s', 'wlan0'), - 'autoconnect': Variant('b', False), - }, - '802-11-wireless': { - 'band': Variant('s', 'bg'), - 'mode': Variant('s', 'ap'), - 'ssid': Variant('ay', ssid.encode('utf-8')), - }, - '802-11-wireless-security': { - 'group': Variant('as', ['ccmp']), - 'key-mgmt': Variant('s', 'wpa-psk'), - 'pairwise': Variant('as', ['ccmp']), - 'proto': Variant('as', ['rsn']), - 'psk': Variant('s', password), - }, - 'ipv4': { - 'method': Variant('s', 'shared'), - 'address-data': Variant('aa{sv}', [{'address': Variant('s', TETHERING_IP_ADDRESS), 'prefix': Variant('u', 24)}]), - 'gateway': Variant('s', TETHERING_IP_ADDRESS), - 'never-default': Variant('b', True), - }, - 'ipv6': { - 'method': Variant('s', 'ignore'), - }, - } + aps[ap.ssid].append(ap) + except Exception: + # catch all for parsing errors + cloudlog.exception(f"Failed to parse AP properties for {ap_path}") - settings_iface = await self._get_interface(NM, NM_SETTINGS_PATH, NM_SETTINGS_IFACE) - new_connection = await settings_iface.call_add_connection(connection) - print(f"Added tethering connection with path: {new_connection}") - return True - except DBusError as e: - print(f"Failed to add tethering connection: {e}") - return False - except Exception as e: - print(f"Unexpected error adding tethering connection: {e}") - return False + known_connections = self._get_connections() + networks = [Network.from_dbus(ssid, ap_list, ssid in known_connections) for ssid, ap_list in aps.items()] + networks.sort(key=lambda n: (-n.is_connected, -n.strength, n.ssid.lower())) + self._networks = networks - async def get_tethering_password(self) -> str: - """Get the current tethering password.""" - try: - hotspot_path = self.saved_connections.get(self._tethering_ssid) - if hotspot_path: - conn_iface = await self._get_interface(NM, hotspot_path, NM_CONNECTION_IFACE) - secrets = await conn_iface.call_get_secrets('802-11-wireless-security') - if secrets and '802-11-wireless-security' in secrets: - psk = secrets.get('802-11-wireless-security', {}).get('psk', Variant('s', '')).value - return str(psk) if psk is not None else "" - return "" - except DBusError as e: - print(f"Failed to get tethering password: {e}") - return "" - except Exception as e: - print(f"Unexpected error getting tethering password: {e}") - return "" + if self._networks_updated is not None: + self._enqueue_callback(self._networks_updated, self._networks) - async def set_tethering_password(self, password: str) -> bool: - """Set the tethering password.""" - if len(password) < 8: - cloudlog.error("Tethering password must be at least 8 characters") - return False + def __del__(self): + self.stop() - try: - hotspot_path = self.saved_connections.get(self._tethering_ssid) - if not hotspot_path: - print("No hotspot connection found") - return False + def stop(self): + if not self._exit: + self._exit = True + self._scan_thread.join() + self._state_thread.join() - # Update the connection settings with new password - settings = await self._get_connection_settings(hotspot_path) - if '802-11-wireless-security' not in settings: - settings['802-11-wireless-security'] = {} - settings['802-11-wireless-security']['psk'] = Variant('s', password) - - # Apply changes - conn_iface = await self._get_interface(NM, hotspot_path, NM_CONNECTION_IFACE) - await conn_iface.call_update(settings) - - # Check if connection is active and restart if needed - is_active = False - nm_iface = await self._get_interface(NM, NM_PATH, NM_IFACE) - active_connections = await nm_iface.get_active_connections() - - for conn_path in active_connections: - props_iface = await self._get_interface(NM, conn_path, NM_PROPERTIES_IFACE) - conn_id_path = await props_iface.call_get('org.freedesktop.NetworkManager.Connection.Active', 'Connection') - if conn_id_path.value == hotspot_path: - is_active = True - await nm_iface.call_deactivate_connection(conn_path) - break - - if is_active: - await nm_iface.call_activate_connection(hotspot_path, self.device_path, "/") - - print("Tethering password updated successfully") - return True - except DBusError as e: - print(f"Failed to set tethering password: {e}") - return False - except Exception as e: - print(f"Unexpected error setting tethering password: {e}") - return False - - async def is_tethering_active(self) -> bool: - """Check if tethering is active for the specified SSID.""" - try: - hotspot_path = self.saved_connections.get(self._tethering_ssid) - if not hotspot_path: - return False - - nm_iface = await self._get_interface(NM, NM_PATH, NM_IFACE) - active_connections = await nm_iface.get_active_connections() - - for conn_path in active_connections: - props_iface = await self._get_interface(NM, conn_path, NM_PROPERTIES_IFACE) - conn_id_path = await props_iface.call_get('org.freedesktop.NetworkManager.Connection.Active', 'Connection') - - if conn_id_path.value == hotspot_path: - return True - - return False - except Exception: - return False - - async def _periodic_scan(self): - while self.running: - try: - await self._request_scan() - await asyncio.sleep(30) - except asyncio.CancelledError: - break - except DBusError as e: - cloudlog.error(f"Scan failed: {e}") - await asyncio.sleep(5) - - async def _setup_signals(self, device_path: str) -> None: - rules = [ - f"type='signal',interface='{NM_PROPERTIES_IFACE}',member='PropertiesChanged',path='{device_path}'", - f"type='signal',interface='{NM_DEVICE_IFACE}',member='StateChanged',path='{device_path}'", - f"type='signal',interface='{NM_SETTINGS_IFACE}',member='NewConnection',path='{NM_SETTINGS_PATH}'", - f"type='signal',interface='{NM_SETTINGS_IFACE}',member='ConnectionRemoved',path='{NM_SETTINGS_PATH}'", - ] - for rule in rules: - await self._add_match_rule(rule) - - # Set up signal handlers - self.device_proxy.get_interface(NM_PROPERTIES_IFACE).on_properties_changed(self._on_properties_changed) - self.device_proxy.get_interface(NM_DEVICE_IFACE).on_state_changed(self._on_state_changed) - - settings_iface = await self._get_interface(NM, NM_SETTINGS_PATH, NM_SETTINGS_IFACE) - settings_iface.on_new_connection(self._on_new_connection) - settings_iface.on_connection_removed(self._on_connection_removed) - - def _on_properties_changed(self, interface: str, changed: dict, invalidated: list): - if interface == NM_WIRELESS_IFACE and 'LastScan' in changed: - asyncio.create_task(self._refresh_networks()) - elif interface == NM_WIRELESS_IFACE and "ActiveAccessPoint" in changed: - new_ap_path = changed["ActiveAccessPoint"].value - if self.active_ap_path != new_ap_path: - self.active_ap_path = new_ap_path - - def _on_state_changed(self, new_state: int, old_state: int, reason: int): - if new_state == NMDeviceState.ACTIVATED: - if self.callbacks.activated: - self.callbacks.activated() - self._current_connection_ssid = None - asyncio.create_task(self._refresh_networks()) - elif new_state in (NMDeviceState.DISCONNECTED, NMDeviceState.NEED_AUTH): - for network in self.networks: - network.is_connected = False - - # BAD PASSWORD - if new_state == NMDeviceState.NEED_AUTH and reason == NM_DEVICE_STATE_REASON_SUPPLICANT_DISCONNECT and self.callbacks.need_auth: - if self._current_connection_ssid: - asyncio.create_task(self.forget_connection(self._current_connection_ssid)) - self.callbacks.need_auth(self._current_connection_ssid) - else: - # Try to find the network from active_ap_path - for network in self.networks: - if network.path == self.active_ap_path: - asyncio.create_task(self.forget_connection(network.ssid)) - self.callbacks.need_auth(network.ssid) - break - else: - # Couldn't identify the network that needs auth - cloudlog.error("Network needs authentication but couldn't identify which one") - - def _on_new_connection(self, path: str) -> None: - """Callback for NewConnection signal.""" - asyncio.create_task(self._add_saved_connection(path)) - - def _on_connection_removed(self, path: str) -> None: - """Callback for ConnectionRemoved signal.""" - for ssid, p in list(self.saved_connections.items()): - if path == p: - del self.saved_connections[ssid] - - if self.callbacks.forgotten: - self.callbacks.forgotten(ssid) - break - - async def _add_saved_connection(self, path: str) -> None: - """Add a new saved connection to the dictionary.""" - try: - settings = await self._get_connection_settings(path) - if ssid := self._extract_ssid(settings): - self.saved_connections[ssid] = path - except DBusError as e: - cloudlog.error(f"Failed to add connection {path}: {e}") - - def _extract_ssid(self, settings: dict) -> str | None: - """Extract SSID from connection settings.""" - ssid_variant = settings.get('802-11-wireless', {}).get('ssid', Variant('ay', b'')).value - return bytes(ssid_variant).decode('utf-8') if ssid_variant else None - - async def _add_match_rule(self, rule): - """Add a match rule on the bus.""" - reply = await self.bus.call( - Message( - message_type=MessageType.METHOD_CALL, - destination='org.freedesktop.DBus', - interface="org.freedesktop.DBus", - path='/org/freedesktop/DBus', - member='AddMatch', - signature='s', - body=[rule], - ) - ) - - assert reply.message_type == MessageType.METHOD_RETURN - return reply - - async def _refresh_networks(self): - """Get a list of available networks via NetworkManager.""" - wifi_iface = self.device_proxy.get_interface(NM_WIRELESS_IFACE) - access_points = await wifi_iface.get_access_points() - self.active_ap_path = await self.get_active_access_point() - network_dict = {} - for ap_path in access_points: - try: - props_iface = await self._get_interface(NM, ap_path, NM_PROPERTIES_IFACE) - properties = await props_iface.call_get_all('org.freedesktop.NetworkManager.AccessPoint') - ssid_variant = properties['Ssid'].value - ssid = bytes(ssid_variant).decode('utf-8') - if not ssid: - continue - - bssid = properties.get('HwAddress', Variant('s', '')).value - strength = properties['Strength'].value - flags = properties['Flags'].value - wpa_flags = properties['WpaFlags'].value - rsn_flags = properties['RsnFlags'].value - - # May be multiple access points for each SSID. Use first for ssid - # and security type, then update the rest using all APs - if ssid not in network_dict: - network_dict[ssid] = NetworkInfo( - ssid=ssid, - strength=0, - security_type=self._get_security_type(flags, wpa_flags, rsn_flags), - path="", - bssid="", - is_connected=False, - is_saved=ssid in self.saved_connections - ) - - existing_network = network_dict.get(ssid) - if existing_network.strength < strength: - existing_network.strength = strength - existing_network.path = ap_path - existing_network.bssid = bssid - if self.active_ap_path == ap_path: - existing_network.is_connected = self._current_connection_ssid != ssid - - except DBusError as e: - cloudlog.error(f"Error fetching networks: {e}") - except Exception as e: - cloudlog.error({e}) - - self.networks = sorted( - network_dict.values(), - key=lambda network: ( - not network.is_connected, - -network.strength, # Higher signal strength first - network.ssid.lower(), - ), - ) - - if self.callbacks.networks_updated: - self.callbacks.networks_updated(copy.deepcopy(self.networks)) - - async def _get_connection_settings(self, path): - """Fetch connection settings for a specific connection path.""" - try: - settings = await self._get_interface(NM, path, NM_CONNECTION_IFACE) - return await settings.call_get_settings() - except DBusError as e: - cloudlog.error(f"Failed to get settings for {path}: {str(e)}") - return {} - - async def _process_chunk(self, paths_chunk): - """Process a chunk of connection paths.""" - tasks = [self._get_connection_settings(path) for path in paths_chunk] - return await asyncio.gather(*tasks, return_exceptions=True) - - async def _get_saved_connections(self) -> dict[str, str]: - try: - settings_iface = await self._get_interface(NM, NM_SETTINGS_PATH, NM_SETTINGS_IFACE) - connection_paths = await settings_iface.call_list_connections() - saved_ssids: dict[str, str] = {} - batch_size = 20 - for i in range(0, len(connection_paths), batch_size): - chunk = connection_paths[i : i + batch_size] - results = await self._process_chunk(chunk) - for path, config in zip(chunk, results, strict=True): - if isinstance(config, dict) and '802-11-wireless' in config: - if ssid := self._extract_ssid(config): - saved_ssids[ssid] = path - return saved_ssids - except DBusError as e: - cloudlog.error(f"Error fetching saved connections: {str(e)}") - return {} - - async def _get_interface(self, bus_name: str, path: str, name: str): - introspection = await self.bus.introspect(bus_name, path) - proxy = self.bus.get_proxy_object(bus_name, path, introspection) - return proxy.get_interface(name) - - def _get_security_type(self, flags: int, wpa_flags: int, rsn_flags: int) -> SecurityType: - """Determine the security type based on flags.""" - if flags == 0 and not (wpa_flags or rsn_flags): - return SecurityType.OPEN - if rsn_flags & 0x200: # SAE (WPA3 Personal) - # TODO: support WPA3 - return SecurityType.UNSUPPORTED - if rsn_flags: # RSN indicates WPA2 or higher - return SecurityType.WPA2 - if wpa_flags: # WPA flags indicate WPA - return SecurityType.WPA - return SecurityType.UNSUPPORTED - - -class WifiManagerWrapper: - def __init__(self): - self._manager: WifiManager | None = None - self._callbacks: WifiManagerCallbacks = WifiManagerCallbacks() - - self._thread = threading.Thread(target=self._run, daemon=True) - self._loop: asyncio.EventLoop | None = None - self._running = False - - def set_callbacks(self, callbacks: WifiManagerCallbacks): - self._callbacks = callbacks - - def start(self) -> None: - if not self._running: - self._thread.start() - while self._thread is not None and not self._running: - time.sleep(0.1) - - def _run(self): - self._loop = asyncio.new_event_loop() - asyncio.set_event_loop(self._loop) - - try: - self._manager = WifiManager(self._callbacks) - self._running = True - self._loop.run_forever() - except Exception as e: - cloudlog.error(f"Error in WifiManagerWrapper thread: {e}") - finally: - if self._loop.is_running(): - self._loop.stop() - self._running = False - - def shutdown(self) -> None: - if self._running: - if self._manager is not None and self._loop: - shutdown_future = asyncio.run_coroutine_threadsafe(self._manager.shutdown(), self._loop) - shutdown_future.result(timeout=3.0) - - if self._loop and self._loop.is_running(): - self._loop.call_soon_threadsafe(self._loop.stop) - if self._thread and self._thread.is_alive(): - self._thread.join(timeout=2.0) - self._running = False - - def is_saved(self, ssid: str) -> bool: - """Check if a network is saved.""" - return self._run_coroutine_sync(lambda manager: manager.is_saved(ssid), default=False) - - def connect(self): - """Connect to DBus and start Wi-Fi scanning.""" - if not self._manager: - return - self._run_coroutine(self._manager.connect()) - - def forget_connection(self, ssid: str): - """Forget a saved Wi-Fi connection.""" - if not self._manager: - return - self._run_coroutine(self._manager.forget_connection(ssid)) - - def activate_connection(self, ssid: str): - """Activate an existing Wi-Fi connection.""" - if not self._manager: - return - self._run_coroutine(self._manager.activate_connection(ssid)) - - def connect_to_network(self, ssid: str, password: str = None, bssid: str = None, is_hidden: bool = False): - """Connect to a Wi-Fi network.""" - if not self._manager: - return - self._run_coroutine(self._manager.connect_to_network(ssid, password, bssid, is_hidden)) - - def _run_coroutine(self, coro): - """Run a coroutine in the async thread.""" - if not self._running or not self._loop: - cloudlog.error("WifiManager thread is not running") - return - asyncio.run_coroutine_threadsafe(coro, self._loop) - - def _run_coroutine_sync(self, func: Callable[[WifiManager], T], default: T) -> T: - """Run a function synchronously in the async thread.""" - if not self._running or not self._loop or not self._manager: - return default - future = concurrent.futures.Future[T]() - - def wrapper(manager: WifiManager) -> None: - try: - future.set_result(func(manager)) - except Exception as e: - future.set_exception(e) - - try: - self._loop.call_soon_threadsafe(wrapper, self._manager) - return future.result(timeout=1.0) - except Exception as e: - cloudlog.error(f"WifiManagerWrapper property access failed: {e}") - return default + self._router_main.close() + self._router_main.conn.close() + self._conn_monitor.close() diff --git a/system/ui/reset.py b/system/ui/reset.py index b1bf5a6b6a..a5cf1731dc 100755 --- a/system/ui/reset.py +++ b/system/ui/reset.py @@ -13,7 +13,6 @@ 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, gui_text_box -NVME = "/dev/nvme0n1" USERDATA = "/dev/disk/by-partlabel/userdata" TIMEOUT = 3*60 @@ -49,10 +48,6 @@ class Reset(Widget): if PC: return - # Best effort to wipe NVME - os.system(f"sudo umount {NVME}") - os.system(f"yes | sudo mkfs.ext4 {NVME}") - # Removing data and formatting rm = os.system("sudo rm -rf /data/*") os.system(f"sudo umount {USERDATA}") diff --git a/system/ui/setup.py b/system/ui/setup.py index 800ca7662c..a985e783be 100755 --- a/system/ui/setup.py +++ b/system/ui/setup.py @@ -19,7 +19,7 @@ from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.button import Button, ButtonStyle, ButtonRadio from openpilot.system.ui.widgets.keyboard import Keyboard from openpilot.system.ui.widgets.label import Label, TextAlignment -from openpilot.system.ui.widgets.network import WifiManagerUI, WifiManagerWrapper +from openpilot.system.ui.widgets.network import WifiManagerUI, WifiManager NetworkType = log.DeviceState.NetworkType @@ -72,8 +72,7 @@ class Setup(Widget): self.download_url = "" self.download_progress = 0 self.download_thread = None - self.wifi_manager = WifiManagerWrapper() - self.wifi_ui = WifiManagerUI(self.wifi_manager) + self.wifi_ui = WifiManagerUI(WifiManager()) self.keyboard = Keyboard() self.selected_radio = None self.warning = gui_app.texture("icons/warning.png", 150, 150) diff --git a/system/ui/updater.py b/system/ui/updater.py index 31799d3628..b3cdc82cf5 100755 --- a/system/ui/updater.py +++ b/system/ui/updater.py @@ -7,7 +7,7 @@ from enum import IntEnum from openpilot.system.hardware import HARDWARE from openpilot.system.ui.lib.application import gui_app, FontWeight -from openpilot.system.ui.lib.wifi_manager import WifiManagerWrapper +from openpilot.system.ui.lib.wifi_manager import WifiManager from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.button import gui_button, ButtonStyle from openpilot.system.ui.widgets.label import gui_text_box, gui_label @@ -43,8 +43,7 @@ class Updater(Widget): self.show_reboot_button = False self.process = None self.update_thread = None - self.wifi_manager = WifiManagerWrapper() - self.wifi_manager_ui = WifiManagerUI(self.wifi_manager) + self.wifi_manager_ui = WifiManagerUI(WifiManager()) def install_update(self): self.current_screen = Screen.PROGRESS diff --git a/system/ui/widgets/__init__.py b/system/ui/widgets/__init__.py index d45f48ac38..cca2cec7ad 100644 --- a/system/ui/widgets/__init__.py +++ b/system/ui/widgets/__init__.py @@ -129,3 +129,10 @@ class Widget(abc.ABC): def _handle_mouse_release(self, mouse_pos: MousePos) -> bool: """Optionally handle mouse release events.""" return False + + def show_event(self): + """Optionally handle show event. Parent must manually call this""" + + def hide_event(self): + """Optionally handle hide event. Parent must manually call this""" + diff --git a/system/ui/widgets/list_view.py b/system/ui/widgets/list_view.py index e871eef0a1..a8d81a8ba2 100644 --- a/system/ui/widgets/list_view.py +++ b/system/ui/widgets/list_view.py @@ -213,6 +213,7 @@ class ListItem(Widget): self.set_rect(rl.Rectangle(0, 0, ITEM_BASE_WIDTH, ITEM_BASE_HEIGHT)) self._font = gui_app.font(FontWeight.NORMAL) + self._icon_texture = gui_app.texture(os.path.join("icons", self.icon), ICON_SIZE, ICON_SIZE) if self.icon else None # Cached properties for performance self._prev_max_width: int = 0 @@ -261,8 +262,7 @@ class ListItem(Widget): if self.title: # Draw icon if present if self.icon: - icon_texture = gui_app.texture(os.path.join("icons", self.icon), ICON_SIZE, ICON_SIZE) - rl.draw_texture(icon_texture, int(content_x), int(self._rect.y + (ITEM_BASE_HEIGHT - icon_texture.width) // 2), rl.WHITE) + rl.draw_texture(self._icon_texture, int(content_x), int(self._rect.y + (ITEM_BASE_HEIGHT - self._icon_texture.width) // 2), rl.WHITE) text_x += ICON_SIZE + ITEM_PADDING # Draw main text diff --git a/system/ui/widgets/network.py b/system/ui/widgets/network.py index 0bb759a919..3e6317a49c 100644 --- a/system/ui/widgets/network.py +++ b/system/ui/widgets/network.py @@ -1,12 +1,11 @@ from enum import IntEnum from functools import partial -from threading import Lock from typing import cast import pyray as rl from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel -from openpilot.system.ui.lib.wifi_manager import NetworkInfo, WifiManagerCallbacks, WifiManagerWrapper, SecurityType +from openpilot.system.ui.lib.wifi_manager import WifiManager, SecurityType, Network from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.button import ButtonStyle, Button from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog @@ -36,52 +35,58 @@ class UIState(IntEnum): class WifiManagerUI(Widget): - def __init__(self, wifi_manager: WifiManagerWrapper): + def __init__(self, wifi_manager: WifiManager): super().__init__() + self.wifi_manager = wifi_manager self.state: UIState = UIState.IDLE - self._state_network: NetworkInfo | None = None # for CONNECTING / NEEDS_AUTH / SHOW_FORGET_CONFIRM / FORGETTING + self._state_network: Network | None = None # for CONNECTING / NEEDS_AUTH / SHOW_FORGET_CONFIRM / FORGETTING self._password_retry: bool = False # for NEEDS_AUTH self.btn_width: int = 200 self.scroll_panel = GuiScrollPanel() self.keyboard = Keyboard(max_text_size=MAX_PASSWORD_LENGTH, min_text_size=MIN_PASSWORD_LENGTH, show_password_toggle=True) + self._load_icons() - self._networks: list[NetworkInfo] = [] + self._networks: list[Network] = [] self._networks_buttons: dict[str, Button] = {} self._forget_networks_buttons: dict[str, Button] = {} - self._lock = Lock() - self.wifi_manager = wifi_manager self._confirm_dialog = ConfirmDialog("", "Forget", "Cancel") - self.wifi_manager.set_callbacks( - WifiManagerCallbacks( - need_auth=self._on_need_auth, - activated=self._on_activated, - forgotten=self._on_forgotten, - networks_updated=self._on_network_updated, - connection_failed=self._on_connection_failed - ) - ) - self.wifi_manager.start() - self.wifi_manager.connect() + self.wifi_manager.set_callbacks(need_auth=self._on_need_auth, + activated=self._on_activated, + forgotten=self._on_forgotten, + networks_updated=self._on_network_updated, + disconnected=self._on_disconnected) + + def show_event(self): + # start/stop scanning when widget is visible + self.wifi_manager.set_active(True) + + def hide_event(self): + self.wifi_manager.set_active(False) + + def _load_icons(self): + for icon in STRENGTH_ICONS + ["icons/checkmark.png", "icons/circled_slash.png", "icons/lock_closed.png"]: + gui_app.texture(icon, ICON_SIZE, ICON_SIZE) def _render(self, rect: rl.Rectangle): - with self._lock: - if not self._networks: - gui_label(rect, "Scanning Wi-Fi networks...", 72, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) - return + self.wifi_manager.process_callbacks() - if self.state == UIState.NEEDS_AUTH and self._state_network: - self.keyboard.set_title("Wrong password" if self._password_retry else "Enter password", f"for {self._state_network.ssid}") - self.keyboard.reset() - gui_app.set_modal_overlay(self.keyboard, lambda result: self._on_password_entered(cast(NetworkInfo, self._state_network), result)) - elif self.state == UIState.SHOW_FORGET_CONFIRM and self._state_network: - self._confirm_dialog.set_text(f'Forget Wi-Fi Network "{self._state_network.ssid}"?') - self._confirm_dialog.reset() - gui_app.set_modal_overlay(self._confirm_dialog, callback=lambda result: self.on_forgot_confirm_finished(self._state_network, result)) - else: - self._draw_network_list(rect) + if not self._networks: + gui_label(rect, "Scanning Wi-Fi networks...", 72, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) + return - def _on_password_entered(self, network: NetworkInfo, result: int): + if self.state == UIState.NEEDS_AUTH and self._state_network: + self.keyboard.set_title("Wrong password" if self._password_retry else "Enter password", f"for {self._state_network.ssid}") + self.keyboard.reset() + gui_app.set_modal_overlay(self.keyboard, lambda result: self._on_password_entered(cast(Network, self._state_network), result)) + elif self.state == UIState.SHOW_FORGET_CONFIRM and self._state_network: + self._confirm_dialog.set_text(f'Forget Wi-Fi Network "{self._state_network.ssid}"?') + self._confirm_dialog.reset() + gui_app.set_modal_overlay(self._confirm_dialog, callback=lambda result: self.on_forgot_confirm_finished(self._state_network, result)) + else: + self._draw_network_list(rect) + + def _on_password_entered(self, network: Network, result: int): if result == 1: password = self.keyboard.text self.keyboard.clear() @@ -116,7 +121,7 @@ class WifiManagerUI(Widget): rl.end_scissor_mode() - def _draw_network_item(self, rect, network: NetworkInfo, clicked: bool): + def _draw_network_item(self, rect, network: Network, clicked: bool): spacing = 50 ssid_rect = rl.Rectangle(rect.x, rect.y, rect.width - self.btn_width * 2, ITEM_HEIGHT) signal_icon_rect = rl.Rectangle(rect.x + rect.width - ICON_SIZE, rect.y + (ITEM_HEIGHT - ICON_SIZE) / 2, ICON_SIZE, ICON_SIZE) @@ -169,10 +174,10 @@ class WifiManagerUI(Widget): self.state = UIState.SHOW_FORGET_CONFIRM self._state_network = network - def _draw_status_icon(self, rect, network: NetworkInfo): + def _draw_status_icon(self, rect, network: Network): """Draw the status icon based on network's connection state""" icon_file = None - if network.is_connected: + if network.is_connected and self.state != UIState.CONNECTING: icon_file = "icons/checkmark.png" elif network.security_type == SecurityType.UNSUPPORTED: icon_file = "icons/circled_slash.png" @@ -186,12 +191,12 @@ class WifiManagerUI(Widget): icon_rect = rl.Vector2(rect.x, rect.y + (ICON_SIZE - texture.height) / 2) rl.draw_texture_v(texture, icon_rect, rl.WHITE) - def _draw_signal_strength_icon(self, rect: rl.Rectangle, network: NetworkInfo): + def _draw_signal_strength_icon(self, rect: rl.Rectangle, network: Network): """Draw the Wi-Fi signal strength icon based on network's signal strength""" strength_level = max(0, min(3, round(network.strength / 33.0))) rl.draw_texture_v(gui_app.texture(STRENGTH_ICONS[strength_level], ICON_SIZE, ICON_SIZE), rl.Vector2(rect.x, rect.y), rl.WHITE) - def connect_to_network(self, network: NetworkInfo, password=''): + def connect_to_network(self, network: Network, password=''): self.state = UIState.CONNECTING self._state_network = network if network.is_saved and not password: @@ -199,54 +204,46 @@ class WifiManagerUI(Widget): else: self.wifi_manager.connect_to_network(network.ssid, password) - def forget_network(self, network: NetworkInfo): + def forget_network(self, network: Network): self.state = UIState.FORGETTING self._state_network = network - network.is_saved = False self.wifi_manager.forget_connection(network.ssid) - def _on_network_updated(self, networks: list[NetworkInfo]): - with self._lock: - self._networks = networks - for n in self._networks: - self._networks_buttons[n.ssid] = Button(n.ssid, partial(self._networks_buttons_callback, n), font_size=55, text_alignment=TextAlignment.LEFT, - button_style=ButtonStyle.NO_EFFECT) - self._forget_networks_buttons[n.ssid] = Button("Forget", partial(self._forget_networks_buttons_callback, n), button_style=ButtonStyle.FORGET_WIFI, - font_size=45) + def _on_network_updated(self, networks: list[Network]): + self._networks = networks + for n in self._networks: + self._networks_buttons[n.ssid] = Button(n.ssid, partial(self._networks_buttons_callback, n), font_size=55, text_alignment=TextAlignment.LEFT, + button_style=ButtonStyle.NO_EFFECT) + self._forget_networks_buttons[n.ssid] = Button("Forget", partial(self._forget_networks_buttons_callback, n), button_style=ButtonStyle.FORGET_WIFI, + font_size=45) def _on_need_auth(self, ssid): - with self._lock: - network = next((n for n in self._networks if n.ssid == ssid), None) - if network: - self.state = UIState.NEEDS_AUTH - self._state_network = network - self._password_retry = True + network = next((n for n in self._networks if n.ssid == ssid), None) + if network: + self.state = UIState.NEEDS_AUTH + self._state_network = network + self._password_retry = True def _on_activated(self): - with self._lock: - if self.state == UIState.CONNECTING: - self.state = UIState.IDLE + if self.state == UIState.CONNECTING: + self.state = UIState.IDLE - def _on_forgotten(self, ssid): - with self._lock: - if self.state == UIState.FORGETTING: - self.state = UIState.IDLE + def _on_forgotten(self): + if self.state == UIState.FORGETTING: + self.state = UIState.IDLE - def _on_connection_failed(self, ssid: str, error: str): - with self._lock: - if self.state == UIState.CONNECTING: - self.state = UIState.IDLE + def _on_disconnected(self): + if self.state == UIState.CONNECTING: + self.state = UIState.IDLE def main(): gui_app.init_window("Wi-Fi Manager") - wifi_manager = WifiManagerWrapper() - wifi_ui = WifiManagerUI(wifi_manager) + wifi_ui = WifiManagerUI(WifiManager()) for _ in gui_app.render(): wifi_ui.render(rl.Rectangle(50, 50, gui_app.width - 100, gui_app.height - 100)) - wifi_manager.shutdown() gui_app.close() diff --git a/system/updated/updated.py b/system/updated/updated.py index 11928bc24c..7ab9e070dc 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") @@ -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 = { - ("tici", "release3"): "release-tici" - }.get((HARDWARE.get_device_type(), b), b) + b = SP_BRANCH_MIGRATIONS.get((HARDWARE.get_device_type(), b), b) return b @property diff --git a/system/version.py b/system/version.py index 5aa8d0115f..9719311b7e 100755 --- a/system/version.py +++ b/system/version.py @@ -10,12 +10,22 @@ 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'] -TESTED_SP_BRANCHES = ['staging-c3', 'staging-c3-new'] +RELEASE_SP_BRANCHES = ['release-c3', 'release'] +TESTED_SP_BRANCHES = ['staging-c3', 'staging-c3-new', 'staging'] MASTER_SP_BRANCHES = ['master'] RELEASE_BRANCHES = ['release3-staging', 'release3', 'release-tici', 'nightly'] + RELEASE_SP_BRANCHES TESTED_BRANCHES = RELEASE_BRANCHES + ['devel', 'devel-staging', 'nightly-dev'] + 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" @@ -85,7 +95,8 @@ class OpenpilotMetadata: @property def sunnypilot_remote(self) -> bool: - return self.git_normalized_origin == "github.com/sunnypilot/sunnypilot" + return self.git_normalized_origin in ("github.com/sunnypilot/sunnypilot", + "github.com/sunnypilot/openpilot") @property def git_normalized_origin(self) -> str: @@ -123,17 +134,19 @@ class BuildMetadata: @property def development_channel(self) -> bool: - return self.channel.startswith("dev-") or self.channel.endswith("-prebuilt") + return self.channel == "dev" or self.channel.startswith("dev-") or self.channel.endswith("-prebuilt") @property def channel_type(self) -> str: - if self.development_channel: + if self.channel.endswith("-tici"): + return "tici" + elif self.development_channel: return "development" - elif self.channel.startswith("staging-"): + elif self.tested_channel: return "staging" elif self.master_channel: return "master" - elif self.tested_channel: + elif self.release_channel: return "release" else: return "feature" diff --git a/third_party/SConscript b/third_party/SConscript index 507c17c4a5..3a7497d162 100644 --- a/third_party/SConscript +++ b/third_party/SConscript @@ -1,4 +1,3 @@ Import('env') env.Library('json11', ['json11/json11.cpp'], CCFLAGS=env['CCFLAGS'] + ['-Wno-unqualified-std-cast-call']) -env.Library('kaitai', ['kaitai/kaitaistream.cpp'], CPPDEFINES=['KS_STR_ENCODING_NONE']) 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 - -namespace kaitai { - -class custom_decoder { -public: - virtual ~custom_decoder() {}; - virtual std::string decode(std::string src) = 0; -}; - -} - -#endif diff --git a/third_party/kaitai/exceptions.h b/third_party/kaitai/exceptions.h deleted file mode 100644 index 5c09c4672b..0000000000 --- a/third_party/kaitai/exceptions.h +++ /dev/null @@ -1,189 +0,0 @@ -#ifndef KAITAI_EXCEPTIONS_H -#define KAITAI_EXCEPTIONS_H - -#include - -#include -#include - -// We need to use "noexcept" in virtual destructor of our exceptions -// subclasses. Different compilers have different ideas on how to -// achieve that: C++98 compilers prefer `throw()`, C++11 and later -// use `noexcept`. We define KS_NOEXCEPT macro for that. - -#if __cplusplus >= 201103L || (defined(_MSC_VER) && _MSC_VER >= 1900) -#define KS_NOEXCEPT noexcept -#else -#define KS_NOEXCEPT throw() -#endif - -namespace kaitai { - -/** - * Common ancestor for all error originating from Kaitai Struct usage. - * Stores KSY source path, pointing to an element supposedly guilty of - * an error. - */ -class kstruct_error: public std::runtime_error { -public: - kstruct_error(const std::string what, const std::string src_path): - std::runtime_error(src_path + ": " + what), - m_src_path(src_path) - { - } - - virtual ~kstruct_error() KS_NOEXCEPT {}; - -protected: - const std::string m_src_path; -}; - -/** - * Error that occurs when default endianness should be decided with - * a switch, but nothing matches (although using endianness expression - * implies that there should be some positive result). - */ -class undecided_endianness_error: public kstruct_error { -public: - undecided_endianness_error(const std::string src_path): - kstruct_error("unable to decide on endianness for a type", src_path) - { - } - - virtual ~undecided_endianness_error() KS_NOEXCEPT {}; -}; - -/** - * Common ancestor for all validation failures. Stores pointer to - * KaitaiStream IO object which was involved in an error. - */ -class validation_failed_error: public kstruct_error { -public: - validation_failed_error(const std::string what, kstream* io, const std::string src_path): - kstruct_error("at pos " + kstream::to_string(static_cast(io->pos())) + ": validation failed: " + what, src_path), - m_io(io) - { - } - -// "at pos #{io.pos}: validation failed: #{msg}" - - virtual ~validation_failed_error() KS_NOEXCEPT {}; - -protected: - kstream* m_io; -}; - -/** - * Signals validation failure: we required "actual" value to be equal to - * "expected", but it turned out that it's not. - */ -template -class validation_not_equal_error: public validation_failed_error { -public: - validation_not_equal_error(const T& expected, const T& actual, kstream* io, const std::string src_path): - validation_failed_error("not equal", io, src_path), - m_expected(expected), - m_actual(actual) - { - } - - // "not equal, expected #{expected.inspect}, but got #{actual.inspect}" - - virtual ~validation_not_equal_error() KS_NOEXCEPT {}; - -protected: - const T& m_expected; - const T& m_actual; -}; - -/** - * Signals validation failure: we required "actual" value to be greater - * than or equal to "min", but it turned out that it's not. - */ -template -class validation_less_than_error: public validation_failed_error { -public: - validation_less_than_error(const T& min, const T& actual, kstream* io, const std::string src_path): - validation_failed_error("not in range", io, src_path), - m_min(min), - m_actual(actual) - { - } - - // "not in range, min #{min.inspect}, but got #{actual.inspect}" - - virtual ~validation_less_than_error() KS_NOEXCEPT {}; - -protected: - const T& m_min; - const T& m_actual; -}; - -/** - * Signals validation failure: we required "actual" value to be less - * than or equal to "max", but it turned out that it's not. - */ -template -class validation_greater_than_error: public validation_failed_error { -public: - validation_greater_than_error(const T& max, const T& actual, kstream* io, const std::string src_path): - validation_failed_error("not in range", io, src_path), - m_max(max), - m_actual(actual) - { - } - - // "not in range, max #{max.inspect}, but got #{actual.inspect}" - - virtual ~validation_greater_than_error() KS_NOEXCEPT {}; - -protected: - const T& m_max; - const T& m_actual; -}; - -/** - * Signals validation failure: we required "actual" value to be from - * the list, but it turned out that it's not. - */ -template -class validation_not_any_of_error: public validation_failed_error { -public: - validation_not_any_of_error(const T& actual, kstream* io, const std::string src_path): - validation_failed_error("not any of the list", io, src_path), - m_actual(actual) - { - } - - // "not any of the list, got #{actual.inspect}" - - virtual ~validation_not_any_of_error() KS_NOEXCEPT {}; - -protected: - const T& m_actual; -}; - -/** - * Signals validation failure: we required "actual" value to match - * the expression, but it turned out that it doesn't. - */ -template -class validation_expr_error: public validation_failed_error { -public: - validation_expr_error(const T& actual, kstream* io, const std::string src_path): - validation_failed_error("not matching the expression", io, src_path), - m_actual(actual) - { - } - - // "not matching the expression, got #{actual.inspect}" - - virtual ~validation_expr_error() KS_NOEXCEPT {}; - -protected: - const T& m_actual; -}; - -} - -#endif diff --git a/third_party/kaitai/kaitaistream.cpp b/third_party/kaitai/kaitaistream.cpp deleted file mode 100644 index d82ddb7e82..0000000000 --- a/third_party/kaitai/kaitaistream.cpp +++ /dev/null @@ -1,689 +0,0 @@ -#include - -#if defined(__APPLE__) -#include -#include -#define bswap_16(x) OSSwapInt16(x) -#define bswap_32(x) OSSwapInt32(x) -#define bswap_64(x) OSSwapInt64(x) -#define __BYTE_ORDER BYTE_ORDER -#define __BIG_ENDIAN BIG_ENDIAN -#define __LITTLE_ENDIAN LITTLE_ENDIAN -#elif defined(_MSC_VER) // !__APPLE__ -#include -#define __LITTLE_ENDIAN 1234 -#define __BIG_ENDIAN 4321 -#define __BYTE_ORDER __LITTLE_ENDIAN -#define bswap_16(x) _byteswap_ushort(x) -#define bswap_32(x) _byteswap_ulong(x) -#define bswap_64(x) _byteswap_uint64(x) -#else // !__APPLE__ or !_MSC_VER -#include -#include -#endif - -#include -#include -#include - -kaitai::kstream::kstream(std::istream* io) { - m_io = io; - init(); -} - -kaitai::kstream::kstream(std::string& data): m_io_str(data) { - m_io = &m_io_str; - init(); -} - -void kaitai::kstream::init() { - exceptions_enable(); - align_to_byte(); -} - -void kaitai::kstream::close() { - // m_io->close(); -} - -void kaitai::kstream::exceptions_enable() const { - m_io->exceptions( - std::istream::eofbit | - std::istream::failbit | - std::istream::badbit - ); -} - -// ======================================================================== -// Stream positioning -// ======================================================================== - -bool kaitai::kstream::is_eof() const { - if (m_bits_left > 0) { - return false; - } - char t; - m_io->exceptions( - std::istream::badbit - ); - m_io->get(t); - if (m_io->eof()) { - m_io->clear(); - exceptions_enable(); - return true; - } else { - m_io->unget(); - exceptions_enable(); - return false; - } -} - -void kaitai::kstream::seek(uint64_t pos) { - m_io->seekg(pos); -} - -uint64_t kaitai::kstream::pos() { - return m_io->tellg(); -} - -uint64_t kaitai::kstream::size() { - std::iostream::pos_type cur_pos = m_io->tellg(); - m_io->seekg(0, std::ios::end); - std::iostream::pos_type len = m_io->tellg(); - m_io->seekg(cur_pos); - return len; -} - -// ======================================================================== -// Integer numbers -// ======================================================================== - -// ------------------------------------------------------------------------ -// Signed -// ------------------------------------------------------------------------ - -int8_t kaitai::kstream::read_s1() { - char t; - m_io->get(t); - return t; -} - -// ........................................................................ -// Big-endian -// ........................................................................ - -int16_t kaitai::kstream::read_s2be() { - int16_t t; - m_io->read(reinterpret_cast(&t), 2); -#if __BYTE_ORDER == __LITTLE_ENDIAN - t = bswap_16(t); -#endif - return t; -} - -int32_t kaitai::kstream::read_s4be() { - int32_t t; - m_io->read(reinterpret_cast(&t), 4); -#if __BYTE_ORDER == __LITTLE_ENDIAN - t = bswap_32(t); -#endif - return t; -} - -int64_t kaitai::kstream::read_s8be() { - int64_t t; - m_io->read(reinterpret_cast(&t), 8); -#if __BYTE_ORDER == __LITTLE_ENDIAN - t = bswap_64(t); -#endif - return t; -} - -// ........................................................................ -// Little-endian -// ........................................................................ - -int16_t kaitai::kstream::read_s2le() { - int16_t t; - m_io->read(reinterpret_cast(&t), 2); -#if __BYTE_ORDER == __BIG_ENDIAN - t = bswap_16(t); -#endif - return t; -} - -int32_t kaitai::kstream::read_s4le() { - int32_t t; - m_io->read(reinterpret_cast(&t), 4); -#if __BYTE_ORDER == __BIG_ENDIAN - t = bswap_32(t); -#endif - return t; -} - -int64_t kaitai::kstream::read_s8le() { - int64_t t; - m_io->read(reinterpret_cast(&t), 8); -#if __BYTE_ORDER == __BIG_ENDIAN - t = bswap_64(t); -#endif - return t; -} - -// ------------------------------------------------------------------------ -// Unsigned -// ------------------------------------------------------------------------ - -uint8_t kaitai::kstream::read_u1() { - char t; - m_io->get(t); - return t; -} - -// ........................................................................ -// Big-endian -// ........................................................................ - -uint16_t kaitai::kstream::read_u2be() { - uint16_t t; - m_io->read(reinterpret_cast(&t), 2); -#if __BYTE_ORDER == __LITTLE_ENDIAN - t = bswap_16(t); -#endif - return t; -} - -uint32_t kaitai::kstream::read_u4be() { - uint32_t t; - m_io->read(reinterpret_cast(&t), 4); -#if __BYTE_ORDER == __LITTLE_ENDIAN - t = bswap_32(t); -#endif - return t; -} - -uint64_t kaitai::kstream::read_u8be() { - uint64_t t; - m_io->read(reinterpret_cast(&t), 8); -#if __BYTE_ORDER == __LITTLE_ENDIAN - t = bswap_64(t); -#endif - return t; -} - -// ........................................................................ -// Little-endian -// ........................................................................ - -uint16_t kaitai::kstream::read_u2le() { - uint16_t t; - m_io->read(reinterpret_cast(&t), 2); -#if __BYTE_ORDER == __BIG_ENDIAN - t = bswap_16(t); -#endif - return t; -} - -uint32_t kaitai::kstream::read_u4le() { - uint32_t t; - m_io->read(reinterpret_cast(&t), 4); -#if __BYTE_ORDER == __BIG_ENDIAN - t = bswap_32(t); -#endif - return t; -} - -uint64_t kaitai::kstream::read_u8le() { - uint64_t t; - m_io->read(reinterpret_cast(&t), 8); -#if __BYTE_ORDER == __BIG_ENDIAN - t = bswap_64(t); -#endif - return t; -} - -// ======================================================================== -// Floating point numbers -// ======================================================================== - -// ........................................................................ -// Big-endian -// ........................................................................ - -float kaitai::kstream::read_f4be() { - uint32_t t; - m_io->read(reinterpret_cast(&t), 4); -#if __BYTE_ORDER == __LITTLE_ENDIAN - t = bswap_32(t); -#endif - return reinterpret_cast(t); -} - -double kaitai::kstream::read_f8be() { - uint64_t t; - m_io->read(reinterpret_cast(&t), 8); -#if __BYTE_ORDER == __LITTLE_ENDIAN - t = bswap_64(t); -#endif - return reinterpret_cast(t); -} - -// ........................................................................ -// Little-endian -// ........................................................................ - -float kaitai::kstream::read_f4le() { - uint32_t t; - m_io->read(reinterpret_cast(&t), 4); -#if __BYTE_ORDER == __BIG_ENDIAN - t = bswap_32(t); -#endif - return reinterpret_cast(t); -} - -double kaitai::kstream::read_f8le() { - uint64_t t; - m_io->read(reinterpret_cast(&t), 8); -#if __BYTE_ORDER == __BIG_ENDIAN - t = bswap_64(t); -#endif - return reinterpret_cast(t); -} - -// ======================================================================== -// Unaligned bit values -// ======================================================================== - -void kaitai::kstream::align_to_byte() { - m_bits_left = 0; - m_bits = 0; -} - -uint64_t kaitai::kstream::read_bits_int_be(int n) { - int bits_needed = n - m_bits_left; - if (bits_needed > 0) { - // 1 bit => 1 byte - // 8 bits => 1 byte - // 9 bits => 2 bytes - int bytes_needed = ((bits_needed - 1) / 8) + 1; - if (bytes_needed > 8) - throw std::runtime_error("read_bits_int: more than 8 bytes requested"); - char buf[8]; - m_io->read(buf, bytes_needed); - for (int i = 0; i < bytes_needed; i++) { - uint8_t b = buf[i]; - m_bits <<= 8; - m_bits |= b; - m_bits_left += 8; - } - } - - // raw mask with required number of 1s, starting from lowest bit - uint64_t mask = get_mask_ones(n); - // shift mask to align with highest bits available in @bits - int shift_bits = m_bits_left - n; - mask <<= shift_bits; - // derive reading result - uint64_t res = (m_bits & mask) >> shift_bits; - // clear top bits that we've just read => AND with 1s - m_bits_left -= n; - mask = get_mask_ones(m_bits_left); - m_bits &= mask; - - return res; -} - -// Deprecated, use read_bits_int_be() instead. -uint64_t kaitai::kstream::read_bits_int(int n) { - return read_bits_int_be(n); -} - -uint64_t kaitai::kstream::read_bits_int_le(int n) { - int bits_needed = n - m_bits_left; - if (bits_needed > 0) { - // 1 bit => 1 byte - // 8 bits => 1 byte - // 9 bits => 2 bytes - int bytes_needed = ((bits_needed - 1) / 8) + 1; - if (bytes_needed > 8) - throw std::runtime_error("read_bits_int_le: more than 8 bytes requested"); - char buf[8]; - m_io->read(buf, bytes_needed); - for (int i = 0; i < bytes_needed; i++) { - uint8_t b = buf[i]; - m_bits |= (static_cast(b) << m_bits_left); - m_bits_left += 8; - } - } - - // raw mask with required number of 1s, starting from lowest bit - uint64_t mask = get_mask_ones(n); - // derive reading result - uint64_t res = m_bits & mask; - // remove bottom bits that we've just read by shifting - m_bits >>= n; - m_bits_left -= n; - - return res; -} - -uint64_t kaitai::kstream::get_mask_ones(int n) { - if (n == 64) { - return 0xFFFFFFFFFFFFFFFF; - } else { - return ((uint64_t) 1 << n) - 1; - } -} - -// ======================================================================== -// Byte arrays -// ======================================================================== - -std::string kaitai::kstream::read_bytes(std::streamsize len) { - std::vector result(len); - - // NOTE: streamsize type is signed, negative values are only *supposed* to not be used. - // http://en.cppreference.com/w/cpp/io/streamsize - if (len < 0) { - throw std::runtime_error("read_bytes: requested a negative amount"); - } - - if (len > 0) { - m_io->read(&result[0], len); - } - - return std::string(result.begin(), result.end()); -} - -std::string kaitai::kstream::read_bytes_full() { - std::iostream::pos_type p1 = m_io->tellg(); - m_io->seekg(0, std::ios::end); - std::iostream::pos_type p2 = m_io->tellg(); - size_t len = p2 - p1; - - // Note: this requires a std::string to be backed with a - // contiguous buffer. Officially, it's a only requirement since - // C++11 (C++98 and C++03 didn't have this requirement), but all - // major implementations had contiguous buffers anyway. - std::string result(len, ' '); - m_io->seekg(p1); - m_io->read(&result[0], len); - - return result; -} - -std::string kaitai::kstream::read_bytes_term(char term, bool include, bool consume, bool eos_error) { - std::string result; - std::getline(*m_io, result, term); - if (m_io->eof()) { - // encountered EOF - if (eos_error) { - throw std::runtime_error("read_bytes_term: encountered EOF"); - } - } else { - // encountered terminator - if (include) - result.push_back(term); - if (!consume) - m_io->unget(); - } - return result; -} - -std::string kaitai::kstream::ensure_fixed_contents(std::string expected) { - std::string actual = read_bytes(expected.length()); - - if (actual != expected) { - // NOTE: I think printing it outright is not best idea, it could contain non-ascii charactes like backspace and beeps and whatnot. It would be better to print hexlified version, and also to redirect it to stderr. - throw std::runtime_error("ensure_fixed_contents: actual data does not match expected data"); - } - - return actual; -} - -std::string kaitai::kstream::bytes_strip_right(std::string src, char pad_byte) { - std::size_t new_len = src.length(); - - while (new_len > 0 && src[new_len - 1] == pad_byte) - new_len--; - - return src.substr(0, new_len); -} - -std::string kaitai::kstream::bytes_terminate(std::string src, char term, bool include) { - std::size_t new_len = 0; - std::size_t max_len = src.length(); - - while (new_len < max_len && src[new_len] != term) - new_len++; - - if (include && new_len < max_len) - new_len++; - - return src.substr(0, new_len); -} - -// ======================================================================== -// Byte array processing -// ======================================================================== - -std::string kaitai::kstream::process_xor_one(std::string data, uint8_t key) { - size_t len = data.length(); - std::string result(len, ' '); - - for (size_t i = 0; i < len; i++) - result[i] = data[i] ^ key; - - return result; -} - -std::string kaitai::kstream::process_xor_many(std::string data, std::string key) { - size_t len = data.length(); - size_t kl = key.length(); - std::string result(len, ' '); - - size_t ki = 0; - for (size_t i = 0; i < len; i++) { - result[i] = data[i] ^ key[ki]; - ki++; - if (ki >= kl) - ki = 0; - } - - return result; -} - -std::string kaitai::kstream::process_rotate_left(std::string data, int amount) { - size_t len = data.length(); - std::string result(len, ' '); - - for (size_t i = 0; i < len; i++) { - uint8_t bits = data[i]; - result[i] = (bits << amount) | (bits >> (8 - amount)); - } - - return result; -} - -#ifdef KS_ZLIB -#include - -std::string kaitai::kstream::process_zlib(std::string data) { - int ret; - - unsigned char *src_ptr = reinterpret_cast(&data[0]); - std::stringstream dst_strm; - - z_stream strm; - strm.zalloc = Z_NULL; - strm.zfree = Z_NULL; - strm.opaque = Z_NULL; - - ret = inflateInit(&strm); - if (ret != Z_OK) - throw std::runtime_error("process_zlib: inflateInit error"); - - strm.next_in = src_ptr; - strm.avail_in = data.length(); - - unsigned char outbuffer[ZLIB_BUF_SIZE]; - std::string outstring; - - // get the decompressed bytes blockwise using repeated calls to inflate - do { - strm.next_out = reinterpret_cast(outbuffer); - strm.avail_out = sizeof(outbuffer); - - ret = inflate(&strm, 0); - - if (outstring.size() < strm.total_out) - outstring.append(reinterpret_cast(outbuffer), strm.total_out - outstring.size()); - } while (ret == Z_OK); - - if (ret != Z_STREAM_END) { // an error occurred that was not EOF - std::ostringstream exc_msg; - exc_msg << "process_zlib: error #" << ret << "): " << strm.msg; - throw std::runtime_error(exc_msg.str()); - } - - if (inflateEnd(&strm) != Z_OK) - throw std::runtime_error("process_zlib: inflateEnd error"); - - return outstring; -} -#endif - -// ======================================================================== -// Misc utility methods -// ======================================================================== - -int kaitai::kstream::mod(int a, int b) { - if (b <= 0) - throw std::invalid_argument("mod: divisor b <= 0"); - int r = a % b; - if (r < 0) - r += b; - return r; -} - -#include -std::string kaitai::kstream::to_string(int val) { - // if int is 32 bits, "-2147483648" is the longest string representation - // => 11 chars + zero => 12 chars - // if int is 64 bits, "-9223372036854775808" is the longest - // => 20 chars + zero => 21 chars - char buf[25]; - int got_len = snprintf(buf, sizeof(buf), "%d", val); - - // should never happen, but check nonetheless - if (got_len > sizeof(buf)) - throw std::invalid_argument("to_string: integer is longer than string buffer"); - - return std::string(buf); -} - -#include -std::string kaitai::kstream::reverse(std::string val) { - std::reverse(val.begin(), val.end()); - - return val; -} - -uint8_t kaitai::kstream::byte_array_min(const std::string val) { - uint8_t min = 0xff; // UINT8_MAX - std::string::const_iterator end = val.end(); - for (std::string::const_iterator it = val.begin(); it != end; ++it) { - uint8_t cur = static_cast(*it); - if (cur < min) { - min = cur; - } - } - return min; -} - -uint8_t kaitai::kstream::byte_array_max(const std::string val) { - uint8_t max = 0; // UINT8_MIN - std::string::const_iterator end = val.end(); - for (std::string::const_iterator it = val.begin(); it != end; ++it) { - uint8_t cur = static_cast(*it); - if (cur > max) { - max = cur; - } - } - return max; -} - -// ======================================================================== -// Other internal methods -// ======================================================================== - -#ifndef KS_STR_DEFAULT_ENCODING -#define KS_STR_DEFAULT_ENCODING "UTF-8" -#endif - -#ifdef KS_STR_ENCODING_ICONV - -#include -#include -#include - -std::string kaitai::kstream::bytes_to_str(std::string src, std::string src_enc) { - iconv_t cd = iconv_open(KS_STR_DEFAULT_ENCODING, src_enc.c_str()); - - if (cd == (iconv_t) -1) { - if (errno == EINVAL) { - throw std::runtime_error("bytes_to_str: invalid encoding pair conversion requested"); - } else { - throw std::runtime_error("bytes_to_str: error opening iconv"); - } - } - - size_t src_len = src.length(); - size_t src_left = src_len; - - // Start with a buffer length of double the source length. - size_t dst_len = src_len * 2; - std::string dst(dst_len, ' '); - size_t dst_left = dst_len; - - char *src_ptr = &src[0]; - char *dst_ptr = &dst[0]; - - while (true) { - size_t res = iconv(cd, &src_ptr, &src_left, &dst_ptr, &dst_left); - - if (res == (size_t) -1) { - if (errno == E2BIG) { - // dst buffer is not enough to accomodate whole string - // enlarge the buffer and try again - size_t dst_used = dst_len - dst_left; - dst_left += dst_len; - dst_len += dst_len; - dst.resize(dst_len); - - // dst.resize might have allocated destination buffer in another area - // of memory, thus our previous pointer "dst" will be invalid; re-point - // it using "dst_used". - dst_ptr = &dst[dst_used]; - } else { - throw std::runtime_error("bytes_to_str: iconv error"); - } - } else { - // conversion successful - dst.resize(dst_len - dst_left); - break; - } - } - - if (iconv_close(cd) != 0) { - throw std::runtime_error("bytes_to_str: iconv close error"); - } - - return dst; -} -#elif defined(KS_STR_ENCODING_NONE) -std::string kaitai::kstream::bytes_to_str(std::string src, std::string src_enc) { - return src; -} -#else -#error Need to decide how to handle strings: please define one of: KS_STR_ENCODING_ICONV, KS_STR_ENCODING_NONE -#endif diff --git a/third_party/kaitai/kaitaistream.h b/third_party/kaitai/kaitaistream.h deleted file mode 100644 index e7f4c6ce34..0000000000 --- a/third_party/kaitai/kaitaistream.h +++ /dev/null @@ -1,268 +0,0 @@ -#ifndef KAITAI_STREAM_H -#define KAITAI_STREAM_H - -// Kaitai Struct runtime API version: x.y.z = 'xxxyyyzzz' decimal -#define KAITAI_STRUCT_VERSION 9000L - -#include -#include -#include -#include - -namespace kaitai { - -/** - * Kaitai Stream class (kaitai::kstream) is an implementation of - * Kaitai Struct stream API - * for C++/STL. It's implemented as a wrapper over generic STL std::istream. - * - * It provides a wide variety of simple methods to read (parse) binary - * representations of primitive types, such as integer and floating - * point numbers, byte arrays and strings, and also provides stream - * positioning / navigation methods with unified cross-language and - * cross-toolkit semantics. - * - * Typically, end users won't access Kaitai Stream class manually, but would - * describe a binary structure format using .ksy language and then would use - * Kaitai Struct compiler to generate source code in desired target language. - * That code, in turn, would use this class and API to do the actual parsing - * job. - */ -class kstream { -public: - /** - * Constructs new Kaitai Stream object, wrapping a given std::istream. - * \param io istream object to use for this Kaitai Stream - */ - kstream(std::istream* io); - - /** - * Constructs new Kaitai Stream object, wrapping a given in-memory data - * buffer. - * \param data data buffer to use for this Kaitai Stream - */ - kstream(std::string& data); - - void close(); - - /** @name Stream positioning */ - //@{ - /** - * Check if stream pointer is at the end of stream. Note that the semantics - * are different from traditional STL semantics: one does *not* need to do a - * read (which will fail) after the actual end of the stream to trigger EOF - * flag, which can be accessed after that read. It is sufficient to just be - * at the end of the stream for this method to return true. - * \return "true" if we are located at the end of the stream. - */ - bool is_eof() const; - - /** - * Set stream pointer to designated position. - * \param pos new position (offset in bytes from the beginning of the stream) - */ - void seek(uint64_t pos); - - /** - * Get current position of a stream pointer. - * \return pointer position, number of bytes from the beginning of the stream - */ - uint64_t pos(); - - /** - * Get total size of the stream in bytes. - * \return size of the stream in bytes - */ - uint64_t size(); - //@} - - /** @name Integer numbers */ - //@{ - - // ------------------------------------------------------------------------ - // Signed - // ------------------------------------------------------------------------ - - int8_t read_s1(); - - // ........................................................................ - // Big-endian - // ........................................................................ - - int16_t read_s2be(); - int32_t read_s4be(); - int64_t read_s8be(); - - // ........................................................................ - // Little-endian - // ........................................................................ - - int16_t read_s2le(); - int32_t read_s4le(); - int64_t read_s8le(); - - // ------------------------------------------------------------------------ - // Unsigned - // ------------------------------------------------------------------------ - - uint8_t read_u1(); - - // ........................................................................ - // Big-endian - // ........................................................................ - - uint16_t read_u2be(); - uint32_t read_u4be(); - uint64_t read_u8be(); - - // ........................................................................ - // Little-endian - // ........................................................................ - - uint16_t read_u2le(); - uint32_t read_u4le(); - uint64_t read_u8le(); - - //@} - - /** @name Floating point numbers */ - //@{ - - // ........................................................................ - // Big-endian - // ........................................................................ - - float read_f4be(); - double read_f8be(); - - // ........................................................................ - // Little-endian - // ........................................................................ - - float read_f4le(); - double read_f8le(); - - //@} - - /** @name Unaligned bit values */ - //@{ - - void align_to_byte(); - uint64_t read_bits_int_be(int n); - uint64_t read_bits_int(int n); - uint64_t read_bits_int_le(int n); - - //@} - - /** @name Byte arrays */ - //@{ - - std::string read_bytes(std::streamsize len); - std::string read_bytes_full(); - std::string read_bytes_term(char term, bool include, bool consume, bool eos_error); - std::string ensure_fixed_contents(std::string expected); - - static std::string bytes_strip_right(std::string src, char pad_byte); - static std::string bytes_terminate(std::string src, char term, bool include); - static std::string bytes_to_str(std::string src, std::string src_enc); - - //@} - - /** @name Byte array processing */ - //@{ - - /** - * Performs a XOR processing with given data, XORing every byte of input with a single - * given value. - * @param data data to process - * @param key value to XOR with - * @return processed data - */ - static std::string process_xor_one(std::string data, uint8_t key); - - /** - * Performs a XOR processing with given data, XORing every byte of input with a key - * array, repeating key array many times, if necessary (i.e. if data array is longer - * than key array). - * @param data data to process - * @param key array of bytes to XOR with - * @return processed data - */ - static std::string process_xor_many(std::string data, std::string key); - - /** - * Performs a circular left rotation shift for a given buffer by a given amount of bits, - * using groups of 1 bytes each time. Right circular rotation should be performed - * using this procedure with corrected amount. - * @param data source data to process - * @param amount number of bits to shift by - * @return copy of source array with requested shift applied - */ - static std::string process_rotate_left(std::string data, int amount); - -#ifdef KS_ZLIB - /** - * Performs an unpacking ("inflation") of zlib-compressed data with usual zlib headers. - * @param data data to unpack - * @return unpacked data - * @throws IOException - */ - static std::string process_zlib(std::string data); -#endif - - //@} - - /** - * Performs modulo operation between two integers: dividend `a` - * and divisor `b`. Divisor `b` is expected to be positive. The - * result is always 0 <= x <= b - 1. - */ - static int mod(int a, int b); - - /** - * Converts given integer `val` to a decimal string representation. - * Should be used in place of std::to_string() (which is available only - * since C++11) in older C++ implementations. - */ - static std::string to_string(int val); - - /** - * Reverses given string `val`, so that the first character becomes the - * last and the last one becomes the first. This should be used to avoid - * the need of local variables at the caller. - */ - static std::string reverse(std::string val); - - /** - * Finds the minimal byte in a byte array, treating bytes as - * unsigned values. - * @param val byte array to scan - * @return minimal byte in byte array as integer - */ - static uint8_t byte_array_min(const std::string val); - - /** - * Finds the maximal byte in a byte array, treating bytes as - * unsigned values. - * @param val byte array to scan - * @return maximal byte in byte array as integer - */ - static uint8_t byte_array_max(const std::string val); - -private: - std::istream* m_io; - std::istringstream m_io_str; - int m_bits_left; - uint64_t m_bits; - - void init(); - void exceptions_enable() const; - - static uint64_t get_mask_ones(int n); - - static const int ZLIB_BUF_SIZE = 128 * 1024; -}; - -} - -#endif diff --git a/third_party/kaitai/kaitaistruct.h b/third_party/kaitai/kaitaistruct.h deleted file mode 100644 index 8172ede6c9..0000000000 --- a/third_party/kaitai/kaitaistruct.h +++ /dev/null @@ -1,20 +0,0 @@ -#ifndef KAITAI_STRUCT_H -#define KAITAI_STRUCT_H - -#include - -namespace kaitai { - -class kstruct { -public: - kstruct(kstream *_io) { m__io = _io; } - virtual ~kstruct() {} -protected: - kstream *m__io; -public: - kstream *_io() { return m__io; } -}; - -} - -#endif diff --git a/tools/auto_source.py b/tools/auto_source.py index 401929a9ad..bef6a43e53 100755 --- a/tools/auto_source.py +++ b/tools/auto_source.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 import sys -from openpilot.tools.lib.logreader import LogReader +from openpilot.tools.lib.logreader import LogReader, ReadMode def main(): @@ -9,7 +9,7 @@ def main(): sys.exit(1) log_path = sys.argv[1] - lr = LogReader(log_path, sort_by_time=True) + lr = LogReader(log_path, default_mode=ReadMode.AUTO, sort_by_time=True) print("\n".join(lr.logreader_identifiers)) diff --git a/tools/jotpluggler/assets/pause.png b/tools/jotpluggler/assets/pause.png new file mode 100644 index 0000000000..8040099831 --- /dev/null +++ b/tools/jotpluggler/assets/pause.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3ea96d8193eb9067a5efdc5d88a3099730ecafa40efcd09d7402bb3efd723603 +size 2305 diff --git a/tools/jotpluggler/assets/play.png b/tools/jotpluggler/assets/play.png new file mode 100644 index 0000000000..b1556cf0ab --- /dev/null +++ b/tools/jotpluggler/assets/play.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:53097ac5403b725ff1841dfa186ea770b4bb3714205824bde36ec3c2a0fb5dba +size 2758 diff --git a/tools/jotpluggler/assets/split_h.png b/tools/jotpluggler/assets/split_h.png new file mode 100644 index 0000000000..4fd88806e1 --- /dev/null +++ b/tools/jotpluggler/assets/split_h.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:54dd035ff898d881509fa686c402a61af8ef5fb408b92414722da01f773b0d33 +size 2900 diff --git a/tools/jotpluggler/assets/split_v.png b/tools/jotpluggler/assets/split_v.png new file mode 100644 index 0000000000..752e62a4ae --- /dev/null +++ b/tools/jotpluggler/assets/split_v.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:adbd4e5df1f58694dca9dde46d1d95b4e7471684e42e6bca9f41ea5d346e67c5 +size 3669 diff --git a/tools/jotpluggler/assets/x.png b/tools/jotpluggler/assets/x.png new file mode 100644 index 0000000000..3b2eabd447 --- /dev/null +++ b/tools/jotpluggler/assets/x.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a6d9c90cb0dd906e0b15e1f7f3fd9f0dfad3c3b0b34eeed7a7882768dc5f3961 +size 2053 diff --git a/tools/jotpluggler/data.py b/tools/jotpluggler/data.py new file mode 100644 index 0000000000..100dfe544d --- /dev/null +++ b/tools/jotpluggler/data.py @@ -0,0 +1,352 @@ +import numpy as np +import threading +import multiprocessing +import bisect +from collections import defaultdict +from tqdm import tqdm +from openpilot.common.swaglog import cloudlog +from openpilot.tools.lib.logreader import _LogFileReader, LogReader + + +def flatten_dict(d: dict, sep: str = "/", prefix: str = None) -> dict: + result = {} + stack: list[tuple] = [(d, prefix)] + + while stack: + obj, current_prefix = stack.pop() + + if isinstance(obj, dict): + for key, val in obj.items(): + new_prefix = key if current_prefix is None else f"{current_prefix}{sep}{key}" + if isinstance(val, (dict, list)): + stack.append((val, new_prefix)) + else: + result[new_prefix] = val + elif isinstance(obj, list): + for i, item in enumerate(obj): + new_prefix = f"{current_prefix}{sep}{i}" + if isinstance(item, (dict, list)): + stack.append((item, new_prefix)) + else: + result[new_prefix] = item + else: + if current_prefix is not None: + result[current_prefix] = obj + return result + + +def extract_field_types(schema, prefix, field_types_dict): + stack = [(schema, prefix)] + + while stack: + current_schema, current_prefix = stack.pop() + + for field in current_schema.fields_list: + field_name = field.proto.name + field_path = f"{current_prefix}/{field_name}" + field_proto = field.proto + field_which = field_proto.which() + + field_type = field_proto.slot.type.which() if field_which == 'slot' else field_which + field_types_dict[field_path] = field_type + + if field_which == 'slot': + slot_type = field_proto.slot.type + type_which = slot_type.which() + + if type_which == 'list': + element_type = slot_type.list.elementType.which() + list_path = f"{field_path}/*" + field_types_dict[list_path] = element_type + + if element_type == 'struct': + stack.append((field.schema.elementType, list_path)) + + elif type_which == 'struct': + stack.append((field.schema, field_path)) + + elif field_which == 'group': + stack.append((field.schema, field_path)) + + +def _convert_to_optimal_dtype(values_list, capnp_type): + dtype_mapping = { + 'bool': np.bool_, 'int8': np.int8, 'int16': np.int16, 'int32': np.int32, 'int64': np.int64, + 'uint8': np.uint8, 'uint16': np.uint16, 'uint32': np.uint32, 'uint64': np.uint64, + 'float32': np.float32, 'float64': np.float64, 'text': object, 'data': object, + 'enum': object, 'anyPointer': object, + } + + target_dtype = dtype_mapping.get(capnp_type, object) + return np.array(values_list, dtype=target_dtype) + + +def _match_field_type(field_path, field_types): + if field_path in field_types: + return field_types[field_path] + + path_parts = field_path.split('/') + template_parts = [p if not p.isdigit() else '*' for p in path_parts] + template_path = '/'.join(template_parts) + return field_types.get(template_path) + + +def _get_field_times_values(segment, field_name): + if field_name not in segment: + return None, None + + field_data = segment[field_name] + segment_times = segment['t'] + + if field_data['sparse']: + if len(field_data['t_index']) == 0: + return None, None + return segment_times[field_data['t_index']], field_data['values'] + else: + return segment_times, field_data['values'] + + +def msgs_to_time_series(msgs): + """Extract scalar fields and return (time_series_data, start_time, end_time).""" + collected_data = defaultdict(lambda: {'timestamps': [], 'columns': defaultdict(list), 'sparse_fields': set()}) + field_types = {} + extracted_schemas = set() + min_time = max_time = None + + for msg in msgs: + typ = msg.which() + timestamp = msg.logMonoTime * 1e-9 + if typ != 'initData': + if min_time is None: + min_time = timestamp + max_time = timestamp + + sub_msg = getattr(msg, typ) + if not hasattr(sub_msg, 'to_dict'): + continue + + if hasattr(sub_msg, 'schema') and typ not in extracted_schemas: + extract_field_types(sub_msg.schema, typ, field_types) + extracted_schemas.add(typ) + + try: + msg_dict = sub_msg.to_dict(verbose=True) + except Exception as e: + cloudlog.warning(f"Failed to convert sub_msg.to_dict() for message of type: {typ}: {e}") + continue + + flat_dict = flatten_dict(msg_dict) + flat_dict['_valid'] = msg.valid + field_types[f"{typ}/_valid"] = 'bool' + + type_data = collected_data[typ] + columns, sparse_fields = type_data['columns'], type_data['sparse_fields'] + known_fields = set(columns.keys()) + missing_fields = known_fields - flat_dict.keys() + + for field, value in flat_dict.items(): + if field not in known_fields and type_data['timestamps']: + sparse_fields.add(field) + columns[field].append(value) + if value is None: + sparse_fields.add(field) + + for field in missing_fields: + columns[field].append(None) + sparse_fields.add(field) + + type_data['timestamps'].append(timestamp) + + final_result = {} + for typ, data in collected_data.items(): + if not data['timestamps']: + continue + + typ_result = {'t': np.array(data['timestamps'], dtype=np.float64)} + sparse_fields = data['sparse_fields'] + + for field_name, values in data['columns'].items(): + if len(values) < len(data['timestamps']): + values = [None] * (len(data['timestamps']) - len(values)) + values + sparse_fields.add(field_name) + + capnp_type = _match_field_type(f"{typ}/{field_name}", field_types) + + if field_name in sparse_fields: # extract non-None values and their indices + non_none_indices = [] + non_none_values = [] + for i, value in enumerate(values): + if value is not None: + non_none_indices.append(i) + non_none_values.append(value) + + if non_none_values: # check if indices > uint16 max, currently would require a 1000+ Hz signal since indices are within segments + assert max(non_none_indices) <= 65535, f"Sparse field {typ}/{field_name} has timestamp indices exceeding uint16 max. Max: {max(non_none_indices)}" + + typ_result[field_name] = { + 'values': _convert_to_optimal_dtype(non_none_values, capnp_type), + 'sparse': True, + 't_index': np.array(non_none_indices, dtype=np.uint16), + } + else: # dense representation + typ_result[field_name] = {'values': _convert_to_optimal_dtype(values, capnp_type), 'sparse': False} + + final_result[typ] = typ_result + + return final_result, min_time or 0.0, max_time or 0.0 + + +def _process_segment(segment_identifier: str): + try: + lr = _LogFileReader(segment_identifier, sort_by_time=True) + return msgs_to_time_series(lr) + except Exception as e: + cloudlog.warning(f"Warning: Failed to process segment {segment_identifier}: {e}") + return {}, 0.0, 0.0 + + +class DataManager: + def __init__(self): + self._segments = [] + self._segment_starts = [] + self._start_time = 0.0 + self._duration = 0.0 + self._paths = set() + self._observers = [] + self._loading = False + self._lock = threading.RLock() + + def load_route(self, route: str) -> None: + if self._loading: + return + self._reset() + threading.Thread(target=self._load_async, args=(route,), daemon=True).start() + + def get_timeseries(self, path: str): + with self._lock: + msg_type, field = path.split('/', 1) + times, values = [], [] + + for segment in self._segments: + if msg_type in segment: + field_times, field_values = _get_field_times_values(segment[msg_type], field) + if field_times is not None: + times.append(field_times) + values.append(field_values) + + if not times: + return np.array([]), np.array([]) + + combined_times = np.concatenate(times) - self._start_time + + if len(values) > 1: + first_dtype = values[0].dtype + if all(arr.dtype == first_dtype for arr in values): # check if all arrays have compatible dtypes + combined_values = np.concatenate(values) + else: + combined_values = np.concatenate([arr.astype(object) for arr in values]) + else: + combined_values = values[0] if values else np.array([]) + + return combined_times, combined_values + + def get_value_at(self, path: str, time: float): + with self._lock: + MAX_LOOKBACK = 5.0 # seconds + absolute_time = self._start_time + time + message_type, field = path.split('/', 1) + current_index = bisect.bisect_right(self._segment_starts, absolute_time) - 1 + for index in (current_index, current_index - 1): + if not 0 <= index < len(self._segments): + continue + segment = self._segments[index].get(message_type) + if not segment: + continue + times, values = _get_field_times_values(segment, field) + if times is None or len(times) == 0 or (index != current_index and absolute_time - times[-1] > MAX_LOOKBACK): + continue + position = np.searchsorted(times, absolute_time, 'right') - 1 + if position >= 0 and absolute_time - times[position] <= MAX_LOOKBACK: + return values[position] + return None + + def get_all_paths(self): + with self._lock: + return sorted(self._paths) + + def get_duration(self): + with self._lock: + return self._duration + + def is_plottable(self, path: str): + _, values = self.get_timeseries(path) + if len(values) == 0: + return False + return np.issubdtype(values.dtype, np.number) or np.issubdtype(values.dtype, np.bool_) + + def add_observer(self, callback): + with self._lock: + self._observers.append(callback) + + def remove_observer(self, callback): + with self._lock: + if callback in self._observers: + self._observers.remove(callback) + + def _reset(self): + with self._lock: + self._loading = True + self._segments.clear() + self._segment_starts.clear() + self._paths.clear() + self._start_time = self._duration = 0.0 + observers = self._observers.copy() + + for callback in observers: + callback({'reset': True}) + + def _load_async(self, route: str): + try: + lr = LogReader(route, sort_by_time=True) + if not lr.logreader_identifiers: + cloudlog.warning(f"Warning: No log segments found for route: {route}") + return + + num_processes = max(1, multiprocessing.cpu_count() // 2) + with multiprocessing.Pool(processes=num_processes) as pool, tqdm(total=len(lr.logreader_identifiers), desc="Processing Segments") as pbar: + for segment_result, start_time, end_time in pool.imap(_process_segment, lr.logreader_identifiers): + pbar.update(1) + if segment_result: + self._add_segment(segment_result, start_time, end_time) + except Exception: + cloudlog.exception(f"Error loading route {route}:") + finally: + self._finalize_loading() + + def _add_segment(self, segment_data: dict, start_time: float, end_time: float): + with self._lock: + self._segments.append(segment_data) + self._segment_starts.append(start_time) + + if len(self._segments) == 1: + self._start_time = start_time + self._duration = end_time - self._start_time + + for msg_type, data in segment_data.items(): + for field_name in data.keys(): + if field_name != 't': + self._paths.add(f"{msg_type}/{field_name}") + + observers = self._observers.copy() + + for callback in observers: + callback({'segment_added': True, 'duration': self._duration, 'segment_count': len(self._segments)}) + + def _finalize_loading(self): + with self._lock: + self._loading = False + observers = self._observers.copy() + duration = self._duration + + for callback in observers: + callback({'loading_complete': True, 'duration': duration}) diff --git a/tools/jotpluggler/datatree.py b/tools/jotpluggler/datatree.py new file mode 100644 index 0000000000..3390fed2e1 --- /dev/null +++ b/tools/jotpluggler/datatree.py @@ -0,0 +1,315 @@ +import os +import re +import threading +import numpy as np +import dearpygui.dearpygui as dpg + + +class DataTreeNode: + def __init__(self, name: str, full_path: str = "", parent=None): + self.name = name + self.full_path = full_path + self.parent = parent + self.children: dict[str, DataTreeNode] = {} + self.filtered_children: dict[str, DataTreeNode] = {} + self.created_children: dict[str, DataTreeNode] = {} + self.is_leaf = False + self.is_plottable: bool | None = None + self.ui_created = False + self.children_ui_created = False + self.ui_tag: str | None = None + + +class DataTree: + MAX_NODES_PER_FRAME = 50 + + def __init__(self, data_manager, playback_manager): + self.data_manager = data_manager + self.playback_manager = playback_manager + self.current_search = "" + self.data_tree = DataTreeNode(name="root") + self._build_queue: dict[str, tuple[DataTreeNode, DataTreeNode, str | int]] = {} # full_path -> (node, parent, before_tag) + self._current_created_paths: set[str] = set() + self._current_filtered_paths: set[str] = set() + self._path_to_node: dict[str, DataTreeNode] = {} # full_path -> node + self._expanded_tags: set[str] = set() + self._item_handlers: dict[str, str] = {} # ui_tag -> handler_tag + self._char_width = None + self._queued_search = None + self._new_data = False + self._ui_lock = threading.RLock() + self._handlers_to_delete = [] + self.data_manager.add_observer(self._on_data_loaded) + + def create_ui(self, parent_tag: str): + with dpg.child_window(parent=parent_tag, border=False, width=-1, height=-1): + dpg.add_text("Timeseries List") + dpg.add_separator() + dpg.add_input_text(tag="search_input", width=-1, hint="Search fields...", callback=self.search_data) + dpg.add_separator() + with dpg.child_window(border=False, width=-1, height=-1): + with dpg.group(tag="data_tree_container"): + pass + + def _on_data_loaded(self, data: dict): + with self._ui_lock: + if data.get('segment_added') or data.get('reset'): + self._new_data = True + + def update_frame(self, font): + if self._handlers_to_delete: # we need to do everything in main thread, frame callbacks are flaky + dpg.render_dearpygui_frame() # wait a frame to ensure queued callbacks are done + with self._ui_lock: + for handler in self._handlers_to_delete: + dpg.delete_item(handler) + self._handlers_to_delete.clear() + + with self._ui_lock: + if self._char_width is None: + if size := dpg.get_text_size(" ", font=font): + self._char_width = size[0] + + if self._new_data: + self._process_path_change() + self._new_data = False + return + + if self._queued_search is not None: + self.current_search = self._queued_search + self._process_path_change() + self._queued_search = None + return + + nodes_processed = 0 + while self._build_queue and nodes_processed < self.MAX_NODES_PER_FRAME: + child_node, parent, before_tag = self._build_queue.pop(next(iter(self._build_queue))) + parent_tag = "data_tree_container" if parent.name == "root" else parent.ui_tag + if not child_node.ui_created: + if child_node.is_leaf: + self._create_leaf_ui(child_node, parent_tag, before_tag) + else: + self._create_tree_node_ui(child_node, parent_tag, before_tag) + parent.created_children[child_node.name] = parent.children[child_node.name] + self._current_created_paths.add(child_node.full_path) + nodes_processed += 1 + + def _process_path_change(self): + self._build_queue.clear() + search_term = self.current_search.strip().lower() + all_paths = set(self.data_manager.get_all_paths()) + new_filtered_leafs = {path for path in all_paths if self._should_show_path(path, search_term)} + new_filtered_paths = set(new_filtered_leafs) + for path in new_filtered_leafs: + parts = path.split('/') + for i in range(1, len(parts)): + prefix = '/'.join(parts[:i]) + new_filtered_paths.add(prefix) + created_paths_to_remove = self._current_created_paths - new_filtered_paths + filtered_paths_to_remove = self._current_filtered_paths - new_filtered_leafs + + if created_paths_to_remove or filtered_paths_to_remove: + self._remove_paths_from_tree(created_paths_to_remove, filtered_paths_to_remove) + self._apply_expansion_to_tree(self.data_tree, search_term) + + paths_to_add = new_filtered_leafs - self._current_created_paths + if paths_to_add: + self._add_paths_to_tree(paths_to_add) + self._apply_expansion_to_tree(self.data_tree, search_term) + self._current_filtered_paths = new_filtered_paths + + def _remove_paths_from_tree(self, created_paths_to_remove, filtered_paths_to_remove): + for path in sorted(created_paths_to_remove, reverse=True): + current_node = self._path_to_node[path] + + if len(current_node.created_children) == 0: + self._current_created_paths.remove(current_node.full_path) + if item_handler_tag := self._item_handlers.get(current_node.ui_tag): + dpg.configure_item(item_handler_tag, show=False) + self._handlers_to_delete.append(item_handler_tag) + del self._item_handlers[current_node.ui_tag] + dpg.delete_item(current_node.ui_tag) + current_node.ui_created = False + current_node.ui_tag = None + current_node.children_ui_created = False + del current_node.parent.created_children[current_node.name] + del current_node.parent.filtered_children[current_node.name] + + for path in filtered_paths_to_remove: + parts = path.split('/') + current_node = self._path_to_node[path] + + part_array_index = -1 + while len(current_node.filtered_children) == 0 and part_array_index >= -len(parts): + current_node = current_node.parent + if parts[part_array_index] in current_node.filtered_children: + del current_node.filtered_children[parts[part_array_index]] + part_array_index -= 1 + + def _add_paths_to_tree(self, paths): + parent_nodes_to_recheck = set() + for path in sorted(paths): + parts = path.split('/') + current_node = self.data_tree + current_path_prefix = "" + + for i, part in enumerate(parts): + current_path_prefix = f"{current_path_prefix}/{part}" if current_path_prefix else part + if i < len(parts) - 1: + parent_nodes_to_recheck.add(current_node) # for incremental changes from new data + if part not in current_node.children: + current_node.children[part] = DataTreeNode(name=part, full_path=current_path_prefix, parent=current_node) + self._path_to_node[current_path_prefix] = current_node.children[part] + current_node.filtered_children[part] = current_node.children[part] + current_node = current_node.children[part] + + if not current_node.is_leaf: + current_node.is_leaf = True + + for p_node in parent_nodes_to_recheck: + p_node.children_ui_created = False + self._request_children_build(p_node) + + def _get_node_label_and_expand(self, node: DataTreeNode, search_term: str): + label = f"{node.name} ({len(node.filtered_children)} fields)" + expand = len(search_term) > 0 and any(search_term in path for path in self._get_descendant_paths(node)) + if expand and node.parent and len(node.parent.filtered_children) > 100 and len(node.filtered_children) > 2: + label += " (+)" # symbol for large lists which aren't fully expanded for performance (only affects procLog rn) + expand = False + return label, expand + + def _apply_expansion_to_tree(self, node: DataTreeNode, search_term: str): + if node.ui_created and not node.is_leaf and node.ui_tag and dpg.does_item_exist(node.ui_tag): + label, expand = self._get_node_label_and_expand(node, search_term) + if expand: + self._expanded_tags.add(node.ui_tag) + dpg.set_value(node.ui_tag, expand) + elif node.ui_tag in self._expanded_tags: # not expanded and was expanded + self._expanded_tags.remove(node.ui_tag) + dpg.set_value(node.ui_tag, expand) + dpg.delete_item(node.ui_tag, children_only=True) # delete children (not visible since collapsed) + self._reset_ui_state_recursive(node) + node.children_ui_created = False + dpg.set_item_label(node.ui_tag, label) + for child in node.created_children.values(): + self._apply_expansion_to_tree(child, search_term) + + def _reset_ui_state_recursive(self, node: DataTreeNode): + for child in node.created_children.values(): + if child.ui_tag is not None: + if item_handler_tag := self._item_handlers.get(child.ui_tag): + self._handlers_to_delete.append(item_handler_tag) + dpg.configure_item(item_handler_tag, show=False) + del self._item_handlers[child.ui_tag] + self._reset_ui_state_recursive(child) + child.ui_created = False + child.ui_tag = None + child.children_ui_created = False + self._current_created_paths.remove(child.full_path) + node.created_children.clear() + + def search_data(self): + with self._ui_lock: + self._queued_search = dpg.get_value("search_input") + + def _create_tree_node_ui(self, node: DataTreeNode, parent_tag: str, before: str | int): + node.ui_tag = f"tree_{node.full_path}" + search_term = self.current_search.strip().lower() + label, expand = self._get_node_label_and_expand(node, search_term) + if expand: + self._expanded_tags.add(node.ui_tag) + elif node.ui_tag in self._expanded_tags: + self._expanded_tags.remove(node.ui_tag) + + with dpg.tree_node( + label=label, parent=parent_tag, tag=node.ui_tag, default_open=expand, open_on_arrow=True, open_on_double_click=True, before=before, delay_search=True + ): + with dpg.item_handler_registry() as handler_tag: + dpg.add_item_toggled_open_handler(callback=lambda s, a, u: self._request_children_build(node)) + dpg.add_item_visible_handler(callback=lambda s, a, u: self._request_children_build(node)) + dpg.bind_item_handler_registry(node.ui_tag, handler_tag) + self._item_handlers[node.ui_tag] = handler_tag + node.ui_created = True + + def _create_leaf_ui(self, node: DataTreeNode, parent_tag: str, before: str | int): + node.ui_tag = f"leaf_{node.full_path}" + with dpg.group(parent=parent_tag, tag=node.ui_tag, before=before, delay_search=True): + with dpg.table(header_row=False, policy=dpg.mvTable_SizingStretchProp, delay_search=True): + dpg.add_table_column(init_width_or_weight=0.5) + dpg.add_table_column(init_width_or_weight=0.5) + with dpg.table_row(): + dpg.add_text(node.name) + dpg.add_text("N/A", tag=f"value_{node.full_path}") + + if node.is_plottable is None: + node.is_plottable = self.data_manager.is_plottable(node.full_path) + if node.is_plottable: + with dpg.drag_payload(parent=node.ui_tag, drag_data=node.full_path, payload_type="TIMESERIES_PAYLOAD"): + dpg.add_text(f"Plot: {node.full_path}") + + with dpg.item_handler_registry() as handler_tag: + dpg.add_item_visible_handler(callback=self._on_item_visible, user_data=node.full_path) + dpg.bind_item_handler_registry(node.ui_tag, handler_tag) + self._item_handlers[node.ui_tag] = handler_tag + node.ui_created = True + + def _on_item_visible(self, sender, app_data, user_data): + with self._ui_lock: + path = user_data + value_tag = f"value_{path}" + if not dpg.does_item_exist(value_tag): + return + value_column_width = dpg.get_item_rect_size(f"leaf_{path}")[0] // 2 + value = self.data_manager.get_value_at(path, self.playback_manager.current_time_s) + if value is not None: + formatted_value = self.format_and_truncate(value, value_column_width, self._char_width) + dpg.set_value(value_tag, formatted_value) + else: + dpg.set_value(value_tag, "N/A") + + def _request_children_build(self, node: DataTreeNode): + with self._ui_lock: + if not node.children_ui_created and (node.name == "root" or (node.ui_tag is not None and dpg.get_value(node.ui_tag))): # check root or node expanded + sorted_children = sorted(node.filtered_children.values(), key=self._natural_sort_key) + next_existing: list[int | str] = [0] * len(sorted_children) + current_before_tag: int | str = 0 + + for i in range(len(sorted_children) - 1, -1, -1): # calculate "before_tag" for correct ordering when incrementally building tree + child = sorted_children[i] + next_existing[i] = current_before_tag + if child.ui_created: + candidate_tag = f"leaf_{child.full_path}" if child.is_leaf else f"tree_{child.full_path}" + if dpg.does_item_exist(candidate_tag): + current_before_tag = candidate_tag + + for i, child_node in enumerate(sorted_children): + if not child_node.ui_created: + before_tag = next_existing[i] + self._build_queue[child_node.full_path] = (child_node, node, before_tag) + node.children_ui_created = True + + def _should_show_path(self, path: str, search_term: str) -> bool: + if 'DEPRECATED' in path and not os.environ.get('SHOW_DEPRECATED'): + return False + return not search_term or search_term in path.lower() + + def _natural_sort_key(self, node: DataTreeNode): + node_type_key = node.is_leaf + parts = [int(p) if p.isdigit() else p.lower() for p in re.split(r'(\d+)', node.name) if p] + return (node_type_key, parts) + + def _get_descendant_paths(self, node: DataTreeNode): + for child_name, child_node in node.filtered_children.items(): + child_name_lower = child_name.lower() + if child_node.is_leaf: + yield child_name_lower + else: + for path in self._get_descendant_paths(child_node): + yield f"{child_name_lower}/{path}" + + @staticmethod + def format_and_truncate(value, available_width: float, char_width: float) -> str: + s = f"{value:.5f}" if np.issubdtype(type(value), np.floating) else str(value) + max_chars = int(available_width / char_width) + if len(s) > max_chars: + return s[: max(0, max_chars - 3)] + "..." + return s diff --git a/tools/jotpluggler/layout.py b/tools/jotpluggler/layout.py new file mode 100644 index 0000000000..917c156f9f --- /dev/null +++ b/tools/jotpluggler/layout.py @@ -0,0 +1,272 @@ +import dearpygui.dearpygui as dpg +from openpilot.tools.jotpluggler.data import DataManager +from openpilot.tools.jotpluggler.views import TimeSeriesPanel + +GRIP_SIZE = 4 +MIN_PANE_SIZE = 60 + + +class PlotLayoutManager: + def __init__(self, data_manager: DataManager, playback_manager, worker_manager, scale: float = 1.0): + self.data_manager = data_manager + self.playback_manager = playback_manager + self.worker_manager = worker_manager + self.scale = scale + self.container_tag = "plot_layout_container" + self.active_panels: list = [] + + self.grip_size = int(GRIP_SIZE * self.scale) + self.min_pane_size = int(MIN_PANE_SIZE * self.scale) + + initial_panel = TimeSeriesPanel(data_manager, playback_manager, worker_manager) + self.layout: dict = {"type": "panel", "panel": initial_panel} + + def create_ui(self, parent_tag: str): + if dpg.does_item_exist(self.container_tag): + dpg.delete_item(self.container_tag) + + with dpg.child_window(tag=self.container_tag, parent=parent_tag, border=False, width=-1, height=-1, no_scrollbar=True, no_scroll_with_mouse=True): + container_width, container_height = dpg.get_item_rect_size(self.container_tag) + self._create_ui_recursive(self.layout, self.container_tag, [], container_width, container_height) + + def _create_ui_recursive(self, layout: dict, parent_tag: str, path: list[int], width: int, height: int): + if layout["type"] == "panel": + self._create_panel_ui(layout, parent_tag, path, width, height) + else: + self._create_split_ui(layout, parent_tag, path, width, height) + + def _create_panel_ui(self, layout: dict, parent_tag: str, path: list[int], width: int, height:int): + panel_tag = self._path_to_tag(path, "panel") + panel = layout["panel"] + self.active_panels.append(panel) + text_size = int(13 * self.scale) + bar_height = (text_size+24) if width < int(279 * self.scale + 80) else (text_size+8) # adjust height to allow for scrollbar + + with dpg.child_window(parent=parent_tag, border=True, width=-1, height=-1, no_scrollbar=True): + with dpg.group(horizontal=True): + with dpg.child_window(tag=panel_tag, width=-(text_size + 16), height=bar_height, horizontal_scrollbar=True, no_scroll_with_mouse=True, border=False): + with dpg.group(horizontal=True): + dpg.add_input_text(default_value=panel.title, width=int(100 * self.scale), callback=lambda s, v: setattr(panel, "title", v)) + dpg.add_combo(items=["Time Series"], default_value="Time Series", width=int(100 * self.scale)) + dpg.add_button(label="Clear", callback=lambda: self.clear_panel(panel), width=int(40 * self.scale)) + dpg.add_image_button(texture_tag="split_h_texture", callback=lambda: self.split_panel(path, 0), width=text_size, height=text_size) + dpg.add_image_button(texture_tag="split_v_texture", callback=lambda: self.split_panel(path, 1), width=text_size, height=text_size) + dpg.add_image_button(texture_tag="x_texture", callback=lambda: self.delete_panel(path), width=text_size, height=text_size) + + dpg.add_separator() + + content_tag = self._path_to_tag(path, "content") + with dpg.child_window(tag=content_tag, border=False, height=-1, width=-1, no_scrollbar=True): + panel.create_ui(content_tag) + + def _create_split_ui(self, layout: dict, parent_tag: str, path: list[int], width: int, height: int): + split_tag = self._path_to_tag(path, "split") + orientation, _, pane_sizes = self._get_split_geometry(layout, (width, height)) + + with dpg.group(tag=split_tag, parent=parent_tag, horizontal=orientation == 0): + for i, child_layout in enumerate(layout["children"]): + child_path = path + [i] + container_tag = self._path_to_tag(child_path, "container") + pane_width, pane_height = [(pane_sizes[i], -1), (-1, pane_sizes[i])][orientation] # fill 2nd dim up to the border + with dpg.child_window(tag=container_tag, width=pane_width, height=pane_height, border=False, no_scrollbar=True): + child_width, child_height = [(pane_sizes[i], height), (width, pane_sizes[i])][orientation] + self._create_ui_recursive(child_layout, container_tag, child_path, child_width, child_height) + if i < len(layout["children"]) - 1: + self._create_grip(split_tag, path, i, orientation) + + def clear_panel(self, panel): + panel.clear() + + def delete_panel(self, panel_path: list[int]): + if not panel_path: # Root deletion + old_panel = self.layout["panel"] + old_panel.destroy_ui() + self.active_panels.remove(old_panel) + new_panel = TimeSeriesPanel(self.data_manager, self.playback_manager, self.worker_manager) + self.layout = {"type": "panel", "panel": new_panel} + self._rebuild_ui_at_path([]) + return + + parent, child_index = self._get_parent_and_index(panel_path) + layout_to_delete = parent["children"][child_index] + self._cleanup_ui_recursive(layout_to_delete, panel_path) + + parent["children"].pop(child_index) + parent["proportions"].pop(child_index) + + if len(parent["children"]) == 1: # remove parent and collapse + remaining_child = parent["children"][0] + if len(panel_path) == 1: # parent is at root level - promote remaining child to root + self.layout = remaining_child + self._rebuild_ui_at_path([]) + else: # replace parent with remaining child in grandparent + grandparent_path = panel_path[:-2] + parent_index = panel_path[-2] + self._replace_layout_at_path(grandparent_path + [parent_index], remaining_child) + self._rebuild_ui_at_path(grandparent_path + [parent_index]) + else: # redistribute proportions + equal_prop = 1.0 / len(parent["children"]) + parent["proportions"] = [equal_prop] * len(parent["children"]) + self._rebuild_ui_at_path(panel_path[:-1]) + + def split_panel(self, panel_path: list[int], orientation: int): + current_layout = self._get_layout_at_path(panel_path) + existing_panel = current_layout["panel"] + new_panel = TimeSeriesPanel(self.data_manager, self.playback_manager, self.worker_manager) + parent, child_index = self._get_parent_and_index(panel_path) + + if parent is None: # Root split + self.layout = { + "type": "split", + "orientation": orientation, + "children": [{"type": "panel", "panel": existing_panel}, {"type": "panel", "panel": new_panel}], + "proportions": [0.5, 0.5], + } + self._rebuild_ui_at_path([]) + elif parent["type"] == "split" and parent["orientation"] == orientation: # Same orientation - insert into existing split + parent["children"].insert(child_index + 1, {"type": "panel", "panel": new_panel}) + parent["proportions"] = [1.0 / len(parent["children"])] * len(parent["children"]) + self._rebuild_ui_at_path(panel_path[:-1]) + else: # Different orientation - create new split level + new_split = {"type": "split", "orientation": orientation, "children": [current_layout, {"type": "panel", "panel": new_panel}], "proportions": [0.5, 0.5]} + self._replace_layout_at_path(panel_path, new_split) + self._rebuild_ui_at_path(panel_path) + + def _rebuild_ui_at_path(self, path: list[int]): + layout = self._get_layout_at_path(path) + if path: + container_tag = self._path_to_tag(path, "container") + else: # Root update + container_tag = self.container_tag + + self._cleanup_ui_recursive(layout, path) + dpg.delete_item(container_tag, children_only=True) + width, height = dpg.get_item_rect_size(container_tag) + self._create_ui_recursive(layout, container_tag, path, width, height) + + def _cleanup_ui_recursive(self, layout: dict, path: list[int]): + if layout["type"] == "panel": + panel = layout["panel"] + panel.destroy_ui() + if panel in self.active_panels: + self.active_panels.remove(panel) + else: + for i in range(len(layout["children"]) - 1): + handler_tag = f"{self._path_to_tag(path, f'grip_{i}')}_handler" + if dpg.does_item_exist(handler_tag): + dpg.delete_item(handler_tag) + + for i, child in enumerate(layout["children"]): + self._cleanup_ui_recursive(child, path + [i]) + + def update_all_panels(self): + for panel in self.active_panels: + panel.update() + + def on_viewport_resize(self): + self._resize_splits_recursive(self.layout, []) + + def _resize_splits_recursive(self, layout: dict, path: list[int], width: int | None = None, height: int | None = None): + if layout["type"] == "split": + split_tag = self._path_to_tag(path, "split") + if dpg.does_item_exist(split_tag): + available_sizes = (width, height) if width and height else dpg.get_item_rect_size(dpg.get_item_parent(split_tag)) + orientation, _, pane_sizes = self._get_split_geometry(layout, available_sizes) + size_properties = ("width", "height") + + for i, child_layout in enumerate(layout["children"]): + child_path = path + [i] + container_tag = self._path_to_tag(child_path, "container") + if dpg.does_item_exist(container_tag): + dpg.configure_item(container_tag, **{size_properties[orientation]: pane_sizes[i]}) + child_width, child_height = [(pane_sizes[i], available_sizes[1]), (available_sizes[0], pane_sizes[i])][orientation] + self._resize_splits_recursive(child_layout, child_path, child_width, child_height) + else: # leaf node/panel - adjust bar height to allow for scrollbar + panel_tag = self._path_to_tag(path, "panel") + if width is not None and width < int(279 * self.scale + 80): # scaled widths of the elements in top bar + fixed 8 padding on left and right of each item + dpg.configure_item(panel_tag, height=(int(13*self.scale) + 24)) + else: + dpg.configure_item(panel_tag, height=(int(13*self.scale) + 8)) + + def _get_split_geometry(self, layout: dict, available_size: tuple[int, int]) -> tuple[int, int, list[int]]: + orientation = layout["orientation"] + num_grips = len(layout["children"]) - 1 + usable_size = max(self.min_pane_size, available_size[orientation] - (num_grips * (self.grip_size + 8 * (2-orientation)))) # approximate, scaling is weird + pane_sizes = [max(self.min_pane_size, int(usable_size * prop)) for prop in layout["proportions"]] + return orientation, usable_size, pane_sizes + + def _get_layout_at_path(self, path: list[int]) -> dict: + current = self.layout + for index in path: + current = current["children"][index] + return current + + def _get_parent_and_index(self, path: list[int]) -> tuple: + return (None, -1) if not path else (self._get_layout_at_path(path[:-1]), path[-1]) + + def _replace_layout_at_path(self, path: list[int], new_layout: dict): + if not path: + self.layout = new_layout + else: + parent, index = self._get_parent_and_index(path) + parent["children"][index] = new_layout + + def _path_to_tag(self, path: list[int], prefix: str = "") -> str: + path_str = "_".join(map(str, path)) if path else "root" + return f"{prefix}_{path_str}" if prefix else path_str + + def _create_grip(self, parent_tag: str, path: list[int], grip_index: int, orientation: int): + grip_tag = self._path_to_tag(path, f"grip_{grip_index}") + width, height = [(self.grip_size, -1), (-1, self.grip_size)][orientation] + + with dpg.child_window(tag=grip_tag, parent=parent_tag, width=width, height=height, no_scrollbar=True, border=False): + button_tag = dpg.add_button(label="", width=-1, height=-1) + + with dpg.item_handler_registry(tag=f"{grip_tag}_handler"): + user_data = (path, grip_index, orientation) + dpg.add_item_active_handler(callback=self._on_grip_drag, user_data=user_data) + dpg.add_item_deactivated_handler(callback=self._on_grip_end, user_data=user_data) + dpg.bind_item_handler_registry(button_tag, f"{grip_tag}_handler") + + def _on_grip_drag(self, sender, app_data, user_data): + path, grip_index, orientation = user_data + layout = self._get_layout_at_path(path) + + if "_drag_data" not in layout: + layout["_drag_data"] = {"initial_proportions": layout["proportions"][:], "start_mouse": dpg.get_mouse_pos(local=False)[orientation]} + return + + drag_data = layout["_drag_data"] + split_tag = self._path_to_tag(path, "split") + if not dpg.does_item_exist(split_tag): + return + + _, usable_size, _ = self._get_split_geometry(layout, dpg.get_item_rect_size(split_tag)) + current_coord = dpg.get_mouse_pos(local=False)[orientation] + delta = current_coord - drag_data["start_mouse"] + delta_prop = delta / usable_size + + left_idx = grip_index + right_idx = left_idx + 1 + initial = drag_data["initial_proportions"] + min_prop = self.min_pane_size / usable_size + + new_left = max(min_prop, initial[left_idx] + delta_prop) + new_right = max(min_prop, initial[right_idx] - delta_prop) + + total_available = initial[left_idx] + initial[right_idx] + if new_left + new_right > total_available: + if new_left > new_right: + new_left = total_available - new_right + else: + new_right = total_available - new_left + + layout["proportions"] = initial[:] + layout["proportions"][left_idx] = new_left + layout["proportions"][right_idx] = new_right + + self._resize_splits_recursive(layout, path) + + def _on_grip_end(self, sender, app_data, user_data): + path, _, _ = user_data + self._get_layout_at_path(path).pop("_drag_data", None) diff --git a/tools/jotpluggler/pluggle.py b/tools/jotpluggler/pluggle.py new file mode 100755 index 0000000000..582a44454e --- /dev/null +++ b/tools/jotpluggler/pluggle.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python3 +import argparse +import os +import pyautogui +import subprocess +import dearpygui.dearpygui as dpg +import multiprocessing +import uuid +import signal +from openpilot.common.basedir import BASEDIR +from openpilot.tools.jotpluggler.data import DataManager +from openpilot.tools.jotpluggler.datatree import DataTree +from openpilot.tools.jotpluggler.layout import PlotLayoutManager + +DEMO_ROUTE = "a2a0ccea32023010|2023-07-27--13-01-19" + + +class WorkerManager: + def __init__(self, max_workers=None): + self.pool = multiprocessing.Pool(max_workers or min(4, multiprocessing.cpu_count()), initializer=WorkerManager.worker_initializer) + self.active_tasks = {} + + def submit_task(self, func, args_list, callback=None, task_id=None): + task_id = task_id or str(uuid.uuid4()) + + if task_id in self.active_tasks: + try: + self.active_tasks[task_id].terminate() + except Exception: + pass + + def handle_success(result): + self.active_tasks.pop(task_id, None) + if callback: + try: + callback(result) + except Exception as e: + print(f"Callback for task {task_id} failed: {e}") + + def handle_error(error): + self.active_tasks.pop(task_id, None) + print(f"Task {task_id} failed: {error}") + + async_result = self.pool.starmap_async(func, args_list, callback=handle_success, error_callback=handle_error) + self.active_tasks[task_id] = async_result + return task_id + + @staticmethod + def worker_initializer(): + signal.signal(signal.SIGINT, signal.SIG_IGN) + + def shutdown(self): + for task in self.active_tasks.values(): + try: + task.terminate() + except Exception: + pass + self.pool.terminate() + self.pool.join() + + +class PlaybackManager: + def __init__(self): + self.is_playing = False + self.current_time_s = 0.0 + self.duration_s = 0.0 + + def set_route_duration(self, duration: float): + self.duration_s = duration + self.seek(min(self.current_time_s, duration)) + + def toggle_play_pause(self): + if not self.is_playing and self.current_time_s >= self.duration_s: + self.seek(0.0) + self.is_playing = not self.is_playing + texture_tag = "pause_texture" if self.is_playing else "play_texture" + dpg.configure_item("play_pause_button", texture_tag=texture_tag) + + def seek(self, time_s: float): + self.current_time_s = max(0.0, min(time_s, self.duration_s)) + + def update_time(self, delta_t: float): + if self.is_playing: + self.current_time_s = min(self.current_time_s + delta_t, self.duration_s) + if self.current_time_s >= self.duration_s: + self.is_playing = False + dpg.configure_item("play_pause_button", texture_tag="play_texture") + return self.current_time_s + + +class MainController: + def __init__(self, scale: float = 1.0): + self.scale = scale + self.data_manager = DataManager() + self.playback_manager = PlaybackManager() + self.worker_manager = WorkerManager() + self._create_global_themes() + self.data_tree = DataTree(self.data_manager, self.playback_manager) + self.plot_layout_manager = PlotLayoutManager(self.data_manager, self.playback_manager, self.worker_manager, scale=self.scale) + self.data_manager.add_observer(self.on_data_loaded) + + def _create_global_themes(self): + with dpg.theme(tag="global_line_theme"): + with dpg.theme_component(dpg.mvLineSeries): + scaled_thickness = max(1.0, self.scale) + dpg.add_theme_style(dpg.mvPlotStyleVar_LineWeight, scaled_thickness, category=dpg.mvThemeCat_Plots) + + with dpg.theme(tag="global_timeline_theme"): + with dpg.theme_component(dpg.mvInfLineSeries): + scaled_thickness = max(1.0, self.scale) + dpg.add_theme_style(dpg.mvPlotStyleVar_LineWeight, scaled_thickness, category=dpg.mvThemeCat_Plots) + dpg.add_theme_color(dpg.mvPlotCol_Line, (255, 0, 0, 128), category=dpg.mvThemeCat_Plots) + + def on_data_loaded(self, data: dict): + duration = data.get('duration', 0.0) + self.playback_manager.set_route_duration(duration) + + if data.get('reset'): + self.playback_manager.current_time_s = 0.0 + self.playback_manager.duration_s = 0.0 + self.playback_manager.is_playing = False + dpg.set_value("load_status", "Loading...") + dpg.set_value("timeline_slider", 0.0) + dpg.configure_item("timeline_slider", max_value=0.0) + dpg.configure_item("play_pause_button", texture_tag="play_texture") + dpg.configure_item("load_button", enabled=True) + elif data.get('loading_complete'): + num_paths = len(self.data_manager.get_all_paths()) + dpg.set_value("load_status", f"Loaded {num_paths} data paths") + dpg.configure_item("load_button", enabled=True) + elif data.get('segment_added'): + segment_count = data.get('segment_count', 0) + dpg.set_value("load_status", f"Loading... {segment_count} segments processed") + + dpg.configure_item("timeline_slider", max_value=duration) + + def setup_ui(self): + with dpg.texture_registry(): + script_dir = os.path.dirname(os.path.realpath(__file__)) + for image in ["play", "pause", "x", "split_h", "split_v"]: + texture = dpg.load_image(os.path.join(script_dir, "assets", f"{image}.png")) + dpg.add_static_texture(width=texture[0], height=texture[1], default_value=texture[3], tag=f"{image}_texture") + + with dpg.window(tag="Primary Window"): + with dpg.group(horizontal=True): + # Left panel - Data tree + with dpg.child_window(label="Sidebar", width=300 * self.scale, tag="sidebar_window", border=True, resizable_x=True): + with dpg.group(horizontal=True): + dpg.add_input_text(tag="route_input", width=-75 * self.scale, hint="Enter route name...") + dpg.add_button(label="Load", callback=self.load_route, tag="load_button", width=-1) + dpg.add_text("Ready to load route", tag="load_status") + dpg.add_separator() + self.data_tree.create_ui("sidebar_window") + + # Right panel - Plots and timeline + with dpg.group(tag="right_panel"): + with dpg.child_window(label="Plot Window", border=True, height=-(32 + 13 * self.scale), tag="main_plot_area"): + self.plot_layout_manager.create_ui("main_plot_area") + + with dpg.child_window(label="Timeline", border=True): + with dpg.table(header_row=False, borders_innerH=False, borders_innerV=False, borders_outerH=False, borders_outerV=False): + btn_size = int(13 * self.scale) + dpg.add_table_column(width_fixed=True, init_width_or_weight=(btn_size + 8)) # Play button + dpg.add_table_column(width_stretch=True) # Timeline slider + dpg.add_table_column(width_fixed=True, init_width_or_weight=int(50 * self.scale)) # FPS counter + with dpg.table_row(): + dpg.add_image_button(texture_tag="play_texture", tag="play_pause_button", callback=self.toggle_play_pause, width=btn_size, height=btn_size) + dpg.add_slider_float(tag="timeline_slider", default_value=0.0, label="", width=-1, callback=self.timeline_drag) + dpg.add_text("", tag="fps_counter") + with dpg.item_handler_registry(tag="plot_resize_handler"): + dpg.add_item_resize_handler(callback=self.on_plot_resize) + dpg.bind_item_handler_registry("right_panel", "plot_resize_handler") + + dpg.set_primary_window("Primary Window", True) + + def on_plot_resize(self, sender, app_data, user_data): + self.plot_layout_manager.on_viewport_resize() + + def load_route(self): + route_name = dpg.get_value("route_input").strip() + if route_name: + dpg.set_value("load_status", "Loading route...") + dpg.configure_item("load_button", enabled=False) + self.data_manager.load_route(route_name) + + def toggle_play_pause(self, sender): + self.playback_manager.toggle_play_pause() + + def timeline_drag(self, sender, app_data): + self.playback_manager.seek(app_data) + + def update_frame(self, font): + self.data_tree.update_frame(font) + + new_time = self.playback_manager.update_time(dpg.get_delta_time()) + if not dpg.is_item_active("timeline_slider"): + dpg.set_value("timeline_slider", new_time) + + self.plot_layout_manager.update_all_panels() + + dpg.set_value("fps_counter", f"{dpg.get_frame_rate():.1f} FPS") + + def shutdown(self): + self.worker_manager.shutdown() + + +def main(route_to_load=None): + dpg.create_context() + + # TODO: find better way of calculating display scaling + try: + w, h = next(tuple(map(int, l.split()[0].split('x'))) for l in subprocess.check_output(['xrandr']).decode().split('\n') if '*' in l) # actual resolution + scale = pyautogui.size()[0] / w # scaled resolution + except Exception: + scale = 1 + + with dpg.font_registry(): + default_font = dpg.add_font(os.path.join(BASEDIR, "selfdrive/assets/fonts/JetBrainsMono-Medium.ttf"), int(13 * scale)) + dpg.bind_font(default_font) + + viewport_width, viewport_height = int(1200 * scale), int(800 * scale) + mouse_x, mouse_y = pyautogui.position() # TODO: find better way of creating the window where the user is (default dpg behavior annoying on multiple displays) + dpg.create_viewport( + title='JotPluggler', width=viewport_width, height=viewport_height, x_pos=mouse_x - viewport_width // 2, y_pos=mouse_y - viewport_height // 2 + ) + dpg.setup_dearpygui() + + controller = MainController(scale=scale) + controller.setup_ui() + + if route_to_load: + dpg.set_value("route_input", route_to_load) + controller.load_route() + + dpg.show_viewport() + + # Main loop + try: + while dpg.is_dearpygui_running(): + controller.update_frame(default_font) + dpg.render_dearpygui_frame() + finally: + controller.shutdown() + dpg.destroy_context() + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="A tool for visualizing openpilot logs.") + parser.add_argument("--demo", action="store_true", help="Use the demo route instead of providing one") + parser.add_argument("route", nargs='?', default=None, help="Optional route name to load on startup.") + args = parser.parse_args() + route = DEMO_ROUTE if args.demo else args.route + main(route_to_load=route) diff --git a/tools/jotpluggler/views.py b/tools/jotpluggler/views.py new file mode 100644 index 0000000000..4af9a102ac --- /dev/null +++ b/tools/jotpluggler/views.py @@ -0,0 +1,195 @@ +import uuid +import threading +import numpy as np +from collections import deque +import dearpygui.dearpygui as dpg +from abc import ABC, abstractmethod + + +class ViewPanel(ABC): + """Abstract base class for all view panels that can be displayed in a plot container""" + + def __init__(self, panel_id: str = None): + self.panel_id = panel_id or str(uuid.uuid4()) + self.title = "Untitled Panel" + + @abstractmethod + def clear(self): + pass + + @abstractmethod + def create_ui(self, parent_tag: str): + pass + + @abstractmethod + def destroy_ui(self): + pass + + @abstractmethod + def get_panel_type(self) -> str: + pass + + @abstractmethod + def update(self): + pass + + +class TimeSeriesPanel(ViewPanel): + def __init__(self, data_manager, playback_manager, worker_manager, panel_id: str | None = None): + super().__init__(panel_id) + self.data_manager = data_manager + self.playback_manager = playback_manager + self.worker_manager = worker_manager + self.title = "Time Series Plot" + self.plot_tag = f"plot_{self.panel_id}" + self.x_axis_tag = f"{self.plot_tag}_x_axis" + self.y_axis_tag = f"{self.plot_tag}_y_axis" + self.timeline_indicator_tag = f"{self.plot_tag}_timeline" + self._ui_created = False + self._series_data: dict[str, tuple[np.ndarray, np.ndarray]] = {} + self._last_plot_duration = 0 + self._update_lock = threading.RLock() + self._results_deque: deque[tuple[str, list, list]] = deque() + self._new_data = False + + def create_ui(self, parent_tag: str): + self.data_manager.add_observer(self.on_data_loaded) + with dpg.plot(height=-1, width=-1, tag=self.plot_tag, parent=parent_tag, drop_callback=self._on_series_drop, payload_type="TIMESERIES_PAYLOAD"): + dpg.add_plot_legend() + dpg.add_plot_axis(dpg.mvXAxis, no_label=True, tag=self.x_axis_tag) + dpg.add_plot_axis(dpg.mvYAxis, no_label=True, tag=self.y_axis_tag) + timeline_series_tag = dpg.add_inf_line_series(x=[0], label="Timeline", parent=self.y_axis_tag, tag=self.timeline_indicator_tag) + dpg.bind_item_theme(timeline_series_tag, "global_timeline_theme") + + for series_path in list(self._series_data.keys()): + self.add_series(series_path) + self._ui_created = True + + def update(self): + with self._update_lock: + if not self._ui_created: + return + + if self._new_data: # handle new data in main thread + self._new_data = False + for series_path in list(self._series_data.keys()): + self.add_series(series_path, update=True) + + while self._results_deque: # handle downsampled results in main thread + results = self._results_deque.popleft() + for series_path, downsampled_time, downsampled_values in results: + series_tag = f"series_{self.panel_id}_{series_path}" + if dpg.does_item_exist(series_tag): + dpg.set_value(series_tag, (downsampled_time, downsampled_values.astype(float))) + + # update timeline + current_time_s = self.playback_manager.current_time_s + dpg.set_value(self.timeline_indicator_tag, [[current_time_s], [0]]) + + # update timeseries legend label + for series_path, (time_array, value_array) in self._series_data.items(): + position = np.searchsorted(time_array, current_time_s, side='right') - 1 + if position >= 0 and (current_time_s - time_array[position]) <= 1.0: + value = value_array[position] + formatted_value = f"{value:.5f}" if np.issubdtype(type(value), np.floating) else str(value) + series_tag = f"series_{self.panel_id}_{series_path}" + if dpg.does_item_exist(series_tag): + dpg.configure_item(series_tag, label=f"{series_path}: {formatted_value}") + + # downsample if plot zoom changed significantly + plot_duration = dpg.get_axis_limits(self.x_axis_tag)[1] - dpg.get_axis_limits(self.x_axis_tag)[0] + if plot_duration > self._last_plot_duration * 2 or plot_duration < self._last_plot_duration * 0.5: + self._downsample_all_series(plot_duration) + + def _downsample_all_series(self, plot_duration): + plot_width = dpg.get_item_rect_size(self.plot_tag)[0] + if plot_width <= 0 or plot_duration <= 0: + return + + self._last_plot_duration = plot_duration + target_points_per_second = plot_width / plot_duration + work_items = [] + for series_path, (time_array, value_array) in self._series_data.items(): + if len(time_array) == 0: + continue + series_duration = time_array[-1] - time_array[0] if len(time_array) > 1 else 1 + points_per_second = len(time_array) / series_duration + if points_per_second > target_points_per_second * 2: + target_points = max(int(target_points_per_second * series_duration), plot_width) + work_items.append((series_path, time_array, value_array, target_points)) + elif dpg.does_item_exist(f"series_{self.panel_id}_{series_path}"): + dpg.set_value(f"series_{self.panel_id}_{series_path}", (time_array, value_array.astype(float))) + + if work_items: + self.worker_manager.submit_task( + TimeSeriesPanel._downsample_worker, work_items, callback=lambda results: self._results_deque.append(results), task_id=f"downsample_{self.panel_id}" + ) + + def add_series(self, series_path: str, update: bool = False): + with self._update_lock: + if update or series_path not in self._series_data: + self._series_data[series_path] = self.data_manager.get_timeseries(series_path) + + time_array, value_array = self._series_data[series_path] + series_tag = f"series_{self.panel_id}_{series_path}" + if dpg.does_item_exist(series_tag): + dpg.set_value(series_tag, (time_array, value_array.astype(float))) + else: + line_series_tag = dpg.add_line_series(x=time_array, y=value_array.astype(float), label=series_path, parent=self.y_axis_tag, tag=series_tag) + dpg.bind_item_theme(line_series_tag, "global_line_theme") + dpg.fit_axis_data(self.x_axis_tag) + dpg.fit_axis_data(self.y_axis_tag) + plot_duration = dpg.get_axis_limits(self.x_axis_tag)[1] - dpg.get_axis_limits(self.x_axis_tag)[0] + self._downsample_all_series(plot_duration) + + def destroy_ui(self): + with self._update_lock: + self.data_manager.remove_observer(self.on_data_loaded) + if dpg.does_item_exist(self.plot_tag): + dpg.delete_item(self.plot_tag) + self._ui_created = False + + def get_panel_type(self) -> str: + return "timeseries" + + def clear(self): + with self._update_lock: + for series_path in list(self._series_data.keys()): + self.remove_series(series_path) + + def remove_series(self, series_path: str): + with self._update_lock: + if series_path in self._series_data: + if dpg.does_item_exist(f"series_{self.panel_id}_{series_path}"): + dpg.delete_item(f"series_{self.panel_id}_{series_path}") + del self._series_data[series_path] + + def on_data_loaded(self, data: dict): + self._new_data = True + + def _on_series_drop(self, sender, app_data, user_data): + self.add_series(app_data) + + @staticmethod + def _downsample_worker(series_path, time_array, value_array, target_points): + if len(time_array) <= target_points: + return series_path, time_array, value_array + + step = len(time_array) / target_points + indices = [] + + for i in range(target_points): + start_idx = int(i * step) + end_idx = int((i + 1) * step) + if start_idx == end_idx: + indices.append(start_idx) + else: + bucket_values = value_array[start_idx:end_idx] + min_idx = start_idx + np.argmin(bucket_values) + max_idx = start_idx + np.argmax(bucket_values) + if min_idx != max_idx: + indices.extend([min(min_idx, max_idx), max(min_idx, max_idx)]) + else: + indices.append(min_idx) + indices = sorted(set(indices)) + return series_path, time_array[indices], value_array[indices] diff --git a/tools/op.sh b/tools/op.sh index e83b46b2d2..54ff8e97e9 100755 --- a/tools/op.sh +++ b/tools/op.sh @@ -365,6 +365,7 @@ function op_switch() { fi BRANCH="$1" + git config --replace-all remote.origin.fetch "+refs/heads/*:refs/remotes/origin/*" git fetch "$REMOTE" "$BRANCH" git checkout -f FETCH_HEAD git checkout -B "$BRANCH" --track "$REMOTE"/"$BRANCH" diff --git a/tools/plotjuggler/layouts/torque-controller.xml b/tools/plotjuggler/layouts/torque-controller.xml index 606df03611..8e9a1a8526 100644 --- a/tools/plotjuggler/layouts/torque-controller.xml +++ b/tools/plotjuggler/layouts/torque-controller.xml @@ -1,77 +1,45 @@ - + - + - - + + - - + + - - + + - - + + - - + + - + - + - - + + - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + @@ -79,115 +47,134 @@ - + - - + + - - + + - - + + - - + + - - + + - - + + - + - - - - + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - + + - - + + - - - - + + + + - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + @@ -199,25 +186,34 @@ + + + + + + + + + + + + + - + - return (0) - /carState/canValid + return value * 3.6 + /carState/vEgo - + - return (value * v1 ^ 2) - (v2 * 9.81) - /controlsState/curvature - - /carState/vEgo - /liveParameters/roll - + return value * 2.23694 + /carState/vEgo @@ -228,15 +224,24 @@ /liveParameters/roll - + - return value * 2.23694 - /carState/vEgo + return (value * v1 ^ 2) - (v2 * 9.81) + /controlsState/curvature + + /carState/vEgo + /liveParameters/roll + - + - return value * 3.6 - /carState/vEgo + return value + 0.2 + /carParams/steerActuatorDelay + + + + return (0) + /carState/canValid diff --git a/tools/replay/SConscript b/tools/replay/SConscript index 18849407cf..136c4119f6 100644 --- a/tools/replay/SConscript +++ b/tools/replay/SConscript @@ -13,6 +13,8 @@ else: replay_lib_src = ["replay.cc", "consoleui.cc", "camera.cc", "filereader.cc", "logreader.cc", "framereader.cc", "route.cc", "util.cc", "seg_mgr.cc", "timeline.cc", "api.cc"] +if arch != "Darwin": + replay_lib_src.append("qcom_decoder.cc") replay_lib = replay_env.Library("replay", replay_lib_src, LIBS=base_libs, FRAMEWORKS=base_frameworks) Export('replay_lib') replay_libs = [replay_lib, 'avutil', 'avcodec', 'avformat', 'bz2', 'zstd', 'curl', 'yuv', 'ncurses'] + base_libs diff --git a/tools/replay/framereader.cc b/tools/replay/framereader.cc index ed88626be3..e9cd090446 100644 --- a/tools/replay/framereader.cc +++ b/tools/replay/framereader.cc @@ -8,6 +8,7 @@ #include "common/util.h" #include "third_party/libyuv/include/libyuv.h" #include "tools/replay/util.h" +#include "system/hardware/hw.h" #ifdef __APPLE__ #define HW_DEVICE_TYPE AV_HWDEVICE_TYPE_VIDEOTOOLBOX @@ -37,7 +38,16 @@ struct DecoderManager { return it->second.get(); } - auto decoder = std::make_unique(); + std::unique_ptr decoder; + #ifndef __APPLE__ + if (Hardware::TICI() && hw_decoder) { + decoder = std::make_unique(); + } else + #endif + { + decoder = std::make_unique(); + } + if (!decoder->open(codecpar, hw_decoder)) { decoder.reset(nullptr); } @@ -114,19 +124,19 @@ bool FrameReader::get(int idx, VisionBuf *buf) { // class VideoDecoder -VideoDecoder::VideoDecoder() { +FFmpegVideoDecoder::FFmpegVideoDecoder() { av_frame_ = av_frame_alloc(); hw_frame_ = av_frame_alloc(); } -VideoDecoder::~VideoDecoder() { +FFmpegVideoDecoder::~FFmpegVideoDecoder() { if (hw_device_ctx) av_buffer_unref(&hw_device_ctx); if (decoder_ctx) avcodec_free_context(&decoder_ctx); av_frame_free(&av_frame_); av_frame_free(&hw_frame_); } -bool VideoDecoder::open(AVCodecParameters *codecpar, bool hw_decoder) { +bool FFmpegVideoDecoder::open(AVCodecParameters *codecpar, bool hw_decoder) { const AVCodec *decoder = avcodec_find_decoder(codecpar->codec_id); if (!decoder) return false; @@ -149,7 +159,7 @@ bool VideoDecoder::open(AVCodecParameters *codecpar, bool hw_decoder) { return true; } -bool VideoDecoder::initHardwareDecoder(AVHWDeviceType hw_device_type) { +bool FFmpegVideoDecoder::initHardwareDecoder(AVHWDeviceType hw_device_type) { const AVCodecHWConfig *config = nullptr; for (int i = 0; (config = avcodec_get_hw_config(decoder_ctx->codec, i)) != nullptr; i++) { if (config->methods & AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX && config->device_type == hw_device_type) { @@ -175,7 +185,7 @@ bool VideoDecoder::initHardwareDecoder(AVHWDeviceType hw_device_type) { return true; } -bool VideoDecoder::decode(FrameReader *reader, int idx, VisionBuf *buf) { +bool FFmpegVideoDecoder::decode(FrameReader *reader, int idx, VisionBuf *buf) { int current_idx = idx; if (idx != reader->prev_idx + 1) { // seeking to the nearest key frame @@ -219,7 +229,7 @@ bool VideoDecoder::decode(FrameReader *reader, int idx, VisionBuf *buf) { return false; } -AVFrame *VideoDecoder::decodeFrame(AVPacket *pkt) { +AVFrame *FFmpegVideoDecoder::decodeFrame(AVPacket *pkt) { int ret = avcodec_send_packet(decoder_ctx, pkt); if (ret < 0) { rError("Error sending a packet for decoding: %d", ret); @@ -239,7 +249,7 @@ AVFrame *VideoDecoder::decodeFrame(AVPacket *pkt) { return (av_frame_->format == hw_pix_fmt) ? hw_frame_ : av_frame_; } -bool VideoDecoder::copyBuffer(AVFrame *f, VisionBuf *buf) { +bool FFmpegVideoDecoder::copyBuffer(AVFrame *f, VisionBuf *buf) { if (hw_pix_fmt == HW_PIX_FMT) { for (int i = 0; i < height/2; i++) { memcpy(buf->y + (i*2 + 0)*buf->stride, f->data[0] + (i*2 + 0)*f->linesize[0], width); @@ -256,3 +266,47 @@ bool VideoDecoder::copyBuffer(AVFrame *f, VisionBuf *buf) { } return true; } + +#ifndef __APPLE__ +bool QcomVideoDecoder::open(AVCodecParameters *codecpar, bool hw_decoder) { + if (codecpar->codec_id != AV_CODEC_ID_HEVC) { + rError("Hardware decoder only supports HEVC codec"); + return false; + } + width = codecpar->width; + height = codecpar->height; + msm_vidc.init(VIDEO_DEVICE, width, height, V4L2_PIX_FMT_HEVC); + return true; +} + +bool QcomVideoDecoder::decode(FrameReader *reader, int idx, VisionBuf *buf) { + int from_idx = idx; + if (idx != reader->prev_idx + 1) { + // seeking to the nearest key frame + for (int i = idx; i >= 0; --i) { + if (reader->packets_info[i].flags & AV_PKT_FLAG_KEY) { + from_idx = i; + break; + } + } + + auto pos = reader->packets_info[from_idx].pos; + int ret = avformat_seek_file(reader->input_ctx, 0, pos, pos, pos, AVSEEK_FLAG_BYTE); + if (ret < 0) { + rError("Failed to seek to byte position %lld: %d", pos, AVERROR(ret)); + return false; + } + } + reader->prev_idx = idx; + bool result = false; + AVPacket pkt; + msm_vidc.avctx = reader->input_ctx; + for (int i = from_idx; i <= idx; ++i) { + if (av_read_frame(reader->input_ctx, &pkt) == 0) { + result = msm_vidc.decodeFrame(&pkt, buf) && (i == idx); + av_packet_unref(&pkt); + } + } + return result; +} +#endif diff --git a/tools/replay/framereader.h b/tools/replay/framereader.h index a15847e311..d8e86fce0f 100644 --- a/tools/replay/framereader.h +++ b/tools/replay/framereader.h @@ -7,6 +7,10 @@ #include "tools/replay/filereader.h" #include "tools/replay/util.h" +#ifndef __APPLE__ +#include "tools/replay/qcom_decoder.h" +#endif + extern "C" { #include #include @@ -40,11 +44,18 @@ public: class VideoDecoder { public: - VideoDecoder(); - ~VideoDecoder(); - bool open(AVCodecParameters *codecpar, bool hw_decoder); - bool decode(FrameReader *reader, int idx, VisionBuf *buf); + virtual ~VideoDecoder() = default; + virtual bool open(AVCodecParameters *codecpar, bool hw_decoder) = 0; + virtual bool decode(FrameReader *reader, int idx, VisionBuf *buf) = 0; int width = 0, height = 0; +}; + +class FFmpegVideoDecoder : public VideoDecoder { +public: + FFmpegVideoDecoder(); + ~FFmpegVideoDecoder() override; + bool open(AVCodecParameters *codecpar, bool hw_decoder) override; + bool decode(FrameReader *reader, int idx, VisionBuf *buf) override; private: bool initHardwareDecoder(AVHWDeviceType hw_device_type); @@ -56,3 +67,16 @@ private: AVPixelFormat hw_pix_fmt = AV_PIX_FMT_NONE; AVBufferRef *hw_device_ctx = nullptr; }; + +#ifndef __APPLE__ +class QcomVideoDecoder : public VideoDecoder { +public: + QcomVideoDecoder() {}; + ~QcomVideoDecoder() override {}; + bool open(AVCodecParameters *codecpar, bool hw_decoder) override; + bool decode(FrameReader *reader, int idx, VisionBuf *buf) override; + +private: + MsmVidc msm_vidc = MsmVidc(); +}; +#endif diff --git a/tools/replay/qcom_decoder.cc b/tools/replay/qcom_decoder.cc new file mode 100644 index 0000000000..eb5409daa3 --- /dev/null +++ b/tools/replay/qcom_decoder.cc @@ -0,0 +1,346 @@ +#include "qcom_decoder.h" + +#include +#include "third_party/linux/include/v4l2-controls.h" +#include + + +#include "common/swaglog.h" +#include "common/util.h" + +// echo "0xFFFF" > /sys/kernel/debug/msm_vidc/debug_level + +static void copyBuffer(VisionBuf *src_buf, VisionBuf *dst_buf) { + // Copy Y plane + memcpy(dst_buf->y, src_buf->y, src_buf->height * src_buf->stride); + // Copy UV plane + memcpy(dst_buf->uv, src_buf->uv, src_buf->height / 2 * src_buf->stride); +} + +static void request_buffers(int fd, v4l2_buf_type buf_type, unsigned int count) { + struct v4l2_requestbuffers reqbuf = { + .count = count, + .type = buf_type, + .memory = V4L2_MEMORY_USERPTR + }; + util::safe_ioctl(fd, VIDIOC_REQBUFS, &reqbuf, "VIDIOC_REQBUFS failed"); +} + +MsmVidc::~MsmVidc() { + if (fd > 0) { + close(fd); + } +} + +bool MsmVidc::init(const char* dev, size_t width, size_t height, uint64_t codec) { + LOG("Initializing msm_vidc device %s", dev); + this->w = width; + this->h = height; + this->fd = open(dev, O_RDWR, 0); + if (fd < 0) { + LOGE("failed to open video device %s", dev); + return false; + } + subscribeEvents(); + v4l2_buf_type out_type = V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE; + setPlaneFormat(out_type, V4L2_PIX_FMT_HEVC); // Also allocates the output buffer + setFPS(FPS); + request_buffers(fd, out_type, OUTPUT_BUFFER_COUNT); + util::safe_ioctl(fd, VIDIOC_STREAMON, &out_type, "VIDIOC_STREAMON OUTPUT failed"); + restartCapture(); + setupPolling(); + + this->initialized = true; + return true; +} + +VisionBuf* MsmVidc::decodeFrame(AVPacket *pkt, VisionBuf *buf) { + assert(initialized && (pkt != nullptr) && (buf != nullptr)); + + this->frame_ready = false; + this->current_output_buf = buf; + bool sent_packet = false; + + while (!this->frame_ready) { + if (!sent_packet) { + int buf_index = getBufferUnlocked(); + if (buf_index >= 0) { + assert(buf_index < out_buf_cnt); + sendPacket(buf_index, pkt); + sent_packet = true; + } + } + + if (poll(pfd, nfds, -1) < 0) { + LOGE("poll() error: %d", errno); + return nullptr; + } + + if (VisionBuf* result = processEvents()) { + return result; + } + } + + return buf; +} + +VisionBuf* MsmVidc::processEvents() { + for (int idx = 0; idx < nfds; idx++) { + short revents = pfd[idx].revents; + if (!revents) continue; + + if (idx == ev[EV_VIDEO]) { + if (revents & (POLLIN | POLLRDNORM)) { + VisionBuf *result = handleCapture(); + if (result == this->current_output_buf) { + this->frame_ready = true; + } + } + if (revents & (POLLOUT | POLLWRNORM)) { + handleOutput(); + } + if (revents & POLLPRI) { + handleEvent(); + } + } else { + LOGE("Unexpected event on fd %d", pfd[idx].fd); + } + } + return nullptr; +} + +VisionBuf* MsmVidc::handleCapture() { + struct v4l2_buffer buf = {0}; + struct v4l2_plane planes[1] = {0}; + buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE; + buf.memory = V4L2_MEMORY_USERPTR; + buf.m.planes = planes; + buf.length = 1; + util::safe_ioctl(this->fd, VIDIOC_DQBUF, &buf, "VIDIOC_DQBUF CAPTURE failed"); + + if (this->reconfigure_pending || buf.m.planes[0].bytesused == 0) { + return nullptr; + } + + copyBuffer(&cap_bufs[buf.index], this->current_output_buf); + queueCaptureBuffer(buf.index); + return this->current_output_buf; +} + +bool MsmVidc::subscribeEvents() { + for (uint32_t event : subscriptions) { + struct v4l2_event_subscription sub = { .type = event}; + util::safe_ioctl(fd, VIDIOC_SUBSCRIBE_EVENT, &sub, "VIDIOC_SUBSCRIBE_EVENT failed"); + } + return true; +} + +bool MsmVidc::setPlaneFormat(enum v4l2_buf_type type, uint32_t fourcc) { + struct v4l2_format fmt = {.type = type}; + struct v4l2_pix_format_mplane *pix = &fmt.fmt.pix_mp; + *pix = { + .width = (__u32)this->w, + .height = (__u32)this->h, + .pixelformat = fourcc + }; + util::safe_ioctl(fd, VIDIOC_S_FMT, &fmt, "VIDIOC_S_FMT failed"); + if (type == V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE) { + this->out_buf_size = pix->plane_fmt[0].sizeimage; + int ion_size = this->out_buf_size * OUTPUT_BUFFER_COUNT; // Output (input) buffers are ION buffer. + this->out_buf.allocate(ion_size); // mmap rw + for (int i = 0; i < OUTPUT_BUFFER_COUNT; i++) { + this->out_buf_off[i] = i * this->out_buf_size; + this->out_buf_addr[i] = (char *)this->out_buf.addr + this->out_buf_off[i]; + this->out_buf_flag[i] = false; + } + LOGD("Set output buffer size to %d, count %d, addr %p", this->out_buf_size, OUTPUT_BUFFER_COUNT, this->out_buf.addr); + } else if (type == V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE) { + request_buffers(this->fd, type, CAPTURE_BUFFER_COUNT); + util::safe_ioctl(fd, VIDIOC_G_FMT, &fmt, "VIDIOC_G_FMT failed"); + const __u32 y_size = pix->plane_fmt[0].sizeimage; + const __u32 y_stride = pix->plane_fmt[0].bytesperline; + for (int i = 0; i < CAPTURE_BUFFER_COUNT; i++) { + size_t uv_offset = (size_t)y_stride * pix->height; + size_t required = uv_offset + (y_stride * pix->height / 2); // enough for Y + UV. For linear NV12, UV plane starts at y_stride * height. + size_t alloc_size = std::max(y_size, required); + this->cap_bufs[i].allocate(alloc_size); + this->cap_bufs[i].init_yuv(pix->width, pix->height, y_stride, uv_offset); + } + LOGD("Set capture buffer size to %d, count %d, addr %p, extradata size %d", + pix->plane_fmt[0].sizeimage, CAPTURE_BUFFER_COUNT, this->cap_bufs[0].addr, pix->plane_fmt[1].sizeimage); + } + return true; +} + +bool MsmVidc::setFPS(uint32_t fps) { + struct v4l2_streamparm streamparam = { + .type = V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE, + .parm.output.timeperframe = {1, fps} + }; + util::safe_ioctl(fd, VIDIOC_S_PARM, &streamparam, "VIDIOC_S_PARM failed"); + return true; +} + +bool MsmVidc::restartCapture() { + // stop if already initialized + enum v4l2_buf_type type = V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE; + if (this->initialized) { + LOGD("Restarting capture, flushing buffers..."); + util::safe_ioctl(this->fd, VIDIOC_STREAMOFF, &type, "VIDIOC_STREAMOFF CAPTURE failed"); + struct v4l2_requestbuffers reqbuf = {.type = type, .memory = V4L2_MEMORY_USERPTR}; + util::safe_ioctl(this->fd, VIDIOC_REQBUFS, &reqbuf, "VIDIOC_REQBUFS failed"); + for (size_t i = 0; i < CAPTURE_BUFFER_COUNT; ++i) { + this->cap_bufs[i].free(); + this->cap_buf_flag[i] = false; // mark as not queued + cap_bufs[i].~VisionBuf(); + new (&cap_bufs[i]) VisionBuf(); + } + } + // setup, start and queue capture buffers + setDBP(); + setPlaneFormat(type, V4L2_PIX_FMT_NV12); + util::safe_ioctl(this->fd, VIDIOC_STREAMON, &type, "VIDIOC_STREAMON CAPTURE failed"); + for (size_t i = 0; i < CAPTURE_BUFFER_COUNT; ++i) { + queueCaptureBuffer(i); + } + + return true; +} + +bool MsmVidc::queueCaptureBuffer(int i) { + struct v4l2_buffer buf = {0}; + struct v4l2_plane planes[1] = {0}; + + buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE; + buf.memory = V4L2_MEMORY_USERPTR; + buf.index = i; + buf.m.planes = planes; + buf.length = 1; + // decoded frame plane + planes[0].m.userptr = (unsigned long)this->cap_bufs[i].addr; // no security + planes[0].length = this->cap_bufs[i].len; + planes[0].reserved[0] = this->cap_bufs[i].fd; // ION fd + planes[0].reserved[1] = 0; + planes[0].bytesused = this->cap_bufs[i].len; + planes[0].data_offset = 0; + util::safe_ioctl(this->fd, VIDIOC_QBUF, &buf, "VIDIOC_QBUF failed"); + this->cap_buf_flag[i] = true; // mark as queued + return true; +} + +bool MsmVidc::queueOutputBuffer(int i, size_t size) { + struct v4l2_buffer buf = {0}; + struct v4l2_plane planes[1] = {0}; + + buf.type = V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE; + buf.memory = V4L2_MEMORY_USERPTR; + buf.index = i; + buf.m.planes = planes; + buf.length = 1; + // decoded frame plane + planes[0].m.userptr = (unsigned long)this->out_buf_off[i]; // check this + planes[0].length = this->out_buf_size; + planes[0].reserved[0] = this->out_buf.fd; // ION fd + planes[0].reserved[1] = 0; + planes[0].bytesused = size; + planes[0].data_offset = 0; + assert((this->out_buf_off[i] & 0xfff) == 0); // must be 4 KiB aligned + assert(this->out_buf_size % 4096 == 0); // ditto for size + + util::safe_ioctl(this->fd, VIDIOC_QBUF, &buf, "VIDIOC_QBUF failed"); + this->out_buf_flag[i] = true; // mark as queued + return true; +} + +bool MsmVidc::setDBP() { + struct v4l2_ext_control control[2] = {0}; + struct v4l2_ext_controls controls = {0}; + control[0].id = V4L2_CID_MPEG_VIDC_VIDEO_STREAM_OUTPUT_MODE; + control[0].value = 1; // V4L2_CID_MPEG_VIDC_VIDEO_STREAM_OUTPUT_SECONDARY + control[1].id = V4L2_CID_MPEG_VIDC_VIDEO_DPB_COLOR_FORMAT; + control[1].value = 0; // V4L2_MPEG_VIDC_VIDEO_DPB_COLOR_FMT_NONE + controls.count = 2; + controls.ctrl_class = V4L2_CTRL_CLASS_MPEG; + controls.controls = control; + util::safe_ioctl(fd, VIDIOC_S_EXT_CTRLS, &controls, "VIDIOC_S_EXT_CTRLS failed"); + return true; +} + +bool MsmVidc::setupPolling() { + // Initialize poll array + pfd[EV_VIDEO] = {fd, POLLIN | POLLOUT | POLLWRNORM | POLLRDNORM | POLLPRI, 0}; + ev[EV_VIDEO] = EV_VIDEO; + nfds = 1; + return true; +} + +bool MsmVidc::sendPacket(int buf_index, AVPacket *pkt) { + assert(buf_index >= 0 && buf_index < out_buf_cnt); + assert(pkt != nullptr && pkt->data != nullptr && pkt->size > 0); + // Prepare output buffer + memset(this->out_buf_addr[buf_index], 0, this->out_buf_size); + uint8_t * data = (uint8_t *)this->out_buf_addr[buf_index]; + memcpy(data, pkt->data, pkt->size); + queueOutputBuffer(buf_index, pkt->size); + return true; +} + +int MsmVidc::getBufferUnlocked() { + for (int i = 0; i < this->out_buf_cnt; i++) { + if (!out_buf_flag[i]) { + return i; + } + } + return -1; +} + + +bool MsmVidc::handleOutput() { + struct v4l2_buffer buf = {0}; + struct v4l2_plane planes[1]; + buf.type = V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE; + buf.memory = V4L2_MEMORY_USERPTR; + buf.m.planes = planes; + buf.length = 1; + util::safe_ioctl(this->fd, VIDIOC_DQBUF, &buf, "VIDIOC_DQBUF OUTPUT failed"); + this->out_buf_flag[buf.index] = false; // mark as not queued + return true; +} + +bool MsmVidc::handleEvent() { + // dequeue event + struct v4l2_event event = {0}; + util::safe_ioctl(this->fd, VIDIOC_DQEVENT, &event, "VIDIOC_DQEVENT failed"); + switch (event.type) { + case V4L2_EVENT_MSM_VIDC_PORT_SETTINGS_CHANGED_INSUFFICIENT: { + unsigned int *ptr = (unsigned int *)event.u.data; + unsigned int height = ptr[0]; + unsigned int width = ptr[1]; + this->w = width; + this->h = height; + LOGD("Port Reconfig received insufficient, new size %ux%u, flushing capture bufs...", width, height); // This is normal + struct v4l2_decoder_cmd dec; + dec.flags = V4L2_QCOM_CMD_FLUSH_CAPTURE; + dec.cmd = V4L2_QCOM_CMD_FLUSH; + util::safe_ioctl(this->fd, VIDIOC_DECODER_CMD, &dec, "VIDIOC_DECODER_CMD FLUSH_CAPTURE failed"); + this->reconfigure_pending = true; + LOGD("Waiting for flush done event to reconfigure capture queue"); + break; + } + + case V4L2_EVENT_MSM_VIDC_FLUSH_DONE: { + unsigned int *ptr = (unsigned int *)event.u.data; + unsigned int flags = ptr[0]; + if (flags & V4L2_QCOM_CMD_FLUSH_CAPTURE) { + if (this->reconfigure_pending) { + this->restartCapture(); + this->reconfigure_pending = false; + } + } + break; + } + default: + break; + } + return true; +} \ No newline at end of file diff --git a/tools/replay/qcom_decoder.h b/tools/replay/qcom_decoder.h new file mode 100644 index 0000000000..1d7522cc70 --- /dev/null +++ b/tools/replay/qcom_decoder.h @@ -0,0 +1,88 @@ +#pragma once + +#include +#include + +#include "msgq/visionipc/visionbuf.h" + +extern "C" { + #include + #include +} + +#define V4L2_EVENT_MSM_VIDC_START (V4L2_EVENT_PRIVATE_START + 0x00001000) +#define V4L2_EVENT_MSM_VIDC_FLUSH_DONE (V4L2_EVENT_MSM_VIDC_START + 1) +#define V4L2_EVENT_MSM_VIDC_PORT_SETTINGS_CHANGED_INSUFFICIENT (V4L2_EVENT_MSM_VIDC_START + 3) +#define V4L2_CID_MPEG_MSM_VIDC_BASE 0x00992000 +#define V4L2_CID_MPEG_VIDC_VIDEO_DPB_COLOR_FORMAT (V4L2_CID_MPEG_MSM_VIDC_BASE + 44) +#define V4L2_CID_MPEG_VIDC_VIDEO_STREAM_OUTPUT_MODE (V4L2_CID_MPEG_MSM_VIDC_BASE + 22) +#define V4L2_QCOM_CMD_FLUSH_CAPTURE (1 << 1) +#define V4L2_QCOM_CMD_FLUSH (4) + +#define VIDEO_DEVICE "/dev/video32" +#define OUTPUT_BUFFER_COUNT 8 +#define CAPTURE_BUFFER_COUNT 8 +#define FPS 20 + + +class MsmVidc { +public: + MsmVidc() = default; + ~MsmVidc(); + + bool init(const char* dev, size_t width, size_t height, uint64_t codec); + VisionBuf* decodeFrame(AVPacket* pkt, VisionBuf* buf); + + AVFormatContext* avctx = nullptr; + int fd = 0; + +private: + bool initialized = false; + bool reconfigure_pending = false; + bool frame_ready = false; + + VisionBuf* current_output_buf = nullptr; + VisionBuf out_buf; // Single input buffer + VisionBuf cap_bufs[CAPTURE_BUFFER_COUNT]; // Capture (output) buffers + + size_t w = 1928, h = 1208; + size_t cap_height = 0, cap_width = 0; + + int cap_buf_size = 0; + int out_buf_size = 0; + + size_t cap_plane_off[CAPTURE_BUFFER_COUNT] = {0}; + size_t cap_plane_stride[CAPTURE_BUFFER_COUNT] = {0}; + bool cap_buf_flag[CAPTURE_BUFFER_COUNT] = {false}; + + size_t out_buf_off[OUTPUT_BUFFER_COUNT] = {0}; + void* out_buf_addr[OUTPUT_BUFFER_COUNT] = {0}; + bool out_buf_flag[OUTPUT_BUFFER_COUNT] = {false}; + const int out_buf_cnt = OUTPUT_BUFFER_COUNT; + + const int subscriptions[2] = { + V4L2_EVENT_MSM_VIDC_FLUSH_DONE, + V4L2_EVENT_MSM_VIDC_PORT_SETTINGS_CHANGED_INSUFFICIENT + }; + + enum { EV_VIDEO, EV_COUNT }; + struct pollfd pfd[EV_COUNT] = {0}; + int ev[EV_COUNT] = {-1}; + int nfds = 0; + + VisionBuf* processEvents(); + bool setupOutput(); + bool subscribeEvents(); + bool setPlaneFormat(v4l2_buf_type type, uint32_t fourcc); + bool setFPS(uint32_t fps); + bool restartCapture(); + bool queueCaptureBuffer(int i); + bool queueOutputBuffer(int i, size_t size); + bool setDBP(); + bool setupPolling(); + bool sendPacket(int buf_index, AVPacket* pkt); + int getBufferUnlocked(); + VisionBuf* handleCapture(); + bool handleOutput(); + bool handleEvent(); +}; diff --git a/tools/sim/lib/simulated_car.py b/tools/sim/lib/simulated_car.py index 2681b26904..68ff3050db 100644 --- a/tools/sim/lib/simulated_car.py +++ b/tools/sim/lib/simulated_car.py @@ -11,7 +11,7 @@ from openpilot.tools.sim.lib.common import SimulatorState class SimulatedCar: """Simulates a honda civic 2022 (panda state + can messages) to OpenPilot""" - packer = CANPacker("honda_civic_ex_2022_can_generated") + packer = CANPacker("honda_bosch_radarless_generated") def __init__(self): self.pm = messaging.PubMaster(['can', 'pandaStates']) @@ -23,7 +23,7 @@ class SimulatedCar: @staticmethod def get_car_can_parser(): - dbc_f = 'honda_civic_ex_2022_can_generated' + dbc_f = 'honda_bosch_radarless_generated' checks = [] return CANParser(dbc_f, checks, 0) diff --git a/uv.lock b/uv.lock index 49ae160993..25d2b626cf 100644 --- a/uv.lock +++ b/uv.lock @@ -451,6 +451,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/fc/c0a3f4c4eaa5a22fbef91713474666e13d0ea2a69c84532579490a9f2cc8/dbus_next-0.2.3-py3-none-any.whl", hash = "sha256:58948f9aff9db08316734c0be2a120f6dc502124d9642f55e90ac82ffb16a18b", size = 57885, upload-time = "2021-07-25T22:11:25.466Z" }, ] +[[package]] +name = "dearpygui" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/fe/66293fc40254a29f060efd3398f2b1001ed79263ae1837db9ec42caa8f1d/dearpygui-2.1.0-cp311-cp311-macosx_10_6_x86_64.whl", hash = "sha256:03e5dc0b3dd2f7965e50bbe41f3316a814408064b582586de994d93afedb125c", size = 2100924, upload-time = "2025-07-07T14:20:00.602Z" }, + { url = "https://files.pythonhosted.org/packages/c4/4d/9fa1c3156ba7bbf4dc89e2e322998752fccfdc3575923a98dd6a4da48911/dearpygui-2.1.0-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:b5b37710c3fa135c48e2347f39ecd1f415146e86db5d404707a0bf72d16bd304", size = 1874441, upload-time = "2025-07-07T14:20:09.165Z" }, + { url = "https://files.pythonhosted.org/packages/5a/3c/af5673b50699e1734296a0b5bcef39bb6989175b001ad1f9b0e7888ad90d/dearpygui-2.1.0-cp311-cp311-manylinux1_x86_64.whl", hash = "sha256:b0cfd7ac7eaa090fc22d6aa60fc4b527fc631cee10c348e4d8df92bb39af03d2", size = 2636574, upload-time = "2025-07-07T14:20:14.951Z" }, + { url = "https://files.pythonhosted.org/packages/7f/db/ed4db0bb3d88e7a8c405472641419086bef9632c4b8b0489dc0c43519c0d/dearpygui-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:a9af54f96d3ef30c5db9d12cdf3266f005507396fb0da2e12e6b22b662161070", size = 1810266, upload-time = "2025-07-07T14:19:51.565Z" }, + { url = "https://files.pythonhosted.org/packages/55/9d/20a55786cc9d9266395544463d5db3be3528f7d5244bc52ba760de5dcc2d/dearpygui-2.1.0-cp312-cp312-macosx_10_6_x86_64.whl", hash = "sha256:1270ceb9cdb8ecc047c42477ccaa075b7864b314a5d09191f9280a24c8aa90a0", size = 2101499, upload-time = "2025-07-07T14:20:01.701Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/39d820796b7ac4d0ebf93306c1f031bf3516b159408286f1fb495c6babeb/dearpygui-2.1.0-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:ce9969eb62057b9d4c88a8baaed13b5fbe4058caa9faf5b19fec89da75aece3d", size = 1874385, upload-time = "2025-07-07T14:20:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/fc/26/c29998ffeb5eb8d638f307851e51a81c8bd4aeaf89ad660fc67ea4d1ac1a/dearpygui-2.1.0-cp312-cp312-manylinux1_x86_64.whl", hash = "sha256:a3ca8cf788db63ef7e2e8d6f277631b607d548b37606f080ca1b42b1f0a9b183", size = 2635863, upload-time = "2025-07-07T14:20:17.186Z" }, + { url = "https://files.pythonhosted.org/packages/28/9c/3ab33927f1d8c839c5b7033a33d44fc9f0aeb00c264fc9772cb7555a03c4/dearpygui-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:43f0e4db9402f44fc3683a1f5c703564819de18cc15a042de7f1ed1c8cb5d148", size = 1810460, upload-time = "2025-07-07T14:19:53.13Z" }, +] + [[package]] name = "dictdiffer" version = "0.9.0" @@ -510,27 +525,27 @@ wheels = [ [[package]] name = "fonttools" -version = "4.59.1" +version = "4.59.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/11/7f/29c9c3fe4246f6ad96fee52b88d0dc3a863c7563b0afc959e36d78b965dc/fonttools-4.59.1.tar.gz", hash = "sha256:74995b402ad09822a4c8002438e54940d9f1ecda898d2bb057729d7da983e4cb", size = 3534394, upload-time = "2025-08-14T16:28:14.266Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/a5/fba25f9fbdab96e26dedcaeeba125e5f05a09043bf888e0305326e55685b/fonttools-4.59.2.tar.gz", hash = "sha256:e72c0749b06113f50bcb80332364c6be83a9582d6e3db3fe0b280f996dc2ef22", size = 3540889, upload-time = "2025-08-27T16:40:30.97Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/34/62/9667599561f623d4a523cc9eb4f66f3b94b6155464110fa9aebbf90bbec7/fonttools-4.59.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4909cce2e35706f3d18c54d3dcce0414ba5e0fb436a454dffec459c61653b513", size = 2778815, upload-time = "2025-08-14T16:26:28.484Z" }, - { url = "https://files.pythonhosted.org/packages/8f/78/cc25bcb2ce86033a9df243418d175e58f1956a35047c685ef553acae67d6/fonttools-4.59.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:efbec204fa9f877641747f2d9612b2b656071390d7a7ef07a9dbf0ecf9c7195c", size = 2341631, upload-time = "2025-08-14T16:26:30.396Z" }, - { url = "https://files.pythonhosted.org/packages/a4/cc/fcbb606dd6871f457ac32f281c20bcd6cc77d9fce77b5a4e2b2afab1f500/fonttools-4.59.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39dfd42cc2dc647b2c5469bc7a5b234d9a49e72565b96dd14ae6f11c2c59ef15", size = 5022222, upload-time = "2025-08-14T16:26:32.447Z" }, - { url = "https://files.pythonhosted.org/packages/61/96/c0b1cf2b74d08eb616a80dbf5564351fe4686147291a25f7dce8ace51eb3/fonttools-4.59.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b11bc177a0d428b37890825d7d025040d591aa833f85f8d8878ed183354f47df", size = 4966512, upload-time = "2025-08-14T16:26:34.621Z" }, - { url = "https://files.pythonhosted.org/packages/a4/26/51ce2e3e0835ffc2562b1b11d1fb9dafd0aca89c9041b64a9e903790a761/fonttools-4.59.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5b9b4c35b3be45e5bc774d3fc9608bbf4f9a8d371103b858c80edbeed31dd5aa", size = 5001645, upload-time = "2025-08-14T16:26:36.876Z" }, - { url = "https://files.pythonhosted.org/packages/36/11/ef0b23f4266349b6d5ccbd1a07b7adc998d5bce925792aa5d1ec33f593e3/fonttools-4.59.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:01158376b8a418a0bae9625c476cebfcfcb5e6761e9d243b219cd58341e7afbb", size = 5113777, upload-time = "2025-08-14T16:26:39.002Z" }, - { url = "https://files.pythonhosted.org/packages/d0/da/b398fe61ef433da0a0472cdb5d4399124f7581ffe1a31b6242c91477d802/fonttools-4.59.1-cp311-cp311-win32.whl", hash = "sha256:cf7c5089d37787387123f1cb8f1793a47c5e1e3d1e4e7bfbc1cc96e0f925eabe", size = 2215076, upload-time = "2025-08-14T16:26:41.196Z" }, - { url = "https://files.pythonhosted.org/packages/94/bd/e2624d06ab94e41c7c77727b2941f1baed7edb647e63503953e6888020c9/fonttools-4.59.1-cp311-cp311-win_amd64.whl", hash = "sha256:c866eef7a0ba320486ade6c32bfc12813d1a5db8567e6904fb56d3d40acc5116", size = 2262779, upload-time = "2025-08-14T16:26:43.483Z" }, - { url = "https://files.pythonhosted.org/packages/ac/fe/6e069cc4cb8881d164a9bd956e9df555bc62d3eb36f6282e43440200009c/fonttools-4.59.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:43ab814bbba5f02a93a152ee61a04182bb5809bd2bc3609f7822e12c53ae2c91", size = 2769172, upload-time = "2025-08-14T16:26:45.729Z" }, - { url = "https://files.pythonhosted.org/packages/b9/98/ec4e03f748fefa0dd72d9d95235aff6fef16601267f4a2340f0e16b9330f/fonttools-4.59.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4f04c3ffbfa0baafcbc550657cf83657034eb63304d27b05cff1653b448ccff6", size = 2337281, upload-time = "2025-08-14T16:26:47.921Z" }, - { url = "https://files.pythonhosted.org/packages/8b/b1/890360a7e3d04a30ba50b267aca2783f4c1364363797e892e78a4f036076/fonttools-4.59.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d601b153e51a5a6221f0d4ec077b6bfc6ac35bfe6c19aeaa233d8990b2b71726", size = 4909215, upload-time = "2025-08-14T16:26:49.682Z" }, - { url = "https://files.pythonhosted.org/packages/8a/ec/2490599550d6c9c97a44c1e36ef4de52d6acf742359eaa385735e30c05c4/fonttools-4.59.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c735e385e30278c54f43a0d056736942023c9043f84ee1021eff9fd616d17693", size = 4951958, upload-time = "2025-08-14T16:26:51.616Z" }, - { url = "https://files.pythonhosted.org/packages/d1/40/bd053f6f7634234a9b9805ff8ae4f32df4f2168bee23cafd1271ba9915a9/fonttools-4.59.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1017413cdc8555dce7ee23720da490282ab7ec1cf022af90a241f33f9a49afc4", size = 4894738, upload-time = "2025-08-14T16:26:53.836Z" }, - { url = "https://files.pythonhosted.org/packages/ac/a1/3cd12a010d288325a7cfcf298a84825f0f9c29b01dee1baba64edfe89257/fonttools-4.59.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5c6d8d773470a5107052874341ed3c487c16ecd179976d81afed89dea5cd7406", size = 5045983, upload-time = "2025-08-14T16:26:56.153Z" }, - { url = "https://files.pythonhosted.org/packages/a2/af/8a2c3f6619cc43cf87951405337cc8460d08a4e717bb05eaa94b335d11dc/fonttools-4.59.1-cp312-cp312-win32.whl", hash = "sha256:2a2d0d33307f6ad3a2086a95dd607c202ea8852fa9fb52af9b48811154d1428a", size = 2203407, upload-time = "2025-08-14T16:26:58.165Z" }, - { url = "https://files.pythonhosted.org/packages/8e/f2/a19b874ddbd3ebcf11d7e25188ef9ac3f68b9219c62263acb34aca8cde05/fonttools-4.59.1-cp312-cp312-win_amd64.whl", hash = "sha256:0b9e4fa7eaf046ed6ac470f6033d52c052481ff7a6e0a92373d14f556f298dc0", size = 2251561, upload-time = "2025-08-14T16:27:00.646Z" }, - { url = "https://files.pythonhosted.org/packages/0f/64/9d606e66d498917cd7a2ff24f558010d42d6fd4576d9dd57f0bd98333f5a/fonttools-4.59.1-py3-none-any.whl", hash = "sha256:647db657073672a8330608970a984d51573557f328030566521bc03415535042", size = 1130094, upload-time = "2025-08-14T16:28:12.048Z" }, + { url = "https://files.pythonhosted.org/packages/f8/53/742fcd750ae0bdc74de4c0ff923111199cc2f90a4ee87aaddad505b6f477/fonttools-4.59.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:511946e8d7ea5c0d6c7a53c4cb3ee48eda9ab9797cd9bf5d95829a398400354f", size = 2774961, upload-time = "2025-08-27T16:38:47.536Z" }, + { url = "https://files.pythonhosted.org/packages/57/2a/976f5f9fa3b4dd911dc58d07358467bec20e813d933bc5d3db1a955dd456/fonttools-4.59.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8e5e2682cf7be766d84f462ba8828d01e00c8751a8e8e7ce12d7784ccb69a30d", size = 2344690, upload-time = "2025-08-27T16:38:49.723Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/b7eefc274fcf370911e292e95565c8253b0b87c82a53919ab3c795a4f50e/fonttools-4.59.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5729e12a982dba3eeae650de48b06f3b9ddb51e9aee2fcaf195b7d09a96250e2", size = 5026910, upload-time = "2025-08-27T16:38:51.904Z" }, + { url = "https://files.pythonhosted.org/packages/69/95/864726eaa8f9d4e053d0c462e64d5830ec7c599cbdf1db9e40f25ca3972e/fonttools-4.59.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c52694eae5d652361d59ecdb5a2246bff7cff13b6367a12da8499e9df56d148d", size = 4971031, upload-time = "2025-08-27T16:38:53.676Z" }, + { url = "https://files.pythonhosted.org/packages/24/4c/b8c4735ebdea20696277c70c79e0de615dbe477834e5a7c2569aa1db4033/fonttools-4.59.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f1bbc23ba1312bd8959896f46f667753b90216852d2a8cfa2d07e0cb234144", size = 5006112, upload-time = "2025-08-27T16:38:55.69Z" }, + { url = "https://files.pythonhosted.org/packages/3b/23/f9ea29c292aa2fc1ea381b2e5621ac436d5e3e0a5dee24ffe5404e58eae8/fonttools-4.59.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1a1bfe5378962825dabe741720885e8b9ae9745ec7ecc4a5ec1f1ce59a6062bf", size = 5117671, upload-time = "2025-08-27T16:38:58.984Z" }, + { url = "https://files.pythonhosted.org/packages/ba/07/cfea304c555bf06e86071ff2a3916bc90f7c07ec85b23bab758d4908c33d/fonttools-4.59.2-cp311-cp311-win32.whl", hash = "sha256:e937790f3c2c18a1cbc7da101550a84319eb48023a715914477d2e7faeaba570", size = 2218157, upload-time = "2025-08-27T16:39:00.75Z" }, + { url = "https://files.pythonhosted.org/packages/d7/de/35d839aa69db737a3f9f3a45000ca24721834d40118652a5775d5eca8ebb/fonttools-4.59.2-cp311-cp311-win_amd64.whl", hash = "sha256:9836394e2f4ce5f9c0a7690ee93bd90aa1adc6b054f1a57b562c5d242c903104", size = 2265846, upload-time = "2025-08-27T16:39:02.453Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3d/1f45db2df51e7bfa55492e8f23f383d372200be3a0ded4bf56a92753dd1f/fonttools-4.59.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:82906d002c349cad647a7634b004825a7335f8159d0d035ae89253b4abf6f3ea", size = 2769711, upload-time = "2025-08-27T16:39:04.423Z" }, + { url = "https://files.pythonhosted.org/packages/29/df/cd236ab32a8abfd11558f296e064424258db5edefd1279ffdbcfd4fd8b76/fonttools-4.59.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a10c1bd7644dc58f8862d8ba0cf9fb7fef0af01ea184ba6ce3f50ab7dfe74d5a", size = 2340225, upload-time = "2025-08-27T16:39:06.143Z" }, + { url = "https://files.pythonhosted.org/packages/98/12/b6f9f964fe6d4b4dd4406bcbd3328821c3de1f909ffc3ffa558fe72af48c/fonttools-4.59.2-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:738f31f23e0339785fd67652a94bc69ea49e413dfdb14dcb8c8ff383d249464e", size = 4912766, upload-time = "2025-08-27T16:39:08.138Z" }, + { url = "https://files.pythonhosted.org/packages/73/78/82bde2f2d2c306ef3909b927363170b83df96171f74e0ccb47ad344563cd/fonttools-4.59.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ec99f9bdfee9cdb4a9172f9e8fd578cce5feb231f598909e0aecf5418da4f25", size = 4955178, upload-time = "2025-08-27T16:39:10.094Z" }, + { url = "https://files.pythonhosted.org/packages/92/77/7de766afe2d31dda8ee46d7e479f35c7d48747e558961489a2d6e3a02bd4/fonttools-4.59.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0476ea74161322e08c7a982f83558a2b81b491509984523a1a540baf8611cc31", size = 4897898, upload-time = "2025-08-27T16:39:12.087Z" }, + { url = "https://files.pythonhosted.org/packages/c5/77/ce0e0b905d62a06415fda9f2b2e109a24a5db54a59502b769e9e297d2242/fonttools-4.59.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:95922a922daa1f77cc72611747c156cfb38030ead72436a2c551d30ecef519b9", size = 5049144, upload-time = "2025-08-27T16:39:13.84Z" }, + { url = "https://files.pythonhosted.org/packages/d9/ea/870d93aefd23fff2e07cbeebdc332527868422a433c64062c09d4d5e7fe6/fonttools-4.59.2-cp312-cp312-win32.whl", hash = "sha256:39ad9612c6a622726a6a130e8ab15794558591f999673f1ee7d2f3d30f6a3e1c", size = 2206473, upload-time = "2025-08-27T16:39:15.854Z" }, + { url = "https://files.pythonhosted.org/packages/61/c4/e44bad000c4a4bb2e9ca11491d266e857df98ab6d7428441b173f0fe2517/fonttools-4.59.2-cp312-cp312-win_amd64.whl", hash = "sha256:980fd7388e461b19a881d35013fec32c713ffea1fc37aef2f77d11f332dfd7da", size = 2254706, upload-time = "2025-08-27T16:39:17.893Z" }, + { url = "https://files.pythonhosted.org/packages/65/a4/d2f7be3c86708912c02571db0b550121caab8cd88a3c0aacb9cfa15ea66e/fonttools-4.59.2-py3-none-any.whl", hash = "sha256:8bd0f759020e87bb5d323e6283914d9bf4ae35a7307dafb2cbd1e379e720ad37", size = 1132315, upload-time = "2025-08-27T16:40:28.984Z" }, ] [[package]] @@ -622,10 +637,10 @@ name = "gymnasium" version = "1.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cloudpickle" }, - { name = "farama-notifications" }, - { name = "numpy" }, - { name = "typing-extensions" }, + { name = "cloudpickle", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "farama-notifications", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "numpy", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "typing-extensions", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fd/17/c2a0e15c2cd5a8e788389b280996db927b923410de676ec5c7b2695e9261/gymnasium-1.2.0.tar.gz", hash = "sha256:344e87561012558f603880baf264ebc97f8a5c997a957b0c9f910281145534b0", size = 821142, upload-time = "2025-06-27T08:21:20.262Z" } wheels = [ @@ -690,6 +705,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" }, ] +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -711,6 +735,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/9e/820c4b086ad01ba7d77369fb8b11470a01fac9b4977f02e18659cf378b6b/json_rpc-1.15.0-py2.py3-none-any.whl", hash = "sha256:4a4668bbbe7116feb4abbd0f54e64a4adcf4b8f648f19ffa0848ad0f6606a9bf", size = 39450, upload-time = "2023-06-11T09:45:47.136Z" }, ] +[[package]] +name = "kaitaistruct" +version = "0.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/04/dd60b9cb65d580ef6cb6eaee975ad1bdd22d46a3f51b07a1e0606710ea88/kaitaistruct-0.10.tar.gz", hash = "sha256:a044dee29173d6afbacf27bcac39daf89b654dd418cfa009ab82d9178a9ae52a", size = 7061, upload-time = "2022-07-09T00:34:06.729Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/bf/88ad23efc08708bda9a2647169828e3553bb2093a473801db61f75356395/kaitaistruct-0.10-py2.py3-none-any.whl", hash = "sha256:a97350919adbf37fda881f75e9365e2fb88d04832b7a4e57106ec70119efb235", size = 7013, upload-time = "2022-07-09T00:34:03.905Z" }, +] + [[package]] name = "kiwisolver" version = "1.4.9" @@ -846,7 +879,7 @@ wheels = [ [[package]] name = "matplotlib" -version = "3.10.5" +version = "3.10.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "contourpy" }, @@ -859,25 +892,25 @@ dependencies = [ { name = "pyparsing" }, { name = "python-dateutil" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/91/f2939bb60b7ebf12478b030e0d7f340247390f402b3b189616aad790c366/matplotlib-3.10.5.tar.gz", hash = "sha256:352ed6ccfb7998a00881692f38b4ca083c691d3e275b4145423704c34c909076", size = 34804044, upload-time = "2025-07-31T18:09:33.805Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a0/59/c3e6453a9676ffba145309a73c462bb407f4400de7de3f2b41af70720a3c/matplotlib-3.10.6.tar.gz", hash = "sha256:ec01b645840dd1996df21ee37f208cd8ba57644779fa20464010638013d3203c", size = 34804264, upload-time = "2025-08-30T00:14:25.137Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/c7/1f2db90a1d43710478bb1e9b57b162852f79234d28e4f48a28cc415aa583/matplotlib-3.10.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:dcfc39c452c6a9f9028d3e44d2d721484f665304857188124b505b2c95e1eecf", size = 8239216, upload-time = "2025-07-31T18:07:51.947Z" }, - { url = "https://files.pythonhosted.org/packages/82/6d/ca6844c77a4f89b1c9e4d481c412e1d1dbabf2aae2cbc5aa2da4a1d6683e/matplotlib-3.10.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:903352681b59f3efbf4546985142a9686ea1d616bb054b09a537a06e4b892ccf", size = 8102130, upload-time = "2025-07-31T18:07:53.65Z" }, - { url = "https://files.pythonhosted.org/packages/1d/1e/5e187a30cc673a3e384f3723e5f3c416033c1d8d5da414f82e4e731128ea/matplotlib-3.10.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:080c3676a56b8ee1c762bcf8fca3fe709daa1ee23e6ef06ad9f3fc17332f2d2a", size = 8666471, upload-time = "2025-07-31T18:07:55.304Z" }, - { url = "https://files.pythonhosted.org/packages/03/c0/95540d584d7d645324db99a845ac194e915ef75011a0d5e19e1b5cee7e69/matplotlib-3.10.5-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b4984d5064a35b6f66d2c11d668565f4389b1119cc64db7a4c1725bc11adffc", size = 9500518, upload-time = "2025-07-31T18:07:57.199Z" }, - { url = "https://files.pythonhosted.org/packages/ba/2e/e019352099ea58b4169adb9c6e1a2ad0c568c6377c2b677ee1f06de2adc7/matplotlib-3.10.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3967424121d3a46705c9fa9bdb0931de3228f13f73d7bb03c999c88343a89d89", size = 9552372, upload-time = "2025-07-31T18:07:59.41Z" }, - { url = "https://files.pythonhosted.org/packages/b7/81/3200b792a5e8b354f31f4101ad7834743ad07b6d620259f2059317b25e4d/matplotlib-3.10.5-cp311-cp311-win_amd64.whl", hash = "sha256:33775bbeb75528555a15ac29396940128ef5613cf9a2d31fb1bfd18b3c0c0903", size = 8100634, upload-time = "2025-07-31T18:08:01.801Z" }, - { url = "https://files.pythonhosted.org/packages/52/46/a944f6f0c1f5476a0adfa501969d229ce5ae60cf9a663be0e70361381f89/matplotlib-3.10.5-cp311-cp311-win_arm64.whl", hash = "sha256:c61333a8e5e6240e73769d5826b9a31d8b22df76c0778f8480baf1b4b01c9420", size = 7978880, upload-time = "2025-07-31T18:08:03.407Z" }, - { url = "https://files.pythonhosted.org/packages/66/1e/c6f6bcd882d589410b475ca1fc22e34e34c82adff519caf18f3e6dd9d682/matplotlib-3.10.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:00b6feadc28a08bd3c65b2894f56cf3c94fc8f7adcbc6ab4516ae1e8ed8f62e2", size = 8253056, upload-time = "2025-07-31T18:08:05.385Z" }, - { url = "https://files.pythonhosted.org/packages/53/e6/d6f7d1b59413f233793dda14419776f5f443bcccb2dfc84b09f09fe05dbe/matplotlib-3.10.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ee98a5c5344dc7f48dc261b6ba5d9900c008fc12beb3fa6ebda81273602cc389", size = 8110131, upload-time = "2025-07-31T18:08:07.293Z" }, - { url = "https://files.pythonhosted.org/packages/66/2b/bed8a45e74957549197a2ac2e1259671cd80b55ed9e1fe2b5c94d88a9202/matplotlib-3.10.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a17e57e33de901d221a07af32c08870ed4528db0b6059dce7d7e65c1122d4bea", size = 8669603, upload-time = "2025-07-31T18:08:09.064Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a7/315e9435b10d057f5e52dfc603cd353167ae28bb1a4e033d41540c0067a4/matplotlib-3.10.5-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97b9d6443419085950ee4a5b1ee08c363e5c43d7176e55513479e53669e88468", size = 9508127, upload-time = "2025-07-31T18:08:10.845Z" }, - { url = "https://files.pythonhosted.org/packages/7f/d9/edcbb1f02ca99165365d2768d517898c22c6040187e2ae2ce7294437c413/matplotlib-3.10.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ceefe5d40807d29a66ae916c6a3915d60ef9f028ce1927b84e727be91d884369", size = 9566926, upload-time = "2025-07-31T18:08:13.186Z" }, - { url = "https://files.pythonhosted.org/packages/3b/d9/6dd924ad5616c97b7308e6320cf392c466237a82a2040381163b7500510a/matplotlib-3.10.5-cp312-cp312-win_amd64.whl", hash = "sha256:c04cba0f93d40e45b3c187c6c52c17f24535b27d545f757a2fffebc06c12b98b", size = 8107599, upload-time = "2025-07-31T18:08:15.116Z" }, - { url = "https://files.pythonhosted.org/packages/0e/f3/522dc319a50f7b0279fbe74f86f7a3506ce414bc23172098e8d2bdf21894/matplotlib-3.10.5-cp312-cp312-win_arm64.whl", hash = "sha256:a41bcb6e2c8e79dc99c5511ae6f7787d2fb52efd3d805fff06d5d4f667db16b2", size = 7978173, upload-time = "2025-07-31T18:08:21.518Z" }, - { url = "https://files.pythonhosted.org/packages/dc/d6/e921be4e1a5f7aca5194e1f016cb67ec294548e530013251f630713e456d/matplotlib-3.10.5-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:160e125da27a749481eaddc0627962990f6029811dbeae23881833a011a0907f", size = 8233224, upload-time = "2025-07-31T18:09:27.512Z" }, - { url = "https://files.pythonhosted.org/packages/ec/74/a2b9b04824b9c349c8f1b2d21d5af43fa7010039427f2b133a034cb09e59/matplotlib-3.10.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ac3d50760394d78a3c9be6b28318fe22b494c4fcf6407e8fd4794b538251899b", size = 8098539, upload-time = "2025-07-31T18:09:29.629Z" }, - { url = "https://files.pythonhosted.org/packages/fc/66/cd29ebc7f6c0d2a15d216fb572573e8fc38bd5d6dec3bd9d7d904c0949f7/matplotlib-3.10.5-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6c49465bf689c4d59d174d0c7795fb42a21d4244d11d70e52b8011987367ac61", size = 8672192, upload-time = "2025-07-31T18:09:31.407Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/5d3665aa44c49005aaacaa68ddea6fcb27345961cd538a98bb0177934ede/matplotlib-3.10.6-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:905b60d1cb0ee604ce65b297b61cf8be9f4e6cfecf95a3fe1c388b5266bc8f4f", size = 8257527, upload-time = "2025-08-30T00:12:45.31Z" }, + { url = "https://files.pythonhosted.org/packages/8c/af/30ddefe19ca67eebd70047dabf50f899eaff6f3c5e6a1a7edaecaf63f794/matplotlib-3.10.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7bac38d816637343e53d7185d0c66677ff30ffb131044a81898b5792c956ba76", size = 8119583, upload-time = "2025-08-30T00:12:47.236Z" }, + { url = "https://files.pythonhosted.org/packages/d3/29/4a8650a3dcae97fa4f375d46efcb25920d67b512186f8a6788b896062a81/matplotlib-3.10.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:942a8de2b5bfff1de31d95722f702e2966b8a7e31f4e68f7cd963c7cd8861cf6", size = 8692682, upload-time = "2025-08-30T00:12:48.781Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d3/b793b9cb061cfd5d42ff0f69d1822f8d5dbc94e004618e48a97a8373179a/matplotlib-3.10.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3276c85370bc0dfca051ec65c5817d1e0f8f5ce1b7787528ec8ed2d524bbc2f", size = 9521065, upload-time = "2025-08-30T00:12:50.602Z" }, + { url = "https://files.pythonhosted.org/packages/f7/c5/53de5629f223c1c66668d46ac2621961970d21916a4bc3862b174eb2a88f/matplotlib-3.10.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9df5851b219225731f564e4b9e7f2ac1e13c9e6481f941b5631a0f8e2d9387ce", size = 9576888, upload-time = "2025-08-30T00:12:52.92Z" }, + { url = "https://files.pythonhosted.org/packages/fc/8e/0a18d6d7d2d0a2e66585032a760d13662e5250c784d53ad50434e9560991/matplotlib-3.10.6-cp311-cp311-win_amd64.whl", hash = "sha256:abb5d9478625dd9c9eb51a06d39aae71eda749ae9b3138afb23eb38824026c7e", size = 8115158, upload-time = "2025-08-30T00:12:54.863Z" }, + { url = "https://files.pythonhosted.org/packages/07/b3/1a5107bb66c261e23b9338070702597a2d374e5aa7004b7adfc754fbed02/matplotlib-3.10.6-cp311-cp311-win_arm64.whl", hash = "sha256:886f989ccfae63659183173bb3fced7fd65e9eb793c3cc21c273add368536951", size = 7992444, upload-time = "2025-08-30T00:12:57.067Z" }, + { url = "https://files.pythonhosted.org/packages/ea/1a/7042f7430055d567cc3257ac409fcf608599ab27459457f13772c2d9778b/matplotlib-3.10.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:31ca662df6a80bd426f871105fdd69db7543e28e73a9f2afe80de7e531eb2347", size = 8272404, upload-time = "2025-08-30T00:12:59.112Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5d/1d5f33f5b43f4f9e69e6a5fe1fb9090936ae7bc8e2ff6158e7a76542633b/matplotlib-3.10.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1678bb61d897bb4ac4757b5ecfb02bfb3fddf7f808000fb81e09c510712fda75", size = 8128262, upload-time = "2025-08-30T00:13:01.141Z" }, + { url = "https://files.pythonhosted.org/packages/67/c3/135fdbbbf84e0979712df58e5e22b4f257b3f5e52a3c4aacf1b8abec0d09/matplotlib-3.10.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:56cd2d20842f58c03d2d6e6c1f1cf5548ad6f66b91e1e48f814e4fb5abd1cb95", size = 8697008, upload-time = "2025-08-30T00:13:03.24Z" }, + { url = "https://files.pythonhosted.org/packages/9c/be/c443ea428fb2488a3ea7608714b1bd85a82738c45da21b447dc49e2f8e5d/matplotlib-3.10.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:662df55604a2f9a45435566d6e2660e41efe83cd94f4288dfbf1e6d1eae4b0bb", size = 9530166, upload-time = "2025-08-30T00:13:05.951Z" }, + { url = "https://files.pythonhosted.org/packages/a9/35/48441422b044d74034aea2a3e0d1a49023f12150ebc58f16600132b9bbaf/matplotlib-3.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:08f141d55148cd1fc870c3387d70ca4df16dee10e909b3b038782bd4bda6ea07", size = 9593105, upload-time = "2025-08-30T00:13:08.356Z" }, + { url = "https://files.pythonhosted.org/packages/45/c3/994ef20eb4154ab84cc08d033834555319e4af970165e6c8894050af0b3c/matplotlib-3.10.6-cp312-cp312-win_amd64.whl", hash = "sha256:590f5925c2d650b5c9d813c5b3b5fc53f2929c3f8ef463e4ecfa7e052044fb2b", size = 8122784, upload-time = "2025-08-30T00:13:10.367Z" }, + { url = "https://files.pythonhosted.org/packages/57/b8/5c85d9ae0e40f04e71bedb053aada5d6bab1f9b5399a0937afb5d6b02d98/matplotlib-3.10.6-cp312-cp312-win_arm64.whl", hash = "sha256:f44c8d264a71609c79a78d50349e724f5d5fc3684ead7c2a473665ee63d868aa", size = 7992823, upload-time = "2025-08-30T00:13:12.24Z" }, + { url = "https://files.pythonhosted.org/packages/12/bb/02c35a51484aae5f49bd29f091286e7af5f3f677a9736c58a92b3c78baeb/matplotlib-3.10.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f2d684c3204fa62421bbf770ddfebc6b50130f9cad65531eeba19236d73bb488", size = 8252296, upload-time = "2025-08-30T00:14:19.49Z" }, + { url = "https://files.pythonhosted.org/packages/7d/85/41701e3092005aee9a2445f5ee3904d9dbd4a7df7a45905ffef29b7ef098/matplotlib-3.10.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:6f4a69196e663a41d12a728fab8751177215357906436804217d6d9cf0d4d6cf", size = 8116749, upload-time = "2025-08-30T00:14:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/16/53/8d8fa0ea32a8c8239e04d022f6c059ee5e1b77517769feccd50f1df43d6d/matplotlib-3.10.6-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d6ca6ef03dfd269f4ead566ec6f3fb9becf8dab146fb999022ed85ee9f6b3eb", size = 8693933, upload-time = "2025-08-30T00:14:22.942Z" }, ] [[package]] @@ -894,22 +927,22 @@ name = "metadrive-simulator" version = "0.4.2.4" source = { url = "https://github.com/commaai/metadrive/releases/download/MetaDrive-minimal-0.4.2.4/metadrive_simulator-0.4.2.4-py3-none-any.whl" } dependencies = [ - { name = "filelock" }, - { name = "gymnasium" }, - { name = "lxml" }, - { name = "matplotlib" }, - { name = "numpy" }, - { name = "opencv-python-headless" }, - { name = "panda3d" }, - { name = "panda3d-gltf" }, - { name = "pillow" }, - { name = "progressbar" }, - { name = "psutil" }, - { name = "pygments" }, - { name = "requests" }, - { name = "shapely" }, - { name = "tqdm" }, - { name = "yapf" }, + { name = "filelock", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "gymnasium", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "lxml", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "matplotlib", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "numpy", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "opencv-python-headless", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "panda3d", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "panda3d-gltf", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "pillow", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "progressbar", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "psutil", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "pygments", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "requests", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "shapely", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "tqdm", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "yapf", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, ] wheels = [ { url = "https://github.com/commaai/metadrive/releases/download/MetaDrive-minimal-0.4.2.4/metadrive_simulator-0.4.2.4-py3-none-any.whl", hash = "sha256:fbf0ea9be67e65cd45d38ff930e3d49f705dd76c9ddbd1e1482e3f87b61efcef" }, @@ -981,6 +1014,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521, upload-time = "2023-11-20T17:51:08.587Z" }, ] +[[package]] +name = "ml-dtypes" +version = "0.5.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/a7/aad060393123cfb383956dca68402aff3db1e1caffd5764887ed5153f41b/ml_dtypes-0.5.3.tar.gz", hash = "sha256:95ce33057ba4d05df50b1f3cfefab22e351868a843b3b15a46c65836283670c9", size = 692316, upload-time = "2025-07-29T18:39:19.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/f1/720cb1409b5d0c05cff9040c0e9fba73fa4c67897d33babf905d5d46a070/ml_dtypes-0.5.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4a177b882667c69422402df6ed5c3428ce07ac2c1f844d8a1314944651439458", size = 667412, upload-time = "2025-07-29T18:38:25.275Z" }, + { url = "https://files.pythonhosted.org/packages/6a/d5/05861ede5d299f6599f86e6bc1291714e2116d96df003cfe23cc54bcc568/ml_dtypes-0.5.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9849ce7267444c0a717c80c6900997de4f36e2815ce34ac560a3edb2d9a64cd2", size = 4964606, upload-time = "2025-07-29T18:38:27.045Z" }, + { url = "https://files.pythonhosted.org/packages/db/dc/72992b68de367741bfab8df3b3fe7c29f982b7279d341aa5bf3e7ef737ea/ml_dtypes-0.5.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3f5ae0309d9f888fd825c2e9d0241102fadaca81d888f26f845bc8c13c1e4ee", size = 4938435, upload-time = "2025-07-29T18:38:29.193Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/d27a930bca31fb07d975a2d7eaf3404f9388114463b9f15032813c98f893/ml_dtypes-0.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:58e39349d820b5702bb6f94ea0cb2dc8ec62ee81c0267d9622067d8333596a46", size = 206334, upload-time = "2025-07-29T18:38:30.687Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d8/6922499effa616012cb8dc445280f66d100a7ff39b35c864cfca019b3f89/ml_dtypes-0.5.3-cp311-cp311-win_arm64.whl", hash = "sha256:66c2756ae6cfd7f5224e355c893cfd617fa2f747b8bbd8996152cbdebad9a184", size = 157584, upload-time = "2025-07-29T18:38:32.187Z" }, + { url = "https://files.pythonhosted.org/packages/0d/eb/bc07c88a6ab002b4635e44585d80fa0b350603f11a2097c9d1bfacc03357/ml_dtypes-0.5.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:156418abeeda48ea4797db6776db3c5bdab9ac7be197c1233771e0880c304057", size = 663864, upload-time = "2025-07-29T18:38:33.777Z" }, + { url = "https://files.pythonhosted.org/packages/cf/89/11af9b0f21b99e6386b6581ab40fb38d03225f9de5f55cf52097047e2826/ml_dtypes-0.5.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1db60c154989af253f6c4a34e8a540c2c9dce4d770784d426945e09908fbb177", size = 4951313, upload-time = "2025-07-29T18:38:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d8/a9/b98b86426c24900b0c754aad006dce2863df7ce0bb2bcc2c02f9cc7e8489/ml_dtypes-0.5.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1b255acada256d1fa8c35ed07b5f6d18bc21d1556f842fbc2d5718aea2cd9e55", size = 4928805, upload-time = "2025-07-29T18:38:38.29Z" }, + { url = "https://files.pythonhosted.org/packages/50/c1/85e6be4fc09c6175f36fb05a45917837f30af9a5146a5151cb3a3f0f9e09/ml_dtypes-0.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:da65e5fd3eea434ccb8984c3624bc234ddcc0d9f4c81864af611aaebcc08a50e", size = 208182, upload-time = "2025-07-29T18:38:39.72Z" }, + { url = "https://files.pythonhosted.org/packages/9e/17/cf5326d6867be057f232d0610de1458f70a8ce7b6290e4b4a277ea62b4cd/ml_dtypes-0.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:8bb9cd1ce63096567f5f42851f5843b5a0ea11511e50039a7649619abfb4ba6d", size = 161560, upload-time = "2025-07-29T18:38:41.072Z" }, +] + [[package]] name = "mouseinfo" version = "0.1.3" @@ -1155,27 +1209,28 @@ wheels = [ [[package]] name = "onnx" -version = "1.18.0" +version = "1.19.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "ml-dtypes" }, { name = "numpy" }, { name = "protobuf" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/60/e56e8ec44ed34006e6d4a73c92a04d9eea6163cc12440e35045aec069175/onnx-1.18.0.tar.gz", hash = "sha256:3d8dbf9e996629131ba3aa1afd1d8239b660d1f830c6688dd7e03157cccd6b9c", size = 12563009, upload-time = "2025-05-12T22:03:09.626Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/bf/b0a63ee9f3759dcd177b28c6f2cb22f2aecc6d9b3efecaabc298883caa5f/onnx-1.19.0.tar.gz", hash = "sha256:aa3f70b60f54a29015e41639298ace06adf1dd6b023b9b30f1bca91bb0db9473", size = 11949859, upload-time = "2025-08-27T02:34:27.107Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/3a/a336dac4db1eddba2bf577191e5b7d3e4c26fcee5ec518a5a5b11d13540d/onnx-1.18.0-cp311-cp311-macosx_12_0_universal2.whl", hash = "sha256:735e06d8d0cf250dc498f54038831401063c655a8d6e5975b2527a4e7d24be3e", size = 18281831, upload-time = "2025-05-12T22:02:06.429Z" }, - { url = "https://files.pythonhosted.org/packages/02/3a/56475a111120d1e5d11939acbcbb17c92198c8e64a205cd68e00bdfd8a1f/onnx-1.18.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:73160799472e1a86083f786fecdf864cf43d55325492a9b5a1cfa64d8a523ecc", size = 17424359, upload-time = "2025-05-12T22:02:09.866Z" }, - { url = "https://files.pythonhosted.org/packages/cf/03/5eb5e9ef446ed9e78c4627faf3c1bc25e0f707116dd00e9811de232a8df5/onnx-1.18.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6acafb3823238bbe8f4340c7ac32fb218689442e074d797bee1c5c9a02fdae75", size = 17586006, upload-time = "2025-05-12T22:02:13.217Z" }, - { url = "https://files.pythonhosted.org/packages/b0/4e/70943125729ce453271a6e46bb847b4a612496f64db6cbc6cb1f49f41ce1/onnx-1.18.0-cp311-cp311-win32.whl", hash = "sha256:4c8c4bbda760c654e65eaffddb1a7de71ec02e60092d33f9000521f897c99be9", size = 15734988, upload-time = "2025-05-12T22:02:16.561Z" }, - { url = "https://files.pythonhosted.org/packages/44/b0/435fd764011911e8f599e3361f0f33425b1004662c1ea33a0ad22e43db2d/onnx-1.18.0-cp311-cp311-win_amd64.whl", hash = "sha256:a5810194f0f6be2e58c8d6dedc6119510df7a14280dd07ed5f0f0a85bd74816a", size = 15849576, upload-time = "2025-05-12T22:02:19.569Z" }, - { url = "https://files.pythonhosted.org/packages/6c/f0/9e31f4b4626d60f1c034f71b411810bc9fafe31f4e7dd3598effd1b50e05/onnx-1.18.0-cp311-cp311-win_arm64.whl", hash = "sha256:aa1b7483fac6cdec26922174fc4433f8f5c2f239b1133c5625063bb3b35957d0", size = 15822961, upload-time = "2025-05-12T22:02:22.735Z" }, - { url = "https://files.pythonhosted.org/packages/a7/fe/16228aca685392a7114625b89aae98b2dc4058a47f0f467a376745efe8d0/onnx-1.18.0-cp312-cp312-macosx_12_0_universal2.whl", hash = "sha256:521bac578448667cbb37c50bf05b53c301243ede8233029555239930996a625b", size = 18285770, upload-time = "2025-05-12T22:02:26.116Z" }, - { url = "https://files.pythonhosted.org/packages/1e/77/ba50a903a9b5e6f9be0fa50f59eb2fca4a26ee653375408fbc72c3acbf9f/onnx-1.18.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4da451bf1c5ae381f32d430004a89f0405bc57a8471b0bddb6325a5b334aa40", size = 17421291, upload-time = "2025-05-12T22:02:29.645Z" }, - { url = "https://files.pythonhosted.org/packages/11/23/25ec2ba723ac62b99e8fed6d7b59094dadb15e38d4c007331cc9ae3dfa5f/onnx-1.18.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99afac90b4cdb1471432203c3c1f74e16549c526df27056d39f41a9a47cfb4af", size = 17584084, upload-time = "2025-05-12T22:02:32.789Z" }, - { url = "https://files.pythonhosted.org/packages/6a/4d/2c253a36070fb43f340ff1d2c450df6a9ef50b938adcd105693fee43c4ee/onnx-1.18.0-cp312-cp312-win32.whl", hash = "sha256:ee159b41a3ae58d9c7341cf432fc74b96aaf50bd7bb1160029f657b40dc69715", size = 15734892, upload-time = "2025-05-12T22:02:35.527Z" }, - { url = "https://files.pythonhosted.org/packages/e8/92/048ba8fafe6b2b9a268ec2fb80def7e66c0b32ab2cae74de886981f05a27/onnx-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:102c04edc76b16e9dfeda5a64c1fccd7d3d2913b1544750c01d38f1ac3c04e05", size = 15850336, upload-time = "2025-05-12T22:02:38.545Z" }, - { url = "https://files.pythonhosted.org/packages/a1/66/bbc4ffedd44165dcc407a51ea4c592802a5391ce3dc94aa5045350f64635/onnx-1.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:911b37d724a5d97396f3c2ef9ea25361c55cbc9aa18d75b12a52b620b67145af", size = 15823802, upload-time = "2025-05-12T22:02:42.037Z" }, + { url = "https://files.pythonhosted.org/packages/db/5c/b959b17608cfb6ccf6359b39fe56a5b0b7d965b3d6e6a3c0add90812c36e/onnx-1.19.0-cp311-cp311-macosx_12_0_universal2.whl", hash = "sha256:206f00c47b85b5c7af79671e3307147407991a17994c26974565aadc9e96e4e4", size = 18312580, upload-time = "2025-08-27T02:33:03.081Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ee/ac052bbbc832abe0debb784c2c57f9582444fb5f51d63c2967fd04432444/onnx-1.19.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4d7bee94abaac28988b50da675ae99ef8dd3ce16210d591fbd0b214a5930beb3", size = 18029165, upload-time = "2025-08-27T02:33:05.771Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c9/8687ba0948d46fd61b04e3952af9237883bbf8f16d716e7ed27e688d73b8/onnx-1.19.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7730b96b68c0c354bbc7857961bb4909b9aaa171360a8e3708d0a4c749aaadeb", size = 18202125, upload-time = "2025-08-27T02:33:09.325Z" }, + { url = "https://files.pythonhosted.org/packages/e2/16/6249c013e81bd689f46f96c7236d7677f1af5dd9ef22746716b48f10e506/onnx-1.19.0-cp311-cp311-win32.whl", hash = "sha256:7cb7a3ad8059d1a0dfdc5e0a98f71837d82002e441f112825403b137227c2c97", size = 16332738, upload-time = "2025-08-27T02:33:12.448Z" }, + { url = "https://files.pythonhosted.org/packages/6a/28/34a1e2166e418c6a78e5c82e66f409d9da9317832f11c647f7d4e23846a6/onnx-1.19.0-cp311-cp311-win_amd64.whl", hash = "sha256:d75452a9be868bd30c3ef6aa5991df89bbfe53d0d90b2325c5e730fbd91fff85", size = 16452303, upload-time = "2025-08-27T02:33:15.176Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b7/639664626e5ba8027860c4d2a639ee02b37e9c322215c921e9222513c3aa/onnx-1.19.0-cp311-cp311-win_arm64.whl", hash = "sha256:23c7959370d7b3236f821e609b0af7763cff7672a758e6c1fc877bac099e786b", size = 16425340, upload-time = "2025-08-27T02:33:17.78Z" }, + { url = "https://files.pythonhosted.org/packages/0d/94/f56f6ca5e2f921b28c0f0476705eab56486b279f04e1d568ed64c14e7764/onnx-1.19.0-cp312-cp312-macosx_12_0_universal2.whl", hash = "sha256:61d94e6498ca636756f8f4ee2135708434601b2892b7c09536befb19bc8ca007", size = 18322331, upload-time = "2025-08-27T02:33:20.373Z" }, + { url = "https://files.pythonhosted.org/packages/c8/00/8cc3f3c40b54b28f96923380f57c9176872e475face726f7d7a78bd74098/onnx-1.19.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:224473354462f005bae985c72028aaa5c85ab11de1b71d55b06fdadd64a667dd", size = 18027513, upload-time = "2025-08-27T02:33:23.44Z" }, + { url = "https://files.pythonhosted.org/packages/61/90/17c4d2566fd0117a5e412688c9525f8950d467f477fbd574e6b32bc9cb8d/onnx-1.19.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ae475c85c89bc4d1f16571006fd21a3e7c0e258dd2c091f6e8aafb083d1ed9b", size = 18202278, upload-time = "2025-08-27T02:33:26.103Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6e/a9383d9cf6db4ac761a129b081e9fa5d0cd89aad43cf1e3fc6285b915c7d/onnx-1.19.0-cp312-cp312-win32.whl", hash = "sha256:323f6a96383a9cdb3960396cffea0a922593d221f3929b17312781e9f9b7fb9f", size = 16333080, upload-time = "2025-08-27T02:33:28.559Z" }, + { url = "https://files.pythonhosted.org/packages/a7/2e/3ff480a8c1fa7939662bdc973e41914add2d4a1f2b8572a3c39c2e4982e5/onnx-1.19.0-cp312-cp312-win_amd64.whl", hash = "sha256:50220f3499a499b1a15e19451a678a58e22ad21b34edf2c844c6ef1d9febddc2", size = 16453927, upload-time = "2025-08-27T02:33:31.177Z" }, + { url = "https://files.pythonhosted.org/packages/57/37/ad500945b1b5c154fe9d7b826b30816ebd629d10211ea82071b5bcc30aa4/onnx-1.19.0-cp312-cp312-win_arm64.whl", hash = "sha256:efb768299580b786e21abe504e1652ae6189f0beed02ab087cd841cb4bb37e43", size = 16426022, upload-time = "2025-08-27T02:33:33.515Z" }, ] [[package]] @@ -1206,9 +1261,11 @@ dependencies = [ { name = "cffi" }, { name = "crcmod" }, { name = "cython" }, + { name = "dearpygui" }, { name = "future-fstrings" }, { name = "inputs" }, { name = "json-rpc" }, + { name = "kaitaistruct" }, { name = "libusb1" }, { name = "numpy" }, { name = "onnx" }, @@ -1243,6 +1300,7 @@ dev = [ { name = "azure-storage-blob" }, { name = "dbus-next" }, { name = "dictdiffer" }, + { name = "jeepney" }, { name = "matplotlib" }, { name = "opencv-python-headless" }, { name = "parameterized" }, @@ -1295,12 +1353,15 @@ requires-dist = [ { name = "crcmod" }, { name = "cython" }, { name = "dbus-next", marker = "extra == 'dev'" }, + { name = "dearpygui", specifier = ">=2.1.0" }, { name = "dictdiffer", marker = "extra == 'dev'" }, { name = "future-fstrings" }, { name = "hypothesis", marker = "extra == 'testing'", specifier = "==6.47.*" }, { name = "inputs" }, + { name = "jeepney", marker = "extra == 'dev'" }, { name = "jinja2", marker = "extra == 'docs'" }, { name = "json-rpc" }, + { name = "kaitaistruct" }, { name = "libusb1" }, { name = "matplotlib", marker = "extra == 'dev'" }, { name = "metadrive-simulator", marker = "platform_machine != 'aarch64' and extra == 'tools'", url = "https://github.com/commaai/metadrive/releases/download/MetaDrive-minimal-0.4.2.4/metadrive_simulator-0.4.2.4-py3-none-any.whl" }, @@ -1389,8 +1450,8 @@ name = "panda3d-gltf" version = "0.13" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "panda3d" }, - { name = "panda3d-simplepbr" }, + { name = "panda3d", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "panda3d-simplepbr", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/07/7f/9f18fc3fa843a080acb891af6bcc12262e7bdf1d194a530f7042bebfc81f/panda3d-gltf-0.13.tar.gz", hash = "sha256:d06d373bdd91cf530909b669f43080e599463bbf6d3ef00c3558bad6c6b19675", size = 25573, upload-time = "2021-05-21T05:46:32.738Z" } wheels = [ @@ -1402,8 +1463,8 @@ name = "panda3d-simplepbr" version = "0.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "panda3d" }, - { name = "typing-extensions" }, + { name = "panda3d", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "typing-extensions", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0d/be/c4d1ded04c22b357277cf6e6a44c1ab4abb285a700bd1991460460e05b99/panda3d_simplepbr-0.13.1.tar.gz", hash = "sha256:c83766d7c8f47499f365a07fe1dff078fc8b3054c2689bdc8dceabddfe7f1a35", size = 6216055, upload-time = "2025-03-30T16:57:41.087Z" } wheels = [ @@ -1467,11 +1528,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.3.8" +version = "4.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362, upload-time = "2025-05-07T22:47:42.121Z" } +sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" }, + { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" }, ] [[package]] @@ -4140,9 +4201,9 @@ name = "pyopencl" version = "2025.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, - { name = "platformdirs" }, - { name = "pytools" }, + { name = "numpy", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "platformdirs", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "pytools", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/28/88/0ac460d3e2def08b2ad6345db6a13613815f616bbbd60c6f4bdf774f4c41/pyopencl-2025.1.tar.gz", hash = "sha256:0116736d7f7920f87b8db4b66a03f27b1d930d2e37ddd14518407cc22dd24779", size = 422510, upload-time = "2025-01-22T00:16:58.421Z" } wheels = [ @@ -4318,7 +4379,7 @@ wheels = [ [[package]] name = "pytest-xdist" -version = "3.7.1.dev24+g2b4372bd6" +version = "3.7.1.dev24+g2b4372b" source = { git = "https://github.com/sshane/pytest-xdist?rev=2b4372bd62699fb412c4fe2f95bf9f01bd2018da#2b4372bd62699fb412c4fe2f95bf9f01bd2018da" } dependencies = [ { name = "execnet" }, @@ -4360,9 +4421,9 @@ name = "pytools" version = "2024.1.10" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "platformdirs" }, - { name = "siphash24" }, - { name = "typing-extensions" }, + { name = "platformdirs", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "siphash24", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "typing-extensions", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ee/0f/56e109c0307f831b5d598ad73976aaaa84b4d0e98da29a642e797eaa940c/pytools-2024.1.10.tar.gz", hash = "sha256:9af6f4b045212c49be32bb31fe19606c478ee4b09631886d05a32459f4ce0a12", size = 81741, upload-time = "2024-07-17T18:47:38.287Z" } wheels = [ @@ -4594,28 +4655,28 @@ wheels = [ [[package]] name = "ruff" -version = "0.12.10" +version = "0.12.11" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3b/eb/8c073deb376e46ae767f4961390d17545e8535921d2f65101720ed8bd434/ruff-0.12.10.tar.gz", hash = "sha256:189ab65149d11ea69a2d775343adf5f49bb2426fc4780f65ee33b423ad2e47f9", size = 5310076, upload-time = "2025-08-21T18:23:22.595Z" } +sdist = { url = "https://files.pythonhosted.org/packages/de/55/16ab6a7d88d93001e1ae4c34cbdcfb376652d761799459ff27c1dc20f6fa/ruff-0.12.11.tar.gz", hash = "sha256:c6b09ae8426a65bbee5425b9d0b82796dbb07cb1af045743c79bfb163001165d", size = 5347103, upload-time = "2025-08-28T13:59:08.87Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/24/e7/560d049d15585d6c201f9eeacd2fd130def3741323e5ccf123786e0e3c95/ruff-0.12.10-py3-none-linux_armv6l.whl", hash = "sha256:8b593cb0fb55cc8692dac7b06deb29afda78c721c7ccfed22db941201b7b8f7b", size = 11935161, upload-time = "2025-08-21T18:22:26.965Z" }, - { url = "https://files.pythonhosted.org/packages/d1/b0/ad2464922a1113c365d12b8f80ed70fcfb39764288ac77c995156080488d/ruff-0.12.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ebb7333a45d56efc7c110a46a69a1b32365d5c5161e7244aaf3aa20ce62399c1", size = 12660884, upload-time = "2025-08-21T18:22:30.925Z" }, - { url = "https://files.pythonhosted.org/packages/d7/f1/97f509b4108d7bae16c48389f54f005b62ce86712120fd8b2d8e88a7cb49/ruff-0.12.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d59e58586829f8e4a9920788f6efba97a13d1fa320b047814e8afede381c6839", size = 11872754, upload-time = "2025-08-21T18:22:34.035Z" }, - { url = "https://files.pythonhosted.org/packages/12/ad/44f606d243f744a75adc432275217296095101f83f966842063d78eee2d3/ruff-0.12.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:822d9677b560f1fdeab69b89d1f444bf5459da4aa04e06e766cf0121771ab844", size = 12092276, upload-time = "2025-08-21T18:22:36.764Z" }, - { url = "https://files.pythonhosted.org/packages/06/1f/ed6c265e199568010197909b25c896d66e4ef2c5e1c3808caf461f6f3579/ruff-0.12.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:37b4a64f4062a50c75019c61c7017ff598cb444984b638511f48539d3a1c98db", size = 11734700, upload-time = "2025-08-21T18:22:39.822Z" }, - { url = "https://files.pythonhosted.org/packages/63/c5/b21cde720f54a1d1db71538c0bc9b73dee4b563a7dd7d2e404914904d7f5/ruff-0.12.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2c6f4064c69d2542029b2a61d39920c85240c39837599d7f2e32e80d36401d6e", size = 13468783, upload-time = "2025-08-21T18:22:42.559Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/39369e6ac7f2a1848f22fb0b00b690492f20811a1ac5c1fd1d2798329263/ruff-0.12.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:059e863ea3a9ade41407ad71c1de2badfbe01539117f38f763ba42a1206f7559", size = 14436642, upload-time = "2025-08-21T18:22:45.612Z" }, - { url = "https://files.pythonhosted.org/packages/e3/03/5da8cad4b0d5242a936eb203b58318016db44f5c5d351b07e3f5e211bb89/ruff-0.12.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bef6161e297c68908b7218fa6e0e93e99a286e5ed9653d4be71e687dff101cf", size = 13859107, upload-time = "2025-08-21T18:22:48.886Z" }, - { url = "https://files.pythonhosted.org/packages/19/19/dd7273b69bf7f93a070c9cec9494a94048325ad18fdcf50114f07e6bf417/ruff-0.12.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4f1345fbf8fb0531cd722285b5f15af49b2932742fc96b633e883da8d841896b", size = 12886521, upload-time = "2025-08-21T18:22:51.567Z" }, - { url = "https://files.pythonhosted.org/packages/c0/1d/b4207ec35e7babaee62c462769e77457e26eb853fbdc877af29417033333/ruff-0.12.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f68433c4fbc63efbfa3ba5db31727db229fa4e61000f452c540474b03de52a9", size = 13097528, upload-time = "2025-08-21T18:22:54.609Z" }, - { url = "https://files.pythonhosted.org/packages/ff/00/58f7b873b21114456e880b75176af3490d7a2836033779ca42f50de3b47a/ruff-0.12.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:141ce3d88803c625257b8a6debf4a0473eb6eed9643a6189b68838b43e78165a", size = 13080443, upload-time = "2025-08-21T18:22:57.413Z" }, - { url = "https://files.pythonhosted.org/packages/12/8c/9e6660007fb10189ccb78a02b41691288038e51e4788bf49b0a60f740604/ruff-0.12.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f3fc21178cd44c98142ae7590f42ddcb587b8e09a3b849cbc84edb62ee95de60", size = 11896759, upload-time = "2025-08-21T18:23:00.473Z" }, - { url = "https://files.pythonhosted.org/packages/67/4c/6d092bb99ea9ea6ebda817a0e7ad886f42a58b4501a7e27cd97371d0ba54/ruff-0.12.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7d1a4e0bdfafcd2e3e235ecf50bf0176f74dd37902f241588ae1f6c827a36c56", size = 11701463, upload-time = "2025-08-21T18:23:03.211Z" }, - { url = "https://files.pythonhosted.org/packages/59/80/d982c55e91df981f3ab62559371380616c57ffd0172d96850280c2b04fa8/ruff-0.12.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e67d96827854f50b9e3e8327b031647e7bcc090dbe7bb11101a81a3a2cbf1cc9", size = 12691603, upload-time = "2025-08-21T18:23:06.935Z" }, - { url = "https://files.pythonhosted.org/packages/ad/37/63a9c788bbe0b0850611669ec6b8589838faf2f4f959647f2d3e320383ae/ruff-0.12.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ae479e1a18b439c59138f066ae79cc0f3ee250712a873d00dbafadaad9481e5b", size = 13164356, upload-time = "2025-08-21T18:23:10.225Z" }, - { url = "https://files.pythonhosted.org/packages/47/d4/1aaa7fb201a74181989970ebccd12f88c0fc074777027e2a21de5a90657e/ruff-0.12.10-py3-none-win32.whl", hash = "sha256:9de785e95dc2f09846c5e6e1d3a3d32ecd0b283a979898ad427a9be7be22b266", size = 11896089, upload-time = "2025-08-21T18:23:14.232Z" }, - { url = "https://files.pythonhosted.org/packages/ad/14/2ad38fd4037daab9e023456a4a40ed0154e9971f8d6aed41bdea390aabd9/ruff-0.12.10-py3-none-win_amd64.whl", hash = "sha256:7837eca8787f076f67aba2ca559cefd9c5cbc3a9852fd66186f4201b87c1563e", size = 13004616, upload-time = "2025-08-21T18:23:17.422Z" }, - { url = "https://files.pythonhosted.org/packages/24/3c/21cf283d67af33a8e6ed242396863af195a8a6134ec581524fd22b9811b6/ruff-0.12.10-py3-none-win_arm64.whl", hash = "sha256:cc138cc06ed9d4bfa9d667a65af7172b47840e1a98b02ce7011c391e54635ffc", size = 12074225, upload-time = "2025-08-21T18:23:20.137Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a2/3b3573e474de39a7a475f3fbaf36a25600bfeb238e1a90392799163b64a0/ruff-0.12.11-py3-none-linux_armv6l.whl", hash = "sha256:93fce71e1cac3a8bf9200e63a38ac5c078f3b6baebffb74ba5274fb2ab276065", size = 11979885, upload-time = "2025-08-28T13:58:26.654Z" }, + { url = "https://files.pythonhosted.org/packages/76/e4/235ad6d1785a2012d3ded2350fd9bc5c5af8c6f56820e696b0118dfe7d24/ruff-0.12.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b8e33ac7b28c772440afa80cebb972ffd823621ded90404f29e5ab6d1e2d4b93", size = 12742364, upload-time = "2025-08-28T13:58:30.256Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0d/15b72c5fe6b1e402a543aa9d8960e0a7e19dfb079f5b0b424db48b7febab/ruff-0.12.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d69fb9d4937aa19adb2e9f058bc4fbfe986c2040acb1a4a9747734834eaa0bfd", size = 11920111, upload-time = "2025-08-28T13:58:33.677Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/f66339d7893798ad3e17fa5a1e587d6fd9806f7c1c062b63f8b09dda6702/ruff-0.12.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:411954eca8464595077a93e580e2918d0a01a19317af0a72132283e28ae21bee", size = 12160060, upload-time = "2025-08-28T13:58:35.74Z" }, + { url = "https://files.pythonhosted.org/packages/03/69/9870368326db26f20c946205fb2d0008988aea552dbaec35fbacbb46efaa/ruff-0.12.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6a2c0a2e1a450f387bf2c6237c727dd22191ae8c00e448e0672d624b2bbd7fb0", size = 11799848, upload-time = "2025-08-28T13:58:38.051Z" }, + { url = "https://files.pythonhosted.org/packages/25/8c/dd2c7f990e9b3a8a55eee09d4e675027d31727ce33cdb29eab32d025bdc9/ruff-0.12.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ca4c3a7f937725fd2413c0e884b5248a19369ab9bdd850b5781348ba283f644", size = 13536288, upload-time = "2025-08-28T13:58:40.046Z" }, + { url = "https://files.pythonhosted.org/packages/7a/30/d5496fa09aba59b5e01ea76775a4c8897b13055884f56f1c35a4194c2297/ruff-0.12.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4d1df0098124006f6a66ecf3581a7f7e754c4df7644b2e6704cd7ca80ff95211", size = 14490633, upload-time = "2025-08-28T13:58:42.285Z" }, + { url = "https://files.pythonhosted.org/packages/9b/2f/81f998180ad53445d403c386549d6946d0748e536d58fce5b5e173511183/ruff-0.12.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a8dd5f230efc99a24ace3b77e3555d3fbc0343aeed3fc84c8d89e75ab2ff793", size = 13888430, upload-time = "2025-08-28T13:58:44.641Z" }, + { url = "https://files.pythonhosted.org/packages/87/71/23a0d1d5892a377478c61dbbcffe82a3476b050f38b5162171942a029ef3/ruff-0.12.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4dc75533039d0ed04cd33fb8ca9ac9620b99672fe7ff1533b6402206901c34ee", size = 12913133, upload-time = "2025-08-28T13:58:47.039Z" }, + { url = "https://files.pythonhosted.org/packages/80/22/3c6cef96627f89b344c933781ed38329bfb87737aa438f15da95907cbfd5/ruff-0.12.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4fc58f9266d62c6eccc75261a665f26b4ef64840887fc6cbc552ce5b29f96cc8", size = 13169082, upload-time = "2025-08-28T13:58:49.157Z" }, + { url = "https://files.pythonhosted.org/packages/05/b5/68b3ff96160d8b49e8dd10785ff3186be18fd650d356036a3770386e6c7f/ruff-0.12.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5a0113bd6eafd545146440225fe60b4e9489f59eb5f5f107acd715ba5f0b3d2f", size = 13139490, upload-time = "2025-08-28T13:58:51.593Z" }, + { url = "https://files.pythonhosted.org/packages/59/b9/050a3278ecd558f74f7ee016fbdf10591d50119df8d5f5da45a22c6afafc/ruff-0.12.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0d737b4059d66295c3ea5720e6efc152623bb83fde5444209b69cd33a53e2000", size = 11958928, upload-time = "2025-08-28T13:58:53.943Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bc/93be37347db854806904a43b0493af8d6873472dfb4b4b8cbb27786eb651/ruff-0.12.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:916fc5defee32dbc1fc1650b576a8fed68f5e8256e2180d4d9855aea43d6aab2", size = 11764513, upload-time = "2025-08-28T13:58:55.976Z" }, + { url = "https://files.pythonhosted.org/packages/7a/a1/1471751e2015a81fd8e166cd311456c11df74c7e8769d4aabfbc7584c7ac/ruff-0.12.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c984f07d7adb42d3ded5be894fb4007f30f82c87559438b4879fe7aa08c62b39", size = 12745154, upload-time = "2025-08-28T13:58:58.16Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/2542b14890d0f4872dd81b7b2a6aed3ac1786fae1ce9b17e11e6df9e31e3/ruff-0.12.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e07fbb89f2e9249f219d88331c833860489b49cdf4b032b8e4432e9b13e8a4b9", size = 13227653, upload-time = "2025-08-28T13:59:00.276Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/2fbfc61047dbfd009c58a28369a693a1484ad15441723be1cd7fe69bb679/ruff-0.12.11-py3-none-win32.whl", hash = "sha256:c792e8f597c9c756e9bcd4d87cf407a00b60af77078c96f7b6366ea2ce9ba9d3", size = 11944270, upload-time = "2025-08-28T13:59:02.347Z" }, + { url = "https://files.pythonhosted.org/packages/08/a5/34276984705bfe069cd383101c45077ee029c3fe3b28225bf67aa35f0647/ruff-0.12.11-py3-none-win_amd64.whl", hash = "sha256:a3283325960307915b6deb3576b96919ee89432ebd9c48771ca12ee8afe4a0fd", size = 13046600, upload-time = "2025-08-28T13:59:04.751Z" }, + { url = "https://files.pythonhosted.org/packages/84/a8/001d4a7c2b37623a3fd7463208267fb906df40ff31db496157549cfd6e72/ruff-0.12.11-py3-none-win_arm64.whl", hash = "sha256:bae4d6e6a2676f8fb0f98b74594a048bae1b944aab17e9f5d504062303c6dbea", size = 12135290, upload-time = "2025-08-28T13:59:06.933Z" }, ] [[package]] @@ -4629,15 +4690,15 @@ wheels = [ [[package]] name = "sentry-sdk" -version = "2.35.0" +version = "2.35.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/31/83/055dc157b719651ef13db569bb8cf2103df11174478649735c1b2bf3f6bc/sentry_sdk-2.35.0.tar.gz", hash = "sha256:5ea58d352779ce45d17bc2fa71ec7185205295b83a9dbb5707273deb64720092", size = 343014, upload-time = "2025-08-14T17:11:20.223Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/79/0ecb942f3f1ad26c40c27f81ff82392d85c01d26a45e3c72c2b37807e680/sentry_sdk-2.35.2.tar.gz", hash = "sha256:e9e8f3c795044beb59f2c8f4c6b9b0f9779e5e604099882df05eec525e782cc6", size = 343377, upload-time = "2025-09-01T11:00:58.633Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/36/3d/742617a7c644deb0c1628dcf6bb2d2165ab7c6aab56fe5222758994007f8/sentry_sdk-2.35.0-py2.py3-none-any.whl", hash = "sha256:6e0c29b9a5d34de8575ffb04d289a987ff3053cf2c98ede445bea995e3830263", size = 363806, upload-time = "2025-08-14T17:11:18.29Z" }, + { url = "https://files.pythonhosted.org/packages/c0/91/a43308dc82a0e32d80cd0dfdcfca401ecbd0f431ab45f24e48bb97b7800d/sentry_sdk-2.35.2-py2.py3-none-any.whl", hash = "sha256:38c98e3cbb620dd3dd80a8d6e39c753d453dd41f8a9df581b0584c19a52bc926", size = 363975, upload-time = "2025-09-01T11:00:56.574Z" }, ] [[package]] @@ -4686,7 +4747,7 @@ name = "shapely" version = "2.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, + { name = "numpy", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ca/3c/2da625233f4e605155926566c0e7ea8dda361877f48e8b1655e53456f252/shapely-2.1.1.tar.gz", hash = "sha256:500621967f2ffe9642454808009044c21e5b35db89ce69f8a2042c2ffd0e2772", size = 315422, upload-time = "2025-05-19T11:04:41.265Z" } wheels = [ @@ -4832,11 +4893,11 @@ wheels = [ [[package]] name = "typing-extensions" -version = "4.14.1" +version = "4.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" }, + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] [[package]] @@ -4915,7 +4976,7 @@ name = "yapf" version = "0.43.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "platformdirs" }, + { name = "platformdirs", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/23/97/b6f296d1e9cc1ec25c7604178b48532fa5901f721bcf1b8d8148b13e5588/yapf-0.43.0.tar.gz", hash = "sha256:00d3aa24bfedff9420b2e0d5d9f5ab6d9d4268e72afbf59bb3fa542781d5218e", size = 254907, upload-time = "2024-11-14T00:11:41.584Z" } wheels = [