├── .editorconfig ├── .github ├── CODEOWNERS ├── PULL_REQUEST_TEMPLATE │ └── pull_request_template.md ├── dependabot.yml └── workflows │ ├── check-commit-message.yml │ ├── pull-request.yml │ └── release.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── NOTICE ├── README.md ├── docs ├── CONTRIBUTING.md ├── adr │ └── 001_encoder.md ├── camera-controls.md ├── contributors.md └── developer-certificate-of-origin.md ├── pyproject.toml ├── requirements-dev.txt ├── requirements-test.txt ├── requirements.txt ├── resources ├── controls_style.css ├── spyglass.conf └── spyglass.service ├── run.py ├── scripts └── spyglass ├── setup.cfg ├── setup.py ├── spyglass ├── __init__.py ├── __main__.py ├── __version__.py ├── camera │ ├── __init__.py │ ├── camera.py │ ├── csi.py │ └── usb.py ├── camera_options.py ├── cli.py ├── exif.py ├── server │ ├── __init__.py │ ├── controls.py │ ├── http_server.py │ ├── jpeg.py │ └── webrtc_whep.py └── url_parsing.py └── tests ├── test_cli.py ├── test_exif.py └── test_url_parsing.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | root = true 3 | 4 | [*] 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [{Makefile,**.mk}] 11 | indent_style = tab 12 | 13 | [*.py] 14 | indent_size = 4 15 | indent_style = space 16 | 17 | [{**.yaml,**.yml}] 18 | indent_style = space 19 | indent_size = 2 20 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @roamingthings @mryel00 @KwadFan 2 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE/pull_request_template.md: -------------------------------------------------------------------------------- 1 | Describe what the submission changes. 2 | 3 | What is its intention? 4 | 5 | What benefit does it bring to the project? 6 | 7 | Signed-off-by: My Name 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | target-branch: "develop" 8 | -------------------------------------------------------------------------------- /.github/workflows/check-commit-message.yml: -------------------------------------------------------------------------------- 1 | name: Enforce conventional commits 2 | 3 | on: 4 | pull_request_review: 5 | types: [submitted] 6 | 7 | jobs: 8 | validate: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout code 12 | uses: actions/checkout@v3 13 | with: 14 | fetch-depth: 0 15 | ref: ${{ github.head_ref }} 16 | 17 | - name: Set up Python 18 | uses: actions/setup-python@v4 19 | with: 20 | python-version: '3.10' 21 | cache: 'pip' 22 | 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install commitizen 27 | 28 | - name: Validate pull request title and commit messages 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | run: | 32 | echo "${{ github.event.pull_request.title }}" | cz check 33 | -------------------------------------------------------------------------------- /.github/workflows/pull-request.yml: -------------------------------------------------------------------------------- 1 | name: Pull Request 2 | 3 | on: 4 | pull_request: 5 | branches: [ main ] 6 | 7 | jobs: 8 | build: 9 | name: build and check 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v3 15 | 16 | - name: Set up Python 17 | uses: actions/setup-python@v4 18 | with: 19 | python-version: '3.10' 20 | cache: 'pip' 21 | 22 | - name: Install dependencies 23 | run: | 24 | pip install --upgrade pip 25 | pip install flake8 26 | pip install build 27 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 28 | if [ -f requirements-test.txt ]; then pip install -r requirements-test.txt; fi 29 | 30 | - name: Lint with flake8 31 | run: | 32 | # stop the build if there are Python syntax errors or undefined names 33 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 34 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 35 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 36 | 37 | - name: Test with pytest 38 | run: | 39 | pytest 40 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release version 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | permissions: 7 | contents: write 8 | 9 | jobs: 10 | push_to_main: 11 | name: FF Merge and Bump version 12 | runs-on: ubuntu-latest 13 | outputs: 14 | version: ${{ steps.bump.outputs.version }} 15 | steps: 16 | - name: Check out 17 | uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | ref: 'main' 21 | 22 | - name: Fast Forward Merge To Main 23 | uses: MaximeHeckel/github-action-merge-fast-forward@v1.1.0 24 | with: 25 | branchtomerge: origin/develop 26 | branch: main 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | 30 | - name: Create bump and changelog 31 | uses: commitizen-tools/commitizen-action@master 32 | id: bump 33 | with: 34 | github_token: ${{ secrets.GITHUB_TOKEN }} 35 | changelog_increment_filename: release_body.md 36 | 37 | - name: Fast Forward Merge To Develop 38 | uses: MaximeHeckel/github-action-merge-fast-forward@v1.1.0 39 | with: 40 | branchtomerge: main 41 | branch: develop 42 | env: 43 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 44 | 45 | - name: Upload Release Body 46 | uses: actions/upload-artifact@v4 47 | with: 48 | name: release-body.md 49 | path: release_body.md 50 | 51 | release: 52 | name: Create Release 53 | needs: push_to_main 54 | runs-on: ubuntu-latest 55 | steps: 56 | - name: Check out 57 | uses: actions/checkout@v4 58 | with: 59 | fetch-depth: 0 60 | ref: 'main' 61 | 62 | - name: Set up Python 63 | uses: actions/setup-python@v4 64 | with: 65 | python-version: '3.10' 66 | cache: 'pip' 67 | 68 | - name: Install dependencies 69 | run: | 70 | python -m pip install --upgrade pip 71 | pip install build 72 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 73 | 74 | - name: Build package 75 | run: | 76 | python -m build 77 | 78 | - name: Download Release Body 79 | uses: actions/download-artifact@v4 80 | with: 81 | name: release-body.md 82 | path: . 83 | 84 | - name: Release 85 | uses: softprops/action-gh-release@v1 86 | with: 87 | body_path: release_body.md 88 | tag_name: ${{ needs.push_to_main.outputs.version }} 89 | files: dist/* 90 | env: 91 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 92 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # JetBrains 55 | .idea/ 56 | 57 | venv 58 | .venv 59 | .vscode 60 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - hooks: 3 | - id: commitizen 4 | - id: commitizen-branch 5 | stages: 6 | - push 7 | repo: https://github.com/commitizen-tools/commitizen 8 | rev: v2.42.0 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v0.17.0 (2025-05-04) 2 | 3 | ### Feat 4 | 5 | - add software encoding for mjpg and jpeg (#105) 6 | 7 | ## v0.16.3 (2025-03-09) 8 | 9 | ### Fix 10 | 11 | - fix --disable_webrtc to update the module variable correctly (#102) 12 | 13 | ## v0.16.2 (2025-02-26) 14 | 15 | ### Fix 16 | 17 | - fix crash for picamera2 v0.3.23+ (#100) 18 | 19 | ## v0.16.1 (2025-02-24) 20 | 21 | ### Fix 22 | 23 | - fix controls_style.css path to relative path in camera_options.py (#98) 24 | 25 | ## v0.16.0 (2025-02-24) 26 | 27 | ### Feat 28 | 29 | - add WebRTC stream (#84) 30 | 31 | ## v0.15.0 (2024-08-14) 32 | 33 | ### Feat 34 | 35 | - Add basic USB-Camera support (#10) 36 | 37 | ## v0.14.0 (2024-06-05) 38 | 39 | ### Feat 40 | 41 | - Expand camera control ability (#14) 42 | 43 | ## v0.13.1 (2024-06-05) 44 | 45 | ### Fix 46 | 47 | - **install**: fix missing directory during install (#73) 48 | 49 | ## v0.13.0 (2023-07-22) 50 | 51 | ### Feat 52 | 53 | - add picamera2 tuning filters option (#55) 54 | 55 | ## v0.12.0 (2023-07-06) 56 | 57 | ### Feat 58 | 59 | - Add compatibility with moonraker update manager (#54) 60 | 61 | ## v0.11.2 (2023-07-02) 62 | 63 | ### Fix 64 | 65 | - fix url parsing (#53) 66 | 67 | ## v0.11.1 (2023-06-10) 68 | 69 | ### Fix 70 | 71 | - URL routing to accept requests that contain unused parameters (#42) 72 | 73 | ## v0.11.0 (2023-03-19) 74 | 75 | ### Feat 76 | 77 | - add exif based image rotation (#37) 78 | 79 | ## v0.10.3 (2023-03-05) 80 | 81 | ### Fix 82 | 83 | - fix issue with python2 (#40) 84 | 85 | ## v0.10.2 (2023-02-19) 86 | 87 | ### Fix 88 | 89 | - fix version project configuration 90 | 91 | ## v0.10.1 (2023-02-19) 92 | 93 | ### Fix 94 | 95 | - build packages after version bump 96 | 97 | ## v0.10.0 (2023-02-19) 98 | 99 | ### Feat 100 | 101 | - Restrict resolution to 1920x1920 (#31) 102 | - Add testing to project (#7) 103 | - add basic service and installer (#17) 104 | 105 | ## v0.0.0 (2023-02-02) 106 | 107 | ### Feat 108 | 109 | - Improve project structure and setup (#4) 110 | 111 | ### Fix 112 | 113 | - Fix vertical flip (#16) 114 | - Make run.py executable 115 | - Fix python version in GitHub actions 116 | 117 | ### Refactor 118 | 119 | - Rename GitHub Action jobs (#6) 120 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Spyglass Copyright (C) 2023 Alexander Sparkowsky 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ## Spyglass Installer 2 | ## 3 | ## Selfdocumenting Makefile 4 | ## Based on https://www.freecodecamp.org/news/self-documenting-makefile/ 5 | 6 | 7 | .PHONY: help install uninstall update 8 | 9 | 10 | #### Install Paths 11 | USER = $(shell whoami) 12 | SYSTEMD = /etc/systemd/system 13 | BIN_PATH = /usr/local/bin 14 | PRINTER_DATA_PATH = /home/$(USER)/printer_data 15 | CONF_PATH = $(PRINTER_DATA_PATH)/config 16 | 17 | all: 18 | $(MAKE) help 19 | 20 | install: ## Install Spyglass as service 21 | @if [ "$$(id -u)" -eq 0 ]; then \ 22 | echo "Please run without sudo/not as root"; \ 23 | exit 1; \ 24 | fi 25 | @mkdir -p $(CONF_PATH) 26 | @printf "\nInstall virtual environment ...\n" 27 | @python -m venv --system-site-packages .venv 28 | @. .venv/bin/activate && pip install -r requirements.txt 29 | @printf "\nCopying systemd service file ...\n" 30 | @sudo cp -f "${PWD}/resources/spyglass.service" $(SYSTEMD) 31 | @sudo sed -i "s/%USER%/$(USER)/g" $(SYSTEMD)/spyglass.service 32 | @printf "\nCopying Spyglass launch script ...\n" 33 | @sudo ln -sf "${PWD}/scripts/spyglass" $(BIN_PATH) 34 | @printf "\nCopying basic configuration file ...\n" 35 | @cp -f "${PWD}/resources/spyglass.conf" $(CONF_PATH) 36 | @printf "\nPopulate new service file ... \n" 37 | @sudo systemctl daemon-reload 38 | @sudo echo "spyglass" >> $(PRINTER_DATA_PATH)/moonraker.asvc 39 | @printf "\nEnable Spyglass service ... \n" 40 | @sudo systemctl enable spyglass 41 | @printf "\nTo be sure, everything is setup please reboot ...\n" 42 | @printf "Thanks for choosing Spyglass ...\n" 43 | 44 | uninstall: ## Uninstall Spyglass 45 | @printf "\nDisable Spyglass service ... \n" 46 | @sudo systemctl disable spyglass 47 | @printf "\nRemove systemd service file ...\n" 48 | @sudo rm -f $(SYSTEMD)/spyglass.service 49 | @printf "\nRemoving Spyglass launch script ...\n" 50 | @sudo rm -f $(BIN_PATH)/spyglass 51 | @sudo sed '/spyglass/d' $(PRINTER_DATA_PATH)/moonraker.asvc > $(PRINTER_DATA_PATH)/moonraker.asvc 52 | 53 | update: ## Update Spyglass (via git Repository) 54 | @git fetch && git pull 55 | 56 | upgrade-moonraker: ## In case of old version of Spyglass being upgraded to newer version with Moonraker update manager compatibility 57 | @printf "Upgrading systemctl ...\n" 58 | @sudo cp -f "${PWD}/resources/spyglass.service" $(SYSTEMD) 59 | @sudo sed -i "s/%USER%/$(USER)/g" $(SYSTEMD)/spyglass.service 60 | @printf "Saving backup of moonraker.asvc file as %s ...\n" $(PRINTER_DATA_PATH)/moonraker.asvc.bak 61 | @sudo cp -f $(PRINTER_DATA_PATH)/moonraker.asvc $(PRINTER_DATA_PATH)/moonraker.asvc.bak 62 | @printf "Upgrading Moonraker update manager authorization ...\n" 63 | @sudo sed -i '/spyglass/d' $(PRINTER_DATA_PATH)/moonraker.asvc 64 | @sudo echo "spyglass" >> $(PRINTER_DATA_PATH)/moonraker.asvc 65 | @printf "You can now include the configuration in moonraker.conf to manage Spyglass updates ...\n" 66 | @printf "Upgrade completed ...\n" 67 | @printf "Thanks for choosing Spyglass ...\n" 68 | 69 | help: ## Show this help 70 | @printf "\nSpyglass Install Helper:\n" 71 | @grep -E -h '\s##\s' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' 72 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Some portions of this application are based on example-code from 2 | picamera2: 3 | ---- 4 | This product uses 'picamera2' developed by Raspberry Pi 5 | (https://github.com/raspberrypi/picamera2). 6 | 7 | BSD 2-Clause License 8 | 9 | Copyright (c) 2021, Raspberry Pi 10 | All rights reserved. 11 | 12 | Redistribution and use in source and binary forms, with or without 13 | modification, are permitted provided that the following conditions are met: 14 | 15 | 1. Redistributions of source code must retain the above copyright notice, this 16 | list of conditions and the following disclaimer. 17 | 18 | 2. Redistributions in binary form must reproduce the above copyright notice, 19 | this list of conditions and the following disclaimer in the documentation 20 | and/or other materials provided with the distribution. 21 | 22 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 23 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 24 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 25 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 26 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 27 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 28 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 29 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 30 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 31 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spyglass 2 | 3 | > **Please note that this project is in a very early stage. Use at your own risk. Think about contributing to the 4 | project if you find that something is not working, and you are able to fix it. Every contribution is appreciated.** 5 | 6 | A simple mjpeg server for the python module [Picamera2](https://github.com/raspberrypi/picamera2). 7 | 8 | With Spyglass you are able to stream videos from a camera that is supported by [libcamera](http://libcamera.org) like 9 | the [Raspberry Pi Camera Modules](https://www.raspberrypi.com/documentation/accessories/camera.html). 10 | 11 | Current version: 0.17.0 12 | 13 | ## Overview 14 | 15 | - [Quickstart](#quick-start) 16 | - [Installation](#installation) 17 | - [CLI arguments](#cli-arguments) 18 | - [FAQ](#faq) 19 | - [How can I add CLI arguments to my `spyglass.conf`?](#how-can-i-add-cli-arguments-to-my-spyglassconf) 20 | - [How to use resolutions higher than maximum resolution?](#how-to-use-resolutions-higher-than-maximum-resolution) 21 | - [Why is the CPU load on my Pi5 so high?](#why-is-the-cpu-load-on-my-pi5-so-high) 22 | - [How can I rotate the image of my stream?](#how-can-i-rotate-the-image-of-my-stream) 23 | - [How to apply tuning filter?](#how-to-apply-tuning-filter) 24 | - [How to use the WebRTC endpoint?](#how-to-use-the-webrtc-endpoint) 25 | - [How to use Spyglass with Mainsail?](#how-to-use-spyglass-with-mainsail) 26 | - [How to use the controls endpoint?](#how-to-use-the-controls-endpoint) 27 | - [How to start developing?](#how-to-start-developing) 28 | 29 | 30 | ## Quick Start 31 | 32 | The server can be started with 33 | 34 | ```bash 35 | ./run.py 36 | ``` 37 | 38 | This will start the server with the following default configuration: 39 | 40 | - Address the server binds to: 0.0.0.0 41 | - Port: 8080 42 | - Resolution: 640x480 43 | - Framerate: 15 FPS 44 | - Stream URL: /stream 45 | - Snapshot URL: /snapshot 46 | - WebRTC URL: /webrtc 47 | - Controls URL: /controls 48 | 49 | The stream can then be accessed at `http://:8080/stream`.\ 50 | You might need to install dependencies, refer to the [installation section](#installation) below. 51 | 52 | ## Installation 53 | 54 | Run following commands to install and run Spyglass as a service: 55 | 56 | ```bash 57 | cd ~ 58 | sudo apt update 59 | sudo apt install python3-libcamera python3-kms++ python3-picamera2 git -y 60 | git clone https://github.com/mryel00/spyglass 61 | cd ~/spyglass 62 | make install 63 | ``` 64 | 65 | This will ask you for your `sudo` password.\ 66 | After install is done, please reboot to ensure service starts properly 67 | 68 | To uninstall the service simply use 69 | 70 | ```bash 71 | cd ~/spyglass 72 | make uninstall 73 | ``` 74 | 75 | ### Use Moonraker Update Manager 76 | 77 | To be able to use Moonraker update manager, add the following lines to your `moonraker.conf`: 78 | 79 | ```conf 80 | [update_manager spyglass] 81 | type: git_repo 82 | channel: beta 83 | path: ~/spyglass 84 | origin: https://github.com/mryel00/spyglass.git 85 | managed_services: spyglass 86 | ``` 87 | > Make sure moonraker.asvc contains `spyglass` in the list: `cat ~/printer_data/moonraker.asvc | grep spyglass`. 88 | > If it is not there execute `make upgrade-moonraker` or add it manually 89 | 90 | ### Configuration 91 | 92 | After installation you should find a configuration file in `~/printer_data/config/spyglass.conf`.\ 93 | Please see [spyglass.conf](resources/spyglass.conf) for the default config file and [CLI arguments](#cli-arguments) for 94 | all available options. 95 | 96 | ### Restart the service 97 | 98 | To restart the service use `systemctl`: 99 | 100 | ```bash 101 | sudo systemctl restart spyglass 102 | ``` 103 | 104 | ## CLI arguments 105 | 106 | On startup the following arguments are supported: 107 | 108 | | Argument | Description | Default | 109 | |--------------------------------|------------------------------------------------------------------------------------------------------------------------------------|--------------| 110 | | `-b`, `--bindaddress` | Address where the server will listen for incoming connections. | `0.0.0.0` | 111 | | `-p`, `--port` | Port where the server will listen for incoming connections. | `8080` | 112 | | `-r`, `--resolution` | Resolution of the captured frames. This argument expects the format \x\. | `640x480` | 113 | | `-f`, `--fps` | Framerate in frames per second (FPS). | `15` | 114 | | `-st`, `--stream_url` | Set the URL for the mjpeg stream. | `/stream` | 115 | | `-sn`, `--snapshot_url` | Set the URL for snapshots (single frame of stream). | `/snapshot` | 116 | | `-w`, `--webrtc_url` | Set the URL for WebRTC (H264 compressed stream). | `/webrtc` | 117 | | `-af`, `--autofocus` | Autofocus mode. Supported modes: `manual`, `continuous`. | `continuous` | 118 | | `-l`, `--lensposition` | Set focal distance. 0 for infinite focus, 0.5 for approximate 50cm. Only used with Autofocus manual. | `0.0` | 119 | | `-s`, `--autofocusspeed` | Autofocus speed. Supported values: `normal`, `fast`. Only used with Autofocus continuous. | `normal` | 120 | | `-ud`, `--upsidedown` | Rotate the image by 180° (see [below](#image-orientation)). | | 121 | | `-fh`, `--flip_horizontal` | Mirror the image horizontally (see [below](#image-orientation)). | | 122 | | `-fv`, `--flip_vertical` | Mirror the image vertically (see [below](#image-orientation)). | | 123 | | `-or`, `--orientation_exif` | Set the image orientation using an EXIF header (see [below](#image-orientation)). | | 124 | | `-c`, `--controls` | Define camera controls to start spyglass with. Can be used multiple times. This argument expects the format \=\. | | 125 | | `-tf`, `--tuning_filter` | Set a tuning filter file name. | | 126 | | `-tfd`, `--tuning_filter_dir` | Set the directory to look for tuning filters. | | 127 | | `-n`, `--camera_num` | Camera number to be used. All cameras with their number can be shown with `libcamera-hello`. | `0` | 128 | | `-sw`, `--use_sw_jpg_encoding` | Use software encoding for JPEG and MJPG (recommended on Pi5). | | 129 | | `--disable_webrtc` | Disable WebRTC encoding (recommended on Pi5). | | 130 | | `--list-controls` | List all available libcamera controls onto the console. Those can be used with `--controls`. | | 131 | 132 | 133 | ## FAQ 134 | 135 | ### How can I add CLI arguments to my `spyglass.conf`? 136 | 137 | All supported CLI arguments are already inside the [defaul config](resources/spyglass.conf). 138 | If we add new arguments we will add them there, so please refer to it, if you want to use a new argument. 139 | 140 | In the following sections we will only refer to the CLI arguments but you can use the `spyglass.conf` for all these too. 141 | 142 | ### How to use resolutions higher than maximum resolution? 143 | 144 | Please note that the maximum recommended resolution is 1920x1080 (16:9). 145 | 146 | The absolute maximum resolution is 1920x1920. If you choose a higher resolution spyglass may stop with 147 | `Maximum supported resolution is 1920x1920`. This is limited by the hardware (HW) encoder of the Pis.\ 148 | You can disable this limit with `--use_sw_jpg_encoding` and `--disable_webrtc`, or the respective config in 149 | `spyglass.conf`, but it will take way more CPU resources to run the stream and WebRTC won't work anymore. 150 | Only a Pi5 you don't need to add `--disable_webrtc`, for further information please refer to 151 | [Pi5 recommendations](#pi5-recommendations). 152 | 153 | ### Why is the CPU load on my Pi5 so high? 154 | 155 | The Pi5 is the newest generation of Raspberry Pi SBCs but not all new things come with improvements. 156 | The Raspberry Pi foundation decided to remove the hardware (HW) encoders from the Pi5. 157 | This results in overall higher CPU usage on a Pi5 compared to previous generations. 158 | 159 | The following sections should only be followed on a Pi5.\ 160 | WebRTC is also a big toll on your CPU. Therefore you should use `--disable_webrtc`.\ 161 | To reduce the CPU usage further you should add `--use_sw_jpg_encoding` to make sure to use the optimized software (SW) 162 | encoder, instead of the HW encoder falling back to an unoptimized SW encoder. 163 | 164 | ### How can I rotate the image of my stream? 165 | 166 | There are two ways to change the image orientation. 167 | 168 | To use the ability of picamera2 to transform the image you can use the following options when starting spyglass: 169 | * `-ud` or `--upsidedown` - Rotate the image by 180° 170 | * `-fh` or `--flip_horizontal` - Mirror the image horizontally 171 | * `-fv` or `--flip_vertical` - Mirror the image vertically 172 | 173 | This will work with all endpoints Spyglass offers. 174 | 175 | Alternatively you can create an EXIF header to modify the image orientation. Most modern browsers should respect 176 | the exif header. This will only work for the MJPG and JPEG endpoints. 177 | 178 | Use the `-or` or `--orientation_exif` option and choose from one of the following orientations 179 | * `h` - Horizontal (normal) 180 | * `mh` - Mirror horizontal 181 | * `r180` - Rotate 180 182 | * `mv` - Mirror vertical 183 | * `mhr270` - Mirror horizontal and rotate 270 CW 184 | * `r90` - Rotate 90 CW 185 | * `mhr90` - Mirror horizontal and rotate 90 CW 186 | * `r270` - Rotate 270 CW 187 | 188 | For example to rotate the image 90 degree clockwise you would start spyglass the following way: 189 | ```bash 190 | ./run.py -or r90 191 | ``` 192 | 193 | ### How to apply tuning filter? 194 | Tuning filters are used to normalize or modify the camera image output, for example, using an NoIR camera can lead to a 195 | pink color, whether applying a filter to it you could remove its tone pink. More information here: 196 | https://github.com/raspberrypi/picamera2/blob/main/examples/tuning_file.py 197 | 198 | 199 | Predefined filters can be found at one of the picamera2 directories: 200 | - `~/libcamera/src/ipa/rpi/vc4/data` 201 | - `/usr/local/share/libcamera/ipa/rpi/vc4` 202 | - `/usr/share/libcamera/ipa/rpi/vc4` 203 | - `/usr/share/libcamera/ipa/raspberrypi` 204 | 205 | You can use all the files present in there in our config, e.g.: `--tuning_filter=ov5647_noir.json` 206 | 207 | You can also define your own directory for filters using the `--tuning_filter_dir` argument. 208 | 209 | ### How to use the WebRTC endpoint? 210 | 211 | Spyglass does not deliver a streaming client for WebRTC but only the endpoint. We are using the same WebRTC protocol as 212 | [MediaMTX](https://github.com/bluenviron/mediamtx). Therefore you need to use e.g. Mainsail or any other client capable 213 | of using the MediaMTX stream. 214 | 215 | ### How to use Spyglass with Mainsail? 216 | 217 | > Note: In the following section we assume default settings. 218 | 219 | If you want to use Spyglass as a webcam source for [Mainsail](https://github.com/mainsail-crew/Mainsail) add a webcam 220 | with the following configuration: 221 | 222 | - URL Stream: `/webcam/stream` 223 | - URL Snapshot: `/webcam/snapshot` 224 | - Service: `MJPEG-Streamer` 225 | 226 | Alternatively you can use WebRTC. This will take less network bandwidth and might help to fix low FPS: 227 | 228 | - URL Stream: `/webcam/webrtc` 229 | - URL Snapshot: `/webcam/snapshot` 230 | - Service: `WebRTC (MediaMTX)` 231 | 232 | WebRTC needs [aiortc](https://github.com/aiortc/aiortc) installed. This gets automatically installed with `make install` 233 | for further instructions, please see the [install](#installation) chapter below. 234 | 235 | ### How to use the controls endpoint? 236 | 237 | For the control endpoint please refer to [this](docs/camera-controls.md) 238 | 239 | ### How to start developing? 240 | 241 | If you want to setup your environment for development perform the following steps: 242 | 243 | Setup your Python virtual environment: 244 | ```bash 245 | python -m venv .venv # Create a new virtual environment 246 | . .venv/bin/activate # Activate virtual environment 247 | python -m pip install --upgrade pip # Upgrade PIP to the current version 248 | pip install -r requirements.txt # Install application dependencies 249 | pip install -r requirements-test.txt # Install test dependencies 250 | pip install -r requirements-dev.txt # Install development dependencies 251 | ``` 252 | 253 | The project uses [commitizen](https://github.com/commitizen-tools/commitizen) to check commit messages to comply with 254 | [Conventional Commits](http://conventionalcommits.org). When installing the development dependencies git-hooks will be 255 | set up to check commit messages pre commit. 256 | -------------------------------------------------------------------------------- /docs/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | :tada: Fist off, thank you very much that you want to contribute! :tada: 4 | 5 | Please help to keep the project maintainable, easy to contribute to, and more secure by following this guide. 6 | 7 | ## The Contribution Process 8 | 9 | If you want to contribute to the project, making a fork and pull request: 10 | 11 | 1. Create your own fork. 12 | 2. Clone the fork locally. 13 | 3. Make changes in your local clone. 14 | 4. Push the changes from local to your fork. 15 | 5. Create a [GitHub Pull Request](https://github.com/roamingthings/spyglass/pulls) when your submission is ready to 16 | to be deployed into the project. 17 | 6. A [reviewer](contributors.md) that is ready to review your submission will assign themselves to the Pull Request on 18 | GitHub. The review process aims at checking the submission for defects, completeness and overall fit into the 19 | general architecture 20 | of the project. 21 | 7. After a successful review the Pull Request will be 'approved' on GitHub and will be committed to the main branch by 22 | a [contributor](contributors.md). 23 | 24 | ## About the Review Process 25 | 26 | Every contribution to Spyglass will be reviewed before it is merged into the main branch. The review aims to check for 27 | defects and to ensure that the submission follows the general style and architecture of the project. 28 | 29 | It is understood that there is not a single 'best way' to accomplish a task. Therefore, it is not intended to discuss 30 | if the submission is the 'best' implementation. 31 | 32 | Most of the time you will receive feedback from the review. Please be prepared to provide more information or details 33 | and update your submission, if required. 34 | 35 | Common aspects that a review looks for: 36 | 37 | 1. Are there any defects and is the submission ready to be widely distributed? 38 | 2. Does the submission provide real additional value to the project that will improve what users will get out of the 39 | software? 40 | 3. Does the submission include automated tests (e.g. unit test) when applicable that will ensure that the implementation 41 | does what it 42 | is intended to do? 43 | 4. Is the copyright of the submission clear, compatible with the project and non-gratuitous? 44 | 5. Commits well formatted, cover a single topic and independent? 45 | 6. Is the documentation updated to reflect the changes? 46 | 7. Does the implementation follow the general style of the project? 47 | 48 | ## Format of Commit Messages 49 | 50 | The header of the commit should be conformal with [conventional commits](https://www.conventionalcommits.org) and the 51 | description should be contained in the commit message body. 52 | 53 | ``` 54 | : lowercase, present form, short summary (the 'what' of the change) 55 | 56 | Optional, more detailed explanation of what the commit does (the 'why' and 'how'). 57 | 58 | Signed-off-by: My Name 59 | ``` 60 | 61 | The `` may be one of the following list: 62 | 63 | * `feat` - A new feature 64 | * `fix` - A bug fix 65 | * `test` - Adding a new test or improve an existing one 66 | * `docs` - Changes or additions to the documentation 67 | * `refactor` - Refactoring of the code or other project elements 68 | * `chore` - Other modifications that do not modify implementation, test or documentations 69 | 70 | It is important to have a "Signed-off-by" line on each commit to certify that you agree to the 71 | [developer certificate of origin](developer-certificate-of-origin.md). Depending on your IDE or editor you can 72 | automatically add this submission line with each commit. 73 | It has to contain your real name (please don't use pseudonyms) and contain a current email address. Unfortunately, we 74 | cannot accept anonymous submissions. 75 | 76 | You can use `git commit -s` to sign off a commit. 77 | 78 | ## Format of the Pull Request 79 | 80 | The pull request title and description will help the contributors to describe the change that is finally merged into 81 | the main branch. Each submission will be squashed into a single commit before the merge. 82 | 83 | This project follows the [Conventional Commits specification](https://www.conventionalcommits.org) to help to easily 84 | understand the intention and change of a commit. 85 | 86 | When crating the pull request we ask you to use the following guideline: 87 | 88 | The title of the pull request has the following format: 89 | 90 | ``` 91 | : lowercase, present form, short summary (the 'what' of the change) 92 | ``` 93 | 94 | If your submission does introduce a breaking change please add `BREAKING CHANGE` to the beginning of the description. 95 | 96 | Similar to a commit the description describes the overall change of the submission and include a `Signed-off-by` line 97 | at the end: 98 | 99 | ``` 100 | Describe what the submission changes. 101 | 102 | What is its intention? 103 | 104 | What benefit does it bring to the project? 105 | 106 | Signed-off-by: My Name 107 | ``` 108 | -------------------------------------------------------------------------------- /docs/adr/001_encoder.md: -------------------------------------------------------------------------------- 1 | # 001 - Encoder used for Capturing Video 2 | 3 | ## Date 4 | 5 | 2023-01-26 6 | 7 | ## Status 8 | 9 | Decision 10 | 11 | ## Category 12 | 13 | Architecture 14 | 15 | ## Authors 16 | 17 | @roamingthings, @mryel00 18 | 19 | ## References 20 | 21 | [The Picamera2 Library Documentation](https://datasheets.raspberrypi.com/camera/picamera2-manual.pdf) 22 | 23 | ## Context 24 | 25 | The Picamera2 library contains different encoders to capture video. 26 | 27 | We want to provide an mjpeg video stream and single still images (snapshots). 28 | 29 | This software aims at systems that will run additional tasks like 3D printers running Klipper, 30 | Mainsail etc. 31 | 32 | ## Options 33 | 34 | 1. Use `JpegEncoder` a multi-threaded software JPEG encoder 35 | 2. Use `MJPEGEncoder` an MJPEG encoder using the Raspberry Pi’s hardware 36 | 37 | ## Decision 38 | 39 | We will use the `MJPEGEncoder` that is using the Raspberry Pi's hardware. 40 | 41 | ## Consequences 42 | 43 | * Following the documentation and some experiments this encoder will consume less CPU than the software encoder. 44 | * This encoder is only available on Raspberry Pi hardware 45 | 46 | ## Useful information 47 | 48 | -- 49 | -------------------------------------------------------------------------------- /docs/camera-controls.md: -------------------------------------------------------------------------------- 1 | Spyglass offers a few CLI parameters for the most commonly used camera controls. 2 | Controls not directly available through the CLI can be used with the `--controls` (`-c`) or `--controls-string` (`-cs`) parameters or the `CONTROLS` section inside the `spyglass.conf`. 3 | 4 | 5 | ## How to list available controls? 6 | 7 | Spyglass provides a CLI parameter to list all available controls `--list-controls`. The available controls are then printed onto your shell under `Available controls:`. 8 | 9 | Following shows an example for a Raspberry Pi Module v3: 10 | ```sh 11 | Available controls: 12 | NoiseReductionMode (int) : min=0 max=4 default=0 13 | ScalerCrop (tuple) : min=(0, 0, 0, 0) max=(65535, 65535, 65535, 65535) default=(0, 0, 0, 0) 14 | Sharpness (float) : min=0.0 max=16.0 default=1.0 15 | AwbEnable (bool) : min=False max=True default=None 16 | FrameDurationLimits (int) : min=33333 max=120000 default=None 17 | ExposureValue (float) : min=-8.0 max=8.0 default=0.0 18 | AwbMode (int) : min=0 max=7 default=0 19 | AeExposureMode (int) : min=0 max=3 default=0 20 | Brightness (float) : min=-1.0 max=1.0 default=0.0 21 | AfWindows (tuple) : min=(0, 0, 0, 0) max=(65535, 65535, 65535, 65535) default=(0, 0, 0, 0) 22 | AfSpeed (int) : min=0 max=1 default=0 23 | AfTrigger (int) : min=0 max=1 default=0 24 | LensPosition (float) : min=0.0 max=32.0 default=1.0 25 | AfRange (int) : min=0 max=2 default=0 26 | AfPause (int) : min=0 max=2 default=0 27 | ExposureTime (int) : min=0 max=66666 default=None 28 | AeEnable (bool) : min=False max=True default=None 29 | AeConstraintMode (int) : min=0 max=3 default=0 30 | AfMode (int) : min=0 max=2 default=0 31 | AnalogueGain (float) : min=1.0 max=16.0 default=None 32 | ColourGains (float) : min=0.0 max=32.0 default=None 33 | AfMetering (int) : min=0 max=1 default=0 34 | AeMeteringMode (int) : min=0 max=3 default=0 35 | Contrast (float) : min=0.0 max=32.0 default=1.0 36 | Saturation (float) : min=0.0 max=32.0 default=1.0 37 | ``` 38 | 39 | 40 | ## How to apply a camera control? 41 | 42 | There are multiple ways to apply a camera control. All methods are case insensitive. 43 | 44 | ### Shell 45 | 46 | There are two different parameters to apply the controls: 47 | 48 | - `--controls`/`-c` can be used multiple times, to set multiple controls. E.g. using `-c brightness=0.5 -c awbenable=false` will apply `0.5` to the `Brightness` and `False` as the new `AwbEnable` value. 49 | - `--controls-string`/`cs` can be used only once. E.g. using `--controls-string "brightness=0.5, awbenable=16"` will apply `0.5` on the `Brightness` and `False` as the new `AwbEnable` control. Note: The `"` are required and the controls need to be separated by a `,`. This is intended only for parsing the config. 50 | 51 | ### Config 52 | 53 | The `spyglass.conf` accepts camera controls under the `CONTROLS` option. E.g. `CONTROLS="brightness=0,awbenable=false"` will apply `0.5` to the `Brightness` and `False` as the new `AwbEnable` value. 54 | 55 | ### Webinterface 56 | 57 | Spyglass also provides an API endpoint to change the camera controls during runtime. This endpoint is available under `http://:/controls` and cannot be changed. 58 | 59 | Calling it without any parameters will show you a list of all available controls, like `--list-controls`. 60 | 61 | E.g. `http://:/controls?brightness=0.5&awbenable=false` will apply `0.5` to the `Brightness` and `False` as the new `AwbEnable` value. 62 | 63 | If you apply parameters the interface will show you the parameters Spyglass found inside the url and which controls got actually processed: 64 | - `Parsed Controls` shows you the parameters Spyglass found during the request. 65 | - `Processed Controls` shows you the parameters of the `Parsed Controls` Spyglass could actually set for the cam. 66 | 67 | E.g. `http://:/controls?brightness=0.5&foo=bar&foobar` will show you `Parsed Controls: [('brightness', '1'), ('foo', 'bar')]` and `Processed Controls: {'Brightness': 1}`. 68 | -------------------------------------------------------------------------------- /docs/contributors.md: -------------------------------------------------------------------------------- 1 | # Main Contributors 2 | 3 | In alphabetical order of GitHub handles: 4 | 5 | | GitHub Handle | Name | 6 | |---------------------------------------------------|----------------------| 7 | | [KwadFan](https://github.com/KwadFan) | Stephan Wendel | 8 | | [mryel00](https://github.com/mryel00) | Patrick Gehrsitz | 9 | | [roamingthings](https://github.com/roamingthings) | Alexander Sparkowsky | 10 | -------------------------------------------------------------------------------- /docs/developer-certificate-of-origin.md: -------------------------------------------------------------------------------- 1 | Developer Certificate of Origin 2 | Version 1.1 3 | 4 | Copyright (C) 2004, 2006 The Linux Foundation and its contributors. 5 | 6 | Everyone is permitted to copy and distribute verbatim copies of this 7 | license document, but changing it is not allowed. 8 | 9 | 10 | Developer's Certificate of Origin 1.1 11 | 12 | By making a contribution to this project, I certify that: 13 | 14 | (a) The contribution was created in whole or in part by me and I 15 | have the right to submit it under the open source license 16 | indicated in the file; or 17 | 18 | (b) The contribution is based upon previous work that, to the best 19 | of my knowledge, is covered under an appropriate open source 20 | license and I have the right under that license to submit that 21 | work with modifications, whether created in whole or in part 22 | by me, under the same open source license (unless I am 23 | permitted to submit under a different license), as indicated 24 | in the file; or 25 | 26 | (c) The contribution was provided directly to me by some other 27 | person who certified (a), (b) or (c) and I have not modified 28 | it. 29 | 30 | (d) I understand and agree that this project and the contribution 31 | are public and that a record of the contribution (including all 32 | personal information I submit with it, including my sign-off) is 33 | maintained indefinitely and may be redistributed consistent with 34 | this project or the open source license(s) involved. 35 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "setuptools.build_meta" 3 | requires = ["setuptools", "wheel"] 4 | 5 | [tool.pytest.ini_options] 6 | addopts = [ 7 | "--import-mode=importlib", 8 | ] 9 | pythonpath = [ 10 | "." 11 | ] 12 | mock_use_standalone_module = true 13 | 14 | [tool.commitizen] 15 | name = "cz_conventional_commits" 16 | version = "0.17.0" 17 | tag_format = "v$version" 18 | version_files = [ 19 | "spyglass/__version__.py", 20 | "setup.cfg:version", 21 | "pyproject.toml:version", 22 | "README.md:Current version" 23 | ] 24 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | commitizen 2 | pre-commit 3 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | pylint 2 | pytest 3 | mock 4 | pytest-mock 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiortc~=1.9.0 2 | setuptools~=75.1.0 3 | -------------------------------------------------------------------------------- /resources/controls_style.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: Arial, sans-serif; 3 | background-color: #f6f6f6; 4 | margin: 40px; 5 | display: flex; 6 | flex-direction: column; 7 | align-items: center; 8 | } 9 | 10 | .card-container { 11 | display: flex; 12 | justify-content: center; 13 | align-items: center; 14 | width: 100%; 15 | max-width: 1100px; 16 | } 17 | 18 | .card-container:nth-child(odd) .card { 19 | background-color: white; 20 | } 21 | 22 | .card-container:nth-child(even) .card { 23 | background-color: #f0f0f0; 24 | } 25 | 26 | .card { 27 | border-radius: 5px; 28 | box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); 29 | padding: 0px 20px 20px 20px; /* top right bottom left */ 30 | width: 80%; 31 | box-sizing: border-box; 32 | transition: 0.3s; 33 | } 34 | 35 | .card:hover { 36 | box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2); 37 | } 38 | 39 | .card h2 { 40 | color: #2C3E50; 41 | border-bottom: 1px solid #2C3E50; 42 | padding-bottom: 10px; 43 | margin-bottom: 10px; 44 | } 45 | 46 | .card-content { 47 | display: flex; 48 | margin-bottom: 1px; 49 | } 50 | 51 | .setting { 52 | flex: 1; 53 | display: flex; 54 | margin: 0 5px; 55 | } 56 | 57 | .label, 58 | .value { 59 | font-size: 14px; 60 | } 61 | 62 | .label { 63 | font-weight: bold; 64 | color: #7F8C8D; 65 | } 66 | 67 | .value { 68 | margin-left: 4px; 69 | } 70 | -------------------------------------------------------------------------------- /resources/spyglass.conf: -------------------------------------------------------------------------------- 1 | #### spyglass - Picamera2 MJPG Streamer 2 | #### 3 | #### https://github.com/roamingthings/spyglass 4 | #### 5 | #### This File is distributed under GPLv3 6 | #### 7 | 8 | #### NOTE: Please ensure parameters are in capital letters and values in lowercase! 9 | #### NOTE: Values has to be surrounded by double quotes! ("value") 10 | #### NOTE: If commented out or includes typos it will use hardcoded defaults! 11 | 12 | #### Libcamera camera to use (INTEGER)[default: 0] 13 | CAMERA_NUM="0" 14 | 15 | #### Running Spyglass with proxy or Standalone (BOOL)[default: true] 16 | NO_PROXY="true" 17 | 18 | #### HTTP Port to listen on (INTEGER)[default: 8080] 19 | HTTP_PORT="8080" 20 | 21 | #### Resolution (INTEGERxINTEGER)[default: 640x480] 22 | RESOLUTION="640x480" 23 | #### NOTE: the maximum supported resolution is 1920x1920 (recommended maximum 1920x1080) 24 | 25 | #### Frames per second (INTEGER)[default: 15] 26 | FPS="15" 27 | 28 | #### Stream URL (STRING)[default: /stream] 29 | STREAM_URL="/stream" 30 | #### NOTE: use format as shown below to stay MJPG-Streamer URL compatible 31 | ## STREAM_URL="/?action=stream" 32 | 33 | #### Snapshot URL (STRING)[default: /snapshot] 34 | SNAPSHOT_URL="/snapshot" 35 | #### NOTE: use format as shown below to stay MJPG-Streamer URL compatible 36 | ## SNAPSHOT_URL="/?action=snapshot" 37 | 38 | #### Use Software JPG Encoding (BOOL)[default: false] 39 | #USE_SW_JPG_ENCODING="true" 40 | 41 | #### WebRTC URL (STRING)[default: /webrtc] 42 | WEBRTC_URL="/webrtc" 43 | 44 | #### Disable WebRTC (BOOL)[default: false] 45 | #DISABLE_WEBRTC="true" 46 | 47 | #### Autofocus behavior (STRING:manual,continuous)[default: continuous] 48 | AUTO_FOCUS="continuous" 49 | 50 | #### Focal Distance (float:0.0)[default: 0.0] 51 | #### NOTE: Set focal distance. 0 for infinite focus, 0.5 for approximate 50cm. 52 | #### Only used with Autofocus manual 53 | FOCAL_DIST="0.0" 54 | 55 | #### Auto Focus Speed (STRING:normal,fast)[default: normal] 56 | #### NOTE: Autofocus speed. Supported values: normal, fast. 57 | #### Only used with Autofocus continuous 58 | AF_SPEED="normal" 59 | 60 | #### EXIF Orientation (STRING:h,mh,r180,mv,mhr270,r90,mhr90,r270)[default: h] 61 | #### NOTE: Set the image orientation using an EXIF header. 62 | #### h - Horizontal (normal) 63 | #### mh - Mirror horizontal 64 | #### r180 - Rotate 180 65 | #### mv - Mirror vertical 66 | #### mhr270 - Mirror horizontal and rotate 270 CW 67 | #### r90 - Rotate 90 CW 68 | #### mhr90 - Mirror horizontal and rotate 90 CW 69 | #### r270 - Rotate 270 CW 70 | ORIENTATION_EXIF="h" 71 | 72 | #### Camera Controls 73 | #### NOTE: Set v4l2 controls your camera supports at startup 74 | #### EXAMPLE: CONTROLS="brightness=0,awbenable=false" 75 | CONTROLS="" 76 | 77 | #### Tuning Filter Directory (STRING)[default: none] 78 | #### NOTE: Directory where to search for tuning filters(if defined). 79 | #### Directory only used if TUNING_FILTER is defined 80 | # TUNING_FILTER_DIR="/usr/share/libcamera/ipa/raspberrypi" 81 | 82 | #### Tuning Filter (STRING)[default: none] 83 | #### NOTE: Name of the file to be used to apply tuning filter. 84 | #### If dir not defined, default pycamera2 directories will be used. 85 | # TUNING_FILTER="ov5647_noir.json" 86 | -------------------------------------------------------------------------------- /resources/spyglass.service: -------------------------------------------------------------------------------- 1 | #### spyglass - Picamera2 MJPG Streamer 2 | #### 3 | #### https://github.com/roamingthings/spyglass 4 | #### 5 | #### This File is distributed under GPLv3 6 | #### 7 | 8 | [Unit] 9 | Description=spyglass - Picamera2 MJPG Streamer 10 | Documentation=https://github.com/roamingthings/spyglass 11 | After=udev.service network-online.target nss-lookup.target 12 | Wants=udev.service network-online.target 13 | StartLimitBurst=10 14 | StartLimitIntervalSec=180 15 | 16 | [Install] 17 | WantedBy=multi-user.target 18 | 19 | [Service] 20 | Type=simple 21 | User=%USER% 22 | RemainAfterExit=Yes 23 | WorkingDirectory=/home/%USER%/spyglass 24 | ExecStart= /usr/local/bin/spyglass 25 | Restart=on-failure 26 | RestartSec=5 27 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | 3 | from spyglass.cli import main 4 | 5 | if __name__ == "__main__": 6 | raise SystemExit(main()) 7 | -------------------------------------------------------------------------------- /scripts/spyglass: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | #### spyglass - Picamera2 MJPG Streamer 3 | #### 4 | #### https://github.com/roamingthings/spyglass 5 | #### 6 | #### This File is distributed under GPLv3 7 | #### 8 | 9 | # shellcheck enable=require-variable-braces 10 | 11 | ### Error Handling 12 | set -Eeou pipefail 13 | 14 | 15 | ### Global Variables 16 | BASE_SPY_PATH="$(dirname "$(readlink -f "${0}")")" 17 | PY_BIN="$(. .venv/bin/activate && command -v python)" 18 | SPYGLASS_CFG="${HOME}/printer_data/config/spyglass.conf" 19 | 20 | ### Helper Messages 21 | debug_msg() { 22 | printf "DEBUG: %s\n" "${1}" 23 | } 24 | 25 | help_msg() { 26 | echo -e "spyglass - Picamera2 MJPG Streamer\nUsage:" 27 | echo -e "\t spyglass [Options]" 28 | echo -e "\n\t\t-h Prints this help." 29 | echo -e "\n\t\t-c \n\t\t\tPath to your spyglass.conf" 30 | echo -e "\n\t\t-v Show spyglass version\n" 31 | } 32 | 33 | wrong_args_msg() { 34 | echo -e "spyglass: Wrong Arguments!" 35 | echo -e "\n\tTry: spyglass -h\n" 36 | } 37 | 38 | ### Helper Funcs 39 | 40 | ## Version of spyglass 41 | self_version() { 42 | pushd "${BASE_SPY_PATH}" &> /dev/null 43 | git describe --always --tags 44 | popd &> /dev/null 45 | } 46 | 47 | check_py_version() { 48 | local version 49 | if [[ -n "${PY_BIN}" ]]; then 50 | version=$("${PY_BIN}" -V | cut -d" " -f2 | cut -d"." -f1) 51 | else 52 | printf "ERROR: Python interpreter is not installed! [EXITING]\n" 53 | exit 1 54 | fi 55 | if [[ -n "${version}" ]] && [[ "${version}" = "3" ]]; then 56 | printf "INFO: Python interpreter Version %s found ... [OK]\n" "$("${PY_BIN}" -V)" 57 | elif [[ -n "${version}" ]] && [[ "${version}" = "2" ]]; then 58 | printf "ERROR: Python interpreter Version 3 is required! [EXITING]\n" 59 | exit 1 60 | fi 61 | } 62 | 63 | get_config() { 64 | if [[ -n "${SPYGLASS_CFG}" ]] && [[ -f "${SPYGLASS_CFG}" ]]; then 65 | printf "INFO: Configuration file found in %s\n" "${SPYGLASS_CFG}" 66 | print_config 67 | # shellcheck disable=SC1090 68 | . "${SPYGLASS_CFG}" 69 | else 70 | printf "ERROR: No configuration file found in %s! [EXITING]\n" "${SPYGLASS_CFG}" 71 | exit 1 72 | fi 73 | } 74 | 75 | print_config() { 76 | local prefix 77 | prefix="\t\t" 78 | printf "INFO: Print Configfile: '%s'\n" "${SPYGLASS_CFG}" 79 | (sed '/^#.*/d;/./,$!d;/^$/d' | cut -d'#' -f1) < "${SPYGLASS_CFG}" | \ 80 | while read -r line; do 81 | printf "%b%s\n" "${prefix}" "${line}" 82 | done 83 | } 84 | 85 | run_spyglass() { 86 | local bind_adress 87 | # ensure default for NO_PROXY 88 | [[ -n "${NO_PROXY}" ]] || NO_PROXY="true" 89 | 90 | if [[ "${NO_PROXY}" != "true" ]]; then 91 | bind_adress="127.0.0.1" 92 | else 93 | bind_adress="0.0.0.0" 94 | fi 95 | 96 | if [[ "${USE_SW_JPG_ENCODING:-false}" == "true" ]]; then 97 | use_sw_jpg_encoding="--use_sw_jpg_encoding" 98 | else 99 | use_sw_jpg_encoding="" 100 | fi 101 | 102 | if [[ "${DISABLE_WEBRTC:-false}" == "true" ]]; then 103 | disable_webrtc="--disable_webrtc" 104 | else 105 | disable_webrtc="" 106 | fi 107 | 108 | "${PY_BIN}" "$(dirname "${BASE_SPY_PATH}")/run.py" \ 109 | --camera_num "${CAMERA_NUM:-0}" \ 110 | --bindaddress "${bind_adress}" \ 111 | --port "${HTTP_PORT:-8080}" \ 112 | --resolution "${RESOLUTION:-640x480}" \ 113 | --fps "${FPS:-15}" \ 114 | --stream_url "${STREAM_URL:-/stream}" \ 115 | --snapshot_url "${SNAPSHOT_URL:-/snapshot}" \ 116 | ${use_sw_jpg_encoding} \ 117 | --webrtc_url "${WEBRTC_URL:-/webrtc}" \ 118 | ${disable_webrtc} \ 119 | --autofocus "${AUTO_FOCUS:-continuous}" \ 120 | --lensposition "${FOCAL_DIST:-0.0}" \ 121 | --autofocusspeed "${AF_SPEED:-normal}" \ 122 | --orientation_exif "${ORIENTATION_EXIF:-h}" \ 123 | --tuning_filter "${TUNING_FILTER:-}"\ 124 | --tuning_filter_dir "${TUNING_FILTER_DIR:-}" \ 125 | --controls-string "${CONTROLS:-0=0}" # 0=0 to prevent error on empty string 126 | } 127 | 128 | #### MAIN 129 | ## Parse Args 130 | while getopts ":vhc:d" arg; do 131 | case "${arg}" in 132 | v ) 133 | echo -e "\nspyglass Version: $(self_version)\n" 134 | exit 0 135 | ;; 136 | h ) 137 | help_msg 138 | exit 0 139 | ;; 140 | c ) 141 | SPYGLASS_CFG="${OPTARG}" 142 | ;; 143 | d ) 144 | set -x 145 | ;; 146 | \?) 147 | wrong_args_msg 148 | exit 1 149 | ;; 150 | esac 151 | done 152 | 153 | 154 | check_py_version 155 | get_config 156 | run_spyglass 157 | 158 | ### Loop to keep running 159 | while true; do 160 | sleep 1 161 | done 162 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = spyglass 3 | version = 0.17.0 4 | description = A simple mjpeg server for Picamera2 5 | url = https://github.com/roamingthings/spyglass 6 | license = GPL-3.0-only 7 | license_files = 8 | LICENSE 9 | NOTICE 10 | 11 | [options] 12 | packages = find: 13 | python_requires = >=3.8.1 14 | 15 | [options.entry_points] 16 | console_scripts = 17 | spyglass = spyglass.cli:main 18 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /spyglass/__init__.py: -------------------------------------------------------------------------------- 1 | """init py module.""" 2 | import logging 3 | import importlib.util 4 | 5 | logging.basicConfig(level=logging.INFO) 6 | logger = logging.getLogger(__name__) 7 | 8 | if importlib.util.find_spec("aiortc"): 9 | WEBRTC_ENABLED=True 10 | else: 11 | WEBRTC_ENABLED=False 12 | 13 | def set_webrtc_enabled(enabled): 14 | global WEBRTC_ENABLED 15 | WEBRTC_ENABLED = enabled 16 | -------------------------------------------------------------------------------- /spyglass/__main__.py: -------------------------------------------------------------------------------- 1 | """Module allowing for ``python -m spyglass ...``.""" 2 | from __future__ import annotations 3 | 4 | from spyglass.cli import main 5 | 6 | if __name__ == "__main__": 7 | raise SystemExit(main()) 8 | -------------------------------------------------------------------------------- /spyglass/__version__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.17.0" 2 | -------------------------------------------------------------------------------- /spyglass/camera/__init__.py: -------------------------------------------------------------------------------- 1 | from picamera2 import Picamera2 2 | 3 | from spyglass.camera.camera import Camera 4 | from spyglass.camera.csi import CSI 5 | from spyglass.camera.usb import USB 6 | 7 | def init_camera( 8 | camera_num: int, 9 | tuning_filter=None, 10 | tuning_filter_dir=None 11 | ) -> Camera: 12 | tuning = None 13 | 14 | if tuning_filter: 15 | params = {'tuning_file': tuning_filter} 16 | if tuning_filter_dir: 17 | params['dir'] = tuning_filter_dir 18 | tuning = Picamera2.load_tuning_file(**params) 19 | 20 | picam2 = Picamera2(camera_num, tuning=tuning) 21 | if picam2._is_rpi_camera(): 22 | cam = CSI(picam2) 23 | else: 24 | cam = USB(picam2) 25 | return cam 26 | -------------------------------------------------------------------------------- /spyglass/camera/camera.py: -------------------------------------------------------------------------------- 1 | import libcamera 2 | import threading 3 | 4 | from abc import ABC, abstractmethod 5 | from picamera2 import Picamera2 6 | 7 | from spyglass import logger, WEBRTC_ENABLED 8 | from spyglass.exif import create_exif_header 9 | from spyglass.camera_options import process_controls 10 | from spyglass.server.http_server import StreamingServer, StreamingHandler 11 | from spyglass.server.webrtc_whep import PicameraStreamTrack 12 | 13 | class Camera(ABC): 14 | def __init__(self, picam2: Picamera2): 15 | self.picam2 = picam2 16 | self.media_track = PicameraStreamTrack() 17 | 18 | def create_controls(self, fps: int, autofocus: str, lens_position: float, autofocus_speed: str): 19 | controls = {} 20 | 21 | if 'FrameDurationLimits' in self.picam2.camera_controls: 22 | controls['FrameRate'] = fps 23 | 24 | if 'AfMode' in self.picam2.camera_controls: 25 | controls['AfMode'] = autofocus 26 | controls['AfSpeed'] = autofocus_speed 27 | if autofocus == libcamera.controls.AfModeEnum.Manual: 28 | controls['LensPosition'] = lens_position 29 | else: 30 | logger.warning('Attached camera does not support autofocus') 31 | 32 | return controls 33 | 34 | def configure(self, 35 | width: int, 36 | height: int, 37 | fps: int, 38 | autofocus: str, 39 | lens_position: float, 40 | autofocus_speed: str, 41 | control_list: list[list[str]]=[], 42 | upsidedown=False, 43 | flip_horizontal=False, 44 | flip_vertical=False): 45 | controls = self.create_controls(fps, autofocus, lens_position, autofocus_speed) 46 | c = process_controls(self.picam2, [tuple(ctrl) for ctrl in control_list]) 47 | controls.update(c) 48 | 49 | transform = libcamera.Transform( 50 | hflip=int(flip_horizontal or upsidedown), 51 | vflip=int(flip_vertical or upsidedown) 52 | ) 53 | 54 | self.picam2.configure( 55 | self.picam2.create_video_configuration( 56 | main={'size': (width, height)}, 57 | controls=controls, 58 | transform=transform 59 | ) 60 | ) 61 | 62 | def _run_server(self, 63 | bind_address, 64 | port, 65 | streaming_handler: StreamingHandler, 66 | get_frame, 67 | stream_url='/stream', 68 | snapshot_url='/snapshot', 69 | webrtc_url='/webrtc', 70 | orientation_exif=0): 71 | logger.info(f'Server listening on {bind_address}:{port}') 72 | logger.info(f'Streaming endpoint: {stream_url}') 73 | logger.info(f'Snapshot endpoint: {snapshot_url}') 74 | if WEBRTC_ENABLED: 75 | logger.info(f'WebRTC endpoint: {webrtc_url}') 76 | logger.info(f'Controls endpoint: /controls') 77 | address = (bind_address, port) 78 | streaming_handler.picam2 = self.picam2 79 | streaming_handler.media_track = self.media_track 80 | streaming_handler.get_frame = get_frame 81 | streaming_handler.stream_url = stream_url 82 | streaming_handler.snapshot_url = snapshot_url 83 | streaming_handler.webrtc_url = webrtc_url 84 | 85 | if orientation_exif > 0: 86 | streaming_handler.exif_header = create_exif_header(orientation_exif) 87 | else: 88 | streaming_handler.exif_header = None 89 | current_server = StreamingServer(address, streaming_handler) 90 | async_loop = threading.Thread(target=StreamingHandler.loop.run_forever) 91 | async_loop.start() 92 | current_server.serve_forever() 93 | 94 | @abstractmethod 95 | def start_and_run_server(self, 96 | bind_address, 97 | port, 98 | stream_url='/stream', 99 | snapshot_url='/snapshot', 100 | webrtc_url='/webrtc', 101 | orientation_exif=0, 102 | use_sw_jpg_encoding=False): 103 | pass 104 | 105 | @abstractmethod 106 | def stop(self): 107 | pass 108 | -------------------------------------------------------------------------------- /spyglass/camera/csi.py: -------------------------------------------------------------------------------- 1 | import io 2 | 3 | from picamera2.outputs import FileOutput 4 | from threading import Condition 5 | 6 | from spyglass import camera, WEBRTC_ENABLED 7 | from spyglass.server.http_server import StreamingHandler 8 | 9 | class CSI(camera.Camera): 10 | def start_and_run_server(self, 11 | bind_address, 12 | port, 13 | stream_url='/stream', 14 | snapshot_url='/snapshot', 15 | webrtc_url='/webrtc', 16 | orientation_exif=0, 17 | use_sw_jpg_encoding=False): 18 | if use_sw_jpg_encoding: 19 | from picamera2.encoders import JpegEncoder as MJPEGEncoder 20 | else: 21 | from picamera2.encoders import MJPEGEncoder 22 | class StreamingOutput(io.BufferedIOBase): 23 | def __init__(self): 24 | self.frame = None 25 | self.condition = Condition() 26 | 27 | def write(self, buf): 28 | with self.condition: 29 | self.frame = buf 30 | self.condition.notify_all() 31 | output = StreamingOutput() 32 | def get_frame(inner_self): 33 | with output.condition: 34 | output.condition.wait() 35 | return output.frame 36 | 37 | self.picam2.start_encoder(MJPEGEncoder(), FileOutput(output)) 38 | if WEBRTC_ENABLED: 39 | from picamera2.encoders import H264Encoder 40 | self.picam2.start_encoder(H264Encoder(), self.media_track) 41 | self.picam2.start() 42 | 43 | self._run_server( 44 | bind_address, 45 | port, 46 | StreamingHandler, 47 | get_frame, 48 | stream_url=stream_url, 49 | snapshot_url=snapshot_url, 50 | webrtc_url=webrtc_url, 51 | orientation_exif=orientation_exif 52 | ) 53 | 54 | def stop(self): 55 | self.picam2.stop_recording() 56 | -------------------------------------------------------------------------------- /spyglass/camera/usb.py: -------------------------------------------------------------------------------- 1 | from spyglass import camera 2 | from spyglass.server.http_server import StreamingHandler 3 | 4 | class USB(camera.Camera): 5 | def start_and_run_server(self, 6 | bind_address, 7 | port, 8 | stream_url='/stream', 9 | snapshot_url='/snapshot', 10 | webrtc_url='/webrtc', 11 | orientation_exif=0, 12 | use_sw_jpg_encoding=False): 13 | def get_frame(inner_self): 14 | #TODO: Cuts framerate in 1/n with n streams open, add some kind of buffer 15 | return self.picam2.capture_buffer() 16 | 17 | self.picam2.start() 18 | 19 | self._run_server( 20 | bind_address, 21 | port, 22 | StreamingHandler, 23 | get_frame, 24 | stream_url=stream_url, 25 | snapshot_url=snapshot_url, 26 | webrtc_url=webrtc_url, 27 | orientation_exif=orientation_exif 28 | ) 29 | 30 | def stop(self): 31 | self.picam2.stop() 32 | -------------------------------------------------------------------------------- /spyglass/camera_options.py: -------------------------------------------------------------------------------- 1 | import libcamera 2 | import ast 3 | import pathlib 4 | 5 | def parse_dictionary_to_html_page(camera, parsed_controls={}, processed_controls={}): 6 | if not parsed_controls: 7 | parsed_controls = 'None' 8 | if not processed_controls: 9 | processed_controls = 'None' 10 | html = """ 11 | 12 | 13 | """ 14 | html += f""" 15 | 16 | 17 | 18 | Camera Settings 19 | 20 | 21 | """ 22 | html += f""" 23 | 24 |

