diff --git a/poetry.lock b/poetry.lock index d052e730..2916ba3c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. [[package]] name = "aiosqlite" @@ -1348,6 +1348,115 @@ bcrypt = ["bcrypt (>=3.1.0)"] build-docs = ["cloud-sptheme (>=1.10.1)", "sphinx (>=1.6)", "sphinxcontrib-fulltoc (>=1.2.0)"] totp = ["cryptography"] +[[package]] +name = "pillow" +version = "12.1.1" +description = "Python Imaging Library (fork)" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "pillow-12.1.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1f1625b72740fdda5d77b4def688eb8fd6490975d06b909fd19f13f391e077e0"}, + {file = "pillow-12.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:178aa072084bd88ec759052feca8e56cbb14a60b39322b99a049e58090479713"}, + {file = "pillow-12.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b66e95d05ba806247aaa1561f080abc7975daf715c30780ff92a20e4ec546e1b"}, + {file = "pillow-12.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89c7e895002bbe49cdc5426150377cbbc04767d7547ed145473f496dfa40408b"}, + {file = "pillow-12.1.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a5cbdcddad0af3da87cb16b60d23648bc3b51967eb07223e9fed77a82b457c4"}, + {file = "pillow-12.1.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f51079765661884a486727f0729d29054242f74b46186026582b4e4769918e4"}, + {file = "pillow-12.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:99c1506ea77c11531d75e3a412832a13a71c7ebc8192ab9e4b2e355555920e3e"}, + {file = "pillow-12.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36341d06738a9f66c8287cf8b876d24b18db9bd8740fa0672c74e259ad408cff"}, + {file = "pillow-12.1.1-cp310-cp310-win32.whl", hash = "sha256:6c52f062424c523d6c4db85518774cc3d50f5539dd6eed32b8f6229b26f24d40"}, + {file = "pillow-12.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:c6008de247150668a705a6338156efb92334113421ceecf7438a12c9a12dab23"}, + {file = "pillow-12.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:1a9b0ee305220b392e1124a764ee4265bd063e54a751a6b62eff69992f457fa9"}, + {file = "pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32"}, + {file = "pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38"}, + {file = "pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5"}, + {file = "pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090"}, + {file = "pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af"}, + {file = "pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b"}, + {file = "pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5"}, + {file = "pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d"}, + {file = "pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c"}, + {file = "pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563"}, + {file = "pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80"}, + {file = "pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052"}, + {file = "pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984"}, + {file = "pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79"}, + {file = "pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293"}, + {file = "pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397"}, + {file = "pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0"}, + {file = "pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3"}, + {file = "pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35"}, + {file = "pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a"}, + {file = "pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6"}, + {file = "pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523"}, + {file = "pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e"}, + {file = "pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9"}, + {file = "pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6"}, + {file = "pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60"}, + {file = "pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2"}, + {file = "pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850"}, + {file = "pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289"}, + {file = "pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e"}, + {file = "pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717"}, + {file = "pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a"}, + {file = "pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029"}, + {file = "pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b"}, + {file = "pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1"}, + {file = "pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a"}, + {file = "pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da"}, + {file = "pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc"}, + {file = "pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c"}, + {file = "pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8"}, + {file = "pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20"}, + {file = "pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13"}, + {file = "pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf"}, + {file = "pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524"}, + {file = "pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986"}, + {file = "pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c"}, + {file = "pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3"}, + {file = "pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af"}, + {file = "pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f"}, + {file = "pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642"}, + {file = "pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd"}, + {file = "pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202"}, + {file = "pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f"}, + {file = "pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f"}, + {file = "pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f"}, + {file = "pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e"}, + {file = "pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0"}, + {file = "pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb"}, + {file = "pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f"}, + {file = "pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15"}, + {file = "pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f"}, + {file = "pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8"}, + {file = "pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9"}, + {file = "pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60"}, + {file = "pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7"}, + {file = "pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f"}, + {file = "pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586"}, + {file = "pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce"}, + {file = "pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8"}, + {file = "pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36"}, + {file = "pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b"}, + {file = "pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334"}, + {file = "pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f"}, + {file = "pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9"}, + {file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e"}, + {file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9"}, + {file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3"}, + {file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735"}, + {file = "pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e"}, + {file = "pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4"}, +] + +[package.extras] +docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] +fpx = ["olefile"] +mic = ["olefile"] +test-arrow = ["arro3-compute", "arro3-core", "nanoarrow", "pyarrow"] +tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma (>=5)", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"] +xmp = ["defusedxml"] + [[package]] name = "platformdirs" version = "4.5.1" @@ -1751,6 +1860,30 @@ files = [ [package.dependencies] certifi = "*" +[[package]] +name = "pyro-camera-api-client" +version = "0.1.0" +description = "Python client to interact with the Pyro Camera API" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [] +develop = false + +[package.dependencies] +pillow = "*" +requests = "*" + +[package.extras] +dev = ["httpx", "mypy", "pytest", "ruff"] + +[package.source] +type = "git" +url = "https://github.com/pyronear/pyro-engine.git" +reference = "develop" +resolved_reference = "e2a6de993902cd589de031792fbfe27a898e73c1" +subdirectory = "pyro_camera_api/client" + [[package]] name = "pytest" version = "8.3.5" @@ -2061,10 +2194,10 @@ files = [ ] [package.dependencies] -botocore = ">=1.37.4,<2.0a.0" +botocore = ">=1.37.4,<2.0a0" [package.extras] -crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"] +crt = ["botocore[crt] (>=1.37.4,<2.0a0)"] [[package]] name = "sentry-sdk" @@ -2510,4 +2643,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "aa48b3d2633da3dff397471f72c63e0bbce90edde9c7a9235101518e549770b6" +content-hash = "5c3801f7e9bc66dc943e81e09dff2ed30b1959b85c5ed719a7589e8673adb8e8" diff --git a/pyproject.toml b/pyproject.toml index 8dbc7e61..c77b780b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ python-multipart = "==0.0.7" python-magic = "^0.4.17" boto3 = "^1.26.0" httpx = "^0.24.0" +pyro-camera-api-client = {git = "https://github.com/pyronear/pyro-engine.git", subdirectory = "pyro_camera_api/client", branch = "develop"} geopy = "^2.4.0" networkx = "^3.2.0" numpy = "^1.26.0" @@ -62,6 +63,10 @@ aiosqlite = ">=0.16.0,<1.0.0" [tool.coverage.run] source = ["src/app", "client/pyroclient"] +[tool.bandit] +exclude_dirs = ["src/tests", "client/tests"] +skips = ["B101"] + [tool.ruff] line-length = 120 target-version = "py311" @@ -134,6 +139,7 @@ known-third-party = ["fastapi"] "scripts/**.py" = ["D", "T201", "S101", "ANN", "RUF030"] ".github/**.py" = ["D", "T201", "ANN"] "src/tests/**.py" = ["D103", "CPY001", "S101", "T201", "ANN001", "ANN201", "ANN202", "ARG001", "RUF030"] +"src/app/api/api_v1/endpoints/camera_proxy.py" = ["ANN401"] "src/migrations/versions/**.py" = ["CPY001"] "src/migrations/**.py" = ["ANN"] "src/app/main.py" = ["ANN"] @@ -184,5 +190,6 @@ module = [ "posthog", "prometheus_fastapi_instrumentator", "pydantic_settings", + "pyro_camera_api_client", ] ignore_missing_imports = true diff --git a/src/Dockerfile b/src/Dockerfile index d1c31790..8627a527 100644 --- a/src/Dockerfile +++ b/src/Dockerfile @@ -9,7 +9,7 @@ ENV PYTHONPATH="/app" # Install curl RUN apt-get -y update \ - && apt-get -y install curl libmagic1 \ + && apt-get -y install curl git libmagic1 \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* diff --git a/src/app/api/api_v1/endpoints/camera_proxy.py b/src/app/api/api_v1/endpoints/camera_proxy.py new file mode 100644 index 00000000..36a9fa04 --- /dev/null +++ b/src/app/api/api_v1/endpoints/camera_proxy.py @@ -0,0 +1,286 @@ +# Copyright (C) 2025-2026, Pyronear. + +# This program is licensed under the Apache License 2.0. +# See LICENSE or go to for full license details. + + +import asyncio +import io +from collections.abc import Callable +from functools import partial +from typing import Any, cast + +import requests +from fastapi import APIRouter, Depends, HTTPException, Path, Query, Response, Security, status +from pyro_camera_api_client import PyroCameraAPIClient + +from app.api.dependencies import get_camera_crud, get_jwt +from app.crud import CameraCRUD +from app.models import Camera, UserRole +from app.schemas.login import TokenPayload + +router = APIRouter() + +DEVICE_PORT = 8081 +TIMEOUT = 10.0 + + +def _make_client(device_ip: str) -> PyroCameraAPIClient: + return PyroCameraAPIClient(base_url=f"http://{device_ip}:{DEVICE_PORT}", timeout=TIMEOUT) + + +async def _run_sync(fn: Callable[..., Any], *args: Any, **kwargs: Any) -> Any: + loop = asyncio.get_running_loop() + try: + return await loop.run_in_executor(None, partial(fn, *args, **kwargs)) + except requests.exceptions.Timeout: + raise HTTPException( + status_code=status.HTTP_504_GATEWAY_TIMEOUT, + detail="Camera device is not responding.", + ) + except requests.exceptions.HTTPError as exc: + raise HTTPException( + status_code=exc.response.status_code, + detail=exc.response.text, + ) + except requests.exceptions.ConnectionError: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Failed to reach camera device.", + ) + + +# ── Shared helpers ──────────────────────────────────────────────────────────── + + +async def _require_read( + camera_id: int = Path(..., gt=0), + cameras: CameraCRUD = Depends(get_camera_crud), + token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN, UserRole.AGENT, UserRole.USER]), +) -> Camera: + camera = cast(Camera, await cameras.get(camera_id, strict=True)) + if token_payload.organization_id != camera.organization_id and UserRole.ADMIN not in token_payload.scopes: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access forbidden.") + return camera + + +async def _require_write( + camera_id: int = Path(..., gt=0), + cameras: CameraCRUD = Depends(get_camera_crud), + token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN, UserRole.AGENT]), +) -> Camera: + camera = cast(Camera, await cameras.get(camera_id, strict=True)) + if token_payload.organization_id != camera.organization_id and UserRole.ADMIN not in token_payload.scopes: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access forbidden.") + return camera + + +def _device_config(camera: Camera) -> tuple[str, str]: + """Return (device_ip, camera_ip) or raise 409 if the camera is not configured.""" + if not camera.device_ip or not camera.camera_ip: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Camera device connection is not configured (missing device_ip or camera_ip).", + ) + return camera.device_ip, camera.camera_ip + + +# ── Health ──────────────────────────────────────────────────────────────────── + + +@router.get("/{camera_id}/health", status_code=status.HTTP_200_OK, summary="Camera device health check") +async def proxy_health(camera: Camera = Depends(_require_read)) -> Any: + device_ip, _ = _device_config(camera) + return await _run_sync(_make_client(device_ip).health) + + +# ── Device cameras ──────────────────────────────────────────────────────────── + + +@router.get("/{camera_id}/cameras_list", status_code=status.HTTP_200_OK, summary="List all cameras on the device") +async def proxy_cameras_list(camera: Camera = Depends(_require_read)) -> Any: + device_ip, _ = _device_config(camera) + return await _run_sync(_make_client(device_ip).list_cameras) + + +@router.get("/{camera_id}/camera_infos", status_code=status.HTTP_200_OK, summary="Get all camera infos from the device") +async def proxy_camera_infos(camera: Camera = Depends(_require_read)) -> Any: + device_ip, _ = _device_config(camera) + return await _run_sync(_make_client(device_ip).get_camera_infos) + + +@router.get("/{camera_id}/capture", status_code=status.HTTP_200_OK, summary="Capture a JPEG snapshot from the camera") +async def proxy_capture( + pos_id: int | None = Query(default=None, description="Move to this preset pose before capturing"), + anonymize: bool = Query(default=True, description="Overlay anonymization masks on the image"), + max_age_ms: int | None = Query(default=None, description="Only use detection boxes newer than this many ms"), + strict: bool = Query(default=False, description="Return 503 if no recent boxes are available for anonymization"), + width: int | None = Query(default=None, description="Resize output to this width (px), preserving aspect ratio"), + quality: int = Query(default=95, ge=1, le=100, description="JPEG quality (1-100)"), + camera: Camera = Depends(_require_read), +) -> Response: + device_ip, camera_ip = _device_config(camera) + data = await _run_sync( + _make_client(device_ip).capture_jpeg, + camera_ip, + pos_id=pos_id, + anonymize=anonymize, + max_age_ms=max_age_ms, + strict=strict, + width=width, + quality=quality, + ) + return Response(content=data, media_type="image/jpeg") + + +@router.get("/{camera_id}/latest_image", status_code=status.HTTP_200_OK, summary="Get the last stored image for a pose") +async def proxy_latest_image( + pose: int = Query(..., description="Pose index whose cached image to retrieve"), + quality: int = Query(default=95, ge=1, le=100, description="JPEG quality (1-100)"), + camera: Camera = Depends(_require_read), +) -> Response: + device_ip, camera_ip = _device_config(camera) + image = await _run_sync(_make_client(device_ip).get_latest_image, camera_ip, pose, quality) + if image is None: + return Response(status_code=status.HTTP_204_NO_CONTENT) + buf = io.BytesIO() + image.save(buf, format="JPEG") + return Response(content=buf.getvalue(), media_type="image/jpeg") + + +# ── Control ─────────────────────────────────────────────────────────────────── + + +@router.post("/{camera_id}/control/move", status_code=status.HTTP_200_OK, summary="Move the camera") +async def proxy_move( + direction: str | None = Query(default=None, description="Direction: Left, Right, Up, Down"), + speed: int = Query(default=10, description="Movement speed"), + pose_id: int | None = Query(default=None, description="Move to this preset pose index"), + degrees: float | None = Query(default=None, description="Rotate by this many degrees (requires direction)"), + camera: Camera = Depends(_require_write), +) -> Any: + device_ip, camera_ip = _device_config(camera) + return await _run_sync( + _make_client(device_ip).move_camera, + camera_ip, + direction=direction, + speed=speed, + pose_id=pose_id, + degrees=degrees, + ) + + +@router.post("/{camera_id}/control/stop", status_code=status.HTTP_200_OK, summary="Stop camera movement") +async def proxy_stop(camera: Camera = Depends(_require_write)) -> Any: + device_ip, camera_ip = _device_config(camera) + return await _run_sync(_make_client(device_ip).stop_camera, camera_ip) + + +@router.get("/{camera_id}/control/presets", status_code=status.HTTP_200_OK, summary="List available presets") +async def proxy_list_presets(camera: Camera = Depends(_require_read)) -> Any: + device_ip, camera_ip = _device_config(camera) + return await _run_sync(_make_client(device_ip).list_presets, camera_ip) + + +@router.post("/{camera_id}/control/preset", status_code=status.HTTP_200_OK, summary="Set a preset position") +async def proxy_set_preset( + idx: int | None = Query( + default=None, description="Preset slot index to write (adapter picks free slot if omitted)" + ), + camera: Camera = Depends(_require_write), +) -> Any: + device_ip, camera_ip = _device_config(camera) + return await _run_sync(_make_client(device_ip).set_preset, camera_ip, idx=idx) + + +@router.post("/{camera_id}/control/zoom/{level}", status_code=status.HTTP_200_OK, summary="Zoom the camera") +async def proxy_zoom( + level: int = Path(..., ge=0, le=64, description="Zoom level (0-64)"), + camera: Camera = Depends(_require_write), +) -> Any: + device_ip, camera_ip = _device_config(camera) + return await _run_sync(_make_client(device_ip).zoom, camera_ip, level) + + +# ── Focus ───────────────────────────────────────────────────────────────────── + + +@router.post("/{camera_id}/focus/manual", status_code=status.HTTP_200_OK, summary="Set manual focus position") +async def proxy_manual_focus( + position: int = Query(..., description="Focus motor position (0-1000)"), + camera: Camera = Depends(_require_write), +) -> Any: + device_ip, camera_ip = _device_config(camera) + return await _run_sync(_make_client(device_ip).set_manual_focus, camera_ip, position) + + +@router.post("/{camera_id}/focus/autofocus", status_code=status.HTTP_200_OK, summary="Toggle autofocus") +async def proxy_set_autofocus( + disable: bool = Query(default=True, description="True to disable autofocus (enable manual), False to re-enable it"), + camera: Camera = Depends(_require_write), +) -> Any: + device_ip, camera_ip = _device_config(camera) + return await _run_sync(_make_client(device_ip).set_autofocus, camera_ip, disable) + + +@router.get("/{camera_id}/focus/status", status_code=status.HTTP_200_OK, summary="Get focus status") +async def proxy_focus_status(camera: Camera = Depends(_require_read)) -> Any: + device_ip, camera_ip = _device_config(camera) + return await _run_sync(_make_client(device_ip).get_focus_status, camera_ip) + + +@router.post("/{camera_id}/focus/optimize", status_code=status.HTTP_200_OK, summary="Run focus optimization") +async def proxy_focus_finder( + save_images: bool = Query(default=False, description="Save intermediate frames captured during focus search"), + camera: Camera = Depends(_require_write), +) -> Any: + device_ip, camera_ip = _device_config(camera) + return await _run_sync(_make_client(device_ip).run_focus_optimization, camera_ip, save_images=save_images) + + +# ── Patrol ──────────────────────────────────────────────────────────────────── + + +@router.post("/{camera_id}/patrol/start", status_code=status.HTTP_200_OK, summary="Start patrol") +async def proxy_start_patrol(camera: Camera = Depends(_require_write)) -> Any: + device_ip, camera_ip = _device_config(camera) + return await _run_sync(_make_client(device_ip).start_patrol, camera_ip) + + +@router.post("/{camera_id}/patrol/stop", status_code=status.HTTP_200_OK, summary="Stop patrol") +async def proxy_stop_patrol(camera: Camera = Depends(_require_write)) -> Any: + device_ip, camera_ip = _device_config(camera) + return await _run_sync(_make_client(device_ip).stop_patrol, camera_ip) + + +@router.get("/{camera_id}/patrol/status", status_code=status.HTTP_200_OK, summary="Get patrol status") +async def proxy_patrol_status(camera: Camera = Depends(_require_read)) -> Any: + device_ip, camera_ip = _device_config(camera) + return await _run_sync(_make_client(device_ip).get_patrol_status, camera_ip) + + +# ── Stream ──────────────────────────────────────────────────────────────────── + + +@router.post("/{camera_id}/stream/start", status_code=status.HTTP_200_OK, summary="Start video stream") +async def proxy_start_stream(camera: Camera = Depends(_require_write)) -> Any: + device_ip, camera_ip = _device_config(camera) + return await _run_sync(_make_client(device_ip).start_stream, camera_ip) + + +@router.post("/{camera_id}/stream/stop", status_code=status.HTTP_200_OK, summary="Stop video stream") +async def proxy_stop_stream(camera: Camera = Depends(_require_write)) -> Any: + device_ip, _ = _device_config(camera) + return await _run_sync(_make_client(device_ip).stop_stream) + + +@router.get("/{camera_id}/stream/status", status_code=status.HTTP_200_OK, summary="Get stream status") +async def proxy_stream_status(camera: Camera = Depends(_require_read)) -> Any: + device_ip, _ = _device_config(camera) + return await _run_sync(_make_client(device_ip).get_stream_status) + + +@router.get("/{camera_id}/stream/is_running", status_code=status.HTTP_200_OK, summary="Check if stream is running") +async def proxy_is_stream_running(camera: Camera = Depends(_require_read)) -> Any: + device_ip, camera_ip = _device_config(camera) + return await _run_sync(_make_client(device_ip).is_stream_running, camera_ip) diff --git a/src/app/api/api_v1/endpoints/cameras.py b/src/app/api/api_v1/endpoints/cameras.py index d63c0197..3d402664 100644 --- a/src/app/api/api_v1/endpoints/cameras.py +++ b/src/app/api/api_v1/endpoints/cameras.py @@ -17,8 +17,10 @@ from app.models import Camera, Role, UserRole from app.schemas.cameras import ( CameraCreate, + CameraDeviceConfig, CameraEdit, CameraName, + CameraOut, CameraRead, LastActive, LastImage, @@ -36,11 +38,12 @@ async def register_camera( payload: CameraCreate, cameras: CameraCRUD = Depends(get_camera_crud), token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN, UserRole.AGENT]), -) -> Camera: +) -> CameraOut: telemetry_client.capture(token_payload.sub, event="cameras-create", properties={"device_login": payload.name}) if token_payload.organization_id != payload.organization_id and UserRole.ADMIN not in token_payload.scopes: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access forbidden.") - return await cameras.create(payload) + camera = await cameras.create(payload) + return CameraOut(**camera.model_dump()) @router.get("/{camera_id}", status_code=status.HTTP_200_OK, summary="Fetch the information of a specific camera") @@ -115,9 +118,10 @@ async def get_poses(cam: Camera) -> list[PoseReadWithoutImgInfo]: async def heartbeat( cameras: CameraCRUD = Depends(get_camera_crud), token_payload: TokenPayload = Security(get_jwt, scopes=[Role.CAMERA]), -) -> Camera: +) -> CameraOut: # telemetry_client.capture(f"camera|{token_payload.sub}", event="cameras-heartbeat") - return await cameras.update(token_payload.sub, LastActive(last_active_at=datetime.utcnow())) + camera = await cameras.update(token_payload.sub, LastActive(last_active_at=datetime.utcnow())) + return CameraOut(**camera.model_dump()) @router.patch("/image", status_code=status.HTTP_200_OK, summary="Update last image of a camera") @@ -125,7 +129,7 @@ async def update_image( file: UploadFile = File(..., alias="file"), cameras: CameraCRUD = Depends(get_camera_crud), token_payload: TokenPayload = Security(get_jwt, scopes=[Role.CAMERA]), -) -> Camera: +) -> CameraOut: # telemetry_client.capture(f"camera|{token_payload.sub}", event="cameras-image") cam = cast(Camera, await cameras.get(token_payload.sub, strict=True)) bucket_key = await upload_file(file, token_payload.organization_id, token_payload.sub) @@ -133,7 +137,8 @@ async def update_image( if isinstance(cam.last_image, str): s3_service.get_bucket(s3_service.resolve_bucket_name(token_payload.organization_id)).delete_file(cam.last_image) # Update the DB entry - return await cameras.update(token_payload.sub, LastImage(last_image=bucket_key, last_active_at=datetime.utcnow())) + camera = await cameras.update(token_payload.sub, LastImage(last_image=bucket_key, last_active_at=datetime.utcnow())) + return CameraOut(**camera.model_dump()) @router.post("/{camera_id}/token", status_code=status.HTTP_200_OK, summary="Request an access token for the camera") @@ -156,9 +161,10 @@ async def update_camera_location( camera_id: int = Path(..., gt=0), cameras: CameraCRUD = Depends(get_camera_crud), token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN]), -) -> Camera: +) -> CameraOut: telemetry_client.capture(token_payload.sub, event="cameras-update-location", properties={"camera_id": camera_id}) - return await cameras.update(camera_id, payload) + camera = await cameras.update(camera_id, payload) + return CameraOut(**camera.model_dump()) @router.patch("/{camera_id}/name", status_code=status.HTTP_200_OK, summary="Update the name of a camera") @@ -167,9 +173,10 @@ async def update_camera_name( camera_id: int = Path(..., gt=0), cameras: CameraCRUD = Depends(get_camera_crud), token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN]), -) -> Camera: +) -> CameraOut: telemetry_client.capture(token_payload.sub, event="cameras-update-name", properties={"camera_id": camera_id}) - return await cameras.update(camera_id, payload) + camera = await cameras.update(camera_id, payload) + return CameraOut(**camera.model_dump()) @router.delete("/{camera_id}", status_code=status.HTTP_200_OK, summary="Delete a camera") @@ -180,3 +187,19 @@ async def delete_camera( ) -> None: telemetry_client.capture(token_payload.sub, event="cameras-deletion", properties={"camera_id": camera_id}) await cameras.delete(camera_id) + + +@router.patch( + "/{camera_id}/device_config", status_code=status.HTTP_200_OK, summary="Update camera device connection config" +) +async def update_camera_device_config( + payload: CameraDeviceConfig, + camera_id: int = Path(..., gt=0), + cameras: CameraCRUD = Depends(get_camera_crud), + token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN]), +) -> CameraOut: + telemetry_client.capture( + token_payload.sub, event="cameras-update-device-config", properties={"camera_id": camera_id} + ) + camera = await cameras.update(camera_id, payload) + return CameraOut(**camera.model_dump()) diff --git a/src/app/api/api_v1/router.py b/src/app/api/api_v1/router.py index 1bdbe27f..e9cb614b 100644 --- a/src/app/api/api_v1/router.py +++ b/src/app/api/api_v1/router.py @@ -7,6 +7,7 @@ from app.api.api_v1.endpoints import ( alerts, + camera_proxy, cameras, detections, login, @@ -22,6 +23,7 @@ api_router.include_router(login.router, prefix="/login", tags=["login"]) api_router.include_router(users.router, prefix="/users", tags=["users"]) api_router.include_router(cameras.router, prefix="/cameras", tags=["cameras"]) +api_router.include_router(camera_proxy.router, prefix="/cameras", tags=["camera-proxy"]) api_router.include_router(poses.router, prefix="/poses", tags=["poses"]) api_router.include_router(occlusion_masks.router, prefix="/occlusion_masks", tags=["occlusion_masks"]) api_router.include_router(detections.router, prefix="/detections", tags=["detections"]) diff --git a/src/app/crud/crud_camera.py b/src/app/crud/crud_camera.py index eb5401b1..0e16414d 100644 --- a/src/app/crud/crud_camera.py +++ b/src/app/crud/crud_camera.py @@ -7,11 +7,17 @@ from app.crud.base import BaseCRUD from app.models import Camera -from app.schemas.cameras import CameraCreate, CameraEdit, CameraName, LastActive +from app.schemas.cameras import ( + CameraCreate, + CameraDeviceConfig, + CameraEdit, + CameraName, + LastActive, +) __all__ = ["CameraCRUD"] -class CameraCRUD(BaseCRUD[Camera, CameraCreate, LastActive | CameraEdit | CameraName]): +class CameraCRUD(BaseCRUD[Camera, CameraCreate, LastActive | CameraEdit | CameraName | CameraDeviceConfig]): def __init__(self, session: AsyncSession) -> None: super().__init__(session, Camera) diff --git a/src/app/models.py b/src/app/models.py index 88ea6d61..c3b30b3c 100644 --- a/src/app/models.py +++ b/src/app/models.py @@ -57,6 +57,9 @@ class Camera(SQLModel, table=True): last_active_at: Union[datetime, None] = None last_image: Union[str, None] = None created_at: datetime = Field(default_factory=datetime.utcnow, nullable=False) + # Device connection — never exposed in public API responses + camera_ip: Union[str, None] = Field(default=None, nullable=True) + device_ip: Union[str, None] = Field(default=None, nullable=True) class Pose(SQLModel, table=True): diff --git a/src/app/schemas/cameras.py b/src/app/schemas/cameras.py index 95cf523f..d0f4e82e 100644 --- a/src/app/schemas/cameras.py +++ b/src/app/schemas/cameras.py @@ -58,10 +58,30 @@ class CameraName(BaseModel): name: str = Field(..., min_length=5, max_length=100, description="name of the camera") -class CameraRead(CameraCreate): +class CameraDeviceConfig(BaseModel): + """Internal-only: set the device connection details for a camera. Never returned in public responses.""" + + camera_ip: str | None = Field(default=None, description="IP of the camera within the device's local network") + device_ip: str | None = Field( + default=None, description="IP of the device box (VPN-reachable) running the camera API" + ) + + +class CameraOut(CameraCreate): + """ + Returned by mutation endpoints + """ + id: int last_active_at: datetime | None last_image: str | None + created_at: datetime + + +class CameraRead(CameraOut): + """ + Returned by read endpoints + """ + last_image_url: str | None = Field(None, description="URL of the last image of the camera") poses: list[PoseReadWithoutImgInfo] = Field(default_factory=list) - created_at: datetime diff --git a/src/tests/endpoints/test_camera_proxy.py b/src/tests/endpoints/test_camera_proxy.py new file mode 100644 index 00000000..52921b01 --- /dev/null +++ b/src/tests/endpoints/test_camera_proxy.py @@ -0,0 +1,291 @@ +from unittest.mock import AsyncMock, patch + +import pytest +import pytest_asyncio +from fastapi import HTTPException +from httpx import AsyncClient +from PIL import Image +from sqlmodel.ext.asyncio.session import AsyncSession + +from app.models import Camera + +# A camera that has device_ip and camera_ip configured (org1, so admin & agent can access it). +CONFIGURED_CAM_ID = 10 +CONFIGURED_CAM = { + "id": CONFIGURED_CAM_ID, + "organization_id": 1, + "name": "cam-configured", + "angle_of_view": 90.0, + "elevation": 100.0, + "lat": 44.0, + "lon": 4.0, + "device_ip": "192.168.1.100", + "camera_ip": "192.168.1.101", +} + +# Fake JPEG bytes (minimal valid header is enough for content-type tests) +FAKE_JPEG = b"\xff\xd8\xff" + b"\x00" * 64 + +_PROXY_MODULE = "app.api.api_v1.endpoints.camera_proxy" + + +@pytest_asyncio.fixture() +async def configured_camera_session(camera_session: AsyncSession): + """camera_session extended with one camera that has device_ip / camera_ip set.""" + camera_session.add(Camera(**CONFIGURED_CAM)) + await camera_session.commit() + yield camera_session + await camera_session.rollback() + + +def _auth(user_idx: int) -> dict: + return pytest.get_token( + pytest.user_table[user_idx]["id"], + pytest.user_table[user_idx]["role"].split(), + pytest.user_table[user_idx]["organization_id"], + ) + + +# ── Auth / Organisation isolation ───────────────────────────────────────────── +# +# Users in conftest: +# [0] admin - org 1 +# [1] agent - org 1 +# [2] user - org 2 +# +# Cameras in conftest (no device_ip / camera_ip): +# id=1 org 1 (cam-1) +# id=2 org 2 (cam-2) +# +# Expected flow for a correctly-scoped caller on a cam without device config: +# auth passes → org check passes → _device_config raises 409 +# This lets us use the unconfigured cameras to cover all auth cases for free. + + +@pytest.mark.parametrize( + ("user_idx", "cam_id", "status_code", "status_detail"), + [ + # No token + (None, 1, 401, "Not authenticated"), + # Camera does not exist + (0, 999, 404, "Table Camera has no corresponding entry."), + # Cross-org: agent org1 → cam-2 org2 + (1, 2, 403, "Access forbidden."), + # Cross-org: user org2 → cam-1 org1 + (2, 1, 403, "Access forbidden."), + # Correct scope + own org → no device config → 409 + (0, 1, 409, "Camera device connection is not configured"), + (1, 1, 409, "Camera device connection is not configured"), + (2, 2, 409, "Camera device connection is not configured"), + ], +) +@pytest.mark.asyncio +async def test_proxy_read_auth( + async_client: AsyncClient, + camera_session: AsyncSession, + user_idx, + cam_id, + status_code, + status_detail, +): + auth = _auth(user_idx) if user_idx is not None else None + response = await async_client.get(f"/cameras/{cam_id}/health", headers=auth) + assert response.status_code == status_code + if status_detail: + assert status_detail in response.json()["detail"] + + +@pytest.mark.parametrize( + ("user_idx", "cam_id", "status_code", "status_detail"), + [ + # No token + (None, 1, 401, "Not authenticated"), + # USER role is not allowed on write endpoints + (2, 2, 403, "Incompatible token scope."), + # Cross-org: agent org1 → cam-2 org2 + (1, 2, 403, "Access forbidden."), + # Correct scope + own org → no device config → 409 + (0, 1, 409, "Camera device connection is not configured"), + (1, 1, 409, "Camera device connection is not configured"), + ], +) +@pytest.mark.asyncio +async def test_proxy_write_auth( + async_client: AsyncClient, + camera_session: AsyncSession, + user_idx, + cam_id, + status_code, + status_detail, +): + auth = _auth(user_idx) if user_idx is not None else None + response = await async_client.post(f"/cameras/{cam_id}/control/move", headers=auth) + assert response.status_code == status_code + if status_detail: + assert status_detail in response.json()["detail"] + + +# ── Device not configured → 409 on every route ─────────────────────────────── + + +@pytest.mark.parametrize( + "path", + [ + "/cameras/1/health", + "/cameras/1/cameras_list", + "/cameras/1/camera_infos", + "/cameras/1/capture", + "/cameras/1/latest_image?pose=0", + "/cameras/1/control/presets", + "/cameras/1/focus/status", + "/cameras/1/patrol/status", + "/cameras/1/stream/status", + "/cameras/1/stream/is_running", + ], +) +@pytest.mark.asyncio +async def test_proxy_unconfigured_get(async_client: AsyncClient, camera_session: AsyncSession, path: str): + response = await async_client.get(path, headers=_auth(0)) + assert response.status_code == 409 + assert "not configured" in response.json()["detail"] + + +@pytest.mark.parametrize( + "path", + [ + "/cameras/1/control/move", + "/cameras/1/control/stop", + "/cameras/1/control/preset", + "/cameras/1/control/zoom/5", + "/cameras/1/patrol/start", + "/cameras/1/patrol/stop", + "/cameras/1/stream/start", + "/cameras/1/stream/stop", + "/cameras/1/focus/manual?position=500", + "/cameras/1/focus/autofocus", + "/cameras/1/focus/optimize", + ], +) +@pytest.mark.asyncio +async def test_proxy_unconfigured_post(async_client: AsyncClient, camera_session: AsyncSession, path: str): + response = await async_client.post(path, headers=_auth(0)) + assert response.status_code == 409 + assert "not configured" in response.json()["detail"] + + +# ── Device error forwarding ─────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_proxy_device_timeout(async_client: AsyncClient, configured_camera_session: AsyncSession): + with patch( + f"{_PROXY_MODULE}._run_sync", + side_effect=HTTPException(status_code=504, detail="Camera device is not responding."), + ): + response = await async_client.get(f"/cameras/{CONFIGURED_CAM_ID}/health", headers=_auth(0)) + assert response.status_code == 504 + assert "not responding" in response.json()["detail"] + + +@pytest.mark.asyncio +async def test_proxy_device_unreachable(async_client: AsyncClient, configured_camera_session: AsyncSession): + with patch( + f"{_PROXY_MODULE}._run_sync", + side_effect=HTTPException(status_code=502, detail="Failed to reach camera device."), + ): + response = await async_client.get(f"/cameras/{CONFIGURED_CAM_ID}/health", headers=_auth(0)) + assert response.status_code == 502 + assert "reach camera device" in response.json()["detail"] + + +@pytest.mark.asyncio +async def test_proxy_device_error_forwarded(async_client: AsyncClient, configured_camera_session: AsyncSession): + """A 404 from the device (unknown camera_ip) is forwarded as-is.""" + with patch(f"{_PROXY_MODULE}._run_sync", side_effect=HTTPException(status_code=404, detail="Unknown camera")): + response = await async_client.get(f"/cameras/{CONFIGURED_CAM_ID}/health", headers=_auth(0)) + assert response.status_code == 404 + + +# ── Binary (JPEG) responses ─────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_proxy_capture_returns_jpeg(async_client: AsyncClient, configured_camera_session: AsyncSession): + with patch(f"{_PROXY_MODULE}._run_sync", new=AsyncMock(return_value=FAKE_JPEG)): + response = await async_client.get(f"/cameras/{CONFIGURED_CAM_ID}/capture", headers=_auth(0)) + assert response.status_code == 200 + assert response.headers["content-type"] == "image/jpeg" + assert response.content == FAKE_JPEG + + +@pytest.mark.asyncio +async def test_proxy_latest_image_returns_jpeg(async_client: AsyncClient, configured_camera_session: AsyncSession): + """The endpoint re-encodes the PIL Image returned by the client into JPEG bytes.""" + img = Image.new("RGB", (4, 4), color=(255, 0, 0)) + with patch(f"{_PROXY_MODULE}._run_sync", new=AsyncMock(return_value=img)): + response = await async_client.get(f"/cameras/{CONFIGURED_CAM_ID}/latest_image?pose=0", headers=_auth(0)) + assert response.status_code == 200 + assert response.headers["content-type"] == "image/jpeg" + assert response.content[:2] == b"\xff\xd8" # JPEG magic bytes + + +@pytest.mark.asyncio +async def test_proxy_latest_image_no_content(async_client: AsyncClient, configured_camera_session: AsyncSession): + """When the device has no cached image for the requested pose it returns 204.""" + with patch(f"{_PROXY_MODULE}._run_sync", new=AsyncMock(return_value=None)): + response = await async_client.get(f"/cameras/{CONFIGURED_CAM_ID}/latest_image?pose=0", headers=_auth(0)) + assert response.status_code == 204 + + +# ── device_ip / camera_ip must never appear in API responses ───────────────── + + +@pytest.mark.asyncio +async def test_device_ip_not_leaked_in_camera_response( + async_client: AsyncClient, configured_camera_session: AsyncSession, pose_session: AsyncSession +): + """GET /cameras/{id} must not expose device_ip or camera_ip even for a configured camera.""" + response = await async_client.get(f"/cameras/{CONFIGURED_CAM_ID}", headers=_auth(0)) + assert response.status_code == 200 + data = response.json() + assert "device_ip" not in data + assert "camera_ip" not in data + + +# ── Happy-path coverage for all remaining proxy endpoints ──────────────────── + + +@pytest.mark.parametrize( + ("path", "method"), + [ + (f"/cameras/{CONFIGURED_CAM_ID}/health", "get"), + (f"/cameras/{CONFIGURED_CAM_ID}/cameras_list", "get"), + (f"/cameras/{CONFIGURED_CAM_ID}/camera_infos", "get"), + (f"/cameras/{CONFIGURED_CAM_ID}/control/presets", "get"), + (f"/cameras/{CONFIGURED_CAM_ID}/focus/status", "get"), + (f"/cameras/{CONFIGURED_CAM_ID}/patrol/status", "get"), + (f"/cameras/{CONFIGURED_CAM_ID}/stream/status", "get"), + (f"/cameras/{CONFIGURED_CAM_ID}/stream/is_running", "get"), + (f"/cameras/{CONFIGURED_CAM_ID}/control/move", "post"), + (f"/cameras/{CONFIGURED_CAM_ID}/control/stop", "post"), + (f"/cameras/{CONFIGURED_CAM_ID}/control/preset", "post"), + (f"/cameras/{CONFIGURED_CAM_ID}/control/zoom/5", "post"), + (f"/cameras/{CONFIGURED_CAM_ID}/focus/manual?position=500", "post"), + (f"/cameras/{CONFIGURED_CAM_ID}/focus/autofocus", "post"), + (f"/cameras/{CONFIGURED_CAM_ID}/focus/optimize", "post"), + (f"/cameras/{CONFIGURED_CAM_ID}/patrol/start", "post"), + (f"/cameras/{CONFIGURED_CAM_ID}/patrol/stop", "post"), + (f"/cameras/{CONFIGURED_CAM_ID}/stream/start", "post"), + (f"/cameras/{CONFIGURED_CAM_ID}/stream/stop", "post"), + ], +) +@pytest.mark.asyncio +async def test_proxy_happy_path( + async_client: AsyncClient, + configured_camera_session: AsyncSession, + path: str, + method: str, +): + with patch(f"{_PROXY_MODULE}._run_sync", new=AsyncMock(return_value={"ok": True})): + response = await getattr(async_client, method)(path, headers=_auth(0)) + assert response.status_code == 200