diff --git a/.gitattributes b/.gitattributes index 5c086a8be..195fb726b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -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/iq/*.png -filter -diff -merge -text +artifacts/runtime_wheels/*.whl -filter -diff -merge -text diff --git a/.gitignore b/.gitignore index 3b0cb3f47..d1af22dbb 100644 --- a/.gitignore +++ b/.gitignore @@ -29,7 +29,6 @@ a.out /iqdbc /msgq /openpilot -/teleoprtc /tinygrad /docs_site/ diff --git a/artifacts/iqpilot_hephaestusd_private/.source_sha b/artifacts/iqpilot_hephaestusd_private/.source_sha new file mode 100644 index 000000000..c1f23d51c --- /dev/null +++ b/artifacts/iqpilot_hephaestusd_private/.source_sha @@ -0,0 +1 @@ +292ca77fab7ffaa87ab6a011206a1f5fdd3aeeb5 diff --git a/artifacts/iqpilot_hephaestusd_private/manifest.json b/artifacts/iqpilot_hephaestusd_private/manifest.json index 080d369fe..25d681c88 100644 --- a/artifacts/iqpilot_hephaestusd_private/manifest.json +++ b/artifacts/iqpilot_hephaestusd_private/manifest.json @@ -161,8 +161,8 @@ }, "python/_iqclosure/iqpilot/selfdrive/iqmodeld/models/helpers.py": { "mode": 420, - "sha256": "ce5d23a33b0e2d9eb81525ad63a88823d20a0578191ceab48c20e24594f8f690", - "size": 10952 + "sha256": "e4f524c7595754fd9db86724a7bd9b67524c630275ac78599ab6838d1aa7d809", + "size": 11037 }, "python/_iqclosure/iqpilot/selfdrive/locationd/__init__.py": { "mode": 420, @@ -351,27 +351,27 @@ }, "python/iqpilot_private/konn3kt/backups/archive_codec.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "2ec8be1fb7b3b4e657a79c8085996729a7e7b1d9cf8c37f5008a62f4aa952aab", + "sha256": "82183cba3d305058bda02b49949fe7b12d52cd906cf10107a2e091632173b82e", "size": 135552 }, "python/iqpilot_private/konn3kt/backups/backup_keys.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "87e315903c8dbe8803111e2fe21c2bd367ff69cfb05ab88458e12130022a6c2c", + "sha256": "62b3742751af4c8fef9e3b0a439ba68dfc7acf6680340275894c4156c05de223", "size": 67664 }, "python/iqpilot_private/konn3kt/backups/backup_orchestrator.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "187b5e13f2b21def80888ee353be65dee1aced7b2b1c7a4f8c962ce8895f8f89", + "sha256": "59a6ffcd63088e8b44c52d013f52d89fee01db9a3774b29a86de806c83960454", "size": 204640 }, "python/iqpilot_private/konn3kt/backups/cbc_vault.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "9338a83201734ff572d431de5f56c8279fc4a656d10d0a67ea44e8057bb88016", + "sha256": "f8d9435ef9281a3fa268b504dc326a98962dd02bd52df0570b2a0904e77a961f", "size": 69768 }, "python/iqpilot_private/konn3kt/backups/imahelper.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "df671d37153130a8dcf67fd8a7ae7b024206d19aaa6db85b48811a259289481d", + "sha256": "bcbcf79fb098d3647d856958be01bbf4b2cc28b57db0e4dfe4cd41d90b0ea040", "size": 203088 }, "python/iqpilot_private/konn3kt/flockd/__init__.py": { @@ -381,12 +381,12 @@ }, "python/iqpilot_private/konn3kt/flockd/flockd.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "a67c26048adfb32e76168bdb6a260e11dedbf05257a66eba5eef3b4a623ce1e6", + "sha256": "83ac740a10c113e1342cb9eee311fd2c0cb88e550441468155318207bdf400b7", "size": 202184 }, "python/iqpilot_private/konn3kt/flockd/signatures.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "e09bb698e3128449a4da3c2d767621928e531140181b20acd2adcf4186617953", + "sha256": "fbd898edf0446b6dcfb6787400dd6fd5bb344c429be491ac40318f8a1946d195", "size": 68080 }, "python/iqpilot_private/konn3kt/hephaestus/__init__.py": { @@ -396,67 +396,67 @@ }, "python/iqpilot_private/konn3kt/hephaestus/_vendor/localapi_runtime.zip": { "mode": 420, - "sha256": "cb21ff749633ca4b01aef63c178df260d51fff631aed83923b223d70bf6533bd", + "sha256": "9acc808279e1d740fd384ad2f31de7398c52c27baa8fa7a6e2f65b38cafc49e4", "size": 2100238 }, "python/iqpilot_private/konn3kt/hephaestus/ble_auth.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "85bf27b0c19387fdb52b493f259e84a23d31c73b5e275e964dbeb934d329c821", + "sha256": "eef8e9fdaea989bd323b8a3dde2ce1cc1090a8c60d31a81cf3eec53dd3600071", "size": 268080 }, "python/iqpilot_private/konn3kt/hephaestus/ble_gatt.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "1525c92a7fe8dd898a07188fcdc05dc608a5b77b35ab2fc9d250fcc46bf69999", + "sha256": "343423a3dbec1198f40745a6f303569c7b566d1a5eff8ff10ae5e3dacd0044b9", "size": 406176 }, "python/iqpilot_private/konn3kt/hephaestus/ble_rpc_dispatch.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "f5163ae4cbaa77c09ccbea174f659bf719fa9f9c403db478af33ab09a8a9673b", + "sha256": "bf40085a06442714fb8386a77ac8e2d047eeadac084c09e80f3a5846137b2a16", "size": 68048 }, "python/iqpilot_private/konn3kt/hephaestus/ble_transportd.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "c69f659c3736c14a57065e118c35cf06c262520a35c37afea3e7bbc43844c129", + "sha256": "a14d91fb1ec6150a2eb11e206301ca1275e2a28c5e759823ed2ede57da05e34b", "size": 335776 }, "python/iqpilot_private/konn3kt/hephaestus/bt_gamepad.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "543534177922974da9872b56ba91b5f63b26ab9cb3b275f8e7bd5517e2971ca6", + "sha256": "2ba6c8cb47106c3073d72d3291957c7aeefb5d704b851732b532c4f20e9f6b71", "size": 271696 }, "python/iqpilot_private/konn3kt/hephaestus/cloud_routes.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "53a637f886ac6c465c2e3a5ec74b7f2d4e624bbfdb0186c11f29f8d50a6af8d2", + "sha256": "a73ebe2e3dba51692fb8b227e3287209c2763a3f2d9fc8d5c7d62f7cdbf760dc", "size": 134408 }, "python/iqpilot_private/konn3kt/hephaestus/hephaestusd.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "52b6306bc0b6a69198ea139eb311e3c70a7f7d0b2093d17cae75e280dfdf52d1", + "sha256": "5825a4f141a829f7b3a8bbaf848fd8c25f38d99c6bf8c97cf19ca75c1c025241", "size": 3715880 }, "python/iqpilot_private/konn3kt/hephaestus/kwp2000.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "1dab38fc494466cac932289b9805266d211b1b510fe838907f3cb3afe53ed93c", + "sha256": "dc2147325ea4e1dafcaa3357c0878c3f39f3c4630417314f8bcb9af95abc7e91", "size": 136472 }, "python/iqpilot_private/konn3kt/hephaestus/manage_hephaestusd.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "1adfdd3fa1ebd5e0f83e147dadf6b0a91e044bce00ea2a8c90c539c1ad0970dc", + "sha256": "c3755a8dcfda46fce09fb8eb69d6b9dccdc5487fe6aa94f4df49279ebeb41acc", "size": 68208 }, "python/iqpilot_private/konn3kt/hephaestus/motd.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "af1119b628946776d468bad4c6f05126a9f1b76010e76d84615f004fb10ea0cd", + "sha256": "270b887b04eba51006aff5c71c6b57a29099813752a49a6a1942380edb25790a", "size": 67840 }, "python/iqpilot_private/konn3kt/hephaestus/tp20.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "e5e0fa2832d0ace95843e5d732ecd9e1de370c00d5dc41a55f33103236194125", + "sha256": "fdc61d802b53f6233eab0a73f7ef732d4a67134a3849343e259871bc08fec0ef", "size": 135664 }, "python/iqpilot_private/konn3kt/hephaestus/vw_pq_flasher.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "f20bd4b330e641b481db8f545b72d472007402c709666b687d4e9897c42d0dc0", + "sha256": "98770387cdda540ce42c8d0b0508fd76b1c03a457d675823aa827b5725d5f888", "size": 269600 }, "python/iqpilot_private/konn3kt/uploaderd/__init__.py": { @@ -466,7 +466,7 @@ }, "python/iqpilot_private/konn3kt/uploaderd/iquploaderd.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "eee4e9e58787962b9cb5e63f9b946fa07ac1121c3c29373f947d3d461db3f1e3", + "sha256": "84394628653c76537d7e2be1938415eeea3decc0791d57b4296f6177e30588db", "size": 204296 }, "runtime": { @@ -510,25 +510,25 @@ } }, "signatures": { - "python/iqpilot_private/konn3kt/backups/archive_codec.cpython-312-aarch64-linux-gnu.so": "l+KcwF57otKYCL17nXSTJ8s0so+z9q4b/SsOfagBc9G+TbGDO9qHYcbsMtWSKoIj7Np6NGwAhBBKGubGNA6NDw==", - "python/iqpilot_private/konn3kt/backups/backup_keys.cpython-312-aarch64-linux-gnu.so": "7gttAqiZR2SOutahE8XnQEH+es5E7nHt8TprLg3QAv/q/aYVTvJMHM/uMYUr7CVqtNSk9NXzi4kuahry96A2DQ==", - "python/iqpilot_private/konn3kt/backups/backup_orchestrator.cpython-312-aarch64-linux-gnu.so": "otqtacOKGvys+mxrA0GSs/C2gvL/RoyNVXXeTtwhYYBYsBwWEacuM6xEAANZtnkowt2EIrY22RdIDS9091KBBQ==", - "python/iqpilot_private/konn3kt/backups/cbc_vault.cpython-312-aarch64-linux-gnu.so": "4qPIHhjLGAGjKTXtvAWbdrOqxLL5vJtZnF3xVUBQiB/3mQo20i0hPT8+6164HQXMyf596FsetmzUclPPlAeoBQ==", - "python/iqpilot_private/konn3kt/backups/imahelper.cpython-312-aarch64-linux-gnu.so": "jPid/JOUWsCYqtCVG4JD5c86nLtadg9L3YICEhsodZiaVPx2Yga0wxPvS7wkaNKCBohIGD20QxJqBUgrKnXQCA==", - "python/iqpilot_private/konn3kt/flockd/flockd.cpython-312-aarch64-linux-gnu.so": "Wl2IcgkvLI/bHQfozQP4vcMhbhMhph3rb/Zu4Mg7E6XKNfxw/N8/frq+8x8QV9N2kRro9/6eANyAamOsQUFuBw==", - "python/iqpilot_private/konn3kt/flockd/signatures.cpython-312-aarch64-linux-gnu.so": "nzibDoeERknQpLGPUCUQ0qa7FxkoJ2UNXHA8pgcIFWCLMUXDPODjGbKdx2mpSQDU7Q5E5xNx6gjSU0JgZvpZAA==", - "python/iqpilot_private/konn3kt/hephaestus/ble_auth.cpython-312-aarch64-linux-gnu.so": "uuROebZDc2abXyiuhL8kvQszS5/aADOnEG1wP0cssD2I6CRPqJb47y6mwXKlUJrgasod2XDhD22mCbn9sDuVDA==", - "python/iqpilot_private/konn3kt/hephaestus/ble_gatt.cpython-312-aarch64-linux-gnu.so": "5wGiTrEA8uaDEkk5W0eHztI+gaixGHIT4sbgUKeiW8nUlJYAsC7pDXodOZMmcmcjtQTtgTFrzhDnA9O1aaC+Ag==", - "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_transportd.cpython-312-aarch64-linux-gnu.so": "lP6OEHgbOtQu8AskEdKgy+773g7F/NWGrkeD4LeC7zcdHmdmfg2dYcN+Wdrs8FrlvAjBilFqyv3wZVfoKv8dBg==", - "python/iqpilot_private/konn3kt/hephaestus/bt_gamepad.cpython-312-aarch64-linux-gnu.so": "q1XPOv/SdBC4xfSsiDSf8yR2mdfgWbseTBrUbl9xilFYUd565cv8iauUqeh3/EsVV8qZvcIZViTWuCpy00VZAQ==", - "python/iqpilot_private/konn3kt/hephaestus/cloud_routes.cpython-312-aarch64-linux-gnu.so": "zY8c7e8Qq5vSDlbymf3o6hsPz+slQ8oxqiLk8qpKDNFfP6hJxK2eUf/9WHQGGTvGg99d/QoQVyozCu1XRGoFCA==", - "python/iqpilot_private/konn3kt/hephaestus/hephaestusd.cpython-312-aarch64-linux-gnu.so": "xu/P0Ekw0d1yjINg542+10BROwGUU5G6qktfwnhFymcF0jhWGaThSndJmPeFmWq0k9bs4asAXimgq5/BUAIABw==", - "python/iqpilot_private/konn3kt/hephaestus/kwp2000.cpython-312-aarch64-linux-gnu.so": "zFPl0ip4i/baX0GJzDnPGrm9vAgeXXMIotBr0VArZOxtOM1pOyGwOaJpgWBtb4kVUsNLepjfPgva6UVhrvb6Dg==", - "python/iqpilot_private/konn3kt/hephaestus/manage_hephaestusd.cpython-312-aarch64-linux-gnu.so": "Ai6K1+DGajQIi6tEBl0wfPoHWHA499mXbLrEZQZZb0X7dlASbwu+MVW1jovY3kgskMI9JcVANNEWPfSTvPnyAg==", - "python/iqpilot_private/konn3kt/hephaestus/motd.cpython-312-aarch64-linux-gnu.so": "GMyhhOO0gGxbbLiB0yjUKVOwV6JeeTfhKNnjytjAboFDUj4ueh/Kuf9L6CRuFZf9xU6uvB6QXSBuUr+FHEYQDQ==", - "python/iqpilot_private/konn3kt/hephaestus/tp20.cpython-312-aarch64-linux-gnu.so": "+fLA6hdx0m3WISNW+mvi8s8Lk/N+ZOvfyEF69ZmQ8lFWVdN08g52wbaH6lR2hh2bl8y8rekqFGSHKj38NnYcAg==", - "python/iqpilot_private/konn3kt/hephaestus/vw_pq_flasher.cpython-312-aarch64-linux-gnu.so": "DGU9axrJW6Mtcpt43w07LgP9u+fk67pOZ4pfmiTGddnsZkw1IEJJZneguVTO5mUN7OZq9iWH2KsqV+N1aaLbDw==", - "python/iqpilot_private/konn3kt/uploaderd/iquploaderd.cpython-312-aarch64-linux-gnu.so": "SSw7fJoNsbQ1GuomiW9rETXgMVqHKgqv7wuPIbzL+Y+pNJyYgvmikeyTFaloTIhllvr9n0XzSp/KT+qr41weCw==" + "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": "GmZl9B5gnEtrfR2puNGucdznMZ1QAdTwI1T+HkJeXooHRdnhSh9/GarqxzjhOBT/br471jx9h7Qm8ZcYkc8VBw==", + "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": "J01e8wupmVBf6jEqodutokSkmkSAbMCeoiTPkhM1ME125NtHrSbJOuoka9c3wdUXVeVz3itnt2KkiayDwCvmBA==", + "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": "z+x9JhNkR5wwo+oN64kBuDfeGmWQtrm73iqmK8e4KYDi6bW5EbR17YGJd2w2Qpy3Y+O4hqWPTzimC76NvvgoBg==", + "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": "YxpFvXNm0HIXYVVaW1zDXwWOsh5nz7eZS6DdsMlnMdl8OHaSgabWZBN7RF4iWJP/GU9S0x6u9hOBXLosf12wDQ==", + "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": "uPcSh8CGEYHeu9ApsPIn0rNCFz+EBTyhuqQyGLGjU/RSnoWhkhHmqpbeCfgZTBmkwLol7Onfex0w/ELEjUyrDQ==", + "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": "UzuHr0ym4jsHBzBW4e5XPcP+46zeds6Ec+7FK9hqMjJx+CVRfg9/1CfLCwqCUhe7QC4puqt0R8AQ6tCWppflCg==", + "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": "9LDSMTtL21Ec4iVjYxBL8UjclFf16M/vTSehYveVHOnVOaZPiki9mhf1UJScCxbTK24EqLMD14AdKzZ44qokBA==", + "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": "agjSn1SbGK6VW8X/dDRFpqz5sTpjvBnlnMKnLXf/o0ApUeECcgujLuOAZu+TPyfmcEVcPyE8TYYamCM0VGY0Dw==", + "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": "1U+W1BeavzaoaMEQxCFBPB4RleaE704xgf7a9tsqMfvScfU80nvge/VuvgIF64OPxYZIoYv9s0oMrzTNy6ZfDw==", + "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": "3W7enZXiH/fkwzwm3YMt3lw8OvetDzHK08jeZdNvQ6JMTjojSiO/jgulmOplMaNOrdp4aC/C1jOER70DwzxPCQ==" } } diff --git a/artifacts/iqpilot_hephaestusd_private/manifest.json.sig b/artifacts/iqpilot_hephaestusd_private/manifest.json.sig index aa65720a7..c1fb342f3 100644 --- a/artifacts/iqpilot_hephaestusd_private/manifest.json.sig +++ b/artifacts/iqpilot_hephaestusd_private/manifest.json.sig @@ -1 +1 @@ -aT5ZgWVbq0qWHt3m8wQmPWVfaFivYO6zCFB2LxmMC6bn1ZS+k/tNYZTbIANNFwhKGM4Q6zdQeGVicoDreqtYBw== +cQ9jTWO/J9039E1TJAs/oACuUTbhSRciZy07VantcDjTofWJPxCZ32Go8eqQPHipjbeXU8Oo4jJlNdjsSg2DAg== diff --git a/artifacts/iqpilot_hephaestusd_private/python/_iqclosure/iqpilot/selfdrive/iqmodeld/models/helpers.py b/artifacts/iqpilot_hephaestusd_private/python/_iqclosure/iqpilot/selfdrive/iqmodeld/models/helpers.py index 186c9d623..4b9a2f4d4 100644 --- a/artifacts/iqpilot_hephaestusd_private/python/_iqclosure/iqpilot/selfdrive/iqmodeld/models/helpers.py +++ b/artifacts/iqpilot_hephaestusd_private/python/_iqclosure/iqpilot/selfdrive/iqmodeld/models/helpers.py @@ -29,6 +29,7 @@ _ACTIVE_BUNDLE_KEY = "ModelManager_ActiveBundle" _MODELS_CACHE_KEY = "ModelManager_ModelsCache" _RUNNER_CACHE_KEY = "ModelRunnerTypeCache" _DOWNLOAD_INDEX_KEY = "ModelManager_DownloadIndex" +_PENDING_INDEX_KEY = "ModelManager_PendingIndex" _PENDING_MODEL_RESTORE_FILE = "/data/k3_pending_model_restore" _STOCK_RUNNER = int(Runner.stock) _TINYGRAD_RUNNER = int(Runner.tinygrad) @@ -226,6 +227,7 @@ def select_default_model(params: Params = None) -> None: bundle_dict = _load_default_bundle_dict() ensure_default_model_files(bundle_dict) params.remove(_DOWNLOAD_INDEX_KEY) + params.remove(_PENDING_INDEX_KEY) params.put(_ACTIVE_BUNDLE_KEY, bundle_dict) params.remove(_RUNNER_CACHE_KEY) params.put(_RUNNER_CACHE_KEY, _TINYGRAD_RUNNER) diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/archive_codec.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/archive_codec.cpython-312-aarch64-linux-gnu.so index 9e509d962..c649728b7 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/archive_codec.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/archive_codec.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/backup_keys.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/backup_keys.cpython-312-aarch64-linux-gnu.so index b9dd2e505..09c396637 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/backup_keys.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/backup_keys.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/backup_orchestrator.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/backup_orchestrator.cpython-312-aarch64-linux-gnu.so index c13f3ee64..c34ddfc52 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/backup_orchestrator.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/backup_orchestrator.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/cbc_vault.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/cbc_vault.cpython-312-aarch64-linux-gnu.so index d00dd8fa1..3c251f3af 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/cbc_vault.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/cbc_vault.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/imahelper.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/imahelper.cpython-312-aarch64-linux-gnu.so index e756cdcca..343195582 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/imahelper.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/imahelper.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/flockd/flockd.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/flockd/flockd.cpython-312-aarch64-linux-gnu.so index 7a685ad8a..b26b7ff62 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/flockd/flockd.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/flockd/flockd.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/flockd/signatures.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/flockd/signatures.cpython-312-aarch64-linux-gnu.so index 53ac0901d..acd3841f0 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/flockd/signatures.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/flockd/signatures.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/_vendor/localapi_runtime.zip b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/_vendor/localapi_runtime.zip index 4b8b44f6c..168953c82 100644 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/_vendor/localapi_runtime.zip and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/_vendor/localapi_runtime.zip differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_auth.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_auth.cpython-312-aarch64-linux-gnu.so index 97dd631d2..d818ff559 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_auth.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_auth.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_gatt.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_gatt.cpython-312-aarch64-linux-gnu.so index 24f76e3b5..21bb4700b 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_gatt.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_gatt.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_rpc_dispatch.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_rpc_dispatch.cpython-312-aarch64-linux-gnu.so index a2c9b1a4f..0542a0db9 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_rpc_dispatch.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_rpc_dispatch.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_transportd.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_transportd.cpython-312-aarch64-linux-gnu.so index 76f4eb238..ba8a10caf 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_transportd.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_transportd.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/bt_gamepad.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/bt_gamepad.cpython-312-aarch64-linux-gnu.so index 54bab2f3c..67f1b5c13 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/bt_gamepad.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/bt_gamepad.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/cloud_routes.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/cloud_routes.cpython-312-aarch64-linux-gnu.so index 7144fd042..65dfa3d21 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/cloud_routes.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/cloud_routes.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/hephaestusd.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/hephaestusd.cpython-312-aarch64-linux-gnu.so index 0789a9306..a0472cc5d 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/hephaestusd.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/hephaestusd.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/kwp2000.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/kwp2000.cpython-312-aarch64-linux-gnu.so index e4b8fe4c4..9d1b79b01 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/kwp2000.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/kwp2000.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/manage_hephaestusd.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/manage_hephaestusd.cpython-312-aarch64-linux-gnu.so index 8fb55495e..fae74198e 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/manage_hephaestusd.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/manage_hephaestusd.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/motd.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/motd.cpython-312-aarch64-linux-gnu.so index f194e08d8..7e6e412d1 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/motd.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/motd.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/tp20.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/tp20.cpython-312-aarch64-linux-gnu.so index 58c6ae02f..9cc2ac6f0 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/tp20.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/tp20.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/vw_pq_flasher.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/vw_pq_flasher.cpython-312-aarch64-linux-gnu.so index 7a31ce003..062d373bd 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/vw_pq_flasher.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/vw_pq_flasher.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/uploaderd/iquploaderd.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/uploaderd/iquploaderd.cpython-312-aarch64-linux-gnu.so index 320ff90e2..c7bdfd48c 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/uploaderd/iquploaderd.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/uploaderd/iquploaderd.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_model_selector_private/manifest.json b/artifacts/iqpilot_model_selector_private/manifest.json index 61f005abb..218dc9785 100644 --- a/artifacts/iqpilot_model_selector_private/manifest.json +++ b/artifacts/iqpilot_model_selector_private/manifest.json @@ -46,8 +46,8 @@ }, "python/_iqclosure/iqpilot/common/realtime.py": { "mode": 420, - "sha256": "ff112acc1fdc227e2ffa4b0fa74f27b967a488bb72fe21c5be82fc70d1104f02", - "size": 3488 + "sha256": "f137ef62c1602a1f7c329731883dc518c62288bd25596891858d460ae3209ae2", + "size": 3966 }, "python/_iqclosure/iqpilot/common/swaglog.py": { "mode": 420, @@ -91,18 +91,18 @@ }, "python/_iqclosure/iqpilot/selfdrive/iqmodeld/models/__init__.py": { "mode": 420, - "sha256": "e5ddcbc7b4dc348957c3d141c6bf8e7c321067f2ac56aecec24f6de107f9c2d3", - "size": 81 + "sha256": "5865243a95ca4557d2a960302124a9898545cc3a8387e88ef569308e3d0f7077", + "size": 119 }, "python/_iqclosure/iqpilot/selfdrive/iqmodeld/models/fetcher.py": { "mode": 420, - "sha256": "0487789aec270f9a712a19afafe0b686f825f06f57006c2346c052d3622ac5f8", - "size": 619 + "sha256": "23622cdec9eed5649a05422ba19eae956ba16a63850927af4a222c3d9030a1bb", + "size": 399 }, "python/_iqclosure/iqpilot/selfdrive/iqmodeld/models/helpers.py": { "mode": 420, - "sha256": "5ddd486e47cdf811c0c293c460aba2d3b15bb30059ff6da8da775c5cd236465b", - "size": 10945 + "sha256": "e4f524c7595754fd9db86724a7bd9b67524c630275ac78599ab6838d1aa7d809", + "size": 11037 }, "python/_iqclosure/iqpilot/system/__init__.py": { "mode": 420, @@ -121,8 +121,8 @@ }, "python/_iqclosure/iqpilot/system/hardware/hw.py": { "mode": 420, - "sha256": "32da46465ff0a232d21c6d54a54cf387db5e8441e568fe6c169abf9b8a016053", - "size": 3441 + "sha256": "c69185d8f6482c8b021f687b793246d2e2ede853e89a88748c64b85f60711b59", + "size": 3638 }, "python/_iqclosure/iqpilot/system/hardware/pc/__init__.py": { "mode": 420, @@ -146,8 +146,8 @@ }, "python/_iqclosure/iqpilot/system/hardware/tici/hardware.py": { "mode": 420, - "sha256": "49d817c9df4b25524190fd92c6899c4c6bfb0f101f2c58a53f0a99a7933f93b9", - "size": 25530 + "sha256": "f1ddffa9bdb5071a49fd65eb4b49958b60b1873e1ec70e6f3f2e2b2501abce2f", + "size": 25584 }, "python/_iqclosure/iqpilot/system/hardware/tici/iwlist.py": { "mode": 420, @@ -166,8 +166,8 @@ }, "python/_iqclosure/iqpilot/system/version.py": { "mode": 420, - "sha256": "b39a686854c9bfa3c6db9158f68734d7044760cf7ee79980102b7d23230836fd", - "size": 5909 + "sha256": "3a6bc7520dae7944a1da6f911cd090704a463e011f39949ea6c9df51b0e8c616", + "size": 6033 }, "python/iqpilot_private/__init__.py": { "mode": 420, @@ -176,7 +176,7 @@ }, "python/iqpilot_private/models/__init__.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "6aa9c6c725971a6ea6e1781dcc752297b072599a8a5d1ba78605ea69617a0a18", + "sha256": "c0d8a584bfeb79f0a109108ff463a95a57db24635f48d936461dddf4673e25b4", "size": 67616 }, "python/iqpilot_private/models/__init__.py": { @@ -186,23 +186,23 @@ }, "python/iqpilot_private/models/fetcher.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "dfbd700d0d637203969c39f9270d3aa2b8e1fb882ca3e852ef8c6055c828119b", - "size": 136176 + "sha256": "fe34e89be0539fd173457cadc1fdf3a1467d6fefd965031c37cc3d5566f8b1bb", + "size": 136200 }, "python/iqpilot_private/models/git_auth.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "532beea0658a2cc045b3039b6aa5d2c16e0225c5046b86e48fba745f93419f32", + "sha256": "9e7dd5fbf72f7737130fb36718a2b212afa55757b39ad6ad99d7e4643fa852fa", "size": 67864 }, "python/iqpilot_private/models/helpers.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "650f1b94ad3bd468718096d194c69768779541cf7c20069cbb9bac4c06e1e016", - "size": 137936 + "sha256": "8bedb7406b942dbee7274cd97bb448ded69284911f9760dd702bb90e44325d20", + "size": 137944 }, "python/iqpilot_private/models/manager.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "6ad8abc92158cf45e27999520d7712d69c7246cf2e5a0682fc9f15d2f6ed6b33", - "size": 274232 + "sha256": "65f503a85a00b729f0f4eeb033601f7978a0aba81fd4ba3ea58c0a3d18e45dad", + "size": 340000 }, "runtime": { "entries": { @@ -213,10 +213,10 @@ } }, "signatures": { - "python/iqpilot_private/models/__init__.cpython-312-aarch64-linux-gnu.so": "xO18IfX1kJN00FcBH4UqAWLU5qE+K2z/tTYRkkCf1W+hEUyZTcID1xFwhA1wafpMnBNCd0mVJUMc9oaYaFdjBA==", - "python/iqpilot_private/models/fetcher.cpython-312-aarch64-linux-gnu.so": "gk48ywZ8vN9RZCxpNEG7OKM1eWTJfb3qMRWX2/BfGJ5HGshPOkKN48EUmvJgpClA9AXaIRQlGppzsRdG8iOOAg==", - "python/iqpilot_private/models/git_auth.cpython-312-aarch64-linux-gnu.so": "u5H2u7uxdyRaoYvXSkfosXJZc5gLmE2H8kmwTMWquuo2gKrqbXDB6noRGgJbj7qDtzZ5Q9k9RReLTfmOqRnJBQ==", - "python/iqpilot_private/models/helpers.cpython-312-aarch64-linux-gnu.so": "ihqRyFfd5K62WCGstETp5xyFbkFwafUmBBobxke7NyFPtKH3Qxbr6rUvNRilfGl5NzB3aT6mpmxbbomKj1iUAw==", - "python/iqpilot_private/models/manager.cpython-312-aarch64-linux-gnu.so": "xRQGJvIRdFO/RHyqpWKXuh6AWAERHVs+0tts4UiAR7s9gzQIdFc96DXtHqmE2b1zTQLKUy8K22kX7gr4JovUAw==" + "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": "BbwoBwc9jhIgfty30cSgr1mD0u633HyT9MmO7Um7/CrcF9bO5uyhE5hDHsotvHaQmwKnTBGW4+D9hLs8xNo0Bw==", + "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": "g2oM0JvMIJOvhat23cMmWq8QY+jAAsrQFEFZSk9sC/Kq4aqDZZ/KthrmlLQaNclXtkATJShOxfIlPuEVFzpzAg==", + "python/iqpilot_private/models/manager.cpython-312-aarch64-linux-gnu.so": "ZGu3WKX4AELSUXdXpPu4IF+gjzLeRqx1jdi+WGcnCJK17dKtvui9OQ0s3uF3j37DnvBMjpjxnnvwwIQjcEjoAQ==" } } diff --git a/artifacts/iqpilot_model_selector_private/manifest.json.sig b/artifacts/iqpilot_model_selector_private/manifest.json.sig index 5132eca82..64d92df48 100644 --- a/artifacts/iqpilot_model_selector_private/manifest.json.sig +++ b/artifacts/iqpilot_model_selector_private/manifest.json.sig @@ -1 +1 @@ -ryT5nYzhX7vJdIvgT2U4dWDom4BNtUtffuIUrVZbwhtQHJVBNawplodSLlHxOvQEyd+u6+xKP81NhSoTEpMqAg== +rL5ip/O3aYwgySr2d0kyzVfFnyOTfAAwVbDl8lxmNJIjPkpSLsEDrgglLQ5MWjmL4s2oHeJA70f2ahx31WgVBw== diff --git a/artifacts/iqpilot_model_selector_private/python/_iqclosure/iqpilot/common/realtime.py b/artifacts/iqpilot_model_selector_private/python/_iqclosure/iqpilot/common/realtime.py index d19d801e3..9fd2f695d 100644 --- a/artifacts/iqpilot_model_selector_private/python/_iqclosure/iqpilot/common/realtime.py +++ b/artifacts/iqpilot_model_selector_private/python/_iqclosure/iqpilot/common/realtime.py @@ -41,6 +41,12 @@ def config_realtime_process(cores: int | list[int], priority: int) -> None: 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: """mlockall this process so memory reclaim/compaction can't stall it. RT control procs only (locking ui/modeld would worsen pressure). Best-effort.""" @@ -76,6 +82,13 @@ class Ratekeeper: self.avg_dt = MovingAverage(100) 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 def frame(self) -> int: return self._frame @@ -84,6 +97,10 @@ class Ratekeeper: def remaining(self) -> float: return self._remaining + @property + def lag(self) -> float: + return max(0., -self._remaining) + @property def lagging(self) -> bool: expected_dt = self._interval * (1 / 0.9) diff --git a/artifacts/iqpilot_model_selector_private/python/_iqclosure/iqpilot/selfdrive/iqmodeld/models/__init__.py b/artifacts/iqpilot_model_selector_private/python/_iqclosure/iqpilot/selfdrive/iqmodeld/models/__init__.py index c7d1e92cc..5ece1c074 100644 --- a/artifacts/iqpilot_model_selector_private/python/_iqclosure/iqpilot/selfdrive/iqmodeld/models/__init__.py +++ b/artifacts/iqpilot_model_selector_private/python/_iqclosure/iqpilot/selfdrive/iqmodeld/models/__init__.py @@ -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/ """ diff --git a/artifacts/iqpilot_model_selector_private/python/_iqclosure/iqpilot/selfdrive/iqmodeld/models/fetcher.py b/artifacts/iqpilot_model_selector_private/python/_iqclosure/iqpilot/selfdrive/iqmodeld/models/fetcher.py index 8ce6aff59..d06c7c3dd 100644 --- a/artifacts/iqpilot_model_selector_private/python/_iqclosure/iqpilot/selfdrive/iqmodeld/models/fetcher.py +++ b/artifacts/iqpilot_model_selector_private/python/_iqclosure/iqpilot/selfdrive/iqmodeld/models/fetcher.py @@ -1,11 +1,8 @@ #!/usr/bin/env python3 """ -Copyright (c) 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. +Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/ """ + from iqpilot._proprietary_loader import ProprietaryModuleMissing, load_private_module try: diff --git a/artifacts/iqpilot_model_selector_private/python/_iqclosure/iqpilot/selfdrive/iqmodeld/models/helpers.py b/artifacts/iqpilot_model_selector_private/python/_iqclosure/iqpilot/selfdrive/iqmodeld/models/helpers.py index 191d8a1b1..4b9a2f4d4 100644 --- a/artifacts/iqpilot_model_selector_private/python/_iqclosure/iqpilot/selfdrive/iqmodeld/models/helpers.py +++ b/artifacts/iqpilot_model_selector_private/python/_iqclosure/iqpilot/selfdrive/iqmodeld/models/helpers.py @@ -29,6 +29,7 @@ _ACTIVE_BUNDLE_KEY = "ModelManager_ActiveBundle" _MODELS_CACHE_KEY = "ModelManager_ModelsCache" _RUNNER_CACHE_KEY = "ModelRunnerTypeCache" _DOWNLOAD_INDEX_KEY = "ModelManager_DownloadIndex" +_PENDING_INDEX_KEY = "ModelManager_PendingIndex" _PENDING_MODEL_RESTORE_FILE = "/data/k3_pending_model_restore" _STOCK_RUNNER = int(Runner.stock) _TINYGRAD_RUNNER = int(Runner.tinygrad) @@ -40,7 +41,6 @@ _DEFAULT_BUNDLE_REF = "default" def get_default_model_bundle(_bundles): - """Legacy compatibility hook: stock default is preinstalled, not a manifest bundle.""" return None @@ -227,6 +227,7 @@ def select_default_model(params: Params = None) -> None: bundle_dict = _load_default_bundle_dict() ensure_default_model_files(bundle_dict) params.remove(_DOWNLOAD_INDEX_KEY) + params.remove(_PENDING_INDEX_KEY) params.put(_ACTIVE_BUNDLE_KEY, bundle_dict) params.remove(_RUNNER_CACHE_KEY) 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: 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 + queued_download = params.get(_DOWNLOAD_INDEX_KEY) try: 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") except Exception as e: cloudlog.exception(f"default_model: failed to seed default bundle: {e}") diff --git a/artifacts/iqpilot_model_selector_private/python/_iqclosure/iqpilot/system/hardware/hw.py b/artifacts/iqpilot_model_selector_private/python/_iqclosure/iqpilot/system/hardware/hw.py index eba33dfd2..7263ddbfc 100644 --- a/artifacts/iqpilot_model_selector_private/python/_iqclosure/iqpilot/system/hardware/hw.py +++ b/artifacts/iqpilot_model_selector_private/python/_iqclosure/iqpilot/system/hardware/hw.py @@ -28,6 +28,12 @@ class Paths: def comma_home() -> str: 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 def log_root() -> str: if os.environ.get('LOG_ROOT', False): diff --git a/artifacts/iqpilot_model_selector_private/python/_iqclosure/iqpilot/system/hardware/tici/hardware.py b/artifacts/iqpilot_model_selector_private/python/_iqclosure/iqpilot/system/hardware/tici/hardware.py index 67c33c79a..b9fa7fbff 100644 --- a/artifacts/iqpilot_model_selector_private/python/_iqclosure/iqpilot/system/hardware/tici/hardware.py +++ b/artifacts/iqpilot_model_selector_private/python/_iqclosure/iqpilot/system/hardware/tici/hardware.py @@ -515,7 +515,7 @@ class Tici(HardwareBase): try: pid = subprocess.check_output(["pgrep", "-f", "spi0"], encoding='utf8').strip() 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: print(str(e)) diff --git a/artifacts/iqpilot_model_selector_private/python/_iqclosure/iqpilot/system/version.py b/artifacts/iqpilot_model_selector_private/python/_iqclosure/iqpilot/system/version.py index 980a250bd..cb731d009 100644 --- a/artifacts/iqpilot_model_selector_private/python/_iqclosure/iqpilot/system/version.py +++ b/artifacts/iqpilot_model_selector_private/python/_iqclosure/iqpilot/system/version.py @@ -33,8 +33,13 @@ def get_version(path: str = BASEDIR) -> str: def get_release_notes(path: str = BASEDIR) -> str: - with open(os.path.join(path, "docs", "CHANGELOG.md")) as f: - return f.read().split('\n\n', 1)[0] + 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] + except OSError: + continue + return "" @cache diff --git a/artifacts/iqpilot_model_selector_private/python/iqpilot_private/models/__init__.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_model_selector_private/python/iqpilot_private/models/__init__.cpython-312-aarch64-linux-gnu.so index 711baf16f..ed886630d 100755 Binary files a/artifacts/iqpilot_model_selector_private/python/iqpilot_private/models/__init__.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_model_selector_private/python/iqpilot_private/models/__init__.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_model_selector_private/python/iqpilot_private/models/fetcher.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_model_selector_private/python/iqpilot_private/models/fetcher.cpython-312-aarch64-linux-gnu.so index 2eedd16d6..208c82d5c 100755 Binary files a/artifacts/iqpilot_model_selector_private/python/iqpilot_private/models/fetcher.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_model_selector_private/python/iqpilot_private/models/fetcher.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_model_selector_private/python/iqpilot_private/models/git_auth.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_model_selector_private/python/iqpilot_private/models/git_auth.cpython-312-aarch64-linux-gnu.so index 516aa1b2b..342b9ae9f 100755 Binary files a/artifacts/iqpilot_model_selector_private/python/iqpilot_private/models/git_auth.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_model_selector_private/python/iqpilot_private/models/git_auth.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_model_selector_private/python/iqpilot_private/models/helpers.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_model_selector_private/python/iqpilot_private/models/helpers.cpython-312-aarch64-linux-gnu.so index 433e529ee..c3a061ecd 100755 Binary files a/artifacts/iqpilot_model_selector_private/python/iqpilot_private/models/helpers.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_model_selector_private/python/iqpilot_private/models/helpers.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_model_selector_private/python/iqpilot_private/models/manager.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_model_selector_private/python/iqpilot_private/models/manager.cpython-312-aarch64-linux-gnu.so index 24c30abd4..249ae9b9a 100755 Binary files a/artifacts/iqpilot_model_selector_private/python/iqpilot_private/models/manager.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_model_selector_private/python/iqpilot_private/models/manager.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/package_sources/teleoprtc/.gitignore b/artifacts/package_sources/teleoprtc/.gitignore deleted file mode 100644 index e9863cb7e..000000000 --- a/artifacts/package_sources/teleoprtc/.gitignore +++ /dev/null @@ -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 \ No newline at end of file diff --git a/artifacts/package_sources/teleoprtc/.pre-commit-config.yaml b/artifacts/package_sources/teleoprtc/.pre-commit-config.yaml deleted file mode 100644 index 89e745c07..000000000 --- a/artifacts/package_sources/teleoprtc/.pre-commit-config.yaml +++ /dev/null @@ -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 diff --git a/artifacts/package_sources/teleoprtc/LICENSE b/artifacts/package_sources/teleoprtc/LICENSE deleted file mode 100644 index cae6fd0a1..000000000 --- a/artifacts/package_sources/teleoprtc/LICENSE +++ /dev/null @@ -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. diff --git a/artifacts/package_sources/teleoprtc/README.md b/artifacts/package_sources/teleoprtc/README.md deleted file mode 100644 index f20d25db5..000000000 --- a/artifacts/package_sources/teleoprtc/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# teleoprtc - -Set of abstractions for webRTC communication with [openpilot](https://github.com/commaai/openpilot). - diff --git a/artifacts/package_sources/teleoprtc/examples/face_detection/README.md b/artifacts/package_sources/teleoprtc/examples/face_detection/README.md deleted file mode 100644 index 74d6ea2cf..000000000 --- a/artifacts/package_sources/teleoprtc/examples/face_detection/README.md +++ /dev/null @@ -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] -``` \ No newline at end of file diff --git a/artifacts/package_sources/teleoprtc/examples/face_detection/face_detection.py b/artifacts/package_sources/teleoprtc/examples/face_detection/face_detection.py deleted file mode 100755 index f6fba9064..000000000 --- a/artifacts/package_sources/teleoprtc/examples/face_detection/face_detection.py +++ /dev/null @@ -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)) diff --git a/artifacts/package_sources/teleoprtc/examples/videostream_cli/README.md b/artifacts/package_sources/teleoprtc/examples/videostream_cli/README.md deleted file mode 100644 index 02b49b108..000000000 --- a/artifacts/package_sources/teleoprtc/examples/videostream_cli/README.md +++ /dev/null @@ -1 +0,0 @@ -# VideoStream CLI diff --git a/artifacts/package_sources/teleoprtc/examples/videostream_cli/cli.py b/artifacts/package_sources/teleoprtc/examples/videostream_cli/cli.py deleted file mode 100755 index 9640edc19..000000000 --- a/artifacts/package_sources/teleoprtc/examples/videostream_cli/cli.py +++ /dev/null @@ -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)) diff --git a/artifacts/package_sources/teleoprtc/pyproject.toml b/artifacts/package_sources/teleoprtc/pyproject.toml deleted file mode 100644 index b93df69fc..000000000 --- a/artifacts/package_sources/teleoprtc/pyproject.toml +++ /dev/null @@ -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" diff --git a/artifacts/package_sources/teleoprtc/scripts/bump_tag.sh b/artifacts/package_sources/teleoprtc/scripts/bump_tag.sh deleted file mode 100755 index 0f15c558e..000000000 --- a/artifacts/package_sources/teleoprtc/scripts/bump_tag.sh +++ /dev/null @@ -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" diff --git a/artifacts/package_sources/teleoprtc/scripts/publish_pypi.sh b/artifacts/package_sources/teleoprtc/scripts/publish_pypi.sh deleted file mode 100755 index 1cb0563ee..000000000 --- a/artifacts/package_sources/teleoprtc/scripts/publish_pypi.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env bash - -set -e - -if [[ -z "$1" ]]; then - echo "Usage: $0 " - 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/* diff --git a/artifacts/package_sources/teleoprtc/teleoprtc/__init__.py b/artifacts/package_sources/teleoprtc/teleoprtc/__init__.py deleted file mode 100644 index 4104f949b..000000000 --- a/artifacts/package_sources/teleoprtc/teleoprtc/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from teleoprtc.builder import WebRTCOfferBuilder, WebRTCAnswerBuilder # noqa -from teleoprtc.stream import WebRTCBaseStream, StreamingOffer, ConnectionProvider, MessageHandler # noqa \ No newline at end of file diff --git a/artifacts/package_sources/teleoprtc/teleoprtc/builder.py b/artifacts/package_sources/teleoprtc/teleoprtc/builder.py deleted file mode 100644 index 9dabf1383..000000000 --- a/artifacts/package_sources/teleoprtc/teleoprtc/builder.py +++ /dev/null @@ -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, - ) diff --git a/artifacts/package_sources/teleoprtc/teleoprtc/info.py b/artifacts/package_sources/teleoprtc/teleoprtc/info.py deleted file mode 100644 index fb786feda..000000000 --- a/artifacts/package_sources/teleoprtc/teleoprtc/info.py +++ /dev/null @@ -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) diff --git a/artifacts/package_sources/teleoprtc/teleoprtc/stream.py b/artifacts/package_sources/teleoprtc/teleoprtc/stream.py deleted file mode 100644 index 21edd9c77..000000000 --- a/artifacts/package_sources/teleoprtc/teleoprtc/stream.py +++ /dev/null @@ -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 diff --git a/artifacts/package_sources/teleoprtc/teleoprtc/tracks.py b/artifacts/package_sources/teleoprtc/teleoprtc/tracks.py deleted file mode 100644 index 2e583666a..000000000 --- a/artifacts/package_sources/teleoprtc/teleoprtc/tracks.py +++ /dev/null @@ -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() diff --git a/artifacts/package_sources/teleoprtc/tests/test_info.py b/artifacts/package_sources/teleoprtc/tests/test_info.py deleted file mode 100755 index 5e939cdfe..000000000 --- a/artifacts/package_sources/teleoprtc/tests/test_info.py +++ /dev/null @@ -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 diff --git a/artifacts/package_sources/teleoprtc/tests/test_integration.py b/artifacts/package_sources/teleoprtc/tests/test_integration.py deleted file mode 100755 index 7a6cdec1d..000000000 --- a/artifacts/package_sources/teleoprtc/tests/test_integration.py +++ /dev/null @@ -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 diff --git a/artifacts/package_sources/teleoprtc/tests/test_stream.py b/artifacts/package_sources/teleoprtc/tests/test_stream.py deleted file mode 100755 index 29bdef9c0..000000000 --- a/artifacts/package_sources/teleoprtc/tests/test_stream.py +++ /dev/null @@ -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() diff --git a/artifacts/package_sources/teleoprtc/tests/test_track.py b/artifacts/package_sources/teleoprtc/tests/test_track.py deleted file mode 100755 index 52b448355..000000000 --- a/artifacts/package_sources/teleoprtc/tests/test_track.py +++ /dev/null @@ -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 diff --git a/artifacts/runtime/env_sync.sh b/artifacts/runtime/env_sync.sh index e40b1be87..8f8d75a78 100755 --- a/artifacts/runtime/env_sync.sh +++ b/artifacts/runtime/env_sync.sh @@ -35,59 +35,84 @@ sync_python_env() { 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 - 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)" + 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])')" 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 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_RUNTIME_WHEEL_LOCK_SHA="" fi VENV_SITE_PACKAGES="$("$DIR/.venv/bin/python3" -c 'import site; print(site.getsitepackages()[0])' 2>/dev/null || true)" 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 - 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 # a top-level import passes on a partially extracted install (lazy backends), # so readiness also requires every wheel RECORD file to exist on disk PACKAGES_READY=1 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" 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 VENV_SITE_PACKAGES="$("$DIR/.venv/bin/python3" -c 'import site; print(site.getsitepackages()[0])')" fi sudo chown -R "$(id -u):$(id -g)" "$DIR/.venv" "$UV_CACHE_DIR" "$DIR/artifacts/package_sources" 2>/dev/null || true - IQDBC_PACKAGE_SOURCE="" - PACKAGE_SOURCES=() - while IFS=$'\t' read -r package_name package_source; do - PACKAGE_SOURCES+=("$package_source") - if [ "$package_name" = "iqdbc" ]; then - IQDBC_PACKAGE_SOURCE="$package_source" - fi - done < <(/usr/local/venv/bin/python3 "$DIR/iqpilot/system/runtime_package_sources.py" "$DIR") - if [ "${#PACKAGE_SOURCES[@]}" = "0" ] || [ -z "$IQDBC_PACKAGE_SOURCE" ]; then + if [ "${#RUNTIME_WHEELS[@]}" = "0" ] || [ "${#RUNTIME_WHEEL_SOURCES[@]}" = "0" ]; then return 1 fi - PACKAGE_BUILD_PYTHONPATH="$BASE_SITE_PACKAGES:$VENV_SITE_PACKAGES" - # The base AGNOS venv ships Eigen as a Python package instead of under - # /usr/include, so source-package builds need its install directory on the compiler include path. - # Resolve it through Python rather than pinning the Python minor version. - EIGEN_INCLUDE_ROOT="$(/usr/local/venv/bin/python3 -c \ - 'from pathlib import Path; import eigen; root = Path(eigen.__file__).resolve().parent / "install"; assert (root / "eigen3/Eigen/Dense").is_file(); print(root)' \ - 2>/dev/null || true)" - PACKAGE_BUILD_CPATH="${CPATH:-}" - if [ -n "$EIGEN_INCLUDE_ROOT" ]; then - PACKAGE_BUILD_CPATH="$EIGEN_INCLUDE_ROOT${PACKAGE_BUILD_CPATH:+:$PACKAGE_BUILD_CPATH}" + if [ "$PACKAGES_READY" != "1" ]; then + IQDBC_PACKAGE_SOURCE="" + PACKAGE_SOURCES=() + while IFS=$'\t' read -r package_name package_source; do + PACKAGE_SOURCES+=("$package_source") + if [ "$package_name" = "iqdbc" ]; then + IQDBC_PACKAGE_SOURCE="$package_source" + fi + done < <(/usr/local/venv/bin/python3 "$DIR/iqpilot/system/runtime_package_sources.py" "$DIR") + if [ "${#PACKAGE_SOURCES[@]}" = "0" ] || [ -z "$IQDBC_PACKAGE_SOURCE" ]; then + return 1 + fi + PACKAGE_BUILD_PYTHONPATH="$BASE_SITE_PACKAGES:$VENV_SITE_PACKAGES" + # The base AGNOS venv ships Eigen as a Python package instead of under + # /usr/include, so source-package builds need its install directory on the compiler include path. + # Resolve it through Python rather than pinning the Python minor version. + EIGEN_INCLUDE_ROOT="$(/usr/local/venv/bin/python3 -c \ + 'from pathlib import Path; import eigen; root = Path(eigen.__file__).resolve().parent / "install"; assert (root / "eigen3/Eigen/Dense").is_file(); print(root)' \ + 2>/dev/null || true)" + PACKAGE_BUILD_CPATH="${CPATH:-}" + if [ -n "$EIGEN_INCLUDE_ROOT" ]; then + PACKAGE_BUILD_CPATH="$EIGEN_INCLUDE_ROOT${PACKAGE_BUILD_CPATH:+:$PACKAGE_BUILD_CPATH}" + fi + UV_CACHE_DIR="$UV_CACHE_DIR" PYTHONPATH="$PACKAGE_BUILD_PYTHONPATH" CPATH="$PACKAGE_BUILD_CPATH" PATH="/usr/local/venv/bin:/usr/bin:$PATH" \ + uv pip install --python "$DIR/.venv/bin/python" --no-build-isolation --no-deps --reinstall "$IQDBC_PACKAGE_SOURCE" || return 1 + UV_CACHE_DIR="$UV_CACHE_DIR" PYTHONPATH="$PACKAGE_BUILD_PYTHONPATH" CPATH="$PACKAGE_BUILD_CPATH" PATH="/usr/local/venv/bin:/usr/bin:$PATH" \ + 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" + 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 - UV_CACHE_DIR="$UV_CACHE_DIR" PYTHONPATH="$PACKAGE_BUILD_PYTHONPATH" CPATH="$PACKAGE_BUILD_CPATH" PATH="/usr/local/venv/bin:/usr/bin:$PATH" \ - uv pip install --python "$DIR/.venv/bin/python" --no-build-isolation --no-deps --reinstall "$IQDBC_PACKAGE_SOURCE" || return 1 - UV_CACHE_DIR="$UV_CACHE_DIR" PYTHONPATH="$PACKAGE_BUILD_PYTHONPATH" CPATH="$PACKAGE_BUILD_CPATH" PATH="/usr/local/venv/bin:/usr/bin:$PATH" \ - 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" fi 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 diff --git a/artifacts/runtime_wheels/libdatachannel_py-2026.1.0.dev2-cp312-cp312-manylinux_2_35_aarch64.whl b/artifacts/runtime_wheels/libdatachannel_py-2026.1.0.dev2-cp312-cp312-manylinux_2_35_aarch64.whl new file mode 100644 index 000000000..67597026a Binary files /dev/null and b/artifacts/runtime_wheels/libdatachannel_py-2026.1.0.dev2-cp312-cp312-manylinux_2_35_aarch64.whl differ diff --git a/iqpilot/cereal/custom.capnp b/iqpilot/cereal/custom.capnp index 89880e1c9..1e813ec0c 100644 --- a/iqpilot/cereal/custom.capnp +++ b/iqpilot/cereal/custom.capnp @@ -322,8 +322,6 @@ struct IQOnroadEvent @0xf4621d3ee9233bc9 { # camera hardware wideCamFaulty @32; - # lane-change safety - lateralEdgeBlocked @33; } } @@ -503,15 +501,8 @@ enum IQTurnSignalDirection { turnRight @2; } -enum IQLateralEdgeBlock { - none @0; - left @1; - right @2; -} - struct IQDriveModelData @0xcdf0f7f14f46cb86 { turnSignalDirection @0 :IQTurnSignalDirection; - lateralEdgeBlock @1 :IQLateralEdgeBlock; } enum NavDirection { diff --git a/iqpilot/common/params_keys.h b/iqpilot/common/params_keys.h index 43eb96077..a9f7c7496 100644 --- a/iqpilot/common/params_keys.h +++ b/iqpilot/common/params_keys.h @@ -78,7 +78,6 @@ inline static std::unordered_map keys = { {"JoystickAolRequest", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, STRING}}, {"Konn3ktSshKeys", {PERSISTENT, STRING}}, {"Konn3ktBleTransportEnabled", {PERSISTENT, BOOL, "1"}}, - {"Konn3ktLibdatachannelWebRTC", {PERSISTENT, BOOL, "0"}}, {"LanguageSetting", {PERSISTENT, STRING, "en"}}, {"LastAthenaPingTime", {CLEAR_ON_MANAGER_START, INT}}, {"LastGPSPosition", {PERSISTENT, STRING}}, @@ -219,6 +218,7 @@ inline static std::unordered_map keys = { {"ModelManager_ActiveBundle", {PERSISTENT, JSON}}, {"ModelManager_ClearCache", {CLEAR_ON_MANAGER_START, BOOL}}, {"ModelManager_DownloadIndex", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, INT, "-1"}}, + {"ModelManager_PendingIndex", {PERSISTENT, INT, "-1"}}, {"IQModelFavorites", {PERSISTENT, STRING}}, {"ModelManager_LastSyncTime", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, INT, "0"}}, {"ModelManager_ModelsCache", {PERSISTENT, JSON}}, diff --git a/iqpilot/docs/CHANGELOG.md b/iqpilot/docs/CHANGELOG.md index 531d6a279..0bfdfaa66 100644 --- a/iqpilot/docs/CHANGELOG.md +++ b/iqpilot/docs/CHANGELOG.md @@ -66,10 +66,6 @@ - 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. -#### Lane Changes - -- Added a model-based road-edge guard that blocks unsafe lane changes with an alert. - #### Lateral Tuning - Added configurable steering smoothing, slew limiting, and curvature lookahead. diff --git a/iqpilot/selfdrive/controls/lib/desire_helper.py b/iqpilot/selfdrive/controls/lib/desire_helper.py index ce77ab134..e19a2e24f 100644 --- a/iqpilot/selfdrive/controls/lib/desire_helper.py +++ b/iqpilot/selfdrive/controls/lib/desire_helper.py @@ -14,13 +14,11 @@ from iqpilot.selfdrive.controls.lib.helpers.lane_change import ( AutoLaneChangeMode, NavExitLaneChangeController, ) -from iqpilot.selfdrive.controls.lib.helpers.lateral_edge_guard import LateralEdgeGuard from iqpilot.selfdrive.controls.lib.helpers.lane_turn import IQNavTurnController LaneChangeState = log.LaneChangeState LaneChangeDirection = log.LaneChangeDirection TurnDirection = custom.IQTurnSignalDirection -LateralEdgeBlock = custom.IQLateralEdgeBlock NavManeuverPhase = custom.IQNavState.ManeuverPhase LANE_CHANGE_SPEED_MIN = 20 * CV.MPH_TO_MS @@ -107,8 +105,6 @@ class DesireHelper: self.alc = IQLaneSwapController(self) self.lane_turn_controller = IQNavTurnController(self) 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.nav_turn_direction = TurnDirection.none 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: self._refresh_requested_direction(one_blinker, nav_exit_active) 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) 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: 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 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): 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 below_speed = carstate.vEgo < LANE_CHANGE_SPEED_MIN nav_exit_active = self._refresh_turn_overrides(carstate, nav_state) diff --git a/iqpilot/selfdrive/controls/lib/helpers/lateral_edge_guard.py b/iqpilot/selfdrive/controls/lib/helpers/lateral_edge_guard.py deleted file mode 100644 index f1b8a24e6..000000000 --- a/iqpilot/selfdrive/controls/lib/helpers/lateral_edge_guard.py +++ /dev/null @@ -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 diff --git a/iqpilot/selfdrive/controls/lib/tests/test_lateral_edge_guard.py b/iqpilot/selfdrive/controls/lib/tests/test_lateral_edge_guard.py deleted file mode 100644 index 56f7efe09..000000000 --- a/iqpilot/selfdrive/controls/lib/tests/test_lateral_edge_guard.py +++ /dev/null @@ -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 diff --git a/iqpilot/selfdrive/iqmodeld/daemon.py b/iqpilot/selfdrive/iqmodeld/daemon.py index c038c4bbb..bf3e0001f 100755 --- a/iqpilot/selfdrive/iqmodeld/daemon.py +++ b/iqpilot/selfdrive/iqmodeld/daemon.py @@ -590,7 +590,6 @@ class InferenceDaemon: driving_msg.drivingModelData.meta.laneChangeState = self._desire_logic.lane_change_state driving_msg.drivingModelData.meta.laneChangeDirection = self._desire_logic.lane_change_direction iq_msg.iqDriveModelData.turnSignalDirection = self._desire_logic.lane_turn_direction - iq_msg.iqDriveModelData.lateralEdgeBlock = self._desire_logic.lateral_edge_block populate_odometry_message( pose_msg, diff --git a/iqpilot/selfdrive/iqmodeld/models/helpers.py b/iqpilot/selfdrive/iqmodeld/models/helpers.py index 186c9d623..4b9a2f4d4 100644 --- a/iqpilot/selfdrive/iqmodeld/models/helpers.py +++ b/iqpilot/selfdrive/iqmodeld/models/helpers.py @@ -29,6 +29,7 @@ _ACTIVE_BUNDLE_KEY = "ModelManager_ActiveBundle" _MODELS_CACHE_KEY = "ModelManager_ModelsCache" _RUNNER_CACHE_KEY = "ModelRunnerTypeCache" _DOWNLOAD_INDEX_KEY = "ModelManager_DownloadIndex" +_PENDING_INDEX_KEY = "ModelManager_PendingIndex" _PENDING_MODEL_RESTORE_FILE = "/data/k3_pending_model_restore" _STOCK_RUNNER = int(Runner.stock) _TINYGRAD_RUNNER = int(Runner.tinygrad) @@ -226,6 +227,7 @@ def select_default_model(params: Params = None) -> None: bundle_dict = _load_default_bundle_dict() ensure_default_model_files(bundle_dict) params.remove(_DOWNLOAD_INDEX_KEY) + params.remove(_PENDING_INDEX_KEY) params.put(_ACTIVE_BUNDLE_KEY, bundle_dict) params.remove(_RUNNER_CACHE_KEY) params.put(_RUNNER_CACHE_KEY, _TINYGRAD_RUNNER) diff --git a/iqpilot/selfdrive/selfdrived/iq_events.py b/iqpilot/selfdrive/selfdrived/iq_events.py index 7ecf1cc0a..e9073f689 100644 --- a/iqpilot/selfdrive/selfdrived/iq_events.py +++ b/iqpilot/selfdrive/selfdrived/iq_events.py @@ -155,14 +155,6 @@ class IQEvents(EventsBase): EVENTS_IQ_TYPE = dict[int, dict[str, Alert | AlertCallbackType]] _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: { ET.WARNING: speed_limit_adjust_alert, }, diff --git a/iqpilot/selfdrive/selfdrived/selfdrived.py b/iqpilot/selfdrive/selfdrived/selfdrived.py index f7df1be9d..10176b9ac 100755 --- a/iqpilot/selfdrive/selfdrived/selfdrived.py +++ b/iqpilot/selfdrive/selfdrived/selfdrived.py @@ -222,9 +222,6 @@ class SelfdriveD(GapButtonActions): model_data = self._get_model_data_ext() model_events = [] - if model_data.lateralEdgeBlock != custom.IQLateralEdgeBlock.none: - model_events.append(custom.IQOnroadEvent.EventName.lateralEdgeBlocked) - lane_turn_direction = model_data.turnSignalDirection if lane_turn_direction == TurnDirection.turnLeft: model_events.append(custom.IQOnroadEvent.EventName.modelTurnLeft) diff --git a/iqpilot/system/manager/manager.py b/iqpilot/system/manager/manager.py index 9a376619e..5edb2b626 100755 --- a/iqpilot/system/manager/manager.py +++ b/iqpilot/system/manager/manager.py @@ -26,6 +26,22 @@ from iqpilot.system.version import get_build_metadata 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: heal_param_perms() save_bootlog() @@ -55,16 +71,22 @@ def manager_init() -> None: params.put_bool("RecordFront", True) # set unset params to their default value + initialized_defaults = {} for k in params.all_keys(): default_value = params.get_default_value(k) if default_value is not None and params.get(k) is None: params.put(k, default_value) + if default_value is not None: + initialized_defaults[k] = params.get(k) try: from iqpilot.selfdrive.iqmodeld.models.helpers import seed_default_bundle_if_unset seed_default_bundle_if_unset(params) except Exception: 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 try: @@ -142,7 +164,7 @@ def manager_thread() -> None: ignore.append("pandad") 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']) write_onroad_params(False, params) @@ -151,6 +173,7 @@ def manager_thread() -> None: started_prev = False ignition_prev = False running_prev = None + modeld_deadline = None while True: sm.update(1000) @@ -178,6 +201,9 @@ def manager_thread() -> None: ignition_prev = ignition 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 procs = [p for p in managed_processes.values() if p.proc] diff --git a/iqpilot/system/manager/test/test_manager.py b/iqpilot/system/manager/test/test_manager.py index c7daa9767..0c6a283b0 100644 --- a/iqpilot/system/manager/test/test_manager.py +++ b/iqpilot/system/manager/test/test_manager.py @@ -42,6 +42,31 @@ class TestManager: assert proc.bundle == "iqpilot_model_selector_private" 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): proc = BundleProcess("test", "bundle", "entry", lambda *_: True) proc.proc = mocker.Mock(exitcode=None, pid=123) diff --git a/iqpilot/system/runtime_package_sources.py b/iqpilot/system/runtime_package_sources.py index 1d73f5737..dd1f7e9f6 100644 --- a/iqpilot/system/runtime_package_sources.py +++ b/iqpilot/system/runtime_package_sources.py @@ -4,7 +4,7 @@ import sys from pathlib import Path -PACKAGE_NAMES = ("msgq", "iqdbc", "panda", "teleoprtc", "tinygrad") +PACKAGE_NAMES = ("msgq", "iqdbc", "panda", "tinygrad") root = Path(sys.argv[1]).resolve() text = (root / "pyproject.toml").read_text() for name in PACKAGE_NAMES: diff --git a/iqpilot/system/runtime_packages_verify.py b/iqpilot/system/runtime_packages_verify.py index 0ea99abb3..2eecf409c 100644 --- a/iqpilot/system/runtime_packages_verify.py +++ b/iqpilot/system/runtime_packages_verify.py @@ -11,7 +11,7 @@ import sys from importlib import metadata from pathlib import Path -PACKAGES = ("iqdbc", "msgq", "panda", "teleoprtc", "tinygrad") +PACKAGES = ("iqdbc", "msgq", "panda", "tinygrad") missing = [] for name in PACKAGES: diff --git a/iqpilot/system/runtime_wheel_requirements.py b/iqpilot/system/runtime_wheel_requirements.py new file mode 100644 index 000000000..9ae3d088d --- /dev/null +++ b/iqpilot/system/runtime_wheel_requirements.py @@ -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)) diff --git a/iqpilot/system/runtime_wheels_verify.py b/iqpilot/system/runtime_wheels_verify.py new file mode 100644 index 000000000..b342cf1a7 --- /dev/null +++ b/iqpilot/system/runtime_wheels_verify.py @@ -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) diff --git a/iqpilot/system/webrtc/device/audio.py b/iqpilot/system/webrtc/device/audio.py deleted file mode 100644 index 002aa1a2a..000000000 --- a/iqpilot/system/webrtc/device/audio.py +++ /dev/null @@ -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 diff --git a/iqpilot/system/webrtc/device/audio_ldc.py b/iqpilot/system/webrtc/device/native_audio.py similarity index 86% rename from iqpilot/system/webrtc/device/audio_ldc.py rename to iqpilot/system/webrtc/device/native_audio.py index 1e40ba917..c0235f992 100644 --- a/iqpilot/system/webrtc/device/audio_ldc.py +++ b/iqpilot/system/webrtc/device/native_audio.py @@ -7,17 +7,19 @@ import av from iqpilot.cereal import messaging 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: - """Micd PCM -> 48 kHz Opus payloads for libdatachannel's RTP packetizer.""" - def __init__(self) -> None: self._sock = messaging.sub_sock("rawAudioData", conflate=False) self._pcm = bytearray() self._source_rate = 16_000 self._next_pts = 0 + self._packet_pts = 0 self._pending: deque[tuple[bytes, int]] = deque() self._enabled = True self._resampler = av.AudioResampler(format="fltp", layout="mono", rate=48_000) @@ -65,13 +67,22 @@ class AudioInputOpusProducer: frame.time_base = Fraction(1, 48_000) self._next_pts += frame.samples 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() -class IncomingOpusCerealProxy: - """libdatachannel Opus payloads -> soundd-compatible PCM cereal messages.""" +class DebugAudioOpusProducer(AudioInputOpusProducer): + 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: self._loop = asyncio.get_running_loop() self._queue: asyncio.Queue[bytes] = asyncio.Queue(maxsize=32) diff --git a/iqpilot/system/webrtc/device/video_ldc.py b/iqpilot/system/webrtc/device/native_video.py similarity index 88% rename from iqpilot/system/webrtc/device/video_ldc.py rename to iqpilot/system/webrtc/device/native_video.py index 31082abe8..63f487fc4 100644 --- a/iqpilot/system/webrtc/device/video_ldc.py +++ b/iqpilot/system/webrtc/device/native_video.py @@ -4,7 +4,7 @@ import struct import time 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.common.params import Params @@ -272,3 +272,40 @@ class LiveStreamVideoStreamTrack(TiciVideoStreamTrack): def codec_preference(self) -> str | None: 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}") diff --git a/iqpilot/system/webrtc/device/video.py b/iqpilot/system/webrtc/device/video.py deleted file mode 100644 index 83dfaac78..000000000 --- a/iqpilot/system/webrtc/device/video.py +++ /dev/null @@ -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" diff --git a/iqpilot/system/webrtc/teleoprtc_ldc/__init__.py b/iqpilot/system/webrtc/rtc/__init__.py similarity index 100% rename from iqpilot/system/webrtc/teleoprtc_ldc/__init__.py rename to iqpilot/system/webrtc/rtc/__init__.py diff --git a/iqpilot/system/webrtc/teleoprtc_ldc/builder.py b/iqpilot/system/webrtc/rtc/builder.py similarity index 98% rename from iqpilot/system/webrtc/teleoprtc_ldc/builder.py rename to iqpilot/system/webrtc/rtc/builder.py index f9e5e9836..32173da13 100644 --- a/iqpilot/system/webrtc/teleoprtc_ldc/builder.py +++ b/iqpilot/system/webrtc/rtc/builder.py @@ -1,4 +1,4 @@ -# ruff: noqa: TID251, UP006, UP035 +# ruff: noqa: UP006, UP035 import abc from typing import Dict, List, Optional diff --git a/iqpilot/system/webrtc/teleoprtc_ldc/decoder.py b/iqpilot/system/webrtc/rtc/decoder.py similarity index 100% rename from iqpilot/system/webrtc/teleoprtc_ldc/decoder.py rename to iqpilot/system/webrtc/rtc/decoder.py diff --git a/iqpilot/system/webrtc/teleoprtc_ldc/info.py b/iqpilot/system/webrtc/rtc/info.py similarity index 100% rename from iqpilot/system/webrtc/teleoprtc_ldc/info.py rename to iqpilot/system/webrtc/rtc/info.py diff --git a/iqpilot/system/webrtc/teleoprtc_ldc/stream.py b/iqpilot/system/webrtc/rtc/stream.py similarity index 98% rename from iqpilot/system/webrtc/teleoprtc_ldc/stream.py rename to iqpilot/system/webrtc/rtc/stream.py index 41d47de85..c63cd3997 100644 --- a/iqpilot/system/webrtc/teleoprtc_ldc/stream.py +++ b/iqpilot/system/webrtc/rtc/stream.py @@ -1,4 +1,4 @@ -# ruff: noqa: TID251, UP006, UP035 +# ruff: noqa: UP006, UP035 import abc import asyncio @@ -14,6 +14,7 @@ from libdatachannel import ( Candidate, Description, FrameInfo, + H264RtpDepacketizer, H264RtpPacketizer, IceServer, NalUnit, @@ -100,7 +101,6 @@ class WebRTCBaseStream(abc.ABC): @staticmethod def _make_ice_servers(servers: Optional[List[dict]]) -> List[IceServer]: - """Preserve Konn3kt's authenticated STUN/TURN configuration in libdatachannel.""" parsed: List[IceServer] = [] for server in servers or []: urls = server.get("urls", []) if isinstance(server, dict) else [] @@ -119,7 +119,9 @@ class WebRTCBaseStream(abc.ABC): except Exception: 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. - 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): 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.add_h264_codec(96) track = self.peer_connection.add_track(media) - track.set_media_handler(OpusRtpDepacketizer()) + track.set_media_handler(H264RtpDepacketizer()) self._consumer_tracks.append(track) self.incoming_camera_tracks[camera_type] = track if self.expected_incoming_audio: media = Description.Audio("audio", Description.Direction.RecvOnly) media.add_opus_codec(111) track = self.peer_connection.add_track(media) + track.set_media_handler(OpusRtpDepacketizer()) self._consumer_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]: mid, payload_type = self._find_offer_video(remote_sdp) 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}" media = Description.Video(mid, Description.Direction.SendOnly) 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]: mid, payload_type = self._find_offer_audio(remote_sdp) 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 media = Description.Audio(mid, direction) media.add_opus_codec(payload_type) @@ -352,7 +355,6 @@ class WebRTCBaseStream(abc.ABC): self.incoming_message_handlers.append(message_handler) 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 "" if not candidate_sdp: return diff --git a/iqpilot/system/webrtc/teleoprtc_ldc/tracks.py b/iqpilot/system/webrtc/rtc/tracks.py similarity index 99% rename from iqpilot/system/webrtc/teleoprtc_ldc/tracks.py rename to iqpilot/system/webrtc/rtc/tracks.py index 32ff2cf0d..fd692949c 100644 --- a/iqpilot/system/webrtc/teleoprtc_ldc/tracks.py +++ b/iqpilot/system/webrtc/rtc/tracks.py @@ -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): assert camera_type in ["driver", "wideRoad", "road"] self._id: str = video_track_id(camera_type, str(uuid.uuid4())) + self._dt = dt self._time_base: fractions.Fraction = time_base self._clock_rate: int = clock_rate self._logger = logging.getLogger("WebRTCStream") diff --git a/iqpilot/system/webrtc/webrtcd_ldc.py b/iqpilot/system/webrtc/session.py similarity index 85% rename from iqpilot/system/webrtc/webrtcd_ldc.py rename to iqpilot/system/webrtc/session.py index c8c0a6d7c..c07e53f19 100644 --- a/iqpilot/system/webrtc/webrtcd_ldc.py +++ b/iqpilot/system/webrtc/session.py @@ -14,7 +14,6 @@ from iqpilot.system.webrtc.webrtcd import CerealIncomingMessageProxy, CerealOutg 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) try: sock.connect(("8.8.8.8", 53)) @@ -25,9 +24,7 @@ def _default_route_ip() -> str | None: sock.close() -class LibdatachannelBitrateController: - """Loss-driven bitrate control using native RTCP receiver reports.""" - +class LivestreamBitrateController: 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]} sample_interval = 0.2 @@ -103,45 +100,45 @@ class LibdatachannelBitrateController: self._publish(self.bitrates[self._level]) -class StreamSessionLibdatachannel: - """IQ.Pilot's libdatachannel stream path. It preserves Konn3kt signalling and controls.""" - - shared_pub_master = DynamicPubMaster([]) +class StreamSession: + shared_pub_master: DynamicPubMaster | None = None 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 iqpilot.system.webrtc.device.video_ldc import LiveStreamVideoStreamTrack - from iqpilot.system.webrtc.teleoprtc_ldc.builder import WebRTCAnswerBuilder - from iqpilot.system.webrtc.teleoprtc_ldc.info import parse_info_from_offer + from iqpilot.system.webrtc.device.native_audio import AudioInputOpusProducer, DebugAudioOpusProducer + from iqpilot.system.webrtc.device.native_video import DebugVideoStreamTrack, LiveStreamVideoStreamTrack + 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) if len(cameras) != config.n_expected_camera_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 []) - 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): builder.add_video_stream(camera, track) - # The browser uses a single sendrecv audio m-line. libdatachannel's Python binding - # currently cannot negotiate that bidirectional track reliably, so this experimental - # transport deliberately remains video/control-only. The default aiortc path keeps - # both audio directions until the native duplex path passes the same integration test. - self.audio_output = None + audio_track_type = DebugAudioOpusProducer if debug_mode else AudioInputOpusProducer + self.audio_output = audio_track_type() if config.expected_audio_track else None + if self.audio_output is not None: + builder.add_audio_stream(self.audio_output) + if config.incoming_audio_track: + builder.offer_to_receive_audio_stream() self.stream = builder.stream() self.identifier = str(uuid.uuid4()) 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_runner = CerealProxyRunner(self.outgoing_bridge) if self.outgoing_bridge is not None else None self.ui_stream_requested = ui_stream self.ui_stream_runner: CerealProxyRunner | None = None self.audio_input_proxy = None - self.audio_recv_requested = False - self.bitrate_controller = LibdatachannelBitrateController(self.stream.get_receiver_report_stats) + self.audio_recv_requested = config.incoming_audio_track + self.bitrate_controller = LivestreamBitrateController(self.stream.get_receiver_report_stats) self.run_task: asyncio.Task | None = None self._cleanup_lock = asyncio.Lock() self._cleanup_done = False @@ -216,7 +213,7 @@ class StreamSessionLibdatachannel: if self.ui_stream_requested: self.set_ui_stream(True) 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.start() self.bitrate_controller.start() diff --git a/iqpilot/system/webrtc/tests/test_native_session.py b/iqpilot/system/webrtc/tests/test_native_session.py new file mode 100644 index 000000000..88046644a --- /dev/null +++ b/iqpilot/system/webrtc/tests/test_native_session.py @@ -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") diff --git a/iqpilot/system/webrtc/tests/test_rtc.py b/iqpilot/system/webrtc/tests/test_rtc.py new file mode 100644 index 000000000..ac71dbb60 --- /dev/null +++ b/iqpilot/system/webrtc/tests/test_rtc.py @@ -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 diff --git a/iqpilot/system/webrtc/tests/test_rtc_integration.py b/iqpilot/system/webrtc/tests/test_rtc_integration.py new file mode 100644 index 000000000..c9388ffd4 --- /dev/null +++ b/iqpilot/system/webrtc/tests/test_rtc_integration.py @@ -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() diff --git a/iqpilot/system/webrtc/tests/test_stream_session.py b/iqpilot/system/webrtc/tests/test_stream_session.py index 939612e03..5b3e0d2b0 100644 --- a/iqpilot/system/webrtc/tests/test_stream_session.py +++ b/iqpilot/system/webrtc/tests/test_stream_session.py @@ -1,19 +1,14 @@ import asyncio +import gc 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 from iqpilot.cereal import messaging, log from iqpilot.system.webrtc.webrtcd import CerealOutgoingMessageProxy, CerealIncomingMessageProxy -from iqpilot.system.webrtc.device.video import LiveStreamVideoStreamTrack -from iqpilot.system.webrtc.device.audio import AudioInputStreamTrack +from iqpilot.system.webrtc.device.native_audio import AudioInputOpusProducer, DebugAudioOpusProducer +from iqpilot.system.webrtc.device.native_video import DebugVideoStreamTrack, LiveStreamVideoStreamTrack +from iqpilot.system.webrtc.rtc.tracks import VIDEO_TIME_BASE class TestStreamSession: @@ -23,6 +18,7 @@ class TestStreamSession: def teardown_method(self): self.loop.stop() self.loop.close() + gc.collect() def test_outgoing_proxy(self, mocker): test_msg = log.Event.new_message() @@ -32,12 +28,12 @@ class TestStreamSession: expected_dict = {"type": "customReservedRawData0", "logMonoTime": 123, "valid": True, "data": "test"} expected_json = json.dumps(expected_dict).encode() - channel = mocker.Mock(spec=RTCDataChannel) - mocked_submaster = messaging.SubMaster(["customReservedRawData0"]) - def mocked_update(t): - mocked_submaster.update_msgs(0, [test_msg]) - - mocker.patch.object(messaging.SubMaster, "update", side_effect=mocked_update) + channel = mocker.Mock() + mocked_submaster = mocker.MagicMock() + mocked_submaster.updated = {"customReservedRawData0": True} + mocked_submaster.logMonoTime = {"customReservedRawData0": 123} + mocked_submaster.valid = {"customReservedRawData0": True} + mocked_submaster.__getitem__.return_value = test_msg.customReservedRawData0 proxy = CerealOutgoingMessageProxy(mocked_submaster) proxy.add_channel(channel) @@ -47,9 +43,9 @@ class TestStreamSession: def test_incoming_proxy(self, mocker): tested_msgs = [ - {"type": "customReservedRawData0", "data": "test"}, # primitive - {"type": "can", "data": [{"address": 0, "dat": "", "src": 0}]}, # list - {"type": "testJoystick", "data": {"axes": [0, 0], "buttons": [False]}}, # dict + {"type": "customReservedRawData0", "data": "test"}, + {"type": "can", "data": [{"address": 0, "dat": "", "src": 0}]}, + {"type": "testJoystick", "data": {"axes": [0, 0], "buttons": [False]}}, ] mocked_pubmaster = mocker.MagicMock(spec=messaging.PubMaster) @@ -72,20 +68,15 @@ class TestStreamSession: fake_msg.livestreamDriverEncodeData.header = b"header" 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") assert track.id.startswith("driver") - assert track.codec_preference() == "H264" - - for i in range(5): - packet = self.loop.run_until_complete(track.recv()) - assert packet.time_base == VIDEO_TIME_BASE - if i == 0: - 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") + packet = self.loop.run_until_complete(track.recv()) + assert packet.time_base == VIDEO_TIME_BASE + assert packet.pts is not None + assert packet.size == len(b"header\x00\x00\x00\x01\x65") def test_input_audio_track(self, mocker): packet_time, rate = 0.02, 16000 @@ -93,11 +84,27 @@ class TestStreamSession: fake_msg = messaging.new_message("rawAudioData") fake_msg.rawAudioData.data = b"\x00" * 2 * sample_count fake_msg.rawAudioData.sampleRate = rate - mocker.patch("iqpilot.system.webrtc.device.audio.messaging.recv_one_or_none", return_value=fake_msg) - track = AudioInputStreamTrack(rate=rate) + mocker.patch("iqpilot.system.webrtc.device.native_audio.messaging.sub_sock", return_value=mocker.Mock()) + 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): - frame = self.loop.run_until_complete(track.recv()) - assert frame.rate == rate - assert frame.samples == sample_count - assert frame.pts == i * sample_count + packet = self.loop.run_until_complete(track.recv()) + assert packet is not None + payload, pts = packet + assert payload + 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 diff --git a/iqpilot/system/webrtc/tests/test_ui_stream.py b/iqpilot/system/webrtc/tests/test_ui_stream.py index f8346c3fa..9d74c9967 100644 --- a/iqpilot/system/webrtc/tests/test_ui_stream.py +++ b/iqpilot/system/webrtc/tests/test_ui_stream.py @@ -1,6 +1,9 @@ +import asyncio import json import math +import pytest + from iqpilot.cereal import log, messaging from iqpilot.system.webrtc.ui_stream import ( UI_STREAM_SERVICES, @@ -270,11 +273,13 @@ class TestUIStreamFrame: class TestSessionWiring: - def test_set_ui_stream_control_message(self): - import asyncio + @pytest.mark.asyncio + async def test_set_ui_stream_control_message(self, mocker): import logging 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.logger = logging.getLogger("webrtcd") @@ -287,14 +292,11 @@ class TestSessionWiring: get_messaging_channel=lambda: channel, ) - async def go(): - await session.message_handler(b'{"type":"setUiStream","enabled":true}') - assert session.ui_stream_runner is not None - await asyncio.sleep(0.05) - await session.message_handler(b'{"type":"setUiStream","enabled":false}') - assert session.ui_stream_runner is None - - asyncio.run(go()) + session.message_handler(b'{"type":"setUiStream","enabled":true}') + assert session.ui_stream_runner is not None + await asyncio.sleep(0.05) + session.message_handler(b'{"type":"setUiStream","enabled":false}') + assert session.ui_stream_runner is None assert len(channel.sent) >= 1 frame = json.loads(channel.sent[0]) diff --git a/iqpilot/system/webrtc/tests/test_webrtcd.py b/iqpilot/system/webrtc/tests/test_webrtcd.py index 93c08918f..c4b10d67e 100644 --- a/iqpilot/system/webrtc/tests/test_webrtcd.py +++ b/iqpilot/system/webrtc/tests/test_webrtcd.py @@ -1,16 +1,33 @@ -import pytest -import asyncio import json -# 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 types import SimpleNamespace -from iqpilot.system.webrtc.webrtcd import get_stream - -import aiortc -from teleoprtc import WebRTCOfferBuilder 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"), [ @@ -21,44 +38,38 @@ from parameterized import parameterized_class ]) @pytest.mark.asyncio 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): - mock_request = mocker.MagicMock() - async def connect(offer): - body = {'sdp': offer.sdp, 'cameras': offer.video, 'bridge_services_in': self.in_services, 'bridge_services_out': self.out_services} - mock_request.json.side_effect = mocker.AsyncMock(return_value=body) - response = await get_stream(mock_request) - response_json = json.loads(response.text) - return aiortc.RTCSessionDescription(**response_json) + session = FakeSession() + mocker.patch("iqpilot.system.webrtc.webrtcd._new_stream_session", return_value=session) + request = mocker.MagicMock() + request.app = {"streams": {}, "debug": False} + request.json = mocker.AsyncMock(return_value={ + "sdp": "offer", + "cameras": ["road"], + "bridge_services_in": self.in_services, + "bridge_services_out": self.out_services, + }) - builder = WebRTCOfferBuilder(connect, ice_servers=[]) - 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() + response = await get_stream(request) - 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()) - await self.assertCompletesWithTimeout(stream.wait_for_connection()) + async def test_replaces_session_and_routes_ice(self, mocker): + 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") - assert stream.has_incoming_audio_track() - assert stream.has_messaging_channel() == (len(self.in_services) > 0 or len(self.out_services) > 0) + response = await get_stream(request) - video_track, audio_track = stream.get_incoming_video_track("road"), stream.get_incoming_audio_track() - await self.assertCompletesWithTimeout(video_track.recv()) - await self.assertCompletesWithTimeout(audio_track.recv()) - - await self.assertCompletesWithTimeout(stream.stop()) - - # 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()) + assert response.status == 200 + assert previous.stopped + request.json = mocker.AsyncMock(return_value={"candidate": {"candidate": "candidate:1"}}) + ice_response = await add_ice(request) + assert ice_response.status == 200 + assert session.candidates == [{"candidate": "candidate:1"}] diff --git a/iqpilot/system/webrtc/webrtcd.py b/iqpilot/system/webrtc/webrtcd.py index 40b69d411..28c20cc84 100755 --- a/iqpilot/system/webrtc/webrtcd.py +++ b/iqpilot/system/webrtc/webrtcd.py @@ -3,52 +3,27 @@ import argparse import asyncio import json -import os -import uuid import logging from dataclasses import dataclass, field -from typing import Any, TYPE_CHECKING - -from iqpilot.common.params import Params +from typing import Any import warnings warnings.filterwarnings("ignore", category=DeprecationWarning) warnings.filterwarnings("ignore", category=RuntimeWarning) import capnp -import aiortc.rtcrtpsender 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.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: def __init__(self, sm: messaging.SubMaster): 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) def to_json(self, msg_content: Any): @@ -114,43 +89,6 @@ class AsyncTaskRunner: 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: def __init__(self, proxy: CerealOutgoingMessageProxy): self.proxy = proxy @@ -169,110 +107,14 @@ class CerealProxyRunner: self.task = None async def run(self): - from aiortc.exceptions import InvalidStateError - while True: try: self.proxy.update() - except InvalidStateError: - self.logger.warning("Cereal outgoing proxy invalid state (connection closed)") - break except Exception: self.logger.exception("Cereal outgoing proxy failure") 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): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -285,239 +127,11 @@ class DynamicPubMaster(messaging.PubMaster): 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: - # 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)) -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: return try: @@ -543,15 +157,7 @@ class StreamRequestBody: def _new_stream_session(offer_sdp: str, body: StreamRequestBody, debug_mode: bool): - if Params().get_bool("Konn3ktLibdatachannelWebRTC"): - 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") + from iqpilot.system.webrtc.session import StreamSession return StreamSession( offer_sdp, body.cameras, body.bridge_services_in, body.bridge_services_out, body.iceServers, debug_mode, 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'): stream_dict, debug_mode = request.app['streams'], request.app['debug'] logger = logging.getLogger("webrtcd") - session: StreamSession | None = None + session: Any | None = None try: raw_body = await request.json() body = StreamRequestBody(**raw_body) diff --git a/pyproject.toml b/pyproject.toml index 8ed1c6c2e..a568d6766 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,17 +27,12 @@ dependencies = [ "msgq", "iqdbc", "panda", - "teleoprtc", "tinygrad", # body / webrtcd "aiohttp", - "aiortc", + "av", "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 "libusb1", @@ -100,7 +95,6 @@ testing = [ ] dev = [ - "av", "azure-identity", "azure-storage-blob", "dictdiffer", @@ -255,5 +249,4 @@ not-subscriptable = "ignore" msgq = { path = "artifacts/package_sources/msgq" } iqdbc = { path = "artifacts/package_sources/iqdbc" } panda = { path = "artifacts/package_sources/panda" } -teleoprtc = { path = "artifacts/package_sources/teleoprtc" } tinygrad = { path = "artifacts/package_sources/tinygrad" } diff --git a/uv.lock b/uv.lock index 53d2d590a..a381a861d 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, ] -[[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]] name = "aiosignal" 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" }, ] -[[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]] name = "ewmhlib" 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" }, ] -[[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]] name = "hypothesis" 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" }, ] -[[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]] name = "imgui" version = "1.92.7" @@ -573,7 +504,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "aiohttp" }, - { name = "aiortc" }, + { name = "av" }, { name = "casadi" }, { name = "cffi" }, { name = "crcmod-plus" }, @@ -591,11 +522,9 @@ dependencies = [ { name = "onnx" }, { name = "panda" }, { name = "psutil" }, - { name = "pyaudio" }, { name = "pycapnp" }, { name = "pycryptodome" }, { name = "pyjwt" }, - { name = "pyopenssl" }, { name = "pyserial" }, { name = "pyzbar", marker = "sys_platform == 'linux'" }, { name = "pyzmq" }, @@ -609,7 +538,6 @@ dependencies = [ { name = "sounddevice" }, { name = "spidev", marker = "sys_platform == 'linux'" }, { name = "sympy" }, - { name = "teleoprtc" }, { name = "tinygrad" }, { name = "tqdm" }, { name = "websocket-client" }, @@ -619,7 +547,6 @@ dependencies = [ [package.optional-dependencies] dev = [ - { name = "av" }, { name = "azure-identity" }, { name = "azure-storage-blob" }, { name = "dictdiffer" }, @@ -655,8 +582,7 @@ tools = [ [package.metadata] requires-dist = [ { name = "aiohttp" }, - { name = "aiortc" }, - { name = "av", marker = "extra == 'dev'" }, + { name = "av" }, { name = "azure-identity", marker = "extra == 'dev'" }, { name = "azure-storage-blob", marker = "extra == 'dev'" }, { name = "casadi", specifier = ">=3.6.6" }, @@ -687,12 +613,10 @@ requires-dist = [ { name = "parameterized", marker = "extra == 'dev'", specifier = ">=0.8,<0.9" }, { name = "pre-commit-hooks", marker = "extra == 'testing'" }, { name = "psutil" }, - { name = "pyaudio" }, { name = "pyautogui", marker = "extra == 'dev'" }, { name = "pycapnp", specifier = "==2.1.0" }, { name = "pycryptodome" }, { name = "pyjwt" }, - { name = "pyopenssl", specifier = "<24.3.0" }, { name = "pyserial" }, { name = "pytest", marker = "extra == 'testing'" }, { name = "pytest-asyncio", marker = "extra == 'testing'" }, @@ -717,7 +641,6 @@ requires-dist = [ { name = "spidev", marker = "sys_platform == 'linux'" }, { name = "sympy" }, { name = "tabulate", marker = "extra == 'dev'" }, - { name = "teleoprtc", directory = "artifacts/package_sources/teleoprtc" }, { name = "tinygrad", directory = "artifacts/package_sources/tinygrad" }, { name = "tqdm" }, { 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" }, ] -[[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]] name = "pyautogui" 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" }, ] -[[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]] name = "pygetwindow" version = "0.0.9" @@ -1312,28 +1213,6 @@ crypto = [ { 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]] name = "pymonctl" 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" }, ] -[[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]] name = "pyparsing" 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" }, ] -[[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]] name = "tinygrad" version = "0.13.0"