Compare commits

..

79 Commits

Author SHA1 Message Date
royjr 965bfa9653 Merge branch 'master' into laneful-curves 2026-10-27 14:40:25 -04:00
amzoo db27a433fc Toyota TSS2: custom longitudinal tuning (#1774)
* TSS2-Smooth: add param, flag injection, UI toggle, and metadata

- Register TSS2-Smooth param in params_keys.h
- Add TSS2-Smooth to toyota keys in initialize_params()
- Update opendbc submodule to use CP_SP flag instead of direct Params import
- Add TSS2 Smooth Longitudinal toggle to Toyota settings UI
- Disable toggle when stock longitudinal is enforced
- Add TSS2-Smooth entry to params_metadata.json

* toyota: rework TSS2 smooth param and toggle

* bump opendbc

---------

Co-authored-by: Jason Wen <haibin.wen3@gmail.com>
2026-10-27 00:43:20 -04:00
discountchubbs 591a09b496 small regression test 2026-10-26 13:29:45 -07:00
discountchubbs bec376c25b ci 2026-10-26 12:28:51 -07:00
James Vecellio-Grant bf212e2386 more accurate 2026-10-26 12:21:43 -07:00
James Vecellio-Grant d6578461bd better 2026-10-26 12:21:13 -07:00
James Vecellio-Grant c131d7ec0b reduce 2026-10-26 12:19:57 -07:00
James Vecellio-Grant aeaa51bd1e more 2026-10-26 12:18:31 -07:00
James Vecellio-Grant faa1fd6a50 more accurate 2026-10-26 12:17:05 -07:00
nayan 69acd8bbee add custom model gate & Alpha tag 2026-10-26 15:09:50 -04:00
discountchubbs ddfe022d74 early return 2026-10-25 20:52:33 -07:00
James Vecellio-Grant ebff0645cf Merge branch 'master' into laneful-curves 2026-10-25 20:20:55 -07:00
Jason Wen a06366ce92 controls: decouple RELC from BSM blocking, allow nudge override (#2049)
* controls: decouple RELC from BSM blocking, allow nudge override

* nah
2026-10-25 22:48:26 -04:00
James Vecellio-Grant a36de9b7d1 Merge branch 'master' into laneful-curves 2026-10-25 19:46:11 -07:00
discountchubbs 11fce55412 rm 2026-10-25 19:44:37 -07:00
nitrobass24 861fd118a1 ci: scons cache test runs (#2074) 2026-10-25 17:23:33 -07:00
royjr 99700587d8 hold blue 2026-10-25 08:52:19 -04:00
discountchubbs c2f980f769 oop 2026-10-24 11:31:06 -07:00
discountchubbs e063ce146e force tg 2026-10-24 11:25:51 -07:00
discountchubbs 10b508929d smoother 2026-10-23 23:17:33 -07:00
discountchubbs 9d98fe4376 he does not like 1ms 2026-10-23 11:56:25 -07:00
discountchubbs 0ce9fca933 decay one line and give full authority in curvy curves; kappa=curv 2026-10-23 11:55:31 -07:00
James Vecellio-Grant 2110ffab00 Merge branch 'master' into laneful-curves 2026-10-23 00:05:23 -07:00
discountchubbs cda505ea6d satisfy royjr 2026-10-22 23:54:18 -07:00
discountchubbs aa559e48a2 smoother release authority 2026-10-22 23:52:10 -07:00
royjr 02c7b60600 fine 2026-10-23 00:35:06 -04:00
royjr d28e027943 keep pill current 2026-10-23 00:12:41 -04:00
royjr f179460982 so I can sleep at night 2026-10-23 00:05:04 -04:00
royjr 16b17e051f tick on new data 2026-10-23 00:03:15 -04:00
royjr 8efe325a71 gate like rainbow 2026-10-22 23:55:28 -04:00
royjr 9c391b4842 cleanup 2026-10-22 23:46:50 -04:00
royjr 6ac88a7be0 move to models 2026-10-22 23:25:32 -04:00
royjr 4dbb49430b tizi 2026-10-22 23:25:32 -04:00
royjr 5de324ab96 ill take it from here thanks 2026-10-22 23:25:32 -04:00
royjr d904487eed sqrt bar 2026-10-22 23:25:32 -04:00
royjr 2ffb986f3b blue only 2026-10-22 23:25:32 -04:00
discountchubbs c4f1dc179f dead 2026-10-22 20:17:03 -07:00
Jason Wen 8e740e0ac6 bump opendbc 2026-10-22 22:20:29 -04:00
royjr 15bf3dcd94 fade out blue 2026-10-22 18:15:10 -04:00
royjr 6e0ce4a6f9 match width 2026-10-22 18:15:10 -04:00
royjr d45d7e2de7 white is model path 2026-10-22 18:15:10 -04:00
royjr a3ef498512 dont guess 2026-10-22 18:15:10 -04:00
royjr 21f45673fd use blendful trust 2026-10-22 18:15:10 -04:00
royjr fd39f4f820 fade on trust 2026-10-22 18:15:10 -04:00
royjr cb71cdc54d use constants 2026-10-22 18:15:10 -04:00
royjr afd0ce66c9 no tap 2026-10-22 18:15:10 -04:00
royjr 5854346c2f blendful 2026-10-22 18:15:10 -04:00
James Vecellio-Grant 270704ad47 Merge branch 'master' into laneful-curves 2026-10-22 10:55:59 -07:00
James Vecellio-Grant 9d029d683d models: self healing test for tg (#2072)
* models: self healing test for tg

* rm container

* ew

* set env

* set var
2026-10-22 13:43:42 -04:00
James Vecellio-Grant 4ebb756ee5 Merge branch 'master' into laneful-curves 2026-10-22 10:15:23 -07:00
Amy Jeanes f78c6c2377 models: publish the download speed and derive the eta from it (#2052)
* models: publish the download speed and derive the eta from it

Fills in DownloadProgress.speed (bytes per second, declared with the parallel
downloader). The reporter measures the rate between its 0.5 s reports, smooths
it, and derives the eta from the bytes remaining at that rate instead of
extrapolating elapsed time. Speed is zeroed when a download ends, fails or is
cancelled and for a cached artifact, and copied across artifacts that share a
file like the other progress fields.

The big UI's Models page shows "12.3 MB/s" next to the progress bar and the
mici models panel shows it beside the percentage. Both take the max across a
bundle's artifacts, not the sum, since artifacts sharing a file mirror the same
transfer and only one file is ever in flight.

A stall decays the smoothed speed toward 0 without ever reaching it, so below
0.05 MB/s, which would only display as "0.0 MB/s", it snaps to 0. Both UIs
then show "--.- MB/s" while downloading with no rate (before the first reading
or during a stall) instead of a stuck "0.0 MB/s" or nothing, and the speed
recovers straight to the real rate once data flows again. The snap also keeps
the eta in range: without it a stall of about 25 s drove remaining / speed past
the capnp UInt32 eta, and the capnp error failed the download outright instead
of leaving the stall to the read timeout and retries. A test holds a piece
stalled until the published speed reaches exactly 0, and errors on that
overflow without the snap.

Assisted-by: Claude Code (Claude Opus 4.8)
Assisted-by: Claude Code (Claude Opus 5.5)

* remove unneeded comments

Assisted-by: Claude Code (Claude Opus 5.5)

* models: take the speed baseline before the workers start

The reporter took its starting byte count after the piece workers were
submitted, and starting each pool thread lets earlier workers run, so
bytes landed in that gap never counted toward a speed sample. Take the
baseline in _download_file before any worker exists and pass it in.

The test runs every piece inline in submit, so the whole file lands
before the reporter starts; with the late baseline the speed stays 0.

Assisted-by: Claude Code (Claude Opus 5.5)
2026-10-21 16:13:52 -07:00
nayan ee7bf203fe ffs 2026-10-21 17:20:08 -04:00
James Vecellio-Grant 1aa3a430fb models: pin tg to @02e6981 (#2071) 2026-10-21 11:11:24 -07:00
Amy Jeanes ddd7b14ebc models: download whole-file artifacts with parallel byte ranges (#2051)
* models: download whole-file artifacts with parallel byte ranges

HuggingFace's Xet CAS throttles each connection to roughly 1-2 MB/s but does
not limit connection count, so the single-stream _download_file left the link
idle most of the time. Replace it with a downloader that probes the size with
a one-byte Range request, creates the file at its final size and streams each
8 MB piece straight to its offset from one of 12 worker threads. The file is
never copied or assembled and only ever occupies its own size on disk. A
server that answers the probe with 200 gets one plain stream instead.
Every request sends Accept-Encoding: identity, because byte ranges count
encoded bytes while requests hands back decoded ones, so a compressed
response would land at the wrong offsets and fail as a short read.

Workers only write bytes; a reporter coroutine on the event loop publishes
progress every 0.5 s (instead of every 128 KB), re-raises the first worker
failure and polls the cancel ref. Transient errors (connection, timeout,
truncated body, HTTP 408/429/500/502/503/504) give a piece up to three
attempts; any other HTTP error fails the download at once. The whole-file
path now also publishes "verifying" while hashing before and after the
transfer, and the download is hashed against the manifest exactly as before.

A cancel has to wait for in-flight pieces to unwind (a blocked socket read
cannot be interrupted), so the row shows "canceling..." through a new
DownloadProgress.canceling flag instead of a frozen bar, and the mici cancel
button hides once the ref is gone, as it already does on the big UI.

DownloadProgress gains speed, canceling and resuming, appended to the capnp so
older readers are unaffected. All three are declared here so the wire format
lands in one place; speed and resuming are filled in by the follow-up speed
and resume changes.

Builds on #2050, which drops chunking and publishes models as whole files;
this makes that whole-file download parallel.

Measured on a comma 3X on WiFi: ~2 MB/s single stream, 17-24 MB/s steady and
30-60 MB/s peak with 12 range connections, the count picked from an on-device
sweep. Disk is not the bottleneck (eMMC writes at 56-67 MB/s with fdatasync).

Tests drive the real downloader against a local threaded range server: piece
tiling, actual parallelism (a stalled piece does not block the others), the
no-range fallback, per-piece retry, 4xx, cancel via ModelManager_DownloadRef
and mid-piece, verify/cached/downloaded state, a probe failure moving
past verifying, and hash mismatch cleanup.

Also drops two chunk-era leftovers in the tests: a stale "no chunks" note on
manifest_bundle and a one-item URL loop in the live manifest check.

Assisted-by: Claude Code (Claude Opus 4.8)
Assisted-by: Claude Code (Claude Opus 5.5)

* remove unneeded comments

* models: trim the manager tests to the regressions they catch

Each remaining test is there because a deliberate break in manager.py,
helpers.py or fetcher.py fails it. Tests that only re-checked what another
already catches are merged into a happy-path download or a table, and
checks of constants rather than behaviour are dropped. The fake server
now starts per test, keeps its state in one dataclass, and can cut a
response short.

Against 66 deliberate breaks, every one the old tests caught still fails,
and a 404 being retried, a short read and its retry, and the display
flags choosing the big model without a GPU are now caught too.

Assisted-by: Claude Code (Claude Opus 5.5)

* Update test_manager_download.py

* Update manager.py

---------

Co-authored-by: James Vecellio-Grant <159560811+Discountchubbs@users.noreply.github.com>
2026-10-21 09:35:43 -07:00
discountchubbs 62cf10ff05 let unwind be handled by laneless 2026-10-21 08:17:55 -07:00
discountchubbs 7707a3e384 interpolate smoothness on straights to reduce mr kumar and his mdv3 jiggle shennanigans 2026-10-20 22:56:10 -07:00
discountchubbs e87a605fb2 relearn ideal sim pose 2026-10-20 18:39:44 -07:00
James Vecellio-Grant 11603b2de7 Update modeld.py 2026-10-20 18:02:52 -07:00
James Vecellio-Grant 644e23f8ef Update modeld.py 2026-10-20 17:55:15 -07:00
discountchubbs 7847dbf148 sm logging + qlog logging 2026-10-20 15:47:46 -07:00
discountchubbs 0af369cae0 blendful 2026-10-20 13:28:20 -07:00
James Vecellio-Grant 77d0c2d665 Ui fix 2026-10-20 07:16:14 -07:00
discountchubbs 4d2d3f69f5 winner takes all trust system in lane lines 2026-10-19 23:11:22 -07:00
discountchubbs e9fc7a2465 less is more 2026-10-19 21:43:35 -07:00
discountchubbs 61154655cd handle one line flicker by trust÷2 2026-10-19 20:25:29 -07:00
discountchubbs ed4e8aeea6 jerk limiting correctin strength so that hecker doesnt spin spin yo wheel 2026-10-19 20:20:45 -07:00
discountchubbs a3ed6bd2f0 blending 50% on sharp curvaruerqw 2026-10-19 16:49:48 -07:00
discountchubbs 46c0b31997 blend 30% plan with action projeciton 2026-10-19 16:28:07 -07:00
discountchubbs 8a50860648 quicker entry 2026-10-19 14:16:46 -07:00
discountchubbs 563fa3c782 sharp curvies have some issues :O 2026-10-19 13:38:52 -07:00
discountchubbs 0b3459fbdc decay trust when model intends to move plant off lane. think going around cones and stuff 2026-10-19 13:10:03 -07:00
discountchubbs 54a22bd8bd blended correction over .3 s 2026-10-19 12:54:53 -07:00
discountchubbs 6c674810ee default to 1.0 while i test variable weighting 2026-10-19 12:38:16 -07:00
discountchubbs e64f67ec7e red diff that 2026-10-19 12:36:35 -07:00
discountchubbs 9c7655c45e modeld_v2: blended laneful using weighted stds 2026-10-19 12:11:27 -07:00
discountchubbs 60c6464a14 Merge remote-tracking branch 'origin/master' into laneful-curves
# Conflicts:
#	openpilot/selfdrive/ui/mici/onroad/model_renderer.py
#	openpilot/sunnypilot/modeld_v2/modeld.py
2026-10-19 12:08:14 -07:00
royjr bf93807e7b simple 2026-10-18 01:06:10 -04:00
royjr f54f2d462e settings 2026-10-18 00:15:49 -04:00
royjr 80494ec3cf init 2026-10-18 00:15:48 -04:00
58 changed files with 1909 additions and 4900 deletions
+53
View File
@@ -3,9 +3,62 @@ on:
pull_request:
paths:
- 'tinygrad_repo'
- 'openpilot/sunnypilot/modeld_v2/**'
workflow_dispatch:
env:
CI: 1
GIT_CONFIG_COUNT: 1
GIT_CONFIG_KEY_0: lfs.fetchexclude
GIT_CONFIG_VALUE_0: openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl
jobs:
plain-artifact:
name: Plain artifact round trip and pickle converter
runs-on: ubuntu-24.04
env:
PYTHONPATH: ${{ github.workspace }}/tinygrad_repo:${{ github.workspace }}
TINYGRAD_TESTS: 1
steps:
- uses: actions/checkout@v7
- run: ./tools/op.sh setup
- name: Build openpilot
run: scons -j$(nproc)
- name: Compile, load, cache and fuse a model with the pinned tinygrad
env:
DEV: CPU
run: tools/op.sh test -v openpilot/sunnypilot/modeld_v2/tests/test_plain_artifact.py
- name: Install LLVM 20 and ROCm comgr for the amd mock, as tinygrad_repo/.github/actions/setup-tinygrad does
run: |
. /etc/os-release
curl -fsSL https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc > /dev/null
echo "deb http://apt.llvm.org/$VERSION_CODENAME/ llvm-toolchain-$VERSION_CODENAME-20 main" | sudo tee /etc/apt/sources.list.d/llvm.list
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://repo.radeon.com/rocm/rocm.gpg.key | gpg --dearmor | sudo tee /etc/apt/keyrings/rocm.gpg > /dev/null
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/rocm.gpg] https://repo.radeon.com/rocm/apt/7.1 $VERSION_CODENAME main" | sudo tee /etc/apt/sources.list.d/rocm.list
printf 'Package: *\nPin: release o=repo.radeon.com\nPin-Priority: 600\n' | sudo tee /etc/apt/preferences.d/rocm-pin-600
sudo apt-get -qq update && sudo apt-get -qq install -y --no-install-recommends libllvm20 clang-20 lld-20 comgr
printf '/opt/rocm/lib\n/opt/rocm/lib64\n' | sudo tee /etc/ld.so.conf.d/rocm.conf && sudo ldconfig
echo "/usr/lib/llvm-20/bin" >> "$GITHUB_PATH"
- name: Convert a pickled USB+AMD jit and replay it under tinygrad's amd mock
env:
DEV: MOCKUSB+AMD
CHECK_OOB: 0
GMMU: 0
run: tools/op.sh test -v openpilot/sunnypilot/modeld_v2/tests/test_convert_upstream_pkl.py
- name: Where tinygrad moved, for the helper or failure point named above
if: failure() && github.event_name == 'pull_request'
run: |
git fetch -q --depth=1 origin "${{ github.base_ref }}"
old=$(git rev-parse "origin/${{ github.base_ref }}:tinygrad_repo") new=$(git rev-parse HEAD:tinygrad_repo)
git -C tinygrad_repo fetch -q origin "$old" || true
echo "tinygrad_repo $old -> $new, in what helpers.py and plain_artifact_compiler.py read:"
git -C tinygrad_repo diff "$old" "$new" -- tinygrad/uop/ops.py tinygrad/engine/jit.py tinygrad/engine/realize.py tinygrad/device.py \
tinygrad/helpers.py tinygrad/dtype.py tinygrad/renderer/__init__.py
echo "and in what convert_upstream_pkl.py decodes:"
git -C tinygrad_repo diff --stat "$old" "$new" -- tinygrad/runtime/autogen/am/pm4_nv.py tinygrad/runtime/autogen/amd_gpu.py
git -C tinygrad_repo diff "$old" "$new" -- tinygrad/runtime/support/hcq2.py tinygrad/runtime/support/usb.py tinygrad/runtime/ops_amd.py
generate-matrix:
runs-on: ubuntu-latest
outputs:
+30
View File
@@ -50,6 +50,12 @@ jobs:
timeout-minutes: 3
run: TARGET_DIR=$STRIPPED_DIR tools/release/build_stripped.sh
- run: ./tools/op.sh setup
- name: Restore scons cache
uses: actions/cache@v6
with:
path: /tmp/scons_cache
key: scons-${{ runner.os }}-${{ github.job }}-${{ github.sha }}
restore-keys: scons-${{ runner.os }}-${{ github.job }}-
- name: Build openpilot and run checks
timeout-minutes: 30
working-directory: ${{ env.STRIPPED_DIR }}
@@ -89,6 +95,12 @@ jobs:
FILTERED=$(echo "$PATH" | tr ':' '\n' | grep -v '/opt/homebrew' | tr '\n' ':')
echo "PATH=${FILTERED}/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" >> $GITHUB_ENV
- run: ./tools/op.sh setup
- name: Restore scons cache
uses: actions/cache@v6
with:
path: /tmp/scons_cache
key: scons-${{ runner.os }}-${{ github.job }}-${{ github.sha }}
restore-keys: scons-${{ runner.os }}-${{ github.job }}-
- name: Building openpilot
run: scons
@@ -121,6 +133,12 @@ jobs:
- name: Install raylib headless dependencies
if: ${{ !contains(runner.name, 'nsc') }}
run: sudo apt-get install -y --no-install-recommends libgles2 libegl1
- name: Restore scons cache
uses: actions/cache@v6
with:
path: /tmp/scons_cache
key: scons-${{ runner.os }}-${{ github.job }}-${{ github.sha }}
restore-keys: scons-${{ runner.os }}-${{ github.job }}-
- name: Build openpilot
run: scons
- name: Run unit tests
@@ -140,6 +158,12 @@ jobs:
steps:
- uses: actions/checkout@v7
- run: ./tools/op.sh setup
- name: Restore scons cache
uses: actions/cache@v6
with:
path: /tmp/scons_cache
key: scons-${{ runner.os }}-${{ github.job }}-${{ github.sha }}
restore-keys: scons-${{ runner.os }}-${{ github.job }}-
- name: Build openpilot
run: scons
- name: Run replay
@@ -228,6 +252,12 @@ jobs:
- name: Install raylib headless dependencies
if: ${{ !contains(runner.name, 'nsc') }}
run: sudo apt-get install -y --no-install-recommends libgles2 libegl1
- name: Restore scons cache
uses: actions/cache@v6
with:
path: /tmp/scons_cache
key: scons-${{ runner.os }}-${{ github.job }}-${{ github.sha }}
restore-keys: scons-${{ runner.os }}-${{ github.job }}-
- name: Build openpilot
run: scons
- name: Create UI Report
+10
View File
@@ -138,6 +138,9 @@ struct ModelManagerSP @0xaedffd8f31e7b55d {
status @0 :DownloadStatus;
progress @1 :Float32;
eta @2 :UInt32;
speed @3 :Float32;
canceling @4 :Bool;
resuming @5 :Bool;
}
struct Chunk {
@@ -465,6 +468,13 @@ struct ModelDataV2SP @0xa1680744031fdb2d {
laneTurnDirection @0 :TurnDirection;
leftLaneChangeEdgeBlock @1 :Bool;
rightLaneChangeEdgeBlock @2 :Bool;
blendfulCurvature @3 :Float32;
blendfulTrust @4 :Float32;
modelCurvature @5 :Float32;
blendfulGoingAround @6 :Bool;
blendfulLeftProb @7 :Float32;
blendfulRightProb @8 :Float32;
blendfulLaneWidth @9 :Float32;
enum TurnDirection {
none @0;
+1 -1
View File
@@ -94,7 +94,7 @@ _services: dict[str, tuple] = {
"carControlSP": (True, 100., 10),
"carStateSP": (True, 100., 10),
"liveMapDataSP": (True, 1., 1),
"modelDataV2SP": (True, 20., None, QueueSize.BIG),
"modelDataV2SP": (True, 20., 10, QueueSize.BIG),
"liveLocationKalman": (True, 20.),
# debug
+2 -11
View File
@@ -234,17 +234,6 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"SunnylinkEnabled", {PERSISTENT, BOOL, "1"}},
{"SunnylinkTempFault", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL, "0"}},
{"SunnylinkLocalApps", {PERSISTENT, JSON}},
{"SunnylinkLocalAppsMeta", {PERSISTENT, JSON}},
{"SunnylinkLocalEnabled", {PERSISTENT, BOOL, "1"}},
{"SunnylinkLocalDiscoveredApp", {CLEAR_ON_MANAGER_START, JSON}},
{"SunnylinkLocalPairingRequest", {CLEAR_ON_MANAGER_START, BOOL}},
{"SunnylinkLocalPairingQr", {CLEAR_ON_MANAGER_START, STRING}},
{"SunnylinkLocalRevoke", {CLEAR_ON_MANAGER_START, STRING}},
{"SunnylinkLocalEnroll", {CLEAR_ON_MANAGER_START, STRING}},
{"SunnylinkLocalCloudRevoke", {CLEAR_ON_MANAGER_START, STRING}},
{"SunnylinkLocalRevokeAll", {CLEAR_ON_MANAGER_START, BOOL}},
// Backup Manager params
{"BackupManager_CreateBackup", {PERSISTENT, BOOL}},
{"BackupManager_RestoreVersion", {PERSISTENT, STRING}},
@@ -257,6 +246,7 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"TeslaMadsScreenButton", {PERSISTENT | BACKUP, INT, "0"}},
{"ToyotaEnforceStockLongitudinal", {PERSISTENT | BACKUP, BOOL, "0"}},
{"ToyotaStopAndGoHack", {PERSISTENT | BACKUP, BOOL, "0"}},
{"ToyotaTSS2Smooth", {PERSISTENT | BACKUP, BOOL, "0"}},
{"BlindSpot", {PERSISTENT | BACKUP, BOOL, "0"}},
@@ -267,6 +257,7 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"LagdValueCache", {PERSISTENT, FLOAT, "0.2"}},
{"LaneTurnDesire", {PERSISTENT | BACKUP, BOOL, "0"}},
{"LaneTurnValue", {PERSISTENT | BACKUP, FLOAT, "19.0"}},
{"Blendful", {PERSISTENT | BACKUP, BOOL, "0"}},
{"PlanplusControl", {PERSISTENT | BACKUP, FLOAT, "1.0"}},
// mapd
@@ -64,16 +64,19 @@ class DesireHelper:
((carstate.steeringTorque > 0 and self.lane_change_direction == LaneChangeDirection.left) or
(carstate.steeringTorque < 0 and self.lane_change_direction == LaneChangeDirection.right))
blindspot_detected = (((carstate.leftBlindspot or left_edge_detected) and self.lane_change_direction == LaneChangeDirection.left) or
((carstate.rightBlindspot or right_edge_detected) and self.lane_change_direction == LaneChangeDirection.right))
blindspot_detected = ((carstate.leftBlindspot and self.lane_change_direction == LaneChangeDirection.left) or
(carstate.rightBlindspot and self.lane_change_direction == LaneChangeDirection.right))
self.alc.update_lane_change(blindspot_detected, carstate.brakePressed)
edge_detected = ((left_edge_detected and self.lane_change_direction == LaneChangeDirection.left) or
(right_edge_detected and self.lane_change_direction == LaneChangeDirection.right))
self.alc.update_lane_change(blindspot_detected, edge_detected, carstate.brakePressed, torque_applied)
if not one_blinker or below_lane_change_speed:
self.lane_change_state = LaneChangeState.off
self.lane_change_direction = LaneChangeDirection.none
self.lane_change_timer = 0.0
elif (torque_applied or self.alc.auto_lane_change_allowed) and not blindspot_detected:
elif (torque_applied or self.alc.auto_lane_change_allowed) and not self.alc.lane_change_blocked:
self.lane_change_state = LaneChangeState.laneChangeStarting
self.lane_change_timer = 0.0
@@ -141,6 +141,7 @@ class ModelRenderer(Widget, ModelRendererSP):
if ui_state.status != UIStatus.DISENGAGED:
self._draw_lane_lines()
self._draw_path()
self.blendful.draw(self._rect)
if render_lead_indicator:
self._update_leads(sm)
@@ -219,6 +220,7 @@ class ModelRenderer(Widget, ModelRendererSP):
# Update road edges using raw points
for road_edge in self._road_edges:
road_edge.projected_points = self._map_line_to_polygon(road_edge.raw_points, line_width_factor, 0.0, max_idx)
self.blendful.update(self, max_idx)
# Update path using raw points
if lead and lead.present:
@@ -123,6 +123,8 @@ class ModelRenderer(Widget, ChevronMetrics, ModelRendererSP):
# Draw elements
self._draw_lane_lines()
self._draw_path()
if self._lateral_active:
self.blendful.draw(self._rect)
if render_lead_indicator and radar_state:
self._draw_lead_indicator()
@@ -172,6 +174,7 @@ class ModelRenderer(Widget, ChevronMetrics, ModelRendererSP):
# Update road edges using raw points
for road_edge in self._road_edges:
road_edge.projected_points = self._map_line_to_polygon(road_edge.raw_points, 0.025, 0.0, max_idx, max_distance)
self.blendful.update(self, max_idx, max_distance)
# Update path using raw points
if lead and lead.present:
@@ -102,13 +102,19 @@ class ModelsLayout(Widget):
self.lagd_toggle = toggle_item_sp(tr("Live Learning Steer Delay"), "", param="LagdToggle")
self.blendful_toggle = toggle_item_sp(tr("Blendful (Alpha)"),
tr("Gently pulls the model toward the lane center, as much as it trusts the lane lines," +
" and yields around obstacles."),
param="Blendful")
self.camera_offset = option_item_sp(tr("Adjust Camera Offset"), "CameraOffset", -35, 35,
tr("Virtually shift camera's perspective to move model's center to Left(+ values) or Right (- values)"),
1, None, True, "", style.BUTTON_ACTION_WIDTH, None, True,
lambda v: f"{v / 100:.2f} m")
self.items = [self.small_model_item, self.big_model_item, self.cancel_download_item, self.download_item, self.refresh_item, self.clear_cache_item,
self.lane_turn_desire_toggle, self.lane_turn_value_control, self.lagd_toggle, self.delay_control, self.camera_offset]
self.blendful_toggle, self.lane_turn_desire_toggle, self.lane_turn_value_control, self.lagd_toggle, self.delay_control,
self.camera_offset]
def _update_lagd_description(self, lagd_toggle: bool):
desc = tr("Enable this for the car to learn and adapt its steering response time. Disable to use a fixed steering response time. " +
@@ -235,13 +241,17 @@ class ModelsLayout(Widget):
progress = sum(p.progress for p in progresses) / len(progresses)
ds = custom.ModelManagerSP.DownloadStatus
if any(p.canceling for p in progresses):
return {"name": name, "downloading": True, "progress": progress, "status_text": tr("canceling...")}
if ds.failed in statuses:
# close.png is authored black and a tint cannot lift it, hence close2
return {"name": name, "status_text": tr("download failed"), "text_color": rl.RED, "icon": "icons/close2.png"}
if ds.verifying in statuses:
return {"name": name, "downloading": True, "progress": progress, "status_text": tr("verifying")}
if ds.downloading in statuses:
return {"name": name, "downloading": True, "progress": progress}
speed = max((p.speed for p in progresses), default=0.0)
status_text = f"{speed / 1024 ** 2:.1f} MB/s" if speed > 0 else "--.- MB/s"
return {"name": name, "downloading": True, "progress": progress, "status_text": status_text}
if statuses <= {ds.downloaded, ds.cached}:
return {"name": name, "text_color": ON_COLOR, "icon": "icons/checkmark.png"}
# circled_slash is authored grey; tinting it again only darkens it
@@ -318,6 +328,15 @@ class ModelsLayout(Widget):
self.lane_turn_desire_toggle.action_item.set_state(turn_desire)
self.lane_turn_value_control.set_visible(turn_desire and advanced_controls)
self.lagd_toggle.action_item.set_state(live_delay)
blendful_allowed = ui_state.active_bundle is not None
if blendful_allowed:
self.blendful_toggle.action_item.set_state(ui_state.params.get_bool("Blendful"))
self.blendful_toggle.action_item.set_enabled(True)
else:
self.blendful_toggle.action_item.set_state(False)
self.blendful_toggle.action_item.set_enabled(False)
self.delay_control.set_visible(not live_delay and advanced_controls)
new_step = int(round(100 / CV.MPH_TO_KPH)) if ui_state.is_metric else 100
if self.lane_turn_value_control.action_item is not None and self.lane_turn_value_control.action_item.value_change_step != new_step:
@@ -4,48 +4,23 @@ Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
import time
import pyray as rl
from functools import partial
from openpilot.cereal import custom
from openpilot.common.params import Params
from openpilot.common.qrcode import make_texture
from openpilot.common.swaglog import cloudlog
from openpilot.common.version import sunnylink_consent_version
from openpilot.selfdrive.ui.sunnypilot.layouts.onboarding import SunnylinkConsentPage
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.sunnypilot.sunnylink.api import UNREGISTERED_SUNNYLINK_DONGLE_ID
from openpilot.sunnypilot.sunnylink.athena.local_auth_daemon import (
clear_pairing_params,
read_grant_meta,
read_granted_apps,
read_pairing_qr,
request_revoke,
request_revoke_all,
)
from openpilot.sunnypilot.sunnylink.athena.local_app_rows import (
MAX_LOCAL_APP_ROWS,
LocalAppRow,
build_local_app_rows,
)
from openpilot.sunnypilot.sunnylink.athena.local_discovery import latest_discovered_app
from openpilot.sunnypilot.sunnylink.athena.local_state import PAIRING_REQUEST_KEY
from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment, TextAlignmentVertical
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.lib.wrap_text import wrap_text
from openpilot.system.ui.sunnypilot.widgets.list_view import ListItemSP, button_item_sp, toggle_item_sp
from openpilot.system.ui.sunnypilot.widgets.list_view import button_item_sp
from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp
from openpilot.system.ui.sunnypilot.widgets.sunnylink_pairing_dialog import SunnylinkPairingDialog
from openpilot.system.ui.widgets import Widget, DialogResult
from openpilot.system.ui.widgets.button import ButtonStyle, Button, IconButton
from openpilot.system.ui.widgets.button import ButtonStyle, Button
from openpilot.system.ui.widgets.confirm_dialog import alert_dialog, ConfirmDialog
from openpilot.system.ui.widgets.label import UnifiedLabel
from openpilot.system.ui.widgets.list_view import dual_button_item
from openpilot.system.ui.widgets.network import NavButton
from openpilot.system.ui.widgets.scroller_tici import Scroller, LineSeparator
# Read-only value colors used by the local-mode rows.
_LOCAL_DISCOVERED_COLOR = rl.Color(170, 170, 170, 255) # grey: no app in sight
_LOCAL_ACTIVE_COLOR = rl.Color(0, 255, 0, 255) # green: discovered / pairing code
from openpilot.common.version import sunnylink_consent_version
class SunnylinkHeader(Widget):
@@ -170,7 +145,6 @@ class SunnylinkLayout(Widget):
self._restore_in_progress = False
self._backup_in_progress = False
self._sunnylink_enabled = ui_state.params.get("SunnylinkEnabled")
self._local_enabled = ui_state.params.get_bool("SunnylinkLocalEnabled")
items = self._initialize_items()
self._scroller = Scroller(items, line_separator=False, spacing=0)
@@ -218,23 +192,6 @@ class SunnylinkLayout(Widget):
self._backup_btn.set_button_style(ButtonStyle.NORMAL)
self._restore_btn.set_button_style(ButtonStyle.PRIMARY)
self._local_connections_toggle = toggle_item_sp(
title=tr("Enable local connections"),
description=tr("Let the sunnylink mobile app connect to this device over Wi-Fi. ") +
tr("Turn this off to stop the local daemon and keep sunnylink itself running."),
param="SunnylinkLocalEnabled",
)
self._local_connections_toggle.set_visible(lambda: self._sunnylink_enabled)
self._mobile_app_btn = button_item_sp(
title=tr("Sunnylink Local Connections"),
button_text=tr("CONFIGURE"),
description=tr("Manage the mobile app(s) connected over Wi-Fi: pair a new app ") +
tr("or unpair existing ones."),
callback=self._open_local_apps,
)
self._mobile_app_btn.set_visible(lambda: self._sunnylink_enabled and self._local_enabled)
items = [
SunnylinkHeader(),
LineSeparator(),
@@ -245,13 +202,9 @@ class SunnylinkLayout(Widget):
LineSeparator(),
self._pair_btn,
LineSeparator(),
self._local_connections_toggle,
LineSeparator(),
self._mobile_app_btn,
LineSeparator(),
self._sunnylink_uploader_toggle,
LineSeparator(),
self._sunnylink_backup_restore_buttons,
self._sunnylink_backup_restore_buttons
]
return items
@@ -364,8 +317,6 @@ class SunnylinkLayout(Widget):
gui_app.push_widget(sl_terms_dlg)
else:
ui_state.params.put_bool("SunnylinkEnabled", state)
if not state:
clear_pairing_params()
self._update_description(state)
def _update_description(self, state: bool):
@@ -385,7 +336,6 @@ class SunnylinkLayout(Widget):
def _update_state(self):
super()._update_state()
self._sunnylink_enabled = ui_state.params.get_bool("SunnylinkEnabled")
self._local_enabled = ui_state.params.get_bool("SunnylinkLocalEnabled")
self._sunnylink_toggle.set_right_value(tr("Dongle ID") + ": " + self._get_sunnylink_dongle_id())
self._sunnylink_toggle.action_item.set_enabled(not ui_state.is_onroad())
self._sunnylink_toggle.action_item.set_state(self._sunnylink_enabled)
@@ -402,9 +352,6 @@ class SunnylinkLayout(Widget):
self._pair_btn.action_item.set_text(pair_btn_text)
self._pair_btn.action_item.set_enabled(self._sunnylink_enabled)
def _open_local_apps(self):
gui_app.push_widget(SunnylinkLocalAppLayout())
def _render(self, rect):
self._scroller.render(rect)
@@ -417,241 +364,3 @@ class SunnylinkLayout(Widget):
def hide_event(self):
super().hide_event()
ui_state.sunnylink_state.set_settings_open(False)
class SunnylinkLocalAppLayout(Widget):
def __init__(self):
super().__init__()
self._rows_cache: list[LocalAppRow] = []
self._has_grants = False
self._back_button = NavButton(tr("Back"))
self._back_button.set_click_callback(gui_app.pop_widget)
self._pair_app_btn = button_item_sp(
title=tr("Pair App"),
button_text=tr("SCAN"),
description=tr("Show a QR code the sunnylink app scans to enroll this phone's key. ") +
tr("The app must be signed in, and the code expires after 2 minutes."),
callback=self._show_pairing_dialog,
)
self._revoke_all_btn = button_item_sp(
title=tr("Unpair all phones"),
button_text=tr("REVOKE ALL"),
description=tr("Revoke every enrolled phone at once. Each phone must pair again ") +
tr("from the sunnylink app to reconnect."),
callback=self._revoke_all,
)
self._revoke_all_btn.set_visible(lambda: self._has_grants)
self._local_app_rows: list[ListItemSP] = []
self._local_app_seps: list[LineSeparator] = []
for i in range(MAX_LOCAL_APP_ROWS):
row = button_item_sp(
title=lambda i=i: self._row_title(i),
button_text=tr("UNPAIR"),
description=lambda i=i: self._row_subtitle(i),
callback=partial(self._unpair_row, i),
)
sep = LineSeparator()
row.set_visible(lambda i=i: self._local_row_visible(i))
sep.set_visible(lambda i=i: self._local_row_visible(i))
self._local_app_rows.append(row)
self._local_app_seps.append(sep)
items = [self._pair_app_btn, LineSeparator(), self._revoke_all_btn, LineSeparator()]
for row, sep in zip(self._local_app_rows, self._local_app_seps, strict=True):
items.extend((row, sep))
self._scroller = Scroller(items, line_separator=False, spacing=0)
def _local_row_visible(self, i: int) -> bool:
return i < len(self._rows_cache)
def _row_title(self, i: int) -> str:
return self._rows_cache[i].title if i < len(self._rows_cache) else ""
def _row_subtitle(self, i: int) -> str:
return self._rows_cache[i].subtitle if i < len(self._rows_cache) else ""
def _show_pairing_dialog(self):
gui_app.push_widget(SunnylinkLocalPairingDialog())
def _revoke_all(self):
def on_confirm(_dialog_result: int):
# A request the daemon applies on its next tick.
request_revoke_all()
dialog = ConfirmDialog(
text=tr("Revoke all phones?") + " " + tr("They will need to pair again from the app."),
confirm_text=tr("Revoke all"),
callback=on_confirm,
)
gui_app.push_widget(dialog)
def _unpair_row(self, index: int):
rows = self._rows_cache
if index >= len(rows):
return
row = rows[index]
def on_confirm(_dialog_result: int):
# A request only: the daemon revokes through its live authority on its next tick, so a
# phone enrolled after this row was read cannot be dropped by a stale snapshot.
request_revoke(row.app_id)
dialog = ConfirmDialog(
text=tr("Unpair") + f" {row.title}? " + tr("You will need to enroll this phone again from the app."),
confirm_text=tr("Unpair"),
callback=on_confirm,
)
gui_app.push_widget(dialog)
def _update_state(self):
super()._update_state()
grants = read_granted_apps()
self._has_grants = bool(grants)
self._rows_cache = build_local_app_rows(grants, read_grant_meta(), time.monotonic())
def _render(self, rect):
self._back_button.set_position(self._rect.x, self._rect.y + 20)
self._back_button.render()
content_rect = rl.Rectangle(rect.x, rect.y + self._back_button.rect.height + 40,
rect.width, rect.height - self._back_button.rect.height - 40)
self._scroller.render(content_rect)
def show_event(self):
super().show_event()
self._scroller.show_event()
def hide_event(self):
super().hide_event()
self._scroller.hide_event()
class SunnylinkLocalPairingDialog(Widget):
"""Pairing: the phone scans this QR, enrolls through the cloud and only then dials the device
over pinned TLS.
The window belongs to the local daemon (another process): this dialog sets the arm request and
renders the QR that gets published. The published QR is the window state — removing it (Cancel,
or the daemon's own expiry) closes the window.
"""
# sunnylinkd services the request about once a second; taking longer means it is not running.
ARM_GRACE_S = 5.0
def __init__(self):
super().__init__()
self._params = Params()
self._requested_at = time.monotonic()
self._seen_qr = False
self._qr_string: str | None = None
self._qr_texture: rl.Texture | None = None
self._request_window()
self._close_btn = IconButton(gui_app.texture("icons/close.png", 80, 80))
self._close_btn.set_click_callback(self._cancel)
def _request_window(self) -> None:
try:
self._params.put_bool(PAIRING_REQUEST_KEY, True, block=True)
except Exception:
cloudlog.exception("sunnylink.local_pairing.request_failed")
def _cancel(self) -> None:
# Params only: this process does not own the window, the daemon cancels it on its next tick.
clear_pairing_params()
gui_app.pop_widget()
def _refresh_qr_texture(self, qr: str | None) -> None:
"""Regenerate the texture only when the published QR actually changed."""
if qr == self._qr_string:
return
if self._qr_texture is not None and self._qr_texture.id != 0:
rl.unload_texture(self._qr_texture)
self._qr_texture = None
self._qr_string = qr
if not qr:
return
try:
self._qr_texture = make_texture(qr)
# The texture is drawn smaller than it is built, and the modules must stay hard-edged for
# a camera to read them.
rl.set_texture_filter(self._qr_texture, rl.TextureFilter.TEXTURE_FILTER_POINT)
except Exception:
cloudlog.exception("sunnylink.local_pairing.qr_texture_failed")
def _update_state(self):
if read_pairing_qr() is not None:
self._seen_qr = True
return
# The daemon clears the published QR when the window ends (enrolled, expired or cancelled).
if self._seen_qr or time.monotonic() - self._requested_at > self.ARM_GRACE_S:
gui_app.pop_widget()
def _render(self, rect) -> int:
rl.clear_background(rl.Color(224, 224, 224, 255))
self._refresh_qr_texture(read_pairing_qr())
margin = 70
content_rect = rl.Rectangle(rect.x + margin, rect.y + margin,
rect.width - 2 * margin, rect.height - 2 * margin)
y = content_rect.y
close_size = 80
pad = 20
close_rect = rl.Rectangle(content_rect.x - pad, y - pad, close_size + pad * 2, close_size + pad * 2)
self._close_btn.render(close_rect)
y += close_size + 40
# Two columns, the layout the cloud pairing dialog uses: the QR takes half the width and the
# full height, which is 900px at this display's 2160x1080 — 31 px per module for the compact
# 29-module pointer, read easily from any scanning distance.
left_width = int(content_rect.width * 0.5 - 15)
right_width = int(content_rect.width // 2 - 20)
title_font = gui_app.font(FontWeight.NORMAL)
title_wrapped = wrap_text(title_font, tr("Pair with mobile app"), 70, left_width)
rl.draw_text_ex(title_font, "\n".join(title_wrapped), rl.Vector2(content_rect.x, y), 70, 0.0, rl.BLACK)
y += len(title_wrapped) * 70 + 40
qr_size = int(min(right_width, content_rect.height) - 40)
qr_rect = rl.Rectangle(content_rect.x + left_width + 40 + (right_width - qr_size) / 2,
content_rect.y, qr_size, qr_size)
card = rl.Rectangle(qr_rect.x - 10, qr_rect.y - 10, qr_rect.width + 20, qr_rect.height + 20)
rl.draw_rectangle_rounded(card, 0.05, 10, rl.WHITE)
if self._qr_texture is not None:
source = rl.Rectangle(0, 0, self._qr_texture.width, self._qr_texture.height)
rl.draw_texture_pro(self._qr_texture, source, qr_rect, rl.Vector2(0, 0), 0, rl.WHITE)
# Left column: what to do. The QR is the only way to enroll, so what matters here is that the
# steps are readable next to a code that is as large as the panel allows inside the QR's own
# calm zone.
hint_font = gui_app.font(FontWeight.NORMAL)
hints = [
tr("In the sunnylink app, open Local Connectivity → Pair a device."),
tr("Sign in to the same sunnypilot account on both."),
tr("The code expires after 2 minutes."),
] if self._qr_texture is not None else [tr("Preparing a secure pairing code…")]
for hint in hints:
wrapped = wrap_text(hint_font, hint, 38, left_width)
rl.draw_text_ex(hint_font, "\n".join(wrapped), rl.Vector2(content_rect.x, y), 38, 0.0, rl.BLACK)
y += len(wrapped) * 38 + 14
discovered = latest_discovered_app()
if discovered is not None:
endpoint, age = discovered
status = endpoint if age < 2 else f"{endpoint} ({age}s)"
color = _LOCAL_ACTIVE_COLOR
else:
status = tr("Waiting for the phone…")
color = _LOCAL_DISCOVERED_COLOR
status_font = gui_app.font(FontWeight.NORMAL)
rl.draw_text_ex(status_font, status, rl.Vector2(content_rect.x, y + 10), 40, 0.0, color)
return -1
def __del__(self):
if self._qr_texture is not None and self._qr_texture.id != 0:
rl.unload_texture(self._qr_texture)
@@ -11,10 +11,12 @@ from openpilot.system.ui.lib.multilang import tr, tr_noop
from openpilot.system.ui.widgets import DialogResult
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog
from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp
from opendbc.car.toyota.values import ToyotaFlags
ONROAD_ONLY_DESCRIPTION = tr_noop("Start the vehicle to check vehicle compatibility.")
SNG_HACK_UNAVAILABLE = tr_noop("sunnypilot Longitudinal Control must be available and enabled for your vehicle to use this feature.")
PLATFORM_UNAVAILABLE = tr_noop("This feature is currently not available on this platform.")
DESCRIPTIONS = {
'enforce_stock_longitudinal': tr_noop(
@@ -23,7 +25,11 @@ DESCRIPTIONS = {
'stop_and_go_hack': tr_noop(
'sunnypilot will allow some Toyota/Lexus cars to auto resume during stop and go traffic. ' +
'This feature is only applicable to certain models that are able to use longitudinal control. This is an alpha feature. Use at your own risk.'
)
),
'tss2_smooth': tr_noop(
'Enable smoother longitudinal control for TSS2 vehicles. ' +
'Uses a custom speed-dependent integral gain curve for smoother traffic driving.'
),
}
@@ -47,9 +53,18 @@ class ToyotaSettings(BrandSettings):
enabled=lambda: not ui_state.engaged,
)
self.tss2_smooth = toggle_item_sp(
lambda: tr("TSS2 Smooth Longitudinal (Alpha)"),
description=lambda: tr(DESCRIPTIONS["tss2_smooth"]),
initial_state=ui_state.params.get_bool("ToyotaTSS2Smooth"),
callback=self._on_enable_tss2_smooth,
enabled=lambda: not ui_state.engaged,
)
self.items = [
self.enforce_stock_longitudinal,
self.stop_and_go_hack,
self.tss2_smooth,
]
def _on_enable_enforce_stock_longitudinal(self, state: bool):
@@ -61,6 +76,8 @@ class ToyotaSettings(BrandSettings):
ui_state.params.put_bool("AlphaLongitudinalEnabled", False)
ui_state.params.put_bool("ToyotaStopAndGoHack", False)
self.stop_and_go_hack.action_item.set_state(False)
ui_state.params.put_bool("ToyotaTSS2Smooth", False)
self.tss2_smooth.action_item.set_state(False)
ui_state.params.put_bool("OnroadCycleRequested", True)
else:
self.enforce_stock_longitudinal.action_item.set_state(False)
@@ -75,6 +92,25 @@ class ToyotaSettings(BrandSettings):
ui_state.params.put_bool("ToyotaEnforceStockLongitudinal", False)
ui_state.params.put_bool("OnroadCycleRequested", True)
def _on_enable_tss2_smooth(self, state: bool):
if state:
def confirm_callback(result: int):
if result == DialogResult.CONFIRM:
ui_state.params.put_bool("ToyotaTSS2Smooth", True)
ui_state.params.put_bool("OnroadCycleRequested", True)
else:
self.tss2_smooth.action_item.set_state(False)
content = (f"<h1>{self.tss2_smooth.title}</h1><br>" +
f"<p>{self.tss2_smooth.description}</p>")
dlg = ConfirmDialog(content, tr("Enable"), rich=True, callback=confirm_callback)
gui_app.push_widget(dlg)
else:
ui_state.params.put_bool("ToyotaTSS2Smooth", False)
ui_state.params.put_bool("OnroadCycleRequested", True)
def _on_enable_stop_and_go_hack(self, state: bool):
if state:
def confirm_callback(result: int):
@@ -113,9 +149,32 @@ class ToyotaSettings(BrandSettings):
self.stop_and_go_hack.set_description(new_desc)
if show_desc:
self.stop_and_go_hack.show_description(True)
tss2 = bool(ui_state.CP.flags & ToyotaFlags.TSS2)
if longitudinal and not enforce_stock and tss2:
self.tss2_smooth.action_item.set_enabled(not ui_state.engaged)
new_desc = tr(DESCRIPTIONS["tss2_smooth"])
show_desc = False
else:
self.tss2_smooth.action_item.set_enabled(False)
self.tss2_smooth.action_item.set_state(False)
unavailable = SNG_HACK_UNAVAILABLE if tss2 else PLATFORM_UNAVAILABLE
new_desc = "<b>" + tr(unavailable) + "</b>\n\n" + tr(DESCRIPTIONS["tss2_smooth"])
show_desc = True
if self.tss2_smooth.description != new_desc:
self.tss2_smooth.set_description(new_desc)
if show_desc:
self.tss2_smooth.show_description(True)
else:
self.stop_and_go_hack.action_item.set_enabled(False)
new_desc = "<b>" + tr(ONROAD_ONLY_DESCRIPTION) + "</b>\n\n" + tr(DESCRIPTIONS["stop_and_go_hack"])
if self.stop_and_go_hack.description != new_desc:
self.stop_and_go_hack.set_description(new_desc)
self.stop_and_go_hack.show_description(True)
self.tss2_smooth.action_item.set_enabled(False)
new_desc = "<b>" + tr(ONROAD_ONLY_DESCRIPTION) + "</b>\n\n" + tr(DESCRIPTIONS["tss2_smooth"])
if self.tss2_smooth.description != new_desc:
self.tss2_smooth.set_description(new_desc)
self.tss2_smooth.show_description(True)
@@ -11,7 +11,7 @@ import pyray as rl
from openpilot.cereal import custom
from openpilot.selfdrive.ui.mici.widgets.dialog import BigConfirmationDialog, BigDialog
from openpilot.sunnypilot.models.helpers import ACTIVE_BUNDLE_KEYS, get_selected_bundle
from openpilot.selfdrive.ui.mici.widgets.button import BigButton
from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigParamControl
from openpilot.selfdrive.ui.ui_state import ui_state, device
from openpilot.selfdrive.ui.sunnypilot.model_info import (active_source, big_model_state, bundles_for_source, carrying_model,
default_model_name, model_cache_size_mb, model_info, queued_name,
@@ -95,7 +95,12 @@ class ModelsLayoutMici(NavScroller):
self.clear_cache_btn.set_click_callback(self._confirm_clear_cache)
self._cache_size_time = 0.0
self.main_items = [self.current_model_info, self.select_model_btn, self.cancel_download_btn, self.refresh_btn, self.clear_cache_btn]
self.blendful_toggle = BigParamControl("blendful (alpha)", "Blendful",
description="Gently pulls the model toward the lane center, as much as it trusts the lane lines, " +
"and yields around obstacles.")
self.main_items = [self.current_model_info, self.select_model_btn, self.cancel_download_btn, self.refresh_btn, self.clear_cache_btn,
self.blendful_toggle]
self._scroller.add_widgets(self.main_items)
@property
@@ -205,6 +210,10 @@ class ModelsLayoutMici(NavScroller):
device.set_override_interactive_timeout(None)
self._was_downloading = False
def show_event(self):
super().show_event()
self.blendful_toggle.refresh()
def _update_state(self):
super()._update_state()
@@ -213,6 +222,14 @@ class ModelsLayoutMici(NavScroller):
self.current_model_info.current_model_header._shimmer = False
self.current_model_info.info_header._shimmer = False
blendful_allowed = ui_state.active_bundle is not None
if blendful_allowed:
self.blendful_toggle.set_checked(ui_state.params.get_bool("Blendful"))
self.blendful_toggle.set_enabled(True)
else:
self.blendful_toggle.set_checked(False)
self.blendful_toggle.set_enabled(False)
manager = self.model_manager
self._download_frame += 1
should_update = self._download_frame % (gui_app.target_fps / 2) == 0
@@ -251,11 +268,13 @@ class ModelsLayoutMici(NavScroller):
self.current_model_info.info_text.set_text(tr("download failed"))
elif manager.selectedBundle and manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.downloading:
self.cancel_download_btn.set_visible(True)
self.cancel_download_btn.set_visible(ui_state.params.get("ModelManager_DownloadRef") is not None)
device.set_override_interactive_timeout(5)
progress = 0.0
count = 0
verifying = False
canceling = False
speed = 0.0
for model in manager.selectedBundle.models:
count += 1
p = model.artifact.downloadProgress
@@ -263,11 +282,14 @@ class ModelsLayoutMici(NavScroller):
custom.ModelManagerSP.DownloadStatus.verifying):
progress += p.progress
verifying = verifying or p.status == custom.ModelManagerSP.DownloadStatus.verifying
canceling = canceling or p.canceling
speed = max(speed, p.speed) # only one file downloads at a time, so the non-zero speed is the live one
elif p.status in (custom.ModelManagerSP.DownloadStatus.downloaded,
custom.ModelManagerSP.DownloadStatus.cached):
progress += 100.0
self.current_model_info.current_model_header.set_text(tr("verifying") if verifying else tr("downloading"))
header = tr("canceling...") if canceling else tr("verifying") if verifying else tr("downloading")
self.current_model_info.current_model_header.set_text(header)
self.cancel_download_btn.set_text(tr("cancel verification") if verifying else tr("cancel download"))
self.current_model_info.current_model_header._shimmer = True
name_text = manager.selectedBundle.internalName.lower()
@@ -276,7 +298,10 @@ class ModelsLayoutMici(NavScroller):
self.current_model_info.current_model_text.set_text(name_text)
self.current_model_info.info_header.set_text(tr("progress") + self._download_progress)
self.current_model_info.info_header._shimmer = True
self.current_model_info.info_text.set_text(f"{progress/count:.2f}%")
progress_text = f"{progress/count:.2f}%"
if not verifying and not canceling:
progress_text += f" | {speed / 1024 ** 2:.1f} MB/s" if speed > 0 else " | --.- MB/s"
self.current_model_info.info_text.set_text(progress_text)
elif manager.selectedBundle and manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.downloaded:
self.current_model_info.info_header.set_text(tr("downloaded"))
@@ -4,40 +4,22 @@ Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
import time
import pyray as rl
from functools import partial
from openpilot.cereal import custom
from openpilot.common.qrcode import make_texture
from openpilot.common.swaglog import cloudlog
from openpilot.common.version import sunnylink_consent_version, sunnylink_consent_declined
from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigToggle
from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationDialog, BigDialogBase
from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationDialog
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.onboarding import SunnylinkConsentPage
from openpilot.selfdrive.ui.sunnypilot.mici.widgets.sunnylink_pairing_dialog import SunnylinkPairingDialog
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.sunnypilot.sunnylink.api import UNREGISTERED_SUNNYLINK_DONGLE_ID
from openpilot.sunnypilot.sunnylink.athena.local_auth_daemon import (
clear_pairing_params,
read_grant_meta,
read_granted_apps,
read_pairing_qr,
request_revoke,
request_revoke_all,
)
from openpilot.sunnypilot.sunnylink.athena.local_app_rows import (
MAX_LOCAL_APP_ROWS,
LocalAppRow,
build_local_app_rows,
)
from openpilot.sunnypilot.sunnylink.athena.local_discovery import latest_discovered_app
from openpilot.sunnypilot.sunnylink.athena.local_state import PAIRING_REQUEST_KEY
from openpilot.system.ui.lib.application import gui_app, MousePos, FontWeight
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.label import UnifiedLabel
from openpilot.system.ui.widgets.scroller import NavScroller
from openpilot.common.version import sunnylink_consent_version, sunnylink_consent_declined
class SunnylinkInfo(Widget):
def __init__(self):
@@ -91,18 +73,11 @@ class SunnylinkLayoutMici(NavScroller):
self._sunnylink_uploader_toggle = BigToggle(text=tr("sunnylink uploader"), initial_state=False,
toggle_callback=self._sunnylink_uploader_callback)
self._local_toggle = BigToggle(text=tr("local connections"), initial_state=False,
toggle_callback=self._local_connections_callback)
self._mobile_app_btn = BigButton(tr("sunnylink local"), "")
self._mobile_app_btn.set_click_callback(lambda: gui_app.push_widget(LocalAppsPanelMici()))
self._scroller.add_widgets([
self._sunnylink_info,
self._sunnylink_toggle,
self._sunnylink_sponsor_button,
self._sunnylink_pair_button,
self._local_toggle,
self._mobile_app_btn,
self._backup_btn,
self._restore_btn,
self._sunnylink_uploader_toggle
@@ -136,11 +111,6 @@ class SunnylinkLayoutMici(NavScroller):
else:
self._sunnylink_pair_button.set_text(tr("pair"))
local_enabled = ui_state.params.get_bool("SunnylinkLocalEnabled")
self._local_toggle.set_visible(self._sunnylink_enabled)
self._local_toggle.set_checked(local_enabled)
self._mobile_app_btn.set_visible(self._sunnylink_enabled and local_enabled)
def show_event(self):
super().show_event()
ui_state.update_params()
@@ -170,8 +140,6 @@ class SunnylinkLayoutMici(NavScroller):
gui_app.push_widget(sl_terms_dlg)
else:
ui_state.params.put_bool("SunnylinkEnabled", state)
if not state:
clear_pairing_params()
ui_state.update_params()
@@ -179,11 +147,6 @@ class SunnylinkLayoutMici(NavScroller):
def _sunnylink_uploader_callback(state: bool):
ui_state.params.put_bool("EnableSunnylinkUploader", state)
@staticmethod
def _local_connections_callback(state: bool):
ui_state.params.put_bool("SunnylinkLocalEnabled", state)
ui_state.update_params()
def _handle_backup_restore_btn(self, restore: bool = False):
lbl = tr("slide to restore") if restore else tr("slide to backup")
icon = gui_app.texture("icons_mici/settings/device/update.png", 64, 64)
@@ -289,164 +252,3 @@ class SunnylinkPairBigButton(BigButton):
dlg = SunnylinkPairingDialog(sponsor_pairing=False)
if dlg:
gui_app.push_widget(dlg)
class LocalAppsPanelMici(NavScroller):
def __init__(self):
super().__init__()
self._rows_cache: list[LocalAppRow] = []
self._pair_app_btn = BigButton(tr("pair app"), "")
self._pair_app_btn.set_click_callback(lambda: gui_app.push_widget(LocalPairingDialogMici()))
self._revoke_all_btn = BigButton(tr("revoke all phones"), "")
self._revoke_all_btn.set_click_callback(self._confirm_revoke_all)
self._local_app_btns: list[BigButton] = []
for i in range(MAX_LOCAL_APP_ROWS):
btn = BigButton("", "")
btn.set_click_callback(partial(self._confirm_unpair_row, i))
self._local_app_btns.append(btn)
self._scroller.add_widgets([self._pair_app_btn, self._revoke_all_btn, *self._local_app_btns])
def _update_state(self):
super()._update_state()
grants = read_granted_apps()
self._rows_cache = build_local_app_rows(grants, read_grant_meta(), time.monotonic())
self._revoke_all_btn.set_visible(bool(grants))
for i, btn in enumerate(self._local_app_btns):
btn.set_visible(i < len(self._rows_cache))
if i < len(self._rows_cache):
row = self._rows_cache[i]
btn.set_text(row.title)
btn.set_value(row.subtitle)
def _confirm_revoke_all(self):
icon = gui_app.texture("icons_mici/settings/device/update.png", 64, 64)
dlg = BigConfirmationDialog(
tr("revoke all phones"),
icon,
confirm_callback=request_revoke_all,
red=True,
)
gui_app.push_widget(dlg)
def _confirm_unpair_row(self, index: int):
rows = self._rows_cache
if index >= len(rows):
return
row = rows[index]
def unpair():
# A request only: sunnylinkd revokes through its live authority on its next tick.
request_revoke(row.app_id)
icon = gui_app.texture("icons_mici/settings/device/update.png", 64, 64)
dlg = BigConfirmationDialog(
tr("slide to unpair"),
icon,
confirm_callback=unpair,
red=True,
)
gui_app.push_widget(dlg)
class LocalPairingDialogMici(BigDialogBase):
"""Pairing for the small display. This dialog only requests the window the local daemon
publishes and renders it; the phone scans it, enrolls through the cloud, then dials."""
# sunnylinkd services the request about once a second; taking longer means it is not running.
ARM_GRACE_S = 5.0
def __init__(self):
super().__init__()
self._requested_at = time.monotonic()
self._seen_qr = False
self._qr_string: str | None = None
self._qr_texture: rl.Texture | None = None
self._request_window()
self.set_back_callback(self._cancel)
subheader_color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65))
# One label doubles as the instruction and the live status: 240px of height has no room for a
# title, an instruction and a status line beside a full-height QR. Scanning is the only way
# in, and this payload is not something anyone could type from a screen.
self._hint = UnifiedLabel(tr("scan this code in the sunnylink app"), font_size=26,
text_color=subheader_color, line_height=0.9)
def _request_window(self) -> None:
try:
ui_state.params.put_bool(PAIRING_REQUEST_KEY, True, block=True)
except Exception:
cloudlog.exception("sunnylink.local_pairing.request_failed")
def _cancel(self) -> None:
# Params only: this process does not own the window, the daemon cancels it on its next tick.
clear_pairing_params()
def _refresh_qr_texture(self, qr: str | None) -> None:
"""Regenerate the texture only when the published QR actually changed."""
if qr == self._qr_string:
return
if self._qr_texture is not None and self._qr_texture.id != 0:
rl.unload_texture(self._qr_texture)
self._qr_texture = None
self._qr_string = qr
if not qr:
return
try:
# Standard polarity (dark modules, the encoder's own light quiet zone), not light-on-dark:
# on a panel this small the code is dense enough already without asking every scanner to
# handle an inverted symbol too.
self._qr_texture = make_texture(qr)
# The texture is drawn smaller than it is built, and the modules must stay hard-edged for
# a camera to read them.
rl.set_texture_filter(self._qr_texture, rl.TextureFilter.TEXTURE_FILTER_POINT)
except Exception:
cloudlog.exception("sunnylink.local_pairing.qr_texture_failed")
def _update_state(self):
super()._update_state()
if self.is_dismissing:
return
if read_pairing_qr() is not None:
self._seen_qr = True
return
# The daemon clears the published QR when the window ends (enrolled, expired or cancelled).
if self._seen_qr or time.monotonic() - self._requested_at > self.ARM_GRACE_S:
self.dismiss()
def _render(self, _):
self._refresh_qr_texture(read_pairing_qr())
rect = self._rect
# The QR takes the full height, the way the cloud pairing dialog sizes it and the only size the
# code can be scanned at on this display. Four pixels of margin is the border the panel needs;
# the quiet zone the scanner needs is inside the texture.
pad = 4
qr_size = int(rect.height - 2 * pad)
if self._qr_texture is not None:
qr_rect = rl.Rectangle(rect.x + pad, rect.y + pad, qr_size, qr_size)
source = rl.Rectangle(0, 0, self._qr_texture.width, self._qr_texture.height)
rl.draw_texture_pro(self._qr_texture, source, qr_rect, rl.Vector2(0, 0), 0, rl.WHITE)
text_x = int(rect.x + pad + qr_size + 20)
text_width = int(rect.x + rect.width - pad - text_x)
discovered = latest_discovered_app()
if discovered is not None:
endpoint, age = discovered
self._hint.set_text(endpoint if age < 2 else f"{endpoint} ({age}s)")
self._hint.set_text_color(rl.Color(0, 255, 0, 255))
else:
self._hint.set_text(tr("scan this code in the sunnylink app"))
self._hint.set_text_color(rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)))
self._hint.set_max_width(text_width)
self._hint.set_position(text_x, rect.y + pad)
self._hint.render()
def __del__(self):
if self._qr_texture is not None and self._qr_texture.id != 0:
rl.unload_texture(self._qr_texture)
@@ -6,6 +6,7 @@ See the LICENSE.md file in the root directory for more details.
"""
import pyray as rl
from openpilot.selfdrive.ui.ui_state import UIStatus
from openpilot.selfdrive.ui.sunnypilot.onroad.blendful import BlendfulRenderer
from openpilot.selfdrive.ui.sunnypilot.onroad.rainbow_path import RainbowPath
LANE_LINE_COLORS_SP = {
@@ -17,3 +18,4 @@ LANE_LINE_COLORS_SP = {
class ModelRendererSP:
def __init__(self):
self.rainbow_path = RainbowPath()
self.blendful = BlendfulRenderer()
@@ -0,0 +1,90 @@
"""
Copyright (c) 2021-, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
import numpy as np
import pyray as rl
from openpilot.common.filter_simple import FirstOrderFilter
from openpilot.common.realtime import DT_MDL
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.sunnypilot.modeld_v2.blendful import LANE_WIDTH_CLIP, LOOKAHEAD_TIME, MIN_LOOKAHEAD
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.shader_polygon import draw_polygon
from openpilot.system.ui.lib.text_measure import measure_text_cached
BLENDFUL_COLOR = (0, 200, 255)
FULL_BAR = 1.0
class BlendfulRenderer:
def __init__(self, readout: bool = True):
self._readout = readout
self._center_points = np.empty((0, 2), dtype=np.float32)
self._trust = 0.0
self._font = gui_app.font(FontWeight.BOLD)
self._pull_display = FirstOrderFilter(0.0, 0.5, DT_MDL)
self._trust_display = FirstOrderFilter(0.0, 0.5, DT_MDL)
self._alpha = FirstOrderFilter(0.0, 0.3, DT_MDL)
def update(self, renderer, max_idx: int, *map_args) -> None:
blendful = ui_state.sm['modelDataV2SP']
if ui_state.sm.updated['modelDataV2SP']:
self._alpha.update(float(blendful.blendfulTrust > 0.0))
if blendful.blendfulTrust > 0.0:
self._trust = blendful.blendfulTrust
if self._readout:
lookahead = max(ui_state.sm['carState'].vEgo * LOOKAHEAD_TIME, MIN_LOOKAHEAD)
self._pull_display.update(blendful.blendfulCurvature * lookahead ** 2 / 2)
self._trust_display.update(self._trust)
left, right = renderer._lane_lines[1].raw_points, renderer._lane_lines[2].raw_points
stds = list(ui_state.sm['modelV2'].laneLineStds)
if self._alpha.x < 0.01 or len(left) == 0 or len(right) != len(left) or len(stds) < 3:
self._center_points = np.empty((0, 2), dtype=np.float32)
return
l_prob, r_prob = blendful.blendfulLeftProb, blendful.blendfulRightProb
if blendful.blendfulTrust > 0.0 and l_prob + r_prob > 0.0:
half_width = np.clip(right[:, 1] - left[:, 1], *LANE_WIDTH_CLIP) / 2 if l_prob * r_prob > 0.5 else blendful.blendfulLaneWidth / 2
l_var = stds[1] ** 2 / max(l_prob, 1e-4)
r_var = stds[2] ** 2 / max(r_prob, 1e-4)
center = left.copy()
center[:, 1] = (r_var * (left[:, 1] + half_width) + l_var * (right[:, 1] - half_width)) / (l_var + r_var)
self._center_points = renderer._map_line_to_polygon(center, 0.15, 0.0, max_idx, *map_args)
def _color(self, rgb, alpha: float) -> rl.Color:
return rl.Color(*rgb, int(alpha * self._alpha.x))
def draw(self, rect: rl.Rectangle) -> None:
if self._center_points.size and self._trust > 0.05:
draw_polygon(rect, self._center_points, self._color(BLENDFUL_COLOR, np.clip(self._trust, 0.2, 1.0) * 255))
if self._center_points.size and self._readout:
self._draw_readout(rect)
def _draw_readout(self, rect: rl.Rectangle) -> None:
pull, trust = self._pull_display.x, self._trust_display.x
w, h = 190, 76
x, y = rect.x + (rect.width - w) / 2, rect.y + 10
cx, cy = x + w / 2, y + 30
rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, h), 0.5, 12, self._color((0, 0, 0), 170))
text = f"{abs(pull) * 100:.0f}"
text_w = measure_text_cached(self._font, text, 44, 0).x
rl.draw_text_ex(self._font, text, rl.Vector2(cx - text_w / 2, cy - 24), 44, 0, self._color((255, 255, 255), 255))
active = self._color(BLENDFUL_COLOR, np.clip(abs(pull) / 0.1, 0.0, 1.0) * 255)
idle = self._color((255, 255, 255), 40)
lx, rx = x + 18, x + w - 18
rl.draw_triangle(rl.Vector2(lx, cy), rl.Vector2(lx + 16, cy + 12), rl.Vector2(lx + 16, cy - 12), active if pull < 0 else idle)
rl.draw_triangle(rl.Vector2(rx - 16, cy - 12), rl.Vector2(rx - 16, cy + 12), rl.Vector2(rx, cy), active if pull > 0 else idle)
bar = rl.Rectangle(x + 24, y + h - 16, w - 48, 6)
rl.draw_rectangle_rounded(bar, 1.0, 6, self._color((255, 255, 255), 40))
rl.draw_rectangle(int(cx - 1), int(bar.y - 2), 2, int(bar.height + 4), self._color((255, 255, 255), 120))
fill = bar.width / 2 * float(np.sqrt(np.clip(abs(pull) / FULL_BAR, 0.0, 1.0)))
if fill > 1:
fill_x = cx if pull > 0 else cx - fill
rl.draw_rectangle_rounded(rl.Rectangle(fill_x, bar.y, fill, bar.height), 1.0, 6,
self._color(BLENDFUL_COLOR, np.clip(0.3 + 0.7 * trust, 0.0, 1.0) * 255))
@@ -6,6 +6,7 @@ See the LICENSE.md file in the root directory for more details.
"""
from openpilot.common.filter_simple import FirstOrderFilter
from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus
from openpilot.selfdrive.ui.sunnypilot.onroad.blendful import BlendfulRenderer
from openpilot.selfdrive.ui.sunnypilot.onroad.chevron_metrics import ChevronMetrics
from openpilot.selfdrive.ui.sunnypilot.onroad.rainbow_path import RainbowPath
from openpilot.selfdrive.ui.sunnypilot.ui_state import MADSState
@@ -16,6 +17,7 @@ class ModelRendererSP:
def __init__(self):
self.rainbow_path = RainbowPath()
self.chevron_metrics = ChevronMetrics()
self.blendful = BlendfulRenderer(readout=False)
self._width_filter = FirstOrderFilter(0.9, 0.1, 1 / gui_app.target_fps)
@property
@@ -36,7 +36,7 @@ class UIStateSP:
self.is_sp_release: bool = self.params.get_bool("IsReleaseSpBranch")
self.sm_services_ext = [
"modelManagerSP", "selfdriveStateSP", "longitudinalPlanSP", "backupManagerSP",
"lateralTorqueParameters", "carStateSP", "liveMapDataSP", "carParamsSP", "lateralDelay"
"lateralTorqueParameters", "carStateSP", "liveMapDataSP", "carParamsSP", "lateralDelay", "modelDataV2SP"
]
self.sunnylink_state = SunnylinkState()
+145
View File
@@ -0,0 +1,145 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
import numpy as np
from openpilot.common.filter_simple import FirstOrderFilter
from openpilot.common.realtime import DT_MDL
from openpilot.sunnypilot.modeld_v2.constants import ModelConstants
LANE_WIDTH_DEFAULT = 3.6 # m
LANE_WIDTH_CLIP = (2.8, 4.5) # m
LOOKAHEAD_TIME = 1.5 # s
MIN_LOOKAHEAD = 10.0 # m
MIN_SPEED = 5.0 # m/s
LINE_STD = (0.2, 0.6)
POINT_STD = 0.1
YIELD = (0.2, 0.45)
X_IDXS = np.array(ModelConstants.X_IDXS)
class Blendful:
def __init__(self):
self.lane_width = FirstOrderFilter(LANE_WIDTH_DEFAULT, 10.0, DT_MDL, initialized=False)
self.line_gone = FirstOrderFilter(np.zeros(2), 2.0, DT_MDL)
self.line_probs_slow = FirstOrderFilter(np.zeros(2), 3.0, DT_MDL)
self.line_probs = FirstOrderFilter(np.zeros(2), 0.5, DT_MDL)
self.usual_offset = FirstOrderFilter(0.0, 10.0, DT_MDL, initialized=False)
self.going_around = False
self.since_turning = 0.0
self.trust = 0.0
self.line_trust = 0.0
self.dk = 0.0 # delta kappa; difference between lane center and model plan
self.l_prob = 0.0
self.r_prob = 0.0
self.model_curvature = 0.0
self.smooth = 0.0 # smoothens lanelines below .53 lat accel
self.curvature = 0.0
def update(self, model_output: dict[str, np.ndarray], v_ego: float, active: bool, model_curvature: float, enabled: bool = True) -> float:
self.l_prob = self.r_prob = 0.0
self.model_curvature = model_curvature
if not enabled:
if self.curvature or self.trust:
self.__init__()
return 0.0
fading = not active or v_ego < MIN_SPEED
if fading:
self.trust = self.line_trust = 0.0
self.usual_offset.initialized = False
self.lane_width.initialized = False
self.going_around = False
target = 0.0 if fading else self._target(model_output, v_ego, model_curvature)
target = self.smooth = self.smooth + (target - self.smooth) * DT_MDL / (DT_MDL + np.interp(abs(model_curvature) * v_ego ** 2, [0.18, 0.53], [0.5, 0.0]))
build = 0.5 / max(v_ego, 1.0) ** 2 * DT_MDL # jerk over 1 second
release = (0.5 if fading else 2.0) / max(v_ego, 1.0) ** 2 * DT_MDL # lets go fast, but past zero it builds like any other pull
k = self.curvature
self.curvature = float(np.clip(target, max(k - release, -build) if k > 0 else k - build, min(k + release, build) if k < 0 else k + build))
return self.curvature
def _target(self, model_output: dict[str, np.ndarray], v_ego: float, model_curvature: float) -> float:
lookahead = max(v_ego * LOOKAHEAD_TIME, MIN_LOOKAHEAD)
lines = model_output['lane_lines'][0, :, :, 0]
stds = model_output['lane_lines_stds'][0, :, :, 0]
lone_weight = np.interp(abs(model_curvature) * v_ego ** 2, [0.18, 0.53], [0.5, 1.0]) # 0.5 on straights, 1.0 in curves
probs = self.line_probs.update(model_output['lane_lines_prob'][0, 1::2][1:3])
absent = (probs < 0.3) & (lone_weight > 0.5)
gone = self.line_gone.update((model_output['lane_lines_prob'][0, 1::2][1:3] < 0.3).astype(float))
missing = np.where(absent, 1.0, gone)
dropping = bool(np.any((probs < 0.6 * self.line_probs_slow.update(model_output['lane_lines_prob'][0, 1::2][1:3])) & ~absent))
l_prob = float(probs[0] * np.interp(np.interp(0.5 * lookahead, X_IDXS, stds[1]), LINE_STD, [1.0, 0.0]))
r_prob = float(probs[1] * np.interp(np.interp(0.5 * lookahead, X_IDXS, stds[2]), LINE_STD, [1.0, 0.0]))
self.l_prob, self.r_prob = l_prob, r_prob
# one line steadily on its own still centers at lone_weight. a line that is disappearing gives control back to laneless
if missing[1] > 0.8 and l_prob > 0.5:
lane_trust = l_prob * lone_weight
elif missing[0] > 0.8 and r_prob > 0.5:
lane_trust = r_prob * lone_weight
elif dropping:
lane_trust = min(l_prob, r_prob)
else:
lane_trust = l_prob + r_prob - l_prob * r_prob
if lone_weight > 0.5 and lane_trust < self.line_trust:
lane_trust = self.line_trust + (lane_trust - self.line_trust) * DT_MDL / (lone_weight - 0.5 + DT_MDL)
self.line_trust = lane_trust
if lane_trust <= 0.0:
self.trust = 0.0
return 0.0
if l_prob + r_prob <= 0.0: # keep the last fit while trust lets go
self.trust = min(self.trust, lane_trust)
return float(self.trust * self.dk)
win = (X_IDXS >= 0.5 * lookahead) & (X_IDXS <= 1.5 * lookahead)
x = X_IDXS[win]
half_width = self.lane_width.x / 2
if l_prob * r_prob > 0.5:
width = np.interp(lookahead, X_IDXS, lines[2]) - np.interp(lookahead, X_IDXS, lines[1])
self.lane_width.update(float(np.clip(width, *LANE_WIDTH_CLIP)))
half_width = np.clip(lines[2, win] - lines[1, win], *LANE_WIDTH_CLIP) / 2
l_var = stds[1, win] ** 2 / max(l_prob, 1e-4)
r_var = stds[2, win] ** 2 / max(r_prob, 1e-4)
center = (r_var * (lines[1, win] + half_width) + l_var * (lines[2, win] - half_width)) / (l_var + r_var)
weight = 1.0 / (l_var * r_var / (l_var + r_var) + POINT_STD ** 2)
# Fit curvature to place the plant in center: e ~ dk x^2 / 2
plan_x = np.interp(x, model_output['plan'][0, :, 0], model_output['plan'][0, :, 1])
plan_share = np.interp(abs(model_curvature) * lookahead ** 2 / 2, [0.5, 2.0], [0.3, 0.5]) # blending up to 50% plan with action during sharp curvature
error = np.clip(center - ((1 - plan_share) * model_curvature * x ** 2 / 2 + plan_share * plan_x), -1.0, 1.0)
dk = 2.0 * np.sum(weight * error * x ** 2) / np.sum(weight * x ** 4)
# A model planning to end up further from its usual spot in the lane than the plant is now is likely going around something
hw_now = np.clip(lines[2, 0] - lines[1, 0], *LANE_WIDTH_CLIP) / 2 if l_prob * r_prob > 0.5 else self.lane_width.x / 2
center_now = (l_prob * (lines[1, 0] + hw_now) + r_prob * (lines[2, 0] - hw_now)) / (l_prob + r_prob)
plan_y = np.interp(lookahead, model_output['plan'][0, :, 0], model_output['plan'][0, :, 1])
offset = plan_y - np.interp(lookahead, x, center)
if min(gone) > 0.5: # both lines gone: turns, intersections, relearn ideal model position
self.usual_offset.initialized = False
usual = self.usual_offset.x if self.usual_offset.initialized else offset
swerve = abs(offset - usual) - 0.25 * abs(model_curvature) * lookahead ** 2 / 2
away = swerve - abs(-center_now - usual)
lane_curvature = 2.0 * np.polyfit(x, center, 2)[0]
inside_curve = abs(lane_curvature) * lookahead ** 2 / 2 > 0.1 and (offset - usual) * lane_curvature > 0
self.since_turning = 0.0 if abs(model_curvature) * lookahead ** 2 / 2 > 0.5 else self.since_turning + DT_MDL
if inside_curve:
self.going_around = False
elif away > YIELD[0] and self.since_turning > 2.0:
self.going_around = True
elif swerve < YIELD[0] / 2:
self.going_around = False
self.usual_offset.update_alpha(20.0 if self.going_around else 10.0)
self.usual_offset.update(offset)
if inside_curve:
lane_trust *= np.interp(abs(offset) - abs(center_now) - 0.25 * abs(model_curvature) * lookahead ** 2 / 2, (0.5, 1.0), [1.0, 0.0])
else:
lane_trust *= np.interp(swerve, (YIELD[0] / 2, YIELD[0]), [1.0, 0.0]) if self.going_around else np.interp(away, YIELD, [1.0, 0.0])
if lane_trust < self.trust:
dk = float(np.clip(dk, min(self.dk, 0.0), max(self.dk, 0.0)))
self.trust = lane_trust if lane_trust < self.trust else self.trust + (lane_trust - self.trust) * DT_MDL / (1.5 + DT_MDL)
self.dk = dk
return float(self.trust * dk)
@@ -7,6 +7,7 @@ See the LICENSE.md file in the root directory for more details.
"""
import argparse
import dataclasses
import inspect
import itertools
import mmap
import re
@@ -45,6 +46,11 @@ def _address(v):
return _root(v.src[0]), add + start, size
def _buffer(u):
from tinygrad.uop.ops import Ops
return u.buffer if u.op is Ops.BUFFER else getattr(u.arg, "buffer", None)
def _patched(buf, topo) -> tuple[bytearray, dict]:
from tinygrad.uop.ops import Ops
blob, rows = bytearray(buf.max_numel() * buf.dtype.itemsize), {}
@@ -72,7 +78,7 @@ def _evaluate(u, env):
table, index = _root(u.src[0].src[0]), _evaluate(u.src[0].src[1], env)
return struct.unpack_from(u.dtype.fmt, table.arg, index * u.dtype.itemsize)[0] if table.op is Ops.BINARY and index is not None else None
if u.op in GroupOp.ALU and None not in (values := [_evaluate(x, env) for x in u.src]):
return exec_alu(u.op, u.dtype, values, False)
return exec_alu(u.op, u.dtype, values, *([False] if "truncate_output" in inspect.signature(exec_alu).parameters else []))
return None
@@ -112,7 +118,7 @@ def convert(jit, kwargs: dict, frame: dict, target) -> tuple[dict, memoryview]:
from tinygrad.uop.ops import Ops
submit = jit.captured._linear.src[0].without_after
kernels, topo = submit.arg.aux.kernels, list(jit.captured._linear.toposort(enter_calls=True))
allocs = {str(u.tag): u for u in topo if u.op is Ops.ALLOC and u.tag is not None}
allocs = {str(name): u for u in topo if u.op is Ops.ALLOC and (name := getattr(u, "tag", None) or u.arg.name) is not None}
(commands, patched), kernargs = _patched(allocs["cmdbuf_compute_0"], topo), allocs["kernargs_compute_0"]
argument_words, pokes = _patched(kernargs, topo)[1], _input_pokes(submit, kernargs)
sh = {name: getattr(amd_gpu, f"reg{name}") + amd_gpu.GC_BASE__INST0_SEG0 - pm4.PACKET3_SET_SH_REG_START
@@ -158,7 +164,7 @@ def convert(jit, kwargs: dict, frame: dict, target) -> tuple[dict, memoryview]:
args.append({"input": pokes[slot][0]})
elif slot in argument_words:
base, at, nbytes = _address(argument_words[slot])
args.append({"buffer": buffers.setdefault(base, len(buffers)), "offset": at, "nbytes": nbytes or base.arg.buffer.nbytes, "dtype": u8})
args.append({"buffer": buffers.setdefault(base, len(buffers)), "offset": at, "nbytes": nbytes or base.max_numel() * base.dtype.itemsize, "dtype": u8})
outs, ins = kernel[6]
calls.append({"kind": "program", "name": re.sub(r"\x1b\[[0-9;]*m", "", kernel[1]), "lib": programs[image_alloc], "target": dataclasses.asdict(target),
"signature": [[f"data{i}", i, u8, [a.get("nbytes", 1)]] for i, a in enumerate(args)], "global_size": grid, "local_size": local,
@@ -166,12 +172,13 @@ def convert(jit, kwargs: dict, frame: dict, target) -> tuple[dict, memoryview]:
needs_init |= {args[i]["buffer"] for i in ins if "buffer" in args[i] and args[i]["buffer"] not in written}
written |= {args[i]["buffer"] for i in outs if "buffer" in args[i]}
assert all(_buffer(b) is not None for b, i in buffers.items() if i in needs_init), "a kernel reads a scratch before any kernel writes it"
names = list(jit.captured.expected_names)
inputs = [{"slot": slot, "name": names[slot] if slot < len(names) else None, "dtype": repr(u.dtype), "numel": int(u.max_numel()), "device": u.device}
for slot, u in enumerate(_prepare_jit_inputs((), kwargs)[0])]
header = {"input_names": names, "calls": calls, "returns": {"kind": "NoneType", "tensors": []}, "inputs": inputs,
"buffers": [{"nbytes": b.arg.buffer.nbytes, "device": b.arg.buffer.device, "dtype": u8}
| ({"init": blob.add(b.arg.buffer.ensure_allocated().as_memoryview())} if i in needs_init else {}) for b, i in buffers.items()],
"buffers": [{"nbytes": bb.nbytes if (bb := _buffer(b)) else b.max_numel() * b.dtype.itemsize, "device": bb.device if bb else b.device, "dtype": u8}
| ({"init": blob.add(bb.ensure_allocated().as_memoryview())} if i in needs_init else {}) for b, i in buffers.items()],
"check": {"inputs": [[path, store(array), device] for path, array, device in frame["inputs"]],
"outputs": [store(array) for array in frame["outputs"]], "output_container": frame["output_container"]}}
with sink:
+9 -3
View File
@@ -250,6 +250,12 @@ def _allocate_buffers(header: dict, blob: memoryview, device_map: dict | None, s
return buffers
def _fields(what, /, **values): # a tinygrad dataclass built or replaced with the fields it has today: a dropped field is fine, a new required one is not
known = {field.name for field in dataclasses.fields(what)}
values = {name: value for name, value in values.items() if name in known}
return what(**values) if isinstance(what, type) else dataclasses.replace(what, **values)
def _program_uop(call: dict, lib: bytes):
from tinygrad.helpers import Target
from tinygrad.uop.ops import KernelInfo, Ops, ProgramInfo, UOp
@@ -257,9 +263,9 @@ def _program_uop(call: dict, lib: bytes):
for name, compact_slot, dtype, shape in call["signature"]:
parameter = UOp.param(call["globals"][compact_slot], _dtype(dtype), math.prod(shape))
image = (shape[0], shape[1]) if len(shape) == 3 and shape[2] == 4 else None
parameters.append(parameter.replace(arg=dataclasses.replace(parameter.arg, name=name, image=image)))
info = ProgramInfo(global_size=tuple(call["global_size"]), local_size=tuple(call["local_size"]), vars=(), globals=tuple(call["globals"]),
outs=tuple(call["outs"]), ins=tuple(call["ins"]), target=Target(**call["target"]))
parameters.append(parameter.replace(arg=_fields(parameter.arg, name=name, image=image)))
info = _fields(ProgramInfo, global_size=tuple(call["global_size"]), local_size=tuple(call["local_size"]), globals=tuple(call["globals"]),
outs=tuple(call["outs"]), ins=tuple(call["ins"]), target=_fields(Target, **call["target"]))
return UOp(Ops.PROGRAM, src=(UOp(Ops.SINK, arg=KernelInfo(name=call["name"])), UOp(Ops.LINEAR, src=tuple(parameters)),
UOp(Ops.SOURCE, arg=""), UOp(Ops.BINARY, arg=lib)), arg=info)
+19 -2
View File
@@ -7,6 +7,7 @@ See the LICENSE.md file in the root directory for more details.
"""
from collections.abc import Callable
from functools import partial
import gc
import os
os.environ['GMMU'] = '0'
@@ -39,6 +40,7 @@ from openpilot.selfdrive.modeld.modeld import ChestnutGpuState
from openpilot.sunnypilot.modeld_v2.fill_model_msg import fill_model_msg, fill_pose_msg, PublishState, get_curvature_from_output
from openpilot.sunnypilot.modeld_v2.parse_model_outputs import Parser
from openpilot.sunnypilot.modeld_v2.blendful import Blendful
from openpilot.sunnypilot.modeld_v2.constants import ModelConstants, Plan
from openpilot.sunnypilot.modeld_v2.meta_helper import load_meta_constants
from openpilot.sunnypilot.modeld_v2.camera_offset_helper import CameraOffsetHelper
@@ -228,7 +230,8 @@ class ModelState(ModelStateBase):
return outputs
def get_action_from_model(self, model_output: dict[str, np.ndarray], prev_action: log.ModelDataV2.Action,
lat_action_t: float, long_action_t: float, v_ego: float) -> log.ModelDataV2.Action:
lat_action_t: float, long_action_t: float, v_ego: float,
blendful: Callable[[float], float] | None = None) -> log.ModelDataV2.Action:
if 'action' not in model_output:
plan = model_output['plan'][0]
desired_accel = get_accel_from_plan(plan[:, Plan.VELOCITY][:, 0], plan[:, Plan.ACCELERATION][:, 0], self.constants.T_IDXS,
@@ -240,6 +243,8 @@ class ModelState(ModelStateBase):
else:
desired_accel = model_output['action'][0, 1]
desired_curvature = model_output['action'][0, 0] / (max(1.0, v_ego))**2
if blendful is not None:
desired_curvature += blendful(desired_curvature)
stop = v_ego < 0.3 and desired_accel < 0.1
desired_accel = smooth_value(desired_accel, prev_action.desiredAcceleration, self.LONG_SMOOTH_SECONDS)
@@ -366,6 +371,8 @@ def main(demo=False):
DH = DesireHelper()
meta_constants = load_meta_constants()
RELC = RoadEdgeLaneChangeController()
blendful = Blendful()
blendful_enabled = False
while True:
# Keep receiving frames until we are at least 1 frame ahead of previous extra frame
@@ -409,6 +416,7 @@ def main(demo=False):
model.lat_delay = get_lat_delay(params, sm["lateralDelay"].lateralDelay)
model.PLANPLUS_CONTROL = params.get("PlanplusControl", return_default=True)
camera_offset_helper.set_offset(params.get("CameraOffset", return_default=True))
blendful_enabled = params.get_bool("Blendful")
lat_delay = model.lat_delay + model.LAT_SMOOTH_SECONDS
if sm.updated["extrinsicsCalibration"] and sm.seen['narrowRoadCameraState'] and sm.seen['deviceState']:
device_from_calib_euler = np.array(sm["extrinsicsCalibration"].rpyCalib, dtype=np.float32)
@@ -482,7 +490,9 @@ def main(demo=False):
if not sm['carControl'].latActive:
prev_action.desiredCurvature = sm['controlsState'].curvature
action = model.get_action_from_model(model_output, prev_action, lat_action_t, long_action_t, v_ego)
blendful_active = desire == log.Desire.none and DH.lane_change_state == log.LaneChangeState.off
action = model.get_action_from_model(model_output, prev_action, lat_action_t, long_action_t, v_ego,
partial(blendful.update, model_output, v_ego, blendful_active, enabled=blendful_enabled))
prev_action = action
fill_model_msg(drivingdata_send, modelv2_send, model_output, action,
publish_state, meta_main.frame_id, meta_extra.frame_id, frame_id,
@@ -499,6 +509,13 @@ def main(demo=False):
modelv2_send.modelV2.meta.laneChangeDirection = DH.lane_change_direction
mdv2sp_send.valid = modelv2_send.valid
mdv2sp_send.modelDataV2SP.laneTurnDirection = DH.lane_turn_direction
mdv2sp_send.modelDataV2SP.blendfulCurvature = blendful.curvature
mdv2sp_send.modelDataV2SP.blendfulTrust = float(blendful.trust)
mdv2sp_send.modelDataV2SP.modelCurvature = float(blendful.model_curvature)
mdv2sp_send.modelDataV2SP.blendfulGoingAround = bool(blendful.going_around)
mdv2sp_send.modelDataV2SP.blendfulLeftProb = blendful.l_prob
mdv2sp_send.modelDataV2SP.blendfulRightProb = blendful.r_prob
mdv2sp_send.modelDataV2SP.blendfulLaneWidth = float(blendful.lane_width.x)
drivingdata_send.drivingModelData.meta.laneChangeState = DH.lane_change_state
drivingdata_send.drivingModelData.meta.laneChangeDirection = DH.lane_change_direction
@@ -23,7 +23,7 @@ def record_kernel_linear():
recorded, hcq_compile, compile_linear = [], realize.hcq_compile, jit.compile_linear
def recording_compile_linear(*args, **kwargs):
with mock.patch.object(realize, "hcq_compile", lambda linear, *a, **k: (recorded.append(linear), hcq_compile(linear, *a, **k))[1]):
with mock.patch.object(realize, "hcq_compile", lambda linear, *a, **k: (linear.src and recorded.append(linear), hcq_compile(linear, *a, **k))[1]):
return compile_linear(*args, **kwargs)
with mock.patch.object(jit, "compile_linear", recording_compile_linear):
@@ -38,6 +38,18 @@ def capture_frame(jit, kwargs: dict, output_container: str | None = None):
return {"inputs": inputs, "outputs": [tensor.numpy().copy() for tensor in outputs], "output_container": output_container}, result
def kernel_signature(ast, elf) -> list:
from tinygrad.dtype import AddrSpace
if hasattr(ast, "kernel_params"):
if any(p.arg.addrspace is AddrSpace.ALU for p in ast.kernel_params):
raise ValueError(f"{elf.name} takes a variable, which plain artifacts don't support")
compact = {slot: i for i, slot in enumerate(ast.arg.globals)}
return [[p.arg.name, compact[p.arg.slot], repr(p.dtype), [int(x) for x in p._shape]] for p in ast.kernel_params]
if not all(isinstance(slot, int) for _, slot, _, _ in elf.signature):
raise ValueError("TinyELF.signature lost its slots and this tinygrad has no UOp.kernel_params: update kernel_signature")
return [[name, slot, repr(dtype), [int(x) for x in shape]] for name, slot, dtype, shape in elf.signature]
def serialize_jit(jit, kernel_linear, check: dict | None = None) -> tuple[dict, memoryview]:
from tinygrad.uop.ops import Ops
sink = tempfile.TemporaryFile(dir=".")
@@ -51,6 +63,12 @@ def serialize_jit(jit, kernel_linear, check: dict | None = None) -> tuple[dict,
if uop.op is Ops.PARAM:
input_specs.setdefault(uop.arg.slot, {"dtype": repr(uop.dtype), "numel": int(uop.max_numel()), "device": uop.device})
return {"input": uop.arg.slot}
root, offset = uop, 0
while root.op in (Ops.AFTER, Ops.BITCAST, Ops.SHRINK):
offset, root = offset + (root.src[1].arg * root.src[0].dtype.itemsize if root.op is Ops.SHRINK else 0), root.src[0]
if root.op is Ops.ALLOC:
base = bases.setdefault(id(root), (len(bases), root, root.dtype))
return {"buffer": base[0], "offset": offset, "nbytes": int(uop.max_numel()) * uop.dtype.itemsize, "dtype": repr(uop.dtype)}
buffer = uop.buffer
root = buffer._base if buffer._base is not None else buffer
base = bases.setdefault(id(root), (len(bases), root, uop.base.dtype if buffer._base is None else uop.dtype))
@@ -63,7 +81,7 @@ def serialize_jit(jit, kernel_linear, check: dict | None = None) -> tuple[dict,
elf, info = ast.to_elf(), ast.arg
global_size, local_size = info.launch_dims({})
calls.append({"kind": "program", "name": elf.name, "lib": blob.add(bytes(elf.lib)), "target": dataclasses.asdict(elf.target),
"signature": [[name, slot, repr(dtype), [int(x) for x in shape]] for name, slot, dtype, shape in elf.signature],
"signature": kernel_signature(ast, elf),
"global_size": [int(x) for x in global_size], "local_size": [int(x) for x in local_size],
"globals": list(info.globals), "outs": list(info.outs), "ins": list(info.ins), "args": arguments})
outs, ins = info.outs, info.ins
@@ -77,7 +95,9 @@ def serialize_jit(jit, kernel_linear, check: dict | None = None) -> tuple[dict,
ret = jit.captured.ret
returns = {"kind": type(ret).__name__, "tensors": [describe(t.uop.base) | {"shape": [int(x) for x in t.shape]} for t in flatten(ret)]}
buffers = [{"nbytes": base.nbytes, "device": base.device, "dtype": repr(dtype)}
from tinygrad.device import Buffer
assert not [base for index, base, _ in bases.values() if index in needs_init and not isinstance(base, Buffer)], "a kernel reads a scratch before writing it"
buffers = [{"nbytes": base.nbytes if isinstance(base, Buffer) else int(base.max_numel()) * dtype.itemsize, "device": base.device, "dtype": repr(dtype)}
| ({"init": blob.add(base.as_memoryview())} if index in needs_init else {}) for index, base, dtype in bases.values()]
names = list(jit.captured.expected_names)
if check is not None:
@@ -0,0 +1,112 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
import numpy as np
from openpilot.common.realtime import DT_MDL
from openpilot.common.test import OpenpilotTestCase
from openpilot.sunnypilot.modeld_v2.blendful import Blendful
from openpilot.sunnypilot.modeld_v2.constants import ModelConstants
X = np.array(ModelConstants.X_IDXS)
BUILD, RELEASE = 0.5, 2.0
def frame(center=0.0, plan=0.0, k=0.0, probs=(0.9, 0.9), stds=(0.1, 0.1), width=3.5):
road = k * X ** 2 / 2
lines = np.zeros((1, 4, 33, 2))
lane_stds = np.ones((1, 4, 33, 2))
for i, off in enumerate((-1.5 * width, -width / 2, width / 2, 1.5 * width)):
lines[0, i, :, 0] = road + center + off
lane_stds[0, 1, :, 0], lane_stds[0, 2, :, 0] = stds
lane_probs = np.zeros((1, 8))
lane_probs[0, 3], lane_probs[0, 5] = probs
path = np.zeros((1, 33, 3))
path[0, :, 0] = X
path[0, :, 1] = road + plan * np.clip(X / 10, 0, 1)
return {'lane_lines': lines, 'lane_lines_stds': lane_stds, 'lane_lines_prob': lane_probs, 'plan': path}
def drive(bf, seconds, v=20.0, k=0.0, **kw):
out = [bf.update(frame(k=k, **kw), v, True, k) for _ in range(int(seconds / DT_MDL))]
return np.array(out) * v ** 2 # m/s^2
class TestBlendful(OpenpilotTestCase):
def setup_method(self):
self.bf = Blendful()
def test_centered_adds_nothing(self):
push = drive(self.bf, 5.0)
assert self.bf.trust > 0.9
assert np.abs(push).max() < 1e-3
def test_pulls_toward_lane_center(self):
push = drive(self.bf, 5.0, center=0.5)
assert push[-1] > 0.02 # + y side, where the lane center is
assert self.bf.trust > 0.9
assert np.all(np.diff(push) <= BUILD * DT_MDL + 1e-9)
def test_flipping_sides_builds_slowly(self):
k = 0.002 # 0.8 m/s^2 at 20 m/s
drive(self.bf, 5.0, k=k, center=0.5)
push = drive(self.bf, 5.0, k=k, center=-0.5)
rate = np.diff(push) / DT_MDL
assert push[-1] < 0
assert np.all(rate >= -RELEASE - 1e-6)
assert np.all(rate[push[:-1] <= 0] >= -BUILD - 1e-6) # once past zero it builds
crossing = push[1:][(push[:-1] > 0) & (push[1:] <= 0)]
assert len(crossing) and np.all(crossing >= -BUILD * DT_MDL - 1e-9)
def test_weak_lines_drive_laneless(self):
clear = drive(Blendful(), 5.0, center=0.5)[-1]
faint = Blendful()
push = drive(faint, 5.0, center=0.5, probs=(0.1, 0.1))
assert faint.trust < 0.25 and abs(push[-1]) < 0.25 * clear # faint lines = trust and push scale down
blurry = Blendful()
push = drive(blurry, 5.0, center=0.5, stds=(0.8, 0.8))
assert blurry.trust == 0.0 and np.abs(push).max() < 1e-6 # lines too uncertain = laneless
def test_lone_line_holds_through_a_blink_in_a_curve(self):
k, v = 1 / 20, 6.0
drive(self.bf, 6.0, v=v, k=k, plan=0.4, probs=(0.9, 0.05))
before = self.bf.trust
drive(self.bf, 0.25, v=v, k=k, plan=0.4, probs=(0.1, 0.05), stds=(1.0, 0.1))
bottom = self.bf.trust
drive(self.bf, 2.0, v=v, k=k, plan=0.4, probs=(0.9, 0.05))
assert before > 0.5
assert bottom > 0.5 * before # keeps latched through flicker
assert self.bf.trust > 0.85 * before
def test_yields_when_the_model_goes_around(self):
drive(self.bf, 6.0)
push = drive(self.bf, 3.0, plan=0.8)
assert self.bf.going_around
assert self.bf.trust < 0.05
assert abs(push[-1]) < 0.01
def test_fit_does_not_grow_while_trust_falls(self):
drive(self.bf, 5.0, center=0.3)
for i in range(40):
dk, trust = self.bf.dk, self.bf.trust
p = 0.9 - 0.02 * i
self.bf.update(frame(center=0.3, plan=-0.02 * i, probs=(p, p)), 20.0, True, 0.0)
if self.bf.trust < trust:
assert abs(self.bf.dk) <= abs(dk) + 1e-12
def test_toggle_off_returns_zero_and_resets(self):
drive(self.bf, 5.0, center=0.5)
assert self.bf.curvature != 0.0
assert self.bf.update(frame(center=0.5), 20.0, True, 0.0, enabled=False) == 0.0
assert self.bf.curvature == 0.0 and self.bf.trust == 0.0
def test_fades_below_min_speed(self):
drive(self.bf, 5.0, v=10.0, center=0.5)
start = self.bf.curvature
out = np.array([self.bf.update(frame(center=0.5), 4.0, True, 0.0) for _ in range(100)])
assert self.bf.trust == 0.0
assert abs(out[0]) < abs(start)
assert abs(out[-1]) < 1e-6
@@ -0,0 +1,121 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
import os
import re
import sys
import tempfile
import unittest
from unittest import mock
import numpy as np
os.environ.setdefault("CHECK_OOB", "0")
os.environ.setdefault("GMMU", "0")
if sys.platform == "darwin" and os.environ.get("DEV", "").startswith("MOCKUSB"): # the mock's VRAM is a memfd, which macOS lacks: an unlinked file is the same
from tinygrad.runtime.autogen import libc
def _memfd_create(name, flags):
fd, path = tempfile.mkstemp()
os.unlink(path)
return fd
libc.memfd_create = _memfd_create
from openpilot.common.test import OpenpilotTestCase
from openpilot.sunnypilot.modeld_v2 import convert_upstream_pkl
from openpilot.sunnypilot.modeld_v2.convert_upstream_pkl import convert
from openpilot.sunnypilot.modeld_v2.helpers import PlainJit, load_model
from openpilot.sunnypilot.modeld_v2.plain_artifact_compiler import capture_frame
IMG, DESIRE, STATE, OUT = (6, 8, 8), (8,), (4, 16), (1, 32)
class TestConvertUpstreamPkl(OpenpilotTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
if not os.environ.get("TINYGRAD_TESTS"):
raise unittest.SkipTest("TINYGRAD_TESTS not set. skipping converter test")
from tinygrad import Device, Tensor
assert Device.DEFAULT == "AMD" and getattr(Device["AMD"], "is_usb", False), f"{Device.DEFAULT} is not an AMD device behind the USB bridge"
assert (Tensor([1, 2, 3]) * 2).tolist() == [2, 4, 6]
cls.tmp = tempfile.TemporaryDirectory()
cls.rng = np.random.default_rng(0)
cls.weights = Tensor(cls.rng.standard_normal((16, 32)).astype(np.float32)).realize()
cls.bias = Tensor(cls.rng.standard_normal((1, 32)).astype(np.float32)).realize()
cls.input_specs = {"input_imgs": (IMG, "uint8", Device.DEFAULT), "desire": (DESIRE, "float32", Device.DEFAULT),
"features": (STATE, "float32", Device.DEFAULT)}
cls.output_specs = {"outputs": (OUT, "float32", Device.DEFAULT), "next_features": (STATE, "float32", Device.DEFAULT)}
@classmethod
def tearDownClass(cls):
cls.tmp.cleanup()
super().tearDownClass()
def model(self, input_imgs, desire, features, output_buffers):
from tinygrad import Tensor
feat = features.mean(axis=0).reshape(1, 16) + input_imgs.float().mean() + desire.sum()
Tensor.realize(output_buffers["outputs"].assign((feat @ self.weights + self.bias).relu()),
output_buffers["next_features"].assign(features[1:].cat(feat * 2)))
def inputs(self, seed: int) -> dict:
from examples.openpilot.helpers import allocate_inputs
rng = np.random.default_rng(seed)
def initialize(arrays: dict):
for value in arrays.values():
value[...] = rng.standard_normal(value.shape) if value.dtype.kind == "f" else rng.integers(0, 256, value.shape)
return allocate_inputs(self.input_specs, initialize) | {"output_buffers": allocate_inputs(self.output_specs)}
def jit(self):
from tinygrad import TinyJit
run = TinyJit(self.model, prune=True)
kwargs = self.inputs(1)
run(**kwargs)
run(**kwargs)
return run
def assert_matches_eager(self, plain, seed: int):
fresh, expected = self.inputs(seed), self.inputs(seed)
plain(**fresh)
self.model(**expected)
for name in self.output_specs:
assert np.array_equal(fresh["output_buffers"][name].numpy(), expected["output_buffers"][name].numpy()), name
def convert(self, jit) -> tuple[dict, memoryview]:
from tinygrad import Device
kwargs = self.inputs(2)
frame, _ = capture_frame(jit, kwargs, "output_buffers")
header, blob = convert(jit, kwargs, frame, Device[Device.DEFAULT].renderer.target)
kernels = jit.captured._linear.src[0].without_after.arg.aux.kernels
assert [call["name"] for call in header["calls"]] == [re.sub(r"\x1b\[[0-9;]*m", "", kernel[1]) for kernel in kernels]
assert all(re.fullmatch(r"\w+", call["name"]) for call in header["calls"]), "kernel names: failure point 1"
assert [(tuple(call["outs"]), tuple(call["ins"])) for call in header["calls"]] == [tuple(kernel[6]) for kernel in kernels], "outs/ins: failure point 1"
bound = {argument["input"] for call in header["calls"] for argument in call["args"] if "input" in argument}
assert bound == {spec["slot"] for spec in header["inputs"]}, f"input slots bound by pokes {sorted(bound)}: failure point 4"
assert [spec["name"] for spec in header["inputs"]][:len(self.input_specs)] == sorted(self.input_specs)
return header, blob
def test_converted_jit_matches_eager(self): # the submit decoded into plain calls, replayed against its own frame
jit = self.jit()
plain = PlainJit(*self.convert(jit))
self.assert_matches_eager(plain, 3)
self.assert_matches_eager(plain, 4)
def test_pickle_round_trip_through_main(self):
from examples.openpilot.helpers import dump_pickle, load_pickle
from tinygrad.engine.realize import lower_and_compile
pkl, out = os.path.join(self.tmp.name, "tiny_tinygrad.pkl"), os.path.join(self.tmp.name, "tiny_plain.pkl")
dump_pickle({"run": self.jit(), "input_specs": self.input_specs, "output_specs": self.output_specs, "metadata": {"name": "tiny"}}, pkl)
artifact = load_pickle(pkl, out_of_band=True)
artifact["run"].captured._linear = lower_and_compile(artifact["run"].captured._linear)
self.assert_matches_eager(PlainJit(*self.convert(artifact["run"])), 5)
with mock.patch.object(sys, "argv", ["convert_upstream_pkl", pkl, out]):
convert_upstream_pkl.main()
bundle = load_model(out, cache=False, verify=True)
assert bundle["metadata"] == {"name": "tiny"} and bundle["input_specs"] == self.input_specs and bundle["output_specs"] == self.output_specs
self.assert_matches_eager(bundle["run"], 6)
@@ -0,0 +1,162 @@
"""
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 dataclasses
import functools
import inspect
import os
import sys
import tempfile
from unittest import mock
import numpy as np
import unittest
import openpilot.sunnypilot.modeld_v2.helpers as helpers
from openpilot.common.test import OpenpilotTestCase
from openpilot.sunnypilot.modeld_v2 import compile_warp
from openpilot.sunnypilot.modeld_v2.frame_fusion import frame_part, pack_layout
from openpilot.sunnypilot.modeld_v2.helpers import PlainJit, cache_is_fresh, load_model, read_parts, write_bundle
from openpilot.sunnypilot.modeld_v2.plain_artifact_compiler import capture_frame, record_kernel_linear, serialize_jit
CAM_W, CAM_H, MODEL_W, MODEL_H = 32, 16, 16, 8
FRAME = f"{CAM_W},{CAM_H},{CAM_W},{CAM_H},{CAM_H // 2},{CAM_W * CAM_H * 3 // 2}"
FRAME_SIZE = CAM_W * CAM_H * 3 // 2
HOST = {"desire": (8,), "traffic_convention": (2,)}
STATE, OUT = (4, 16), (1, 32)
def compile_jit(fn, inputs: dict, output_container: str | None = None) -> tuple[dict, memoryview]:
from tinygrad import TinyJit
run = TinyJit(fn, prune=True)
with record_kernel_linear() as recorded:
run(**inputs)
run(**inputs)
check, _ = capture_frame(run, inputs, output_container)
assert len(recorded) == 1, f"expected one captured kernel list, got {len(recorded)}"
return serialize_jit(run, recorded[0], check)
class TestPlainArtifact(OpenpilotTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
if not os.environ.get("TINYGRAD_TESTS"):
raise unittest.SkipTest("TINYGRAD_TESTS env var not set, skipping artifact test.")
from tinygrad import Tensor
cls.tmp = tempfile.TemporaryDirectory()
cls.rng = np.random.default_rng(0)
cls.weights = Tensor(cls.rng.standard_normal((16, 32)).astype(np.float32)).realize()
cls.bias = Tensor(cls.rng.standard_normal((1, 32)).astype(np.float32)).realize()
@classmethod
def tearDownClass(cls):
cls.tmp.cleanup()
super().tearDownClass()
def model(self, x, h): # a two input, one weight model: the shape of a policy without the size
return ((x @ self.weights + self.bias).relu() + h.mean()).contiguous()
def tensors(self, seed: int):
from tinygrad import Tensor
rng = np.random.default_rng(seed)
return {"x": Tensor(rng.standard_normal((1, 16)).astype(np.float32)).realize(), "h": Tensor(rng.standard_normal((4, 16)).astype(np.float32)).realize()}
def artifact(self, name: str = "model") -> str:
path = os.path.join(self.tmp.name, f"{name}_tinygrad.pkl")
if not os.path.exists(path):
write_bundle(path, {"run": compile_jit(self.model, self.tensors(1))}, {"metadata": {"name": name}})
return path
def test_round_trip_matches_jit(self): # compiler -> bundle -> PlainJit, replay verified, then fresh inputs bit exact with eager tinygrad
bundle = load_model(self.artifact(), cache=False, verify=True)
assert bundle["metadata"] == {"name": "model"} and isinstance(bundle["run"], PlainJit)
fresh = self.tensors(2)
assert np.array_equal(bundle["run"](**fresh).numpy(), self.model(**fresh).numpy())
def test_replay_check_catches_a_changed_kernel(self):
parts, extras = read_parts(self.artifact())
header, blob = parts["run"]
poisoned = dict(header, check=dict(header["check"], outputs=[header["check"]["inputs"][0][1]]))
with self.assertRaises(helpers.ArtifactMismatch):
PlainJit(poisoned, blob)
def test_cache_round_trip_holds_no_weights_and_is_rebuilt_when_stale(self):
path = self.artifact("cached")
expected = load_model(path, cache=False, verify=False)["run"](**self.tensors(3)).numpy()
load_model(path)
assert cache_is_fresh(path)
with open(f"{path}.cache", "rb") as file:
cache = file.read()
assert self.weights.numpy().tobytes() not in cache and len(cache) < os.path.getsize(path)
with mock.patch.object(helpers, "PlainJit", side_effect=AssertionError("a fresh cache must not rebuild")):
cached = load_model(path)
assert np.array_equal(cached["run"](**self.tensors(3)).numpy(), expected)
with mock.patch.object(helpers, "tinygrad_identity", return_value="another tinygrad"):
assert not cache_is_fresh(path)
assert isinstance(load_model(path)["run"], PlainJit)
assert cache_is_fresh(path)
assert not cache_is_fresh(path) # keyed on the tinygrad that wrote it, so the next boot after a bump rebuilds once
os.utime(path)
with mock.patch.object(helpers, "tinygrad_identity", return_value="another tinygrad"):
assert not cache_is_fresh(path)
def test_fused_with_warp_matches_warp_then_model(self):
from tinygrad import Tensor
warp_path = os.path.join(self.tmp.name, "warp_tinygrad.pkl")
with mock.patch.object(sys, "argv", ["compile_warp", "--frame", FRAME, "--warp-to", f"{MODEL_W}x{MODEL_H}", "--output", warp_path]):
compile_warp.main()
warp = load_model(warp_path, cache=False)["run"]
def native(new_img, desire, traffic_convention, state_feat_q, output_buffers):
feat = state_feat_q.mean(axis=0).reshape(1, 16) + new_img.float().mean() + desire.sum() + traffic_convention[0]
Tensor.realize(output_buffers["outputs"].assign((feat @ self.weights + self.bias).relu()),
output_buffers["next_state_feat_q"].assign(state_feat_q[1:].cat(feat * 2)))
def model_inputs(new_img):
return {"new_img": new_img} | {name: Tensor(np.ones(shape, np.float32) * 0.5).realize() for name, shape in HOST.items()} | \
{"state_feat_q": Tensor(self.rng.standard_normal(STATE).astype(np.float32)).realize(),
"output_buffers": {"outputs": Tensor.zeros(OUT).contiguous().realize(), "next_state_feat_q": Tensor.zeros(STATE).contiguous().realize()}}
frames = Tensor(self.rng.integers(0, 256, (2, FRAME_SIZE), dtype=np.uint8)).realize()
tfm = Tensor(np.tile(np.eye(3, dtype=np.float32), (2, 1, 1))).realize()
path = os.path.join(self.tmp.name, "native_tinygrad.pkl")
part = compile_jit(native, model_inputs(warp(input_frame=frames, M_inv=tfm).realize()), "output_buffers")
extras = {"input_specs": {"new_img": ((2, 6, MODEL_H // 2, MODEL_W // 2), "|u1", "CPU"), "state_feat_q": (STATE, "<f4", "CPU")}
| {name: (shape, "<f4", "CPU") for name, shape in HOST.items()},
"output_specs": {"outputs": (OUT, "<f4", "CPU"), "next_state_feat_q": (STATE, "<f4", "CPU")},
"metadata": {"output_shapes": {"outputs": OUT, "next_state_feat_q": STATE}}}
write_bundle(path, {"run": part}, extras)
fused = load_model(path, cache=False, extend=functools.partial(frame_part, warp_path))
assert fused["run"].input_names == ["host", "state_feat_q"]
offsets, packed = pack_layout(HOST, FRAME_SIZE)
host = np.zeros(packed, np.uint8)
inputs = model_inputs(None)
for name, shape in ({"tfm": (2, 3, 3)} | HOST).items():
np.ndarray(shape, np.float32, host, offsets[name])[...] = tfm.numpy() if name == "tfm" else inputs[name].numpy()
host[offsets["frames"]:] = frames.numpy().ravel()
fused["run"](host=Tensor(host, device="NPY"), state_feat_q=inputs["state_feat_q"], output_buffers=inputs["output_buffers"])
expected = model_inputs(warp(input_frame=frames, M_inv=tfm).realize()) | {k: inputs[k] for k in ("state_feat_q",)}
native(**expected)
for name in ("outputs", "next_state_feat_q"):
assert np.array_equal(inputs["output_buffers"][name].numpy(), expected["output_buffers"][name].numpy()), name
def test_tinygrad_api_the_loader_reads(self):
import tinygrad.engine.jit as jit
from tinygrad.device import Buffer, TinyELF
from tinygrad.helpers import Target
from tinygrad.uop.ops import ParamArg, ProgramInfo, UOp
fields = {cls: {f.name for f in dataclasses.fields(cls)} for cls in (ProgramInfo, ParamArg, Target)}
assert fields[ProgramInfo] >= {"global_size", "local_size", "globals", "outs", "ins", "target"}, "ProgramInfo changed: update helpers._program_uop"
assert fields[ParamArg] >= {"slot", "dtype", "name", "image"}, "ParamArg changed: update helpers._program_uop"
assert fields[Target] >= {"device", "renderer", "arch"}, "Target changed: update the 'target' key in plain_artifact_compiler.serialize_jit"
assert "signature" in getattr(TinyELF, "__annotations__", {}) or hasattr(UOp, "kernel_params"), \
"kernel signatures moved: update plain_artifact_compiler.serialize_jit"
assert callable(getattr(UOp, "param", None)) and callable(getattr(UOp, "from_buffer", None)), "UOp.param/from_buffer: update helpers.PlainJit"
assert callable(getattr(UOp, "store_call", None)) and callable(getattr(UOp, "call", None)), "UOp.call/store_call: update helpers.PlainJit"
assert callable(getattr(Buffer, "view", None)) and callable(getattr(Buffer, "ensure_allocated", None)), "Buffer.view: update helpers._upload"
assert callable(getattr(jit, "compile_linear", None)) and callable(getattr(jit, "_prepare_jit_inputs", None)), "engine.jit: update helpers._Jit"
assert "expected_names" in inspect.signature(jit.CapturedJit).parameters, "CapturedJit: update helpers.PlainJit and plain_artifact_compiler.serialize_jit"
+168 -48
View File
@@ -7,7 +7,10 @@ See the LICENSE.md file in the root directory for more details.
import asyncio
import os
import re
import threading
import time
from concurrent.futures import ThreadPoolExecutor
import requests
from openpilot.common.params import Params
@@ -22,12 +25,101 @@ from openpilot.sunnypilot.models.helpers import (ACTIVE_BUNDLE_KEYS, get_active_
# (connect, read) seconds. read is per-request inactivity, not a total cap
DOWNLOAD_TIMEOUT = (30, 30)
# HuggingFace throttles each connection to ~1-2 MB/s but not the connection count; 12 saturates the device link
MAX_CONCURRENT_PIECES = 12
PIECE_SIZE = 8 * 1024 * 1024
PIECE_ATTEMPTS = 3
PIECE_BACKOFF = 1.0 # seconds, growing linearly: waits 1 s then 2 s between a piece's three attempts
TRANSIENT_HTTP_STATUS = frozenset({408, 429, 500, 502, 503, 504})
REPORT_INTERVAL = 0.5 # seconds between progress publications
# byte ranges count encoded bytes but iter_content yields decoded ones, so ask for an unencoded body
NO_COMPRESSION = {"Accept-Encoding": "identity"}
SPEED_SMOOTHING = 0.7 # weight of the previous speed sample in the published speed
SPEED_FLOOR = 0.05 * 1024 ** 2 # bytes/s; anything slower would only display as 0.0 MB/s, so it reads as no rate
class DownloadCancelled(Exception):
pass
def _is_transient(e: BaseException) -> bool:
if isinstance(e, requests.HTTPError):
return e.response is not None and e.response.status_code in TRANSIENT_HTTP_STATUS
return isinstance(e, (requests.ConnectionError, requests.Timeout, requests.exceptions.ChunkedEncodingError))
class _Progress:
def __init__(self):
self._bytes = 0
self._lock = threading.Lock()
def add_bytes(self, n: int) -> None:
with self._lock:
self._bytes += n
def snapshot(self) -> int:
with self._lock:
return self._bytes
def _piece_ranges(total: int, piece_size: int) -> list[tuple[int, int]]:
return [(start, min(start + piece_size, total)) for start in range(0, total, piece_size)]
def _prepare_target(path: str, total: int) -> None:
with open(path, "wb") as f:
f.truncate(total)
def _content_range_total(response: requests.Response) -> int | None:
if response.status_code != 206:
return None
match = re.fullmatch(r"bytes \d+-\d+/(\d+)", response.headers.get("Content-Range", ""))
return int(match.group(1)) if match else None
def _probe_size(url: str) -> tuple[int, bool]:
with requests.get(url, headers={**NO_COMPRESSION, "Range": "bytes=0-0"}, stream=True, timeout=DOWNLOAD_TIMEOUT) as response:
response.raise_for_status()
total = _content_range_total(response)
if total is not None:
return total, True
return int(response.headers.get("Content-Length", 0)), False
def _fetch_piece(url: str, path: str, index: int, start: int, end: int | None, progress: _Progress,
cancel: threading.Event, block_size: int) -> None:
headers = {**NO_COMPRESSION, "Range": f"bytes={start}-{end - 1}"} if end is not None else NO_COMPRESSION
label = f"{os.path.basename(path)} piece {index}"
for attempt in range(PIECE_ATTEMPTS):
written = 0
try:
with requests.get(url, headers=headers, stream=True, timeout=DOWNLOAD_TIMEOUT) as response:
response.raise_for_status()
if end is not None and response.status_code != 206:
raise ValueError(f"server ignored byte range for {label}")
with open(path, "r+b") as f:
f.seek(start)
for block in response.iter_content(chunk_size=block_size):
if cancel.is_set():
raise DownloadCancelled("Download cancelled")
f.write(block)
written += len(block)
progress.add_bytes(len(block))
if end is None:
f.truncate() # unknown length: the body defines the file size
if end is not None and written != end - start:
raise requests.exceptions.ConnectionError(f"short read: {written} of {end - start} bytes")
return
except requests.RequestException as e:
progress.add_bytes(-written) # the piece restarts from scratch
if not _is_transient(e) or attempt == PIECE_ATTEMPTS - 1:
raise
cloudlog.warning(f"retrying {label} after {type(e).__name__}: {e}")
if cancel.wait(PIECE_BACKOFF * (1 + attempt)):
raise DownloadCancelled("Download cancelled") from None
class ModelManagerSP:
"""Manages model downloads and status reporting"""
@@ -41,7 +133,7 @@ class ModelManagerSP:
self.source_models: dict[str, list[custom.ModelManagerSP.ModelBundle]] = {}
self.selected_bundle: custom.ModelManagerSP.ModelBundle = None
self.active_bundle: custom.ModelManagerSP.ModelBundle = get_active_bundle(self.params, chestnut=self.chestnut_present)
self._chunk_size = 128 * 1000 # 128 KB chunks
self._block_size = 128 * 1000 # 128 KB network read blocks
self._download_start_times: dict[str, float] = {} # Track start time per model
self._download_ref: bytes | str | None = None
@@ -56,7 +148,6 @@ class ModelManagerSP:
self._download_ref = None
def _sync_artifact_progress(self, source_artifact) -> None:
"""Mirror download progress to all artifacts sharing the same filename in the selected bundle."""
if not self.selected_bundle:
return
for model in self.selected_bundle.models:
@@ -65,9 +156,9 @@ class ModelManagerSP:
artifact.downloadProgress.status = source_artifact.downloadProgress.status
artifact.downloadProgress.progress = source_artifact.downloadProgress.progress
artifact.downloadProgress.eta = source_artifact.downloadProgress.eta
artifact.downloadProgress.speed = source_artifact.downloadProgress.speed
def _calculate_eta(self, filename: str, progress: float) -> int:
"""Calculate ETA based on elapsed time and current progress"""
if filename not in self._download_start_times or progress <= 0:
return 60 # Default ETA for new downloads
@@ -75,39 +166,74 @@ class ModelManagerSP:
if elapsed_time <= 0:
return 60
# If we're at X% after Y seconds, we can estimate total time as (Y / X) * 100
total_estimated_time = (elapsed_time / progress) * 100
eta = total_estimated_time - elapsed_time
return max(1, int(eta)) # Return at least 1 second if download is ongoing
async def _download_file(self, url: str, path: str, model) -> None:
"""Downloads a file with progress tracking"""
self._download_start_times[model.fileName] = time.monotonic()
def _set_progress(self, artifact, status, progress: float, eta: int = 0, speed: float = 0.0) -> None:
artifact.downloadProgress.status = status
artifact.downloadProgress.progress = progress
artifact.downloadProgress.eta = eta
artifact.downloadProgress.speed = speed
self._sync_artifact_progress(artifact)
self._report_status()
with requests.get(url, stream=True, timeout=DOWNLOAD_TIMEOUT) as response: # noqa: ASYNC210
response.raise_for_status()
total_size = int(response.headers.get("content-length", 0))
bytes_downloaded = 0
def _publish_progress(self, artifact, done_bytes: int, total: int, speed: float) -> None:
# held at 99 until the file passes its hash check
progress = min(99.0, done_bytes / total * 100) if total > 0 else 0.0
if speed > 0 and total > 0:
eta = max(1, int((total - done_bytes) / speed))
else:
eta = self._calculate_eta(artifact.fileName, progress)
self._set_progress(artifact, custom.ModelManagerSP.DownloadStatus.downloading, progress, eta, speed)
with open(path, 'wb') as f: # noqa: ASYNC230
for chunk in response.iter_content(chunk_size=self._chunk_size): # type: bytes
f.write(chunk)
bytes_downloaded += len(chunk)
async def _report_until_done(self, tasks: list[asyncio.Future], artifact, progress: _Progress, total: int,
started: tuple[int, float]) -> None:
pending = set(tasks)
last_bytes, last_time = started
speed = 0.0
while pending:
done, pending = await asyncio.wait(pending, timeout=REPORT_INTERVAL)
for task in done:
task.result() # re-raises a worker failure
if self._download_interrupted():
raise DownloadCancelled("Download cancelled")
now = time.monotonic()
done_bytes = progress.snapshot()
if (dt := now - last_time) > 0:
instant = max(0.0, (done_bytes - last_bytes) / dt) # bytes/s this tick; clamped since retried pieces can lower the byte count
speed = instant if speed == 0 else SPEED_SMOOTHING * speed + (1 - SPEED_SMOOTHING) * instant
if speed < SPEED_FLOOR: # a stall decays toward 0 without reaching it, and a near-0 speed overflows the eta
speed = 0.0
last_time, last_bytes = now, done_bytes
self._publish_progress(artifact, done_bytes, total, speed)
if self._download_interrupted():
raise DownloadCancelled("Download cancelled")
async def _download_file(self, url: str, path: str, artifact) -> None:
self._download_start_times[artifact.fileName] = time.monotonic()
loop = asyncio.get_running_loop()
if total_size > 0:
progress = (bytes_downloaded / total_size) * 100
model.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.downloading
model.downloadProgress.progress = progress
model.downloadProgress.eta = self._calculate_eta(model.fileName, progress)
self._sync_artifact_progress(model)
self._report_status()
total, ranged = await loop.run_in_executor(None, _probe_size, url)
pieces: list[tuple[int, int | None]] = list(_piece_ranges(total, PIECE_SIZE)) if ranged else [(0, None)]
await loop.run_in_executor(None, _prepare_target, path, total)
progress = _Progress()
cancel = threading.Event()
started = progress.snapshot(), time.monotonic() # before any worker can land bytes
# Clean up start time after download completes
del self._download_start_times[model.fileName]
with ThreadPoolExecutor(max_workers=MAX_CONCURRENT_PIECES) as pool:
tasks = [loop.run_in_executor(pool, _fetch_piece, url, path, i, start, end, progress, cancel, self._block_size)
for i, (start, end) in enumerate(pieces)]
try:
await self._report_until_done(tasks, artifact, progress, total, started)
except BaseException as e:
cancel.set()
if isinstance(e, DownloadCancelled):
artifact.downloadProgress.canceling = True
self._set_progress(artifact, custom.ModelManagerSP.DownloadStatus.downloading, artifact.downloadProgress.progress)
for task in tasks:
task.cancel() # drops queued pieces; running ones see the event at their next block
await asyncio.gather(*tasks, return_exceptions=True)
raise
del self._download_start_times[artifact.fileName]
async def _process_artifact(self, artifact, destination_path: str) -> None:
if not artifact.downloadUri.uri:
@@ -119,33 +245,31 @@ class ModelManagerSP:
expected_hash = artifact.downloadUri.sha256
filename = artifact.fileName
full_path = os.path.join(destination_path, filename)
status = custom.ModelManagerSP.DownloadStatus
artifact.downloadProgress.canceling = False
try:
self._set_progress(artifact, status.verifying, 0)
if await verify_file(full_path, expected_hash):
artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.cached
artifact.downloadProgress.progress = 100
artifact.downloadProgress.eta = 0
self._sync_artifact_progress(artifact)
self._report_status()
self._set_progress(artifact, status.cached, 100)
return
self._set_progress(artifact, status.downloading, 0)
await self._download_file(url, full_path, artifact)
self._set_progress(artifact, status.verifying, 99)
if not await verify_file(full_path, expected_hash):
raise ValueError(f"Hash validation failed for {filename}")
artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.downloaded
artifact.downloadProgress.progress = 100
artifact.downloadProgress.eta = 0
self._sync_artifact_progress(artifact)
self._report_status()
self._set_progress(artifact, status.downloaded, 100)
except DownloadCancelled:
self._download_start_times.pop(artifact.fileName, None)
artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.failed
artifact.downloadProgress.status = status.failed
artifact.downloadProgress.eta = 0
artifact.downloadProgress.speed = 0
self._sync_artifact_progress(artifact)
if self.selected_bundle:
self.selected_bundle.status = custom.ModelManagerSP.DownloadStatus.failed
self.selected_bundle.status = status.failed
self._report_status()
raise
@@ -153,11 +277,12 @@ class ModelManagerSP:
cloudlog.error(f"Error downloading {filename}: {str(e)}")
if os.path.isfile(full_path): # noqa: ASYNC240
os.remove(full_path)
artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.failed
artifact.downloadProgress.status = status.failed
artifact.downloadProgress.eta = 0
artifact.downloadProgress.speed = 0
self._sync_artifact_progress(artifact)
if self.selected_bundle:
self.selected_bundle.status = custom.ModelManagerSP.DownloadStatus.failed
self.selected_bundle.status = status.failed
self._report_status()
self._download_start_times.pop(artifact.fileName, None)
raise
@@ -197,6 +322,7 @@ class ModelManagerSP:
artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.cached
artifact.downloadProgress.progress = 100
artifact.downloadProgress.eta = 0
artifact.downloadProgress.speed = 0
else:
seen_artifacts.add(artifact.fileName)
await self._process_artifact(artifact, destination_path)
@@ -272,12 +398,7 @@ class ModelManagerSP:
rk.keep_time()
def clear_model_cache(self) -> None:
"""
Clears the model cache directory of all files except those in the active model bundle.
"""
# Get list of files used by both slots' selected bundles (either may become
# the truly active bundle depending on hardware availability)
""" Clears the model cache directory of all files except those in the active model bundle."""
active_files = []
for source in ACTIVE_BUNDLE_KEYS:
if selected_bundle := get_selected_bundle(self.params, source):
@@ -285,7 +406,6 @@ class ModelManagerSP:
if model.artifact.fileName:
active_files.append(model.artifact.fileName)
# Remove all files except active ones
model_dir = Paths.model_root()
try:
for filename in os.listdir(model_dir):
File diff suppressed because it is too large Load Diff
@@ -126,6 +126,7 @@ def initialize_params(params) -> list[dict[str, Any]]:
keys.extend([
"ToyotaEnforceStockLongitudinal",
"ToyotaStopAndGoHack",
"ToyotaTSS2Smooth",
])
return [{k: params.get(k, return_default=True)} for k in keys]
@@ -10,6 +10,8 @@ from openpilot.common.params import Params
from openpilot.common.realtime import DT_MDL
class AutoLaneChangeMode:
OFF = -1
NUDGE = 0 # default
@@ -31,6 +33,7 @@ AUTO_LANE_CHANGE_TIMER = {
}
ONE_SECOND_DELAY = -1
EDGE_DELAY = -2
class AutoLaneChangeController:
@@ -48,6 +51,7 @@ class AutoLaneChangeController:
self.prev_brake_pressed = False
self.auto_lane_change_allowed = False
self.prev_lane_change = False
self.lane_change_blocked = False
self.read_params()
@@ -68,7 +72,7 @@ class AutoLaneChangeController:
self.read_params()
self.param_read_counter += 1
def update_lane_change_timers(self, blindspot_detected: bool) -> None:
def update_lane_change_timers(self, blindspot_detected: bool, edge_detected: bool) -> None:
self.lane_change_delay = AUTO_LANE_CHANGE_TIMER.get(self.lane_change_set_timer,
AUTO_LANE_CHANGE_TIMER[AutoLaneChangeMode.NUDGE])
@@ -80,6 +84,12 @@ class AutoLaneChangeController:
else:
self.lane_change_wait_timer = self.lane_change_delay + ONE_SECOND_DELAY
if edge_detected and self.lane_change_delay > 0:
if self.lane_change_delay == AUTO_LANE_CHANGE_TIMER[AutoLaneChangeMode.NUDGELESS]:
self.lane_change_wait_timer = min(self.lane_change_wait_timer, EDGE_DELAY)
else:
self.lane_change_wait_timer = min(self.lane_change_wait_timer, self.lane_change_delay + EDGE_DELAY)
def update_allowed(self) -> bool:
# Auto lane change allowed if:
# 1. A valid delay is set (non-zero)
@@ -97,14 +107,23 @@ class AutoLaneChangeController:
return bool(self.lane_change_wait_timer > self.lane_change_delay)
def update_lane_change(self, blindspot_detected: bool, brake_pressed: bool) -> None:
def update_lane_change(self, blindspot_detected: bool, edge_detected: bool, brake_pressed: bool,
torque_applied: bool = False) -> None:
if brake_pressed and not self.prev_brake_pressed:
self.prev_brake_pressed = brake_pressed
self.update_lane_change_timers(blindspot_detected)
self.update_lane_change_timers(blindspot_detected, edge_detected)
self.auto_lane_change_allowed = self.update_allowed()
# BSM always blocks. Edge blocks auto mode but nudge can override.
if blindspot_detected:
self.lane_change_blocked = True
elif edge_detected:
self.lane_change_blocked = not torque_applied
else:
self.lane_change_blocked = False
def update_state(self):
if self.DH.lane_change_state == log.LaneChangeState.laneChangeStarting:
self.prev_lane_change = True
@@ -8,7 +8,7 @@ from openpilot.common.parameterized import parameterized
from openpilot.common.realtime import DT_MDL
from openpilot.selfdrive.controls.lib.desire_helper import DesireHelper, LaneChangeState, LaneChangeDirection
from openpilot.sunnypilot.selfdrive.controls.lib.auto_lane_change import AutoLaneChangeController, AutoLaneChangeMode, \
AUTO_LANE_CHANGE_TIMER, ONE_SECOND_DELAY
AUTO_LANE_CHANGE_TIMER, ONE_SECOND_DELAY, EDGE_DELAY
from openpilot.common.test import OpenpilotTestCase
AUTO_LANE_CHANGE_TIMER_COMBOS = [
@@ -61,7 +61,7 @@ class TestAutoLaneChangeController(OpenpilotTestCase):
# Update controller
num_updates = int(5.0 / DT_MDL)
for _ in range(num_updates): # Run for 5 seconds
self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False)
self.alc.update_lane_change(blindspot_detected=False, edge_detected=False, brake_pressed=False)
# Mode should not allow lane change immediately
assert not self.alc.auto_lane_change_allowed
@@ -74,11 +74,11 @@ class TestAutoLaneChangeController(OpenpilotTestCase):
self.alc.lane_change_set_timer = AutoLaneChangeMode.NUDGELESS
# Update controller once to read params
self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False)
self.alc.update_lane_change(blindspot_detected=False, edge_detected=False, brake_pressed=False)
# Update multiple times to exceed the timer threshold
for _ in range(1): # Should exceed 0.1s with multiple DT_MDL updates
self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False)
self.alc.update_lane_change(blindspot_detected=False, edge_detected=False, brake_pressed=False)
# Now lane change should be allowed
assert self.alc.lane_change_wait_timer > self.alc.lane_change_delay
@@ -91,7 +91,7 @@ class TestAutoLaneChangeController(OpenpilotTestCase):
self.alc.lane_change_set_timer = timer_state
# Update controller once
self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False)
self.alc.update_lane_change(blindspot_detected=False, edge_detected=False, brake_pressed=False)
# The timer should still be below the threshold after one update
assert not self.alc.auto_lane_change_allowed
@@ -99,7 +99,7 @@ class TestAutoLaneChangeController(OpenpilotTestCase):
# Update enough times to exceed the threshold (seconds / DT_MDL)
num_updates = int(timer_delay / DT_MDL) + 1 # Add one extra updates to ensure we exceed the threshold
for _ in range(num_updates):
self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False)
self.alc.update_lane_change(blindspot_detected=False, edge_detected=False, brake_pressed=False)
# Now lane change should be allowed
assert self.alc.lane_change_wait_timer > self.alc.lane_change_delay
@@ -116,7 +116,7 @@ class TestAutoLaneChangeController(OpenpilotTestCase):
# Update with brake pressed for 1 second
for _ in range(num_updates):
self.alc.update_lane_change(blindspot_detected=False, brake_pressed=True)
self.alc.update_lane_change(blindspot_detected=False, edge_detected=False, brake_pressed=True)
# Even though it is an auto lane change mode, lane change should be disallowed due to brake pressed prior initiating lane change
assert not self.alc.auto_lane_change_allowed
@@ -126,7 +126,7 @@ class TestAutoLaneChangeController(OpenpilotTestCase):
# Even releasing brake shouldn't allow auto lane change
for _ in range(num_updates):
self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False)
self.alc.update_lane_change(blindspot_detected=False, edge_detected=False, brake_pressed=False)
assert not self.alc.auto_lane_change_allowed
@@ -139,13 +139,13 @@ class TestAutoLaneChangeController(OpenpilotTestCase):
self.alc.lane_change_set_timer = timer_state
# Update with blindspot detected - this should prevent auto lane change
self.alc.update_lane_change(blindspot_detected=True, brake_pressed=False)
self.alc.update_lane_change(blindspot_detected=True, edge_detected=False, brake_pressed=False)
assert not self.alc.auto_lane_change_allowed
# Keep updating with blindspot detected - should still prevent auto lane change
num_updates = int(timer_delay / DT_MDL) + 1 # Add one extra updates to ensure we exceed the threshold
for _ in range(num_updates):
self.alc.update_lane_change(blindspot_detected=True, brake_pressed=False)
self.alc.update_lane_change(blindspot_detected=True, edge_detected=False, brake_pressed=False)
assert not self.alc.auto_lane_change_allowed
@parameterized.expand(AUTO_LANE_CHANGE_TIMER_COMBOS)
@@ -157,13 +157,13 @@ class TestAutoLaneChangeController(OpenpilotTestCase):
self.alc.lane_change_set_timer = timer_state
# First update with blindspot detected to set the negative timer
self.alc.update_lane_change(blindspot_detected=True, brake_pressed=False)
self.alc.update_lane_change(blindspot_detected=True, edge_detected=False, brake_pressed=False)
assert not self.alc.auto_lane_change_allowed
# Now update with blindspot cleared - should start incrementing timer from negative value
num_updates = int((timer_delay + abs(ONE_SECOND_DELAY)) / DT_MDL) + 1
for _ in range(num_updates):
self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False)
self.alc.update_lane_change(blindspot_detected=False, edge_detected=False, brake_pressed=False)
# After sufficient updates with no blindspot, auto lane change should be allowed
assert self.alc.auto_lane_change_allowed
@@ -177,7 +177,7 @@ class TestAutoLaneChangeController(OpenpilotTestCase):
# Update enough times to exceed the threshold (seconds / DT_MDL)
for _ in range(num_updates):
self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False)
self.alc.update_lane_change(blindspot_detected=False, edge_detected=False, brake_pressed=False)
# Now lane change should be allowed
assert self.alc.lane_change_wait_timer > self.alc.lane_change_delay
@@ -193,7 +193,7 @@ class TestAutoLaneChangeController(OpenpilotTestCase):
# Update enough times to exceed the threshold (seconds / DT_MDL)
for _ in range(num_updates):
self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False)
self.alc.update_lane_change(blindspot_detected=False, edge_detected=False, brake_pressed=False)
assert not self.alc.auto_lane_change_allowed
@@ -206,7 +206,86 @@ class TestAutoLaneChangeController(OpenpilotTestCase):
# Simulate updates for a long period of time (e.g., 10 seconds)
num_updates = int(10.0 / DT_MDL)
for _ in range(num_updates):
self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False)
self.alc.update_lane_change(blindspot_detected=False, edge_detected=False, brake_pressed=False)
# Lane change should never be allowed
assert not self.alc.auto_lane_change_allowed
@parameterized.expand(AUTO_LANE_CHANGE_TIMER_COMBOS)
def test_edge_detected_delays_auto_lane_change(self, timer_state, timer_delay):
"""Test that road edge detection adds delay to auto lane change."""
self._reset_states()
self.alc.lane_change_bsm_delay = False
self.alc.lane_change_set_timer = timer_state
# Update with edge detected - should prevent auto lane change
self.alc.update_lane_change(blindspot_detected=False, edge_detected=True, brake_pressed=False)
assert not self.alc.auto_lane_change_allowed
# Keep updating with edge detected - timer keeps resetting, should stay disallowed
num_updates = int(timer_delay / DT_MDL) + 1
for _ in range(num_updates):
self.alc.update_lane_change(blindspot_detected=False, edge_detected=True, brake_pressed=False)
assert not self.alc.auto_lane_change_allowed
@parameterized.expand(AUTO_LANE_CHANGE_TIMER_COMBOS)
def test_edge_detected_then_cleared_allows_after_delay(self, timer_state, timer_delay):
"""Test that auto lane change is allowed after edge clears plus extra delay."""
self._reset_states()
self.alc.lane_change_bsm_delay = False
self.alc.lane_change_set_timer = timer_state
# First update with edge detected to set the negative timer
self.alc.update_lane_change(blindspot_detected=False, edge_detected=True, brake_pressed=False)
assert not self.alc.auto_lane_change_allowed
# Now update with edge cleared - should start incrementing timer from negative value
num_updates = int((timer_delay + abs(EDGE_DELAY)) / DT_MDL) + 1
for _ in range(num_updates):
self.alc.update_lane_change(blindspot_detected=False, edge_detected=False, brake_pressed=False)
# After sufficient updates with no edge, auto lane change should be allowed
assert self.alc.auto_lane_change_allowed
@parameterized.expand(AUTO_LANE_CHANGE_TIMER_COMBOS)
def test_edge_and_bsm_combined_uses_worst_delay(self, timer_state, timer_delay):
"""Test that when both BSM and edge are detected, the worst (longest) delay wins."""
self._reset_states()
self.alc.lane_change_bsm_delay = True
self.alc.lane_change_set_timer = timer_state
# Update with both blindspot and edge detected
self.alc.update_lane_change(blindspot_detected=True, edge_detected=True, brake_pressed=False)
assert not self.alc.auto_lane_change_allowed
# Clear both - need to wait out the worst delay
worst_delay = max(abs(ONE_SECOND_DELAY), abs(EDGE_DELAY))
num_updates = int((timer_delay + worst_delay) / DT_MDL) + 1
for _ in range(num_updates):
self.alc.update_lane_change(blindspot_detected=False, edge_detected=False, brake_pressed=False)
assert self.alc.auto_lane_change_allowed
@parameterized.expand(AUTO_LANE_CHANGE_TIMER_COMBOS)
def test_nudge_overrides_edge_in_lane_change_blocked(self, timer_state, timer_delay):
"""Test that lane_change_blocked is False when nudge applied with edge-only."""
self._reset_states()
self.alc.lane_change_set_timer = timer_state
# Edge detected, no nudge - should be blocked
self.alc.update_lane_change(blindspot_detected=False, edge_detected=True, brake_pressed=False, torque_applied=False)
assert self.alc.lane_change_blocked
# Edge detected, nudge applied - should NOT be blocked
self.alc.update_lane_change(blindspot_detected=False, edge_detected=True, brake_pressed=False, torque_applied=True)
assert not self.alc.lane_change_blocked
@parameterized.expand(AUTO_LANE_CHANGE_TIMER_COMBOS)
def test_nudge_does_not_override_bsm_in_lane_change_blocked(self, timer_state, timer_delay):
"""Test that lane_change_blocked stays True when BSM detected even with nudge."""
self._reset_states()
self.alc.lane_change_set_timer = timer_state
# BSM detected with nudge - should still be blocked
self.alc.update_lane_change(blindspot_detected=True, edge_detected=False, brake_pressed=False, torque_applied=True)
assert self.alc.lane_change_blocked
@@ -113,12 +113,56 @@ class TestDesireHelperIntegration(OpenpilotTestCase):
left_edge_detected=False, right_edge_detected=False)
assert dh.desire == expected_desire
def test_edge_blocks_lane_change(self, set_lane_turn_params):
def test_nudge_overrides_edge_detection(self, set_lane_turn_params):
"""Manual steering nudge should override road edge detection and allow lane change."""
dh = DesireHelper()
dh.alc.lane_change_set_timer = AutoLaneChangeMode.NUDGE
carstate = DummyCarState(vEgo=15, leftBlinker=True, steeringPressed=True, steeringTorque=1)
for _ in range(10):
dh.update(carstate, True, 1.0, left_edge_detected=True, right_edge_detected=False)
assert dh.lane_change_state == LaneChangeState.preLaneChange
assert dh.lane_change_state == LaneChangeState.laneChangeStarting
assert dh.lane_change_direction == LaneChangeDirection.left
assert dh.desire == log.Desire.laneChangeLeft
def test_nudge_does_not_override_bsm(self, set_lane_turn_params):
"""Manual steering nudge must NOT override BSM blindspot detection."""
dh = DesireHelper()
dh.alc.lane_change_set_timer = AutoLaneChangeMode.NUDGE
carstate = DummyCarState(vEgo=15, leftBlinker=True, leftBlindspot=True, steeringPressed=True, steeringTorque=1)
for _ in range(10):
dh.update(carstate, True, 1.0, left_edge_detected=False, right_edge_detected=False)
assert dh.lane_change_state == LaneChangeState.preLaneChange
assert dh.desire == log.Desire.none
def test_edge_blocks_auto_lane_change(self, set_lane_turn_params):
"""Road edge detection should still block auto (nudgeless) lane changes."""
dh = DesireHelper()
dh.alc.lane_change_set_timer = AutoLaneChangeMode.NUDGELESS
dh.alc.param_read_counter = 1
carstate = DummyCarState(vEgo=15, leftBlinker=True)
for _ in range(10):
dh.update(carstate, True, 1.0, left_edge_detected=True, right_edge_detected=False)
assert dh.lane_change_state == LaneChangeState.preLaneChange
assert dh.desire == log.Desire.none
def test_auto_lane_change_allowed_without_edge(self, set_lane_turn_params):
"""Auto lane change should proceed when no edge and no BSM detected."""
dh = DesireHelper()
dh.alc.lane_change_set_timer = AutoLaneChangeMode.NUDGELESS
dh.alc.param_read_counter = 1
carstate = DummyCarState(vEgo=15, leftBlinker=True)
for _ in range(10):
dh.update(carstate, True, 1.0, left_edge_detected=False, right_edge_detected=False)
assert dh.lane_change_state == LaneChangeState.laneChangeStarting
assert dh.desire == log.Desire.laneChangeLeft
def test_right_edge_does_not_block_left_lane_change(self, set_lane_turn_params):
"""Edge on opposite side should not affect lane change direction."""
dh = DesireHelper()
dh.alc.lane_change_set_timer = AutoLaneChangeMode.NUDGELESS
dh.alc.param_read_counter = 1
carstate = DummyCarState(vEgo=15, leftBlinker=True)
for _ in range(10):
dh.update(carstate, True, 1.0, left_edge_detected=False, right_edge_detected=True)
assert dh.lane_change_state == LaneChangeState.laneChangeStarting
assert dh.desire == log.Desire.laneChangeLeft
@@ -1,83 +0,0 @@
"""One row model for the "Local Connections" screen, shared by both UI panels.
Every row is an enrolled phone, so a row's text and its action cannot drift apart between the two
layouts. It reads no Params and no identity key: the panels pass in what they read. Aliases and
last-seen stamps are display metadata only — the authority decides every connection from the grant
itself.
"""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from openpilot.sunnypilot.sunnylink.athena.local_auth import MAX_GRANTS
# Every grant the authority accepts: a phone the screen hides could never be revoked from the
# device.
MAX_LOCAL_APP_ROWS = MAX_GRANTS
DEFAULT_APP_NAME = "sunnylink mobile"
NEVER_CONNECTED = "not connected yet"
@dataclass(frozen=True)
class LocalAppRow:
"""One enrolled phone in the local-connections list."""
app_id: str
title: str
subtitle: str
def short_key(app_id: str, length: int = 10) -> str:
"""A recognizable, privacy-neutral abbreviation of a key id."""
return app_id if len(app_id) <= length else app_id[:length] + "…"
def format_age(seconds: float) -> str:
"""Human-readable 'how long ago', for the display-only last-seen stamp."""
if seconds < 60:
return "just now"
if seconds < 3600:
return f"{int(seconds // 60)}m ago"
if seconds < 86_400:
return f"{int(seconds // 3600)}h ago"
return f"{int(seconds // 86_400)}d ago"
def last_seen_text(entry: Mapping[str, object] | None, now: float) -> str:
"""`last_seen` is monotonic, so a stamp from before the last reboot reads as unknown."""
last_seen = entry.get("last_seen") if isinstance(entry, Mapping) else None
if not isinstance(last_seen, (int, float)) or isinstance(last_seen, bool):
return NEVER_CONNECTED
age = now - float(last_seen)
if age < 0:
return NEVER_CONNECTED
return format_age(age)
def build_local_app_rows(grants: list[dict[str, str]],
meta: Mapping[str, Mapping[str, object]] | None = None,
now: float = 0.0,
max_rows: int = MAX_LOCAL_APP_ROWS) -> list[LocalAppRow]:
"""One row per enrolled phone, in the order the authority holds them."""
entries = meta or {}
rows = []
for grant in grants:
if not isinstance(grant, dict):
continue
app_id = grant.get("app_id")
app_name = grant.get("app_name")
if not isinstance(app_id, str) or not app_id:
continue
entry = entries.get(app_id)
alias = entry.get("alias") if isinstance(entry, Mapping) else None
if isinstance(alias, str) and alias.strip():
title = alias.strip()
elif isinstance(app_name, str) and app_name:
title = app_name
else:
title = DEFAULT_APP_NAME
rows.append(LocalAppRow(app_id, title,
f"secure · {short_key(app_id)} · {last_seen_text(entry, now)}"))
return rows[:max_rows]
@@ -1,523 +0,0 @@
"""Sunnylink local security core: keys, grants, the pending window and the pinned TLS dial.
Nothing here is registered with the RPC dispatcher: an enrollment may only be staged from a
cloud-origin payload, never from a local RPC. See sunnylink-mobile/docs/LOCAL_AUTH.md.
"""
from __future__ import annotations
import base64
import hashlib
import json
import re
import secrets
import socket
import ssl
import threading
import ipaddress
import time
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from enum import Enum
from typing import Any
from urllib.parse import urlsplit
from websocket import WebSocket, create_connection
from cryptography import x509
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa
VERSION = 2
AUTHENTICATE_PURPOSE = "sunnylink-local-authenticate"
PAIRING_TTL_S = 120
# A cloud-delivered enrollment is store-and-forward: the gateway queues the settings-write and the
# device's local half claims it on a later tick, which is minutes late when the cloud link was down
# or the device was asleep. The session a code was scanned from therefore stays usable after the
# code stops being displayed, so the enrollment for the code the user actually scanned can still
# land. It stays single-use, stays bound to that code, and is dropped the moment the user cancels
# the window or it outlives this grace.
PAIRING_DELIVERY_GRACE_S = 600
# How many codes stay usable for a late delivery. A user who retries gets a fresh code, and the
# enrollment for the code they scanned first can still be in flight.
MAX_RECENT_SESSIONS = 3
MAX_ENROLL_BYTES = 4096
MAX_GRANTS = 8
# The QR the app scans is a POINTER, not a proof: it names one device and one pairing window and
# carries nothing else. It is unsigned because a signature does not fit the panel a comma device
# has: the device identity key is RSA (`/persist/comma/id_rsa` — KEYS in common/api/base.py tries
# id_rsa first, and hw.h hardcodes that path), so PKCS#1 makes the signature 256 bytes and base64
# makes it 342 characters, i.e. a 461-character code: version 15, 77 modules, 2.73 px/module on the
# 536x240 mici panel, where it does not decode. The same frame unsigned is 52 characters, a
# 29-module code at 6.27 px/module there. Nothing is given up for that:
#
# * the DEVICE is the gate, not the code: an enrollment is honored only for a nonce this device
# minted in its own live window (`_fresh`), so a code nobody's device made enrolls nothing;
# * the app can only enroll a device its own account has settings access to (the authenticated
# cloud device details are what resolve the key), so a stranger's code points at a device the
# app refuses to touch;
# * the device proves the identity key itself to the phone over pinned TLS, against a fresh
# challenge, before any session exists (`authenticate_device`) — a stronger proof than a
# signature over a static code, and unlike one it costs the code no characters.
#
# The comma device id is deliberately absent too: the app already gets it from the same
# authenticated cloud details, and a second unauthenticated copy is one more thing to disagree with.
#
# Frame (the whole QR is its base64url):
#
# "SLEN" | version | len(cloud id) | cloud id | session (16) | ttl (1)
QR_MAGIC = b"SLEN"
QR_VERSION = 4
QR_SESSION_BYTES = 16
MAX_QR_FRAME_BYTES = 256
# The only RPCs an authenticated local session may ask for. Long-running operations are
# deliberately absent.
LOCAL_METHODS = frozenset({"getParams", "getParamsAllKeys", "getParamsMetadata", "getMessage", "saveParams",
"updateLocalAppAlias", "unpairLocalApp"})
_TOKEN = re.compile(r"[A-Za-z0-9_-]+\Z")
class AuthorizationError(ValueError):
"""Fail closed without exposing protocol internals to an unauthenticated peer."""
class Origin(Enum):
"""Where a trust mutation came from. Local sessions may never enroll or revoke."""
CLOUD = "cloud"
LOCAL = "local"
def encode64(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def decode64(value: Any, *, max_bytes: int = MAX_ENROLL_BYTES) -> bytes:
if not isinstance(value, str) or not value or len(value) > (max_bytes * 4 + 2) // 3 or not _TOKEN.fullmatch(value):
raise AuthorizationError("invalid encoding")
try:
raw = base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True)
except ValueError as e:
raise AuthorizationError("invalid encoding") from e
if len(raw) > max_bytes or encode64(raw) != value:
raise AuthorizationError("noncanonical encoding")
return raw
def load_app_key(value: str) -> ec.EllipticCurvePublicKey:
raw = decode64(value, max_bytes=128)
try:
key = serialization.load_der_public_key(raw)
except ValueError as e:
raise AuthorizationError("invalid key") from e
if not isinstance(key, ec.EllipticCurvePublicKey) or not isinstance(key.curve, ec.SECP256R1):
raise AuthorizationError("unsupported key")
if key.public_bytes(serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo) != raw:
raise AuthorizationError("noncanonical key")
return key
def key_id_bytes(key: ec.EllipticCurvePublicKey | rsa.RSAPublicKey) -> bytes:
raw = key.public_bytes(serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo)
return hashlib.sha256(raw).digest()
def key_id(key: ec.EllipticCurvePublicKey | rsa.RSAPublicKey) -> str:
return encode64(key_id_bytes(key))
@dataclass(frozen=True)
class QrPayload:
"""What the app reads out of the scanned frame. Never authority on its own: the device key it
ends up pinned to comes from authenticated cloud device details, and the nonce only means
something to the window this device is displaying now."""
cloud_device_id: str
session: bytes
ttl_s: int
def qr_frame(cloud_device_id: str, session: bytes, ttl_s: int) -> bytes:
if len(session) != QR_SESSION_BYTES:
raise AuthorizationError("invalid qr session")
if not 0 < ttl_s < 256:
raise AuthorizationError("invalid qr ttl")
encoded = text_field(cloud_device_id).encode("utf-8")
if not 0 < len(encoded) < 256:
raise AuthorizationError("invalid cloud device id")
return QR_MAGIC + bytes([QR_VERSION, len(encoded)]) + encoded + session + bytes([ttl_s])
def decode_qr(raw: str) -> QrPayload:
"""Parse a scanned QR. The app is the side that reads one (it needs the device key from the
cloud); this validates the framing so no caller can disagree with [qr_frame]."""
frame = decode64(raw, max_bytes=MAX_QR_FRAME_BYTES)
header = len(QR_MAGIC) + 2
if len(frame) < header + 1 + QR_SESSION_BYTES + 1 or not frame.startswith(QR_MAGIC):
raise AuthorizationError("invalid qr")
version, id_length = frame[len(QR_MAGIC)], frame[len(QR_MAGIC) + 1]
if version != QR_VERSION or id_length == 0 or len(frame) != header + id_length + QR_SESSION_BYTES + 1:
raise AuthorizationError("invalid qr")
try:
cloud_device_id = text_field(frame[header:header + id_length].decode("utf-8"))
except UnicodeDecodeError as e:
raise AuthorizationError("invalid qr") from e
session_start = header + id_length
return QrPayload(cloud_device_id, frame[session_start:session_start + QR_SESSION_BYTES], frame[-1])
def text_field(value: Any, maximum: int = 128) -> str:
if not isinstance(value, str) or not value or len(value.encode("utf-8")) > maximum or any(ord(c) < 32 or ord(c) == 127 for c in value):
raise AuthorizationError("invalid field")
return value
def statement(purpose: str, *fields: str) -> bytes:
"""Length-bounded UTF-8 fields; LF is forbidden, avoiding JSON canonicalization."""
return ("sunnylink-local\n" + purpose + "\n" + "\n".join(text_field(f, 1024) for f in fields) + "\n").encode("utf-8")
def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
result: dict[str, Any] = {}
for key, value in pairs:
if key in result:
raise AuthorizationError("duplicate field")
result[key] = value
return result
def parse_object(raw: str) -> dict[str, Any]:
if not isinstance(raw, str) or len(raw.encode("utf-8")) > MAX_ENROLL_BYTES:
raise AuthorizationError("message too large")
try:
obj = json.loads(raw, object_pairs_hook=_unique_object)
except (ValueError, UnicodeError) as e:
raise AuthorizationError("invalid message") from e
if not isinstance(obj, dict):
raise AuthorizationError("invalid message")
return obj
@dataclass(frozen=True)
class Grant:
app_id: str
public_key: str
key_id: str
app_name: str
revision: str
@staticmethod
def from_dict(data: Mapping[str, Any]) -> Grant:
if set(data) != {"app_id", "public_key", "key_id", "app_name", "revision"}:
raise AuthorizationError("invalid grant")
public = text_field(data["public_key"], 256)
actual_id = key_id(load_app_key(public))
if data["key_id"] != actual_id or data["app_id"] != actual_id:
raise AuthorizationError("key mismatch")
revision = text_field(data["revision"], 43)
if len(decode64(revision, max_bytes=32)) != 32:
raise AuthorizationError("invalid revision")
return Grant(actual_id, public, actual_id, text_field(data["app_name"], 80), revision)
def to_dict(self) -> dict[str, str]:
return {"app_id": self.app_id, "public_key": self.public_key, "key_id": self.key_id,
"app_name": self.app_name, "revision": self.revision}
@dataclass(frozen=True)
class Window:
nonce: str
# How long the code is on the device screen and can be scanned.
deadline: float
# [deadline] plus [PAIRING_DELIVERY_GRACE_S]: how long an enrollment for this code may still
# arrive through the cloud after the code itself can no longer be scanned.
usable_until: float
qr: str
class LocalAuthority:
"""The daemon-owned authority. Every trust mutation goes through it, and a persistence
failure never publishes a new grant."""
def __init__(self, cloud_device_id: str, comma_device_id: str, device_private_key: ec.EllipticCurvePrivateKey | rsa.RSAPrivateKey,
persist: Callable[[dict[str, Any]], None], registry: Any = None, clock: Callable[[], float] = time.monotonic):
self.cloud_device_id = text_field(cloud_device_id)
self.comma_device_id = text_field(comma_device_id)
self.device_key = device_private_key
self.device_key_id = key_id(device_private_key.public_key())
self.persist = persist
self.clock = clock
self.lock = threading.RLock()
self.window: Window | None = None
# Codes that are no longer displayed but whose enrollment may still be in flight.
self.recent: list[Window] = []
self.pending: Grant | None = None
# The session [pending] was staged from: the grant is committed while that session's delivery
# grace lasts even if the code has left the screen.
self.pending_session: str | None = None
self.grants: dict[str, Grant] = {}
if isinstance(registry, dict) and type(registry.get("v")) is int and registry.get("v") == VERSION and set(registry) == {"v", "grants"}:
entries = registry["grants"]
if isinstance(entries, list) and len(entries) <= MAX_GRANTS:
try:
decoded = [Grant.from_dict(entry) for entry in entries if isinstance(entry, dict)]
if len(decoded) == len(entries) and len({g.key_id for g in decoded}) == len(decoded):
self.grants = {g.key_id: g for g in decoded}
except AuthorizationError:
pass # Corrupt storage is not authorization.
def arm(self) -> str:
"""Open one window and publish the code for it (see the frame note at the top of the module:
the code is a pointer, so minting one does not touch the identity key)."""
with self.lock:
session = secrets.token_bytes(QR_SESSION_BYTES)
qr = encode64(qr_frame(self.cloud_device_id, session, PAIRING_TTL_S))
now = self.clock()
self.window = Window(encode64(session), now + PAIRING_TTL_S, now + PAIRING_TTL_S + PAIRING_DELIVERY_GRACE_S, qr)
self._remember(self.window)
self.pending = None
self.pending_session = None
return qr
def _remember(self, window: Window) -> None:
"""Keep the newest displayed codes usable for a late cloud delivery."""
now = self.clock()
kept = [w for w in self.recent if w.nonce != window.nonce and now < w.usable_until]
self.recent = (kept + [window])[-MAX_RECENT_SESSIONS:]
def cancel(self) -> None:
"""The user cancelled (or the window was replaced): every session it could still be used for
is gone, so a scanned-but-undelivered enrollment cannot land after the fact."""
with self.lock:
self.window = None
self.recent = []
self.pending = None
self.pending_session = None
def expire(self) -> None:
"""The code left the device screen: it can no longer be scanned, but an enrollment for it may
still be in flight, so only the displayed window is dropped — the session stays in [recent]
until its delivery grace ends."""
with self.lock:
self.window = None
def prune_sessions(self) -> None:
with self.lock:
now = self.clock()
self.recent = [w for w in self.recent if now < w.usable_until]
def _fresh(self, nonce: str) -> None:
"""Accept a session that is displayed now, or was displayed recently enough that an enrollment
for it can still be travelling through the cloud ([PAIRING_DELIVERY_GRACE_S])."""
now = self.clock()
with self.lock:
candidates = ([self.window] if self.window is not None else []) + self.recent
if not any(w is not None and now < w.usable_until and secrets.compare_digest(w.nonce, nonce) for w in candidates):
# Name the reason: this line is the only trace of an enrollment that referenced a code the
# device no longer honors, and the difference between "expired" and "never displayed" is
# what tells a user to scan a fresh code instead of retrying the same one.
raise AuthorizationError("pairing code expired")
def enroll(self, raw: str, origin: Origin) -> Grant:
if origin is not Origin.CLOUD:
raise AuthorizationError("cloud authorization required")
data = parse_object(raw)
required = {"v", "cloud_device_id", "comma_device_id", "device_key_id", "pairing_session", "public_key", "key_id", "app_name", "signature"}
if set(data) != required or type(data["v"]) is not int or data["v"] != VERSION:
raise AuthorizationError("invalid enrollment")
if (data["cloud_device_id"], data["comma_device_id"], data["device_key_id"]) != \
(self.cloud_device_id, self.comma_device_id, self.device_key_id):
raise AuthorizationError("wrong device")
public = text_field(data["public_key"], 256)
key = load_app_key(public)
app_id = key_id(key)
if data["key_id"] != app_id:
raise AuthorizationError("wrong key")
# The nonce is the QR's own session field: the window this device is displaying decides it,
# and no other length is honored.
nonce = text_field(data["pairing_session"], 43)
if len(decode64(nonce, max_bytes=32)) != QR_SESSION_BYTES:
raise AuthorizationError("invalid nonce")
name = text_field(data["app_name"], 80)
signed = statement("enroll", self.cloud_device_id, self.comma_device_id, self.device_key_id, nonce, public, app_id, name)
try:
key.verify(decode64(data["signature"], max_bytes=72), signed, ec.ECDSA(hashes.SHA256()))
except InvalidSignature as e:
raise AuthorizationError("invalid proof") from e
with self.lock:
self._fresh(nonce)
if self.pending is not None:
if (self.pending.public_key, self.pending.app_name) != (public, name):
raise AuthorizationError("window already bound")
return self.pending
if app_id not in self.grants and len(self.grants) >= MAX_GRANTS:
raise AuthorizationError("grant limit")
self.pending = Grant(app_id, public, app_id, name, encode64(secrets.token_bytes(32)))
self.pending_session = nonce
return self.pending
def confirm(self, grant: Grant, tls_peer_key_id: str) -> Grant:
"""Called only after the transport proves the pinned TLS server key."""
with self.lock:
if not secrets.compare_digest(grant.key_id, tls_peer_key_id):
raise AuthorizationError("wrong TLS peer")
if self.pending != grant:
raise AuthorizationError("no pending enrollment")
if self.pending_session is None:
raise AuthorizationError("window closed")
# The enrollment may have arrived late, so what has to hold is that the session it was scanned
# from is still inside its delivery grace — not that the code is still on the screen.
self._fresh(self.pending_session)
updated = self.grants | {grant.key_id: grant}
self.persist({"v": VERSION, "grants": [g.to_dict() for g in updated.values()]})
self.grants = updated
self.cancel()
return grant
def authenticate_device(self, phone_key_id: str, challenge: str) -> dict[str, str]:
"""Proof of device identity for the phone's fresh challenge, sent only inside a TLS
channel already pinned to `phone_key_id`."""
if len(decode64(challenge, max_bytes=32)) != 32:
raise AuthorizationError("invalid challenge")
fields = (self.cloud_device_id, self.comma_device_id, self.device_key_id,
text_field(phone_key_id, 128), text_field(challenge, 128))
proof = statement("authenticate-device", *fields)
if isinstance(self.device_key, ec.EllipticCurvePrivateKey):
signature = self.device_key.sign(proof, ec.ECDSA(hashes.SHA256()))
else:
signature = self.device_key.sign(proof, padding.PKCS1v15(), hashes.SHA256())
return {"cloud_device_id": self.cloud_device_id, "comma_device_id": self.comma_device_id,
"device_key_id": self.device_key_id, "phone_key_id": phone_key_id,
"challenge": challenge, "signature": encode64(signature)}
def revoke_by_self_proof(self, raw: str, origin: Origin) -> str:
"""Cloud-origin self-revocation: a phone drops its OWN grant.
The proof is signed by the very key being dropped and verified against the grant this device
already holds, so it can only ever remove an enrolled phone. Removing a key that is not
enrolled is not an error: there is simply nothing left to drop.
"""
if origin is not Origin.CLOUD:
raise AuthorizationError("cloud authorization required")
data = parse_object(raw)
if set(data) != {"v", "cloud_device_id", "comma_device_id", "key_id", "signature"} \
or type(data["v"]) is not int or data["v"] != VERSION:
raise AuthorizationError("invalid revocation")
if (data["cloud_device_id"], data["comma_device_id"]) != (self.cloud_device_id, self.comma_device_id):
raise AuthorizationError("wrong device")
app_id = text_field(data["key_id"], 128)
if len(decode64(app_id, max_bytes=32)) != 32:
raise AuthorizationError("invalid key id")
with self.lock:
grant = self.grants.get(app_id)
if grant is None:
# Nothing enrolled under this key. `revoke` still drops a matching staged enrollment.
self.revoke(app_id)
return app_id
proof = statement("revoke", self.cloud_device_id, self.comma_device_id, app_id)
key = load_app_key(grant.public_key)
try:
key.verify(decode64(data["signature"], max_bytes=72), proof, ec.ECDSA(hashes.SHA256()))
except InvalidSignature as e:
raise AuthorizationError("invalid proof") from e
self.revoke(app_id)
return app_id
def revoke_all(self) -> int:
"""Drop every grant in one durable write, leaving an open pairing window alone."""
with self.lock:
removed = len(self.grants)
if removed == 0:
return 0
self.persist({"v": VERSION, "grants": []})
self.grants = {}
return removed
def revoke(self, app_key_id: str) -> bool:
with self.lock:
if self.pending is not None and self.pending.key_id == app_key_id:
self.cancel()
if app_key_id not in self.grants:
return False
updated = {k: g for k, g in self.grants.items() if k != app_key_id}
self.persist({"v": VERSION, "grants": [g.to_dict() for g in updated.values()]})
self.grants = updated
return True
def session(self, grant: Grant, tls_peer_key_id: str) -> AuthorizedSession:
with self.lock:
if self.grants.get(grant.key_id) != grant or not secrets.compare_digest(grant.key_id, tls_peer_key_id):
raise AuthorizationError("unauthorized peer")
return AuthorizedSession(self, grant)
class AuthorizedSession:
"""Session-owned authorization, checked immediately before each handler executes."""
def __init__(self, authority: LocalAuthority, grant: Grant):
self.authority = authority
self.grant = grant
self.closed = False
def close(self) -> None:
with self.authority.lock:
self.closed = True
def invoke(self, method: str, handler: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
with self.authority.lock:
if self.closed or self.authority.grants.get(self.grant.key_id) != self.grant or method not in LOCAL_METHODS:
raise AuthorizationError("request denied")
# Serializes trust revocation with handler execution. Long-running operations
# are deliberately excluded from LOCAL_METHODS.
return handler(*args, **kwargs)
def connect_pinned(endpoint: str, expected_key_id: str, timeout_s: float = 10) -> WebSocket:
"""Pin before the HTTP upgrade, so no header or RPC leaves the device unverified.
PKIX is replaced by an exact public-key pin for this local socket only; never reuse this
context for cloud traffic. The pin proves private-key possession, nothing else.
"""
parsed = urlsplit(endpoint)
if parsed.scheme != "wss" or parsed.username is not None or parsed.password is not None \
or parsed.query or parsed.fragment or parsed.path not in ("", "/") or parsed.hostname is None:
raise AuthorizationError("invalid local endpoint")
try:
address = ipaddress.ip_address(parsed.hostname)
port = parsed.port
except ValueError as e:
raise AuthorizationError("invalid local endpoint") from e
if port is None or address.is_unspecified or address.is_multicast:
raise AuthorizationError("invalid local endpoint")
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
context.minimum_version = ssl.TLSVersion.TLSv1_3
context.maximum_version = ssl.TLSVersion.TLSv1_3
# New context and no supplied session prevent client resumption/early data.
connection = socket.create_connection((str(address), port), timeout=timeout_s)
tls: ssl.SSLSocket | None = None
try:
tls = context.wrap_socket(connection, server_hostname=None)
certificate = tls.getpeercert(binary_form=True)
if certificate is None:
raise AuthorizationError("missing peer certificate")
verify_tls_peer(certificate, expected_key_id)
return create_connection(endpoint, socket=tls, enable_multithread=True, timeout=timeout_s,
subprotocols=["sunnylink-local"])
except Exception:
(tls or connection).close()
raise
def verify_tls_peer(certificate_der: bytes, expected_key_id: str) -> str:
try:
key = x509.load_der_x509_certificate(certificate_der).public_key()
except ValueError as e:
raise AuthorizationError("invalid peer certificate") from e
if not isinstance(key, ec.EllipticCurvePublicKey) or not isinstance(key.curve, ec.SECP256R1):
raise AuthorizationError("unsupported peer key")
actual = key_id(key)
if not secrets.compare_digest(actual, expected_key_id):
raise AuthorizationError("wrong TLS peer")
return actual
@@ -1,506 +0,0 @@
"""The local daemon's store: the enrolled phones, their display metadata and the Params the
device UI uses to ask for a pairing window or a revocation.
Only `localsunnylinkd` holds the authority; the settings UI (another process) reads a view and
sends requests, so it can never persist a grant list of its own.
"""
from __future__ import annotations
import threading
import time
from typing import Any
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.common.api.base import BaseApi
from openpilot.sunnypilot.sunnylink.athena.local_auth import (
VERSION,
AuthorizationError,
LocalAuthority,
Origin,
text_field,
)
from openpilot.sunnypilot.sunnylink.athena.local_params import ENROLL as CLOUD_ENROLL_KEY, REVOKE as CLOUD_REVOKE_KEY
from openpilot.sunnypilot.sunnylink.athena.local_state import (
LOCAL_APPS_KEY,
LOCAL_APPS_META_KEY,
PAIRING_QR_KEY,
PAIRING_REQUEST_KEY,
REVOKE_ALL_KEY,
REVOKE_KEY,
)
MAX_ALIAS_BYTES = 80
_params = Params()
_authority: LocalAuthority | None = None
_authority_lock = threading.Lock()
_meta_lock = threading.Lock()
def _persist_registry(registry: dict[str, Any]) -> None:
"""Durable write before callers publish the change in memory (fail-closed ordering)."""
_params.put(LOCAL_APPS_KEY, registry, block=True)
def _read_registry() -> dict[str, Any] | None:
raw = _params.get(LOCAL_APPS_KEY)
return raw if isinstance(raw, dict) else None
def _build_authority() -> LocalAuthority | None:
try:
algorithm, private_pem, _public_pem = BaseApi.get_key_pair()
except Exception:
cloudlog.exception("sunnylinkd.local_auth.identity_unavailable")
return None
if algorithm is None or private_pem is None:
return None
try:
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec, rsa
pem = private_pem if isinstance(private_pem, bytes) else str(private_pem).encode()
device_key = serialization.load_pem_private_key(pem, password=None)
except Exception:
cloudlog.exception("sunnylinkd.local_auth.identity_load_failed")
return None
if not isinstance(device_key, (ec.EllipticCurvePrivateKey, rsa.RSAPrivateKey)):
cloudlog.error("sunnylinkd.local_auth.identity_unsupported")
return None
cloud_device_id = _params.get("SunnylinkDongleId")
comma_device_id = _params.get("DongleId")
if not cloud_device_id or not comma_device_id:
return None
return LocalAuthority(
cloud_device_id=cloud_device_id.decode() if isinstance(cloud_device_id, bytes) else cloud_device_id,
comma_device_id=comma_device_id.decode() if isinstance(comma_device_id, bytes) else comma_device_id,
device_private_key=device_key,
persist=_persist_registry,
registry=_read_registry(),
)
def get_authority() -> LocalAuthority | None:
"""The process authority, or None when local trust is unavailable (fail closed).
Every failure reports None instead of raising: the daemon calls this from its serving loop.
"""
global _authority
with _authority_lock:
if _authority is not None:
return _authority
try:
_authority = _build_authority()
except Exception:
cloudlog.exception("sunnylinkd.local_auth.authority_unavailable")
return None
return _authority
def reset_authority() -> None:
global _authority
with _authority_lock:
_authority = None
def arm_local_pairing() -> str | None:
authority = get_authority()
if authority is None:
return None
try:
return authority.arm()
except Exception:
cloudlog.exception("sunnylinkd.local_auth.arm_failed")
return None
def cancel_local_pairing() -> None:
authority = get_authority()
if authority is not None:
authority.cancel()
def _publish_qr(qr: str | None, params: Params) -> None:
try:
if qr:
params.put(PAIRING_QR_KEY, qr, block=True)
else:
params.remove(PAIRING_QR_KEY)
except Exception:
cloudlog.exception("sunnylinkd.local_auth.qr_publish_failed")
def _clear_request(params: Params) -> None:
try:
params.remove(PAIRING_REQUEST_KEY)
except Exception:
cloudlog.exception("sunnylinkd.local_auth.pairing_request_clear_failed")
def _armed_qr(authority: LocalAuthority) -> str | None:
with authority.lock:
window = authority.window
if window is None or authority.clock() >= window.deadline:
return None
return window.qr
def read_pairing_qr(params: Params | None = None) -> str | None:
"""The QR currently published for the UI, which is also the window's visible state."""
params = params or _params
try:
value = params.get(PAIRING_QR_KEY)
except Exception:
cloudlog.exception("sunnylinkd.local_auth.qr_read_failed")
return None
if isinstance(value, bytes):
value = value.decode("utf-8", errors="replace")
return value if isinstance(value, str) and value else None
def _read_meta() -> dict[str, Any]:
try:
raw = _params.get(LOCAL_APPS_META_KEY)
except Exception:
cloudlog.exception("sunnylinkd.local_auth.meta_unreadable")
return {}
apps = raw.get("apps") if isinstance(raw, dict) else None
return dict(apps) if isinstance(apps, dict) else {}
def _write_meta(apps: dict[str, Any]) -> None:
_params.put(LOCAL_APPS_META_KEY, {"v": VERSION, "apps": apps}, block=True)
def read_grant_meta() -> dict[str, dict[str, Any]]:
"""Per-phone display metadata: `{key_id: {"alias": str, "last_seen": float}}`, fail-soft."""
try:
entries = _read_meta()
except Exception:
cloudlog.exception("sunnylinkd.local_auth.meta_unreadable")
return {}
return {key_id: dict(entry) for key_id, entry in entries.items() if isinstance(entry, dict)}
def set_grant_alias(app_key_id: str, alias: str) -> bool:
"""Record the name a phone calls itself. Metadata only: nothing here grants anything."""
cleaned = (alias or "").strip() if isinstance(alias, str) else ""
if cleaned:
text_field(cleaned, MAX_ALIAS_BYTES)
with _meta_lock:
apps = _read_meta()
entry = dict(apps.get(app_key_id) or {})
entry["alias"] = cleaned
apps[app_key_id] = entry
_write_meta(apps)
return True
def note_grant_seen(app_key_id: str) -> None:
"""Stamp "this phone just connected"; a metadata failure never costs a working session."""
try:
with _meta_lock:
apps = _read_meta()
entry = dict(apps.get(app_key_id) or {})
entry["last_seen"] = time.monotonic()
apps[app_key_id] = entry
_write_meta(apps)
except Exception:
cloudlog.exception("sunnylinkd.local_auth.meta_seen_failed")
def clear_grant_meta(app_key_id: str) -> None:
try:
with _meta_lock:
apps = _read_meta()
if apps.pop(app_key_id, None) is not None:
_write_meta(apps)
except Exception:
cloudlog.exception("sunnylinkd.local_auth.meta_clear_failed")
def clear_all_grant_meta() -> None:
try:
with _meta_lock:
if _read_meta():
_write_meta({})
except Exception:
cloudlog.exception("sunnylinkd.local_auth.meta_clear_failed")
def clear_pairing_params(params: Params | None = None) -> None:
"""The UI's cancel signal: the daemon cancels the window it owns on its next tick."""
params = params or _params
_clear_request(params)
_publish_qr(None, params)
def clear_pairing_window(params: Params | None = None) -> None:
"""The daemon's own teardown: cancel the window this process holds and clear both params."""
clear_pairing_params(params)
cancel_local_pairing()
def service_pairing_window(params: Params | None = None) -> str | None:
"""Arm, publish or close the pairing window from the UI's request flag. Never raises."""
params = params or _params
try:
return _service_pairing_window(params)
except Exception:
cloudlog.exception("sunnylinkd.local_auth.pairing_window_failed")
return read_pairing_qr(params)
def _service_pairing_window(params: Params) -> str | None:
try:
requested = bool(params.get_bool(PAIRING_REQUEST_KEY))
except Exception:
cloudlog.exception("sunnylinkd.local_auth.pairing_request_unreadable")
requested = False
authority = get_authority()
if authority is None:
# No identity key means nothing could ever enroll: never show a window that cannot complete.
_clear_request(params)
_publish_qr(None, params)
return None
armed = _armed_qr(authority)
published = read_pairing_qr(params)
if armed is not None:
if published is None:
# A live window the UI no longer shows is a window the user cancelled.
cancel_local_pairing()
cloudlog.event("sunnylinkd.local_auth.window_cancelled")
return None
if published != armed:
# Only a stale QR is replaced: an armed, displayed window is never rotated, the app may
# already have scanned it.
_publish_qr(armed, params)
return armed
if authority.window is not None:
# The code's displayed lifetime is over. Hide it — the UI closes its dialog on the cleared
# param — but keep the session: an enrollment for the code the user just scanned can still be
# in flight through the cloud, which delivers on a later tick. Ending the session here is what
# made a scanned code fail with nothing shown on either side (the phone waited forever, the
# device said nothing). A cancel is different: it clears every session the user could have
# scanned from.
authority.expire()
cloudlog.event("sunnylinkd.local_auth.window_expired")
authority.prune_sessions()
if not requested:
if published is not None:
_publish_qr(None, params)
return None
qr = arm_local_pairing()
_clear_request(params)
_publish_qr(qr, params)
if qr is None:
return None
cloudlog.event("sunnylinkd.local_auth.window_armed")
return qr
def cloud_enroll(raw_enrollment: str) -> dict[str, Any]:
"""Stage an enrollment that arrived through the cloud settings-write transport.
Only ever called with a payload the cloud delivered; raises AuthorizationError for anything
that does not validate. Nothing is durable until the pinned TLS peer proves the key.
"""
authority = get_authority()
if authority is None:
raise AuthorizationError("enrollment unavailable")
grant = authority.enroll(raw_enrollment, Origin.CLOUD)
return {"app_id": grant.app_id, "app_name": grant.app_name, "revision": grant.revision}
def cloud_revoke(raw_revocation: str) -> dict[str, Any]:
"""Apply a phone's signed self-revocation of its own grant, same provenance as enrollment."""
authority = get_authority()
if authority is None:
raise AuthorizationError("revocation unavailable")
return {"app_id": authority.revoke_by_self_proof(raw_revocation, Origin.CLOUD)}
def claim_cloud_commands(params: Params | None = None) -> None:
"""Apply the two commands the cloud may deliver to the local daemon, then always clear them.
The cloud daemon only stores them (`local_params.CLOUD_COMMANDS`); the payload is validated and
staged here. A rejected payload is still cleared, so a bad command cannot be retried or replayed.
"""
params = params or _params
for key, apply in ((CLOUD_ENROLL_KEY, cloud_enroll), (CLOUD_REVOKE_KEY, cloud_revoke)):
value = _take(params, key)
if value is None:
continue
try:
summary = apply(value)
except AuthorizationError as e:
cloudlog.warning(f"sunnylinkd.local_auth.{key}_rejected: {e}")
except Exception:
cloudlog.exception(f"sunnylinkd.local_auth.{key}_failed")
else:
# An enrollment is staged here and only becomes durable when the phone's pinned TLS dial
# proves its key, but the event still records that the command landed. Without it a
# successful arrival is invisible and a rejected one looks like nothing happened at all.
app_name = summary.get("app_name") if isinstance(summary, dict) else None
cloudlog.event(f"sunnylinkd.local_auth.{key}_accepted", app_name=app_name)
def _take(params: Params, key: str) -> str | None:
try:
value = params.get(key)
params.remove(key)
except Exception:
cloudlog.exception("sunnylinkd.local_auth.command_unreadable")
return None
if isinstance(value, bytes):
value = value.decode("utf-8", errors="replace")
return value if isinstance(value, str) and value else None
def confirm_enrollment_tls(grant_key_id: str) -> dict[str, Any]:
"""Commit the pending grant once the pinned TLS peer proved possession of its key."""
authority = get_authority()
if authority is None:
raise AuthorizationError("enrollment unavailable")
pending = authority.pending
if pending is None or pending.key_id != grant_key_id:
raise AuthorizationError("no pending enrollment for this key")
committed = authority.confirm(pending, grant_key_id)
return {"app_id": committed.app_id, "app_name": committed.app_name, "revision": committed.revision}
def revoke_local_app(app_key_id: str) -> bool:
authority = get_authority()
if authority is None:
return False
revoked = authority.revoke(app_key_id)
if revoked:
clear_grant_meta(app_key_id)
return revoked
def revoke_all() -> int:
authority = get_authority()
if authority is None:
return 0
removed = authority.revoke_all()
if not authority.grants:
clear_all_grant_meta()
return removed
def request_revoke(app_key_id: str, params: Params | None = None) -> None:
"""The UI asks the daemon to drop one phone; the daemon applies it through its live authority."""
params = params or _params
if not isinstance(app_key_id, str) or not app_key_id:
return
try:
params.put(REVOKE_KEY, app_key_id, block=True)
except Exception:
cloudlog.exception("sunnylinkd.local_auth.revoke_request_failed")
def request_revoke_all(params: Params | None = None) -> None:
params = params or _params
try:
params.put_bool(REVOKE_ALL_KEY, True, block=True)
except Exception:
cloudlog.exception("sunnylinkd.local_auth.revoke_all_request_failed")
def service_revoke_requests(params: Params | None = None) -> bool:
"""Apply (and always clear) a pending UI revoke request, so one bad request cannot stick."""
params = params or _params
revoke_all_requested = False
try:
revoke_all_requested = bool(params.get_bool(REVOKE_ALL_KEY))
except Exception:
cloudlog.exception("sunnylinkd.local_auth.revoke_all_unreadable")
if revoke_all_requested:
removed = 0
try:
removed = revoke_all()
except Exception:
cloudlog.exception("sunnylinkd.local_auth.revoke_all_failed")
finally:
try:
params.remove(REVOKE_ALL_KEY)
params.remove(REVOKE_KEY) # every grant is gone, so a single request has nothing left
except Exception:
cloudlog.exception("sunnylinkd.local_auth.revoke_all_clear_failed")
if removed:
cloudlog.event("sunnylinkd.local_auth.grants_revoked", count=removed)
return bool(removed)
try:
requested = params.get(REVOKE_KEY)
except Exception:
cloudlog.exception("sunnylinkd.local_auth.revoke_request_unreadable")
return False
if isinstance(requested, bytes):
requested = requested.decode("utf-8", errors="replace")
app_key_id = requested if isinstance(requested, str) else ""
if not app_key_id:
return False
revoked = False
try:
revoked = revoke_local_app(app_key_id)
except Exception:
cloudlog.exception("sunnylinkd.local_auth.revoke_failed")
finally:
try:
params.remove(REVOKE_KEY)
except Exception:
cloudlog.exception("sunnylinkd.local_auth.revoke_clear_failed")
if revoked:
cloudlog.event("sunnylinkd.local_auth.grant_revoked", app_id=app_key_id)
return revoked
def grants() -> list[dict[str, str]]:
authority = get_authority()
if authority is None:
return []
return [grant.to_dict() for grant in authority.grants.values()]
def read_granted_apps() -> list[dict[str, str]]:
"""Read-only view of the enrolled phones for the device UI, fresh from Params.
Deliberately does not build the authority: the settings UI is another process.
"""
raw = _read_registry()
entries = raw.get("grants") if isinstance(raw, dict) else None
if not isinstance(entries, list):
return []
rows = []
for entry in entries:
if not isinstance(entry, dict):
continue
app_id = entry.get("app_id")
app_name = entry.get("app_name")
if isinstance(app_id, str) and app_id and isinstance(app_name, str):
rows.append({"app_id": app_id, "app_name": app_name})
return rows
def pending_enrollment() -> dict[str, str] | None:
authority = get_authority()
if authority is None:
return None
pending = authority.pending
return pending.to_dict() if pending is not None else None
@@ -1,262 +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.
Passive discovery of the sunnylink app on this network.
The app broadcasts a beacon every few seconds; this listener records every endpoint it hears and
keeps the freshest one visible to the settings UI while a pairing window is open. A beacon carries
no key material and no capability — it is a locator, and identity is decided by the pinned TLS dial
that follows (`local_auth.py`), so a forged or replayed beacon changes nothing.
"""
from __future__ import annotations
import json
import socket
import threading
import time
from dataclasses import dataclass
from collections.abc import Callable
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.sunnypilot.sunnylink.athena.local_state import (
BEACON_PREFIX,
DISCOVERED_APP_KEY,
PAIRING_REQUEST_KEY,
SUNNYLINK_LOCAL_UDP_PORT,
format_endpoint,
)
LOCAL_BEACON_FRESH_S = 30
@dataclass
class AppBeacon:
"""A parsed app beacon — the app announcing it is acting as the local backend."""
app_id: str
ws_port: int
source_ip: str
@property
def endpoint(self) -> str:
return format_endpoint(self.source_ip, self.ws_port)
def parse_beacon(raw: str | bytes, source_ip: str = "") -> AppBeacon | None:
"""
Parse one UDP beacon line from the app.
Wire format: `SUNNYLINK1 {"v":1,"role":"app","app_id":"<uuid>","ws_port":8443}`
Returns None for anything else. Beacons carry ids + addresses only — no secrets.
"""
if isinstance(raw, bytes):
raw = raw.decode("utf-8", errors="replace")
raw = raw.strip()
if not raw.startswith(BEACON_PREFIX + " "):
return None
try:
data = json.loads(raw[len(BEACON_PREFIX) + 1:])
except ValueError:
return None
if not isinstance(data, dict):
return None
if data.get("role") != "app" or data.get("v") != 1:
return None
app_id = data.get("app_id")
ws_port = data.get("ws_port")
if not isinstance(app_id, str) or not app_id:
return None
if not isinstance(ws_port, int) or not (0 < ws_port <= 65535):
return None
return AppBeacon(app_id=app_id, ws_port=ws_port, source_ip=source_ip)
class LocalDiscovery(threading.Thread):
"""Passive UDP listener.
- Every beacon heard recently is kept as an endpoint a pinned dial may target, keyed by address
because the dial's job is exactly "can I reach the phone at this address?".
- While a pairing window is open, the freshest one is mirrored into a status param so the
settings UI can show that the phone is there.
IPs are not identity: the app moves between networks, so a new address is simply a new endpoint,
and the key it proves over TLS is what decides whether the session is allowed.
"""
def __init__(self, params: Params | None = None, port: int = SUNNYLINK_LOCAL_UDP_PORT,
sock: socket.socket | None = None, write_interval_s: float = 5.0,
pairing_window_cb: Callable[[], bool] | None = None):
super().__init__(name="local_discovery_listener", daemon=True)
self.params = params or Params()
self.port = port
self._sock = sock
# A window the daemon holds is not visible as a param read the UI can do, so the status line
# needs to see both.
self.pairing_window_cb = pairing_window_cb
self._latest_endpoint: str | None = None
self._latest_app_id: str | None = None
self._last_seen_monotonic: float = 0.0
# Every beacon heard recently, keyed by endpoint: a dial finds its phone by TLS key, not by
# app_id, so it cannot rely on the pairing-window trackers above.
self._recent_beacons: dict[str, tuple[str, float]] = {}
self._recent_lock = threading.Lock()
self._lock = threading.Lock()
self._stop_event = threading.Event()
self.write_interval_s = write_interval_s
self._last_write_monotonic = 0.0
self._last_written_endpoint: str | None = None
self._last_written_app_id: str | None = None
self._discovered_cleared = False
def stop(self) -> None:
self._stop_event.set()
if self._sock is not None:
try:
self._sock.close()
except OSError:
pass
def latest_endpoint(self) -> str | None:
"""The most recently announced app endpoint (None outside a pairing window)."""
with self._lock:
return self._latest_endpoint
def latest_app_id(self) -> str | None:
"""The app_id of the most recently announced beacon (None outside a window)."""
with self._lock:
return self._latest_app_id
def last_seen_ago(self) -> float | None:
"""Seconds since the last app beacon was heard (None when none heard yet)."""
with self._lock:
if self._last_seen_monotonic == 0.0:
return None
return time.monotonic() - self._last_seen_monotonic
def fresh_endpoints(self, max_age_s: float = LOCAL_BEACON_FRESH_S) -> list[str]:
"""Endpoints of every beacon heard within max_age_s, freshest first."""
now = time.monotonic()
with self._recent_lock:
self._recent_beacons = {endpoint: entry for endpoint, entry in self._recent_beacons.items()
if now - entry[1] <= max_age_s}
recent = sorted(self._recent_beacons.items(), key=lambda item: item[1][1], reverse=True)
return [endpoint for endpoint, _ in recent]
def _record_beacon(self, beacon: AppBeacon) -> None:
with self._recent_lock:
self._recent_beacons[beacon.endpoint] = (beacon.app_id, time.monotonic())
def _pairing_window_open(self) -> bool:
"""True while the settings UI asked for a window, or the daemon is displaying one."""
if self.params.get_bool(PAIRING_REQUEST_KEY):
return True
callback = self.pairing_window_cb
if callback is None:
return False
try:
return bool(callback())
except Exception:
cloudlog.exception("local_discovery.pairing_window_cb.exception")
return False
def _handle(self, raw: bytes, source_ip: str) -> None:
beacon = parse_beacon(raw, source_ip)
if beacon is None:
return
self._record_beacon(beacon)
if self._pairing_window_open():
with self._lock:
self._latest_endpoint = beacon.endpoint
self._latest_app_id = beacon.app_id
self._last_seen_monotonic = time.monotonic()
self._write_discovered_param(beacon)
cloudlog.debug(f"local_discovery.app_found {beacon.app_id} at {beacon.endpoint}")
else:
with self._lock:
self._latest_endpoint = None
self._latest_app_id = None
self._last_seen_monotonic = 0.0
self._clear_discovered_param()
def _clear_discovered_param(self) -> None:
if self._discovered_cleared:
return
self._discovered_cleared = True
try:
self.params.remove(DISCOVERED_APP_KEY)
except Exception:
cloudlog.exception("local_discovery.param_clear.exception")
def _write_discovered_param(self, beacon: AppBeacon) -> None:
"""Mirror the freshest beacon into a param the settings UI can read."""
now = time.monotonic()
changed = beacon.endpoint != self._last_written_endpoint or beacon.app_id != self._last_written_app_id
if not changed and now - self._last_write_monotonic < self.write_interval_s:
return
self._last_write_monotonic = now
self._last_written_endpoint = beacon.endpoint
self._last_written_app_id = beacon.app_id
self._discovered_cleared = False
payload = {
"endpoint": beacon.endpoint,
"app_id": beacon.app_id,
"ts": int(time.monotonic()),
}
try:
self.params.put(DISCOVERED_APP_KEY, payload, block=True)
except Exception:
cloudlog.exception("local_discovery.param_write.exception")
def _bind(self) -> socket.socket:
if self._sock is not None:
return self._sock
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("0.0.0.0", self.port))
sock.settimeout(0.5)
return sock
def run(self) -> None:
sock = self._bind()
try:
while not self._stop_event.is_set():
try:
data, addr = sock.recvfrom(4096)
self._handle(data, addr[0] if len(addr) > 0 else "")
except TimeoutError:
continue
except OSError:
# Socket closed by stop() — exit quietly.
if self._stop_event.is_set():
break
cloudlog.exception("local_discovery.recv.exception")
break
finally:
try:
sock.close()
except OSError:
pass
def latest_discovered_app(params: Params | None = None,
fresh_s: float = LOCAL_BEACON_FRESH_S) -> tuple[str, int] | None:
params = params or Params()
data = params.get(DISCOVERED_APP_KEY)
if not isinstance(data, dict):
return None
endpoint = str(data.get("endpoint", ""))
try:
ts = int(data.get("ts") or 0)
except (ValueError, TypeError):
return None
if not endpoint or ts <= 0:
return None
age = time.monotonic() - ts
# Negative age = written before the last reboot (monotonic restarts at boot).
if age < 0 or age > fresh_s:
return None
return endpoint, max(0, int(age))
@@ -1,22 +0,0 @@
"""Param names the local daemon owns, and who may touch them.
`SunnylinkLocal*` (and `_sec_*`) belong to the local daemon: the cloud daemon may not read or write
them, except the two commands an app pushes through the cloud settings-write for it to claim.
"""
from __future__ import annotations
ENROLL = "SunnylinkLocalEnroll"
REVOKE = "SunnylinkLocalCloudRevoke"
CLOUD_COMMANDS = frozenset({ENROLL, REVOKE})
_PREFIXES = ("SunnylinkLocal", "_sec_")
def is_local_param(key: str) -> bool:
"""True for every param the local side owns: hidden from the cloud and from local sessions."""
return key.startswith(_PREFIXES)
def cloud_may_write(key: str) -> bool:
"""The cloud settings-write may deliver its two local commands and nothing else local."""
return key in CLOUD_COMMANDS or not is_local_param(key)
@@ -1,50 +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.
Where local mode keeps its state, and the wire constants both halves share.
Every key here is under the `SunnylinkLocal` prefix, which the cloud daemon may neither read nor
write (`local_params.py`): a phone cannot enroll, rename or unpair itself through a local session,
only through the cloud settings-write an account already has to be allowed to make.
"""
from __future__ import annotations
from openpilot.common.params import Params
SUNNYLINK_LOCAL_UDP_PORT = 53133
SUNNYLINK_LOCAL_WS_PORT = 8443
# The beacon line the phone broadcasts: `SUNNYLINK1 {"v":1,"role":"app",...}`. A locator, never a
# credential.
BEACON_PREFIX = "SUNNYLINK1"
# The enrolled phones, `{"v": 2, "grants": [...]}`, written only by the local daemon.
LOCAL_APPS_KEY = "SunnylinkLocalApps"
# Display metadata per enrolled key id: `{"v": 2, "apps": {key_id: {"alias", "last_seen"}}}`.
LOCAL_APPS_META_KEY = "SunnylinkLocalAppsMeta"
# The freshest beacon, for the settings UI to show that a phone is around.
DISCOVERED_APP_KEY = "SunnylinkLocalDiscoveredApp"
# The settings UI asks for a pairing window here; the daemon publishes the QR it minted.
PAIRING_REQUEST_KEY = "SunnylinkLocalPairingRequest"
PAIRING_QR_KEY = "SunnylinkLocalPairingQr"
# The settings UI asks the daemon to drop one phone (key id) or all of them.
REVOKE_KEY = "SunnylinkLocalRevoke"
REVOKE_ALL_KEY = "SunnylinkLocalRevokeAll"
def is_locally_paired(params: Params | None = None) -> bool:
"""True once this device has at least one enrolled phone.
Reads the registry rather than the authority: `sunnylinkd` asks this at startup and the device UI
renders it continuously, and neither may build a second authority in its own process.
"""
params = params or Params()
return bool(params.get(LOCAL_APPS_KEY))
def format_endpoint(host: str, ws_port: int = SUNNYLINK_LOCAL_WS_PORT) -> str:
return f"ws://{host}:{ws_port}"
@@ -1,347 +0,0 @@
#!/usr/bin/env python3
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
from __future__ import annotations
import json
import threading
import time
from collections.abc import Callable
from urllib.parse import urlsplit
from websocket import WebSocket, WebSocketTimeoutException
from openpilot.system.athena.rpc import dispatcher
from openpilot.system.athena import rpc as rpc_module
from openpilot.common.realtime import set_core_affinity
from openpilot.common.swaglog import cloudlog
from openpilot.sunnypilot.sunnylink.athena import local_auth_daemon
from openpilot.sunnypilot.sunnylink.athena.local_auth import (
AUTHENTICATE_PURPOSE,
LOCAL_METHODS,
VERSION,
AuthorizationError,
AuthorizedSession,
LocalAuthority,
connect_pinned,
parse_object,
)
from openpilot.sunnypilot.sunnylink.athena.local_discovery import LocalDiscovery
from openpilot.sunnypilot.sunnylink.athena.local_params import is_local_param
from openpilot.sunnypilot.sunnylink.athena.sunnylinkd import SUNNYLINK_RECONNECT_TIMEOUT_S, Sunnylinkd, log_connection_error
LOCAL_ENABLED_PARAM = "SunnylinkLocalEnabled"
SESSION_READ_TIMEOUT_S = 5
TICK_INTERVAL_S = 1.0
# A local endpoint is THE phone, and the app is a foreground-only server the user opens and closes,
# so a dial that failed because the app was closed must not outlive it: the first failure costs a
# single tick and doubles from there. The ramp is capped at a minute, because every step of it is a
# few seconds of the user's device sitting on the cloud instead of the LAN. A
# phone whose beacons have lapsed clears its own record entirely (see `forget_absent_endpoints`).
ENDPOINT_BACKOFF_S = float(TICK_INTERVAL_S)
ENDPOINT_BACKOFF_MAX_S = 60.0
# How long a phone may stay silent before its backoff record is dropped. Deliberately shorter than
# the beacon freshness window: a Wi-Fi roam or a switch to another network (home -> the car's
# hotspot) silences the app for a few beacon intervals while it reassociates, and the ramp it had
# accumulated on the old link has nothing to do with the new one. While it is inside that window
# the endpoint stays dialable, so the next beacon gets an immediate attempt rather than a penalty.
BACKOFF_FORGET_S = 12.0
class LocalSunnylinkd(Sunnylinkd):
"""The LAN half of sunnylink, in its own process.
It inherits the settings RPCs and adds what is local: discovery, pinned-TLS sessions with the
cloud-enrolled phones, and the UI's trust requests. It never dials the cloud, so the cloud link
keeps running while a phone is connected here.
"""
def __init__(self, params=None, discovery: LocalDiscovery | None = None):
super().__init__(params)
self.discovery = discovery if discovery is not None else LocalDiscovery(pairing_window_cb=self.pairing_window_open)
self.backoffs: dict[str, float] = {}
# Consecutive failed dials per endpoint, which is what turns the backoff into a ramp.
self.failures: dict[str, int] = {}
self.stop_event = threading.Event()
# --- local trust --------------------------------------------------------
def enrolled(self) -> bool:
"""True once any phone is enrolled, or an enrollment is waiting to be confirmed."""
return bool(local_auth_daemon.grants()) or local_auth_daemon.pending_enrollment() is not None
def pairing_window_open(self) -> bool:
"""A live pairing window counts as a beacon-display window for the settings screen."""
try:
return local_auth_daemon.read_pairing_qr() is not None
except Exception:
return False
def saveParams(self, params_to_update: dict[str, str], compression: bool = False) -> None:
"""Local sessions never touch local params: enrollment and revocation only ever arrive
through the cloud settings-write, so a phone cannot enroll or rename itself here."""
allowed = {key: value for key, value in params_to_update.items() if not is_local_param(key)}
if len(allowed) != len(params_to_update):
cloudlog.warning("sunnylinkd.local.saveParams.refused_local_params")
if allowed:
super().saveParams(allowed, compression)
# --- loop ---------------------------------------------------------------
def serviceable(self) -> bool:
"""Serve the LAN while sunnylink is on, this process is switched on, and there is no fault.
A locally paired device is often not cloud-registered at all, so registration is not part of
this: the cloud daemon waits for it, the local one never does.
"""
return self.params.get_bool("SunnylinkEnabled") and not self.params.get_bool("SunnylinkTempFault") \
and self.params.get_bool(LOCAL_ENABLED_PARAM)
def run(self, exit_event: threading.Event | None = None) -> None:
self.discovery.start()
threading.Thread(target=self._tick_loop, name="localsunnylinkd_tick", daemon=True).start()
try:
while (exit_event is None or not exit_event.is_set()) and self.serviceable():
endpoint = self.pick_target()
if endpoint is None:
time.sleep(TICK_INTERVAL_S)
else:
self.serve(endpoint, exit_event)
finally:
self.stop_event.set()
self.discovery.stop()
local_auth_daemon.clear_pairing_window()
def _tick_loop(self) -> None:
"""The UI's trust requests and the cloud's local commands, ~once a second, in their own
thread: the serving loop above blocks for as long as a session lasts."""
while not self.stop_event.wait(TICK_INTERVAL_S) and self.serviceable():
try:
local_auth_daemon.service_pairing_window()
local_auth_daemon.service_revoke_requests()
local_auth_daemon.claim_cloud_commands()
except Exception:
cloudlog.exception("sunnylinkd.local.tick_failed")
def pick_target(self) -> str | None:
"""Where to dial next: the freshest phone beacon that is not backing off.
Dialing finds the phone by its pinned TLS key, so any fresh endpoint is worth one attempt: the
key is the authorization, and a peer without it cannot complete the handshake.
"""
if not self.enrolled():
return None
now = time.monotonic()
pinned_targets = [pinned for pinned in (pinned_endpoint(beacon) for beacon in self.discovery.fresh_endpoints())
if pinned is not None]
# Backoff bookkeeping reads a shorter window than dialing does: a phone that went quiet for a
# few beacon intervals (a roam, a network switch) keeps its endpoint but loses its ramp.
recent = {pinned for pinned in (pinned_endpoint(beacon) for beacon in self.discovery.fresh_endpoints(max_age_s=BACKOFF_FORGET_S))
if pinned is not None}
self.forget_absent_endpoints(recent)
for pinned in pinned_targets:
if self.backoffs.get(pinned, 0.0) <= now:
return pinned
return None
def forget_absent_endpoints(self, present: set[str]) -> None:
"""Drop the backoff of a phone that stopped announcing.
While the app is closed there is no beacon and nothing to dial, so a failure recorded then says
nothing about the phone the user opens next: the silence window is what tells "the app is gone"
apart from "the app cannot be served". Only a phone that keeps announcing keeps backing off.
The caller passes the short `BACKOFF_FORGET_S` window, so a phone that went quiet across a
network change also loses the ramp it earned on the link it is no longer on.
"""
for endpoint in [key for key in self.backoffs if key.startswith("wss://") and key not in present]:
self.backoffs.pop(endpoint, None)
self.failures.pop(endpoint, None)
def serve(self, endpoint: str, exit_event: threading.Event | None) -> None:
try:
self.dial(endpoint, exit_event)
except Exception as e:
failures = self.failures.get(endpoint, 0) + 1
self.failures[endpoint] = failures
self.backoffs[endpoint] = time.monotonic() + min(ENDPOINT_BACKOFF_S * (2 ** (failures - 1)),
ENDPOINT_BACKOFF_MAX_S)
self.params.remove("LastSunnylinkPingTime")
log_connection_error(e)
return
self.backoffs.pop(endpoint, None)
self.failures.pop(endpoint, None)
# A finished session is not an error, but never redial in a tight loop.
time.sleep(TICK_INTERVAL_S)
def serve_rpc(self, ws: WebSocket, methods: dict[str, Callable[..., object]],
exit_event: threading.Event | None = None) -> None:
"""Answer JSON-RPC calls on one socket until the peer goes away or the session ends."""
ws.settimeout(SESSION_READ_TIMEOUT_S)
while exit_event is None or not exit_event.is_set():
try:
raw = ws.recv()
except WebSocketTimeoutException:
continue
except Exception as e:
cloudlog.warning(f"sunnylinkd.session.{type(e).__name__}")
return
try:
msg = rpc_module.loads(raw)
except Exception:
continue
if not rpc_module.is_call(msg):
continue
try:
response = rpc_module.handle(msg, methods)
except ValueError:
continue
try:
ws.send(response)
except Exception as e:
cloudlog.warning(f"sunnylinkd.send.{type(e).__name__}")
return
def dial(self, endpoint: str, exit_event: threading.Event | None) -> None:
"""Dial an enrolled (or enrolling) phone over pinned TLS and serve it.
The pinned key IS the authorization, so trying each known key is safe: a peer without the
matching private key cannot complete the handshake.
"""
authority = local_auth_daemon.get_authority()
if authority is None:
raise AuthorizationError("enrollment unavailable")
with authority.lock:
pending_key_id = authority.pending.key_id if authority.pending is not None else None
candidates = ([pending_key_id] if pending_key_id is not None else []) + list(authority.grants)
ws: WebSocket | None = None
matched: str | None = None
last_error: Exception | None = None
for key_id in dict.fromkeys(candidates):
try:
ws = connect_pinned(endpoint, key_id)
matched = key_id
break
except Exception as e:
last_error = e
if ws is None or matched is None:
raise last_error or AuthorizationError("no enrolled app at this endpoint")
session: AuthorizedSession | None = None
try:
self.authenticate_phone(ws, authority, matched)
if pending_key_id is not None and matched == pending_key_id:
try:
local_auth_daemon.confirm_enrollment_tls(matched)
except Exception:
# The window closed or failed: drop the stale pending rather than redial forever.
cloudlog.exception("sunnylinkd.local.confirm_failed")
local_auth_daemon.cancel_local_pairing()
raise
with authority.lock:
grant = authority.grants.get(matched)
if grant is None:
raise AuthorizationError("peer is not enrolled")
session = authority.session(grant, matched)
local_auth_daemon.note_grant_seen(grant.key_id)
cloudlog.event("sunnylinkd.local.session_opened", endpoint=endpoint, app_id=grant.app_id)
self.serve_rpc(ws, self.session_methods(session), exit_event)
finally:
if session is not None:
session.close()
close_quietly(ws)
def authenticate_phone(self, ws: WebSocket, authority: LocalAuthority, phone_key_id: str) -> None:
"""Answer the phone's fresh challenge, so it can authenticate THIS device first.
Nothing else is sent, and no request is served, on a session the phone has not verified.
"""
ws.settimeout(SUNNYLINK_RECONNECT_TIMEOUT_S)
raw = ws.recv()
if not isinstance(raw, str):
raise AuthorizationError("invalid challenge")
data = parse_object(raw)
if set(data) != {"v", "purpose", "phone_key_id", "challenge"} \
or type(data["v"]) is not int or data["v"] != VERSION \
or data["purpose"] != AUTHENTICATE_PURPOSE \
or data["phone_key_id"] != phone_key_id:
raise AuthorizationError("invalid challenge")
ws.send(json.dumps(authority.authenticate_device(phone_key_id, data["challenge"]), separators=(",", ":")))
def session_methods(self, session: AuthorizedSession) -> dict[str, Callable[..., object]]:
"""Strictly LOCAL_METHODS, re-authorized by the session immediately before each call."""
def gated(name: str, handler: Callable[..., object]) -> Callable[..., object]:
def call(*args: object, **kwargs: object) -> object:
return session.invoke(name, handler, *args, **kwargs)
return call
def unpair_local_app(app_id: str = "") -> dict[str, bool]:
removed = local_auth_daemon.revoke_local_app(session.grant.key_id)
return {"success": bool(removed), "removed": bool(removed)}
def update_local_app_alias(app_id: str = "", alias: str = "") -> dict[str, bool | str]:
# The principal is the session's grant, so a phone can only rename itself.
try:
local_auth_daemon.set_grant_alias(session.grant.key_id, alias)
except AuthorizationError:
return {"success": False, "error": "invalid alias"}
return {"success": True, "updated": True}
# This daemon's own handlers win, so an overridden method is the one a session reaches.
overridden = ("unpairLocalApp", "updateLocalAppAlias")
surface = self.methods()
methods: dict[str, Callable[..., object]] = {}
for name in LOCAL_METHODS:
if name in overridden:
continue
handler = surface.get(name) or dispatcher.get(name)
if handler is not None:
methods[name] = gated(name, handler)
methods["unpairLocalApp"] = gated("unpairLocalApp", unpair_local_app)
methods["updateLocalAppAlias"] = gated("updateLocalAppAlias", update_local_app_alias)
return methods
def pinned_endpoint(beacon_endpoint: str) -> str | None:
"""Beacons announce `ws://ip:port`; a dial uses the same host and port over pinned TLS."""
parsed = urlsplit(beacon_endpoint)
if parsed.scheme != "ws" or parsed.hostname is None or parsed.port is None:
return None
return f"wss://{parsed.hostname}:{parsed.port}"
def close_quietly(ws: WebSocket | None) -> None:
if ws is None:
return
try:
ws.close()
except Exception:
pass
def main(exit_event: threading.Event | None = None):
try:
set_core_affinity([0, 1, 2, 3])
except Exception:
cloudlog.exception("failed to set core affinity")
daemon = LocalSunnylinkd()
for method in daemon.methods().values():
dispatcher.add_method(method)
daemon.run(exit_event)
if __name__ == "__main__":
main()
@@ -16,7 +16,6 @@ import ssl
import threading
import time
from collections.abc import Callable
from functools import partial
from openpilot.system.athena.rpc import dispatcher
from openpilot.common.params import Params, ParamKeyType
@@ -35,7 +34,6 @@ from openpilot.sunnypilot.sunnylink.api import SunnylinkApi
from openpilot.sunnypilot.sunnylink.utils import sunnylink_need_register, sunnylink_ready, get_param_as_byte, save_param_from_base64_encoded_string
from openpilot.sunnypilot.sunnylink.capabilities import generate_capabilities, CAPABILITY_LABELS
from openpilot.sunnypilot.sunnylink.tools.generate_settings_schema import generate_schema
from openpilot.sunnypilot.sunnylink.athena.local_params import cloud_may_write, is_local_param
SUNNYLINK_ATHENA_HOST = os.getenv('SUNNYLINK_ATHENA_HOST', 'wss://athena.sunnylink.ai')
HANDLER_THREADS = int(os.getenv('HANDLER_THREADS', "4"))
@@ -44,6 +42,8 @@ SUNNYLINK_LOG_ATTR_NAME = "user.sunny.upload"
SUNNYLINK_RECONNECT_TIMEOUT_S = 70 # FYI changing this will also would require a change on sidebar.cc
DISALLOW_LOG_UPLOAD = threading.Event()
params = Params()
# Parameters that should never be remotely modified
BLOCKED_PARAMS = {
"AdbEnabled",
@@ -58,6 +58,60 @@ BLOCKED_PARAMS = {
}
def handle_long_poll(ws: WebSocket, exit_event: threading.Event | None) -> None:
cloudlog.info("sunnylinkd.handle_long_poll started")
sm = messaging.SubMaster(['deviceState'])
end_event = threading.Event()
comma_prime_cellular_end_event = threading.Event()
threads = [
threading.Thread(target=ws_manage, args=(ws, end_event), name='ws_manage'),
threading.Thread(target=ws_recv, args=(ws, end_event), name='ws_recv'),
threading.Thread(target=ws_send, args=(ws, end_event), name='ws_send'),
threading.Thread(target=ws_ping, args=(ws, end_event), name='ws_ping'),
threading.Thread(target=upload_handler, args=(end_event,), name='upload_handler'),
threading.Thread(target=sunny_log_handler, args=(end_event, comma_prime_cellular_end_event), name='log_handler'),
threading.Thread(target=stat_handler, args=(end_event, Paths.stats_sp_root(), True), name='stat_handler'),
] + [
threading.Thread(target=jsonrpc_handler, args=(end_event, partial(startLocalProxy, end_event),), name=f'worker_{x}')
for x in range(HANDLER_THREADS)
]
for thread in threads:
thread.start()
try:
while not end_event.wait(0.1):
if not sunnylink_ready(params):
cloudlog.warning("Exiting sunnylinkd.handle_long_poll as SunnylinkEnabled is False")
break
sm.update(0)
if exit_event is not None and exit_event.is_set():
end_event.set()
comma_prime_cellular_end_event.set()
prime_type = params.get("PrimeType") or 0
metered = sm['deviceState'].networkMetered
if DISALLOW_LOG_UPLOAD.is_set() and not comma_prime_cellular_end_event.is_set():
cloudlog.debug("sunnylinkd.handle_long_poll: DISALLOW_LOG_UPLOAD, setting comma_prime_cellular_end_event")
comma_prime_cellular_end_event.set()
elif metered and int(prime_type) > 2:
cloudlog.debug(f"sunnylinkd.handle_long_poll: PrimeType({prime_type}) > 2 and networkMetered({metered})")
comma_prime_cellular_end_event.set()
elif comma_prime_cellular_end_event.is_set() and not DISALLOW_LOG_UPLOAD.is_set():
cloudlog.debug(
f"sunnylinkd.handle_long_poll: comma_prime_cellular_end_event is set and not PrimeType({prime_type}) > 2 or not networkMetered({metered})")
comma_prime_cellular_end_event.clear()
finally:
end_event.set()
comma_prime_cellular_end_event.set()
for thread in threads:
cloudlog.debug(f"sunnylinkd athena.joining {thread.name}")
thread.join()
cloudlog.debug(f"sunnylinkd athena.joined {thread.name}")
def ws_recv(ws: WebSocket, end_event: threading.Event) -> None:
last_ping = int(time.monotonic() * 1e9)
while not end_event.is_set():
@@ -104,186 +158,112 @@ def sunny_log_handler(end_event: threading.Event, comma_prime_cellular_end_event
comma_prime_cellular_end_event.set()
class Sunnylinkd:
"""The cloud half of sunnylink: the athena connection that serves the settings RPCs.
@dispatcher.add_method
def toggleLogUpload(enabled: bool):
DISALLOW_LOG_UPLOAD.clear() if enabled and DISALLOW_LOG_UPLOAD.is_set() else DISALLOW_LOG_UPLOAD.set()
The LAN half is `localsunnylinkd.LocalSunnylinkd`, which subclasses this and overrides only the
RPCs and helpers it changes. Every method here is shared, and none of them may read or write a
param the local side owns.
@dispatcher.add_method
def getParamsAllKeys() -> list[str]:
keys: list[str] = [k.decode('utf-8') for k in Params().all_keys()]
return keys
@dispatcher.add_method
def getParamsMetadata() -> str:
"""Return settings_ui.json + live capabilities as gzip-compressed, base64-encoded string.
Reads settings_ui.json, injects live capabilities from CarParams, compresses,
and returns. Single RPC for the frontend to get the complete settings UI and
runtime capabilities.
"""
try:
schema = generate_schema()
schema["capabilities"] = generate_capabilities()
schema["capability_labels"] = CAPABILITY_LABELS
schema["default_model"] = DEFAULT_MODEL
schema["default_big_model"] = DEFAULT_BIG_MODEL
schema["chestnut_active"] = params.get_bool("ChestnutActive")
raw = json.dumps(schema, separators=(",", ":")).encode("utf-8")
return base64.b64encode(gzip.compress(raw)).decode("utf-8")
except Exception:
cloudlog.exception("sunnylinkd.getParamsMetadata.exception")
raise
# What this daemon serves over the wire. `startLocalProxy` is deliberately absent: the athena
# jsonrpc workers install it on the dispatcher themselves, with the event this daemon owns.
RPC_METHODS = ("toggleLogUpload", "getParamsAllKeys", "getParamsMetadata", "getParams", "saveParams")
def __init__(self, params: Params | None = None):
self.params = params or Params()
@dispatcher.add_method
def getParams(params_keys: list[str], compression: bool = False) -> str | dict[str, str]:
params = Params()
available_keys: list[str] = [k.decode('utf-8') for k in Params().all_keys()]
def methods(self) -> dict[str, Callable[..., object]]:
"""The RPC surface this daemon serves, keyed by wire method name."""
return {name: getattr(self, name) for name in self.RPC_METHODS}
try:
zero_values: dict[int, bytes] = {
ParamKeyType.STRING.value: b"",
ParamKeyType.BOOL.value: b"0",
ParamKeyType.INT.value: b"0",
ParamKeyType.FLOAT.value: b"0.0",
ParamKeyType.TIME.value: b"",
ParamKeyType.JSON.value: b"{}",
ParamKeyType.BYTES.value: b"",
}
def handle_long_poll(self, ws: WebSocket, exit_event: threading.Event | None) -> None:
cloudlog.info("sunnylinkd.handle_long_poll started")
sm = messaging.SubMaster(['deviceState'])
end_event = threading.Event()
comma_prime_cellular_end_event = threading.Event()
param_keys_validated = [key for key in params_keys if key in available_keys]
params_dict: dict[str, list[dict[str, str | bool | int]]] = {"params": []}
for key in param_keys_validated:
value = get_param_as_byte(key)
if value is None:
value = get_param_as_byte(key, get_default=True)
if value is None:
param_type = params.get_type(key)
value = zero_values.get(param_type.value, b"")
threads = [
threading.Thread(target=ws_manage, args=(ws, end_event), name='ws_manage'),
threading.Thread(target=ws_recv, args=(ws, end_event), name='ws_recv'),
threading.Thread(target=ws_send, args=(ws, end_event), name='ws_send'),
threading.Thread(target=ws_ping, args=(ws, end_event), name='ws_ping'),
threading.Thread(target=upload_handler, args=(end_event,), name='upload_handler'),
threading.Thread(target=sunny_log_handler, args=(end_event, comma_prime_cellular_end_event), name='log_handler'),
threading.Thread(target=stat_handler, args=(end_event, Paths.stats_sp_root(), True), name='stat_handler'),
] + [
threading.Thread(target=jsonrpc_handler, args=(end_event, partial(self.startLocalProxy, end_event),), name=f'worker_{x}')
for x in range(HANDLER_THREADS)
]
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
})
for thread in threads:
thread.start()
try:
while not end_event.wait(0.1):
if not sunnylink_ready(self.params):
cloudlog.warning("Exiting sunnylinkd.handle_long_poll as SunnylinkEnabled is False")
break
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
sm.update(0)
if exit_event is not None and exit_event.is_set():
end_event.set()
comma_prime_cellular_end_event.set()
except Exception as e:
cloudlog.exception("sunnylinkd.getParams.exception", e)
raise
prime_type = self.params.get("PrimeType") or 0
metered = sm['deviceState'].networkMetered
if DISALLOW_LOG_UPLOAD.is_set() and not comma_prime_cellular_end_event.is_set():
cloudlog.debug("sunnylinkd.handle_long_poll: DISALLOW_LOG_UPLOAD, setting comma_prime_cellular_end_event")
comma_prime_cellular_end_event.set()
elif metered and int(prime_type) > 2:
cloudlog.debug(f"sunnylinkd.handle_long_poll: PrimeType({prime_type}) > 2 and networkMetered({metered})")
comma_prime_cellular_end_event.set()
elif comma_prime_cellular_end_event.is_set() and not DISALLOW_LOG_UPLOAD.is_set():
cloudlog.debug(
f"sunnylinkd.handle_long_poll: comma_prime_cellular_end_event is set and not PrimeType({prime_type}) > 2 or not networkMetered({metered})")
comma_prime_cellular_end_event.clear()
finally:
end_event.set()
comma_prime_cellular_end_event.set()
for thread in threads:
cloudlog.debug(f"sunnylinkd athena.joining {thread.name}")
thread.join()
cloudlog.debug(f"sunnylinkd athena.joined {thread.name}")
def toggleLogUpload(self, enabled: bool):
DISALLOW_LOG_UPLOAD.clear() if enabled and DISALLOW_LOG_UPLOAD.is_set() else DISALLOW_LOG_UPLOAD.set()
def getParamsAllKeys(self) -> list[str]:
keys: list[str] = [k.decode('utf-8') for k in self.params.all_keys()]
return [key for key in keys if not is_local_param(key)]
def getParamsMetadata(self) -> str:
"""Return settings_ui.json + live capabilities as gzip-compressed, base64-encoded string.
Reads settings_ui.json, injects live capabilities from CarParams, compresses,
and returns. Single RPC for the frontend to get the complete settings UI and
runtime capabilities.
"""
try:
schema = generate_schema()
schema["capabilities"] = generate_capabilities()
schema["capability_labels"] = CAPABILITY_LABELS
schema["default_model"] = DEFAULT_MODEL
schema["default_big_model"] = DEFAULT_BIG_MODEL
schema["chestnut_active"] = self.params.get_bool("ChestnutActive")
raw = json.dumps(schema, separators=(",", ":")).encode("utf-8")
return base64.b64encode(gzip.compress(raw)).decode("utf-8")
except Exception:
cloudlog.exception("sunnylinkd.getParamsMetadata.exception")
raise
def getParams(self, params_keys: list[str], compression: bool = False) -> str | dict[str, str]:
available_keys: list[str] = [k.decode('utf-8') for k in self.params.all_keys()]
@dispatcher.add_method
def saveParams(params_to_update: dict[str, str], compression: bool = False) -> None:
for key, value in params_to_update.items():
# disallow modifications to blocked parameters
if key in BLOCKED_PARAMS:
cloudlog.warning(f"sunnylinkd.saveParams.blocked: Attempted to modify blocked parameter '{key}'")
continue
try:
zero_values: dict[int, bytes] = {
ParamKeyType.STRING.value: b"",
ParamKeyType.BOOL.value: b"0",
ParamKeyType.INT.value: b"0",
ParamKeyType.FLOAT.value: b"0.0",
ParamKeyType.TIME.value: b"",
ParamKeyType.JSON.value: b"{}",
ParamKeyType.BYTES.value: b"",
}
param_keys_validated = [key for key in params_keys if key in available_keys and not is_local_param(key)]
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:
value = get_param_as_byte(key, get_default=True)
if value is None:
param_type = self.params.get_type(key)
value = zero_values.get(param_type.value, b"")
params_dict["params"].append({
"key": key,
"value": base64.b64encode(gzip.compress(value) if compression else value).decode('utf-8'),
"type": int(self.params.get_type(key).value),
"is_compressed": compression
})
response = {str(param.get('key')): str(param.get('value')) for param in params_dict.get("params", [])}
response |= {"params": json.dumps(params_dict.get("params", []))} # Upcoming for settings v1
return response
save_param_from_base64_encoded_string(key, value, compression)
except Exception as e:
cloudlog.exception("sunnylinkd.getParams.exception", e)
raise
cloudlog.error(f"sunnylinkd.saveParams.exception {e}")
def saveParams(self, params_to_update: dict[str, str], compression: bool = False) -> None:
for key, value in params_to_update.items():
# disallow modifications to blocked parameters, and to anything the local side owns
if key in BLOCKED_PARAMS or not cloud_may_write(key):
cloudlog.warning(f"sunnylinkd.saveParams.blocked: Attempted to modify blocked parameter '{key}'")
continue
try:
save_param_from_base64_encoded_string(key, value, compression)
except Exception as e:
cloudlog.error(f"sunnylinkd.saveParams.exception {e}")
# Increment version counter for frontend change detection
try:
current = int(self.params.get("ParamsVersion") or "0")
self.params.put("ParamsVersion", str(current + 1), block=True)
except Exception:
pass
def startLocalProxy(self, global_end_event: threading.Event, remote_ws_uri: str, local_port: int) -> dict[str, int]:
sunnylink_dongle_id = self.params.get("SunnylinkDongleId")
sunnylink_api = SunnylinkApi(sunnylink_dongle_id)
cloudlog.debug("athena.startLocalProxy.starting")
ws = create_connection(
remote_ws_uri, header={"Authorization": f"Bearer {sunnylink_api.get_token()}"}, enable_multithread=True, sslopt={"cert_reqs": ssl.CERT_NONE}
)
return start_local_proxy_shim(global_end_event, local_port, ws)
# Increment version counter for frontend change detection
try:
current = int(params.get("ParamsVersion") or "0")
params.put("ParamsVersion", str(current + 1), block=True)
except Exception:
pass
def log_connection_error(e: Exception) -> None:
"""Report a failed connection the way stock sunnylinkd.main did; shared with localsunnylinkd."""
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")
def startLocalProxy(global_end_event: threading.Event, remote_ws_uri: str, local_port: int) -> dict[str, int]:
sunnylink_dongle_id = params.get("SunnylinkDongleId")
sunnylink_api = SunnylinkApi(sunnylink_dongle_id)
cloudlog.debug("athena.startLocalProxy.starting")
ws = create_connection(
remote_ws_uri, header={"Authorization": f"Bearer {sunnylink_api.get_token()}"}, enable_multithread=True, sslopt={"cert_reqs": ssl.CERT_NONE}
)
return start_local_proxy_shim(global_end_event, local_port, ws)
def main(exit_event: threading.Event | None = None):
@@ -292,12 +272,6 @@ def main(exit_event: threading.Event | None = None):
except Exception:
cloudlog.exception("failed to set core affinity")
daemon = Sunnylinkd()
for method in daemon.methods().values():
dispatcher.add_method(method)
params = daemon.params
while sunnylink_need_register(params):
cloudlog.info("Waiting for sunnylink registration to complete")
time.sleep(10)
@@ -331,13 +305,22 @@ def main(exit_event: threading.Event | None = None):
conn_retries = 0
cur_upload_items.clear()
daemon.handle_long_poll(ws, exit_event)
handle_long_poll(ws, exit_event)
except (KeyboardInterrupt, SystemExit):
break
except Exception as e:
conn_retries += 1
params.remove("LastSunnylinkPingTime")
log_connection_error(e)
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))
@@ -1,73 +0,0 @@
from typing import cast
from openpilot.common.test import OpenpilotTestCase
from openpilot.sunnypilot.sunnylink.athena.local_app_rows import (
DEFAULT_APP_NAME,
MAX_LOCAL_APP_ROWS,
NEVER_CONNECTED,
build_local_app_rows,
format_age,
short_key,
)
from openpilot.sunnypilot.sunnylink.athena.local_auth import MAX_GRANTS
def grant(app_id: str, app_name: str = "Phone") -> dict[str, str]:
return {"app_id": app_id, "app_name": app_name, "public_key": "x", "key_id": app_id, "revision": "r"}
class TestLocalAppRows(OpenpilotTestCase):
"""The device screen's rows: one per enrolled phone."""
def test_an_enrolled_phone_is_marked_secure(self):
rows = build_local_app_rows([grant("key-a", "Nayan's phone")])
self.assertEqual("Nayan's phone", rows[0].title)
self.assertTrue(short_key("key-a") in rows[0].subtitle)
self.assertTrue("secure" in rows[0].subtitle)
def test_a_nameless_grant_still_gets_a_title(self):
rows = build_local_app_rows([{"app_id": "key-a", "app_name": ""}, {"app_id": "key-b"}])
self.assertEqual([DEFAULT_APP_NAME, DEFAULT_APP_NAME], [row.title for row in rows])
def test_malformed_grants_are_skipped_rather_than_rendered(self):
# The registry is JSON, so a hand-edited or older document can hold anything.
malformed = cast(list[dict[str, str]], ["junk", {"app_name": "no id"},
{"app_id": "", "app_name": "blank"}, grant("key-a")])
rows = build_local_app_rows(malformed)
self.assertEqual(["key-a"], [row.app_id for row in rows])
def test_every_authorized_phone_has_a_row(self):
"""A phone the authority accepts must be visible: an invisible grant cannot be revoked."""
rows = build_local_app_rows([grant(f"key-{i}") for i in range(MAX_GRANTS)])
self.assertEqual(MAX_GRANTS, len(rows))
self.assertEqual(MAX_GRANTS, MAX_LOCAL_APP_ROWS)
self.assertEqual(MAX_LOCAL_APP_ROWS, len(build_local_app_rows([grant(f"key-{i}") for i in range(10)])))
def test_short_key_keeps_short_ids_readable(self):
self.assertEqual("abc", short_key("abc"))
self.assertEqual("abcdefghij…", short_key("abcdefghijklmnop"))
def test_a_phone_supplied_alias_wins_over_the_enrolled_name(self):
rows = build_local_app_rows([grant("key-a", "Nayan's phone")], {"key-a": {"alias": "Garage phone"}})
self.assertEqual("Garage phone", rows[0].title)
# A blank or missing alias falls back to what the phone enrolled with.
for meta in ({}, {"key-a": {}}, {"key-a": {"alias": " "}}, {"key-a": {"alias": 7}}):
self.assertEqual("Nayan's phone", build_local_app_rows([grant("key-a", "Nayan's phone")], meta)[0].title)
def test_each_phone_reports_when_it_was_last_connected(self):
rows = build_local_app_rows([grant("key-a"), grant("key-b")],
{"key-a": {"last_seen": 1_000.0}}, now=1_120.0)
self.assertTrue("2m ago" in rows[0].subtitle)
self.assertTrue(NEVER_CONNECTED in rows[1].subtitle)
def test_a_stamp_from_before_the_last_reboot_reads_as_unknown(self):
# Monotonic time restarts at boot, so a future-looking stamp is not "-5m ago".
rows = build_local_app_rows([grant("key-a")], {"key-a": {"last_seen": 5_000.0}}, now=10.0)
self.assertTrue(NEVER_CONNECTED in rows[0].subtitle)
def test_format_age_is_coarse_and_readable(self):
self.assertEqual("just now", format_age(0))
self.assertEqual("just now", format_age(59.9))
self.assertEqual("5m ago", format_age(300))
self.assertEqual("3h ago", format_age(3 * 3600))
self.assertEqual("2d ago", format_age(2 * 86_400))
@@ -1,388 +0,0 @@
import base64
import hashlib
import json
import socket
import ssl
import tempfile
import threading
from pathlib import Path
from dataclasses import replace
from datetime import UTC, datetime, timedelta
from unittest import mock
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa
from cryptography.x509.oid import NameOID
from openpilot.common.test import OpenpilotTestCase
from openpilot.sunnypilot.sunnylink.athena.local_auth import (
PAIRING_DELIVERY_GRACE_S, PAIRING_TTL_S, QR_SESSION_BYTES, AuthorizationError, Grant, LocalAuthority, Origin,
connect_pinned, decode64, decode_qr, encode64, key_id, parse_object, qr_frame, statement, verify_tls_peer,
)
# The fields an enrollment signs, in the order the statement joins them.
ENROLL_FIELDS = ("cloud_device_id", "comma_device_id", "device_key_id", "pairing_session",
"public_key", "key_id", "app_name")
# A refused pin makes the connector hang up mid-handshake, so the server side of a real socket
# sees the abort as one of these depending on whether it was reading or writing when the peer
# went away. That race is not the property under test — the assertions that count are that the
# client refused before any HTTP request and that the server received nothing (see below).
ABORTED_CONNECTION_ERRORS = (BrokenPipeError, ConnectionResetError, ConnectionAbortedError,
ssl.SSLEOFError, ssl.SSLError)
class TestLocalAuth(OpenpilotTestCase):
def setup_method(self):
self.now = 10.0
self.device_key = ec.generate_private_key(ec.SECP256R1())
self.app_key = ec.generate_private_key(ec.SECP256R1())
self.writes = []
self.authority = LocalAuthority("cloud-device", "comma-device", self.device_key, self.writes.append, clock=lambda: self.now)
self.qr = self.authority.arm()
def enrollment(self, app_key=None, **changes):
app_key = app_key or self.app_key
public = encode64(app_key.public_key().public_bytes(serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo))
data = {"v": 2, "cloud_device_id": self.authority.cloud_device_id, "comma_device_id": self.authority.comma_device_id,
"device_key_id": self.authority.device_key_id,
"pairing_session": encode64(decode_qr(self.qr).session),
"public_key": public, "key_id": key_id(app_key.public_key()), "app_name": "Test phone"}
data.update(changes)
proof = statement("enroll", *(str(data[field]) for field in ENROLL_FIELDS))
data["signature"] = encode64(app_key.sign(proof, ec.ECDSA(hashes.SHA256())))
return json.dumps(data)
def committed(self):
grant = self.authority.enroll(self.enrollment(), Origin.CLOUD)
return self.authority.confirm(grant, grant.key_id)
def test_qr_is_public_fresh_metadata_not_a_capability(self):
"""The code names one device and one window; it carries no key, no signature and no secret."""
self.assertEqual(len(self.qr.split(".")), 1)
frame = decode64(self.qr)
payload = decode_qr(self.qr)
self.assertEqual(payload.cloud_device_id, self.authority.cloud_device_id)
self.assertEqual(payload.session, decode64(self.authority.window.nonce))
self.assertEqual(len(payload.session), QR_SESSION_BYTES)
self.assertEqual(payload.ttl_s, 120)
# Framing is exact: no padding, no extra field, no room for a key id or a signature.
self.assertEqual(frame, qr_frame(self.authority.cloud_device_id, payload.session, 120))
self.assertEqual(len(frame), len("SLEN") + 2 + len(self.authority.cloud_device_id) + QR_SESSION_BYTES + 1)
# Freshness is per window: re-arming publishes a different session.
self.assertNotEqual(decode_qr(self.authority.arm()).session, payload.session)
def test_the_code_is_readable_off_a_240px_panel_whatever_the_identity_key_is(self):
"""An RSA identity key is what a real device has, and a device signature made the code 77
modules — 2.73 px/module on the mici panel, which is not scannable. Both key types mint the
same code now, and the identity key is still what proves the device to the phone."""
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
authority = LocalAuthority("cloud-device", "comma-device", key, self.writes.append)
qr = authority.arm()
self.assertEqual(len(qr), len(self.qr))
payload = decode_qr(qr)
self.assertEqual(payload.cloud_device_id, "cloud-device")
self.assertEqual(payload.ttl_s, PAIRING_TTL_S)
challenge = encode64(bytes(32))
proof = authority.authenticate_device("phone-key", challenge)
self.assertEqual(proof["device_key_id"], authority.device_key_id)
expected = statement("authenticate-device", "cloud-device", "comma-device",
authority.device_key_id, "phone-key", challenge)
key.public_key().verify(decode64(proof["signature"]), expected, padding.PKCS1v15(), hashes.SHA256())
def test_device_authenticates_itself_to_the_phone_challenge(self):
grant = self.committed()
challenge = encode64(bytes(32))
proof = self.authority.authenticate_device(grant.key_id, challenge)
self.assertEqual(proof["device_key_id"], self.authority.device_key_id)
self.assertEqual(proof["phone_key_id"], grant.key_id)
expected = statement("authenticate-device", "cloud-device", "comma-device",
self.authority.device_key_id, grant.key_id, challenge)
self.device_key.public_key().verify(decode64(proof["signature"]), expected, ec.ECDSA(hashes.SHA256()))
with self.assertRaises(AuthorizationError):
self.authority.authenticate_device(grant.key_id, encode64(bytes(31)))
def test_local_origin_cannot_enroll_even_with_correct_qr_and_key(self):
with self.assertRaises(AuthorizationError):
self.authority.enroll(self.enrollment(), Origin.LOCAL)
self.assertEqual(self.writes, [])
self.assertIsNone(self.authority.pending)
def test_pending_grant_is_not_authorized_and_requires_tls_proof(self):
grant = self.authority.enroll(self.enrollment(), Origin.CLOUD)
self.assertEqual(self.writes, [])
with self.assertRaises(AuthorizationError):
self.authority.session(grant, grant.key_id)
with self.assertRaises(AuthorizationError):
self.authority.confirm(grant, key_id(self.device_key.public_key()))
self.assertEqual(self.writes, [])
self.authority.confirm(grant, grant.key_id)
self.assertEqual(len(self.writes), 1)
def test_same_key_retries_are_idempotent_but_competing_scanner_cannot_replace_key(self):
grant = self.authority.enroll(self.enrollment(), Origin.CLOUD)
self.assertEqual(grant, self.authority.enroll(self.enrollment(), Origin.CLOUD))
with self.assertRaises(AuthorizationError):
self.authority.enroll(self.enrollment(ec.generate_private_key(ec.SECP256R1())), Origin.CLOUD)
self.assertEqual(self.authority.pending, grant)
def test_a_late_enrollment_lands_inside_its_delivery_grace(self):
"""The cloud is store-and-forward: the enrollment for the code the user scanned reaches the
device after that code has left its screen, and it still has to enroll and still has to be
committable over the pinned dial. Every step below used to fail silently."""
grant = self.authority.enroll(self.enrollment(), Origin.CLOUD)
self.authority.expire() # the code left the screen …
self.now = PAIRING_TTL_S + 1 # … and its scannable lifetime ended
self.assertEqual(self.authority.confirm(grant, grant.key_id), grant)
self.assertEqual(len(self.writes), 1)
def test_an_enrollment_past_the_delivery_grace_is_refused(self):
self.authority.expire()
window = self.authority.recent[-1]
self.assertEqual(window.usable_until, window.deadline + PAIRING_DELIVERY_GRACE_S)
self.now = window.usable_until + 1
with self.assertRaises(AuthorizationError):
self.authority.enroll(self.enrollment(), Origin.CLOUD)
self.assertEqual(self.writes, [])
def test_cancelling_a_window_refuses_an_in_flight_enrollment(self):
grant = self.authority.enroll(self.enrollment(), Origin.CLOUD)
self.authority.expire()
self.authority.cancel()
with self.assertRaises(AuthorizationError):
self.authority.enroll(self.enrollment(), Origin.CLOUD)
with self.assertRaises(AuthorizationError):
self.authority.confirm(grant, grant.key_id)
self.assertEqual(self.writes, [])
def test_cancel_and_restart_invalidate_an_old_enrollment(self):
raw = self.enrollment()
grant = self.authority.enroll(raw, Origin.CLOUD)
self.authority.cancel()
with self.assertRaises(AuthorizationError):
self.authority.confirm(grant, grant.key_id)
with self.assertRaises(AuthorizationError):
self.authority.enroll(raw, Origin.CLOUD)
restarted = LocalAuthority("cloud-device", "comma-device", self.device_key, self.writes.append)
with self.assertRaises(AuthorizationError):
restarted.enroll(raw, Origin.CLOUD)
self.assertEqual(self.writes, [])
def test_a_rearm_keeps_the_previous_code_in_flight(self):
"""A user who retries gets a fresh code. The enrollment for the code they scanned first can
still be on its way through the cloud, so a re-arm must not invalidate it."""
raw = self.enrollment()
self.assertIsNotNone(self.authority.enroll(raw, Origin.CLOUD))
self.authority.expire()
self.authority.arm()
self.assertEqual(self.authority.enroll(raw, Origin.CLOUD), self.authority.pending)
def test_the_enrollment_nonce_is_the_code_session_and_nothing_else(self):
"""The window mints a 16-byte session, so that is the only nonce an enrollment may name. Both
payloads below carry a valid signature over the altered field, so the refusal is this rule."""
for wrong in (encode64(b"x" * 32), encode64(b"x" * 8)):
with self.subTest(nonce=wrong), self.assertRaises(AuthorizationError):
self.authority.enroll(self.enrollment(pairing_session=wrong), Origin.CLOUD)
self.assertEqual(self.writes, [])
def test_signature_binds_all_enrollment_fields(self):
for field, value in (("app_name", "Other phone"), ("key_id", "other"), ("device_key_id", "other"), ("comma_device_id", "other"),
("cloud_device_id", "other"), ("pairing_session", encode64(b"x" * QR_SESSION_BYTES))):
with self.subTest(field=field):
data = json.loads(self.enrollment())
data[field] = value
with self.assertRaises(AuthorizationError):
self.authority.enroll(json.dumps(data), Origin.CLOUD)
self.assertEqual(self.writes, [])
def test_bad_versions_fields_keys_and_messages_rejected(self):
for change in ({"v": True}, {"v": 1}, {"extra": "bad"}, {"public_key": "garbage"}, {"signature": "garbage"},
{"app_name": "bad\nname"}, {"app_name": "x" * 81}):
with self.subTest(change=change):
data = json.loads(self.enrollment())
data.update(change)
with self.assertRaises((AuthorizationError, ValueError)):
self.authority.enroll(json.dumps(data), Origin.CLOUD)
for raw in ('{"v":2,"v":2}', '[]', 'null', 'x' * 4097):
with self.assertRaises(AuthorizationError):
parse_object(raw)
def test_persistence_failure_never_publishes_grant(self):
grant = self.authority.enroll(self.enrollment(), Origin.CLOUD)
with mock.patch.object(self.authority, "persist", side_effect=OSError("disk full")):
with self.assertRaises(OSError):
self.authority.confirm(grant, grant.key_id)
self.assertEqual(self.authority.grants, {})
with self.assertRaises(AuthorizationError):
self.authority.session(grant, grant.key_id)
def test_grants_reload_but_corrupt_and_duplicate_registry_fail_closed(self):
grant = self.committed()
restarted = LocalAuthority("cloud-device", "comma-device", self.device_key, self.writes.append, self.writes[-1])
self.assertEqual(restarted.grants, {grant.key_id: grant})
corrupt = grant.to_dict() | {"key_id": "wrong"}
for storage in ([{"app_id": "unknown", "endpoint": "ws://127.0.0.1"}], {"v": 1, "grants": [grant.to_dict()]},
{"v": 2, "grants": [corrupt]}, {"v": 2, "grants": [grant.to_dict(), grant.to_dict()]}):
self.assertEqual(LocalAuthority("cloud-device", "comma-device", self.device_key, self.writes.append, storage).grants, {})
def test_revocation_and_closed_session_prevent_handler_execution(self):
grant = self.committed()
session = self.authority.session(grant, grant.key_id)
handler = mock.Mock(return_value="ok")
self.assertEqual(session.invoke("getParams", handler), "ok")
self.authority.revoke(grant.key_id)
with self.assertRaises(AuthorizationError):
session.invoke("saveParams", handler)
self.assertEqual(handler.call_count, 1)
with self.assertRaises(AuthorizationError):
self.authority.session(grant, grant.key_id)
with self.assertRaises(AuthorizationError):
self.authority.enroll(self.enrollment(), Origin.CLOUD)
def test_wrong_peer_revision_and_denied_methods_cannot_dispatch(self):
grant = self.committed()
handler = mock.Mock()
with self.assertRaises(AuthorizationError):
self.authority.session(grant, "wrong")
with self.assertRaises(AuthorizationError):
self.authority.session(replace(grant, revision=encode64(b"x" * 32)), grant.key_id)
session = self.authority.session(grant, grant.key_id)
for method in ("startLocalProxy", "pairLocalApp", "uploadFileToUrl", "startStream", "setNavDestination", "unknown"):
with self.assertRaises(AuthorizationError):
session.invoke(method, handler)
session.close()
with self.assertRaises(AuthorizationError):
session.invoke("getMessage", handler)
handler.assert_not_called()
def test_same_installation_key_can_enroll_multiple_devices(self):
original = self.committed()
self.authority = LocalAuthority("second-cloud-device", "second-comma-device", self.device_key, self.writes.append, clock=lambda: self.now)
self.qr = self.authority.arm()
second = self.committed()
self.assertEqual(original.key_id, second.key_id)
self.assertNotEqual(original.revision, second.revision)
def test_canonical_encoding_and_field_limits(self):
for value in ("", "Zg==", "Zh", "a", "💥", 123, "Z g", "Zg\n"):
with self.assertRaises(AuthorizationError):
decode64(value)
self.assertEqual(decode64("Zg"), b"f")
with self.assertRaises(AuthorizationError):
Grant.from_dict({})
def test_pinned_connector_rejects_downgrade_and_invalid_endpoints_before_dial(self):
with mock.patch("openpilot.sunnypilot.sunnylink.athena.local_auth.socket.create_connection") as dial:
for endpoint in ("ws://192.168.1.2:8443", "wss://host.example:8443", "wss://user:pass@192.168.1.2:8443",
"wss://0.0.0.0:8443", "wss://224.0.0.1:8443", "wss://192.168.1.2:8443/path",
"wss://192.168.1.2:8443?token=secret", "wss://192.168.1.2:99999"):
with self.subTest(endpoint=endpoint), self.assertRaises(AuthorizationError):
connect_pinned(endpoint, key_id(self.app_key.public_key()))
dial.assert_not_called()
def test_wrong_tls_pin_closes_socket_before_websocket_upgrade(self):
base = "openpilot.sunnypilot.sunnylink.athena.local_auth."
with mock.patch(base + "socket.create_connection"), mock.patch(base + "ssl.SSLContext") as context, \
mock.patch(base + "verify_tls_peer", side_effect=AuthorizationError("wrong key")), mock.patch(base + "create_connection") as upgrade:
transport = context.return_value.wrap_socket.return_value
transport.getpeercert.return_value = b"certificate"
with self.assertRaises(AuthorizationError):
connect_pinned("wss://192.168.1.2:8443", key_id(self.app_key.public_key()))
transport.close.assert_called_once()
upgrade.assert_not_called()
def test_real_tls_connector_pins_before_http_and_requires_tls13(self):
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "test phone")])
now = datetime.now(UTC)
certificate = (x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(self.app_key.public_key())
.serial_number(x509.random_serial_number()).not_valid_before(now - timedelta(minutes=1))
.not_valid_after(now + timedelta(days=1)).sign(self.app_key, hashes.SHA256()))
with tempfile.TemporaryDirectory() as temporary:
cert_path = Path(temporary) / "certificate.pem"
key_path = Path(temporary) / "private.pem"
cert_path.write_bytes(certificate.public_bytes(serialization.Encoding.PEM))
key_path.write_bytes(self.app_key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
serialization.NoEncryption()))
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.minimum_version = ssl.TLSVersion.TLSv1_3
context.load_cert_chain(str(cert_path), str(key_path))
for valid in (True, False):
with self.subTest(valid=valid), socket.socket() as listener:
listener.bind(("127.0.0.1", 0))
listener.listen(1)
listener.settimeout(5)
received = []
errors = []
# Every loop-scoped name is bound as a default argument: this runs on another thread
# while the enclosing loop moves on to the next case.
def serve(listener=listener, received=received, errors=errors, valid=valid):
try:
incoming, _ = listener.accept()
with incoming, context.wrap_socket(incoming, server_side=True) as tls:
tls.settimeout(5)
self.assertEqual(tls.version(), "TLSv1.3")
request = b""
while b"\r\n\r\n" not in request:
piece = tls.recv(4096)
if not piece:
break
request += piece
received.append(request)
if not request:
return
headers = dict(line.split(": ", 1) for line in request.decode().split("\r\n")[1:] if ": " in line)
accept = base64.b64encode(hashlib.sha1((headers["Sec-WebSocket-Key"] +
"258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest()).decode()
tls.sendall(("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" +
f"Sec-WebSocket-Accept: {accept}\r\nSec-WebSocket-Protocol: sunnylink-local\r\n\r\n").encode())
tls.recv(4096) # Client close frame.
except ABORTED_CONNECTION_ERRORS as e:
# Expected only when the client is the one that refuses: an error here in the
# valid case is a real failure.
if valid:
errors.append(e)
except Exception as e:
errors.append(e)
thread = threading.Thread(target=serve)
thread.start()
try:
endpoint = f"wss://127.0.0.1:{listener.getsockname()[1]}"
if valid:
client = connect_pinned(endpoint, key_id(self.app_key.public_key()), timeout_s=5)
self.assertEqual(client.sock.version(), "TLSv1.3")
client.close(timeout=1)
else:
with self.assertRaises(AuthorizationError):
connect_pinned(endpoint, key_id(self.device_key.public_key()), timeout_s=5)
finally:
thread.join(timeout=6)
self.assertFalse(thread.is_alive())
# An abort other than the client's own refusal is a failure in both cases.
self.assertEqual(errors, [])
if valid:
# The accepted path is strict: the handshake completes, exactly one request arrives
# and it carries no credential header.
self.assertEqual(len(received), 1)
self.assertTrue(b"GET / HTTP/1.1" in received[0])
self.assertNotIn(b"Authorization", received[0])
else:
# Refusing the pin races the server's own handshake, so the server either lost the
# connection inside `wrap_socket` (nothing recorded) or saw a socket the client
# closed without a request (one empty record). The property that matters is the same
# in both: not one byte of HTTP reached the server.
self.assertLessEqual(len(received), 1)
self.assertEqual([piece for piece in received if piece], [])
def test_tls_pin_checks_key_not_name(self):
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "sunnylink mobile")])
now = datetime.now(UTC)
cert = (x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(self.app_key.public_key())
.serial_number(x509.random_serial_number()).not_valid_before(now - timedelta(minutes=1)).not_valid_after(now + timedelta(days=1))
.sign(self.app_key, hashes.SHA256()).public_bytes(serialization.Encoding.DER))
self.assertEqual(verify_tls_peer(cert, key_id(self.app_key.public_key())), key_id(self.app_key.public_key()))
for bad, expected in ((cert, key_id(self.device_key.public_key())), (b"garbage", key_id(self.app_key.public_key()))):
with self.assertRaises(AuthorizationError):
verify_tls_peer(bad, expected)
@@ -1,526 +0,0 @@
import json
import shutil
import tempfile
from pathlib import Path
from typing import Any, cast
from unittest import mock
from openpilot.common.params import Params
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from openpilot.common.test import OpenpilotTestCase
from openpilot.sunnypilot.sunnylink.athena import local_auth_daemon
from openpilot.sunnypilot.sunnylink.athena.local_auth import (
AuthorizationError,
Grant,
LocalAuthority,
Origin,
encode64,
key_id,
statement,
)
# `<repo>/openpilot/common/params_keys.h` from this file.
PARAMS_KEYS_H = Path(__file__).resolve().parents[4] / "common" / "params_keys.h"
# The fields an enrollment signs, in the order the statement joins them.
ENROLL_FIELDS = ("cloud_device_id", "comma_device_id", "device_key_id",
"pairing_session", "public_key", "key_id", "app_name")
class _ParamsStore:
"""Dict-backed stand-in for the Params surface this module uses.
It deliberately models a JSON-typed param the way libparams does: a dict put in comes back
out as the same dict. That contract is exactly what `_persist_registry` and `_read_registry`
have to agree on, so a mismatched pair (a second base64 decode, say) fails here instead of
only on a device.
"""
def __init__(self, initial: dict[str, Any] | None = None):
self.values: dict[str, Any] = dict(initial or {})
self.removed: list[str] = []
self.fail_reads = False
def get(self, key: str, block: bool = False, return_default: bool = False) -> Any:
if self.fail_reads:
raise RuntimeError("params unavailable")
return self.values.get(key)
def get_bool(self, key: str, block: bool = False) -> bool:
if self.fail_reads:
raise RuntimeError("params unavailable")
return bool(self.values.get(key))
def put(self, key: str, value: Any, block: bool = False) -> None:
self.values[key] = value
def put_bool(self, key: str, value: bool, block: bool = False) -> None:
self.values[key] = bool(value)
def remove(self, key: str) -> None:
self.values.pop(key, None)
self.removed.append(key)
class TestLocalAuthDaemon(OpenpilotTestCase):
def setup_method(self):
self.device_key = ec.generate_private_key(ec.SECP256R1())
self.app_key = ec.generate_private_key(ec.SECP256R1())
self.private_pem = self.device_key.private_bytes(
serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())
self.store = _ParamsStore({"SunnylinkDongleId": "cloud-device", "DongleId": "comma-device"})
local_auth_daemon.reset_authority()
self.addCleanup(local_auth_daemon.reset_authority)
patchers = [
mock.patch.object(local_auth_daemon, "_params", self.store),
mock.patch.object(local_auth_daemon.BaseApi, "get_key_pair", return_value=("ES256", self.private_pem, "pub")),
]
for patcher in patchers:
patcher.start()
self.addCleanup(patcher.stop)
def enrollment(self, authority) -> str:
authority.arm()
public = encode64(self.app_key.public_key().public_bytes(
serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo))
nonce = authority.window.nonce
data = {"v": 2, "cloud_device_id": authority.cloud_device_id, "comma_device_id": authority.comma_device_id,
"device_key_id": authority.device_key_id, "pairing_session": nonce,
"public_key": public, "key_id": key_id(self.app_key.public_key()), "app_name": "Test phone"}
proof = statement("enroll", *(str(data[field]) for field in ENROLL_FIELDS))
data["signature"] = encode64(self.app_key.sign(proof, ec.ECDSA(hashes.SHA256())))
return json.dumps(data)
def enrolled(self) -> dict[str, str]:
"""Enroll one phone end to end: cloud enrollment staged, then TLS-confirmed."""
summary = local_auth_daemon.cloud_enroll(self.enrollment(local_auth_daemon.get_authority()))
return local_auth_daemon.confirm_enrollment_tls(summary["app_id"])
def revocation(self, authority, app_key=None) -> str:
"""What the app pushes when it unpairs: a self-signed request to drop its own grant."""
key = app_key or self.app_key
app_id = key_id(key.public_key())
proof = statement("revoke", authority.cloud_device_id, authority.comma_device_id, app_id)
return json.dumps({"v": 2, "cloud_device_id": authority.cloud_device_id,
"comma_device_id": authority.comma_device_id, "key_id": app_id,
"signature": encode64(key.sign(proof, ec.ECDSA(hashes.SHA256())))})
def registry(self) -> dict[str, Any]:
return self.store.values[local_auth_daemon.LOCAL_APPS_KEY]
def test_a_late_cloud_enrollment_from_an_expired_code_still_enrolls(self):
"""The path an app's push actually takes: the gateway stored the settings-write, the local
half claims it on a later tick, and by then the scanned code has left the device screen. This
is what used to be dropped with only a log line, leaving the app waiting forever."""
authority = local_auth_daemon.get_authority()
raw = self.enrollment(authority) # the app scanned the armed code
authority.expire() # the code stops being displayed …
self.store.put(local_auth_daemon.CLOUD_ENROLL_KEY, raw)
local_auth_daemon.claim_cloud_commands(cast(Params, self.store))
pending = local_auth_daemon.pending_enrollment()
assert pending is not None, "the late enrollment was not staged"
self.assertEqual(key_id(self.app_key.public_key()), pending["key_id"])
self.assertEqual("Test phone", pending["app_name"])
# Claimed exactly once, whether or not it validated.
self.assertIsNone(self.store.get(local_auth_daemon.CLOUD_ENROLL_KEY))
def test_a_cloud_enrollment_for_a_code_past_its_grace_is_refused(self):
authority = local_auth_daemon.get_authority()
raw = self.enrollment(authority)
window = authority.recent[-1]
authority.expire()
self.store.put(local_auth_daemon.CLOUD_ENROLL_KEY, raw)
with mock.patch.object(authority, "clock", lambda: window.usable_until + 1):
local_auth_daemon.claim_cloud_commands(cast(Params, self.store))
self.assertIsNone(local_auth_daemon.pending_enrollment())
self.assertIsNone(self.store.get(local_auth_daemon.CLOUD_ENROLL_KEY))
def test_cloud_enroll_stages_and_tls_confirm_persists(self):
summary = local_auth_daemon.cloud_enroll(self.enrollment(local_auth_daemon.get_authority()))
# Staged: nothing durable until the TLS peer proves possession.
self.assertNotIn(local_auth_daemon.LOCAL_APPS_KEY, self.store.values)
committed = local_auth_daemon.confirm_enrollment_tls(summary["app_id"])
self.assertEqual(summary["app_id"], committed["app_id"])
registry = self.registry()
self.assertEqual(2, registry["v"])
self.assertEqual(1, len(registry["grants"]))
self.assertEqual(summary["app_id"], registry["grants"][0]["app_id"])
def test_grants_reload_from_the_persisted_registry(self):
"""The write and read halves must agree: a grant has to survive a daemon restart."""
summary = self.enrolled()
local_auth_daemon.reset_authority()
grants = local_auth_daemon.grants()
self.assertEqual(1, len(grants))
self.assertEqual(summary["app_id"], grants[0]["app_id"])
self.assertEqual("Test phone", grants[0]["app_name"])
def test_registry_round_trip_is_the_json_param_itself(self):
self.enrolled()
registry = self.registry()
# Stored as the JSON param, not the base64 cloud-transport form: reading it back through the
# same module must return the identical structure.
self.assertIsInstance(registry, dict)
self.assertEqual({"v", "grants"}, set(registry))
self.assertIs(local_auth_daemon._read_registry(), registry)
def test_revocation_persists_removal(self):
summary = self.enrolled()
self.assertTrue(local_auth_daemon.revoke_local_app(summary["app_id"]))
self.assertEqual([], self.registry()["grants"])
self.assertFalse(local_auth_daemon.revoke_local_app("unknown"))
def test_cloud_revoke_drops_the_phones_own_grant(self):
"""Unpairing from an app that is not on the LAN: no live session carries `unpairLocalApp`,
so without this the device would keep authorizing that phone forever."""
summary = self.enrolled()
result = local_auth_daemon.cloud_revoke(self.revocation(local_auth_daemon.get_authority()))
self.assertEqual(summary["app_id"], result["app_id"])
self.assertEqual([], self.registry()["grants"])
# Persisted: a restart must not resurrect the grant.
local_auth_daemon.reset_authority()
self.assertEqual([], local_auth_daemon.grants())
def test_a_forged_proof_for_an_enrolled_key_is_refused(self):
self.enrolled()
authority = local_auth_daemon.get_authority()
forged = json.loads(self.revocation(authority))
forged["signature"] = encode64(ec.generate_private_key(ec.SECP256R1()).sign(
statement("revoke", authority.cloud_device_id, authority.comma_device_id, forged["key_id"]),
ec.ECDSA(hashes.SHA256())))
with self.assertRaises(AuthorizationError):
local_auth_daemon.cloud_revoke(json.dumps(forged))
self.assertEqual(1, len(self.registry()["grants"]))
def test_a_proof_from_another_key_cannot_remove_a_grant(self):
self.enrolled()
other = ec.generate_private_key(ec.SECP256R1())
# A key that is not enrolled has no grant to drop, so this is a no-op — never a way to
# remove someone else's phone.
result = local_auth_daemon.cloud_revoke(
self.revocation(local_auth_daemon.get_authority(), app_key=other))
self.assertEqual(key_id(other.public_key()), result["app_id"])
self.assertEqual(1, len(self.registry()["grants"]))
def test_cloud_revoke_requires_the_right_device(self):
self.enrolled()
payload = json.loads(self.revocation(local_auth_daemon.get_authority()))
payload["comma_device_id"] = "some-other-car"
with self.assertRaises(AuthorizationError):
local_auth_daemon.cloud_revoke(json.dumps(payload))
self.assertEqual(1, len(self.registry()["grants"]))
def test_the_revoke_adapter_requires_cloud_origin(self):
"""Same provenance rule as enrollment: an ordinary local RPC must never be able to reach it."""
self.enrolled()
authority = local_auth_daemon.get_authority()
with self.assertRaises(AuthorizationError):
authority.revoke_by_self_proof(self.revocation(authority), Origin.LOCAL)
self.assertEqual(1, len(self.registry()["grants"]))
def test_registry_key_is_declared_in_params_keys(self):
"""Every param this adapter stores must be registered, or Params raises UnknownKeyName.
That failure is not cosmetic: get_authority() is on the daemon's connection path, so an
undeclared key used to take sunnylinkd (and the cloud link with it) down.
"""
declared = PARAMS_KEYS_H.read_text()
for key in (local_auth_daemon.LOCAL_APPS_KEY, local_auth_daemon.LOCAL_APPS_META_KEY,
local_auth_daemon.PAIRING_REQUEST_KEY, local_auth_daemon.PAIRING_QR_KEY,
local_auth_daemon.REVOKE_KEY, local_auth_daemon.REVOKE_ALL_KEY):
self.assertTrue(f'"{key}"' in declared)
def test_read_registry_ignores_a_value_that_is_not_the_registry(self):
self.store.values[local_auth_daemon.LOCAL_APPS_KEY] = "not a registry"
self.assertIsNone(local_auth_daemon._read_registry())
self.assertEqual([], local_auth_daemon.grants())
def test_authority_fault_fails_closed_without_raising(self):
"""A broken trust store must never propagate out of get_authority(): sunnylinkd calls it
from its connection loop, where an exception ends the process."""
self.store.fail_reads = True
self.assertIsNone(local_auth_daemon.get_authority())
self.assertEqual([], local_auth_daemon.grants())
self.assertFalse(local_auth_daemon.revoke_local_app("anything"))
with self.assertRaises(AuthorizationError):
local_auth_daemon.cloud_enroll("{}")
def test_no_identity_key_fails_closed(self):
with mock.patch.object(local_auth_daemon.BaseApi, "get_key_pair", return_value=(None, None, None)):
local_auth_daemon.reset_authority()
self.assertIsNone(local_auth_daemon.get_authority())
self.assertIsNone(local_auth_daemon.arm_local_pairing())
with self.assertRaises(AuthorizationError):
local_auth_daemon.cloud_enroll("{}")
class TestRegistryThroughRealParams(OpenpilotTestCase):
"""The same round trip against the real Params/libparams, in a temporary params directory.
The double above pins the logic; this proves the actual C library stores and returns the
registry the way the module expects (JSON-typed param: a dict in, the same dict out), which is
where the persistence bug lived. It needs `libparams_c` built from a `params_keys.h` that
declares `SunnylinkLocalApps`.
"""
def setup_method(self):
self.dir = tempfile.mkdtemp(prefix="sunnylink_params_")
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
self.device_key = ec.generate_private_key(ec.SECP256R1())
self.app_key = ec.generate_private_key(ec.SECP256R1())
self.private_pem = self.device_key.private_bytes(
serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())
self.params = Params(self.dir)
self.params.put("SunnylinkDongleId", "cloud-device", block=True)
self.params.put("DongleId", "comma-device", block=True)
local_auth_daemon.reset_authority()
self.addCleanup(local_auth_daemon.reset_authority)
patchers = [
mock.patch.object(local_auth_daemon, "_params", self.params),
mock.patch.object(local_auth_daemon.BaseApi, "get_key_pair", return_value=("ES256", self.private_pem, "pub")),
]
for patcher in patchers:
patcher.start()
self.addCleanup(patcher.stop)
def test_grant_survives_a_write_and_reload_through_libparams(self):
authority = local_auth_daemon.get_authority()
self.assertIsNotNone(authority)
authority.arm()
nonce = authority.window.nonce
public = encode64(self.app_key.public_key().public_bytes(
serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo))
app_id = key_id(self.app_key.public_key())
data = {"v": 2, "cloud_device_id": "cloud-device", "comma_device_id": "comma-device",
"device_key_id": authority.device_key_id, "pairing_session": nonce, "public_key": public,
"key_id": app_id, "app_name": "Test phone"}
proof = statement("enroll", *(str(data[field]) for field in ENROLL_FIELDS))
data["signature"] = encode64(self.app_key.sign(proof, ec.ECDSA(hashes.SHA256())))
summary = local_auth_daemon.cloud_enroll(json.dumps(data))
local_auth_daemon.confirm_enrollment_tls(summary["app_id"])
# A daemon restart is what used to lose every grant.
local_auth_daemon.reset_authority()
grants = local_auth_daemon.grants()
self.assertEqual(1, len(grants))
self.assertEqual(summary["app_id"], grants[0]["app_id"])
self.assertEqual("Test phone", grants[0]["app_name"])
self.assertEqual([{"app_id": summary["app_id"], "app_name": "Test phone"}],
local_auth_daemon.read_granted_apps())
# And a revocation persists through the same store.
self.assertTrue(local_auth_daemon.revoke_local_app(summary["app_id"]))
local_auth_daemon.reset_authority()
self.assertEqual([], local_auth_daemon.grants())
self.assertEqual([], local_auth_daemon.read_granted_apps())
class TestGrantMetadata(OpenpilotTestCase):
"""Display metadata for enrolled phones: cosmetic, and never a source of authority."""
def setup_method(self):
self.store = _ParamsStore()
patcher = mock.patch.object(local_auth_daemon, "_params", self.store)
patcher.start()
self.addCleanup(patcher.stop)
def test_alias_and_last_seen_round_trip(self):
local_auth_daemon.set_grant_alias("key-a", "Nayan's phone")
local_auth_daemon.note_grant_seen("key-a")
meta = local_auth_daemon.read_grant_meta()
self.assertEqual("Nayan's phone", meta["key-a"]["alias"])
self.assertIsInstance(meta["key-a"]["last_seen"], float)
def test_a_blank_alias_clears_back_to_the_enrolled_name(self):
local_auth_daemon.set_grant_alias("key-a", "Named")
local_auth_daemon.set_grant_alias("key-a", " ")
self.assertEqual("", local_auth_daemon.read_grant_meta()["key-a"]["alias"])
def test_an_oversized_alias_is_refused(self):
with self.assertRaises(AuthorizationError):
local_auth_daemon.set_grant_alias("key-a", "x" * 81)
self.assertEqual({}, local_auth_daemon.read_grant_meta())
def test_metadata_never_grants_anything(self):
# A metadata entry for a key with no grant is inert: the authority is the only judge.
local_auth_daemon.set_grant_alias("un-enrolled-key", "attacker")
self.assertEqual([], local_auth_daemon.grants())
self.assertEqual({"un-enrolled-key": {"alias": "attacker"}}, local_auth_daemon.read_grant_meta())
def test_unreadable_metadata_never_raises(self):
self.store.values[local_auth_daemon.LOCAL_APPS_META_KEY] = "not a metadata document"
self.assertEqual({}, local_auth_daemon.read_grant_meta())
self.store.fail_reads = True
self.assertEqual({}, local_auth_daemon.read_grant_meta())
# on the session path, so it must swallow the fault rather than break a connection
local_auth_daemon.note_grant_seen("key-a")
def test_revoking_a_phone_drops_its_metadata(self):
local_auth_daemon.set_grant_alias("key-a", "Nayan's phone")
local_auth_daemon.clear_grant_meta("key-a")
self.assertEqual({}, local_auth_daemon.read_grant_meta())
class TestGrantedAppsView(OpenpilotTestCase):
"""The device UI's read-only view of enrolled phones."""
def setup_method(self):
self.device_key = ec.generate_private_key(ec.SECP256R1())
self.private_pem = self.device_key.private_bytes(
serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())
self.store = _ParamsStore({"SunnylinkDongleId": "cloud-device", "DongleId": "comma-device"})
local_auth_daemon.reset_authority()
self.addCleanup(local_auth_daemon.reset_authority)
patcher = mock.patch.object(local_auth_daemon, "_params", self.store)
patcher.start()
self.addCleanup(patcher.stop)
def write_registry(self, grants: list[Any]) -> None:
self.store.values[local_auth_daemon.LOCAL_APPS_KEY] = {"v": 2, "grants": grants}
def test_lists_app_id_and_name_without_building_the_authority(self):
self.write_registry([{"app_id": "key-a", "app_name": "Nayan's phone", "public_key": "x", "key_id": "key-a",
"revision": "r"}])
with mock.patch.object(local_auth_daemon, "_build_authority",
side_effect=AssertionError("the UI must not load the identity key")):
rows = local_auth_daemon.read_granted_apps()
self.assertEqual([{"app_id": "key-a", "app_name": "Nayan's phone"}], rows)
def test_skips_entries_that_cannot_be_displayed(self):
self.write_registry([{"app_id": "key-a"}, "junk", {"app_name": "no id"}, {"app_id": "", "app_name": "blank"},
{"app_id": "key-b", "app_name": "B"}])
self.assertEqual([{"app_id": "key-b", "app_name": "B"}], local_auth_daemon.read_granted_apps())
def test_no_registry_is_an_empty_list(self):
self.assertEqual([], local_auth_daemon.read_granted_apps())
class TestRevokeRequests(OpenpilotTestCase):
"""Revoking from the device UI is a request the daemon applies through its live authority."""
def setup_method(self):
self.store = _ParamsStore()
patcher = mock.patch.object(local_auth_daemon, "_params", self.store)
patcher.start()
self.addCleanup(patcher.stop)
def test_request_and_service_round_trip(self):
revoked: list[str] = []
with mock.patch.object(local_auth_daemon, "revoke_local_app",
side_effect=lambda key: revoked.append(key) or True):
local_auth_daemon.request_revoke("key-a")
self.assertEqual("key-a", self.store.values[local_auth_daemon.REVOKE_KEY])
self.assertTrue(local_auth_daemon.service_revoke_requests())
self.assertEqual(["key-a"], revoked)
# The request is consumed, so it cannot be applied twice on a later tick.
self.assertNotIn(local_auth_daemon.REVOKE_KEY, self.store.values)
self.assertFalse(local_auth_daemon.service_revoke_requests())
def test_a_failed_revoke_still_clears_the_request(self):
with mock.patch.object(local_auth_daemon, "revoke_local_app", side_effect=RuntimeError("boom")):
local_auth_daemon.request_revoke("key-a")
self.assertFalse(local_auth_daemon.service_revoke_requests())
self.assertNotIn(local_auth_daemon.REVOKE_KEY, self.store.values)
def test_unreadable_request_never_raises(self):
self.store.values[local_auth_daemon.REVOKE_KEY] = "key-a"
self.store.fail_reads = True
self.assertFalse(local_auth_daemon.service_revoke_requests())
def test_blank_requests_are_ignored(self):
self.assertFalse(local_auth_daemon.request_revoke(""))
self.assertEqual({}, self.store.values)
def test_revoke_all_request_clears_every_grant_once(self):
cleared: list[int] = []
with mock.patch.object(local_auth_daemon, "revoke_all", side_effect=lambda: cleared.append(1) or 3):
local_auth_daemon.request_revoke_all()
self.assertTrue(self.store.values[local_auth_daemon.REVOKE_ALL_KEY])
self.assertTrue(local_auth_daemon.service_revoke_requests())
# Consumed, so a later tick does not revoke a phone the user pairs afterwards.
self.assertNotIn(local_auth_daemon.REVOKE_ALL_KEY, self.store.values)
self.assertEqual(1, len(cleared))
self.assertFalse(local_auth_daemon.service_revoke_requests())
def test_a_failed_revoke_all_still_clears_the_request(self):
with mock.patch.object(local_auth_daemon, "revoke_all", side_effect=RuntimeError("boom")):
local_auth_daemon.request_revoke_all()
self.assertFalse(local_auth_daemon.service_revoke_requests())
self.assertNotIn(local_auth_daemon.REVOKE_ALL_KEY, self.store.values)
def test_revoke_all_takes_precedence_over_a_single_request(self):
calls: list[str] = []
with mock.patch.object(local_auth_daemon, "revoke_all", side_effect=lambda: calls.append("all") or 2), \
mock.patch.object(local_auth_daemon, "revoke_local_app", side_effect=lambda key: calls.append(key) or True):
local_auth_daemon.request_revoke("key-a")
local_auth_daemon.request_revoke_all()
local_auth_daemon.service_revoke_requests()
# One action, and no leftover request that could revoke a phone paired afterwards.
self.assertEqual(["all"], calls)
self.assertNotIn(local_auth_daemon.REVOKE_KEY, self.store.values)
self.assertNotIn(local_auth_daemon.REVOKE_ALL_KEY, self.store.values)
class TestRevokeAllThroughTheAuthority(OpenpilotTestCase):
"""`revoke_all` on the core: one durable write, and the grants really go away."""
def setup_method(self):
self.store = _ParamsStore()
self.writes: list[dict] = []
def persist(registry: dict) -> None:
self.writes.append(registry)
self.store.values[local_auth_daemon.LOCAL_APPS_KEY] = registry
self.authority = LocalAuthority(
cloud_device_id="cloud-device", comma_device_id="comma-device",
device_private_key=ec.generate_private_key(ec.SECP256R1()),
persist=persist,
registry=None,
)
def test_revoke_all_writes_once_and_empties_the_registry(self):
for key in ("key-a", "key-b"):
self.authority.grants[key] = Grant(key, "spki", key, "Phone", "rev")
self.assertEqual(2, self.authority.revoke_all())
self.assertEqual({}, self.authority.grants)
# One write for the whole action, and the persisted document is an empty registry.
self.assertEqual([{"v": 2, "grants": []}], self.writes)
def test_revoke_all_with_nothing_enrolled_writes_nothing(self):
self.assertEqual(0, self.authority.revoke_all())
self.assertEqual([], self.writes)
class TestPairingWindowParams(OpenpilotTestCase):
def setup_method(self):
self.store = _ParamsStore()
patcher = mock.patch.object(local_auth_daemon, "_params", self.store)
patcher.start()
self.addCleanup(patcher.stop)
def test_clear_pairing_params_does_not_touch_the_authority(self):
"""The UI's cancel is params-only: that process has no window to cancel, and building an
authority there would load the device identity key for nothing."""
self.store.values[local_auth_daemon.PAIRING_REQUEST_KEY] = True
self.store.values[local_auth_daemon.PAIRING_QR_KEY] = "qr"
with mock.patch.object(local_auth_daemon, "get_authority",
side_effect=AssertionError("must not build an authority")):
local_auth_daemon.clear_pairing_params(cast(Params, self.store))
self.assertNotIn(local_auth_daemon.PAIRING_REQUEST_KEY, self.store.values)
self.assertNotIn(local_auth_daemon.PAIRING_QR_KEY, self.store.values)
@@ -1,163 +0,0 @@
from typing import Any, cast
from unittest import mock
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
from openpilot.common.params import Params
from openpilot.common.test import OpenpilotTestCase
from openpilot.sunnypilot.sunnylink.athena import local_auth_daemon
from openpilot.sunnypilot.sunnylink.athena.local_auth import decode_qr, encode64
class _FakeParams:
"""In-memory stand-in for the tiny Params surface the pairing window uses."""
def __init__(self, fail_reads: bool = False):
self.values: dict[str, Any] = {}
self.removed: list[str] = []
self.fail_reads = fail_reads
def get_bool(self, key: str) -> bool:
if self.fail_reads:
raise RuntimeError("params unavailable")
return bool(self.values.get(key))
def put_bool(self, key: str, value: bool, block: bool = False) -> None:
self.values[key] = bool(value)
def get(self, key: str) -> Any:
if self.fail_reads:
raise RuntimeError("params unavailable")
return self.values.get(key)
def put(self, key: str, value: Any, block: bool = False) -> None:
self.values[key] = value
def remove(self, key: str) -> None:
self.values.pop(key, None)
self.removed.append(key)
class TestLocalAuthPairingWindow(OpenpilotTestCase):
"""The UI (another process) arms through Params; the daemon owns the authority window."""
def setup_method(self):
self.device_key = ec.generate_private_key(ec.SECP256R1())
self.private_pem = self.device_key.private_bytes(
serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())
self.store = _FakeParams()
self.params = cast(Params, self.store)
local_auth_daemon.reset_authority()
self.addCleanup(local_auth_daemon.reset_authority)
patchers = [
mock.patch.object(local_auth_daemon.BaseApi, "get_key_pair", return_value=("ES256", self.private_pem, "pub")),
mock.patch.object(local_auth_daemon, "_read_registry", return_value=None),
]
for patcher in patchers:
patcher.start()
self.addCleanup(patcher.stop)
def request_window(self) -> None:
"""What the device UI does when the user taps the secure-pairing button."""
self.store.put_bool(local_auth_daemon.PAIRING_REQUEST_KEY, True)
def arm(self) -> str:
self.request_window()
qr = local_auth_daemon.service_pairing_window(self.params)
assert qr is not None
return qr
def published(self) -> str | None:
return local_auth_daemon.read_pairing_qr(self.params)
def test_arming_publishes_the_qr_and_clears_the_request(self):
qr = self.arm()
# The QR is the compact pointer the app scans: which device, which window, for how long.
payload = decode_qr(qr)
authority = local_auth_daemon.get_authority()
self.assertEqual(payload.cloud_device_id, authority.cloud_device_id)
self.assertEqual(encode64(payload.session), authority.window.nonce)
self.assertEqual(payload.ttl_s, 120)
self.assertEqual(qr, self.published())
# The request is consumed, so a UI restart cannot arm a second window by accident.
self.assertNotIn(local_auth_daemon.PAIRING_REQUEST_KEY, self.store.values)
self.assertEqual(qr, authority.window.qr)
self.assertIsNone(authority.pending)
def test_an_armed_window_is_not_rearmed_or_rotated(self):
qr = self.arm()
authority = local_auth_daemon.get_authority()
window = authority.window
# Ticks while the window is live must keep the SAME QR: the app may already have scanned it.
for _ in range(3):
self.assertEqual(qr, local_auth_daemon.service_pairing_window(self.params))
self.assertIs(window, authority.window)
self.assertEqual(qr, self.published())
def test_a_live_window_outlives_a_missing_request_flag(self):
qr = self.arm()
# The arm request is already consumed on arming; its absence never closes a live window.
self.assertNotIn(local_auth_daemon.PAIRING_REQUEST_KEY, self.store.values)
self.assertEqual(qr, local_auth_daemon.service_pairing_window(self.params))
self.assertIsNotNone(local_auth_daemon.get_authority().window)
def test_clearing_the_published_qr_is_the_cancel_signal(self):
self.arm()
self.store.remove(local_auth_daemon.PAIRING_QR_KEY)
# The UI cancel only reaches the params; the daemon's tick then cancels the real window.
self.assertIsNone(local_auth_daemon.service_pairing_window(self.params))
self.assertIsNone(self.published())
self.assertIsNone(local_auth_daemon.get_authority().window)
def test_an_expired_window_is_hidden_but_its_session_survives_the_tick(self):
self.arm()
authority = local_auth_daemon.get_authority()
window = authority.window
with mock.patch.object(authority, "clock", lambda: window.deadline + 1):
self.assertIsNone(local_auth_daemon.service_pairing_window(self.params))
self.assertIsNone(self.published())
# The code is off the screen, so it can no longer be scanned — but an enrollment for it may
# still be travelling through the cloud, so the session outlives the displayed window. Only
# the window itself (and any grant it staged) is gone.
self.assertIsNone(authority.window)
self.assertEqual([w.nonce for w in authority.recent], [window.nonce])
self.assertIsNone(authority.pending)
# Past the delivery grace the session is forgotten too.
with mock.patch.object(authority, "clock", lambda: window.usable_until + 1):
self.assertIsNone(local_auth_daemon.service_pairing_window(self.params))
self.assertEqual(authority.recent, [])
def test_a_new_request_after_a_closed_window_arms_a_fresh_qr(self):
first = self.arm()
self.store.remove(local_auth_daemon.PAIRING_QR_KEY)
local_auth_daemon.service_pairing_window(self.params)
second = self.arm()
self.assertNotEqual(first, second)
self.assertEqual(second, self.published())
def test_missing_identity_fails_closed(self):
self.request_window()
with mock.patch.object(local_auth_daemon.BaseApi, "get_key_pair", return_value=(None, None, None)):
local_auth_daemon.reset_authority()
self.assertIsNone(local_auth_daemon.service_pairing_window(self.params))
# Nothing is published and the stale request is dropped, so the UI cannot show a window
# that could never complete.
self.assertIsNone(self.published())
self.assertNotIn(local_auth_daemon.PAIRING_REQUEST_KEY, self.store.values)
def test_clear_pairing_window_clears_both_params_and_the_window(self):
self.arm()
local_auth_daemon.clear_pairing_window(self.params)
self.assertIsNone(self.published())
self.assertNotIn(local_auth_daemon.PAIRING_REQUEST_KEY, self.store.values)
self.assertIsNone(local_auth_daemon.get_authority().window)
# A cleared window is idempotent: another tick publishes nothing.
self.assertIsNone(local_auth_daemon.service_pairing_window(self.params))
def test_unreadable_params_never_raise(self):
broken = _FakeParams(fail_reads=True)
broken.values[local_auth_daemon.PAIRING_REQUEST_KEY] = True
self.assertIsNone(local_auth_daemon.service_pairing_window(cast(Params, broken)))
self.assertIsNone(local_auth_daemon.service_pairing_window(self.params))
@@ -1,551 +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.
"""
import base64
import json
import shutil
import tempfile
import threading
import time
from collections.abc import Iterable
from types import SimpleNamespace
from typing import cast
from unittest import mock
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from websocket import WebSocket
from openpilot.common.params import Params
from openpilot.common.test import OpenpilotTestCase
from openpilot.system.athena import rpc as rpc_module
from openpilot.sunnypilot.sunnylink.athena import local_auth_daemon, localsunnylinkd, sunnylinkd
from openpilot.sunnypilot.sunnylink.athena.local_auth import (
AUTHENTICATE_PURPOSE,
AuthorizationError,
AuthorizedSession,
LocalAuthority,
Origin,
encode64,
key_id,
statement,
)
from openpilot.sunnypilot.sunnylink.athena.local_discovery import LocalDiscovery
from openpilot.sunnypilot.sunnylink.athena.local_params import ENROLL, REVOKE, cloud_may_write, is_local_param
CHALLENGE = "A" * 43 # base64url of 32 zero bytes: the canonical challenge shape
ENROLLMENT_TRANSPORT_VALUE = "eyJ2IjoyfQ==" # base64 of {"v":2}
class _FakeParams(dict):
"""Only the Params calls the daemon makes; a dict is enough to steer them."""
def get_bool(self, key):
return bool(self.get(key))
def put_bool(self, key, value, block=False):
self[key] = value
def put(self, key, value, block=False):
self[key] = value
def remove(self, key):
self.pop(key, None)
def all_keys(self):
return [key.encode() for key in self]
def get_type(self, key):
return SimpleNamespace(value=1)
class _FakeWs:
"""A WebSocket that replays queued frames, then reports the peer gone."""
def __init__(self, messages=()):
self._messages = list(messages)
self.sent: list[str] = []
self.closed = False
self.timeouts: list[float] = []
def settimeout(self, timeout):
self.timeouts.append(timeout)
def recv(self):
if self._messages:
return self._messages.pop(0)
raise sunnylinkd.WebSocketConnectionClosedException("peer gone")
def send(self, payload):
self.sent.append(payload)
def close(self):
self.closed = True
class _FakeSession:
"""Stands in for an AuthorizedSession: records the gated method, never executes it."""
def __init__(self, key_id: str = "grant-key"):
self.calls: list[str] = []
self.grant = SimpleNamespace(key_id=key_id, app_id=key_id)
self.closed = False
def invoke(self, method, handler, *args, **kwargs):
self.calls.append(method)
return {"ok": True}
def close(self):
self.closed = True
class _CallingSession(_FakeSession):
"""Like _FakeSession, but actually runs the gated handler, as AuthorizedSession.invoke does."""
def invoke(self, method, handler, *args, **kwargs):
return handler(*args, **kwargs)
class _FakeDiscovery:
def __init__(self, endpoints: Iterable[str] = (), recent: Iterable[str] | None = None):
self._endpoints: list[str] = list(endpoints)
# What the shorter backoff window returns; None means "same as the dial window".
self._recent: list[str] | None = None if recent is None else list(recent)
self.latest = None
self.seen: float | None = None
def fresh_endpoints(self, max_age_s=None):
if max_age_s is not None and self._recent is not None:
return list(self._recent)
return list(self._endpoints)
def latest_endpoint(self):
return self.latest
def last_seen_ago(self):
return self.seen
def latest_app_id(self):
return "phone-1"
class _Daemon(localsunnylinkd.LocalSunnylinkd):
"""A local daemon with scripted trust state, so target selection is testable in isolation."""
def __init__(self, endpoints=(), enrolled=True, discovery=None, **params):
fake_discovery = discovery if discovery is not None else _FakeDiscovery(endpoints)
super().__init__(params=cast(Params, _FakeParams(**params)),
discovery=cast(LocalDiscovery, fake_discovery))
self._enrolled = enrolled
def enrolled(self):
return self._enrolled
def _challenge(phone_key_id: str) -> str:
return json.dumps({"v": 2, "purpose": AUTHENTICATE_PURPOSE, "phone_key_id": phone_key_id, "challenge": CHALLENGE})
def _authority():
device_key = ec.generate_private_key(ec.SECP256R1())
return LocalAuthority("cloud-device", "comma-device", device_key, persist=lambda _registry: None)
def _enrollment_json(authority: LocalAuthority, app_key: ec.EllipticCurvePrivateKey, name="Test phone") -> str:
public = encode64(app_key.public_key().public_bytes(
serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo))
data = {"v": 2, "cloud_device_id": authority.cloud_device_id, "comma_device_id": authority.comma_device_id,
"device_key_id": authority.device_key_id, "pairing_session": authority.window.nonce, "public_key": public,
"key_id": key_id(app_key.public_key()), "app_name": name}
signed_fields = ("cloud_device_id", "comma_device_id", "device_key_id", "pairing_session",
"public_key", "key_id", "app_name")
proof = statement("enroll", *(str(data[field]) for field in signed_fields))
data["signature"] = encode64(app_key.sign(proof, ec.ECDSA(hashes.SHA256())))
return json.dumps(data)
class TestLocalTargets(OpenpilotTestCase):
"""What the local daemon dials, and when it runs at all."""
def test_pinned_endpoint_converts_a_beacon_to_tls(self):
self.assertEqual("wss://10.0.0.7:8443", localsunnylinkd.pinned_endpoint("ws://10.0.0.7:8443"))
self.assertIsNone(localsunnylinkd.pinned_endpoint("wss://10.0.0.7:8443"))
self.assertIsNone(localsunnylinkd.pinned_endpoint("ws://10.0.0.7"))
def test_an_enrolled_phone_is_dialed_over_pinned_tls(self):
daemon = _Daemon(endpoints=["ws://10.0.0.7:8443"])
self.assertEqual("wss://10.0.0.7:8443", daemon.pick_target())
daemon.backoffs["wss://10.0.0.7:8443"] = time.monotonic() + 60
self.assertIsNone(daemon.pick_target())
def test_a_failed_dial_backs_off_for_a_tick_then_ramps(self):
"""A phone that keeps announcing while it refuses to serve ramps up, and no further."""
daemon = _Daemon(endpoints=["ws://10.0.0.7:8443"])
endpoint = "wss://10.0.0.7:8443"
with mock.patch.object(daemon, "dial", side_effect=AuthorizationError("refused")):
daemon.serve(endpoint, None)
first = daemon.backoffs[endpoint] - time.monotonic()
for _ in range(8):
daemon.serve(endpoint, None)
last = daemon.backoffs[endpoint] - time.monotonic()
# The app is a foreground-only server: one missed dial is worth a beacon interval, not the
# five-minute cloud reconnect ceiling.
self.assertLess(first, localsunnylinkd.ENDPOINT_BACKOFF_S)
self.assertGreater(first, 0)
self.assertLessEqual(last, localsunnylinkd.ENDPOINT_BACKOFF_MAX_S)
self.assertEqual(9, daemon.failures[endpoint])
def test_the_first_failed_dial_waits_only_one_tick(self):
"""One missed dial is worth a tick, not a beacon interval: nothing else re-dials before then."""
daemon = _Daemon(endpoints=["ws://10.0.0.7:8443"])
endpoint = "wss://10.0.0.7:8443"
with mock.patch.object(daemon, "dial", side_effect=AuthorizationError("refused")):
daemon.serve(endpoint, None)
delay = daemon.backoffs[endpoint] - time.monotonic()
self.assertGreater(delay, 0)
self.assertLessEqual(delay, localsunnylinkd.TICK_INTERVAL_S + 0.05)
def test_a_phone_that_went_quiet_past_the_forget_window_clears_its_ramp(self):
"""A network change silences the app for a moment without moving it out of the dial window.
Moving from home Wi-Fi to the car's hotspot (or roaming between mesh nodes) reassociates the
phone: a few beacon intervals pass with no announcement, and the ramp it earned on the old link
must not follow it onto the new one. The endpoint stays dialable for the full beacon window, so
the very next beacon is an immediate attempt rather than the rest of a penalty.
"""
discovery = _FakeDiscovery(["ws://10.0.0.7:8443"], recent=[])
daemon = _Daemon(discovery=discovery)
endpoint = "wss://10.0.0.7:8443"
daemon.backoffs[endpoint] = time.monotonic() + 300
daemon.failures[endpoint] = 5
self.assertEqual(endpoint, daemon.pick_target())
self.assertNotIn(endpoint, daemon.backoffs)
self.assertNotIn(endpoint, daemon.failures)
def test_a_phone_still_announcing_keeps_its_ramp(self):
"""The short window must not reset a ramp for a phone that is right there and refusing to serve."""
discovery = _FakeDiscovery(["ws://10.0.0.7:8443"], recent=["ws://10.0.0.7:8443"])
daemon = _Daemon(discovery=discovery)
endpoint = "wss://10.0.0.7:8443"
daemon.backoffs[endpoint] = time.monotonic() + 300
self.assertIsNone(daemon.pick_target())
self.assertIn(endpoint, daemon.backoffs)
def test_a_served_session_clears_the_backoff_ramp(self):
daemon = _Daemon(endpoints=["ws://10.0.0.7:8443"])
endpoint = "wss://10.0.0.7:8443"
daemon.backoffs[endpoint] = time.monotonic() + 60
daemon.failures[endpoint] = 3
with mock.patch.object(daemon, "dial", lambda endpoint, exit_event: None), \
mock.patch.object(localsunnylinkd, "TICK_INTERVAL_S", 0):
daemon.serve(endpoint, None)
self.assertNotIn(endpoint, daemon.backoffs)
self.assertNotIn(endpoint, daemon.failures)
def test_a_phone_that_stopped_announcing_forgets_its_backoff(self):
"""Closing the app must not cost cloud-only service after it is reopened.
The app is a foreground-only server the user opens and closes: a dial that failed while it
was closed recorded a backoff against an endpoint that then had no beacons at all. Reopening
the app re-announces within seconds, and that record would otherwise hold the device on the
cloud for the rest of the backoff.
"""
discovery = _FakeDiscovery(["ws://10.0.0.7:8443"])
daemon = _Daemon(discovery=discovery)
endpoint = "wss://10.0.0.7:8443"
daemon.backoffs[endpoint] = time.monotonic() + 300
daemon.failures[endpoint] = 1
# The app was closed: it announces nothing, so the failure outlives its reason.
discovery._endpoints = []
self.assertIsNone(daemon.pick_target())
self.assertNotIn(endpoint, daemon.backoffs)
self.assertNotIn(endpoint, daemon.failures)
# The app is opened again: the device dials it now instead of waiting the record out.
discovery._endpoints = ["ws://10.0.0.7:8443"]
self.assertEqual(endpoint, daemon.pick_target())
def test_a_pending_enrollment_is_dialed_even_before_any_grant(self):
daemon = _Daemon(endpoints=["ws://10.0.0.7:8443"])
with mock.patch.object(local_auth_daemon, "pending_enrollment", lambda: {"app_id": "pending"}):
self.assertEqual("wss://10.0.0.7:8443", daemon.pick_target())
def test_the_local_switch_gates_the_process(self):
running = _Daemon(SunnylinkEnabled=True, SunnylinkLocalEnabled=True)
self.assertTrue(running.serviceable())
switched_off = _Daemon(SunnylinkEnabled=True, SunnylinkLocalEnabled=False)
self.assertFalse(switched_off.serviceable())
sunnylink_off = _Daemon(SunnylinkEnabled=False, SunnylinkLocalEnabled=True)
self.assertFalse(sunnylink_off.serviceable())
class TestLocalSurface(OpenpilotTestCase):
"""What a local peer may reach, and what it may never touch."""
def test_a_local_session_can_never_write_local_params(self):
"""Enrollment only ever arrives through the cloud, so a local write is refused outright."""
daemon = localsunnylinkd.LocalSunnylinkd(params=cast(Params, _FakeParams()))
saved: list[str] = []
with mock.patch.object(sunnylinkd, "save_param_from_base64_encoded_string",
lambda key, value, compression=False: saved.append(key)):
daemon.saveParams({ENROLL: ENROLLMENT_TRANSPORT_VALUE, "SunnylinkLocalApps": "{}", "SpeedLimitOffset": "5"})
self.assertEqual(["SpeedLimitOffset"], saved)
def test_session_methods_are_local_only_and_gated(self):
session = _FakeSession()
daemon = localsunnylinkd.LocalSunnylinkd(params=cast(Params, _FakeParams()))
methods = daemon.session_methods(cast(AuthorizedSession, session))
self.assertTrue("getParams" in methods)
self.assertTrue("saveParams" in methods)
self.assertTrue("unpairLocalApp" in methods)
self.assertNotIn("startLocalProxy", methods)
self.assertNotIn("echo", methods)
self.assertEqual(methods["getParams"](params_keys=[]), {"ok": True})
self.assertEqual(session.calls, ["getParams"])
def test_a_session_serves_local_methods_and_refuses_the_rest(self):
daemon = localsunnylinkd.LocalSunnylinkd(params=cast(Params, _FakeParams()))
session = _FakeSession()
methods = daemon.session_methods(cast(AuthorizedSession, session))
ws = _FakeWs([
rpc_module.dumps_call("getParams", {"params_keys": []}, 1),
rpc_module.dumps_call("startLocalProxy", {"port": 8022}, 2),
])
daemon.serve_rpc(cast(WebSocket, ws), methods)
self.assertEqual(session.calls, ["getParams"])
first, second = (json.loads(payload) for payload in ws.sent)
self.assertEqual(1, first["id"])
self.assertEqual({"ok": True}, first["result"])
self.assertEqual(2, second["id"])
self.assertEqual(rpc_module.METHOD_NOT_FOUND, second["error"]["code"])
class TestLocalDial(OpenpilotTestCase):
"""The pinned TLS dial and the challenge the phone must answer."""
def test_the_device_proves_itself_to_the_phone_first(self):
authority = SimpleNamespace(authenticate_device=lambda phone, challenge: {"phone_key_id": phone,
"challenge": challenge})
daemon = localsunnylinkd.LocalSunnylinkd(params=cast(Params, _FakeParams()))
ws = _FakeWs([_challenge("pinned-key")])
daemon.authenticate_phone(cast(WebSocket, ws), cast(LocalAuthority, authority), "pinned-key")
proof = json.loads(ws.sent[0])
self.assertEqual("pinned-key", proof["phone_key_id"])
self.assertEqual(CHALLENGE, proof["challenge"])
def test_a_challenge_for_another_key_is_refused(self):
authority = SimpleNamespace(authenticate_device=lambda phone, challenge: {})
daemon = localsunnylinkd.LocalSunnylinkd(params=cast(Params, _FakeParams()))
with self.assertRaises(AuthorizationError):
daemon.authenticate_phone(cast(WebSocket, _FakeWs([_challenge("someone-else")])),
cast(LocalAuthority, authority), "pinned-key")
def test_a_malformed_challenge_is_refused(self):
authority = SimpleNamespace(authenticate_device=lambda phone, challenge: {})
daemon = localsunnylinkd.LocalSunnylinkd(params=cast(Params, _FakeParams()))
with self.assertRaises(AuthorizationError):
daemon.authenticate_phone(cast(WebSocket, _FakeWs(["not json"])),
cast(LocalAuthority, authority), "pinned-key")
def test_the_dial_confirms_a_pending_enrollment_before_serving(self):
pending = SimpleNamespace(key_id="pending-key", app_id="app-1")
grant = SimpleNamespace(key_id="pending-key", app_id="app-1")
authority = SimpleNamespace(lock=threading.RLock(), pending=pending, grants={},
authenticate_device=lambda phone, challenge: {"phone_key_id": phone,
"challenge": challenge})
session = _FakeSession("pending-key")
confirmed: list[str] = []
ws = _FakeWs([_challenge(pending.key_id)])
def confirm(key_id):
confirmed.append(key_id)
authority.grants[key_id] = grant
authority.pending = None
return {"app_id": grant.app_id, "app_name": "phone", "revision": "r"}
daemon = localsunnylinkd.LocalSunnylinkd(params=cast(Params, _FakeParams()))
with mock.patch.object(authority, "session", lambda g, peer: session, create=True), \
mock.patch.object(local_auth_daemon, "get_authority", lambda: authority), \
mock.patch.object(local_auth_daemon, "confirm_enrollment_tls", confirm), \
mock.patch.object(localsunnylinkd, "connect_pinned", lambda endpoint, key_id, timeout_s=10: ws):
daemon.dial("wss://10.0.0.7:8443", None)
self.assertEqual(["pending-key"], confirmed)
self.assertTrue(session.closed)
self.assertTrue(ws.closed)
self.assertEqual("pending-key", json.loads(ws.sent[0])["phone_key_id"])
def test_a_dial_without_an_enrolled_peer_never_serves(self):
daemon = localsunnylinkd.LocalSunnylinkd(params=cast(Params, _FakeParams()))
authority = SimpleNamespace(lock=threading.RLock(), pending=None, grants={})
with mock.patch.object(local_auth_daemon, "get_authority", lambda: authority), \
mock.patch.object(localsunnylinkd, "connect_pinned",
mock.Mock(side_effect=AuthorizationError("wrong TLS peer"))):
with self.assertRaises(AuthorizationError):
daemon.dial("wss://10.0.0.7:8443", None)
def test_a_rename_only_ever_renames_the_sessions_own_grant(self):
calls: list[tuple[str, str]] = []
session = _CallingSession("grant-key")
with mock.patch.object(local_auth_daemon, "set_grant_alias",
lambda key_id, alias: calls.append((key_id, alias)) or True):
daemon = localsunnylinkd.LocalSunnylinkd(params=cast(Params, _FakeParams()))
methods = daemon.session_methods(cast(AuthorizedSession, session))
result = methods["updateLocalAppAlias"](app_id="another-phone", alias="Nayan's phone")
self.assertEqual([("grant-key", "Nayan's phone")], calls)
self.assertEqual({"success": True, "updated": True}, result)
def test_an_alias_the_protocol_rejects_is_refused(self):
def refuse(key_id, alias):
raise AuthorizationError("invalid alias")
session = _CallingSession("grant-key")
with mock.patch.object(local_auth_daemon, "set_grant_alias", refuse):
daemon = localsunnylinkd.LocalSunnylinkd(params=cast(Params, _FakeParams()))
methods = daemon.session_methods(cast(AuthorizedSession, session))
result = methods["updateLocalAppAlias"](app_id="grant-key", alias="bad\u0000alias")
self.assertEqual({"success": False, "error": "invalid alias"}, result)
def test_unpair_revokes_the_sessions_own_grant(self):
revoked: list[str] = []
session = _CallingSession("grant-key")
with mock.patch.object(local_auth_daemon, "revoke_local_app",
lambda key_id: revoked.append(key_id) or True):
daemon = localsunnylinkd.LocalSunnylinkd(params=cast(Params, _FakeParams()))
methods = daemon.session_methods(cast(AuthorizedSession, session))
result = methods["unpairLocalApp"](app_id="another-phone")
self.assertEqual(["grant-key"], revoked)
self.assertEqual({"success": True, "removed": True}, result)
class TestCloudCommandClaim(OpenpilotTestCase):
"""The handoff: the cloud daemon stores a command, the local daemon claims it."""
def setup_method(self):
self.device_key = ec.generate_private_key(ec.SECP256R1())
self.app_key = ec.generate_private_key(ec.SECP256R1())
self.persisted: list[dict] = []
self.authority = LocalAuthority("cloud-device", "comma-device", self.device_key, persist=self.persisted.append)
self.authority.arm()
patcher = mock.patch.object(local_auth_daemon, "get_authority", lambda: self.authority)
patcher.start()
self.addCleanup(patcher.stop)
def test_a_valid_enrollment_is_staged_and_the_command_is_claimed_once(self):
params = _FakeParams({ENROLL: _enrollment_json(self.authority, self.app_key)})
local_auth_daemon.claim_cloud_commands(cast(Params, params))
self.assertIsNotNone(self.authority.pending)
self.assertEqual("Test phone", self.authority.pending.app_name)
self.assertEqual(key_id(self.app_key.public_key()), self.authority.pending.key_id)
# Staged, not yet authorized, and never replayable.
self.assertEqual([], self.persisted)
self.assertNotIn(ENROLL, params)
def test_a_bad_payload_is_refused_and_still_cleared(self):
params = _FakeParams({ENROLL: "not an enrollment"})
local_auth_daemon.claim_cloud_commands(cast(Params, params))
self.assertIsNone(self.authority.pending)
self.assertNotIn(ENROLL, params)
def test_a_revocation_drops_the_phones_own_grant(self):
committed = self.authority.enroll(_enrollment_json(self.authority, self.app_key), origin=Origin.CLOUD)
self.authority.confirm(committed, committed.key_id)
proof = statement("revoke", self.authority.cloud_device_id, self.authority.comma_device_id, committed.key_id)
revocation = json.dumps({"v": 2, "cloud_device_id": self.authority.cloud_device_id,
"comma_device_id": self.authority.comma_device_id, "key_id": committed.key_id,
"signature": encode64(self.app_key.sign(proof, ec.ECDSA(hashes.SHA256())))})
params = _FakeParams({REVOKE: revocation})
local_auth_daemon.claim_cloud_commands(cast(Params, params))
self.assertEqual({}, self.authority.grants)
self.assertNotIn(REVOKE, params)
def test_no_command_means_no_work(self):
params = _FakeParams()
local_auth_daemon.claim_cloud_commands(cast(Params, params))
self.assertIsNone(self.authority.pending)
def test_the_command_round_trips_through_the_real_library(self):
"""The declared param is a STRING, so the JSON the app pushes is what the claim reads."""
directory = tempfile.mkdtemp(prefix="sunnylink_params_")
self.addCleanup(shutil.rmtree, directory, ignore_errors=True)
params = Params(directory)
params.put(ENROLL, _enrollment_json(self.authority, self.app_key), block=True)
payload = params.get(ENROLL)
if isinstance(payload, bytes):
payload = payload.decode()
local_auth_daemon.claim_cloud_commands(params)
self.assertIsNotNone(self.authority.pending)
self.assertEqual(b"", params.get(ENROLL) or b"")
def test_local_param_policy(self):
for key in ("SunnylinkLocalApps", "SunnylinkLocalPairingQr", "SunnylinkLocalNewTrustKey", "_sec_anything"):
self.assertTrue(is_local_param(key))
self.assertFalse(is_local_param("SpeedLimitOffset"))
# The cloud's only way into the local side is its two commands.
self.assertTrue(cloud_may_write(ENROLL))
self.assertTrue(cloud_may_write(REVOKE))
self.assertTrue(cloud_may_write("SpeedLimitOffset"))
for key in ("SunnylinkLocalApps", "SunnylinkLocalPairingRequest", "SunnylinkLocalRevoke", "_sec_x"):
self.assertFalse(cloud_may_write(key))
class TestLocalDiscovery(OpenpilotTestCase):
"""Beacon handling: the status line the settings screen shows while pairing."""
def test_fresh_beacons_are_recorded(self):
discovery = LocalDiscovery(write_interval_s=0)
discovery._handle(b'SUNNYLINK1 {"v":1,"role":"app","app_id":"phone-1","ws_port":8443}', "10.0.0.7")
discovery._handle(b'not a beacon', "10.0.0.8")
self.assertEqual(["ws://10.0.0.7:8443"], discovery.fresh_endpoints())
self.assertEqual([], discovery.fresh_endpoints(max_age_s=-1))
def test_an_open_window_counts_as_a_live_pairing_window(self):
from openpilot.sunnypilot.sunnylink.athena.local_discovery import latest_discovered_app
window = [False]
discovery = LocalDiscovery(write_interval_s=0, pairing_window_cb=lambda: window[0])
beacon = b'SUNNYLINK1 {"v":1,"role":"app","app_id":"phone-1","ws_port":8443}'
discovery._handle(beacon, "10.0.0.7")
self.assertIsNone(latest_discovered_app(discovery.params))
window[0] = True
discovery._handle(beacon, "10.0.0.7")
discovered = latest_discovered_app(discovery.params)
self.assertIsNotNone(discovered)
assert isinstance(discovered, tuple)
self.assertEqual("ws://10.0.0.7:8443", discovered[0])
def test_a_failing_window_callback_never_breaks_discovery(self):
def boom() -> bool:
raise RuntimeError("params unavailable")
discovery = LocalDiscovery(write_interval_s=0, pairing_window_cb=boom)
discovery._handle(b'SUNNYLINK1 {"v":1,"role":"app","app_id":"phone-1","ws_port":8443}', "10.0.0.7")
self.assertEqual(["ws://10.0.0.7:8443"], discovery.fresh_endpoints())
class TestEnrollmentTransportValue(OpenpilotTestCase):
"""The exact bytes the app pushes, kept in step with the mobile constant."""
def test_the_transport_value_is_standard_base64(self):
self.assertEqual('{"v":2}', base64.b64decode(ENROLLMENT_TRANSPORT_VALUE).decode())
@@ -4,139 +4,57 @@ Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
import base64
import shutil
import tempfile
from unittest import mock
from openpilot.common.params import Params
from openpilot.common.test import OpenpilotTestCase
from openpilot.sunnypilot.sunnylink.athena import sunnylinkd
from openpilot.sunnypilot.sunnylink.athena.local_params import ENROLL, REVOKE
# The local daemon owns this payload's contents; to the cloud transport it is one opaque value.
ENROLLMENT_JSON = '{"v":2,"purpose":"sunnylink-local-enroll"}'
from openpilot.common.test import OpenpilotTestCase
class TestCloudRpcSurface(OpenpilotTestCase):
"""The cloud daemon serves settings and must never expose or mutate local trust."""
class TestSunnylinkdMethods(OpenpilotTestCase):
def setup_method(self):
self.saved_params = []
self.original_save = sunnylinkd.save_param_from_base64_encoded_string
sunnylinkd.save_param_from_base64_encoded_string = self._record # ty: ignore[invalid-assignment]
self.daemon = sunnylinkd.Sunnylinkd()
def mock_save_param(key, value, compression=False):
self.saved_params.append((key, value, compression))
sunnylinkd.save_param_from_base64_encoded_string = mock_save_param # ty: ignore[invalid-assignment]
def teardown_method(self):
sunnylinkd.save_param_from_base64_encoded_string = self.original_save # ty: ignore[invalid-assignment]
def _record(self, key, value, compression=False):
self.saved_params.append((key, value, compression))
def test_methods_are_the_cloud_surface_only(self):
methods = self.daemon.methods()
self.assertTrue("getParams" in methods)
self.assertTrue("saveParams" in methods)
# Local serving is another process: no local pairing or alias RPC is registered here.
for name in ("pairLocalApp", "updateLocalAppAlias", "unpairLocalApp"):
self.assertNotIn(name, methods)
def test_saveParams_blocked(self):
blocked_params = {
"GithubUsername": "attacker",
"GithubSshKeys": "ssh-rsa attacker_key",
}
self.daemon.saveParams(blocked_params)
sunnylinkd.saveParams(blocked_params)
assert len(self.saved_params) == 0
def test_saveParams_allowed(self):
self.daemon.saveParams({
allowed_params = {
"SpeedLimitOffset": "5",
"MyCustomParam": "123",
})
"MyCustomParam": "123"
}
sunnylinkd.saveParams(allowed_params)
# verify content
assert len(self.saved_params) == 2
keys_saved = [p[0] for p in self.saved_params]
assert "SpeedLimitOffset" in keys_saved
assert "MyCustomParam" in keys_saved
def test_saveParams_mixed(self):
self.daemon.saveParams({
mixed_params = {
"GithubUsername": "attacker",
"SpeedLimitOffset": "10",
})
"SpeedLimitOffset": "10"
}
sunnylinkd.saveParams(mixed_params)
# should save allowed one
assert len(self.saved_params) == 1
assert self.saved_params[0][0] == "SpeedLimitOffset"
assert self.saved_params[0][1] == "10"
def test_saveParams_cannot_modify_local_trust(self):
self.daemon.saveParams({
"SunnylinkLocalApps": "attacker registry",
"SunnylinkLocalAppsMeta": "attacker metadata",
"SunnylinkLocalPairingRequest": "1",
"SunnylinkLocalPairingQr": "attacker qr",
"SunnylinkLocalRevoke": "attacker revoke request",
"SunnylinkLocalFutureKey": "attacker key",
"_sec_SunnylinkLocalFuture": "attacker secret",
})
self.assertEqual(self.saved_params, [])
def test_saveParams_delivers_only_the_two_local_commands(self):
self.daemon.saveParams({ENROLL: "ZW5yb2xs", REVOKE: "cmV2b2tl", "SunnylinkLocalApps": "attacker"})
self.assertEqual(self.saved_params, [(ENROLL, "ZW5yb2xs", False), (REVOKE, "cmV2b2tl", False)])
def test_getParams_never_exposes_local_parameters(self):
response = self.daemon.getParams(["SunnylinkLocalApps", "SunnylinkLocalAppsMeta",
"SunnylinkLocalPairingRequest", "SunnylinkLocalPairingQr",
"SunnylinkLocalRevoke", ENROLL, REVOKE])
self.assertEqual(response, {"params": "[]"})
def test_getParams_serves_ordinary_settings(self):
response = self.daemon.getParams(["SunnylinkEnabled"])
self.assertTrue("SunnylinkEnabled" in response)
def test_getParamsAllKeys_excludes_local_params(self):
keys = self.daemon.getParamsAllKeys()
for key in ("SunnylinkLocalApps", "SunnylinkLocalAppsMeta", "SunnylinkLocalPairingQr",
"SunnylinkLocalEnabled", ENROLL, REVOKE):
self.assertNotIn(key, keys)
self.assertTrue("SpeedLimitValueOffset" in keys)
class TestCloudCommandTransport(OpenpilotTestCase):
"""How an app's local command reaches the device: the cloud settings-write stores it.
The two commands are ordinary declared params on the cloud path, so the encoding the app
already uses (standard base64 of a UTF-8 JSON payload) is decoded by the same helper that
every other remotely written param goes through.
"""
def setup_method(self):
self.dir = tempfile.mkdtemp(prefix="sunnylink_params_")
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
self.daemon = sunnylinkd.Sunnylinkd(params=Params(self.dir))
patcher = mock.patch("openpilot.sunnypilot.sunnylink.utils.Params", lambda: Params(self.dir))
patcher.start()
self.addCleanup(patcher.stop)
def test_the_enrollment_lands_as_json_in_its_param(self):
self.daemon.saveParams({ENROLL: base64.b64encode(ENROLLMENT_JSON.encode()).decode("ascii")})
self.assertEqual(ENROLLMENT_JSON, Params(self.dir).get(ENROLL))
def test_the_revocation_lands_as_json_in_its_param(self):
self.daemon.saveParams({REVOKE: base64.b64encode(b'{"v":2}').decode("ascii")})
self.assertEqual('{"v":2}', Params(self.dir).get(REVOKE))
def test_a_compressed_command_also_arrives_readable(self):
import gzip
self.daemon.saveParams({ENROLL: base64.b64encode(gzip.compress(ENROLLMENT_JSON.encode())).decode("ascii")},
compression=True)
self.assertEqual(ENROLLMENT_JSON, Params(self.dir).get(ENROLL))
def test_the_cloud_may_not_write_any_other_local_param(self):
with mock.patch.object(sunnylinkd, "save_param_from_base64_encoded_string") as save:
self.daemon.saveParams({"SunnylinkLocalApps": base64.b64encode(b"{}").decode("ascii")})
save.assert_not_called()
+21 -1
View File
@@ -10,12 +10,13 @@ from openpilot.cereal import custom, messaging
from opendbc.car.structs import car
from opendbc.car.hyundai.values import CAR as HYUNDAI_CAR, UNSUPPORTED_LONGITUDINAL_CAR
from opendbc.car.subaru.values import CAR as SUBARU_CAR, SubaruFlags
from opendbc.car.toyota.values import CAR as TOYOTA_CAR, ToyotaFlags
from opendbc.sunnypilot.car.interfaces import has_bsm
from opendbc.sunnypilot.car.tesla.values import TeslaFlagsSP
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.common.hardware import HARDWARE
from openpilot.sunnypilot.models.helpers import get_active_source, ACTIVE_BUNDLE_KEYS
# Wire-protocol version for the capabilities payload. Bump on breaking changes
# only; additive fields are backward-compatible and do not require a bump.
@@ -43,6 +44,8 @@ CAPABILITY_FIELDS = (
"device_type",
"subaru_has_sng",
"hyundai_alpha_long_available",
"toyota_tss2",
"custom_model_active",
)
CAPABILITY_LABELS: dict[str, str] = {
@@ -65,6 +68,8 @@ CAPABILITY_LABELS: dict[str, str] = {
"device_type": "Device type",
"subaru_has_sng": "Subaru Stop-and-Go available",
"hyundai_alpha_long_available": "Hyundai Alpha Longitudinal available",
"toyota_tss2": "Toyota TSS2 platform",
"custom_model_active": "Custom model selected",
}
# Explicit defaults for non-boolean capability fields
@@ -111,6 +116,15 @@ def _resolve_brand_capabilities(caps: dict, bundle_platform: str, CP) -> None:
caps["subaru_has_sng"] = not bool(CP.flags & (SubaruFlags.GLOBAL_GEN2 | SubaruFlags.HYBRID))
caps["has_stop_and_go"] = caps["subaru_has_sng"]
elif brand == "toyota":
if bundle_platform:
try:
caps["toyota_tss2"] = bool(TOYOTA_CAR[bundle_platform].config.flags & ToyotaFlags.TSS2)
except KeyError:
cloudlog.exception(f"capabilities: unknown toyota platform {bundle_platform!r}")
elif CP is not None:
caps["toyota_tss2"] = bool(CP.flags & ToyotaFlags.TSS2)
def generate_capabilities(params: Params | None = None) -> dict:
"""Generate a SettingsCapabilities dict from CarParams + boolean params.
@@ -133,6 +147,12 @@ def generate_capabilities(params: Params | None = None) -> dict:
caps["is_development"] = params.get_bool("IsDevelopmentBranch")
caps["stock_longitudinal"] = params.get_bool("ToyotaEnforceStockLongitudinal")
# Model source based custom model flag
model_source = get_active_source(chestnut_active=params.get_bool("ChestnutActive"),
chestnut_loading=params.get_bool("ChestnutLoading"),
offroad=params.get_bool("IsOffroad"))
caps["custom_model_active"] = bool(params.get(ACTIVE_BUNDLE_KEYS[model_source]))
bundle = params.get("CarPlatformBundle")
bundle_brand = _bundle_field(bundle, "brand")
bundle_platform = _bundle_field(bundle, "platform")
@@ -2021,8 +2021,21 @@
{
"id": "model_behavior",
"title": "Model Behavior",
"description": "Lane desire and lead-vehicle awareness tuning",
"description": "Lane desire, Blendful lane centering, and lead-vehicle awareness tuning",
"items": [
{
"key": "Blendful",
"widget": "toggle",
"title": "Blendful Mode (Alpha)",
"description": "Gently pulls the model toward the lane center, as much as it trusts the lane lines, and yields around obstacles.",
"enablement": [
{
"type": "capability",
"field": "custom_model_active",
"equals": true
}
]
},
{
"key": "LaneTurnDesire",
"widget": "toggle",
@@ -2324,6 +2337,33 @@
"equals": false
}
]
},
{
"key": "ToyotaTSS2Smooth",
"widget": "toggle",
"needs_onroad_cycle": true,
"title": "TSS2 Smooth Longitudinal (Alpha)",
"description": "Enable smoother longitudinal control for TSS2 vehicles. Uses a custom speed-dependent integral gain curve for smoother traffic driving.",
"enablement": [
{
"type": "not_engaged"
},
{
"type": "capability",
"field": "has_longitudinal_control",
"equals": true
},
{
"type": "param",
"key": "ToyotaEnforceStockLongitudinal",
"equals": false
},
{
"type": "capability",
"field": "toyota_tss2",
"equals": true
}
]
}
]
}
@@ -63,3 +63,7 @@ macros:
release_branches_hide:
- type: not
condition: {type: capability, field: is_sp_release, equals: true}
# Gate for custom model features based on active source.
custom_model:
- { type: capability, field: custom_model_active, equals: true }
@@ -9,8 +9,14 @@ description: Driving model behavior and camera calibration
sections:
- id: model_behavior
title: Model Behavior
description: Lane desire and lead-vehicle awareness tuning
description: Lane desire, Blendful lane centering, and lead-vehicle awareness tuning
items:
- key: Blendful
widget: toggle
title: Blendful Mode (Alpha)
description: Gently pulls the model toward the lane center, as much as it trusts the lane lines, and yields around obstacles.
enablement:
- $ref: '#/macros/custom_model'
- key: LaneTurnDesire
widget: toggle
title: Use Lane Turn Desires
@@ -101,3 +101,18 @@ sections:
- type: param
key: ToyotaEnforceStockLongitudinal
equals: false
- key: ToyotaTSS2Smooth
widget: toggle
needs_onroad_cycle: true
title: TSS2 Smooth Longitudinal (Alpha)
description: Enable smoother longitudinal control for TSS2 vehicles. Uses a custom speed-dependent integral gain curve
for smoother traffic driving.
enablement:
- $ref: '#/macros/not_engaged'
- $ref: '#/macros/longitudinal'
- type: param
key: ToyotaEnforceStockLongitudinal
equals: false
- type: capability
field: toyota_tss2
equals: true
@@ -75,6 +75,12 @@ class TestOpaquePerBrandFlags(OpenpilotTestCase):
def test_hyundai_alpha_long_available_default_false(self, caps):
assert caps["hyundai_alpha_long_available"] is False
def test_toyota_tss2_field_present(self):
assert "toyota_tss2" in CAPABILITY_FIELDS
def test_toyota_tss2_default_false(self, caps):
assert caps["toyota_tss2"] is False
class TestCapabilitiesShape(OpenpilotTestCase):
def test_all_fields_present(self, caps):
@@ -207,6 +207,7 @@ class TestNotEngagedReplacement(OpenpilotTestCase):
"AlphaLongitudinalEnabled",
"ToyotaEnforceStockLongitudinal",
"ToyotaStopAndGoHack",
"ToyotaTSS2Smooth",
], names=["key"])
def test_offroad_only_replaced_with_not_engaged(self, schema, key):
"""These items should use not_engaged, not offroad_only."""
@@ -286,6 +286,7 @@ class TestKnownVehicleSettings(OpenpilotTestCase):
keys = {i["key"] for i in _brand_items(schema["vehicle_settings"].get("toyota"))}
assert "ToyotaEnforceStockLongitudinal" in keys
assert "ToyotaStopAndGoHack" in keys
assert "ToyotaTSS2Smooth" in keys
def test_tesla_has_coop_steering(self, schema):
keys = {i["key"] for i in _brand_items(schema["vehicle_settings"].get("tesla"))}
+4 -7
View File
@@ -2,7 +2,6 @@ import base64
import gzip
import json
from openpilot.sunnypilot.sunnylink.api import SunnylinkApi, UNREGISTERED_SUNNYLINK_DONGLE_ID
from openpilot.sunnypilot.sunnylink.athena.local_state import is_locally_paired
from openpilot.common.params import Params, ParamKeyType
from openpilot.common.version import is_prebuilt
@@ -17,11 +16,10 @@ def get_sunnylink_status(params=None) -> tuple[bool, bool, bool]:
def sunnylink_ready(params=None) -> bool:
"""Enabled and (cloud-registered or locally paired), and not on a temporary
fault. Local pairing makes never-registered devices usable over the LAN."""
"""Check if the device is ready to communicate with Sunnylink. That means it is enabled and registered."""
params = params or Params()
is_sunnylink_enabled, is_registered, is_on_temporary_fault = get_sunnylink_status(params)
return is_sunnylink_enabled and (is_registered or is_locally_paired(params)) and not is_on_temporary_fault
return is_sunnylink_enabled and is_registered and not is_on_temporary_fault
def use_sunnylink_uploader(params) -> bool:
@@ -30,11 +28,10 @@ def use_sunnylink_uploader(params) -> bool:
def sunnylink_need_register(params=None) -> bool:
"""Enabled, unregistered, and not locally paired — a locally paired device
works without cloud registration and must not be blocked."""
"""Check if the device needs to be registered with Sunnylink."""
params = params or Params()
is_sunnylink_enabled, is_registered, is_on_temporary_fault = get_sunnylink_status(params)
return is_sunnylink_enabled and not is_registered and not is_locally_paired(params) and not is_on_temporary_fault
return is_sunnylink_enabled and not is_registered and not is_on_temporary_fault
def register_sunnylink():
@@ -74,11 +74,6 @@ def livestream(started: bool, params: Params, CP: car.CarParams) -> bool:
def use_copyparty(started, params, CP: car.CarParams) -> bool:
return bool(params.get_bool("EnableCopyparty"))
def localsunnylinkd_enabled(started, params, CP: car.CarParams) -> bool:
"""Local connections are a sunnylink feature with their own switch."""
return params.get_bool("SunnylinkEnabled") and not params.get_bool("SunnylinkTempFault") \
and params.get_bool("SunnylinkLocalEnabled")
def sunnylink_ready_shim(started, params, CP: car.CarParams) -> bool:
"""Shim for sunnylink_ready to match the process manager signature."""
return sunnylink_ready(params)
@@ -171,7 +166,6 @@ procs = [
# sunnylink <3
DaemonProcess("manage_sunnylinkd", "openpilot.sunnypilot.sunnylink.athena.manage_sunnylinkd", "SunnylinkdPid"),
PythonProcess("localsunnylinkd", "openpilot.sunnypilot.sunnylink.athena.localsunnylinkd", localsunnylinkd_enabled),
PythonProcess("sunnylink_registration_manager", "openpilot.sunnypilot.sunnylink.registration_manager", sunnylink_need_register_shim),
PythonProcess("statsd_sp", "openpilot.sunnypilot.sunnylink.statsd", and_(always_run, sunnylink_ready_shim)),
]
@@ -57,7 +57,7 @@ class DownloadStatusAction(ItemAction):
self.icon: str | None = None
self.icon_color: rl.Color | None = None
self._font = gui_app.font(FontWeight.NORMAL)
# raw progress arrives in steps, one per 128KB chunk the manager publishes
# raw progress arrives in steps, one per progress report from the manager
self._progress = FirstOrderFilter(0.0, 0.5, 1 / gui_app.target_fps)
# integrated per frame; (t * speed) % span jumps whenever the fill width changes
self._sweep = 0.0