├── spyglass ├── server │ ├── __init__.py │ ├── controls.py │ ├── http_server.py │ ├── jpeg.py │ └── webrtc_whep.py ├── __version__.py ├── __main__.py ├── __init__.py ├── camera │ ├── __init__.py │ ├── usb.py │ ├── csi.py │ └── camera.py ├── url_parsing.py ├── exif.py ├── camera_options.py └── cli.py ├── requirements.txt ├── requirements-dev.txt ├── .github ├── CODEOWNERS ├── dependabot.yml ├── PULL_REQUEST_TEMPLATE │ └── pull_request_template.md └── workflows │ ├── check-commit-message.yml │ ├── pull-request.yml │ ├── release.yml │ └── build.yml ├── requirements-test.txt ├── tox.ini ├── run.py ├── .pre-commit-config.yaml ├── resources ├── system-dependencies.json ├── spyglass.service ├── controls_style.css └── spyglass.conf ├── .editorconfig ├── docs ├── contributors.md ├── adr │ └── 001_encoder.md ├── developer-certificate-of-origin.md ├── camera-controls.md └── CONTRIBUTING.md ├── tests ├── test_exif.py ├── test_url_parsing.py └── test_cli.py ├── .gitignore ├── pyproject.toml ├── NOTICE ├── CHANGELOG.md ├── Makefile ├── scripts ├── build_deb.sh └── spyglass ├── README.md └── LICENSE /spyglass/server/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiortc~=1.13.0 2 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | commitizen 2 | pre-commit 3 | -------------------------------------------------------------------------------- /spyglass/__version__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.17.1" 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @roamingthings @mryel00 @KwadFan 2 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | pylint 2 | pytest 3 | mock 4 | pytest-mock 5 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [flake8] 2 | extend-ignore = E203 3 | exclude = .git,__pycache__,build,dist,.venv 4 | max-complexity = 10 5 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | 3 | from spyglass.cli import main 4 | 5 | if __name__ == "__main__": 6 | raise SystemExit(main()) 7 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | target-branch: "develop" 8 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /spyglass/__main__.py: -------------------------------------------------------------------------------- 1 | """Module allowing for ``python -m spyglass ...``.""" 2 | 3 | from __future__ import annotations 4 | 5 | from spyglass.cli import main 6 | 7 | if __name__ == "__main__": 8 | raise SystemExit(main()) 9 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /resources/system-dependencies.json: -------------------------------------------------------------------------------- 1 | { 2 | "debian": [ 3 | "python3-libcamera; vendor == 'raspberry-pi' and distro_version >= '11'", 4 | "python3-kms++; vendor == 'raspberry-pi' and distro_version >= '11'", 5 | "python3-picamera2; vendor == 'raspberry-pi' and distro_version >= '11'", 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /spyglass/__init__.py: -------------------------------------------------------------------------------- 1 | """init py module.""" 2 | 3 | import importlib.util 4 | import logging 5 | 6 | logging.basicConfig(level=logging.INFO) 7 | logger = logging.getLogger(__name__) 8 | 9 | if importlib.util.find_spec("aiortc"): 10 | WEBRTC_ENABLED = True 11 | else: 12 | WEBRTC_ENABLED = False 13 | 14 | 15 | def set_webrtc_enabled(enabled): 16 | global WEBRTC_ENABLED 17 | WEBRTC_ENABLED = enabled 18 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/test_exif.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.parametrize( 5 | "input_value, expected_output", 6 | [ 7 | ("h", 1), 8 | ("mh", 2), 9 | ("r180", 3), 10 | ("mv", 4), 11 | ("mhr270", 5), 12 | ("r90", 6), 13 | ("mhr90", 7), 14 | ("r270", 8), 15 | ], 16 | ) 17 | def test_option_to_exif_orientation_map(input_value, expected_output): 18 | from spyglass.exif import option_to_exif_orientation 19 | 20 | orientation_value = option_to_exif_orientation[input_value] 21 | assert orientation_value == expected_output 22 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | 8 | def init_camera(camera_num: int, tuning_filter=None, tuning_filter_dir=None) -> Camera: 9 | tuning = None 10 | 11 | if tuning_filter: 12 | params = {"tuning_file": tuning_filter} 13 | if tuning_filter_dir: 14 | params["dir"] = tuning_filter_dir 15 | tuning = Picamera2.load_tuning_file(**params) 16 | 17 | picam2 = Picamera2(camera_num, tuning=tuning) 18 | if picam2._is_rpi_camera(): 19 | cam = CSI(picam2) 20 | else: 21 | cam = USB(picam2) 22 | return cam 23 | -------------------------------------------------------------------------------- /.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@v5 13 | with: 14 | fetch-depth: 0 15 | ref: ${{ github.head_ref }} 16 | 17 | - name: Set up Python 18 | uses: actions/setup-python@v6 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 | -------------------------------------------------------------------------------- /spyglass/server/controls.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | # Used for type hinting 4 | from typing import TYPE_CHECKING 5 | 6 | from spyglass.camera_options import parse_dictionary_to_html_page, process_controls 7 | from spyglass.url_parsing import get_url_params 8 | 9 | if TYPE_CHECKING: 10 | from spyglass.server.http_server import StreamingHandler 11 | 12 | 13 | def do_GET(handler: "StreamingHandler"): 14 | parsed_controls = get_url_params(handler.path) 15 | processed_controls = process_controls(handler.picam2, parsed_controls) 16 | handler.picam2.set_controls(processed_controls) 17 | content = parse_dictionary_to_html_page( 18 | handler.picam2, parsed_controls, processed_controls 19 | ).encode("utf-8") 20 | handler.send_response(HTTPStatus.OK) 21 | handler.send_header("Content-Type", "text/html") 22 | handler.send_header("Content-Length", len(content)) 23 | handler.end_headers() 24 | handler.wfile.write(content) 25 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /spyglass/camera/usb.py: -------------------------------------------------------------------------------- 1 | from spyglass import camera 2 | from spyglass.server.http_server import StreamingHandler 3 | 4 | 5 | class USB(camera.Camera): 6 | def start_and_run_server( 7 | self, 8 | bind_address, 9 | port, 10 | stream_url="/stream", 11 | snapshot_url="/snapshot", 12 | webrtc_url="/webrtc", 13 | orientation_exif=0, 14 | use_sw_jpg_encoding=False, 15 | ): 16 | def get_frame(inner_self): 17 | # TODO: Cuts framerate in 1/n with n streams open, add some kind of buffer 18 | return self.picam2.capture_buffer() 19 | 20 | self.picam2.start() 21 | 22 | self._run_server( 23 | bind_address, 24 | port, 25 | StreamingHandler, 26 | get_frame, 27 | stream_url=stream_url, 28 | snapshot_url=snapshot_url, 29 | webrtc_url=webrtc_url, 30 | orientation_exif=orientation_exif, 31 | ) 32 | 33 | def stop(self): 34 | self.picam2.stop() 35 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "setuptools.build_meta" 3 | requires = ["setuptools", "wheel"] 4 | 5 | [project] 6 | name = "spyglass" 7 | version = "0.17.1" 8 | description = "A simple mjpeg server for Picamera2" 9 | authors = [ 10 | { name="mryel00" }, 11 | { name="roamingthings" } 12 | ] 13 | license = "GPL-3.0-only" 14 | license-files = ["LICENSE", "NOTICE"] 15 | requires-python = ">=3.9" 16 | dependencies = [ 17 | "aiortc~=1.13.0", 18 | ] 19 | 20 | [project.scripts] 21 | spyglass = "spyglass.cli:main" 22 | 23 | [tool.setuptools.packages.find] 24 | where = ["."] 25 | exclude = ["tests", "tests.*"] 26 | 27 | [tool.pytest.ini_options] 28 | addopts = [ 29 | "--import-mode=importlib", 30 | ] 31 | pythonpath = [ 32 | "." 33 | ] 34 | 35 | [tool.commitizen] 36 | name = "cz_customize" 37 | version = "0.17.1" 38 | tag_format = "v$version" 39 | version_files = [ 40 | "spyglass/__version__.py", 41 | "setup.cfg:version", 42 | "pyproject.toml:version", 43 | "README.md:Current version" 44 | ] 45 | 46 | [tool.commitizen.customize] 47 | bump_map = {"break" = "MAJOR", ".*!" = "MAJOR", "feat" = "MINOR", "fix" = "PATCH", "chore\\(deps\\)" = "PATCH"} 48 | commit_parser = "^(?Pbreak|feat|fix|chore\\(deps\\))(!)?:\\s(?P.*)?" 49 | change_type_map = {"feat" = "Feat", "fix" = "Fix", "chore(deps)" = "Dependency Update"} 50 | -------------------------------------------------------------------------------- /.github/workflows/pull-request.yml: -------------------------------------------------------------------------------- 1 | name: Pull Request 2 | 3 | on: 4 | pull_request: 5 | branches: [ develop ] 6 | 7 | jobs: 8 | check: 9 | name: Check syntax and Test 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v5 15 | 16 | - name: Set up Python 17 | uses: actions/setup-python@v6 18 | with: 19 | python-version: '3.12' 20 | cache: 'pip' 21 | 22 | - name: Install dependencies 23 | run: | 24 | pip install --upgrade pip 25 | pip install flake8 26 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 27 | if [ -f requirements-test.txt ]; then pip install -r requirements-test.txt; fi 28 | 29 | - name: Lint with flake8 30 | run: | 31 | # stop the build if there are Python syntax errors or undefined names 32 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 33 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 34 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 35 | 36 | - name: Test with pytest 37 | run: pytest 38 | 39 | build: 40 | name: Build wheel and .deb 41 | needs: check 42 | uses: ./.github/workflows/build.yml 43 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /spyglass/camera/csi.py: -------------------------------------------------------------------------------- 1 | import io 2 | from threading import Condition 3 | 4 | from picamera2.outputs import FileOutput 5 | 6 | from spyglass import WEBRTC_ENABLED, camera 7 | from spyglass.server.http_server import StreamingHandler 8 | 9 | 10 | class CSI(camera.Camera): 11 | def start_and_run_server( 12 | self, 13 | bind_address, 14 | port, 15 | stream_url="/stream", 16 | snapshot_url="/snapshot", 17 | webrtc_url="/webrtc", 18 | orientation_exif=0, 19 | use_sw_jpg_encoding=False, 20 | ): 21 | if use_sw_jpg_encoding: 22 | from picamera2.encoders import JpegEncoder as MJPEGEncoder 23 | else: 24 | from picamera2.encoders import MJPEGEncoder 25 | 26 | class StreamingOutput(io.BufferedIOBase): 27 | def __init__(self): 28 | self.frame = None 29 | self.condition = Condition() 30 | 31 | def write(self, buf): 32 | with self.condition: 33 | self.frame = buf 34 | self.condition.notify_all() 35 | 36 | output = StreamingOutput() 37 | 38 | def get_frame(inner_self): 39 | with output.condition: 40 | output.condition.wait() 41 | return output.frame 42 | 43 | self.picam2.start_encoder(MJPEGEncoder(), FileOutput(output)) 44 | if WEBRTC_ENABLED: 45 | from picamera2.encoders import H264Encoder 46 | 47 | self.picam2.start_encoder(H264Encoder(), self.media_track) 48 | self.picam2.start() 49 | 50 | self._run_server( 51 | bind_address, 52 | port, 53 | StreamingHandler, 54 | get_frame, 55 | stream_url=stream_url, 56 | snapshot_url=snapshot_url, 57 | webrtc_url=webrtc_url, 58 | orientation_exif=orientation_exif, 59 | ) 60 | 61 | def stop(self): 62 | self.picam2.stop_recording() 63 | -------------------------------------------------------------------------------- /spyglass/url_parsing.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import parse_qsl, urlparse 2 | 3 | 4 | def check_paths_match(expected_url, incoming_url, match_full_path=True): 5 | # Assign paths from URL into list 6 | exp_paths = urlparse(expected_url.strip("/")).path.split("/") 7 | inc_paths = urlparse(incoming_url.strip("/")).path.split("/") 8 | 9 | # Drop ip/hostname if present in path 10 | if "." in exp_paths[0]: 11 | exp_paths.pop(0) 12 | if "." in inc_paths[0]: 13 | inc_paths.pop(0) 14 | 15 | # Filter out empty strings 16 | # This allows e.g. /stream/?action=stream for /stream?action=stream 17 | exp_paths = list(filter(None, exp_paths)) 18 | inc_paths = list(filter(None, inc_paths)) 19 | 20 | # Determine if match 21 | if match_full_path and len(exp_paths) == len(inc_paths): 22 | return all([exp == inc for exp, inc in zip(exp_paths, inc_paths)]) 23 | elif not match_full_path and len(exp_paths) <= len(inc_paths): 24 | return all([exp == inc for exp, inc in zip(exp_paths, inc_paths)]) 25 | 26 | return False 27 | 28 | 29 | def get_url_params(url): 30 | # Get URL params 31 | params = parse_qsl(urlparse(url).query) 32 | 33 | return params 34 | 35 | 36 | def check_params_match(expected_url, incoming_url): 37 | # Check URL params 38 | exp_params = get_url_params(expected_url) 39 | inc_params = get_url_params(incoming_url) 40 | 41 | # Create list of matching params 42 | matching_params = set(exp_params) & set(inc_params) 43 | 44 | # Update list order for expected params 45 | exp_params = set(exp_params) 46 | 47 | return matching_params == exp_params 48 | 49 | 50 | def check_urls_match(expected_url, incoming_url, match_full_path=True): 51 | # Check URL paths 52 | paths_match = check_paths_match(expected_url, incoming_url, match_full_path) 53 | 54 | # Check URL params 55 | params_match = check_params_match(expected_url, incoming_url) 56 | 57 | return paths_match and params_match 58 | -------------------------------------------------------------------------------- /spyglass/server/http_server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import asyncio 4 | import socketserver 5 | from http import HTTPStatus, server 6 | 7 | from spyglass import WEBRTC_ENABLED 8 | from spyglass.server import controls, jpeg, webrtc_whep 9 | from spyglass.url_parsing import check_urls_match 10 | 11 | 12 | class StreamingServer(socketserver.ThreadingMixIn, server.HTTPServer): 13 | allow_reuse_address = True 14 | daemon_threads = True 15 | 16 | 17 | class StreamingHandler(server.BaseHTTPRequestHandler): 18 | loop = asyncio.new_event_loop() 19 | 20 | def __init__(self, *args, **kwargs): 21 | super().__init__(*args, **kwargs) 22 | 23 | def do_GET(self): 24 | if self.check_url(self.stream_url): 25 | jpeg.start_streaming(self) 26 | elif self.check_url(self.snapshot_url): 27 | jpeg.send_snapshot(self) 28 | elif self.check_url("/controls"): 29 | controls.do_GET(self) 30 | elif self.check_webrtc(): 31 | pass 32 | else: 33 | self.send_error(HTTPStatus.NOT_FOUND) 34 | 35 | def do_OPTIONS(self): 36 | if self.check_webrtc(): 37 | webrtc_whep.do_OPTIONS(self, self.webrtc_url) 38 | else: 39 | self.send_error(HTTPStatus.NOT_FOUND) 40 | 41 | def do_POST(self): 42 | if self.check_webrtc(): 43 | self.run_async_request(webrtc_whep.do_POST_async) 44 | else: 45 | self.send_error(HTTPStatus.NOT_FOUND) 46 | 47 | def do_PATCH(self): 48 | if self.check_webrtc(): 49 | self.run_async_request(webrtc_whep.do_PATCH_async) 50 | else: 51 | self.send_error(HTTPStatus.NOT_FOUND) 52 | 53 | def check_url(self, url, match_full_path=True): 54 | return check_urls_match(url, self.path, match_full_path) 55 | 56 | def check_webrtc(self): 57 | return WEBRTC_ENABLED and self.check_url(self.webrtc_url, match_full_path=False) 58 | 59 | def run_async_request(self, method): 60 | asyncio.run_coroutine_threadsafe(method(self), StreamingHandler.loop).result() 61 | -------------------------------------------------------------------------------- /spyglass/server/jpeg.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | # Used for type hinting 4 | from typing import TYPE_CHECKING 5 | 6 | from spyglass import logger 7 | 8 | if TYPE_CHECKING: 9 | from spyglass.server.http_server import StreamingHandler 10 | 11 | 12 | def start_streaming(handler: "StreamingHandler"): 13 | try: 14 | send_default_headers(handler) 15 | handler.send_header("Content-Type", "multipart/x-mixed-replace; boundary=FRAME") 16 | handler.end_headers() 17 | while True: 18 | frame = handler.get_frame() 19 | handler.wfile.write(b"--FRAME\r\n") 20 | if handler.exif_header is None: 21 | send_jpeg_content_headers(handler, frame) 22 | handler.wfile.write(frame) 23 | handler.wfile.write(b"\r\n") 24 | else: 25 | send_jpeg_content_headers(handler, frame, len(handler.exif_header) - 2) 26 | handler.wfile.write(handler.exif_header) 27 | handler.wfile.write(frame[2:]) 28 | handler.wfile.write(b"\r\n") 29 | except Exception as e: 30 | logger.warning( 31 | "Removed streaming client %s: %s", handler.client_address, str(e) 32 | ) 33 | 34 | 35 | def send_snapshot(handler: "StreamingHandler"): 36 | try: 37 | send_default_headers(handler) 38 | frame = handler.get_frame() 39 | if handler.exif_header is None: 40 | send_jpeg_content_headers(handler, frame) 41 | handler.wfile.write(frame) 42 | else: 43 | send_jpeg_content_headers(handler, frame, len(handler.exif_header) - 2) 44 | handler.wfile.write(handler.exif_header) 45 | handler.wfile.write(frame[2:]) 46 | except Exception as e: 47 | logger.warning("Removed client %s: %s", handler.client_address, str(e)) 48 | 49 | 50 | def send_default_headers(handler: "StreamingHandler"): 51 | handler.send_response(HTTPStatus.OK) 52 | handler.send_header("Access-Control-Allow-Origin", "*") 53 | handler.send_header("Age", 0) 54 | handler.send_header("Cache-Control", "no-cache, private") 55 | handler.send_header("Pragma", "no-cache") 56 | 57 | 58 | def send_jpeg_content_headers(handler: "StreamingHandler", frame, extra_len=0): 59 | handler.send_header("Content-Type", "image/jpeg") 60 | handler.send_header("Content-Length", str(len(frame) + extra_len)) 61 | handler.end_headers() 62 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 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@v5 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 | build: 52 | name: Build wheel and .deb 53 | needs: push_to_main 54 | uses: ./.github/workflows/build.yml 55 | with: 56 | branch: 'main' 57 | 58 | release: 59 | name: Create Release 60 | needs: [push_to_main, build] 61 | runs-on: ubuntu-latest 62 | steps: 63 | - name: Download all Artifacts 64 | uses: actions/download-artifact@v5 65 | with: 66 | path: ./dist/ 67 | 68 | - name: Move all artifact files to dist root 69 | run: | 70 | find ./dist -mindepth 2 -type f -exec mv -t ./dist {} + 71 | 72 | - name: Move release_body.md 73 | run: | 74 | mv ./dist/release_body.md . 75 | 76 | - name: Release 77 | uses: softprops/action-gh-release@v2 78 | with: 79 | body_path: release_body.md 80 | tag_name: v${{ needs.push_to_main.outputs.version }} 81 | files: ./dist/* 82 | env: 83 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 84 | -------------------------------------------------------------------------------- /spyglass/exif.py: -------------------------------------------------------------------------------- 1 | def create_exif_header(orientation: int): 2 | if orientation <= 0: 3 | return None 4 | 5 | return b"".join( 6 | [ 7 | b"\xff\xd8", # Start of Image (SOI) marker 8 | b"\xff\xe1", # APP1 marker 9 | b"\x00\x62", # Length of APP 1 segment (98 bytes) 10 | b"\x45\x78\x69\x66", # EXIF identifier ("Exif" in ASCII) 11 | b"\x00\x00", # Padding bytes 12 | # TIFF header (with big-endian indicator) 13 | b"\x4d\x4d", # Big endian 14 | b"\x00\x2a", # TIFF magic number 15 | b"\x00\x00\x00\x08", # Offset to first IFD (8 bytes) 16 | # Image File Directory (IFD) 17 | b"\x00\x05", # Number of entries in the IFD (5) 18 | # v-- Orientation tag (tag number = 0x0112, type = USHORT, count = 1) 19 | b"\x01\x12", 20 | b"\x00\x03", 21 | b"\x00\x00\x00\x01", 22 | b"\x00", 23 | orientation.to_bytes(1, "big"), 24 | b"\x00\x00", # Tag data 25 | # v-- XResolution tag (tag number = 0x011A, type = UNSIGNED RATIONAL, count = 1) 26 | b"\x01\x1a", 27 | b"\x00\x05", 28 | b"\x00\x00\x00\x01", 29 | b"\x00\x00\x00\x4a", # Tag data (address) 30 | # v-- YResolution tag (tag number = 0x011B, type = UNSIGNED RATIONAL, count = 1) 31 | b"\x01\x1b", 32 | b"\x00\x05", 33 | b"\x00\x00\x00\x01", 34 | b"\x00\x00\x00\x52", # Tag data (address) 35 | # v-- ResolutionUnit tag (tag number = 0x0128, type = USHORT, count = 1) 36 | b"\x01\x28", 37 | b"\x00\x03", 38 | b"\x00\x00\x00\x01", 39 | b"\x00\x02\x00\x00", # 2 - Inch 40 | # v-- YCbCrPositioning tag (tag number = 0x0213, type = USHORT, count = 1) 41 | b"\x02\x13", 42 | b"\x00\x03", 43 | b"\x00\x00\x00\x01", 44 | b"\x00\x01\x00\x00", # center of pixel array 45 | b"\x00\x00\x00\x00", # Offset to next IFD 0 46 | b"\x00\x00\x00\x48\x00\x00\x00\x01", # XResolution value 47 | b"\x00\x00\x00\x48\x00\x00\x00\x01", # YResolution value 48 | ] 49 | ) 50 | 51 | 52 | option_to_exif_orientation = { 53 | "h": 1, 54 | "mh": 2, 55 | "r180": 3, 56 | "mv": 4, 57 | "mhr270": 5, 58 | "r90": 6, 59 | "mhr90": 7, 60 | "r270": 8, 61 | } 62 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v0.17.1 (2025-10-07) 2 | 3 | ### Fix 4 | 5 | - fix cors issues (#123) 6 | 7 | ### Dependency Update 8 | 9 | - Update aiortc requirement from ~=1.9.0 to ~=1.13.0 (#114) 10 | - Update setuptools requirement from ~=80.8.0 to ~=80.9.0 (#115) 11 | - Update setuptools requirement from ~=80.7.1 to ~=80.8.0 (#113) 12 | - Update setuptools requirement from ~=75.1.0 to ~=80.7.1 (#112) 13 | 14 | ## v0.17.0 (2025-05-04) 15 | 16 | ### Feat 17 | 18 | - add software encoding for mjpg and jpeg (#105) 19 | 20 | ## v0.16.3 (2025-03-09) 21 | 22 | ### Fix 23 | 24 | - fix --disable_webrtc to update the module variable correctly (#102) 25 | 26 | ## v0.16.2 (2025-02-26) 27 | 28 | ### Fix 29 | 30 | - fix crash for picamera2 v0.3.23+ (#100) 31 | 32 | ## v0.16.1 (2025-02-24) 33 | 34 | ### Fix 35 | 36 | - fix controls_style.css path to relative path in camera_options.py (#98) 37 | 38 | ## v0.16.0 (2025-02-24) 39 | 40 | ### Feat 41 | 42 | - add WebRTC stream (#84) 43 | 44 | ## v0.15.0 (2024-08-14) 45 | 46 | ### Feat 47 | 48 | - Add basic USB-Camera support (#10) 49 | 50 | ## v0.14.0 (2024-06-05) 51 | 52 | ### Feat 53 | 54 | - Expand camera control ability (#14) 55 | 56 | ## v0.13.1 (2024-06-05) 57 | 58 | ### Fix 59 | 60 | - **install**: fix missing directory during install (#73) 61 | 62 | ## v0.13.0 (2023-07-22) 63 | 64 | ### Feat 65 | 66 | - add picamera2 tuning filters option (#55) 67 | 68 | ## v0.12.0 (2023-07-06) 69 | 70 | ### Feat 71 | 72 | - Add compatibility with moonraker update manager (#54) 73 | 74 | ## v0.11.2 (2023-07-02) 75 | 76 | ### Fix 77 | 78 | - fix url parsing (#53) 79 | 80 | ## v0.11.1 (2023-06-10) 81 | 82 | ### Fix 83 | 84 | - URL routing to accept requests that contain unused parameters (#42) 85 | 86 | ## v0.11.0 (2023-03-19) 87 | 88 | ### Feat 89 | 90 | - add exif based image rotation (#37) 91 | 92 | ## v0.10.3 (2023-03-05) 93 | 94 | ### Fix 95 | 96 | - fix issue with python2 (#40) 97 | 98 | ## v0.10.2 (2023-02-19) 99 | 100 | ### Fix 101 | 102 | - fix version project configuration 103 | 104 | ## v0.10.1 (2023-02-19) 105 | 106 | ### Fix 107 | 108 | - build packages after version bump 109 | 110 | ## v0.10.0 (2023-02-19) 111 | 112 | ### Feat 113 | 114 | - Restrict resolution to 1920x1920 (#31) 115 | - Add testing to project (#7) 116 | - add basic service and installer (#17) 117 | 118 | ## v0.0.0 (2023-02-02) 119 | 120 | ### Feat 121 | 122 | - Improve project structure and setup (#4) 123 | 124 | ### Fix 125 | 126 | - Fix vertical flip (#16) 127 | - Make run.py executable 128 | - Fix python version in GitHub actions 129 | 130 | ### Refactor 131 | 132 | - Rename GitHub Action jobs (#6) 133 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /scripts/build_deb.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | # Usage: ./build_deb.sh 5 | BINNAME="spyglass" 6 | PKGNAME="mainsail-${BINNAME}" 7 | 8 | DEPENDS=(python3-libcamera python3-kms++ python3-picamera2 python3-av) 9 | 10 | TMP_VENV="/opt/${BINNAME}/venv" 11 | STAGING_DIR="$(mktemp -d /tmp/${BINNAME}-pkg.XXXXXX)" 12 | VENV_DIR="${STAGING_DIR}/opt/${BINNAME}/venv" 13 | BIN_DIR="${STAGING_DIR}/usr/bin" 14 | 15 | echo "Creating virtualenv in ${TMP_VENV}" 16 | python3 -m venv --system-site-packages "${TMP_VENV}" 17 | 18 | "${TMP_VENV}/bin/pip" install --upgrade pip setuptools wheel 19 | "${TMP_VENV}/bin/pip" config list 20 | 21 | WHEEL=$(ls -t *.whl | head -n 1) 22 | VERSION="$(echo "$WHEEL" | awk -F'-' '{print $2}').${1}" 23 | echo "Installing whl into venv" 24 | "${TMP_VENV}/bin/pip" install --no-cache-dir --extra-index-url https://www.piwheels.org/simple "${WHEEL}" 25 | 26 | echo "Cleaning up virtualenv to reduce size" 27 | "${TMP_VENV}/bin/pip" cache purge 28 | find "${TMP_VENV}" -name '__pycache__' -type d -print0 | xargs -0 -r rm -rf 29 | find "${TMP_VENV}" -name '*.pyc' -print0 | xargs -0 -r rm -f 30 | rm -rf "${TMP_VENV}/.cache" "${TMP_VENV}/pip-selfcheck.json" "${TMP_VENV}/share" 31 | 32 | echo "Removing pip/wheel from staged venv to reduce size" 33 | PYVER="$(${TMP_VENV}/bin/python -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" 34 | STAGED_SITEPKG="${TMP_VENV}/lib/python${PYVER}/site-packages" 35 | rm -f "${TMP_VENV}/bin/pip" "${TMP_VENV}/bin/pip3" "${TMP_VENV}/bin/pip${PYVER}" 36 | rm -rf "${STAGED_SITEPKG}/pip" "${STAGED_SITEPKG}"/pip-* 37 | rm -rf "${STAGED_SITEPKG}/wheel" "${STAGED_SITEPKG}"/wheel-* 38 | rm -rf "${STAGED_SITEPKG}/setuptools" "${STAGED_SITEPKG}"/setuptools-* 39 | 40 | echo "Preparing staging layout at ${STAGING_DIR}" 41 | mkdir -p "${VENV_DIR}" "${BIN_DIR}" 42 | 43 | echo "Copying virtualenv to staging" 44 | cp -a "${TMP_VENV}/." "${VENV_DIR}/" 45 | 46 | # Fix permissions in the staged venv so non-root users can run it: 47 | # - directories: 0755 (owner rwx, group/other rx) 48 | # - files: 0644 (owner rw, group/other r) 49 | # - venv/bin/* executables: 0755 50 | echo "Adjusting permissions in staged venv so non-root users can execute it" 51 | # give directories execute bit so they are traversable 52 | find "${VENV_DIR}" -type d -exec chmod 0755 {} + 53 | # make regular files readable 54 | find "${VENV_DIR}" -type f -exec chmod 0644 {} + 55 | # make sure scripts and binaries in bin are executable 56 | if [ -d "${VENV_DIR}/bin" ]; then 57 | find "${VENV_DIR}/bin" -type f -exec chmod 0755 {} + 58 | fi 59 | # ensure any existing shebang scripts under bin are executable (some tools create them) 60 | if [ -d "${VENV_DIR}/bin" ]; then 61 | chmod -R a+rx "${VENV_DIR}/bin" 62 | fi 63 | 64 | echo "Writing wrapper to ${BIN_DIR}/${BINNAME}" 65 | cat > "${BIN_DIR}/${BINNAME}" <<'EOF' 66 | #!/usr/bin/env bash 67 | 68 | APP_BIN="/opt/spyglass/venv/bin/spyglass" 69 | 70 | exec "${APP_BIN}" "$@" 71 | EOF 72 | chmod 0755 "${BIN_DIR}/${BINNAME}" 73 | 74 | FPM_DEPENDS=() 75 | for dep in "${DEPENDS[@]}"; do 76 | FPM_DEPENDS+=(--depends "${dep}") 77 | done 78 | 79 | echo "Building .deb with fpm (declaring system package dependencies: ${DEPENDS[*]:-none})" 80 | # Ensure fpm is installed: sudo gem install --no-document fpm 81 | fpm -s dir -t deb \ 82 | -n "${PKGNAME}" -v "${VERSION}" \ 83 | --description "Spyglass packaged with a bundled virtualenv and pip-installed app" \ 84 | --maintainer "Patrick Gehrsitz " \ 85 | --url "https://github.com/mainsail-crew/spyglass" \ 86 | --license "GPLv3" \ 87 | "${FPM_DEPENDS[@]}" \ 88 | -C "${STAGING_DIR}" . 89 | 90 | echo "Cleaning up" 91 | rm -rf "${TMP_VENV}" "${STAGING_DIR}" 92 | echo "Done. .deb is in the current directory." 93 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_dispatch: 3 | inputs: 4 | branch: 5 | type: string 6 | default: '' 7 | workflow_call: 8 | inputs: 9 | branch: 10 | type: string 11 | default: '' 12 | push: 13 | branches: 14 | - develop 15 | jobs: 16 | build_wheel: 17 | name: Build wheel 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Check out 21 | uses: actions/checkout@v5 22 | with: 23 | ref: ${{ inputs.branch }} 24 | 25 | - name: Set up Python 26 | uses: actions/setup-python@v6 27 | with: 28 | python-version: '3.10' 29 | cache: 'pip' 30 | 31 | - name: Install dependencies 32 | run: | 33 | python -m pip install --upgrade pip 34 | pip install build 35 | 36 | - name: Build package 37 | run: | 38 | python -m build 39 | 40 | - name: Upload wheel 41 | uses: actions/upload-artifact@v4 42 | with: 43 | name: wheel 44 | path: ./dist/ 45 | 46 | build_deb: 47 | name: Build .deb 48 | needs: build_wheel 49 | runs-on: ubuntu-latest 50 | env: 51 | PKGNAME: spyglass 52 | strategy: 53 | fail-fast: false 54 | matrix: 55 | arch: [arm64, armhf] 56 | distro: [bullseye, bookworm, trixie] 57 | include: 58 | - arch: arm64 59 | platform: linux/arm64 60 | - arch: armhf 61 | platform: linux/arm/v7 62 | continue-on-error: ${{ matrix.arch == 'armhf' }} 63 | steps: 64 | - name: Check out 65 | uses: actions/checkout@v5 66 | with: 67 | ref: ${{ inputs.branch }} 68 | 69 | - name: Download wheel 70 | uses: actions/download-artifact@v5 71 | with: 72 | name: wheel 73 | path: . 74 | 75 | - name: Set up QEMU (for running foreign-arch containers) 76 | uses: docker/setup-qemu-action@v3 77 | 78 | - name: Build inside Debian container (use repo's build_deb.sh) 79 | id: build_in_container 80 | run: | 81 | WORKDIR="$(pwd)" 82 | DISTRO="${{ matrix.distro }}" 83 | PLATFORM="${{ matrix.platform }}" 84 | ARCH="${{ matrix.arch }}" 85 | 86 | echo "Packaging ${PKGNAME} arch=${ARCH} distro=${DISTRO} platform=${PLATFORM}" 87 | 88 | # Ensure build script is executable in repo 89 | chmod +x ./scripts/build_deb.sh 90 | 91 | # Run Debian container (emulated if needed) and call the repository script. 92 | # Pass EXTERNAL_REPO as environment variable so the script can install it into the venv. 93 | docker run --rm --platform="${PLATFORM}" -v "${WORKDIR}:/work" -w /work \ 94 | "debian:${DISTRO}-slim" \ 95 | bash -eux -o pipefail -c " 96 | export DEBIAN_FRONTEND=noninteractive 97 | apt-get update 98 | apt-get install -y --no-install-recommends \ 99 | git \ 100 | python3 \ 101 | python3-venv \ 102 | python3-pip \ 103 | python3-av \ 104 | binutils \ 105 | ruby-full \ 106 | curl \ 107 | wget 108 | 109 | # Install fpm (for Debian packaging) 110 | gem install --no-document fpm 111 | 112 | # Run the repository's build script, give it the version argument and EXTERNAL_REPO via env. 113 | # The script in the repo has been adapted to look for EXTERNAL_REPO env var and install it into the venv. 114 | ./scripts/build_deb.sh '${DISTRO}' 115 | " 116 | 117 | - name: Upload deb 118 | uses: actions/upload-artifact@v4 119 | with: 120 | name: ${{ matrix.distro }}.${{ matrix.arch }} 121 | path: ./*.deb 122 | -------------------------------------------------------------------------------- /spyglass/camera/camera.py: -------------------------------------------------------------------------------- 1 | import threading 2 | from abc import ABC, abstractmethod 3 | 4 | import libcamera 5 | from picamera2 import Picamera2 6 | 7 | from spyglass import WEBRTC_ENABLED, logger 8 | from spyglass.camera_options import process_controls 9 | from spyglass.exif import create_exif_header 10 | from spyglass.server.http_server import StreamingHandler, StreamingServer 11 | from spyglass.server.webrtc_whep import PicameraStreamTrack 12 | 13 | 14 | class Camera(ABC): 15 | def __init__(self, picam2: Picamera2): 16 | self.picam2 = picam2 17 | self.media_track = PicameraStreamTrack() 18 | 19 | def create_controls( 20 | self, fps: int, autofocus: str, lens_position: float, autofocus_speed: str 21 | ): 22 | controls = {} 23 | 24 | if "FrameDurationLimits" in self.picam2.camera_controls: 25 | controls["FrameRate"] = fps 26 | 27 | if "AfMode" in self.picam2.camera_controls: 28 | controls["AfMode"] = autofocus 29 | controls["AfSpeed"] = autofocus_speed 30 | if autofocus == libcamera.controls.AfModeEnum.Manual: 31 | controls["LensPosition"] = lens_position 32 | else: 33 | logger.warning("Attached camera does not support autofocus") 34 | 35 | return controls 36 | 37 | def configure( 38 | self, 39 | width: int, 40 | height: int, 41 | fps: int, 42 | autofocus: str, 43 | lens_position: float, 44 | autofocus_speed: str, 45 | control_list: list[list[str]] = [], 46 | upsidedown=False, 47 | flip_horizontal=False, 48 | flip_vertical=False, 49 | ): 50 | controls = self.create_controls(fps, autofocus, lens_position, autofocus_speed) 51 | c = process_controls(self.picam2, [tuple(ctrl) for ctrl in control_list]) 52 | controls.update(c) 53 | 54 | transform = libcamera.Transform( 55 | hflip=int(flip_horizontal or upsidedown), 56 | vflip=int(flip_vertical or upsidedown), 57 | ) 58 | 59 | self.picam2.configure( 60 | self.picam2.create_video_configuration( 61 | main={"size": (width, height)}, controls=controls, transform=transform 62 | ) 63 | ) 64 | 65 | def _run_server( 66 | self, 67 | bind_address, 68 | port, 69 | streaming_handler: StreamingHandler, 70 | get_frame, 71 | stream_url="/stream", 72 | snapshot_url="/snapshot", 73 | webrtc_url="/webrtc", 74 | orientation_exif=0, 75 | ): 76 | logger.info(f"Server listening on {bind_address}:{port}") 77 | logger.info(f"Streaming endpoint: {stream_url}") 78 | logger.info(f"Snapshot endpoint: {snapshot_url}") 79 | if WEBRTC_ENABLED: 80 | logger.info(f"WebRTC endpoint: {webrtc_url}") 81 | logger.info("Controls endpoint: /controls") 82 | address = (bind_address, port) 83 | streaming_handler.picam2 = self.picam2 84 | streaming_handler.media_track = self.media_track 85 | streaming_handler.get_frame = get_frame 86 | streaming_handler.stream_url = stream_url 87 | streaming_handler.snapshot_url = snapshot_url 88 | streaming_handler.webrtc_url = webrtc_url 89 | 90 | if orientation_exif > 0: 91 | streaming_handler.exif_header = create_exif_header(orientation_exif) 92 | else: 93 | streaming_handler.exif_header = None 94 | current_server = StreamingServer(address, streaming_handler) 95 | async_loop = threading.Thread(target=StreamingHandler.loop.run_forever) 96 | async_loop.start() 97 | current_server.serve_forever() 98 | 99 | @abstractmethod 100 | def start_and_run_server( 101 | self, 102 | bind_address, 103 | port, 104 | stream_url="/stream", 105 | snapshot_url="/snapshot", 106 | webrtc_url="/webrtc", 107 | orientation_exif=0, 108 | use_sw_jpg_encoding=False, 109 | ): 110 | pass 111 | 112 | @abstractmethod 113 | def stop(self): 114 | pass 115 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /spyglass/camera_options.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import pathlib 3 | 4 | import libcamera 5 | 6 | 7 | def parse_dictionary_to_html_page(camera, parsed_controls={}, processed_controls={}): 8 | if not parsed_controls: 9 | parsed_controls = "None" 10 | if not processed_controls: 11 | processed_controls = "None" 12 | html = """ 13 | 14 | 15 | """ 16 | html += f""" 17 | 18 | 19 | 20 | Camera Settings 21 | 22 | 23 | """ 24 | html += f""" 25 | 26 |

