From 6c6847e54bee2ca761c7402d93822f63bc218954 Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Thu, 22 May 2025 14:18:41 +0200 Subject: [PATCH 01/44] Update dependencies in requirements.txt Upgraded multiple library versions to ensure compatibility and access to the latest features and fixes. This includes updates for `art`, `psutil`, `gpiozero`, `picamerax`, `beautifulsoup4`, `PyYAML`, and `requests`. --- requirements.txt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/requirements.txt b/requirements.txt index dbbbf26d..1f05c7bd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ -art==6.1 -psutil==5.9.5 -gpiozero==1.6.2 -picamerax==21.9.8 -beautifulsoup4==4.12.2 +art==6.5 +psutil==7.0.0 +gpiozero==2.0.1 +picamerax==24.3.21 +beautifulsoup4==4.13.4 RPi.GPIO==0.7.1 -PyYAML==6.0.1 -requests==2.31.0 +PyYAML==6.0.2 +requests== 2.32.3 From 8bf643dc0f33dac6870a23c8911625a297dda256 Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Thu, 22 May 2025 16:07:02 +0200 Subject: [PATCH 02/44] Add update_precommit script --- update_precommit.py | 260 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100755 update_precommit.py diff --git a/update_precommit.py b/update_precommit.py new file mode 100755 index 00000000..7b087644 --- /dev/null +++ b/update_precommit.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 + +import re +from abc import ABC, abstractmethod +from copy import deepcopy +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + +import requests +import yaml + +REPOSITORIES = "repos" +REPOSITORY = "repo" +MYPY_REPOSITORY = "https://github.com/pre-commit/mirrors-mypy" +HOOKS = "hooks" +ADDITIONAL_DEPENDENCIES = "additional_dependencies" + +CAPTURE_GROUP_URL = "url" +CAPTURE_GROUP_PACKAGE = "package" +CAPTURE_GROUP_VERSION = "version" + + +class AdditionalMypyDependency(ABC): + + @abstractmethod + def __hash__(self) -> int: + raise NotImplementedError + + @abstractmethod + def __eq__(self, other: object) -> bool: + raise NotImplementedError + + @abstractmethod + def serialize(self) -> str: + raise NotImplementedError + + +class Package(AdditionalMypyDependency): + @property + @abstractmethod + def name(self) -> str: + raise NotImplementedError + + @property + @abstractmethod + def version(self) -> str | None: + raise NotImplementedError + + def __hash__(self) -> int: + return hash((self.name, self.version)) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Package): + return False + return (self.name, self.version) == (other.name, other.version) + + +class TypeStubPackage(Package): + @property + def name(self) -> str: + return self._name + + @property + def version(self) -> str | None: + return self._version + + def __init__(self, name: str, version: str | None) -> None: + self._name = name + self._version = version + + def serialize(self) -> str: + return self.name + + +class NormalPackage(Package): + @property + def name(self) -> str: + return self._name + + @property + def version(self) -> str | None: + return self._version + + def __init__(self, name: str, version: str | None) -> None: + self._name = name + self._version = version + + def serialize(self) -> str: + if self.version: + return self.name + "==" + self.version + return self.name + + +@dataclass +class ExtraIndexUrl(AdditionalMypyDependency): + url: str + + def __hash__(self) -> int: + return hash(self.url) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, ExtraIndexUrl): + return False + return self.url == other.url + + def serialize(self) -> str: + return f"--extra-index-url={self.url}" + + +class CustomDumper(yaml.SafeDumper): + def increase_indent(self, flow: bool = False, indentless: bool = False) -> None: + return super(CustomDumper, self).increase_indent(flow, False) + + +def parse_multiple_requirements_file( + files: Iterable[Path], +) -> set[AdditionalMypyDependency]: + packages = set() + for _file in files: + packages.update(parse_requirements_file(_file)) + return packages + + +def parse_requirements_file(requirements_file: Path) -> set[AdditionalMypyDependency]: + """Parse requirements.txt and extract package names using regex.""" + with open(requirements_file, "r") as file: + lines = file.readlines() + + packages = set() + for line in lines: + line = line.strip() + if ( + line and not line.startswith("#") and line != "-r requirements.txt" + ): # Ignore empty lines, comments '-r requirements.txt' + package_name = parse_requirement(line) + if package_name: + packages.add(package_name) + + return packages + + +pattern_package = re.compile( + r"^(?!--extra-index-url)(?P[a-zA-Z0-9_\-\.]+)(?:[<>=~!]+(?P\S*))?" +) +pattern_extra_index_url = re.compile(r"^--extra-index-url\s+(?P\S+)") + + +def parse_requirement(requirement_line: str) -> AdditionalMypyDependency | None: + """Extract package name from a requirement line using regex.""" + # Regex pattern to capture the package name, ignoring version specifiers + if match_extra_index_url := pattern_extra_index_url.match(requirement_line): + return create_extra_index_url( + match_extra_index_url.group(CAPTURE_GROUP_URL).strip() + ) + + match = pattern_package.match(requirement_line) + if not match: + return None + + package_name = match.group(CAPTURE_GROUP_PACKAGE).strip() + if package_version := match.group(CAPTURE_GROUP_VERSION): + package_version = package_version.strip() + + return create_package(name=package_name, version=package_version) + + +def create_extra_index_url(url: str) -> AdditionalMypyDependency: + return ExtraIndexUrl(url) + + +def create_package(name: str, version: str | None) -> AdditionalMypyDependency: + """Check if a type stub exists for a given package name and return it.""" + types_package_name = f"types-{name}" + if __check_types_for_package_exists(types_package_name): + return create_type_stub_package(name=types_package_name, version=version) + + # Some packages already provide type stubs with their package + # If they don't pre-commit mypy won't fail + return create_normal_package(name=name, version=version) + + +def __check_types_for_package_exists(package_name: str) -> bool: + response = requests.get(f"https://pypi.org/pypi/{package_name}/json") + return response.status_code == 200 + + +def create_type_stub_package( + name: str, version: str | None +) -> AdditionalMypyDependency: + return TypeStubPackage(name=name, version=version) + + +def create_normal_package(name: str, version: str | None) -> AdditionalMypyDependency: + return NormalPackage(name=name, version=version) + + +def serialize_packages(packages: Iterable[AdditionalMypyDependency]) -> list[str]: + """Converts packages to a serializable format.""" + return sorted([package.serialize() for package in packages]) + + +def read_precommit_file(precommit_file: Path) -> dict: + with open(precommit_file, "r") as stream: + yaml_config = yaml.safe_load(stream) + return yaml_config + + +def update_precommit_config(config: dict, type_stubs: list[str]) -> dict: + updated_config = deepcopy(config) + for repo in updated_config[REPOSITORIES]: + if repo[REPOSITORY] == MYPY_REPOSITORY: + repo[HOOKS][0][ADDITIONAL_DEPENDENCIES] = type_stubs + break + return updated_config + + +def save_precommit_config(config: dict, save_path: Path) -> None: + with open(save_path, "w") as yaml_file: + yaml.dump( + data=config, + stream=yaml_file, + Dumper=CustomDumper, + explicit_start=True, + default_flow_style=False, + sort_keys=False, + ) + + +def display_available_type_stubs(type_stubs: list[str]) -> None: + if type_stubs: + print("\nType stubs that can be added to your pre-commit configuration:") + for stub in type_stubs: + print(f"- {stub}") + else: + print("\n No type stubs to be added to your pre-commit configuration.") + + +def type_stubs_have_changed(actual: dict, to_compare: dict) -> bool: + return actual != to_compare + + +def main() -> None: + requirements_file = Path("requirements.txt") + requirements_dev_file = Path("requirements-dev.txt") + precommit_file = Path(".pre-commit-config.yaml") + + additional_dependencies = parse_multiple_requirements_file( + [requirements_file, requirements_dev_file] + ) + serializable_dependencies = serialize_packages(additional_dependencies) + precommit_config = read_precommit_file(precommit_file) + updated_precommit_config = update_precommit_config( + precommit_config, serializable_dependencies + ) + save_precommit_config(updated_precommit_config, precommit_file) + + +if __name__ == "__main__": + main() \ No newline at end of file From d072bb1123798682e26c9ff9cfeff77ce1c2122a Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Thu, 22 May 2025 16:08:00 +0200 Subject: [PATCH 03/44] Sort entries in requirements.txt --- requirements.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements.txt b/requirements.txt index 1f05c7bd..b5e5ccfa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ art==6.5 -psutil==7.0.0 +beautifulsoup4==4.13.4 gpiozero==2.0.1 picamerax==24.3.21 -beautifulsoup4==4.13.4 -RPi.GPIO==0.7.1 +psutil==7.0.0 PyYAML==6.0.2 -requests== 2.32.3 +requests==2.32.3 +RPi.GPIO==0.7.1; platform_system != "Windows" and platform_system != "Darwin" From 4575a32d3bf1cf8e40c345c9cd7a648c4a48bcdf Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Thu, 22 May 2025 16:08:25 +0200 Subject: [PATCH 04/44] Pin dependencies in requirements-dev.txt --- requirements-dev.txt | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 94f77a9c..b3d8302a 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,10 @@ -flake8 -black -isort -pytest -pytest-cov +-r requirements.txt +black==25.1.0 +flake8==7.2.0 +hatch-requirements-txt==0.4.1 +isort==6.0.1 +mypy==1.15.0 +pre-commit==4.2.0 +pytest==8.3.5 +pytest-cov==6.1.1 +requests== 2.32.3 From 42ee4e9cb1dcd57369cabb13e285029416b3af37 Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Thu, 22 May 2025 16:13:30 +0200 Subject: [PATCH 05/44] Add missing newline in LED module docstring A newline was added to improve code readability and adhere to style guidelines in the LED module. No functional changes were made. Additionally, the bootstrap CSS file remained unchanged. --- .flake8 | 2 +- .vscode/license.code-snippets | 2 +- OTCamera/__main__.py | 1 + OTCamera/config.py | 1 + OTCamera/hardware/button.py | 1 + OTCamera/hardware/camera.py | 1 + OTCamera/hardware/led.py | 1 + OTCamera/helpers/filesystem.py | 1 + OTCamera/helpers/log.py | 1 + OTCamera/helpers/name.py | 1 + OTCamera/helpers/rpi.py | 1 + OTCamera/html_updater.py | 1 + OTCamera/record.py | 1 + OTCamera/status.py | 1 + raspi-files/install_sshrelay.sh | 1 - raspi-files/uninstall_otcamera.sh | 4 ++-- raspi-files/usr/local/bin/wifistart | 8 ++++---- update_precommit.py | 2 +- webfiles/css/bootstrap.min.css | 2 +- webfiles/template.html | 2 +- 20 files changed, 23 insertions(+), 12 deletions(-) diff --git a/.flake8 b/.flake8 index 1d36346c..2bcd70e3 100644 --- a/.flake8 +++ b/.flake8 @@ -1,2 +1,2 @@ [flake8] -max-line-length = 88 \ No newline at end of file +max-line-length = 88 diff --git a/.vscode/license.code-snippets b/.vscode/license.code-snippets index 9dcb7f8b..6f670c12 100644 --- a/.vscode/license.code-snippets +++ b/.vscode/license.code-snippets @@ -19,4 +19,4 @@ ], "description": "Add GPLv3 license information in source code." } -} \ No newline at end of file +} diff --git a/OTCamera/__main__.py b/OTCamera/__main__.py index 4ed9eb03..36f6c522 100644 --- a/OTCamera/__main__.py +++ b/OTCamera/__main__.py @@ -11,6 +11,7 @@ Stops everthing by keyboard interrupt (Ctrl+C). """ + # Copyright (C) 2023 OpenTrafficCam Contributors # # diff --git a/OTCamera/config.py b/OTCamera/config.py index 9341fa21..f855ef6c 100644 --- a/OTCamera/config.py +++ b/OTCamera/config.py @@ -3,6 +3,7 @@ All the configuration of OTCamera is done here. """ + # Copyright (C) 2023 OpenTrafficCam Contributors # # diff --git a/OTCamera/hardware/button.py b/OTCamera/hardware/button.py index 52eff8bc..d0dd174e 100644 --- a/OTCamera/hardware/button.py +++ b/OTCamera/hardware/button.py @@ -5,6 +5,7 @@ Also includes the basic logic behind button interactions. """ + # Copyright (C) 2023 OpenTrafficCam Contributors # # diff --git a/OTCamera/hardware/camera.py b/OTCamera/hardware/camera.py index 133fa68b..1f76ff02 100644 --- a/OTCamera/hardware/camera.py +++ b/OTCamera/hardware/camera.py @@ -3,6 +3,7 @@ Used to start, split and stop recording. """ + # Copyright (C) 2023 OpenTrafficCam Contributors # # diff --git a/OTCamera/hardware/led.py b/OTCamera/hardware/led.py index afae7a5d..6e8d21b1 100644 --- a/OTCamera/hardware/led.py +++ b/OTCamera/hardware/led.py @@ -4,6 +4,7 @@ blinking) of the LEDs. """ + # Copyright (C) 2023 OpenTrafficCam Contributors # # diff --git a/OTCamera/helpers/filesystem.py b/OTCamera/helpers/filesystem.py index 7dd6e514..e1a36acc 100644 --- a/OTCamera/helpers/filesystem.py +++ b/OTCamera/helpers/filesystem.py @@ -3,6 +3,7 @@ Check if enough filespace is available and delete old files until it's enough. """ + # Copyright (C) 2023 OpenTrafficCam Contributors # # diff --git a/OTCamera/helpers/log.py b/OTCamera/helpers/log.py index 7d84f319..dd3687ea 100644 --- a/OTCamera/helpers/log.py +++ b/OTCamera/helpers/log.py @@ -7,6 +7,7 @@ or log.otc() to log and print a OpenTrafficCam logo. """ + # Copyright (C) 2023 OpenTrafficCam Contributors # # diff --git a/OTCamera/helpers/name.py b/OTCamera/helpers/name.py index 26adbe9f..cccfd9bd 100644 --- a/OTCamera/helpers/name.py +++ b/OTCamera/helpers/name.py @@ -4,6 +4,7 @@ videofilename or the string to annotate the video. """ + # Copyright (C) 2023 OpenTrafficCam Contributors # # diff --git a/OTCamera/helpers/rpi.py b/OTCamera/helpers/rpi.py index ab8f2143..3bceb70f 100644 --- a/OTCamera/helpers/rpi.py +++ b/OTCamera/helpers/rpi.py @@ -3,6 +3,7 @@ Contains all functions to control the Raspberry Pi itself. """ + # Copyright (C) 2023 OpenTrafficCam Contributors # # diff --git a/OTCamera/html_updater.py b/OTCamera/html_updater.py index 736824cd..7f3134c3 100644 --- a/OTCamera/html_updater.py +++ b/OTCamera/html_updater.py @@ -20,6 +20,7 @@ - BANNER_DESC """ + # Copyright (C) 2023 OpenTrafficCam Contributors # # diff --git a/OTCamera/record.py b/OTCamera/record.py index 1bcd8b4e..c9f05ffa 100644 --- a/OTCamera/record.py +++ b/OTCamera/record.py @@ -4,6 +4,7 @@ It is configured by config.py. """ + # Copyright (C) 2023 OpenTrafficCam Contributors # # diff --git a/OTCamera/status.py b/OTCamera/status.py index af893189..38bc9e40 100644 --- a/OTCamera/status.py +++ b/OTCamera/status.py @@ -3,6 +3,7 @@ Contains all status variables and functions to be used across multiple modules. """ + # Copyright (C) 2023 OpenTrafficCam Contributors # # diff --git a/raspi-files/install_sshrelay.sh b/raspi-files/install_sshrelay.sh index e15724a9..675a266b 100644 --- a/raspi-files/install_sshrelay.sh +++ b/raspi-files/install_sshrelay.sh @@ -42,4 +42,3 @@ systemctl enable sshrelay.service echo "### please connect once to add host key using the following command:" echo "$SSHCMD" - diff --git a/raspi-files/uninstall_otcamera.sh b/raspi-files/uninstall_otcamera.sh index e5526981..a5848da3 100644 --- a/raspi-files/uninstall_otcamera.sh +++ b/raspi-files/uninstall_otcamera.sh @@ -6,7 +6,7 @@ OTCAMERA=$USER_HOME/"OTCamera" OTCSERVICE="/lib/systemd/system/otcamera.service" RCLOCAL="/etc/rc.local" NGINXDEFAULT="/etc/nginx/sites-available/default" -HOSTAPD_DIR="/etc/hostapd" +HOSTAPD_DIR="/etc/hostapd" DHCPCDCONF="/etc/dhcpcd.conf" HWCLOCK="/lib/udev/hwclock-set" @@ -78,7 +78,7 @@ apt remove gldriver-test libgl1-mesa-dri echo "Disable I2C bus for hwclock" raspi-config nonint do_i2c 1 -raspi-config nonint do_legacy 1 +raspi-config nonint do_legacy 1 systemctl reset-failed systemctl daemon-reload diff --git a/raspi-files/usr/local/bin/wifistart b/raspi-files/usr/local/bin/wifistart index 9ca24c87..23daae65 100644 --- a/raspi-files/usr/local/bin/wifistart +++ b/raspi-files/usr/local/bin/wifistart @@ -20,15 +20,15 @@ iw dev wlan0 interface add uap0 type __ap #ifconfig uap0 192.168.70.1 netmask 255.255.255.0 broadcast 192.168.70.255 ifconfig uap0 up -# Start hostapd. +# Start hostapd. echo "Starting hostapd service..." systemctl start hostapd.service -sleep 1 +sleep 1 -# Start dhcpcd. +# Start dhcpcd. echo "Starting dhcpcd service..." systemctl start dhcpcd.service -sleep 1 +sleep 1 echo "Starting dnsmasq service..." systemctl start dnsmasq.service diff --git a/update_precommit.py b/update_precommit.py index 7b087644..079c1697 100755 --- a/update_precommit.py +++ b/update_precommit.py @@ -257,4 +257,4 @@ def main() -> None: if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/webfiles/css/bootstrap.min.css b/webfiles/css/bootstrap.min.css index 1472dec0..278436bc 100644 --- a/webfiles/css/bootstrap.min.css +++ b/webfiles/css/bootstrap.min.css @@ -4,4 +4,4 @@ * Copyright 2011-2021 Twitter, Inc. * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) */:root{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-body-color-rgb:33,37,41;--bs-body-bg-rgb:255,255,255;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-bg:#fff}*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;background-color:currentColor;border:0;opacity:.25}hr:not([size]){height:1px}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}.h1,h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){.h1,h1{font-size:2.5rem}}.h2,h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){.h2,h2{font-size:2rem}}.h3,h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){.h3,h3{font-size:1.75rem}}.h4,h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){.h4,h4{font-size:1.5rem}}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[data-bs-original-title],abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}.small,small{font-size:.875em}.mark,mark{padding:.2em;background-color:#fcf8e3}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#0d6efd;text-decoration:underline}a:hover{color:#0a58ca}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:var(--bs-font-monospace);font-size:1em;direction:ltr;unicode-bidi:bidi-override}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:#d63384;word-wrap:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:.875em;color:#fff;background-color:#212529;border-radius:.2rem}kbd kbd{padding:0;font-size:1em;font-weight:700}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:#6c757d;text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]::-webkit-calendar-picker-indicator{display:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:textfield}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::-webkit-file-upload-button{font:inherit}::file-selector-button{font:inherit}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:calc(1.625rem + 4.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-1{font-size:5rem}}.display-2{font-size:calc(1.575rem + 3.9vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-2{font-size:4.5rem}}.display-3{font-size:calc(1.525rem + 3.3vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-3{font-size:4rem}}.display-4{font-size:calc(1.475rem + 2.7vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-4{font-size:3.5rem}}.display-5{font-size:calc(1.425rem + 2.1vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-5{font-size:3rem}}.display-6{font-size:calc(1.375rem + 1.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-6{font-size:2.5rem}}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:.875em;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote>:last-child{margin-bottom:0}.blockquote-footer{margin-top:-1rem;margin-bottom:1rem;font-size:.875em;color:#6c757d}.blockquote-footer::before{content:"— "}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:#fff;border:1px solid #dee2e6;border-radius:.25rem;max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:.875em;color:#6c757d}.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{width:100%;padding-right:var(--bs-gutter-x,.75rem);padding-left:var(--bs-gutter-x,.75rem);margin-right:auto;margin-left:auto}@media (min-width:576px){.container,.container-sm{max-width:540px}}@media (min-width:768px){.container,.container-md,.container-sm{max-width:720px}}@media (min-width:992px){.container,.container-lg,.container-md,.container-sm{max-width:960px}}@media (min-width:1200px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}@media (min-width:1400px){.container,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{max-width:1320px}}.row{--bs-gutter-x:1.5rem;--bs-gutter-y:0;display:flex;flex-wrap:wrap;margin-top:calc(-1 * var(--bs-gutter-y));margin-right:calc(-.5 * var(--bs-gutter-x));margin-left:calc(-.5 * var(--bs-gutter-x))}.row>*{flex-shrink:0;width:100%;max-width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-top:var(--bs-gutter-y)}.col{flex:1 0 0%}.row-cols-auto>*{flex:0 0 auto;width:auto}.row-cols-1>*{flex:0 0 auto;width:100%}.row-cols-2>*{flex:0 0 auto;width:50%}.row-cols-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-4>*{flex:0 0 auto;width:25%}.row-cols-5>*{flex:0 0 auto;width:20%}.row-cols-6>*{flex:0 0 auto;width:16.6666666667%}.col-auto{flex:0 0 auto;width:auto}.col-1{flex:0 0 auto;width:8.33333333%}.col-2{flex:0 0 auto;width:16.66666667%}.col-3{flex:0 0 auto;width:25%}.col-4{flex:0 0 auto;width:33.33333333%}.col-5{flex:0 0 auto;width:41.66666667%}.col-6{flex:0 0 auto;width:50%}.col-7{flex:0 0 auto;width:58.33333333%}.col-8{flex:0 0 auto;width:66.66666667%}.col-9{flex:0 0 auto;width:75%}.col-10{flex:0 0 auto;width:83.33333333%}.col-11{flex:0 0 auto;width:91.66666667%}.col-12{flex:0 0 auto;width:100%}.offset-1{margin-left:8.33333333%}.offset-2{margin-left:16.66666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.33333333%}.offset-5{margin-left:41.66666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.33333333%}.offset-8{margin-left:66.66666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.33333333%}.offset-11{margin-left:91.66666667%}.g-0,.gx-0{--bs-gutter-x:0}.g-0,.gy-0{--bs-gutter-y:0}.g-1,.gx-1{--bs-gutter-x:0.25rem}.g-1,.gy-1{--bs-gutter-y:0.25rem}.g-2,.gx-2{--bs-gutter-x:0.5rem}.g-2,.gy-2{--bs-gutter-y:0.5rem}.g-3,.gx-3{--bs-gutter-x:1rem}.g-3,.gy-3{--bs-gutter-y:1rem}.g-4,.gx-4{--bs-gutter-x:1.5rem}.g-4,.gy-4{--bs-gutter-y:1.5rem}.g-5,.gx-5{--bs-gutter-x:3rem}.g-5,.gy-5{--bs-gutter-y:3rem}@media (min-width:576px){.col-sm{flex:1 0 0%}.row-cols-sm-auto>*{flex:0 0 auto;width:auto}.row-cols-sm-1>*{flex:0 0 auto;width:100%}.row-cols-sm-2>*{flex:0 0 auto;width:50%}.row-cols-sm-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-sm-4>*{flex:0 0 auto;width:25%}.row-cols-sm-5>*{flex:0 0 auto;width:20%}.row-cols-sm-6>*{flex:0 0 auto;width:16.6666666667%}.col-sm-auto{flex:0 0 auto;width:auto}.col-sm-1{flex:0 0 auto;width:8.33333333%}.col-sm-2{flex:0 0 auto;width:16.66666667%}.col-sm-3{flex:0 0 auto;width:25%}.col-sm-4{flex:0 0 auto;width:33.33333333%}.col-sm-5{flex:0 0 auto;width:41.66666667%}.col-sm-6{flex:0 0 auto;width:50%}.col-sm-7{flex:0 0 auto;width:58.33333333%}.col-sm-8{flex:0 0 auto;width:66.66666667%}.col-sm-9{flex:0 0 auto;width:75%}.col-sm-10{flex:0 0 auto;width:83.33333333%}.col-sm-11{flex:0 0 auto;width:91.66666667%}.col-sm-12{flex:0 0 auto;width:100%}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.33333333%}.offset-sm-2{margin-left:16.66666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.33333333%}.offset-sm-5{margin-left:41.66666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.33333333%}.offset-sm-8{margin-left:66.66666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.33333333%}.offset-sm-11{margin-left:91.66666667%}.g-sm-0,.gx-sm-0{--bs-gutter-x:0}.g-sm-0,.gy-sm-0{--bs-gutter-y:0}.g-sm-1,.gx-sm-1{--bs-gutter-x:0.25rem}.g-sm-1,.gy-sm-1{--bs-gutter-y:0.25rem}.g-sm-2,.gx-sm-2{--bs-gutter-x:0.5rem}.g-sm-2,.gy-sm-2{--bs-gutter-y:0.5rem}.g-sm-3,.gx-sm-3{--bs-gutter-x:1rem}.g-sm-3,.gy-sm-3{--bs-gutter-y:1rem}.g-sm-4,.gx-sm-4{--bs-gutter-x:1.5rem}.g-sm-4,.gy-sm-4{--bs-gutter-y:1.5rem}.g-sm-5,.gx-sm-5{--bs-gutter-x:3rem}.g-sm-5,.gy-sm-5{--bs-gutter-y:3rem}}@media (min-width:768px){.col-md{flex:1 0 0%}.row-cols-md-auto>*{flex:0 0 auto;width:auto}.row-cols-md-1>*{flex:0 0 auto;width:100%}.row-cols-md-2>*{flex:0 0 auto;width:50%}.row-cols-md-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-md-4>*{flex:0 0 auto;width:25%}.row-cols-md-5>*{flex:0 0 auto;width:20%}.row-cols-md-6>*{flex:0 0 auto;width:16.6666666667%}.col-md-auto{flex:0 0 auto;width:auto}.col-md-1{flex:0 0 auto;width:8.33333333%}.col-md-2{flex:0 0 auto;width:16.66666667%}.col-md-3{flex:0 0 auto;width:25%}.col-md-4{flex:0 0 auto;width:33.33333333%}.col-md-5{flex:0 0 auto;width:41.66666667%}.col-md-6{flex:0 0 auto;width:50%}.col-md-7{flex:0 0 auto;width:58.33333333%}.col-md-8{flex:0 0 auto;width:66.66666667%}.col-md-9{flex:0 0 auto;width:75%}.col-md-10{flex:0 0 auto;width:83.33333333%}.col-md-11{flex:0 0 auto;width:91.66666667%}.col-md-12{flex:0 0 auto;width:100%}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.33333333%}.offset-md-2{margin-left:16.66666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.33333333%}.offset-md-5{margin-left:41.66666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.33333333%}.offset-md-8{margin-left:66.66666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.33333333%}.offset-md-11{margin-left:91.66666667%}.g-md-0,.gx-md-0{--bs-gutter-x:0}.g-md-0,.gy-md-0{--bs-gutter-y:0}.g-md-1,.gx-md-1{--bs-gutter-x:0.25rem}.g-md-1,.gy-md-1{--bs-gutter-y:0.25rem}.g-md-2,.gx-md-2{--bs-gutter-x:0.5rem}.g-md-2,.gy-md-2{--bs-gutter-y:0.5rem}.g-md-3,.gx-md-3{--bs-gutter-x:1rem}.g-md-3,.gy-md-3{--bs-gutter-y:1rem}.g-md-4,.gx-md-4{--bs-gutter-x:1.5rem}.g-md-4,.gy-md-4{--bs-gutter-y:1.5rem}.g-md-5,.gx-md-5{--bs-gutter-x:3rem}.g-md-5,.gy-md-5{--bs-gutter-y:3rem}}@media (min-width:992px){.col-lg{flex:1 0 0%}.row-cols-lg-auto>*{flex:0 0 auto;width:auto}.row-cols-lg-1>*{flex:0 0 auto;width:100%}.row-cols-lg-2>*{flex:0 0 auto;width:50%}.row-cols-lg-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-lg-4>*{flex:0 0 auto;width:25%}.row-cols-lg-5>*{flex:0 0 auto;width:20%}.row-cols-lg-6>*{flex:0 0 auto;width:16.6666666667%}.col-lg-auto{flex:0 0 auto;width:auto}.col-lg-1{flex:0 0 auto;width:8.33333333%}.col-lg-2{flex:0 0 auto;width:16.66666667%}.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.33333333%}.col-lg-5{flex:0 0 auto;width:41.66666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.33333333%}.col-lg-8{flex:0 0 auto;width:66.66666667%}.col-lg-9{flex:0 0 auto;width:75%}.col-lg-10{flex:0 0 auto;width:83.33333333%}.col-lg-11{flex:0 0 auto;width:91.66666667%}.col-lg-12{flex:0 0 auto;width:100%}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.33333333%}.offset-lg-2{margin-left:16.66666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.33333333%}.offset-lg-5{margin-left:41.66666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.33333333%}.offset-lg-8{margin-left:66.66666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.33333333%}.offset-lg-11{margin-left:91.66666667%}.g-lg-0,.gx-lg-0{--bs-gutter-x:0}.g-lg-0,.gy-lg-0{--bs-gutter-y:0}.g-lg-1,.gx-lg-1{--bs-gutter-x:0.25rem}.g-lg-1,.gy-lg-1{--bs-gutter-y:0.25rem}.g-lg-2,.gx-lg-2{--bs-gutter-x:0.5rem}.g-lg-2,.gy-lg-2{--bs-gutter-y:0.5rem}.g-lg-3,.gx-lg-3{--bs-gutter-x:1rem}.g-lg-3,.gy-lg-3{--bs-gutter-y:1rem}.g-lg-4,.gx-lg-4{--bs-gutter-x:1.5rem}.g-lg-4,.gy-lg-4{--bs-gutter-y:1.5rem}.g-lg-5,.gx-lg-5{--bs-gutter-x:3rem}.g-lg-5,.gy-lg-5{--bs-gutter-y:3rem}}@media (min-width:1200px){.col-xl{flex:1 0 0%}.row-cols-xl-auto>*{flex:0 0 auto;width:auto}.row-cols-xl-1>*{flex:0 0 auto;width:100%}.row-cols-xl-2>*{flex:0 0 auto;width:50%}.row-cols-xl-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-xl-4>*{flex:0 0 auto;width:25%}.row-cols-xl-5>*{flex:0 0 auto;width:20%}.row-cols-xl-6>*{flex:0 0 auto;width:16.6666666667%}.col-xl-auto{flex:0 0 auto;width:auto}.col-xl-1{flex:0 0 auto;width:8.33333333%}.col-xl-2{flex:0 0 auto;width:16.66666667%}.col-xl-3{flex:0 0 auto;width:25%}.col-xl-4{flex:0 0 auto;width:33.33333333%}.col-xl-5{flex:0 0 auto;width:41.66666667%}.col-xl-6{flex:0 0 auto;width:50%}.col-xl-7{flex:0 0 auto;width:58.33333333%}.col-xl-8{flex:0 0 auto;width:66.66666667%}.col-xl-9{flex:0 0 auto;width:75%}.col-xl-10{flex:0 0 auto;width:83.33333333%}.col-xl-11{flex:0 0 auto;width:91.66666667%}.col-xl-12{flex:0 0 auto;width:100%}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.33333333%}.offset-xl-2{margin-left:16.66666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.33333333%}.offset-xl-5{margin-left:41.66666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.33333333%}.offset-xl-8{margin-left:66.66666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.33333333%}.offset-xl-11{margin-left:91.66666667%}.g-xl-0,.gx-xl-0{--bs-gutter-x:0}.g-xl-0,.gy-xl-0{--bs-gutter-y:0}.g-xl-1,.gx-xl-1{--bs-gutter-x:0.25rem}.g-xl-1,.gy-xl-1{--bs-gutter-y:0.25rem}.g-xl-2,.gx-xl-2{--bs-gutter-x:0.5rem}.g-xl-2,.gy-xl-2{--bs-gutter-y:0.5rem}.g-xl-3,.gx-xl-3{--bs-gutter-x:1rem}.g-xl-3,.gy-xl-3{--bs-gutter-y:1rem}.g-xl-4,.gx-xl-4{--bs-gutter-x:1.5rem}.g-xl-4,.gy-xl-4{--bs-gutter-y:1.5rem}.g-xl-5,.gx-xl-5{--bs-gutter-x:3rem}.g-xl-5,.gy-xl-5{--bs-gutter-y:3rem}}@media (min-width:1400px){.col-xxl{flex:1 0 0%}.row-cols-xxl-auto>*{flex:0 0 auto;width:auto}.row-cols-xxl-1>*{flex:0 0 auto;width:100%}.row-cols-xxl-2>*{flex:0 0 auto;width:50%}.row-cols-xxl-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-xxl-4>*{flex:0 0 auto;width:25%}.row-cols-xxl-5>*{flex:0 0 auto;width:20%}.row-cols-xxl-6>*{flex:0 0 auto;width:16.6666666667%}.col-xxl-auto{flex:0 0 auto;width:auto}.col-xxl-1{flex:0 0 auto;width:8.33333333%}.col-xxl-2{flex:0 0 auto;width:16.66666667%}.col-xxl-3{flex:0 0 auto;width:25%}.col-xxl-4{flex:0 0 auto;width:33.33333333%}.col-xxl-5{flex:0 0 auto;width:41.66666667%}.col-xxl-6{flex:0 0 auto;width:50%}.col-xxl-7{flex:0 0 auto;width:58.33333333%}.col-xxl-8{flex:0 0 auto;width:66.66666667%}.col-xxl-9{flex:0 0 auto;width:75%}.col-xxl-10{flex:0 0 auto;width:83.33333333%}.col-xxl-11{flex:0 0 auto;width:91.66666667%}.col-xxl-12{flex:0 0 auto;width:100%}.offset-xxl-0{margin-left:0}.offset-xxl-1{margin-left:8.33333333%}.offset-xxl-2{margin-left:16.66666667%}.offset-xxl-3{margin-left:25%}.offset-xxl-4{margin-left:33.33333333%}.offset-xxl-5{margin-left:41.66666667%}.offset-xxl-6{margin-left:50%}.offset-xxl-7{margin-left:58.33333333%}.offset-xxl-8{margin-left:66.66666667%}.offset-xxl-9{margin-left:75%}.offset-xxl-10{margin-left:83.33333333%}.offset-xxl-11{margin-left:91.66666667%}.g-xxl-0,.gx-xxl-0{--bs-gutter-x:0}.g-xxl-0,.gy-xxl-0{--bs-gutter-y:0}.g-xxl-1,.gx-xxl-1{--bs-gutter-x:0.25rem}.g-xxl-1,.gy-xxl-1{--bs-gutter-y:0.25rem}.g-xxl-2,.gx-xxl-2{--bs-gutter-x:0.5rem}.g-xxl-2,.gy-xxl-2{--bs-gutter-y:0.5rem}.g-xxl-3,.gx-xxl-3{--bs-gutter-x:1rem}.g-xxl-3,.gy-xxl-3{--bs-gutter-y:1rem}.g-xxl-4,.gx-xxl-4{--bs-gutter-x:1.5rem}.g-xxl-4,.gy-xxl-4{--bs-gutter-y:1.5rem}.g-xxl-5,.gx-xxl-5{--bs-gutter-x:3rem}.g-xxl-5,.gy-xxl-5{--bs-gutter-y:3rem}}.table{--bs-table-bg:transparent;--bs-table-accent-bg:transparent;--bs-table-striped-color:#212529;--bs-table-striped-bg:rgba(0, 0, 0, 0.05);--bs-table-active-color:#212529;--bs-table-active-bg:rgba(0, 0, 0, 0.1);--bs-table-hover-color:#212529;--bs-table-hover-bg:rgba(0, 0, 0, 0.075);width:100%;margin-bottom:1rem;color:#212529;vertical-align:top;border-color:#dee2e6}.table>:not(caption)>*>*{padding:.5rem .5rem;background-color:var(--bs-table-bg);border-bottom-width:1px;box-shadow:inset 0 0 0 9999px var(--bs-table-accent-bg)}.table>tbody{vertical-align:inherit}.table>thead{vertical-align:bottom}.table>:not(:first-child){border-top:2px solid currentColor}.caption-top{caption-side:top}.table-sm>:not(caption)>*>*{padding:.25rem .25rem}.table-bordered>:not(caption)>*{border-width:1px 0}.table-bordered>:not(caption)>*>*{border-width:0 1px}.table-borderless>:not(caption)>*>*{border-bottom-width:0}.table-borderless>:not(:first-child){border-top-width:0}.table-striped>tbody>tr:nth-of-type(odd)>*{--bs-table-accent-bg:var(--bs-table-striped-bg);color:var(--bs-table-striped-color)}.table-active{--bs-table-accent-bg:var(--bs-table-active-bg);color:var(--bs-table-active-color)}.table-hover>tbody>tr:hover>*{--bs-table-accent-bg:var(--bs-table-hover-bg);color:var(--bs-table-hover-color)}.table-primary{--bs-table-bg:#cfe2ff;--bs-table-striped-bg:#c5d7f2;--bs-table-striped-color:#000;--bs-table-active-bg:#bacbe6;--bs-table-active-color:#000;--bs-table-hover-bg:#bfd1ec;--bs-table-hover-color:#000;color:#000;border-color:#bacbe6}.table-secondary{--bs-table-bg:#e2e3e5;--bs-table-striped-bg:#d7d8da;--bs-table-striped-color:#000;--bs-table-active-bg:#cbccce;--bs-table-active-color:#000;--bs-table-hover-bg:#d1d2d4;--bs-table-hover-color:#000;color:#000;border-color:#cbccce}.table-success{--bs-table-bg:#d1e7dd;--bs-table-striped-bg:#c7dbd2;--bs-table-striped-color:#000;--bs-table-active-bg:#bcd0c7;--bs-table-active-color:#000;--bs-table-hover-bg:#c1d6cc;--bs-table-hover-color:#000;color:#000;border-color:#bcd0c7}.table-info{--bs-table-bg:#cff4fc;--bs-table-striped-bg:#c5e8ef;--bs-table-striped-color:#000;--bs-table-active-bg:#badce3;--bs-table-active-color:#000;--bs-table-hover-bg:#bfe2e9;--bs-table-hover-color:#000;color:#000;border-color:#badce3}.table-warning{--bs-table-bg:#fff3cd;--bs-table-striped-bg:#f2e7c3;--bs-table-striped-color:#000;--bs-table-active-bg:#e6dbb9;--bs-table-active-color:#000;--bs-table-hover-bg:#ece1be;--bs-table-hover-color:#000;color:#000;border-color:#e6dbb9}.table-danger{--bs-table-bg:#f8d7da;--bs-table-striped-bg:#eccccf;--bs-table-striped-color:#000;--bs-table-active-bg:#dfc2c4;--bs-table-active-color:#000;--bs-table-hover-bg:#e5c7ca;--bs-table-hover-color:#000;color:#000;border-color:#dfc2c4}.table-light{--bs-table-bg:#f8f9fa;--bs-table-striped-bg:#ecedee;--bs-table-striped-color:#000;--bs-table-active-bg:#dfe0e1;--bs-table-active-color:#000;--bs-table-hover-bg:#e5e6e7;--bs-table-hover-color:#000;color:#000;border-color:#dfe0e1}.table-dark{--bs-table-bg:#212529;--bs-table-striped-bg:#2c3034;--bs-table-striped-color:#fff;--bs-table-active-bg:#373b3e;--bs-table-active-color:#fff;--bs-table-hover-bg:#323539;--bs-table-hover-color:#fff;color:#fff;border-color:#373b3e}.table-responsive{overflow-x:auto;-webkit-overflow-scrolling:touch}@media (max-width:575.98px){.table-responsive-sm{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:767.98px){.table-responsive-md{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:991.98px){.table-responsive-lg{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1199.98px){.table-responsive-xl{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1399.98px){.table-responsive-xxl{overflow-x:auto;-webkit-overflow-scrolling:touch}}.form-label{margin-bottom:.5rem}.col-form-label{padding-top:calc(.375rem + 1px);padding-bottom:calc(.375rem + 1px);margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + 1px);padding-bottom:calc(.5rem + 1px);font-size:1.25rem}.col-form-label-sm{padding-top:calc(.25rem + 1px);padding-bottom:calc(.25rem + 1px);font-size:.875rem}.form-text{margin-top:.25rem;font-size:.875em;color:#6c757d}.form-control{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#212529;background-color:#fff;background-clip:padding-box;border:1px solid #ced4da;-webkit-appearance:none;-moz-appearance:none;appearance:none;border-radius:.25rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control[type=file]{overflow:hidden}.form-control[type=file]:not(:disabled):not([readonly]){cursor:pointer}.form-control:focus{color:#212529;background-color:#fff;border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-control::-webkit-date-and-time-value{height:1.5em}.form-control::-moz-placeholder{color:#6c757d;opacity:1}.form-control::placeholder{color:#6c757d;opacity:1}.form-control:disabled,.form-control[readonly]{background-color:#e9ecef;opacity:1}.form-control::-webkit-file-upload-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:#212529;background-color:#e9ecef;pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:1px;border-radius:0;-webkit-transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}.form-control::file-selector-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:#212529;background-color:#e9ecef;pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:1px;border-radius:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::-webkit-file-upload-button{-webkit-transition:none;transition:none}.form-control::file-selector-button{transition:none}}.form-control:hover:not(:disabled):not([readonly])::-webkit-file-upload-button{background-color:#dde0e3}.form-control:hover:not(:disabled):not([readonly])::file-selector-button{background-color:#dde0e3}.form-control::-webkit-file-upload-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:#212529;background-color:#e9ecef;pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:1px;border-radius:0;-webkit-transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::-webkit-file-upload-button{-webkit-transition:none;transition:none}}.form-control:hover:not(:disabled):not([readonly])::-webkit-file-upload-button{background-color:#dde0e3}.form-control-plaintext{display:block;width:100%;padding:.375rem 0;margin-bottom:0;line-height:1.5;color:#212529;background-color:transparent;border:solid transparent;border-width:1px 0}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-right:0;padding-left:0}.form-control-sm{min-height:calc(1.5em + .5rem + 2px);padding:.25rem .5rem;font-size:.875rem;border-radius:.2rem}.form-control-sm::-webkit-file-upload-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-sm::file-selector-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-sm::-webkit-file-upload-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-lg{min-height:calc(1.5em + 1rem + 2px);padding:.5rem 1rem;font-size:1.25rem;border-radius:.3rem}.form-control-lg::-webkit-file-upload-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}.form-control-lg::file-selector-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}.form-control-lg::-webkit-file-upload-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}textarea.form-control{min-height:calc(1.5em + .75rem + 2px)}textarea.form-control-sm{min-height:calc(1.5em + .5rem + 2px)}textarea.form-control-lg{min-height:calc(1.5em + 1rem + 2px)}.form-control-color{width:3rem;height:auto;padding:.375rem}.form-control-color:not(:disabled):not([readonly]){cursor:pointer}.form-control-color::-moz-color-swatch{height:1.5em;border-radius:.25rem}.form-control-color::-webkit-color-swatch{height:1.5em;border-radius:.25rem}.form-select{display:block;width:100%;padding:.375rem 2.25rem .375rem .75rem;-moz-padding-start:calc(0.75rem - 3px);font-size:1rem;font-weight:400;line-height:1.5;color:#212529;background-color:#fff;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right .75rem center;background-size:16px 12px;border:1px solid #ced4da;border-radius:.25rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-appearance:none;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-select{transition:none}}.form-select:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-select[multiple],.form-select[size]:not([size="1"]){padding-right:.75rem;background-image:none}.form-select:disabled{background-color:#e9ecef}.form-select:-moz-focusring{color:transparent;text-shadow:0 0 0 #212529}.form-select-sm{padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;font-size:.875rem;border-radius:.2rem}.form-select-lg{padding-top:.5rem;padding-bottom:.5rem;padding-left:1rem;font-size:1.25rem;border-radius:.3rem}.form-check{display:block;min-height:1.5rem;padding-left:1.5em;margin-bottom:.125rem}.form-check .form-check-input{float:left;margin-left:-1.5em}.form-check-input{width:1em;height:1em;margin-top:.25em;vertical-align:top;background-color:#fff;background-repeat:no-repeat;background-position:center;background-size:contain;border:1px solid rgba(0,0,0,.25);-webkit-appearance:none;-moz-appearance:none;appearance:none;-webkit-print-color-adjust:exact;color-adjust:exact}.form-check-input[type=checkbox]{border-radius:.25em}.form-check-input[type=radio]{border-radius:50%}.form-check-input:active{filter:brightness(90%)}.form-check-input:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-check-input:checked{background-color:#0d6efd;border-color:#0d6efd}.form-check-input:checked[type=checkbox]{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10l3 3l6-6'/%3e%3c/svg%3e")}.form-check-input:checked[type=radio]{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e")}.form-check-input[type=checkbox]:indeterminate{background-color:#0d6efd;border-color:#0d6efd;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e")}.form-check-input:disabled{pointer-events:none;filter:none;opacity:.5}.form-check-input:disabled~.form-check-label,.form-check-input[disabled]~.form-check-label{opacity:.5}.form-switch{padding-left:2.5em}.form-switch .form-check-input{width:2em;margin-left:-2.5em;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e");background-position:left center;border-radius:2em;transition:background-position .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-switch .form-check-input{transition:none}}.form-switch .form-check-input:focus{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2386b7fe'/%3e%3c/svg%3e")}.form-switch .form-check-input:checked{background-position:right center;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.form-check-inline{display:inline-block;margin-right:1rem}.btn-check{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.btn-check:disabled+.btn,.btn-check[disabled]+.btn{pointer-events:none;filter:none;opacity:.65}.form-range{width:100%;height:1.5rem;padding:0;background-color:transparent;-webkit-appearance:none;-moz-appearance:none;appearance:none}.form-range:focus{outline:0}.form-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range::-moz-focus-outer{border:0}.form-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;background-color:#0d6efd;border:0;border-radius:1rem;-webkit-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.form-range::-webkit-slider-thumb:active{background-color:#b6d4fe}.form-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.form-range::-moz-range-thumb{width:1rem;height:1rem;background-color:#0d6efd;border:0;border-radius:1rem;-moz-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-range::-moz-range-thumb{-moz-transition:none;transition:none}}.form-range::-moz-range-thumb:active{background-color:#b6d4fe}.form-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.form-range:disabled{pointer-events:none}.form-range:disabled::-webkit-slider-thumb{background-color:#adb5bd}.form-range:disabled::-moz-range-thumb{background-color:#adb5bd}.form-floating{position:relative}.form-floating>.form-control,.form-floating>.form-select{height:calc(3.5rem + 2px);line-height:1.25}.form-floating>label{position:absolute;top:0;left:0;height:100%;padding:1rem .75rem;pointer-events:none;border:1px solid transparent;transform-origin:0 0;transition:opacity .1s ease-in-out,transform .1s ease-in-out}@media (prefers-reduced-motion:reduce){.form-floating>label{transition:none}}.form-floating>.form-control{padding:1rem .75rem}.form-floating>.form-control::-moz-placeholder{color:transparent}.form-floating>.form-control::placeholder{color:transparent}.form-floating>.form-control:not(:-moz-placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:focus,.form-floating>.form-control:not(:placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:-webkit-autofill{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-select{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:not(:-moz-placeholder-shown)~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control:focus~label,.form-floating>.form-control:not(:placeholder-shown)~label,.form-floating>.form-select~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control:-webkit-autofill~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.input-group{position:relative;display:flex;flex-wrap:wrap;align-items:stretch;width:100%}.input-group>.form-control,.input-group>.form-select{position:relative;flex:1 1 auto;width:1%;min-width:0}.input-group>.form-control:focus,.input-group>.form-select:focus{z-index:3}.input-group .btn{position:relative;z-index:2}.input-group .btn:focus{z-index:3}.input-group-text{display:flex;align-items:center;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#212529;text-align:center;white-space:nowrap;background-color:#e9ecef;border:1px solid #ced4da;border-radius:.25rem}.input-group-lg>.btn,.input-group-lg>.form-control,.input-group-lg>.form-select,.input-group-lg>.input-group-text{padding:.5rem 1rem;font-size:1.25rem;border-radius:.3rem}.input-group-sm>.btn,.input-group-sm>.form-control,.input-group-sm>.form-select,.input-group-sm>.input-group-text{padding:.25rem .5rem;font-size:.875rem;border-radius:.2rem}.input-group-lg>.form-select,.input-group-sm>.form-select{padding-right:3rem}.input-group:not(.has-validation)>.dropdown-toggle:nth-last-child(n+3),.input-group:not(.has-validation)>:not(:last-child):not(.dropdown-toggle):not(.dropdown-menu){border-top-right-radius:0;border-bottom-right-radius:0}.input-group.has-validation>.dropdown-toggle:nth-last-child(n+4),.input-group.has-validation>:nth-last-child(n+3):not(.dropdown-toggle):not(.dropdown-menu){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback){margin-left:-1px;border-top-left-radius:0;border-bottom-left-radius:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:#198754}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:rgba(25,135,84,.9);border-radius:.25rem}.is-valid~.valid-feedback,.is-valid~.valid-tooltip,.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip{display:block}.form-control.is-valid,.was-validated .form-control:valid{border-color:#198754;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:#198754;box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-valid,.was-validated .form-select:valid{border-color:#198754}.form-select.is-valid:not([multiple]):not([size]),.form-select.is-valid:not([multiple])[size="1"],.was-validated .form-select:valid:not([multiple]):not([size]),.was-validated .form-select:valid:not([multiple])[size="1"]{padding-right:4.125rem;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e"),url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-valid:focus,.was-validated .form-select:valid:focus{border-color:#198754;box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.form-check-input.is-valid,.was-validated .form-check-input:valid{border-color:#198754}.form-check-input.is-valid:checked,.was-validated .form-check-input:valid:checked{background-color:#198754}.form-check-input.is-valid:focus,.was-validated .form-check-input:valid:focus{box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:#198754}.form-check-inline .form-check-input~.valid-feedback{margin-left:.5em}.input-group .form-control.is-valid,.input-group .form-select.is-valid,.was-validated .input-group .form-control:valid,.was-validated .input-group .form-select:valid{z-index:1}.input-group .form-control.is-valid:focus,.input-group .form-select.is-valid:focus,.was-validated .input-group .form-control:valid:focus,.was-validated .input-group .form-select:valid:focus{z-index:3}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:#dc3545}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:rgba(220,53,69,.9);border-radius:.25rem}.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip,.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip{display:block}.form-control.is-invalid,.was-validated .form-control:invalid{border-color:#dc3545;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-invalid,.was-validated .form-select:invalid{border-color:#dc3545}.form-select.is-invalid:not([multiple]):not([size]),.form-select.is-invalid:not([multiple])[size="1"],.was-validated .form-select:invalid:not([multiple]):not([size]),.was-validated .form-select:invalid:not([multiple])[size="1"]{padding-right:4.125rem;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e"),url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-invalid:focus,.was-validated .form-select:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.form-check-input.is-invalid,.was-validated .form-check-input:invalid{border-color:#dc3545}.form-check-input.is-invalid:checked,.was-validated .form-check-input:invalid:checked{background-color:#dc3545}.form-check-input.is-invalid:focus,.was-validated .form-check-input:invalid:focus{box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:#dc3545}.form-check-inline .form-check-input~.invalid-feedback{margin-left:.5em}.input-group .form-control.is-invalid,.input-group .form-select.is-invalid,.was-validated .input-group .form-control:invalid,.was-validated .input-group .form-select:invalid{z-index:2}.input-group .form-control.is-invalid:focus,.input-group .form-select.is-invalid:focus,.was-validated .input-group .form-control:invalid:focus,.was-validated .input-group .form-select:invalid:focus{z-index:3}.btn{display:inline-block;font-weight:400;line-height:1.5;color:#212529;text-align:center;text-decoration:none;vertical-align:middle;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;background-color:transparent;border:1px solid transparent;padding:.375rem .75rem;font-size:1rem;border-radius:.25rem;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:#212529}.btn-check:focus+.btn,.btn:focus{outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.btn.disabled,.btn:disabled,fieldset:disabled .btn{pointer-events:none;opacity:.65}.btn-primary{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-primary:hover{color:#fff;background-color:#0b5ed7;border-color:#0a58ca}.btn-check:focus+.btn-primary,.btn-primary:focus{color:#fff;background-color:#0b5ed7;border-color:#0a58ca;box-shadow:0 0 0 .25rem rgba(49,132,253,.5)}.btn-check:active+.btn-primary,.btn-check:checked+.btn-primary,.btn-primary.active,.btn-primary:active,.show>.btn-primary.dropdown-toggle{color:#fff;background-color:#0a58ca;border-color:#0a53be}.btn-check:active+.btn-primary:focus,.btn-check:checked+.btn-primary:focus,.btn-primary.active:focus,.btn-primary:active:focus,.show>.btn-primary.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(49,132,253,.5)}.btn-primary.disabled,.btn-primary:disabled{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-secondary{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-secondary:hover{color:#fff;background-color:#5c636a;border-color:#565e64}.btn-check:focus+.btn-secondary,.btn-secondary:focus{color:#fff;background-color:#5c636a;border-color:#565e64;box-shadow:0 0 0 .25rem rgba(130,138,145,.5)}.btn-check:active+.btn-secondary,.btn-check:checked+.btn-secondary,.btn-secondary.active,.btn-secondary:active,.show>.btn-secondary.dropdown-toggle{color:#fff;background-color:#565e64;border-color:#51585e}.btn-check:active+.btn-secondary:focus,.btn-check:checked+.btn-secondary:focus,.btn-secondary.active:focus,.btn-secondary:active:focus,.show>.btn-secondary.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(130,138,145,.5)}.btn-secondary.disabled,.btn-secondary:disabled{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-success{color:#fff;background-color:#198754;border-color:#198754}.btn-success:hover{color:#fff;background-color:#157347;border-color:#146c43}.btn-check:focus+.btn-success,.btn-success:focus{color:#fff;background-color:#157347;border-color:#146c43;box-shadow:0 0 0 .25rem rgba(60,153,110,.5)}.btn-check:active+.btn-success,.btn-check:checked+.btn-success,.btn-success.active,.btn-success:active,.show>.btn-success.dropdown-toggle{color:#fff;background-color:#146c43;border-color:#13653f}.btn-check:active+.btn-success:focus,.btn-check:checked+.btn-success:focus,.btn-success.active:focus,.btn-success:active:focus,.show>.btn-success.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(60,153,110,.5)}.btn-success.disabled,.btn-success:disabled{color:#fff;background-color:#198754;border-color:#198754}.btn-info{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-info:hover{color:#000;background-color:#31d2f2;border-color:#25cff2}.btn-check:focus+.btn-info,.btn-info:focus{color:#000;background-color:#31d2f2;border-color:#25cff2;box-shadow:0 0 0 .25rem rgba(11,172,204,.5)}.btn-check:active+.btn-info,.btn-check:checked+.btn-info,.btn-info.active,.btn-info:active,.show>.btn-info.dropdown-toggle{color:#000;background-color:#3dd5f3;border-color:#25cff2}.btn-check:active+.btn-info:focus,.btn-check:checked+.btn-info:focus,.btn-info.active:focus,.btn-info:active:focus,.show>.btn-info.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(11,172,204,.5)}.btn-info.disabled,.btn-info:disabled{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-warning{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-warning:hover{color:#000;background-color:#ffca2c;border-color:#ffc720}.btn-check:focus+.btn-warning,.btn-warning:focus{color:#000;background-color:#ffca2c;border-color:#ffc720;box-shadow:0 0 0 .25rem rgba(217,164,6,.5)}.btn-check:active+.btn-warning,.btn-check:checked+.btn-warning,.btn-warning.active,.btn-warning:active,.show>.btn-warning.dropdown-toggle{color:#000;background-color:#ffcd39;border-color:#ffc720}.btn-check:active+.btn-warning:focus,.btn-check:checked+.btn-warning:focus,.btn-warning.active:focus,.btn-warning:active:focus,.show>.btn-warning.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(217,164,6,.5)}.btn-warning.disabled,.btn-warning:disabled{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-danger{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-danger:hover{color:#fff;background-color:#bb2d3b;border-color:#b02a37}.btn-check:focus+.btn-danger,.btn-danger:focus{color:#fff;background-color:#bb2d3b;border-color:#b02a37;box-shadow:0 0 0 .25rem rgba(225,83,97,.5)}.btn-check:active+.btn-danger,.btn-check:checked+.btn-danger,.btn-danger.active,.btn-danger:active,.show>.btn-danger.dropdown-toggle{color:#fff;background-color:#b02a37;border-color:#a52834}.btn-check:active+.btn-danger:focus,.btn-check:checked+.btn-danger:focus,.btn-danger.active:focus,.btn-danger:active:focus,.show>.btn-danger.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(225,83,97,.5)}.btn-danger.disabled,.btn-danger:disabled{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-light{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-light:hover{color:#000;background-color:#f9fafb;border-color:#f9fafb}.btn-check:focus+.btn-light,.btn-light:focus{color:#000;background-color:#f9fafb;border-color:#f9fafb;box-shadow:0 0 0 .25rem rgba(211,212,213,.5)}.btn-check:active+.btn-light,.btn-check:checked+.btn-light,.btn-light.active,.btn-light:active,.show>.btn-light.dropdown-toggle{color:#000;background-color:#f9fafb;border-color:#f9fafb}.btn-check:active+.btn-light:focus,.btn-check:checked+.btn-light:focus,.btn-light.active:focus,.btn-light:active:focus,.show>.btn-light.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(211,212,213,.5)}.btn-light.disabled,.btn-light:disabled{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-dark{color:#fff;background-color:#212529;border-color:#212529}.btn-dark:hover{color:#fff;background-color:#1c1f23;border-color:#1a1e21}.btn-check:focus+.btn-dark,.btn-dark:focus{color:#fff;background-color:#1c1f23;border-color:#1a1e21;box-shadow:0 0 0 .25rem rgba(66,70,73,.5)}.btn-check:active+.btn-dark,.btn-check:checked+.btn-dark,.btn-dark.active,.btn-dark:active,.show>.btn-dark.dropdown-toggle{color:#fff;background-color:#1a1e21;border-color:#191c1f}.btn-check:active+.btn-dark:focus,.btn-check:checked+.btn-dark:focus,.btn-dark.active:focus,.btn-dark:active:focus,.show>.btn-dark.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(66,70,73,.5)}.btn-dark.disabled,.btn-dark:disabled{color:#fff;background-color:#212529;border-color:#212529}.btn-outline-primary{color:#0d6efd;border-color:#0d6efd}.btn-outline-primary:hover{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-check:focus+.btn-outline-primary,.btn-outline-primary:focus{box-shadow:0 0 0 .25rem rgba(13,110,253,.5)}.btn-check:active+.btn-outline-primary,.btn-check:checked+.btn-outline-primary,.btn-outline-primary.active,.btn-outline-primary.dropdown-toggle.show,.btn-outline-primary:active{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-check:active+.btn-outline-primary:focus,.btn-check:checked+.btn-outline-primary:focus,.btn-outline-primary.active:focus,.btn-outline-primary.dropdown-toggle.show:focus,.btn-outline-primary:active:focus{box-shadow:0 0 0 .25rem rgba(13,110,253,.5)}.btn-outline-primary.disabled,.btn-outline-primary:disabled{color:#0d6efd;background-color:transparent}.btn-outline-secondary{color:#6c757d;border-color:#6c757d}.btn-outline-secondary:hover{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-check:focus+.btn-outline-secondary,.btn-outline-secondary:focus{box-shadow:0 0 0 .25rem rgba(108,117,125,.5)}.btn-check:active+.btn-outline-secondary,.btn-check:checked+.btn-outline-secondary,.btn-outline-secondary.active,.btn-outline-secondary.dropdown-toggle.show,.btn-outline-secondary:active{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-check:active+.btn-outline-secondary:focus,.btn-check:checked+.btn-outline-secondary:focus,.btn-outline-secondary.active:focus,.btn-outline-secondary.dropdown-toggle.show:focus,.btn-outline-secondary:active:focus{box-shadow:0 0 0 .25rem rgba(108,117,125,.5)}.btn-outline-secondary.disabled,.btn-outline-secondary:disabled{color:#6c757d;background-color:transparent}.btn-outline-success{color:#198754;border-color:#198754}.btn-outline-success:hover{color:#fff;background-color:#198754;border-color:#198754}.btn-check:focus+.btn-outline-success,.btn-outline-success:focus{box-shadow:0 0 0 .25rem rgba(25,135,84,.5)}.btn-check:active+.btn-outline-success,.btn-check:checked+.btn-outline-success,.btn-outline-success.active,.btn-outline-success.dropdown-toggle.show,.btn-outline-success:active{color:#fff;background-color:#198754;border-color:#198754}.btn-check:active+.btn-outline-success:focus,.btn-check:checked+.btn-outline-success:focus,.btn-outline-success.active:focus,.btn-outline-success.dropdown-toggle.show:focus,.btn-outline-success:active:focus{box-shadow:0 0 0 .25rem rgba(25,135,84,.5)}.btn-outline-success.disabled,.btn-outline-success:disabled{color:#198754;background-color:transparent}.btn-outline-info{color:#0dcaf0;border-color:#0dcaf0}.btn-outline-info:hover{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-check:focus+.btn-outline-info,.btn-outline-info:focus{box-shadow:0 0 0 .25rem rgba(13,202,240,.5)}.btn-check:active+.btn-outline-info,.btn-check:checked+.btn-outline-info,.btn-outline-info.active,.btn-outline-info.dropdown-toggle.show,.btn-outline-info:active{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-check:active+.btn-outline-info:focus,.btn-check:checked+.btn-outline-info:focus,.btn-outline-info.active:focus,.btn-outline-info.dropdown-toggle.show:focus,.btn-outline-info:active:focus{box-shadow:0 0 0 .25rem rgba(13,202,240,.5)}.btn-outline-info.disabled,.btn-outline-info:disabled{color:#0dcaf0;background-color:transparent}.btn-outline-warning{color:#ffc107;border-color:#ffc107}.btn-outline-warning:hover{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-check:focus+.btn-outline-warning,.btn-outline-warning:focus{box-shadow:0 0 0 .25rem rgba(255,193,7,.5)}.btn-check:active+.btn-outline-warning,.btn-check:checked+.btn-outline-warning,.btn-outline-warning.active,.btn-outline-warning.dropdown-toggle.show,.btn-outline-warning:active{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-check:active+.btn-outline-warning:focus,.btn-check:checked+.btn-outline-warning:focus,.btn-outline-warning.active:focus,.btn-outline-warning.dropdown-toggle.show:focus,.btn-outline-warning:active:focus{box-shadow:0 0 0 .25rem rgba(255,193,7,.5)}.btn-outline-warning.disabled,.btn-outline-warning:disabled{color:#ffc107;background-color:transparent}.btn-outline-danger{color:#dc3545;border-color:#dc3545}.btn-outline-danger:hover{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-check:focus+.btn-outline-danger,.btn-outline-danger:focus{box-shadow:0 0 0 .25rem rgba(220,53,69,.5)}.btn-check:active+.btn-outline-danger,.btn-check:checked+.btn-outline-danger,.btn-outline-danger.active,.btn-outline-danger.dropdown-toggle.show,.btn-outline-danger:active{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-check:active+.btn-outline-danger:focus,.btn-check:checked+.btn-outline-danger:focus,.btn-outline-danger.active:focus,.btn-outline-danger.dropdown-toggle.show:focus,.btn-outline-danger:active:focus{box-shadow:0 0 0 .25rem rgba(220,53,69,.5)}.btn-outline-danger.disabled,.btn-outline-danger:disabled{color:#dc3545;background-color:transparent}.btn-outline-light{color:#f8f9fa;border-color:#f8f9fa}.btn-outline-light:hover{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-check:focus+.btn-outline-light,.btn-outline-light:focus{box-shadow:0 0 0 .25rem rgba(248,249,250,.5)}.btn-check:active+.btn-outline-light,.btn-check:checked+.btn-outline-light,.btn-outline-light.active,.btn-outline-light.dropdown-toggle.show,.btn-outline-light:active{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-check:active+.btn-outline-light:focus,.btn-check:checked+.btn-outline-light:focus,.btn-outline-light.active:focus,.btn-outline-light.dropdown-toggle.show:focus,.btn-outline-light:active:focus{box-shadow:0 0 0 .25rem rgba(248,249,250,.5)}.btn-outline-light.disabled,.btn-outline-light:disabled{color:#f8f9fa;background-color:transparent}.btn-outline-dark{color:#212529;border-color:#212529}.btn-outline-dark:hover{color:#fff;background-color:#212529;border-color:#212529}.btn-check:focus+.btn-outline-dark,.btn-outline-dark:focus{box-shadow:0 0 0 .25rem rgba(33,37,41,.5)}.btn-check:active+.btn-outline-dark,.btn-check:checked+.btn-outline-dark,.btn-outline-dark.active,.btn-outline-dark.dropdown-toggle.show,.btn-outline-dark:active{color:#fff;background-color:#212529;border-color:#212529}.btn-check:active+.btn-outline-dark:focus,.btn-check:checked+.btn-outline-dark:focus,.btn-outline-dark.active:focus,.btn-outline-dark.dropdown-toggle.show:focus,.btn-outline-dark:active:focus{box-shadow:0 0 0 .25rem rgba(33,37,41,.5)}.btn-outline-dark.disabled,.btn-outline-dark:disabled{color:#212529;background-color:transparent}.btn-link{font-weight:400;color:#0d6efd;text-decoration:underline}.btn-link:hover{color:#0a58ca}.btn-link.disabled,.btn-link:disabled{color:#6c757d}.btn-group-lg>.btn,.btn-lg{padding:.5rem 1rem;font-size:1.25rem;border-radius:.3rem}.btn-group-sm>.btn,.btn-sm{padding:.25rem .5rem;font-size:.875rem;border-radius:.2rem}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{height:0;overflow:hidden;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.collapsing.collapse-horizontal{width:0;height:auto;transition:width .35s ease}@media (prefers-reduced-motion:reduce){.collapsing.collapse-horizontal{transition:none}}.dropdown,.dropend,.dropstart,.dropup{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid transparent;border-bottom:0;border-left:.3em solid transparent}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{position:absolute;z-index:1000;display:none;min-width:10rem;padding:.5rem 0;margin:0;font-size:1rem;color:#212529;text-align:left;list-style:none;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.15);border-radius:.25rem}.dropdown-menu[data-bs-popper]{top:100%;left:0;margin-top:.125rem}.dropdown-menu-start{--bs-position:start}.dropdown-menu-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-end{--bs-position:end}.dropdown-menu-end[data-bs-popper]{right:0;left:auto}@media (min-width:576px){.dropdown-menu-sm-start{--bs-position:start}.dropdown-menu-sm-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-sm-end{--bs-position:end}.dropdown-menu-sm-end[data-bs-popper]{right:0;left:auto}}@media (min-width:768px){.dropdown-menu-md-start{--bs-position:start}.dropdown-menu-md-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-md-end{--bs-position:end}.dropdown-menu-md-end[data-bs-popper]{right:0;left:auto}}@media (min-width:992px){.dropdown-menu-lg-start{--bs-position:start}.dropdown-menu-lg-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-lg-end{--bs-position:end}.dropdown-menu-lg-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1200px){.dropdown-menu-xl-start{--bs-position:start}.dropdown-menu-xl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xl-end{--bs-position:end}.dropdown-menu-xl-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1400px){.dropdown-menu-xxl-start{--bs-position:start}.dropdown-menu-xxl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xxl-end{--bs-position:end}.dropdown-menu-xxl-end[data-bs-popper]{right:0;left:auto}}.dropup .dropdown-menu[data-bs-popper]{top:auto;bottom:100%;margin-top:0;margin-bottom:.125rem}.dropup .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid transparent;border-bottom:.3em solid;border-left:.3em solid transparent}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-menu[data-bs-popper]{top:0;right:auto;left:100%;margin-top:0;margin-left:.125rem}.dropend .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:0;border-bottom:.3em solid transparent;border-left:.3em solid}.dropend .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-toggle::after{vertical-align:0}.dropstart .dropdown-menu[data-bs-popper]{top:0;right:100%;left:auto;margin-top:0;margin-right:.125rem}.dropstart .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:""}.dropstart .dropdown-toggle::after{display:none}.dropstart .dropdown-toggle::before{display:inline-block;margin-right:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:.3em solid;border-bottom:.3em solid transparent}.dropstart .dropdown-toggle:empty::after{margin-left:0}.dropstart .dropdown-toggle::before{vertical-align:0}.dropdown-divider{height:0;margin:.5rem 0;overflow:hidden;border-top:1px solid rgba(0,0,0,.15)}.dropdown-item{display:block;width:100%;padding:.25rem 1rem;clear:both;font-weight:400;color:#212529;text-align:inherit;text-decoration:none;white-space:nowrap;background-color:transparent;border:0}.dropdown-item:focus,.dropdown-item:hover{color:#1e2125;background-color:#e9ecef}.dropdown-item.active,.dropdown-item:active{color:#fff;text-decoration:none;background-color:#0d6efd}.dropdown-item.disabled,.dropdown-item:disabled{color:#adb5bd;pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:.5rem 1rem;margin-bottom:0;font-size:.875rem;color:#6c757d;white-space:nowrap}.dropdown-item-text{display:block;padding:.25rem 1rem;color:#212529}.dropdown-menu-dark{color:#dee2e6;background-color:#343a40;border-color:rgba(0,0,0,.15)}.dropdown-menu-dark .dropdown-item{color:#dee2e6}.dropdown-menu-dark .dropdown-item:focus,.dropdown-menu-dark .dropdown-item:hover{color:#fff;background-color:rgba(255,255,255,.15)}.dropdown-menu-dark .dropdown-item.active,.dropdown-menu-dark .dropdown-item:active{color:#fff;background-color:#0d6efd}.dropdown-menu-dark .dropdown-item.disabled,.dropdown-menu-dark .dropdown-item:disabled{color:#adb5bd}.dropdown-menu-dark .dropdown-divider{border-color:rgba(0,0,0,.15)}.dropdown-menu-dark .dropdown-item-text{color:#dee2e6}.dropdown-menu-dark .dropdown-header{color:#adb5bd}.btn-group,.btn-group-vertical{position:relative;display:inline-flex;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;flex:1 1 auto}.btn-group-vertical>.btn-check:checked+.btn,.btn-group-vertical>.btn-check:focus+.btn,.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn-check:checked+.btn,.btn-group>.btn-check:focus+.btn,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:1}.btn-toolbar{display:flex;flex-wrap:wrap;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group>.btn-group:not(:first-child),.btn-group>.btn:not(:first-child){margin-left:-1px}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:nth-child(n+3),.btn-group>:not(.btn-check)+.btn{border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}.dropdown-toggle-split::after,.dropend .dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after{margin-left:0}.dropstart .dropdown-toggle-split::before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{flex-direction:column;align-items:flex-start;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn-group:not(:first-child),.btn-group-vertical>.btn:not(:first-child){margin-top:-1px}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn~.btn{border-top-left-radius:0;border-top-right-radius:0}.nav{display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:.5rem 1rem;color:#0d6efd;text-decoration:none;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out}@media (prefers-reduced-motion:reduce){.nav-link{transition:none}}.nav-link:focus,.nav-link:hover{color:#0a58ca}.nav-link.disabled{color:#6c757d;pointer-events:none;cursor:default}.nav-tabs{border-bottom:1px solid #dee2e6}.nav-tabs .nav-link{margin-bottom:-1px;background:0 0;border:1px solid transparent;border-top-left-radius:.25rem;border-top-right-radius:.25rem}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{border-color:#e9ecef #e9ecef #dee2e6;isolation:isolate}.nav-tabs .nav-link.disabled{color:#6c757d;background-color:transparent;border-color:transparent}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:#495057;background-color:#fff;border-color:#dee2e6 #dee2e6 #fff}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-left-radius:0;border-top-right-radius:0}.nav-pills .nav-link{background:0 0;border:0;border-radius:.25rem}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:#fff;background-color:#0d6efd}.nav-fill .nav-item,.nav-fill>.nav-link{flex:1 1 auto;text-align:center}.nav-justified .nav-item,.nav-justified>.nav-link{flex-basis:0;flex-grow:1;text-align:center}.nav-fill .nav-item .nav-link,.nav-justified .nav-item .nav-link{width:100%}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;padding-top:.5rem;padding-bottom:.5rem}.navbar>.container,.navbar>.container-fluid,.navbar>.container-lg,.navbar>.container-md,.navbar>.container-sm,.navbar>.container-xl,.navbar>.container-xxl{display:flex;flex-wrap:inherit;align-items:center;justify-content:space-between}.navbar-brand{padding-top:.3125rem;padding-bottom:.3125rem;margin-right:1rem;font-size:1.25rem;text-decoration:none;white-space:nowrap}.navbar-nav{display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link{padding-right:0;padding-left:0}.navbar-nav .dropdown-menu{position:static}.navbar-text{padding-top:.5rem;padding-bottom:.5rem}.navbar-collapse{flex-basis:100%;flex-grow:1;align-items:center}.navbar-toggler{padding:.25rem .75rem;font-size:1.25rem;line-height:1;background-color:transparent;border:1px solid transparent;border-radius:.25rem;transition:box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.navbar-toggler{transition:none}}.navbar-toggler:hover{text-decoration:none}.navbar-toggler:focus{text-decoration:none;outline:0;box-shadow:0 0 0 .25rem}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;background-repeat:no-repeat;background-position:center;background-size:100%}.navbar-nav-scroll{max-height:var(--bs-scroll-height,75vh);overflow-y:auto}@media (min-width:576px){.navbar-expand-sm{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-sm .navbar-nav{flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-sm .navbar-nav-scroll{overflow:visible}.navbar-expand-sm .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}.navbar-expand-sm .offcanvas-header{display:none}.navbar-expand-sm .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-sm .offcanvas-bottom,.navbar-expand-sm .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-sm .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:768px){.navbar-expand-md{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-md .navbar-nav{flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-md .navbar-nav-scroll{overflow:visible}.navbar-expand-md .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}.navbar-expand-md .offcanvas-header{display:none}.navbar-expand-md .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-md .offcanvas-bottom,.navbar-expand-md .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-md .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:992px){.navbar-expand-lg{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-lg .navbar-nav{flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-lg .navbar-nav-scroll{overflow:visible}.navbar-expand-lg .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}.navbar-expand-lg .offcanvas-header{display:none}.navbar-expand-lg .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-lg .offcanvas-bottom,.navbar-expand-lg .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-lg .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1200px){.navbar-expand-xl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xl .navbar-nav{flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-xl .navbar-nav-scroll{overflow:visible}.navbar-expand-xl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}.navbar-expand-xl .offcanvas-header{display:none}.navbar-expand-xl .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-xl .offcanvas-bottom,.navbar-expand-xl .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-xl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1400px){.navbar-expand-xxl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xxl .navbar-nav{flex-direction:row}.navbar-expand-xxl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xxl .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-xxl .navbar-nav-scroll{overflow:visible}.navbar-expand-xxl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xxl .navbar-toggler{display:none}.navbar-expand-xxl .offcanvas-header{display:none}.navbar-expand-xxl .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-xxl .offcanvas-bottom,.navbar-expand-xxl .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-xxl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}.navbar-expand{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand .navbar-nav{flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand .navbar-nav-scroll{overflow:visible}.navbar-expand .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-expand .offcanvas-header{display:none}.navbar-expand .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand .offcanvas-bottom,.navbar-expand .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}.navbar-light .navbar-brand{color:rgba(0,0,0,.9)}.navbar-light .navbar-brand:focus,.navbar-light .navbar-brand:hover{color:rgba(0,0,0,.9)}.navbar-light .navbar-nav .nav-link{color:rgba(0,0,0,.55)}.navbar-light .navbar-nav .nav-link:focus,.navbar-light .navbar-nav .nav-link:hover{color:rgba(0,0,0,.7)}.navbar-light .navbar-nav .nav-link.disabled{color:rgba(0,0,0,.3)}.navbar-light .navbar-nav .nav-link.active,.navbar-light .navbar-nav .show>.nav-link{color:rgba(0,0,0,.9)}.navbar-light .navbar-toggler{color:rgba(0,0,0,.55);border-color:rgba(0,0,0,.1)}.navbar-light .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%280, 0, 0, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-light .navbar-text{color:rgba(0,0,0,.55)}.navbar-light .navbar-text a,.navbar-light .navbar-text a:focus,.navbar-light .navbar-text a:hover{color:rgba(0,0,0,.9)}.navbar-dark .navbar-brand{color:#fff}.navbar-dark .navbar-brand:focus,.navbar-dark .navbar-brand:hover{color:#fff}.navbar-dark .navbar-nav .nav-link{color:rgba(255,255,255,.55)}.navbar-dark .navbar-nav .nav-link:focus,.navbar-dark .navbar-nav .nav-link:hover{color:rgba(255,255,255,.75)}.navbar-dark .navbar-nav .nav-link.disabled{color:rgba(255,255,255,.25)}.navbar-dark .navbar-nav .nav-link.active,.navbar-dark .navbar-nav .show>.nav-link{color:#fff}.navbar-dark .navbar-toggler{color:rgba(255,255,255,.55);border-color:rgba(255,255,255,.1)}.navbar-dark .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-dark .navbar-text{color:rgba(255,255,255,.55)}.navbar-dark .navbar-text a,.navbar-dark .navbar-text a:focus,.navbar-dark .navbar-text a:hover{color:#fff}.card{position:relative;display:flex;flex-direction:column;min-width:0;word-wrap:break-word;background-color:#fff;background-clip:border-box;border:1px solid rgba(0,0,0,.125);border-radius:.25rem}.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{flex:1 1 auto;padding:1rem 1rem}.card-title{margin-bottom:.5rem}.card-subtitle{margin-top:-.25rem;margin-bottom:0}.card-text:last-child{margin-bottom:0}.card-link+.card-link{margin-left:1rem}.card-header{padding:.5rem 1rem;margin-bottom:0;background-color:rgba(0,0,0,.03);border-bottom:1px solid rgba(0,0,0,.125)}.card-header:first-child{border-radius:calc(.25rem - 1px) calc(.25rem - 1px) 0 0}.card-footer{padding:.5rem 1rem;background-color:rgba(0,0,0,.03);border-top:1px solid rgba(0,0,0,.125)}.card-footer:last-child{border-radius:0 0 calc(.25rem - 1px) calc(.25rem - 1px)}.card-header-tabs{margin-right:-.5rem;margin-bottom:-.5rem;margin-left:-.5rem;border-bottom:0}.card-header-pills{margin-right:-.5rem;margin-left:-.5rem}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:1rem;border-radius:calc(.25rem - 1px)}.card-img,.card-img-bottom,.card-img-top{width:100%}.card-img,.card-img-top{border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.card-img,.card-img-bottom{border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.card-group>.card{margin-bottom:.75rem}@media (min-width:576px){.card-group{display:flex;flex-flow:row wrap}.card-group>.card{flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child) .card-header,.card-group>.card:not(:last-child) .card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-footer,.card-group>.card:not(:last-child) .card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child) .card-header,.card-group>.card:not(:first-child) .card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-footer,.card-group>.card:not(:first-child) .card-img-bottom{border-bottom-left-radius:0}}.accordion-button{position:relative;display:flex;align-items:center;width:100%;padding:1rem 1.25rem;font-size:1rem;color:#212529;text-align:left;background-color:#fff;border:0;border-radius:0;overflow-anchor:none;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out,border-radius .15s ease}@media (prefers-reduced-motion:reduce){.accordion-button{transition:none}}.accordion-button:not(.collapsed){color:#0c63e4;background-color:#e7f1ff;box-shadow:inset 0 -1px 0 rgba(0,0,0,.125)}.accordion-button:not(.collapsed)::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%230c63e4'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");transform:rotate(-180deg)}.accordion-button::after{flex-shrink:0;width:1.25rem;height:1.25rem;margin-left:auto;content:"";background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23212529'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-size:1.25rem;transition:transform .2s ease-in-out}@media (prefers-reduced-motion:reduce){.accordion-button::after{transition:none}}.accordion-button:hover{z-index:2}.accordion-button:focus{z-index:3;border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.accordion-header{margin-bottom:0}.accordion-item{background-color:#fff;border:1px solid rgba(0,0,0,.125)}.accordion-item:first-of-type{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.accordion-item:first-of-type .accordion-button{border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.accordion-item:not(:first-of-type){border-top:0}.accordion-item:last-of-type{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.accordion-item:last-of-type .accordion-button.collapsed{border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.accordion-item:last-of-type .accordion-collapse{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.accordion-body{padding:1rem 1.25rem}.accordion-flush .accordion-collapse{border-width:0}.accordion-flush .accordion-item{border-right:0;border-left:0;border-radius:0}.accordion-flush .accordion-item:first-child{border-top:0}.accordion-flush .accordion-item:last-child{border-bottom:0}.accordion-flush .accordion-item .accordion-button{border-radius:0}.breadcrumb{display:flex;flex-wrap:wrap;padding:0 0;margin-bottom:1rem;list-style:none}.breadcrumb-item+.breadcrumb-item{padding-left:.5rem}.breadcrumb-item+.breadcrumb-item::before{float:left;padding-right:.5rem;color:#6c757d;content:var(--bs-breadcrumb-divider, "/")}.breadcrumb-item.active{color:#6c757d}.pagination{display:flex;padding-left:0;list-style:none}.page-link{position:relative;display:block;color:#0d6efd;text-decoration:none;background-color:#fff;border:1px solid #dee2e6;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.page-link{transition:none}}.page-link:hover{z-index:2;color:#0a58ca;background-color:#e9ecef;border-color:#dee2e6}.page-link:focus{z-index:3;color:#0a58ca;background-color:#e9ecef;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.page-item:not(:first-child) .page-link{margin-left:-1px}.page-item.active .page-link{z-index:3;color:#fff;background-color:#0d6efd;border-color:#0d6efd}.page-item.disabled .page-link{color:#6c757d;pointer-events:none;background-color:#fff;border-color:#dee2e6}.page-link{padding:.375rem .75rem}.page-item:first-child .page-link{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.page-item:last-child .page-link{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.pagination-lg .page-link{padding:.75rem 1.5rem;font-size:1.25rem}.pagination-lg .page-item:first-child .page-link{border-top-left-radius:.3rem;border-bottom-left-radius:.3rem}.pagination-lg .page-item:last-child .page-link{border-top-right-radius:.3rem;border-bottom-right-radius:.3rem}.pagination-sm .page-link{padding:.25rem .5rem;font-size:.875rem}.pagination-sm .page-item:first-child .page-link{border-top-left-radius:.2rem;border-bottom-left-radius:.2rem}.pagination-sm .page-item:last-child .page-link{border-top-right-radius:.2rem;border-bottom-right-radius:.2rem}.badge{display:inline-block;padding:.35em .65em;font-size:.75em;font-weight:700;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25rem}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.alert{position:relative;padding:1rem 1rem;margin-bottom:1rem;border:1px solid transparent;border-radius:.25rem}.alert-heading{color:inherit}.alert-link{font-weight:700}.alert-dismissible{padding-right:3rem}.alert-dismissible .btn-close{position:absolute;top:0;right:0;z-index:2;padding:1.25rem 1rem}.alert-primary{color:#084298;background-color:#cfe2ff;border-color:#b6d4fe}.alert-primary .alert-link{color:#06357a}.alert-secondary{color:#41464b;background-color:#e2e3e5;border-color:#d3d6d8}.alert-secondary .alert-link{color:#34383c}.alert-success{color:#0f5132;background-color:#d1e7dd;border-color:#badbcc}.alert-success .alert-link{color:#0c4128}.alert-info{color:#055160;background-color:#cff4fc;border-color:#b6effb}.alert-info .alert-link{color:#04414d}.alert-warning{color:#664d03;background-color:#fff3cd;border-color:#ffecb5}.alert-warning .alert-link{color:#523e02}.alert-danger{color:#842029;background-color:#f8d7da;border-color:#f5c2c7}.alert-danger .alert-link{color:#6a1a21}.alert-light{color:#636464;background-color:#fefefe;border-color:#fdfdfe}.alert-light .alert-link{color:#4f5050}.alert-dark{color:#141619;background-color:#d3d3d4;border-color:#bcbebf}.alert-dark .alert-link{color:#101214}@-webkit-keyframes progress-bar-stripes{0%{background-position-x:1rem}}@keyframes progress-bar-stripes{0%{background-position-x:1rem}}.progress{display:flex;height:1rem;overflow:hidden;font-size:.75rem;background-color:#e9ecef;border-radius:.25rem}.progress-bar{display:flex;flex-direction:column;justify-content:center;overflow:hidden;color:#fff;text-align:center;white-space:nowrap;background-color:#0d6efd;transition:width .6s ease}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:1rem 1rem}.progress-bar-animated{-webkit-animation:1s linear infinite progress-bar-stripes;animation:1s linear infinite progress-bar-stripes}@media (prefers-reduced-motion:reduce){.progress-bar-animated{-webkit-animation:none;animation:none}}.list-group{display:flex;flex-direction:column;padding-left:0;margin-bottom:0;border-radius:.25rem}.list-group-numbered{list-style-type:none;counter-reset:section}.list-group-numbered>li::before{content:counters(section, ".") ". ";counter-increment:section}.list-group-item-action{width:100%;color:#495057;text-align:inherit}.list-group-item-action:focus,.list-group-item-action:hover{z-index:1;color:#495057;text-decoration:none;background-color:#f8f9fa}.list-group-item-action:active{color:#212529;background-color:#e9ecef}.list-group-item{position:relative;display:block;padding:.5rem 1rem;color:#212529;text-decoration:none;background-color:#fff;border:1px solid rgba(0,0,0,.125)}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:#6c757d;pointer-events:none;background-color:#fff}.list-group-item.active{z-index:2;color:#fff;background-color:#0d6efd;border-color:#0d6efd}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:-1px;border-top-width:1px}.list-group-horizontal{flex-direction:row}.list-group-horizontal>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}@media (min-width:576px){.list-group-horizontal-sm{flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:768px){.list-group-horizontal-md{flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-md>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:992px){.list-group-horizontal-lg{flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:1200px){.list-group-horizontal-xl{flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:1400px){.list-group-horizontal-xxl{flex-direction:row}.list-group-horizontal-xxl>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-xxl>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-xxl>.list-group-item.active{margin-top:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 1px}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{color:#084298;background-color:#cfe2ff}.list-group-item-primary.list-group-item-action:focus,.list-group-item-primary.list-group-item-action:hover{color:#084298;background-color:#bacbe6}.list-group-item-primary.list-group-item-action.active{color:#fff;background-color:#084298;border-color:#084298}.list-group-item-secondary{color:#41464b;background-color:#e2e3e5}.list-group-item-secondary.list-group-item-action:focus,.list-group-item-secondary.list-group-item-action:hover{color:#41464b;background-color:#cbccce}.list-group-item-secondary.list-group-item-action.active{color:#fff;background-color:#41464b;border-color:#41464b}.list-group-item-success{color:#0f5132;background-color:#d1e7dd}.list-group-item-success.list-group-item-action:focus,.list-group-item-success.list-group-item-action:hover{color:#0f5132;background-color:#bcd0c7}.list-group-item-success.list-group-item-action.active{color:#fff;background-color:#0f5132;border-color:#0f5132}.list-group-item-info{color:#055160;background-color:#cff4fc}.list-group-item-info.list-group-item-action:focus,.list-group-item-info.list-group-item-action:hover{color:#055160;background-color:#badce3}.list-group-item-info.list-group-item-action.active{color:#fff;background-color:#055160;border-color:#055160}.list-group-item-warning{color:#664d03;background-color:#fff3cd}.list-group-item-warning.list-group-item-action:focus,.list-group-item-warning.list-group-item-action:hover{color:#664d03;background-color:#e6dbb9}.list-group-item-warning.list-group-item-action.active{color:#fff;background-color:#664d03;border-color:#664d03}.list-group-item-danger{color:#842029;background-color:#f8d7da}.list-group-item-danger.list-group-item-action:focus,.list-group-item-danger.list-group-item-action:hover{color:#842029;background-color:#dfc2c4}.list-group-item-danger.list-group-item-action.active{color:#fff;background-color:#842029;border-color:#842029}.list-group-item-light{color:#636464;background-color:#fefefe}.list-group-item-light.list-group-item-action:focus,.list-group-item-light.list-group-item-action:hover{color:#636464;background-color:#e5e5e5}.list-group-item-light.list-group-item-action.active{color:#fff;background-color:#636464;border-color:#636464}.list-group-item-dark{color:#141619;background-color:#d3d3d4}.list-group-item-dark.list-group-item-action:focus,.list-group-item-dark.list-group-item-action:hover{color:#141619;background-color:#bebebf}.list-group-item-dark.list-group-item-action.active{color:#fff;background-color:#141619;border-color:#141619}.btn-close{box-sizing:content-box;width:1em;height:1em;padding:.25em .25em;color:#000;background:transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e") center/1em auto no-repeat;border:0;border-radius:.25rem;opacity:.5}.btn-close:hover{color:#000;text-decoration:none;opacity:.75}.btn-close:focus{outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25);opacity:1}.btn-close.disabled,.btn-close:disabled{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;opacity:.25}.btn-close-white{filter:invert(1) grayscale(100%) brightness(200%)}.toast{width:350px;max-width:100%;font-size:.875rem;pointer-events:auto;background-color:rgba(255,255,255,.85);background-clip:padding-box;border:1px solid rgba(0,0,0,.1);box-shadow:0 .5rem 1rem rgba(0,0,0,.15);border-radius:.25rem}.toast.showing{opacity:0}.toast:not(.show){display:none}.toast-container{width:-webkit-max-content;width:-moz-max-content;width:max-content;max-width:100%;pointer-events:none}.toast-container>:not(:last-child){margin-bottom:.75rem}.toast-header{display:flex;align-items:center;padding:.5rem .75rem;color:#6c757d;background-color:rgba(255,255,255,.85);background-clip:padding-box;border-bottom:1px solid rgba(0,0,0,.05);border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.toast-header .btn-close{margin-right:-.375rem;margin-left:.75rem}.toast-body{padding:.75rem;word-wrap:break-word}.modal{position:fixed;top:0;left:0;z-index:1055;display:none;width:100%;height:100%;overflow-x:hidden;overflow-y:auto;outline:0}.modal-dialog{position:relative;width:auto;margin:.5rem;pointer-events:none}.modal.fade .modal-dialog{transition:transform .3s ease-out;transform:translate(0,-50px)}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{transform:none}.modal.modal-static .modal-dialog{transform:scale(1.02)}.modal-dialog-scrollable{height:calc(100% - 1rem)}.modal-dialog-scrollable .modal-content{max-height:100%;overflow:hidden}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:flex;align-items:center;min-height:calc(100% - 1rem)}.modal-content{position:relative;display:flex;flex-direction:column;width:100%;pointer-events:auto;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.2);border-radius:.3rem;outline:0}.modal-backdrop{position:fixed;top:0;left:0;z-index:1050;width:100vw;height:100vh;background-color:#000}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:.5}.modal-header{display:flex;flex-shrink:0;align-items:center;justify-content:space-between;padding:1rem 1rem;border-bottom:1px solid #dee2e6;border-top-left-radius:calc(.3rem - 1px);border-top-right-radius:calc(.3rem - 1px)}.modal-header .btn-close{padding:.5rem .5rem;margin:-.5rem -.5rem -.5rem auto}.modal-title{margin-bottom:0;line-height:1.5}.modal-body{position:relative;flex:1 1 auto;padding:1rem}.modal-footer{display:flex;flex-wrap:wrap;flex-shrink:0;align-items:center;justify-content:flex-end;padding:.75rem;border-top:1px solid #dee2e6;border-bottom-right-radius:calc(.3rem - 1px);border-bottom-left-radius:calc(.3rem - 1px)}.modal-footer>*{margin:.25rem}@media (min-width:576px){.modal-dialog{max-width:500px;margin:1.75rem auto}.modal-dialog-scrollable{height:calc(100% - 3.5rem)}.modal-dialog-centered{min-height:calc(100% - 3.5rem)}.modal-sm{max-width:300px}}@media (min-width:992px){.modal-lg,.modal-xl{max-width:800px}}@media (min-width:1200px){.modal-xl{max-width:1140px}}.modal-fullscreen{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen .modal-header{border-radius:0}.modal-fullscreen .modal-body{overflow-y:auto}.modal-fullscreen .modal-footer{border-radius:0}@media (max-width:575.98px){.modal-fullscreen-sm-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-sm-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-sm-down .modal-header{border-radius:0}.modal-fullscreen-sm-down .modal-body{overflow-y:auto}.modal-fullscreen-sm-down .modal-footer{border-radius:0}}@media (max-width:767.98px){.modal-fullscreen-md-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-md-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-md-down .modal-header{border-radius:0}.modal-fullscreen-md-down .modal-body{overflow-y:auto}.modal-fullscreen-md-down .modal-footer{border-radius:0}}@media (max-width:991.98px){.modal-fullscreen-lg-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-lg-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-lg-down .modal-header{border-radius:0}.modal-fullscreen-lg-down .modal-body{overflow-y:auto}.modal-fullscreen-lg-down .modal-footer{border-radius:0}}@media (max-width:1199.98px){.modal-fullscreen-xl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xl-down .modal-header{border-radius:0}.modal-fullscreen-xl-down .modal-body{overflow-y:auto}.modal-fullscreen-xl-down .modal-footer{border-radius:0}}@media (max-width:1399.98px){.modal-fullscreen-xxl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xxl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xxl-down .modal-header{border-radius:0}.modal-fullscreen-xxl-down .modal-body{overflow-y:auto}.modal-fullscreen-xxl-down .modal-footer{border-radius:0}}.tooltip{position:absolute;z-index:1080;display:block;margin:0;font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:.875rem;word-wrap:break-word;opacity:0}.tooltip.show{opacity:.9}.tooltip .tooltip-arrow{position:absolute;display:block;width:.8rem;height:.4rem}.tooltip .tooltip-arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-auto[data-popper-placement^=top],.bs-tooltip-top{padding:.4rem 0}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow,.bs-tooltip-top .tooltip-arrow{bottom:0}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before,.bs-tooltip-top .tooltip-arrow::before{top:-1px;border-width:.4rem .4rem 0;border-top-color:#000}.bs-tooltip-auto[data-popper-placement^=right],.bs-tooltip-end{padding:0 .4rem}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow,.bs-tooltip-end .tooltip-arrow{left:0;width:.4rem;height:.8rem}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow::before,.bs-tooltip-end .tooltip-arrow::before{right:-1px;border-width:.4rem .4rem .4rem 0;border-right-color:#000}.bs-tooltip-auto[data-popper-placement^=bottom],.bs-tooltip-bottom{padding:.4rem 0}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow,.bs-tooltip-bottom .tooltip-arrow{top:0}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow::before,.bs-tooltip-bottom .tooltip-arrow::before{bottom:-1px;border-width:0 .4rem .4rem;border-bottom-color:#000}.bs-tooltip-auto[data-popper-placement^=left],.bs-tooltip-start{padding:0 .4rem}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow,.bs-tooltip-start .tooltip-arrow{right:0;width:.4rem;height:.8rem}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow::before,.bs-tooltip-start .tooltip-arrow::before{left:-1px;border-width:.4rem 0 .4rem .4rem;border-left-color:#000}.tooltip-inner{max-width:200px;padding:.25rem .5rem;color:#fff;text-align:center;background-color:#000;border-radius:.25rem}.popover{position:absolute;top:0;left:0;z-index:1070;display:block;max-width:276px;font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:.875rem;word-wrap:break-word;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.2);border-radius:.3rem}.popover .popover-arrow{position:absolute;display:block;width:1rem;height:.5rem}.popover .popover-arrow::after,.popover .popover-arrow::before{position:absolute;display:block;content:"";border-color:transparent;border-style:solid}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow,.bs-popover-top>.popover-arrow{bottom:calc(-.5rem - 1px)}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::before{bottom:0;border-width:.5rem .5rem 0;border-top-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-top>.popover-arrow::after{bottom:1px;border-width:.5rem .5rem 0;border-top-color:#fff}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow,.bs-popover-end>.popover-arrow{left:calc(-.5rem - 1px);width:.5rem;height:1rem}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::before{left:0;border-width:.5rem .5rem .5rem 0;border-right-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-end>.popover-arrow::after{left:1px;border-width:.5rem .5rem .5rem 0;border-right-color:#fff}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow,.bs-popover-bottom>.popover-arrow{top:calc(-.5rem - 1px)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::before{top:0;border-width:0 .5rem .5rem .5rem;border-bottom-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::after{top:1px;border-width:0 .5rem .5rem .5rem;border-bottom-color:#fff}.bs-popover-auto[data-popper-placement^=bottom] .popover-header::before,.bs-popover-bottom .popover-header::before{position:absolute;top:0;left:50%;display:block;width:1rem;margin-left:-.5rem;content:"";border-bottom:1px solid #f0f0f0}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow,.bs-popover-start>.popover-arrow{right:calc(-.5rem - 1px);width:.5rem;height:1rem}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::before{right:0;border-width:.5rem 0 .5rem .5rem;border-left-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-start>.popover-arrow::after{right:1px;border-width:.5rem 0 .5rem .5rem;border-left-color:#fff}.popover-header{padding:.5rem 1rem;margin-bottom:0;font-size:1rem;background-color:#f0f0f0;border-bottom:1px solid rgba(0,0,0,.2);border-top-left-radius:calc(.3rem - 1px);border-top-right-radius:calc(.3rem - 1px)}.popover-header:empty{display:none}.popover-body{padding:1rem 1rem;color:#212529}.carousel{position:relative}.carousel.pointer-event{touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner::after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;-webkit-backface-visibility:hidden;backface-visibility:hidden;transition:transform .6s ease-in-out}@media (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.active.carousel-item-end,.carousel-item-next:not(.carousel-item-start){transform:translateX(100%)}.active.carousel-item-start,.carousel-item-prev:not(.carousel-item-end){transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transition-property:opacity;transform:none}.carousel-fade .carousel-item-next.carousel-item-start,.carousel-fade .carousel-item-prev.carousel-item-end,.carousel-fade .carousel-item.active{z-index:1;opacity:1}.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{z-index:0;opacity:0;transition:opacity 0s .6s}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{transition:none}}.carousel-control-next,.carousel-control-prev{position:absolute;top:0;bottom:0;z-index:1;display:flex;align-items:center;justify-content:center;width:15%;padding:0;color:#fff;text-align:center;background:0 0;border:0;opacity:.5;transition:opacity .15s ease}@media (prefers-reduced-motion:reduce){.carousel-control-next,.carousel-control-prev{transition:none}}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{display:inline-block;width:2rem;height:2rem;background-repeat:no-repeat;background-position:50%;background-size:100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:2;display:flex;justify-content:center;padding:0;margin-right:15%;margin-bottom:1rem;margin-left:15%;list-style:none}.carousel-indicators [data-bs-target]{box-sizing:content-box;flex:0 1 auto;width:30px;height:3px;padding:0;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:#fff;background-clip:padding-box;border:0;border-top:10px solid transparent;border-bottom:10px solid transparent;opacity:.5;transition:opacity .6s ease}@media (prefers-reduced-motion:reduce){.carousel-indicators [data-bs-target]{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:1.25rem;left:15%;padding-top:1.25rem;padding-bottom:1.25rem;color:#fff;text-align:center}.carousel-dark .carousel-control-next-icon,.carousel-dark .carousel-control-prev-icon{filter:invert(1) grayscale(100)}.carousel-dark .carousel-indicators [data-bs-target]{background-color:#000}.carousel-dark .carousel-caption{color:#000}@-webkit-keyframes spinner-border{to{transform:rotate(360deg)}}@keyframes spinner-border{to{transform:rotate(360deg)}}.spinner-border{display:inline-block;width:2rem;height:2rem;vertical-align:-.125em;border:.25em solid currentColor;border-right-color:transparent;border-radius:50%;-webkit-animation:.75s linear infinite spinner-border;animation:.75s linear infinite spinner-border}.spinner-border-sm{width:1rem;height:1rem;border-width:.2em}@-webkit-keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}.spinner-grow{display:inline-block;width:2rem;height:2rem;vertical-align:-.125em;background-color:currentColor;border-radius:50%;opacity:0;-webkit-animation:.75s linear infinite spinner-grow;animation:.75s linear infinite spinner-grow}.spinner-grow-sm{width:1rem;height:1rem}@media (prefers-reduced-motion:reduce){.spinner-border,.spinner-grow{-webkit-animation-duration:1.5s;animation-duration:1.5s}}.offcanvas{position:fixed;bottom:0;z-index:1045;display:flex;flex-direction:column;max-width:100%;visibility:hidden;background-color:#fff;background-clip:padding-box;outline:0;transition:transform .3s ease-in-out}@media (prefers-reduced-motion:reduce){.offcanvas{transition:none}}.offcanvas-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.offcanvas-backdrop.fade{opacity:0}.offcanvas-backdrop.show{opacity:.5}.offcanvas-header{display:flex;align-items:center;justify-content:space-between;padding:1rem 1rem}.offcanvas-header .btn-close{padding:.5rem .5rem;margin-top:-.5rem;margin-right:-.5rem;margin-bottom:-.5rem}.offcanvas-title{margin-bottom:0;line-height:1.5}.offcanvas-body{flex-grow:1;padding:1rem 1rem;overflow-y:auto}.offcanvas-start{top:0;left:0;width:400px;border-right:1px solid rgba(0,0,0,.2);transform:translateX(-100%)}.offcanvas-end{top:0;right:0;width:400px;border-left:1px solid rgba(0,0,0,.2);transform:translateX(100%)}.offcanvas-top{top:0;right:0;left:0;height:30vh;max-height:100%;border-bottom:1px solid rgba(0,0,0,.2);transform:translateY(-100%)}.offcanvas-bottom{right:0;left:0;height:30vh;max-height:100%;border-top:1px solid rgba(0,0,0,.2);transform:translateY(100%)}.offcanvas.show{transform:none}.placeholder{display:inline-block;min-height:1em;vertical-align:middle;cursor:wait;background-color:currentColor;opacity:.5}.placeholder.btn::before{display:inline-block;content:""}.placeholder-xs{min-height:.6em}.placeholder-sm{min-height:.8em}.placeholder-lg{min-height:1.2em}.placeholder-glow .placeholder{-webkit-animation:placeholder-glow 2s ease-in-out infinite;animation:placeholder-glow 2s ease-in-out infinite}@-webkit-keyframes placeholder-glow{50%{opacity:.2}}@keyframes placeholder-glow{50%{opacity:.2}}.placeholder-wave{-webkit-mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);-webkit-mask-size:200% 100%;mask-size:200% 100%;-webkit-animation:placeholder-wave 2s linear infinite;animation:placeholder-wave 2s linear infinite}@-webkit-keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}@keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}.clearfix::after{display:block;clear:both;content:""}.link-primary{color:#0d6efd}.link-primary:focus,.link-primary:hover{color:#0a58ca}.link-secondary{color:#6c757d}.link-secondary:focus,.link-secondary:hover{color:#565e64}.link-success{color:#198754}.link-success:focus,.link-success:hover{color:#146c43}.link-info{color:#0dcaf0}.link-info:focus,.link-info:hover{color:#3dd5f3}.link-warning{color:#ffc107}.link-warning:focus,.link-warning:hover{color:#ffcd39}.link-danger{color:#dc3545}.link-danger:focus,.link-danger:hover{color:#b02a37}.link-light{color:#f8f9fa}.link-light:focus,.link-light:hover{color:#f9fafb}.link-dark{color:#212529}.link-dark:focus,.link-dark:hover{color:#1a1e21}.ratio{position:relative;width:100%}.ratio::before{display:block;padding-top:var(--bs-aspect-ratio);content:""}.ratio>*{position:absolute;top:0;left:0;width:100%;height:100%}.ratio-1x1{--bs-aspect-ratio:100%}.ratio-4x3{--bs-aspect-ratio:75%}.ratio-16x9{--bs-aspect-ratio:56.25%}.ratio-21x9{--bs-aspect-ratio:42.8571428571%}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}@media (min-width:576px){.sticky-sm-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:768px){.sticky-md-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:992px){.sticky-lg-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:1200px){.sticky-xl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:1400px){.sticky-xxl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}.hstack{display:flex;flex-direction:row;align-items:center;align-self:stretch}.vstack{display:flex;flex:1 1 auto;flex-direction:column;align-self:stretch}.visually-hidden,.visually-hidden-focusable:not(:focus):not(:focus-within){position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;content:""}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.vr{display:inline-block;align-self:stretch;width:1px;min-height:1em;background-color:currentColor;opacity:.25}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.float-start{float:left!important}.float-end{float:right!important}.float-none{float:none!important}.opacity-0{opacity:0!important}.opacity-25{opacity:.25!important}.opacity-50{opacity:.5!important}.opacity-75{opacity:.75!important}.opacity-100{opacity:1!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.overflow-visible{overflow:visible!important}.overflow-scroll{overflow:scroll!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-grid{display:grid!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:flex!important}.d-inline-flex{display:inline-flex!important}.d-none{display:none!important}.shadow{box-shadow:0 .5rem 1rem rgba(0,0,0,.15)!important}.shadow-sm{box-shadow:0 .125rem .25rem rgba(0,0,0,.075)!important}.shadow-lg{box-shadow:0 1rem 3rem rgba(0,0,0,.175)!important}.shadow-none{box-shadow:none!important}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:-webkit-sticky!important;position:sticky!important}.top-0{top:0!important}.top-50{top:50%!important}.top-100{top:100%!important}.bottom-0{bottom:0!important}.bottom-50{bottom:50%!important}.bottom-100{bottom:100%!important}.start-0{left:0!important}.start-50{left:50%!important}.start-100{left:100%!important}.end-0{right:0!important}.end-50{right:50%!important}.end-100{right:100%!important}.translate-middle{transform:translate(-50%,-50%)!important}.translate-middle-x{transform:translateX(-50%)!important}.translate-middle-y{transform:translateY(-50%)!important}.border{border:1px solid #dee2e6!important}.border-0{border:0!important}.border-top{border-top:1px solid #dee2e6!important}.border-top-0{border-top:0!important}.border-end{border-right:1px solid #dee2e6!important}.border-end-0{border-right:0!important}.border-bottom{border-bottom:1px solid #dee2e6!important}.border-bottom-0{border-bottom:0!important}.border-start{border-left:1px solid #dee2e6!important}.border-start-0{border-left:0!important}.border-primary{border-color:#0d6efd!important}.border-secondary{border-color:#6c757d!important}.border-success{border-color:#198754!important}.border-info{border-color:#0dcaf0!important}.border-warning{border-color:#ffc107!important}.border-danger{border-color:#dc3545!important}.border-light{border-color:#f8f9fa!important}.border-dark{border-color:#212529!important}.border-white{border-color:#fff!important}.border-1{border-width:1px!important}.border-2{border-width:2px!important}.border-3{border-width:3px!important}.border-4{border-width:4px!important}.border-5{border-width:5px!important}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.mw-100{max-width:100%!important}.vw-100{width:100vw!important}.min-vw-100{min-width:100vw!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mh-100{max-height:100%!important}.vh-100{height:100vh!important}.min-vh-100{min-height:100vh!important}.flex-fill{flex:1 1 auto!important}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-row-reverse{flex-direction:row-reverse!important}.flex-column-reverse{flex-direction:column-reverse!important}.flex-grow-0{flex-grow:0!important}.flex-grow-1{flex-grow:1!important}.flex-shrink-0{flex-shrink:0!important}.flex-shrink-1{flex-shrink:1!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.flex-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-0{gap:0!important}.gap-1{gap:.25rem!important}.gap-2{gap:.5rem!important}.gap-3{gap:1rem!important}.gap-4{gap:1.5rem!important}.gap-5{gap:3rem!important}.justify-content-start{justify-content:flex-start!important}.justify-content-end{justify-content:flex-end!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.justify-content-around{justify-content:space-around!important}.justify-content-evenly{justify-content:space-evenly!important}.align-items-start{align-items:flex-start!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.align-items-baseline{align-items:baseline!important}.align-items-stretch{align-items:stretch!important}.align-content-start{align-content:flex-start!important}.align-content-end{align-content:flex-end!important}.align-content-center{align-content:center!important}.align-content-between{align-content:space-between!important}.align-content-around{align-content:space-around!important}.align-content-stretch{align-content:stretch!important}.align-self-auto{align-self:auto!important}.align-self-start{align-self:flex-start!important}.align-self-end{align-self:flex-end!important}.align-self-center{align-self:center!important}.align-self-baseline{align-self:baseline!important}.align-self-stretch{align-self:stretch!important}.order-first{order:-1!important}.order-0{order:0!important}.order-1{order:1!important}.order-2{order:2!important}.order-3{order:3!important}.order-4{order:4!important}.order-5{order:5!important}.order-last{order:6!important}.m-0{margin:0!important}.m-1{margin:.25rem!important}.m-2{margin:.5rem!important}.m-3{margin:1rem!important}.m-4{margin:1.5rem!important}.m-5{margin:3rem!important}.m-auto{margin:auto!important}.mx-0{margin-right:0!important;margin-left:0!important}.mx-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-3{margin-right:1rem!important;margin-left:1rem!important}.mx-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-5{margin-right:3rem!important;margin-left:3rem!important}.mx-auto{margin-right:auto!important;margin-left:auto!important}.my-0{margin-top:0!important;margin-bottom:0!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-0{margin-top:0!important}.mt-1{margin-top:.25rem!important}.mt-2{margin-top:.5rem!important}.mt-3{margin-top:1rem!important}.mt-4{margin-top:1.5rem!important}.mt-5{margin-top:3rem!important}.mt-auto{margin-top:auto!important}.me-0{margin-right:0!important}.me-1{margin-right:.25rem!important}.me-2{margin-right:.5rem!important}.me-3{margin-right:1rem!important}.me-4{margin-right:1.5rem!important}.me-5{margin-right:3rem!important}.me-auto{margin-right:auto!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:.25rem!important}.mb-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.mb-4{margin-bottom:1.5rem!important}.mb-5{margin-bottom:3rem!important}.mb-auto{margin-bottom:auto!important}.ms-0{margin-left:0!important}.ms-1{margin-left:.25rem!important}.ms-2{margin-left:.5rem!important}.ms-3{margin-left:1rem!important}.ms-4{margin-left:1.5rem!important}.ms-5{margin-left:3rem!important}.ms-auto{margin-left:auto!important}.p-0{padding:0!important}.p-1{padding:.25rem!important}.p-2{padding:.5rem!important}.p-3{padding:1rem!important}.p-4{padding:1.5rem!important}.p-5{padding:3rem!important}.px-0{padding-right:0!important;padding-left:0!important}.px-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-3{padding-right:1rem!important;padding-left:1rem!important}.px-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-5{padding-right:3rem!important;padding-left:3rem!important}.py-0{padding-top:0!important;padding-bottom:0!important}.py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-0{padding-top:0!important}.pt-1{padding-top:.25rem!important}.pt-2{padding-top:.5rem!important}.pt-3{padding-top:1rem!important}.pt-4{padding-top:1.5rem!important}.pt-5{padding-top:3rem!important}.pe-0{padding-right:0!important}.pe-1{padding-right:.25rem!important}.pe-2{padding-right:.5rem!important}.pe-3{padding-right:1rem!important}.pe-4{padding-right:1.5rem!important}.pe-5{padding-right:3rem!important}.pb-0{padding-bottom:0!important}.pb-1{padding-bottom:.25rem!important}.pb-2{padding-bottom:.5rem!important}.pb-3{padding-bottom:1rem!important}.pb-4{padding-bottom:1.5rem!important}.pb-5{padding-bottom:3rem!important}.ps-0{padding-left:0!important}.ps-1{padding-left:.25rem!important}.ps-2{padding-left:.5rem!important}.ps-3{padding-left:1rem!important}.ps-4{padding-left:1.5rem!important}.ps-5{padding-left:3rem!important}.font-monospace{font-family:var(--bs-font-monospace)!important}.fs-1{font-size:calc(1.375rem + 1.5vw)!important}.fs-2{font-size:calc(1.325rem + .9vw)!important}.fs-3{font-size:calc(1.3rem + .6vw)!important}.fs-4{font-size:calc(1.275rem + .3vw)!important}.fs-5{font-size:1.25rem!important}.fs-6{font-size:1rem!important}.fst-italic{font-style:italic!important}.fst-normal{font-style:normal!important}.fw-light{font-weight:300!important}.fw-lighter{font-weight:lighter!important}.fw-normal{font-weight:400!important}.fw-bold{font-weight:700!important}.fw-bolder{font-weight:bolder!important}.lh-1{line-height:1!important}.lh-sm{line-height:1.25!important}.lh-base{line-height:1.5!important}.lh-lg{line-height:2!important}.text-start{text-align:left!important}.text-end{text-align:right!important}.text-center{text-align:center!important}.text-decoration-none{text-decoration:none!important}.text-decoration-underline{text-decoration:underline!important}.text-decoration-line-through{text-decoration:line-through!important}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-break{word-wrap:break-word!important;word-break:break-word!important}.text-primary{--bs-text-opacity:1;color:rgba(var(--bs-primary-rgb),var(--bs-text-opacity))!important}.text-secondary{--bs-text-opacity:1;color:rgba(var(--bs-secondary-rgb),var(--bs-text-opacity))!important}.text-success{--bs-text-opacity:1;color:rgba(var(--bs-success-rgb),var(--bs-text-opacity))!important}.text-info{--bs-text-opacity:1;color:rgba(var(--bs-info-rgb),var(--bs-text-opacity))!important}.text-warning{--bs-text-opacity:1;color:rgba(var(--bs-warning-rgb),var(--bs-text-opacity))!important}.text-danger{--bs-text-opacity:1;color:rgba(var(--bs-danger-rgb),var(--bs-text-opacity))!important}.text-light{--bs-text-opacity:1;color:rgba(var(--bs-light-rgb),var(--bs-text-opacity))!important}.text-dark{--bs-text-opacity:1;color:rgba(var(--bs-dark-rgb),var(--bs-text-opacity))!important}.text-black{--bs-text-opacity:1;color:rgba(var(--bs-black-rgb),var(--bs-text-opacity))!important}.text-white{--bs-text-opacity:1;color:rgba(var(--bs-white-rgb),var(--bs-text-opacity))!important}.text-body{--bs-text-opacity:1;color:rgba(var(--bs-body-color-rgb),var(--bs-text-opacity))!important}.text-muted{--bs-text-opacity:1;color:#6c757d!important}.text-black-50{--bs-text-opacity:1;color:rgba(0,0,0,.5)!important}.text-white-50{--bs-text-opacity:1;color:rgba(255,255,255,.5)!important}.text-reset{--bs-text-opacity:1;color:inherit!important}.text-opacity-25{--bs-text-opacity:0.25}.text-opacity-50{--bs-text-opacity:0.5}.text-opacity-75{--bs-text-opacity:0.75}.text-opacity-100{--bs-text-opacity:1}.bg-primary{--bs-bg-opacity:1;background-color:rgba(var(--bs-primary-rgb),var(--bs-bg-opacity))!important}.bg-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-rgb),var(--bs-bg-opacity))!important}.bg-success{--bs-bg-opacity:1;background-color:rgba(var(--bs-success-rgb),var(--bs-bg-opacity))!important}.bg-info{--bs-bg-opacity:1;background-color:rgba(var(--bs-info-rgb),var(--bs-bg-opacity))!important}.bg-warning{--bs-bg-opacity:1;background-color:rgba(var(--bs-warning-rgb),var(--bs-bg-opacity))!important}.bg-danger{--bs-bg-opacity:1;background-color:rgba(var(--bs-danger-rgb),var(--bs-bg-opacity))!important}.bg-light{--bs-bg-opacity:1;background-color:rgba(var(--bs-light-rgb),var(--bs-bg-opacity))!important}.bg-dark{--bs-bg-opacity:1;background-color:rgba(var(--bs-dark-rgb),var(--bs-bg-opacity))!important}.bg-black{--bs-bg-opacity:1;background-color:rgba(var(--bs-black-rgb),var(--bs-bg-opacity))!important}.bg-white{--bs-bg-opacity:1;background-color:rgba(var(--bs-white-rgb),var(--bs-bg-opacity))!important}.bg-body{--bs-bg-opacity:1;background-color:rgba(var(--bs-body-bg-rgb),var(--bs-bg-opacity))!important}.bg-transparent{--bs-bg-opacity:1;background-color:transparent!important}.bg-opacity-10{--bs-bg-opacity:0.1}.bg-opacity-25{--bs-bg-opacity:0.25}.bg-opacity-50{--bs-bg-opacity:0.5}.bg-opacity-75{--bs-bg-opacity:0.75}.bg-opacity-100{--bs-bg-opacity:1}.bg-gradient{background-image:var(--bs-gradient)!important}.user-select-all{-webkit-user-select:all!important;-moz-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;-moz-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;-moz-user-select:none!important;user-select:none!important}.pe-none{pointer-events:none!important}.pe-auto{pointer-events:auto!important}.rounded{border-radius:.25rem!important}.rounded-0{border-radius:0!important}.rounded-1{border-radius:.2rem!important}.rounded-2{border-radius:.25rem!important}.rounded-3{border-radius:.3rem!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:50rem!important}.rounded-top{border-top-left-radius:.25rem!important;border-top-right-radius:.25rem!important}.rounded-end{border-top-right-radius:.25rem!important;border-bottom-right-radius:.25rem!important}.rounded-bottom{border-bottom-right-radius:.25rem!important;border-bottom-left-radius:.25rem!important}.rounded-start{border-bottom-left-radius:.25rem!important;border-top-left-radius:.25rem!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}@media (min-width:576px){.float-sm-start{float:left!important}.float-sm-end{float:right!important}.float-sm-none{float:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-grid{display:grid!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:flex!important}.d-sm-inline-flex{display:inline-flex!important}.d-sm-none{display:none!important}.flex-sm-fill{flex:1 1 auto!important}.flex-sm-row{flex-direction:row!important}.flex-sm-column{flex-direction:column!important}.flex-sm-row-reverse{flex-direction:row-reverse!important}.flex-sm-column-reverse{flex-direction:column-reverse!important}.flex-sm-grow-0{flex-grow:0!important}.flex-sm-grow-1{flex-grow:1!important}.flex-sm-shrink-0{flex-shrink:0!important}.flex-sm-shrink-1{flex-shrink:1!important}.flex-sm-wrap{flex-wrap:wrap!important}.flex-sm-nowrap{flex-wrap:nowrap!important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-sm-0{gap:0!important}.gap-sm-1{gap:.25rem!important}.gap-sm-2{gap:.5rem!important}.gap-sm-3{gap:1rem!important}.gap-sm-4{gap:1.5rem!important}.gap-sm-5{gap:3rem!important}.justify-content-sm-start{justify-content:flex-start!important}.justify-content-sm-end{justify-content:flex-end!important}.justify-content-sm-center{justify-content:center!important}.justify-content-sm-between{justify-content:space-between!important}.justify-content-sm-around{justify-content:space-around!important}.justify-content-sm-evenly{justify-content:space-evenly!important}.align-items-sm-start{align-items:flex-start!important}.align-items-sm-end{align-items:flex-end!important}.align-items-sm-center{align-items:center!important}.align-items-sm-baseline{align-items:baseline!important}.align-items-sm-stretch{align-items:stretch!important}.align-content-sm-start{align-content:flex-start!important}.align-content-sm-end{align-content:flex-end!important}.align-content-sm-center{align-content:center!important}.align-content-sm-between{align-content:space-between!important}.align-content-sm-around{align-content:space-around!important}.align-content-sm-stretch{align-content:stretch!important}.align-self-sm-auto{align-self:auto!important}.align-self-sm-start{align-self:flex-start!important}.align-self-sm-end{align-self:flex-end!important}.align-self-sm-center{align-self:center!important}.align-self-sm-baseline{align-self:baseline!important}.align-self-sm-stretch{align-self:stretch!important}.order-sm-first{order:-1!important}.order-sm-0{order:0!important}.order-sm-1{order:1!important}.order-sm-2{order:2!important}.order-sm-3{order:3!important}.order-sm-4{order:4!important}.order-sm-5{order:5!important}.order-sm-last{order:6!important}.m-sm-0{margin:0!important}.m-sm-1{margin:.25rem!important}.m-sm-2{margin:.5rem!important}.m-sm-3{margin:1rem!important}.m-sm-4{margin:1.5rem!important}.m-sm-5{margin:3rem!important}.m-sm-auto{margin:auto!important}.mx-sm-0{margin-right:0!important;margin-left:0!important}.mx-sm-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-sm-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-sm-3{margin-right:1rem!important;margin-left:1rem!important}.mx-sm-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-sm-5{margin-right:3rem!important;margin-left:3rem!important}.mx-sm-auto{margin-right:auto!important;margin-left:auto!important}.my-sm-0{margin-top:0!important;margin-bottom:0!important}.my-sm-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-sm-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-sm-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-sm-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-sm-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-sm-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-sm-0{margin-top:0!important}.mt-sm-1{margin-top:.25rem!important}.mt-sm-2{margin-top:.5rem!important}.mt-sm-3{margin-top:1rem!important}.mt-sm-4{margin-top:1.5rem!important}.mt-sm-5{margin-top:3rem!important}.mt-sm-auto{margin-top:auto!important}.me-sm-0{margin-right:0!important}.me-sm-1{margin-right:.25rem!important}.me-sm-2{margin-right:.5rem!important}.me-sm-3{margin-right:1rem!important}.me-sm-4{margin-right:1.5rem!important}.me-sm-5{margin-right:3rem!important}.me-sm-auto{margin-right:auto!important}.mb-sm-0{margin-bottom:0!important}.mb-sm-1{margin-bottom:.25rem!important}.mb-sm-2{margin-bottom:.5rem!important}.mb-sm-3{margin-bottom:1rem!important}.mb-sm-4{margin-bottom:1.5rem!important}.mb-sm-5{margin-bottom:3rem!important}.mb-sm-auto{margin-bottom:auto!important}.ms-sm-0{margin-left:0!important}.ms-sm-1{margin-left:.25rem!important}.ms-sm-2{margin-left:.5rem!important}.ms-sm-3{margin-left:1rem!important}.ms-sm-4{margin-left:1.5rem!important}.ms-sm-5{margin-left:3rem!important}.ms-sm-auto{margin-left:auto!important}.p-sm-0{padding:0!important}.p-sm-1{padding:.25rem!important}.p-sm-2{padding:.5rem!important}.p-sm-3{padding:1rem!important}.p-sm-4{padding:1.5rem!important}.p-sm-5{padding:3rem!important}.px-sm-0{padding-right:0!important;padding-left:0!important}.px-sm-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-sm-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-sm-3{padding-right:1rem!important;padding-left:1rem!important}.px-sm-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-sm-5{padding-right:3rem!important;padding-left:3rem!important}.py-sm-0{padding-top:0!important;padding-bottom:0!important}.py-sm-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-sm-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-sm-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-sm-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-sm-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-sm-0{padding-top:0!important}.pt-sm-1{padding-top:.25rem!important}.pt-sm-2{padding-top:.5rem!important}.pt-sm-3{padding-top:1rem!important}.pt-sm-4{padding-top:1.5rem!important}.pt-sm-5{padding-top:3rem!important}.pe-sm-0{padding-right:0!important}.pe-sm-1{padding-right:.25rem!important}.pe-sm-2{padding-right:.5rem!important}.pe-sm-3{padding-right:1rem!important}.pe-sm-4{padding-right:1.5rem!important}.pe-sm-5{padding-right:3rem!important}.pb-sm-0{padding-bottom:0!important}.pb-sm-1{padding-bottom:.25rem!important}.pb-sm-2{padding-bottom:.5rem!important}.pb-sm-3{padding-bottom:1rem!important}.pb-sm-4{padding-bottom:1.5rem!important}.pb-sm-5{padding-bottom:3rem!important}.ps-sm-0{padding-left:0!important}.ps-sm-1{padding-left:.25rem!important}.ps-sm-2{padding-left:.5rem!important}.ps-sm-3{padding-left:1rem!important}.ps-sm-4{padding-left:1.5rem!important}.ps-sm-5{padding-left:3rem!important}.text-sm-start{text-align:left!important}.text-sm-end{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.float-md-start{float:left!important}.float-md-end{float:right!important}.float-md-none{float:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-grid{display:grid!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:flex!important}.d-md-inline-flex{display:inline-flex!important}.d-md-none{display:none!important}.flex-md-fill{flex:1 1 auto!important}.flex-md-row{flex-direction:row!important}.flex-md-column{flex-direction:column!important}.flex-md-row-reverse{flex-direction:row-reverse!important}.flex-md-column-reverse{flex-direction:column-reverse!important}.flex-md-grow-0{flex-grow:0!important}.flex-md-grow-1{flex-grow:1!important}.flex-md-shrink-0{flex-shrink:0!important}.flex-md-shrink-1{flex-shrink:1!important}.flex-md-wrap{flex-wrap:wrap!important}.flex-md-nowrap{flex-wrap:nowrap!important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-md-0{gap:0!important}.gap-md-1{gap:.25rem!important}.gap-md-2{gap:.5rem!important}.gap-md-3{gap:1rem!important}.gap-md-4{gap:1.5rem!important}.gap-md-5{gap:3rem!important}.justify-content-md-start{justify-content:flex-start!important}.justify-content-md-end{justify-content:flex-end!important}.justify-content-md-center{justify-content:center!important}.justify-content-md-between{justify-content:space-between!important}.justify-content-md-around{justify-content:space-around!important}.justify-content-md-evenly{justify-content:space-evenly!important}.align-items-md-start{align-items:flex-start!important}.align-items-md-end{align-items:flex-end!important}.align-items-md-center{align-items:center!important}.align-items-md-baseline{align-items:baseline!important}.align-items-md-stretch{align-items:stretch!important}.align-content-md-start{align-content:flex-start!important}.align-content-md-end{align-content:flex-end!important}.align-content-md-center{align-content:center!important}.align-content-md-between{align-content:space-between!important}.align-content-md-around{align-content:space-around!important}.align-content-md-stretch{align-content:stretch!important}.align-self-md-auto{align-self:auto!important}.align-self-md-start{align-self:flex-start!important}.align-self-md-end{align-self:flex-end!important}.align-self-md-center{align-self:center!important}.align-self-md-baseline{align-self:baseline!important}.align-self-md-stretch{align-self:stretch!important}.order-md-first{order:-1!important}.order-md-0{order:0!important}.order-md-1{order:1!important}.order-md-2{order:2!important}.order-md-3{order:3!important}.order-md-4{order:4!important}.order-md-5{order:5!important}.order-md-last{order:6!important}.m-md-0{margin:0!important}.m-md-1{margin:.25rem!important}.m-md-2{margin:.5rem!important}.m-md-3{margin:1rem!important}.m-md-4{margin:1.5rem!important}.m-md-5{margin:3rem!important}.m-md-auto{margin:auto!important}.mx-md-0{margin-right:0!important;margin-left:0!important}.mx-md-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-md-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-md-3{margin-right:1rem!important;margin-left:1rem!important}.mx-md-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-md-5{margin-right:3rem!important;margin-left:3rem!important}.mx-md-auto{margin-right:auto!important;margin-left:auto!important}.my-md-0{margin-top:0!important;margin-bottom:0!important}.my-md-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-md-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-md-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-md-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-md-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-md-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-md-0{margin-top:0!important}.mt-md-1{margin-top:.25rem!important}.mt-md-2{margin-top:.5rem!important}.mt-md-3{margin-top:1rem!important}.mt-md-4{margin-top:1.5rem!important}.mt-md-5{margin-top:3rem!important}.mt-md-auto{margin-top:auto!important}.me-md-0{margin-right:0!important}.me-md-1{margin-right:.25rem!important}.me-md-2{margin-right:.5rem!important}.me-md-3{margin-right:1rem!important}.me-md-4{margin-right:1.5rem!important}.me-md-5{margin-right:3rem!important}.me-md-auto{margin-right:auto!important}.mb-md-0{margin-bottom:0!important}.mb-md-1{margin-bottom:.25rem!important}.mb-md-2{margin-bottom:.5rem!important}.mb-md-3{margin-bottom:1rem!important}.mb-md-4{margin-bottom:1.5rem!important}.mb-md-5{margin-bottom:3rem!important}.mb-md-auto{margin-bottom:auto!important}.ms-md-0{margin-left:0!important}.ms-md-1{margin-left:.25rem!important}.ms-md-2{margin-left:.5rem!important}.ms-md-3{margin-left:1rem!important}.ms-md-4{margin-left:1.5rem!important}.ms-md-5{margin-left:3rem!important}.ms-md-auto{margin-left:auto!important}.p-md-0{padding:0!important}.p-md-1{padding:.25rem!important}.p-md-2{padding:.5rem!important}.p-md-3{padding:1rem!important}.p-md-4{padding:1.5rem!important}.p-md-5{padding:3rem!important}.px-md-0{padding-right:0!important;padding-left:0!important}.px-md-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-md-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-md-3{padding-right:1rem!important;padding-left:1rem!important}.px-md-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-md-5{padding-right:3rem!important;padding-left:3rem!important}.py-md-0{padding-top:0!important;padding-bottom:0!important}.py-md-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-md-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-md-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-md-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-md-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-md-0{padding-top:0!important}.pt-md-1{padding-top:.25rem!important}.pt-md-2{padding-top:.5rem!important}.pt-md-3{padding-top:1rem!important}.pt-md-4{padding-top:1.5rem!important}.pt-md-5{padding-top:3rem!important}.pe-md-0{padding-right:0!important}.pe-md-1{padding-right:.25rem!important}.pe-md-2{padding-right:.5rem!important}.pe-md-3{padding-right:1rem!important}.pe-md-4{padding-right:1.5rem!important}.pe-md-5{padding-right:3rem!important}.pb-md-0{padding-bottom:0!important}.pb-md-1{padding-bottom:.25rem!important}.pb-md-2{padding-bottom:.5rem!important}.pb-md-3{padding-bottom:1rem!important}.pb-md-4{padding-bottom:1.5rem!important}.pb-md-5{padding-bottom:3rem!important}.ps-md-0{padding-left:0!important}.ps-md-1{padding-left:.25rem!important}.ps-md-2{padding-left:.5rem!important}.ps-md-3{padding-left:1rem!important}.ps-md-4{padding-left:1.5rem!important}.ps-md-5{padding-left:3rem!important}.text-md-start{text-align:left!important}.text-md-end{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.float-lg-start{float:left!important}.float-lg-end{float:right!important}.float-lg-none{float:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-grid{display:grid!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:flex!important}.d-lg-inline-flex{display:inline-flex!important}.d-lg-none{display:none!important}.flex-lg-fill{flex:1 1 auto!important}.flex-lg-row{flex-direction:row!important}.flex-lg-column{flex-direction:column!important}.flex-lg-row-reverse{flex-direction:row-reverse!important}.flex-lg-column-reverse{flex-direction:column-reverse!important}.flex-lg-grow-0{flex-grow:0!important}.flex-lg-grow-1{flex-grow:1!important}.flex-lg-shrink-0{flex-shrink:0!important}.flex-lg-shrink-1{flex-shrink:1!important}.flex-lg-wrap{flex-wrap:wrap!important}.flex-lg-nowrap{flex-wrap:nowrap!important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-lg-0{gap:0!important}.gap-lg-1{gap:.25rem!important}.gap-lg-2{gap:.5rem!important}.gap-lg-3{gap:1rem!important}.gap-lg-4{gap:1.5rem!important}.gap-lg-5{gap:3rem!important}.justify-content-lg-start{justify-content:flex-start!important}.justify-content-lg-end{justify-content:flex-end!important}.justify-content-lg-center{justify-content:center!important}.justify-content-lg-between{justify-content:space-between!important}.justify-content-lg-around{justify-content:space-around!important}.justify-content-lg-evenly{justify-content:space-evenly!important}.align-items-lg-start{align-items:flex-start!important}.align-items-lg-end{align-items:flex-end!important}.align-items-lg-center{align-items:center!important}.align-items-lg-baseline{align-items:baseline!important}.align-items-lg-stretch{align-items:stretch!important}.align-content-lg-start{align-content:flex-start!important}.align-content-lg-end{align-content:flex-end!important}.align-content-lg-center{align-content:center!important}.align-content-lg-between{align-content:space-between!important}.align-content-lg-around{align-content:space-around!important}.align-content-lg-stretch{align-content:stretch!important}.align-self-lg-auto{align-self:auto!important}.align-self-lg-start{align-self:flex-start!important}.align-self-lg-end{align-self:flex-end!important}.align-self-lg-center{align-self:center!important}.align-self-lg-baseline{align-self:baseline!important}.align-self-lg-stretch{align-self:stretch!important}.order-lg-first{order:-1!important}.order-lg-0{order:0!important}.order-lg-1{order:1!important}.order-lg-2{order:2!important}.order-lg-3{order:3!important}.order-lg-4{order:4!important}.order-lg-5{order:5!important}.order-lg-last{order:6!important}.m-lg-0{margin:0!important}.m-lg-1{margin:.25rem!important}.m-lg-2{margin:.5rem!important}.m-lg-3{margin:1rem!important}.m-lg-4{margin:1.5rem!important}.m-lg-5{margin:3rem!important}.m-lg-auto{margin:auto!important}.mx-lg-0{margin-right:0!important;margin-left:0!important}.mx-lg-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-lg-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-lg-3{margin-right:1rem!important;margin-left:1rem!important}.mx-lg-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-lg-5{margin-right:3rem!important;margin-left:3rem!important}.mx-lg-auto{margin-right:auto!important;margin-left:auto!important}.my-lg-0{margin-top:0!important;margin-bottom:0!important}.my-lg-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-lg-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-lg-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-lg-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-lg-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-lg-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-lg-0{margin-top:0!important}.mt-lg-1{margin-top:.25rem!important}.mt-lg-2{margin-top:.5rem!important}.mt-lg-3{margin-top:1rem!important}.mt-lg-4{margin-top:1.5rem!important}.mt-lg-5{margin-top:3rem!important}.mt-lg-auto{margin-top:auto!important}.me-lg-0{margin-right:0!important}.me-lg-1{margin-right:.25rem!important}.me-lg-2{margin-right:.5rem!important}.me-lg-3{margin-right:1rem!important}.me-lg-4{margin-right:1.5rem!important}.me-lg-5{margin-right:3rem!important}.me-lg-auto{margin-right:auto!important}.mb-lg-0{margin-bottom:0!important}.mb-lg-1{margin-bottom:.25rem!important}.mb-lg-2{margin-bottom:.5rem!important}.mb-lg-3{margin-bottom:1rem!important}.mb-lg-4{margin-bottom:1.5rem!important}.mb-lg-5{margin-bottom:3rem!important}.mb-lg-auto{margin-bottom:auto!important}.ms-lg-0{margin-left:0!important}.ms-lg-1{margin-left:.25rem!important}.ms-lg-2{margin-left:.5rem!important}.ms-lg-3{margin-left:1rem!important}.ms-lg-4{margin-left:1.5rem!important}.ms-lg-5{margin-left:3rem!important}.ms-lg-auto{margin-left:auto!important}.p-lg-0{padding:0!important}.p-lg-1{padding:.25rem!important}.p-lg-2{padding:.5rem!important}.p-lg-3{padding:1rem!important}.p-lg-4{padding:1.5rem!important}.p-lg-5{padding:3rem!important}.px-lg-0{padding-right:0!important;padding-left:0!important}.px-lg-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-lg-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-lg-3{padding-right:1rem!important;padding-left:1rem!important}.px-lg-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-lg-5{padding-right:3rem!important;padding-left:3rem!important}.py-lg-0{padding-top:0!important;padding-bottom:0!important}.py-lg-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-lg-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-lg-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-lg-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-lg-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-lg-0{padding-top:0!important}.pt-lg-1{padding-top:.25rem!important}.pt-lg-2{padding-top:.5rem!important}.pt-lg-3{padding-top:1rem!important}.pt-lg-4{padding-top:1.5rem!important}.pt-lg-5{padding-top:3rem!important}.pe-lg-0{padding-right:0!important}.pe-lg-1{padding-right:.25rem!important}.pe-lg-2{padding-right:.5rem!important}.pe-lg-3{padding-right:1rem!important}.pe-lg-4{padding-right:1.5rem!important}.pe-lg-5{padding-right:3rem!important}.pb-lg-0{padding-bottom:0!important}.pb-lg-1{padding-bottom:.25rem!important}.pb-lg-2{padding-bottom:.5rem!important}.pb-lg-3{padding-bottom:1rem!important}.pb-lg-4{padding-bottom:1.5rem!important}.pb-lg-5{padding-bottom:3rem!important}.ps-lg-0{padding-left:0!important}.ps-lg-1{padding-left:.25rem!important}.ps-lg-2{padding-left:.5rem!important}.ps-lg-3{padding-left:1rem!important}.ps-lg-4{padding-left:1.5rem!important}.ps-lg-5{padding-left:3rem!important}.text-lg-start{text-align:left!important}.text-lg-end{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.float-xl-start{float:left!important}.float-xl-end{float:right!important}.float-xl-none{float:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-grid{display:grid!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:flex!important}.d-xl-inline-flex{display:inline-flex!important}.d-xl-none{display:none!important}.flex-xl-fill{flex:1 1 auto!important}.flex-xl-row{flex-direction:row!important}.flex-xl-column{flex-direction:column!important}.flex-xl-row-reverse{flex-direction:row-reverse!important}.flex-xl-column-reverse{flex-direction:column-reverse!important}.flex-xl-grow-0{flex-grow:0!important}.flex-xl-grow-1{flex-grow:1!important}.flex-xl-shrink-0{flex-shrink:0!important}.flex-xl-shrink-1{flex-shrink:1!important}.flex-xl-wrap{flex-wrap:wrap!important}.flex-xl-nowrap{flex-wrap:nowrap!important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-xl-0{gap:0!important}.gap-xl-1{gap:.25rem!important}.gap-xl-2{gap:.5rem!important}.gap-xl-3{gap:1rem!important}.gap-xl-4{gap:1.5rem!important}.gap-xl-5{gap:3rem!important}.justify-content-xl-start{justify-content:flex-start!important}.justify-content-xl-end{justify-content:flex-end!important}.justify-content-xl-center{justify-content:center!important}.justify-content-xl-between{justify-content:space-between!important}.justify-content-xl-around{justify-content:space-around!important}.justify-content-xl-evenly{justify-content:space-evenly!important}.align-items-xl-start{align-items:flex-start!important}.align-items-xl-end{align-items:flex-end!important}.align-items-xl-center{align-items:center!important}.align-items-xl-baseline{align-items:baseline!important}.align-items-xl-stretch{align-items:stretch!important}.align-content-xl-start{align-content:flex-start!important}.align-content-xl-end{align-content:flex-end!important}.align-content-xl-center{align-content:center!important}.align-content-xl-between{align-content:space-between!important}.align-content-xl-around{align-content:space-around!important}.align-content-xl-stretch{align-content:stretch!important}.align-self-xl-auto{align-self:auto!important}.align-self-xl-start{align-self:flex-start!important}.align-self-xl-end{align-self:flex-end!important}.align-self-xl-center{align-self:center!important}.align-self-xl-baseline{align-self:baseline!important}.align-self-xl-stretch{align-self:stretch!important}.order-xl-first{order:-1!important}.order-xl-0{order:0!important}.order-xl-1{order:1!important}.order-xl-2{order:2!important}.order-xl-3{order:3!important}.order-xl-4{order:4!important}.order-xl-5{order:5!important}.order-xl-last{order:6!important}.m-xl-0{margin:0!important}.m-xl-1{margin:.25rem!important}.m-xl-2{margin:.5rem!important}.m-xl-3{margin:1rem!important}.m-xl-4{margin:1.5rem!important}.m-xl-5{margin:3rem!important}.m-xl-auto{margin:auto!important}.mx-xl-0{margin-right:0!important;margin-left:0!important}.mx-xl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xl-auto{margin-right:auto!important;margin-left:auto!important}.my-xl-0{margin-top:0!important;margin-bottom:0!important}.my-xl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xl-0{margin-top:0!important}.mt-xl-1{margin-top:.25rem!important}.mt-xl-2{margin-top:.5rem!important}.mt-xl-3{margin-top:1rem!important}.mt-xl-4{margin-top:1.5rem!important}.mt-xl-5{margin-top:3rem!important}.mt-xl-auto{margin-top:auto!important}.me-xl-0{margin-right:0!important}.me-xl-1{margin-right:.25rem!important}.me-xl-2{margin-right:.5rem!important}.me-xl-3{margin-right:1rem!important}.me-xl-4{margin-right:1.5rem!important}.me-xl-5{margin-right:3rem!important}.me-xl-auto{margin-right:auto!important}.mb-xl-0{margin-bottom:0!important}.mb-xl-1{margin-bottom:.25rem!important}.mb-xl-2{margin-bottom:.5rem!important}.mb-xl-3{margin-bottom:1rem!important}.mb-xl-4{margin-bottom:1.5rem!important}.mb-xl-5{margin-bottom:3rem!important}.mb-xl-auto{margin-bottom:auto!important}.ms-xl-0{margin-left:0!important}.ms-xl-1{margin-left:.25rem!important}.ms-xl-2{margin-left:.5rem!important}.ms-xl-3{margin-left:1rem!important}.ms-xl-4{margin-left:1.5rem!important}.ms-xl-5{margin-left:3rem!important}.ms-xl-auto{margin-left:auto!important}.p-xl-0{padding:0!important}.p-xl-1{padding:.25rem!important}.p-xl-2{padding:.5rem!important}.p-xl-3{padding:1rem!important}.p-xl-4{padding:1.5rem!important}.p-xl-5{padding:3rem!important}.px-xl-0{padding-right:0!important;padding-left:0!important}.px-xl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xl-0{padding-top:0!important;padding-bottom:0!important}.py-xl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xl-0{padding-top:0!important}.pt-xl-1{padding-top:.25rem!important}.pt-xl-2{padding-top:.5rem!important}.pt-xl-3{padding-top:1rem!important}.pt-xl-4{padding-top:1.5rem!important}.pt-xl-5{padding-top:3rem!important}.pe-xl-0{padding-right:0!important}.pe-xl-1{padding-right:.25rem!important}.pe-xl-2{padding-right:.5rem!important}.pe-xl-3{padding-right:1rem!important}.pe-xl-4{padding-right:1.5rem!important}.pe-xl-5{padding-right:3rem!important}.pb-xl-0{padding-bottom:0!important}.pb-xl-1{padding-bottom:.25rem!important}.pb-xl-2{padding-bottom:.5rem!important}.pb-xl-3{padding-bottom:1rem!important}.pb-xl-4{padding-bottom:1.5rem!important}.pb-xl-5{padding-bottom:3rem!important}.ps-xl-0{padding-left:0!important}.ps-xl-1{padding-left:.25rem!important}.ps-xl-2{padding-left:.5rem!important}.ps-xl-3{padding-left:1rem!important}.ps-xl-4{padding-left:1.5rem!important}.ps-xl-5{padding-left:3rem!important}.text-xl-start{text-align:left!important}.text-xl-end{text-align:right!important}.text-xl-center{text-align:center!important}}@media (min-width:1400px){.float-xxl-start{float:left!important}.float-xxl-end{float:right!important}.float-xxl-none{float:none!important}.d-xxl-inline{display:inline!important}.d-xxl-inline-block{display:inline-block!important}.d-xxl-block{display:block!important}.d-xxl-grid{display:grid!important}.d-xxl-table{display:table!important}.d-xxl-table-row{display:table-row!important}.d-xxl-table-cell{display:table-cell!important}.d-xxl-flex{display:flex!important}.d-xxl-inline-flex{display:inline-flex!important}.d-xxl-none{display:none!important}.flex-xxl-fill{flex:1 1 auto!important}.flex-xxl-row{flex-direction:row!important}.flex-xxl-column{flex-direction:column!important}.flex-xxl-row-reverse{flex-direction:row-reverse!important}.flex-xxl-column-reverse{flex-direction:column-reverse!important}.flex-xxl-grow-0{flex-grow:0!important}.flex-xxl-grow-1{flex-grow:1!important}.flex-xxl-shrink-0{flex-shrink:0!important}.flex-xxl-shrink-1{flex-shrink:1!important}.flex-xxl-wrap{flex-wrap:wrap!important}.flex-xxl-nowrap{flex-wrap:nowrap!important}.flex-xxl-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-xxl-0{gap:0!important}.gap-xxl-1{gap:.25rem!important}.gap-xxl-2{gap:.5rem!important}.gap-xxl-3{gap:1rem!important}.gap-xxl-4{gap:1.5rem!important}.gap-xxl-5{gap:3rem!important}.justify-content-xxl-start{justify-content:flex-start!important}.justify-content-xxl-end{justify-content:flex-end!important}.justify-content-xxl-center{justify-content:center!important}.justify-content-xxl-between{justify-content:space-between!important}.justify-content-xxl-around{justify-content:space-around!important}.justify-content-xxl-evenly{justify-content:space-evenly!important}.align-items-xxl-start{align-items:flex-start!important}.align-items-xxl-end{align-items:flex-end!important}.align-items-xxl-center{align-items:center!important}.align-items-xxl-baseline{align-items:baseline!important}.align-items-xxl-stretch{align-items:stretch!important}.align-content-xxl-start{align-content:flex-start!important}.align-content-xxl-end{align-content:flex-end!important}.align-content-xxl-center{align-content:center!important}.align-content-xxl-between{align-content:space-between!important}.align-content-xxl-around{align-content:space-around!important}.align-content-xxl-stretch{align-content:stretch!important}.align-self-xxl-auto{align-self:auto!important}.align-self-xxl-start{align-self:flex-start!important}.align-self-xxl-end{align-self:flex-end!important}.align-self-xxl-center{align-self:center!important}.align-self-xxl-baseline{align-self:baseline!important}.align-self-xxl-stretch{align-self:stretch!important}.order-xxl-first{order:-1!important}.order-xxl-0{order:0!important}.order-xxl-1{order:1!important}.order-xxl-2{order:2!important}.order-xxl-3{order:3!important}.order-xxl-4{order:4!important}.order-xxl-5{order:5!important}.order-xxl-last{order:6!important}.m-xxl-0{margin:0!important}.m-xxl-1{margin:.25rem!important}.m-xxl-2{margin:.5rem!important}.m-xxl-3{margin:1rem!important}.m-xxl-4{margin:1.5rem!important}.m-xxl-5{margin:3rem!important}.m-xxl-auto{margin:auto!important}.mx-xxl-0{margin-right:0!important;margin-left:0!important}.mx-xxl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xxl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xxl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xxl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xxl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xxl-auto{margin-right:auto!important;margin-left:auto!important}.my-xxl-0{margin-top:0!important;margin-bottom:0!important}.my-xxl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xxl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xxl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xxl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xxl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xxl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xxl-0{margin-top:0!important}.mt-xxl-1{margin-top:.25rem!important}.mt-xxl-2{margin-top:.5rem!important}.mt-xxl-3{margin-top:1rem!important}.mt-xxl-4{margin-top:1.5rem!important}.mt-xxl-5{margin-top:3rem!important}.mt-xxl-auto{margin-top:auto!important}.me-xxl-0{margin-right:0!important}.me-xxl-1{margin-right:.25rem!important}.me-xxl-2{margin-right:.5rem!important}.me-xxl-3{margin-right:1rem!important}.me-xxl-4{margin-right:1.5rem!important}.me-xxl-5{margin-right:3rem!important}.me-xxl-auto{margin-right:auto!important}.mb-xxl-0{margin-bottom:0!important}.mb-xxl-1{margin-bottom:.25rem!important}.mb-xxl-2{margin-bottom:.5rem!important}.mb-xxl-3{margin-bottom:1rem!important}.mb-xxl-4{margin-bottom:1.5rem!important}.mb-xxl-5{margin-bottom:3rem!important}.mb-xxl-auto{margin-bottom:auto!important}.ms-xxl-0{margin-left:0!important}.ms-xxl-1{margin-left:.25rem!important}.ms-xxl-2{margin-left:.5rem!important}.ms-xxl-3{margin-left:1rem!important}.ms-xxl-4{margin-left:1.5rem!important}.ms-xxl-5{margin-left:3rem!important}.ms-xxl-auto{margin-left:auto!important}.p-xxl-0{padding:0!important}.p-xxl-1{padding:.25rem!important}.p-xxl-2{padding:.5rem!important}.p-xxl-3{padding:1rem!important}.p-xxl-4{padding:1.5rem!important}.p-xxl-5{padding:3rem!important}.px-xxl-0{padding-right:0!important;padding-left:0!important}.px-xxl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xxl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xxl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xxl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xxl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xxl-0{padding-top:0!important;padding-bottom:0!important}.py-xxl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xxl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xxl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xxl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xxl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xxl-0{padding-top:0!important}.pt-xxl-1{padding-top:.25rem!important}.pt-xxl-2{padding-top:.5rem!important}.pt-xxl-3{padding-top:1rem!important}.pt-xxl-4{padding-top:1.5rem!important}.pt-xxl-5{padding-top:3rem!important}.pe-xxl-0{padding-right:0!important}.pe-xxl-1{padding-right:.25rem!important}.pe-xxl-2{padding-right:.5rem!important}.pe-xxl-3{padding-right:1rem!important}.pe-xxl-4{padding-right:1.5rem!important}.pe-xxl-5{padding-right:3rem!important}.pb-xxl-0{padding-bottom:0!important}.pb-xxl-1{padding-bottom:.25rem!important}.pb-xxl-2{padding-bottom:.5rem!important}.pb-xxl-3{padding-bottom:1rem!important}.pb-xxl-4{padding-bottom:1.5rem!important}.pb-xxl-5{padding-bottom:3rem!important}.ps-xxl-0{padding-left:0!important}.ps-xxl-1{padding-left:.25rem!important}.ps-xxl-2{padding-left:.5rem!important}.ps-xxl-3{padding-left:1rem!important}.ps-xxl-4{padding-left:1.5rem!important}.ps-xxl-5{padding-left:3rem!important}.text-xxl-start{text-align:left!important}.text-xxl-end{text-align:right!important}.text-xxl-center{text-align:center!important}}@media (min-width:1200px){.fs-1{font-size:2.5rem!important}.fs-2{font-size:2rem!important}.fs-3{font-size:1.75rem!important}.fs-4{font-size:1.5rem!important}}@media print{.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-grid{display:grid!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:flex!important}.d-print-inline-flex{display:inline-flex!important}.d-print-none{display:none!important}} -/*# sourceMappingURL=bootstrap.min.css.map */ \ No newline at end of file +/*# sourceMappingURL=bootstrap.min.css.map */ diff --git a/webfiles/template.html b/webfiles/template.html index cc1a1585..27f98714 100644 --- a/webfiles/template.html +++ b/webfiles/template.html @@ -39,4 +39,4 @@ - \ No newline at end of file + From 24585fedc02f9dba025b3f60e34c6767b59af025 Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Thu, 22 May 2025 16:14:35 +0200 Subject: [PATCH 06/44] Sort import --- tests/html_updater_test.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/html_updater_test.py b/tests/html_updater_test.py index a1e0bf1d..00241023 100644 --- a/tests/html_updater_test.py +++ b/tests/html_updater_test.py @@ -28,7 +28,9 @@ StatusDataObject, ) from OTCamera.html_updater import StatusHtmlId as status_id -from OTCamera.html_updater import StatusWebsiteUpdater +from OTCamera.html_updater import ( + StatusWebsiteUpdater, +) @pytest.fixture From 67d97304ac6f6e18ad954eea76d35281127b5a70 Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Thu, 22 May 2025 16:16:19 +0200 Subject: [PATCH 07/44] Add pyproject.toml --- pyproject.toml | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 pyproject.toml diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..bface850 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,58 @@ +[build-system] +requires = ["hatchling", "hatch-requirements-txt"] +build-backend = "hatchling.build" + +[project] +name = "OTCamera" +dynamic = ["dependencies", "version"] +authors = [ + { name="OpenTrafficCam contributors", email="team@opentrafficcam.org" }, + { name="platomo GmbH", email="info@platomo.de" }, +] +description = "OTCamera is a core module of the OpenTrafficCam framework to record videos over multiple days with a custom camera system based on the Raspberry Pi Zero W." + +readme = "README.md" +requires-python = ">=3.11" +license = "GPL-3.0-only" +license-files = ["LICENSE"] +classifiers = [ + "Development Status :: 4 - Beta", + "Programming Language :: Python :: 3", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Operating System :: OS Independent", +] +keywords = ["OpenTrafficCam", "Mobile Camera"] + +[project.urls] +Homepage = "https://opentrafficcam.org/" +Documentation = "https://opentrafficcam.org/overview/" +Repository = "https://github.com/OpenTrafficCam/OTCamera" +Issues = "https://github.com/OpenTrafficCam/OTCamera/issues" +Changelog = "https://github.com/OpenTrafficCam/OTCamera/releases" + +[tool.hatch.metadata.hooks.requirements_txt] +files = ["requirements.txt"] + +[tool.hatch.version] +path = "OTCamera/version.py" + +[tool.hatch.build.targets.wheel] +packages = ["OTCamera"] + +[tool.hatch.build] +directory = "dist" + +[tool.black] +line-length = 88 + +[tool.isort] +profile = "black" + +[tool.mypy] +ignore_missing_imports = true +ignore_missing_imports_per_module = true +disallow_untyped_defs = true + +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" \ No newline at end of file From 70558c56954ad82ed1adb56a568019d3fb2a40a9 Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Thu, 22 May 2025 16:17:17 +0200 Subject: [PATCH 08/44] Remove not needed setup.py and setup.cfg setup.py has been replaced with pyproject.toml --- setup.cfg | 19 ------------------- setup.py | 18 ------------------ 2 files changed, 37 deletions(-) delete mode 100644 setup.cfg delete mode 100644 setup.py diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 2510efe1..00000000 --- a/setup.cfg +++ /dev/null @@ -1,19 +0,0 @@ -[metadata] -name=OTCamera - -[options] -package_dir= - =OTCamera -packages=find: - -[options.packages.find] -where=OTCamera -exclude=tests* - -[flake8] -max-line-length=88 -docstring-convention=google -extend-ignore=E203 - -[isort] -profile=black diff --git a/setup.py b/setup.py deleted file mode 100644 index 299541af..00000000 --- a/setup.py +++ /dev/null @@ -1,18 +0,0 @@ -# Copyright (C) 2023 OpenTrafficCam Contributors -# -# - -# This program is free software: you can redistribute it and/or modify it under the -# terms of the GNU General Public License as published by the Free Software Foundation, -# either version 3 of the License, or (at your option) any later version. - -# This program is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A - -# PARTICULAR PURPOSE. See the GNU General Public License for more details. -# You should have received a copy of the GNU General Public License along with this -# program. If not, see . - -from setuptools import setup - -setup() From bfa365942a1c65bb8026e614a12a6c5cd99b692f Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Thu, 22 May 2025 16:18:39 +0200 Subject: [PATCH 09/44] Add pre-commit config file --- .pre-commit-config.yaml | 82 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..660aa385 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,82 @@ +--- +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-yaml + - id: check-json + - id: end-of-file-fixer + exclude_types: + - json + - id: trailing-whitespace + - id: no-commit-to-branch + - id: debug-statements + - id: requirements-txt-fixer + - id: check-executables-have-shebangs + - id: detect-private-key + - repo: local + hooks: + - id: update-type-stubs + name: Check for Type Stubs and Update Config + entry: ./update_precommit.py + language: system + files: ^requirements.*\.txt$ + stages: + - pre-commit + - repo: https://github.com/PyCQA/flake8 + rev: 7.2.0 + hooks: + - id: flake8 + - repo: https://github.com/pycqa/isort + rev: 6.0.1 + hooks: + - id: isort + args: + - --profile + - black + - repo: https://github.com/psf/black + rev: 25.1.0 + hooks: + - id: black + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.15.0 + hooks: + - id: mypy + entry: mypy OTAnalytics tests --config-file=pyproject.toml + additional_dependencies: + - art==6.5 + - black==25.1.0 + - gpiozero==2.0.1 + - hatch-requirements-txt==0.4.1 + - isort==6.0.1 + - mypy==1.15.0 + - picamerax==24.3.21 + - pre-commit==4.2.0 + - pytest-cov==6.1.1 + - pytest==8.3.5 + - types-PyYAML + - types-RPi.GPIO + - types-beautifulsoup4 + - types-flake8 + - types-psutil + - types-requests + - types-requests + always_run: true + pass_filenames: false + - repo: https://github.com/adrienverge/yamllint.git + rev: v1.37.1 + hooks: + - id: yamllint + args: + - -c=./.yamllint.yaml + - repo: https://github.com/koalaman/shellcheck-precommit + rev: v0.10.0 + hooks: + - id: shellcheck + - repo: https://github.com/pecigonzalo/pre-commit-shfmt + rev: v2.2.0 + hooks: + - id: shell-fmt-docker + args: + - -i + - '2' From d4e11d423e6fb1c4f3367fcbf8e67c640bd636d8 Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Fri, 23 May 2025 17:02:38 +0200 Subject: [PATCH 10/44] Add type hints to functions and improve code consistency Refactored the codebase to add or enhance type annotations for functions and methods, improving type safety and readability. Addressed minor inconsistencies such as variable naming, misspellings, and formatting, ensuring better adherence to coding standards. --- OTCamera/__main__.py | 6 +++--- OTCamera/config.py | 2 +- OTCamera/gui/gui.py | 2 +- OTCamera/hardware/button.py | 26 ++++++++++++++---------- OTCamera/hardware/camera.py | 29 ++++++++++++++++----------- OTCamera/hardware/led.py | 18 ++++++++--------- OTCamera/helpers/log.py | 16 +++++++-------- OTCamera/helpers/name.py | 16 +++++++-------- OTCamera/helpers/rpi.py | 8 ++++---- OTCamera/status.py | 14 +++++++------ hardware_test.py | 34 ++++++++++++++++++-------------- tests/conftest.py | 5 ++++- tests/hardware/camera_test.py | 2 +- tests/helpers/filesystem_test.py | 8 +++++--- tests/helpers/name_test.py | 18 +++++++++-------- tests/otcamera_test.py | 2 +- usb_flash_drive_copy.py | 20 +++++++++---------- 17 files changed, 125 insertions(+), 101 deletions(-) diff --git a/OTCamera/__main__.py b/OTCamera/__main__.py index 36f6c522..9b6acaac 100644 --- a/OTCamera/__main__.py +++ b/OTCamera/__main__.py @@ -8,7 +8,7 @@ every interval (see config.py), captures a new preview image and stops recording after recording time ends. -Stops everthing by keyboard interrupt (Ctrl+C). +Stops everything by keyboard interrupt (Ctrl+C). """ @@ -29,10 +29,10 @@ # from record import record -from OTCamera.record import record +from OTCamera.record import main as record -def main(): +def main() -> None: """Starts OTCamera.""" record() diff --git a/OTCamera/config.py b/OTCamera/config.py index f855ef6c..c038c02d 100644 --- a/OTCamera/config.py +++ b/OTCamera/config.py @@ -32,7 +32,7 @@ import yaml -def parse_user_config(config_file: str): +def parse_user_config(config_file: str) -> None: """Parses the OTCamera user configuration YAML file. Args: diff --git a/OTCamera/gui/gui.py b/OTCamera/gui/gui.py index 336e825c..047ac14f 100644 --- a/OTCamera/gui/gui.py +++ b/OTCamera/gui/gui.py @@ -1,2 +1,2 @@ -def main(): +def main() -> None: pass diff --git a/OTCamera/hardware/button.py b/OTCamera/hardware/button.py index d0dd174e..b5d0f0d2 100644 --- a/OTCamera/hardware/button.py +++ b/OTCamera/hardware/button.py @@ -46,9 +46,9 @@ def its_record_time() -> bool: """ current_hour = dt.now().hour record_time = ( - (hour_button.is_pressed) + hour_button.is_pressed or (current_hour >= config.START_HOUR and current_hour < config.END_HOUR) - ) and (not status.SHUTDOWNACTIVE) + ) and (not status.shutdownactive) return record_time @@ -149,7 +149,7 @@ def _on_wifi_button_released() -> None: log.write(f"Turning off Wi-Fi AP in {config.WIFI_DELAY} s") -def init_wifi_button(): +def init_wifi_button() -> None: """Helper to initialize Wi-Fi status on boot according to `wifi_button` At boot time Wi-Fi will always be started by `rc.local`. @@ -157,16 +157,19 @@ def init_wifi_button(): off. """ log.write("Initializing Wi-Fi", level=log.LogLevel.DEBUG) - if wifi_button.is_pressed: + if wifi_button.is_pressed: # type: ignore[attr-defined] rpi.wifi_switch_on() else: rpi.wifi_switch_off() -def handle_power_button_off_state(): +def handle_power_button_off_state() -> None: """Switches off the system after a 5 second delay.""" shutdown_delay = 5 + if status.power_button_pressed_time is None: + return + if status.power_button_pressed_time + timedelta(seconds=shutdown_delay) < dt.now(): if config.DEBUG_MODE_ON: log.write("Mock shutting down RPI in debug mode.", log.LogLevel.DEBUG) @@ -175,8 +178,11 @@ def handle_power_button_off_state(): status.power_button_pressed_time = None -def handle_wifi_button_off_state(): +def handle_wifi_button_off_state() -> None: """Switches off the WiFi after config.WIFI_DELAY seconds.""" + if status.wifi_button_pressed_time is None: + return + if ( status.wifi_button_pressed_time + timedelta(seconds=config.WIFI_DELAY) < dt.now() @@ -219,10 +225,10 @@ def handle_wifi_button_off_state(): hour_button.when_released = _on_hour_button_switched # Set button statuses in status module - status.power_button_pressed = power_button.is_pressed - status.hour_button_pressed = hour_button.is_pressed - status.wifi_button_pressed = wifi_button.is_pressed - status.hour_button_pressed = hour_button.is_pressed + status.power_button_pressed = power_button.is_pressed # type: ignore[attr-defined] + status.hour_button_pressed = hour_button.is_pressed # type: ignore[attr-defined] + status.wifi_button_pressed = wifi_button.is_pressed # type: ignore[attr-defined] + status.hour_button_pressed = hour_button.is_pressed # type: ignore[attr-defined] log.write("Buttons initialized", log.LogLevel.DEBUG) diff --git a/OTCamera/hardware/camera.py b/OTCamera/hardware/camera.py index 1f76ff02..248fa9a8 100644 --- a/OTCamera/hardware/camera.py +++ b/OTCamera/hardware/camera.py @@ -22,7 +22,7 @@ from datetime import datetime as dt from time import sleep -from typing import Tuple, Union +from typing import Any, Optional, Tuple, Type, TypeVar, Union import picamerax as picamera from picamerax import Color @@ -34,25 +34,29 @@ log.write("imported camera", level=log.LogLevel.DEBUG) +T = TypeVar("T", bound="Singleton") + class Singleton(object): """Implements the Singleton design pattern. Classes inheriting from `Singleton` become a singleton class. Meaning only one instance is created. - Constructing a another instance of the concrete class inheriting from `Singleton` + Constructing another instance of the concrete class inheriting from `Singleton` will return the first instance. """ - def __new__(cls, *args, **kwds): + __it__: Optional["Singleton"] = None + + def __new__(cls: Type[T], *args: Any, **kwargs: Any) -> T: it = cls.__dict__.get("__it__") if it is not None: return it cls.__it__ = it = object.__new__(cls) - it.init(*args, **kwds) + it.init(*args, **kwargs) return it - def init(self, *args, **kwds): + def init(self, *args: Any, **kwargs: Any) -> None: pass @@ -71,6 +75,7 @@ class Camera(Singleton): awb_mode (str, optional): The awb mode. Defaults to config.AWB_MODE. drc_strength (str, optional): The DRC strength. Defaults to config.DRC_STRENGTH. rotation (int, optional): The image rotation. Defaults to config.ROTATION. + meter_mode (str, optional): The meter mode. Defaults to config.METER_MODE. """ def init( @@ -97,7 +102,7 @@ def init( self._picam = self._create_picam() log.write("Camera initialized", log.LogLevel.DEBUG) - def start_recording(self): + def start_recording(self) -> None: """Start a recording a video. If picam isn't already recording: @@ -134,7 +139,7 @@ def start_recording(self): self._wait_recording(2) self.capture() - def capture(self): + def capture(self) -> None: """Capture a preview image if camera is recording.""" if self._picam.recording: self._picam.annotate_text = name.annotate() @@ -151,7 +156,7 @@ def capture(self): level=log.LogLevel.WARNING, ) - def _wait_recording(self, timeout: Union[int, float] = 0): + def _wait_recording(self, timeout: Union[int, float] = 0) -> None: """Wait timeout seconds recording. Args: @@ -162,7 +167,7 @@ def _wait_recording(self, timeout: Union[int, float] = 0): else: sleep(timeout) - def _split(self): + def _split(self) -> None: """Splits recording and deletes old video files if no disk space available.""" self._picam.split_recording(name.video()) delete_old_files() @@ -230,7 +235,7 @@ def _is_new_interval(self) -> bool: ) return new_interval - def stop_recording(self): + def stop_recording(self) -> None: """Stops the video recording. If the picamera is recording, the recording is stopped. Additionally, the record @@ -244,7 +249,7 @@ def stop_recording(self): log.write("recorded {n} videos".format(n=status.current_interval)) status.recording = False - def close(self): + def close(self) -> None: """Closes `picamera.PiCamera` instance. Logs to log file if OTCamera has been already closed. But won't do anything @@ -258,7 +263,7 @@ def close(self): log.write("Camera already closed.", level=log.LogLevel.DEBUG) pass - def restart(self): + def restart(self) -> None: """ Restarts the PiCamera instance by closing it and re-initialising it. diff --git a/OTCamera/hardware/led.py b/OTCamera/hardware/led.py index 6e8d21b1..c984cdec 100644 --- a/OTCamera/hardware/led.py +++ b/OTCamera/hardware/led.py @@ -29,7 +29,7 @@ log.write("imported led", level=log.LogLevel.DEBUG) -def off(): +def off() -> None: """Turn all LEDs off.""" if config.USE_LED: power.off() @@ -37,27 +37,27 @@ def off(): rec.off() -def rec_on(): +def rec_on() -> None: """Blink record LED infinite.""" if config.USE_LED: rec.off() rec.blink(on_time=0.1, off_time=4.9, n=None, background=True) -def rec_off(): +def rec_off() -> None: """Pulse record LED 4 times and switch it off.""" if config.USE_LED: rec.off() rec.pulse(fade_in_time=0.25, fade_out_time=0.25, n=4, background=True) -def power_on(): +def power_on() -> None: """Turn power LED on.""" if config.USE_LED: power.on() -def power_blink(): +def power_blink() -> None: """Blink power LED once.""" if config.USE_LED: if not status.noblink: @@ -71,28 +71,28 @@ def power_blink(): ) -def power_pre_off(): +def power_pre_off() -> None: """Rapidly blink power LED.""" if config.USE_LED: power.off() power.blink(on_time=0.1, off_time=0.4, n=None, background=True) -def wifi_on(): +def wifi_on() -> None: """Blink Wi-Fi LED infinite.""" if config.USE_LED: wifi.off() wifi.blink(on_time=0.1, off_time=4.9, n=None, background=True) -def wifi_off(): +def wifi_off() -> None: """Pulse Wi-Fi LED 4 times and switch it off.""" if config.USE_LED: wifi.off() wifi.pulse(fade_in_time=0.25, fade_out_time=0.25, n=4, background=True) -def wifi_pre_off(): +def wifi_pre_off() -> None: """Rapidly blink Wi-Fi LED.""" if config.USE_LED: wifi.off() diff --git a/OTCamera/helpers/log.py b/OTCamera/helpers/log.py index dd3687ea..f5df5523 100644 --- a/OTCamera/helpers/log.py +++ b/OTCamera/helpers/log.py @@ -46,11 +46,11 @@ class LogLevel(Enum): ERROR = "ERROR" EXCEPTION = "EXCEPTION" - def __str__(self): + def __str__(self) -> str: return str(self.value) -def write(msg: str, level: LogLevel = LogLevel.INFO, reboot: bool = True): +def write(msg: str, level: LogLevel = LogLevel.INFO, reboot: bool = True) -> None: """Write any message to logfile. Takes a message, adds date and time and writes it to a logfile (name.log). @@ -113,7 +113,7 @@ def _send_msg_to_ms_teams(msg: str, teams_url: str, time: str) -> None: def _write_exception_msg( err_prefix: str, exception: Exception, -): +) -> None: _write(f"{err_prefix} {exception}") @@ -121,7 +121,7 @@ def _get_stack_trace() -> str: return traceback.format_exc() -def breakline(reboot=True): +def breakline(reboot: bool = True) -> None: """Write a breakline. Write a breakline containing several # to the logfile. @@ -133,25 +133,25 @@ def breakline(reboot=True): _write(msg) -def otc(): +def otc() -> None: """Generate a ASCII logo and write it to the logfile.""" otclogo = text2art("OpenTrafficCam") _write(otclogo) -def _write(msg, reboot=True): +def _write(msg: str, reboot: bool = True) -> None: print(msg) logf.write(msg + "\n") logf.flush() -def closefile(): +def closefile() -> None: """Flush and close the logfile.""" logf.flush() logf.close() -def _check_log_path(): +def _check_log_path() -> None: logfile = name.log() logpath = logfile.parent if not logpath.exists(): diff --git a/OTCamera/helpers/name.py b/OTCamera/helpers/name.py index cccfd9bd..460af3cd 100644 --- a/OTCamera/helpers/name.py +++ b/OTCamera/helpers/name.py @@ -23,7 +23,7 @@ import re from datetime import datetime as dt from pathlib import Path -from typing import Union +from typing import Optional, Union from OTCamera import config @@ -93,7 +93,7 @@ def preview() -> str: return str(Path(filename).expanduser().resolve()) -def get_datetime_from_filename(filename: Union[str, Path]) -> dt: +def get_datetime_from_filename(filename: Union[str, Path]) -> Optional[dt]: """Retrieves the date and time from a filename Searches for "_yyyy-mm-dd_hh-mm-ss". @@ -113,7 +113,7 @@ def get_datetime_from_filename(filename: Union[str, Path]) -> dt: return None # Assume that there is only one timestamp in the file name - datetime_str = match.group(1) # take group withtout underscore + datetime_str = match.group(1) # take the group without the underscore try: date_time = dt.strptime(datetime_str, "%Y-%m-%d_%H-%M-%S") @@ -123,17 +123,17 @@ def get_datetime_from_filename(filename: Union[str, Path]) -> dt: return date_time -def get_fps_from_filename(filename: str) -> int: - """Get frame rate from file name using regex. - Returns None if frame rate is not found in file name. +def get_fps_from_filename(filename: str) -> Optional[int]: + """Get frame rate from the file name using regex. + Returns None if the frame rate is not found in the file name. Args: - input_filename (str): file name + filename (str): file name Returns: int or None: frame rate in frames per second or None """ - # Get input fps frome filename + # Get input fps from filename match = re.search(r"_FR([\d]+)_", filename) if not match: diff --git a/OTCamera/helpers/rpi.py b/OTCamera/helpers/rpi.py index 3bceb70f..0714ccf5 100644 --- a/OTCamera/helpers/rpi.py +++ b/OTCamera/helpers/rpi.py @@ -31,7 +31,7 @@ camera = Camera() -def shutdown(): +def shutdown() -> None: """Shutdown the Raspberry Pi. Shuts down the Raspberry Pi if the power button is still pressed after blink ends @@ -53,7 +53,7 @@ def shutdown(): call("sudo shutdown -h now", shell=True) -def reboot(): +def reboot() -> None: """Reboot the Raspberry Pi. Reboots the Raspberry Pi if any exception is raised. @@ -78,7 +78,7 @@ def reboot(): call("sudo reboot", shell=True) -def wifi_switch_on(): +def wifi_switch_on() -> None: """Turn on Wi-Fi""" if not status.wifi_on: if not config.DEBUG_MODE_ON: @@ -93,7 +93,7 @@ def wifi_switch_on(): led.wifi_on() -def wifi_switch_off(): +def wifi_switch_off() -> None: """Turn off Wi-Fi""" if status.wifi_on: if not config.DEBUG_MODE_ON: diff --git a/OTCamera/status.py b/OTCamera/status.py index 38bc9e40..a445ca04 100644 --- a/OTCamera/status.py +++ b/OTCamera/status.py @@ -22,7 +22,7 @@ import re import subprocess -from datetime import datetime as dt +from datetime import datetime from datetime import timedelta from pathlib import Path from typing import Union @@ -43,8 +43,8 @@ preview_taken: bool = False current_interval: int = 0 recording: bool = False -power_button_pressed_time: Union[dt, None] = None -wifi_button_pressed_time: Union[dt, None] = None +power_button_pressed_time: Union[datetime, None] = None +wifi_button_pressed_time: Union[datetime, None] = None html_updated_after_recording: bool = False # Button statuses @@ -64,7 +64,7 @@ def record_time() -> bool: Returns: bool: Time to record or not. """ - current_hour = dt.now().hour + current_hour = datetime.now().hour bytime = current_hour >= config.START_HOUR and current_hour < config.END_HOUR if config.USE_BUTTONS: record = hour_button_pressed or bytime @@ -86,7 +86,7 @@ def get_status_data() -> StatusDataObject: if wifi_button_pressed_time is not None: wifi_delay = timedelta(seconds=config.WIFI_DELAY) time_until_wifi_off = str_format_timedelta( - (wifi_button_pressed_time + wifi_delay) - dt.now() + (wifi_button_pressed_time + wifi_delay) - datetime.now() ) return StatusDataObject( @@ -155,7 +155,7 @@ def _is_wifi_enabled(network_device_name: str = "wlan0") -> bool: out, error = p.communicate() if error: err_msg = ( - f"Error: '{error} occured while checking {network_device_name} status." + f"Error: '{error!r} occured while checking {network_device_name} status." ) log.write(err_msg, log.LogLevel.ERROR) return False @@ -171,6 +171,8 @@ def _is_wifi_enabled(network_device_name: str = "wlan0") -> bool: log.LogLevel.WARNING, ) return False + else: + return False # TODO: ip address diff --git a/hardware_test.py b/hardware_test.py index 42a90980..d3931f9c 100644 --- a/hardware_test.py +++ b/hardware_test.py @@ -20,6 +20,7 @@ from gpiozero import PWMLED, Button from picamerax import PiCamera +from typing import Any, Callable, TypeVar # Button GPIO Pins BUTTON_POWER_PIN = 17 @@ -54,18 +55,21 @@ hour_led = PWMLED(LED_REC_PIN) -def surround_with_dashes(func): - def wrapper_func(*args, **kwargs): + +F = TypeVar('F', bound=Callable[..., Any]) + +def surround_with_dashes(func: F) -> F: + def wrapper_func(*args: Any, **kwargs: Any) -> Any: print("---") func(*args, **kwargs) print("---") - return wrapper_func + return wrapper_func # type: ignore # Callbacks @surround_with_dashes -def on_power_button_pressed(): +def on_power_button_pressed() -> None: print("Power button pressed") print(f"Power button is on: {power_button.is_pressed}") power_led.on() @@ -73,7 +77,7 @@ def on_power_button_pressed(): @surround_with_dashes -def on_power_button_released(): +def on_power_button_released() -> None: print("Power button released") print(f"Power button is on: {power_button.is_pressed}") power_led.off() @@ -81,7 +85,7 @@ def on_power_button_released(): @surround_with_dashes -def on_wifi_button_pressed(): +def on_wifi_button_pressed() -> None: print("Wifi button pressed") print(f"Wifi button is on: {wifi_button.is_pressed}") wifi_led.on() @@ -89,7 +93,7 @@ def on_wifi_button_pressed(): @surround_with_dashes -def on_wifi_button_released(): +def on_wifi_button_released() -> None: print("Wifi button released") print(f"Wifi button is on: {wifi_button.is_pressed}") wifi_led.off() @@ -97,7 +101,7 @@ def on_wifi_button_released(): @surround_with_dashes -def on_hour_button_pressed(): +def on_hour_button_pressed() -> None: print("Hour button pressed") print(f"Hour button is on: {hour_button.is_pressed}") hour_led.on() @@ -105,7 +109,7 @@ def on_hour_button_pressed(): @surround_with_dashes -def on_hour_button_released(): +def on_hour_button_released() -> None: print("Hour button released") print(f"Hour button is on: {hour_button.is_pressed}") hour_led.off() @@ -113,7 +117,7 @@ def on_hour_button_released(): @surround_with_dashes -def on_external_power_button_pressed(): +def on_external_power_button_pressed() -> None: print("External power is connected") power_led.blink(n=3, on_time=0.25, off_time=0.25) hour_led.blink(n=3, on_time=0.25, off_time=0.25) @@ -124,7 +128,7 @@ def on_external_power_button_pressed(): @surround_with_dashes -def on_external_power_button_released(): +def on_external_power_button_released() -> None: print("External power is not connected") power_led.blink(n=2, on_time=0.25, off_time=0.25) hour_led.blink(n=2, on_time=0.25, off_time=0.25) @@ -208,7 +212,7 @@ def sync_button_with_led(button: Button, led: PWMLED) -> None: led.off() -def sync_all_buttons_with_led(): +def sync_all_buttons_with_led() -> None: sync_button_with_led(power_button, power_led) sync_button_with_led(wifi_button, wifi_led) sync_button_with_led(hour_button, hour_led) @@ -260,7 +264,7 @@ def teardown() -> None: print("Test directory removed") -def start_app(headless: bool = False): +def start_app(headless: bool = False) -> None: print("Test OTCamera Hardware") sync_all_buttons_with_led() @@ -309,7 +313,7 @@ def start_app(headless: bool = False): print("Quit app.") -def parse_args(): +def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() parser.add_argument( "--headless", @@ -321,7 +325,7 @@ def parse_args(): return args -def main(): +def main() -> None: args = parse_args() start_app(headless=args.headless) diff --git a/tests/conftest.py b/tests/conftest.py index 5b6d87bc..5e3ca689 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,12 +15,15 @@ import shutil from pathlib import Path +from typing import Generator, TypeVar import pytest +T = TypeVar("T") +YieldFixture = Generator[T, None, None] @pytest.fixture -def test_dir() -> Path: +def test_dir() -> YieldFixture[Path]: test_dir = Path(__file__).parent / "data" test_dir.mkdir(exist_ok=True) yield test_dir diff --git a/tests/hardware/camera_test.py b/tests/hardware/camera_test.py index 7845584f..99ebe067 100644 --- a/tests/hardware/camera_test.py +++ b/tests/hardware/camera_test.py @@ -16,7 +16,7 @@ import OTCamera.hardware.camera as camera -def test_init_camera_same_instance(): +def test_init_camera_same_instance() -> None: cam_1 = camera.Camera() cam_2 = camera.Camera() assert cam_1 == cam_2 diff --git a/tests/helpers/filesystem_test.py b/tests/helpers/filesystem_test.py index 68fdc4b9..6e723043 100644 --- a/tests/helpers/filesystem_test.py +++ b/tests/helpers/filesystem_test.py @@ -15,16 +15,18 @@ import shutil from pathlib import Path +from typing import Union from unittest import mock import pytest from OTCamera.helpers.errors import NoMoreFilesToDeleteError from OTCamera.helpers.filesystem import delete_old_files +from tests.conftest import YieldFixture @pytest.fixture(scope="function") -def temp_dir(test_dir: Path) -> Path: +def temp_dir(test_dir: Path) -> YieldFixture[Path]: _dir = test_dir / "filesystem" _dir.mkdir(exist_ok=True) Path(_dir, "logfile.log").touch() @@ -38,7 +40,7 @@ def temp_dir(test_dir: Path) -> Path: @pytest.fixture(scope="function") -def empty_dir(test_dir: Path) -> Path: +def empty_dir(test_dir: Path) -> YieldFixture[Path]: _dir = test_dir / "empty" _dir.mkdir(exist_ok=True) assert get_dir_size(_dir) == 0 @@ -94,7 +96,7 @@ def test_delete_old_files_emptyDirAsParam_raisesNoMoreFilesToDeleteError( assert get_dir_size(empty_dir) == 0 -def get_dir_size(dir_path: Path, suffix: str = None) -> int: +def get_dir_size(dir_path: Path, suffix: Union[str, None] = None) -> int: assert dir_path.is_dir() if suffix: return len([f for f in dir_path.iterdir() if f.suffix == suffix]) diff --git a/tests/helpers/name_test.py b/tests/helpers/name_test.py index a284f5a3..d751b64a 100644 --- a/tests/helpers/name_test.py +++ b/tests/helpers/name_test.py @@ -23,7 +23,7 @@ @mock.patch("OTCamera.helpers.name._current_dt", return_value="2022-05-18_22-00-59") -def test_log_correctFilename(mock_current_dt: mock.MagicMock): +def test_log_correctFilename(mock_current_dt: mock.MagicMock) -> None: actual = name.log() expected = ( f"{config.VIDEO_DIR}/{config.PREFIX}_FR{config.FPS}_2022-05-18_22-00-59.log" @@ -33,7 +33,7 @@ def test_log_correctFilename(mock_current_dt: mock.MagicMock): @mock.patch("OTCamera.helpers.name._current_dt", return_value="2022-05-18_22-00-59") -def test_video_correctFilename(mock_current_dt: mock.MagicMock): +def test_video_correctFilename(mock_current_dt: mock.MagicMock) -> None: actual = name.video() expected = ( f"{config.VIDEO_DIR}/{config.PREFIX}_FR{config.FPS}_2022-05-18_22-00-59.h264" @@ -42,9 +42,10 @@ def test_video_correctFilename(mock_current_dt: mock.MagicMock): assert actual == expected -def test_get_datetime_from_filename_correctFilenameAsParam(): +def test_get_datetime_from_filename_correctFilenameAsParam() -> None: timestamp = "otcamera01_2022-05-20_15-57-52.log" result_dt = name.get_datetime_from_filename(timestamp) + assert result_dt is not None assert result_dt.year == 2022 assert result_dt.month == 5 assert result_dt.day == 20 @@ -53,9 +54,10 @@ def test_get_datetime_from_filename_correctFilenameAsParam(): assert result_dt.second == 52 -def test_get_datetime_from_filename_correctPathAsParam(): +def test_get_datetime_from_filename_correctPathAsParam() -> None: timestamp = Path("path/to/otcamera01_2022-05-20_15-57-52.log") result_dt = name.get_datetime_from_filename(timestamp) + assert result_dt is not None assert result_dt.year == 2022 assert result_dt.month == 5 assert result_dt.day == 20 @@ -71,7 +73,7 @@ def test_get_datetime_from_filename_correctPathAsParam(): "fname-13130000-00-00_00-00-00123123", ], ) -def test_get_datetime_from_filename_invalidDateFormatAsParam(file_name): +def test_get_datetime_from_filename_invalidDateFormatAsParam(file_name: str) -> None: result = name.get_datetime_from_filename(file_name) assert result is None @@ -86,7 +88,7 @@ def test_get_datetime_from_filename_invalidDateFormatAsParam(file_name): "fname_0001-02-31_70-01-01", ], ) -def test_get_datetime_from_filename_invalidDateAsParam(file_name): +def test_get_datetime_from_filename_invalidDateAsParam(file_name: str) -> None: result = name.get_datetime_from_filename(file_name) assert result is None @@ -100,7 +102,7 @@ def test_get_datetime_from_filename_invalidDateAsParam(file_name): ("fname_FR0_-01-01_01-01-01", 0), ], ) -def test_get_fps_from_filename_validFpsAsParam(fname: str, expected: int): +def test_get_fps_from_filename_validFpsAsParam(fname: str, expected: int) -> None: result = name.get_fps_from_filename(fname) assert result == expected @@ -114,6 +116,6 @@ def test_get_fps_from_filename_validFpsAsParam(fname: str, expected: int): "fname_FR_", ], ) -def test_get_fps_from_filename_invalidFilenameAsParam(fname): +def test_get_fps_from_filename_invalidFilenameAsParam(fname:str) -> None: result = name.get_fps_from_filename(fname) assert result is None diff --git a/tests/otcamera_test.py b/tests/otcamera_test.py index f1e441d4..d1747d71 100644 --- a/tests/otcamera_test.py +++ b/tests/otcamera_test.py @@ -14,5 +14,5 @@ # program. If not, see . -def test_placeholder(): +def test_placeholder() -> None: pass diff --git a/usb_flash_drive_copy.py b/usb_flash_drive_copy.py index 148afea6..ba410596 100644 --- a/usb_flash_drive_copy.py +++ b/usb_flash_drive_copy.py @@ -43,7 +43,7 @@ class Observer(ABC): """The Observer interface declaring update method used by subjects.""" @abstractmethod - def update(self, is_active: bool): + def update(self, is_active: bool) -> None: """Receive update from subject. Args: @@ -165,16 +165,16 @@ def notify(self) -> None: for observer in self._observers: observer.update(self.is_active) - def _register_callbacks(self): + def _register_callbacks(self) -> None: self._button.when_deactivated = self.on_released log.write(f"Register {self.name} button callbacks.", log.LogLevel.DEBUG) - def on_released(self): + def on_released(self) -> None: """Notifies observers about button released event.""" log.write("Power button released.", log.LogLevel.DEBUG) self.is_active = False self.notify() - log.write("Observers have been notified", log.LogLevel.Debug) + log.write("Observers have been notified", log.LogLevel.DEBUG) def _check_button_is_active(self) -> None: if not self._button.is_active: @@ -194,7 +194,7 @@ def __init__( self.dest_dir = dest_dir self._validate_copy_info() - def _validate_copy_info(self): + def _validate_copy_info(self) -> None: """Validate and update video copy information with actual videos on disk.""" videos_on_src = self._get_videos_from_src() @@ -229,7 +229,7 @@ def _get_videos_from_src(self) -> set[Video]: ) return videos_on_src - def remove(self, video: Video): + def remove(self, video: Video) -> None: """Remove video from videos list.""" self.videos.discard(video) @@ -260,7 +260,7 @@ def get_copy_info_csv(directory: Path) -> Path: return Path(directory, f"{get_hostname()}{COPY_INFO_CSV_SUFFIX}") @staticmethod - def create_new(src_dir: Path, dest_dir: Path, filetype: str): + def create_new(src_dir: Path, dest_dir: Path, filetype: str)-> CopyInformation: dest_dir.mkdir(parents=True, exist_ok=True) copy_csv_file = CopyInformation.get_copy_info_csv(dest_dir) copy_csv_file.touch() @@ -359,7 +359,7 @@ def __init__( def update(self, is_active: bool) -> None: self.shutdown_requested = not is_active - def shutdown(self): + def shutdown(self) -> None: """Shutdown OTCamera.""" self._turn_off_all_leds() @@ -371,7 +371,7 @@ def shutdown(self): log.closefile() subprocess.call("sudo shutdown -h now", shell=True) - def _turn_off_all_leds(self): + def _turn_off_all_leds(self) -> None: """Turn off all LEDs.""" self.power_led.turn_off() self.wifi_led.turn_off() @@ -532,7 +532,7 @@ def build_usb_copier(src_dir: Path, usb_mount_point: Path) -> OTCameraUsbCopier: def main( video_dir: str, mount_point: str, -): +) -> None: """Start the OTCamera USB copy script. Args: From 0ddea5bfc16a0959e3eb804c0595cf2432c3d6b6 Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Fri, 23 May 2025 17:04:58 +0200 Subject: [PATCH 11/44] Sort imports --- OTCamera/status.py | 3 +-- hardware_test.py | 4 ++-- tests/conftest.py | 1 + tests/helpers/name_test.py | 2 +- usb_flash_drive_copy.py | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/OTCamera/status.py b/OTCamera/status.py index a445ca04..68a05e2e 100644 --- a/OTCamera/status.py +++ b/OTCamera/status.py @@ -22,8 +22,7 @@ import re import subprocess -from datetime import datetime -from datetime import timedelta +from datetime import datetime, timedelta from pathlib import Path from typing import Union diff --git a/hardware_test.py b/hardware_test.py index d3931f9c..6e5146ea 100644 --- a/hardware_test.py +++ b/hardware_test.py @@ -17,10 +17,10 @@ import time from pathlib import Path from subprocess import call +from typing import Any, Callable, TypeVar from gpiozero import PWMLED, Button from picamerax import PiCamera -from typing import Any, Callable, TypeVar # Button GPIO Pins BUTTON_POWER_PIN = 17 @@ -55,8 +55,8 @@ hour_led = PWMLED(LED_REC_PIN) +F = TypeVar("F", bound=Callable[..., Any]) -F = TypeVar('F', bound=Callable[..., Any]) def surround_with_dashes(func: F) -> F: def wrapper_func(*args: Any, **kwargs: Any) -> Any: diff --git a/tests/conftest.py b/tests/conftest.py index 5e3ca689..75e862c9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,6 +22,7 @@ T = TypeVar("T") YieldFixture = Generator[T, None, None] + @pytest.fixture def test_dir() -> YieldFixture[Path]: test_dir = Path(__file__).parent / "data" diff --git a/tests/helpers/name_test.py b/tests/helpers/name_test.py index d751b64a..4c87238b 100644 --- a/tests/helpers/name_test.py +++ b/tests/helpers/name_test.py @@ -116,6 +116,6 @@ def test_get_fps_from_filename_validFpsAsParam(fname: str, expected: int) -> Non "fname_FR_", ], ) -def test_get_fps_from_filename_invalidFilenameAsParam(fname:str) -> None: +def test_get_fps_from_filename_invalidFilenameAsParam(fname: str) -> None: result = name.get_fps_from_filename(fname) assert result is None diff --git a/usb_flash_drive_copy.py b/usb_flash_drive_copy.py index ba410596..ad4a933d 100644 --- a/usb_flash_drive_copy.py +++ b/usb_flash_drive_copy.py @@ -260,7 +260,7 @@ def get_copy_info_csv(directory: Path) -> Path: return Path(directory, f"{get_hostname()}{COPY_INFO_CSV_SUFFIX}") @staticmethod - def create_new(src_dir: Path, dest_dir: Path, filetype: str)-> CopyInformation: + def create_new(src_dir: Path, dest_dir: Path, filetype: str) -> "CopyInformation": dest_dir.mkdir(parents=True, exist_ok=True) copy_csv_file = CopyInformation.get_copy_info_csv(dest_dir) copy_csv_file.touch() From 6f2098caad8459a56e13b171ffa72e8511c0fb5e Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Fri, 23 May 2025 17:06:02 +0200 Subject: [PATCH 12/44] Lower Python version requirement and add mypy overrides. Reduced the minimum Python version requirement to >=3.9 since OTCamera runs on Raspberry Pi Zero W. --- pyproject.toml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bface850..1af11164 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ authors = [ description = "OTCamera is a core module of the OpenTrafficCam framework to record videos over multiple days with a custom camera system based on the Raspberry Pi Zero W." readme = "README.md" -requires-python = ">=3.11" +requires-python = ">=3.9" license = "GPL-3.0-only" license-files = ["LICENSE"] classifiers = [ @@ -53,6 +53,10 @@ ignore_missing_imports = true ignore_missing_imports_per_module = true disallow_untyped_defs = true -[tool.pytest.ini_options] -asyncio_mode = "auto" -asyncio_default_fixture_loop_scope = "function" \ No newline at end of file +[[tool.mypy.overrides]] +module = "OTCamera.html_updater" +ignore_errors = true + +[[tool.mypy.overrides]] +module = "tests.html_updater_test" +ignore_errors = true From e91616ec916b24b6729fb4f4db31ae03b1ae0d27 Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Fri, 23 May 2025 17:09:40 +0200 Subject: [PATCH 13/44] Refactor type hints and update dependencies. Replaced Python 3.10+ pipe syntax for Union with explicit Union since we are working with python3.9. Added `opencv-python` to `requirements-dev.txt` and pre-commit additional dependencies. Updated pre-commit mypy entry to target `OTCamera` instead of `OTAnalytics`. --- .pre-commit-config.yaml | 3 ++- requirements-dev.txt | 1 + update_precommit.py | 22 ++++++++++++---------- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 660aa385..37865a40 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -42,7 +42,7 @@ repos: rev: v1.15.0 hooks: - id: mypy - entry: mypy OTAnalytics tests --config-file=pyproject.toml + entry: mypy OTCamera tests --config-file=pyproject.toml additional_dependencies: - art==6.5 - black==25.1.0 @@ -50,6 +50,7 @@ repos: - hatch-requirements-txt==0.4.1 - isort==6.0.1 - mypy==1.15.0 + - opencv-python==4.11.0.86 - picamerax==24.3.21 - pre-commit==4.2.0 - pytest-cov==6.1.1 diff --git a/requirements-dev.txt b/requirements-dev.txt index b3d8302a..6b45cb95 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -8,3 +8,4 @@ pre-commit==4.2.0 pytest==8.3.5 pytest-cov==6.1.1 requests== 2.32.3 +opencv-python==4.11.0.86 diff --git a/update_precommit.py b/update_precommit.py index 079c1697..f3595d9e 100755 --- a/update_precommit.py +++ b/update_precommit.py @@ -5,7 +5,7 @@ from copy import deepcopy from dataclasses import dataclass from pathlib import Path -from typing import Iterable +from typing import Iterable, Union import requests import yaml @@ -44,7 +44,7 @@ def name(self) -> str: @property @abstractmethod - def version(self) -> str | None: + def version(self) -> Union[str, None]: raise NotImplementedError def __hash__(self) -> int: @@ -62,10 +62,10 @@ def name(self) -> str: return self._name @property - def version(self) -> str | None: + def version(self) -> Union[str, None]: return self._version - def __init__(self, name: str, version: str | None) -> None: + def __init__(self, name: str, version: Union[str, None]) -> None: self._name = name self._version = version @@ -79,10 +79,10 @@ def name(self) -> str: return self._name @property - def version(self) -> str | None: + def version(self) -> Union[str, None]: return self._version - def __init__(self, name: str, version: str | None) -> None: + def __init__(self, name: str, version: Union[str, None]) -> None: self._name = name self._version = version @@ -146,7 +146,7 @@ def parse_requirements_file(requirements_file: Path) -> set[AdditionalMypyDepend pattern_extra_index_url = re.compile(r"^--extra-index-url\s+(?P\S+)") -def parse_requirement(requirement_line: str) -> AdditionalMypyDependency | None: +def parse_requirement(requirement_line: str) -> Union[AdditionalMypyDependency, None]: """Extract package name from a requirement line using regex.""" # Regex pattern to capture the package name, ignoring version specifiers if match_extra_index_url := pattern_extra_index_url.match(requirement_line): @@ -169,7 +169,7 @@ def create_extra_index_url(url: str) -> AdditionalMypyDependency: return ExtraIndexUrl(url) -def create_package(name: str, version: str | None) -> AdditionalMypyDependency: +def create_package(name: str, version: Union[str, None]) -> AdditionalMypyDependency: """Check if a type stub exists for a given package name and return it.""" types_package_name = f"types-{name}" if __check_types_for_package_exists(types_package_name): @@ -186,12 +186,14 @@ def __check_types_for_package_exists(package_name: str) -> bool: def create_type_stub_package( - name: str, version: str | None + name: str, version: Union[str, None] ) -> AdditionalMypyDependency: return TypeStubPackage(name=name, version=version) -def create_normal_package(name: str, version: str | None) -> AdditionalMypyDependency: +def create_normal_package( + name: str, version: Union[str, None] +) -> AdditionalMypyDependency: return NormalPackage(name=name, version=version) From 56c6653725c712c5a679c5c4fbf750c836d7f331 Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Fri, 23 May 2025 17:10:22 +0200 Subject: [PATCH 14/44] Sort requirements-dev.txt --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 6b45cb95..55b134e3 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,8 +4,8 @@ flake8==7.2.0 hatch-requirements-txt==0.4.1 isort==6.0.1 mypy==1.15.0 +opencv-python==4.11.0.86 pre-commit==4.2.0 pytest==8.3.5 pytest-cov==6.1.1 requests== 2.32.3 -opencv-python==4.11.0.86 From 2bc93908141ab291dfca194ae40afcbf6106a1cd Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Fri, 23 May 2025 17:12:27 +0200 Subject: [PATCH 15/44] Fix log file sorting functionality to handle files without timestamp contained in file name --- OTCamera/record.py | 49 +++++++++++++++++++++++-------- tests/record_test.py | 70 +++++++++++++++++++++++++++++--------------- 2 files changed, 82 insertions(+), 37 deletions(-) diff --git a/OTCamera/record.py b/OTCamera/record.py index c9f05ffa..9ea0764f 100644 --- a/OTCamera/record.py +++ b/OTCamera/record.py @@ -27,7 +27,7 @@ from datetime import datetime as dt from pathlib import Path from time import sleep -from typing import Union +from typing import Any, Iterator, Union from OTCamera import config, status from OTCamera.hardware import button, led @@ -290,30 +290,26 @@ def _get_log_info(self, start_idx: int, num: int) -> LogDataObject: return LogDataObject(log_data=(LogHtmlId.LOG_DATA, log_data)) def _get_num_recent_log_files(self, start_idx: int, num: int) -> list[Path]: - """Get `num` first log files starting from `start_idx`. + """Get `num` first log files starting from `start_idx`. Args: start_idx (int): The start index specifies the log files to be considered. - The log files are sorted after their time of creation in descending order. - Meaning a start_idx of 1 ignores the newest log file. - num (int): The num recent log files stasrting from `start_idx` to get their - data from. + The log files are sorted after their time of creation in descending + order. Meaning a start_idx of 1 ignores the newest log file. + num (int): The num recent log files starting from `start_idx` to get their + data from. Returns: list[Path]: The log `num` log file paths starting from index `start_idx`. """ - # Gather all log files paths in an ordered list - log_filepaths = [f for f in self._log_dir.iterdir() if f.suffix == ".log"] # Log filepaths sorted in descending order with newest one at index 0 - sorted_log_filepaths = sorted( - log_filepaths, key=name.get_datetime_from_filename, reverse=True - ) + sorted_log_files = get_log_files_sorted(self._log_dir.iterdir()) # Get num recent log files - recent_log_files = sorted_log_filepaths[start_idx:num] + recent_log_files = sorted_log_files[start_idx:num] recent_log_files.reverse() return recent_log_files - def _execute_shutdown(self, *args): + def _execute_shutdown(self, *args: Any) -> None: """Code to execute when stopping OTCamera. ### Following tasks are executed @@ -340,6 +336,33 @@ def _execute_shutdown(self, *args): sys.exit(0) +def get_log_files_sorted(log_files: Iterator[Path]) -> list[Path]: + """ + Get all log files sorted by their timestamp contained in their file name in + descending order. + + Args: + log_files (Iterator[Path]): An iterator of log files. + + Return: + list[Path]: A list of sorted log files. + """ + log_files_with_timestamp: list[tuple[dt, Path]] = [] + log_files_without_timestamp: list[Path] = [] + + for log_file in log_files: + if log_file.suffix == ".log": + if timestamp := name.get_datetime_from_filename(log_file): + log_files_with_timestamp.append((timestamp, log_file)) + else: + log_files_without_timestamp.append(log_file) + + log_files_with_timestamp.sort(key=lambda entry: entry[0], reverse=True) + + sorted_log_files = [log_file for _, log_file in log_files_with_timestamp] + return sorted_log_files + log_files_without_timestamp + + def main() -> None: """Start running OTCamera.""" camera = Camera() diff --git a/tests/record_test.py b/tests/record_test.py index c29d2824..ce7647c2 100644 --- a/tests/record_test.py +++ b/tests/record_test.py @@ -16,19 +16,26 @@ import errno import shutil from pathlib import Path +from typing import Iterator, Union from unittest import mock -import cv2 +from cv2 import VideoCapture import pytest from OTCamera import config from OTCamera.hardware.camera import Camera from OTCamera.html_updater import StatusWebsiteUpdater -from OTCamera.record import OTCamera +from OTCamera.record import OTCamera, get_log_files_sorted +from tests.conftest import YieldFixture + +LOG_FILE_1 = Path("name_1990-01-01_20-00-00.log") +LOG_FILE_2 = Path("name_1990-01-01_21-01-00.log") +LOG_FILE_3 = Path("name_1990-01-01_21-04-00.log") +LOG_FILE_WITHOUT_TIMESTAMP = Path("name_no_timestamp.log") @pytest.fixture -def temp_dir(test_dir: Path): +def temp_dir(test_dir: Path) -> YieldFixture[Path]: tmp_tests_dir = test_dir / "record" tmp_tests_dir.mkdir(exist_ok=True) Path(tmp_tests_dir, "file_1.txt").touch() @@ -38,26 +45,23 @@ def temp_dir(test_dir: Path): @pytest.fixture -def log_files_dir(test_dir: Path): - log_file_name_1 = "name_1990-01-01_20-00-00.log" - log_file_name_2 = "name_1990-01-01_21-01-00.log" - log_file_name_3 = "name_1990-01-01_21-04-00.log" +def log_files_dir(test_dir: Path) -> YieldFixture[Path]: log_files_dir = test_dir / "logs" log_files_dir.mkdir(exist_ok=True) - Path(log_files_dir, log_file_name_1).touch() - Path(log_files_dir, log_file_name_2).touch() - Path(log_files_dir, log_file_name_3).touch() + Path(log_files_dir, LOG_FILE_1).touch() + Path(log_files_dir, LOG_FILE_2).touch() + Path(log_files_dir, LOG_FILE_3).touch() yield log_files_dir shutil.rmtree(log_files_dir) @pytest.fixture def html_updater() -> StatusWebsiteUpdater: - return StatusWebsiteUpdater(debug_mode_on=True) + return mock.Mock() @pytest.fixture -def otcamera(html_updater: StatusWebsiteUpdater, temp_dir: Path): +def otcamera(html_updater: StatusWebsiteUpdater, temp_dir: Path) -> OTCamera: return OTCamera(camera=Camera(), html_updater=html_updater, video_dir=temp_dir) @@ -76,7 +80,7 @@ def test_record_handleENOSPC( mock_loop: mock.MagicMock, mock_execute_shutdown: mock.MagicMock, otcamera: OTCamera, -): +) -> None: with pytest.raises(Exception) as e: # record(camera, temp_dir) otcamera.record() @@ -87,7 +91,10 @@ def test_record_handleENOSPC( assert str(e.value).startswith("ENOSPC error handling section entered") -def test_record_videoRecordedHasCorrectFrames(otcamera: OTCamera, test_dir: Path): +@pytest.mark.skip +def test_record_videoRecordedHasCorrectFrames( + otcamera: OTCamera, test_dir: Path +) -> None: config.NUM_INTERVALS = 2 config.INTERVAL_LENGTH = 2 # in min @@ -113,27 +120,42 @@ def test_record_videoRecordedHasCorrectFrames(otcamera: OTCamera, test_dir: Path ) -def test_get_log_info(log_files_dir: Path): - otcamera = OTCamera(Camera(), None, log_dir=log_files_dir) +def test_get_log_info(log_files_dir: Path) -> None: + otcamera = OTCamera(Camera(), mock.Mock(), log_dir=log_files_dir) otcamera._log_dir = log_files_dir log_files = otcamera._get_num_recent_log_files(0, 2) assert len(log_files) == 2 -def get_frame_count(video_path: Path): - def manual_count(handler): - frames = 0 +def get_frame_count(video_path: Union[Path, str]) -> int: + def manual_count(handler: VideoCapture) -> int: + count = 0 while True: _status, frame = handler.read() if not _status: break - frames += 1 - return frames + count += 1 + return count - cap = cv2.VideoCapture(str(video_path)) + cap = VideoCapture(str(video_path)) # Slow, inefficient but 100% accurate method - frames = manual_count(cap) + frame_count = manual_count(cap) cap.release() - return frames + return frame_count + + +class TestGetLogFilesSorted: + def test_is_sorted_by_timestamp_in_filename(self) -> None: + given = self.create_unsorted_log_files() + actual = get_log_files_sorted(given) + assert actual == [ + LOG_FILE_3, + LOG_FILE_2, + LOG_FILE_1, + LOG_FILE_WITHOUT_TIMESTAMP, + ] + + def create_unsorted_log_files(self) -> Iterator[Path]: + return iter([LOG_FILE_2, LOG_FILE_WITHOUT_TIMESTAMP, LOG_FILE_1, LOG_FILE_3]) From 4231f834884cd153c6a8d3957b20bc802dd012de Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Fri, 23 May 2025 17:22:25 +0200 Subject: [PATCH 16/44] Refactor type hints and fix import formatting. Updated type hints for `parse_args` and `main` to `None` for clarity and consistency. Adjusted --- OTCamera/config.py | 5 ++--- OTCamera/helpers/filesystem.py | 6 +++--- run.py | 4 ++-- tests/record_test.py | 2 +- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/OTCamera/config.py b/OTCamera/config.py index c038c02d..faa44f07 100644 --- a/OTCamera/config.py +++ b/OTCamera/config.py @@ -25,9 +25,9 @@ from pathlib import Path try: - from yaml import CSafeLoader as SafeLoader + from yaml import CSafeLoader as SafeLoader # type: ignore except ImportError: - from yaml import SafeLoader + from yaml import SafeLoader # type: ignore import yaml @@ -84,7 +84,6 @@ def parse_user_config(config_file: str) -> None: except KeyError: _print_key_err_msg("recording.end_hour") try: - global INTERVAL_LENGTH setattr(module, "INTERVAL_LENGTH", section["interval_length"]) except KeyError: _print_key_err_msg("recording.interval_length") diff --git a/OTCamera/helpers/filesystem.py b/OTCamera/helpers/filesystem.py index e1a36acc..a66e2eb3 100644 --- a/OTCamera/helpers/filesystem.py +++ b/OTCamera/helpers/filesystem.py @@ -80,12 +80,12 @@ def delete_old_files( oldest_video.unlink() log.breakline() log.write(f"Deleted {oldest_video}") - free_space = psutil.disk_usage(absolute_video_dirpath).free + free_space = psutil.disk_usage(str(absolute_video_dirpath)).free log.write(f"free space: {free_space}", level=log.LogLevel.INFO) def _enough_space(directory: Path, min_free_space: int) -> bool: - free_space = psutil.disk_usage(directory).free + free_space = psutil.disk_usage(str(directory)).free log.write(f"free space: {free_space}", level=log.LogLevel.DEBUG) log.write(f"min space: {min_free_space}", level=log.LogLevel.DEBUG) return free_space > min_free_space @@ -97,4 +97,4 @@ def resolve_path(path: Union[str, Path]) -> Path: def calc_free_diskspace(directory: Union[str, Path]) -> int: resolved_path = resolve_path(directory) - return psutil.disk_usage(resolved_path).free + return psutil.disk_usage(str(resolved_path)).free diff --git a/run.py b/run.py index 6c0b3220..3c487840 100644 --- a/run.py +++ b/run.py @@ -19,7 +19,7 @@ import OTCamera.config as config -def parse_args() -> Path: +def parse_args() -> None: parser = argparse.ArgumentParser() parser.add_argument( "-c", @@ -45,7 +45,7 @@ def usb_device_exists(usb_device: str) -> bool: return Path(usb_device).exists() -def main(): +def main() -> None: parse_args() if usb_device_exists(config.USB_DEVICE): diff --git a/tests/record_test.py b/tests/record_test.py index ce7647c2..cf89faa6 100644 --- a/tests/record_test.py +++ b/tests/record_test.py @@ -19,8 +19,8 @@ from typing import Iterator, Union from unittest import mock -from cv2 import VideoCapture import pytest +from cv2 import VideoCapture from OTCamera import config from OTCamera.hardware.camera import Camera From 0b73f3508a35a71fa942c46d4360e7790f6d464b Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Tue, 17 Jun 2025 12:08:56 +0200 Subject: [PATCH 17/44] Update docstring format in `camera.py` for improved readability and consistency --- OTCamera/hardware/camera.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/OTCamera/hardware/camera.py b/OTCamera/hardware/camera.py index 248fa9a8..5ab2bf20 100644 --- a/OTCamera/hardware/camera.py +++ b/OTCamera/hardware/camera.py @@ -64,14 +64,14 @@ class Camera(Singleton): """The camera class providing functionality such as starting or stopping a recording, capturing a preview image, or closing the camera - Attributes: + Args: framerate (int, optional): The frame rate. Defaults to config.FPS. - resolution (Tuple[int, int], optional): The resolution. - Defaults to config.RESOLUTION. + resolution (Tuple[int, int], optional): The resolution. Defaults to + config.RESOLUTION. annotate_background (Color, optional): Color of text annotation background. - Defaults to Color("black"). + Defaults to Color("black"). exposure_mode (str, optional): The exposure mode. Defaults to - config.EXPOSURE_MODE. + config.EXPOSURE_MODE. awb_mode (str, optional): The awb mode. Defaults to config.AWB_MODE. drc_strength (str, optional): The DRC strength. Defaults to config.DRC_STRENGTH. rotation (int, optional): The image rotation. Defaults to config.ROTATION. From 919a1f73bd4c79eb8365f90409a83c04d79a81fb Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Tue, 17 Jun 2025 15:52:53 +0200 Subject: [PATCH 18/44] Introduce Camera interface to pave the way for new camera module --- OTCamera/domain/__init__.py | 0 OTCamera/domain/camera.py | 168 ++++++++++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 OTCamera/domain/__init__.py create mode 100644 OTCamera/domain/camera.py diff --git a/OTCamera/domain/__init__.py b/OTCamera/domain/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/OTCamera/domain/camera.py b/OTCamera/domain/camera.py new file mode 100644 index 00000000..8622d5ff --- /dev/null +++ b/OTCamera/domain/camera.py @@ -0,0 +1,168 @@ +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Literal, Tuple + +H264Profile = Literal["baseline", "main", "high", "constrained"] +H264Level = Literal[ + "1", + "1.0", + "1b", + "1.1", + "1.2", + "1.3", + "2", + "2.0", + "2.1", + "2.2", + "3", + "3.0", + "3.1", + "3.2", + "4", + "4.0", + "4.1", + "4.2", +] +VideoFormat = Literal["h264", "mjpeg", "yuv", "rgb", "rgba", "bgr", "bgra"] +""" +h264: Write an H.264 video stream +mjpeg: Write an M-JPEG video stream +yuv: Write the raw video data to a file in YUV420 format +rgb: Write the raw video data to a file in 24-bit RGB format +rgba: Write the raw video data to a file in 32-bit RGBA format +bgr: Write the raw video data to a file in 24-bit BGR format +bgra: Write the raw video data to a file in 32-bit BGRA format +""" + + +class Camera(ABC): + + @property + @abstractmethod + def is_recording(self) -> bool: + raise NotImplementedError + + @property + @abstractmethod + def framerate(self) -> int: + raise NotImplementedError + + @property + @abstractmethod + def resolution(self) -> Tuple[int, int]: + raise NotImplementedError + + @property + @abstractmethod + def exposure_mode(self) -> str: + raise NotImplementedError + + @property + @abstractmethod + def awb_mode(self) -> str: + raise NotImplementedError + + @property + @abstractmethod + def drc_strength(self) -> str: + raise NotImplementedError + + @property + @abstractmethod + def rotation(self) -> int: + raise NotImplementedError + + @property + @abstractmethod + def meter_mode(self) -> str: + raise NotImplementedError + + @abstractmethod + def start_recording( + self, + save_file: Path, + video_format: VideoFormat, + resolution: tuple[int, int], + bitrate: int, + h264_profile: H264Profile, + h264_level: H264Level, + h264_quality: int, + ) -> None: + """Start video recording. + + Args: + save_file (Path): Save location of the video file. + video_format (VideoFormat): Video format used for recording. + resolution (tuple[int, int]): Resolution of the saved video file. + bitrate (int): Bitrate at which the video will be encoded. The maximum + value depends on the selected H.264 leven and profile. Bitrate 0 + indicates the encoder should not use bitrate control. + h264_profile (H264Profile): H.264 profile used for encoding. Possible values + are "baseline", "main", "high", "constrained". + h264_level (H264Level): H.264 level used for encoding. Can be any H.264 + level up to "4.2'. + h264_quality (int): Quality of the encoded video. The value ranges from + 10 and 40 where 10 is extremely high quality and 40 is extremely low + (20-25 is usually a reasonable range for H.264 encoding). + """ + raise NotImplementedError + + @abstractmethod + def capture( + self, + save_file: str, + image_format: str, + resolution: tuple[int, int], + annotation_text: str, + ) -> None: + """Capture a preview image of the camera.""" + raise NotImplementedError + + @abstractmethod + def wait_recording(self, timeout: int) -> None: + raise NotImplementedError + + @abstractmethod + def split_recording(self, save_path: Path) -> None: + raise NotImplementedError + + @abstractmethod + def stop_recording(self) -> None: + raise NotImplementedError + + @abstractmethod + def close(self) -> None: + """Close the camera.""" + raise NotImplementedError + + @abstractmethod + def set_frame_rate(self, value: int) -> None: + raise NotImplementedError + + @abstractmethod + def set_resolution(self, value: tuple[int, int]) -> None: + raise NotImplementedError + + @abstractmethod + def set_exposure_mode(self, value: str) -> None: + raise NotImplementedError + + @abstractmethod + def set_awb_mode(self, value: str) -> None: + raise NotImplementedError + + @abstractmethod + def set_video_format(self, value: str) -> None: + raise NotImplementedError + + @abstractmethod + def set_drc_strength(self, value: str) -> None: + raise NotImplementedError + + @abstractmethod + def set_rotation(self, value: int) -> None: + raise NotImplementedError + + @abstractmethod + def set_meter_mode(self, value: str) -> None: + raise NotImplementedError From dd271a8e267658e8e87b9223638a19b7e911a7c2 Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Tue, 17 Jun 2025 15:55:27 +0200 Subject: [PATCH 19/44] Refactor existing picamerax's PiCamera class to own PiCameraX implementing newly introduced Camera interface --- OTCamera/plugin/__init__.py | 0 OTCamera/plugin/camera/__init__.py | 0 OTCamera/plugin/camera/picamerax.py | 140 ++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+) create mode 100644 OTCamera/plugin/__init__.py create mode 100644 OTCamera/plugin/camera/__init__.py create mode 100644 OTCamera/plugin/camera/picamerax.py diff --git a/OTCamera/plugin/__init__.py b/OTCamera/plugin/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/OTCamera/plugin/camera/__init__.py b/OTCamera/plugin/camera/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/OTCamera/plugin/camera/picamerax.py b/OTCamera/plugin/camera/picamerax.py new file mode 100644 index 00000000..b0d8d2c2 --- /dev/null +++ b/OTCamera/plugin/camera/picamerax.py @@ -0,0 +1,140 @@ +from pathlib import Path +from typing import Tuple + +from picamerax import PiCamera + +from OTCamera.domain.camera import Camera, H264Level, H264Profile, VideoFormat +from OTCamera.hardware.camera import Singleton + + +class PiCameraX(Camera, Singleton): + + @property + def framerate(self) -> int: + return self._picamera.framerate + + @property + def resolution(self) -> Tuple[int, int]: + return self._picamera.resolution + + @property + def exposure_mode(self) -> str: + return self._picamera.exposure_mode + + @property + def awb_mode(self) -> str: + return self._picamera.awb_mode + + @property + def drc_strength(self) -> str: + return self._picamera.drc_strength + + @property + def rotation(self) -> int: + return self._picamera.rotation + + @property + def meter_mode(self) -> str: + return self._picamera.meter_mode + + def init( + self, + picamera: PiCamera, + frame_rate: int, + resolution: tuple[int, int], + exposure_mode: str, + awb_mode: str, + video_format: str, + drc_strength: str, + rotation: int, + meter_mode: str, + ) -> None: + # TODO: Change init to __init__ when removing Singleton abstract class + self._picamera = picamera + self._video_format = video_format + + self.set_frame_rate(frame_rate) + self.set_resolution(resolution) + self.set_exposure_mode(exposure_mode) + self.set_awb_mode(awb_mode) + self.set_video_format(video_format) + self.set_drc_strength(drc_strength) + self.set_rotation(rotation) + self.set_meter_mode(meter_mode) + + @property + def is_recording(self) -> bool: + return self._picamera.recording + + def start_recording( + self, + save_file: Path, + video_format: VideoFormat, + resolution: tuple[int, int], + bitrate: int, + h264_profile: H264Profile, + h264_level: H264Level, + h264_quality: int, + ) -> None: + self._picamera.start_recording( + output=save_file, + format=video_format, + resize=resolution, + bitrate=bitrate, + profile=h264_profile, + level=h264_level, + quality=h264_quality, + ) + + def capture( + self, + save_file: str, + image_format: str, + resolution: tuple[int, int], + annotation_text: str, + ) -> None: + self._picamera.capture( + output=save_file, + format=image_format, + resize=resolution, + use_video_port=True, + ) + + def wait_recording(self, timeout: int) -> None: + self._picamera.wait_recording(timeout=timeout) + + def split_recording(self, save_path: Path) -> None: + self._picamera.split_recording(output=save_path) + + def stop_recording(self) -> None: + self._picamera.stop_recording() + + def close(self) -> None: + self._picamera.close() + + def set_annotation_text(self, value: str) -> None: + self._picamera.annotate_text = value + + def set_frame_rate(self, value: int) -> None: + self._picamera.framerate = value + + def set_resolution(self, value: tuple[int, int]) -> None: + self._picamera.resolution = value + + def set_exposure_mode(self, value: str) -> None: + self._picamera.exposure_mode = value + + def set_awb_mode(self, value: str) -> None: + self._picamera.awb_mode = value + + def set_drc_strength(self, value: str) -> None: + self._picamera.drc_strength = value + + def set_rotation(self, value: int) -> None: + self._picamera.rotation = value + + def set_meter_mode(self, value: str) -> None: + self._picamera.meter_mode = value + + def set_video_format(self, value: str) -> None: + self._video_format = value # Usage -> start_recording From b0764c715862c71af47bcc23e7ede633d5a8a062 Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Tue, 17 Jun 2025 15:56:21 +0200 Subject: [PATCH 20/44] Define initial version constant for OTCamera module. --- OTCamera/version.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 OTCamera/version.py diff --git a/OTCamera/version.py b/OTCamera/version.py new file mode 100644 index 00000000..76fe15da --- /dev/null +++ b/OTCamera/version.py @@ -0,0 +1 @@ +__version__ = "0.0" From bd8ef1d4a4463dc64e5579d18fbdf96933899a2a Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Tue, 17 Jun 2025 15:58:38 +0200 Subject: [PATCH 21/44] Move Singleton definition to abstraction layer --- OTCamera/abstraction/__init__.py | 0 OTCamera/abstraction/singleton.py | 26 ++++++++++++++++++++++++++ OTCamera/hardware/camera.py | 28 ++-------------------------- OTCamera/plugin/camera/picamerax.py | 2 +- 4 files changed, 29 insertions(+), 27 deletions(-) create mode 100644 OTCamera/abstraction/__init__.py create mode 100644 OTCamera/abstraction/singleton.py diff --git a/OTCamera/abstraction/__init__.py b/OTCamera/abstraction/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/OTCamera/abstraction/singleton.py b/OTCamera/abstraction/singleton.py new file mode 100644 index 00000000..08df18dd --- /dev/null +++ b/OTCamera/abstraction/singleton.py @@ -0,0 +1,26 @@ +from typing import Any, Optional, Type, TypeVar + +T = TypeVar("T", bound="Singleton") + + +class Singleton(object): + """Implements the Singleton design pattern. + + Classes inheriting from `Singleton` become a singleton class. + Meaning only one instance is created. + Constructing another instance of the concrete class inheriting from `Singleton` + will return the first instance. + """ + + __it__: Optional["Singleton"] = None + + def __new__(cls: Type[T], *args: Any, **kwargs: Any) -> T: + it = cls.__dict__.get("__it__") + if it is not None: + return it + cls.__it__ = it = object.__new__(cls) + it.init(*args, **kwargs) + return it + + def init(self, *args: Any, **kwargs: Any) -> None: + pass diff --git a/OTCamera/hardware/camera.py b/OTCamera/hardware/camera.py index 5ab2bf20..3c40b885 100644 --- a/OTCamera/hardware/camera.py +++ b/OTCamera/hardware/camera.py @@ -22,43 +22,19 @@ from datetime import datetime as dt from time import sleep -from typing import Any, Optional, Tuple, Type, TypeVar, Union +from typing import Tuple, Union import picamerax as picamera from picamerax import Color from OTCamera import config, status +from OTCamera.abstraction.singleton import Singleton from OTCamera.hardware import led from OTCamera.helpers import log, name from OTCamera.helpers.filesystem import delete_old_files log.write("imported camera", level=log.LogLevel.DEBUG) -T = TypeVar("T", bound="Singleton") - - -class Singleton(object): - """Implements the Singleton design pattern. - - Classes inheriting from `Singleton` become a singleton class. - Meaning only one instance is created. - Constructing another instance of the concrete class inheriting from `Singleton` - will return the first instance. - """ - - __it__: Optional["Singleton"] = None - - def __new__(cls: Type[T], *args: Any, **kwargs: Any) -> T: - it = cls.__dict__.get("__it__") - if it is not None: - return it - cls.__it__ = it = object.__new__(cls) - it.init(*args, **kwargs) - return it - - def init(self, *args: Any, **kwargs: Any) -> None: - pass - class Camera(Singleton): """The camera class providing functionality such as starting or stopping a diff --git a/OTCamera/plugin/camera/picamerax.py b/OTCamera/plugin/camera/picamerax.py index b0d8d2c2..48f850b0 100644 --- a/OTCamera/plugin/camera/picamerax.py +++ b/OTCamera/plugin/camera/picamerax.py @@ -3,8 +3,8 @@ from picamerax import PiCamera +from OTCamera.abstraction.singleton import Singleton from OTCamera.domain.camera import Camera, H264Level, H264Profile, VideoFormat -from OTCamera.hardware.camera import Singleton class PiCameraX(Camera, Singleton): From 204c28190340e6fd4d122b5fe5c4ac1878875606 Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Tue, 17 Jun 2025 16:04:40 +0200 Subject: [PATCH 22/44] Rename `Camera` to `CameraController` and update references across the project. --- .../hardware/{camera.py => camera_controller.py} | 7 ++++--- OTCamera/helpers/rpi.py | 4 ++-- OTCamera/record.py | 13 +++++++------ tests/hardware/camera_test.py | 6 +++--- tests/record_test.py | 10 +++++++--- 5 files changed, 23 insertions(+), 17 deletions(-) rename OTCamera/hardware/{camera.py => camera_controller.py} (98%) diff --git a/OTCamera/hardware/camera.py b/OTCamera/hardware/camera_controller.py similarity index 98% rename from OTCamera/hardware/camera.py rename to OTCamera/hardware/camera_controller.py index 3c40b885..ddd4b0ca 100644 --- a/OTCamera/hardware/camera.py +++ b/OTCamera/hardware/camera_controller.py @@ -36,9 +36,10 @@ log.write("imported camera", level=log.LogLevel.DEBUG) -class Camera(Singleton): - """The camera class providing functionality such as starting or stopping a - recording, capturing a preview image, or closing the camera +class CameraController(Singleton): + """ + The camera controller class providing functionality such as starting or stopping + a recording, capturing a preview image, or closing the camera Args: framerate (int, optional): The frame rate. Defaults to config.FPS. diff --git a/OTCamera/helpers/rpi.py b/OTCamera/helpers/rpi.py index 0714ccf5..f90a19c4 100644 --- a/OTCamera/helpers/rpi.py +++ b/OTCamera/helpers/rpi.py @@ -24,11 +24,11 @@ from OTCamera import config, status from OTCamera.hardware import led -from OTCamera.hardware.camera import Camera +from OTCamera.hardware.camera_controller import CameraController from OTCamera.helpers import log log.write("imported rpi", level=log.LogLevel.DEBUG) -camera = Camera() +camera = CameraController() def shutdown() -> None: diff --git a/OTCamera/record.py b/OTCamera/record.py index 9ea0764f..a49404ab 100644 --- a/OTCamera/record.py +++ b/OTCamera/record.py @@ -31,7 +31,7 @@ from OTCamera import config, status from OTCamera.hardware import button, led -from OTCamera.hardware.camera import Camera +from OTCamera.hardware.camera_controller import CameraController from OTCamera.helpers import log, name from OTCamera.helpers.filesystem import delete_old_files from OTCamera.html_updater import ( @@ -52,7 +52,7 @@ class OTCamera: def __init__( self, - camera: Camera, + camera_controller: CameraController, html_updater: StatusWebsiteUpdater, capture_preview_immediately: bool = False, video_dir: Union[str, Path] = config.VIDEO_DIR, @@ -62,7 +62,8 @@ def __init__( """Constructor to initialise the OTCamera class. Args: - camera (Camera): The Camera class to control the Raspberry Pi camera. + camera_controller (CameraController): The class to control the Raspberry Pi + camera. html_updater (StatusWebsiteUpdater): The class providing functionality to update the status website. capture_preview_immediately (bool, optional): Whether to capture preview @@ -74,7 +75,7 @@ def __init__( num_log_files_html (int, optional): The number of logfiles to be displayed on the status website. Defaults to config.NUM_LOG_FILES_HTML. """ - self._camera = camera + self._camera = camera_controller self._html_updater = html_updater self._capture_preview_immediately = capture_preview_immediately self._video_dir = Path(video_dir) @@ -365,7 +366,7 @@ def get_log_files_sorted(log_files: Iterator[Path]) -> list[Path]: def main() -> None: """Start running OTCamera.""" - camera = Camera() + camera_controller = CameraController() html_updater = StatusWebsiteUpdater( template_html_path=config.TEMPLATE_HTML_PATH, offline_html_path=config.OFFLINE_HTML_PATH, @@ -375,7 +376,7 @@ def main() -> None: log_info_id="log-info", debug_mode_on=config.DEBUG_MODE_ON, ) - otcamera = OTCamera(camera=camera, html_updater=html_updater) + otcamera = OTCamera(camera_controller=camera_controller, html_updater=html_updater) otcamera.record() diff --git a/tests/hardware/camera_test.py b/tests/hardware/camera_test.py index 99ebe067..2c53ba38 100644 --- a/tests/hardware/camera_test.py +++ b/tests/hardware/camera_test.py @@ -13,10 +13,10 @@ # You should have received a copy of the GNU General Public License along with this # program. If not, see . -import OTCamera.hardware.camera as camera +import OTCamera.hardware.camera_controller as camera_controller def test_init_camera_same_instance() -> None: - cam_1 = camera.Camera() - cam_2 = camera.Camera() + cam_1 = camera_controller.CameraController() + cam_2 = camera_controller.CameraController() assert cam_1 == cam_2 diff --git a/tests/record_test.py b/tests/record_test.py index cf89faa6..67d8dcec 100644 --- a/tests/record_test.py +++ b/tests/record_test.py @@ -23,7 +23,7 @@ from cv2 import VideoCapture from OTCamera import config -from OTCamera.hardware.camera import Camera +from OTCamera.hardware.camera_controller import CameraController from OTCamera.html_updater import StatusWebsiteUpdater from OTCamera.record import OTCamera, get_log_files_sorted from tests.conftest import YieldFixture @@ -62,7 +62,11 @@ def html_updater() -> StatusWebsiteUpdater: @pytest.fixture def otcamera(html_updater: StatusWebsiteUpdater, temp_dir: Path) -> OTCamera: - return OTCamera(camera=Camera(), html_updater=html_updater, video_dir=temp_dir) + return OTCamera( + camera_controller=CameraController(), + html_updater=html_updater, + video_dir=temp_dir, + ) @mock.patch.object(OTCamera, "_execute_shutdown", return_value=None) @@ -121,7 +125,7 @@ def test_record_videoRecordedHasCorrectFrames( def test_get_log_info(log_files_dir: Path) -> None: - otcamera = OTCamera(Camera(), mock.Mock(), log_dir=log_files_dir) + otcamera = OTCamera(CameraController(), mock.Mock(), log_dir=log_files_dir) otcamera._log_dir = log_files_dir log_files = otcamera._get_num_recent_log_files(0, 2) From f57b9ccca0da4cde1ceaa93e72d6f14bd6473c71 Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Wed, 18 Jun 2025 11:58:54 +0200 Subject: [PATCH 23/44] Introduce `CameraProvider` for dynamic camera type management and refactor `CameraController` to use the `Camera` interface. --- OTCamera/config.py | 13 +- OTCamera/domain/camera.py | 29 +++-- OTCamera/hardware/camera_controller.py | 148 +++++++--------------- OTCamera/helpers/rpi.py | 3 +- OTCamera/plugin/camera/camera_provider.py | 30 +++++ OTCamera/plugin/camera/picamerax.py | 111 +++++++++++----- OTCamera/record.py | 4 +- tests/hardware/camera_test.py | 6 +- tests/record_test.py | 7 +- user_config.example.yaml | 1 + 10 files changed, 199 insertions(+), 153 deletions(-) create mode 100644 OTCamera/plugin/camera/camera_provider.py diff --git a/OTCamera/config.py b/OTCamera/config.py index faa44f07..03e35adb 100644 --- a/OTCamera/config.py +++ b/OTCamera/config.py @@ -23,6 +23,7 @@ import socket import sys from pathlib import Path +from typing import Literal try: from yaml import CSafeLoader as SafeLoader # type: ignore @@ -101,6 +102,10 @@ def parse_user_config(config_file: str) -> None: except KeyError: _print_key_err_msg("camera") else: + try: + setattr(module, "CAMERA_TYPE", section["type"]) + except KeyError: + _print_key_err_msg("camera.type") try: setattr(module, "FPS", section["fps"]) except KeyError: @@ -277,6 +282,8 @@ def read_text_file(text_file: Path) -> str: """free space in GB on sd card before old videos get deleted.""" # camera config +CAMERA_TYPE = "legacy" +"""Camera type. `legacy` for the original camera module.""" FPS = 20 """Frames per Second. 10-20 should be enough.""" RESOLUTION = (1640, 1232) @@ -305,13 +312,13 @@ def read_text_file(text_file: Path) -> str: # video config VIDEO_DIR = "~/videos/" """path to safe videofiles.""" -VIDEO_FORMAT = "h264" +VIDEO_FORMAT: Literal["h264"] = "h264" """Encoding format.""" RESOLUTION_SAVED_VIDEO_FILE = (800, 600) """Resolution of the saved videofile, not the camera itself.""" -H264_PROFILE = "high" +H264_PROFILE: Literal["high"] = "high" """Profile used in h264 encoder.""" -H264_LEVEL = "4" +H264_LEVEL: Literal["4"] = "4" """Level used in h264 encoder.""" H264_BITRATE = 600000 """Bitrate used in h264 encoder.""" diff --git a/OTCamera/domain/camera.py b/OTCamera/domain/camera.py index 8622d5ff..f9d5831b 100644 --- a/OTCamera/domain/camera.py +++ b/OTCamera/domain/camera.py @@ -1,6 +1,5 @@ from abc import ABC, abstractmethod -from pathlib import Path -from typing import Literal, Tuple +from typing import Literal, Tuple, Union H264Profile = Literal["baseline", "main", "high", "constrained"] H264Level = Literal[ @@ -80,7 +79,7 @@ def meter_mode(self) -> str: @abstractmethod def start_recording( self, - save_file: Path, + save_file: str, video_format: VideoFormat, resolution: tuple[int, int], bitrate: int, @@ -91,7 +90,7 @@ def start_recording( """Start video recording. Args: - save_file (Path): Save location of the video file. + save_file (str): Save location of the video file. video_format (VideoFormat): Video format used for recording. resolution (tuple[int, int]): Resolution of the saved video file. bitrate (int): Bitrate at which the video will be encoded. The maximum @@ -113,17 +112,16 @@ def capture( save_file: str, image_format: str, resolution: tuple[int, int], - annotation_text: str, ) -> None: """Capture a preview image of the camera.""" raise NotImplementedError @abstractmethod - def wait_recording(self, timeout: int) -> None: + def wait_recording(self, timeout: Union[int, float]) -> None: raise NotImplementedError @abstractmethod - def split_recording(self, save_path: Path) -> None: + def split_recording(self, save_path: str) -> None: raise NotImplementedError @abstractmethod @@ -135,6 +133,19 @@ def close(self) -> None: """Close the camera.""" raise NotImplementedError + @abstractmethod + def reinitialize(self) -> None: + """ + Restarts the internal camera instance by closing it and re-initializing it. + + The initialization is being done with the current set of parameters. + """ + raise NotImplementedError + + @abstractmethod + def set_annotation_text(self, value: str) -> None: + raise NotImplementedError + @abstractmethod def set_frame_rate(self, value: int) -> None: raise NotImplementedError @@ -151,10 +162,6 @@ def set_exposure_mode(self, value: str) -> None: def set_awb_mode(self, value: str) -> None: raise NotImplementedError - @abstractmethod - def set_video_format(self, value: str) -> None: - raise NotImplementedError - @abstractmethod def set_drc_strength(self, value: str) -> None: raise NotImplementedError diff --git a/OTCamera/hardware/camera_controller.py b/OTCamera/hardware/camera_controller.py index ddd4b0ca..e1b22f0c 100644 --- a/OTCamera/hardware/camera_controller.py +++ b/OTCamera/hardware/camera_controller.py @@ -22,13 +22,12 @@ from datetime import datetime as dt from time import sleep -from typing import Tuple, Union +from typing import Union import picamerax as picamera -from picamerax import Color from OTCamera import config, status -from OTCamera.abstraction.singleton import Singleton +from OTCamera.domain.camera import Camera from OTCamera.hardware import led from OTCamera.helpers import log, name from OTCamera.helpers.filesystem import delete_old_files @@ -36,56 +35,25 @@ log.write("imported camera", level=log.LogLevel.DEBUG) -class CameraController(Singleton): +class CameraController: """ The camera controller class providing functionality such as starting or stopping a recording, capturing a preview image, or closing the camera Args: - framerate (int, optional): The frame rate. Defaults to config.FPS. - resolution (Tuple[int, int], optional): The resolution. Defaults to - config.RESOLUTION. - annotate_background (Color, optional): Color of text annotation background. - Defaults to Color("black"). - exposure_mode (str, optional): The exposure mode. Defaults to - config.EXPOSURE_MODE. - awb_mode (str, optional): The awb mode. Defaults to config.AWB_MODE. - drc_strength (str, optional): The DRC strength. Defaults to config.DRC_STRENGTH. - rotation (int, optional): The image rotation. Defaults to config.ROTATION. - meter_mode (str, optional): The meter mode. Defaults to config.METER_MODE. + camera (Camera): The camera instance that the controller interacts with. """ - def init( - self, - framerate: int = config.FPS, - resolution: Tuple[int, int] = config.RESOLUTION, - annotate_background: Color = Color("black"), - exposure_mode: str = config.EXPOSURE_MODE, - awb_mode: str = config.AWB_MODE, - drc_strength: str = config.DRC_STRENGTH, - rotation: int = config.ROTATION, - meter_mode: str = config.METER_MODE, - ) -> None: - log.write("Initializing Camera", level=log.LogLevel.DEBUG) - - self.framerate = framerate - self.resolution = resolution - self.annotate_background = annotate_background - self.exposure_mode = exposure_mode - self.awb_mode = awb_mode - self.drc_strength = drc_strength - self.rotation = rotation - self.meter_mode = meter_mode - self._picam = self._create_picam() - log.write("Camera initialized", log.LogLevel.DEBUG) + def __init__(self, camera: Camera) -> None: + self._camera = camera def start_recording(self) -> None: - """Start a recording a video. + """Start video recording. If picam isn't already recording: - - Deletes old files, until enough free space is available. + - Deletes old files until enough free space is available. - Starts a new recording on picam, using the config.py. - - Waits 2 seconds and caputres a preview image. + - Waits 2 seconds and captures a preview image. - Turns on the record LED (if attached). """ @@ -94,20 +62,21 @@ def start_recording(self) -> None: # PiCamera error # https://picamera.readthedocs.io/en/release-1.13/api_exc.html?highlight=exception - if not self._picam.recording and not status.shutdownactive: + if not self._camera.is_recording and not status.shutdownactive: delete_old_files() - self._picam.annotate_text = name.annotate() - self._picam.start_recording( - output=name.video(), - format=config.VIDEO_FORMAT, - resize=config.RESOLUTION_SAVED_VIDEO_FILE, - profile=config.H264_PROFILE, - level=config.H264_LEVEL, + self.__set_annotation_text() + self._camera.start_recording( + save_file=name.video(), + video_format=config.VIDEO_FORMAT, + resolution=config.RESOLUTION_SAVED_VIDEO_FILE, bitrate=config.H264_BITRATE, - quality=config.H264_QUALITY, + h264_profile=config.H264_PROFILE, + h264_level=config.H264_LEVEL, + h264_quality=config.H264_QUALITY, ) log.write( - f"Picam recording: {self._picam.recording}", level=log.LogLevel.DEBUG + f"Picam recording: {self._camera.is_recording}", + level=log.LogLevel.DEBUG, ) log.write("started recording") led.rec_on() @@ -117,14 +86,13 @@ def start_recording(self) -> None: self.capture() def capture(self) -> None: - """Capture a preview image if camera is recording.""" - if self._picam.recording: - self._picam.annotate_text = name.annotate() - self._picam.capture( - name.preview(), - format=config.PREVIEW_FORMAT, - resize=config.RESOLUTION_SAVED_VIDEO_FILE, - use_video_port=True, + """Capture a preview image if the camera is recording.""" + if self._camera.is_recording: + self.__set_annotation_text() + self._camera.capture( + save_file=name.preview(), + image_format=config.PREVIEW_FORMAT, + resolution=config.RESOLUTION_SAVED_VIDEO_FILE, ) log.write("preview captured", level=log.LogLevel.DEBUG) else: @@ -139,23 +107,23 @@ def _wait_recording(self, timeout: Union[int, float] = 0) -> None: Args: timeout (Union[int, float], optional): Timeout in seconds. Defaults to 0. """ - if self._picam.recording: - self._picam.wait_recording(timeout) + if self._camera.is_recording: + self._camera.wait_recording(timeout) else: sleep(timeout) def _split(self) -> None: """Splits recording and deletes old video files if no disk space available.""" - self._picam.split_recording(name.video()) + self._camera.split_recording(name.video()) delete_old_files() - log.write("splitted recording") + log.write("split recording") def split_if_interval_ends(self) -> None: - """Splits the videofile if the configured intervals ends. + """Splits the video file if the configured interval ends. An Interval is configured in config.py. If the current minute matches the interval length, the video file is split and a new file begins. - Counts the full intervals already recorded. If the maximum number of intervals, + Counts the full intervals already recorded. If the maximum number of intervals configured in config.py is reached, recording stops by breaking the loop in record.py. @@ -173,15 +141,18 @@ def split_if_interval_ends(self) -> None: status.interval_finished = True log.write("reset new interval", level=log.LogLevel.DEBUG) self._wait_recording(0.5) - self._picam.annotate_text = name.annotate() + self.__set_annotation_text() + + def __set_annotation_text(self) -> None: + self._camera.set_annotation_text(name.annotate()) def _is_interval_minute(self) -> bool: """Checks if the current minute is the interval minute defined by - `config.INTERVAL_LENGTH` and thus defines whether a video should be splitted + `config.INTERVAL_LENGTH` and thus defines whether a video should be split or not. Returns: - bool: `True` if the interval minute has been reached. Otherwise `False`. + bool: `True` if the interval minute has been reached. Otherwise, `False`. """ current_minute = dt.now().minute interval_minute = (current_minute % config.INTERVAL_LENGTH) == 0 @@ -191,7 +162,7 @@ def _is_after_new_interval_minute(self) -> bool: """Checks if a minute has passed after an interval minute has been reached. Returns: - bool: `True` if a minute has passed after the interval minute. Otherwise + bool: `True` if a minute has passed after the interval minute. Otherwise, `False`. """ after_new_interval = not ( @@ -203,7 +174,7 @@ def _is_new_interval(self) -> bool: """Checks if a new time interval started. Returns: - bool: `True` if new time interval started. Otherwise `False`. + bool: `True` if the new time interval started. Otherwise, `False`. """ new_interval = ( self._is_interval_minute() @@ -216,11 +187,11 @@ def stop_recording(self) -> None: """Stops the video recording. If the picamera is recording, the recording is stopped. Additionally, the record - LED ist switched of (if configured). + LED ist switched off (if configured). """ - if self._picam.recording: - self._picam.stop_recording() + if self._camera.is_recording: + self._camera.stop_recording() led.rec_off() log.write("stopped recording") log.write("recorded {n} videos".format(n=status.current_interval)) @@ -229,12 +200,12 @@ def stop_recording(self) -> None: def close(self) -> None: """Closes `picamera.PiCamera` instance. - Logs to log file if OTCamera has been already closed. But won't do anything + Logs to the log file if OTCamera has been already closed. But won't do anything apart from that. """ try: - self._picam.close() + self._camera.close() log.write("PiCamera closed", log.LogLevel.DEBUG) except picamera.PiCameraClosed: log.write("Camera already closed.", level=log.LogLevel.DEBUG) @@ -242,30 +213,9 @@ def close(self) -> None: def restart(self) -> None: """ - Restarts the PiCamera instance by closing it and re-initialising it. + Restarts the internal camera instance by closing it and re-initializing it. - The initialisation is being done with the current set of parameters. + The initialization is being done with the current set of parameters. """ log.write("restarting camera") - self.close() - - self._picam = self._create_picam() - - def _create_picam(self) -> picamera.PiCamera: - """Creates PiCamera instance and initializes it with the camera settings passed - to the OTCamera class. - - Returns: - picamera.PiCamera: The PiCamera instance acting as the interface to control - the physical picamera. - """ - picam = picamera.PiCamera() - picam.framerate = self.framerate - picam.resolution = self.resolution - picam.annotate_background = self.annotate_background - picam.exposure_mode = self.exposure_mode - picam.awb_mode = self.awb_mode - picam.drc_strength = self.drc_strength - picam.rotation = self.rotation - picam.meter_mode = self.meter_mode - return picam + self._camera.reinitialize() diff --git a/OTCamera/helpers/rpi.py b/OTCamera/helpers/rpi.py index f90a19c4..18e80ea9 100644 --- a/OTCamera/helpers/rpi.py +++ b/OTCamera/helpers/rpi.py @@ -26,9 +26,10 @@ from OTCamera.hardware import led from OTCamera.hardware.camera_controller import CameraController from OTCamera.helpers import log +from OTCamera.plugin.camera.camera_provider import CameraProvider log.write("imported rpi", level=log.LogLevel.DEBUG) -camera = CameraController() +camera = CameraController(CameraProvider().provide()) def shutdown() -> None: diff --git a/OTCamera/plugin/camera/camera_provider.py b/OTCamera/plugin/camera/camera_provider.py new file mode 100644 index 00000000..6ef48cc0 --- /dev/null +++ b/OTCamera/plugin/camera/camera_provider.py @@ -0,0 +1,30 @@ +from typing import Optional + +from picamerax import PiCamera + +from OTCamera import config +from OTCamera.abstraction.singleton import Singleton +from OTCamera.domain.camera import Camera +from OTCamera.plugin.camera.picamerax import PiCameraX + +LEGACY = "legacy" + + +class CameraProvider(Singleton): + def init(self) -> None: + self.__actual: Optional[Camera] = None + + def provide(self) -> Camera: + if self.__actual: + return self.__actual + self.__actual = self.__create(config.CAMERA_TYPE) + return self.__actual + + def __create(self, camera_type: str = LEGACY) -> Camera: + if camera_type == LEGACY: + return PiCameraX(PiCamera()) + else: + raise ValueError( + f"Unknown camera type: {camera_type}. " + f"Supported camera types: '{LEGACY}']" + ) diff --git a/OTCamera/plugin/camera/picamerax.py b/OTCamera/plugin/camera/picamerax.py index 48f850b0..106afaad 100644 --- a/OTCamera/plugin/camera/picamerax.py +++ b/OTCamera/plugin/camera/picamerax.py @@ -1,13 +1,18 @@ -from pathlib import Path -from typing import Tuple +from typing import Tuple, Union -from picamerax import PiCamera +from picamerax import Color, PiCamera -from OTCamera.abstraction.singleton import Singleton -from OTCamera.domain.camera import Camera, H264Level, H264Profile, VideoFormat +from OTCamera import config +from OTCamera.domain.camera import ( + Camera, + H264Level, + H264Profile, + VideoFormat, +) +from OTCamera.helpers import log -class PiCameraX(Camera, Singleton): +class PiCameraX(Camera): @property def framerate(self) -> int: @@ -37,30 +42,59 @@ def rotation(self) -> int: def meter_mode(self) -> str: return self._picamera.meter_mode - def init( + def __init__( self, picamera: PiCamera, - frame_rate: int, - resolution: tuple[int, int], - exposure_mode: str, - awb_mode: str, - video_format: str, - drc_strength: str, - rotation: int, - meter_mode: str, + frame_rate: int = config.FPS, + resolution: tuple[int, int] = config.RESOLUTION, + exposure_mode: str = config.EXPOSURE_MODE, + awb_mode: str = config.AWB_MODE, + drc_strength: str = config.DRC_STRENGTH, + rotation: int = config.ROTATION, + meter_mode: str = config.METER_MODE, + annotation_background_color: Union[Color, None] = Color("black"), ) -> None: - # TODO: Change init to __init__ when removing Singleton abstract class + """ + The PiCamera wrapper providing functionality such as starting or stopping + a recording, capturing a preview image, or closing the camera + + Args: + frame_rate (int): The frame rate. Defaults to config.FPS. + resolution (Tuple[int, int]): The resolution. Defaults to + config.RESOLUTION. + exposure_mode (str): The exposure mode. Defaults to + config.EXPOSURE_MODE. + awb_mode (str): The awb mode. Defaults to config.AWB_MODE. + drc_strength (str): The DRC strength. Defaults to + config.DRC_STRENGTH. + rotation (int): The image rotation. Defaults to config.ROTATION. + meter_mode (str): The meter mode. Defaults to config.METER_MODE. + annotation_background_color (Color): The text annotation + background color. Defaults to Color("black"). + """ self._picamera = picamera - self._video_format = video_format - - self.set_frame_rate(frame_rate) - self.set_resolution(resolution) - self.set_exposure_mode(exposure_mode) - self.set_awb_mode(awb_mode) - self.set_video_format(video_format) - self.set_drc_strength(drc_strength) - self.set_rotation(rotation) - self.set_meter_mode(meter_mode) + self._frame_rate = frame_rate + self._resolution = resolution + self._exposure_mode = exposure_mode + self._awb_mode = awb_mode + self._drc_strength = drc_strength + self._rotation = rotation + self._meter_mode = meter_mode + self._annotation_background_color = annotation_background_color + + log.write("Initializing Camera", level=log.LogLevel.DEBUG) + self._setup_picamera() + log.write("Camera initialized", log.LogLevel.DEBUG) + + def _setup_picamera(self) -> None: + self.set_frame_rate(self._frame_rate) + self.set_resolution(self._resolution) + self.set_exposure_mode(self._exposure_mode) + self.set_awb_mode(self._awb_mode) + self.set_drc_strength(self._drc_strength) + self.set_rotation(self._rotation) + self.set_meter_mode(self._meter_mode) + self.set_annotation_background_color(self._annotation_background_color) @property def is_recording(self) -> bool: @@ -68,7 +102,7 @@ def is_recording(self) -> bool: def start_recording( self, - save_file: Path, + save_file: str, video_format: VideoFormat, resolution: tuple[int, int], bitrate: int, @@ -91,7 +125,6 @@ def capture( save_file: str, image_format: str, resolution: tuple[int, int], - annotation_text: str, ) -> None: self._picamera.capture( output=save_file, @@ -100,10 +133,10 @@ def capture( use_video_port=True, ) - def wait_recording(self, timeout: int) -> None: + def wait_recording(self, timeout: Union[int, float]) -> None: self._picamera.wait_recording(timeout=timeout) - def split_recording(self, save_path: Path) -> None: + def split_recording(self, save_path: str) -> None: self._picamera.split_recording(output=save_path) def stop_recording(self) -> None: @@ -112,29 +145,41 @@ def stop_recording(self) -> None: def close(self) -> None: self._picamera.close() + def reinitialize(self) -> None: + self.close() + self._picamera = PiCamera() + self._setup_picamera() + def set_annotation_text(self, value: str) -> None: self._picamera.annotate_text = value + def set_annotation_background_color(self, value: Color) -> None: + self._picamera.annotate_background = value + def set_frame_rate(self, value: int) -> None: + self._frame_rate = value self._picamera.framerate = value def set_resolution(self, value: tuple[int, int]) -> None: + self._resolution = value self._picamera.resolution = value def set_exposure_mode(self, value: str) -> None: + self._exposure_mode = value self._picamera.exposure_mode = value def set_awb_mode(self, value: str) -> None: + self._awb_mode = value self._picamera.awb_mode = value def set_drc_strength(self, value: str) -> None: + self._drc_strength = value self._picamera.drc_strength = value def set_rotation(self, value: int) -> None: + self._rotation = value self._picamera.rotation = value def set_meter_mode(self, value: str) -> None: + self._meter_mode = value self._picamera.meter_mode = value - - def set_video_format(self, value: str) -> None: - self._video_format = value # Usage -> start_recording diff --git a/OTCamera/record.py b/OTCamera/record.py index a49404ab..271dcd40 100644 --- a/OTCamera/record.py +++ b/OTCamera/record.py @@ -41,6 +41,7 @@ LogHtmlId, StatusWebsiteUpdater, ) +from OTCamera.plugin.camera.camera_provider import CameraProvider log.write("imported record", level=log.LogLevel.DEBUG) @@ -366,7 +367,8 @@ def get_log_files_sorted(log_files: Iterator[Path]) -> list[Path]: def main() -> None: """Start running OTCamera.""" - camera_controller = CameraController() + camera_provider = CameraProvider() + camera_controller = CameraController(camera=camera_provider.provide()) html_updater = StatusWebsiteUpdater( template_html_path=config.TEMPLATE_HTML_PATH, offline_html_path=config.OFFLINE_HTML_PATH, diff --git a/tests/hardware/camera_test.py b/tests/hardware/camera_test.py index 2c53ba38..a5d7b0a7 100644 --- a/tests/hardware/camera_test.py +++ b/tests/hardware/camera_test.py @@ -13,10 +13,10 @@ # You should have received a copy of the GNU General Public License along with this # program. If not, see . -import OTCamera.hardware.camera_controller as camera_controller +from OTCamera.plugin.camera.camera_provider import CameraProvider def test_init_camera_same_instance() -> None: - cam_1 = camera_controller.CameraController() - cam_2 = camera_controller.CameraController() + cam_1 = CameraProvider().provide() + cam_2 = CameraProvider().provide() assert cam_1 == cam_2 diff --git a/tests/record_test.py b/tests/record_test.py index 67d8dcec..fe5bfd50 100644 --- a/tests/record_test.py +++ b/tests/record_test.py @@ -25,6 +25,7 @@ from OTCamera import config from OTCamera.hardware.camera_controller import CameraController from OTCamera.html_updater import StatusWebsiteUpdater +from OTCamera.plugin.camera.camera_provider import CameraProvider from OTCamera.record import OTCamera, get_log_files_sorted from tests.conftest import YieldFixture @@ -63,7 +64,7 @@ def html_updater() -> StatusWebsiteUpdater: @pytest.fixture def otcamera(html_updater: StatusWebsiteUpdater, temp_dir: Path) -> OTCamera: return OTCamera( - camera_controller=CameraController(), + camera_controller=CameraController(CameraProvider().provide()), html_updater=html_updater, video_dir=temp_dir, ) @@ -125,7 +126,9 @@ def test_record_videoRecordedHasCorrectFrames( def test_get_log_info(log_files_dir: Path) -> None: - otcamera = OTCamera(CameraController(), mock.Mock(), log_dir=log_files_dir) + otcamera = OTCamera( + CameraController(CameraProvider().provide()), mock.Mock(), log_dir=log_files_dir + ) otcamera._log_dir = log_files_dir log_files = otcamera._get_num_recent_log_files(0, 2) diff --git a/user_config.example.yaml b/user_config.example.yaml index 1229f745..7d958d73 100644 --- a/user_config.example.yaml +++ b/user_config.example.yaml @@ -22,6 +22,7 @@ camera: rotation: 180 awb_mode: greyworld meter_mode: average + type: legacy preview: path: ~/OTCamera/webfiles/preview.jpg From 32f7143319dbc3a7f13567fee53784389bc9623c Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Wed, 25 Jun 2025 07:34:27 +0200 Subject: [PATCH 24/44] Update linter configuration to use `.flake8` and `pyproject.toml`; adjust `.flake8` settings for conventions and exclusions --- .flake8 | 7 +++- .github/workflows/linter.yml | 64 ++++++++++++++++++------------------ 2 files changed, 38 insertions(+), 33 deletions(-) diff --git a/.flake8 b/.flake8 index 2bcd70e3..64a972a6 100644 --- a/.flake8 +++ b/.flake8 @@ -1,2 +1,7 @@ [flake8] -max-line-length = 88 +max-line-length=88 +docstring-convention=google +extend-ignore=E203 +exclude= + venv + .venv diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index ef720b87..5b2f2f7b 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -1,32 +1,32 @@ ---- -name: super-linter -# -# Documentation: -# https://help.github.com/en/articles/workflow-syntax-for-github-actions -# - -on: pull_request - -jobs: - build: - name: lint - runs-on: ubuntu-latest - steps: - - name: Checkout Code - uses: actions/checkout@v4 - - name: Lint Code Base - uses: docker://github/super-linter:v4 - env: - VALIDATE_ALL_CODEBASE: false - DEFAULT_BRANCH: master - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - VALIDATE_PYTHON_PYLINT: false - VALIDATE_PYTHON_MYPY: false - VALIDATE_JSCPD: false - VALIDATE_PYTHON_BLACK: false - FILTER_REGEX_EXCLUDE: .webfiles/css/bootstrap.* - - LINTER_RULES_PATH: / - PYTHON_FLAKE8_CONFIG_FILE: setup.cfg - PYTHON_ISORT_CONFIG_FILE: setup.cfg - YAML_CONFIG_FILE: .yamllint.yaml +--- +name: super-linter +# +# Documentation: +# https://help.github.com/en/articles/workflow-syntax-for-github-actions +# + +on: pull_request + +jobs: + build: + name: lint + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + - name: Lint Code Base + uses: docker://github/super-linter:v4 + env: + VALIDATE_ALL_CODEBASE: false + DEFAULT_BRANCH: master + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VALIDATE_PYTHON_PYLINT: false + VALIDATE_PYTHON_MYPY: false + VALIDATE_JSCPD: false + VALIDATE_PYTHON_BLACK: false + FILTER_REGEX_EXCLUDE: .webfiles/css/bootstrap.* + + LINTER_RULES_PATH: / + PYTHON_FLAKE8_CONFIG_FILE: .flake8 + PYTHON_ISORT_CONFIG_FILE: pyproject.toml + YAML_CONFIG_FILE: .yamllint.yaml From 34c27b40d45f4907d491b1cf10a877ddc392e0ce Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Wed, 25 Jun 2025 07:44:58 +0200 Subject: [PATCH 25/44] Sort import --- tests/html_updater_test.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/html_updater_test.py b/tests/html_updater_test.py index 00241023..a1e0bf1d 100644 --- a/tests/html_updater_test.py +++ b/tests/html_updater_test.py @@ -28,9 +28,7 @@ StatusDataObject, ) from OTCamera.html_updater import StatusHtmlId as status_id -from OTCamera.html_updater import ( - StatusWebsiteUpdater, -) +from OTCamera.html_updater import StatusWebsiteUpdater @pytest.fixture From 2ddca8f25fbd47f62ed02571d6bee1ec10682d49 Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Wed, 25 Jun 2025 07:49:08 +0200 Subject: [PATCH 26/44] Update super linter to v7 --- .github/workflows/linter.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index 5b2f2f7b..0e4360c2 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -14,12 +14,18 @@ jobs: steps: - name: Checkout Code uses: actions/checkout@v4 + with: + # super-linter needs the full git history to get the + # list of files that changed across commits + fetch-depth: 0 - name: Lint Code Base - uses: docker://github/super-linter:v4 + uses: github/super-linter/slim@v7 env: VALIDATE_ALL_CODEBASE: false DEFAULT_BRANCH: master GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VALIDATE_GO_RELEASER: false + VALIDATE_MARKDOWN_PRETTIER: false VALIDATE_PYTHON_PYLINT: false VALIDATE_PYTHON_MYPY: false VALIDATE_JSCPD: false From 12b486e8b6634b214f039d7937faa5727ad6d087 Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Wed, 25 Jun 2025 08:09:39 +0200 Subject: [PATCH 27/44] Try fixing differing behavior of isort in super-linter --- .github/workflows/linter.yml | 2 +- pyproject.toml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index 0e4360c2..c0c09e0b 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -32,7 +32,7 @@ jobs: VALIDATE_PYTHON_BLACK: false FILTER_REGEX_EXCLUDE: .webfiles/css/bootstrap.* - LINTER_RULES_PATH: / + LINTER_RULES_PATH: . PYTHON_FLAKE8_CONFIG_FILE: .flake8 PYTHON_ISORT_CONFIG_FILE: pyproject.toml YAML_CONFIG_FILE: .yamllint.yaml diff --git a/pyproject.toml b/pyproject.toml index 1af11164..f162249e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ directory = "dist" line-length = 88 [tool.isort] +line_length = 88 profile = "black" [tool.mypy] From 13a86cb0a815ee6f861782eeb168589830dc9d6d Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Wed, 25 Jun 2025 08:21:02 +0200 Subject: [PATCH 28/44] Format imports --- OTCamera/plugin/camera/picamerax.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/OTCamera/plugin/camera/picamerax.py b/OTCamera/plugin/camera/picamerax.py index 106afaad..61e93f00 100644 --- a/OTCamera/plugin/camera/picamerax.py +++ b/OTCamera/plugin/camera/picamerax.py @@ -3,12 +3,7 @@ from picamerax import Color, PiCamera from OTCamera import config -from OTCamera.domain.camera import ( - Camera, - H264Level, - H264Profile, - VideoFormat, -) +from OTCamera.domain.camera import Camera, H264Level, H264Profile, VideoFormat from OTCamera.helpers import log From 4e1159a98ca6e6d0a5216c457bea517147b61a3d Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Wed, 25 Jun 2025 08:31:42 +0200 Subject: [PATCH 29/44] Lint sh-files --- raspi-files/install_otcamera.sh | 7 ++----- raspi-files/install_sshrelay.sh | 7 +++---- raspi-files/uninstall_otcamera.sh | 10 ++++++++-- 3 files changed, 13 insertions(+), 11 deletions(-) mode change 100644 => 100755 raspi-files/install_otcamera.sh mode change 100644 => 100755 raspi-files/install_sshrelay.sh mode change 100644 => 100755 raspi-files/uninstall_otcamera.sh diff --git a/raspi-files/install_otcamera.sh b/raspi-files/install_otcamera.sh old mode 100644 new mode 100755 index 27958784..7acdfa07 --- a/raspi-files/install_otcamera.sh +++ b/raspi-files/install_otcamera.sh @@ -15,13 +15,10 @@ read -r -e -p "Use LEDs? [y/n]: " -i "y" USE_LEDS read -r -e -p "Activate DEBUG mode? [y/n]: " -i "n" USE_DEBUG read -r -e -p "Use relay server? [y/n]: " -i "n" USE_RELAY - - - echo "#### Configure Rasperry Pi" echo "Configure legacy camera mode using raspi-config" raspi-config nonint do_legacy 0 -case $USE_RTC in +case $USE_RTC in [yY] | [yY][eE][sS]) echo "Enable I2C bus for hwclock" raspi-config nonint do_i2c 0 @@ -168,7 +165,7 @@ RCLOCAL="/etc/rc.local" cp ./raspi-files/usr/local/bin/wifistart /usr/local/bin/wifistart sed $RCLOCAL -i -e "/^exit 0/i /bin/bash /usr/local/bin/wifistart" -case $USE_RTC in +case $USE_RTC in [yY] | [yY][eE][sS]) echo " Setting up RTC" HWCLOCK="/lib/udev/hwclock-set" diff --git a/raspi-files/install_sshrelay.sh b/raspi-files/install_sshrelay.sh old mode 100644 new mode 100755 index 675a266b..2212bcd0 --- a/raspi-files/install_sshrelay.sh +++ b/raspi-files/install_sshrelay.sh @@ -8,14 +8,13 @@ read -r -e -p "server address: " -i "relay.opentrafficcam.org" SERVER read -r -e -p "server port: " -i "22" PORT read -r -e -p "public key: " PUBKEY - echo "### Generating new SSH key" HOME="/home/$SUDO_USER" runuser -l "$SUDO_USER" -c "ssh-keygen -t ed25519 -q -f '$HOME/.ssh/id_ed25519' -N ''" echo "### Adding remote server public key as authorized key" -echo "" >> "$HOME"/.ssh/authorized_keys -echo "$PUBKEY" >> "$HOME"/.ssh/authorized_keys +echo "" >>"$HOME"/.ssh/authorized_keys +echo "$PUBKEY" >>"$HOME"/.ssh/authorized_keys echo "" echo "" @@ -35,7 +34,7 @@ SSHCMD="/bin/ssh -NT -R 0:localhost:22 $HOSTNAME@$SERVER -p $PORT" sed $RELAYSERVICE -i -e "s?^User=username?User=$SUDO_USER?g" sed $RELAYSERVICE -i -e "s?^ExecStart=/bin/ssh -NT -R 0:localhost:22 hostname@server -p port?ExecStart=$SSHCMD?g" -echo " ServerAliveInterval 240" >> /etc/ssh/ssh_config +echo " ServerAliveInterval 240" >>/etc/ssh/ssh_config systemctl daemon-reload systemctl enable sshrelay.service diff --git a/raspi-files/uninstall_otcamera.sh b/raspi-files/uninstall_otcamera.sh old mode 100644 new mode 100755 index a5848da3..ae6fe29f --- a/raspi-files/uninstall_otcamera.sh +++ b/raspi-files/uninstall_otcamera.sh @@ -64,9 +64,15 @@ sed $NGINXDEFAULT -i -e "s?root $OTCAMERA/webfiles?root /var/www/html?g" apt remove nginx -y echo "Remove OTCamera directory" -cd "$OTCAMERA" || { echo "Error: Cannot find OTCamera directory"; exit 1; } +cd "$OTCAMERA" || { + echo "Error: Cannot find OTCamera directory" + exit 1 +} pip uninstall -r requirements.txt -y -cd "$USER_HOME" || { echo "Error: Cannot find HOME directory"; exit 1; } +cd "$USER_HOME" || { + echo "Error: Cannot find HOME directory" + exit 1 +} rm -rf "$OTCAMERA" echo "#### Uninstall packages" From e1d5a85c39aa1a8d46a4c569dcc1774fb30d42da Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Wed, 25 Jun 2025 08:34:34 +0200 Subject: [PATCH 30/44] Lint template.html --- webfiles/template.html | 47 +++++++++++++++++++++--------------------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/webfiles/template.html b/webfiles/template.html index 27f98714..4b737481 100644 --- a/webfiles/template.html +++ b/webfiles/template.html @@ -1,42 +1,43 @@ - + - - + - + OTCamera - + - +
-
- Vorschau +
+ Vorschau
-
+
- - + From 429a7142b177f4865c2395412071e734616fb511 Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Wed, 25 Jun 2025 08:37:00 +0200 Subject: [PATCH 31/44] Fix lint errors in usb_flash_drive_copy.py --- usb_flash_drive_copy.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/usb_flash_drive_copy.py b/usb_flash_drive_copy.py index ad4a933d..a5baecf3 100644 --- a/usb_flash_drive_copy.py +++ b/usb_flash_drive_copy.py @@ -390,12 +390,12 @@ def copy_to_usb(self, copy_info: CopyInformation) -> None: log.write("Start copying files") for video in copy_info.videos: if video.copied: - log.write(f"Video at: '{ video.path}' already copied. Skipping.") + log.write(f"Video at: '{video.path}' already copied. Skipping.") continue if not video.path.exists(): log.write( - f"Video at: '{ video.path}' does not exists.", log.LogLevel.WARNING + f"Video at: '{video.path}' does not exists.", log.LogLevel.WARNING ) continue try: @@ -428,13 +428,13 @@ def delete(self, copy_info: CopyInformation) -> None: for video in copy_info.videos.copy(): if not video.path.exists(): log.write( - f"Video at: '{ video.path}' does not exist.", log.LogLevel.WARNING + f"Video at: '{video.path}' does not exist.", log.LogLevel.WARNING ) copy_info.remove(video) continue if not video.delete: log.write( - f"Video at: '{ video.path}' not marked for deletion. Skipping.", + f"Video at: '{video.path}' not marked for deletion. Skipping.", ) continue From c95108b35bbed1a1309153b14e7b5737c14d29bf Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Wed, 25 Jun 2025 08:38:33 +0200 Subject: [PATCH 32/44] Omit pyink in super-linter since flake8 is already used as linter --- .github/workflows/linter.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index c0c09e0b..002a496d 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -30,6 +30,7 @@ jobs: VALIDATE_PYTHON_MYPY: false VALIDATE_JSCPD: false VALIDATE_PYTHON_BLACK: false + VALIDATE_PYTHON_PYINK: false FILTER_REGEX_EXCLUDE: .webfiles/css/bootstrap.* LINTER_RULES_PATH: . From a34fe5bccad0635373808f63c88860b72ef4a034 Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Wed, 25 Jun 2025 08:39:48 +0200 Subject: [PATCH 33/44] Fix lint error in pre-commit config --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 37865a40..b0169bcb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -80,4 +80,4 @@ repos: - id: shell-fmt-docker args: - -i - - '2' + - "2" From 21ccf9ea7ec07967b0c73400e8274025b56f4fcd Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Wed, 25 Jun 2025 08:43:16 +0200 Subject: [PATCH 34/44] Make wifistart executable to fix lint error --- raspi-files/usr/local/bin/wifistart | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 raspi-files/usr/local/bin/wifistart diff --git a/raspi-files/usr/local/bin/wifistart b/raspi-files/usr/local/bin/wifistart old mode 100644 new mode 100755 From ae1acbea6b6edb40dfb649be35c0a30f4645f6e2 Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Wed, 25 Jun 2025 08:44:28 +0200 Subject: [PATCH 35/44] Set permissions for linter GitHub workflow --- .github/workflows/linter.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index 002a496d..ec2dbca1 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -7,6 +7,8 @@ name: super-linter on: pull_request +permissions: read-all + jobs: build: name: lint From dd253142c11842bfe96d036fc1eea2ae802eb890 Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Wed, 25 Jun 2025 08:49:32 +0200 Subject: [PATCH 36/44] Fix lint errors in install_otcamera.sh --- raspi-files/install_otcamera.sh | 199 ++++++++++++++++---------------- 1 file changed, 99 insertions(+), 100 deletions(-) diff --git a/raspi-files/install_otcamera.sh b/raspi-files/install_otcamera.sh index 7acdfa07..e8b94059 100755 --- a/raspi-files/install_otcamera.sh +++ b/raspi-files/install_otcamera.sh @@ -19,30 +19,30 @@ echo "#### Configure Rasperry Pi" echo "Configure legacy camera mode using raspi-config" raspi-config nonint do_legacy 0 case $USE_RTC in - [yY] | [yY][eE][sS]) - echo "Enable I2C bus for hwclock" - raspi-config nonint do_i2c 0 - ;; +[yY] | [yY][eE][sS]) + echo "Enable I2C bus for hwclock" + raspi-config nonint do_i2c 0 + ;; esac echo " Setting config variables" CONFIG="/boot/config.txt" cp $CONFIG $CONFIG.backup { - echo "# OTCamera" - echo "dtoverlay=disable-bt" - echo "disable_camera_led=1" - echo "dtparam=act_led_trigger=none" - echo "dtparam=act_led_activelow=on" - echo "dtparam=audio=off" - echo "display_auto_detect=0" - echo "gpio=17,22,16,27,26=ip" - echo "gpio=22,16,27=pu" - echo "gpio=17,26=pd" - echo "gpio=6,12,13=op" - echo "gpio=6,12=dl" - echo "gpio=13=dh" -} >> $CONFIG + echo "# OTCamera" + echo "dtoverlay=disable-bt" + echo "disable_camera_led=1" + echo "dtparam=act_led_trigger=none" + echo "dtparam=act_led_activelow=on" + echo "dtparam=audio=off" + echo "display_auto_detect=0" + echo "gpio=17,22,16,27,26=ip" + echo "gpio=22,16,27=pu" + echo "gpio=17,26=pd" + echo "gpio=6,12,13=op" + echo "gpio=6,12=dl" + echo "gpio=13=dh" +} >>$CONFIG sed $CONFIG -i -e "s/^dtparam=audio=on/#dtparam=audio=on/g" sed $CONFIG -i -e "s/^display_auto_detect=1/#display_auto_detect=1/g" @@ -59,7 +59,7 @@ sed $RCLOCAL -i -e "/^exit 0/i /usr/bin/tvservice -o" echo " Setting USB mount access permissions" FSTAB="/etc/fstab" cp $FSTAB $FSTAB.backup -echo "/dev/sda1 /home/$SUDO_USER/mnt/usb auto user,noauto,uid=$SUDO_USER,gid=$SUDO_USER,umask=022 0 0" >> $FSTAB +echo "/dev/sda1 /home/$SUDO_USER/mnt/usb auto user,noauto,uid=$SUDO_USER,gid=$SUDO_USER,umask=022 0 0" >>$FSTAB echo "#### Setting up OTCamera" @@ -67,11 +67,10 @@ echo " Installing packages" apt install python3-pip -y apt install python3-venv -y -if [ "$OTC_VERSION" = "latest" ] -then - latest_tag=$(curl -s https://api.github.com/repos/OpenTrafficCam/OTCamera/releases/latest | sed -Ene '/^ *"tag_name": *"(v.+)",$/s//\1/p') +if [ "$OTC_VERSION" = "latest" ]; then + latest_tag=$(curl -s https://api.github.com/repos/OpenTrafficCam/OTCamera/releases/latest | sed -Ene '/^ *"tag_name": *"(v.+)",$/s//\1/p') else - latest_tag=$OTC_VERSION + latest_tag=$OTC_VERSION fi PWD=$(pwd) @@ -81,7 +80,10 @@ runuser -l "$SUDO_USER" -c "mkdir $PWD/OTCamera" runuser -l "$SUDO_USER" -c "tar -xvzf $PWD/otcamera.tar.gz -C $PWD/OTCamera/ --strip-components=1" rm "$PWD"/otcamera.tar.gz -cd OTCamera || { echo "Error: Cannot find OTCamera directory"; exit 1; } +cd OTCamera || { + echo "Error: Cannot find OTCamera directory" + exit 1 +} PIP="venv/bin/pip" python -m venv venv $PIP install -r requirements.txt --upgrade @@ -104,24 +106,24 @@ HOSTAPD="/etc/default/hostapd" HOSTAPDCONF="/etc/hostapd/hostapd.conf" cp $HOSTAPD $HOSTAPD".backup" echo " Current hostapd config moved to $HOSTAPD.backup" -echo "DAEMON_CONF=\"$HOSTAPDCONF\"" >> $HOSTAPD +echo "DAEMON_CONF=\"$HOSTAPDCONF\"" >>$HOSTAPD cp $HOSTAPDCONF $HOSTAPDCONF".backup" echo " Current hostapd config moved to $HOSTAPDCONF.backup" { - echo "channel=$APCHANNEL" - echo "ssid=$APNAME" - echo "wpa_passphrase=$APPASSWORD" - echo "interface=uap0" - echo "hw_mode=g" - echo "macaddr_acl=0" - echo "auth_algs=1" - echo "wpa=2" - echo "wpa_key_mgmt=WPA-PSK" - echo "wpa_pairwise=TKIP" - echo "rsn_pairwise=CCMP" - echo "country_code=DE" -} >> $HOSTAPDCONF + echo "channel=$APCHANNEL" + echo "ssid=$APNAME" + echo "wpa_passphrase=$APPASSWORD" + echo "interface=uap0" + echo "hw_mode=g" + echo "macaddr_acl=0" + echo "auth_algs=1" + echo "wpa=2" + echo "wpa_key_mgmt=WPA-PSK" + echo "wpa_pairwise=TKIP" + echo "rsn_pairwise=CCMP" + echo "country_code=DE" +} >>$HOSTAPDCONF # echo "ctrl_interface=/var/run/hostapd" >> $HOSTAPDCONF # echo "ctrl_interface_group=0" >> $HOSTAPDCONF # echo "ieee80211d=1" >> $HOSTAPDCONF @@ -132,23 +134,23 @@ DHCPDCONF="/etc/dhcpcd.conf" cp $DHCPDCONF $DHCPDCONF".backup" echo "Current DHCPCD config moved to $DHCPDCONF.backup" { - echo "interface uap0" - echo " static ip_address=$IPRANGE.1/24" - echo " nohook wpa_supplicant" -} >> $DHCPDCONF + echo "interface uap0" + echo " static ip_address=$IPRANGE.1/24" + echo " nohook wpa_supplicant" +} >>$DHCPDCONF echo "done" echo " Configure DNSMASQ" DNSMASQCONF="/etc/dnsmasq.conf" { - echo "interface=lo,uap0" - echo "no-dhcp-interface=lo,wlan0" - echo "bind-interfaces" - echo "server=$IPRANGE.1" - echo "domain-needed" - echo "bogus-priv" - echo "dhcp-range=$IPRANGE.10,$IPRANGE.250,2h" -} >> $DNSMASQCONF + echo "interface=lo,uap0" + echo "no-dhcp-interface=lo,wlan0" + echo "bind-interfaces" + echo "server=$IPRANGE.1" + echo "domain-needed" + echo "bogus-priv" + echo "dhcp-range=$IPRANGE.10,$IPRANGE.250,2h" +} >>$DNSMASQCONF echo "done" echo "Unmask hostapd.service" @@ -159,78 +161,75 @@ systemctl disable hostapd.service systemctl disable dhcpcd.service systemctl disable dnsmasq.service - echo " Enable Wifi AP at boot" RCLOCAL="/etc/rc.local" cp ./raspi-files/usr/local/bin/wifistart /usr/local/bin/wifistart sed $RCLOCAL -i -e "/^exit 0/i /bin/bash /usr/local/bin/wifistart" case $USE_RTC in - [yY] | [yY][eE][sS]) - echo " Setting up RTC" - HWCLOCK="/lib/udev/hwclock-set" - cp $HWCLOCK $HWCLOCK.backup - apt install i2c-tools -y - echo "dtoverlay=i2c-rtc,ds3231" >> $CONFIG - apt remove fake-hwclock -y - update-rc.d -f fake-hwclock remove - systemctl disable fake-hwclock - sed $HWCLOCK -i -e "/if.*systemd/ s/^#*/#/" - sed $HWCLOCK -i -e "s?^ exit 0?# exit 0?g" - sed $HWCLOCK -i -e "s?^fi?#fi?g" - sed $HWCLOCK -i -e "/--systz/ s/^#*/#/" - ;; +[yY] | [yY][eE][sS]) + echo " Setting up RTC" + HWCLOCK="/lib/udev/hwclock-set" + cp $HWCLOCK $HWCLOCK.backup + apt install i2c-tools -y + echo "dtoverlay=i2c-rtc,ds3231" >>$CONFIG + apt remove fake-hwclock -y + update-rc.d -f fake-hwclock remove + systemctl disable fake-hwclock + sed $HWCLOCK -i -e "/if.*systemd/ s/^#*/#/" + sed $HWCLOCK -i -e "s?^ exit 0?# exit 0?g" + sed $HWCLOCK -i -e "s?^fi?#fi?g" + sed $HWCLOCK -i -e "/--systz/ s/^#*/#/" + ;; esac - OTCONFIG="$PWD/OTCamera/config.py" case $USE_BUTTONS in - [yY] | [yY][eE][sS]) - echo "Enableing buttons" - sed "$OTCONFIG" -i -e "s?^USE_BUTTONS.*?USE_BUTTONS = True?g" - ;; - [nN] | [nN][oO]) - echo "Disableing buttons" - sed "$OTCONFIG" -i -e "s?^USE_BUTTONS.*?USE_BUTTONS = False?g" - ;; +[yY] | [yY][eE][sS]) + echo "Enableing buttons" + sed "$OTCONFIG" -i -e "s?^USE_BUTTONS.*?USE_BUTTONS = True?g" + ;; +[nN] | [nN][oO]) + echo "Disableing buttons" + sed "$OTCONFIG" -i -e "s?^USE_BUTTONS.*?USE_BUTTONS = False?g" + ;; esac case $USE_LEDS in - [yY] | [yY][eE][sS]) - echo "Enableing LEDs" - sed "$OTCONFIG" -i -e "s?^USE_LED.*?USE_LED = True?g" - ;; - [nN] | [nN][oO]) - echo "Disableing LEDs" - sed "$OTCONFIG" -i -e "s?^USE_LED.*?USE_LED = False?g" - ;; +[yY] | [yY][eE][sS]) + echo "Enableing LEDs" + sed "$OTCONFIG" -i -e "s?^USE_LED.*?USE_LED = True?g" + ;; +[nN] | [nN][oO]) + echo "Disableing LEDs" + sed "$OTCONFIG" -i -e "s?^USE_LED.*?USE_LED = False?g" + ;; esac case $USE_DEBUG in - [yY] | [yY][eE][sS]) - echo "Enableing debug mode" - sed "$OTCONFIG" -i -e "s?^DEBUG_MODE_ON.*?DEBUG_MODE_ON = True?g" - ;; - [nN] | [nN][oO]) - echo "Disableing debug mode" - sed "$OTCONFIG" -i -e "s?^DEBUG_MODE_ON.*?DEBUG_MODE_ON = False?g" - ;; +[yY] | [yY][eE][sS]) + echo "Enableing debug mode" + sed "$OTCONFIG" -i -e "s?^DEBUG_MODE_ON.*?DEBUG_MODE_ON = True?g" + ;; +[nN] | [nN][oO]) + echo "Disableing debug mode" + sed "$OTCONFIG" -i -e "s?^DEBUG_MODE_ON.*?DEBUG_MODE_ON = False?g" + ;; esac case $USE_RELAY in - [yY] | [yY][eE][sS]) - echo "Enableing relay mode" - sed "$OTCONFIG" -i -e "s?^USE_RELAY.*?USE_RELAY = True?g" - bash ./raspi-files/install_sshrelay.sh - ;; - [nN] | [nN][oO]) - echo "Disableing debug mode" - sed "$OTCONFIG" -i -e "s?^USE_RELAY.*?USE_RELAY = False?g" - ;; +[yY] | [yY][eE][sS]) + echo "Enableing relay mode" + sed "$OTCONFIG" -i -e "s?^USE_RELAY.*?USE_RELAY = True?g" + bash ./raspi-files/install_sshrelay.sh + ;; +[nN] | [nN][oO]) + echo "Disableing debug mode" + sed "$OTCONFIG" -i -e "s?^USE_RELAY.*?USE_RELAY = False?g" + ;; esac - echo " Activate OTCamera service" OTCSERVICE="./raspi-files/otcamera.service" cp $OTCSERVICE /lib/systemd/system From bf483235a307a6811045000de7ec66f8cf7b60f3 Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Tue, 22 Jul 2025 12:09:29 +0200 Subject: [PATCH 37/44] Add custom scalar style override to enforce double quotes in YAML serialization --- update_precommit.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/update_precommit.py b/update_precommit.py index f3595d9e..efc0a54e 100755 --- a/update_precommit.py +++ b/update_precommit.py @@ -112,6 +112,16 @@ class CustomDumper(yaml.SafeDumper): def increase_indent(self, flow: bool = False, indentless: bool = False) -> None: return super(CustomDumper, self).increase_indent(flow, False) + def choose_scalar_style(self) -> str: + # Get the default style choice + style = super().choose_scalar_style() + + # If the default choice is single quotes, change to double quotes + if style == "'": + return '"' + + return style + def parse_multiple_requirements_file( files: Iterable[Path], From 8ef26e26672cc7a1b1aa81ec8afcae8546d1dff6 Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Tue, 22 Jul 2025 12:10:41 +0200 Subject: [PATCH 38/44] Add install scripts --- install.sh | 24 ++++++++++++++++++++++++ install_dev.sh | 14 ++++++++++++++ 2 files changed, 38 insertions(+) create mode 100755 install.sh create mode 100755 install_dev.sh diff --git a/install.sh b/install.sh new file mode 100755 index 00000000..b2cf6708 --- /dev/null +++ b/install.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash + +SOURCE=${BASH_SOURCE[0]} +while [ -L "$SOURCE" ]; do # resolve $SOURCE until the file is no longer a symlink + DIR=$(cd -P "$(dirname "$SOURCE")" >/dev/null 2>&1 && pwd) + SOURCE=$(readlink "$SOURCE") + [[ $SOURCE != /* ]] && SOURCE=$DIR/$SOURCE # if $SOURCE was a relative symlink, we need to resolve it relative to the path where the symlink file was located +done +DIR=$(cd -P "$(dirname "$SOURCE")" >/dev/null 2>&1 && pwd) + +set -e +echo "Install OTCamera." + +echo "$DIR" +cd "$DIR" || exit +WORKING_DIR=$(pwd) +VENV="$WORKING_DIR"/venv +PYTHON="$VENV"/bin/python +PIP="$VENV"/bin/pip + +python3.9 -m venv "$VENV" + +$PYTHON -m pip install --upgrade pip +$PIP install -r requirements.txt --no-cache-dir diff --git a/install_dev.sh b/install_dev.sh new file mode 100755 index 00000000..ffecf96f --- /dev/null +++ b/install_dev.sh @@ -0,0 +1,14 @@ +#!/bin/bash +set -e +echo "Install OTCamera development environment." + +WORKING_DIR=$(pwd) +VENV="$WORKING_DIR"/venv +PIP="$VENV"/bin/pip +PRE_COMMIT="$VENV"/bin/pre-commit + +bash "$WORKING_DIR"/install.sh + +$PIP install -r requirements-dev.txt --no-cache-dir +$PIP install -e . +$PRE_COMMIT install --install-hooks From e8d342ba4873976934669d527647f51c7fabe05c Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Tue, 22 Jul 2025 16:47:28 +0200 Subject: [PATCH 39/44] Add .editorconfig to standardize code style --- .editorconfig | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..eb1f60c4 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +# EditorConfig is awesome: http://EditorConfig.org +# top-most EditorConfig file +root = true + +# Unix-style newlines at the bottom of every file +[*] +charset = utf-8 + +# Tab indentation +indent_style = space +indent_size = 4 + +[*.{md,sh,yaml,yml}] +indent_size = 2 From 2358f3e52adcce269ee4ce47ce9117219ab525a6 Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Tue, 22 Jul 2025 17:26:26 +0200 Subject: [PATCH 40/44] Indent template.html content to follow consistent code style --- webfiles/template.html | 76 +++++++++++++++++++++--------------------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/webfiles/template.html b/webfiles/template.html index 4b737481..861fd7e3 100644 --- a/webfiles/template.html +++ b/webfiles/template.html @@ -1,43 +1,43 @@ - - - - - - - - - - OTCamera - + + + + + + + + + + OTCamera + - -
-
-
- Vorschau -
-
- - - + +
+
+
+ Vorschau +
+
+ + + From 9ca2dd7d9616e9d19fbe3d253651f0c46e99dac8 Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Tue, 22 Jul 2025 17:28:32 +0200 Subject: [PATCH 41/44] Fix indentation --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f162249e..e2cfad21 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,8 +6,8 @@ build-backend = "hatchling.build" name = "OTCamera" dynamic = ["dependencies", "version"] authors = [ - { name="OpenTrafficCam contributors", email="team@opentrafficcam.org" }, - { name="platomo GmbH", email="info@platomo.de" }, + { name="OpenTrafficCam contributors", email="team@opentrafficcam.org" }, + { name="platomo GmbH", email="info@platomo.de" }, ] description = "OTCamera is a core module of the OpenTrafficCam framework to record videos over multiple days with a custom camera system based on the Raspberry Pi Zero W." From 6a4872b493560a88dbf54dd588bb7c7a16400570 Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Tue, 22 Jul 2025 17:33:36 +0200 Subject: [PATCH 42/44] Replace tabs with spaces --- .flake8 | 4 +- raspi-files/install_otcamera.sh | 160 +++++++++++++++--------------- raspi-files/uninstall_otcamera.sh | 8 +- 3 files changed, 86 insertions(+), 86 deletions(-) diff --git a/.flake8 b/.flake8 index 64a972a6..30b9e462 100644 --- a/.flake8 +++ b/.flake8 @@ -3,5 +3,5 @@ max-line-length=88 docstring-convention=google extend-ignore=E203 exclude= - venv - .venv + venv + .venv diff --git a/raspi-files/install_otcamera.sh b/raspi-files/install_otcamera.sh index e8b94059..16f1a4b6 100755 --- a/raspi-files/install_otcamera.sh +++ b/raspi-files/install_otcamera.sh @@ -20,28 +20,28 @@ echo "Configure legacy camera mode using raspi-config" raspi-config nonint do_legacy 0 case $USE_RTC in [yY] | [yY][eE][sS]) - echo "Enable I2C bus for hwclock" - raspi-config nonint do_i2c 0 - ;; + echo "Enable I2C bus for hwclock" + raspi-config nonint do_i2c 0 + ;; esac echo " Setting config variables" CONFIG="/boot/config.txt" cp $CONFIG $CONFIG.backup { - echo "# OTCamera" - echo "dtoverlay=disable-bt" - echo "disable_camera_led=1" - echo "dtparam=act_led_trigger=none" - echo "dtparam=act_led_activelow=on" - echo "dtparam=audio=off" - echo "display_auto_detect=0" - echo "gpio=17,22,16,27,26=ip" - echo "gpio=22,16,27=pu" - echo "gpio=17,26=pd" - echo "gpio=6,12,13=op" - echo "gpio=6,12=dl" - echo "gpio=13=dh" + echo "# OTCamera" + echo "dtoverlay=disable-bt" + echo "disable_camera_led=1" + echo "dtparam=act_led_trigger=none" + echo "dtparam=act_led_activelow=on" + echo "dtparam=audio=off" + echo "display_auto_detect=0" + echo "gpio=17,22,16,27,26=ip" + echo "gpio=22,16,27=pu" + echo "gpio=17,26=pd" + echo "gpio=6,12,13=op" + echo "gpio=6,12=dl" + echo "gpio=13=dh" } >>$CONFIG sed $CONFIG -i -e "s/^dtparam=audio=on/#dtparam=audio=on/g" sed $CONFIG -i -e "s/^display_auto_detect=1/#display_auto_detect=1/g" @@ -68,9 +68,9 @@ apt install python3-pip -y apt install python3-venv -y if [ "$OTC_VERSION" = "latest" ]; then - latest_tag=$(curl -s https://api.github.com/repos/OpenTrafficCam/OTCamera/releases/latest | sed -Ene '/^ *"tag_name": *"(v.+)",$/s//\1/p') + latest_tag=$(curl -s https://api.github.com/repos/OpenTrafficCam/OTCamera/releases/latest | sed -Ene '/^ *"tag_name": *"(v.+)",$/s//\1/p') else - latest_tag=$OTC_VERSION + latest_tag=$OTC_VERSION fi PWD=$(pwd) @@ -81,8 +81,8 @@ runuser -l "$SUDO_USER" -c "tar -xvzf $PWD/otcamera.tar.gz -C $PWD/OTCamera/ --s rm "$PWD"/otcamera.tar.gz cd OTCamera || { - echo "Error: Cannot find OTCamera directory" - exit 1 + echo "Error: Cannot find OTCamera directory" + exit 1 } PIP="venv/bin/pip" python -m venv venv @@ -111,18 +111,18 @@ echo "DAEMON_CONF=\"$HOSTAPDCONF\"" >>$HOSTAPD cp $HOSTAPDCONF $HOSTAPDCONF".backup" echo " Current hostapd config moved to $HOSTAPDCONF.backup" { - echo "channel=$APCHANNEL" - echo "ssid=$APNAME" - echo "wpa_passphrase=$APPASSWORD" - echo "interface=uap0" - echo "hw_mode=g" - echo "macaddr_acl=0" - echo "auth_algs=1" - echo "wpa=2" - echo "wpa_key_mgmt=WPA-PSK" - echo "wpa_pairwise=TKIP" - echo "rsn_pairwise=CCMP" - echo "country_code=DE" + echo "channel=$APCHANNEL" + echo "ssid=$APNAME" + echo "wpa_passphrase=$APPASSWORD" + echo "interface=uap0" + echo "hw_mode=g" + echo "macaddr_acl=0" + echo "auth_algs=1" + echo "wpa=2" + echo "wpa_key_mgmt=WPA-PSK" + echo "wpa_pairwise=TKIP" + echo "rsn_pairwise=CCMP" + echo "country_code=DE" } >>$HOSTAPDCONF # echo "ctrl_interface=/var/run/hostapd" >> $HOSTAPDCONF # echo "ctrl_interface_group=0" >> $HOSTAPDCONF @@ -134,22 +134,22 @@ DHCPDCONF="/etc/dhcpcd.conf" cp $DHCPDCONF $DHCPDCONF".backup" echo "Current DHCPCD config moved to $DHCPDCONF.backup" { - echo "interface uap0" - echo " static ip_address=$IPRANGE.1/24" - echo " nohook wpa_supplicant" + echo "interface uap0" + echo " static ip_address=$IPRANGE.1/24" + echo " nohook wpa_supplicant" } >>$DHCPDCONF echo "done" echo " Configure DNSMASQ" DNSMASQCONF="/etc/dnsmasq.conf" { - echo "interface=lo,uap0" - echo "no-dhcp-interface=lo,wlan0" - echo "bind-interfaces" - echo "server=$IPRANGE.1" - echo "domain-needed" - echo "bogus-priv" - echo "dhcp-range=$IPRANGE.10,$IPRANGE.250,2h" + echo "interface=lo,uap0" + echo "no-dhcp-interface=lo,wlan0" + echo "bind-interfaces" + echo "server=$IPRANGE.1" + echo "domain-needed" + echo "bogus-priv" + echo "dhcp-range=$IPRANGE.10,$IPRANGE.250,2h" } >>$DNSMASQCONF echo "done" @@ -168,66 +168,66 @@ sed $RCLOCAL -i -e "/^exit 0/i /bin/bash /usr/local/bin/wifistart" case $USE_RTC in [yY] | [yY][eE][sS]) - echo " Setting up RTC" - HWCLOCK="/lib/udev/hwclock-set" - cp $HWCLOCK $HWCLOCK.backup - apt install i2c-tools -y - echo "dtoverlay=i2c-rtc,ds3231" >>$CONFIG - apt remove fake-hwclock -y - update-rc.d -f fake-hwclock remove - systemctl disable fake-hwclock - sed $HWCLOCK -i -e "/if.*systemd/ s/^#*/#/" - sed $HWCLOCK -i -e "s?^ exit 0?# exit 0?g" - sed $HWCLOCK -i -e "s?^fi?#fi?g" - sed $HWCLOCK -i -e "/--systz/ s/^#*/#/" - ;; + echo " Setting up RTC" + HWCLOCK="/lib/udev/hwclock-set" + cp $HWCLOCK $HWCLOCK.backup + apt install i2c-tools -y + echo "dtoverlay=i2c-rtc,ds3231" >>$CONFIG + apt remove fake-hwclock -y + update-rc.d -f fake-hwclock remove + systemctl disable fake-hwclock + sed $HWCLOCK -i -e "/if.*systemd/ s/^#*/#/" + sed $HWCLOCK -i -e "s?^ exit 0?# exit 0?g" + sed $HWCLOCK -i -e "s?^fi?#fi?g" + sed $HWCLOCK -i -e "/--systz/ s/^#*/#/" + ;; esac OTCONFIG="$PWD/OTCamera/config.py" case $USE_BUTTONS in [yY] | [yY][eE][sS]) - echo "Enableing buttons" - sed "$OTCONFIG" -i -e "s?^USE_BUTTONS.*?USE_BUTTONS = True?g" - ;; + echo "Enableing buttons" + sed "$OTCONFIG" -i -e "s?^USE_BUTTONS.*?USE_BUTTONS = True?g" + ;; [nN] | [nN][oO]) - echo "Disableing buttons" - sed "$OTCONFIG" -i -e "s?^USE_BUTTONS.*?USE_BUTTONS = False?g" - ;; + echo "Disableing buttons" + sed "$OTCONFIG" -i -e "s?^USE_BUTTONS.*?USE_BUTTONS = False?g" + ;; esac case $USE_LEDS in [yY] | [yY][eE][sS]) - echo "Enableing LEDs" - sed "$OTCONFIG" -i -e "s?^USE_LED.*?USE_LED = True?g" - ;; + echo "Enableing LEDs" + sed "$OTCONFIG" -i -e "s?^USE_LED.*?USE_LED = True?g" + ;; [nN] | [nN][oO]) - echo "Disableing LEDs" - sed "$OTCONFIG" -i -e "s?^USE_LED.*?USE_LED = False?g" - ;; + echo "Disableing LEDs" + sed "$OTCONFIG" -i -e "s?^USE_LED.*?USE_LED = False?g" + ;; esac case $USE_DEBUG in [yY] | [yY][eE][sS]) - echo "Enableing debug mode" - sed "$OTCONFIG" -i -e "s?^DEBUG_MODE_ON.*?DEBUG_MODE_ON = True?g" - ;; + echo "Enableing debug mode" + sed "$OTCONFIG" -i -e "s?^DEBUG_MODE_ON.*?DEBUG_MODE_ON = True?g" + ;; [nN] | [nN][oO]) - echo "Disableing debug mode" - sed "$OTCONFIG" -i -e "s?^DEBUG_MODE_ON.*?DEBUG_MODE_ON = False?g" - ;; + echo "Disableing debug mode" + sed "$OTCONFIG" -i -e "s?^DEBUG_MODE_ON.*?DEBUG_MODE_ON = False?g" + ;; esac case $USE_RELAY in [yY] | [yY][eE][sS]) - echo "Enableing relay mode" - sed "$OTCONFIG" -i -e "s?^USE_RELAY.*?USE_RELAY = True?g" - bash ./raspi-files/install_sshrelay.sh - ;; + echo "Enableing relay mode" + sed "$OTCONFIG" -i -e "s?^USE_RELAY.*?USE_RELAY = True?g" + bash ./raspi-files/install_sshrelay.sh + ;; [nN] | [nN][oO]) - echo "Disableing debug mode" - sed "$OTCONFIG" -i -e "s?^USE_RELAY.*?USE_RELAY = False?g" - ;; + echo "Disableing debug mode" + sed "$OTCONFIG" -i -e "s?^USE_RELAY.*?USE_RELAY = False?g" + ;; esac echo " Activate OTCamera service" diff --git a/raspi-files/uninstall_otcamera.sh b/raspi-files/uninstall_otcamera.sh index ae6fe29f..7019ece4 100755 --- a/raspi-files/uninstall_otcamera.sh +++ b/raspi-files/uninstall_otcamera.sh @@ -65,13 +65,13 @@ apt remove nginx -y echo "Remove OTCamera directory" cd "$OTCAMERA" || { - echo "Error: Cannot find OTCamera directory" - exit 1 + echo "Error: Cannot find OTCamera directory" + exit 1 } pip uninstall -r requirements.txt -y cd "$USER_HOME" || { - echo "Error: Cannot find HOME directory" - exit 1 + echo "Error: Cannot find HOME directory" + exit 1 } rm -rf "$OTCAMERA" From dbab94b1132a7099038d37eca23890f4c7271ac2 Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Tue, 27 Jan 2026 17:01:21 +0100 Subject: [PATCH 43/44] OP#7975: Cleanup merge conflicts --- OTCamera/hardware/camera_controller.py | 45 +++++++++++++++++++++----- OTCamera/plugin/camera/picamerax.py | 8 +++-- 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/OTCamera/hardware/camera_controller.py b/OTCamera/hardware/camera_controller.py index 64a123f2..de025bd3 100644 --- a/OTCamera/hardware/camera_controller.py +++ b/OTCamera/hardware/camera_controller.py @@ -26,13 +26,12 @@ from time import sleep from typing import Union -import picamerax as picamera import requests import urllib3 -from picamerax import Color from OTCamera import config, status from OTCamera.domain.camera import Camera +from OTCamera.domain.camera_errors import CameraClosedError from OTCamera.hardware import led from OTCamera.helpers import log, name from OTCamera.helpers.filesystem import delete_old_files @@ -59,6 +58,7 @@ class CameraController: def __init__(self, camera: Camera) -> None: self._camera = camera + self._current_video_file: str = name.video() def start_recording(self) -> None: """Start video recording. @@ -68,7 +68,6 @@ def start_recording(self) -> None: - Starts a new recording on picam, using the config.py. - Waits 2 seconds and captures a preview image. - Turns on the record LED (if attached). - """ # TODO: exception handling # OSError Errno 28 No space left on device @@ -78,8 +77,9 @@ def start_recording(self) -> None: if not self._camera.is_recording and not status.shutdownactive: delete_old_files() self.__set_annotation_text() + self._current_video_file = name.video() self._camera.start_recording( - save_file=name.video(), + save_file=self._current_video_file, video_format=config.VIDEO_FORMAT, resolution=config.RESOLUTION_SAVED_VIDEO_FILE, bitrate=config.H264_BITRATE, @@ -115,6 +115,32 @@ def capture(self) -> None: level=log.LogLevel.WARNING, ) + def _try_send_preview(self) -> None: + """Try to send preview image to an external server.""" + if config.SEND_PREVIEW_TO_EXTERNAL: + try: + image = read_preview() + response = requests.post( + config.PREVIEW_URL, + json={ + "frame": 0, + "image": image, + }, + verify=False, + stream=True, + ) + if response.status_code != 200: + log.write( + "Error sending preview to external server: " + f"{response.status_code}" + ) + log.write( + "preview sent to external server", + level=log.LogLevel.DEBUG, + ) + except Exception as e: + log.write(f"Error sending preview to external server: {e}") + def _wait_recording(self, timeout: Union[int, float] = 0) -> None: """Wait timeout seconds recording. @@ -128,9 +154,13 @@ def _wait_recording(self, timeout: Union[int, float] = 0) -> None: def _split(self) -> None: """Splits recording and deletes old video files if no disk space available.""" - self._camera.split_recording(name.video()) - delete_old_files() + current_video_file = self._current_video_file + new_video_file = name.video() + self._camera.split_recording(new_video_file) + self._current_video_file = new_video_file log.write("split recording") + self._try_upload_to_cloud(current_video_file) + delete_old_files() def _try_upload_to_cloud(self, video_name: str) -> None: """Try to upload video file to cloud storage.""" @@ -247,9 +277,8 @@ def close(self) -> None: try: self._camera.close() log.write("PiCamera closed", log.LogLevel.DEBUG) - except picamera.PiCameraClosed: + except CameraClosedError: log.write("Camera already closed.", level=log.LogLevel.DEBUG) - pass def restart(self) -> None: """ diff --git a/OTCamera/plugin/camera/picamerax.py b/OTCamera/plugin/camera/picamerax.py index 61e93f00..fcefe6bf 100644 --- a/OTCamera/plugin/camera/picamerax.py +++ b/OTCamera/plugin/camera/picamerax.py @@ -1,9 +1,10 @@ from typing import Tuple, Union -from picamerax import Color, PiCamera +from picamerax import Color, PiCamera, PiCameraClosed from OTCamera import config from OTCamera.domain.camera import Camera, H264Level, H264Profile, VideoFormat +from OTCamera.domain.camera_errors import CameraClosedError from OTCamera.helpers import log @@ -138,7 +139,10 @@ def stop_recording(self) -> None: self._picamera.stop_recording() def close(self) -> None: - self._picamera.close() + try: + self._picamera.close() + except PiCameraClosed: + raise CameraClosedError("Camera is already closed.") def reinitialize(self) -> None: self.close() From 84727a8bca83c3eff02a48e21263782178fb2630 Mon Sep 17 00:00:00 2001 From: Randy Seng <19281702+randy-seng@users.noreply.github.com> Date: Tue, 27 Jan 2026 17:02:50 +0100 Subject: [PATCH 44/44] OP#7975: Add `CameraClosedError` exception class to handle specific camera closure errors --- OTCamera/domain/camera_errors.py | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 OTCamera/domain/camera_errors.py diff --git a/OTCamera/domain/camera_errors.py b/OTCamera/domain/camera_errors.py new file mode 100644 index 00000000..10c3a8bb --- /dev/null +++ b/OTCamera/domain/camera_errors.py @@ -0,0 +1,2 @@ +class CameraClosedError(Exception): + pass