IQ.Pilot Release Commit @ 3fe374f
This commit is contained in:
1
.gitattributes
vendored
1
.gitattributes
vendored
@@ -50,3 +50,4 @@ iqpilot/selfdrive/assets/icons_mici/settings/vehicle.png -filter -diff -merge -t
|
|||||||
iqpilot/selfdrive/assets/icons_mici/speedometer.png -filter -diff -merge -text
|
iqpilot/selfdrive/assets/icons_mici/speedometer.png -filter -diff -merge -text
|
||||||
|
|
||||||
iqpilot/selfdrive/assets/icons/iq/*.png -filter -diff -merge -text
|
iqpilot/selfdrive/assets/icons/iq/*.png -filter -diff -merge -text
|
||||||
|
artifacts/runtime_wheels/*.whl -filter -diff -merge -text
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -29,7 +29,6 @@ a.out
|
|||||||
/iqdbc
|
/iqdbc
|
||||||
/msgq
|
/msgq
|
||||||
/openpilot
|
/openpilot
|
||||||
/teleoprtc
|
|
||||||
/tinygrad
|
/tinygrad
|
||||||
|
|
||||||
/docs_site/
|
/docs_site/
|
||||||
|
|||||||
1
artifacts/iqpilot_hephaestusd_private/.source_sha
Normal file
1
artifacts/iqpilot_hephaestusd_private/.source_sha
Normal file
@@ -0,0 +1 @@
|
|||||||
|
292ca77fab7ffaa87ab6a011206a1f5fdd3aeeb5
|
||||||
@@ -161,8 +161,8 @@
|
|||||||
},
|
},
|
||||||
"python/_iqclosure/iqpilot/selfdrive/iqmodeld/models/helpers.py": {
|
"python/_iqclosure/iqpilot/selfdrive/iqmodeld/models/helpers.py": {
|
||||||
"mode": 420,
|
"mode": 420,
|
||||||
"sha256": "ce5d23a33b0e2d9eb81525ad63a88823d20a0578191ceab48c20e24594f8f690",
|
"sha256": "e4f524c7595754fd9db86724a7bd9b67524c630275ac78599ab6838d1aa7d809",
|
||||||
"size": 10952
|
"size": 11037
|
||||||
},
|
},
|
||||||
"python/_iqclosure/iqpilot/selfdrive/locationd/__init__.py": {
|
"python/_iqclosure/iqpilot/selfdrive/locationd/__init__.py": {
|
||||||
"mode": 420,
|
"mode": 420,
|
||||||
@@ -351,27 +351,27 @@
|
|||||||
},
|
},
|
||||||
"python/iqpilot_private/konn3kt/backups/archive_codec.cpython-312-aarch64-linux-gnu.so": {
|
"python/iqpilot_private/konn3kt/backups/archive_codec.cpython-312-aarch64-linux-gnu.so": {
|
||||||
"mode": 493,
|
"mode": 493,
|
||||||
"sha256": "2ec8be1fb7b3b4e657a79c8085996729a7e7b1d9cf8c37f5008a62f4aa952aab",
|
"sha256": "82183cba3d305058bda02b49949fe7b12d52cd906cf10107a2e091632173b82e",
|
||||||
"size": 135552
|
"size": 135552
|
||||||
},
|
},
|
||||||
"python/iqpilot_private/konn3kt/backups/backup_keys.cpython-312-aarch64-linux-gnu.so": {
|
"python/iqpilot_private/konn3kt/backups/backup_keys.cpython-312-aarch64-linux-gnu.so": {
|
||||||
"mode": 493,
|
"mode": 493,
|
||||||
"sha256": "87e315903c8dbe8803111e2fe21c2bd367ff69cfb05ab88458e12130022a6c2c",
|
"sha256": "62b3742751af4c8fef9e3b0a439ba68dfc7acf6680340275894c4156c05de223",
|
||||||
"size": 67664
|
"size": 67664
|
||||||
},
|
},
|
||||||
"python/iqpilot_private/konn3kt/backups/backup_orchestrator.cpython-312-aarch64-linux-gnu.so": {
|
"python/iqpilot_private/konn3kt/backups/backup_orchestrator.cpython-312-aarch64-linux-gnu.so": {
|
||||||
"mode": 493,
|
"mode": 493,
|
||||||
"sha256": "187b5e13f2b21def80888ee353be65dee1aced7b2b1c7a4f8c962ce8895f8f89",
|
"sha256": "59a6ffcd63088e8b44c52d013f52d89fee01db9a3774b29a86de806c83960454",
|
||||||
"size": 204640
|
"size": 204640
|
||||||
},
|
},
|
||||||
"python/iqpilot_private/konn3kt/backups/cbc_vault.cpython-312-aarch64-linux-gnu.so": {
|
"python/iqpilot_private/konn3kt/backups/cbc_vault.cpython-312-aarch64-linux-gnu.so": {
|
||||||
"mode": 493,
|
"mode": 493,
|
||||||
"sha256": "9338a83201734ff572d431de5f56c8279fc4a656d10d0a67ea44e8057bb88016",
|
"sha256": "f8d9435ef9281a3fa268b504dc326a98962dd02bd52df0570b2a0904e77a961f",
|
||||||
"size": 69768
|
"size": 69768
|
||||||
},
|
},
|
||||||
"python/iqpilot_private/konn3kt/backups/imahelper.cpython-312-aarch64-linux-gnu.so": {
|
"python/iqpilot_private/konn3kt/backups/imahelper.cpython-312-aarch64-linux-gnu.so": {
|
||||||
"mode": 493,
|
"mode": 493,
|
||||||
"sha256": "df671d37153130a8dcf67fd8a7ae7b024206d19aaa6db85b48811a259289481d",
|
"sha256": "bcbcf79fb098d3647d856958be01bbf4b2cc28b57db0e4dfe4cd41d90b0ea040",
|
||||||
"size": 203088
|
"size": 203088
|
||||||
},
|
},
|
||||||
"python/iqpilot_private/konn3kt/flockd/__init__.py": {
|
"python/iqpilot_private/konn3kt/flockd/__init__.py": {
|
||||||
@@ -381,12 +381,12 @@
|
|||||||
},
|
},
|
||||||
"python/iqpilot_private/konn3kt/flockd/flockd.cpython-312-aarch64-linux-gnu.so": {
|
"python/iqpilot_private/konn3kt/flockd/flockd.cpython-312-aarch64-linux-gnu.so": {
|
||||||
"mode": 493,
|
"mode": 493,
|
||||||
"sha256": "a67c26048adfb32e76168bdb6a260e11dedbf05257a66eba5eef3b4a623ce1e6",
|
"sha256": "83ac740a10c113e1342cb9eee311fd2c0cb88e550441468155318207bdf400b7",
|
||||||
"size": 202184
|
"size": 202184
|
||||||
},
|
},
|
||||||
"python/iqpilot_private/konn3kt/flockd/signatures.cpython-312-aarch64-linux-gnu.so": {
|
"python/iqpilot_private/konn3kt/flockd/signatures.cpython-312-aarch64-linux-gnu.so": {
|
||||||
"mode": 493,
|
"mode": 493,
|
||||||
"sha256": "e09bb698e3128449a4da3c2d767621928e531140181b20acd2adcf4186617953",
|
"sha256": "fbd898edf0446b6dcfb6787400dd6fd5bb344c429be491ac40318f8a1946d195",
|
||||||
"size": 68080
|
"size": 68080
|
||||||
},
|
},
|
||||||
"python/iqpilot_private/konn3kt/hephaestus/__init__.py": {
|
"python/iqpilot_private/konn3kt/hephaestus/__init__.py": {
|
||||||
@@ -396,67 +396,67 @@
|
|||||||
},
|
},
|
||||||
"python/iqpilot_private/konn3kt/hephaestus/_vendor/localapi_runtime.zip": {
|
"python/iqpilot_private/konn3kt/hephaestus/_vendor/localapi_runtime.zip": {
|
||||||
"mode": 420,
|
"mode": 420,
|
||||||
"sha256": "cb21ff749633ca4b01aef63c178df260d51fff631aed83923b223d70bf6533bd",
|
"sha256": "9acc808279e1d740fd384ad2f31de7398c52c27baa8fa7a6e2f65b38cafc49e4",
|
||||||
"size": 2100238
|
"size": 2100238
|
||||||
},
|
},
|
||||||
"python/iqpilot_private/konn3kt/hephaestus/ble_auth.cpython-312-aarch64-linux-gnu.so": {
|
"python/iqpilot_private/konn3kt/hephaestus/ble_auth.cpython-312-aarch64-linux-gnu.so": {
|
||||||
"mode": 493,
|
"mode": 493,
|
||||||
"sha256": "85bf27b0c19387fdb52b493f259e84a23d31c73b5e275e964dbeb934d329c821",
|
"sha256": "eef8e9fdaea989bd323b8a3dde2ce1cc1090a8c60d31a81cf3eec53dd3600071",
|
||||||
"size": 268080
|
"size": 268080
|
||||||
},
|
},
|
||||||
"python/iqpilot_private/konn3kt/hephaestus/ble_gatt.cpython-312-aarch64-linux-gnu.so": {
|
"python/iqpilot_private/konn3kt/hephaestus/ble_gatt.cpython-312-aarch64-linux-gnu.so": {
|
||||||
"mode": 493,
|
"mode": 493,
|
||||||
"sha256": "1525c92a7fe8dd898a07188fcdc05dc608a5b77b35ab2fc9d250fcc46bf69999",
|
"sha256": "343423a3dbec1198f40745a6f303569c7b566d1a5eff8ff10ae5e3dacd0044b9",
|
||||||
"size": 406176
|
"size": 406176
|
||||||
},
|
},
|
||||||
"python/iqpilot_private/konn3kt/hephaestus/ble_rpc_dispatch.cpython-312-aarch64-linux-gnu.so": {
|
"python/iqpilot_private/konn3kt/hephaestus/ble_rpc_dispatch.cpython-312-aarch64-linux-gnu.so": {
|
||||||
"mode": 493,
|
"mode": 493,
|
||||||
"sha256": "f5163ae4cbaa77c09ccbea174f659bf719fa9f9c403db478af33ab09a8a9673b",
|
"sha256": "bf40085a06442714fb8386a77ac8e2d047eeadac084c09e80f3a5846137b2a16",
|
||||||
"size": 68048
|
"size": 68048
|
||||||
},
|
},
|
||||||
"python/iqpilot_private/konn3kt/hephaestus/ble_transportd.cpython-312-aarch64-linux-gnu.so": {
|
"python/iqpilot_private/konn3kt/hephaestus/ble_transportd.cpython-312-aarch64-linux-gnu.so": {
|
||||||
"mode": 493,
|
"mode": 493,
|
||||||
"sha256": "c69f659c3736c14a57065e118c35cf06c262520a35c37afea3e7bbc43844c129",
|
"sha256": "a14d91fb1ec6150a2eb11e206301ca1275e2a28c5e759823ed2ede57da05e34b",
|
||||||
"size": 335776
|
"size": 335776
|
||||||
},
|
},
|
||||||
"python/iqpilot_private/konn3kt/hephaestus/bt_gamepad.cpython-312-aarch64-linux-gnu.so": {
|
"python/iqpilot_private/konn3kt/hephaestus/bt_gamepad.cpython-312-aarch64-linux-gnu.so": {
|
||||||
"mode": 493,
|
"mode": 493,
|
||||||
"sha256": "543534177922974da9872b56ba91b5f63b26ab9cb3b275f8e7bd5517e2971ca6",
|
"sha256": "2ba6c8cb47106c3073d72d3291957c7aeefb5d704b851732b532c4f20e9f6b71",
|
||||||
"size": 271696
|
"size": 271696
|
||||||
},
|
},
|
||||||
"python/iqpilot_private/konn3kt/hephaestus/cloud_routes.cpython-312-aarch64-linux-gnu.so": {
|
"python/iqpilot_private/konn3kt/hephaestus/cloud_routes.cpython-312-aarch64-linux-gnu.so": {
|
||||||
"mode": 493,
|
"mode": 493,
|
||||||
"sha256": "53a637f886ac6c465c2e3a5ec74b7f2d4e624bbfdb0186c11f29f8d50a6af8d2",
|
"sha256": "a73ebe2e3dba51692fb8b227e3287209c2763a3f2d9fc8d5c7d62f7cdbf760dc",
|
||||||
"size": 134408
|
"size": 134408
|
||||||
},
|
},
|
||||||
"python/iqpilot_private/konn3kt/hephaestus/hephaestusd.cpython-312-aarch64-linux-gnu.so": {
|
"python/iqpilot_private/konn3kt/hephaestus/hephaestusd.cpython-312-aarch64-linux-gnu.so": {
|
||||||
"mode": 493,
|
"mode": 493,
|
||||||
"sha256": "52b6306bc0b6a69198ea139eb311e3c70a7f7d0b2093d17cae75e280dfdf52d1",
|
"sha256": "5825a4f141a829f7b3a8bbaf848fd8c25f38d99c6bf8c97cf19ca75c1c025241",
|
||||||
"size": 3715880
|
"size": 3715880
|
||||||
},
|
},
|
||||||
"python/iqpilot_private/konn3kt/hephaestus/kwp2000.cpython-312-aarch64-linux-gnu.so": {
|
"python/iqpilot_private/konn3kt/hephaestus/kwp2000.cpython-312-aarch64-linux-gnu.so": {
|
||||||
"mode": 493,
|
"mode": 493,
|
||||||
"sha256": "1dab38fc494466cac932289b9805266d211b1b510fe838907f3cb3afe53ed93c",
|
"sha256": "dc2147325ea4e1dafcaa3357c0878c3f39f3c4630417314f8bcb9af95abc7e91",
|
||||||
"size": 136472
|
"size": 136472
|
||||||
},
|
},
|
||||||
"python/iqpilot_private/konn3kt/hephaestus/manage_hephaestusd.cpython-312-aarch64-linux-gnu.so": {
|
"python/iqpilot_private/konn3kt/hephaestus/manage_hephaestusd.cpython-312-aarch64-linux-gnu.so": {
|
||||||
"mode": 493,
|
"mode": 493,
|
||||||
"sha256": "1adfdd3fa1ebd5e0f83e147dadf6b0a91e044bce00ea2a8c90c539c1ad0970dc",
|
"sha256": "c3755a8dcfda46fce09fb8eb69d6b9dccdc5487fe6aa94f4df49279ebeb41acc",
|
||||||
"size": 68208
|
"size": 68208
|
||||||
},
|
},
|
||||||
"python/iqpilot_private/konn3kt/hephaestus/motd.cpython-312-aarch64-linux-gnu.so": {
|
"python/iqpilot_private/konn3kt/hephaestus/motd.cpython-312-aarch64-linux-gnu.so": {
|
||||||
"mode": 493,
|
"mode": 493,
|
||||||
"sha256": "af1119b628946776d468bad4c6f05126a9f1b76010e76d84615f004fb10ea0cd",
|
"sha256": "270b887b04eba51006aff5c71c6b57a29099813752a49a6a1942380edb25790a",
|
||||||
"size": 67840
|
"size": 67840
|
||||||
},
|
},
|
||||||
"python/iqpilot_private/konn3kt/hephaestus/tp20.cpython-312-aarch64-linux-gnu.so": {
|
"python/iqpilot_private/konn3kt/hephaestus/tp20.cpython-312-aarch64-linux-gnu.so": {
|
||||||
"mode": 493,
|
"mode": 493,
|
||||||
"sha256": "e5e0fa2832d0ace95843e5d732ecd9e1de370c00d5dc41a55f33103236194125",
|
"sha256": "fdc61d802b53f6233eab0a73f7ef732d4a67134a3849343e259871bc08fec0ef",
|
||||||
"size": 135664
|
"size": 135664
|
||||||
},
|
},
|
||||||
"python/iqpilot_private/konn3kt/hephaestus/vw_pq_flasher.cpython-312-aarch64-linux-gnu.so": {
|
"python/iqpilot_private/konn3kt/hephaestus/vw_pq_flasher.cpython-312-aarch64-linux-gnu.so": {
|
||||||
"mode": 493,
|
"mode": 493,
|
||||||
"sha256": "f20bd4b330e641b481db8f545b72d472007402c709666b687d4e9897c42d0dc0",
|
"sha256": "98770387cdda540ce42c8d0b0508fd76b1c03a457d675823aa827b5725d5f888",
|
||||||
"size": 269600
|
"size": 269600
|
||||||
},
|
},
|
||||||
"python/iqpilot_private/konn3kt/uploaderd/__init__.py": {
|
"python/iqpilot_private/konn3kt/uploaderd/__init__.py": {
|
||||||
@@ -466,7 +466,7 @@
|
|||||||
},
|
},
|
||||||
"python/iqpilot_private/konn3kt/uploaderd/iquploaderd.cpython-312-aarch64-linux-gnu.so": {
|
"python/iqpilot_private/konn3kt/uploaderd/iquploaderd.cpython-312-aarch64-linux-gnu.so": {
|
||||||
"mode": 493,
|
"mode": 493,
|
||||||
"sha256": "eee4e9e58787962b9cb5e63f9b946fa07ac1121c3c29373f947d3d461db3f1e3",
|
"sha256": "84394628653c76537d7e2be1938415eeea3decc0791d57b4296f6177e30588db",
|
||||||
"size": 204296
|
"size": 204296
|
||||||
},
|
},
|
||||||
"runtime": {
|
"runtime": {
|
||||||
@@ -510,25 +510,25 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"signatures": {
|
"signatures": {
|
||||||
"python/iqpilot_private/konn3kt/backups/archive_codec.cpython-312-aarch64-linux-gnu.so": "l+KcwF57otKYCL17nXSTJ8s0so+z9q4b/SsOfagBc9G+TbGDO9qHYcbsMtWSKoIj7Np6NGwAhBBKGubGNA6NDw==",
|
"python/iqpilot_private/konn3kt/backups/archive_codec.cpython-312-aarch64-linux-gnu.so": "NAbCqbvyXIPYE7Il+5iOFO4gAWCSn+45RUlrGjxJydXtONcibCK7MMZmwC/bxvf94FdwaayTc5Bk2viE4sR3Aw==",
|
||||||
"python/iqpilot_private/konn3kt/backups/backup_keys.cpython-312-aarch64-linux-gnu.so": "7gttAqiZR2SOutahE8XnQEH+es5E7nHt8TprLg3QAv/q/aYVTvJMHM/uMYUr7CVqtNSk9NXzi4kuahry96A2DQ==",
|
"python/iqpilot_private/konn3kt/backups/backup_keys.cpython-312-aarch64-linux-gnu.so": "GmZl9B5gnEtrfR2puNGucdznMZ1QAdTwI1T+HkJeXooHRdnhSh9/GarqxzjhOBT/br471jx9h7Qm8ZcYkc8VBw==",
|
||||||
"python/iqpilot_private/konn3kt/backups/backup_orchestrator.cpython-312-aarch64-linux-gnu.so": "otqtacOKGvys+mxrA0GSs/C2gvL/RoyNVXXeTtwhYYBYsBwWEacuM6xEAANZtnkowt2EIrY22RdIDS9091KBBQ==",
|
"python/iqpilot_private/konn3kt/backups/backup_orchestrator.cpython-312-aarch64-linux-gnu.so": "ShJ0b8MtUi1uupmlF26qPutyMxazGJFRmoI7phHNyYZaocv3iEtqtUPwO9icjO9Oh6q2dNZhYNUel+MpRW9oBw==",
|
||||||
"python/iqpilot_private/konn3kt/backups/cbc_vault.cpython-312-aarch64-linux-gnu.so": "4qPIHhjLGAGjKTXtvAWbdrOqxLL5vJtZnF3xVUBQiB/3mQo20i0hPT8+6164HQXMyf596FsetmzUclPPlAeoBQ==",
|
"python/iqpilot_private/konn3kt/backups/cbc_vault.cpython-312-aarch64-linux-gnu.so": "J01e8wupmVBf6jEqodutokSkmkSAbMCeoiTPkhM1ME125NtHrSbJOuoka9c3wdUXVeVz3itnt2KkiayDwCvmBA==",
|
||||||
"python/iqpilot_private/konn3kt/backups/imahelper.cpython-312-aarch64-linux-gnu.so": "jPid/JOUWsCYqtCVG4JD5c86nLtadg9L3YICEhsodZiaVPx2Yga0wxPvS7wkaNKCBohIGD20QxJqBUgrKnXQCA==",
|
"python/iqpilot_private/konn3kt/backups/imahelper.cpython-312-aarch64-linux-gnu.so": "DKRmG1/vNDmW/fM5LDEfiRFRKCUXWl/zs/w9pyLuP3P0uX+NOvVP4V3niGVjdfpcVQsoeSds+Y/2ml2BVhepDg==",
|
||||||
"python/iqpilot_private/konn3kt/flockd/flockd.cpython-312-aarch64-linux-gnu.so": "Wl2IcgkvLI/bHQfozQP4vcMhbhMhph3rb/Zu4Mg7E6XKNfxw/N8/frq+8x8QV9N2kRro9/6eANyAamOsQUFuBw==",
|
"python/iqpilot_private/konn3kt/flockd/flockd.cpython-312-aarch64-linux-gnu.so": "z+x9JhNkR5wwo+oN64kBuDfeGmWQtrm73iqmK8e4KYDi6bW5EbR17YGJd2w2Qpy3Y+O4hqWPTzimC76NvvgoBg==",
|
||||||
"python/iqpilot_private/konn3kt/flockd/signatures.cpython-312-aarch64-linux-gnu.so": "nzibDoeERknQpLGPUCUQ0qa7FxkoJ2UNXHA8pgcIFWCLMUXDPODjGbKdx2mpSQDU7Q5E5xNx6gjSU0JgZvpZAA==",
|
"python/iqpilot_private/konn3kt/flockd/signatures.cpython-312-aarch64-linux-gnu.so": "6+o1p0LzuO2fyXgzmKQ8yqIxTPoww9wkE5BBQNiTthEw/KSa8mFn1QxmN/5Rc+O3fmgNydxEdjMIwcsmQpL/DA==",
|
||||||
"python/iqpilot_private/konn3kt/hephaestus/ble_auth.cpython-312-aarch64-linux-gnu.so": "uuROebZDc2abXyiuhL8kvQszS5/aADOnEG1wP0cssD2I6CRPqJb47y6mwXKlUJrgasod2XDhD22mCbn9sDuVDA==",
|
"python/iqpilot_private/konn3kt/hephaestus/ble_auth.cpython-312-aarch64-linux-gnu.so": "YxpFvXNm0HIXYVVaW1zDXwWOsh5nz7eZS6DdsMlnMdl8OHaSgabWZBN7RF4iWJP/GU9S0x6u9hOBXLosf12wDQ==",
|
||||||
"python/iqpilot_private/konn3kt/hephaestus/ble_gatt.cpython-312-aarch64-linux-gnu.so": "5wGiTrEA8uaDEkk5W0eHztI+gaixGHIT4sbgUKeiW8nUlJYAsC7pDXodOZMmcmcjtQTtgTFrzhDnA9O1aaC+Ag==",
|
"python/iqpilot_private/konn3kt/hephaestus/ble_gatt.cpython-312-aarch64-linux-gnu.so": "FX+aDGY2Xs5Y6ekJBuRhjlw/CxxwSfC/ZwOyY+k/v7PULJ9T5fEJjuHE8hAmkdPK4ED7GT5VO5FB4twOvOiiCw==",
|
||||||
"python/iqpilot_private/konn3kt/hephaestus/ble_rpc_dispatch.cpython-312-aarch64-linux-gnu.so": "P6k37fMBW5ryhdHxReumrOSIuRnIb7rqaq+3OOVpgwol/lDwST0WL/wZfSNsnGB8tQL+YZOLOTw6daJe6BanBQ==",
|
"python/iqpilot_private/konn3kt/hephaestus/ble_rpc_dispatch.cpython-312-aarch64-linux-gnu.so": "uPcSh8CGEYHeu9ApsPIn0rNCFz+EBTyhuqQyGLGjU/RSnoWhkhHmqpbeCfgZTBmkwLol7Onfex0w/ELEjUyrDQ==",
|
||||||
"python/iqpilot_private/konn3kt/hephaestus/ble_transportd.cpython-312-aarch64-linux-gnu.so": "lP6OEHgbOtQu8AskEdKgy+773g7F/NWGrkeD4LeC7zcdHmdmfg2dYcN+Wdrs8FrlvAjBilFqyv3wZVfoKv8dBg==",
|
"python/iqpilot_private/konn3kt/hephaestus/ble_transportd.cpython-312-aarch64-linux-gnu.so": "BJgU130UhimX2PSMYbNZJ0/nUFHI0MolmTrk0d5wTJ7uvmIT0wGsaO78PGp66krMtXaDFdhg/UJUz/KRyVPCBg==",
|
||||||
"python/iqpilot_private/konn3kt/hephaestus/bt_gamepad.cpython-312-aarch64-linux-gnu.so": "q1XPOv/SdBC4xfSsiDSf8yR2mdfgWbseTBrUbl9xilFYUd565cv8iauUqeh3/EsVV8qZvcIZViTWuCpy00VZAQ==",
|
"python/iqpilot_private/konn3kt/hephaestus/bt_gamepad.cpython-312-aarch64-linux-gnu.so": "UzuHr0ym4jsHBzBW4e5XPcP+46zeds6Ec+7FK9hqMjJx+CVRfg9/1CfLCwqCUhe7QC4puqt0R8AQ6tCWppflCg==",
|
||||||
"python/iqpilot_private/konn3kt/hephaestus/cloud_routes.cpython-312-aarch64-linux-gnu.so": "zY8c7e8Qq5vSDlbymf3o6hsPz+slQ8oxqiLk8qpKDNFfP6hJxK2eUf/9WHQGGTvGg99d/QoQVyozCu1XRGoFCA==",
|
"python/iqpilot_private/konn3kt/hephaestus/cloud_routes.cpython-312-aarch64-linux-gnu.so": "aPUVcHH/ashhYDylUw7AblIIYPOzavy+NHAwxhCkavILBvEwyvpoZAQuq1ajAUrsqUUoVDBRGUT8qQpcXQslBw==",
|
||||||
"python/iqpilot_private/konn3kt/hephaestus/hephaestusd.cpython-312-aarch64-linux-gnu.so": "xu/P0Ekw0d1yjINg542+10BROwGUU5G6qktfwnhFymcF0jhWGaThSndJmPeFmWq0k9bs4asAXimgq5/BUAIABw==",
|
"python/iqpilot_private/konn3kt/hephaestus/hephaestusd.cpython-312-aarch64-linux-gnu.so": "9LDSMTtL21Ec4iVjYxBL8UjclFf16M/vTSehYveVHOnVOaZPiki9mhf1UJScCxbTK24EqLMD14AdKzZ44qokBA==",
|
||||||
"python/iqpilot_private/konn3kt/hephaestus/kwp2000.cpython-312-aarch64-linux-gnu.so": "zFPl0ip4i/baX0GJzDnPGrm9vAgeXXMIotBr0VArZOxtOM1pOyGwOaJpgWBtb4kVUsNLepjfPgva6UVhrvb6Dg==",
|
"python/iqpilot_private/konn3kt/hephaestus/kwp2000.cpython-312-aarch64-linux-gnu.so": "hjQ9KQsNSTO3Sj2DpZ9RQHMIgzvGQMhCcFsMs7fjV2v4gzd4AblfY09I2/Jg/WmghjB9q2onqaA+ARKixvAvCA==",
|
||||||
"python/iqpilot_private/konn3kt/hephaestus/manage_hephaestusd.cpython-312-aarch64-linux-gnu.so": "Ai6K1+DGajQIi6tEBl0wfPoHWHA499mXbLrEZQZZb0X7dlASbwu+MVW1jovY3kgskMI9JcVANNEWPfSTvPnyAg==",
|
"python/iqpilot_private/konn3kt/hephaestus/manage_hephaestusd.cpython-312-aarch64-linux-gnu.so": "agjSn1SbGK6VW8X/dDRFpqz5sTpjvBnlnMKnLXf/o0ApUeECcgujLuOAZu+TPyfmcEVcPyE8TYYamCM0VGY0Dw==",
|
||||||
"python/iqpilot_private/konn3kt/hephaestus/motd.cpython-312-aarch64-linux-gnu.so": "GMyhhOO0gGxbbLiB0yjUKVOwV6JeeTfhKNnjytjAboFDUj4ueh/Kuf9L6CRuFZf9xU6uvB6QXSBuUr+FHEYQDQ==",
|
"python/iqpilot_private/konn3kt/hephaestus/motd.cpython-312-aarch64-linux-gnu.so": "MUiDt9IKQ1YTyGTAVYO2bcG8rfvkbECFQOAq5q6m1y+V37y9O0dN6OK8YyHPDRbshBCDupcaUx3xdu2Rh5toAA==",
|
||||||
"python/iqpilot_private/konn3kt/hephaestus/tp20.cpython-312-aarch64-linux-gnu.so": "+fLA6hdx0m3WISNW+mvi8s8Lk/N+ZOvfyEF69ZmQ8lFWVdN08g52wbaH6lR2hh2bl8y8rekqFGSHKj38NnYcAg==",
|
"python/iqpilot_private/konn3kt/hephaestus/tp20.cpython-312-aarch64-linux-gnu.so": "1U+W1BeavzaoaMEQxCFBPB4RleaE704xgf7a9tsqMfvScfU80nvge/VuvgIF64OPxYZIoYv9s0oMrzTNy6ZfDw==",
|
||||||
"python/iqpilot_private/konn3kt/hephaestus/vw_pq_flasher.cpython-312-aarch64-linux-gnu.so": "DGU9axrJW6Mtcpt43w07LgP9u+fk67pOZ4pfmiTGddnsZkw1IEJJZneguVTO5mUN7OZq9iWH2KsqV+N1aaLbDw==",
|
"python/iqpilot_private/konn3kt/hephaestus/vw_pq_flasher.cpython-312-aarch64-linux-gnu.so": "RzZ8ZwI9zExHqNph32fB54/fs1oZZHHFoYWlqR5P53QPKws15FJ/hPalVSaijO9rgspgNSHMOZ6Z/ln3Z3JpDg==",
|
||||||
"python/iqpilot_private/konn3kt/uploaderd/iquploaderd.cpython-312-aarch64-linux-gnu.so": "SSw7fJoNsbQ1GuomiW9rETXgMVqHKgqv7wuPIbzL+Y+pNJyYgvmikeyTFaloTIhllvr9n0XzSp/KT+qr41weCw=="
|
"python/iqpilot_private/konn3kt/uploaderd/iquploaderd.cpython-312-aarch64-linux-gnu.so": "3W7enZXiH/fkwzwm3YMt3lw8OvetDzHK08jeZdNvQ6JMTjojSiO/jgulmOplMaNOrdp4aC/C1jOER70DwzxPCQ=="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
aT5ZgWVbq0qWHt3m8wQmPWVfaFivYO6zCFB2LxmMC6bn1ZS+k/tNYZTbIANNFwhKGM4Q6zdQeGVicoDreqtYBw==
|
cQ9jTWO/J9039E1TJAs/oACuUTbhSRciZy07VantcDjTofWJPxCZ32Go8eqQPHipjbeXU8Oo4jJlNdjsSg2DAg==
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ _ACTIVE_BUNDLE_KEY = "ModelManager_ActiveBundle"
|
|||||||
_MODELS_CACHE_KEY = "ModelManager_ModelsCache"
|
_MODELS_CACHE_KEY = "ModelManager_ModelsCache"
|
||||||
_RUNNER_CACHE_KEY = "ModelRunnerTypeCache"
|
_RUNNER_CACHE_KEY = "ModelRunnerTypeCache"
|
||||||
_DOWNLOAD_INDEX_KEY = "ModelManager_DownloadIndex"
|
_DOWNLOAD_INDEX_KEY = "ModelManager_DownloadIndex"
|
||||||
|
_PENDING_INDEX_KEY = "ModelManager_PendingIndex"
|
||||||
_PENDING_MODEL_RESTORE_FILE = "/data/k3_pending_model_restore"
|
_PENDING_MODEL_RESTORE_FILE = "/data/k3_pending_model_restore"
|
||||||
_STOCK_RUNNER = int(Runner.stock)
|
_STOCK_RUNNER = int(Runner.stock)
|
||||||
_TINYGRAD_RUNNER = int(Runner.tinygrad)
|
_TINYGRAD_RUNNER = int(Runner.tinygrad)
|
||||||
@@ -226,6 +227,7 @@ def select_default_model(params: Params = None) -> None:
|
|||||||
bundle_dict = _load_default_bundle_dict()
|
bundle_dict = _load_default_bundle_dict()
|
||||||
ensure_default_model_files(bundle_dict)
|
ensure_default_model_files(bundle_dict)
|
||||||
params.remove(_DOWNLOAD_INDEX_KEY)
|
params.remove(_DOWNLOAD_INDEX_KEY)
|
||||||
|
params.remove(_PENDING_INDEX_KEY)
|
||||||
params.put(_ACTIVE_BUNDLE_KEY, bundle_dict)
|
params.put(_ACTIVE_BUNDLE_KEY, bundle_dict)
|
||||||
params.remove(_RUNNER_CACHE_KEY)
|
params.remove(_RUNNER_CACHE_KEY)
|
||||||
params.put(_RUNNER_CACHE_KEY, _TINYGRAD_RUNNER)
|
params.put(_RUNNER_CACHE_KEY, _TINYGRAD_RUNNER)
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -46,8 +46,8 @@
|
|||||||
},
|
},
|
||||||
"python/_iqclosure/iqpilot/common/realtime.py": {
|
"python/_iqclosure/iqpilot/common/realtime.py": {
|
||||||
"mode": 420,
|
"mode": 420,
|
||||||
"sha256": "ff112acc1fdc227e2ffa4b0fa74f27b967a488bb72fe21c5be82fc70d1104f02",
|
"sha256": "f137ef62c1602a1f7c329731883dc518c62288bd25596891858d460ae3209ae2",
|
||||||
"size": 3488
|
"size": 3966
|
||||||
},
|
},
|
||||||
"python/_iqclosure/iqpilot/common/swaglog.py": {
|
"python/_iqclosure/iqpilot/common/swaglog.py": {
|
||||||
"mode": 420,
|
"mode": 420,
|
||||||
@@ -91,18 +91,18 @@
|
|||||||
},
|
},
|
||||||
"python/_iqclosure/iqpilot/selfdrive/iqmodeld/models/__init__.py": {
|
"python/_iqclosure/iqpilot/selfdrive/iqmodeld/models/__init__.py": {
|
||||||
"mode": 420,
|
"mode": 420,
|
||||||
"sha256": "e5ddcbc7b4dc348957c3d141c6bf8e7c321067f2ac56aecec24f6de107f9c2d3",
|
"sha256": "5865243a95ca4557d2a960302124a9898545cc3a8387e88ef569308e3d0f7077",
|
||||||
"size": 81
|
"size": 119
|
||||||
},
|
},
|
||||||
"python/_iqclosure/iqpilot/selfdrive/iqmodeld/models/fetcher.py": {
|
"python/_iqclosure/iqpilot/selfdrive/iqmodeld/models/fetcher.py": {
|
||||||
"mode": 420,
|
"mode": 420,
|
||||||
"sha256": "0487789aec270f9a712a19afafe0b686f825f06f57006c2346c052d3622ac5f8",
|
"sha256": "23622cdec9eed5649a05422ba19eae956ba16a63850927af4a222c3d9030a1bb",
|
||||||
"size": 619
|
"size": 399
|
||||||
},
|
},
|
||||||
"python/_iqclosure/iqpilot/selfdrive/iqmodeld/models/helpers.py": {
|
"python/_iqclosure/iqpilot/selfdrive/iqmodeld/models/helpers.py": {
|
||||||
"mode": 420,
|
"mode": 420,
|
||||||
"sha256": "5ddd486e47cdf811c0c293c460aba2d3b15bb30059ff6da8da775c5cd236465b",
|
"sha256": "e4f524c7595754fd9db86724a7bd9b67524c630275ac78599ab6838d1aa7d809",
|
||||||
"size": 10945
|
"size": 11037
|
||||||
},
|
},
|
||||||
"python/_iqclosure/iqpilot/system/__init__.py": {
|
"python/_iqclosure/iqpilot/system/__init__.py": {
|
||||||
"mode": 420,
|
"mode": 420,
|
||||||
@@ -121,8 +121,8 @@
|
|||||||
},
|
},
|
||||||
"python/_iqclosure/iqpilot/system/hardware/hw.py": {
|
"python/_iqclosure/iqpilot/system/hardware/hw.py": {
|
||||||
"mode": 420,
|
"mode": 420,
|
||||||
"sha256": "32da46465ff0a232d21c6d54a54cf387db5e8441e568fe6c169abf9b8a016053",
|
"sha256": "c69185d8f6482c8b021f687b793246d2e2ede853e89a88748c64b85f60711b59",
|
||||||
"size": 3441
|
"size": 3638
|
||||||
},
|
},
|
||||||
"python/_iqclosure/iqpilot/system/hardware/pc/__init__.py": {
|
"python/_iqclosure/iqpilot/system/hardware/pc/__init__.py": {
|
||||||
"mode": 420,
|
"mode": 420,
|
||||||
@@ -146,8 +146,8 @@
|
|||||||
},
|
},
|
||||||
"python/_iqclosure/iqpilot/system/hardware/tici/hardware.py": {
|
"python/_iqclosure/iqpilot/system/hardware/tici/hardware.py": {
|
||||||
"mode": 420,
|
"mode": 420,
|
||||||
"sha256": "49d817c9df4b25524190fd92c6899c4c6bfb0f101f2c58a53f0a99a7933f93b9",
|
"sha256": "f1ddffa9bdb5071a49fd65eb4b49958b60b1873e1ec70e6f3f2e2b2501abce2f",
|
||||||
"size": 25530
|
"size": 25584
|
||||||
},
|
},
|
||||||
"python/_iqclosure/iqpilot/system/hardware/tici/iwlist.py": {
|
"python/_iqclosure/iqpilot/system/hardware/tici/iwlist.py": {
|
||||||
"mode": 420,
|
"mode": 420,
|
||||||
@@ -166,8 +166,8 @@
|
|||||||
},
|
},
|
||||||
"python/_iqclosure/iqpilot/system/version.py": {
|
"python/_iqclosure/iqpilot/system/version.py": {
|
||||||
"mode": 420,
|
"mode": 420,
|
||||||
"sha256": "b39a686854c9bfa3c6db9158f68734d7044760cf7ee79980102b7d23230836fd",
|
"sha256": "3a6bc7520dae7944a1da6f911cd090704a463e011f39949ea6c9df51b0e8c616",
|
||||||
"size": 5909
|
"size": 6033
|
||||||
},
|
},
|
||||||
"python/iqpilot_private/__init__.py": {
|
"python/iqpilot_private/__init__.py": {
|
||||||
"mode": 420,
|
"mode": 420,
|
||||||
@@ -176,7 +176,7 @@
|
|||||||
},
|
},
|
||||||
"python/iqpilot_private/models/__init__.cpython-312-aarch64-linux-gnu.so": {
|
"python/iqpilot_private/models/__init__.cpython-312-aarch64-linux-gnu.so": {
|
||||||
"mode": 493,
|
"mode": 493,
|
||||||
"sha256": "6aa9c6c725971a6ea6e1781dcc752297b072599a8a5d1ba78605ea69617a0a18",
|
"sha256": "c0d8a584bfeb79f0a109108ff463a95a57db24635f48d936461dddf4673e25b4",
|
||||||
"size": 67616
|
"size": 67616
|
||||||
},
|
},
|
||||||
"python/iqpilot_private/models/__init__.py": {
|
"python/iqpilot_private/models/__init__.py": {
|
||||||
@@ -186,23 +186,23 @@
|
|||||||
},
|
},
|
||||||
"python/iqpilot_private/models/fetcher.cpython-312-aarch64-linux-gnu.so": {
|
"python/iqpilot_private/models/fetcher.cpython-312-aarch64-linux-gnu.so": {
|
||||||
"mode": 493,
|
"mode": 493,
|
||||||
"sha256": "dfbd700d0d637203969c39f9270d3aa2b8e1fb882ca3e852ef8c6055c828119b",
|
"sha256": "fe34e89be0539fd173457cadc1fdf3a1467d6fefd965031c37cc3d5566f8b1bb",
|
||||||
"size": 136176
|
"size": 136200
|
||||||
},
|
},
|
||||||
"python/iqpilot_private/models/git_auth.cpython-312-aarch64-linux-gnu.so": {
|
"python/iqpilot_private/models/git_auth.cpython-312-aarch64-linux-gnu.so": {
|
||||||
"mode": 493,
|
"mode": 493,
|
||||||
"sha256": "532beea0658a2cc045b3039b6aa5d2c16e0225c5046b86e48fba745f93419f32",
|
"sha256": "9e7dd5fbf72f7737130fb36718a2b212afa55757b39ad6ad99d7e4643fa852fa",
|
||||||
"size": 67864
|
"size": 67864
|
||||||
},
|
},
|
||||||
"python/iqpilot_private/models/helpers.cpython-312-aarch64-linux-gnu.so": {
|
"python/iqpilot_private/models/helpers.cpython-312-aarch64-linux-gnu.so": {
|
||||||
"mode": 493,
|
"mode": 493,
|
||||||
"sha256": "650f1b94ad3bd468718096d194c69768779541cf7c20069cbb9bac4c06e1e016",
|
"sha256": "8bedb7406b942dbee7274cd97bb448ded69284911f9760dd702bb90e44325d20",
|
||||||
"size": 137936
|
"size": 137944
|
||||||
},
|
},
|
||||||
"python/iqpilot_private/models/manager.cpython-312-aarch64-linux-gnu.so": {
|
"python/iqpilot_private/models/manager.cpython-312-aarch64-linux-gnu.so": {
|
||||||
"mode": 493,
|
"mode": 493,
|
||||||
"sha256": "6ad8abc92158cf45e27999520d7712d69c7246cf2e5a0682fc9f15d2f6ed6b33",
|
"sha256": "65f503a85a00b729f0f4eeb033601f7978a0aba81fd4ba3ea58c0a3d18e45dad",
|
||||||
"size": 274232
|
"size": 340000
|
||||||
},
|
},
|
||||||
"runtime": {
|
"runtime": {
|
||||||
"entries": {
|
"entries": {
|
||||||
@@ -213,10 +213,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"signatures": {
|
"signatures": {
|
||||||
"python/iqpilot_private/models/__init__.cpython-312-aarch64-linux-gnu.so": "xO18IfX1kJN00FcBH4UqAWLU5qE+K2z/tTYRkkCf1W+hEUyZTcID1xFwhA1wafpMnBNCd0mVJUMc9oaYaFdjBA==",
|
"python/iqpilot_private/models/__init__.cpython-312-aarch64-linux-gnu.so": "9lPKvj8ZTe6wKhWcX4Bha8oHtb/BbWQThP610EWKYWHYNDm6mkUVKGFcyhkH+tmy+PSk5eGDjBnxS0tq/TstBw==",
|
||||||
"python/iqpilot_private/models/fetcher.cpython-312-aarch64-linux-gnu.so": "gk48ywZ8vN9RZCxpNEG7OKM1eWTJfb3qMRWX2/BfGJ5HGshPOkKN48EUmvJgpClA9AXaIRQlGppzsRdG8iOOAg==",
|
"python/iqpilot_private/models/fetcher.cpython-312-aarch64-linux-gnu.so": "BbwoBwc9jhIgfty30cSgr1mD0u633HyT9MmO7Um7/CrcF9bO5uyhE5hDHsotvHaQmwKnTBGW4+D9hLs8xNo0Bw==",
|
||||||
"python/iqpilot_private/models/git_auth.cpython-312-aarch64-linux-gnu.so": "u5H2u7uxdyRaoYvXSkfosXJZc5gLmE2H8kmwTMWquuo2gKrqbXDB6noRGgJbj7qDtzZ5Q9k9RReLTfmOqRnJBQ==",
|
"python/iqpilot_private/models/git_auth.cpython-312-aarch64-linux-gnu.so": "D4SIcunDU2v7PQ+DExObejMtufrJVn8LQDl6SwyRr9EzoemViY0dS/Ff7tTz0KwbvzpHz10ksOCYn2/3t6uxAg==",
|
||||||
"python/iqpilot_private/models/helpers.cpython-312-aarch64-linux-gnu.so": "ihqRyFfd5K62WCGstETp5xyFbkFwafUmBBobxke7NyFPtKH3Qxbr6rUvNRilfGl5NzB3aT6mpmxbbomKj1iUAw==",
|
"python/iqpilot_private/models/helpers.cpython-312-aarch64-linux-gnu.so": "g2oM0JvMIJOvhat23cMmWq8QY+jAAsrQFEFZSk9sC/Kq4aqDZZ/KthrmlLQaNclXtkATJShOxfIlPuEVFzpzAg==",
|
||||||
"python/iqpilot_private/models/manager.cpython-312-aarch64-linux-gnu.so": "xRQGJvIRdFO/RHyqpWKXuh6AWAERHVs+0tts4UiAR7s9gzQIdFc96DXtHqmE2b1zTQLKUy8K22kX7gr4JovUAw=="
|
"python/iqpilot_private/models/manager.cpython-312-aarch64-linux-gnu.so": "ZGu3WKX4AELSUXdXpPu4IF+gjzLeRqx1jdi+WGcnCJK17dKtvui9OQ0s3uF3j37DnvBMjpjxnnvwwIQjcEjoAQ=="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
ryT5nYzhX7vJdIvgT2U4dWDom4BNtUtffuIUrVZbwhtQHJVBNawplodSLlHxOvQEyd+u6+xKP81NhSoTEpMqAg==
|
rL5ip/O3aYwgySr2d0kyzVfFnyOTfAAwVbDl8lxmNJIjPkpSLsEDrgglLQ5MWjmL4s2oHeJA70f2ahx31WgVBw==
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ def config_realtime_process(cores: int | list[int], priority: int) -> None:
|
|||||||
set_core_affinity(c)
|
set_core_affinity(c)
|
||||||
|
|
||||||
|
|
||||||
|
def config_background_thread() -> None:
|
||||||
|
if sys.platform == 'linux' and not PC:
|
||||||
|
os.sched_setscheduler(0, os.SCHED_OTHER, os.sched_param(0))
|
||||||
|
set_core_affinity(list(range(os.cpu_count() or 1)))
|
||||||
|
|
||||||
|
|
||||||
def lock_memory() -> None:
|
def lock_memory() -> None:
|
||||||
"""mlockall this process so memory reclaim/compaction can't stall it. RT control
|
"""mlockall this process so memory reclaim/compaction can't stall it. RT control
|
||||||
procs only (locking ui/modeld would worsen pressure). Best-effort."""
|
procs only (locking ui/modeld would worsen pressure). Best-effort."""
|
||||||
@@ -76,6 +82,13 @@ class Ratekeeper:
|
|||||||
self.avg_dt = MovingAverage(100)
|
self.avg_dt = MovingAverage(100)
|
||||||
self.avg_dt.add_value(self._interval)
|
self.avg_dt.add_value(self._interval)
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self._remaining = 0.0
|
||||||
|
self._last_monitor_time = -1.
|
||||||
|
self._next_frame_time = -1.
|
||||||
|
self.avg_dt = MovingAverage(100)
|
||||||
|
self.avg_dt.add_value(self._interval)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def frame(self) -> int:
|
def frame(self) -> int:
|
||||||
return self._frame
|
return self._frame
|
||||||
@@ -84,6 +97,10 @@ class Ratekeeper:
|
|||||||
def remaining(self) -> float:
|
def remaining(self) -> float:
|
||||||
return self._remaining
|
return self._remaining
|
||||||
|
|
||||||
|
@property
|
||||||
|
def lag(self) -> float:
|
||||||
|
return max(0., -self._remaining)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def lagging(self) -> bool:
|
def lagging(self) -> bool:
|
||||||
expected_dt = self._interval * (1 / 0.9)
|
expected_dt = self._interval * (1 / 0.9)
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""
|
"""
|
||||||
IQ model selection and runner support that is actively used by iqmodeld.
|
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Copyright (c) IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||||
|
|
||||||
Public entry point for the model-manifest fetcher: prefers the compiled private
|
|
||||||
bundle, falling back to the in-tree source. The default-runner fallback lives in
|
|
||||||
ManifestDecoder now, so no post-import patching is needed.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from iqpilot._proprietary_loader import ProprietaryModuleMissing, load_private_module
|
from iqpilot._proprietary_loader import ProprietaryModuleMissing, load_private_module
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ _ACTIVE_BUNDLE_KEY = "ModelManager_ActiveBundle"
|
|||||||
_MODELS_CACHE_KEY = "ModelManager_ModelsCache"
|
_MODELS_CACHE_KEY = "ModelManager_ModelsCache"
|
||||||
_RUNNER_CACHE_KEY = "ModelRunnerTypeCache"
|
_RUNNER_CACHE_KEY = "ModelRunnerTypeCache"
|
||||||
_DOWNLOAD_INDEX_KEY = "ModelManager_DownloadIndex"
|
_DOWNLOAD_INDEX_KEY = "ModelManager_DownloadIndex"
|
||||||
|
_PENDING_INDEX_KEY = "ModelManager_PendingIndex"
|
||||||
_PENDING_MODEL_RESTORE_FILE = "/data/k3_pending_model_restore"
|
_PENDING_MODEL_RESTORE_FILE = "/data/k3_pending_model_restore"
|
||||||
_STOCK_RUNNER = int(Runner.stock)
|
_STOCK_RUNNER = int(Runner.stock)
|
||||||
_TINYGRAD_RUNNER = int(Runner.tinygrad)
|
_TINYGRAD_RUNNER = int(Runner.tinygrad)
|
||||||
@@ -40,7 +41,6 @@ _DEFAULT_BUNDLE_REF = "default"
|
|||||||
|
|
||||||
|
|
||||||
def get_default_model_bundle(_bundles):
|
def get_default_model_bundle(_bundles):
|
||||||
"""Legacy compatibility hook: stock default is preinstalled, not a manifest bundle."""
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -227,6 +227,7 @@ def select_default_model(params: Params = None) -> None:
|
|||||||
bundle_dict = _load_default_bundle_dict()
|
bundle_dict = _load_default_bundle_dict()
|
||||||
ensure_default_model_files(bundle_dict)
|
ensure_default_model_files(bundle_dict)
|
||||||
params.remove(_DOWNLOAD_INDEX_KEY)
|
params.remove(_DOWNLOAD_INDEX_KEY)
|
||||||
|
params.remove(_PENDING_INDEX_KEY)
|
||||||
params.put(_ACTIVE_BUNDLE_KEY, bundle_dict)
|
params.put(_ACTIVE_BUNDLE_KEY, bundle_dict)
|
||||||
params.remove(_RUNNER_CACHE_KEY)
|
params.remove(_RUNNER_CACHE_KEY)
|
||||||
params.put(_RUNNER_CACHE_KEY, _TINYGRAD_RUNNER)
|
params.put(_RUNNER_CACHE_KEY, _TINYGRAD_RUNNER)
|
||||||
@@ -239,10 +240,13 @@ def select_default_model(params: Params = None) -> None:
|
|||||||
|
|
||||||
def seed_default_bundle_if_unset(params: Params = None) -> None:
|
def seed_default_bundle_if_unset(params: Params = None) -> None:
|
||||||
params = Params() if params is None else params
|
params = Params() if params is None else params
|
||||||
if params.get(_ACTIVE_BUNDLE_KEY) or params.get(_DOWNLOAD_INDEX_KEY) is not None:
|
if params.get(_ACTIVE_BUNDLE_KEY):
|
||||||
return
|
return
|
||||||
|
queued_download = params.get(_DOWNLOAD_INDEX_KEY)
|
||||||
try:
|
try:
|
||||||
select_default_model(params)
|
select_default_model(params)
|
||||||
|
if queued_download is not None:
|
||||||
|
params.put(_DOWNLOAD_INDEX_KEY, queued_download)
|
||||||
cloudlog.warning("default_model: seeded Default (CD210) as active bundle")
|
cloudlog.warning("default_model: seeded Default (CD210) as active bundle")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
cloudlog.exception(f"default_model: failed to seed default bundle: {e}")
|
cloudlog.exception(f"default_model: failed to seed default bundle: {e}")
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ class Paths:
|
|||||||
def comma_home() -> str:
|
def comma_home() -> str:
|
||||||
return os.path.join(str(Path.home()), ".comma" + os.environ.get("OPENPILOT_PREFIX", ""))
|
return os.path.join(str(Path.home()), ".comma" + os.environ.get("OPENPILOT_PREFIX", ""))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def params() -> str:
|
||||||
|
if os.environ.get("PARAMS_ROOT"):
|
||||||
|
return os.environ["PARAMS_ROOT"]
|
||||||
|
return os.path.join(Paths.comma_home(), "params") if PC else "/data/params"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def log_root() -> str:
|
def log_root() -> str:
|
||||||
if os.environ.get('LOG_ROOT', False):
|
if os.environ.get('LOG_ROOT', False):
|
||||||
|
|||||||
@@ -515,7 +515,7 @@ class Tici(HardwareBase):
|
|||||||
try:
|
try:
|
||||||
pid = subprocess.check_output(["pgrep", "-f", "spi0"], encoding='utf8').strip()
|
pid = subprocess.check_output(["pgrep", "-f", "spi0"], encoding='utf8').strip()
|
||||||
subprocess.call(["sudo", "chrt", "-f", "-p", "1", pid])
|
subprocess.call(["sudo", "chrt", "-f", "-p", "1", pid])
|
||||||
subprocess.call(["sudo", "taskset", "-pc", "3", pid])
|
subprocess.call(["sudo", "taskset", "-pc", "3", pid], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
except subprocess.CalledProcessException as e:
|
except subprocess.CalledProcessException as e:
|
||||||
print(str(e))
|
print(str(e))
|
||||||
|
|
||||||
|
|||||||
@@ -33,8 +33,13 @@ def get_version(path: str = BASEDIR) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def get_release_notes(path: str = BASEDIR) -> str:
|
def get_release_notes(path: str = BASEDIR) -> str:
|
||||||
with open(os.path.join(path, "docs", "CHANGELOG.md")) as f:
|
for rel in (("iqpilot", "docs", "CHANGELOG.md"), ("docs", "CHANGELOG.md")):
|
||||||
|
try:
|
||||||
|
with open(os.path.join(path, *rel)) as f:
|
||||||
return f.read().split('\n\n', 1)[0]
|
return f.read().split('\n\n', 1)[0]
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
@cache
|
@cache
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
163
artifacts/package_sources/teleoprtc/.gitignore
vendored
163
artifacts/package_sources/teleoprtc/.gitignore
vendored
@@ -1,163 +0,0 @@
|
|||||||
# Byte-compiled / optimized / DLL files
|
|
||||||
__pycache__/
|
|
||||||
*.py[cod]
|
|
||||||
*$py.class
|
|
||||||
|
|
||||||
# C extensions
|
|
||||||
*.so
|
|
||||||
|
|
||||||
# Distribution / packaging
|
|
||||||
.Python
|
|
||||||
build/
|
|
||||||
develop-eggs/
|
|
||||||
dist/
|
|
||||||
downloads/
|
|
||||||
eggs/
|
|
||||||
.eggs/
|
|
||||||
lib/
|
|
||||||
lib64/
|
|
||||||
parts/
|
|
||||||
sdist/
|
|
||||||
var/
|
|
||||||
wheels/
|
|
||||||
share/python-wheels/
|
|
||||||
*.egg-info/
|
|
||||||
.installed.cfg
|
|
||||||
*.egg
|
|
||||||
MANIFEST
|
|
||||||
|
|
||||||
# PyInstaller
|
|
||||||
# Usually these files are written by a python script from a template
|
|
||||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
||||||
*.manifest
|
|
||||||
*.spec
|
|
||||||
|
|
||||||
# Installer logs
|
|
||||||
pip-log.txt
|
|
||||||
pip-delete-this-directory.txt
|
|
||||||
|
|
||||||
# Unit test / coverage reports
|
|
||||||
htmlcov/
|
|
||||||
.tox/
|
|
||||||
.nox/
|
|
||||||
.coverage
|
|
||||||
.coverage.*
|
|
||||||
.cache
|
|
||||||
nosetests.xml
|
|
||||||
coverage.xml
|
|
||||||
*.cover
|
|
||||||
*.py,cover
|
|
||||||
.hypothesis/
|
|
||||||
.pytest_cache/
|
|
||||||
cover/
|
|
||||||
|
|
||||||
# Translations
|
|
||||||
*.mo
|
|
||||||
*.pot
|
|
||||||
|
|
||||||
# Django stuff:
|
|
||||||
*.log
|
|
||||||
local_settings.py
|
|
||||||
db.sqlite3
|
|
||||||
db.sqlite3-journal
|
|
||||||
|
|
||||||
# Flask stuff:
|
|
||||||
instance/
|
|
||||||
.webassets-cache
|
|
||||||
|
|
||||||
# Scrapy stuff:
|
|
||||||
.scrapy
|
|
||||||
|
|
||||||
# Sphinx documentation
|
|
||||||
docs/_build/
|
|
||||||
|
|
||||||
# PyBuilder
|
|
||||||
.pybuilder/
|
|
||||||
target/
|
|
||||||
|
|
||||||
# Jupyter Notebook
|
|
||||||
.ipynb_checkpoints
|
|
||||||
|
|
||||||
# IPython
|
|
||||||
profile_default/
|
|
||||||
ipython_config.py
|
|
||||||
|
|
||||||
# pyenv
|
|
||||||
# For a library or package, you might want to ignore these files since the code is
|
|
||||||
# intended to run in multiple environments; otherwise, check them in:
|
|
||||||
# .python-version
|
|
||||||
|
|
||||||
# pipenv
|
|
||||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
||||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
||||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
||||||
# install all needed dependencies.
|
|
||||||
#Pipfile.lock
|
|
||||||
|
|
||||||
# poetry
|
|
||||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
||||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
||||||
# commonly ignored for libraries.
|
|
||||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
||||||
#poetry.lock
|
|
||||||
|
|
||||||
# pdm
|
|
||||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
||||||
#pdm.lock
|
|
||||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
||||||
# in version control.
|
|
||||||
# https://pdm.fming.dev/#use-with-ide
|
|
||||||
.pdm.toml
|
|
||||||
|
|
||||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
||||||
__pypackages__/
|
|
||||||
|
|
||||||
# Celery stuff
|
|
||||||
celerybeat-schedule
|
|
||||||
celerybeat.pid
|
|
||||||
|
|
||||||
# SageMath parsed files
|
|
||||||
*.sage.py
|
|
||||||
|
|
||||||
# Environments
|
|
||||||
.env
|
|
||||||
.venv
|
|
||||||
env/
|
|
||||||
venv/
|
|
||||||
ENV/
|
|
||||||
env.bak/
|
|
||||||
venv.bak/
|
|
||||||
|
|
||||||
# Spyder project settings
|
|
||||||
.spyderproject
|
|
||||||
.spyproject
|
|
||||||
|
|
||||||
# Rope project settings
|
|
||||||
.ropeproject
|
|
||||||
|
|
||||||
# mkdocs documentation
|
|
||||||
/site
|
|
||||||
|
|
||||||
# mypy
|
|
||||||
.mypy_cache/
|
|
||||||
.dmypy.json
|
|
||||||
dmypy.json
|
|
||||||
|
|
||||||
# Pyre type checker
|
|
||||||
.pyre/
|
|
||||||
|
|
||||||
# pytype static type analyzer
|
|
||||||
.pytype/
|
|
||||||
|
|
||||||
# Cython debug symbols
|
|
||||||
cython_debug/
|
|
||||||
|
|
||||||
# PyCharm
|
|
||||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
||||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
||||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
||||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
||||||
#.idea/
|
|
||||||
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
repos:
|
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
||||||
rev: v4.5.0
|
|
||||||
hooks:
|
|
||||||
- id: check-ast
|
|
||||||
- id: check-json
|
|
||||||
- id: check-xml
|
|
||||||
- id: check-yaml
|
|
||||||
- id: check-merge-conflict
|
|
||||||
- id: check-symlinks
|
|
||||||
- id: check-executables-have-shebangs
|
|
||||||
- id: check-shebang-scripts-are-executable
|
|
||||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
|
||||||
rev: v1.7.1
|
|
||||||
hooks:
|
|
||||||
- id: mypy
|
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
||||||
rev: v0.2.2
|
|
||||||
hooks:
|
|
||||||
- id: ruff
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2023, Comma.ai, Inc.
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# teleoprtc
|
|
||||||
|
|
||||||
Set of abstractions for webRTC communication with [openpilot](https://github.com/commaai/openpilot).
|
|
||||||
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
## Face detection demo
|
|
||||||
|
|
||||||
Run simple face detection model on video stream from driver camera of comma three.
|
|
||||||
|
|
||||||
This example streams video frames, runs face-detection model and displays window with live detection results (bounding boxes).
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# pass the ip address of comma three, if running remotely (by default localhost)
|
|
||||||
python3 face_detection.py [--host comma-ip-address]
|
|
||||||
```
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
import aiortc
|
|
||||||
import aiohttp
|
|
||||||
import cv2
|
|
||||||
import pygame
|
|
||||||
|
|
||||||
from teleoprtc import WebRTCOfferBuilder, StreamingOffer
|
|
||||||
|
|
||||||
|
|
||||||
def pygame_should_quit():
|
|
||||||
for event in pygame.event.get():
|
|
||||||
if event.type == pygame.QUIT:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
class WebrtcdConnectionProvider:
|
|
||||||
"""
|
|
||||||
Connection provider reaching webrtcd server on comma three
|
|
||||||
"""
|
|
||||||
def __init__(self, host, port=5001):
|
|
||||||
self.url = f"http://{host}:{port}/stream"
|
|
||||||
|
|
||||||
async def __call__(self, offer: StreamingOffer) -> aiortc.RTCSessionDescription:
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
body = {'sdp': offer.sdp, 'cameras': offer.video, 'bridge_services_in': [], 'bridge_services_out': []}
|
|
||||||
async with session.post(self.url, json=body) as resp:
|
|
||||||
payload = await resp.json()
|
|
||||||
answer = aiortc.RTCSessionDescription(**payload)
|
|
||||||
return answer
|
|
||||||
|
|
||||||
|
|
||||||
class FaceDetector:
|
|
||||||
"""
|
|
||||||
Simple face detector using opencv
|
|
||||||
"""
|
|
||||||
def __init__(self):
|
|
||||||
self.classifier = cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_default.xml')
|
|
||||||
|
|
||||||
def detect(self, array):
|
|
||||||
gray_array = cv2.cvtColor(array, cv2.COLOR_RGB2GRAY)
|
|
||||||
faces = self.classifier.detectMultiScale(gray_array, scaleFactor=1.1, minNeighbors=5, minSize=(30, 30))
|
|
||||||
return faces
|
|
||||||
|
|
||||||
def draw(self, array, faces):
|
|
||||||
for (x, y, w, h) in faces:
|
|
||||||
cv2.rectangle(array, (x, y), (x + w, y + h), (0, 255, 0), 2)
|
|
||||||
return array
|
|
||||||
|
|
||||||
|
|
||||||
async def run_face_detection(stream):
|
|
||||||
# setup pygame window
|
|
||||||
pygame.init()
|
|
||||||
screen_width, screen_height = 1280, 720
|
|
||||||
screen = pygame.display.set_mode((screen_width, screen_height))
|
|
||||||
pygame.display.set_caption("Face detection demo")
|
|
||||||
surface = pygame.Surface((screen_width, screen_height))
|
|
||||||
|
|
||||||
# get the driver camera video track from the stream
|
|
||||||
# generally its better to reuse the track object instead of getting it every time
|
|
||||||
track = stream.get_incoming_video_track("driver", buffered=False)
|
|
||||||
# cv2 face detector
|
|
||||||
detector = FaceDetector()
|
|
||||||
while stream.is_connected_and_ready and not pygame_should_quit():
|
|
||||||
try:
|
|
||||||
# receive frame as pyAV VideoFrame, convert to rgb24 numpy array
|
|
||||||
frame = await track.recv()
|
|
||||||
array = frame.to_ndarray(format="rgb24")
|
|
||||||
|
|
||||||
# detect faces and draw rects around them
|
|
||||||
resized_array = cv2.resize(array, (screen_width, screen_height))
|
|
||||||
faces = detector.detect(resized_array)
|
|
||||||
detector.draw(resized_array, faces)
|
|
||||||
|
|
||||||
# display the image
|
|
||||||
pygame.surfarray.blit_array(surface, resized_array.swapaxes(0, 1))
|
|
||||||
screen.blit(surface, (0, 0))
|
|
||||||
pygame.display.flip()
|
|
||||||
|
|
||||||
print("Received frame from", "driver", frame.time)
|
|
||||||
except aiortc.mediastreams.MediaStreamError:
|
|
||||||
break
|
|
||||||
|
|
||||||
pygame.quit()
|
|
||||||
await stream.stop()
|
|
||||||
|
|
||||||
|
|
||||||
async def run(args):
|
|
||||||
# build your own the offer stream
|
|
||||||
builder = WebRTCOfferBuilder(WebrtcdConnectionProvider(args.host))
|
|
||||||
# request video stream from drivers camera
|
|
||||||
builder.offer_to_receive_video_stream("driver")
|
|
||||||
# add cereal messaging streaming support
|
|
||||||
builder.add_messaging()
|
|
||||||
|
|
||||||
stream = builder.stream()
|
|
||||||
|
|
||||||
# start the stream then wait for connection
|
|
||||||
# server will receive the offer and attempt to fulfill it
|
|
||||||
await stream.start()
|
|
||||||
await stream.wait_for_connection()
|
|
||||||
# all the tracks and channel are ready to be used at this point
|
|
||||||
|
|
||||||
assert stream.has_incoming_video_track("driver") and stream.has_messaging_channel()
|
|
||||||
|
|
||||||
# run face detection loop on the drivers camera
|
|
||||||
await run_face_detection(stream)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__=='__main__':
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("--host", default="localhost", help="Host for webrtcd server")
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
asyncio.run(run(args))
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# VideoStream CLI
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import asyncio
|
|
||||||
import dataclasses
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import aiortc
|
|
||||||
from aiortc.mediastreams import VideoStreamTrack, AudioStreamTrack
|
|
||||||
|
|
||||||
from teleoprtc import WebRTCOfferBuilder, WebRTCAnswerBuilder
|
|
||||||
from teleoprtc.stream import StreamingOffer
|
|
||||||
from teleoprtc.info import parse_info_from_offer
|
|
||||||
|
|
||||||
|
|
||||||
async def async_input():
|
|
||||||
return await asyncio.to_thread(input)
|
|
||||||
|
|
||||||
|
|
||||||
async def StdioConnectionProvider(offer: StreamingOffer) -> aiortc.RTCSessionDescription:
|
|
||||||
print("-- Please send this JSON to server --")
|
|
||||||
print(json.dumps(dataclasses.asdict(offer)))
|
|
||||||
print("-- Press enter when the answer is ready --")
|
|
||||||
raw_payload = await async_input()
|
|
||||||
payload = json.loads(raw_payload)
|
|
||||||
answer = aiortc.RTCSessionDescription(**payload)
|
|
||||||
|
|
||||||
return answer
|
|
||||||
|
|
||||||
|
|
||||||
async def run_answer(args):
|
|
||||||
streams = []
|
|
||||||
while True:
|
|
||||||
print("-- Please enter a JSON from client --")
|
|
||||||
raw_payload = await async_input()
|
|
||||||
|
|
||||||
payload = json.loads(raw_payload)
|
|
||||||
offer = StreamingOffer(**payload)
|
|
||||||
info = parse_info_from_offer(offer.sdp)
|
|
||||||
assert len(offer.video) == info.n_expected_camera_tracks
|
|
||||||
video_tracks = [VideoStreamTrack() for _ in offer.video]
|
|
||||||
audio_tracks = [AudioStreamTrack()] if info.expected_audio_track else []
|
|
||||||
|
|
||||||
stream_builder = WebRTCAnswerBuilder(offer.sdp)
|
|
||||||
for cam, track in zip(offer.video, video_tracks, strict=True):
|
|
||||||
stream_builder.add_video_stream(cam, track)
|
|
||||||
for track in audio_tracks:
|
|
||||||
stream_builder.add_audio_stream(track)
|
|
||||||
stream = stream_builder.stream()
|
|
||||||
answer = await stream.start()
|
|
||||||
streams.append(stream)
|
|
||||||
|
|
||||||
print("-- Please send this JSON to client --")
|
|
||||||
print(json.dumps({"sdp": answer.sdp, "type": answer.type}))
|
|
||||||
|
|
||||||
await stream.wait_for_connection()
|
|
||||||
|
|
||||||
|
|
||||||
async def run_offer(args):
|
|
||||||
stream_builder = WebRTCOfferBuilder(StdioConnectionProvider)
|
|
||||||
for cam in args.cameras:
|
|
||||||
stream_builder.offer_to_receive_video_stream(cam)
|
|
||||||
if args.audio:
|
|
||||||
stream_builder.offer_to_receive_audio_stream()
|
|
||||||
if args.messaging:
|
|
||||||
stream_builder.add_messaging()
|
|
||||||
stream = stream_builder.stream()
|
|
||||||
_ = await stream.start()
|
|
||||||
await stream.wait_for_connection()
|
|
||||||
print("Connection established and all tracks are ready")
|
|
||||||
|
|
||||||
video_tracks = [stream.get_incoming_video_track(cam, False) for cam in args.cameras]
|
|
||||||
audio_track = None
|
|
||||||
if stream.has_incoming_audio_track():
|
|
||||||
audio_track = stream.get_incoming_audio_track(False)
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
frames = await asyncio.gather(*[track.recv() for track in video_tracks])
|
|
||||||
for key, frame in zip(args.cameras, frames, strict=True):
|
|
||||||
print("Received frame from", key, frame.time)
|
|
||||||
if audio_track:
|
|
||||||
frame = await audio_track.recv()
|
|
||||||
print("Received frame from audio", frame.time)
|
|
||||||
except aiortc.mediastreams.MediaStreamError:
|
|
||||||
return
|
|
||||||
print("=====================================")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
||||||
|
|
||||||
offer_parser = subparsers.add_parser("offer", description="Create offer stream")
|
|
||||||
offer_parser.add_argument("--audio", action="store_true", help="Offer to receive audio")
|
|
||||||
offer_parser.add_argument("--messaging", action="store_true", help="Add messaging support")
|
|
||||||
offer_parser.add_argument("cameras", metavar="CAMERA", type=str, nargs="+", default=[], help="Camera types to stream")
|
|
||||||
|
|
||||||
answer_parser = subparsers.add_parser("answer", description="Create answer stream")
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.CRITICAL, handlers=[logging.StreamHandler()])
|
|
||||||
logger = logging.getLogger("WebRTCStream")
|
|
||||||
logger.setLevel(logging.DEBUG)
|
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
if args.command == "offer":
|
|
||||||
loop.run_until_complete(run_offer(args))
|
|
||||||
elif args.command == "answer":
|
|
||||||
loop.run_until_complete(run_answer(args))
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
[build-system]
|
|
||||||
requires = ["setuptools"]
|
|
||||||
build-backend = "setuptools.build_meta"
|
|
||||||
|
|
||||||
[project]
|
|
||||||
name = "teleoprtc"
|
|
||||||
version = "1.0.1"
|
|
||||||
authors = [{ name="Vehicle Researcher", email="user@comma.ai" }]
|
|
||||||
description = "Comma webRTC abstractions"
|
|
||||||
readme = "README.md"
|
|
||||||
license = { file="LICENSE" }
|
|
||||||
requires-python = ">=3.8"
|
|
||||||
classifiers = [
|
|
||||||
"Programming Language :: Python :: 3",
|
|
||||||
"License :: OSI Approved :: MIT License",
|
|
||||||
"Operating System :: OS Independent",
|
|
||||||
]
|
|
||||||
dependencies = [
|
|
||||||
"aiortc>=1.6.0",
|
|
||||||
"aiohttp>=3.7.0",
|
|
||||||
"av>=11.0.0,<13.0.0",
|
|
||||||
"numpy>=1.19.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[project.optional-dependencies]
|
|
||||||
dev = [
|
|
||||||
"parameterized>=0.8",
|
|
||||||
"pre-commit",
|
|
||||||
"pytest",
|
|
||||||
"pytest-asyncio",
|
|
||||||
"pytest-xdist"
|
|
||||||
]
|
|
||||||
|
|
||||||
[project.urls]
|
|
||||||
"Homepage" = "https://github.com/commaai/teleoprtc"
|
|
||||||
"Bug Tracker" = "https://github.com/commaai/teleoprtc/issues"
|
|
||||||
|
|
||||||
# https://beta.ruff.rs/docs/configuration/#using-pyprojecttoml
|
|
||||||
[tool.ruff]
|
|
||||||
line-length = 160
|
|
||||||
target-version="py38"
|
|
||||||
|
|
||||||
[tool.ruff.lint]
|
|
||||||
select = ["E", "F", "W", "PIE", "C4", "ISC", "RUF008", "RUF100", "A", "B", "TID251"]
|
|
||||||
ignore = ["W292", "E741", "E402", "C408", "ISC003", "B027", "B024"]
|
|
||||||
flake8-implicit-str-concat.allow-multiline=false
|
|
||||||
|
|
||||||
[tool.ruff.lint.flake8-tidy-imports.banned-api]
|
|
||||||
"unittest".msg = "Use pytest"
|
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
|
||||||
addopts = "--durations=10 -n auto"
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
BRANCH="$(git branch --show-current)"
|
|
||||||
TOML_VERSION="$(python3 -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')"
|
|
||||||
LATEST_TAG_VERSION="$(git tag --list | sort -V -r | head -n 1)"
|
|
||||||
TAGGED_VERSION=""
|
|
||||||
|
|
||||||
if [[ "$BRANCH" != "master" ]]; then
|
|
||||||
echo "Not on master branch."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$TOML_VERSION" == "$LATEST_TAG_VERSION" ]]; then
|
|
||||||
TAGGED_VERSION=$(echo "$TOML_VERSION" | python3 -c "v = input().split('.'); v[-1]=str(int(v[-1])+1); print('.'.join(v))")
|
|
||||||
sed -i "s/version = \"$TOML_VERSION\"/version = \"$TAGGED_VERSION\"/" pyproject.toml
|
|
||||||
elif [[ -z "$LATEST_TAG_VERSION" ]] || printf "$LATEST_TAG_VERSION\n$TOML_VERSION" | sort -V -C; then
|
|
||||||
TAGGED_VERSION="$TOML_VERSION"
|
|
||||||
else
|
|
||||||
echo "Version in pyproject.toml is lower than the latest tag version."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Tagging $TAGGED_VERSION..."
|
|
||||||
if [[ -n "$(git ls-files -m | grep pyproject.toml)" ]]; then
|
|
||||||
echo "Commiting pyproject.toml..."
|
|
||||||
git add pyproject.toml
|
|
||||||
git commit --no-verify -m "Bump version to $TAGGED_VERSION"
|
|
||||||
fi
|
|
||||||
git tag "$TAGGED_VERSION"
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
if [[ -z "$1" ]]; then
|
|
||||||
echo "Usage: $0 <PyPI token>"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
PYPI_TOKEN="$1"
|
|
||||||
|
|
||||||
# install required packages
|
|
||||||
pip install --upgrade twine build
|
|
||||||
|
|
||||||
# build the package
|
|
||||||
python3 -m build
|
|
||||||
|
|
||||||
# upload to PyPI
|
|
||||||
REPOSITORY=""
|
|
||||||
if [[ -n "$TEST_UPLOAD" ]]; then
|
|
||||||
REPOSITORY="--repository testpypi"
|
|
||||||
fi
|
|
||||||
|
|
||||||
python3 -m twine upload $REPOSITORY --username __token__ --password "$PYPI_TOKEN" dist/*
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
from teleoprtc.builder import WebRTCOfferBuilder, WebRTCAnswerBuilder # noqa
|
|
||||||
from teleoprtc.stream import WebRTCBaseStream, StreamingOffer, ConnectionProvider, MessageHandler # noqa
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import abc
|
|
||||||
from typing import Dict, List, Optional
|
|
||||||
|
|
||||||
import aiortc
|
|
||||||
|
|
||||||
from teleoprtc.stream import WebRTCBaseStream, WebRTCOfferStream, WebRTCAnswerStream, ConnectionProvider
|
|
||||||
from teleoprtc.tracks import TiciVideoStreamTrack, TiciTrackWrapper
|
|
||||||
|
|
||||||
|
|
||||||
class WebRTCStreamBuilder(abc.ABC):
|
|
||||||
@abc.abstractmethod
|
|
||||||
def stream(self) -> WebRTCBaseStream:
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
class WebRTCOfferBuilder(WebRTCStreamBuilder):
|
|
||||||
def __init__(self, connection_provider: ConnectionProvider, ice_servers: Optional[List[dict]] = None):
|
|
||||||
self.connection_provider = connection_provider
|
|
||||||
self.ice_servers = ice_servers
|
|
||||||
self.requested_camera_types: List[str] = []
|
|
||||||
self.requested_audio = False
|
|
||||||
self.audio_tracks: List[aiortc.MediaStreamTrack] = []
|
|
||||||
self.messaging_enabled = False
|
|
||||||
|
|
||||||
def offer_to_receive_video_stream(self, camera_type: str):
|
|
||||||
assert camera_type in ["driver", "wideRoad", "road"]
|
|
||||||
self.requested_camera_types.append(camera_type)
|
|
||||||
|
|
||||||
def offer_to_receive_audio_stream(self):
|
|
||||||
self.requested_audio = True
|
|
||||||
|
|
||||||
def add_audio_stream(self, track: aiortc.MediaStreamTrack):
|
|
||||||
assert len(self.audio_tracks) == 0
|
|
||||||
self.audio_tracks = [track]
|
|
||||||
|
|
||||||
def add_messaging(self):
|
|
||||||
self.messaging_enabled = True
|
|
||||||
|
|
||||||
def stream(self) -> WebRTCBaseStream:
|
|
||||||
return WebRTCOfferStream(
|
|
||||||
self.connection_provider,
|
|
||||||
consumed_camera_types=self.requested_camera_types,
|
|
||||||
consume_audio=self.requested_audio,
|
|
||||||
video_producer_tracks=[],
|
|
||||||
audio_producer_tracks=self.audio_tracks,
|
|
||||||
should_add_data_channel=self.messaging_enabled,
|
|
||||||
ice_servers=self.ice_servers,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class WebRTCAnswerBuilder(WebRTCStreamBuilder):
|
|
||||||
def __init__(self, offer_sdp: str, ice_servers: Optional[List[dict]] = None):
|
|
||||||
self.offer_sdp = offer_sdp
|
|
||||||
self.ice_servers = ice_servers
|
|
||||||
self.video_tracks: Dict[str, aiortc.MediaStreamTrack] = dict()
|
|
||||||
self.requested_audio = False
|
|
||||||
self.audio_tracks: List[aiortc.MediaStreamTrack] = []
|
|
||||||
|
|
||||||
def offer_to_receive_audio_stream(self):
|
|
||||||
self.requested_audio = True
|
|
||||||
|
|
||||||
def add_video_stream(self, camera_type: str, track: aiortc.MediaStreamTrack):
|
|
||||||
assert camera_type not in self.video_tracks
|
|
||||||
assert camera_type in ["driver", "wideRoad", "road"]
|
|
||||||
if not isinstance(track, TiciVideoStreamTrack):
|
|
||||||
track = TiciTrackWrapper(camera_type, track)
|
|
||||||
self.video_tracks[camera_type] = track
|
|
||||||
|
|
||||||
def add_audio_stream(self, track: aiortc.MediaStreamTrack):
|
|
||||||
assert len(self.audio_tracks) == 0
|
|
||||||
self.audio_tracks = [track]
|
|
||||||
|
|
||||||
def stream(self) -> WebRTCBaseStream:
|
|
||||||
description = aiortc.RTCSessionDescription(sdp=self.offer_sdp, type="offer")
|
|
||||||
return WebRTCAnswerStream(
|
|
||||||
description,
|
|
||||||
consumed_camera_types=[],
|
|
||||||
consume_audio=self.requested_audio,
|
|
||||||
video_producer_tracks=list(self.video_tracks.values()),
|
|
||||||
audio_producer_tracks=self.audio_tracks,
|
|
||||||
should_add_data_channel=False,
|
|
||||||
ice_servers=self.ice_servers,
|
|
||||||
)
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import dataclasses
|
|
||||||
|
|
||||||
import aiortc
|
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass
|
|
||||||
class StreamingMediaInfo:
|
|
||||||
n_expected_camera_tracks: int
|
|
||||||
expected_audio_track: bool
|
|
||||||
incoming_audio_track: bool
|
|
||||||
incoming_datachannel: bool
|
|
||||||
|
|
||||||
|
|
||||||
def parse_info_from_offer(sdp: str) -> StreamingMediaInfo:
|
|
||||||
"""
|
|
||||||
helper function to parse info about outgoing and incoming streams from an offer sdp
|
|
||||||
"""
|
|
||||||
desc = aiortc.sdp.SessionDescription.parse(sdp)
|
|
||||||
audio_tracks = [m for m in desc.media if m.kind == "audio"]
|
|
||||||
video_tracks = [m for m in desc.media if m.kind == "video" and m.direction in ["recvonly", "sendrecv"]]
|
|
||||||
application_tracks = [m for m in desc.media if m.kind == "application"]
|
|
||||||
has_incoming_audio_track = next((t for t in audio_tracks if t.direction in ["sendonly", "sendrecv"]), None) is not None
|
|
||||||
has_incoming_datachannel = len(application_tracks) > 0
|
|
||||||
expects_outgoing_audio_track = next((t for t in audio_tracks if t.direction in ["recvonly", "sendrecv"]), None) is not None
|
|
||||||
|
|
||||||
return StreamingMediaInfo(len(video_tracks), expects_outgoing_audio_track, has_incoming_audio_track, has_incoming_datachannel)
|
|
||||||
@@ -1,334 +0,0 @@
|
|||||||
import abc
|
|
||||||
import asyncio
|
|
||||||
import dataclasses
|
|
||||||
import logging
|
|
||||||
from typing import Any, Awaitable, Callable, Dict, List, Optional
|
|
||||||
|
|
||||||
import aiortc
|
|
||||||
from aiortc.contrib.media import MediaRelay
|
|
||||||
|
|
||||||
from teleoprtc.tracks import parse_video_track_id
|
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass
|
|
||||||
class StreamingOffer:
|
|
||||||
sdp: str
|
|
||||||
video: List[str]
|
|
||||||
|
|
||||||
|
|
||||||
ConnectionProvider = Callable[[StreamingOffer], Awaitable[aiortc.RTCSessionDescription]]
|
|
||||||
MessageHandler = Callable[[bytes], Awaitable[None]]
|
|
||||||
|
|
||||||
|
|
||||||
def _rtc_configuration_from_ice_servers(ice_servers: Optional[List[Dict[str, Any]]]) -> Optional[aiortc.RTCConfiguration]:
|
|
||||||
if ice_servers is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
servers: List[aiortc.RTCIceServer] = []
|
|
||||||
for s in ice_servers:
|
|
||||||
if not isinstance(s, dict):
|
|
||||||
continue
|
|
||||||
urls = s.get("urls") or s.get("url")
|
|
||||||
if not urls:
|
|
||||||
continue
|
|
||||||
username = s.get("username")
|
|
||||||
credential = s.get("credential")
|
|
||||||
servers.append(aiortc.RTCIceServer(urls=urls, username=username, credential=credential))
|
|
||||||
|
|
||||||
return aiortc.RTCConfiguration(iceServers=servers)
|
|
||||||
|
|
||||||
|
|
||||||
class WebRTCBaseStream(abc.ABC):
|
|
||||||
def __init__(self,
|
|
||||||
consumed_camera_types: List[str],
|
|
||||||
consume_audio: bool,
|
|
||||||
video_producer_tracks: List[aiortc.MediaStreamTrack],
|
|
||||||
audio_producer_tracks: List[aiortc.MediaStreamTrack],
|
|
||||||
should_add_data_channel: bool,
|
|
||||||
ice_servers: Optional[List[Dict[str, Any]]] = None):
|
|
||||||
configuration = _rtc_configuration_from_ice_servers(ice_servers)
|
|
||||||
self.peer_connection = aiortc.RTCPeerConnection(configuration=configuration) if configuration else aiortc.RTCPeerConnection()
|
|
||||||
self.media_relay = MediaRelay()
|
|
||||||
self.expected_incoming_camera_types = consumed_camera_types
|
|
||||||
self.expected_incoming_audio = consume_audio
|
|
||||||
self.expected_number_of_incoming_media: Optional[int] = None
|
|
||||||
|
|
||||||
self.incoming_camera_tracks: Dict[str, aiortc.MediaStreamTrack] = dict()
|
|
||||||
self.incoming_audio_tracks: List[aiortc.MediaStreamTrack] = []
|
|
||||||
self.outgoing_video_tracks: List[aiortc.MediaStreamTrack] = video_producer_tracks
|
|
||||||
self.outgoing_audio_tracks: List[aiortc.MediaStreamTrack] = audio_producer_tracks
|
|
||||||
|
|
||||||
self.should_add_data_channel = should_add_data_channel
|
|
||||||
self.messaging_channel: Optional[aiortc.RTCDataChannel] = None
|
|
||||||
self.incoming_message_handlers: List[MessageHandler] = []
|
|
||||||
|
|
||||||
self.incoming_media_ready_event = asyncio.Event()
|
|
||||||
self.messaging_channel_ready_event = asyncio.Event()
|
|
||||||
self.connection_attempted_event = asyncio.Event()
|
|
||||||
self.connection_stopped_event = asyncio.Event()
|
|
||||||
|
|
||||||
self.peer_connection.on("connectionstatechange", self._on_connectionstatechange)
|
|
||||||
self.peer_connection.on("datachannel", self._on_incoming_datachannel)
|
|
||||||
self.peer_connection.on("track", self._on_incoming_track)
|
|
||||||
|
|
||||||
self.logger = logging.getLogger("WebRTCStream")
|
|
||||||
|
|
||||||
async def _wait_for_ice_gathering_complete(self, timeout_s: float = 5.0):
|
|
||||||
# Non-trickle ICE: wait for candidates to be included in SDP.
|
|
||||||
if self.peer_connection.iceGatheringState == "complete":
|
|
||||||
return
|
|
||||||
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
end = loop.time() + timeout_s
|
|
||||||
while self.peer_connection.iceGatheringState != "complete" and loop.time() < end:
|
|
||||||
await asyncio.sleep(0.05)
|
|
||||||
|
|
||||||
def _log_debug(self, msg: Any, *args):
|
|
||||||
self.logger.debug(f"{type(self)}() {msg}", *args)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _number_of_incoming_media(self) -> int:
|
|
||||||
media = len(self.incoming_camera_tracks) + len(self.incoming_audio_tracks)
|
|
||||||
# if stream does not add data_channel, then it means its incoming
|
|
||||||
media += int(self.messaging_channel is not None) if not self.should_add_data_channel else 0
|
|
||||||
return media
|
|
||||||
|
|
||||||
def _add_consumer_transceivers(self):
|
|
||||||
for _ in self.expected_incoming_camera_types:
|
|
||||||
self.peer_connection.addTransceiver("video", direction="recvonly")
|
|
||||||
if self.expected_incoming_audio:
|
|
||||||
self.peer_connection.addTransceiver("audio", direction="recvonly")
|
|
||||||
|
|
||||||
def _find_trackless_transceiver(self, kind: str) -> Optional[aiortc.RTCRtpTransceiver]:
|
|
||||||
transceivers = self.peer_connection.getTransceivers()
|
|
||||||
target_transceiver = None
|
|
||||||
for t in transceivers:
|
|
||||||
if t.kind == kind and t.sender.track is None:
|
|
||||||
target_transceiver = t
|
|
||||||
break
|
|
||||||
|
|
||||||
return target_transceiver
|
|
||||||
|
|
||||||
def _add_producer_tracks(self):
|
|
||||||
for track in self.outgoing_video_tracks:
|
|
||||||
target_transceiver = self._find_trackless_transceiver(track.kind)
|
|
||||||
if target_transceiver is None:
|
|
||||||
self.peer_connection.addTransceiver(track.kind, direction="sendonly")
|
|
||||||
|
|
||||||
sender = self.peer_connection.addTrack(track)
|
|
||||||
if hasattr(track, "codec_preference") and track.codec_preference() is not None:
|
|
||||||
transceiver = next(t for t in self.peer_connection.getTransceivers() if t.sender == sender)
|
|
||||||
self._force_codec(transceiver, track.codec_preference(), "video")
|
|
||||||
for track in self.outgoing_audio_tracks:
|
|
||||||
target_transceiver = self._find_trackless_transceiver(track.kind)
|
|
||||||
if target_transceiver is None:
|
|
||||||
self.peer_connection.addTransceiver(track.kind, direction="sendonly")
|
|
||||||
|
|
||||||
self.peer_connection.addTrack(track)
|
|
||||||
|
|
||||||
def _add_messaging_channel(self, channel: Optional[aiortc.RTCDataChannel] = None):
|
|
||||||
if not channel:
|
|
||||||
channel = self.peer_connection.createDataChannel("data", ordered=True)
|
|
||||||
|
|
||||||
for handler in self.incoming_message_handlers:
|
|
||||||
channel.on("message", handler)
|
|
||||||
|
|
||||||
if channel.readyState == "open":
|
|
||||||
self.messaging_channel_ready_event.set()
|
|
||||||
else:
|
|
||||||
channel.on("open", lambda: self.messaging_channel_ready_event.set())
|
|
||||||
self.messaging_channel = channel
|
|
||||||
|
|
||||||
def _force_codec(self, transceiver: aiortc.RTCRtpTransceiver, codec: str, stream_type: str):
|
|
||||||
codec_mime = f"{stream_type}/{codec.upper()}"
|
|
||||||
rtp_codecs = aiortc.RTCRtpSender.getCapabilities(stream_type).codecs
|
|
||||||
rtp_codec = [c for c in rtp_codecs if c.mimeType == codec_mime]
|
|
||||||
transceiver.setCodecPreferences(rtp_codec)
|
|
||||||
|
|
||||||
def _on_connectionstatechange(self):
|
|
||||||
self._log_debug("connection state is %s", self.peer_connection.connectionState)
|
|
||||||
if self.peer_connection.connectionState in ['connected', 'failed']:
|
|
||||||
self.connection_attempted_event.set()
|
|
||||||
if self.peer_connection.connectionState in ['disconnected', 'closed', 'failed']:
|
|
||||||
self.connection_stopped_event.set()
|
|
||||||
|
|
||||||
def _on_incoming_track(self, track: aiortc.MediaStreamTrack):
|
|
||||||
self._log_debug("got track: %s %s", track.kind, track.id)
|
|
||||||
if track.kind == "video":
|
|
||||||
camera_type, _ = parse_video_track_id(track.id)
|
|
||||||
if camera_type in self.expected_incoming_camera_types:
|
|
||||||
self.incoming_camera_tracks[camera_type] = track
|
|
||||||
elif track.kind == "audio":
|
|
||||||
if self.expected_incoming_audio:
|
|
||||||
self.incoming_audio_tracks.append(track)
|
|
||||||
self._on_after_media()
|
|
||||||
|
|
||||||
def _on_incoming_datachannel(self, channel: aiortc.RTCDataChannel):
|
|
||||||
self._log_debug("got data channel: %s", channel.label)
|
|
||||||
if channel.label == "data" and self.messaging_channel is None:
|
|
||||||
self._add_messaging_channel(channel)
|
|
||||||
self._on_after_media()
|
|
||||||
|
|
||||||
def _on_after_media(self):
|
|
||||||
if self._number_of_incoming_media == self.expected_number_of_incoming_media:
|
|
||||||
self.incoming_media_ready_event.set()
|
|
||||||
|
|
||||||
def _parse_incoming_streams(self, remote_sdp: str):
|
|
||||||
desc = aiortc.sdp.SessionDescription.parse(remote_sdp)
|
|
||||||
sending_medias = [m for m in desc.media if m.direction in ["sendonly", "sendrecv"]]
|
|
||||||
incoming_media_count = len(sending_medias)
|
|
||||||
if not self.should_add_data_channel:
|
|
||||||
channel_medias = [m for m in desc.media if m.kind == "application"]
|
|
||||||
incoming_media_count += len(channel_medias)
|
|
||||||
self.expected_number_of_incoming_media = incoming_media_count
|
|
||||||
|
|
||||||
def has_incoming_video_track(self, camera_type: str) -> bool:
|
|
||||||
return camera_type in self.incoming_camera_tracks
|
|
||||||
|
|
||||||
def has_incoming_audio_track(self) -> bool:
|
|
||||||
return len(self.incoming_audio_tracks) > 0
|
|
||||||
|
|
||||||
def has_messaging_channel(self) -> bool:
|
|
||||||
return self.messaging_channel is not None
|
|
||||||
|
|
||||||
def get_incoming_video_track(self, camera_type: str, buffered: bool = False) -> aiortc.MediaStreamTrack:
|
|
||||||
assert camera_type in self.incoming_camera_tracks, "Video tracks are not enabled on this stream"
|
|
||||||
assert self.is_started, "Stream must be started"
|
|
||||||
|
|
||||||
track = self.incoming_camera_tracks[camera_type]
|
|
||||||
relay_track = self.media_relay.subscribe(track, buffered=buffered)
|
|
||||||
return relay_track
|
|
||||||
|
|
||||||
def get_incoming_audio_track(self, buffered: bool = False) -> aiortc.MediaStreamTrack:
|
|
||||||
assert len(self.incoming_audio_tracks) > 0, "Audio tracks are not enabled on this stream"
|
|
||||||
assert self.is_started, "Stream must be started"
|
|
||||||
|
|
||||||
track = self.incoming_audio_tracks[0]
|
|
||||||
relay_track = self.media_relay.subscribe(track, buffered=buffered)
|
|
||||||
return relay_track
|
|
||||||
|
|
||||||
def get_messaging_channel(self) -> aiortc.RTCDataChannel:
|
|
||||||
assert self.messaging_channel is not None, "Messaging channel is not enabled on this stream"
|
|
||||||
assert self.is_started, "Stream must be started"
|
|
||||||
|
|
||||||
return self.messaging_channel
|
|
||||||
|
|
||||||
def set_message_handler(self, message_handler: MessageHandler):
|
|
||||||
self.incoming_message_handlers.append(message_handler)
|
|
||||||
if self.messaging_channel is not None:
|
|
||||||
self.messaging_channel.on("message", message_handler)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_started(self) -> bool:
|
|
||||||
return self.peer_connection is not None and \
|
|
||||||
self.peer_connection.localDescription is not None and \
|
|
||||||
self.peer_connection.remoteDescription is not None and \
|
|
||||||
self.peer_connection.connectionState != "closed"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_connected_and_ready(self) -> bool:
|
|
||||||
return self.peer_connection is not None and \
|
|
||||||
self.peer_connection.connectionState == "connected" and \
|
|
||||||
(self.expected_number_of_incoming_media == 0 or self.incoming_media_ready_event.is_set())
|
|
||||||
|
|
||||||
async def wait_for_connection(self):
|
|
||||||
assert self.is_started
|
|
||||||
await self.connection_attempted_event.wait()
|
|
||||||
if self.peer_connection.connectionState != 'connected':
|
|
||||||
raise ValueError("Connection failed.")
|
|
||||||
if self.expected_number_of_incoming_media:
|
|
||||||
await self.incoming_media_ready_event.wait()
|
|
||||||
if self.messaging_channel is not None:
|
|
||||||
await self.messaging_channel_ready_event.wait()
|
|
||||||
|
|
||||||
async def wait_for_disconnection(self):
|
|
||||||
assert self.is_connected_and_ready, "Stream is not connected/ready yet (make sure wait_for_connection was awaited)"
|
|
||||||
await self.connection_stopped_event.wait()
|
|
||||||
|
|
||||||
async def stop(self):
|
|
||||||
await self.peer_connection.close()
|
|
||||||
|
|
||||||
@abc.abstractmethod
|
|
||||||
async def start(self) -> aiortc.RTCSessionDescription:
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
class WebRTCOfferStream(WebRTCBaseStream):
|
|
||||||
def __init__(self, session_provider: ConnectionProvider, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self.session_provider = session_provider
|
|
||||||
|
|
||||||
async def start(self) -> aiortc.RTCSessionDescription:
|
|
||||||
self._add_consumer_transceivers()
|
|
||||||
if self.should_add_data_channel:
|
|
||||||
self._add_messaging_channel()
|
|
||||||
self._add_producer_tracks()
|
|
||||||
|
|
||||||
offer = await self.peer_connection.createOffer()
|
|
||||||
await self.peer_connection.setLocalDescription(offer)
|
|
||||||
await self._wait_for_ice_gathering_complete()
|
|
||||||
actual_offer = self.peer_connection.localDescription
|
|
||||||
|
|
||||||
streaming_offer = StreamingOffer(
|
|
||||||
sdp=actual_offer.sdp,
|
|
||||||
video=list(self.expected_incoming_camera_types),
|
|
||||||
)
|
|
||||||
remote_answer = await self.session_provider(streaming_offer)
|
|
||||||
self._parse_incoming_streams(remote_sdp=remote_answer.sdp)
|
|
||||||
await self.peer_connection.setRemoteDescription(remote_answer)
|
|
||||||
actual_answer = self.peer_connection.remoteDescription
|
|
||||||
|
|
||||||
return actual_answer
|
|
||||||
|
|
||||||
|
|
||||||
class WebRTCAnswerStream(WebRTCBaseStream):
|
|
||||||
def __init__(self, session: aiortc.RTCSessionDescription, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self.session = session
|
|
||||||
|
|
||||||
def _probe_video_codecs(self) -> List[str]:
|
|
||||||
codecs = []
|
|
||||||
for track in self.outgoing_video_tracks:
|
|
||||||
if hasattr(track, "codec_preference") and track.codec_preference() is not None:
|
|
||||||
codecs.append(track.codec_preference())
|
|
||||||
|
|
||||||
return codecs
|
|
||||||
|
|
||||||
def _override_incoming_video_codecs(self, remote_sdp: str, codecs: List[str]) -> str:
|
|
||||||
desc = aiortc.sdp.SessionDescription.parse(remote_sdp)
|
|
||||||
codec_mimes = [f"video/{c}" for c in codecs]
|
|
||||||
for m in desc.media:
|
|
||||||
if m.kind != "video":
|
|
||||||
continue
|
|
||||||
|
|
||||||
preferred_codecs: List[aiortc.RTCRtpCodecParameters] = [c for c in m.rtp.codecs if c.mimeType in codec_mimes]
|
|
||||||
if len(preferred_codecs) == 0:
|
|
||||||
raise ValueError(f"None of {preferred_codecs} codecs is supported in remote SDP")
|
|
||||||
|
|
||||||
m.rtp.codecs = preferred_codecs
|
|
||||||
m.fmt = [c.payloadType for c in preferred_codecs]
|
|
||||||
|
|
||||||
return str(desc)
|
|
||||||
|
|
||||||
async def start(self) -> aiortc.RTCSessionDescription:
|
|
||||||
assert self.peer_connection.remoteDescription is None, "Connection already established"
|
|
||||||
|
|
||||||
self._add_consumer_transceivers()
|
|
||||||
|
|
||||||
# since we sent already encoded frames in some cases (e.g. livestream video tracks are in H264), we need to force aiortc to actually use it
|
|
||||||
# we do that by overriding supported codec information on incoming sdp
|
|
||||||
preferred_codecs = self._probe_video_codecs()
|
|
||||||
if len(preferred_codecs) > 0:
|
|
||||||
self.session.sdp = self._override_incoming_video_codecs(self.session.sdp, preferred_codecs)
|
|
||||||
|
|
||||||
self._parse_incoming_streams(remote_sdp=self.session.sdp)
|
|
||||||
await self.peer_connection.setRemoteDescription(self.session)
|
|
||||||
|
|
||||||
self._add_producer_tracks()
|
|
||||||
|
|
||||||
answer = await self.peer_connection.createAnswer()
|
|
||||||
await self.peer_connection.setLocalDescription(answer)
|
|
||||||
await self._wait_for_ice_gathering_complete()
|
|
||||||
actual_answer = self.peer_connection.localDescription
|
|
||||||
|
|
||||||
return actual_answer
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
import fractions
|
|
||||||
from typing import Any, Optional, Tuple
|
|
||||||
|
|
||||||
import aiortc
|
|
||||||
from aiortc.mediastreams import VIDEO_CLOCK_RATE, VIDEO_TIME_BASE
|
|
||||||
|
|
||||||
|
|
||||||
def video_track_id(camera_type: str, track_id: str) -> str:
|
|
||||||
return f"{camera_type}:{track_id}"
|
|
||||||
|
|
||||||
|
|
||||||
def parse_video_track_id(track_id: str) -> Tuple[str, str]:
|
|
||||||
parts = track_id.split(":")
|
|
||||||
if len(parts) != 2:
|
|
||||||
raise ValueError(f"Invalid video track id: {track_id}")
|
|
||||||
|
|
||||||
camera_type, track_id = parts
|
|
||||||
return camera_type, track_id
|
|
||||||
|
|
||||||
|
|
||||||
class TiciVideoStreamTrack(aiortc.MediaStreamTrack):
|
|
||||||
"""
|
|
||||||
Abstract video track which associates video track with camera_type
|
|
||||||
"""
|
|
||||||
kind = "video"
|
|
||||||
|
|
||||||
def __init__(self, camera_type: str, dt: float, time_base: fractions.Fraction = VIDEO_TIME_BASE, clock_rate: int = VIDEO_CLOCK_RATE):
|
|
||||||
assert camera_type in ["driver", "wideRoad", "road"]
|
|
||||||
super().__init__()
|
|
||||||
# override track id to include camera type - client needs that for identification
|
|
||||||
self._id: str = video_track_id(camera_type, self._id)
|
|
||||||
self._dt: float = dt
|
|
||||||
self._time_base: fractions.Fraction = time_base
|
|
||||||
self._clock_rate: int = clock_rate
|
|
||||||
self._start: Optional[float] = None
|
|
||||||
self._logger = logging.getLogger("WebRTCStream")
|
|
||||||
|
|
||||||
def log_debug(self, msg: Any, *args):
|
|
||||||
self._logger.debug(f"{type(self)}() {msg}", *args)
|
|
||||||
|
|
||||||
async def next_pts(self, current_pts) -> float:
|
|
||||||
pts: float = current_pts + self._dt * self._clock_rate
|
|
||||||
|
|
||||||
data_time = pts * self._time_base
|
|
||||||
if self._start is None:
|
|
||||||
self._start = time.time() - data_time
|
|
||||||
else:
|
|
||||||
wait_time = self._start + data_time - time.time()
|
|
||||||
await asyncio.sleep(wait_time)
|
|
||||||
|
|
||||||
return pts
|
|
||||||
|
|
||||||
def codec_preference(self) -> Optional[str]:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class TiciTrackWrapper(aiortc.MediaStreamTrack):
|
|
||||||
"""
|
|
||||||
Associates video track with camera_type
|
|
||||||
"""
|
|
||||||
def __init__(self, camera_type: str, track: aiortc.MediaStreamTrack):
|
|
||||||
assert track.kind == "video"
|
|
||||||
assert not isinstance(track, TiciVideoStreamTrack)
|
|
||||||
super().__init__()
|
|
||||||
self._id = video_track_id(camera_type, track.id)
|
|
||||||
self._track = track
|
|
||||||
|
|
||||||
@property
|
|
||||||
def kind(self) -> str:
|
|
||||||
return self._track.kind
|
|
||||||
|
|
||||||
async def recv(self):
|
|
||||||
return await self._track.recv()
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
from teleoprtc.info import parse_info_from_offer
|
|
||||||
|
|
||||||
|
|
||||||
def lf2crlf(x):
|
|
||||||
return x.replace("\n", "\r\n")
|
|
||||||
|
|
||||||
|
|
||||||
class TestStream:
|
|
||||||
def test_double_video_tracks(self):
|
|
||||||
sdp = """v=0
|
|
||||||
o=- 3910210993 3910210993 IN IP4 0.0.0.0
|
|
||||||
s=-
|
|
||||||
t=0 0
|
|
||||||
a=group:BUNDLE 0 1
|
|
||||||
a=msid-semantic:WMS *
|
|
||||||
m=video 9 UDP/TLS/RTP/SAVPF 97 98 99 100 101 102
|
|
||||||
c=IN IP4 0.0.0.0
|
|
||||||
a=recvonly
|
|
||||||
a=extmap:1 urn:ietf:params:rtp-hdrext:sdes:mid
|
|
||||||
a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time
|
|
||||||
a=mid:0
|
|
||||||
a=msid:e123f852-010c-4b7b-8761-71b72fbfd013 2b75cb0e-6b34-48d6-8bf9-21b809f2e08e
|
|
||||||
a=rtcp:9 IN IP4 0.0.0.0
|
|
||||||
a=rtcp-mux
|
|
||||||
a=ssrc-group:FID 1048118556 4149054509
|
|
||||||
a=ssrc:1048118556 cname:61992fce-bab5-42a0-ab8c-7112adfb1857
|
|
||||||
a=ssrc:4149054509 cname:61992fce-bab5-42a0-ab8c-7112adfb1857
|
|
||||||
a=rtpmap:97 VP8/90000
|
|
||||||
a=rtcp-fb:97 nack
|
|
||||||
a=rtcp-fb:97 nack pli
|
|
||||||
a=rtcp-fb:97 goog-remb
|
|
||||||
a=rtpmap:98 rtx/90000
|
|
||||||
a=fmtp:98 apt=97
|
|
||||||
a=rtpmap:99 H264/90000
|
|
||||||
a=rtcp-fb:99 nack
|
|
||||||
a=rtcp-fb:99 nack pli
|
|
||||||
a=rtcp-fb:99 goog-remb
|
|
||||||
a=fmtp:99 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f
|
|
||||||
a=rtpmap:100 rtx/90000
|
|
||||||
a=fmtp:100 apt=99
|
|
||||||
a=rtpmap:101 H264/90000
|
|
||||||
a=rtcp-fb:101 nack
|
|
||||||
a=rtcp-fb:101 nack pli
|
|
||||||
a=rtcp-fb:101 goog-remb
|
|
||||||
a=fmtp:101 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f
|
|
||||||
a=rtpmap:102 rtx/90000
|
|
||||||
a=fmtp:102 apt=101
|
|
||||||
a=ice-ufrag:jxQW
|
|
||||||
a=ice-pwd:KpJ0tfaY2RxnIYpTHqPSSv
|
|
||||||
a=fingerprint:sha-256 70:3A:2D:37:3C:52:96:0E:10:F6:4D:7A:EB:18:38:1B:FD:CA:A5:90:D7:6C:DA:A9:39:76:C9:2F:FB:FF:56:0C
|
|
||||||
a=setup:actpass
|
|
||||||
m=video 9 UDP/TLS/RTP/SAVPF 97 98 99 100 101 102
|
|
||||||
c=IN IP4 0.0.0.0
|
|
||||||
a=recvonly
|
|
||||||
a=extmap:1 urn:ietf:params:rtp-hdrext:sdes:mid
|
|
||||||
a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time
|
|
||||||
a=mid:1
|
|
||||||
a=msid:e123f852-010c-4b7b-8761-71b72fbfd013 311db759-8d51-479c-a5b4-5c8d055c43ec
|
|
||||||
a=rtcp:9 IN IP4 0.0.0.0
|
|
||||||
a=rtcp-mux
|
|
||||||
a=ssrc-group:FID 4096183284 2713379498
|
|
||||||
a=ssrc:4096183284 cname:61992fce-bab5-42a0-ab8c-7112adfb1857
|
|
||||||
a=ssrc:2713379498 cname:61992fce-bab5-42a0-ab8c-7112adfb1857
|
|
||||||
a=rtpmap:97 VP8/90000
|
|
||||||
a=rtcp-fb:97 nack
|
|
||||||
a=rtcp-fb:97 nack pli
|
|
||||||
a=rtcp-fb:97 goog-remb
|
|
||||||
a=rtpmap:98 rtx/90000
|
|
||||||
a=fmtp:98 apt=97
|
|
||||||
a=rtpmap:99 H264/90000
|
|
||||||
a=rtcp-fb:99 nack
|
|
||||||
a=rtcp-fb:99 nack pli
|
|
||||||
a=rtcp-fb:99 goog-remb
|
|
||||||
a=fmtp:99 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f
|
|
||||||
a=rtpmap:100 rtx/90000
|
|
||||||
a=fmtp:100 apt=99
|
|
||||||
a=rtpmap:101 H264/90000
|
|
||||||
a=rtcp-fb:101 nack
|
|
||||||
a=rtcp-fb:101 nack pli
|
|
||||||
a=rtcp-fb:101 goog-remb
|
|
||||||
a=fmtp:101 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f
|
|
||||||
a=rtpmap:102 rtx/90000
|
|
||||||
a=fmtp:102 apt=101
|
|
||||||
a=ice-ufrag:1234
|
|
||||||
a=ice-pwd:1234
|
|
||||||
a=fingerprint:sha-256 70:3A:2D:37:3C:52:96:0E:10:F6:4D:7A:EB:18:38:1B:FD:CA:A5:90:D7:6C:DA:A9:39:76:C9:2F:FB:FF:56:0C
|
|
||||||
a=setup:actpass"""
|
|
||||||
info = parse_info_from_offer(lf2crlf(sdp))
|
|
||||||
assert info.n_expected_camera_tracks == 2
|
|
||||||
assert not info.expected_audio_track
|
|
||||||
assert not info.incoming_audio_track
|
|
||||||
assert not info.incoming_datachannel
|
|
||||||
|
|
||||||
def test_recvonly_audio(self):
|
|
||||||
sdp = """v=0
|
|
||||||
o=- 3910210904 3910210904 IN IP4 0.0.0.0
|
|
||||||
s=-
|
|
||||||
t=0 0
|
|
||||||
a=group:BUNDLE 0
|
|
||||||
a=msid-semantic:WMS *
|
|
||||||
m=audio 9 UDP/TLS/RTP/SAVPF 96 0 8
|
|
||||||
c=IN IP4 0.0.0.0
|
|
||||||
a=recvonly
|
|
||||||
a=extmap:1 urn:ietf:params:rtp-hdrext:sdes:mid
|
|
||||||
a=extmap:2 urn:ietf:params:rtp-hdrext:ssrc-audio-level
|
|
||||||
a=mid:0
|
|
||||||
a=msid:eb1d3f1a-569a-465f-b419-319477bfded6 e44eecb2-1a04-4547-97d8-481389f50d5b
|
|
||||||
a=rtcp:9 IN IP4 0.0.0.0
|
|
||||||
a=rtcp-mux
|
|
||||||
a=ssrc:1233332626 cname:ca4dede8-4994-4a6d-9ae3-923b28177ca5
|
|
||||||
a=rtpmap:96 opus/48000/2
|
|
||||||
a=rtpmap:0 PCMU/8000
|
|
||||||
a=rtpmap:8 PCMA/8000
|
|
||||||
a=ice-ufrag:1234
|
|
||||||
a=ice-pwd:1234
|
|
||||||
a=fingerprint:sha-256 40:4B:14:CF:70:B8:67:E1:B1:FF:7E:F9:22:6E:60:7D:73:B5:1E:38:4B:10:20:9C:CD:1C:47:02:52:ED:45:25
|
|
||||||
a=setup:actpass"""
|
|
||||||
info = parse_info_from_offer(lf2crlf(sdp))
|
|
||||||
assert info.n_expected_camera_tracks == 0
|
|
||||||
assert info.expected_audio_track
|
|
||||||
assert not info.incoming_audio_track
|
|
||||||
assert not info.incoming_datachannel
|
|
||||||
|
|
||||||
def test_incoming_datachanel(self):
|
|
||||||
sdp = """v=0
|
|
||||||
o=- 3910211092 3910211092 IN IP4 0.0.0.0
|
|
||||||
s=-
|
|
||||||
t=0 0
|
|
||||||
a=group:BUNDLE 0
|
|
||||||
a=msid-semantic:WMS *
|
|
||||||
m=application 9 DTLS/SCTP 5000
|
|
||||||
c=IN IP4 0.0.0.0
|
|
||||||
a=mid:0
|
|
||||||
a=sctpmap:5000 webrtc-datachannel 65535
|
|
||||||
a=max-message-size:65536
|
|
||||||
a=ice-ufrag:1234
|
|
||||||
a=ice-pwd:1234
|
|
||||||
a=fingerprint:sha-256 9B:C0:F3:35:8E:05:A1:15:DB:F8:39:0E:B0:E0:0C:EB:82:E4:B9:26:18:A6:43:2D:B9:9A:23:96:0A:59:B6:58
|
|
||||||
a=setup:actpass"""
|
|
||||||
info = parse_info_from_offer(lf2crlf(sdp))
|
|
||||||
assert info.n_expected_camera_tracks == 0
|
|
||||||
assert not info.expected_audio_track
|
|
||||||
assert not info.incoming_audio_track
|
|
||||||
assert info.incoming_datachannel
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import asyncio
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from aiortc.mediastreams import AudioStreamTrack, VideoStreamTrack
|
|
||||||
from teleoprtc.builder import WebRTCOfferBuilder, WebRTCAnswerBuilder
|
|
||||||
from teleoprtc.stream import StreamingOffer
|
|
||||||
from teleoprtc.info import parse_info_from_offer
|
|
||||||
|
|
||||||
|
|
||||||
if sys.version_info >= (3, 11):
|
|
||||||
timeout = asyncio.timeout
|
|
||||||
else:
|
|
||||||
class Timeout:
|
|
||||||
def __init__(self, delay: float):
|
|
||||||
self._delay = delay
|
|
||||||
self._task = None
|
|
||||||
self._timeout_handle = None
|
|
||||||
|
|
||||||
def _timeout(self):
|
|
||||||
if self._task:
|
|
||||||
self._task.cancel()
|
|
||||||
|
|
||||||
async def __aenter__(self):
|
|
||||||
self._task = asyncio.current_task()
|
|
||||||
loop = asyncio.events.get_running_loop()
|
|
||||||
self._timeout_handle = loop.call_later(self._delay, self._timeout)
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, exc_type, exc, tb):
|
|
||||||
if self._timeout_handle:
|
|
||||||
self._timeout_handle.cancel()
|
|
||||||
if exc_type is asyncio.CancelledError and self._task and self._task.cancelled():
|
|
||||||
raise asyncio.TimeoutError from exc
|
|
||||||
return False
|
|
||||||
|
|
||||||
def timeout(delay):
|
|
||||||
return Timeout(delay)
|
|
||||||
|
|
||||||
|
|
||||||
class SimpleAnswerProvider:
|
|
||||||
def __init__(self):
|
|
||||||
self.stream = None
|
|
||||||
|
|
||||||
async def __call__(self, offer: StreamingOffer):
|
|
||||||
assert self.stream is None, "This may only be called once"
|
|
||||||
|
|
||||||
info = parse_info_from_offer(offer.sdp)
|
|
||||||
|
|
||||||
builder = WebRTCAnswerBuilder(offer.sdp, ice_servers=[])
|
|
||||||
for cam in offer.video:
|
|
||||||
builder.add_video_stream(cam, VideoStreamTrack())
|
|
||||||
if info.expected_audio_track:
|
|
||||||
builder.add_audio_stream(AudioStreamTrack())
|
|
||||||
if info.incoming_audio_track:
|
|
||||||
builder.offer_to_receive_audio_stream()
|
|
||||||
|
|
||||||
self.stream = builder.stream()
|
|
||||||
answer = await self.stream.start()
|
|
||||||
|
|
||||||
return answer
|
|
||||||
|
|
||||||
|
|
||||||
class TestStreamIntegration:
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.parametrize("name,cameras,recv_audio,add_messaging", [
|
|
||||||
("multi_camera", ["driver", "wideRoad", "road"], False, False),
|
|
||||||
("camera_and_audio", ["driver"], True, False),
|
|
||||||
("camera_and__messaging", ["driver"], False, True),
|
|
||||||
("camera_and_audio_and_messaging", ["driver", "wideRoad", "road"], True, True),
|
|
||||||
])
|
|
||||||
async def test_multi_camera(self, name, cameras, recv_audio, add_messaging):
|
|
||||||
simple_answerer = SimpleAnswerProvider()
|
|
||||||
offer_builder = WebRTCOfferBuilder(simple_answerer, ice_servers=[])
|
|
||||||
for cam in cameras:
|
|
||||||
offer_builder.offer_to_receive_video_stream(cam)
|
|
||||||
if recv_audio:
|
|
||||||
offer_builder.offer_to_receive_audio_stream()
|
|
||||||
if add_messaging:
|
|
||||||
offer_builder.add_messaging()
|
|
||||||
stream = offer_builder.stream()
|
|
||||||
|
|
||||||
try:
|
|
||||||
await stream.start()
|
|
||||||
assert stream.is_started
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with timeout(2):
|
|
||||||
await stream.wait_for_connection()
|
|
||||||
except TimeoutError:
|
|
||||||
pytest.fail("Timed out waiting for connection")
|
|
||||||
assert stream.is_connected_and_ready
|
|
||||||
|
|
||||||
assert stream.has_messaging_channel() == add_messaging
|
|
||||||
if stream.has_messaging_channel():
|
|
||||||
channel = stream.get_messaging_channel()
|
|
||||||
assert channel is not None
|
|
||||||
assert channel.readyState == "open"
|
|
||||||
|
|
||||||
assert stream.has_incoming_audio_track() == recv_audio
|
|
||||||
if stream.has_incoming_audio_track():
|
|
||||||
track = stream.get_incoming_audio_track(False)
|
|
||||||
assert track is not None
|
|
||||||
assert track.readyState == "live"
|
|
||||||
assert track.kind == "audio"
|
|
||||||
try:
|
|
||||||
async with timeout(1):
|
|
||||||
await track.recv()
|
|
||||||
except TimeoutError:
|
|
||||||
pytest.fail("Timed out waiting for audio frame")
|
|
||||||
|
|
||||||
for cam in cameras:
|
|
||||||
assert stream.has_incoming_video_track(cam)
|
|
||||||
if stream.has_incoming_video_track(cam):
|
|
||||||
track = stream.get_incoming_video_track(cam, False)
|
|
||||||
assert track is not None
|
|
||||||
assert track.readyState == "live"
|
|
||||||
assert track.kind == "video"
|
|
||||||
try:
|
|
||||||
async with timeout(1):
|
|
||||||
await track.recv()
|
|
||||||
except TimeoutError:
|
|
||||||
pytest.fail("Timed out waiting for video frame")
|
|
||||||
finally:
|
|
||||||
await stream.stop()
|
|
||||||
if simple_answerer.stream is not None:
|
|
||||||
await simple_answerer.stream.stop()
|
|
||||||
assert not stream.is_started
|
|
||||||
assert not stream.is_connected_and_ready
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
import aiortc
|
|
||||||
from aiortc.mediastreams import AudioStreamTrack
|
|
||||||
|
|
||||||
from teleoprtc.builder import WebRTCOfferBuilder, WebRTCAnswerBuilder
|
|
||||||
from teleoprtc.info import parse_info_from_offer
|
|
||||||
from teleoprtc.stream import _rtc_configuration_from_ice_servers
|
|
||||||
from teleoprtc.tracks import TiciVideoStreamTrack
|
|
||||||
|
|
||||||
|
|
||||||
class OfferCapture:
|
|
||||||
def __init__(self):
|
|
||||||
self.offer = None
|
|
||||||
|
|
||||||
async def __call__(self, offer):
|
|
||||||
self.offer = offer
|
|
||||||
raise Exception("Offer captured")
|
|
||||||
|
|
||||||
|
|
||||||
class DummyH264VideoStreamTrack(TiciVideoStreamTrack):
|
|
||||||
kind = "video"
|
|
||||||
|
|
||||||
async def recv(self):
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
def codec_preference(self):
|
|
||||||
return "H264"
|
|
||||||
|
|
||||||
|
|
||||||
def test_explicit_empty_ice_servers_disable_defaults():
|
|
||||||
assert _rtc_configuration_from_ice_servers(None) is None
|
|
||||||
assert _rtc_configuration_from_ice_servers([]).iceServers == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
class TestOfferStream:
|
|
||||||
async def test_offer_stream_sdp_recvonly_audio(self):
|
|
||||||
capture = OfferCapture()
|
|
||||||
builder = WebRTCOfferBuilder(capture, ice_servers=[])
|
|
||||||
builder.offer_to_receive_audio_stream()
|
|
||||||
stream = builder.stream()
|
|
||||||
|
|
||||||
try:
|
|
||||||
with pytest.raises(Exception, match="Offer captured"):
|
|
||||||
await stream.start()
|
|
||||||
finally:
|
|
||||||
await stream.stop()
|
|
||||||
|
|
||||||
info = parse_info_from_offer(capture.offer.sdp)
|
|
||||||
assert info.expected_audio_track
|
|
||||||
assert not info.incoming_audio_track
|
|
||||||
|
|
||||||
async def test_offer_stream_sdp_sendonly_audio(self):
|
|
||||||
capture = OfferCapture()
|
|
||||||
builder = WebRTCOfferBuilder(capture, ice_servers=[])
|
|
||||||
builder.add_audio_stream(AudioStreamTrack())
|
|
||||||
stream = builder.stream()
|
|
||||||
|
|
||||||
try:
|
|
||||||
with pytest.raises(Exception, match="Offer captured"):
|
|
||||||
await stream.start()
|
|
||||||
finally:
|
|
||||||
await stream.stop()
|
|
||||||
|
|
||||||
info = parse_info_from_offer(capture.offer.sdp)
|
|
||||||
assert not info.expected_audio_track
|
|
||||||
assert info.incoming_audio_track
|
|
||||||
|
|
||||||
async def test_offer_stream_sdp_channel(self):
|
|
||||||
capture = OfferCapture()
|
|
||||||
builder = WebRTCOfferBuilder(capture, ice_servers=[])
|
|
||||||
builder.add_messaging()
|
|
||||||
stream = builder.stream()
|
|
||||||
|
|
||||||
try:
|
|
||||||
with pytest.raises(Exception, match="Offer captured"):
|
|
||||||
await stream.start()
|
|
||||||
finally:
|
|
||||||
await stream.stop()
|
|
||||||
|
|
||||||
info = parse_info_from_offer(capture.offer.sdp)
|
|
||||||
assert info.incoming_datachannel
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
class TestAnswerStream:
|
|
||||||
async def test_codec_preference(self):
|
|
||||||
offer_sdp = """v=0
|
|
||||||
o=- 3910274679 3910274679 IN IP4 0.0.0.0
|
|
||||||
s=-
|
|
||||||
t=0 0
|
|
||||||
a=group:BUNDLE 0
|
|
||||||
a=msid-semantic:WMS *
|
|
||||||
m=video 1337 UDP/TLS/RTP/SAVPF 97 98 99 100 101 102
|
|
||||||
c=IN IP4 0.0.0.0
|
|
||||||
a=recvonly
|
|
||||||
a=mid:0
|
|
||||||
a=msid:34803878-98f8-4245-b45c-f773e5f926df 881dbc20-356a-499c-b4e8-695303bb901d
|
|
||||||
a=rtcp:9 IN IP4 0.0.0.0
|
|
||||||
a=rtcp-mux
|
|
||||||
a=ssrc-group:FID 1303546896 3784011659
|
|
||||||
a=ssrc:1303546896 cname:a59185ac-c115-48d3-b39b-db7d615a6966
|
|
||||||
a=ssrc:3784011659 cname:a59185ac-c115-48d3-b39b-db7d615a6966
|
|
||||||
a=rtpmap:97 VP8/90000
|
|
||||||
a=rtcp-fb:97 nack
|
|
||||||
a=rtcp-fb:97 nack pli
|
|
||||||
a=rtcp-fb:97 goog-remb
|
|
||||||
a=rtpmap:99 H264/90000
|
|
||||||
a=rtcp-fb:99 nack
|
|
||||||
a=rtcp-fb:99 nack pli
|
|
||||||
a=rtcp-fb:99 goog-remb
|
|
||||||
a=fmtp:99 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f
|
|
||||||
a=ice-ufrag:1234
|
|
||||||
a=ice-pwd:1234
|
|
||||||
a=fingerprint:sha-256 15:F3:F0:23:67:44:EE:2C:AA:8C:D9:50:95:26:42:7C:67:EA:1F:D2:92:C5:97:01:7B:2E:57:C9:A3:13:00:4A
|
|
||||||
a=setup:actpass"""
|
|
||||||
|
|
||||||
builder = WebRTCAnswerBuilder(offer_sdp, ice_servers=[])
|
|
||||||
builder.add_video_stream("road", DummyH264VideoStreamTrack("road", 0.05))
|
|
||||||
stream = builder.stream()
|
|
||||||
try:
|
|
||||||
answer = await stream.start()
|
|
||||||
sdp_desc = aiortc.sdp.SessionDescription.parse(answer.sdp)
|
|
||||||
video_desc = [m for m in sdp_desc.media if m.kind == "video"][0]
|
|
||||||
codecs = video_desc.rtp.codecs
|
|
||||||
assert codecs[0].mimeType == "video/H264"
|
|
||||||
finally:
|
|
||||||
await stream.stop()
|
|
||||||
|
|
||||||
async def test_fail_if_preferred_codec_not_in_offer(self):
|
|
||||||
offer_sdp = """v=0
|
|
||||||
o=- 3910274679 3910274679 IN IP4 0.0.0.0
|
|
||||||
s=-
|
|
||||||
t=0 0
|
|
||||||
a=group:BUNDLE 0
|
|
||||||
a=msid-semantic:WMS *
|
|
||||||
m=video 1337 UDP/TLS/RTP/SAVPF 97 98 99 100 101 102
|
|
||||||
c=IN IP4 0.0.0.0
|
|
||||||
a=recvonly
|
|
||||||
a=mid:0
|
|
||||||
a=msid:34803878-98f8-4245-b45c-f773e5f926df 881dbc20-356a-499c-b4e8-695303bb901d
|
|
||||||
a=rtcp:9 IN IP4 0.0.0.0
|
|
||||||
a=rtcp-mux
|
|
||||||
a=ssrc-group:FID 1303546896 3784011659
|
|
||||||
a=ssrc:1303546896 cname:a59185ac-c115-48d3-b39b-db7d615a6966
|
|
||||||
a=ssrc:3784011659 cname:a59185ac-c115-48d3-b39b-db7d615a6966
|
|
||||||
a=rtpmap:97 VP8/90000
|
|
||||||
a=rtcp-fb:97 nack
|
|
||||||
a=rtcp-fb:97 nack pli
|
|
||||||
a=rtcp-fb:97 goog-remb
|
|
||||||
a=ice-ufrag:1234
|
|
||||||
a=ice-pwd:1234
|
|
||||||
a=fingerprint:sha-256 15:F3:F0:23:67:44:EE:2C:AA:8C:D9:50:95:26:42:7C:67:EA:1F:D2:92:C5:97:01:7B:2E:57:C9:A3:13:00:4A
|
|
||||||
a=setup:actpass"""
|
|
||||||
|
|
||||||
builder = WebRTCAnswerBuilder(offer_sdp, ice_servers=[])
|
|
||||||
builder.add_video_stream("road", DummyH264VideoStreamTrack("road", 0.05))
|
|
||||||
stream = builder.stream()
|
|
||||||
|
|
||||||
try:
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
await stream.start()
|
|
||||||
finally:
|
|
||||||
await stream.stop()
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
import aiortc
|
|
||||||
|
|
||||||
from teleoprtc.tracks import video_track_id, parse_video_track_id, TiciVideoStreamTrack, TiciTrackWrapper
|
|
||||||
|
|
||||||
|
|
||||||
class TestTracks:
|
|
||||||
def test_track_id(self):
|
|
||||||
expected_camera_type, expected_track_id = "driver", "test"
|
|
||||||
track_id = video_track_id(expected_camera_type, expected_track_id)
|
|
||||||
camera_type, track_id = parse_video_track_id(track_id)
|
|
||||||
assert expected_camera_type == camera_type
|
|
||||||
assert expected_track_id == track_id
|
|
||||||
|
|
||||||
def test_track_id_invalid(self):
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
parse_video_track_id("test")
|
|
||||||
|
|
||||||
def test_tici_track_id(self):
|
|
||||||
class VideoStream(TiciVideoStreamTrack):
|
|
||||||
async def recv(self):
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
track = VideoStream("driver", 0.1)
|
|
||||||
camera_type, _ = parse_video_track_id(track.id)
|
|
||||||
assert "driver" == camera_type
|
|
||||||
|
|
||||||
def test_tici_wrapper_id(self):
|
|
||||||
track = TiciTrackWrapper("driver", aiortc.mediastreams.VideoStreamTrack())
|
|
||||||
camera_type, _ = parse_video_track_id(track.id)
|
|
||||||
assert "driver" == camera_type
|
|
||||||
@@ -35,32 +35,49 @@ sync_python_env() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
PYTHONPATH="$DIR" /usr/local/venv/bin/python3 -c "from iqpilot.common.git_creds import install_credential_helper; install_credential_helper('$DIR')" 2>/dev/null || true
|
PYTHONPATH="$DIR" /usr/local/venv/bin/python3 -c "from iqpilot.common.git_creds import install_credential_helper; install_credential_helper('$DIR')" 2>/dev/null || true
|
||||||
PACKAGE_LOCK_SHA="$(sha256sum "$DIR/uv.lock" | awk '{print $1}')"
|
PACKAGE_SOURCES_ID="$(git -C "$DIR" rev-parse --verify --quiet "HEAD:artifacts/package_sources" 2>/dev/null || true)"
|
||||||
|
PACKAGE_LOCK_SHA="$(printf '%s\n%s\n' "$(sha256sum "$DIR/uv.lock" | awk '{print $1}')" "$PACKAGE_SOURCES_ID" | sha256sum | awk '{print $1}')"
|
||||||
INSTALLED_PACKAGE_LOCK_SHA="$(cat "$DIR/.iqpilot-package-lock-sha256" 2>/dev/null || true)"
|
INSTALLED_PACKAGE_LOCK_SHA="$(cat "$DIR/.iqpilot-package-lock-sha256" 2>/dev/null || true)"
|
||||||
|
mapfile -t RUNTIME_WHEELS < <(/usr/local/venv/bin/python3 "$DIR/iqpilot/system/runtime_wheel_requirements.py" "$DIR")
|
||||||
|
mapfile -t RUNTIME_WHEEL_SOURCES < <(/usr/local/venv/bin/python3 "$DIR/iqpilot/system/runtime_wheel_requirements.py" "$DIR" --sources)
|
||||||
|
RUNTIME_WHEEL_LOCK_SHA="$(printf '%s\n' "${RUNTIME_WHEELS[@]}" | sha256sum | awk '{print $1}')"
|
||||||
|
INSTALLED_RUNTIME_WHEEL_LOCK_SHA="$(cat "$DIR/.iqpilot-runtime-wheel-lock-sha256" 2>/dev/null || true)"
|
||||||
BASE_SITE_PACKAGES="$(/usr/local/venv/bin/python3 -c 'import site; print(site.getsitepackages()[0])')"
|
BASE_SITE_PACKAGES="$(/usr/local/venv/bin/python3 -c 'import site; print(site.getsitepackages()[0])')"
|
||||||
PROJECT_RAYLIB="$("$DIR/.venv/bin/python3" -c 'import importlib.metadata; print(importlib.metadata.distribution("raylib").locate_file(""))' 2>/dev/null || true)"
|
PROJECT_RAYLIB="$("$DIR/.venv/bin/python3" -c 'import importlib.metadata; print(importlib.metadata.distribution("raylib").locate_file(""))' 2>/dev/null || true)"
|
||||||
if [[ "$PROJECT_RAYLIB" = "$DIR/.venv"/* ]]; then
|
if [[ "$PROJECT_RAYLIB" = "$DIR/.venv"/* ]]; then
|
||||||
sudo rm -rf "$DIR/.venv"
|
sudo rm -rf "$DIR/.venv"
|
||||||
rm -f "$DIR/.iqpilot-package-lock-sha256"
|
rm -f "$DIR/.iqpilot-package-lock-sha256" "$DIR/.iqpilot-runtime-wheel-lock-sha256"
|
||||||
INSTALLED_PACKAGE_LOCK_SHA=""
|
INSTALLED_PACKAGE_LOCK_SHA=""
|
||||||
|
INSTALLED_RUNTIME_WHEEL_LOCK_SHA=""
|
||||||
fi
|
fi
|
||||||
VENV_SITE_PACKAGES="$("$DIR/.venv/bin/python3" -c 'import site; print(site.getsitepackages()[0])' 2>/dev/null || true)"
|
VENV_SITE_PACKAGES="$("$DIR/.venv/bin/python3" -c 'import site; print(site.getsitepackages()[0])' 2>/dev/null || true)"
|
||||||
PACKAGES_READY=0
|
PACKAGES_READY=0
|
||||||
if [ -d "$DIR/artifacts/package_runtime" ] && PYTHONPATH="$DIR/artifacts/package_runtime" /usr/local/venv/bin/python3 -c "import iqdbc, msgq, panda, teleoprtc, tinygrad" 2>/dev/null; then
|
if [ -d "$DIR/artifacts/package_runtime" ] && PYTHONPATH="$DIR/artifacts/package_runtime" /usr/local/venv/bin/python3 -c "import iqdbc, msgq, panda, tinygrad" 2>/dev/null; then
|
||||||
PACKAGES_READY=1
|
PACKAGES_READY=1
|
||||||
elif [ "$PACKAGE_LOCK_SHA" = "$INSTALLED_PACKAGE_LOCK_SHA" ] && "$DIR/.venv/bin/python3" -c "import iqdbc, msgq, panda, teleoprtc, tinygrad" 2>/dev/null \
|
elif [ "$PACKAGE_LOCK_SHA" = "$INSTALLED_PACKAGE_LOCK_SHA" ] && "$DIR/.venv/bin/python3" -c "import iqdbc, msgq, panda, tinygrad" 2>/dev/null \
|
||||||
&& "$DIR/.venv/bin/python3" "$DIR/iqpilot/system/runtime_packages_verify.py"; then
|
&& "$DIR/.venv/bin/python3" "$DIR/iqpilot/system/runtime_packages_verify.py"; then
|
||||||
# a top-level import passes on a partially extracted install (lazy backends),
|
# a top-level import passes on a partially extracted install (lazy backends),
|
||||||
# so readiness also requires every wheel RECORD file to exist on disk
|
# so readiness also requires every wheel RECORD file to exist on disk
|
||||||
PACKAGES_READY=1
|
PACKAGES_READY=1
|
||||||
fi
|
fi
|
||||||
if [ "$PACKAGES_READY" != "1" ]; then
|
RUNTIME_WHEELS_READY=0
|
||||||
|
if [ -d "$DIR/artifacts/package_runtime" ] && PYTHONPATH="$DIR/artifacts/package_runtime" /usr/local/venv/bin/python3 -c "import libdatachannel" 2>/dev/null; then
|
||||||
|
RUNTIME_WHEELS_READY=1
|
||||||
|
elif [ "$RUNTIME_WHEEL_LOCK_SHA" = "$INSTALLED_RUNTIME_WHEEL_LOCK_SHA" ] && "$DIR/.venv/bin/python3" -c "import libdatachannel" 2>/dev/null \
|
||||||
|
&& PYTHONPATH="$DIR" "$DIR/.venv/bin/python3" "$DIR/iqpilot/system/runtime_wheels_verify.py"; then
|
||||||
|
RUNTIME_WHEELS_READY=1
|
||||||
|
fi
|
||||||
|
if [ "$PACKAGES_READY" != "1" ] || [ "$RUNTIME_WHEELS_READY" != "1" ]; then
|
||||||
UV_CACHE_DIR="$DIR/.uv-cache"
|
UV_CACHE_DIR="$DIR/.uv-cache"
|
||||||
if [ ! -x "$DIR/.venv/bin/python3" ]; then
|
if [ ! -x "$DIR/.venv/bin/python3" ]; then
|
||||||
UV_CACHE_DIR="$UV_CACHE_DIR" uv venv --python /usr/local/venv/bin/python3 "$DIR/.venv" || return 1
|
UV_CACHE_DIR="$UV_CACHE_DIR" uv venv --python /usr/local/venv/bin/python3 "$DIR/.venv" || return 1
|
||||||
VENV_SITE_PACKAGES="$("$DIR/.venv/bin/python3" -c 'import site; print(site.getsitepackages()[0])')"
|
VENV_SITE_PACKAGES="$("$DIR/.venv/bin/python3" -c 'import site; print(site.getsitepackages()[0])')"
|
||||||
fi
|
fi
|
||||||
sudo chown -R "$(id -u):$(id -g)" "$DIR/.venv" "$UV_CACHE_DIR" "$DIR/artifacts/package_sources" 2>/dev/null || true
|
sudo chown -R "$(id -u):$(id -g)" "$DIR/.venv" "$UV_CACHE_DIR" "$DIR/artifacts/package_sources" 2>/dev/null || true
|
||||||
|
if [ "${#RUNTIME_WHEELS[@]}" = "0" ] || [ "${#RUNTIME_WHEEL_SOURCES[@]}" = "0" ]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [ "$PACKAGES_READY" != "1" ]; then
|
||||||
IQDBC_PACKAGE_SOURCE=""
|
IQDBC_PACKAGE_SOURCE=""
|
||||||
PACKAGE_SOURCES=()
|
PACKAGE_SOURCES=()
|
||||||
while IFS=$'\t' read -r package_name package_source; do
|
while IFS=$'\t' read -r package_name package_source; do
|
||||||
@@ -89,6 +106,14 @@ sync_python_env() {
|
|||||||
uv pip install --python "$DIR/.venv/bin/python" --no-build-isolation --no-deps --reinstall "${PACKAGE_SOURCES[@]}" || return 1
|
uv pip install --python "$DIR/.venv/bin/python" --no-build-isolation --no-deps --reinstall "${PACKAGE_SOURCES[@]}" || return 1
|
||||||
printf '%s\n' "$PACKAGE_LOCK_SHA" > "$DIR/.iqpilot-package-lock-sha256"
|
printf '%s\n' "$PACKAGE_LOCK_SHA" > "$DIR/.iqpilot-package-lock-sha256"
|
||||||
fi
|
fi
|
||||||
|
if [ "$RUNTIME_WHEELS_READY" != "1" ]; then
|
||||||
|
UV_CACHE_DIR="$UV_CACHE_DIR" PATH="/usr/local/venv/bin:/usr/bin:$PATH" \
|
||||||
|
uv pip install --python "$DIR/.venv/bin/python" --no-deps --reinstall "${RUNTIME_WHEEL_SOURCES[@]}" || return 1
|
||||||
|
"$DIR/.venv/bin/python3" -c "import libdatachannel" || return 1
|
||||||
|
PYTHONPATH="$DIR" "$DIR/.venv/bin/python3" "$DIR/iqpilot/system/runtime_wheels_verify.py" || return 1
|
||||||
|
printf '%s\n' "$RUNTIME_WHEEL_LOCK_SHA" > "$DIR/.iqpilot-runtime-wheel-lock-sha256"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
if [ -n "$VENV_SITE_PACKAGES" ]; then
|
if [ -n "$VENV_SITE_PACKAGES" ]; then
|
||||||
printf 'import site; site.addsitedir("%s")\n' "$BASE_SITE_PACKAGES" | sudo tee "$VENV_SITE_PACKAGES/iqpilot-system-venv.pth" >/dev/null
|
printf 'import site; site.addsitedir("%s")\n' "$BASE_SITE_PACKAGES" | sudo tee "$VENV_SITE_PACKAGES/iqpilot-system-venv.pth" >/dev/null
|
||||||
fi
|
fi
|
||||||
|
|||||||
Binary file not shown.
@@ -322,8 +322,6 @@ struct IQOnroadEvent @0xf4621d3ee9233bc9 {
|
|||||||
# camera hardware
|
# camera hardware
|
||||||
wideCamFaulty @32;
|
wideCamFaulty @32;
|
||||||
|
|
||||||
# lane-change safety
|
|
||||||
lateralEdgeBlocked @33;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -503,15 +501,8 @@ enum IQTurnSignalDirection {
|
|||||||
turnRight @2;
|
turnRight @2;
|
||||||
}
|
}
|
||||||
|
|
||||||
enum IQLateralEdgeBlock {
|
|
||||||
none @0;
|
|
||||||
left @1;
|
|
||||||
right @2;
|
|
||||||
}
|
|
||||||
|
|
||||||
struct IQDriveModelData @0xcdf0f7f14f46cb86 {
|
struct IQDriveModelData @0xcdf0f7f14f46cb86 {
|
||||||
turnSignalDirection @0 :IQTurnSignalDirection;
|
turnSignalDirection @0 :IQTurnSignalDirection;
|
||||||
lateralEdgeBlock @1 :IQLateralEdgeBlock;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum NavDirection {
|
enum NavDirection {
|
||||||
|
|||||||
@@ -78,7 +78,6 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
|
|||||||
{"JoystickAolRequest", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, STRING}},
|
{"JoystickAolRequest", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, STRING}},
|
||||||
{"Konn3ktSshKeys", {PERSISTENT, STRING}},
|
{"Konn3ktSshKeys", {PERSISTENT, STRING}},
|
||||||
{"Konn3ktBleTransportEnabled", {PERSISTENT, BOOL, "1"}},
|
{"Konn3ktBleTransportEnabled", {PERSISTENT, BOOL, "1"}},
|
||||||
{"Konn3ktLibdatachannelWebRTC", {PERSISTENT, BOOL, "0"}},
|
|
||||||
{"LanguageSetting", {PERSISTENT, STRING, "en"}},
|
{"LanguageSetting", {PERSISTENT, STRING, "en"}},
|
||||||
{"LastAthenaPingTime", {CLEAR_ON_MANAGER_START, INT}},
|
{"LastAthenaPingTime", {CLEAR_ON_MANAGER_START, INT}},
|
||||||
{"LastGPSPosition", {PERSISTENT, STRING}},
|
{"LastGPSPosition", {PERSISTENT, STRING}},
|
||||||
@@ -219,6 +218,7 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
|
|||||||
{"ModelManager_ActiveBundle", {PERSISTENT, JSON}},
|
{"ModelManager_ActiveBundle", {PERSISTENT, JSON}},
|
||||||
{"ModelManager_ClearCache", {CLEAR_ON_MANAGER_START, BOOL}},
|
{"ModelManager_ClearCache", {CLEAR_ON_MANAGER_START, BOOL}},
|
||||||
{"ModelManager_DownloadIndex", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, INT, "-1"}},
|
{"ModelManager_DownloadIndex", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, INT, "-1"}},
|
||||||
|
{"ModelManager_PendingIndex", {PERSISTENT, INT, "-1"}},
|
||||||
{"IQModelFavorites", {PERSISTENT, STRING}},
|
{"IQModelFavorites", {PERSISTENT, STRING}},
|
||||||
{"ModelManager_LastSyncTime", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, INT, "0"}},
|
{"ModelManager_LastSyncTime", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, INT, "0"}},
|
||||||
{"ModelManager_ModelsCache", {PERSISTENT, JSON}},
|
{"ModelManager_ModelsCache", {PERSISTENT, JSON}},
|
||||||
|
|||||||
@@ -66,10 +66,6 @@
|
|||||||
- Added Always-On Lateral support through compatible Hyundai LFA buttons.
|
- Added Always-On Lateral support through compatible Hyundai LFA buttons.
|
||||||
- Added an optional mode that pauses steering torque when the driver takes the wheel and resumes after release.
|
- Added an optional mode that pauses steering torque when the driver takes the wheel and resumes after release.
|
||||||
|
|
||||||
#### Lane Changes
|
|
||||||
|
|
||||||
- Added a model-based road-edge guard that blocks unsafe lane changes with an alert.
|
|
||||||
|
|
||||||
#### Lateral Tuning
|
#### Lateral Tuning
|
||||||
|
|
||||||
- Added configurable steering smoothing, slew limiting, and curvature lookahead.
|
- Added configurable steering smoothing, slew limiting, and curvature lookahead.
|
||||||
|
|||||||
@@ -14,13 +14,11 @@ from iqpilot.selfdrive.controls.lib.helpers.lane_change import (
|
|||||||
AutoLaneChangeMode,
|
AutoLaneChangeMode,
|
||||||
NavExitLaneChangeController,
|
NavExitLaneChangeController,
|
||||||
)
|
)
|
||||||
from iqpilot.selfdrive.controls.lib.helpers.lateral_edge_guard import LateralEdgeGuard
|
|
||||||
from iqpilot.selfdrive.controls.lib.helpers.lane_turn import IQNavTurnController
|
from iqpilot.selfdrive.controls.lib.helpers.lane_turn import IQNavTurnController
|
||||||
|
|
||||||
LaneChangeState = log.LaneChangeState
|
LaneChangeState = log.LaneChangeState
|
||||||
LaneChangeDirection = log.LaneChangeDirection
|
LaneChangeDirection = log.LaneChangeDirection
|
||||||
TurnDirection = custom.IQTurnSignalDirection
|
TurnDirection = custom.IQTurnSignalDirection
|
||||||
LateralEdgeBlock = custom.IQLateralEdgeBlock
|
|
||||||
NavManeuverPhase = custom.IQNavState.ManeuverPhase
|
NavManeuverPhase = custom.IQNavState.ManeuverPhase
|
||||||
|
|
||||||
LANE_CHANGE_SPEED_MIN = 20 * CV.MPH_TO_MS
|
LANE_CHANGE_SPEED_MIN = 20 * CV.MPH_TO_MS
|
||||||
@@ -107,8 +105,6 @@ class DesireHelper:
|
|||||||
self.alc = IQLaneSwapController(self)
|
self.alc = IQLaneSwapController(self)
|
||||||
self.lane_turn_controller = IQNavTurnController(self)
|
self.lane_turn_controller = IQNavTurnController(self)
|
||||||
self.nav_exit = NavExitLaneChangeController(_read_enable_bsm())
|
self.nav_exit = NavExitLaneChangeController(_read_enable_bsm())
|
||||||
self.lateral_edge_guard = LateralEdgeGuard()
|
|
||||||
self.lateral_edge_block = LateralEdgeBlock.none
|
|
||||||
self.lane_turn_direction = TurnDirection.none
|
self.lane_turn_direction = TurnDirection.none
|
||||||
self.nav_turn_direction = TurnDirection.none
|
self.nav_turn_direction = TurnDirection.none
|
||||||
self.turn_desire_stop_timer = 0.0
|
self.turn_desire_stop_timer = 0.0
|
||||||
@@ -178,8 +174,6 @@ class DesireHelper:
|
|||||||
def _step_pre_lane_change(self, one_blinker: bool, nav_exit_active: bool, below_speed: bool) -> None:
|
def _step_pre_lane_change(self, one_blinker: bool, nav_exit_active: bool, below_speed: bool) -> None:
|
||||||
self._refresh_requested_direction(one_blinker, nav_exit_active)
|
self._refresh_requested_direction(one_blinker, nav_exit_active)
|
||||||
blindspot_detected = _blindspot_matches(self._last_carstate, self.lane_change_direction)
|
blindspot_detected = _blindspot_matches(self._last_carstate, self.lane_change_direction)
|
||||||
self.lateral_edge_block = self.lateral_edge_guard.block_for_direction(self.lane_change_direction)
|
|
||||||
lateral_edge_blocked = self.lateral_edge_block != LateralEdgeBlock.none
|
|
||||||
steering_ready = _steering_nudge_matches(self._last_carstate, self.lane_change_direction)
|
steering_ready = _steering_nudge_matches(self._last_carstate, self.lane_change_direction)
|
||||||
nav_auto_start = nav_exit_active and self.nav_exit.auto_allowed
|
nav_auto_start = nav_exit_active and self.nav_exit.auto_allowed
|
||||||
|
|
||||||
@@ -188,7 +182,7 @@ class DesireHelper:
|
|||||||
|
|
||||||
if (not (one_blinker or nav_exit_active)) or below_speed:
|
if (not (one_blinker or nav_exit_active)) or below_speed:
|
||||||
self._clear_lane_change()
|
self._clear_lane_change()
|
||||||
elif allowed_to_launch and not blindspot_detected and not lateral_edge_blocked:
|
elif allowed_to_launch and not blindspot_detected:
|
||||||
self.lane_change_state = LaneChangeState.laneChangeStarting
|
self.lane_change_state = LaneChangeState.laneChangeStarting
|
||||||
|
|
||||||
def _step_lane_change_starting(self, lane_change_prob: float) -> None:
|
def _step_lane_change_starting(self, lane_change_prob: float) -> None:
|
||||||
@@ -275,8 +269,6 @@ class DesireHelper:
|
|||||||
|
|
||||||
def update(self, carstate, lateral_active, lane_change_prob, nav_state=None, modeldata=None, radar_state=None):
|
def update(self, carstate, lateral_active, lane_change_prob, nav_state=None, modeldata=None, radar_state=None):
|
||||||
self._last_carstate = carstate
|
self._last_carstate = carstate
|
||||||
self.lateral_edge_guard.update(modeldata, carstate.vEgo, DT_MDL)
|
|
||||||
self.lateral_edge_block = LateralEdgeBlock.none
|
|
||||||
one_blinker = carstate.leftBlinker != carstate.rightBlinker
|
one_blinker = carstate.leftBlinker != carstate.rightBlinker
|
||||||
below_speed = carstate.vEgo < LANE_CHANGE_SPEED_MIN
|
below_speed = carstate.vEgo < LANE_CHANGE_SPEED_MIN
|
||||||
nav_exit_active = self._refresh_turn_overrides(carstate, nav_state)
|
nav_exit_active = self._refresh_turn_overrides(carstate, nav_state)
|
||||||
|
|||||||
@@ -1,243 +0,0 @@
|
|||||||
"""
|
|
||||||
Lateral Edge Guard uses the model's lateral road-edge geometry to withhold lane
|
|
||||||
changes that lack room for a target lane. The model standard deviation remains
|
|
||||||
in metres: measurements above the validity limit are rejected, while valid
|
|
||||||
measurements use a one-sigma lower confidence bound for conservative clearance.
|
|
||||||
Unavailable geometry briefly holds the last output, then fails open because a
|
|
||||||
model dropout is not geometric evidence of a nearby edge. A visible outer lane
|
|
||||||
line on the target side is direct evidence that a lane exists and overrides the
|
|
||||||
edge-distance inference.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import math
|
|
||||||
from dataclasses import dataclass, replace
|
|
||||||
from enum import IntEnum
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from iqpilot.cereal import custom, log
|
|
||||||
from iqpilot.common.constants import CV
|
|
||||||
from iqpilot.common.swaglog import cloudlog
|
|
||||||
|
|
||||||
|
|
||||||
MIN_ACTIVE_SPEED_MPS = 20.0 * CV.MPH_TO_MS # Matches the lane-change speed gate and excludes parking manoeuvres.
|
|
||||||
MAX_VALID_ROAD_EDGE_STD_M = 1.0 # A 2-sigma bound beyond 2 m cannot distinguish an adjacent 3.5 m lane reliably.
|
|
||||||
# roadEdgeStd describes a single edge point, but it is applied to a 5-40 m minimum that already absorbs the
|
|
||||||
# spatial worst case; 1 sigma covers ~1.1x the measured p99 frame-to-frame spread of that minimum, 2 sigma 2.2x.
|
|
||||||
EDGE_CONFIDENCE_SIGMA = 1.0
|
|
||||||
ROAD_EDGE_LOOKAHEAD_MIN_M = 5.0 # Ignore near-field edge points dominated by vehicle-body perspective.
|
|
||||||
ROAD_EDGE_LOOKAHEAD_MAX_M = 40.0 # Covers about 2 s at the 20 m/s model-training reference speed.
|
|
||||||
LANE_CENTER_OFFSET_M = 3.5 # Typical freeway lane width and the target-centre lateral displacement.
|
|
||||||
# CarParams exposes neither width nor track; 0.95 m is half of an assumed conservative 1.90 m body width.
|
|
||||||
VEHICLE_LATERAL_HALF_WIDTH_M = 1.90 / 2.0
|
|
||||||
EDGE_CLEARANCE_MARGIN_M = 0.25 # Additional lateral separation between the vehicle body and detected road edge.
|
|
||||||
ADJACENT_LANE_LINE_PROB = 0.5
|
|
||||||
EGO_LANE_LINE_PROB_MIN = 0.5
|
|
||||||
MIN_MEASURED_LANE_WIDTH_M = 2.5
|
|
||||||
MAX_MEASURED_LANE_WIDTH_M = 4.5
|
|
||||||
# modelV2 lane lines are ordered outer-left, ego-left, ego-right, outer-right.
|
|
||||||
OUTER_LANE_LINE_INDEX = (0, 3)
|
|
||||||
EGO_LANE_LINE_INDEX = (1, 2)
|
|
||||||
REQUIRED_ROAD_EDGE_DISTANCE_M = LANE_CENTER_OFFSET_M + VEHICLE_LATERAL_HALF_WIDTH_M + EDGE_CLEARANCE_MARGIN_M
|
|
||||||
BLOCK_DEBOUNCE_S = 0.30 # Six model frames reject a transient close-edge prediction before blocking.
|
|
||||||
CLEAR_DEBOUNCE_S = 0.50 # Ten model frames make release slower than assertion for conservative hysteresis.
|
|
||||||
UNAVAILABLE_HOLD_S = 0.50 # Ten model frames bridge a short model-data dropout before failing open.
|
|
||||||
TIMER_EPSILON_S = 1e-9 # Floating-point comparison tolerance, far below one model tick.
|
|
||||||
|
|
||||||
LaneChangeDirection = log.LaneChangeDirection
|
|
||||||
LateralEdgeBlock = custom.IQLateralEdgeBlock
|
|
||||||
|
|
||||||
|
|
||||||
class RoadEdgeDataState(IntEnum):
|
|
||||||
VALID = 0
|
|
||||||
UNAVAILABLE = 1
|
|
||||||
INVALID = 2
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class RoadEdgeMeasurement:
|
|
||||||
state: RoadEdgeDataState
|
|
||||||
lateral_distance_m: float | None = None
|
|
||||||
conservative_distance_m: float | None = None
|
|
||||||
should_block: bool | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class _SideState:
|
|
||||||
blocked: bool = False
|
|
||||||
block_timer_s: float = 0.0
|
|
||||||
clear_timer_s: float = 0.0
|
|
||||||
unavailable_timer_s: float = 0.0
|
|
||||||
fallback_reported: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
def evaluate_road_edge(edge: Any, std_m: Any, direction: int,
|
|
||||||
lane_width_m: float = LANE_CENTER_OFFSET_M) -> RoadEdgeMeasurement:
|
|
||||||
if edge is None or std_m is None:
|
|
||||||
return RoadEdgeMeasurement(RoadEdgeDataState.UNAVAILABLE)
|
|
||||||
|
|
||||||
try:
|
|
||||||
xs = edge.x
|
|
||||||
ys = edge.y
|
|
||||||
count = len(xs)
|
|
||||||
y_count = len(ys)
|
|
||||||
except (AttributeError, TypeError):
|
|
||||||
return RoadEdgeMeasurement(RoadEdgeDataState.UNAVAILABLE)
|
|
||||||
|
|
||||||
if count == 0 or y_count != count:
|
|
||||||
return RoadEdgeMeasurement(RoadEdgeDataState.UNAVAILABLE)
|
|
||||||
|
|
||||||
try:
|
|
||||||
std = float(std_m)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return RoadEdgeMeasurement(RoadEdgeDataState.INVALID)
|
|
||||||
if not math.isfinite(std) or std < 0.0 or std > MAX_VALID_ROAD_EDGE_STD_M:
|
|
||||||
return RoadEdgeMeasurement(RoadEdgeDataState.INVALID)
|
|
||||||
|
|
||||||
lateral_distance_m: float | None = None
|
|
||||||
for idx in range(count):
|
|
||||||
try:
|
|
||||||
x_m = float(xs[idx])
|
|
||||||
y_m = float(ys[idx])
|
|
||||||
except (IndexError, TypeError, ValueError):
|
|
||||||
return RoadEdgeMeasurement(RoadEdgeDataState.UNAVAILABLE)
|
|
||||||
if not math.isfinite(x_m) or not math.isfinite(y_m):
|
|
||||||
return RoadEdgeMeasurement(RoadEdgeDataState.UNAVAILABLE)
|
|
||||||
if not ROAD_EDGE_LOOKAHEAD_MIN_M <= x_m <= ROAD_EDGE_LOOKAHEAD_MAX_M:
|
|
||||||
continue
|
|
||||||
if ((direction == LaneChangeDirection.left and y_m >= 0.0) or
|
|
||||||
(direction == LaneChangeDirection.right and y_m <= 0.0)):
|
|
||||||
return RoadEdgeMeasurement(RoadEdgeDataState.INVALID)
|
|
||||||
distance_m = abs(y_m)
|
|
||||||
lateral_distance_m = distance_m if lateral_distance_m is None else min(lateral_distance_m, distance_m)
|
|
||||||
|
|
||||||
if lateral_distance_m is None:
|
|
||||||
return RoadEdgeMeasurement(RoadEdgeDataState.UNAVAILABLE)
|
|
||||||
|
|
||||||
conservative_distance_m = lateral_distance_m - EDGE_CONFIDENCE_SIGMA * std
|
|
||||||
required_distance_m = lane_width_m + VEHICLE_LATERAL_HALF_WIDTH_M + EDGE_CLEARANCE_MARGIN_M
|
|
||||||
return RoadEdgeMeasurement(
|
|
||||||
RoadEdgeDataState.VALID,
|
|
||||||
lateral_distance_m,
|
|
||||||
conservative_distance_m,
|
|
||||||
conservative_distance_m < required_distance_m,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def step_side_guard(state: _SideState, measurement: RoadEdgeMeasurement, speed_active: bool,
|
|
||||||
dt_s: float) -> tuple[_SideState, bool]:
|
|
||||||
if not speed_active:
|
|
||||||
return _SideState(), False
|
|
||||||
|
|
||||||
if measurement.state == RoadEdgeDataState.UNAVAILABLE:
|
|
||||||
unavailable_timer_s = state.unavailable_timer_s + dt_s
|
|
||||||
if unavailable_timer_s < UNAVAILABLE_HOLD_S - TIMER_EPSILON_S:
|
|
||||||
return _SideState(state.blocked, unavailable_timer_s=unavailable_timer_s,
|
|
||||||
fallback_reported=state.fallback_reported), False
|
|
||||||
fallback_started = not state.fallback_reported
|
|
||||||
return _SideState(unavailable_timer_s=unavailable_timer_s, fallback_reported=True), fallback_started
|
|
||||||
|
|
||||||
should_block = bool(measurement.should_block) if measurement.state == RoadEdgeDataState.VALID else False
|
|
||||||
if should_block == state.blocked:
|
|
||||||
return _SideState(blocked=state.blocked), False
|
|
||||||
|
|
||||||
if should_block:
|
|
||||||
block_timer_s = state.block_timer_s + dt_s
|
|
||||||
if block_timer_s >= BLOCK_DEBOUNCE_S - TIMER_EPSILON_S:
|
|
||||||
return _SideState(blocked=True), False
|
|
||||||
return _SideState(block_timer_s=block_timer_s), False
|
|
||||||
|
|
||||||
clear_timer_s = state.clear_timer_s + dt_s
|
|
||||||
if clear_timer_s >= CLEAR_DEBOUNCE_S - TIMER_EPSILON_S:
|
|
||||||
return _SideState(), False
|
|
||||||
return _SideState(blocked=True, clear_timer_s=clear_timer_s), False
|
|
||||||
|
|
||||||
|
|
||||||
class LateralEdgeGuard:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._left = _SideState()
|
|
||||||
self._right = _SideState()
|
|
||||||
self.left_measurement = RoadEdgeMeasurement(RoadEdgeDataState.UNAVAILABLE)
|
|
||||||
self.right_measurement = RoadEdgeMeasurement(RoadEdgeDataState.UNAVAILABLE)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _model_side(modeldata: Any, side_index: int) -> tuple[Any | None, Any | None]:
|
|
||||||
if modeldata is None:
|
|
||||||
return None, None
|
|
||||||
try:
|
|
||||||
edges = modeldata.roadEdges
|
|
||||||
stds = modeldata.roadEdgeStds
|
|
||||||
if len(edges) <= side_index or len(stds) <= side_index:
|
|
||||||
return None, None
|
|
||||||
return edges[side_index], stds[side_index]
|
|
||||||
except (AttributeError, TypeError):
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _lane_line_prob(modeldata: Any, index: int) -> float | None:
|
|
||||||
if modeldata is None:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
probs = modeldata.laneLineProbs
|
|
||||||
if len(probs) <= index:
|
|
||||||
return None
|
|
||||||
value = float(probs[index])
|
|
||||||
except (AttributeError, TypeError, IndexError, ValueError):
|
|
||||||
return None
|
|
||||||
return value if math.isfinite(value) else None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _adjacent_lane_visible(cls, modeldata: Any, side_index: int) -> bool:
|
|
||||||
prob = cls._lane_line_prob(modeldata, OUTER_LANE_LINE_INDEX[side_index])
|
|
||||||
return prob is not None and prob > ADJACENT_LANE_LINE_PROB
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _measured_lane_width(cls, modeldata: Any) -> float:
|
|
||||||
left_prob = cls._lane_line_prob(modeldata, EGO_LANE_LINE_INDEX[0])
|
|
||||||
right_prob = cls._lane_line_prob(modeldata, EGO_LANE_LINE_INDEX[1])
|
|
||||||
if left_prob is None or right_prob is None:
|
|
||||||
return LANE_CENTER_OFFSET_M
|
|
||||||
if left_prob <= EGO_LANE_LINE_PROB_MIN or right_prob <= EGO_LANE_LINE_PROB_MIN:
|
|
||||||
return LANE_CENTER_OFFSET_M
|
|
||||||
try:
|
|
||||||
lines = modeldata.laneLines
|
|
||||||
left_y = float(lines[EGO_LANE_LINE_INDEX[0]].y[0])
|
|
||||||
right_y = float(lines[EGO_LANE_LINE_INDEX[1]].y[0])
|
|
||||||
except (AttributeError, TypeError, IndexError, ValueError):
|
|
||||||
return LANE_CENTER_OFFSET_M
|
|
||||||
width = abs(right_y - left_y)
|
|
||||||
if not math.isfinite(width):
|
|
||||||
return LANE_CENTER_OFFSET_M
|
|
||||||
return min(max(width, MIN_MEASURED_LANE_WIDTH_M), MAX_MEASURED_LANE_WIDTH_M)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _apply_lane_evidence(measurement: RoadEdgeMeasurement, lane_visible: bool) -> RoadEdgeMeasurement:
|
|
||||||
if lane_visible and measurement.state == RoadEdgeDataState.VALID and measurement.should_block:
|
|
||||||
return replace(measurement, should_block=False)
|
|
||||||
return measurement
|
|
||||||
|
|
||||||
def update(self, modeldata: Any, v_ego_mps: float, dt_s: float) -> None:
|
|
||||||
dt = max(float(dt_s), 0.0)
|
|
||||||
left_edge, left_std = self._model_side(modeldata, 0)
|
|
||||||
right_edge, right_std = self._model_side(modeldata, 1)
|
|
||||||
lane_width_m = self._measured_lane_width(modeldata)
|
|
||||||
self.left_measurement = self._apply_lane_evidence(
|
|
||||||
evaluate_road_edge(left_edge, left_std, LaneChangeDirection.left, lane_width_m),
|
|
||||||
self._adjacent_lane_visible(modeldata, 0))
|
|
||||||
self.right_measurement = self._apply_lane_evidence(
|
|
||||||
evaluate_road_edge(right_edge, right_std, LaneChangeDirection.right, lane_width_m),
|
|
||||||
self._adjacent_lane_visible(modeldata, 1))
|
|
||||||
speed_active = math.isfinite(v_ego_mps) and v_ego_mps >= MIN_ACTIVE_SPEED_MPS
|
|
||||||
self._left, left_fallback = step_side_guard(self._left, self.left_measurement, speed_active, dt)
|
|
||||||
self._right, right_fallback = step_side_guard(self._right, self.right_measurement, speed_active, dt)
|
|
||||||
if left_fallback:
|
|
||||||
cloudlog.warning(f"lateral edge guard: left road edge unavailable for {UNAVAILABLE_HOLD_S:.2f} s; falling back to not blocking")
|
|
||||||
if right_fallback:
|
|
||||||
cloudlog.warning(f"lateral edge guard: right road edge unavailable for {UNAVAILABLE_HOLD_S:.2f} s; falling back to not blocking")
|
|
||||||
|
|
||||||
def block_for_direction(self, direction: int) -> custom.IQLateralEdgeBlock:
|
|
||||||
if direction == LaneChangeDirection.left and self._left.blocked:
|
|
||||||
return LateralEdgeBlock.left
|
|
||||||
if direction == LaneChangeDirection.right and self._right.blocked:
|
|
||||||
return LateralEdgeBlock.right
|
|
||||||
return LateralEdgeBlock.none
|
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import math
|
|
||||||
|
|
||||||
from iqpilot.cereal import custom, log
|
|
||||||
import iqpilot.cereal.messaging as messaging
|
|
||||||
from iqpilot.common.realtime import DT_MDL
|
|
||||||
from iqpilot.selfdrive.controls.lib.desire_helper import DesireHelper
|
|
||||||
from iqpilot.selfdrive.controls.lib.helpers.lane_change import AutoLaneChangeMode
|
|
||||||
from iqpilot.selfdrive.controls.lib.helpers.lateral_edge_guard import (
|
|
||||||
ADJACENT_LANE_LINE_PROB,
|
|
||||||
BLOCK_DEBOUNCE_S,
|
|
||||||
CLEAR_DEBOUNCE_S,
|
|
||||||
MAX_VALID_ROAD_EDGE_STD_M,
|
|
||||||
MIN_ACTIVE_SPEED_MPS,
|
|
||||||
REQUIRED_ROAD_EDGE_DISTANCE_M,
|
|
||||||
UNAVAILABLE_HOLD_S,
|
|
||||||
LANE_CENTER_OFFSET_M,
|
|
||||||
MAX_MEASURED_LANE_WIDTH_M,
|
|
||||||
MIN_MEASURED_LANE_WIDTH_M,
|
|
||||||
LateralEdgeGuard,
|
|
||||||
RoadEdgeDataState,
|
|
||||||
evaluate_road_edge,
|
|
||||||
)
|
|
||||||
from iqpilot.selfdrive.selfdrived.iq_events import EVENTS_IQ, ET
|
|
||||||
from iqpilot.selfdrive.selfdrived.selfdrived import SelfdriveD
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Edge:
|
|
||||||
x: list[float]
|
|
||||||
y: list[float]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ModelData:
|
|
||||||
roadEdges: list[Edge]
|
|
||||||
roadEdgeStds: list[float]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class LaneModelData:
|
|
||||||
roadEdges: list[Edge]
|
|
||||||
roadEdgeStds: list[float]
|
|
||||||
laneLines: list[Edge]
|
|
||||||
laneLineProbs: list[float]
|
|
||||||
|
|
||||||
|
|
||||||
def lane_model(left_distance_m: float = 4.0, outer_prob: float = 0.0,
|
|
||||||
ego_width_m: float = 3.5, ego_prob: float = 0.9) -> LaneModelData:
|
|
||||||
xs = [5.0, 20.0, 40.0]
|
|
||||||
base = edge_model(left_distance_m, left_distance_m)
|
|
||||||
half = ego_width_m / 2.0
|
|
||||||
lines = [Edge(xs, [-(half + 3.0)] * 3), Edge(xs, [-half] * 3),
|
|
||||||
Edge(xs, [half] * 3), Edge(xs, [half + 3.0] * 3)]
|
|
||||||
return LaneModelData(base.roadEdges, base.roadEdgeStds, lines,
|
|
||||||
[outer_prob, ego_prob, ego_prob, outer_prob])
|
|
||||||
|
|
||||||
|
|
||||||
class CarState:
|
|
||||||
def __init__(self, left_blindspot: bool = False) -> None:
|
|
||||||
self.vEgo = MIN_ACTIVE_SPEED_MPS + 1.0
|
|
||||||
self.leftBlinker = True
|
|
||||||
self.rightBlinker = False
|
|
||||||
self.leftBlindspot = left_blindspot
|
|
||||||
self.rightBlindspot = False
|
|
||||||
self.steeringPressed = True
|
|
||||||
self.steeringTorque = 1.0
|
|
||||||
self.brakePressed = False
|
|
||||||
self.standstill = False
|
|
||||||
|
|
||||||
|
|
||||||
def edge_model(left_distance_m: float = 6.0, right_distance_m: float = 6.0,
|
|
||||||
left_std_m: float = 0.0, right_std_m: float = 0.0) -> ModelData:
|
|
||||||
xs = [5.0, 20.0, 40.0]
|
|
||||||
return ModelData(
|
|
||||||
[Edge(xs, [-left_distance_m] * len(xs)), Edge(xs, [right_distance_m] * len(xs))],
|
|
||||||
[left_std_m, right_std_m],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def cycles(duration_s: float) -> int:
|
|
||||||
return math.ceil(duration_s / DT_MDL)
|
|
||||||
|
|
||||||
|
|
||||||
def update_for(guard: LateralEdgeGuard, modeldata: ModelData | None, duration_s: float,
|
|
||||||
speed_mps: float = MIN_ACTIVE_SPEED_MPS) -> None:
|
|
||||||
for _ in range(cycles(duration_s)):
|
|
||||||
guard.update(modeldata, speed_mps, DT_MDL)
|
|
||||||
|
|
||||||
|
|
||||||
def test_valid_geometry_blocks_and_clear_geometry_does_not_block() -> None:
|
|
||||||
blocked = evaluate_road_edge(edge_model(4.0).roadEdges[0], 0.2, log.LaneChangeDirection.left)
|
|
||||||
clear = evaluate_road_edge(edge_model(6.0).roadEdges[0], 0.2, log.LaneChangeDirection.left)
|
|
||||||
assert blocked.state == RoadEdgeDataState.VALID
|
|
||||||
assert blocked.should_block is True
|
|
||||||
assert clear.state == RoadEdgeDataState.VALID
|
|
||||||
assert clear.should_block is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_unavailable_and_invalid_are_distinct() -> None:
|
|
||||||
unavailable = evaluate_road_edge(Edge([5.0], []), 0.2, log.LaneChangeDirection.left)
|
|
||||||
invalid = evaluate_road_edge(edge_model().roadEdges[0], MAX_VALID_ROAD_EDGE_STD_M + 0.01,
|
|
||||||
log.LaneChangeDirection.left)
|
|
||||||
assert unavailable.state == RoadEdgeDataState.UNAVAILABLE
|
|
||||||
assert unavailable.lateral_distance_m is None
|
|
||||||
assert invalid.state == RoadEdgeDataState.INVALID
|
|
||||||
assert invalid.should_block is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_one_sigma_bound_uses_std_in_metres() -> None:
|
|
||||||
measurement = evaluate_road_edge(edge_model(5.0).roadEdges[0], 0.2, log.LaneChangeDirection.left)
|
|
||||||
assert measurement.lateral_distance_m == 5.0
|
|
||||||
assert measurement.conservative_distance_m == 4.8
|
|
||||||
assert measurement.should_block is False
|
|
||||||
|
|
||||||
blocking = evaluate_road_edge(edge_model(4.5).roadEdges[0], 0.2, log.LaneChangeDirection.left)
|
|
||||||
assert blocking.conservative_distance_m == 4.3
|
|
||||||
assert blocking.should_block is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_distance_threshold_on_either_side() -> None:
|
|
||||||
epsilon_m = 0.001
|
|
||||||
for direction, edge_index in ((log.LaneChangeDirection.left, 0), (log.LaneChangeDirection.right, 1)):
|
|
||||||
below = edge_model(REQUIRED_ROAD_EDGE_DISTANCE_M - epsilon_m, REQUIRED_ROAD_EDGE_DISTANCE_M - epsilon_m)
|
|
||||||
above = edge_model(REQUIRED_ROAD_EDGE_DISTANCE_M + epsilon_m, REQUIRED_ROAD_EDGE_DISTANCE_M + epsilon_m)
|
|
||||||
assert evaluate_road_edge(below.roadEdges[edge_index], 0.0, direction).should_block is True
|
|
||||||
assert evaluate_road_edge(above.roadEdges[edge_index], 0.0, direction).should_block is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_block_debounce_rejects_a_single_clear_frame() -> None:
|
|
||||||
guard = LateralEdgeGuard()
|
|
||||||
blocking = edge_model(4.0)
|
|
||||||
clear = edge_model(6.0)
|
|
||||||
update_for(guard, blocking, BLOCK_DEBOUNCE_S - DT_MDL)
|
|
||||||
assert guard.block_for_direction(log.LaneChangeDirection.left) == custom.IQLateralEdgeBlock.none
|
|
||||||
guard.update(clear, MIN_ACTIVE_SPEED_MPS, DT_MDL)
|
|
||||||
update_for(guard, blocking, BLOCK_DEBOUNCE_S)
|
|
||||||
assert guard.block_for_direction(log.LaneChangeDirection.left) == custom.IQLateralEdgeBlock.left
|
|
||||||
|
|
||||||
|
|
||||||
def test_clear_debounce_rejects_a_single_blocking_frame() -> None:
|
|
||||||
guard = LateralEdgeGuard()
|
|
||||||
update_for(guard, edge_model(4.0), BLOCK_DEBOUNCE_S)
|
|
||||||
update_for(guard, edge_model(6.0), CLEAR_DEBOUNCE_S - DT_MDL)
|
|
||||||
assert guard.block_for_direction(log.LaneChangeDirection.left) == custom.IQLateralEdgeBlock.left
|
|
||||||
guard.update(edge_model(4.0), MIN_ACTIVE_SPEED_MPS, DT_MDL)
|
|
||||||
update_for(guard, edge_model(6.0), CLEAR_DEBOUNCE_S)
|
|
||||||
assert guard.block_for_direction(log.LaneChangeDirection.left) == custom.IQLateralEdgeBlock.none
|
|
||||||
|
|
||||||
|
|
||||||
def test_unavailable_holds_then_falls_back_to_not_blocking() -> None:
|
|
||||||
guard = LateralEdgeGuard()
|
|
||||||
update_for(guard, edge_model(4.0), BLOCK_DEBOUNCE_S)
|
|
||||||
update_for(guard, None, UNAVAILABLE_HOLD_S - DT_MDL)
|
|
||||||
assert guard.left_measurement.state == RoadEdgeDataState.UNAVAILABLE
|
|
||||||
assert guard.block_for_direction(log.LaneChangeDirection.left) == custom.IQLateralEdgeBlock.left
|
|
||||||
guard.update(None, MIN_ACTIVE_SPEED_MPS, DT_MDL)
|
|
||||||
assert guard.block_for_direction(log.LaneChangeDirection.left) == custom.IQLateralEdgeBlock.none
|
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_measurement_clears_through_release_debounce() -> None:
|
|
||||||
guard = LateralEdgeGuard()
|
|
||||||
update_for(guard, edge_model(4.0), BLOCK_DEBOUNCE_S)
|
|
||||||
invalid = edge_model(4.0, left_std_m=MAX_VALID_ROAD_EDGE_STD_M + 0.01)
|
|
||||||
update_for(guard, invalid, CLEAR_DEBOUNCE_S - DT_MDL)
|
|
||||||
assert guard.block_for_direction(log.LaneChangeDirection.left) == custom.IQLateralEdgeBlock.left
|
|
||||||
guard.update(invalid, MIN_ACTIVE_SPEED_MPS, DT_MDL)
|
|
||||||
assert guard.block_for_direction(log.LaneChangeDirection.left) == custom.IQLateralEdgeBlock.none
|
|
||||||
|
|
||||||
|
|
||||||
def test_speed_gate_is_inactive_below_threshold() -> None:
|
|
||||||
guard = LateralEdgeGuard()
|
|
||||||
update_for(guard, edge_model(4.0), BLOCK_DEBOUNCE_S, MIN_ACTIVE_SPEED_MPS - 0.01)
|
|
||||||
assert guard.block_for_direction(log.LaneChangeDirection.left) == custom.IQLateralEdgeBlock.none
|
|
||||||
update_for(guard, edge_model(4.0), BLOCK_DEBOUNCE_S, MIN_ACTIVE_SPEED_MPS)
|
|
||||||
assert guard.block_for_direction(log.LaneChangeDirection.left) == custom.IQLateralEdgeBlock.left
|
|
||||||
|
|
||||||
|
|
||||||
def test_desire_helper_keeps_edge_block_out_of_blindspot_path() -> None:
|
|
||||||
helper = DesireHelper()
|
|
||||||
helper.alc.lane_change_set_timer = AutoLaneChangeMode.NUDGE
|
|
||||||
helper.lane_change_state = log.LaneChangeState.preLaneChange
|
|
||||||
helper.lane_change_direction = log.LaneChangeDirection.left
|
|
||||||
update_for(helper.lateral_edge_guard, edge_model(4.0), BLOCK_DEBOUNCE_S)
|
|
||||||
blindspot_arguments: list[bool] = []
|
|
||||||
|
|
||||||
def record_blindspot(blindspot_detected: bool, brake_pressed: bool) -> None:
|
|
||||||
blindspot_arguments.append(blindspot_detected)
|
|
||||||
|
|
||||||
helper.alc.update_lane_change = record_blindspot
|
|
||||||
helper.update(CarState(left_blindspot=False), True, 1.0, modeldata=edge_model(4.0))
|
|
||||||
assert blindspot_arguments == [False]
|
|
||||||
assert helper.lateral_edge_block == custom.IQLateralEdgeBlock.left
|
|
||||||
assert helper.lane_change_state == log.LaneChangeState.preLaneChange
|
|
||||||
|
|
||||||
helper.update(CarState(left_blindspot=True), True, 1.0, modeldata=edge_model(4.0))
|
|
||||||
assert blindspot_arguments[-1] is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_published_edge_block_maps_to_distinct_event_and_alert() -> None:
|
|
||||||
message = messaging.new_message("iqDriveModelData")
|
|
||||||
message.iqDriveModelData.lateralEdgeBlock = custom.IQLateralEdgeBlock.right
|
|
||||||
|
|
||||||
class SubMaster:
|
|
||||||
updated = {"iqDriveModelData": True}
|
|
||||||
|
|
||||||
def __getitem__(self, service: str):
|
|
||||||
assert service == "iqDriveModelData"
|
|
||||||
return message.iqDriveModelData
|
|
||||||
|
|
||||||
selfdrived = SelfdriveD.__new__(SelfdriveD)
|
|
||||||
selfdrived.sm = SubMaster()
|
|
||||||
selfdrived._cached_model_event_names = ()
|
|
||||||
selfdrived._refresh_cached_model_events()
|
|
||||||
|
|
||||||
event_name = custom.IQOnroadEvent.EventName.lateralEdgeBlocked
|
|
||||||
assert selfdrived._cached_model_event_names == (event_name,)
|
|
||||||
alert = EVENTS_IQ[event_name][ET.WARNING]
|
|
||||||
assert alert.alert_text_1 == "Lane Change Blocked"
|
|
||||||
assert alert.alert_text_2 == "Road edge detected"
|
|
||||||
|
|
||||||
|
|
||||||
def test_visible_outer_lane_line_overrides_edge_block() -> None:
|
|
||||||
blocking = lane_model(4.0, outer_prob=0.0)
|
|
||||||
guard = LateralEdgeGuard()
|
|
||||||
update_for(guard, blocking, BLOCK_DEBOUNCE_S)
|
|
||||||
assert guard.block_for_direction(log.LaneChangeDirection.left) != custom.IQLateralEdgeBlock.none
|
|
||||||
|
|
||||||
guard = LateralEdgeGuard()
|
|
||||||
update_for(guard, lane_model(4.0, outer_prob=ADJACENT_LANE_LINE_PROB + 0.2), BLOCK_DEBOUNCE_S * 4)
|
|
||||||
assert guard.block_for_direction(log.LaneChangeDirection.left) == custom.IQLateralEdgeBlock.none
|
|
||||||
|
|
||||||
|
|
||||||
def test_outer_lane_line_below_threshold_still_blocks() -> None:
|
|
||||||
guard = LateralEdgeGuard()
|
|
||||||
update_for(guard, lane_model(4.0, outer_prob=ADJACENT_LANE_LINE_PROB - 0.1), BLOCK_DEBOUNCE_S)
|
|
||||||
assert guard.block_for_direction(log.LaneChangeDirection.left) != custom.IQLateralEdgeBlock.none
|
|
||||||
|
|
||||||
|
|
||||||
def test_narrow_measured_lane_relaxes_required_distance() -> None:
|
|
||||||
narrow = evaluate_road_edge(edge_model(4.3).roadEdges[0], 0.0, log.LaneChangeDirection.left, 3.0)
|
|
||||||
wide = evaluate_road_edge(edge_model(4.3).roadEdges[0], 0.0, log.LaneChangeDirection.left, LANE_CENTER_OFFSET_M)
|
|
||||||
assert narrow.should_block is False
|
|
||||||
assert wide.should_block is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_measured_lane_width_is_clamped_and_falls_back() -> None:
|
|
||||||
assert LateralEdgeGuard._measured_lane_width(None) == LANE_CENTER_OFFSET_M
|
|
||||||
assert LateralEdgeGuard._measured_lane_width(edge_model(4.0)) == LANE_CENTER_OFFSET_M
|
|
||||||
assert LateralEdgeGuard._measured_lane_width(lane_model(4.0, ego_prob=0.1)) == LANE_CENTER_OFFSET_M
|
|
||||||
assert LateralEdgeGuard._measured_lane_width(lane_model(4.0, ego_width_m=9.0)) == MAX_MEASURED_LANE_WIDTH_M
|
|
||||||
assert LateralEdgeGuard._measured_lane_width(lane_model(4.0, ego_width_m=0.5)) == MIN_MEASURED_LANE_WIDTH_M
|
|
||||||
assert LateralEdgeGuard._measured_lane_width(lane_model(4.0, ego_width_m=3.2)) == 3.2
|
|
||||||
@@ -590,7 +590,6 @@ class InferenceDaemon:
|
|||||||
driving_msg.drivingModelData.meta.laneChangeState = self._desire_logic.lane_change_state
|
driving_msg.drivingModelData.meta.laneChangeState = self._desire_logic.lane_change_state
|
||||||
driving_msg.drivingModelData.meta.laneChangeDirection = self._desire_logic.lane_change_direction
|
driving_msg.drivingModelData.meta.laneChangeDirection = self._desire_logic.lane_change_direction
|
||||||
iq_msg.iqDriveModelData.turnSignalDirection = self._desire_logic.lane_turn_direction
|
iq_msg.iqDriveModelData.turnSignalDirection = self._desire_logic.lane_turn_direction
|
||||||
iq_msg.iqDriveModelData.lateralEdgeBlock = self._desire_logic.lateral_edge_block
|
|
||||||
|
|
||||||
populate_odometry_message(
|
populate_odometry_message(
|
||||||
pose_msg,
|
pose_msg,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ _ACTIVE_BUNDLE_KEY = "ModelManager_ActiveBundle"
|
|||||||
_MODELS_CACHE_KEY = "ModelManager_ModelsCache"
|
_MODELS_CACHE_KEY = "ModelManager_ModelsCache"
|
||||||
_RUNNER_CACHE_KEY = "ModelRunnerTypeCache"
|
_RUNNER_CACHE_KEY = "ModelRunnerTypeCache"
|
||||||
_DOWNLOAD_INDEX_KEY = "ModelManager_DownloadIndex"
|
_DOWNLOAD_INDEX_KEY = "ModelManager_DownloadIndex"
|
||||||
|
_PENDING_INDEX_KEY = "ModelManager_PendingIndex"
|
||||||
_PENDING_MODEL_RESTORE_FILE = "/data/k3_pending_model_restore"
|
_PENDING_MODEL_RESTORE_FILE = "/data/k3_pending_model_restore"
|
||||||
_STOCK_RUNNER = int(Runner.stock)
|
_STOCK_RUNNER = int(Runner.stock)
|
||||||
_TINYGRAD_RUNNER = int(Runner.tinygrad)
|
_TINYGRAD_RUNNER = int(Runner.tinygrad)
|
||||||
@@ -226,6 +227,7 @@ def select_default_model(params: Params = None) -> None:
|
|||||||
bundle_dict = _load_default_bundle_dict()
|
bundle_dict = _load_default_bundle_dict()
|
||||||
ensure_default_model_files(bundle_dict)
|
ensure_default_model_files(bundle_dict)
|
||||||
params.remove(_DOWNLOAD_INDEX_KEY)
|
params.remove(_DOWNLOAD_INDEX_KEY)
|
||||||
|
params.remove(_PENDING_INDEX_KEY)
|
||||||
params.put(_ACTIVE_BUNDLE_KEY, bundle_dict)
|
params.put(_ACTIVE_BUNDLE_KEY, bundle_dict)
|
||||||
params.remove(_RUNNER_CACHE_KEY)
|
params.remove(_RUNNER_CACHE_KEY)
|
||||||
params.put(_RUNNER_CACHE_KEY, _TINYGRAD_RUNNER)
|
params.put(_RUNNER_CACHE_KEY, _TINYGRAD_RUNNER)
|
||||||
|
|||||||
@@ -155,14 +155,6 @@ class IQEvents(EventsBase):
|
|||||||
EVENTS_IQ_TYPE = dict[int, dict[str, Alert | AlertCallbackType]]
|
EVENTS_IQ_TYPE = dict[int, dict[str, Alert | AlertCallbackType]]
|
||||||
|
|
||||||
_GUIDANCE_EVENTS: EVENTS_IQ_TYPE = {
|
_GUIDANCE_EVENTS: EVENTS_IQ_TYPE = {
|
||||||
EventNameIQ.lateralEdgeBlocked: {
|
|
||||||
ET.WARNING: Alert(
|
|
||||||
"Lane Change Blocked",
|
|
||||||
"Road edge detected",
|
|
||||||
AlertStatus.userPrompt, AlertSize.mid,
|
|
||||||
Priority.LOW, VisualAlert.none, AudibleAlert.prompt, .1),
|
|
||||||
},
|
|
||||||
|
|
||||||
EventNameIQ.speedLimitActive: {
|
EventNameIQ.speedLimitActive: {
|
||||||
ET.WARNING: speed_limit_adjust_alert,
|
ET.WARNING: speed_limit_adjust_alert,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -222,9 +222,6 @@ class SelfdriveD(GapButtonActions):
|
|||||||
|
|
||||||
model_data = self._get_model_data_ext()
|
model_data = self._get_model_data_ext()
|
||||||
model_events = []
|
model_events = []
|
||||||
if model_data.lateralEdgeBlock != custom.IQLateralEdgeBlock.none:
|
|
||||||
model_events.append(custom.IQOnroadEvent.EventName.lateralEdgeBlocked)
|
|
||||||
|
|
||||||
lane_turn_direction = model_data.turnSignalDirection
|
lane_turn_direction = model_data.turnSignalDirection
|
||||||
if lane_turn_direction == TurnDirection.turnLeft:
|
if lane_turn_direction == TurnDirection.turnLeft:
|
||||||
model_events.append(custom.IQOnroadEvent.EventName.modelTurnLeft)
|
model_events.append(custom.IQOnroadEvent.EventName.modelTurnLeft)
|
||||||
|
|||||||
@@ -26,6 +26,22 @@ from iqpilot.system.version import get_build_metadata
|
|||||||
from iqpilot.system.hardware.hw import Paths
|
from iqpilot.system.hardware.hw import Paths
|
||||||
|
|
||||||
|
|
||||||
|
MODELD_WATCHDOG_TIMEOUT = 30.0
|
||||||
|
|
||||||
|
|
||||||
|
def update_modeld_watchdog(deadline: float | None, started: bool, model_updated: bool, process, now: float) -> float | None:
|
||||||
|
running = process.proc is not None and process.proc.is_alive()
|
||||||
|
if not started or not running:
|
||||||
|
return None
|
||||||
|
if deadline is None or model_updated:
|
||||||
|
return now + MODELD_WATCHDOG_TIMEOUT
|
||||||
|
if now >= deadline:
|
||||||
|
cloudlog.error("iqmodeld is alive but not publishing modelV2; restarting")
|
||||||
|
process.restart()
|
||||||
|
return now + MODELD_WATCHDOG_TIMEOUT
|
||||||
|
return deadline
|
||||||
|
|
||||||
|
|
||||||
def manager_init() -> None:
|
def manager_init() -> None:
|
||||||
heal_param_perms()
|
heal_param_perms()
|
||||||
save_bootlog()
|
save_bootlog()
|
||||||
@@ -55,16 +71,22 @@ def manager_init() -> None:
|
|||||||
params.put_bool("RecordFront", True)
|
params.put_bool("RecordFront", True)
|
||||||
|
|
||||||
# set unset params to their default value
|
# set unset params to their default value
|
||||||
|
initialized_defaults = {}
|
||||||
for k in params.all_keys():
|
for k in params.all_keys():
|
||||||
default_value = params.get_default_value(k)
|
default_value = params.get_default_value(k)
|
||||||
if default_value is not None and params.get(k) is None:
|
if default_value is not None and params.get(k) is None:
|
||||||
params.put(k, default_value)
|
params.put(k, default_value)
|
||||||
|
if default_value is not None:
|
||||||
|
initialized_defaults[k] = params.get(k)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from iqpilot.selfdrive.iqmodeld.models.helpers import seed_default_bundle_if_unset
|
from iqpilot.selfdrive.iqmodeld.models.helpers import seed_default_bundle_if_unset
|
||||||
seed_default_bundle_if_unset(params)
|
seed_default_bundle_if_unset(params)
|
||||||
except Exception:
|
except Exception:
|
||||||
cloudlog.exception("failed to seed default model bundle")
|
cloudlog.exception("failed to seed default model bundle")
|
||||||
|
for k, value in initialized_defaults.items():
|
||||||
|
if value is not None and params.get(k) is None:
|
||||||
|
params.put(k, value)
|
||||||
|
|
||||||
# Create folders needed for msgq
|
# Create folders needed for msgq
|
||||||
try:
|
try:
|
||||||
@@ -142,7 +164,7 @@ def manager_thread() -> None:
|
|||||||
ignore.append("pandad")
|
ignore.append("pandad")
|
||||||
ignore += [x for x in os.getenv("BLOCK", "").split(",") if len(x) > 0]
|
ignore += [x for x in os.getenv("BLOCK", "").split(",") if len(x) > 0]
|
||||||
|
|
||||||
sm = messaging.SubMaster(['deviceState', 'carParams', 'pandaStates'], poll='deviceState')
|
sm = messaging.SubMaster(['deviceState', 'carParams', 'pandaStates', 'modelV2'], poll='deviceState')
|
||||||
pm = messaging.PubMaster(['managerState'])
|
pm = messaging.PubMaster(['managerState'])
|
||||||
|
|
||||||
write_onroad_params(False, params)
|
write_onroad_params(False, params)
|
||||||
@@ -151,6 +173,7 @@ def manager_thread() -> None:
|
|||||||
started_prev = False
|
started_prev = False
|
||||||
ignition_prev = False
|
ignition_prev = False
|
||||||
running_prev = None
|
running_prev = None
|
||||||
|
modeld_deadline = None
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
sm.update(1000)
|
sm.update(1000)
|
||||||
@@ -178,6 +201,9 @@ def manager_thread() -> None:
|
|||||||
ignition_prev = ignition
|
ignition_prev = ignition
|
||||||
|
|
||||||
ensure_running(managed_processes.values(), started, params=params, CP=sm['carParams'], not_run=ignore)
|
ensure_running(managed_processes.values(), started, params=params, CP=sm['carParams'], not_run=ignore)
|
||||||
|
modeld_deadline = update_modeld_watchdog(
|
||||||
|
modeld_deadline, started, sm.updated['modelV2'], managed_processes['iqmodeld'], time.monotonic()
|
||||||
|
)
|
||||||
|
|
||||||
# print only on change (reprinting every loop floods the shared tmux); always logged
|
# print only on change (reprinting every loop floods the shared tmux); always logged
|
||||||
procs = [p for p in managed_processes.values() if p.proc]
|
procs = [p for p in managed_processes.values() if p.proc]
|
||||||
|
|||||||
@@ -42,6 +42,31 @@ class TestManager:
|
|||||||
assert proc.bundle == "iqpilot_model_selector_private"
|
assert proc.bundle == "iqpilot_model_selector_private"
|
||||||
assert proc.entry == "iqpilot_private.models.manager"
|
assert proc.entry == "iqpilot_private.models.manager"
|
||||||
|
|
||||||
|
def test_modeld_watchdog_restarts_stalled_process(self, mocker):
|
||||||
|
proc = mocker.Mock()
|
||||||
|
proc.proc.is_alive.return_value = True
|
||||||
|
|
||||||
|
deadline = manager.update_modeld_watchdog(None, True, False, proc, 10.0)
|
||||||
|
assert deadline == 10.0 + manager.MODELD_WATCHDOG_TIMEOUT
|
||||||
|
assert manager.update_modeld_watchdog(deadline, True, False, proc, deadline - 0.1) == deadline
|
||||||
|
proc.restart.assert_not_called()
|
||||||
|
|
||||||
|
next_deadline = manager.update_modeld_watchdog(deadline, True, False, proc, deadline)
|
||||||
|
proc.restart.assert_called_once_with()
|
||||||
|
assert next_deadline == deadline + manager.MODELD_WATCHDOG_TIMEOUT
|
||||||
|
|
||||||
|
def test_modeld_watchdog_tracks_output_and_resets(self, mocker):
|
||||||
|
proc = mocker.Mock()
|
||||||
|
proc.proc.is_alive.return_value = True
|
||||||
|
|
||||||
|
deadline = manager.update_modeld_watchdog(20.0, True, True, proc, 15.0)
|
||||||
|
assert deadline == 15.0 + manager.MODELD_WATCHDOG_TIMEOUT
|
||||||
|
assert manager.update_modeld_watchdog(deadline, False, False, proc, 16.0) is None
|
||||||
|
|
||||||
|
proc.proc.is_alive.return_value = False
|
||||||
|
assert manager.update_modeld_watchdog(deadline, True, False, proc, 16.0) is None
|
||||||
|
proc.restart.assert_not_called()
|
||||||
|
|
||||||
def test_bundle_process_stops_with_sigterm(self, mocker):
|
def test_bundle_process_stops_with_sigterm(self, mocker):
|
||||||
proc = BundleProcess("test", "bundle", "entry", lambda *_: True)
|
proc = BundleProcess("test", "bundle", "entry", lambda *_: True)
|
||||||
proc.proc = mocker.Mock(exitcode=None, pid=123)
|
proc.proc = mocker.Mock(exitcode=None, pid=123)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
PACKAGE_NAMES = ("msgq", "iqdbc", "panda", "teleoprtc", "tinygrad")
|
PACKAGE_NAMES = ("msgq", "iqdbc", "panda", "tinygrad")
|
||||||
root = Path(sys.argv[1]).resolve()
|
root = Path(sys.argv[1]).resolve()
|
||||||
text = (root / "pyproject.toml").read_text()
|
text = (root / "pyproject.toml").read_text()
|
||||||
for name in PACKAGE_NAMES:
|
for name in PACKAGE_NAMES:
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import sys
|
|||||||
from importlib import metadata
|
from importlib import metadata
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
PACKAGES = ("iqdbc", "msgq", "panda", "teleoprtc", "tinygrad")
|
PACKAGES = ("iqdbc", "msgq", "panda", "tinygrad")
|
||||||
|
|
||||||
missing = []
|
missing = []
|
||||||
for name in PACKAGES:
|
for name in PACKAGES:
|
||||||
|
|||||||
47
iqpilot/system/runtime_wheel_requirements.py
Normal file
47
iqpilot/system/runtime_wheel_requirements.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import hashlib
|
||||||
|
import sys
|
||||||
|
import tomllib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
PACKAGE_NAMES = ("libdatachannel-py",)
|
||||||
|
PACKAGE_IMPORTS = ("libdatachannel",)
|
||||||
|
PACKAGE_WHEELS = {
|
||||||
|
"libdatachannel-py": "libdatachannel_py-2026.1.0.dev2-cp312-cp312-manylinux_2_35_aarch64.whl",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def locked_packages(root: Path) -> dict[str, dict]:
|
||||||
|
lock = tomllib.loads((root / "uv.lock").read_text())
|
||||||
|
packages = {package["name"]: package for package in lock["package"] if package["name"] in PACKAGE_NAMES}
|
||||||
|
if packages.keys() != set(PACKAGE_NAMES):
|
||||||
|
raise RuntimeError("missing locked runtime wheel")
|
||||||
|
return packages
|
||||||
|
|
||||||
|
|
||||||
|
def requirements(root: Path) -> list[str]:
|
||||||
|
packages = locked_packages(root)
|
||||||
|
return [f"{name}=={packages[name]['version']}" for name in PACKAGE_NAMES]
|
||||||
|
|
||||||
|
|
||||||
|
def install_sources(root: Path) -> list[str]:
|
||||||
|
packages = locked_packages(root)
|
||||||
|
sources = []
|
||||||
|
for name in PACKAGE_NAMES:
|
||||||
|
filename = PACKAGE_WHEELS[name]
|
||||||
|
wheel = root / "artifacts" / "runtime_wheels" / filename
|
||||||
|
locked_wheel = next((item for item in packages[name]["wheels"] if item["url"].endswith(f"/{filename}")), None)
|
||||||
|
if locked_wheel is None or not wheel.is_file():
|
||||||
|
raise RuntimeError(f"missing vendored runtime wheel for {name}")
|
||||||
|
expected = locked_wheel["hash"].removeprefix("sha256:")
|
||||||
|
if hashlib.sha256(wheel.read_bytes()).hexdigest() != expected:
|
||||||
|
raise RuntimeError(f"invalid vendored runtime wheel for {name}")
|
||||||
|
sources.append(str(wheel))
|
||||||
|
return sources
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
root = Path(sys.argv[1]).resolve()
|
||||||
|
values = install_sources(root) if "--sources" in sys.argv[2:] else requirements(root)
|
||||||
|
print("\n".join(values))
|
||||||
31
iqpilot/system/runtime_wheels_verify.py
Normal file
31
iqpilot/system/runtime_wheels_verify.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import csv
|
||||||
|
import sys
|
||||||
|
from importlib import metadata
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from iqpilot.system.runtime_wheel_requirements import PACKAGE_NAMES
|
||||||
|
|
||||||
|
|
||||||
|
missing = []
|
||||||
|
for name in PACKAGE_NAMES:
|
||||||
|
try:
|
||||||
|
dist = metadata.distribution(name)
|
||||||
|
except metadata.PackageNotFoundError:
|
||||||
|
missing.append(f"{name}: not installed")
|
||||||
|
continue
|
||||||
|
record = dist.read_text("RECORD")
|
||||||
|
if not record:
|
||||||
|
missing.append(f"{name}: no RECORD")
|
||||||
|
continue
|
||||||
|
base = Path(str(dist._path)).parent
|
||||||
|
for row in csv.reader(record.splitlines()):
|
||||||
|
if not row or row[0].endswith((".pyc", "/")):
|
||||||
|
continue
|
||||||
|
if not (base / row[0]).exists():
|
||||||
|
missing.append(f"{name}: {row[0]}")
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
print("\n".join(missing[:20]), file=sys.stderr)
|
||||||
|
print(f"runtime wheels damaged: {len(missing)} missing files", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import fractions
|
|
||||||
|
|
||||||
import aiortc
|
|
||||||
import av
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
from iqpilot.cereal import messaging
|
|
||||||
|
|
||||||
|
|
||||||
WEBRTC_AUDIO_SERVICE = "webrtcAudioData"
|
|
||||||
WEBRTC_AUDIO_PTIME = 0.020
|
|
||||||
|
|
||||||
|
|
||||||
class AudioInputStreamTrack(aiortc.mediastreams.AudioStreamTrack):
|
|
||||||
"""Device microphone -> WebRTC, sourced from micd's `rawAudioData` cereal stream.
|
|
||||||
|
|
||||||
micd owns the ALSA capture device, so opening it again via PyAudio fails with a host error
|
|
||||||
('audio in use', PortAudio errno -9999). Instead we consume micd's already-published int16 mono
|
|
||||||
PCM and repacketize it into WebRTC audio frames — no device contention, and it works whenever micd
|
|
||||||
is running. Reading one message per recv() paces playout to micd's real-time publish rate.
|
|
||||||
"""
|
|
||||||
def __init__(self, rate: int = 16000, channels: int = 1):
|
|
||||||
super().__init__()
|
|
||||||
self.rate = rate
|
|
||||||
self.channels = channels
|
|
||||||
# conflate=False: keep audio continuous (don't drop buffered samples) for clean playback.
|
|
||||||
self._sock = messaging.sub_sock("rawAudioData", conflate=False)
|
|
||||||
self._start: float | None = None
|
|
||||||
self.pts = 0
|
|
||||||
self.enabled = True
|
|
||||||
self._audio_buffer = bytearray()
|
|
||||||
|
|
||||||
def enable(self, enabled: bool) -> None:
|
|
||||||
self.enabled = enabled
|
|
||||||
|
|
||||||
async def _fill_audio_buffer(self, target_bytes: int) -> None:
|
|
||||||
deadline = asyncio.get_running_loop().time() + WEBRTC_AUDIO_PTIME
|
|
||||||
while len(self._audio_buffer) < target_bytes:
|
|
||||||
msg = messaging.recv_one_or_none(self._sock)
|
|
||||||
if msg is not None:
|
|
||||||
audio = msg.rawAudioData
|
|
||||||
rate = int(audio.sampleRate) or self.rate
|
|
||||||
if rate != self.rate:
|
|
||||||
self.rate = rate
|
|
||||||
self._audio_buffer.clear()
|
|
||||||
self._start = None
|
|
||||||
self.pts = 0
|
|
||||||
self._audio_buffer.extend(bytes(audio.data))
|
|
||||||
continue
|
|
||||||
if asyncio.get_running_loop().time() >= deadline:
|
|
||||||
break
|
|
||||||
await asyncio.sleep(0.005)
|
|
||||||
|
|
||||||
async def _next_audio_data(self) -> tuple[bytes, int]:
|
|
||||||
samples = max(1, int(WEBRTC_AUDIO_PTIME * self.rate))
|
|
||||||
target_bytes = samples * 2
|
|
||||||
await self._fill_audio_buffer(target_bytes)
|
|
||||||
|
|
||||||
if len(self._audio_buffer) >= target_bytes:
|
|
||||||
data = bytes(self._audio_buffer[:target_bytes])
|
|
||||||
del self._audio_buffer[:target_bytes]
|
|
||||||
else:
|
|
||||||
data = bytes(self._audio_buffer)
|
|
||||||
self._audio_buffer.clear()
|
|
||||||
data += bytes(target_bytes - len(data))
|
|
||||||
|
|
||||||
return data, self.rate
|
|
||||||
|
|
||||||
async def _pace(self, pts: int, sample_rate: int) -> None:
|
|
||||||
if self._start is None:
|
|
||||||
self._start = asyncio.get_running_loop().time()
|
|
||||||
return
|
|
||||||
|
|
||||||
wait = self._start + (pts / sample_rate) - asyncio.get_running_loop().time()
|
|
||||||
if wait > 0:
|
|
||||||
await asyncio.sleep(wait)
|
|
||||||
|
|
||||||
async def recv(self):
|
|
||||||
while True:
|
|
||||||
if not self.enabled:
|
|
||||||
break
|
|
||||||
data, sample_rate = await self._next_audio_data()
|
|
||||||
if data:
|
|
||||||
samples = len(data) // 2
|
|
||||||
pts = self.pts
|
|
||||||
self.pts += samples
|
|
||||||
await self._pace(pts, sample_rate)
|
|
||||||
|
|
||||||
frame = av.AudioFrame(format="s16", layout="mono", samples=samples)
|
|
||||||
frame.planes[0].update(data)
|
|
||||||
frame.pts = pts
|
|
||||||
frame.sample_rate = sample_rate
|
|
||||||
frame.time_base = fractions.Fraction(1, sample_rate)
|
|
||||||
return frame
|
|
||||||
|
|
||||||
samples_per_frame = max(1, int(WEBRTC_AUDIO_PTIME * self.rate))
|
|
||||||
samples = np.zeros((1, samples_per_frame), dtype=np.int16)
|
|
||||||
frame = av.AudioFrame.from_ndarray(samples, format='s16', layout='mono')
|
|
||||||
frame.sample_rate = self.rate
|
|
||||||
frame.time_base = fractions.Fraction(1, self.rate)
|
|
||||||
frame.pts = self.pts
|
|
||||||
self.pts += frame.samples
|
|
||||||
await self._pace(frame.pts, self.rate)
|
|
||||||
return frame
|
|
||||||
@@ -7,17 +7,19 @@ import av
|
|||||||
|
|
||||||
from iqpilot.cereal import messaging
|
from iqpilot.cereal import messaging
|
||||||
from iqpilot.selfdrive.ui.soundd import SAMPLE_RATE as SOUND_SAMPLE_RATE
|
from iqpilot.selfdrive.ui.soundd import SAMPLE_RATE as SOUND_SAMPLE_RATE
|
||||||
from iqpilot.system.webrtc.device.audio import WEBRTC_AUDIO_PTIME, WEBRTC_AUDIO_SERVICE
|
|
||||||
|
|
||||||
|
WEBRTC_AUDIO_SERVICE = "webrtcAudioData"
|
||||||
|
WEBRTC_AUDIO_PTIME = 0.020
|
||||||
|
|
||||||
|
|
||||||
class AudioInputOpusProducer:
|
class AudioInputOpusProducer:
|
||||||
"""Micd PCM -> 48 kHz Opus payloads for libdatachannel's RTP packetizer."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._sock = messaging.sub_sock("rawAudioData", conflate=False)
|
self._sock = messaging.sub_sock("rawAudioData", conflate=False)
|
||||||
self._pcm = bytearray()
|
self._pcm = bytearray()
|
||||||
self._source_rate = 16_000
|
self._source_rate = 16_000
|
||||||
self._next_pts = 0
|
self._next_pts = 0
|
||||||
|
self._packet_pts = 0
|
||||||
self._pending: deque[tuple[bytes, int]] = deque()
|
self._pending: deque[tuple[bytes, int]] = deque()
|
||||||
self._enabled = True
|
self._enabled = True
|
||||||
self._resampler = av.AudioResampler(format="fltp", layout="mono", rate=48_000)
|
self._resampler = av.AudioResampler(format="fltp", layout="mono", rate=48_000)
|
||||||
@@ -65,13 +67,22 @@ class AudioInputOpusProducer:
|
|||||||
frame.time_base = Fraction(1, 48_000)
|
frame.time_base = Fraction(1, 48_000)
|
||||||
self._next_pts += frame.samples
|
self._next_pts += frame.samples
|
||||||
for packet in self._encoder.encode(frame):
|
for packet in self._encoder.encode(frame):
|
||||||
self._pending.append((bytes(packet), int(packet.pts or 0)))
|
self._pending.append((bytes(packet), self._packet_pts))
|
||||||
|
self._packet_pts += int(packet.duration or frame.samples)
|
||||||
return self._pending.popleft()
|
return self._pending.popleft()
|
||||||
|
|
||||||
|
|
||||||
class IncomingOpusCerealProxy:
|
class DebugAudioOpusProducer(AudioInputOpusProducer):
|
||||||
"""libdatachannel Opus payloads -> soundd-compatible PCM cereal messages."""
|
async def _read_pcm_frame(self) -> av.AudioFrame:
|
||||||
|
samples = int(WEBRTC_AUDIO_PTIME * self._source_rate)
|
||||||
|
await asyncio.sleep(WEBRTC_AUDIO_PTIME)
|
||||||
|
frame = av.AudioFrame(format="s16", layout="mono", samples=samples)
|
||||||
|
frame.planes[0].update(bytes(samples * 2))
|
||||||
|
frame.sample_rate = self._source_rate
|
||||||
|
return frame
|
||||||
|
|
||||||
|
|
||||||
|
class IncomingOpusCerealProxy:
|
||||||
def __init__(self, track) -> None:
|
def __init__(self, track) -> None:
|
||||||
self._loop = asyncio.get_running_loop()
|
self._loop = asyncio.get_running_loop()
|
||||||
self._queue: asyncio.Queue[bytes] = asyncio.Queue(maxsize=32)
|
self._queue: asyncio.Queue[bytes] = asyncio.Queue(maxsize=32)
|
||||||
@@ -4,7 +4,7 @@ import struct
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
import av
|
import av
|
||||||
from iqpilot.system.webrtc.teleoprtc_ldc.tracks import TiciVideoStreamTrack
|
from iqpilot.system.webrtc.rtc.tracks import TiciVideoStreamTrack
|
||||||
|
|
||||||
from iqpilot.cereal import messaging
|
from iqpilot.cereal import messaging
|
||||||
from iqpilot.common.params import Params
|
from iqpilot.common.params import Params
|
||||||
@@ -272,3 +272,40 @@ class LiveStreamVideoStreamTrack(TiciVideoStreamTrack):
|
|||||||
|
|
||||||
def codec_preference(self) -> str | None:
|
def codec_preference(self) -> str | None:
|
||||||
return "H264"
|
return "H264"
|
||||||
|
|
||||||
|
|
||||||
|
class DebugVideoStreamTrack(TiciVideoStreamTrack):
|
||||||
|
def __init__(self, camera_type: str):
|
||||||
|
super().__init__(camera_type, 0.05)
|
||||||
|
self._codec = av.CodecContext.create("libx264", "w")
|
||||||
|
self._codec.width = 640
|
||||||
|
self._codec.height = 480
|
||||||
|
self._codec.pix_fmt = "yuv420p"
|
||||||
|
self._codec.time_base = self._time_base
|
||||||
|
self._codec.framerate = 20
|
||||||
|
self._codec.options = {"preset": "ultrafast", "tune": "zerolatency"}
|
||||||
|
self._codec.open()
|
||||||
|
self._pts = 0
|
||||||
|
self.timing_sei_enabled = False
|
||||||
|
|
||||||
|
async def recv(self):
|
||||||
|
await asyncio.sleep(self._dt)
|
||||||
|
frame = av.VideoFrame(self._codec.width, self._codec.height, "yuv420p")
|
||||||
|
frame.planes[0].update(bytes(frame.planes[0].buffer_size))
|
||||||
|
for plane in frame.planes[1:]:
|
||||||
|
plane.update(bytes([128]) * plane.buffer_size)
|
||||||
|
frame.pts = self._pts
|
||||||
|
self._pts += int(self._dt * self._clock_rate)
|
||||||
|
packets = self._codec.encode(frame)
|
||||||
|
if not packets:
|
||||||
|
return await self.recv()
|
||||||
|
packet = av.Packet(b"".join(bytes(encoded) for encoded in packets))
|
||||||
|
packet.pts = frame.pts
|
||||||
|
packet.dts = frame.pts
|
||||||
|
packet.time_base = self._time_base
|
||||||
|
packet.duration = int(self._dt * self._clock_rate)
|
||||||
|
return packet
|
||||||
|
|
||||||
|
def switch_camera(self, camera_type: str) -> None:
|
||||||
|
if camera_type not in LiveStreamVideoStreamTrack.livestream_camera_to_sock_mapping:
|
||||||
|
raise ValueError(f"Unknown camera {camera_type}")
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import struct
|
|
||||||
import time
|
|
||||||
|
|
||||||
import av
|
|
||||||
from teleoprtc.tracks import TiciVideoStreamTrack
|
|
||||||
|
|
||||||
from iqpilot.cereal import messaging
|
|
||||||
from iqpilot.common.params import Params
|
|
||||||
from iqpilot.common.realtime import DT_MDL, DT_DMON
|
|
||||||
|
|
||||||
# Arbitrary 16-byte UUID identifying konn3kt frame-timing SEI messages. When timing
|
|
||||||
# telemetry is enabled, each frame carries a user_data_unregistered SEI NAL with four
|
|
||||||
# big-endian doubles (ms): encode duration, IPC/queue delay, host transit, and the
|
|
||||||
# device wall clock. The client decodes these to compute true glass-to-glass latency.
|
|
||||||
TIMING_SEI_UUID = bytes([
|
|
||||||
0xa5, 0xe0, 0xc4, 0xa4, 0x5b, 0x6e, 0x4e, 0x1e,
|
|
||||||
0x9c, 0x7e, 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc,
|
|
||||||
])
|
|
||||||
# Annex-B start code + SEI NAL (type 6) + user_data_unregistered (type 5) + payload size
|
|
||||||
# (0x30 = 48 bytes = 16 UUID + 32 data). Trailing 0x80 is the RBSP stop bit.
|
|
||||||
_SEI_PREFIX = b'\x00\x00\x00\x01\x06\x05\x30' + TIMING_SEI_UUID
|
|
||||||
|
|
||||||
|
|
||||||
class LiveStreamVideoStreamTrack(TiciVideoStreamTrack):
|
|
||||||
livestream_camera_to_sock_mapping = {
|
|
||||||
"driver": "livestreamDriverEncodeData",
|
|
||||||
"wideRoad": "livestreamWideRoadEncodeData",
|
|
||||||
"road": "livestreamRoadEncodeData",
|
|
||||||
}
|
|
||||||
main_camera_to_sock_mapping = {
|
|
||||||
"driver": "driverEncodeData",
|
|
||||||
"wideRoad": "wideRoadEncodeData",
|
|
||||||
"road": "roadEncodeData",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Number of live tracks still waiting for their first keyframe. The on-demand
|
|
||||||
# keyframe request (LivestreamRequestKeyframe) is a single global param honored by
|
|
||||||
# every encoder, so with multiple concurrent tracks (dual-camera PiP) we must not
|
|
||||||
# clear it until *all* tracks have received an IDR — otherwise the first track to
|
|
||||||
# get its keyframe clears the request and starves the others (black feed).
|
|
||||||
_kf_pending_count = 0
|
|
||||||
|
|
||||||
def __init__(self, camera_type: str):
|
|
||||||
dt = DT_DMON if camera_type == "driver" else DT_MDL
|
|
||||||
super().__init__(camera_type, dt)
|
|
||||||
|
|
||||||
self._params = Params()
|
|
||||||
self._camera_type = camera_type
|
|
||||||
self._candidate_topics = [
|
|
||||||
self.main_camera_to_sock_mapping[camera_type],
|
|
||||||
self.livestream_camera_to_sock_mapping[camera_type],
|
|
||||||
]
|
|
||||||
self._socks = {topic: messaging.sub_sock(topic, conflate=True) for topic in self._candidate_topics}
|
|
||||||
self._active_topic = self._preferred_topics()[0]
|
|
||||||
self._pts = 0
|
|
||||||
self._t0_ns = time.monotonic_ns()
|
|
||||||
self._cached_header: bytes = b""
|
|
||||||
self._sent_keyframe = False
|
|
||||||
self._kf_requested = False # whether this track counts toward _kf_pending_count
|
|
||||||
self._frame_count = 0
|
|
||||||
self._last_frame_time = 0.0
|
|
||||||
self._last_preference_refresh = 0.0
|
|
||||||
# Tracks how long the H264 livestream feed has been silent, to gate the last-resort main-feed
|
|
||||||
# fallback (see recv) without flapping between sources frame-by-frame.
|
|
||||||
self._live_silent_since: float | None = None
|
|
||||||
# Opt-in glass-to-glass latency telemetry (toggled by the client over the data channel).
|
|
||||||
self.timing_sei_enabled = False
|
|
||||||
self._logger = logging.getLogger("LiveStreamVideoStreamTrack")
|
|
||||||
|
|
||||||
# Ask the encoder for an immediate IDR so the stream starts fast instead of waiting up to a full
|
|
||||||
# GOP for the next periodic keyframe (encoderd honors LivestreamRequestKeyframe per-frame).
|
|
||||||
self._mark_keyframe_needed()
|
|
||||||
|
|
||||||
def _request_keyframe(self, enabled: bool) -> None:
|
|
||||||
try:
|
|
||||||
self._params.put_bool("LivestreamRequestKeyframe", enabled)
|
|
||||||
except Exception:
|
|
||||||
self._logger.exception("failed to set LivestreamRequestKeyframe")
|
|
||||||
|
|
||||||
def _mark_keyframe_needed(self) -> None:
|
|
||||||
"""This track needs (another) keyframe: keep the global request asserted."""
|
|
||||||
if not self._kf_requested:
|
|
||||||
LiveStreamVideoStreamTrack._kf_pending_count += 1
|
|
||||||
self._kf_requested = True
|
|
||||||
self._request_keyframe(True)
|
|
||||||
|
|
||||||
def request_keyframe(self) -> None:
|
|
||||||
self._mark_keyframe_needed()
|
|
||||||
|
|
||||||
def _mark_keyframe_received(self) -> None:
|
|
||||||
"""This track got its keyframe; only clear the global request once no track needs one."""
|
|
||||||
if self._kf_requested:
|
|
||||||
self._kf_requested = False
|
|
||||||
LiveStreamVideoStreamTrack._kf_pending_count = max(0, LiveStreamVideoStreamTrack._kf_pending_count - 1)
|
|
||||||
if LiveStreamVideoStreamTrack._kf_pending_count == 0:
|
|
||||||
self._request_keyframe(False)
|
|
||||||
|
|
||||||
def stop(self):
|
|
||||||
# Release our pending-keyframe hold so a torn-down track that never received an
|
|
||||||
# IDR doesn't pin LivestreamRequestKeyframe True forever (continuous keyframes).
|
|
||||||
if getattr(self, "_kf_requested", False):
|
|
||||||
self._kf_requested = False
|
|
||||||
LiveStreamVideoStreamTrack._kf_pending_count = max(0, LiveStreamVideoStreamTrack._kf_pending_count - 1)
|
|
||||||
try:
|
|
||||||
super().stop()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def switch_camera(self, camera_type: str) -> None:
|
|
||||||
"""Repoint this track at a different camera without renegotiating the peer connection.
|
|
||||||
|
|
||||||
Lets a single video track back the whole Live View — the client flips cameras over the
|
|
||||||
data channel and we swap the source here, instead of uplinking every camera at once."""
|
|
||||||
if camera_type not in self.livestream_camera_to_sock_mapping:
|
|
||||||
self._logger.warning("[%s] ignoring switch to unknown camera %s", self._id, camera_type)
|
|
||||||
return
|
|
||||||
if camera_type == self._camera_type:
|
|
||||||
return
|
|
||||||
self._logger.info("[%s] switching camera %s -> %s", self._id, self._camera_type, camera_type)
|
|
||||||
self._camera_type = camera_type
|
|
||||||
self._candidate_topics = [
|
|
||||||
self.main_camera_to_sock_mapping[camera_type],
|
|
||||||
self.livestream_camera_to_sock_mapping[camera_type],
|
|
||||||
]
|
|
||||||
self._socks = {topic: messaging.sub_sock(topic, conflate=True) for topic in self._candidate_topics}
|
|
||||||
self._active_topic = self._preferred_topics()[0]
|
|
||||||
# Force a fresh keyframe/header before emitting frames from the new source, and ask the encoder
|
|
||||||
# for an immediate IDR so the camera switch isn't stalled waiting for the next periodic keyframe.
|
|
||||||
self._cached_header = b""
|
|
||||||
self._sent_keyframe = False
|
|
||||||
self._last_preference_refresh = 0.0
|
|
||||||
self._live_silent_since = None
|
|
||||||
self._mark_keyframe_needed()
|
|
||||||
|
|
||||||
def _preferred_topics(self) -> list[str]:
|
|
||||||
# WebRTC currently forces H.264. The dedicated livestream topics are the H.264 feeds,
|
|
||||||
# while the main encode topics are the full-resolution HEVC recordings. Prefer the
|
|
||||||
# livestream feeds both onroad and offroad, and keep the main topics only as fallback.
|
|
||||||
return [
|
|
||||||
self.livestream_camera_to_sock_mapping[self._camera_type],
|
|
||||||
self.main_camera_to_sock_mapping[self._camera_type],
|
|
||||||
]
|
|
||||||
|
|
||||||
def _reset_decoder_state(self, topic: str) -> None:
|
|
||||||
if topic == self._active_topic:
|
|
||||||
return
|
|
||||||
self._logger.info("[%s] switching video source from %s to %s", self._id, self._active_topic, topic)
|
|
||||||
self._active_topic = topic
|
|
||||||
self._cached_header = b""
|
|
||||||
self._sent_keyframe = False
|
|
||||||
|
|
||||||
def _timing_sei(self, evta, log_mono_time: int) -> bytes:
|
|
||||||
"""Build a timing SEI NAL from encode metadata, or empty bytes when disabled."""
|
|
||||||
if not self.timing_sei_enabled:
|
|
||||||
return b""
|
|
||||||
idx = evta.idx
|
|
||||||
return _SEI_PREFIX + struct.pack(
|
|
||||||
'>4d',
|
|
||||||
(idx.timestampEof - idx.timestampSof) / 1e6, # encode duration (ms)
|
|
||||||
(log_mono_time - idx.timestampEof) / 1e6, # IPC/queue delay (ms)
|
|
||||||
(time.monotonic_ns() - log_mono_time) / 1e6, # host transit so far (ms)
|
|
||||||
time.time() * 1000, # device wall clock (ms) # noqa: TID251
|
|
||||||
) + b'\x80'
|
|
||||||
|
|
||||||
def _is_keyframe(self, data: bytes) -> bool:
|
|
||||||
"""Check if H.264 NAL unit contains an IDR keyframe (NAL type 5)."""
|
|
||||||
i = 0
|
|
||||||
while i < len(data) - 4:
|
|
||||||
# Look for Annex B start codes: 0x000001 or 0x00000001
|
|
||||||
if data[i:i+3] == b'\x00\x00\x01':
|
|
||||||
nal_type = data[i+3] & 0x1f
|
|
||||||
if nal_type == 5: # IDR slice
|
|
||||||
return True
|
|
||||||
i += 3
|
|
||||||
elif data[i:i+4] == b'\x00\x00\x00\x01':
|
|
||||||
nal_type = data[i+4] & 0x1f
|
|
||||||
if nal_type == 5: # IDR slice
|
|
||||||
return True
|
|
||||||
i += 4
|
|
||||||
else:
|
|
||||||
i += 1
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def recv(self):
|
|
||||||
while True:
|
|
||||||
now = time.monotonic()
|
|
||||||
# Resolve topics each iteration: a camera switch (different async task) can rebuild self._socks
|
|
||||||
# across the await below, so a value cached before the loop would index a stale key (KeyError).
|
|
||||||
live_topic = self.livestream_camera_to_sock_mapping[self._camera_type]
|
|
||||||
main_topic = self.main_camera_to_sock_mapping[self._camera_type]
|
|
||||||
# Lock onto the dedicated H264 livestream feed. Onroad the HEVC main feed also publishes at
|
|
||||||
# 20fps; eagerly preferring whichever socket had a frame ready raced frame-by-frame, reset the
|
|
||||||
# decoder every frame, and (the track is negotiated H264) shoved HEVC garbage into the stream —
|
|
||||||
# the onroad choppiness. Only fall back to the main feed as a last resort after a long
|
|
||||||
# livestream silence (e.g. stream_encoderd still spinning up), and snap back when it returns.
|
|
||||||
msg = messaging.recv_one_or_none(self._socks[live_topic])
|
|
||||||
if msg is not None:
|
|
||||||
self._reset_decoder_state(live_topic)
|
|
||||||
self._last_frame_time = now
|
|
||||||
self._live_silent_since = None
|
|
||||||
break
|
|
||||||
|
|
||||||
if self._live_silent_since is None:
|
|
||||||
self._live_silent_since = now
|
|
||||||
elif now - self._live_silent_since > 3.0:
|
|
||||||
maybe_msg = messaging.recv_one_or_none(self._socks[main_topic])
|
|
||||||
if maybe_msg is not None:
|
|
||||||
self._reset_decoder_state(main_topic)
|
|
||||||
self._last_frame_time = now
|
|
||||||
msg = maybe_msg
|
|
||||||
break
|
|
||||||
|
|
||||||
await asyncio.sleep(0.005)
|
|
||||||
|
|
||||||
evta = getattr(msg, msg.which())
|
|
||||||
|
|
||||||
header = bytes(evta.header)
|
|
||||||
data = bytes(evta.data)
|
|
||||||
self._frame_count += 1
|
|
||||||
|
|
||||||
# Cache SPS/PPS header when it arrives
|
|
||||||
if header:
|
|
||||||
self._cached_header = header
|
|
||||||
self._logger.debug(f"[{self._id}] cached SPS/PPS header ({len(header)} bytes)")
|
|
||||||
|
|
||||||
# CRITICAL: Cannot decode without SPS/PPS. Wait for it.
|
|
||||||
if not self._cached_header:
|
|
||||||
self._logger.debug(f"[{self._id}] frame {self._frame_count}: no SPS/PPS yet, skipping")
|
|
||||||
return await self.recv()
|
|
||||||
|
|
||||||
is_keyframe = self._is_keyframe(data)
|
|
||||||
|
|
||||||
# Wait for first keyframe before sending any frames
|
|
||||||
# Browser decoder needs IDR to initialize properly
|
|
||||||
if not self._sent_keyframe:
|
|
||||||
if not is_keyframe:
|
|
||||||
self._logger.debug(f"[{self._id}] frame {self._frame_count}: waiting for keyframe")
|
|
||||||
return await self.recv()
|
|
||||||
self._sent_keyframe = True
|
|
||||||
# Got the IDR we asked for — stop nagging the encoder, but only once every
|
|
||||||
# concurrent track has its keyframe (multi-track PiP shares the global param).
|
|
||||||
self._mark_keyframe_received()
|
|
||||||
self._logger.info(f"[{self._id}] first keyframe received, starting stream")
|
|
||||||
|
|
||||||
# Optional timing SEI NAL, inserted before the slice data (and after SPS/PPS on keyframes).
|
|
||||||
sei_nal = self._timing_sei(evta, msg.logMonoTime)
|
|
||||||
|
|
||||||
# Prepend SPS/PPS header to keyframes (required by some decoders)
|
|
||||||
# For non-keyframes, header is optional but safe to include
|
|
||||||
if is_keyframe:
|
|
||||||
payload = self._cached_header + sei_nal + data
|
|
||||||
else:
|
|
||||||
payload = sei_nal + data
|
|
||||||
|
|
||||||
self._pts = ((time.monotonic_ns() - self._t0_ns) * self._clock_rate) // 1_000_000_000
|
|
||||||
|
|
||||||
packet = av.Packet(payload)
|
|
||||||
packet.time_base = self._time_base
|
|
||||||
packet.pts = int(self._pts)
|
|
||||||
packet.dts = int(self._pts)
|
|
||||||
packet.duration = int(self._dt * self._clock_rate)
|
|
||||||
|
|
||||||
if is_keyframe:
|
|
||||||
packet.is_keyframe = True
|
|
||||||
|
|
||||||
self.log_debug("track sending frame %s (keyframe=%s, size=%d)", self._pts, is_keyframe, len(payload))
|
|
||||||
|
|
||||||
return packet
|
|
||||||
|
|
||||||
def codec_preference(self) -> str | None:
|
|
||||||
return "H264"
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# ruff: noqa: TID251, UP006, UP035
|
# ruff: noqa: UP006, UP035
|
||||||
|
|
||||||
import abc
|
import abc
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# ruff: noqa: TID251, UP006, UP035
|
# ruff: noqa: UP006, UP035
|
||||||
|
|
||||||
import abc
|
import abc
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -14,6 +14,7 @@ from libdatachannel import (
|
|||||||
Candidate,
|
Candidate,
|
||||||
Description,
|
Description,
|
||||||
FrameInfo,
|
FrameInfo,
|
||||||
|
H264RtpDepacketizer,
|
||||||
H264RtpPacketizer,
|
H264RtpPacketizer,
|
||||||
IceServer,
|
IceServer,
|
||||||
NalUnit,
|
NalUnit,
|
||||||
@@ -100,7 +101,6 @@ class WebRTCBaseStream(abc.ABC):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _make_ice_servers(servers: Optional[List[dict]]) -> List[IceServer]:
|
def _make_ice_servers(servers: Optional[List[dict]]) -> List[IceServer]:
|
||||||
"""Preserve Konn3kt's authenticated STUN/TURN configuration in libdatachannel."""
|
|
||||||
parsed: List[IceServer] = []
|
parsed: List[IceServer] = []
|
||||||
for server in servers or []:
|
for server in servers or []:
|
||||||
urls = server.get("urls", []) if isinstance(server, dict) else []
|
urls = server.get("urls", []) if isinstance(server, dict) else []
|
||||||
@@ -119,7 +119,9 @@ class WebRTCBaseStream(abc.ABC):
|
|||||||
except Exception:
|
except Exception:
|
||||||
logging.getLogger("WebRTCStream").warning("Ignoring invalid ICE server %r", url, exc_info=True)
|
logging.getLogger("WebRTCStream").warning("Ignoring invalid ICE server %r", url, exc_info=True)
|
||||||
# A supplied list is authoritative, including LAN-only sessions where relay is deliberately filtered.
|
# A supplied list is authoritative, including LAN-only sessions where relay is deliberately filtered.
|
||||||
return parsed or [IceServer("stun:stun.l.google.com:19302")]
|
if servers is not None:
|
||||||
|
return parsed
|
||||||
|
return [IceServer("stun:stun.l.google.com:19302")]
|
||||||
|
|
||||||
def _log_debug(self, msg: Any, *args):
|
def _log_debug(self, msg: Any, *args):
|
||||||
self.logger.debug(f"{type(self)}() {msg}", *args)
|
self.logger.debug(f"{type(self)}() {msg}", *args)
|
||||||
@@ -144,13 +146,14 @@ class WebRTCBaseStream(abc.ABC):
|
|||||||
media = Description.Video(camera_type, Description.Direction.RecvOnly)
|
media = Description.Video(camera_type, Description.Direction.RecvOnly)
|
||||||
media.add_h264_codec(96)
|
media.add_h264_codec(96)
|
||||||
track = self.peer_connection.add_track(media)
|
track = self.peer_connection.add_track(media)
|
||||||
track.set_media_handler(OpusRtpDepacketizer())
|
track.set_media_handler(H264RtpDepacketizer())
|
||||||
self._consumer_tracks.append(track)
|
self._consumer_tracks.append(track)
|
||||||
self.incoming_camera_tracks[camera_type] = track
|
self.incoming_camera_tracks[camera_type] = track
|
||||||
if self.expected_incoming_audio:
|
if self.expected_incoming_audio:
|
||||||
media = Description.Audio("audio", Description.Direction.RecvOnly)
|
media = Description.Audio("audio", Description.Direction.RecvOnly)
|
||||||
media.add_opus_codec(111)
|
media.add_opus_codec(111)
|
||||||
track = self.peer_connection.add_track(media)
|
track = self.peer_connection.add_track(media)
|
||||||
|
track.set_media_handler(OpusRtpDepacketizer())
|
||||||
self._consumer_tracks.append(track)
|
self._consumer_tracks.append(track)
|
||||||
self.incoming_audio_tracks.append(track)
|
self.incoming_audio_tracks.append(track)
|
||||||
|
|
||||||
@@ -170,7 +173,7 @@ class WebRTCBaseStream(abc.ABC):
|
|||||||
def _make_video_media(self, track: TiciVideoStreamTrack, remote_sdp: str) -> Tuple[Description.Video, int, int, str]:
|
def _make_video_media(self, track: TiciVideoStreamTrack, remote_sdp: str) -> Tuple[Description.Video, int, int, str]:
|
||||||
mid, payload_type = self._find_offer_video(remote_sdp)
|
mid, payload_type = self._find_offer_video(remote_sdp)
|
||||||
ssrc = random.randint(1, 0xFFFFFFFF)
|
ssrc = random.randint(1, 0xFFFFFFFF)
|
||||||
cname = f"teleoprtc-{random.getrandbits(32):08x}"
|
cname = f"iqpilot-video-{random.getrandbits(32):08x}"
|
||||||
stream_id = f"stream-{random.getrandbits(32):08x}"
|
stream_id = f"stream-{random.getrandbits(32):08x}"
|
||||||
media = Description.Video(mid, Description.Direction.SendOnly)
|
media = Description.Video(mid, Description.Direction.SendOnly)
|
||||||
media.add_h264_codec(payload_type)
|
media.add_h264_codec(payload_type)
|
||||||
@@ -193,7 +196,7 @@ class WebRTCBaseStream(abc.ABC):
|
|||||||
def _make_audio_media(self, remote_sdp: str) -> Tuple[Description.Audio, int, int, str]:
|
def _make_audio_media(self, remote_sdp: str) -> Tuple[Description.Audio, int, int, str]:
|
||||||
mid, payload_type = self._find_offer_audio(remote_sdp)
|
mid, payload_type = self._find_offer_audio(remote_sdp)
|
||||||
ssrc = random.randint(1, 0xFFFFFFFF)
|
ssrc = random.randint(1, 0xFFFFFFFF)
|
||||||
cname = f"teleoprtc-audio-{random.getrandbits(32):08x}"
|
cname = f"iqpilot-audio-{random.getrandbits(32):08x}"
|
||||||
direction = Description.Direction.SendRecv if self.expected_incoming_audio else Description.Direction.SendOnly
|
direction = Description.Direction.SendRecv if self.expected_incoming_audio else Description.Direction.SendOnly
|
||||||
media = Description.Audio(mid, direction)
|
media = Description.Audio(mid, direction)
|
||||||
media.add_opus_codec(payload_type)
|
media.add_opus_codec(payload_type)
|
||||||
@@ -352,7 +355,6 @@ class WebRTCBaseStream(abc.ABC):
|
|||||||
self.incoming_message_handlers.append(message_handler)
|
self.incoming_message_handlers.append(message_handler)
|
||||||
|
|
||||||
def add_ice_candidate(self, candidate: dict) -> None:
|
def add_ice_candidate(self, candidate: dict) -> None:
|
||||||
"""Accept post-offer browser candidates for Konn3kt's existing trickle endpoint."""
|
|
||||||
candidate_sdp = str(candidate.get("candidate") or "") if isinstance(candidate, dict) else ""
|
candidate_sdp = str(candidate.get("candidate") or "") if isinstance(candidate, dict) else ""
|
||||||
if not candidate_sdp:
|
if not candidate_sdp:
|
||||||
return
|
return
|
||||||
@@ -32,6 +32,7 @@ class TiciVideoStreamTrack:
|
|||||||
def __init__(self, camera_type: str, dt: float, time_base: fractions.Fraction = VIDEO_TIME_BASE, clock_rate: int = VIDEO_CLOCK_RATE):
|
def __init__(self, camera_type: str, dt: float, time_base: fractions.Fraction = VIDEO_TIME_BASE, clock_rate: int = VIDEO_CLOCK_RATE):
|
||||||
assert camera_type in ["driver", "wideRoad", "road"]
|
assert camera_type in ["driver", "wideRoad", "road"]
|
||||||
self._id: str = video_track_id(camera_type, str(uuid.uuid4()))
|
self._id: str = video_track_id(camera_type, str(uuid.uuid4()))
|
||||||
|
self._dt = dt
|
||||||
self._time_base: fractions.Fraction = time_base
|
self._time_base: fractions.Fraction = time_base
|
||||||
self._clock_rate: int = clock_rate
|
self._clock_rate: int = clock_rate
|
||||||
self._logger = logging.getLogger("WebRTCStream")
|
self._logger = logging.getLogger("WebRTCStream")
|
||||||
@@ -14,7 +14,6 @@ from iqpilot.system.webrtc.webrtcd import CerealIncomingMessageProxy, CerealOutg
|
|||||||
|
|
||||||
|
|
||||||
def _default_route_ip() -> str | None:
|
def _default_route_ip() -> str | None:
|
||||||
"""Use the interface the kernel will actually use for Internet/relay media."""
|
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
try:
|
try:
|
||||||
sock.connect(("8.8.8.8", 53))
|
sock.connect(("8.8.8.8", 53))
|
||||||
@@ -25,9 +24,7 @@ def _default_route_ip() -> str | None:
|
|||||||
sock.close()
|
sock.close()
|
||||||
|
|
||||||
|
|
||||||
class LibdatachannelBitrateController:
|
class LivestreamBitrateController:
|
||||||
"""Loss-driven bitrate control using native RTCP receiver reports."""
|
|
||||||
|
|
||||||
bitrates = [500_000, 1_500_000, int(os.environ.get("STREAM_BITRATE", 5_000_000))]
|
bitrates = [500_000, 1_500_000, int(os.environ.get("STREAM_BITRATE", 5_000_000))]
|
||||||
label_to_bitrate = {"low": bitrates[0], "med": bitrates[1], "high": bitrates[2]}
|
label_to_bitrate = {"low": bitrates[0], "med": bitrates[1], "high": bitrates[2]}
|
||||||
sample_interval = 0.2
|
sample_interval = 0.2
|
||||||
@@ -103,45 +100,45 @@ class LibdatachannelBitrateController:
|
|||||||
self._publish(self.bitrates[self._level])
|
self._publish(self.bitrates[self._level])
|
||||||
|
|
||||||
|
|
||||||
class StreamSessionLibdatachannel:
|
class StreamSession:
|
||||||
"""IQ.Pilot's libdatachannel stream path. It preserves Konn3kt signalling and controls."""
|
shared_pub_master: DynamicPubMaster | None = None
|
||||||
|
|
||||||
shared_pub_master = DynamicPubMaster([])
|
|
||||||
|
|
||||||
def __init__(self, sdp: str, cameras: list[str], incoming_services: list[str], outgoing_services: list[str],
|
def __init__(self, sdp: str, cameras: list[str], incoming_services: list[str], outgoing_services: list[str],
|
||||||
ice_servers: list[dict[str, Any]] | None = None, debug_mode: bool = False, ui_stream: bool = False):
|
ice_servers: list[dict[str, Any]] | None = None, debug_mode: bool = False, ui_stream: bool = False):
|
||||||
from iqpilot.system.webrtc.device.video_ldc import LiveStreamVideoStreamTrack
|
from iqpilot.system.webrtc.device.native_audio import AudioInputOpusProducer, DebugAudioOpusProducer
|
||||||
from iqpilot.system.webrtc.teleoprtc_ldc.builder import WebRTCAnswerBuilder
|
from iqpilot.system.webrtc.device.native_video import DebugVideoStreamTrack, LiveStreamVideoStreamTrack
|
||||||
from iqpilot.system.webrtc.teleoprtc_ldc.info import parse_info_from_offer
|
from iqpilot.system.webrtc.rtc.builder import WebRTCAnswerBuilder
|
||||||
|
from iqpilot.system.webrtc.rtc.info import parse_info_from_offer
|
||||||
|
|
||||||
config = parse_info_from_offer(sdp)
|
config = parse_info_from_offer(sdp)
|
||||||
if len(cameras) != config.n_expected_camera_tracks:
|
if len(cameras) != config.n_expected_camera_tracks:
|
||||||
raise ValueError("Incoming stream has misconfigured number of video tracks")
|
raise ValueError("Incoming stream has misconfigured number of video tracks")
|
||||||
if debug_mode:
|
|
||||||
raise ValueError("libdatachannel debug tracks are not supported")
|
|
||||||
|
|
||||||
builder = WebRTCAnswerBuilder(sdp, bind_address=_default_route_ip(), ice_servers=ice_servers or [])
|
builder = WebRTCAnswerBuilder(sdp, bind_address=_default_route_ip(), ice_servers=ice_servers or [])
|
||||||
self.video_tracks = [LiveStreamVideoStreamTrack(camera) for camera in cameras]
|
video_track_type = DebugVideoStreamTrack if debug_mode else LiveStreamVideoStreamTrack
|
||||||
|
self.video_tracks = [video_track_type(camera) for camera in cameras]
|
||||||
for camera, track in zip(cameras, self.video_tracks, strict=True):
|
for camera, track in zip(cameras, self.video_tracks, strict=True):
|
||||||
builder.add_video_stream(camera, track)
|
builder.add_video_stream(camera, track)
|
||||||
|
|
||||||
# The browser uses a single sendrecv audio m-line. libdatachannel's Python binding
|
audio_track_type = DebugAudioOpusProducer if debug_mode else AudioInputOpusProducer
|
||||||
# currently cannot negotiate that bidirectional track reliably, so this experimental
|
self.audio_output = audio_track_type() if config.expected_audio_track else None
|
||||||
# transport deliberately remains video/control-only. The default aiortc path keeps
|
if self.audio_output is not None:
|
||||||
# both audio directions until the native duplex path passes the same integration test.
|
builder.add_audio_stream(self.audio_output)
|
||||||
self.audio_output = None
|
if config.incoming_audio_track:
|
||||||
|
builder.offer_to_receive_audio_stream()
|
||||||
self.stream = builder.stream()
|
self.stream = builder.stream()
|
||||||
|
|
||||||
self.identifier = str(uuid.uuid4())
|
self.identifier = str(uuid.uuid4())
|
||||||
self.incoming_bridge_services = incoming_services
|
self.incoming_bridge_services = incoming_services
|
||||||
self.incoming_bridge = CerealIncomingMessageProxy(self.shared_pub_master) if incoming_services else None
|
if incoming_services and self.shared_pub_master is None:
|
||||||
|
StreamSession.shared_pub_master = DynamicPubMaster([])
|
||||||
|
self.incoming_bridge = CerealIncomingMessageProxy(self.shared_pub_master) if self.shared_pub_master is not None and incoming_services else None
|
||||||
self.outgoing_bridge = CerealOutgoingMessageProxy(messaging.SubMaster(outgoing_services)) if outgoing_services else None
|
self.outgoing_bridge = CerealOutgoingMessageProxy(messaging.SubMaster(outgoing_services)) if outgoing_services else None
|
||||||
self.outgoing_bridge_runner = CerealProxyRunner(self.outgoing_bridge) if self.outgoing_bridge is not None else None
|
self.outgoing_bridge_runner = CerealProxyRunner(self.outgoing_bridge) if self.outgoing_bridge is not None else None
|
||||||
self.ui_stream_requested = ui_stream
|
self.ui_stream_requested = ui_stream
|
||||||
self.ui_stream_runner: CerealProxyRunner | None = None
|
self.ui_stream_runner: CerealProxyRunner | None = None
|
||||||
self.audio_input_proxy = None
|
self.audio_input_proxy = None
|
||||||
self.audio_recv_requested = False
|
self.audio_recv_requested = config.incoming_audio_track
|
||||||
self.bitrate_controller = LibdatachannelBitrateController(self.stream.get_receiver_report_stats)
|
self.bitrate_controller = LivestreamBitrateController(self.stream.get_receiver_report_stats)
|
||||||
self.run_task: asyncio.Task | None = None
|
self.run_task: asyncio.Task | None = None
|
||||||
self._cleanup_lock = asyncio.Lock()
|
self._cleanup_lock = asyncio.Lock()
|
||||||
self._cleanup_done = False
|
self._cleanup_done = False
|
||||||
@@ -216,7 +213,7 @@ class StreamSessionLibdatachannel:
|
|||||||
if self.ui_stream_requested:
|
if self.ui_stream_requested:
|
||||||
self.set_ui_stream(True)
|
self.set_ui_stream(True)
|
||||||
if self.audio_recv_requested and self.stream.has_incoming_audio_track():
|
if self.audio_recv_requested and self.stream.has_incoming_audio_track():
|
||||||
from iqpilot.system.webrtc.device.audio_ldc import IncomingOpusCerealProxy
|
from iqpilot.system.webrtc.device.native_audio import IncomingOpusCerealProxy
|
||||||
self.audio_input_proxy = IncomingOpusCerealProxy(self.stream.get_incoming_audio_track())
|
self.audio_input_proxy = IncomingOpusCerealProxy(self.stream.get_incoming_audio_track())
|
||||||
self.audio_input_proxy.start()
|
self.audio_input_proxy.start()
|
||||||
self.bitrate_controller.start()
|
self.bitrate_controller.start()
|
||||||
96
iqpilot/system/webrtc/tests/test_native_session.py
Normal file
96
iqpilot/system/webrtc/tests/test_native_session.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from iqpilot.system.webrtc import session as session_module
|
||||||
|
|
||||||
|
|
||||||
|
class FakeStream:
|
||||||
|
def get_receiver_report_stats(self):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeBuilder:
|
||||||
|
instance = None
|
||||||
|
|
||||||
|
def __init__(self, sdp, bind_address=None, ice_servers=None):
|
||||||
|
self.sdp = sdp
|
||||||
|
self.bind_address = bind_address
|
||||||
|
self.ice_servers = ice_servers
|
||||||
|
self.video = []
|
||||||
|
self.audio = []
|
||||||
|
self.receive_audio = False
|
||||||
|
self.result = FakeStream()
|
||||||
|
FakeBuilder.instance = self
|
||||||
|
|
||||||
|
def add_video_stream(self, camera, track):
|
||||||
|
self.video.append((camera, track))
|
||||||
|
|
||||||
|
def add_audio_stream(self, track):
|
||||||
|
self.audio.append(track)
|
||||||
|
|
||||||
|
def offer_to_receive_audio_stream(self):
|
||||||
|
self.receive_audio = True
|
||||||
|
|
||||||
|
def stream(self):
|
||||||
|
return self.result
|
||||||
|
|
||||||
|
|
||||||
|
class FakeVideoTrack:
|
||||||
|
def __init__(self, camera):
|
||||||
|
self.camera = camera
|
||||||
|
self.timing_sei_enabled = False
|
||||||
|
self.switched = None
|
||||||
|
|
||||||
|
def switch_camera(self, camera):
|
||||||
|
self.switched = camera
|
||||||
|
|
||||||
|
|
||||||
|
class FakeAudioProducer:
|
||||||
|
def __init__(self):
|
||||||
|
self.enabled = True
|
||||||
|
|
||||||
|
def enable(self, enabled):
|
||||||
|
self.enabled = enabled
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def native_session(mocker):
|
||||||
|
config = SimpleNamespace(
|
||||||
|
n_expected_camera_tracks=1,
|
||||||
|
expected_audio_track=True,
|
||||||
|
incoming_audio_track=True,
|
||||||
|
incoming_datachannel=True,
|
||||||
|
)
|
||||||
|
mocker.patch("iqpilot.system.webrtc.rtc.info.parse_info_from_offer", return_value=config)
|
||||||
|
mocker.patch("iqpilot.system.webrtc.rtc.builder.WebRTCAnswerBuilder", FakeBuilder)
|
||||||
|
mocker.patch("iqpilot.system.webrtc.device.native_video.LiveStreamVideoStreamTrack", FakeVideoTrack)
|
||||||
|
mocker.patch("iqpilot.system.webrtc.device.native_audio.AudioInputOpusProducer", FakeAudioProducer)
|
||||||
|
mocker.patch.object(session_module, "_default_route_ip", return_value="192.0.2.1")
|
||||||
|
mocker.patch.object(session_module, "Params", return_value=mocker.Mock())
|
||||||
|
return session_module.StreamSession(
|
||||||
|
"offer", ["road"], [], [], [{"urls": "turn:example.com"}], ui_stream=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_native_session_builds_duplex_audio(native_session):
|
||||||
|
builder = FakeBuilder.instance
|
||||||
|
assert builder is not None
|
||||||
|
assert builder.bind_address == "192.0.2.1"
|
||||||
|
assert builder.ice_servers == [{"urls": "turn:example.com"}]
|
||||||
|
assert [camera for camera, _ in builder.video] == ["road"]
|
||||||
|
assert builder.audio == [native_session.audio_output]
|
||||||
|
assert builder.receive_audio
|
||||||
|
assert native_session.audio_recv_requested
|
||||||
|
|
||||||
|
|
||||||
|
def test_native_session_controls(native_session, mocker):
|
||||||
|
native_session.bitrate_controller = mocker.Mock()
|
||||||
|
native_session.message_handler('{"type":"timingSei","enabled":true}')
|
||||||
|
assert native_session.video_tracks[0].timing_sei_enabled
|
||||||
|
native_session.message_handler('{"type":"switchCamera","camera":"driver"}')
|
||||||
|
assert native_session.video_tracks[0].switched == "driver"
|
||||||
|
native_session.message_handler('{"type":"setAudioEnabled","enabled":false}')
|
||||||
|
assert not native_session.audio_output.enabled
|
||||||
|
native_session.message_handler('{"type":"setQuality","quality":"low"}')
|
||||||
|
native_session.bitrate_controller.set_quality.assert_called_once_with("low")
|
||||||
116
iqpilot/system/webrtc/tests/test_rtc.py
Normal file
116
iqpilot/system/webrtc/tests/test_rtc.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from iqpilot.system.webrtc.rtc.info import parse_info_from_offer
|
||||||
|
from iqpilot.system.webrtc.rtc.stream import WebRTCBaseStream
|
||||||
|
from iqpilot.system.webrtc.rtc.tracks import TiciTrackWrapper, TiciVideoStreamTrack, parse_video_track_id, video_track_id
|
||||||
|
|
||||||
|
|
||||||
|
def sdp_with_media(media):
|
||||||
|
mids = " ".join(str(i) for i in range(len(media)))
|
||||||
|
sections = []
|
||||||
|
for index, (kind, direction) in enumerate(media):
|
||||||
|
if kind == "video":
|
||||||
|
payload, mapping = "96", "H264/90000"
|
||||||
|
protocol = "UDP/TLS/RTP/SAVPF"
|
||||||
|
elif kind == "audio":
|
||||||
|
payload, mapping = "111", "opus/48000/2"
|
||||||
|
protocol = "UDP/TLS/RTP/SAVPF"
|
||||||
|
else:
|
||||||
|
sections.extend([
|
||||||
|
"m=application 9 UDP/DTLS/SCTP webrtc-datachannel",
|
||||||
|
"c=IN IP4 0.0.0.0",
|
||||||
|
f"a=mid:{index}",
|
||||||
|
"a=sctp-port:5000",
|
||||||
|
])
|
||||||
|
continue
|
||||||
|
sections.extend([
|
||||||
|
f"m={kind} 9 {protocol} {payload}",
|
||||||
|
"c=IN IP4 0.0.0.0",
|
||||||
|
f"a=mid:{index}",
|
||||||
|
f"a={direction}",
|
||||||
|
f"a=rtpmap:{payload} {mapping}",
|
||||||
|
"a=rtcp-mux",
|
||||||
|
])
|
||||||
|
lines = [
|
||||||
|
"v=0",
|
||||||
|
"o=- 1 1 IN IP4 0.0.0.0",
|
||||||
|
"s=-",
|
||||||
|
"t=0 0",
|
||||||
|
f"a=group:BUNDLE {mids}",
|
||||||
|
*sections,
|
||||||
|
]
|
||||||
|
return "\r\n".join(lines) + "\r\n"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("direction,expected_outgoing,expected_incoming", [
|
||||||
|
("recvonly", True, False),
|
||||||
|
("sendonly", False, True),
|
||||||
|
("sendrecv", True, True),
|
||||||
|
("inactive", False, False),
|
||||||
|
])
|
||||||
|
def test_audio_directions(direction, expected_outgoing, expected_incoming):
|
||||||
|
info = parse_info_from_offer(sdp_with_media([("audio", direction)]))
|
||||||
|
assert info.expected_audio_track == expected_outgoing
|
||||||
|
assert info.incoming_audio_track == expected_incoming
|
||||||
|
|
||||||
|
|
||||||
|
def test_video_and_data_channel_metadata():
|
||||||
|
info = parse_info_from_offer(sdp_with_media([
|
||||||
|
("video", "recvonly"),
|
||||||
|
("video", "sendrecv"),
|
||||||
|
("application", "sendrecv"),
|
||||||
|
]))
|
||||||
|
assert info.n_expected_camera_tracks == 2
|
||||||
|
assert info.incoming_datachannel
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_empty_ice_servers_disable_defaults():
|
||||||
|
assert WebRTCBaseStream._make_ice_servers([]) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_ice_server():
|
||||||
|
servers = WebRTCBaseStream._make_ice_servers(None)
|
||||||
|
assert len(servers) == 1
|
||||||
|
assert servers[0].hostname == "stun.l.google.com"
|
||||||
|
assert servers[0].port == 19302
|
||||||
|
|
||||||
|
|
||||||
|
def test_authenticated_ice_servers():
|
||||||
|
servers = WebRTCBaseStream._make_ice_servers([{
|
||||||
|
"urls": ["turn:relay.example.com:3478", "stun:stun.example.com:3478"],
|
||||||
|
"username": "user",
|
||||||
|
"credential": "secret",
|
||||||
|
}])
|
||||||
|
assert [(server.hostname, server.port) for server in servers] == [
|
||||||
|
("relay.example.com", 3478),
|
||||||
|
("stun.example.com", 3478),
|
||||||
|
]
|
||||||
|
assert all(server.username == "user" and server.password == "secret" for server in servers)
|
||||||
|
|
||||||
|
|
||||||
|
def test_track_id_roundtrip():
|
||||||
|
assert parse_video_track_id(video_track_id("driver", "track")) == ("driver", "track")
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_track_id():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
parse_video_track_id("driver")
|
||||||
|
|
||||||
|
|
||||||
|
def test_track_wrapper_preserves_camera():
|
||||||
|
class Track:
|
||||||
|
kind = "video"
|
||||||
|
id = "source"
|
||||||
|
|
||||||
|
async def recv(self):
|
||||||
|
return b"frame"
|
||||||
|
|
||||||
|
wrapper = TiciTrackWrapper("road", Track())
|
||||||
|
assert parse_video_track_id(wrapper.id)[0] == "road"
|
||||||
|
wrapper.stop()
|
||||||
|
assert wrapper.readyState == "ended"
|
||||||
|
|
||||||
|
|
||||||
|
def test_track_stores_frame_period():
|
||||||
|
track = TiciVideoStreamTrack("wideRoad", 0.05)
|
||||||
|
assert track._dt == 0.05
|
||||||
79
iqpilot/system/webrtc/tests/test_rtc_integration.py
Normal file
79
iqpilot/system/webrtc/tests/test_rtc_integration.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from iqpilot.system.webrtc.rtc import WebRTCOfferBuilder
|
||||||
|
from iqpilot.system.webrtc.rtc.stream import RTCSessionDescription
|
||||||
|
from iqpilot.system.webrtc.session import StreamSession
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_native_video_audio_and_data_channel():
|
||||||
|
if not os.environ.get("CI"):
|
||||||
|
return
|
||||||
|
|
||||||
|
answer_session = None
|
||||||
|
video_received = asyncio.Event()
|
||||||
|
audio_received = asyncio.Event()
|
||||||
|
|
||||||
|
async def connect(offer):
|
||||||
|
nonlocal answer_session
|
||||||
|
answer_session = StreamSession(offer.sdp, offer.video, [], [], [], debug_mode=True)
|
||||||
|
answer = await answer_session.get_answer()
|
||||||
|
answer_session.start()
|
||||||
|
return RTCSessionDescription(answer.sdp, answer.type)
|
||||||
|
|
||||||
|
builder = WebRTCOfferBuilder(connect, ice_servers=[])
|
||||||
|
builder.offer_to_receive_video_stream("road")
|
||||||
|
builder.offer_to_receive_audio_stream()
|
||||||
|
builder.add_messaging()
|
||||||
|
stream = builder.stream()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(stream.start(), 10)
|
||||||
|
stream.get_incoming_video_track("road").on_frame(lambda *_: video_received.set())
|
||||||
|
stream.get_incoming_audio_track().on_frame(lambda *_: audio_received.set())
|
||||||
|
await asyncio.wait_for(stream.wait_for_connection(), 10)
|
||||||
|
await asyncio.wait_for(video_received.wait(), 10)
|
||||||
|
await asyncio.wait_for(audio_received.wait(), 10)
|
||||||
|
stream.get_messaging_channel().send('{"type":"timingSei","enabled":true}')
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
assert answer_session is not None
|
||||||
|
assert answer_session.video_tracks[0].timing_sei_enabled
|
||||||
|
finally:
|
||||||
|
await stream.stop()
|
||||||
|
if answer_session is not None:
|
||||||
|
await answer_session.stop_async()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_native_duplex_audio_negotiation():
|
||||||
|
if not os.environ.get("CI"):
|
||||||
|
return
|
||||||
|
|
||||||
|
answer_session = None
|
||||||
|
|
||||||
|
async def connect(offer):
|
||||||
|
nonlocal answer_session
|
||||||
|
sendrecv_offer = offer.sdp.replace("a=recvonly", "a=sendrecv", 1)
|
||||||
|
answer_session = StreamSession(sendrecv_offer, offer.video, [], [], [], debug_mode=True)
|
||||||
|
answer = await answer_session.get_answer()
|
||||||
|
answer_session.start()
|
||||||
|
return RTCSessionDescription(answer.sdp, answer.type)
|
||||||
|
|
||||||
|
builder = WebRTCOfferBuilder(connect, ice_servers=[])
|
||||||
|
builder.offer_to_receive_audio_stream()
|
||||||
|
stream = builder.stream()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(stream.start(), 10)
|
||||||
|
await asyncio.wait_for(stream.wait_for_connection(), 10)
|
||||||
|
assert stream.has_incoming_audio_track()
|
||||||
|
assert answer_session is not None
|
||||||
|
assert answer_session.audio_recv_requested
|
||||||
|
assert answer_session.audio_output is not None
|
||||||
|
finally:
|
||||||
|
await stream.stop()
|
||||||
|
if answer_session is not None:
|
||||||
|
await answer_session.stop_async()
|
||||||
@@ -1,19 +1,14 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import gc
|
||||||
import json
|
import json
|
||||||
import time
|
|
||||||
# for aiortc and its dependencies
|
|
||||||
import warnings
|
|
||||||
warnings.filterwarnings("ignore", category=DeprecationWarning)
|
|
||||||
warnings.filterwarnings("ignore", category=RuntimeWarning) # TODO: remove this when google-crc32c publish a python3.12 wheel
|
|
||||||
|
|
||||||
from aiortc import RTCDataChannel
|
|
||||||
from aiortc.mediastreams import VIDEO_CLOCK_RATE, VIDEO_TIME_BASE
|
|
||||||
import capnp
|
import capnp
|
||||||
from iqpilot.cereal import messaging, log
|
from iqpilot.cereal import messaging, log
|
||||||
|
|
||||||
from iqpilot.system.webrtc.webrtcd import CerealOutgoingMessageProxy, CerealIncomingMessageProxy
|
from iqpilot.system.webrtc.webrtcd import CerealOutgoingMessageProxy, CerealIncomingMessageProxy
|
||||||
from iqpilot.system.webrtc.device.video import LiveStreamVideoStreamTrack
|
from iqpilot.system.webrtc.device.native_audio import AudioInputOpusProducer, DebugAudioOpusProducer
|
||||||
from iqpilot.system.webrtc.device.audio import AudioInputStreamTrack
|
from iqpilot.system.webrtc.device.native_video import DebugVideoStreamTrack, LiveStreamVideoStreamTrack
|
||||||
|
from iqpilot.system.webrtc.rtc.tracks import VIDEO_TIME_BASE
|
||||||
|
|
||||||
|
|
||||||
class TestStreamSession:
|
class TestStreamSession:
|
||||||
@@ -23,6 +18,7 @@ class TestStreamSession:
|
|||||||
def teardown_method(self):
|
def teardown_method(self):
|
||||||
self.loop.stop()
|
self.loop.stop()
|
||||||
self.loop.close()
|
self.loop.close()
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
def test_outgoing_proxy(self, mocker):
|
def test_outgoing_proxy(self, mocker):
|
||||||
test_msg = log.Event.new_message()
|
test_msg = log.Event.new_message()
|
||||||
@@ -32,12 +28,12 @@ class TestStreamSession:
|
|||||||
expected_dict = {"type": "customReservedRawData0", "logMonoTime": 123, "valid": True, "data": "test"}
|
expected_dict = {"type": "customReservedRawData0", "logMonoTime": 123, "valid": True, "data": "test"}
|
||||||
expected_json = json.dumps(expected_dict).encode()
|
expected_json = json.dumps(expected_dict).encode()
|
||||||
|
|
||||||
channel = mocker.Mock(spec=RTCDataChannel)
|
channel = mocker.Mock()
|
||||||
mocked_submaster = messaging.SubMaster(["customReservedRawData0"])
|
mocked_submaster = mocker.MagicMock()
|
||||||
def mocked_update(t):
|
mocked_submaster.updated = {"customReservedRawData0": True}
|
||||||
mocked_submaster.update_msgs(0, [test_msg])
|
mocked_submaster.logMonoTime = {"customReservedRawData0": 123}
|
||||||
|
mocked_submaster.valid = {"customReservedRawData0": True}
|
||||||
mocker.patch.object(messaging.SubMaster, "update", side_effect=mocked_update)
|
mocked_submaster.__getitem__.return_value = test_msg.customReservedRawData0
|
||||||
proxy = CerealOutgoingMessageProxy(mocked_submaster)
|
proxy = CerealOutgoingMessageProxy(mocked_submaster)
|
||||||
proxy.add_channel(channel)
|
proxy.add_channel(channel)
|
||||||
|
|
||||||
@@ -47,9 +43,9 @@ class TestStreamSession:
|
|||||||
|
|
||||||
def test_incoming_proxy(self, mocker):
|
def test_incoming_proxy(self, mocker):
|
||||||
tested_msgs = [
|
tested_msgs = [
|
||||||
{"type": "customReservedRawData0", "data": "test"}, # primitive
|
{"type": "customReservedRawData0", "data": "test"},
|
||||||
{"type": "can", "data": [{"address": 0, "dat": "", "src": 0}]}, # list
|
{"type": "can", "data": [{"address": 0, "dat": "", "src": 0}]},
|
||||||
{"type": "testJoystick", "data": {"axes": [0, 0], "buttons": [False]}}, # dict
|
{"type": "testJoystick", "data": {"axes": [0, 0], "buttons": [False]}},
|
||||||
]
|
]
|
||||||
|
|
||||||
mocked_pubmaster = mocker.MagicMock(spec=messaging.PubMaster)
|
mocked_pubmaster = mocker.MagicMock(spec=messaging.PubMaster)
|
||||||
@@ -72,19 +68,14 @@ class TestStreamSession:
|
|||||||
fake_msg.livestreamDriverEncodeData.header = b"header"
|
fake_msg.livestreamDriverEncodeData.header = b"header"
|
||||||
fake_msg.livestreamDriverEncodeData.data = b"\x00\x00\x00\x01\x65"
|
fake_msg.livestreamDriverEncodeData.data = b"\x00\x00\x00\x01\x65"
|
||||||
|
|
||||||
mocker.patch("iqpilot.system.webrtc.device.video.messaging.recv_one_or_none", return_value=fake_msg)
|
mocker.patch("iqpilot.system.webrtc.device.native_video.messaging.sub_sock", return_value=mocker.Mock())
|
||||||
|
mocker.patch("iqpilot.system.webrtc.device.native_video.messaging.recv_one_or_none", return_value=fake_msg)
|
||||||
track = LiveStreamVideoStreamTrack("driver")
|
track = LiveStreamVideoStreamTrack("driver")
|
||||||
|
|
||||||
assert track.id.startswith("driver")
|
assert track.id.startswith("driver")
|
||||||
assert track.codec_preference() == "H264"
|
|
||||||
|
|
||||||
for i in range(5):
|
|
||||||
packet = self.loop.run_until_complete(track.recv())
|
packet = self.loop.run_until_complete(track.recv())
|
||||||
assert packet.time_base == VIDEO_TIME_BASE
|
assert packet.time_base == VIDEO_TIME_BASE
|
||||||
if i == 0:
|
assert packet.pts is not None
|
||||||
start_ns = time.monotonic_ns()
|
|
||||||
start_pts = packet.pts
|
|
||||||
assert abs(i + packet.pts - (start_pts + (((time.monotonic_ns() - start_ns) * VIDEO_CLOCK_RATE) // 1_000_000_000))) < 450 #5ms
|
|
||||||
assert packet.size == len(b"header\x00\x00\x00\x01\x65")
|
assert packet.size == len(b"header\x00\x00\x00\x01\x65")
|
||||||
|
|
||||||
def test_input_audio_track(self, mocker):
|
def test_input_audio_track(self, mocker):
|
||||||
@@ -93,11 +84,27 @@ class TestStreamSession:
|
|||||||
fake_msg = messaging.new_message("rawAudioData")
|
fake_msg = messaging.new_message("rawAudioData")
|
||||||
fake_msg.rawAudioData.data = b"\x00" * 2 * sample_count
|
fake_msg.rawAudioData.data = b"\x00" * 2 * sample_count
|
||||||
fake_msg.rawAudioData.sampleRate = rate
|
fake_msg.rawAudioData.sampleRate = rate
|
||||||
mocker.patch("iqpilot.system.webrtc.device.audio.messaging.recv_one_or_none", return_value=fake_msg)
|
mocker.patch("iqpilot.system.webrtc.device.native_audio.messaging.sub_sock", return_value=mocker.Mock())
|
||||||
track = AudioInputStreamTrack(rate=rate)
|
track = AudioInputOpusProducer()
|
||||||
|
track._source_rate = rate
|
||||||
|
mocker.patch("iqpilot.system.webrtc.device.native_audio.messaging.recv_one_or_none", return_value=fake_msg)
|
||||||
|
|
||||||
for i in range(5):
|
packet = self.loop.run_until_complete(track.recv())
|
||||||
frame = self.loop.run_until_complete(track.recv())
|
assert packet is not None
|
||||||
assert frame.rate == rate
|
payload, pts = packet
|
||||||
assert frame.samples == sample_count
|
assert payload
|
||||||
assert frame.pts == i * sample_count
|
assert pts >= 0
|
||||||
|
|
||||||
|
def test_debug_video_track(self):
|
||||||
|
track = DebugVideoStreamTrack("road")
|
||||||
|
packet = self.loop.run_until_complete(track.recv())
|
||||||
|
assert packet.size > 0
|
||||||
|
assert packet.pts == 0
|
||||||
|
|
||||||
|
def test_debug_audio_track(self):
|
||||||
|
track = DebugAudioOpusProducer()
|
||||||
|
packet = self.loop.run_until_complete(track.recv())
|
||||||
|
assert packet is not None
|
||||||
|
payload, pts = packet
|
||||||
|
assert payload
|
||||||
|
assert pts == 0
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from iqpilot.cereal import log, messaging
|
from iqpilot.cereal import log, messaging
|
||||||
from iqpilot.system.webrtc.ui_stream import (
|
from iqpilot.system.webrtc.ui_stream import (
|
||||||
UI_STREAM_SERVICES,
|
UI_STREAM_SERVICES,
|
||||||
@@ -270,11 +273,13 @@ class TestUIStreamFrame:
|
|||||||
|
|
||||||
|
|
||||||
class TestSessionWiring:
|
class TestSessionWiring:
|
||||||
def test_set_ui_stream_control_message(self):
|
@pytest.mark.asyncio
|
||||||
import asyncio
|
async def test_set_ui_stream_control_message(self, mocker):
|
||||||
import logging
|
import logging
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from iqpilot.system.webrtc.webrtcd import StreamSession
|
from iqpilot.system.webrtc.session import StreamSession
|
||||||
|
|
||||||
|
mocker.patch("iqpilot.system.webrtc.ui_stream.messaging.SubMaster", return_value=FakeSubMaster(make_readers()))
|
||||||
|
|
||||||
session = StreamSession.__new__(StreamSession)
|
session = StreamSession.__new__(StreamSession)
|
||||||
session.logger = logging.getLogger("webrtcd")
|
session.logger = logging.getLogger("webrtcd")
|
||||||
@@ -287,15 +292,12 @@ class TestSessionWiring:
|
|||||||
get_messaging_channel=lambda: channel,
|
get_messaging_channel=lambda: channel,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def go():
|
session.message_handler(b'{"type":"setUiStream","enabled":true}')
|
||||||
await session.message_handler(b'{"type":"setUiStream","enabled":true}')
|
|
||||||
assert session.ui_stream_runner is not None
|
assert session.ui_stream_runner is not None
|
||||||
await asyncio.sleep(0.05)
|
await asyncio.sleep(0.05)
|
||||||
await session.message_handler(b'{"type":"setUiStream","enabled":false}')
|
session.message_handler(b'{"type":"setUiStream","enabled":false}')
|
||||||
assert session.ui_stream_runner is None
|
assert session.ui_stream_runner is None
|
||||||
|
|
||||||
asyncio.run(go())
|
|
||||||
|
|
||||||
assert len(channel.sent) >= 1
|
assert len(channel.sent) >= 1
|
||||||
frame = json.loads(channel.sent[0])
|
frame = json.loads(channel.sent[0])
|
||||||
assert frame["type"] == "uiStream"
|
assert frame["type"] == "uiStream"
|
||||||
|
|||||||
@@ -1,16 +1,33 @@
|
|||||||
import pytest
|
|
||||||
import asyncio
|
|
||||||
import json
|
import json
|
||||||
# for aiortc and its dependencies
|
from types import SimpleNamespace
|
||||||
import warnings
|
|
||||||
warnings.filterwarnings("ignore", category=DeprecationWarning)
|
|
||||||
warnings.filterwarnings("ignore", category=RuntimeWarning) # TODO: remove this when google-crc32c publish a python3.12 wheel
|
|
||||||
|
|
||||||
from iqpilot.system.webrtc.webrtcd import get_stream
|
|
||||||
|
|
||||||
import aiortc
|
|
||||||
from teleoprtc import WebRTCOfferBuilder
|
|
||||||
from parameterized import parameterized_class
|
from parameterized import parameterized_class
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from iqpilot.system.webrtc.webrtcd import add_ice, get_stream
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSession:
|
||||||
|
instances = []
|
||||||
|
|
||||||
|
def __init__(self, identifier="session"):
|
||||||
|
self.identifier = identifier
|
||||||
|
self.started = False
|
||||||
|
self.stopped = False
|
||||||
|
self.candidates = []
|
||||||
|
self.instances.append(self)
|
||||||
|
|
||||||
|
async def get_answer(self):
|
||||||
|
return SimpleNamespace(sdp="answer", type="answer")
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self.started = True
|
||||||
|
|
||||||
|
async def stop_async(self):
|
||||||
|
self.stopped = True
|
||||||
|
|
||||||
|
async def add_ice_candidate(self, candidate):
|
||||||
|
self.candidates.append(candidate)
|
||||||
|
|
||||||
|
|
||||||
@parameterized_class(("in_services", "out_services"), [
|
@parameterized_class(("in_services", "out_services"), [
|
||||||
@@ -21,44 +38,38 @@ from parameterized import parameterized_class
|
|||||||
])
|
])
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
class TestWebrtcdProc:
|
class TestWebrtcdProc:
|
||||||
async def assertCompletesWithTimeout(self, awaitable, timeout=10):
|
|
||||||
try:
|
|
||||||
async with asyncio.timeout(timeout):
|
|
||||||
await awaitable
|
|
||||||
except TimeoutError:
|
|
||||||
pytest.fail("Timeout while waiting for awaitable to complete")
|
|
||||||
|
|
||||||
async def test_webrtcd(self, mocker):
|
async def test_webrtcd(self, mocker):
|
||||||
mock_request = mocker.MagicMock()
|
session = FakeSession()
|
||||||
async def connect(offer):
|
mocker.patch("iqpilot.system.webrtc.webrtcd._new_stream_session", return_value=session)
|
||||||
body = {'sdp': offer.sdp, 'cameras': offer.video, 'bridge_services_in': self.in_services, 'bridge_services_out': self.out_services}
|
request = mocker.MagicMock()
|
||||||
mock_request.json.side_effect = mocker.AsyncMock(return_value=body)
|
request.app = {"streams": {}, "debug": False}
|
||||||
response = await get_stream(mock_request)
|
request.json = mocker.AsyncMock(return_value={
|
||||||
response_json = json.loads(response.text)
|
"sdp": "offer",
|
||||||
return aiortc.RTCSessionDescription(**response_json)
|
"cameras": ["road"],
|
||||||
|
"bridge_services_in": self.in_services,
|
||||||
|
"bridge_services_out": self.out_services,
|
||||||
|
})
|
||||||
|
|
||||||
builder = WebRTCOfferBuilder(connect, ice_servers=[])
|
response = await get_stream(request)
|
||||||
builder.offer_to_receive_video_stream("road")
|
|
||||||
builder.offer_to_receive_audio_stream()
|
|
||||||
if len(self.in_services) > 0 or len(self.out_services) > 0:
|
|
||||||
builder.add_messaging()
|
|
||||||
|
|
||||||
stream = builder.stream()
|
assert response.status == 200
|
||||||
|
assert json.loads(response.text) == {"sdp": "answer", "type": "answer"}
|
||||||
|
assert request.app["streams"] == {session.identifier: session}
|
||||||
|
assert session.started
|
||||||
|
|
||||||
await self.assertCompletesWithTimeout(stream.start())
|
async def test_replaces_session_and_routes_ice(self, mocker):
|
||||||
await self.assertCompletesWithTimeout(stream.wait_for_connection())
|
previous = FakeSession("previous")
|
||||||
|
session = FakeSession("current")
|
||||||
|
mocker.patch("iqpilot.system.webrtc.webrtcd._new_stream_session", return_value=session)
|
||||||
|
request = mocker.MagicMock()
|
||||||
|
request.app = {"streams": {previous.identifier: previous}, "debug": False}
|
||||||
|
request.json = mocker.AsyncMock(return_value={"sdp": "offer", "cameras": ["road"]})
|
||||||
|
|
||||||
assert stream.has_incoming_video_track("road")
|
response = await get_stream(request)
|
||||||
assert stream.has_incoming_audio_track()
|
|
||||||
assert stream.has_messaging_channel() == (len(self.in_services) > 0 or len(self.out_services) > 0)
|
|
||||||
|
|
||||||
video_track, audio_track = stream.get_incoming_video_track("road"), stream.get_incoming_audio_track()
|
assert response.status == 200
|
||||||
await self.assertCompletesWithTimeout(video_track.recv())
|
assert previous.stopped
|
||||||
await self.assertCompletesWithTimeout(audio_track.recv())
|
request.json = mocker.AsyncMock(return_value={"candidate": {"candidate": "candidate:1"}})
|
||||||
|
ice_response = await add_ice(request)
|
||||||
await self.assertCompletesWithTimeout(stream.stop())
|
assert ice_response.status == 200
|
||||||
|
assert session.candidates == [{"candidate": "candidate:1"}]
|
||||||
# cleanup, very implementation specific, test may break if it changes
|
|
||||||
assert mock_request.app["streams"].__setitem__.called, "Implementation changed, please update this test"
|
|
||||||
_, session = mock_request.app["streams"].__setitem__.call_args.args
|
|
||||||
await self.assertCompletesWithTimeout(session.post_run_cleanup())
|
|
||||||
|
|||||||
@@ -3,52 +3,27 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import uuid
|
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, TYPE_CHECKING
|
from typing import Any
|
||||||
|
|
||||||
from iqpilot.common.params import Params
|
|
||||||
|
|
||||||
import warnings
|
import warnings
|
||||||
warnings.filterwarnings("ignore", category=DeprecationWarning)
|
warnings.filterwarnings("ignore", category=DeprecationWarning)
|
||||||
warnings.filterwarnings("ignore", category=RuntimeWarning)
|
warnings.filterwarnings("ignore", category=RuntimeWarning)
|
||||||
|
|
||||||
import capnp
|
import capnp
|
||||||
import aiortc.rtcrtpsender
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from aiortc.rtp import RTCP_PSFB_APP, RtcpPsfbPacket, unpack_remb_fci
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from aiortc.rtcdatachannel import RTCDataChannel
|
|
||||||
|
|
||||||
from iqpilot.system.webrtc.schema import generate_field
|
from iqpilot.system.webrtc.schema import generate_field
|
||||||
from iqpilot.cereal import messaging, log
|
from iqpilot.cereal import messaging, log
|
||||||
|
|
||||||
|
|
||||||
_handle_rtcp_packet = aiortc.rtcrtpsender.RTCRtpSender._handle_rtcp_packet
|
|
||||||
|
|
||||||
|
|
||||||
async def _handle_rtcp_packet_with_remb(self, packet):
|
|
||||||
if isinstance(packet, RtcpPsfbPacket) and packet.fmt == RTCP_PSFB_APP:
|
|
||||||
try:
|
|
||||||
bitrate, ssrcs = unpack_remb_fci(packet.fci)
|
|
||||||
if getattr(self, "_ssrc", None) in ssrcs:
|
|
||||||
self._remb_bitrate = bitrate
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
return await _handle_rtcp_packet(self, packet)
|
|
||||||
|
|
||||||
|
|
||||||
aiortc.rtcrtpsender.RTCRtpSender._handle_rtcp_packet = _handle_rtcp_packet_with_remb
|
|
||||||
|
|
||||||
|
|
||||||
class CerealOutgoingMessageProxy:
|
class CerealOutgoingMessageProxy:
|
||||||
def __init__(self, sm: messaging.SubMaster):
|
def __init__(self, sm: messaging.SubMaster):
|
||||||
self.sm = sm
|
self.sm = sm
|
||||||
self.channels: list[RTCDataChannel] = []
|
self.channels: list[Any] = []
|
||||||
|
|
||||||
def add_channel(self, channel: 'RTCDataChannel'):
|
def add_channel(self, channel: Any):
|
||||||
self.channels.append(channel)
|
self.channels.append(channel)
|
||||||
|
|
||||||
def to_json(self, msg_content: Any):
|
def to_json(self, msg_content: Any):
|
||||||
@@ -114,43 +89,6 @@ class AsyncTaskRunner:
|
|||||||
self.task = None
|
self.task = None
|
||||||
|
|
||||||
|
|
||||||
class IncomingAudioCerealProxy(AsyncTaskRunner):
|
|
||||||
def __init__(self, track: Any):
|
|
||||||
super().__init__()
|
|
||||||
from av.audio.resampler import AudioResampler
|
|
||||||
from iqpilot.selfdrive.ui.soundd import SAMPLE_RATE as SOUND_SAMPLE_RATE
|
|
||||||
from iqpilot.system.webrtc.device.audio import WEBRTC_AUDIO_SERVICE
|
|
||||||
|
|
||||||
self.track = track
|
|
||||||
self.service = WEBRTC_AUDIO_SERVICE
|
|
||||||
self.pm = messaging.PubMaster([self.service])
|
|
||||||
self.resampler = AudioResampler(format="s16", layout="mono", rate=SOUND_SAMPLE_RATE)
|
|
||||||
|
|
||||||
def _publish(self, frame: Any) -> None:
|
|
||||||
data = frame.to_ndarray().tobytes()
|
|
||||||
if not data:
|
|
||||||
return
|
|
||||||
|
|
||||||
msg = messaging.new_message(self.service, valid=True)
|
|
||||||
msg.webrtcAudioData.data = data
|
|
||||||
msg.webrtcAudioData.sampleRate = frame.sample_rate
|
|
||||||
self.pm.send(self.service, msg)
|
|
||||||
|
|
||||||
async def run(self):
|
|
||||||
from aiortc.mediastreams import MediaStreamError
|
|
||||||
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
frame = await self.track.recv()
|
|
||||||
for resampled_frame in self.resampler.resample(frame):
|
|
||||||
self._publish(resampled_frame)
|
|
||||||
except MediaStreamError:
|
|
||||||
break
|
|
||||||
except Exception:
|
|
||||||
self.logger.exception("Incoming audio cereal proxy failure")
|
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
|
|
||||||
|
|
||||||
class CerealProxyRunner:
|
class CerealProxyRunner:
|
||||||
def __init__(self, proxy: CerealOutgoingMessageProxy):
|
def __init__(self, proxy: CerealOutgoingMessageProxy):
|
||||||
self.proxy = proxy
|
self.proxy = proxy
|
||||||
@@ -169,110 +107,14 @@ class CerealProxyRunner:
|
|||||||
self.task = None
|
self.task = None
|
||||||
|
|
||||||
async def run(self):
|
async def run(self):
|
||||||
from aiortc.exceptions import InvalidStateError
|
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
self.proxy.update()
|
self.proxy.update()
|
||||||
except InvalidStateError:
|
|
||||||
self.logger.warning("Cereal outgoing proxy invalid state (connection closed)")
|
|
||||||
break
|
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.exception("Cereal outgoing proxy failure")
|
self.logger.exception("Cereal outgoing proxy failure")
|
||||||
await asyncio.sleep(0.01)
|
await asyncio.sleep(0.01)
|
||||||
|
|
||||||
|
|
||||||
class LivestreamBitrateController:
|
|
||||||
"""Adaptive bitrate for the livestream encoder using browser REMB feedback."""
|
|
||||||
|
|
||||||
# Match comma's rung choices more closely. A steadier capped stream tends to look better than
|
|
||||||
# an occasionally-higher bitrate stream that induces queueing, jitter, and frame pacing swings.
|
|
||||||
bitrates = [500_000, 1_500_000, int(os.environ.get("STREAM_BITRATE", 5_000_000))]
|
|
||||||
label_to_bitrate = {"low": bitrates[0], "med": bitrates[1], "high": bitrates[-1]}
|
|
||||||
|
|
||||||
sample_interval = 1.0
|
|
||||||
lower_factor = 0.9
|
|
||||||
probe_after = 10
|
|
||||||
settle_samples = 3
|
|
||||||
|
|
||||||
def __init__(self, peer_connection: Any):
|
|
||||||
self.pc = peer_connection
|
|
||||||
self.params = Params()
|
|
||||||
self.task: asyncio.Task | None = None
|
|
||||||
|
|
||||||
# Start conservative and probe UP only when REMB proves headroom. Previously this started at
|
|
||||||
# the top rung (5 Mbps); with no REMB feedback (e.g. transport-cc-only receivers, or a flaky
|
|
||||||
# uplink that never delivers RTCP), _bandwidth_estimate() returns None and run() hits
|
|
||||||
# `if estimate is None: continue` — so the level never moves and the encoder stays pinned at
|
|
||||||
# 5 Mbps, flooding a marginal cellular uplink until webrtcd's send buffer balloons and trips the
|
|
||||||
# device's lowMemory soft-disable. The med rung is carriable on typical cellular; healthy links
|
|
||||||
# with working REMB still probe up to high within ~probe_after seconds.
|
|
||||||
self.level = min(1, len(self.bitrates) - 1)
|
|
||||||
self.stable = 0
|
|
||||||
self.settle = 0
|
|
||||||
self._auto = True
|
|
||||||
self.current_bitrate = self.bitrates[self.level]
|
|
||||||
self._publish(self.bitrates[self.level])
|
|
||||||
|
|
||||||
def start(self):
|
|
||||||
if self.task is None:
|
|
||||||
self.task = asyncio.create_task(self.run())
|
|
||||||
|
|
||||||
def stop(self):
|
|
||||||
if self.task is not None and not self.task.done():
|
|
||||||
self.task.cancel()
|
|
||||||
self.task = None
|
|
||||||
|
|
||||||
async def run(self):
|
|
||||||
while True:
|
|
||||||
await asyncio.sleep(self.sample_interval)
|
|
||||||
if not self._auto:
|
|
||||||
continue
|
|
||||||
estimate = self._bandwidth_estimate()
|
|
||||||
if estimate is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if self.settle > 0:
|
|
||||||
self.settle -= 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
if estimate < self.bitrates[self.level] * self.lower_factor:
|
|
||||||
while self.level > 0 and estimate < self.bitrates[self.level] * self.lower_factor:
|
|
||||||
self.level -= 1
|
|
||||||
self.stable = 0
|
|
||||||
self._publish(self.bitrates[self.level])
|
|
||||||
elif self.level < len(self.bitrates) - 1:
|
|
||||||
self.stable += 1
|
|
||||||
if self.stable >= self.probe_after:
|
|
||||||
self.level += 1
|
|
||||||
self.stable = 0
|
|
||||||
self.settle = self.settle_samples
|
|
||||||
self._publish(self.bitrates[self.level])
|
|
||||||
else:
|
|
||||||
self.stable = 0
|
|
||||||
|
|
||||||
def _bandwidth_estimate(self) -> int | None:
|
|
||||||
estimate = None
|
|
||||||
for sender in self.pc.getSenders():
|
|
||||||
bitrate = getattr(sender, "_remb_bitrate", None)
|
|
||||||
if bitrate is not None:
|
|
||||||
estimate = bitrate if estimate is None else min(estimate, bitrate)
|
|
||||||
return estimate
|
|
||||||
|
|
||||||
def set_quality(self, quality: str):
|
|
||||||
if quality in self.label_to_bitrate:
|
|
||||||
self._auto = False
|
|
||||||
self._publish(self.label_to_bitrate[quality])
|
|
||||||
elif quality == "auto":
|
|
||||||
self._auto = True
|
|
||||||
|
|
||||||
def _publish(self, bitrate: int):
|
|
||||||
# Param is registered as INT — must pass a Python int, not str. Passing str throws
|
|
||||||
# TypeError in Params.put (type mismatch) and crashes StreamSession.__init__ → HTTP 500.
|
|
||||||
self.current_bitrate = int(bitrate)
|
|
||||||
self.params.put("LivestreamEncoderBitrate", int(bitrate))
|
|
||||||
|
|
||||||
|
|
||||||
class DynamicPubMaster(messaging.PubMaster):
|
class DynamicPubMaster(messaging.PubMaster):
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
@@ -285,239 +127,11 @@ class DynamicPubMaster(messaging.PubMaster):
|
|||||||
self.sock[service] = messaging.pub_sock(service)
|
self.sock[service] = messaging.pub_sock(service)
|
||||||
|
|
||||||
|
|
||||||
class StreamSession:
|
|
||||||
shared_pub_master = DynamicPubMaster([])
|
|
||||||
|
|
||||||
def __init__(self, sdp: str, cameras: list[str], incoming_services: list[str], outgoing_services: list[str],
|
|
||||||
ice_servers: list[dict[str, Any]] | None = None, debug_mode: bool = False, ui_stream: bool = False):
|
|
||||||
from aiortc.mediastreams import VideoStreamTrack, AudioStreamTrack
|
|
||||||
from iqpilot.system.webrtc.device.video import LiveStreamVideoStreamTrack
|
|
||||||
from iqpilot.system.webrtc.device.audio import AudioInputStreamTrack
|
|
||||||
from teleoprtc import WebRTCAnswerBuilder
|
|
||||||
from teleoprtc.info import parse_info_from_offer
|
|
||||||
|
|
||||||
config = parse_info_from_offer(sdp)
|
|
||||||
builder = WebRTCAnswerBuilder(sdp, ice_servers=ice_servers or [])
|
|
||||||
|
|
||||||
assert len(cameras) == config.n_expected_camera_tracks, "Incoming stream has misconfigured number of video tracks"
|
|
||||||
self.video_tracks: list[LiveStreamVideoStreamTrack] = []
|
|
||||||
for cam in cameras:
|
|
||||||
track = LiveStreamVideoStreamTrack(cam) if not debug_mode else VideoStreamTrack()
|
|
||||||
if isinstance(track, LiveStreamVideoStreamTrack):
|
|
||||||
self.video_tracks.append(track)
|
|
||||||
builder.add_video_stream(cam, track)
|
|
||||||
# Audio init may fail if openpilot is using the audio subsystem - skip gracefully
|
|
||||||
if config.expected_audio_track:
|
|
||||||
try:
|
|
||||||
self.audio_input_track = AudioInputStreamTrack() if not debug_mode else AudioStreamTrack()
|
|
||||||
builder.add_audio_stream(self.audio_input_track)
|
|
||||||
self.audio_send_enabled = True
|
|
||||||
except Exception as e:
|
|
||||||
logging.warning(f"Could not init audio input (audio in use?): {e}")
|
|
||||||
if config.incoming_audio_track:
|
|
||||||
builder.offer_to_receive_audio_stream()
|
|
||||||
|
|
||||||
self.stream = builder.stream()
|
|
||||||
self.identifier = str(uuid.uuid4())
|
|
||||||
|
|
||||||
self.incoming_bridge: CerealIncomingMessageProxy | None = None
|
|
||||||
self.incoming_bridge_services = incoming_services
|
|
||||||
self.outgoing_bridge: CerealOutgoingMessageProxy | None = None
|
|
||||||
self.outgoing_bridge_runner: CerealProxyRunner | None = None
|
|
||||||
if len(incoming_services) > 0:
|
|
||||||
self.incoming_bridge = CerealIncomingMessageProxy(self.shared_pub_master)
|
|
||||||
if len(outgoing_services) > 0:
|
|
||||||
self.outgoing_bridge = CerealOutgoingMessageProxy(messaging.SubMaster(outgoing_services))
|
|
||||||
self.outgoing_bridge_runner = CerealProxyRunner(self.outgoing_bridge)
|
|
||||||
|
|
||||||
self.ui_stream_requested = ui_stream
|
|
||||||
self.ui_stream_runner: CerealProxyRunner | None = None
|
|
||||||
|
|
||||||
self.incoming_audio_proxy: IncomingAudioCerealProxy | None = None
|
|
||||||
self.audio_input_track: AudioInputStreamTrack | AudioStreamTrack | None = None
|
|
||||||
self.audio_send_enabled = False
|
|
||||||
self.audio_recv_requested = bool(config.incoming_audio_track)
|
|
||||||
self.audio_send_requested = bool(config.expected_audio_track)
|
|
||||||
self.run_task: asyncio.Task | None = None
|
|
||||||
# Adaptive bitrate controller for the livestream encoder (no-op in debug mode).
|
|
||||||
self.bitrate_controller: LivestreamBitrateController | None = None
|
|
||||||
if not debug_mode and len(self.video_tracks) > 0:
|
|
||||||
self.bitrate_controller = LivestreamBitrateController(self.stream.peer_connection)
|
|
||||||
self.logger = logging.getLogger("webrtcd")
|
|
||||||
self.logger.info("New stream session (%s), cameras %s, audio in %s out %s, incoming services %s, outgoing services %s",
|
|
||||||
self.identifier, cameras, config.incoming_audio_track, config.expected_audio_track, incoming_services, outgoing_services)
|
|
||||||
|
|
||||||
def start(self):
|
|
||||||
self.run_task = asyncio.create_task(self.run())
|
|
||||||
|
|
||||||
async def stop_async(self):
|
|
||||||
if self.run_task is not None and not self.run_task.done():
|
|
||||||
self.run_task.cancel()
|
|
||||||
try:
|
|
||||||
await self.run_task
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
self.logger.exception("Stream session stop task failure")
|
|
||||||
self.run_task = None
|
|
||||||
await self.post_run_cleanup()
|
|
||||||
|
|
||||||
def stop(self):
|
|
||||||
# Backwards-compatible sync wrapper. Prefer `await stop_async()` from async contexts.
|
|
||||||
try:
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
# If we're already in an event loop, schedule async shutdown and return.
|
|
||||||
loop.create_task(self.stop_async())
|
|
||||||
return
|
|
||||||
except RuntimeError:
|
|
||||||
pass
|
|
||||||
asyncio.run(self.stop_async())
|
|
||||||
|
|
||||||
async def get_answer(self):
|
|
||||||
return await self.stream.start()
|
|
||||||
|
|
||||||
async def message_handler(self, message: bytes):
|
|
||||||
# Control messages are handled in-process and don't require an incoming cereal bridge.
|
|
||||||
try:
|
|
||||||
payload = json.loads(message) if isinstance(message, (bytes, str)) else None
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
payload = None
|
|
||||||
if isinstance(payload, dict) and payload.get("type") == "timingSei":
|
|
||||||
enabled = bool(payload.get("enabled", False))
|
|
||||||
for track in self.video_tracks:
|
|
||||||
track.timing_sei_enabled = enabled
|
|
||||||
self.logger.info("timing SEI %s", "enabled" if enabled else "disabled")
|
|
||||||
return
|
|
||||||
if isinstance(payload, dict) and payload.get("type") == "setQuality":
|
|
||||||
if self.bitrate_controller is not None:
|
|
||||||
quality = str(payload.get("quality", "auto"))
|
|
||||||
self.bitrate_controller.set_quality(quality)
|
|
||||||
self.logger.info("livestream quality set to %s", quality)
|
|
||||||
return
|
|
||||||
if isinstance(payload, dict) and payload.get("type") == "setAudioEnabled":
|
|
||||||
enabled = bool(payload.get("enabled", True))
|
|
||||||
if hasattr(self.audio_input_track, "enable"):
|
|
||||||
self.audio_input_track.enable(enabled)
|
|
||||||
self.audio_send_enabled = enabled
|
|
||||||
self.logger.info("livestream audio send %s", "enabled" if enabled else "disabled")
|
|
||||||
return
|
|
||||||
if isinstance(payload, dict) and payload.get("type") == "setUiStream":
|
|
||||||
enabled = bool(payload.get("enabled", False))
|
|
||||||
self.set_ui_stream(enabled)
|
|
||||||
self.logger.info("ui stream %s", "enabled" if enabled else "disabled")
|
|
||||||
return
|
|
||||||
if isinstance(payload, dict) and payload.get("type") == "switchCamera":
|
|
||||||
camera = str(payload.get("camera", ""))
|
|
||||||
# Single-track model: repoint the (one) video track at the requested camera.
|
|
||||||
for track in self.video_tracks:
|
|
||||||
track.switch_camera(camera)
|
|
||||||
return
|
|
||||||
|
|
||||||
if self.incoming_bridge is None:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
self.incoming_bridge.send(message)
|
|
||||||
except Exception:
|
|
||||||
self.logger.exception("Cereal incoming proxy failure")
|
|
||||||
|
|
||||||
def set_ui_stream(self, enabled: bool):
|
|
||||||
if enabled:
|
|
||||||
if self.ui_stream_runner is not None or not self.stream.has_messaging_channel():
|
|
||||||
return
|
|
||||||
from iqpilot.system.webrtc.ui_stream import UIStreamMessageProxy
|
|
||||||
bitrate_getter = None
|
|
||||||
if self.bitrate_controller is not None:
|
|
||||||
controller = self.bitrate_controller
|
|
||||||
|
|
||||||
def bitrate_getter():
|
|
||||||
return controller.current_bitrate
|
|
||||||
proxy = UIStreamMessageProxy(bitrate_getter=bitrate_getter)
|
|
||||||
proxy.add_channel(self.stream.get_messaging_channel())
|
|
||||||
self.ui_stream_runner = CerealProxyRunner(proxy)
|
|
||||||
self.ui_stream_runner.start()
|
|
||||||
elif self.ui_stream_runner is not None:
|
|
||||||
self.ui_stream_runner.stop()
|
|
||||||
self.ui_stream_runner = None
|
|
||||||
|
|
||||||
async def add_ice_candidate(self, cand: Any):
|
|
||||||
"""Add a trickled ICE candidate from the client to the live peer connection."""
|
|
||||||
if not isinstance(cand, dict):
|
|
||||||
return
|
|
||||||
cand_str = cand.get("candidate") or ""
|
|
||||||
if not cand_str:
|
|
||||||
return # end-of-candidates marker; aiortc needs no explicit signal
|
|
||||||
try:
|
|
||||||
from aiortc.sdp import candidate_from_sdp
|
|
||||||
sdp_str = cand_str.split(":", 1)[-1] if cand_str.startswith("candidate:") else cand_str
|
|
||||||
ice = candidate_from_sdp(sdp_str)
|
|
||||||
ice.sdpMid = cand.get("sdpMid")
|
|
||||||
ice.sdpMLineIndex = cand.get("sdpMLineIndex")
|
|
||||||
await self.stream.peer_connection.addIceCandidate(ice)
|
|
||||||
except Exception:
|
|
||||||
self.logger.exception("Failed to add ICE candidate")
|
|
||||||
|
|
||||||
async def run(self):
|
|
||||||
try:
|
|
||||||
await self.stream.wait_for_connection()
|
|
||||||
if self.stream.has_messaging_channel():
|
|
||||||
# Always install the handler so control messages (e.g. timing SEI toggle) work
|
|
||||||
# even when no incoming cereal bridge service was requested.
|
|
||||||
self.stream.set_message_handler(self.message_handler)
|
|
||||||
if self.incoming_bridge is not None:
|
|
||||||
await self.shared_pub_master.add_services_if_needed(self.incoming_bridge_services)
|
|
||||||
if self.outgoing_bridge_runner is not None:
|
|
||||||
channel = self.stream.get_messaging_channel()
|
|
||||||
self.outgoing_bridge_runner.proxy.add_channel(channel)
|
|
||||||
self.outgoing_bridge_runner.start()
|
|
||||||
if self.ui_stream_requested:
|
|
||||||
self.set_ui_stream(True)
|
|
||||||
if self.audio_recv_requested and self.stream.has_incoming_audio_track():
|
|
||||||
track = self.stream.get_incoming_audio_track(buffered=False)
|
|
||||||
self.incoming_audio_proxy = IncomingAudioCerealProxy(track)
|
|
||||||
self.incoming_audio_proxy.start()
|
|
||||||
self.logger.info("Stream session (%s) incoming audio proxy started", self.identifier)
|
|
||||||
else:
|
|
||||||
self.logger.info("Stream session (%s) no incoming audio track from client", self.identifier)
|
|
||||||
if self.bitrate_controller is not None:
|
|
||||||
self.bitrate_controller.start()
|
|
||||||
self.logger.info(
|
|
||||||
"Stream session (%s) audio state send_requested=%s send_enabled=%s recv_requested=%s recv_active=%s",
|
|
||||||
self.identifier,
|
|
||||||
self.audio_send_requested,
|
|
||||||
self.audio_send_enabled,
|
|
||||||
self.audio_recv_requested,
|
|
||||||
self.incoming_audio_proxy is not None,
|
|
||||||
)
|
|
||||||
self.logger.info("Stream session (%s) connected", self.identifier)
|
|
||||||
|
|
||||||
await self.stream.wait_for_disconnection()
|
|
||||||
await self.post_run_cleanup()
|
|
||||||
|
|
||||||
self.logger.info("Stream session (%s) ended", self.identifier)
|
|
||||||
except Exception:
|
|
||||||
self.logger.exception("Stream session failure")
|
|
||||||
|
|
||||||
async def post_run_cleanup(self):
|
|
||||||
if self.bitrate_controller is not None:
|
|
||||||
self.bitrate_controller.stop()
|
|
||||||
await self.stream.stop()
|
|
||||||
if self.ui_stream_runner is not None:
|
|
||||||
self.ui_stream_runner.stop()
|
|
||||||
self.ui_stream_runner = None
|
|
||||||
if self.outgoing_bridge is not None:
|
|
||||||
self.outgoing_bridge_runner.stop()
|
|
||||||
if self.incoming_audio_proxy is not None:
|
|
||||||
await self.incoming_audio_proxy.stop()
|
|
||||||
|
|
||||||
|
|
||||||
def _is_retryable_stream_error(e: Exception) -> bool:
|
def _is_retryable_stream_error(e: Exception) -> bool:
|
||||||
# Transient failures seen during answer generation: SDP/candidate parse issues
|
|
||||||
# (typically browser mDNS .local host candidates aiortc can't resolve) and
|
|
||||||
# socket-level hiccups while gathering. Anything else is a real error.
|
|
||||||
return isinstance(e, (ValueError, OSError))
|
return isinstance(e, (ValueError, OSError))
|
||||||
|
|
||||||
|
|
||||||
async def _cleanup_failed_session(session: 'StreamSession | None', logger: logging.Logger) -> None:
|
async def _cleanup_failed_session(session: Any | None, logger: logging.Logger) -> None:
|
||||||
if session is None:
|
if session is None:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
@@ -543,15 +157,7 @@ class StreamRequestBody:
|
|||||||
|
|
||||||
|
|
||||||
def _new_stream_session(offer_sdp: str, body: StreamRequestBody, debug_mode: bool):
|
def _new_stream_session(offer_sdp: str, body: StreamRequestBody, debug_mode: bool):
|
||||||
if Params().get_bool("Konn3ktLibdatachannelWebRTC"):
|
from iqpilot.system.webrtc.session import StreamSession
|
||||||
try:
|
|
||||||
from iqpilot.system.webrtc.webrtcd_ldc import StreamSessionLibdatachannel
|
|
||||||
return StreamSessionLibdatachannel(
|
|
||||||
offer_sdp, body.cameras, body.bridge_services_in, body.bridge_services_out, body.iceServers, debug_mode,
|
|
||||||
ui_stream=body.ui_stream,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logging.getLogger("webrtcd").exception("libdatachannel unavailable; falling back to aiortc")
|
|
||||||
return StreamSession(
|
return StreamSession(
|
||||||
offer_sdp, body.cameras, body.bridge_services_in, body.bridge_services_out, body.iceServers, debug_mode,
|
offer_sdp, body.cameras, body.bridge_services_in, body.bridge_services_out, body.iceServers, debug_mode,
|
||||||
ui_stream=body.ui_stream,
|
ui_stream=body.ui_stream,
|
||||||
@@ -561,7 +167,7 @@ def _new_stream_session(offer_sdp: str, body: StreamRequestBody, debug_mode: boo
|
|||||||
async def get_stream(request: 'web.Request'):
|
async def get_stream(request: 'web.Request'):
|
||||||
stream_dict, debug_mode = request.app['streams'], request.app['debug']
|
stream_dict, debug_mode = request.app['streams'], request.app['debug']
|
||||||
logger = logging.getLogger("webrtcd")
|
logger = logging.getLogger("webrtcd")
|
||||||
session: StreamSession | None = None
|
session: Any | None = None
|
||||||
try:
|
try:
|
||||||
raw_body = await request.json()
|
raw_body = await request.json()
|
||||||
body = StreamRequestBody(**raw_body)
|
body = StreamRequestBody(**raw_body)
|
||||||
|
|||||||
@@ -27,17 +27,12 @@ dependencies = [
|
|||||||
"msgq",
|
"msgq",
|
||||||
"iqdbc",
|
"iqdbc",
|
||||||
"panda",
|
"panda",
|
||||||
"teleoprtc",
|
|
||||||
"tinygrad",
|
"tinygrad",
|
||||||
|
|
||||||
# body / webrtcd
|
# body / webrtcd
|
||||||
"aiohttp",
|
"aiohttp",
|
||||||
"aiortc",
|
"av",
|
||||||
"libdatachannel-py>=2026.1.0.dev2",
|
"libdatachannel-py>=2026.1.0.dev2",
|
||||||
# aiortc does not put an upper bound on pyopenssl and is now incompatible
|
|
||||||
# with the latest release
|
|
||||||
"pyopenssl < 24.3.0",
|
|
||||||
"pyaudio",
|
|
||||||
|
|
||||||
# panda
|
# panda
|
||||||
"libusb1",
|
"libusb1",
|
||||||
@@ -100,7 +95,6 @@ testing = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
dev = [
|
dev = [
|
||||||
"av",
|
|
||||||
"azure-identity",
|
"azure-identity",
|
||||||
"azure-storage-blob",
|
"azure-storage-blob",
|
||||||
"dictdiffer",
|
"dictdiffer",
|
||||||
@@ -255,5 +249,4 @@ not-subscriptable = "ignore"
|
|||||||
msgq = { path = "artifacts/package_sources/msgq" }
|
msgq = { path = "artifacts/package_sources/msgq" }
|
||||||
iqdbc = { path = "artifacts/package_sources/iqdbc" }
|
iqdbc = { path = "artifacts/package_sources/iqdbc" }
|
||||||
panda = { path = "artifacts/package_sources/panda" }
|
panda = { path = "artifacts/package_sources/panda" }
|
||||||
teleoprtc = { path = "artifacts/package_sources/teleoprtc" }
|
|
||||||
tinygrad = { path = "artifacts/package_sources/tinygrad" }
|
tinygrad = { path = "artifacts/package_sources/tinygrad" }
|
||||||
|
|||||||
162
uv.lock
generated
162
uv.lock
generated
@@ -45,44 +45,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" },
|
{ url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "aioice"
|
|
||||||
version = "0.10.2"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "dnspython" },
|
|
||||||
{ name = "ifaddr" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/67/04/df7286233f468e19e9bedff023b6b246182f0b2ccb04ceeb69b2994021c6/aioice-0.10.2.tar.gz", hash = "sha256:bf236c6829ee33c8e540535d31cd5a066b531cb56de2be94c46be76d68b1a806", size = 44307, upload-time = "2025-11-28T15:56:48.836Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c7/e3/0d23b1f930c17d371ce1ec36ee529f22fd19ebc2a07fe3418e3d1d884ce2/aioice-0.10.2-py3-none-any.whl", hash = "sha256:14911c15ab12d096dd14d372ebb4aecbb7420b52c9b76fdfcf54375dec17fcbf", size = 24875, upload-time = "2025-11-28T15:56:47.847Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "aiortc"
|
|
||||||
version = "1.10.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "aioice" },
|
|
||||||
{ name = "av" },
|
|
||||||
{ name = "cffi" },
|
|
||||||
{ name = "cryptography" },
|
|
||||||
{ name = "google-crc32c" },
|
|
||||||
{ name = "pyee" },
|
|
||||||
{ name = "pylibsrtp" },
|
|
||||||
{ name = "pyopenssl" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/8a/f8/408e092748521889c9d33dddcef920afd9891cf6db4615ba6b6bfe114ff8/aiortc-1.10.1.tar.gz", hash = "sha256:64926ad86bde20c1a4dacb7c3a164e57b522606b70febe261fada4acf79641b5", size = 1179406, upload-time = "2025-02-02T17:36:38.684Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0a/6b/74547a30d1ddcc81f905ef4ff7fcc2c89b7482cb2045688f2aaa4fa918aa/aiortc-1.10.1-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:3bef536f38394b518aefae9dbf9cdd08f39e4c425f316f9692f0d8dc724810bd", size = 1218457, upload-time = "2025-02-02T17:36:23.172Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/46/92/b4ccf39cd18e366ace2a11dc7d98ed55967b4b325707386b5788149db15e/aiortc-1.10.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:8842c02e38513d9432ef22982572833487bb015f23348fa10a690616dbf55143", size = 898855, upload-time = "2025-02-02T17:36:25.9Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a4/e9/2676de48b493787d8b03129713e6bb2dfbacca2a565090f2a89cbad71f96/aiortc-1.10.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:954a420de01c0bf6b07a0c58b662029b1c4204ddbd8f5c4162bbdebd43f882b1", size = 1750403, upload-time = "2025-02-02T17:36:28.446Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c3/9d/ab6d09183cdaf5df060923d9bd5c9ed5fb1802661d9401dba35f3c85a57b/aiortc-1.10.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7c0d46fb30307a9d7deb4b7d66f0b0e73b77a7221b063fb6dc78821a5d2aa1e", size = 1867886, upload-time = "2025-02-02T17:36:30.209Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c2/71/0b5666e6b965dbd9a7f331aa827a6c3ab3eb4d582fefb686a7f4227b7954/aiortc-1.10.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89582f6923046f79f15d9045f432bc78191eacc95f6bed18714e86ec935188d9", size = 1893709, upload-time = "2025-02-02T17:36:32.342Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9d/0a/8c0c78fad79ef595a0ed6e2ab413900e6bd0eac65fc5c31c9d8736bff909/aiortc-1.10.1-cp39-abi3-win32.whl", hash = "sha256:d1cbe87f740b33ffaa8e905f21092773e74916be338b64b81c8b79af4c3847eb", size = 923265, upload-time = "2025-02-02T17:36:34.685Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/73/12/a27dd588a4988021da88cb4d338d8ee65ac097afc14e9193ab0be4a48790/aiortc-1.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:c9a5a0b23f8a77540068faec8837fa0a65b0396c20f09116bdb874b75e0b6abe", size = 1009488, upload-time = "2025-02-02T17:36:36.317Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aiosignal"
|
name = "aiosignal"
|
||||||
version = "1.4.0"
|
version = "1.4.0"
|
||||||
@@ -384,15 +346,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/47/ef/4cb333825d10317a36a1154341ba37e6e9c087bac99c1990ef07ffdb376f/dictdiffer-0.9.0-py2.py3-none-any.whl", hash = "sha256:442bfc693cfcadaf46674575d2eba1c53b42f5e404218ca2c2ff549f2df56595", size = 16754, upload-time = "2021-07-22T13:24:26.783Z" },
|
{ url = "https://files.pythonhosted.org/packages/47/ef/4cb333825d10317a36a1154341ba37e6e9c087bac99c1990ef07ffdb376f/dictdiffer-0.9.0-py2.py3-none-any.whl", hash = "sha256:442bfc693cfcadaf46674575d2eba1c53b42f5e404218ca2c2ff549f2df56595", size = 16754, upload-time = "2021-07-22T13:24:26.783Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "dnspython"
|
|
||||||
version = "2.8.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ewmhlib"
|
name = "ewmhlib"
|
||||||
version = "0.2"
|
version = "0.2"
|
||||||
@@ -456,19 +409,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
|
{ url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "google-crc32c"
|
|
||||||
version = "1.8.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/03/41/4b9c02f99e4c5fb477122cd5437403b552873f014616ac1d19ac8221a58d/google_crc32c-1.8.0.tar.gz", hash = "sha256:a428e25fb7691024de47fecfbff7ff957214da51eddded0da0ae0e0f03a2cf79", size = 14192, upload-time = "2025-12-16T00:35:25.142Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e9/5f/7307325b1198b59324c0fa9807cafb551afb65e831699f2ce211ad5c8240/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:4b8286b659c1335172e39563ab0a768b8015e88e08329fa5321f774275fc3113", size = 31300, upload-time = "2025-12-16T00:21:56.723Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/21/8e/58c0d5d86e2220e6a37befe7e6a94dd2f6006044b1a33edf1ff6d9f7e319/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:2a3dc3318507de089c5384cc74d54318401410f82aa65b2d9cdde9d297aca7cb", size = 30867, upload-time = "2025-12-16T00:38:31.302Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ce/a9/a780cc66f86335a6019f557a8aaca8fbb970728f0efd2430d15ff1beae0e/google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411", size = 33364, upload-time = "2025-12-16T00:40:22.96Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/21/3f/3457ea803db0198c9aaca2dd373750972ce28a26f00544b6b85088811939/google_crc32c-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb5c869c2923d56cb0c8e6bcdd73c009c36ae39b652dbe46a05eb4ef0ad01454", size = 33740, upload-time = "2025-12-16T00:40:23.96Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/df/c0/87c2073e0c72515bb8733d4eef7b21548e8d189f094b5dad20b0ecaf64f6/google_crc32c-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc0c8912038065eafa603b238abf252e204accab2a704c63b9e14837a854962", size = 34437, upload-time = "2025-12-16T00:35:21.395Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hypothesis"
|
name = "hypothesis"
|
||||||
version = "6.47.5"
|
version = "6.47.5"
|
||||||
@@ -491,15 +431,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ifaddr"
|
|
||||||
version = "0.2.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/e8/ac/fb4c578f4a3256561548cd825646680edcadb9440f3f68add95ade1eb791/ifaddr-0.2.0.tar.gz", hash = "sha256:cc0cbfcaabf765d44595825fb96a99bb12c79716b73b44330ea38ee2b0c4aed4", size = 10485, upload-time = "2022-06-15T21:40:27.561Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9c/1f/19ebc343cc71a7ffa78f17018535adc5cbdd87afb31d7c34874680148b32/ifaddr-0.2.0-py3-none-any.whl", hash = "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748", size = 12314, upload-time = "2022-06-15T21:40:25.756Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "imgui"
|
name = "imgui"
|
||||||
version = "1.92.7"
|
version = "1.92.7"
|
||||||
@@ -573,7 +504,7 @@ version = "0.1.0"
|
|||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
{ name = "aiortc" },
|
{ name = "av" },
|
||||||
{ name = "casadi" },
|
{ name = "casadi" },
|
||||||
{ name = "cffi" },
|
{ name = "cffi" },
|
||||||
{ name = "crcmod-plus" },
|
{ name = "crcmod-plus" },
|
||||||
@@ -591,11 +522,9 @@ dependencies = [
|
|||||||
{ name = "onnx" },
|
{ name = "onnx" },
|
||||||
{ name = "panda" },
|
{ name = "panda" },
|
||||||
{ name = "psutil" },
|
{ name = "psutil" },
|
||||||
{ name = "pyaudio" },
|
|
||||||
{ name = "pycapnp" },
|
{ name = "pycapnp" },
|
||||||
{ name = "pycryptodome" },
|
{ name = "pycryptodome" },
|
||||||
{ name = "pyjwt" },
|
{ name = "pyjwt" },
|
||||||
{ name = "pyopenssl" },
|
|
||||||
{ name = "pyserial" },
|
{ name = "pyserial" },
|
||||||
{ name = "pyzbar", marker = "sys_platform == 'linux'" },
|
{ name = "pyzbar", marker = "sys_platform == 'linux'" },
|
||||||
{ name = "pyzmq" },
|
{ name = "pyzmq" },
|
||||||
@@ -609,7 +538,6 @@ dependencies = [
|
|||||||
{ name = "sounddevice" },
|
{ name = "sounddevice" },
|
||||||
{ name = "spidev", marker = "sys_platform == 'linux'" },
|
{ name = "spidev", marker = "sys_platform == 'linux'" },
|
||||||
{ name = "sympy" },
|
{ name = "sympy" },
|
||||||
{ name = "teleoprtc" },
|
|
||||||
{ name = "tinygrad" },
|
{ name = "tinygrad" },
|
||||||
{ name = "tqdm" },
|
{ name = "tqdm" },
|
||||||
{ name = "websocket-client" },
|
{ name = "websocket-client" },
|
||||||
@@ -619,7 +547,6 @@ dependencies = [
|
|||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "av" },
|
|
||||||
{ name = "azure-identity" },
|
{ name = "azure-identity" },
|
||||||
{ name = "azure-storage-blob" },
|
{ name = "azure-storage-blob" },
|
||||||
{ name = "dictdiffer" },
|
{ name = "dictdiffer" },
|
||||||
@@ -655,8 +582,7 @@ tools = [
|
|||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
{ name = "aiortc" },
|
{ name = "av" },
|
||||||
{ name = "av", marker = "extra == 'dev'" },
|
|
||||||
{ name = "azure-identity", marker = "extra == 'dev'" },
|
{ name = "azure-identity", marker = "extra == 'dev'" },
|
||||||
{ name = "azure-storage-blob", marker = "extra == 'dev'" },
|
{ name = "azure-storage-blob", marker = "extra == 'dev'" },
|
||||||
{ name = "casadi", specifier = ">=3.6.6" },
|
{ name = "casadi", specifier = ">=3.6.6" },
|
||||||
@@ -687,12 +613,10 @@ requires-dist = [
|
|||||||
{ name = "parameterized", marker = "extra == 'dev'", specifier = ">=0.8,<0.9" },
|
{ name = "parameterized", marker = "extra == 'dev'", specifier = ">=0.8,<0.9" },
|
||||||
{ name = "pre-commit-hooks", marker = "extra == 'testing'" },
|
{ name = "pre-commit-hooks", marker = "extra == 'testing'" },
|
||||||
{ name = "psutil" },
|
{ name = "psutil" },
|
||||||
{ name = "pyaudio" },
|
|
||||||
{ name = "pyautogui", marker = "extra == 'dev'" },
|
{ name = "pyautogui", marker = "extra == 'dev'" },
|
||||||
{ name = "pycapnp", specifier = "==2.1.0" },
|
{ name = "pycapnp", specifier = "==2.1.0" },
|
||||||
{ name = "pycryptodome" },
|
{ name = "pycryptodome" },
|
||||||
{ name = "pyjwt" },
|
{ name = "pyjwt" },
|
||||||
{ name = "pyopenssl", specifier = "<24.3.0" },
|
|
||||||
{ name = "pyserial" },
|
{ name = "pyserial" },
|
||||||
{ name = "pytest", marker = "extra == 'testing'" },
|
{ name = "pytest", marker = "extra == 'testing'" },
|
||||||
{ name = "pytest-asyncio", marker = "extra == 'testing'" },
|
{ name = "pytest-asyncio", marker = "extra == 'testing'" },
|
||||||
@@ -717,7 +641,6 @@ requires-dist = [
|
|||||||
{ name = "spidev", marker = "sys_platform == 'linux'" },
|
{ name = "spidev", marker = "sys_platform == 'linux'" },
|
||||||
{ name = "sympy" },
|
{ name = "sympy" },
|
||||||
{ name = "tabulate", marker = "extra == 'dev'" },
|
{ name = "tabulate", marker = "extra == 'dev'" },
|
||||||
{ name = "teleoprtc", directory = "artifacts/package_sources/teleoprtc" },
|
|
||||||
{ name = "tinygrad", directory = "artifacts/package_sources/tinygrad" },
|
{ name = "tinygrad", directory = "artifacts/package_sources/tinygrad" },
|
||||||
{ name = "tqdm" },
|
{ name = "tqdm" },
|
||||||
{ name = "ty", marker = "extra == 'testing'" },
|
{ name = "ty", marker = "extra == 'testing'" },
|
||||||
@@ -1194,16 +1117,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/3e/73/2ce007f4198c80fcf2cb24c169884f833fe93fbc03d55d302627b094ee91/psutil-7.2.1-cp37-abi3-win_arm64.whl", hash = "sha256:0d67c1822c355aa6f7314d92018fb4268a76668a536f133599b91edd48759442", size = 133836, upload-time = "2025-12-29T08:26:43.086Z" },
|
{ url = "https://files.pythonhosted.org/packages/3e/73/2ce007f4198c80fcf2cb24c169884f833fe93fbc03d55d302627b094ee91/psutil-7.2.1-cp37-abi3-win_arm64.whl", hash = "sha256:0d67c1822c355aa6f7314d92018fb4268a76668a536f133599b91edd48759442", size = 133836, upload-time = "2025-12-29T08:26:43.086Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pyaudio"
|
|
||||||
version = "0.2.14"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/26/1d/8878c7752febb0f6716a7e1a52cb92ac98871c5aa522cba181878091607c/PyAudio-0.2.14.tar.gz", hash = "sha256:78dfff3879b4994d1f4fc6485646a57755c6ee3c19647a491f790a0895bd2f87", size = 47066, upload-time = "2023-11-07T07:11:48.806Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8d/45/8d2b76e8f6db783f9326c1305f3f816d4a12c8eda5edc6a2e1d03c097c3b/PyAudio-0.2.14-cp312-cp312-win32.whl", hash = "sha256:5fce4bcdd2e0e8c063d835dbe2860dac46437506af509353c7f8114d4bacbd5b", size = 144750, upload-time = "2023-11-07T07:11:40.142Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b0/6a/d25812e5f79f06285767ec607b39149d02aa3b31d50c2269768f48768930/PyAudio-0.2.14-cp312-cp312-win_amd64.whl", hash = "sha256:12f2f1ba04e06ff95d80700a78967897a489c05e093e3bffa05a84ed9c0a7fa3", size = 164126, upload-time = "2023-11-07T07:11:41.539Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyautogui"
|
name = "pyautogui"
|
||||||
version = "0.9.54"
|
version = "0.9.54"
|
||||||
@@ -1268,18 +1181,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
|
{ url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pyee"
|
|
||||||
version = "13.0.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "typing-extensions" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/95/03/1fd98d5841cd7964a27d729ccf2199602fe05eb7a405c1462eb7277945ed/pyee-13.0.0.tar.gz", hash = "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37", size = 31250, upload-time = "2025-03-17T18:53:15.955Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9b/4d/b9add7c84060d4c1906abe9a7e5359f2a60f7a9a4f67268b2766673427d8/pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498", size = 15730, upload-time = "2025-03-17T18:53:14.532Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pygetwindow"
|
name = "pygetwindow"
|
||||||
version = "0.0.9"
|
version = "0.0.9"
|
||||||
@@ -1312,28 +1213,6 @@ crypto = [
|
|||||||
{ name = "cryptography" },
|
{ name = "cryptography" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pylibsrtp"
|
|
||||||
version = "1.0.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "cffi" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/0d/a6/6e532bec974aaecbf9fe4e12538489fb1c28456e65088a50f305aeab9f89/pylibsrtp-1.0.0.tar.gz", hash = "sha256:b39dff075b263a8ded5377f2490c60d2af452c9f06c4d061c7a2b640612b34d4", size = 10858, upload-time = "2025-10-13T16:12:31.552Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/aa/af/89e61a62fa3567f1b7883feb4d19e19564066c2fcd41c37e08d317b51881/pylibsrtp-1.0.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:822c30ea9e759b333dc1f56ceac778707c51546e97eb874de98d7d378c000122", size = 1865017, upload-time = "2025-10-13T16:12:15.62Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8d/0e/8d215484a9877adcf2459a8b28165fc89668b034565277fd55d666edd247/pylibsrtp-1.0.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:aaad74e5c8cbc1c32056c3767fea494c1e62b3aea2c908eda2a1051389fdad76", size = 2182739, upload-time = "2025-10-13T16:12:17.121Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/57/3f/76a841978877ae13eac0d4af412c13bbd5d83b3df2c1f5f2175f2e0f68e5/pylibsrtp-1.0.0-cp310-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9209b86e662ebbd17c8a9e8549ba57eca92a3e87fb5ba8c0e27b8c43cd08a767", size = 2732922, upload-time = "2025-10-13T16:12:18.348Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0e/14/cf5d2a98a66fdfe258f6b036cda570f704a644fa861d7883a34bc359501e/pylibsrtp-1.0.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:293c9f2ac21a2bd689c477603a1aa235d85cf252160e6715f0101e42a43cbedc", size = 2434534, upload-time = "2025-10-13T16:12:20.074Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bd/08/a3f6e86c04562f7dce6717cd2206a0f84ca85c5e38121d998e0e330194c3/pylibsrtp-1.0.0-cp310-abi3-manylinux_2_28_i686.whl", hash = "sha256:81fb8879c2e522021a7cbd3f4bda1b37c192e1af939dfda3ff95b4723b329663", size = 2345818, upload-time = "2025-10-13T16:12:21.439Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8e/d5/130c2b5b4b51df5631684069c6f0a6761c59d096a33d21503ac207cf0e47/pylibsrtp-1.0.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4ddb562e443cf2e557ea2dfaeef0d7e6b90e96dd38eb079b4ab2c8e34a79f50b", size = 2774490, upload-time = "2025-10-13T16:12:22.659Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/91/e3/715a453bfee3bea92a243888ad359094a7727cc6d393f21281320fe7798c/pylibsrtp-1.0.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:f02e616c9dfab2b03b32d8cc7b748f9d91814c0211086f987629a60f05f6e2cc", size = 2372603, upload-time = "2025-10-13T16:12:24.036Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e3/56/52fa74294254e1f53a4ff170ee2006e57886cf4bb3db46a02b4f09e1d99f/pylibsrtp-1.0.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:c134fa09e7b80a5b7fed626230c5bc257fd771bd6978e754343e7a61d96bc7e6", size = 2451269, upload-time = "2025-10-13T16:12:25.475Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1e/51/2e9b34f484cbdd3bac999bf1f48b696d7389433e900639089e8fc4e0da0d/pylibsrtp-1.0.0-cp310-abi3-win32.whl", hash = "sha256:bae377c3b402b17b9bbfbfe2534c2edba17aa13bea4c64ce440caacbe0858b55", size = 1247503, upload-time = "2025-10-13T16:12:27.39Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c3/70/43db21af194580aba2d9a6d4c7bd8c1a6e887fa52cd810b88f89096ecad2/pylibsrtp-1.0.0-cp310-abi3-win_amd64.whl", hash = "sha256:8d6527c4a78a39a8d397f8862a8b7cdad4701ee866faf9de4ab8c70be61fd34d", size = 1601659, upload-time = "2025-10-13T16:12:29.037Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8e/ec/6e02b2561d056ea5b33046e3cad21238e6a9097b97d6ccc0fbe52b50c858/pylibsrtp-1.0.0-cp310-abi3-win_arm64.whl", hash = "sha256:2696bdb2180d53ac55d0eb7b58048a2aa30cd4836dd2ca683669889137a94d2a", size = 1159246, upload-time = "2025-10-13T16:12:30.285Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pymonctl"
|
name = "pymonctl"
|
||||||
version = "0.92"
|
version = "0.92"
|
||||||
@@ -3670,18 +3549,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/db/67/64920c8d201a7fc27962f467c636c4e763b43845baba2e091a50a97a5d52/pyobjc_framework_webkit-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:af2c7197447638b92aafbe4847c063b6dd5e1ed83b44d3ce7e71e4c9b042ab5a", size = 50084, upload-time = "2025-11-14T10:07:05.868Z" },
|
{ url = "https://files.pythonhosted.org/packages/db/67/64920c8d201a7fc27962f467c636c4e763b43845baba2e091a50a97a5d52/pyobjc_framework_webkit-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:af2c7197447638b92aafbe4847c063b6dd5e1ed83b44d3ce7e71e4c9b042ab5a", size = 50084, upload-time = "2025-11-14T10:07:05.868Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pyopenssl"
|
|
||||||
version = "24.2.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "cryptography" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/5d/70/ff56a63248562e77c0c8ee4aefc3224258f1856977e0c1472672b62dadb8/pyopenssl-24.2.1.tar.gz", hash = "sha256:4247f0dbe3748d560dcbb2ff3ea01af0f9a1a001ef5f7c4c647956ed8cbf0e95", size = 184323, upload-time = "2024-07-20T17:26:31.252Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d9/dd/e0aa7ebef5168c75b772eda64978c597a9129b46be17779054652a7999e4/pyOpenSSL-24.2.1-py3-none-any.whl", hash = "sha256:967d5719b12b243588573f39b0c677637145c7a1ffedcd495a487e58177fbb8d", size = 58390, upload-time = "2024-07-20T17:26:29.057Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyparsing"
|
name = "pyparsing"
|
||||||
version = "3.3.2"
|
version = "3.3.2"
|
||||||
@@ -4123,31 +3990,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f", size = 35252, upload-time = "2022-10-06T17:21:44.262Z" },
|
{ url = "https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f", size = 35252, upload-time = "2022-10-06T17:21:44.262Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "teleoprtc"
|
|
||||||
version = "1.0.1"
|
|
||||||
source = { directory = "artifacts/package_sources/teleoprtc" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "aiohttp" },
|
|
||||||
{ name = "aiortc" },
|
|
||||||
{ name = "av" },
|
|
||||||
{ name = "numpy" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.metadata]
|
|
||||||
requires-dist = [
|
|
||||||
{ name = "aiohttp", specifier = ">=3.7.0" },
|
|
||||||
{ name = "aiortc", specifier = ">=1.6.0" },
|
|
||||||
{ name = "av", specifier = ">=11.0.0,<13.0.0" },
|
|
||||||
{ name = "numpy", specifier = ">=1.19.0" },
|
|
||||||
{ name = "parameterized", marker = "extra == 'dev'", specifier = ">=0.8" },
|
|
||||||
{ name = "pre-commit", marker = "extra == 'dev'" },
|
|
||||||
{ name = "pytest", marker = "extra == 'dev'" },
|
|
||||||
{ name = "pytest-asyncio", marker = "extra == 'dev'" },
|
|
||||||
{ name = "pytest-xdist", marker = "extra == 'dev'" },
|
|
||||||
]
|
|
||||||
provides-extras = ["dev"]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinygrad"
|
name = "tinygrad"
|
||||||
version = "0.13.0"
|
version = "0.13.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user