Available camera options

27 |

Parsed Controls: {parsed_controls}

28 |

Processed Controls: {processed_controls}

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

{control}

35 |
36 |
37 | Min: 38 | {values[0]} 39 |
40 |
41 | Max: 42 | {values[1]} 43 |
44 |
45 | Default: 46 | {values[2]} 47 |
48 |
49 |
50 |
51 | """ 52 | html += """ 53 | 54 | 55 | """ 56 | return html 57 | 58 | 59 | def get_style(): 60 | file_dir = pathlib.Path(__file__).parent.resolve() 61 | controls_style = file_dir / ".." / "resources" / "controls_style.css" 62 | with open(controls_style, "r") as f: 63 | return f.read() 64 | 65 | 66 | def process_controls(camera, controls: list[tuple[str, str]]) -> dict[str, any]: 67 | controls_dict_lower = {k.lower(): k for k in camera.camera_controls.keys()} 68 | if controls is None: 69 | return {} 70 | processed_controls = {} 71 | for key, value in controls: 72 | key = key.lower().strip() 73 | if key.lower() in controls_dict_lower.keys(): 74 | value = value.lower().strip() 75 | k = controls_dict_lower[key] 76 | v = parse_from_string(value) 77 | processed_controls[k] = v 78 | return processed_controls 79 | 80 | 81 | def parse_from_string(input_string: str) -> any: 82 | try: 83 | return ast.literal_eval(input_string) 84 | except (ValueError, TypeError, SyntaxError): 85 | pass 86 | 87 | if input_string.lower() in ["true", "false"]: 88 | return input_string.lower() == "true" 89 | 90 | return input_string 91 | 92 | 93 | def get_type_str(obj) -> str: 94 | return str(type(obj)).split("'")[1] 95 | 96 | 97 | def get_libcamera_controls_string(camera_num: str) -> str: 98 | ctrls_str = "" 99 | libcam_cm = libcamera.CameraManager.singleton() 100 | if camera_num > len(libcam_cm.cameras) - 1: 101 | return ctrls_str 102 | cam = libcam_cm.cameras[camera_num] 103 | 104 | def rectangle_to_tuple(rectangle): 105 | return (rectangle.x, rectangle.y, rectangle.width, rectangle.height) 106 | 107 | for k, v in cam.controls.items(): 108 | if isinstance(v.min, libcamera.Rectangle): 109 | min = rectangle_to_tuple(v.min) 110 | max = rectangle_to_tuple(v.max) 111 | default = rectangle_to_tuple(v.default) 112 | else: 113 | min = v.min 114 | max = v.max 115 | default = v.default 116 | 117 | str_first = f"{k.name} ({get_type_str(min)})" 118 | str_second = f"min={min} max={max} default={default}" 119 | str_indent = (30 - len(str_first)) * " " + ": " 120 | ctrls_str += str_first + str_indent + str_second + "\n" 121 | 122 | return ctrls_str.strip() 123 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/test_url_parsing.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.parametrize( 5 | "expected_url, incoming_url, expected_output", 6 | [ 7 | ("/?a=b", "/?a=b", True), 8 | ("/?a=b", "/?b=a", True), 9 | ("/?a=b", "/?a=b&", True), 10 | ("/?a=b", "/?b=c&d=e", True), 11 | ("/?a=b", "/?a=b&c=d", True), 12 | ("/?a=b", "/?c=d&a=b", True), 13 | ("/?a=b&c=d", "/?a=b", True), 14 | ("/?a=b&c=d", "/?a=b&c=d", True), 15 | ("/?a=b&c=d", "/?c=d&a=b", True), 16 | ("/?a=b&c=d", "/?d=e&a=b", True), 17 | ("/?a=b", "/a?a=b", False), 18 | ("/?a=b", "/a?b=a", False), 19 | ("/?a=b", "/a?b=c&d=e", False), 20 | ("/?a=b&c=d", "/a?a=b", False), 21 | ("/?a=b&c=d", "/a?a=b&c=d", False), 22 | ("/?a=b&c=d", "/a?c=d&a=b", False), 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 | ("/a?a=b", "/a?a=b", True), 30 | ("/a?a=b", "/a?b=a", True), 31 | ("/a?a=b", "/a?b=c&d=e", True), 32 | ("/a?a=b&c=d", "/a?a=b", True), 33 | ("/a?a=b&c=d", "/a?a=b&c=d", True), 34 | ("/a?a=b&c=d", "/a?c=d&a=b", True), 35 | ("/a?a=b", "/b?a=b", False), 36 | ("/a?a=b", "/b?b=a", False), 37 | ("/a?a=b", "/b?b=c&d=e", False), 38 | ("/a?a=b&c=d", "/b?a=b", False), 39 | ("/a?a=b&c=d", "/b?a=b&c=d", False), 40 | ("/a?a=b&c=d", "/b?c=d&a=b", False), 41 | ], 42 | ) 43 | def test_check_paths_match(expected_url, incoming_url, expected_output): 44 | from spyglass.url_parsing import check_paths_match 45 | 46 | match_value = check_paths_match(expected_url, incoming_url) 47 | assert match_value == expected_output 48 | 49 | 50 | @pytest.mark.parametrize( 51 | "expected_url, incoming_url, expected_output", 52 | [ 53 | ("/?a=b", "/?a=b", True), 54 | ("/?a=b", "/?b=a", False), 55 | ("/?a=b", "/?a=b&", True), 56 | ("/?a=b", "/?b=c&d=e", False), 57 | ("/?a=b", "/?a=b&c=d", True), 58 | ("/?a=b", "/?c=d&a=b", True), 59 | ("/?a=b&c=d", "/?a=b", False), 60 | ("/?a=b&c=d", "/?a=b&c=d", True), 61 | ("/?a=b&c=d", "/?c=d&a=b", True), 62 | ("/?a=b&c=d", "/?d=e&a=b", False), 63 | ("/?a=b", "/a?a=b", True), 64 | ("/?a=b", "/a?b=a", False), 65 | ("/?a=b", "/a?b=c&d=e", False), 66 | ("/?a=b&c=d", "/a?a=b", False), 67 | ("/?a=b&c=d", "/a?a=b&c=d", True), 68 | ("/?a=b&c=d", "/a?c=d&a=b", True), 69 | ("/a", "/a", True), 70 | ("/a", "/b", True), 71 | ("/a", "/a?b=c", True), 72 | ("/a", "/a/?b=c", True), 73 | ("/a", "/b/?b=c", True), 74 | ("/a", "/?a=b", True), 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 | ("/a?a=b", "/b?a=b", True), 82 | ("/a?a=b", "/b?b=a", False), 83 | ("/a?a=b", "/b?b=c&d=e", False), 84 | ("/a?a=b&c=d", "/b?a=b", False), 85 | ("/a?a=b&c=d", "/b?a=b&c=d", True), 86 | ("/a?a=b&c=d", "/b?c=d&a=b", True), 87 | ], 88 | ) 89 | def test_check_params_match(expected_url, incoming_url, expected_output): 90 | from spyglass.url_parsing import check_params_match 91 | 92 | match_value = check_params_match(expected_url, incoming_url) 93 | assert match_value == expected_output 94 | 95 | 96 | @pytest.mark.parametrize( 97 | "expected_url, incoming_url, expected_output", 98 | [ 99 | ("/?a=b", "/?a=b", True), 100 | ("/?a=b", "/?b=a", False), 101 | ("/?a=b", "/?a=b&", True), 102 | ("/?a=b", "/?b=c&d=e", False), 103 | ("/?a=b", "/?a=b&c=d", True), 104 | ("/?a=b", "/?c=d&a=b", True), 105 | ("/?a=b&c=d", "/?a=b", False), 106 | ("/?a=b&c=d", "/?a=b&c=d", True), 107 | ("/?a=b&c=d", "/?c=d&a=b", True), 108 | ("/?a=b&c=d", "/?d=e&a=b", False), 109 | ("/?a=b", "/a?a=b", False), 110 | ("/?a=b", "/a?b=a", False), 111 | ("/?a=b", "/a?b=c&d=e", False), 112 | ("/?a=b&c=d", "/a?a=b", False), 113 | ("/?a=b&c=d", "/a?a=b&c=d", False), 114 | ("/?a=b&c=d", "/a?c=d&a=b", False), 115 | ("/a", "/a", True), 116 | ("/a", "/b", False), 117 | ("/a", "/a?b=c", True), 118 | ("/a", "/a/?b=c", True), 119 | ("/a", "/b/?b=c", False), 120 | ("/a", "/?a=b", False), 121 | ("/a?a=b", "/a?a=b", True), 122 | ("/a?a=b", "/a?b=a", False), 123 | ("/a?a=b", "/a?b=c&d=e", False), 124 | ("/a?a=b&c=d", "/a?a=b", False), 125 | ("/a?a=b&c=d", "/a?a=b&c=d", True), 126 | ("/a?a=b&c=d", "/a?c=d&a=b", True), 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 | ) 135 | def test_check_urls_match(expected_url, incoming_url, expected_output): 136 | from spyglass.url_parsing import check_urls_match 137 | 138 | match_value = check_urls_match(expected_url, incoming_url) 139 | assert match_value == expected_output 140 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from unittest.mock import MagicMock, patch 3 | 4 | import pytest 5 | 6 | AF_SPEED_ENUM_NORMAL = 1 7 | AF_SPEED_ENUM_FAST = 2 8 | AF_MODE_ENUM_CONTINUOUS = 2 9 | AF_MODE_ENUM_MANUAL = 3 10 | 11 | DEFAULT_HEIGHT = 480 12 | DEFAULT_WIDTH = 640 13 | DEFAULT_FLIP_VERTICALLY = False 14 | DEFAULT_FLIP_HORIZONTALLY = False 15 | DEFAULT_UPSIDE_DOWN = False 16 | DEFAULT_LENS_POSITION = 0.0 17 | DEFAULT_FPS = 15 18 | DEFAULT_AF_SPEED = AF_SPEED_ENUM_NORMAL 19 | DEFAULT_AUTOFOCUS_MODE = AF_MODE_ENUM_CONTINUOUS 20 | DEFAULT_CONTROLS = [] 21 | DEFAULT_TUNING_FILTER = None 22 | DEFAULT_TUNING_FILTER_DIR = None 23 | DEFAULT_CAMERA_NUM = 0 24 | 25 | 26 | @pytest.fixture(autouse=True) 27 | def mock_libraries(mocker): 28 | mock_libcamera = MagicMock() 29 | mock_picamera2 = MagicMock() 30 | mock_picamera2_encoders = MagicMock() 31 | mock_picamera2_outputs = MagicMock() 32 | mock_picamera2_outputs.Output = MagicMock 33 | mocker.patch.dict( 34 | "sys.modules", 35 | { 36 | "libcamera": mock_libcamera, 37 | "picamera2": mock_picamera2, 38 | "picamera2.encoders": mock_picamera2_encoders, 39 | "picamera2.outputs": mock_picamera2_outputs, 40 | }, 41 | ) 42 | mocker.patch("libcamera.controls.AfModeEnum.Manual", AF_MODE_ENUM_MANUAL) 43 | mocker.patch("libcamera.controls.AfModeEnum.Continuous", AF_MODE_ENUM_CONTINUOUS) 44 | mocker.patch("libcamera.controls.AfSpeedEnum.Normal", AF_SPEED_ENUM_NORMAL) 45 | mocker.patch("libcamera.controls.AfSpeedEnum.Fast", AF_SPEED_ENUM_FAST) 46 | 47 | 48 | def test_parse_bindaddress(): 49 | from spyglass import cli 50 | 51 | args = cli.get_args(["-b", "1.2.3.4"]) 52 | assert args.bindaddress == "1.2.3.4" 53 | 54 | 55 | def test_parse_port(): 56 | from spyglass import cli 57 | 58 | args = cli.get_args(["-p", "123"]) 59 | assert args.port == 123 60 | 61 | 62 | def test_parse_resolution(): 63 | from spyglass import cli 64 | 65 | args = cli.get_args(["-r", "100x200"]) 66 | assert args.resolution == "100x200" 67 | 68 | 69 | def test_split_resolution(): 70 | from spyglass import cli 71 | 72 | (width, height) = cli.split_resolution("100x200") 73 | assert width == 100 74 | assert height == 200 75 | 76 | 77 | def test_parse_tuning_filter(): 78 | from spyglass import cli 79 | 80 | args = cli.get_args(["-tf", "filter"]) 81 | assert args.tuning_filter == "filter" 82 | 83 | 84 | def test_parse_tuning_filter_dir(): 85 | from spyglass import cli 86 | 87 | args = cli.get_args(["-tfd", "dir"]) 88 | assert args.tuning_filter_dir == "dir" 89 | 90 | 91 | @patch("spyglass.camera.init_camera") 92 | def test_init_camera_with_defaults( 93 | mock_spyglass_camera, 94 | ): 95 | from spyglass import cli 96 | 97 | cli.main(args=[]) 98 | mock_spyglass_camera.assert_called_once_with( 99 | DEFAULT_CAMERA_NUM, DEFAULT_TUNING_FILTER, DEFAULT_TUNING_FILTER_DIR 100 | ) 101 | 102 | 103 | @patch("spyglass.camera.init_camera") 104 | def test_configure_with_defaults(mock_init_camera): 105 | from spyglass import cli 106 | 107 | cli.main(args=[]) 108 | cam_instance = mock_init_camera.return_value 109 | cam_instance.configure.assert_called_once_with( 110 | DEFAULT_WIDTH, 111 | DEFAULT_HEIGHT, 112 | DEFAULT_FPS, 113 | DEFAULT_AUTOFOCUS_MODE, 114 | DEFAULT_LENS_POSITION, 115 | DEFAULT_AF_SPEED, 116 | DEFAULT_CONTROLS, 117 | DEFAULT_UPSIDE_DOWN, 118 | DEFAULT_FLIP_HORIZONTALLY, 119 | DEFAULT_FLIP_VERTICALLY, 120 | ) 121 | 122 | 123 | @patch("spyglass.camera.init_camera") 124 | def test_configure_with_parameters(mock_init_camera): 125 | from spyglass import cli 126 | 127 | cli.main( 128 | args=[ 129 | "-n", 130 | "1", 131 | "-tf", 132 | "test", 133 | "-tfd", 134 | "test-dir", 135 | "-r", 136 | "200x100", 137 | "-f", 138 | "20", 139 | "-af", 140 | "manual", 141 | "-l", 142 | "1.0", 143 | "-s", 144 | "normal", 145 | "-ud", 146 | "-fh", 147 | "-fv", 148 | "-c", 149 | "brightness=-0.4", 150 | "-c", 151 | "awbenable=false", 152 | ] 153 | ) 154 | cam_instance = mock_init_camera.return_value 155 | cam_instance.configure.assert_called_once_with( 156 | 200, 157 | 100, 158 | 20, 159 | AF_MODE_ENUM_MANUAL, 160 | 1.0, 161 | AF_SPEED_ENUM_NORMAL, 162 | [["brightness", "-0.4"], ["awbenable", "false"]], 163 | True, 164 | True, 165 | True, 166 | ) 167 | 168 | 169 | def test_raise_error_when_width_greater_than_maximum(): 170 | from spyglass import cli 171 | 172 | with pytest.raises(argparse.ArgumentTypeError): 173 | cli.main(args=["-r", "1921x1920"]) 174 | 175 | 176 | def test_raise_error_when_height_greater_than_maximum(): 177 | from spyglass import cli 178 | 179 | with pytest.raises(argparse.ArgumentTypeError): 180 | cli.main(args=["-r", "1920x1921"]) 181 | 182 | 183 | @patch("spyglass.camera.init_camera") 184 | def test_configure_camera_af_continuous_speed_fast(mock_init_camera): 185 | from spyglass import cli 186 | 187 | cli.main(args=["-af", "continuous", "-s", "fast"]) 188 | cam_instance = mock_init_camera.return_value 189 | cam_instance.configure.assert_called_once_with( 190 | DEFAULT_WIDTH, 191 | DEFAULT_HEIGHT, 192 | DEFAULT_FPS, 193 | AF_MODE_ENUM_CONTINUOUS, 194 | DEFAULT_LENS_POSITION, 195 | AF_SPEED_ENUM_FAST, 196 | DEFAULT_CONTROLS, 197 | DEFAULT_UPSIDE_DOWN, 198 | DEFAULT_FLIP_HORIZONTALLY, 199 | DEFAULT_FLIP_VERTICALLY, 200 | ) 201 | 202 | 203 | @patch("spyglass.camera.init_camera") 204 | def test_run_server_with_configuration_from_arguments(mock_init_camera): 205 | from spyglass import cli 206 | 207 | cli.main( 208 | args=[ 209 | "-b", 210 | "1.2.3.4", 211 | "-p", 212 | "1234", 213 | "-st", 214 | "streaming-url", 215 | "-sn", 216 | "snapshot-url", 217 | "-w", 218 | "webrtc-url", 219 | "-or", 220 | "h", 221 | "-sw", 222 | ] 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", 1234, "streaming-url", "snapshot-url", "webrtc-url", 1, True 227 | ) 228 | 229 | 230 | @patch("spyglass.camera.init_camera") 231 | @pytest.mark.parametrize( 232 | "input_value, expected_output", 233 | [ 234 | ("h", 1), 235 | ("mh", 2), 236 | ("r180", 3), 237 | ("mv", 4), 238 | ("mhr270", 5), 239 | ("r90", 6), 240 | ("mhr90", 7), 241 | ("r270", 8), 242 | ], 243 | ) 244 | def test_run_server_with_orientation(mock_init_camera, input_value, expected_output): 245 | from spyglass import cli 246 | 247 | cli.main( 248 | args=[ 249 | "-b", 250 | "1.2.3.4", 251 | "-p", 252 | "1234", 253 | "-st", 254 | "streaming-url", 255 | "-sn", 256 | "snapshot-url", 257 | "-w", 258 | "webrtc-url", 259 | "-or", 260 | input_value, 261 | "-sw", 262 | ] 263 | ) 264 | cam_instance = mock_init_camera.return_value 265 | cam_instance.start_and_run_server.assert_called_once_with( 266 | "1.2.3.4", 267 | 1234, 268 | "streaming-url", 269 | "snapshot-url", 270 | "webrtc-url", 271 | expected_output, 272 | True, 273 | ) 274 | -------------------------------------------------------------------------------- /spyglass/server/webrtc_whep.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import uuid 3 | from collections import deque 4 | from fractions import Fraction 5 | from http import HTTPStatus 6 | 7 | # Used for type hinting 8 | from typing import TYPE_CHECKING 9 | 10 | from picamera2.outputs import Output 11 | 12 | from spyglass.url_parsing import check_urls_match 13 | 14 | if TYPE_CHECKING: 15 | from spyglass.server.http_server import StreamingHandler 16 | 17 | from spyglass import WEBRTC_ENABLED 18 | 19 | if WEBRTC_ENABLED: 20 | from aiortc import RTCPeerConnection, RTCSessionDescription, sdp 21 | from aiortc.contrib.media import MediaRelay 22 | from aiortc.rtcrtpsender import RTCRtpSender 23 | 24 | pcs: dict[uuid.UUID, RTCPeerConnection] = {} 25 | max_connections = 20 26 | media_relay = MediaRelay() 27 | 28 | 29 | def send_default_headers(response_code: int, handler: "StreamingHandler"): 30 | handler.send_response(response_code) 31 | handler.send_header("Access-Control-Allow-Origin", "*") 32 | handler.send_header("Access-Control-Allow-Credentials", False) 33 | 34 | 35 | def do_OPTIONS(handler: "StreamingHandler", webrtc_url="/webrtc"): 36 | # Adapted from MediaMTX http_server.go 37 | # https://github.com/bluenviron/mediamtx/blob/main/internal/servers/webrtc/http_server.go#L173-L189 38 | def response_headers(): 39 | send_default_headers(HTTPStatus.NO_CONTENT, handler) 40 | handler.send_header( 41 | "Access-Control-Allow-Methods", "OPTIONS, GET, POST, PATCH, DELETE" 42 | ) 43 | handler.send_header( 44 | "Access-Control-Allow-Headers", "Authorization, Content-Type, If-Match" 45 | ) 46 | 47 | if handler.headers.get("Access-Control-Request-Method") is not None: 48 | response_headers() 49 | handler.end_headers() 50 | elif check_urls_match(f"{webrtc_url}/whip", handler.path) or check_urls_match( 51 | f"{webrtc_url}/whep", handler.path 52 | ): 53 | response_headers() 54 | handler.send_header("Access-Control-Expose-Headers", "Link") 55 | handler.headers["Link"] = get_ICE_servers() 56 | handler.end_headers() 57 | 58 | 59 | async def do_POST_async(handler: "StreamingHandler"): 60 | # Adapted from MediaMTX http_server.go 61 | # https://github.com/bluenviron/mediamtx/blob/main/internal/servers/webrtc/http_server.go#L191-L246 62 | if handler.headers.get("Content-Type") != "application/sdp": 63 | handler.send_error(HTTPStatus.BAD_REQUEST) 64 | return 65 | 66 | # Limit simultanous clients to save resources 67 | if len(pcs) >= max_connections: 68 | handler.send_error( 69 | HTTPStatus.TOO_MANY_REQUESTS, message="Too many clients connected" 70 | ) 71 | return 72 | 73 | content_length = int(handler.headers["Content-Length"]) 74 | offer_text = handler.rfile.read(content_length).decode("utf-8") 75 | offer = RTCSessionDescription(sdp=offer_text, type="offer") 76 | 77 | pc = RTCPeerConnection() 78 | secret = uuid.uuid4() 79 | 80 | @pc.on("connectionstatechange") 81 | async def on_connectionstatechange(): 82 | print(f"Connection state {pc.connectionState}") 83 | if pc.connectionState == "failed": 84 | await pc.close() 85 | elif pc.connectionState == "closed": 86 | pcs.pop(str(secret)) 87 | print(f"{len(pcs)} connections still open.") 88 | 89 | pcs[str(secret)] = pc 90 | track = media_relay.subscribe(handler.media_track) 91 | sender = pc.addTrack(track) 92 | codecs = RTCRtpSender.getCapabilities("video").codecs 93 | transceiver = next(t for t in pc.getTransceivers() if t.sender == sender) 94 | transceiver.setCodecPreferences( 95 | [codec for codec in codecs if codec.mimeType == "video/H264"] 96 | ) 97 | 98 | await pc.setRemoteDescription(offer) 99 | answer = await pc.createAnswer() 100 | await pc.setLocalDescription(answer) 101 | 102 | while pc.iceGatheringState != "complete": 103 | await asyncio.sleep(1) 104 | 105 | send_default_headers(HTTPStatus.CREATED, handler) 106 | 107 | handler.send_header("Content-Type", "application/sdp") 108 | handler.send_header("ETag", "*") 109 | 110 | handler.send_header("ID", secret) 111 | handler.send_header( 112 | "Access-Control-Expose-Headers", "ETag, ID, Accept-Patch, Link, Location" 113 | ) 114 | handler.send_header("Accept-Patch", "application/trickle-ice-sdpfrag") 115 | handler.headers["Link"] = get_ICE_servers() 116 | handler.send_header("Location", f"/whep/{secret}") 117 | handler.send_header("Content-Length", len(pc.localDescription.sdp)) 118 | handler.end_headers() 119 | handler.wfile.write(bytes(pc.localDescription.sdp, "utf-8")) 120 | 121 | 122 | async def do_PATCH_async(streaming_handler: "StreamingHandler"): 123 | # Adapted from MediaMTX http_server.go 124 | # https://github.com/bluenviron/mediamtx/blob/main/internal/servers/webrtc/http_server.go#L248-L287 125 | if ( 126 | len(streaming_handler.path.split("/")) < 3 127 | or streaming_handler.headers.get("Content-Type") 128 | != "application/trickle-ice-sdpfrag" 129 | ): 130 | send_default_headers(HTTPStatus.BAD, streaming_handler) 131 | streaming_handler.end_headers() 132 | return 133 | content_length = int(streaming_handler.headers["Content-Length"]) 134 | sdp_str = streaming_handler.rfile.read(content_length).decode("utf-8") 135 | candidates = parse_ice_candidates(sdp_str) 136 | secret = streaming_handler.path.split("/")[-1] 137 | pc = pcs[secret] 138 | for candidate in candidates: 139 | await pc.addIceCandidate(candidate) 140 | 141 | send_default_headers(HTTPStatus.NO_CONTENT, streaming_handler) 142 | streaming_handler.end_headers() 143 | 144 | 145 | def get_ICE_servers(): 146 | return None 147 | 148 | 149 | def parse_ice_candidates(sdp_message): 150 | sdp_message = sdp_message.replace("\\r\\n", "\r\n") 151 | 152 | lines = sdp_message.splitlines() 153 | 154 | candidates = [] 155 | cand_str = "a=candidate:" 156 | mid_str = "a=mid:" 157 | mid = "" 158 | for line in lines: 159 | if line.startswith(mid_str): 160 | mid = line[len(mid_str) :] 161 | elif line.startswith(cand_str): 162 | candidate_str = line[len(cand_str) :] 163 | candidate = sdp.candidate_from_sdp(candidate_str) 164 | candidate.sdpMid = mid 165 | candidates.append(candidate) 166 | return candidates 167 | 168 | 169 | if WEBRTC_ENABLED: 170 | import av 171 | from aiortc import MediaStreamTrack 172 | else: 173 | 174 | class MediaStreamTrack: 175 | pass 176 | 177 | 178 | class PicameraStreamTrack(MediaStreamTrack, Output): 179 | kind = "video" 180 | 181 | def __init__(self): 182 | super().__init__() 183 | self.img_queue = deque(maxlen=60) 184 | from spyglass.server.http_server import StreamingHandler 185 | 186 | asyncio.set_event_loop(StreamingHandler.loop) 187 | self.condition = asyncio.Condition() 188 | 189 | def outputframe( 190 | self, frame, keyframe=True, timestamp=None, packet=None, audio=False 191 | ): 192 | from spyglass.server.http_server import StreamingHandler 193 | 194 | asyncio.run_coroutine_threadsafe( 195 | self.put_frame(frame, keyframe, timestamp), StreamingHandler.loop 196 | ) 197 | 198 | async def put_frame(self, frame, keyframe=True, timestamp=None): 199 | async with self.condition: 200 | self.img_queue.append((frame, keyframe, timestamp)) 201 | self.condition.notify_all() 202 | 203 | async def recv(self): 204 | async with self.condition: 205 | 206 | def not_empty(): 207 | return len(self.img_queue) > 0 208 | 209 | await self.condition.wait_for(not_empty) 210 | img, keyframe, pts = self.img_queue.popleft() 211 | packet = av.packet.Packet(img) 212 | packet.pts = pts 213 | packet.time_base = Fraction(1, 1000000) 214 | packet.is_keyframe = keyframe 215 | return packet 216 | -------------------------------------------------------------------------------- /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 | 10 | import libcamera 11 | 12 | from spyglass import WEBRTC_ENABLED, camera_options, logger, set_webrtc_enabled 13 | from spyglass.__version__ import __version__ 14 | from spyglass.exif import option_to_exif_orientation 15 | 16 | # Maximum resolution for hardware encoding 17 | MAX_WIDTH = MAX_HEIGHT = 1920 18 | 19 | 20 | def main(args=None): 21 | """Entry point for hello cli. 22 | 23 | The setup_py entry_point wraps this in sys.exit already so this effectively 24 | becomes sys.exit(main()). 25 | The __main__ entry point similarly wraps sys.exit(). 26 | """ 27 | logger.info(f"Spyglass {__version__}") 28 | 29 | if args is None: 30 | args = sys.argv[1:] 31 | 32 | parsed_args = get_args(args) 33 | 34 | if parsed_args.list_controls: 35 | controls_str = camera_options.get_libcamera_controls_string( 36 | parsed_args.camera_num 37 | ) 38 | if not controls_str: 39 | print(f"Camera {parsed_args.camera_num} not found") 40 | else: 41 | print("Available controls:\n" + controls_str) 42 | return 43 | 44 | use_sw_jpg_encoding = parsed_args.use_sw_jpg_encoding 45 | # Disable max resolution limit for software encoding of JPEG 46 | width, height = split_resolution( 47 | parsed_args.resolution, check_limit=not use_sw_jpg_encoding 48 | ) 49 | controls = parsed_args.controls 50 | if parsed_args.controls_string: 51 | controls += [c.split("=") for c in parsed_args.controls_string.split(",")] 52 | 53 | set_webrtc_enabled(WEBRTC_ENABLED and not parsed_args.disable_webrtc) 54 | 55 | # Has to be imported after WEBRTC_ENABLED got set correctly 56 | from spyglass.camera import init_camera 57 | 58 | cam = init_camera( 59 | parsed_args.camera_num, parsed_args.tuning_filter, parsed_args.tuning_filter_dir 60 | ) 61 | 62 | cam.configure( 63 | width, 64 | height, 65 | parsed_args.fps, 66 | parse_autofocus(parsed_args.autofocus), 67 | parsed_args.lensposition, 68 | parse_autofocus_speed(parsed_args.autofocusspeed), 69 | controls, 70 | parsed_args.upsidedown, 71 | parsed_args.flip_horizontal, 72 | parsed_args.flip_vertical, 73 | ) 74 | try: 75 | cam.start_and_run_server( 76 | parsed_args.bindaddress, 77 | parsed_args.port, 78 | parsed_args.stream_url, 79 | parsed_args.snapshot_url, 80 | parsed_args.webrtc_url, 81 | parsed_args.orientation_exif, 82 | use_sw_jpg_encoding, 83 | ) 84 | finally: 85 | cam.stop() 86 | 87 | 88 | # region args parsers 89 | 90 | 91 | def resolution_type(arg_value, pat=re.compile(r"^\d+x\d+$")): 92 | if not pat.match(arg_value): 93 | raise argparse.ArgumentTypeError("invalid value: x expected.") 94 | return arg_value 95 | 96 | 97 | def control_type(arg_value: str): 98 | if "=" in arg_value: 99 | return arg_value.split("=") 100 | else: 101 | raise argparse.ArgumentTypeError(f"invalid control: Missing value: {arg_value}") 102 | 103 | 104 | def orientation_type(arg_value): 105 | if arg_value in option_to_exif_orientation: 106 | return option_to_exif_orientation[arg_value] 107 | else: 108 | raise argparse.ArgumentTypeError( 109 | f"invalid value: unknown orientation {arg_value}." 110 | ) 111 | 112 | 113 | def parse_autofocus(arg_value): 114 | if arg_value == "manual": 115 | return libcamera.controls.AfModeEnum.Manual 116 | elif arg_value == "continuous": 117 | return libcamera.controls.AfModeEnum.Continuous 118 | else: 119 | raise argparse.ArgumentTypeError( 120 | "invalid value: manual or continuous expected." 121 | ) 122 | 123 | 124 | def parse_autofocus_speed(arg_value): 125 | if arg_value == "normal": 126 | return libcamera.controls.AfSpeedEnum.Normal 127 | elif arg_value == "fast": 128 | return libcamera.controls.AfSpeedEnum.Fast 129 | else: 130 | raise argparse.ArgumentTypeError("invalid value: normal or fast expected.") 131 | 132 | 133 | def split_resolution(res, check_limit=True): 134 | parts = res.split("x") 135 | w = int(parts[0]) 136 | h = int(parts[1]) 137 | if check_limit and (w > MAX_WIDTH or h > MAX_HEIGHT): 138 | raise argparse.ArgumentTypeError("Maximum supported resolution is 1920x1920") 139 | return w, h 140 | 141 | 142 | # endregion args parsers 143 | 144 | 145 | # region cli args 146 | 147 | 148 | def get_args(args): 149 | """Parse arguments passed in from shell.""" 150 | return get_parser().parse_args(args) 151 | 152 | 153 | def get_parser(): 154 | """Return ArgumentParser for hello cli.""" 155 | parser = argparse.ArgumentParser( 156 | allow_abbrev=True, 157 | prog="spyglass", 158 | description="Start a webserver for Picamera2 videostreams.", 159 | formatter_class=argparse.RawTextHelpFormatter, 160 | ) 161 | parser.add_argument( 162 | "-b", 163 | "--bindaddress", 164 | type=str, 165 | default="0.0.0.0", 166 | help="Bind to address for incoming " "connections", 167 | ) 168 | parser.add_argument( 169 | "-p", 170 | "--port", 171 | type=int, 172 | default=8080, 173 | help="Bind to port for incoming connections", 174 | ) 175 | parser.add_argument( 176 | "-r", 177 | "--resolution", 178 | type=resolution_type, 179 | default="640x480", 180 | help="Resolution of the images width x height. Maximum is 1920x1920.", 181 | ) 182 | parser.add_argument( 183 | "-f", "--fps", type=int, default=15, help="Frames per second to capture" 184 | ) 185 | parser.add_argument( 186 | "-st", 187 | "--stream_url", 188 | type=str, 189 | default="/stream", 190 | help="Sets the URL for the MJPG stream", 191 | ) 192 | parser.add_argument( 193 | "-sn", 194 | "--snapshot_url", 195 | type=str, 196 | default="/snapshot", 197 | help="Sets the URL for snapshots (single frame of stream)", 198 | ) 199 | parser.add_argument( 200 | "-sw", 201 | "--use_sw_jpg_encoding", 202 | action="store_true", 203 | help="Use software encoding for JPEG and MJPG (recommended on Pi5)", 204 | ) 205 | parser.add_argument( 206 | "-w", 207 | "--webrtc_url", 208 | type=str, 209 | default="/webrtc", 210 | help="Sets the URL for the WebRTC stream", 211 | ) 212 | parser.add_argument( 213 | "--disable_webrtc", 214 | action="store_true", 215 | help="Disables WebRTC encoding (recommended on Pi5)", 216 | ) 217 | parser.add_argument( 218 | "-af", 219 | "--autofocus", 220 | type=str, 221 | default="continuous", 222 | choices=["manual", "continuous"], 223 | help="Autofocus mode", 224 | ) 225 | parser.add_argument( 226 | "-l", 227 | "--lensposition", 228 | type=float, 229 | default=0.0, 230 | help="Set focal distance. 0 for infinite focus, 0.5 for approximate 50cm. " 231 | "Only used with Autofocus manual", 232 | ) 233 | parser.add_argument( 234 | "-s", 235 | "--autofocusspeed", 236 | type=str, 237 | default="normal", 238 | choices=["normal", "fast"], 239 | help="Autofocus speed. Only used with Autofocus continuous", 240 | ) 241 | parser.add_argument( 242 | "-ud", 243 | "--upsidedown", 244 | action="store_true", 245 | help="Rotate the image by 180° (sensor level)", 246 | ) 247 | parser.add_argument( 248 | "-fh", 249 | "--flip_horizontal", 250 | action="store_true", 251 | help="Mirror the image horizontally (sensor level)", 252 | ) 253 | parser.add_argument( 254 | "-fv", 255 | "--flip_vertical", 256 | action="store_true", 257 | help="Mirror the image vertically (sensor level)", 258 | ) 259 | parser.add_argument( 260 | "-or", 261 | "--orientation_exif", 262 | type=orientation_type, 263 | default="h", 264 | help="Set the image orientation using an EXIF header. This does not work with WebRTC:\n" 265 | " h - Horizontal (normal)\n" 266 | " mh - Mirror horizontal\n" 267 | " r180 - Rotate 180\n" 268 | " mv - Mirror vertical\n" 269 | " mhr270 - Mirror horizontal and rotate 270 CW\n" 270 | " r90 - Rotate 90 CW\n" 271 | " mhr90 - Mirror horizontal and rotate 90 CW\n" 272 | " r270 - Rotate 270 CW", 273 | ) 274 | parser.add_argument( 275 | "-c", 276 | "--controls", 277 | default=[], 278 | type=control_type, 279 | action="extend", 280 | nargs="*", 281 | help="Define camera controls to start with spyglass. " 282 | "Can be used multiple times.\n" 283 | "Format: =", 284 | ) 285 | parser.add_argument( 286 | "-cs", 287 | "--controls-string", 288 | default="", 289 | type=str, 290 | help="Define camera controls to start with spyglass. " 291 | "Input as a long string.\n" 292 | "Format: = =", 293 | ) 294 | parser.add_argument( 295 | "-tf", 296 | "--tuning_filter", 297 | type=str, 298 | default=None, 299 | nargs="?", 300 | const="", 301 | help="Set a tuning filter file name.", 302 | ) 303 | parser.add_argument( 304 | "-tfd", 305 | "--tuning_filter_dir", 306 | type=str, 307 | default=None, 308 | nargs="?", 309 | const="", 310 | help="Set the directory to look for tuning filters.", 311 | ) 312 | parser.add_argument( 313 | "--list-controls", 314 | action="store_true", 315 | help="List available camera controls and exits.", 316 | ) 317 | parser.add_argument( 318 | "-n", 319 | "--camera_num", 320 | type=int, 321 | default=0, 322 | help="Camera number to be used (Works with --list-controls)", 323 | ) 324 | return parser 325 | 326 | 327 | # endregion cli args 328 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spyglass 2 | 3 | > **Please note that we cannot support 32 bit systems. For more information please 4 | have a look at [this comment](https://github.com/mryel00/spyglass/issues/116#issuecomment-3361184578).** 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.1 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 | path: ~/spyglass 83 | origin: https://github.com/mryel00/spyglass.git 84 | primary_branch: main 85 | virtualenv: .venv 86 | requirements: requirements.txt 87 | system_dependencies: resources/system-dependencies.json 88 | managed_services: spyglass 89 | ``` 90 | > Make sure moonraker.asvc contains `spyglass` in the list: `cat ~/printer_data/moonraker.asvc | grep spyglass`. 91 | > If it is not there execute `make upgrade-moonraker` or add it manually 92 | 93 | ### Configuration 94 | 95 | After installation you should find a configuration file in `~/printer_data/config/spyglass.conf`.\ 96 | Please see [spyglass.conf](resources/spyglass.conf) for the default config file and [CLI arguments](#cli-arguments) for 97 | all available options. 98 | 99 | ### Restart the service 100 | 101 | To restart the service use `systemctl`: 102 | 103 | ```bash 104 | sudo systemctl restart spyglass 105 | ``` 106 | 107 | ## CLI arguments 108 | 109 | On startup the following arguments are supported: 110 | 111 | | Argument | Description | Default | 112 | |--------------------------------|------------------------------------------------------------------------------------------------------------------------------------|--------------| 113 | | `-b`, `--bindaddress` | Address where the server will listen for incoming connections. | `0.0.0.0` | 114 | | `-p`, `--port` | Port where the server will listen for incoming connections. | `8080` | 115 | | `-r`, `--resolution` | Resolution of the captured frames. This argument expects the format \x\. | `640x480` | 116 | | `-f`, `--fps` | Framerate in frames per second (FPS). | `15` | 117 | | `-st`, `--stream_url` | Set the URL for the mjpeg stream. | `/stream` | 118 | | `-sn`, `--snapshot_url` | Set the URL for snapshots (single frame of stream). | `/snapshot` | 119 | | `-w`, `--webrtc_url` | Set the URL for WebRTC (H264 compressed stream). | `/webrtc` | 120 | | `-af`, `--autofocus` | Autofocus mode. Supported modes: `manual`, `continuous`. | `continuous` | 121 | | `-l`, `--lensposition` | Set focal distance. 0 for infinite focus, 0.5 for approximate 50cm. Only used with Autofocus manual. | `0.0` | 122 | | `-s`, `--autofocusspeed` | Autofocus speed. Supported values: `normal`, `fast`. Only used with Autofocus continuous. | `normal` | 123 | | `-ud`, `--upsidedown` | Rotate the image by 180° (see [below](#image-orientation)). | | 124 | | `-fh`, `--flip_horizontal` | Mirror the image horizontally (see [below](#image-orientation)). | | 125 | | `-fv`, `--flip_vertical` | Mirror the image vertically (see [below](#image-orientation)). | | 126 | | `-or`, `--orientation_exif` | Set the image orientation using an EXIF header (see [below](#image-orientation)). | | 127 | | `-c`, `--controls` | Define camera controls to start spyglass with. Can be used multiple times. This argument expects the format \=\. | | 128 | | `-tf`, `--tuning_filter` | Set a tuning filter file name. | | 129 | | `-tfd`, `--tuning_filter_dir` | Set the directory to look for tuning filters. | | 130 | | `-n`, `--camera_num` | Camera number to be used. All cameras with their number can be shown with `libcamera-hello`. | `0` | 131 | | `-sw`, `--use_sw_jpg_encoding` | Use software encoding for JPEG and MJPG (recommended on Pi5). | | 132 | | `--disable_webrtc` | Disable WebRTC encoding (recommended on Pi5). | | 133 | | `--list-controls` | List all available libcamera controls onto the console. Those can be used with `--controls`. | | 134 | 135 | 136 | ## FAQ 137 | 138 | ### How can I add CLI arguments to my `spyglass.conf`? 139 | 140 | All supported CLI arguments are already inside the [defaul config](resources/spyglass.conf). 141 | If we add new arguments we will add them there, so please refer to it, if you want to use a new argument. 142 | 143 | In the following sections we will only refer to the CLI arguments but you can use the `spyglass.conf` for all these too. 144 | 145 | ### How to use resolutions higher than maximum resolution? 146 | 147 | Please note that the maximum recommended resolution is 1920x1080 (16:9). 148 | 149 | The absolute maximum resolution is 1920x1920. If you choose a higher resolution spyglass may stop with 150 | `Maximum supported resolution is 1920x1920`. This is limited by the hardware (HW) encoder of the Pis.\ 151 | You can disable this limit with `--use_sw_jpg_encoding` and `--disable_webrtc`, or the respective config in 152 | `spyglass.conf`, but it will take way more CPU resources to run the stream and WebRTC won't work anymore. 153 | Only a Pi5 you don't need to add `--disable_webrtc`, for further information please refer to 154 | [Pi5 recommendations](#pi5-recommendations). 155 | 156 | ### Why is the CPU load on my Pi5 so high? 157 | 158 | The Pi5 is the newest generation of Raspberry Pi SBCs but not all new things come with improvements. 159 | The Raspberry Pi foundation decided to remove the hardware (HW) encoders from the Pi5. 160 | This results in overall higher CPU usage on a Pi5 compared to previous generations. 161 | 162 | The following sections should only be followed on a Pi5.\ 163 | WebRTC is also a big toll on your CPU. Therefore you should use `--disable_webrtc`.\ 164 | To reduce the CPU usage further you should add `--use_sw_jpg_encoding` to make sure to use the optimized software (SW) 165 | encoder, instead of the HW encoder falling back to an unoptimized SW encoder. 166 | 167 | ### How can I rotate the image of my stream? 168 | 169 | There are two ways to change the image orientation. 170 | 171 | To use the ability of picamera2 to transform the image you can use the following options when starting spyglass: 172 | * `-ud` or `--upsidedown` - Rotate the image by 180° 173 | * `-fh` or `--flip_horizontal` - Mirror the image horizontally 174 | * `-fv` or `--flip_vertical` - Mirror the image vertically 175 | 176 | This will work with all endpoints Spyglass offers. 177 | 178 | Alternatively you can create an EXIF header to modify the image orientation. Most modern browsers should respect 179 | the exif header. This will only work for the MJPG and JPEG endpoints. 180 | 181 | Use the `-or` or `--orientation_exif` option and choose from one of the following orientations 182 | * `h` - Horizontal (normal) 183 | * `mh` - Mirror horizontal 184 | * `r180` - Rotate 180 185 | * `mv` - Mirror vertical 186 | * `mhr270` - Mirror horizontal and rotate 270 CW 187 | * `r90` - Rotate 90 CW 188 | * `mhr90` - Mirror horizontal and rotate 90 CW 189 | * `r270` - Rotate 270 CW 190 | 191 | For example to rotate the image 90 degree clockwise you would start spyglass the following way: 192 | ```bash 193 | ./run.py -or r90 194 | ``` 195 | 196 | ### How to apply tuning filter? 197 | Tuning filters are used to normalize or modify the camera image output, for example, using an NoIR camera can lead to a 198 | pink color, whether applying a filter to it you could remove its tone pink. More information here: 199 | https://github.com/raspberrypi/picamera2/blob/main/examples/tuning_file.py 200 | 201 | 202 | Predefined filters can be found at one of the picamera2 directories: 203 | - `~/libcamera/src/ipa/rpi/vc4/data` 204 | - `/usr/local/share/libcamera/ipa/rpi/vc4` 205 | - `/usr/share/libcamera/ipa/rpi/vc4` 206 | - `/usr/share/libcamera/ipa/raspberrypi` 207 | 208 | You can use all the files present in there in our config, e.g.: `--tuning_filter=ov5647_noir.json` 209 | 210 | You can also define your own directory for filters using the `--tuning_filter_dir` argument. 211 | 212 | ### How to use the WebRTC endpoint? 213 | 214 | Spyglass does not deliver a streaming client for WebRTC but only the endpoint. We are using the same WebRTC protocol as 215 | [MediaMTX](https://github.com/bluenviron/mediamtx). Therefore you need to use e.g. Mainsail or any other client capable 216 | of using the MediaMTX stream. 217 | 218 | ### How to use Spyglass with Mainsail? 219 | 220 | > Note: In the following section we assume default settings. 221 | 222 | If you want to use Spyglass as a webcam source for [Mainsail](https://github.com/mainsail-crew/Mainsail) add a webcam 223 | with the following configuration: 224 | 225 | - URL Stream: `/webcam/stream` 226 | - URL Snapshot: `/webcam/snapshot` 227 | - Service: `MJPEG-Streamer` 228 | 229 | Alternatively you can use WebRTC. This will take less network bandwidth and might help to fix low FPS: 230 | 231 | - URL Stream: `/webcam/webrtc` 232 | - URL Snapshot: `/webcam/snapshot` 233 | - Service: `WebRTC (MediaMTX)` 234 | 235 | WebRTC needs [aiortc](https://github.com/aiortc/aiortc) installed. This gets automatically installed with `make install` 236 | for further instructions, please see the [install](#installation) chapter below. 237 | 238 | ### How to use the controls endpoint? 239 | 240 | For the control endpoint please refer to [this](docs/camera-controls.md) 241 | 242 | ### How to start developing? 243 | 244 | If you want to setup your environment for development perform the following steps: 245 | 246 | Setup your Python virtual environment: 247 | ```bash 248 | python -m venv .venv # Create a new virtual environment 249 | . .venv/bin/activate # Activate virtual environment 250 | python -m pip install --upgrade pip # Upgrade PIP to the current version 251 | pip install -r requirements.txt # Install application dependencies 252 | pip install -r requirements-test.txt # Install test dependencies 253 | pip install -r requirements-dev.txt # Install development dependencies 254 | ``` 255 | 256 | The project uses [commitizen](https://github.com/commitizen-tools/commitizen) to check commit messages to comply with 257 | [Conventional Commits](http://conventionalcommits.org). When installing the development dependencies git-hooks will be 258 | set up to check commit messages pre commit. 259 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------