Available camera options

25 |

Parsed Controls: {parsed_controls}

26 |

Processed Controls: {processed_controls}

27 | """ 28 | for control, values in camera.camera_controls.items(): 29 | html += f""" 30 |
31 |
32 |

{control}

33 |
34 |
35 | Min: 36 | {values[0]} 37 |
38 |
39 | Max: 40 | {values[1]} 41 |
42 |
43 | Default: 44 | {values[2]} 45 |
46 |
47 |
48 |
49 | """ 50 | html += """ 51 | 52 | 53 | """ 54 | return html 55 | 56 | def get_style(): 57 | file_dir = pathlib.Path(__file__).parent.resolve() 58 | controls_style = file_dir / '..' / 'resources' / 'controls_style.css' 59 | with (open(controls_style, 'r')) as f: 60 | return f.read() 61 | 62 | def process_controls(camera, controls: list[tuple[str, str]]) -> dict[str, any]: 63 | controls_dict_lower = { k.lower(): k for k in camera.camera_controls.keys() } 64 | if controls == None: 65 | return {} 66 | processed_controls = {} 67 | for key, value in controls: 68 | key = key.lower().strip() 69 | if key.lower() in controls_dict_lower.keys(): 70 | value = value.lower().strip() 71 | k = controls_dict_lower[key] 72 | v = parse_from_string(value) 73 | processed_controls[k] = v 74 | return processed_controls 75 | 76 | def parse_from_string(input_string: str) -> any: 77 | try: 78 | return ast.literal_eval(input_string) 79 | except (ValueError, TypeError, SyntaxError): 80 | pass 81 | 82 | if input_string.lower() in ['true', 'false']: 83 | return input_string.lower() == 'true' 84 | 85 | return input_string 86 | 87 | def get_type_str(obj) -> str: 88 | return str(type(obj)).split('\'')[1] 89 | 90 | def get_libcamera_controls_string(camera_num: str) -> str: 91 | ctrls_str = "" 92 | libcam_cm = libcamera.CameraManager.singleton() 93 | if camera_num > len(libcam_cm.cameras) - 1: 94 | return ctrls_str 95 | cam = libcam_cm.cameras[camera_num] 96 | 97 | def rectangle_to_tuple(rectangle): 98 | return (rectangle.x, rectangle.y, rectangle.width, rectangle.height) 99 | 100 | for k, v in cam.controls.items(): 101 | if isinstance(v.min, libcamera.Rectangle): 102 | min = rectangle_to_tuple(v.min) 103 | max = rectangle_to_tuple(v.max) 104 | default = rectangle_to_tuple(v.default) 105 | else: 106 | min = v.min 107 | max = v.max 108 | default = v.default 109 | 110 | str_first = f"{k.name} ({get_type_str(min)})" 111 | str_second = f"min={min} max={max} default={default}" 112 | str_indent = (30 - len(str_first)) * ' ' + ': ' 113 | ctrls_str += str_first + str_indent + str_second + '\n' 114 | 115 | return ctrls_str.strip() 116 | -------------------------------------------------------------------------------- /spyglass/cli.py: -------------------------------------------------------------------------------- 1 | """cli entry point for spyglass. 2 | 3 | Parse command line arguments in, invoke server. 4 | """ 5 | 6 | import argparse 7 | import re 8 | import sys 9 | import libcamera 10 | 11 | from spyglass import camera_options, logger, WEBRTC_ENABLED, set_webrtc_enabled 12 | from spyglass.exif import option_to_exif_orientation 13 | from spyglass.__version__ import __version__ 14 | 15 | # Maximum resolution for hardware encoding 16 | MAX_WIDTH = MAX_HEIGHT = 1920 17 | 18 | def main(args=None): 19 | global WEBRTC_ENABLED 20 | """Entry point for hello cli. 21 | 22 | The setup_py entry_point wraps this in sys.exit already so this effectively 23 | becomes sys.exit(main()). 24 | The __main__ entry point similarly wraps sys.exit(). 25 | """ 26 | logger.info(f"Spyglass {__version__}") 27 | 28 | if args is None: 29 | args = sys.argv[1:] 30 | 31 | parsed_args = get_args(args) 32 | 33 | if parsed_args.list_controls: 34 | controls_str = camera_options.get_libcamera_controls_string(parsed_args.camera_num) 35 | if not controls_str: 36 | print(f"Camera {parsed_args.camera_num} not found") 37 | else: 38 | print('Available controls:\n'+controls_str) 39 | return 40 | 41 | use_sw_jpg_encoding = parsed_args.use_sw_jpg_encoding 42 | # Disable max resolution limit for software encoding of JPEG 43 | width, height = split_resolution(parsed_args.resolution, check_limit=not use_sw_jpg_encoding) 44 | controls = parsed_args.controls 45 | if parsed_args.controls_string: 46 | controls += [c.split('=') for c in parsed_args.controls_string.split(',')] 47 | 48 | set_webrtc_enabled(WEBRTC_ENABLED and not parsed_args.disable_webrtc) 49 | 50 | # Has to be imported after WEBRTC_ENABLED got set correctly 51 | from spyglass.camera import init_camera 52 | 53 | cam = init_camera( 54 | parsed_args.camera_num, 55 | parsed_args.tuning_filter, 56 | parsed_args.tuning_filter_dir) 57 | 58 | cam.configure(width, 59 | height, 60 | parsed_args.fps, 61 | parse_autofocus(parsed_args.autofocus), 62 | parsed_args.lensposition, 63 | parse_autofocus_speed(parsed_args.autofocusspeed), 64 | controls, 65 | parsed_args.upsidedown, 66 | parsed_args.flip_horizontal, 67 | parsed_args.flip_vertical) 68 | try: 69 | cam.start_and_run_server(parsed_args.bindaddress, 70 | parsed_args.port, 71 | parsed_args.stream_url, 72 | parsed_args.snapshot_url, 73 | parsed_args.webrtc_url, 74 | parsed_args.orientation_exif, 75 | use_sw_jpg_encoding) 76 | finally: 77 | cam.stop() 78 | 79 | # region args parsers 80 | 81 | def resolution_type(arg_value, pat=re.compile(r"^\d+x\d+$")): 82 | if not pat.match(arg_value): 83 | raise argparse.ArgumentTypeError("invalid value: x expected.") 84 | return arg_value 85 | 86 | 87 | def control_type(arg_value: str): 88 | if '=' in arg_value: 89 | return arg_value.split('=') 90 | else: 91 | raise argparse.ArgumentTypeError(f"invalid control: Missing value: {arg_value}") 92 | 93 | 94 | def orientation_type(arg_value): 95 | if arg_value in option_to_exif_orientation: 96 | return option_to_exif_orientation[arg_value] 97 | else: 98 | raise argparse.ArgumentTypeError(f"invalid value: unknown orientation {arg_value}.") 99 | 100 | 101 | def parse_autofocus(arg_value): 102 | if arg_value == 'manual': 103 | return libcamera.controls.AfModeEnum.Manual 104 | elif arg_value == 'continuous': 105 | return libcamera.controls.AfModeEnum.Continuous 106 | else: 107 | raise argparse.ArgumentTypeError("invalid value: manual or continuous expected.") 108 | 109 | 110 | def parse_autofocus_speed(arg_value): 111 | if arg_value == 'normal': 112 | return libcamera.controls.AfSpeedEnum.Normal 113 | elif arg_value == 'fast': 114 | return libcamera.controls.AfSpeedEnum.Fast 115 | else: 116 | raise argparse.ArgumentTypeError("invalid value: normal or fast expected.") 117 | 118 | 119 | def split_resolution(res, check_limit=True): 120 | parts = res.split('x') 121 | w = int(parts[0]) 122 | h = int(parts[1]) 123 | if check_limit and (w > MAX_WIDTH or h > MAX_HEIGHT): 124 | raise argparse.ArgumentTypeError("Maximum supported resolution is 1920x1920") 125 | return w, h 126 | 127 | # endregion args parsers 128 | 129 | 130 | # region cli args 131 | 132 | def get_args(args): 133 | """Parse arguments passed in from shell.""" 134 | return get_parser().parse_args(args) 135 | 136 | 137 | def get_parser(): 138 | """Return ArgumentParser for hello cli.""" 139 | parser = argparse.ArgumentParser( 140 | allow_abbrev=True, 141 | prog='spyglass', 142 | description='Start a webserver for Picamera2 videostreams.', 143 | formatter_class=argparse.RawTextHelpFormatter) 144 | parser.add_argument('-b', '--bindaddress', type=str, default='0.0.0.0', help='Bind to address for incoming ' 145 | 'connections') 146 | parser.add_argument('-p', '--port', type=int, default=8080, help='Bind to port for incoming connections') 147 | parser.add_argument('-r', '--resolution', type=resolution_type, default='640x480', 148 | help='Resolution of the images width x height. Maximum is 1920x1920.') 149 | parser.add_argument('-f', '--fps', type=int, default=15, help='Frames per second to capture') 150 | parser.add_argument('-st', '--stream_url', type=str, default='/stream', 151 | help='Sets the URL for the MJPG stream') 152 | parser.add_argument('-sn', '--snapshot_url', type=str, default='/snapshot', 153 | help='Sets the URL for snapshots (single frame of stream)') 154 | parser.add_argument('-sw', '--use_sw_jpg_encoding', action='store_true', 155 | help='Use software encoding for JPEG and MJPG (recommended on Pi5)') 156 | parser.add_argument('-w', '--webrtc_url', type=str, default='/webrtc', 157 | help='Sets the URL for the WebRTC stream') 158 | parser.add_argument('--disable_webrtc', action='store_true', 159 | help='Disables WebRTC encoding (recommended on Pi5)') 160 | parser.add_argument('-af', '--autofocus', type=str, default='continuous', choices=['manual', 'continuous'], 161 | help='Autofocus mode') 162 | parser.add_argument('-l', '--lensposition', type=float, default=0.0, 163 | help='Set focal distance. 0 for infinite focus, 0.5 for approximate 50cm. ' 164 | 'Only used with Autofocus manual') 165 | parser.add_argument('-s', '--autofocusspeed', type=str, default='normal', choices=['normal', 'fast'], 166 | help='Autofocus speed. Only used with Autofocus continuous') 167 | parser.add_argument('-ud', '--upsidedown', action='store_true', 168 | help='Rotate the image by 180° (sensor level)') 169 | parser.add_argument('-fh', '--flip_horizontal', action='store_true', 170 | help='Mirror the image horizontally (sensor level)') 171 | parser.add_argument('-fv', '--flip_vertical', action='store_true', 172 | help='Mirror the image vertically (sensor level)') 173 | parser.add_argument('-or', '--orientation_exif', type=orientation_type, default='h', 174 | help='Set the image orientation using an EXIF header. This does not work with WebRTC:\n' 175 | ' h - Horizontal (normal)\n' 176 | ' mh - Mirror horizontal\n' 177 | ' r180 - Rotate 180\n' 178 | ' mv - Mirror vertical\n' 179 | ' mhr270 - Mirror horizontal and rotate 270 CW\n' 180 | ' r90 - Rotate 90 CW\n' 181 | ' mhr90 - Mirror horizontal and rotate 90 CW\n' 182 | ' r270 - Rotate 270 CW' 183 | ) 184 | parser.add_argument('-c', '--controls', default=[], type=control_type, action='extend', nargs='*', 185 | help='Define camera controls to start with spyglass. ' 186 | 'Can be used multiple times.\n' 187 | 'Format: =') 188 | parser.add_argument('-cs', '--controls-string', default='', type=str, 189 | help='Define camera controls to start with spyglass. ' 190 | 'Input as a long string.\n' 191 | 'Format: = =') 192 | parser.add_argument('-tf', '--tuning_filter', type=str, default=None, nargs='?', const="", 193 | help='Set a tuning filter file name.') 194 | parser.add_argument('-tfd', '--tuning_filter_dir', type=str, default=None, nargs='?',const="", 195 | help='Set the directory to look for tuning filters.') 196 | parser.add_argument('--list-controls', action='store_true', help='List available camera controls and exits.') 197 | parser.add_argument('-n', '--camera_num', type=int, default=0, help='Camera number to be used (Works with --list-controls)') 198 | return parser 199 | 200 | # endregion cli args 201 | -------------------------------------------------------------------------------- /spyglass/exif.py: -------------------------------------------------------------------------------- 1 | def create_exif_header(orientation: int): 2 | if orientation <= 0: 3 | return None 4 | 5 | return b''.join([ 6 | b'\xFF\xD8', # Start of Image (SOI) marker 7 | b'\xFF\xE1', # APP1 marker 8 | b'\x00\x62', # Length of APP 1 segment (98 bytes) 9 | b'\x45\x78\x69\x66', # EXIF identifier ("Exif" in ASCII) 10 | b'\x00\x00', # Padding bytes 11 | # TIFF header (with big-endian indicator) 12 | b'\x4D\x4D', # Big endian 13 | b'\x00\x2A', # TIFF magic number 14 | b'\x00\x00\x00\x08', # Offset to first IFD (8 bytes) 15 | # Image File Directory (IFD) 16 | b'\x00\x05', # Number of entries in the IFD (5) 17 | # v-- Orientation tag (tag number = 0x0112, type = USHORT, count = 1) 18 | b'\x01\x12', b'\x00\x03', b'\x00\x00\x00\x01', 19 | b'\x00', orientation.to_bytes(1, 'big'), b'\x00\x00', # Tag data 20 | # v-- XResolution tag (tag number = 0x011A, type = UNSIGNED RATIONAL, count = 1) 21 | b'\x01\x1A', b'\x00\x05', b'\x00\x00\x00\x01', 22 | b'\x00\x00\x00\x4A', # Tag data (address) 23 | # v-- YResolution tag (tag number = 0x011B, type = UNSIGNED RATIONAL, count = 1) 24 | b'\x01\x1B', b'\x00\x05', b'\x00\x00\x00\x01', 25 | b'\x00\x00\x00\x52', # Tag data (address) 26 | # v-- ResolutionUnit tag (tag number = 0x0128, type = USHORT, count = 1) 27 | b'\x01\x28', b'\x00\x03', b'\x00\x00\x00\x01', 28 | b'\x00\x02\x00\x00', # 2 - Inch 29 | # v-- YCbCrPositioning tag (tag number = 0x0213, type = USHORT, count = 1) 30 | b'\x02\x13', b'\x00\x03', b'\x00\x00\x00\x01', 31 | b'\x00\x01\x00\x00', # center of pixel array 32 | b'\x00\x00\x00\x00', # Offset to next IFD 0 33 | b'\x00\x00\x00\x48\x00\x00\x00\x01', # XResolution value 34 | b'\x00\x00\x00\x48\x00\x00\x00\x01' # YResolution value 35 | ]) 36 | 37 | 38 | option_to_exif_orientation = { 39 | 'h': 1, 40 | 'mh': 2, 41 | 'r180': 3, 42 | 'mv': 4, 43 | 'mhr270': 5, 44 | 'r90': 6, 45 | 'mhr90': 7, 46 | 'r270': 8 47 | } 48 | -------------------------------------------------------------------------------- /spyglass/server/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mryel00/spyglass/7144d019b210796f575ccf3c8da8d4380087b5da/spyglass/server/__init__.py -------------------------------------------------------------------------------- /spyglass/server/controls.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from spyglass.url_parsing import get_url_params 3 | from spyglass.camera_options import parse_dictionary_to_html_page, process_controls 4 | 5 | # Used for type hinting 6 | from typing import TYPE_CHECKING 7 | if TYPE_CHECKING: 8 | from spyglass.server.http_server import StreamingHandler 9 | 10 | def do_GET(handler: 'StreamingHandler'): 11 | parsed_controls = get_url_params(handler.path) 12 | processed_controls = process_controls(handler.picam2, parsed_controls) 13 | handler.picam2.set_controls(processed_controls) 14 | content = parse_dictionary_to_html_page(handler.picam2, parsed_controls, processed_controls).encode('utf-8') 15 | handler.send_response(HTTPStatus.OK) 16 | handler.send_header('Content-Type', 'text/html') 17 | handler.send_header('Content-Length', len(content)) 18 | handler.end_headers() 19 | handler.wfile.write(content) 20 | -------------------------------------------------------------------------------- /spyglass/server/http_server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import socketserver 4 | import asyncio 5 | import socketserver 6 | 7 | from http import server, HTTPStatus 8 | 9 | from spyglass import WEBRTC_ENABLED 10 | from spyglass.server import jpeg, webrtc_whep, controls 11 | from spyglass.url_parsing import check_urls_match 12 | 13 | class StreamingServer(socketserver.ThreadingMixIn, server.HTTPServer): 14 | allow_reuse_address = True 15 | daemon_threads = True 16 | 17 | class StreamingHandler(server.BaseHTTPRequestHandler): 18 | loop = asyncio.new_event_loop() 19 | def __init__(self, *args, **kwargs): 20 | super().__init__(*args, **kwargs) 21 | 22 | def do_GET(self): 23 | if self.check_url(self.stream_url): 24 | jpeg.start_streaming(self) 25 | elif self.check_url(self.snapshot_url): 26 | jpeg.send_snapshot(self) 27 | elif self.check_url('/controls'): 28 | controls.do_GET(self) 29 | elif self.check_webrtc(): 30 | pass 31 | else: 32 | self.send_error(HTTPStatus.NOT_FOUND) 33 | 34 | def do_OPTIONS(self): 35 | if self.check_webrtc(): 36 | webrtc_whep.do_OPTIONS(self, self.webrtc_url) 37 | else: 38 | self.send_error(HTTPStatus.NOT_FOUND) 39 | 40 | def do_POST(self): 41 | if self.check_webrtc(): 42 | self.run_async_request(webrtc_whep.do_POST_async) 43 | else: 44 | self.send_error(HTTPStatus.NOT_FOUND) 45 | 46 | def do_PATCH(self): 47 | if self.check_webrtc(): 48 | self.run_async_request(webrtc_whep.do_PATCH_async) 49 | else: 50 | self.send_error(HTTPStatus.NOT_FOUND) 51 | 52 | def check_url(self, url, match_full_path=True): 53 | return check_urls_match(url, self.path, match_full_path) 54 | 55 | def check_webrtc(self): 56 | return WEBRTC_ENABLED and self.check_url(self.webrtc_url, match_full_path=False) 57 | 58 | def run_async_request(self, method): 59 | asyncio.run_coroutine_threadsafe(method(self), StreamingHandler.loop).result() 60 | -------------------------------------------------------------------------------- /spyglass/server/jpeg.py: -------------------------------------------------------------------------------- 1 | from spyglass import logger 2 | from http import HTTPStatus 3 | 4 | # Used for type hinting 5 | from typing import TYPE_CHECKING 6 | if TYPE_CHECKING: 7 | from spyglass.server.http_server import StreamingHandler 8 | 9 | def start_streaming(handler: 'StreamingHandler'): 10 | try: 11 | send_default_headers(handler) 12 | handler.send_header('Content-Type', 'multipart/x-mixed-replace; boundary=FRAME') 13 | handler.end_headers() 14 | while True: 15 | frame = handler.get_frame() 16 | handler.wfile.write(b'--FRAME\r\n') 17 | if handler.exif_header is None: 18 | send_jpeg_content_headers(handler, frame) 19 | handler.wfile.write(frame) 20 | handler.wfile.write(b'\r\n') 21 | else: 22 | send_jpeg_content_headers(handler, frame, len(handler.exif_header) - 2) 23 | handler.wfile.write(handler.exif_header) 24 | handler.wfile.write(frame[2:]) 25 | handler.wfile.write(b'\r\n') 26 | except Exception as e: 27 | logger.warning('Removed streaming client %s: %s', handler.client_address, str(e)) 28 | 29 | def send_snapshot(handler: 'StreamingHandler'): 30 | try: 31 | send_default_headers(handler) 32 | frame = handler.get_frame() 33 | if handler.exif_header is None: 34 | send_jpeg_content_headers(handler, frame) 35 | handler.wfile.write(frame) 36 | else: 37 | send_jpeg_content_headers(handler, frame, len(handler.exif_header) - 2) 38 | handler.wfile.write(handler.exif_header) 39 | handler.wfile.write(frame[2:]) 40 | except Exception as e: 41 | logger.warning( 42 | 'Removed client %s: %s', 43 | handler.client_address, str(e)) 44 | 45 | def send_default_headers(handler: 'StreamingHandler'): 46 | handler.send_response(HTTPStatus.OK) 47 | handler.send_header('Age', 0) 48 | handler.send_header('Cache-Control', 'no-cache, private') 49 | handler.send_header('Pragma', 'no-cache') 50 | 51 | def send_jpeg_content_headers(handler: 'StreamingHandler', frame, extra_len=0): 52 | handler.send_header('Content-Type', 'image/jpeg') 53 | handler.send_header('Content-Length', str(len(frame) + extra_len)) 54 | handler.end_headers() 55 | -------------------------------------------------------------------------------- /spyglass/server/webrtc_whep.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import asyncio 3 | 4 | from spyglass.url_parsing import check_urls_match 5 | from http import HTTPStatus 6 | 7 | # Used for type hinting 8 | from typing import TYPE_CHECKING 9 | if TYPE_CHECKING: 10 | from spyglass.server.http_server import StreamingHandler 11 | 12 | from spyglass import WEBRTC_ENABLED 13 | if WEBRTC_ENABLED: 14 | from aiortc import RTCSessionDescription, RTCPeerConnection, sdp 15 | from aiortc.rtcrtpsender import RTCRtpSender 16 | from aiortc.contrib.media import MediaRelay 17 | pcs: dict[uuid.UUID, RTCPeerConnection] = {} 18 | max_connections = 20 19 | media_relay = MediaRelay() 20 | 21 | def send_default_headers(response_code: int, handler: 'StreamingHandler'): 22 | handler.send_response(response_code) 23 | handler.send_header('Access-Control-Allow-Origin', '*') 24 | handler.send_header('Access-Control-Allow-Credentials', False) 25 | 26 | def do_OPTIONS(handler: 'StreamingHandler', webrtc_url='/webrtc'): 27 | # Adapted from MediaMTX http_server.go 28 | # https://github.com/bluenviron/mediamtx/blob/main/internal/servers/webrtc/http_server.go#L173-L189 29 | def response_headers(): 30 | send_default_headers(HTTPStatus.NO_CONTENT, handler) 31 | handler.send_header('Access-Control-Allow-Methods', 'OPTIONS, GET, POST, PATCH, DELETE') 32 | handler.send_header('Access-Control-Allow-Headers', 'Authorization, Content-Type, If-Match') 33 | 34 | if handler.headers.get("Access-Control-Request-Method") != None: 35 | response_headers() 36 | handler.end_headers() 37 | elif check_urls_match(f'{webrtc_url}/whip', handler.path) \ 38 | or check_urls_match(f'{webrtc_url}/whep', handler.path): 39 | response_headers() 40 | handler.send_header('Access-Control-Expose-Headers', 'Link') 41 | handler.headers['Link'] = get_ICE_servers() 42 | handler.end_headers() 43 | 44 | async def do_POST_async(handler: 'StreamingHandler'): 45 | # Adapted from MediaMTX http_server.go 46 | # https://github.com/bluenviron/mediamtx/blob/main/internal/servers/webrtc/http_server.go#L191-L246 47 | if handler.headers.get("Content-Type") != "application/sdp": 48 | handler.send_error(HTTPStatus.BAD_REQUEST) 49 | return 50 | 51 | # Limit simultanous clients to save resources 52 | if len(pcs) >= max_connections: 53 | handler.send_error(HTTPStatus.TOO_MANY_REQUESTS, message="Too many clients connected") 54 | return 55 | 56 | content_length = int(handler.headers['Content-Length']) 57 | offer_text = handler.rfile.read(content_length).decode('utf-8') 58 | offer = RTCSessionDescription(sdp=offer_text, type='offer') 59 | 60 | pc = RTCPeerConnection() 61 | secret = uuid.uuid4() 62 | @pc.on('connectionstatechange') 63 | async def on_connectionstatechange(): 64 | print(f'Connection state {pc.connectionState}') 65 | if pc.connectionState == 'failed': 66 | await pc.close() 67 | elif pc.connectionState == 'closed': 68 | pcs.pop(str(secret)) 69 | print(f'{len(pcs)} connections still open.') 70 | pcs[str(secret)] = pc 71 | track = media_relay.subscribe(handler.media_track) 72 | sender = pc.addTrack(track) 73 | codecs = RTCRtpSender.getCapabilities('video').codecs 74 | transceiver = next(t for t in pc.getTransceivers() if t.sender == sender) 75 | transceiver.setCodecPreferences( 76 | [codec for codec in codecs if codec.mimeType == 'video/H264'] 77 | ) 78 | 79 | await pc.setRemoteDescription(offer) 80 | answer = await pc.createAnswer() 81 | await pc.setLocalDescription(answer) 82 | 83 | while pc.iceGatheringState != "complete": 84 | await asyncio.sleep(1) 85 | 86 | send_default_headers(HTTPStatus.CREATED, handler) 87 | 88 | handler.send_header("Content-Type", "application/sdp") 89 | handler.send_header("ETag", "*") 90 | 91 | handler.send_header("ID", secret) 92 | handler.send_header("Access-Control-Expose-Headers", "ETag, ID, Accept-Patch, Link, Location") 93 | handler.send_header("Accept-Patch", "application/trickle-ice-sdpfrag") 94 | handler.headers['Link'] = get_ICE_servers() 95 | handler.send_header("Location", f'/whep/{secret}') 96 | handler.send_header('Content-Length', len(pc.localDescription.sdp)) 97 | handler.end_headers() 98 | handler.wfile.write(bytes(pc.localDescription.sdp, 'utf-8')) 99 | 100 | async def do_PATCH_async(streaming_handler: 'StreamingHandler'): 101 | # Adapted from MediaMTX http_server.go 102 | # https://github.com/bluenviron/mediamtx/blob/main/internal/servers/webrtc/http_server.go#L248-L287 103 | if len(streaming_handler.path.split('/')) < 3 \ 104 | or streaming_handler.headers.get('Content-Type') != 'application/trickle-ice-sdpfrag': 105 | send_default_headers(HTTPStatus.BAD, streaming_handler) 106 | streaming_handler.end_headers() 107 | return 108 | content_length = int(streaming_handler.headers['Content-Length']) 109 | sdp_str = streaming_handler.rfile.read(content_length).decode('utf-8') 110 | candidates = parse_ice_candidates(sdp_str) 111 | secret = streaming_handler.path.split('/')[-1] 112 | pc = pcs[secret] 113 | for candidate in candidates: 114 | await pc.addIceCandidate(candidate) 115 | 116 | send_default_headers(HTTPStatus.NO_CONTENT, streaming_handler) 117 | streaming_handler.end_headers() 118 | 119 | def get_ICE_servers(): 120 | return None 121 | 122 | def parse_ice_candidates(sdp_message): 123 | sdp_message = sdp_message.replace('\\r\\n', '\r\n') 124 | 125 | lines = sdp_message.splitlines() 126 | 127 | candidates = [] 128 | cand_str = 'a=candidate:' 129 | mid_str = 'a=mid:' 130 | mid = '' 131 | for line in lines: 132 | if line.startswith(mid_str): 133 | mid = line[len(mid_str):] 134 | elif line.startswith(cand_str): 135 | candidate_str = line[len(cand_str):] 136 | candidate = sdp.candidate_from_sdp(candidate_str) 137 | candidate.sdpMid = mid 138 | candidates.append(candidate) 139 | return candidates 140 | 141 | 142 | if WEBRTC_ENABLED: 143 | import av 144 | from aiortc import MediaStreamTrack 145 | else: 146 | class MediaStreamTrack(): 147 | pass 148 | from collections import deque 149 | from fractions import Fraction 150 | from picamera2.outputs import Output 151 | 152 | class PicameraStreamTrack(MediaStreamTrack, Output): 153 | kind = "video" 154 | def __init__(self): 155 | super().__init__() 156 | self.img_queue = deque(maxlen=60) 157 | from spyglass.server.http_server import StreamingHandler 158 | asyncio.set_event_loop(StreamingHandler.loop) 159 | self.condition = asyncio.Condition() 160 | 161 | def outputframe(self, frame, keyframe=True, timestamp=None, packet=None, audio=False): 162 | from spyglass.server.http_server import StreamingHandler 163 | asyncio.run_coroutine_threadsafe(self.put_frame(frame, keyframe, timestamp), StreamingHandler.loop) 164 | 165 | async def put_frame(self, frame, keyframe=True, timestamp=None): 166 | async with self.condition: 167 | self.img_queue.append((frame, keyframe, timestamp)) 168 | self.condition.notify_all() 169 | 170 | async def recv(self): 171 | async with self.condition: 172 | def not_empty(): 173 | return len(self.img_queue) > 0 174 | await self.condition.wait_for(not_empty) 175 | img, keyframe, pts = self.img_queue.popleft() 176 | packet = av.packet.Packet(img) 177 | packet.pts = pts 178 | packet.time_base = Fraction(1,1000000) 179 | packet.is_keyframe = keyframe 180 | return packet 181 | -------------------------------------------------------------------------------- /spyglass/url_parsing.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urlparse, parse_qsl 2 | 3 | def check_paths_match(expected_url, incoming_url, match_full_path=True): 4 | # Assign paths from URL into list 5 | exp_paths = urlparse(expected_url.strip("/")).path.split("/") 6 | inc_paths = urlparse(incoming_url.strip("/")).path.split("/") 7 | 8 | # Drop ip/hostname if present in path 9 | if '.' in exp_paths[0]: exp_paths.pop(0) 10 | if '.' in inc_paths[0]: inc_paths.pop(0) 11 | 12 | # Filter out empty strings 13 | # This allows e.g. /stream/?action=stream for /stream?action=stream 14 | exp_paths = list(filter(None, exp_paths)) 15 | inc_paths = list(filter(None, inc_paths)) 16 | 17 | # Determine if match 18 | if match_full_path and len(exp_paths)==len(inc_paths): 19 | return all([exp == inc for exp, inc in zip(exp_paths, inc_paths)]) 20 | elif not match_full_path and len(exp_paths)<=len(inc_paths): 21 | return all([exp == inc for exp, inc in zip(exp_paths, inc_paths)]) 22 | 23 | return False 24 | 25 | def get_url_params(url): 26 | # Get URL params 27 | params = parse_qsl(urlparse(url).query) 28 | 29 | return params 30 | 31 | def check_params_match(expected_url, incoming_url): 32 | # Check URL params 33 | exp_params = get_url_params(expected_url) 34 | inc_params = get_url_params(incoming_url) 35 | 36 | # Create list of matching params 37 | matching_params = set(exp_params) & set(inc_params) 38 | 39 | # Update list order for expected params 40 | exp_params = set(exp_params) 41 | 42 | return matching_params==exp_params 43 | 44 | def check_urls_match(expected_url, incoming_url, match_full_path=True): 45 | # Check URL paths 46 | paths_match = check_paths_match(expected_url, incoming_url, match_full_path) 47 | 48 | # Check URL params 49 | params_match = check_params_match(expected_url, incoming_url) 50 | 51 | return paths_match and params_match 52 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import pytest 3 | from unittest.mock import MagicMock, ANY, patch 4 | 5 | AF_SPEED_ENUM_NORMAL = 1 6 | AF_SPEED_ENUM_FAST = 2 7 | AF_MODE_ENUM_CONTINUOUS = 2 8 | AF_MODE_ENUM_MANUAL = 3 9 | 10 | DEFAULT_HEIGHT = 480 11 | DEFAULT_WIDTH = 640 12 | DEFAULT_FLIP_VERTICALLY = False 13 | DEFAULT_FLIP_HORIZONTALLY = False 14 | DEFAULT_UPSIDE_DOWN = False 15 | DEFAULT_LENS_POSITION = 0.0 16 | DEFAULT_FPS = 15 17 | DEFAULT_AF_SPEED = AF_SPEED_ENUM_NORMAL 18 | DEFAULT_AUTOFOCUS_MODE = AF_MODE_ENUM_CONTINUOUS 19 | DEFAULT_CONTROLS = [] 20 | DEFAULT_TUNING_FILTER = None 21 | DEFAULT_TUNING_FILTER_DIR = None 22 | DEFAULT_CAMERA_NUM = 0 23 | 24 | 25 | @pytest.fixture(autouse=True) 26 | def mock_libraries(mocker): 27 | mock_libcamera = MagicMock() 28 | mock_picamera2 = MagicMock() 29 | mock_picamera2_encoders = MagicMock() 30 | mock_picamera2_outputs = MagicMock() 31 | mock_picamera2_outputs.Output = MagicMock 32 | mocker.patch.dict('sys.modules', { 33 | 'libcamera': mock_libcamera, 34 | 'picamera2': mock_picamera2, 35 | 'picamera2.encoders': mock_picamera2_encoders, 36 | 'picamera2.outputs': mock_picamera2_outputs, 37 | }) 38 | mocker.patch('libcamera.controls.AfModeEnum.Manual', AF_MODE_ENUM_MANUAL) 39 | mocker.patch('libcamera.controls.AfModeEnum.Continuous', AF_MODE_ENUM_CONTINUOUS) 40 | mocker.patch('libcamera.controls.AfSpeedEnum.Normal', AF_SPEED_ENUM_NORMAL) 41 | mocker.patch('libcamera.controls.AfSpeedEnum.Fast', AF_SPEED_ENUM_FAST) 42 | 43 | 44 | def test_parse_bindaddress(): 45 | from spyglass import cli 46 | args = cli.get_args(['-b', '1.2.3.4']) 47 | assert args.bindaddress == '1.2.3.4' 48 | 49 | 50 | def test_parse_port(): 51 | from spyglass import cli 52 | args = cli.get_args(['-p', '123']) 53 | assert args.port == 123 54 | 55 | 56 | def test_parse_resolution(): 57 | from spyglass import cli 58 | args = cli.get_args(['-r', '100x200']) 59 | assert args.resolution == '100x200' 60 | 61 | 62 | def test_split_resolution(): 63 | from spyglass import cli 64 | (width, height) = cli.split_resolution('100x200') 65 | assert width == 100 66 | assert height == 200 67 | 68 | 69 | def test_parse_tuning_filter(): 70 | from spyglass import cli 71 | args = cli.get_args(['-tf', 'filter']) 72 | assert args.tuning_filter == 'filter' 73 | 74 | 75 | def test_parse_tuning_filter_dir(): 76 | from spyglass import cli 77 | args = cli.get_args(['-tfd', 'dir']) 78 | assert args.tuning_filter_dir == 'dir' 79 | 80 | 81 | @patch("spyglass.camera.init_camera") 82 | def test_init_camera_with_defaults(mock_spyglass_camera,): 83 | from spyglass import cli 84 | cli.main(args=[]) 85 | mock_spyglass_camera.assert_called_once_with( 86 | DEFAULT_CAMERA_NUM, 87 | DEFAULT_TUNING_FILTER, 88 | DEFAULT_TUNING_FILTER_DIR 89 | ) 90 | 91 | @patch("spyglass.camera.init_camera") 92 | def test_configure_with_defaults(mock_init_camera): 93 | from spyglass import cli 94 | cli.main(args=[]) 95 | cam_instance = mock_init_camera.return_value 96 | cam_instance.configure.assert_called_once_with( 97 | DEFAULT_WIDTH, 98 | DEFAULT_HEIGHT, 99 | DEFAULT_FPS, 100 | DEFAULT_AUTOFOCUS_MODE, 101 | DEFAULT_LENS_POSITION, 102 | DEFAULT_AF_SPEED, 103 | DEFAULT_CONTROLS, 104 | DEFAULT_UPSIDE_DOWN, 105 | DEFAULT_FLIP_HORIZONTALLY, 106 | DEFAULT_FLIP_VERTICALLY 107 | ) 108 | 109 | @patch("spyglass.camera.init_camera") 110 | def test_configure_with_parameters(mock_init_camera): 111 | from spyglass import cli 112 | cli.main(args=[ 113 | '-n', '1', 114 | '-tf', 'test', 115 | '-tfd', 'test-dir', 116 | '-r', '200x100', 117 | '-f', '20', 118 | '-af', 'manual', 119 | '-l', '1.0', 120 | '-s', 'normal', 121 | '-ud', '-fh', '-fv', 122 | '-c', 'brightness=-0.4', 123 | '-c', 'awbenable=false', 124 | ]) 125 | cam_instance = mock_init_camera.return_value 126 | cam_instance.configure.assert_called_once_with( 127 | 200, 128 | 100, 129 | 20, 130 | AF_MODE_ENUM_MANUAL, 131 | 1.0, 132 | AF_SPEED_ENUM_NORMAL, 133 | [['brightness', '-0.4'],['awbenable', 'false']], 134 | True, 135 | True, 136 | True 137 | ) 138 | 139 | 140 | def test_raise_error_when_width_greater_than_maximum(): 141 | from spyglass import cli 142 | with pytest.raises(argparse.ArgumentTypeError): 143 | cli.main(args=[ 144 | '-r', '1921x1920' 145 | ]) 146 | 147 | 148 | def test_raise_error_when_height_greater_than_maximum(): 149 | from spyglass import cli 150 | with pytest.raises(argparse.ArgumentTypeError): 151 | cli.main(args=[ 152 | '-r', '1920x1921' 153 | ]) 154 | 155 | 156 | @patch("spyglass.camera.init_camera") 157 | def test_configure_camera_af_continuous_speed_fast(mock_init_camera): 158 | from spyglass import cli 159 | cli.main(args=[ 160 | '-af', 'continuous', 161 | '-s', 'fast' 162 | ]) 163 | cam_instance = mock_init_camera.return_value 164 | cam_instance.configure.assert_called_once_with( 165 | DEFAULT_WIDTH, 166 | DEFAULT_HEIGHT, 167 | DEFAULT_FPS, 168 | AF_MODE_ENUM_CONTINUOUS, 169 | DEFAULT_LENS_POSITION, 170 | AF_SPEED_ENUM_FAST, 171 | DEFAULT_CONTROLS, 172 | DEFAULT_UPSIDE_DOWN, 173 | DEFAULT_FLIP_HORIZONTALLY, 174 | DEFAULT_FLIP_VERTICALLY 175 | ) 176 | 177 | 178 | @patch("spyglass.camera.init_camera") 179 | def test_run_server_with_configuration_from_arguments(mock_init_camera): 180 | from spyglass import cli 181 | cli.main(args=[ 182 | '-b', '1.2.3.4', 183 | '-p', '1234', 184 | '-st', 'streaming-url', 185 | '-sn', 'snapshot-url', 186 | '-w', 'webrtc-url', 187 | '-or', 'h', 188 | '-sw' 189 | ]) 190 | cam_instance = mock_init_camera.return_value 191 | cam_instance.start_and_run_server.assert_called_once_with( 192 | '1.2.3.4', 193 | 1234, 194 | 'streaming-url', 195 | 'snapshot-url', 196 | 'webrtc-url', 197 | 1, 198 | True 199 | ) 200 | 201 | 202 | @patch("spyglass.camera.init_camera") 203 | @pytest.mark.parametrize("input_value, expected_output", [ 204 | ('h', 1), 205 | ('mh', 2), 206 | ('r180', 3), 207 | ('mv', 4), 208 | ('mhr270', 5), 209 | ('r90', 6), 210 | ('mhr90', 7), 211 | ('r270', 8), 212 | ]) 213 | def test_run_server_with_orientation(mock_init_camera, input_value, expected_output): 214 | from spyglass import cli 215 | cli.main(args=[ 216 | '-b', '1.2.3.4', 217 | '-p', '1234', 218 | '-st', 'streaming-url', 219 | '-sn', 'snapshot-url', 220 | '-w', 'webrtc-url', 221 | '-or', input_value, 222 | '-sw' 223 | ]) 224 | cam_instance = mock_init_camera.return_value 225 | cam_instance.start_and_run_server.assert_called_once_with( 226 | '1.2.3.4', 227 | 1234, 228 | 'streaming-url', 229 | 'snapshot-url', 230 | 'webrtc-url', 231 | expected_output, 232 | True 233 | ) 234 | -------------------------------------------------------------------------------- /tests/test_exif.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.parametrize("input_value, expected_output", [ 5 | ('h', 1), 6 | ('mh', 2), 7 | ('r180', 3), 8 | ('mv', 4), 9 | ('mhr270', 5), 10 | ('r90', 6), 11 | ('mhr90', 7), 12 | ('r270', 8), 13 | ]) 14 | def test_option_to_exif_orientation_map(input_value, expected_output): 15 | from spyglass.exif import option_to_exif_orientation 16 | orientation_value = option_to_exif_orientation[input_value] 17 | assert orientation_value == expected_output 18 | -------------------------------------------------------------------------------- /tests/test_url_parsing.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.parametrize("expected_url, incoming_url, expected_output", [ 5 | ('/?a=b', '/?a=b', True), 6 | ('/?a=b', '/?b=a', True), 7 | ('/?a=b', '/?a=b&', True), 8 | ('/?a=b', '/?b=c&d=e', True), 9 | ('/?a=b', '/?a=b&c=d', True), 10 | ('/?a=b', '/?c=d&a=b', True), 11 | ('/?a=b&c=d', '/?a=b', True), 12 | ('/?a=b&c=d', '/?a=b&c=d', True), 13 | ('/?a=b&c=d', '/?c=d&a=b', True), 14 | ('/?a=b&c=d', '/?d=e&a=b', True), 15 | 16 | ('/?a=b', '/a?a=b', False), 17 | ('/?a=b', '/a?b=a', False), 18 | ('/?a=b', '/a?b=c&d=e', False), 19 | ('/?a=b&c=d', '/a?a=b', False), 20 | ('/?a=b&c=d', '/a?a=b&c=d', False), 21 | ('/?a=b&c=d', '/a?c=d&a=b', False), 22 | 23 | ('/a', '/a', True), 24 | ('/a', '/b', False), 25 | ('/a', '/a?b=c', True), 26 | ('/a', '/a/?b=c', True), 27 | ('/a', '/b/?b=c', False), 28 | ('/a', '/?a=b', False), 29 | 30 | ('/a?a=b', '/a?a=b', True), 31 | ('/a?a=b', '/a?b=a', True), 32 | ('/a?a=b', '/a?b=c&d=e', True), 33 | ('/a?a=b&c=d', '/a?a=b', True), 34 | ('/a?a=b&c=d', '/a?a=b&c=d', True), 35 | ('/a?a=b&c=d', '/a?c=d&a=b', True), 36 | 37 | ('/a?a=b', '/b?a=b', False), 38 | ('/a?a=b', '/b?b=a', False), 39 | ('/a?a=b', '/b?b=c&d=e', False), 40 | ('/a?a=b&c=d', '/b?a=b', False), 41 | ('/a?a=b&c=d', '/b?a=b&c=d', False), 42 | ('/a?a=b&c=d', '/b?c=d&a=b', False) 43 | ]) 44 | def test_check_paths_match(expected_url, incoming_url, expected_output): 45 | from spyglass.url_parsing import check_paths_match 46 | match_value = check_paths_match(expected_url, incoming_url) 47 | assert match_value == expected_output 48 | 49 | @pytest.mark.parametrize("expected_url, incoming_url, expected_output", [ 50 | ('/?a=b', '/?a=b', True), 51 | ('/?a=b', '/?b=a', False), 52 | ('/?a=b', '/?a=b&', True), 53 | ('/?a=b', '/?b=c&d=e', False), 54 | ('/?a=b', '/?a=b&c=d', True), 55 | ('/?a=b', '/?c=d&a=b', True), 56 | ('/?a=b&c=d', '/?a=b', False), 57 | ('/?a=b&c=d', '/?a=b&c=d', True), 58 | ('/?a=b&c=d', '/?c=d&a=b', True), 59 | ('/?a=b&c=d', '/?d=e&a=b', False), 60 | 61 | ('/?a=b', '/a?a=b', True), 62 | ('/?a=b', '/a?b=a', False), 63 | ('/?a=b', '/a?b=c&d=e', False), 64 | ('/?a=b&c=d', '/a?a=b', False), 65 | ('/?a=b&c=d', '/a?a=b&c=d', True), 66 | ('/?a=b&c=d', '/a?c=d&a=b', True), 67 | 68 | ('/a', '/a', True), 69 | ('/a', '/b', True), 70 | ('/a', '/a?b=c', True), 71 | ('/a', '/a/?b=c', True), 72 | ('/a', '/b/?b=c', True), 73 | ('/a', '/?a=b', True), 74 | 75 | ('/a?a=b', '/a?a=b', True), 76 | ('/a?a=b', '/a?b=a', False), 77 | ('/a?a=b', '/a?b=c&d=e', False), 78 | ('/a?a=b&c=d', '/a?a=b', False), 79 | ('/a?a=b&c=d', '/a?a=b&c=d', True), 80 | ('/a?a=b&c=d', '/a?c=d&a=b', True), 81 | 82 | ('/a?a=b', '/b?a=b', True), 83 | ('/a?a=b', '/b?b=a', False), 84 | ('/a?a=b', '/b?b=c&d=e', False), 85 | ('/a?a=b&c=d', '/b?a=b', False), 86 | ('/a?a=b&c=d', '/b?a=b&c=d', True), 87 | ('/a?a=b&c=d', '/b?c=d&a=b', True) 88 | ]) 89 | def test_check_params_match(expected_url, incoming_url, expected_output): 90 | from spyglass.url_parsing import check_params_match 91 | match_value = check_params_match(expected_url, incoming_url) 92 | assert match_value == expected_output 93 | 94 | @pytest.mark.parametrize("expected_url, incoming_url, expected_output", [ 95 | ('/?a=b', '/?a=b', True), 96 | ('/?a=b', '/?b=a', False), 97 | ('/?a=b', '/?a=b&', True), 98 | ('/?a=b', '/?b=c&d=e', False), 99 | ('/?a=b', '/?a=b&c=d', True), 100 | ('/?a=b', '/?c=d&a=b', True), 101 | ('/?a=b&c=d', '/?a=b', False), 102 | ('/?a=b&c=d', '/?a=b&c=d', True), 103 | ('/?a=b&c=d', '/?c=d&a=b', True), 104 | ('/?a=b&c=d', '/?d=e&a=b', False), 105 | 106 | ('/?a=b', '/a?a=b', False), 107 | ('/?a=b', '/a?b=a', False), 108 | ('/?a=b', '/a?b=c&d=e', False), 109 | ('/?a=b&c=d', '/a?a=b', False), 110 | ('/?a=b&c=d', '/a?a=b&c=d', False), 111 | ('/?a=b&c=d', '/a?c=d&a=b', False), 112 | 113 | ('/a', '/a', True), 114 | ('/a', '/b', False), 115 | ('/a', '/a?b=c', True), 116 | ('/a', '/a/?b=c', True), 117 | ('/a', '/b/?b=c', False), 118 | ('/a', '/?a=b', False), 119 | 120 | ('/a?a=b', '/a?a=b', True), 121 | ('/a?a=b', '/a?b=a', False), 122 | ('/a?a=b', '/a?b=c&d=e', False), 123 | ('/a?a=b&c=d', '/a?a=b', False), 124 | ('/a?a=b&c=d', '/a?a=b&c=d', True), 125 | ('/a?a=b&c=d', '/a?c=d&a=b', True), 126 | 127 | ('/a?a=b', '/b?a=b', False), 128 | ('/a?a=b', '/b?b=a', False), 129 | ('/a?a=b', '/b?b=c&d=e', False), 130 | ('/a?a=b&c=d', '/b?a=b', False), 131 | ('/a?a=b&c=d', '/b?a=b&c=d', False), 132 | ('/a?a=b&c=d', '/b?c=d&a=b', False) 133 | ]) 134 | def test_check_urls_match(expected_url, incoming_url, expected_output): 135 | from spyglass.url_parsing import check_urls_match 136 | match_value = check_urls_match(expected_url, incoming_url) 137 | assert match_value == expected_output 138 | --------------------------------------------------------------------------------