├── isdb_scanner ├── __init__.py ├── constants.py ├── analyzer.py ├── __main__.py ├── formatter.py └── tuner.py ├── poetry.toml ├── .vscode ├── extensions.json └── settings.json ├── .editorconfig ├── License.txt ├── pyproject.toml ├── .gitignore ├── .github └── workflows │ └── build.yml ├── Readme.md └── poetry.lock /isdb_scanner/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '1.3.2' 2 | -------------------------------------------------------------------------------- /poetry.toml: -------------------------------------------------------------------------------- 1 | [virtualenvs] 2 | in-project = true 3 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "editorconfig.editorconfig", 4 | "ms-python.python", 5 | "ms-python.vscode-pylance", 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_size = 4 8 | indent_style = space 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | indent_size = 2 13 | trim_trailing_whitespace = false 14 | 15 | [*.yml] 16 | indent_size = 2 17 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // 保存時に Ruff による自動フォーマットを行う 3 | "[python]": { 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll.ruff": "explicit", 6 | "source.organizeImports.ruff": "explicit", 7 | }, 8 | "editor.defaultFormatter": "charliermarsh.ruff", 9 | "editor.formatOnSave": true, 10 | }, 11 | // Pylance の Type Checking を有効化 12 | "python.languageServer": "Pylance", 13 | "python.analysis.typeCheckingMode": "strict", 14 | // Pylance の Type Checking のうち、いくつかのエラー報告を抑制する 15 | "python.analysis.diagnosticSeverityOverrides": { 16 | "reportConstantRedefinition": "none", 17 | "reportMissingTypeStubs": "none", 18 | "reportPrivateImportUsage": "none", 19 | "reportShadowedImports": "none", 20 | "reportUnnecessaryComparison": "none", 21 | "reportUnknownArgumentType": "none", 22 | "reportUnknownMemberType": "none", 23 | "reportUnknownVariableType": "none", 24 | "reportUnusedFunction": "none", 25 | }, 26 | } 27 | -------------------------------------------------------------------------------- /License.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023-2025 tsukumi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "isdb-scanner" 3 | version = "1.3.2" 4 | description = "受信可能な日本のテレビチャンネル (ISDB-T/ISDB-S) を全自動でスキャンし、スキャン結果を様々な形式で出力するツール" 5 | authors = ["tsukumi "] 6 | license = "MIT" 7 | readme = "Readme.md" 8 | 9 | [tool.taskipy.tasks] 10 | lint = "ruff check --fix ." 11 | format = "ruff format ." 12 | 13 | [tool.poetry.dependencies] 14 | python = ">=3.11,<3.13" 15 | ariblib = {url = "https://github.com/tsukumijima/ariblib/releases/download/v0.1.4/ariblib-0.1.4-py3-none-any.whl"} 16 | devtools = ">=0.12.0" 17 | libusb-package = ">=1.0.26" 18 | pydantic = ">=2.1.0" 19 | pyusb = ">=1.2.1" 20 | ruamel-yaml = ">=0.17.0" 21 | typer = ">=0.9.0" 22 | typing-extensions = ">=4.7.1" 23 | 24 | [tool.poetry.group.dev.dependencies] 25 | pyinstaller = ">=5.13.2" 26 | ruff = ">=0.9.1" 27 | taskipy = "^1.14.0" 28 | 29 | [build-system] 30 | requires = ["poetry-core"] 31 | build-backend = "poetry.core.masonry.api" 32 | 33 | [tool.ruff] 34 | # 1行の長さを最大140文字に設定 35 | line-length = 140 36 | # インデントの幅を4スペースに設定 37 | indent-width = 4 38 | # Python 3.11 を利用する 39 | target-version = "py311" 40 | 41 | [tool.ruff.lint] 42 | # flake8, pycodestyle, pyupgrade, isort, Ruff 固有のルールを使う 43 | select = ["F", "E", "W", "UP", "I", "RUF", "TID251"] 44 | ignore = [ 45 | "E501", # 1行の長さを超えている場合の警告を抑制 46 | "E731", # Do not assign a `lambda` expression, use a `def` を抑制 47 | "RUF001", # 全角記号など `ambiguous unicode character` も使いたいため 48 | "RUF002", # 全角記号など `ambiguous unicode character` も使いたいため 49 | "RUF003", # 全角記号など `ambiguous unicode character` も使いたいため 50 | "RUF012", 51 | ] 52 | 53 | [tool.ruff.lint.isort] 54 | # インポートブロックの後に2行空ける 55 | lines-after-imports = 2 56 | 57 | [tool.ruff.lint.flake8-tidy-imports.banned-api] 58 | # Python 3.11 + Pydantic で TypedDict を扱う際は、typing_extensions.TypedDict を使う必要がある 59 | # ref: https://github.com/langchain-ai/langgraph/pull/2910 60 | "typing.TypedDict".msg = "Use typing_extensions.TypedDict instead." 61 | 62 | [tool.ruff.format] 63 | # シングルクオートを使う 64 | quote-style = "single" 65 | # インデントにはスペースを使う 66 | indent-style = "space" 67 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/python 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=python 4 | 5 | ### Python ### 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # PyInstaller 15 | # Usually these files are written by a python script from a template 16 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 17 | *.manifest 18 | *.spec 19 | 20 | # Installer logs 21 | pip-log.txt 22 | pip-delete-this-directory.txt 23 | 24 | # Unit test / coverage reports 25 | htmlcov/ 26 | .tox/ 27 | .nox/ 28 | .coverage 29 | .coverage.* 30 | .cache 31 | nosetests.xml 32 | coverage.xml 33 | *.cover 34 | *.py,cover 35 | .hypothesis/ 36 | .pytest_cache/ 37 | cover/ 38 | 39 | # Translations 40 | *.mo 41 | *.pot 42 | 43 | # Django stuff: 44 | *.log 45 | local_settings.py 46 | db.sqlite3 47 | db.sqlite3-journal 48 | 49 | # Flask stuff: 50 | instance/ 51 | .webassets-cache 52 | 53 | # Scrapy stuff: 54 | .scrapy 55 | 56 | # Sphinx documentation 57 | docs/_build/ 58 | 59 | # PyBuilder 60 | .pybuilder/ 61 | target/ 62 | 63 | # Jupyter Notebook 64 | .ipynb_checkpoints 65 | 66 | # IPython 67 | profile_default/ 68 | ipython_config.py 69 | 70 | # pyenv 71 | # For a library or package, you might want to ignore these files since the code is 72 | # intended to run in multiple environments; otherwise, check them in: 73 | # .python-version 74 | 75 | # pipenv 76 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 77 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 78 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 79 | # install all needed dependencies. 80 | #Pipfile.lock 81 | 82 | # poetry 83 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 84 | # This is especially recommended for binary packages to ensure reproducibility, and is more 85 | # commonly ignored for libraries. 86 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 87 | #poetry.lock 88 | 89 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 90 | __pypackages__/ 91 | 92 | # Celery stuff 93 | celerybeat-schedule 94 | celerybeat.pid 95 | 96 | # SageMath parsed files 97 | *.sage.py 98 | 99 | # Environments 100 | .env 101 | .venv 102 | env/ 103 | venv/ 104 | ENV/ 105 | env.bak/ 106 | venv.bak/ 107 | 108 | # Spyder project settings 109 | .spyderproject 110 | .spyproject 111 | 112 | # Rope project settings 113 | .ropeproject 114 | 115 | # mkdocs documentation 116 | /site 117 | 118 | # mypy 119 | .mypy_cache/ 120 | .dmypy.json 121 | dmypy.json 122 | 123 | # Pyre type checker 124 | .pyre/ 125 | 126 | # pytype static type analyzer 127 | .pytype/ 128 | 129 | # Cython debug symbols 130 | cython_debug/ 131 | 132 | # PyCharm 133 | # JetBrains specific template is maintainted in a separate JetBrains.gitignore that can 134 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 135 | # and can be added to the global gitignore or merged into this file. For a more nuclear 136 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 137 | #.idea/ 138 | 139 | # End of https://www.toptal.com/developers/gitignore/api/python 140 | 141 | # ignore .gitkeep 142 | !.gitkeep 143 | 144 | .python-version 145 | build/ 146 | dist/ 147 | scanned*/ 148 | isdb-scanner 149 | *.ts 150 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | workflow_call: 6 | workflow_dispatch: 7 | 8 | jobs: 9 | 10 | build-linux: 11 | strategy: 12 | fail-fast: false # 一つのビルドが失敗しても他のビルドは継続する 13 | matrix: 14 | include: 15 | # x64 アーキテクチャ向けのビルド設定 16 | - arch: amd64 17 | runner: ubuntu-22.04 18 | artifact_suffix: '' 19 | # arm64 アーキテクチャ向けのビルド設定 20 | - arch: arm64 21 | runner: ubuntu-22.04-arm 22 | artifact_suffix: '-arm' 23 | runs-on: ${{ matrix.runner }} 24 | steps: 25 | 26 | # Docker Buildx のセットアップ 27 | - name: Set up Docker Buildx 28 | uses: docker/setup-buildx-action@v3 29 | 30 | # Dockerfile を作成 31 | - name: Create Dockerfile 32 | run: | 33 | cat < Dockerfile 34 | FROM ubuntu:20.04 35 | ENV DEBIAN_FRONTEND=noninteractive 36 | RUN apt-get update && \ 37 | apt-get install -y --no-install-recommends software-properties-common && \ 38 | add-apt-repository -y ppa:deadsnakes/ppa && \ 39 | apt-get install -y \ 40 | build-essential \ 41 | curl \ 42 | patchelf \ 43 | python3.11 \ 44 | python3.11-dev \ 45 | python3.11-distutils \ 46 | python3.11-venv \ 47 | zlib1g \ 48 | zlib1g-dev 49 | RUN curl https://bootstrap.pypa.io/get-pip.py | python3.11 50 | RUN python3.11 -m pip install poetry 51 | EOF 52 | 53 | # Ubuntu 20.04 の Docker イメージをビルド 54 | - name: Build Docker Image 55 | uses: docker/build-push-action@v5 56 | with: 57 | context: . 58 | tags: ubuntu:20.04-custom 59 | cache-from: type=gha,scope=ubuntu:20.04-custom(${{ matrix.arch }}) 60 | cache-to: type=gha,scope=ubuntu:20.04-custom(${{ matrix.arch }}),mode=max 61 | load: true 62 | 63 | # Dockerfile を削除 64 | - name: Remove Dockerfile 65 | run: rm Dockerfile 66 | 67 | # ISDBScanner のソースコードをチェックアウト 68 | - name: Checkout Repository 69 | uses: actions/checkout@v4 70 | 71 | # ISDBScanner を PyInstaller でビルド 72 | # arm64 ビルドではリリースでの区別のため、ファイル名を isdb-scanner-arm に変更する 73 | - name: Build with PyInstaller 74 | run: | 75 | docker run --rm -i -v $(pwd):/work -w /work ubuntu:20.04-custom bash -c \ 76 | 'poetry install && poetry run pyinstaller --onefile --collect-submodules shellingham --name=isdb-scanner${{ matrix.artifact_suffix }} isdb_scanner/__main__.py' 77 | 78 | # 単一実行ファイルにビルドされたバイナリを Artifact としてアップロード 79 | - name: Upload Executable as Artifact 80 | uses: actions/upload-artifact@v4 81 | with: 82 | name: isdb-scanner${{ matrix.artifact_suffix }} 83 | path: dist/isdb-scanner${{ matrix.artifact_suffix }} 84 | 85 | # Wheel をビルド 86 | # ISDBScanner 自体は Pure Python パッケージなので、重複回避のため x64 版のみ実行 87 | - name: Build Wheel 88 | if: matrix.arch == 'amd64' 89 | run: | 90 | docker run --rm -i -v $(pwd):/work -w /work ubuntu:20.04-custom bash -c \ 91 | 'poetry build' 92 | 93 | # Wheel を Artifact としてアップロード 94 | # ISDBScanner 自体は Pure Python パッケージなので、重複回避のため x64 版のみ実行 95 | - name: Upload Wheel as Artifact 96 | if: matrix.arch == 'amd64' 97 | uses: actions/upload-artifact@v4 98 | with: 99 | name: isdb-scanner-wheel 100 | path: dist/isdb_scanner-*.whl 101 | 102 | # タグが push されたときのみ実行 103 | release: 104 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 105 | runs-on: ubuntu-22.04 106 | needs: 107 | - build-linux 108 | permissions: 109 | contents: write 110 | steps: 111 | 112 | # Artifact をダウンロード (x86_64) 113 | - name: Download Artifact (x86_64) 114 | uses: actions/download-artifact@v4 115 | with: 116 | name: isdb-scanner 117 | path: dist 118 | 119 | # Artifact をダウンロード (arm64) 120 | - name: Download Artifact (arm64) 121 | uses: actions/download-artifact@v4 122 | with: 123 | name: isdb-scanner-arm 124 | path: dist 125 | 126 | # Artifact をダウンロード (wheel) 127 | - name: Download Artifact (wheel) 128 | uses: actions/download-artifact@v4 129 | with: 130 | name: isdb-scanner-wheel 131 | path: dist 132 | 133 | # リリースを作成 134 | - name: Release 135 | uses: softprops/action-gh-release@v2 136 | with: 137 | generate_release_notes: true 138 | files: | 139 | ./dist/isdb-scanner 140 | ./dist/isdb-scanner-arm 141 | ./dist/isdb_scanner-*.whl 142 | -------------------------------------------------------------------------------- /isdb_scanner/constants.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from enum import StrEnum 4 | from pathlib import Path 5 | from typing import Literal 6 | 7 | from pydantic import BaseModel, RootModel, computed_field 8 | 9 | 10 | # Pydantic モデルの定義 11 | 12 | BroadcastType = Literal['Terrestrial', 'BS', 'CS1', 'CS2'] 13 | 14 | 15 | # Typer が Literal をサポートしていないため、StrEnum を使用する 16 | # ref: https://github.com/fastapi/typer/issues/76 17 | class LNBVoltage(StrEnum): 18 | _11V = '11v' 19 | _15V = '15v' 20 | LOW = 'low' 21 | 22 | 23 | class ServiceInfo(BaseModel): 24 | # fmt: off 25 | channel_number: str = 'Unknown' # 3桁チャンネル番号 (BS/CS ではサービス ID と同一) 26 | service_id: int = -1 # サービス ID 27 | service_type: int = -1 # サービス種別 (1: 映像サービス, 161: 臨時映像サービス, 192: データサービス/ワンセグ放送) 28 | service_name: str = 'Unknown' # サービス名 29 | is_free: bool = True # 無料放送かどうか 30 | is_oneseg: bool = False # ワンセグ放送かどうか 31 | # fmt: on 32 | 33 | def __str__(self) -> str: 34 | message = f'Ch: {self.channel_number} | {self.service_name} ' 35 | if self.service_type == 0x02: 36 | message += '[Radio]' 37 | elif 0xA1 <= self.service_type <= 0xA3: 38 | message += '[Temporary]' 39 | elif self.service_type == 0xA4: 40 | message += '[Engineering Service]' 41 | elif 0xA5 <= self.service_type <= 0xA7: 42 | message += '[Promotion]' 43 | elif self.service_type == 0xC0 and not self.is_oneseg: 44 | message += '[Data]' 45 | if not self.is_free: 46 | message += '[Pay TV]' 47 | if self.is_oneseg: 48 | message += '[OneSeg]' 49 | return message.rstrip() 50 | 51 | def isVideoServiceType(self) -> bool: 52 | """ 53 | サービスタイプが映像サービスかどうか 54 | ref: https://github.com/xtne6f/EDCB/blob/work-plus-s-230823/BonCtrl/ChSetUtil.h#L66-L74 55 | """ 56 | return ( 57 | self.service_type == 0x01 # デジタルTVサービス 58 | or self.service_type == 0xA5 # プロモーション映像サービス 59 | or self.service_type == 0xAD # 超高精細度4K専用TVサービス 60 | ) 61 | 62 | 63 | class TransportStreamInfo(BaseModel): 64 | # fmt: off 65 | physical_channel: str = 'Unknown' # 物理チャンネル (ex: "T13", "BS23/TS3", "ND04") 66 | transport_stream_id: int = -1 # トランスポートストリーム ID 67 | network_id: int = -1 # ネットワーク ID 68 | network_name: str = 'Unknown' # 地上波: トランスポートストリーム名 / BS/CS: ネットワーク名 69 | remote_control_key_id: int | None = None # 地上波: リモコンキー ID 70 | satellite_frequency: float | None = None # BS/CS: 周波数 (単位: GHz) 71 | satellite_transponder: int | None = None # BS/CS: トランスポンダ番号 72 | satellite_slot_number: int | None = None # BS: いわゆるスロット番号 (厳密には相対 TS 番号) 73 | services: list[ServiceInfo] = [] 74 | # fmt: on 75 | 76 | @computed_field 77 | @property 78 | def broadcast_type(self) -> BroadcastType: # 放送種別 79 | if 0x7880 <= self.network_id <= 0x7FE8 or self.physical_channel.startswith('T'): 80 | return 'Terrestrial' 81 | elif self.network_id == 4 or self.physical_channel.startswith('BS'): 82 | return 'BS' 83 | elif self.network_id == 6 or self.physical_channel in ['ND02', 'ND08', 'ND10']: 84 | return 'CS1' 85 | elif self.network_id == 7 or self.physical_channel.startswith('ND'): 86 | return 'CS2' 87 | else: 88 | assert False, f'Unreachable: {self.physical_channel}' 89 | 90 | @computed_field 91 | @property 92 | def physical_channel_recisdb(self) -> str: # recisdb が受け付けるフォーマットの物理チャンネル 93 | if self.broadcast_type == 'Terrestrial': 94 | return self.physical_channel # T13 -> T13 95 | elif self.broadcast_type == 'BS': 96 | return self.physical_channel.replace('/TS', '_') # BS23/TS3 -> BS23_3 97 | elif self.broadcast_type == 'CS1' or self.broadcast_type == 'CS2': 98 | return self.physical_channel.replace('ND', 'CS') # ND04 -> CS04 99 | else: 100 | assert False, f'Unreachable: {self.physical_channel}' 101 | 102 | @computed_field 103 | @property 104 | def physical_channel_recpt1(self) -> str: # recpt1 が受け付けるフォーマットの物理チャンネル 105 | if self.broadcast_type == 'Terrestrial': 106 | return self.physical_channel.replace('T', '') # T13 -> 13 107 | elif self.broadcast_type == 'BS': 108 | return self.physical_channel.replace('/TS', '_') # BS23/TS3 -> BS23_3 109 | elif self.broadcast_type == 'CS1' or self.broadcast_type == 'CS2': 110 | return self.physical_channel.replace('ND', 'CS').replace('CS0', 'CS') # ND04 -> CS4 111 | else: 112 | assert False, f'Unreachable: {self.physical_channel}' 113 | 114 | def __str__(self) -> str: 115 | physical_channel = self.physical_channel 116 | if self.broadcast_type == 'Terrestrial': 117 | physical_channel = self.physical_channel.replace('T', '') + 'ch' 118 | message = f'{self.broadcast_type} - {physical_channel} / TSID: {self.transport_stream_id} ' 119 | if self.broadcast_type == 'Terrestrial': 120 | message += f'| {self.remote_control_key_id:02d}: {self.network_name}' 121 | else: 122 | message += f'/ Frequency: {self.satellite_frequency:.5f} GHz | {self.network_name}' 123 | return message.rstrip() 124 | 125 | 126 | class TransportStreamInfoList(RootModel[list[TransportStreamInfo]]): 127 | root: list[TransportStreamInfo] 128 | 129 | 130 | class DVBDeviceInfo(BaseModel): 131 | device_path: Path 132 | tuner_type: Literal['ISDB-T', 'ISDB-S', 'ISDB-T/ISDB-S'] 133 | tuner_name: str 134 | 135 | 136 | # V4L-DVB 版ドライバにおけるチューナーデバイスのパス 137 | # 歴史的な経緯で "DVB" という名称だが ISDB-T/ISDB-S をはじめ ATSC などにも対応している 138 | # V4L-DVB デバイスが接続されている場合、/dev/dvb/adapter0 などのディレクトリ配下に demux0, dvr0, frontend0 の各チューナーデバイスが存在する 139 | # chardev 版ドライバと異なりデバイス名からは機種や対応放送方式などは判別できないため、チューナーの種類によらず全てのデバイスを列挙する 140 | DVB_INTERFACE_TUNER_DEVICE_PATHS = sorted([path for path in Path('/dev/dvb').glob('adapter*/frontend*')]) 141 | 142 | # chardev 版ドライバにおけるチューナーデバイスのパス 143 | # ref: https://github.com/tsukumijima/px4_drv 144 | # ref: https://github.com/stz2012/recpt1/blob/master/recpt1/pt1_dev.h 145 | 146 | # ISDB-T 専用のチューナーデバイスのパス 147 | # Earthsoft PT1/PT2/PT3: 全体で最大8チューナーまで想定 148 | # PLEX PX-W3U4/PX-Q3U4/PX-W3PE4/PX-Q3PE4/PX-W3PE5/PX-Q3PE5: 全体で最大8チューナーまで想定 149 | # PLEX PX-S1UR: 最大8台接続まで想定 150 | ISDBT_TUNER_DEVICE_PATHS = [ 151 | # Earthsoft PT1 / PT2 152 | Path('/dev/pt1video2'), 153 | Path('/dev/pt1video3'), 154 | Path('/dev/pt1video6'), 155 | Path('/dev/pt1video7'), 156 | Path('/dev/pt1video10'), 157 | Path('/dev/pt1video11'), 158 | Path('/dev/pt1video14'), 159 | Path('/dev/pt1video15'), 160 | # Earthsoft PT3 161 | Path('/dev/pt3video2'), 162 | Path('/dev/pt3video3'), 163 | Path('/dev/pt3video6'), 164 | Path('/dev/pt3video7'), 165 | Path('/dev/pt3video10'), 166 | Path('/dev/pt3video11'), 167 | Path('/dev/pt3video14'), 168 | Path('/dev/pt3video15'), 169 | # PLEX PX-W3U4/PX-Q3U4/PX-W3PE4/PX-Q3PE4/PX-W3PE5/PX-Q3PE5 170 | Path('/dev/px4video2'), 171 | Path('/dev/px4video3'), 172 | Path('/dev/px4video6'), 173 | Path('/dev/px4video7'), 174 | Path('/dev/px4video10'), 175 | Path('/dev/px4video11'), 176 | Path('/dev/px4video14'), 177 | Path('/dev/px4video15'), 178 | # PX-S1UR (1台目) 179 | Path('/dev/pxs1urvideo0'), 180 | # PX-S1UR (2台目) 181 | Path('/dev/pxs1urvideo1'), 182 | # PX-S1UR (3台目) 183 | Path('/dev/pxs1urvideo2'), 184 | # PX-S1UR (4台目) 185 | Path('/dev/pxs1urvideo3'), 186 | # PX-S1UR (5台目) 187 | Path('/dev/pxs1urvideo4'), 188 | # PX-S1UR (6台目) 189 | Path('/dev/pxs1urvideo5'), 190 | # PX-S1UR (7台目) 191 | Path('/dev/pxs1urvideo6'), 192 | # PX-S1UR (8台目) 193 | Path('/dev/pxs1urvideo7'), 194 | # e-better DTV03A-1TU (1台目) 195 | Path('/dev/isdbt2071video0'), 196 | # e-better DTV03A-1TU (2台目) 197 | Path('/dev/isdbt2071video1'), 198 | # e-better DTV03A-1TU (3台目) 199 | Path('/dev/isdbt2071video2'), 200 | # e-better DTV03A-1TU (4台目) 201 | Path('/dev/isdbt2071video3'), 202 | # e-better DTV03A-1TU (5台目) 203 | Path('/dev/isdbt2071video4'), 204 | # e-better DTV03A-1TU (6台目) 205 | Path('/dev/isdbt2071video5'), 206 | # e-better DTV03A-1TU (7台目) 207 | Path('/dev/isdbt2071video6'), 208 | # e-better DTV03A-1TU (8台目) 209 | Path('/dev/isdbt2071video7'), 210 | ] 211 | 212 | # ISDB-S 専用のチューナーデバイスのパス 213 | # Earthsoft PT1/PT2/PT3: 全体で最大8チューナーまで想定 214 | # PLEX PX-W3U4/PX-Q3U4/PX-W3PE4/PX-Q3PE4/PX-W3PE5/PX-Q3PE5: 全体で最大8チューナーまで想定 215 | ISDBS_TUNER_DEVICE_PATHS = [ 216 | # Earthsoft PT1 / PT2 217 | Path('/dev/pt1video0'), 218 | Path('/dev/pt1video1'), 219 | Path('/dev/pt1video4'), 220 | Path('/dev/pt1video5'), 221 | Path('/dev/pt1video8'), 222 | Path('/dev/pt1video9'), 223 | Path('/dev/pt1video12'), 224 | Path('/dev/pt1video13'), 225 | # Earthsoft PT3 226 | Path('/dev/pt3video0'), 227 | Path('/dev/pt3video1'), 228 | Path('/dev/pt3video4'), 229 | Path('/dev/pt3video5'), 230 | Path('/dev/pt3video8'), 231 | Path('/dev/pt3video9'), 232 | Path('/dev/pt3video12'), 233 | Path('/dev/pt3video13'), 234 | # PLEX PX-W3U4/PX-Q3U4/PX-W3PE4/PX-Q3PE4/PX-W3PE5/PX-Q3PE5 235 | Path('/dev/px4video0'), 236 | Path('/dev/px4video1'), 237 | Path('/dev/px4video4'), 238 | Path('/dev/px4video5'), 239 | Path('/dev/px4video8'), 240 | Path('/dev/px4video9'), 241 | Path('/dev/px4video12'), 242 | Path('/dev/px4video13'), 243 | ] 244 | 245 | # ISDB-T/ISDB-S 共用のマルチチューナーデバイスのパス 246 | # PLEX PX-MLT5PE/PX-MLT8PE, e-better DTV02A-4TS-P: それぞれ最大2台接続まで想定 247 | # PLEX PX-M1UR, e-better DTV02A-1T1S-U: それぞれ最大8台接続まで想定 248 | ISDB_MULTI_TUNER_DEVICE_PATHS = [ 249 | # e-better DTV02A-4TS-P (1台目) 250 | Path('/dev/isdb6014video0'), 251 | Path('/dev/isdb6014video1'), 252 | Path('/dev/isdb6014video2'), 253 | Path('/dev/isdb6014video3'), 254 | # e-better DTV02A-4TS-P (2台目) 255 | Path('/dev/isdb6014video4'), 256 | Path('/dev/isdb6014video5'), 257 | Path('/dev/isdb6014video6'), 258 | Path('/dev/isdb6014video7'), 259 | # PLEX PX-MLT5PE (1台目) 260 | Path('/dev/pxmlt5video0'), 261 | Path('/dev/pxmlt5video1'), 262 | Path('/dev/pxmlt5video2'), 263 | Path('/dev/pxmlt5video3'), 264 | Path('/dev/pxmlt5video4'), 265 | # PLEX PX-MLT5PE (2台目) 266 | Path('/dev/pxmlt5video5'), 267 | Path('/dev/pxmlt5video6'), 268 | Path('/dev/pxmlt5video7'), 269 | Path('/dev/pxmlt5video8'), 270 | Path('/dev/pxmlt5video9'), 271 | # PLEX PX-MLT8PE (1台目) 272 | Path('/dev/pxmlt8video0'), 273 | Path('/dev/pxmlt8video1'), 274 | Path('/dev/pxmlt8video2'), 275 | Path('/dev/pxmlt8video3'), 276 | Path('/dev/pxmlt8video4'), 277 | Path('/dev/pxmlt8video5'), 278 | Path('/dev/pxmlt8video6'), 279 | Path('/dev/pxmlt8video7'), 280 | # PLEX PX-MLT8PE (2台目) 281 | Path('/dev/pxmlt8video8'), 282 | Path('/dev/pxmlt8video9'), 283 | Path('/dev/pxmlt8video10'), 284 | Path('/dev/pxmlt8video11'), 285 | Path('/dev/pxmlt8video12'), 286 | Path('/dev/pxmlt8video13'), 287 | Path('/dev/pxmlt8video14'), 288 | Path('/dev/pxmlt8video15'), 289 | # e-better DTV02A-1T1S-U (1台目) 290 | Path('/dev/isdb2056video0'), 291 | # e-better DTV02A-1T1S-U (2台目) 292 | Path('/dev/isdb2056video1'), 293 | # e-better DTV02A-1T1S-U (3台目) 294 | Path('/dev/isdb2056video2'), 295 | # e-better DTV02A-1T1S-U (4台目) 296 | Path('/dev/isdb2056video3'), 297 | # e-better DTV02A-1T1S-U (5台目) 298 | Path('/dev/isdb2056video4'), 299 | # e-better DTV02A-1T1S-U (6台目) 300 | Path('/dev/isdb2056video5'), 301 | # e-better DTV02A-1T1S-U (7台目) 302 | Path('/dev/isdb2056video6'), 303 | # e-better DTV02A-1T1S-U (8台目) 304 | Path('/dev/isdb2056video7'), 305 | # PLEX PX-M1UR (1台目) 306 | Path('/dev/pxm1urvideo0'), 307 | # PLEX PX-M1UR (2台目) 308 | Path('/dev/pxm1urvideo1'), 309 | # PLEX PX-M1UR (3台目) 310 | Path('/dev/pxm1urvideo2'), 311 | # PLEX PX-M1UR (4台目) 312 | Path('/dev/pxm1urvideo3'), 313 | # PLEX PX-M1UR (5台目) 314 | Path('/dev/pxm1urvideo4'), 315 | # PLEX PX-M1UR (6台目) 316 | Path('/dev/pxm1urvideo5'), 317 | # PLEX PX-M1UR (7台目) 318 | Path('/dev/pxm1urvideo6'), 319 | # PLEX PX-M1UR (8台目) 320 | Path('/dev/pxm1urvideo7'), 321 | ] 322 | -------------------------------------------------------------------------------- /isdb_scanner/analyzer.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from io import BytesIO 3 | from typing import Any 4 | 5 | from ariblib import TransportStreamFile 6 | from ariblib.aribstr import AribString 7 | from ariblib.descriptors import ( 8 | NetworkNameDescriptor, 9 | PartialReceptionDescriptor, 10 | SatelliteDeliverySystemDescriptor, 11 | ServiceDescriptor, 12 | TSInformationDescriptor, 13 | ) 14 | from ariblib.sections import ( 15 | ActualNetworkNetworkInformationSection, 16 | ServiceDescriptionSection, 17 | ) 18 | 19 | from isdb_scanner.constants import ServiceInfo, TransportStreamInfo 20 | 21 | 22 | class TransportStreamAnalyzer(TransportStreamFile): 23 | """ 24 | ISDB-T/ISDB-S (地上波・BS・CS110) の TS ストリームに含まれる PSI/SI を解析するクラス 25 | ariblib の TransportStreamFile を継承しているが、メモリ上に格納された TS ストリームを直接解析できる 26 | """ 27 | 28 | def __init__(self, ts_stream_data: bytearray, tuned_physical_channel: str, chunk_size: int = 10000): 29 | """ 30 | TransportStreamAnalyzer を初期化する 31 | BS / CS110 では、NIT や SDT などの SI (Service Information) の送出間隔 (2023/08 時点で最大 10 秒周期) の関係で、 32 | 最低 15 秒以上の長さを持つ TS ストリームを指定する必要がある (なお地上波の SI 送出間隔は最大 2 秒周期) 33 | 34 | Args: 35 | ts_stream_data (bytearray): チューナーから受信した TS ストリーム 36 | tuned_physical_channel (str): TS ストリームの受信時に選局した物理チャンネル (ex: "T13", "BS23/TS3", "ND04") 37 | chunk_size (int, optional): チャンクサイズ. Defaults to 10000. 38 | """ 39 | 40 | self.tuned_physical_channel = tuned_physical_channel 41 | self.chunk_size = chunk_size 42 | self._bytes_io = BytesIO(ts_stream_data) 43 | self._callbacks: Any = dict() 44 | 45 | # TransportStreamFile は BufferedReader を継承しているが、BufferedReader ではメモリ上のバッファを直接操作できないため、 46 | # BufferedReader から継承しているメソッドのうち、TransportStreamFile の動作に必要なメソッドだけをオーバーライドしている 47 | def read(self, size: int | None = -1) -> bytes: 48 | return self._bytes_io.read(size) 49 | 50 | def seek(self, offset: int, whence: int = 0) -> int: 51 | return self._bytes_io.seek(offset, whence) 52 | 53 | def tell(self) -> int: 54 | return self._bytes_io.tell() 55 | 56 | def analyze(self) -> list[TransportStreamInfo]: 57 | """ 58 | トランスポートストリームとサービスの情報を解析する 59 | ref: https://codepen.io/ppsrbn/pen/KKZPapG 60 | 61 | Returns: 62 | list[TransportStreamInfo]: トランスポートストリームとサービスの情報 63 | """ 64 | 65 | # transport_stream_id をキーにして TS の情報を格納する 66 | # transport_stream_id は事実上日本国内すべての放送波で一意 (のはず) 67 | ts_infos: dict[int, TransportStreamInfo] = {} 68 | 69 | try: 70 | # NIT (自ネットワーク) からトランスポートストリームの情報を取得 71 | # 自ネットワーク: 選局中の TS が所属するものと同一のネットワーク 72 | self.seek(0) 73 | for nit in self.sections(ActualNetworkNetworkInformationSection): 74 | for transport_stream in nit.transport_streams: 75 | # トランスポートストリームの情報を格納 76 | # すでに同じ transport_stream_id の TS が登録されている場合は既存の情報を上書きする 77 | if transport_stream.transport_stream_id in ts_infos: 78 | ts_info = ts_infos[transport_stream.transport_stream_id] 79 | else: 80 | ts_info = TransportStreamInfo() 81 | ts_info.transport_stream_id = int(transport_stream.transport_stream_id) 82 | ts_infos[ts_info.transport_stream_id] = ts_info 83 | ts_info.network_id = int(nit.network_id) 84 | # BS の TSID は、ARIB TR-B15 第三分冊 第一部 第七編 8.1.1 によると 85 | # (network_idの下位4ビット:4bit)(放送開始時期を示すフラグ:3bit)(トランスポンダ番号:5bit)(予約:1bit)(スロット番号:3bit) 86 | # の 16bit で構成されている 87 | # ここからビット演算でトランスポンダ番号とスロット番号を取得する 88 | if ts_info.network_id == 4: 89 | ts_info.satellite_transponder = (ts_info.transport_stream_id >> 4) & 0b11111 90 | ts_info.satellite_slot_number = ts_info.transport_stream_id & 0b111 91 | ts_info.physical_channel = f'BS{ts_info.satellite_transponder:02d}/TS{ts_info.satellite_slot_number}' 92 | # CS110 の TSID は、ARIB TR-B15 第四分冊 第二部 第七編 8.1.1 によると 93 | # (network_idの下位4ビット:4bit)(予約:3bit)(トランスポンダ番号:5bit)(予約:1bit)(スロット番号:3bit) の 16bit で構成されている 94 | # ここからビット演算でトランスポンダ番号を取得する (CS110 ではスロット番号は常に 0 なので取得しない) 95 | elif ts_info.network_id == 6 or ts_info.network_id == 7: 96 | ts_info.satellite_transponder = (ts_info.transport_stream_id >> 4) & 0b11111 97 | ts_info.physical_channel = f'ND{ts_info.satellite_transponder:02d}' 98 | if 0x7880 <= ts_info.network_id <= 0x7FE8: 99 | # TS 情報記述子 (地上波のみ) 100 | for ts_information in transport_stream.descriptors.get(TSInformationDescriptor, []): 101 | # TS 名 (ネットワーク名として設定) 102 | ts_info.network_name = self.__fullWidthToHalfWith(ts_information.ts_name_char) 103 | # リモコンキー ID 104 | ts_info.remote_control_key_id = int(ts_information.remote_control_key_id) 105 | break 106 | # 部分受信記述子 (地上波のみ) 107 | # ワンセグ放送のサービスを特定するために必要 108 | for partial_reception in transport_stream.descriptors.get(PartialReceptionDescriptor, []): 109 | for partial_service in partial_reception.services: 110 | # すでに同じ service_id のサービスが登録されている場合は既存の情報を上書きする 111 | if partial_service.service_id in [sv.service_id for sv in ts_info.services]: 112 | service_info = next(sv for sv in ts_info.services if sv.service_id == partial_service.service_id) 113 | else: 114 | service_info = ServiceInfo() 115 | service_info.service_id = int(partial_service.service_id) 116 | ts_info.services.append(service_info) 117 | service_info.is_oneseg = True 118 | break 119 | else: 120 | # 衛星分配システム記述子 (衛星放送のみ) 121 | for satellite_delivery_system in transport_stream.descriptors.get(SatelliteDeliverySystemDescriptor, []): 122 | ts_info.satellite_frequency = float(satellite_delivery_system.frequency) # GHz 単位 123 | # ネットワーク名記述子 124 | for network_name in nit.network_descriptors.get(NetworkNameDescriptor, []): 125 | # ネットワーク名 (地上波では "関東広域0" のような値になるので利用しない) 126 | ts_info.network_name = self.__fullWidthToHalfWith(network_name.char) 127 | break 128 | 129 | # BS のスロット番号を適切に振り直す 130 | ## TSID は ARIB TR-B15 第三分冊 第一部 第七編 8.1.1 の規定により末尾 3bit がスロット番号となっていて、 131 | ## ISDB-S の TMCC 信号内の相対 TS 番号と同一になるとされている 132 | ## ところが、BS 帯域再編や閉局の影響でスロット番号に歯抜けが生じる場合がある 133 | ## (規定にも「ただし例外として、再編により相対 TS 番号の若い TS が他中継器へ移動あるいは消滅する場合は、 134 | ## 残る TS に対し相対 TS 番号を前詰めとし、bit (2-0) は従前の値を継承して割り付けることを可能とする」とある) 135 | ## 一方 px4_drv は選局時に 0 スタートの相対 TS 番号を求めるため、スロット番号に齟齬が生じる 136 | ## ここでは、相対 TS 番号が 0 スタートになるよう、適切に相対 TS 番号を振り直すこととする 137 | ## 同じトランスポンダ (中継器) の中にかつて TS0, TS1, TS2, TS3 が放送されていたと仮定した際、下記の通り振る舞う 138 | ## 1. 再編や閉局で TS0, TS1 が消滅した場合、消滅した分の相対 TS 番号分、旧 TS2, TS3 をそれぞれ TS0, TS1 に相対 TS 番号をずらす 139 | ## 2. 再編や閉局で TS0, TS2 が消滅した場合、旧 TS1 を TS0 に、旧 TS3 を TS1 に相対 TS 番号をずらす 140 | ## 以前は『再編や閉局で TS2 が消滅した場合、物理的には TS2 は残存している (ヌルパケットが送られている) ため、相対 TS 番号の振り直しは行わない』挙動だったが、 141 | ## 2025年2月末の帯域再編で閉局した空き帯域の相対 TS 番号が一斉に詰められた関係で齟齬が出たため、現在は上記挙動に変更している 142 | ## (本来はこのように厳密に一意に定まらない相対 TS 番号ではなく、TSID を指定して選局すべき) 143 | # 同じトランスポンダ (中継器) を持つ TS ごとにグループ化 144 | bs_groups: defaultdict[int, list[TransportStreamInfo]] = defaultdict(list) 145 | for ts_info in ts_infos.values(): 146 | if ts_info.network_id == 4 and ts_info.satellite_transponder is not None: 147 | bs_groups[ts_info.satellite_transponder].append(ts_info) 148 | # 各グループをスロット番号順にソートし、satellite_slot を適切に振り直して、合わせて physical_channel を更新する 149 | for bs_group in bs_groups.values(): 150 | bs_group.sort(key=lambda ts_info: ts_info.satellite_slot_number or -1) 151 | slot_numbers = [ts_info.satellite_slot_number for ts_info in bs_group if ts_info.satellite_slot_number is not None] 152 | new_slot_numbers: list[int] = [] 153 | """ 154 | # 相対 TS 番号を適切に詰める 155 | if 0 not in slot_numbers: 156 | # 0 が存在しない場合のみ、他の相対 TS 番号を前にずらす 157 | shift = min(slot_numbers) 158 | new_slot_numbers = [slot - shift for slot in slot_numbers] 159 | else: 160 | # 0 が存在する場合は変更しない 161 | new_slot_numbers = slot_numbers 162 | """ 163 | # 相対 TS 番号を0スタートの連番になるように振り直す 164 | # 例: [0,1,3,5] → [0,1,2,3], [1,3,5] → [0,1,2] 165 | new_slot_numbers = [] 166 | sorted_slots = sorted(slot_numbers) 167 | for i, _ in enumerate(sorted_slots): 168 | new_slot_numbers.append(i) 169 | # 新しいスロット番号を割り当てる 170 | for ts_info, new_slot in zip(bs_group, new_slot_numbers): 171 | ts_info.satellite_slot_number = new_slot 172 | ts_info.physical_channel = f'BS{ts_info.satellite_transponder:02d}/TS{new_slot}' 173 | 174 | # 解析中の TS ストリーム選局時の物理チャンネルが地上波 ("T13" など) なら、常に選局した 1TS のみが取得されるはず 175 | ## 地上波では当然ながら PSI/SI からは受信中の物理チャンネルを判定できないので、ここで別途セットする 176 | if self.tuned_physical_channel.startswith('T'): 177 | assert len(ts_infos) == 1 178 | ts_infos[next(iter(ts_infos.keys()))].physical_channel = self.tuned_physical_channel 179 | # 地上波以外では、TS 情報を物理チャンネル順に並び替える 180 | else: 181 | ts_infos = dict(sorted(ts_infos.items(), key=lambda x: x[1].physical_channel)) 182 | 183 | # SDT からサービスの情報を取得 184 | self.seek(0) 185 | for sdt in self.sections(ServiceDescriptionSection): 186 | for service in sdt.services: 187 | # すでに取得されているはずのトランスポートストリームの情報を取得 188 | ts_info = ts_infos.get(sdt.transport_stream_id) 189 | if ts_info is None: 190 | continue 191 | # サービスの情報を格納 192 | # すでに同じ service_id のサービスが登録されている場合は既存の情報を上書きする 193 | if service.service_id in [sv.service_id for sv in ts_info.services]: 194 | service_info = next(sv for sv in ts_info.services if sv.service_id == service.service_id) 195 | else: 196 | service_info = ServiceInfo() 197 | service_info.service_id = int(service.service_id) 198 | ts_info.services.append(service_info) 199 | service_info.is_free = not bool(service.free_CA_mode) 200 | for service in service.descriptors.get(ServiceDescriptor, []): 201 | service_info.service_type = int(service.service_type) 202 | service_info.service_name = self.__fullWidthToHalfWith(service.service_name) 203 | break 204 | # service_id 順にソート 205 | ts_info = ts_infos.get(sdt.transport_stream_id) 206 | if ts_info is not None: 207 | ts_info.services.sort(key=lambda x: x.service_id) 208 | 209 | # 3桁チャンネル番号を算出 210 | for ts_info in ts_infos.values(): 211 | for service_info in ts_info.services: 212 | # 地上波: ((サービス種別 × 200) + remote_control_key_id × 10) + (サービス番号 + 1) 213 | if 0x7880 <= ts_info.network_id <= 0x7FE8: 214 | assert ts_info.remote_control_key_id is not None 215 | # 地上波のサービス ID は、ARIB TR-B14 第五分冊 第七編 9.1 によると 216 | # (地域種別:6bit)(県複フラグ:1bit)(サービス種別:2bit)(地域事業者識別:4bit)(サービス番号:3bit) 217 | # の 16bit で構成されている 218 | # ビット演算でサービス識別 (0~3) を取得する 219 | service_type = (service_info.service_id & 0b0000000110000000) >> 7 220 | # ビット演算でサービス番号 (0~7) を取得する (1~8 に直すために +1 する) 221 | service_number = (service_info.service_id & 0b0000000000000111) + 1 222 | # ARIB TR-B14 第五分冊 第七編 9.1.3 (d) の「3桁番号」の通りに算出する 223 | service_info.channel_number = f'{(service_type * 200) + (ts_info.remote_control_key_id * 10) + service_number:03d}' 224 | # BS/CS: サービス ID と同一 225 | else: 226 | service_info.channel_number = f'{service_info.service_id:03d}' 227 | 228 | # TS データが破損しているなどエラーの原因は色々考えられるが想定のしようがないので、とりあえず例外を送出しておく 229 | except Exception as ex: 230 | raise TransportStreamAnalyzeError(ex) 231 | 232 | # list に変換して返す 233 | return list(ts_infos.values()) 234 | 235 | @staticmethod 236 | def __fullWidthToHalfWith(string: str | AribString) -> str: 237 | """ 238 | 全角英数字を半角英数字に変換する 239 | 囲み文字の置換処理が入っていない以外は KonomiTV での実装とほぼ同じ 240 | ref: https://github.com/tsukumijima/KonomiTV/blob/master/server/app/utils/TSInformation.py#L79-L104 241 | 242 | SI に含まれている ARIB 独自の文字コードである8単位符号では半角と全角のコードポイント上の厳密な区別がなく、 243 | 本来は MSZ (半角) と NSZ (全角) という制御コードが指定されているかで半角/全角どちらのコードポイントにマップすべきか決めるべき 244 | しかしデコードに利用している ariblib は MSZ / NSZ の制御コードの解釈をサポートしていないため、 245 | 8単位符号中で2バイト文字で指定された英数字は全角、ASCII で指定された英数字は半角としてデコードされてしまう 246 | ただ元より EDCB は EPG のチャンネル名文字列と ChSet4/5.txt のチャンネル名文字列を一致させる必要はない設計になっているため、 247 | ariblib での MSZ / NSZ 制御コード対応の手間を鑑み、すべて半角に変換することとする 248 | 249 | Args: 250 | string (str | AribString): 変換前の文字列 251 | 252 | Returns: 253 | str: 変換後の文字列 254 | """ 255 | 256 | # AribString になっているので明示的に str 型にキャストする 257 | string = str(string) 258 | 259 | # 全角英数を半角英数に置換 260 | # ref: https://github.com/ikegami-yukino/jaconv/blob/master/jaconv/conv_table.py 261 | zenkaku_table = ( 262 | '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' 263 | ) 264 | hankaku_table = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' 265 | merged_table = dict(zip(list(zenkaku_table), list(hankaku_table))) 266 | 267 | # 全角記号を半角記号に置換 268 | symbol_zenkaku_table = '"#$%&'()+,-./:;<=>[\]^_`{|} ' 269 | symbol_hankaku_table = '"#$%&\'()+,-./:;<=>[\\]^_`{|} ' 270 | merged_table.update(zip(list(symbol_zenkaku_table), list(symbol_hankaku_table))) 271 | merged_table.update( 272 | { 273 | # 一部の半角記号を全角に置換 274 | # 主に見栄え的な問題(全角の方が字面が良い) 275 | '!': '!', 276 | '?': '?', 277 | '*': '*', 278 | '~': '~', 279 | '@': '@', 280 | # シャープ → ハッシュ 281 | '♯': '#', 282 | # 波ダッシュ → 全角チルダ 283 | ## EDCB は ~ を全角チルダとして扱っているため、ISDBScanner でもそのように統一する 284 | '〜': '~', 285 | } 286 | ) 287 | 288 | return string.translate(str.maketrans(merged_table)) # type: ignore 289 | 290 | 291 | class TransportStreamAnalyzeError(Exception): 292 | """何らかの問題でトランスポートストリームの解析に失敗したときに送出される例外""" 293 | 294 | pass 295 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | 2 | # ISDBScanner 3 | 4 | ![Screenshot](https://github.com/tsukumijima/ISDBScanner/assets/39271166/a60871ed-3fb8-4a6b-9b3e-41a3ccb63362) 5 | 6 | **受信可能な日本のテレビチャンネル (ISDB-T/ISDB-S) を全自動でスキャンし、スキャン結果を [EDCB](https://github.com/xtne6f/EDCB) ([EDCB-Wine](https://github.com/tsukumijima/EDCB-Wine))・[Mirakurun](https://github.com/Chinachu/Mirakurun)・[mirakc](https://github.com/mirakc/mirakc) の各設定ファイルや JSON 形式で出力するツールです。** 7 | 8 | お使いの Linux PC に接続されているチューナーデバイスを自動的に検出し、全自動で受信可能なすべての地上波・BS・CS チャンネルをスキャンします。 9 | **実行時に `--exclude-pay-tv` オプションを指定すれば、CS と BS の有料放送をスキャン結果から除外することも可能です。** 10 | **さらに PC に接続されている対応チューナーを自動的に認識し、Mirakurun / mirakc のチューナー設定ファイルとして出力できます。** 11 | 12 | 地上波では、13ch 〜 62ch までの物理チャンネルをすべてスキャンして、お住まいの地域で受信可能なチャンネルを検出します。 13 | BS・CS では、**BS・CS1・CS2 ごとに1つの物理チャンネルのみをスキャンし TS 内のメタデータを解析することで、他のチャンネルスキャンツールよりも高速に現在放送中の衛星チャンネルを検出できます。** 14 | 15 | > [!NOTE] 16 | > 日本のテレビ放送は、主に地上波 (ISDB-T) と衛星放送 (ISDB-S) の2つの方式で行われています。 17 | > さらに、衛星放送は BS (Broadcasting Satellite) と CS (Communication Satellite) の2つの方式があります。 18 | > 各放送媒体を区別するため、各媒体には一意なネットワーク ID が割り当てられています。 19 | > 20 | > 地上波放送では、居住地域によって受信可能な放送局が異なる特性上、放送局ごとにネットワーク ID が割り当てられています。 21 | > BS 放送では、すべて同じネットワーク ID (0x0004) が割り当てられています。 22 | > CS 放送では歴史的経緯から、CS1 (0x0006: 旧プラット・ワン系) と CS2 (0x0007: スカイパーフェクTV!2系) で異なるネットワーク ID が割り当てられています。 23 | > 具体的には、物理チャンネル ND02 / ND08 / ND10 内で放送されているチャンネルは CS1 ネットワーク、それ以外は CS2 ネットワークになります。現在両者の表面的な違いはほとんどありませんが、技術的には異なるネットワークとして扱われています。 24 | > 25 | > BS・CS (衛星放送) では、**同一ネットワークに属するすべてのチャンネルの情報が、放送波の MPEG-2 TS 内の NIT (Network Information Table) や SDT (Service Description Table) というメタデータに含まれています。** 26 | > そのため、**BS・CS1・CS2 の各ネットワークごとに1つの物理チャンネルをスキャンするだけで、そのネットワークに属するすべてのチャンネルを一括で検出できます。** 27 | > 28 | > さらに NIT に含まれる「現在放送中の BS/CS 物理チャンネルリスト」の情報を元にチャンネル設定ファイルを出力するため、**将来 BS 帯域再編 (トランスポンダ/スロット移動) が行われた際も、再度 ISDBScanner でチャンネルスキャンを行い、出力されたチャンネル設定ファイルを反映するだけで対応できます。** 29 | 30 | > [!NOTE] 31 | > 地上波の物理チャンネルのうち 53ch - 62ch はすでに廃止されていますが、依然として一部ケーブルテレビのコミュニティチャンネル (自主放送) にて利用されているため、スキャン対象に含めています。 32 | 33 | > [!IMPORTANT] 34 | > 検証環境がないため、ISDB-T の C13 - C63ch (周波数変換パススルー方式) と、ISDB-C (トランスモジュレーション方式) で放送されているチャンネルのスキャンには対応していません。 35 | 36 | - [ISDBScanner](#isdbscanner) 37 | - [対応チューナー](#対応チューナー) 38 | - [chardev 版ドライバ](#chardev-版ドライバ) 39 | - [DVB 版ドライバ](#dvb-版ドライバ) 40 | - [対応出力フォーマット](#対応出力フォーマット) 41 | - [インストール](#インストール) 42 | - [使い方](#使い方) 43 | - [PC に接続されている利用可能なチューナーのリストを表示](#pc-に接続されている利用可能なチューナーのリストを表示) 44 | - [チャンネルスキャンを実行](#チャンネルスキャンを実行) 45 | - [注意事項](#注意事項) 46 | - [License](#license) 47 | 48 | ## 対応チューナー 49 | 50 | [px4_drv](https://github.com/tsukumijima/px4_drv) / [smsusb (Linux カーネル標準ドライバ)](https://github.com/torvalds/linux/tree/master/drivers/media/usb/siano) 対応チューナー以外での動作は検証していませんが、おそらく動作すると思います。 51 | 52 | > [!IMPORTANT] 53 | > **DVB 版ドライバを利用するには、ISDBScanner v1.1.0 / [recisdb](https://github.com/kazuki0824/recisdb-rs) v1.2.0 以降が必要です。** 54 | > recisdb v1.2.0 以前のバージョンは DVB 版ドライバの操作に対応していません。 55 | 56 | ### chardev 版ドライバ 57 | 58 | - [px4_drv](https://github.com/tsukumijima/px4_drv) 59 | - PLEX PX-W3U4 60 | - PLEX PX-Q3U4 61 | - PLEX PX-W3PE4 62 | - PLEX PX-Q3PE4 63 | - PLEX PX-W3PE5 64 | - PLEX PX-Q3PE5 65 | - PLEX PX-MLT5PE 66 | - PLEX PX-MLT8PE 67 | - PLEX PX-M1UR 68 | - PLEX PX-S1UR 69 | - e-better DTV02A-1T1S-U 70 | - e-better DTV02A-4TS-P 71 | - e-better DTV03A-1TU 72 | - [pt1_drv](https://github.com/stz2012/recpt1/tree/master/driver) 73 | - Earthsoft PT1 74 | - Earthsoft PT2 75 | - [pt3_drv](https://github.com/m-tsudo/pt3) 76 | - Earthsoft PT3 77 | 78 | ### DVB 版ドライバ 79 | 80 | 動作検証は smsusb + VASTDTV VT20 のみ行っています。 81 | ほかの PX-S1UD 同等品 (Siano SMS2270 採用チューナー) シリーズであれば同様に動作するはずです。 82 | 83 | ISDB-T / ISDB-S 対応であれば smsusb 以外のドライバ ([PT1・PT2](https://github.com/torvalds/linux/tree/master/drivers/media/pci/pt1) / [PT3](https://github.com/torvalds/linux/tree/master/drivers/media/pci/pt3) の DVB 版ドライバや [dddvb](https://github.com/DigitalDevices/dddvb) など) でも動作するはずですが、検証はできていません。 84 | 85 | - [smsusb (Linux カーネル標準ドライバ)](https://github.com/torvalds/linux/tree/master/drivers/media/usb/siano) 86 | - PLEX PX-S1UD 87 | - PLEX PX-Q1UD 88 | - MyGica S880i 89 | - MyGica S270 (PLEX PX-S1UD 同等品) 90 | - VASTDTV VT20 (PLEX PX-S1UD 同等品) 91 | 92 | ## 対応出力フォーマット 93 | 94 | ISDBScanner は、引数で指定されたディレクトリ以下に複数のファイルを出力します。 95 | 出力されるファイルのフォーマットは以下の通りです。 96 | 97 | > [!NOTE] 98 | > **`--exclude-pay-tv` オプションを指定すると、Channels.json を除き、すべての出力ファイルにおいて有料放送チャンネルの定義が除外されます。** 99 | > なお、Channels.json に限り、BS 放送のみ常に有料放送チャンネルも含めた結果が出力されます (CS 放送はチャンネルスキャン処理自体が省略されるため出力されない) 。 100 | 101 | - **Channels.json** 102 | - スキャン時に内部的に保持しているトランスポートストリームとサービスの情報を JSON 形式で出力します。 103 | - 出力される JSON のデータ構造は [constants.py](https://github.com/tsukumijima/ISDBScanner/blob/master/isdb_scanner/constants.py#L8-L77) 内の実装を参照してください。 104 | - BS/CS の周波数やトランスポンダ番号などかなり詳細な情報が出力されるため、スキャン結果を自作ツールなどで加工したい場合にはこのファイルを利用することをおすすめします。 105 | - **EDCB-Wine** 106 | - **出力されるファイルはいずれも [EDCB-Wine](https://github.com/tsukumijima/EDCB-Wine) + [Mirakurun](https://github.com/Chinachu/Mirakurun)/[mirakc](https://github.com/mirakc/mirakc) + [BonDriver_mirakc](https://github.com/tkmsst/BonDriver_mirakc) を組み合わせた環境での利用を前提としています。** 107 | - EDCB のチャンネル設定ファイルには、ChSet4.txt と ChSet5.txt という2つのフォーマットがあります。 108 | - 両者とも中身は TSV で、各行にチャンネル設定データが記述されています。詳細なフォーマットは [formatter.py](https://github.com/tsukumijima/ISDBScanner/blob/master/isdb_scanner/formatter.py#L105-L266) の実装を参照してください。 109 | - ChSet4.txt には、ファイル名に対応する BonDriver ”単体” で受信可能なチャンネルの情報が記述されています。 110 | - **ISDBScanner で生成される ChSet4.txt は BonDriver_mirakc / BonDriver_Mirakurun 専用です。** 111 | - それ以外の BonDriver で使う際は、別途 ChSet4.txt 内の物理チャンネルの通し番号やチューナー空間番号の対応を変更する必要があります。 112 | - ChSet5.txt には、EDCB に登録されている BonDriver 全体で受信可能なチャンネルの情報が記述されています。 113 | - EDCB-Wine (EpgTimerSrv) のチューナー割り当て/チューナー不足判定のロジックが正常に作動しなくなる可能性があるため、**BonDriver_mirakc(_T/_S).dll のチューナー数割り当ては、Mirakurun/mirakc に登録したチューナーの数と種類 (地上波専用/衛星専用/地上波衛星共用) に合わせることを強く推奨します。** 114 | - **BonDriver_mirakc_T(BonDriver_mirakc).ChSet4.txt** 115 | - EDCB 用のチャンネル設定ファイルです。EDCB-Wine + Mirakurun/mirakc + BonDriver_mirakc の組み合わせの環境での利用を前提にしています。 116 | - **地上波のみのチャンネル設定データが含まれます。** 117 | - 別途 BonDriver_mirakc.dll を BonDriver_mirakc_T.dll にコピーすることで、**BonDriver_mirakc_T.dll を地上波専用の Mirakurun/mirakc 用 BonDriver にすることができます。** 118 | - PX-W3U4・PX-W3PE4 などの地上波チューナーと衛星チューナーが分かれている機種をお使いの環境では、**EpgTimerSrv のチューナー数設定で Mirakurun/mirakc に登録している地上波チューナーの数だけ BonDriver_mirakc_T.dll に割り当てることで、EDCB 上で地上波チューナーと衛星チューナーを分けて利用できます。** 119 | - **BonDriver_mirakc_S(BonDriver_mirakc).ChSet4.txt** 120 | - **BS・CS (衛星放送) のみのチャンネル設定データが含まれます。** 121 | - 別途 BonDriver_mirakc.dll を BonDriver_mirakc_S.dll にコピーすることで、**BonDriver_mirakc_S.dll を衛星 (BS・CS) 専用の Mirakurun/mirakc 用 BonDriver にすることができます。** 122 | - PX-W3U4・PX-W3PE4 などの地上波チューナーと衛星チューナーが分かれている機種をお使いの環境では、**EpgTimerSrv のチューナー数設定で Mirakurun/mirakc に登録している衛星チューナーの数だけ BonDriver_mirakc_S.dll に割り当てることで、EDCB 上で地上波チューナーと衛星チューナーを分けて利用できます。** 123 | - **BonDriver_mirakc(BonDriver_mirakc).ChSet4.txt** 124 | - **地上波・BS・CS すべてのチャンネル設定データが含まれます。** 125 | - このチャンネル設定ファイルを合わせて使うことで、**BonDriver_mirakc.dll を地上波・衛星 (BS・CS) 共用の Mirakurun/mirakc 用 BonDriver にすることができます。** 126 | - PX-MLT5PE などの地上波チューナーと衛星チューナーが統合されている機種 (マルチチューナー) をお使いの環境では、**EpgTimerSrv のチューナー数設定で Mirakurun/mirakc に登録しているマルチチューナーの数だけ BonDriver_mirakc.dll に割り当てることで、EDCB 上で適切にマルチチューナーを利用できます。** 127 | - **ChSet5.txt** 128 | - **ChSet4.txt と異なり、登録されている BonDriver のいずれかで受信可能な、地上波・BS・CS すべてのチャンネル設定データが含まれます。** 129 | - 各チューナー (BonDriver) に依存するチャンネル情報は ChSet4.txt の方に書き込まれます。 130 | - **Mirakurun** 131 | - **channels.yml** 132 | - Mirakurun のチャンネル設定ファイルです。地上波・BS・CS すべてのチャンネル設定データが含まれます。 133 | - **`channel` プロパティに記述されている物理チャンネル名は、[recisdb](https://github.com/kazuki0824/recisdb-rs) が受け入れる物理チャンネル指定フォーマット (T13 ~ T62 / BS01_0 ~ BS23_3 / CS02 ~ CS24) に対応しています。** 134 | - **recpt1 の物理チャンネル指定フォーマットとは互換性がありません。** 135 | - recpt1 をチューナーコマンドとして使用している場合は、代わりに channels_recpt1.yml を利用してください。 136 | - **channels_recpt1.yml** 137 | - Mirakurun のチャンネル設定ファイルです。地上波・BS・CS すべてのチャンネル設定データが含まれます。 138 | - **`channel` プロパティに記述されている物理チャンネル名は、[recpt1](https://github.com/stz2012/recpt1) が受け入れる物理チャンネル指定フォーマット (13 ~ 62 / BS01_0 ~ BS23_3 / CS2 ~ CS24) に対応しています。** 139 | - **recisdb の物理チャンネル指定フォーマットとは互換性がありません。** 140 | - recisdb をチューナーコマンドとして使用している場合は、代わりに channels.yml を利用してください。 141 | - **tuners.yml** 142 | - Mirakurun のチューナー設定ファイルです。 143 | - ISDBScanner で自動検出された、PC に接続されているすべてのチューナーの情報が含まれます。 144 | - **`command` プロパティに記述されているチューナーコマンドには、[recisdb](https://github.com/kazuki0824/recisdb-rs) の `tune` サブコマンドが設定されています。** 145 | - recpt1 をチューナーコマンドとして使用している場合は、代わりに tuners_recpt1.yml を利用してください。 146 | - **tuners_recpt1.yml** 147 | - Mirakurun のチューナー設定ファイルです。 148 | - ISDBScanner で自動検出された、PC に接続されているすべてのチューナーの情報が含まれます。 149 | - **`command` プロパティに記述されているチューナーコマンドには、[recpt1](https://github.com/stz2012/recpt1) コマンドが設定されています。** 150 | - recisdb をチューナーコマンドとして使用している場合は、代わりに tuners.yml を利用してください。 151 | - recpt1 に加え、`decoder` として arib-b25-stream-test コマンドが導入されていることを前提としています。 152 | - recisdb と異なり recpt1 は DVB 版ドライバに対応していないため、DVB デバイスは記述から除外されます。 153 | - **mirakc** 154 | - **config.yml** 155 | - mirakc の設定ファイルです。 156 | - `channels` セクションには、地上波・BS・CS すべてのチャンネル設定データが含まれます。 157 | - **`channel` プロパティに記述されている物理チャンネル名は、[recisdb](https://github.com/kazuki0824/recisdb-rs) が受け入れる物理チャンネル指定フォーマット (T13 ~ T62 / BS01_0 ~ BS23_3 / CS02 ~ CS24) に対応しています。** 158 | - **recpt1 の物理チャンネル指定フォーマットとは互換性がありません。** 159 | - recpt1 をチューナーコマンドとして使用している場合は、代わりに config_recpt1.yml を利用してください。 160 | - `tuners` セクションには、ISDBScanner で自動検出された、PC に接続されているすべてのチューナーの情報が含まれます。 161 | - **`command` プロパティに記述されているチューナーコマンドには、[recisdb](https://github.com/kazuki0824/recisdb-rs) の `tune` サブコマンドが設定されています。** 162 | - recpt1 をチューナーコマンドとして使用している場合は、代わりに config_recpt1.yml を利用してください。 163 | - **config_recpt1.yml** 164 | - mirakc の設定ファイルです。 165 | - `channels` セクションには、地上波・BS・CS すべてのチャンネル設定データが含まれます。 166 | - **`channel` プロパティに記述されている物理チャンネル名は、[recpt1](https://github.com/stz2012/recpt1) が受け入れる物理チャンネル指定フォーマット (13 ~ 62 / BS01_0 ~ BS23_3 / CS2 ~ CS24) に対応しています。** 167 | - **recisdb の物理チャンネル指定フォーマットとは互換性がありません。** 168 | - recisdb をチューナーコマンドとして使用している場合は、代わりに config.yml を利用してください。 169 | - `tuners` セクションには、ISDBScanner で自動検出された、PC に接続されているすべてのチューナーの情報が含まれます。 170 | - **`command` プロパティに記述されているチューナーコマンドには、[recpt1](https://github.com/stz2012/recpt1) コマンドが設定されています。** 171 | - recisdb をチューナーコマンドとして使用している場合は、代わりに config.yml を利用してください。 172 | - recpt1 に加え、`decode-filter` として arib-b25-stream-test コマンドが導入されていることを前提としています。 173 | - recisdb と異なり recpt1 は DVB 版ドライバに対応していないため、DVB デバイスは記述から除外されます。 174 | 175 | ## インストール 176 | 177 | ISDBScanner は、チューナー受信コマンドとして [recisdb](https://github.com/kazuki0824/recisdb-rs) を利用しています。 178 | そのため、事前に recisdb のインストールが必要です。 179 | 180 | > [!NOTE] 181 | > **[recisdb](https://github.com/kazuki0824/recisdb-rs) は、旧来から chardev 版ドライバ用チューナー受信コマンドとして利用されてきた [recpt1](https://github.com/stz2012/recpt1) と、標準入出力経由で B25 デコードを行う [arib-b25-stream-test](https://www.npmjs.com/package/arib-b25-stream-test) / [b25 (libaribb25 同梱)](https://github.com/tsukumijima/libaribb25) のモダンな代替として開発された、次世代の Rust 製チューナー受信コマンドです。** 182 | > 183 | > チューナーからの放送波の受信と B25 デコード、さらに信号レベルの確認 (checksignal) をすべて recisdb ひとつで行えます。 184 | > さらに recpt1 と異なり BS の物理チャンネルがハードコードされていないため、**将来 BS 帯域再編 (トランスポンダ/スロット移動) が行われた際も、recisdb を更新することなく ISDBScanner でのチャンネルスキャンと各設定ファイルの更新だけで対応できます。** 185 | 186 | 以下の手順で、recisdb をインストールしてください。 187 | 下記は recisdb v1.2.3 時点でのインストール手順です。 188 | 189 | ```bash 190 | # Deb パッケージは Ubuntu 20.04 LTS / Debian 11 以降に対応 191 | 192 | # x86_64 環境 193 | wget https://github.com/kazuki0824/recisdb-rs/releases/download/1.2.3/recisdb_1.2.3-1_amd64.deb 194 | sudo apt install ./recisdb_1.2.3-1_amd64.deb 195 | rm ./recisdb_1.2.3-1_amd64.deb 196 | 197 | # arm64 環境 198 | wget https://github.com/kazuki0824/recisdb-rs/releases/download/1.2.3/recisdb_1.2.3-1_arm64.deb 199 | sudo apt install ./recisdb_1.2.3-1_arm64.deb 200 | rm ./recisdb_1.2.3-1_arm64.deb 201 | ``` 202 | > [!NOTE] 203 | > アンインストールは `sudo apt remove recisdb` で行えます。 204 | 205 | ISDBScanner 自体は Python スクリプトですが、Python 3.11 がインストールされていない環境でも動かせるよう、PyInstaller でシングルバイナリ化した実行ファイルを公開しています。 206 | 下記は ISDBScanner v1.3.2 時点でのインストール手順です。 207 | 208 | ```bash 209 | # x86_64 環境 210 | sudo wget https://github.com/tsukumijima/ISDBScanner/releases/download/v1.3.2/isdb-scanner -O /usr/local/bin/isdb-scanner 211 | sudo chmod +x /usr/local/bin/isdb-scanner 212 | 213 | # arm64 環境 214 | sudo wget https://github.com/tsukumijima/ISDBScanner/releases/download/v1.3.2/isdb-scanner-arm -O /usr/local/bin/isdb-scanner 215 | sudo chmod +x /usr/local/bin/isdb-scanner 216 | ``` 217 | 218 | ## 使い方 219 | 220 | ![Screenshot](https://github.com/user-attachments/assets/ff759d35-b902-47f6-aeb4-18e5949bee30) 221 | 222 | ISDBScanner は、引数で指定されたディレクトリ (デフォルト: `./scanned/`) 以下に複数のファイルを出力します。 223 | 出力される各ファイルのフォーマットは [対応出力フォーマット](#対応出力フォーマット) を参照してください。 224 | 225 | > [!IMPORTANT] 226 | > **重要: ISDBScanner v1.3.0 以降を recisdb + px4_drv 環境で使う場合は、[tsukumijima/px4_drv](https://github.com/tsukumijima/px4_drv) かつ v0.4.0 以降に更新する必要があります。** 227 | > ISDBScanner v1.3.0 からは、px4_drv の chardev 版デバイスと全ての DVB 版デバイスで、**BS チャンネルを物理チャンネル番号(スロット番号・相対 TS 番号)ではなく、TSID (Transport Stream ID) で選局するように変更されました。** 228 | > TSID で選局することで、2025年2月末の帯域再編のように放送局側が通知なしに相対 TS 番号を変更した場合でも、受信できなくなる問題を防げます。 229 | > ただし、オリジナルの [nns779/px4_drv](https://github.com/nns779/px4_drv) は TSID での選局に対応していないため、**ISDBScanner が生成した設定ファイルを使うには、px4_drv をフォーク版である [tsukumijima/px4_drv](https://github.com/tsukumijima/px4_drv) に更新する必要があります。** 230 | > なお、PT1/PT2/PT3 の chardev 版ドライバは TSID での選局に対応していないため、これらのチューナー向けには TSID 選局の設定は生成されません。 231 | > また、[stz2012/recpt1](https://github.com/stz2012/recpt1) は TSID での選局に対応していないため、recpt1 向けの設定ファイルでは TSID での選局は行いません。 232 | 233 | > [!TIP] 234 | > **ISDBScanner v1.2.0 以降では、`--lnb` オプションを指定すると、衛星放送受信時にチューナーからアンテナに給電できます(動作未確認)。** 235 | > `--lnb 11v` と `--lnb 15v` の両方を指定できますが、px4_drv 対応チューナーには `--lnb 15v` のみ指定できます。 236 | > 明示的に LNB 給電を無効化するには、`--lnb low` を指定します。何も指定されなかったときは LNB 給電を行いません。 237 | 238 | ### PC に接続されている利用可能なチューナーのリストを表示 239 | 240 | ![Screenshot](https://github.com/tsukumijima/ISDBScanner/assets/39271166/99a9fcd4-0afb-4c42-914a-d284fb3cf057) 241 | 242 | `isdb-scanner --list-tuners` と実行すると、PC に接続されている、利用可能なチューナーのリストが表示されます。 243 | チューナーが現在使用中の場合、チューナー情報の横に `(Busy)` と表示されます。 244 | 245 | PC に接続したはずのチューナーが認識されていない場合は、チューナードライバのインストール・ロード状態や、チューナーとの物理的な接続状況を確認してみてください。 246 | 247 | > [!NOTE] 248 | > チューナーは chardev 版デバイスが先に認識され、DVB 版デバイスは後に認識されます。 249 | > chardev 版デバイスと DVB 版デバイスが同時に接続されている場合、chardev 版デバイスの方を優先してチャンネルスキャンに使用します。 250 | 251 | ### チャンネルスキャンを実行 252 | 253 | 地上波・BS・CS すべてのチャンネルをスキャンする際は、`isdb-scanner` と実行してください。 254 | 出力先ディレクトリを指定しない場合は `./scanned/` に出力されます。 255 | 256 | 地上波と BS の無料放送のみをスキャン結果に含めたい場合は、`isdb-scanner --exclude-pay-tv` と実行してください。 257 | 258 | 259 | 260 |

261 | 262 | **チャンネルスキャン中は、検出されたトランスポートストリーム / チャンネル (サービス) のリストとスキャンの進捗状況が、リアルタイムでグラフィカルに表示されます。** 263 | チャンネルスキャンに使おうとしたチューナーが現在使用中の際は、自動的に空いているチューナーを選択してスキャンを行います。 264 | もし地上波で特定のチャンネルが受信できていない場合は、停波中でないかや受信状態などを確認してみてください。 265 | 266 | なお、チャンネルスキャンには地デジ・BS・CS のフルスキャンを行う場合で 6 分程度、地デジ・BS の無料放送のみをスキャンする場合で 5 分半程度かかります。 267 | コマンドを実行した後は終わるまで放置しておくのがおすすめです。 268 | 269 | > [!IMPORTANT] 270 | > **地上波で複数の中継局の電波を受信できる地域にお住まいの場合、同一のチャンネルが重複して検出されることがあります。** 271 | > この場合、ISDBScanner は同一のチャンネルを放送している各物理チャンネルごとに信号レベルを計測し、最も受信状態の良い物理チャンネルのみを選択します。動作確認はできていないけどおそらく動くはず…? 272 | 273 | > [!NOTE] 274 | > 出力される Mirakurun / mirakc のチューナー設定ファイルには、現在 PC に接続中のチューナーのみが記載されます。 275 | > 接続しているはずのチューナーが記載されない (ISDBScanner で認識されていない) 場合は、カーネルドライバのロード状態や、物理的なチューナーの接続状態を確認してみてください。 276 | 277 | ## 注意事項 278 | 279 | - **すでに Mirakurun / mirakc を導入している環境でチャンネルスキャンを行う際は、できるだけ Mirakurun / mirakc を停止してから行ってください。** 280 | - ISDBScanner は Mirakurun / mirakc を経由せず、recisdb を通してダイレクトにチューナーデバイスにアクセスします。 281 | **チャンネルスキャンと Mirakurun / mirakc による EPG 更新や録画のタイミングが重なると、チューナー数次第ではチューナーが不足してスキャンに失敗する可能性があります。** 282 | - 録画中でないことを確認の上一旦 Mirakurun / mirakc サービスを停止し、ほかのソフトにチューナーを横取りされない状況でスキャンすることをおすすめします。 283 | スキャン完了後は停止した Mirakurun / mirakc サービスの再開を忘れずに。 284 | - **EDCB-Wine のチャンネル設定ファイルを実稼働環境に反映する場合は、EDCB-Wine で利用している Mirakurun / mirakc のチャンネル設定ファイルも、必ず同時に更新してください。** 285 | - BonDriver は物理チャンネル自体の数値ではなく基本 0 からの連番となる「通し番号」でチャンネル切り替えを行う仕様になっていて、ChSet4.txt にはこの通し番号が記載されています。 286 | - EDCB-Wine で利用している BonDriver_mirakc の場合、Mirakurun / mirakc 側で登録した物理チャンネルの配列インデックスがそのまま「通し番号」になります。 287 | - つまり、**Mirakurun / mirakc のチャンネル設定ファイルを変更して登録中の物理チャンネルを増減させると、この BonDriver の「通し番号」がズレてしまい、再度 ChSet4.txt を生成し直さない限り正しくチャンネル切り替えが行えない状態に陥ります。** 288 | - 実際私はこれが原因で録画に失敗したことがあります…。 289 | - こうした事態を避けるため、**EDCB-Wine と Mirakurun / mirakc のチャンネル設定ファイルは、片方だけを更新するのではなく、常に両方を同時に更新するようにしてください。** 290 | - **深夜にチャンネルスキャンを行うと、停波中のチャンネルがスキャン結果から漏れてしまいます。** 291 | - 特に NHK Eテレは毎日深夜に放送を休止しているため、深夜にスキャンを行うとスキャン結果から漏れてしまいます。 292 | - できるだけ (停波中のチャンネルがない) 日中時間帯でのチャンネルスキャンをおすすめします。 293 | 294 | ## License 295 | 296 | [MIT License](License.txt) 297 | -------------------------------------------------------------------------------- /isdb_scanner/__main__.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import sys 3 | import time 4 | from pathlib import Path 5 | 6 | import typer 7 | from rich import print 8 | from rich.progress import BarColumn, Progress, TaskProgressColumn, TextColumn, TimeRemainingColumn 9 | from rich.rule import Rule 10 | from rich.style import Style 11 | 12 | from isdb_scanner import __version__ 13 | from isdb_scanner.analyzer import TransportStreamAnalyzeError, TransportStreamAnalyzer 14 | from isdb_scanner.constants import LNBVoltage, TransportStreamInfo 15 | from isdb_scanner.formatter import ( 16 | EDCBChSet4TxtFormatter, 17 | EDCBChSet5TxtFormatter, 18 | JSONFormatter, 19 | MirakcConfigYmlFormatter, 20 | MirakurunChannelsYmlFormatter, 21 | MirakurunTunersYmlFormatter, 22 | ) 23 | from isdb_scanner.tuner import ISDBTuner, TunerOpeningError, TunerOutputError, TunerTuningError 24 | 25 | 26 | def version(value: bool): 27 | if value is True: 28 | typer.echo(f'ISDBScanner version {__version__}') 29 | raise typer.Exit() 30 | 31 | 32 | app = typer.Typer() 33 | 34 | 35 | @app.command( 36 | help='ISDBScanner: Scans Japanese TV broadcast channels (ISDB-T/ISDB-S) and outputs results in various formats (depends on recisdb)' 37 | ) 38 | def main( 39 | output: Path = typer.Argument(Path('scanned/'), help='Output scan results to the specified directory.'), 40 | exclude_pay_tv: bool = typer.Option( 41 | False, 42 | help='Exclude pay-TV channels from scan results and include only free-to-air terrestrial and BS channels.', 43 | ), 44 | output_recisdb_log: bool = typer.Option(False, help='Output recisdb log to stderr.'), 45 | list_tuners: bool = typer.Option(False, help='List available ISDB-T/ISDB-S tuners and exit.'), 46 | lnb: LNBVoltage = typer.Option(LNBVoltage.LOW, help='LNB voltage for satellite antenna power supply.'), 47 | version: bool = typer.Option(None, '--version', callback=version, is_eager=True, help='Show version information.'), 48 | ): 49 | print( 50 | Rule( 51 | title=f'ISDBScanner version {__version__}', 52 | characters='=', 53 | style=Style(color='#E33157'), 54 | align='center', 55 | ) 56 | ) 57 | 58 | # recisdb の実行ファイルがインストールされているか確認 59 | if subprocess.run(['/bin/bash', '-c', 'type recisdb'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode != 0: 60 | print('[red]recisdb not found.[/red]') 61 | print('[red]Please install recisdb and try again.[/red]') 62 | print(Rule(characters='=', style=Style(color='#E33157'))) 63 | return 64 | 65 | # 実行環境が Linux か確認 66 | if sys.platform != 'linux': 67 | print('[red]ISDBScanner only supports Linux.[/red]') 68 | print('[red]Please run this tool on Linux.[/red]') 69 | print(Rule(characters='=', style=Style(color='#E33157'))) 70 | return 71 | 72 | # --list-tuners が指定されている場合、利用可能な ISDB-T/ISDB-S チューナーを表示して終了 73 | if list_tuners is True: 74 | print('[bright_blue]Available ISDB-T tuners:[/bright_blue]') 75 | for tuner in ISDBTuner.getAvailableISDBTOnlyTuners(): 76 | print(f' [{tuner.device_type}] [green]{tuner.name}[/green] ({tuner.device_path}) {"(Busy)" if tuner.isBusy() else ""}') 77 | print(Rule(characters='=', style=Style(color='#E33157'))) 78 | print('[bright_blue]Available ISDB-S tuners:[/bright_blue]') 79 | for tuner in ISDBTuner.getAvailableISDBSOnlyTuners(): 80 | print(f' [{tuner.device_type}] [green]{tuner.name}[/green] ({tuner.device_path}) {"(Busy)" if tuner.isBusy() else ""}') 81 | print(Rule(characters='=', style=Style(color='#E33157'))) 82 | print('[bright_blue]Available ISDB-T/ISDB-S multi tuners:[/bright_blue]') 83 | for tuner in ISDBTuner.getAvailableMultiTuners(): 84 | print(f' [{tuner.device_type}] [green]{tuner.name}[/green] ({tuner.device_path}) {"(Busy)" if tuner.isBusy() else ""}') 85 | print(Rule(characters='=', style=Style(color='#E33157'))) 86 | return 87 | 88 | scan_start_time = time.time() 89 | 90 | # トータルでスキャンする必要がある物理チャンネル数 91 | ## 13ch - 62ch + BS01/TS0 (BS) + ND02 (CS1) + ND04 (CS2) 92 | ## 地上波はフルスキャン、衛星放送はそれぞれのネットワークごとの最初の物理チャンネルのみをスキャン 93 | ## 衛星放送では同一ネットワーク内の異なるチャンネルの情報を一括で取得できるため、スキャンは 3 回のみで済む 94 | ## BS のデフォルト TS は運用規定で 0x40F1 (NHKBS1: BS15/TS0) だが、手元環境ではなぜか他 TS と比べ NIT の送出間隔が不安定 (?) で 95 | ## 20 秒程度録画しないと NIT を確実に取得できないため、ここでは BS01/TS0 (BS朝日) をスキャンする 96 | scan_terrestrial_physical_channels = [TransportStreamInfo(physical_channel=f'T{i}') for i in range(13, 63)] 97 | scan_satellite_physical_channels = [ 98 | TransportStreamInfo(physical_channel='BS01/TS0'), 99 | ] 100 | if exclude_pay_tv is False: # 有料放送を除外しない場合は CS1/CS2 もスキャン 101 | scan_satellite_physical_channels += [ 102 | TransportStreamInfo(physical_channel='ND02'), 103 | TransportStreamInfo(physical_channel='ND04'), 104 | ] 105 | total_channel_count = len(scan_terrestrial_physical_channels) + len(scan_satellite_physical_channels) 106 | 107 | # スキャンし終えたチャンネル数 (受信できたかは問わない) 108 | scanned_channel_count = -1 # 初期値は -1 で、地上波のチャンネルスキャンが始まる前に 0 になる 109 | 110 | # プログレスバーを開始 111 | progress = Progress( 112 | TextColumn('[progress.description]{task.description}'), 113 | BarColumn(bar_width=9999), 114 | TaskProgressColumn(), 115 | TimeRemainingColumn(), 116 | transient=True, 117 | ) 118 | task = progress.add_task('[bright_red]Scanning...', total=total_channel_count) 119 | with progress: 120 | # ***** 地上波のチャンネルスキャン ***** 121 | 122 | print('Scanning ISDB-T (Terrestrial) channels...') 123 | 124 | # チューナーを取得 125 | print(Rule(characters='-', style=Style(color='#E33157'))) 126 | isdbt_tuners = ISDBTuner.getAvailableISDBTTuners(lnb=lnb, output_recisdb_log=output_recisdb_log) 127 | if len(isdbt_tuners) == 0: 128 | print('[red]No ISDB-T tuner found.[/red]') 129 | print('[red]Please connect an ISDB-T tuner and try again.[/red]') 130 | # チューナーがないため ISDB-T のスキャン処理は実行されない (for ループがスキップされる) 131 | # プログレスバーはスキャンする予定だった地上波チャンネル分だけ進める 132 | progress.update(task, completed=len(scan_terrestrial_physical_channels)) 133 | for isdbt_tuner in isdbt_tuners: 134 | print(f'Found Tuner: [green]{isdbt_tuner.name}[/green] ({isdbt_tuner.device_path})') 135 | 136 | # 地上波のチャンネルスキャンを実行 (13ch - 62ch) 137 | ## 地上波のうち 53ch - 62ch はすでに廃止されているが、依然一部ケーブルテレビのコミュニティチャンネル (自主放送) で利用されている 138 | tr_ts_infos: list[TransportStreamInfo] = [] 139 | for channel in scan_terrestrial_physical_channels: 140 | scanned_channel_count += 1 141 | progress.update(task, completed=scanned_channel_count) 142 | try: 143 | for tuner in isdbt_tuners: 144 | # 前回チューナーオープンに失敗したチューナーはスキップ 145 | if tuner.last_tuner_opening_failed is True: 146 | continue 147 | # チューナーの起動と TS 解析を実行 148 | print(Rule(characters='-', style=Style(color='#E33157'))) 149 | print(f' Channel: [bright_blue]Terrestrial - {channel.physical_channel.replace("T", "")}ch[/bright_blue]') 150 | print(f' Tuner: [green]{tuner.name}[/green] ({tuner.device_path})') 151 | try: 152 | # 録画時間: 2.25 秒 (地上波の SI 送出間隔は最大 2 秒周期) 153 | start_time = time.time() 154 | try: 155 | ts_stream_data = tuner.tune(channel.physical_channel_recisdb, recording_time=2.25) 156 | finally: 157 | print(f'Tune Time: {time.time() - start_time:.2f} seconds') 158 | # トランスポートストリームとサービスの情報を解析 159 | ts_infos = TransportStreamAnalyzer(ts_stream_data, channel.physical_channel).analyze() 160 | tr_ts_infos.extend(ts_infos) 161 | for ts_info in ts_infos: 162 | print(f'[green]Transport Stream[/green]: {ts_info}') 163 | for service_info in ts_info.services: 164 | print(f'[green] Service[/green]: {service_info}') 165 | break 166 | except TunerOpeningError as ex: 167 | print(f'[red]Failed to open tuner. {ex}[/red]') 168 | print('[red]Trying again with the next tuner...[/red]') 169 | continue 170 | except TransportStreamAnalyzeError as ex: 171 | print(f'[red]Failed to analyze transport stream. {ex}[/red]') 172 | print('[red]Trying again with the next tuner...[/red]') 173 | continue 174 | except TunerTuningError as ex: 175 | print(f'[yellow]{ex}[/yellow]') 176 | print('[yellow]Channel may not be received in your area. Skipping...[/yellow]') 177 | continue 178 | except TunerOutputError: 179 | print('[yellow]Failed to receive data.[/yellow]') 180 | print('[yellow]Channel may not be received in your area. Skipping...[/yellow]') 181 | continue 182 | 183 | # 地上波で同一チャンネルが重複して検出された場合の処理 184 | ## 居住地域によっては、複数の中継所の電波が受信できるなどの理由で、同一チャンネルが複数の物理チャンネルで受信できる場合がある 185 | ## 同一チャンネルが複数の物理チャンネルから受信できると誤動作の要因になるため、TSID が一致する物理チャンネルを集計し、 186 | ## 次にどの物理チャンネルが一番信号レベルが高いかを判定して、その物理チャンネルのみを残す 187 | ## (地上波の TSID は放送局ごとに全国で一意であるため、TSID が一致する物理チャンネルは同一チャンネルであることが保証される) 188 | 189 | # 同一 TSID を持つ物理チャンネルをグループ化 190 | tsid_grouped_physical_channels: dict[int, list[TransportStreamInfo]] = {} 191 | for ts_info in tr_ts_infos: 192 | if ts_info.transport_stream_id not in tsid_grouped_physical_channels: 193 | tsid_grouped_physical_channels[ts_info.transport_stream_id] = [] 194 | tsid_grouped_physical_channels[ts_info.transport_stream_id].append(ts_info) 195 | 196 | # 同一 TSID を持つ物理チャンネルのうち、信号レベルが最も高い物理チャンネルのみを残す 197 | for ts_infos in tsid_grouped_physical_channels.values(): 198 | # 同一 TSID を持つ物理チャンネルが1つだけ (正常) の場合は何もしない 199 | if len(ts_infos) == 1: 200 | continue 201 | 202 | print(Rule(characters='-', style=Style(color='#E33157'))) 203 | print( 204 | f'[yellow]{ts_infos[0].network_name} (TSID: {ts_infos[0].transport_stream_id}) ' 205 | 'was detected redundantly across multiple physical channels.[/yellow]' 206 | ) 207 | print('[yellow]Outputs only the physical channel with the highest signal level...[/yellow]') 208 | 209 | # それぞれの物理チャンネルの信号レベルを計測 210 | signal_levels: dict[str, float] = {} 211 | for ts_info in ts_infos: 212 | signal_levels[ts_info.physical_channel] = -99.99 # デフォルト値 (信号レベルを計測できなかった場合用) 213 | for tuner in isdbt_tuners: 214 | # 前回チューナーオープンに失敗したチューナーはスキップ 215 | if tuner.last_tuner_opening_failed is True: 216 | continue 217 | # チューナーの起動と平均信号レベル取得を実行 218 | ## チューナーの起動失敗などで平均信号レベルが取得できなかった場合は None が返されるので、次のチューナーで試す 219 | result = tuner.getSignalLevelMean(ts_info.physical_channel) 220 | if result is None: 221 | continue 222 | signal_levels[ts_info.physical_channel] = result 223 | print(f'Physical Channel: {ts_info.physical_channel.replace("T", "")}ch | Signal Level: {result:.2f} dB') 224 | break # 信号レベルが取得できたら次の物理チャンネルへ 225 | if signal_levels[ts_info.physical_channel] == -99.99: 226 | print(f'Physical Channel: {ts_info.physical_channel.replace("T", "")}ch | Signal Level: Failed to get signal level') 227 | 228 | # 信号レベルが最も高い物理チャンネル以外の物理チャンネルを terrestrial_ts_infos から削除 229 | max_signal_level = max(signal_levels.values()) 230 | for physical_channel, signal_level in signal_levels.items(): 231 | ts_info = next(ts_info for ts_info in ts_infos if ts_info.physical_channel == physical_channel) 232 | if signal_level != max_signal_level: 233 | tr_ts_infos.remove(ts_info) 234 | else: 235 | print( 236 | f'[green]Selected Physical Channel: {ts_info.physical_channel.replace("T", "")}ch | ' 237 | f'Signal Level: {signal_level:.2f} dB[/green]' 238 | ) 239 | 240 | # 物理チャンネル順にソート 241 | tr_ts_infos = sorted(tr_ts_infos, key=lambda x: x.physical_channel) 242 | 243 | # ***** BS・CS110 のチャンネルスキャン ***** 244 | 245 | print(Rule(characters='=', style=Style(color='#E33157'))) 246 | print('Scanning ISDB-S (Satellite) channels...') 247 | 248 | # チューナーを取得 249 | print(Rule(characters='-', style=Style(color='#E33157'))) 250 | isdbs_tuners = ISDBTuner.getAvailableISDBSTuners(lnb=lnb, output_recisdb_log=output_recisdb_log) 251 | if len(isdbs_tuners) == 0: 252 | print('[red]No ISDB-S tuner found.[/red]') 253 | print('[red]Please connect an ISDB-S tuner and try again.[/red]') 254 | # チューナーがないため ISDB-S のスキャン処理は実行されない (for ループがスキップされる) 255 | # プログレスバーはスキャンする予定だった BS・CS110 チャンネル分だけ進める 256 | progress.update(task, completed=len(scan_terrestrial_physical_channels) + len(scan_satellite_physical_channels)) 257 | for isdbs_tuner in isdbs_tuners: 258 | print(f'Found Tuner: [green]{isdbs_tuner.name}[/green] ({isdbs_tuner.device_path})') 259 | 260 | # BS・CS1・CS2 のチャンネルスキャンを実行 261 | bs_ts_infos: list[TransportStreamInfo] = [] 262 | cs_ts_infos: list[TransportStreamInfo] = [] 263 | for channel in scan_satellite_physical_channels: 264 | scanned_channel_count += 1 265 | progress.update(task, completed=scanned_channel_count) 266 | for tuner in isdbs_tuners: 267 | # 前回チューナーオープンに失敗したチューナーはスキップ 268 | if tuner.last_tuner_opening_failed is True: 269 | continue 270 | # チューナーの起動と TS 解析を実行 271 | print(Rule(characters='-', style=Style(color='#E33157'))) 272 | print(f' Channel: [bright_blue]{channel.broadcast_type} (All channels)[/bright_blue]') 273 | print(f' Tuner: [green]{tuner.name}[/green] ({tuner.device_path})') 274 | try: 275 | # 録画時間: 11 秒 (BS・CS110 の SI 送出間隔は最大 10 秒周期) 276 | start_time = time.time() 277 | try: 278 | ts_stream_data = tuner.tune(channel.physical_channel_recisdb, recording_time=11) 279 | finally: 280 | print(f'Tune Time: {time.time() - start_time:.2f} seconds') 281 | # トランスポートストリームとサービスの情報を解析 282 | ts_infos = TransportStreamAnalyzer(ts_stream_data, channel.physical_channel).analyze() 283 | if channel.broadcast_type == 'BS': 284 | bs_ts_infos.extend(ts_infos) 285 | elif channel.broadcast_type == 'CS1' or channel.broadcast_type == 'CS2': 286 | cs_ts_infos.extend(ts_infos) 287 | for ts_info in ts_infos: 288 | print(f'[green]Transport Stream[/green]: {ts_info}') 289 | for service_info in ts_info.services: 290 | print(f'[green] Service[/green]: {service_info}') 291 | break 292 | except TunerOpeningError as ex: 293 | print(f'[red]Failed to open tuner. {ex}[/red]') 294 | print('[red]Trying again with the next tuner...[/red]') 295 | continue 296 | except TunerTuningError as ex: 297 | print(f'[red]{ex}[/red]') 298 | print('[red]Trying again with the next tuner...[/red]') 299 | continue 300 | except TunerOutputError: 301 | print('[red]Failed to receive data.[/red]') 302 | print('[red]Trying again with the next tuner...[/red]') 303 | continue 304 | except TransportStreamAnalyzeError as ex: 305 | print(f'[red]Failed to analyze transport stream. {ex}[/red]') 306 | print('[red]Trying again with the next tuner...[/red]') 307 | continue 308 | 309 | # 物理チャンネル順にソート 310 | bs_ts_infos = sorted(bs_ts_infos, key=lambda x: x.physical_channel) 311 | cs_ts_infos = sorted(cs_ts_infos, key=lambda x: x.physical_channel) 312 | 313 | progress.update(task, completed=total_channel_count) 314 | 315 | # 出力先ディレクトリがなければ作成 316 | # 事前に絶対パスに変換しておく 317 | output = output.resolve() 318 | output.mkdir(parents=True, exist_ok=True) 319 | (output / 'EDCB-Wine').mkdir(parents=True, exist_ok=True) 320 | (output / 'Mirakurun').mkdir(parents=True, exist_ok=True) 321 | (output / 'mirakc').mkdir(parents=True, exist_ok=True) 322 | 323 | # ISDB-T 専用チューナー・ISDB-S 専用チューナー・ISDB-T/ISDB-S 共用チューナーを取得 324 | available_isdbt_tuners = ISDBTuner.getAvailableISDBTOnlyTuners() 325 | available_isdbs_tuners = ISDBTuner.getAvailableISDBSOnlyTuners() 326 | available_multi_tuners = ISDBTuner.getAvailableMultiTuners() 327 | 328 | # チャンネルスキャン結果 (&一部のフォーマットでは利用可能なチューナー情報も) を様々なフォーマットで保存 329 | ## JSON のみ常に取得した全チャンネルを出力 330 | JSONFormatter( 331 | output / 'Channels.json', 332 | tr_ts_infos, 333 | bs_ts_infos, 334 | cs_ts_infos, 335 | exclude_pay_tv=False, 336 | ).save() 337 | EDCBChSet4TxtFormatter( 338 | output / 'EDCB-Wine/BonDriver_mirakc(BonDriver_mirakc).ChSet4.txt', 339 | tr_ts_infos, 340 | bs_ts_infos, 341 | cs_ts_infos, 342 | exclude_pay_tv, 343 | ).save() 344 | EDCBChSet4TxtFormatter( 345 | output / 'EDCB-Wine/BonDriver_mirakc_T(BonDriver_mirakc).ChSet4.txt', 346 | tr_ts_infos, 347 | [], 348 | [], 349 | exclude_pay_tv, 350 | ).save() 351 | EDCBChSet4TxtFormatter( 352 | output / 'EDCB-Wine/BonDriver_mirakc_S(BonDriver_mirakc).ChSet4.txt', 353 | [], 354 | bs_ts_infos, 355 | cs_ts_infos, 356 | exclude_pay_tv, 357 | ).save() 358 | EDCBChSet5TxtFormatter( 359 | output / 'EDCB-Wine/ChSet5.txt', 360 | tr_ts_infos, 361 | bs_ts_infos, 362 | cs_ts_infos, 363 | exclude_pay_tv, 364 | ).save() 365 | MirakurunChannelsYmlFormatter( 366 | output / 'Mirakurun/channels.yml', 367 | tr_ts_infos, 368 | bs_ts_infos, 369 | cs_ts_infos, 370 | exclude_pay_tv, 371 | ).save() 372 | MirakurunChannelsYmlFormatter( 373 | output / 'Mirakurun/channels_recpt1.yml', 374 | tr_ts_infos, 375 | bs_ts_infos, 376 | cs_ts_infos, 377 | exclude_pay_tv, 378 | recpt1_compatible=True, 379 | ).save() 380 | MirakurunTunersYmlFormatter( 381 | output / 'Mirakurun/tuners.yml', available_isdbt_tuners, available_isdbs_tuners, available_multi_tuners 382 | ).save() 383 | MirakurunTunersYmlFormatter( 384 | output / 'Mirakurun/tuners_recpt1.yml', 385 | available_isdbt_tuners, 386 | available_isdbs_tuners, 387 | available_multi_tuners, 388 | recpt1_compatible=True, 389 | ).save() 390 | MirakcConfigYmlFormatter( 391 | output / 'mirakc/config.yml', 392 | available_isdbt_tuners, 393 | available_isdbs_tuners, 394 | available_multi_tuners, 395 | tr_ts_infos, 396 | bs_ts_infos, 397 | cs_ts_infos, 398 | exclude_pay_tv, 399 | ).save() 400 | MirakcConfigYmlFormatter( 401 | output / 'mirakc/config_recpt1.yml', 402 | available_isdbt_tuners, 403 | available_isdbs_tuners, 404 | available_multi_tuners, 405 | tr_ts_infos, 406 | bs_ts_infos, 407 | cs_ts_infos, 408 | exclude_pay_tv, 409 | recpt1_compatible=True, 410 | ).save() 411 | 412 | print(Rule(characters='=', style=Style(color='#E33157'))) 413 | print(f'Finished in {time.time() - scan_start_time:.2f} seconds.') 414 | print(Rule(characters='=', style=Style(color='#E33157'))) 415 | 416 | 417 | if __name__ == '__main__': 418 | app() 419 | -------------------------------------------------------------------------------- /isdb_scanner/formatter.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: UP013 2 | 3 | import copy 4 | import csv 5 | import json 6 | from io import StringIO 7 | from pathlib import Path 8 | from typing import Any, NotRequired, cast 9 | 10 | from ruamel.yaml import YAML 11 | from typing_extensions import TypedDict 12 | 13 | from isdb_scanner.constants import TransportStreamInfo, TransportStreamInfoList 14 | from isdb_scanner.tuner import ISDBTuner 15 | 16 | 17 | class BaseFormatter: 18 | """ 19 | フォーマッターの基底クラス 20 | """ 21 | 22 | def __init__( 23 | self, 24 | save_file_path: Path, 25 | terrestrial_ts_infos: list[TransportStreamInfo], 26 | bs_ts_infos: list[TransportStreamInfo], 27 | cs_ts_infos: list[TransportStreamInfo], 28 | exclude_pay_tv: bool = False, 29 | ) -> None: 30 | """ 31 | Args: 32 | save_file_path (Path): 保存先のファイルパス 33 | terrestrial_ts_infos (list[TransportStreamInfo]): スキャン結果の地上波の TS 情報 34 | bs_ts_infos (list[TransportStreamInfo]): スキャン結果の BS の TS 情報 35 | cs_ts_infos (list[TransportStreamInfo]): スキャン結果の CS の TS 情報 36 | exclude_pay_tv (bool): 有料放送 (+ショップチャンネル&QVC) を除外し、地上波と BS 無料放送のみを保存するか 37 | """ 38 | 39 | self._save_file_path = save_file_path 40 | self._terrestrial_ts_infos = terrestrial_ts_infos 41 | self._bs_ts_infos = bs_ts_infos 42 | self._cs_ts_infos = cs_ts_infos 43 | self._exclude_pay_tv = exclude_pay_tv 44 | 45 | # 与えられた TS 情報から有料放送サービスを除外 46 | ## 地上波は実運用上有料放送は存在しないが、念のため除外しておく 47 | if exclude_pay_tv is True: 48 | for terrestrial_ts_info in self._terrestrial_ts_infos: 49 | terrestrial_ts_info.services = [service for service in terrestrial_ts_info.services if service.is_free is True] 50 | for bs_ts_info in self._bs_ts_infos: 51 | bs_ts_info.services = [service for service in bs_ts_info.services if service.is_free is True] 52 | for cs_ts_info in self._cs_ts_infos: 53 | # CS はショップチャンネルと QVC 以外の全サービスが有料放送 (スカパー!) として運用されている上、 54 | # 無料とはいえわざわざ通販チャンネルを見る人がいるとも思えないので全てのサービスを除外する 55 | cs_ts_info.services = [] 56 | 57 | def format(self) -> str: 58 | """ 59 | フォーマットを実行する (実装はサブクラスで行う) 60 | 61 | Returns: 62 | str: フォーマットされた文字列 63 | """ 64 | 65 | raise NotImplementedError 66 | 67 | def save(self) -> str: 68 | """ 69 | フォーマットを実行し、結果をファイルに保存する 70 | 71 | Returns: 72 | str: フォーマットされた文字列 73 | """ 74 | 75 | formatted_str = self.format() 76 | with open(self._save_file_path, mode='w', encoding='utf-8') as f: 77 | f.write(formatted_str) 78 | 79 | return formatted_str 80 | 81 | 82 | class JSONFormatter(BaseFormatter): 83 | """ 84 | スキャン解析結果である TS 情報を JSON データとして保存するフォーマッター 85 | """ 86 | 87 | def format(self) -> str: 88 | """ 89 | JSON データとしてフォーマットする 90 | 91 | Returns: 92 | str: フォーマットされた文字列 93 | """ 94 | 95 | # BS のみ、有料放送を除外する場合で、TS 内のサービスが空 (= TS 内に無料放送サービスが存在しない) ならその TS 自体を削除する 96 | # (有料放送を除外する場合は、この時点ですでに各 TS 情報のサービス情報から有料放送が除外されている) 97 | ## 正確には有料放送の TS に無料独立データ放送が含まれる場合もあるので (WOWOW など) 、それらも除外してから判定する 98 | ## 独立データ放送の service_type は 0xC0 なので、それ以外のサービスが空かどうかで判定する 99 | bs_ts_infos = list( 100 | filter( 101 | ( 102 | lambda ts_info: (self._exclude_pay_tv is False) 103 | or (len([service for service in ts_info.services if service.service_type != 0xC0]) > 0) 104 | ), 105 | copy.deepcopy(self._bs_ts_infos), 106 | ) 107 | ) 108 | 109 | channels_dict = { 110 | 'Terrestrial': TransportStreamInfoList(root=self._terrestrial_ts_infos).model_dump(mode='json'), 111 | 'BS': TransportStreamInfoList(root=bs_ts_infos).model_dump(mode='json'), 112 | 'CS': TransportStreamInfoList(root=self._cs_ts_infos).model_dump(mode='json'), 113 | } 114 | 115 | # 有料放送を除外する場合、CS の TS 情報は空になっているはずなので、それを削除する 116 | if self._exclude_pay_tv is True: 117 | channels_dict.pop('CS') 118 | 119 | formatted_str = json.dumps(channels_dict, indent=4, ensure_ascii=False) 120 | return formatted_str 121 | 122 | 123 | class EDCBChSet4TxtFormatter(BaseFormatter): 124 | """ 125 | スキャン解析結果である TS 情報を EDCB の ChSet4.txt (ファイル名に対応する BonDriver で受信可能なチャンネル設定データ) として保存するフォーマッター 126 | 初期化時は保存対象の ChSet4.txt に対応する BonDriver の種別 (地上波 or 衛星 or マルチチューナー) に合わせた TS 情報のみを引数に渡すこと 127 | このフォーマッターで生成される ChSet4.txt は BonDriver_mirakc / BonDriver_Mirakurun 専用で、 128 | 他 BonDriver では物理チャンネルやチューナー空間の対応を別途変更する必要がある 129 | """ 130 | 131 | def format(self) -> str: 132 | """ 133 | EDCB の ChSet4.txt としてフォーマットする 134 | 135 | Returns: 136 | str: フォーマットされた文字列 137 | """ 138 | 139 | # 各 TS 情報を物理チャンネル昇順でソート 140 | ## この時点で処理対象が地上波チューナーなら BS/CS の TS 情報、衛星チューナーなら地上波の TS 情報として空のリストが渡されているはず 141 | terrestrial_ts_infos = sorted(self._terrestrial_ts_infos, key=lambda x: x.physical_channel) 142 | bs_ts_infos = sorted(self._bs_ts_infos, key=lambda x: x.physical_channel) 143 | cs_ts_infos = sorted(self._cs_ts_infos, key=lambda x: x.physical_channel) 144 | 145 | # 各トランスポートストリームのサービス情報を service_id 昇順でソート 146 | for ts_info in terrestrial_ts_infos: 147 | ts_info.services = sorted(ts_info.services, key=lambda x: x.service_id) 148 | for ts_info in bs_ts_infos: 149 | ts_info.services = sorted(ts_info.services, key=lambda x: x.service_id) 150 | for ts_info in cs_ts_infos: 151 | ts_info.services = sorted(ts_info.services, key=lambda x: x.service_id) 152 | 153 | """ 154 | ChSet4.txt のフォーマット: 155 | ch_name, service_name, network_name, space, ch, network_id, transport_stream_id, service_id, service_type, partial_flag, use_view_flag, remocon_id 156 | ch_name は物理チャンネル名で任意の値でよく、ISDBScanner では物理チャンネル名をそのまま使用する 157 | space (チューナー空間) は BonDriver_mirakc の場合、地上波: 0, BS: 1, CS: 2 158 | ch (物理チャンネルに対応する BonDriver の通し番号) BonDriver_mirakc の場合、地上波・BS・CS それぞれで 0 から通し番号を振る 159 | partial_flag は is_oneseg に対応する (ワンセグ放送の場合は 1 、それ以外は 0) 160 | use_view_flag は service_type が映像サービスの場合は 1 、それ以外は 0 161 | remocon_id は remote_control_key_id に対応する (リモコンキー ID が存在しないチャンネルでは 0) 162 | """ 163 | 164 | # ヘッダーなし TSV (CRLF) に変換 165 | ## 実際のファイル書き込みは save() メソッドで行うため、ここでは StringIO に書き込む 166 | string_io = StringIO() 167 | writer = csv.writer(string_io, delimiter='\t', lineterminator='\r\n') 168 | for ts_infos in [terrestrial_ts_infos, bs_ts_infos, cs_ts_infos]: 169 | ch = 0 # 地上波・BS・CS ごとにチューナー空間が異なるので、通し番号をリセットする 170 | for ts_info in ts_infos: 171 | # 有料放送を除外する場合で、TS 内のサービスが空 (= TS 内に無料放送サービスが存在しない) ならチャンネル自体を登録しない 172 | # (有料放送を除外する場合は、この時点ですでに各 TS 情報のサービス情報から有料放送が除外されている) 173 | ## 正確には有料放送の TS に無料独立データ放送が含まれる場合もあるので (WOWOW など) 、それらも除外してから判定する 174 | ## 独立データ放送の service_type は 0xC0 なので、それ以外のサービスが空かどうかで判定する 175 | if self._exclude_pay_tv is True and len([service for service in ts_info.services if service.service_type != 0xC0]) == 0: 176 | continue 177 | for service in ts_info.services: 178 | ch_name_prefix = '' 179 | space = 0 180 | if ts_info.broadcast_type == 'Terrestrial': 181 | # 地上波 182 | ch_name_prefix = 'Terrestrial' 183 | space = 0 184 | elif ts_info.broadcast_type == 'BS': 185 | # BS 186 | ch_name_prefix = 'BS' 187 | space = 1 188 | elif ts_info.broadcast_type == 'CS1' or ts_info.broadcast_type == 'CS2': 189 | # CS 190 | ch_name_prefix = 'CS' 191 | space = 2 192 | ch_name = f'{ch_name_prefix}:{ts_info.physical_channel}' 193 | partial_flag = 1 if service.is_oneseg else 0 194 | use_view_flag = 1 if service.isVideoServiceType() else 0 195 | remocon_id = ts_info.remote_control_key_id if ts_info.remote_control_key_id is not None else 0 196 | writer.writerow( 197 | [ 198 | ch_name, 199 | service.service_name, 200 | ts_info.network_name, 201 | space, 202 | ch, 203 | ts_info.network_id, 204 | ts_info.transport_stream_id, 205 | service.service_id, 206 | service.service_type, 207 | partial_flag, 208 | use_view_flag, 209 | remocon_id, 210 | ] 211 | ) 212 | ch += 1 # 0 スタートなので処理完了後にインクリメントする 213 | 214 | # StringIO の先頭にシークする 215 | string_io.seek(0) 216 | 217 | # メモリ上に保存した TSV を文字列として取得して返す 218 | ## EDCB は UTF-8 with BOM でないと受け付けないため、先頭に BOM を付与する 219 | return '\ufeff' + string_io.getvalue() 220 | 221 | 222 | class EDCBChSet5TxtFormatter(BaseFormatter): 223 | """ 224 | スキャン解析結果である TS 情報を EDCB の ChSet5.txt (EDCB 全体で受信可能なチャンネル設定データ) として保存するフォーマッター 225 | 各チューナー (BonDriver) に依存する情報は ChSet4.txt の方に書き込まれる 226 | """ 227 | 228 | def format(self) -> str: 229 | """ 230 | EDCB の ChSet5.txt としてフォーマットする 231 | 232 | Returns: 233 | str: フォーマットされた文字列 234 | """ 235 | 236 | # 地上波・BS・CS の TS 情報を結合し、network_id, transport_stream_id それぞれ昇順でソート 237 | ts_infos = self._terrestrial_ts_infos + self._bs_ts_infos + self._cs_ts_infos 238 | ts_infos = sorted(ts_infos, key=lambda x: (x.network_id, x.transport_stream_id)) 239 | 240 | # 各トランスポートストリームのサービス情報を service_id 昇順でソート 241 | for ts_info in ts_infos: 242 | ts_info.services = sorted(ts_info.services, key=lambda x: x.service_id) 243 | 244 | """ 245 | ChSet5.txt のフォーマット: 246 | service_name, network_name, network_id, transport_stream_id, service_id, service_type, partial_flag, epg_cap_flag, search_flag (未使用) 247 | partial_flag は is_oneseg に対応する (ワンセグ放送の場合は 1 、それ以外は 0) 248 | epg_cap_flag と search_flag (定義のみで未使用) は service_type が映像サービスの場合は 1 、それ以外は 0 249 | """ 250 | 251 | # ヘッダーなし TSV (CRLF) に変換 252 | ## 実際のファイル書き込みは save() メソッドで行うため、ここでは StringIO に書き込む 253 | string_io = StringIO() 254 | writer = csv.writer(string_io, delimiter='\t', lineterminator='\r\n') 255 | for ts_info in ts_infos: 256 | # 有料放送を除外する場合で、TS 内のサービスが空 (= TS 内に無料放送サービスが存在しない) ならチャンネル自体を登録しない 257 | # (有料放送を除外する場合は、この時点ですでに各 TS 情報のサービス情報から有料放送が除外されている) 258 | ## 正確には有料放送の TS に無料独立データ放送が含まれる場合もあるので (WOWOW など) 、それらも除外してから判定する 259 | ## 独立データ放送の service_type は 0xC0 なので、それ以外のサービスが空かどうかで判定する 260 | if self._exclude_pay_tv is True and len([service for service in ts_info.services if service.service_type != 0xC0]) == 0: 261 | continue 262 | for service in ts_info.services: 263 | partial_flag = 1 if service.is_oneseg else 0 264 | epg_cap_flag = 1 if service.isVideoServiceType() else 0 265 | search_flag = 1 if service.isVideoServiceType() else 0 266 | writer.writerow( 267 | [ 268 | service.service_name, 269 | ts_info.network_name, 270 | ts_info.network_id, 271 | ts_info.transport_stream_id, 272 | service.service_id, 273 | service.service_type, 274 | partial_flag, 275 | epg_cap_flag, 276 | search_flag, 277 | ] 278 | ) 279 | 280 | # StringIO の先頭にシークする 281 | string_io.seek(0) 282 | 283 | # メモリ上に保存した TSV を文字列として取得して返す 284 | ## EDCB は UTF-8 with BOM でないと受け付けないため、先頭に BOM を付与する 285 | return '\ufeff' + string_io.getvalue() 286 | 287 | 288 | MirakurunChannel = TypedDict( 289 | 'MirakurunChannel', 290 | { 291 | 'name': str, 292 | 'type': str, 293 | 'channel': str, 294 | # 本来は SPHD 向けの受信衛星指定用パラメータだが、mirakc における extra-args の代わりに TSID 指定用のパラメータに転用している 295 | 'satellite': str, 296 | 'isDisabled': NotRequired[bool], 297 | }, 298 | ) 299 | 300 | 301 | class MirakurunChannelsYmlFormatter(BaseFormatter): 302 | """ 303 | スキャン解析結果である TS 情報を Mirakurun のチャンネル設定ファイルとして保存するフォーマッター 304 | """ 305 | 306 | def __init__( 307 | self, 308 | save_file_path: Path, 309 | terrestrial_ts_infos: list[TransportStreamInfo], 310 | bs_ts_infos: list[TransportStreamInfo], 311 | cs_ts_infos: list[TransportStreamInfo], 312 | exclude_pay_tv: bool = False, 313 | recpt1_compatible: bool = False, 314 | ) -> None: 315 | """ 316 | Args: 317 | save_file_path (Path): 保存先のファイルパス 318 | terrestrial_ts_infos (list[TransportStreamInfo]): スキャン結果の地上波の TS 情報 319 | bs_ts_infos (list[TransportStreamInfo]): スキャン結果の BS の TS 情報 320 | cs_ts_infos (list[TransportStreamInfo]): スキャン結果の CS の TS 情報 321 | exclude_pay_tv (bool): 有料放送 (+ショップチャンネル&QVC) を除外し、地上波と BS 無料放送のみを保存するか 322 | recpt1_compatible (bool): recpt1 と互換性のある物理チャンネル指定フォーマットで保存するか 323 | """ 324 | 325 | self._recpt1_compatible = recpt1_compatible 326 | super().__init__(save_file_path, terrestrial_ts_infos, bs_ts_infos, cs_ts_infos, exclude_pay_tv) 327 | 328 | def format(self) -> str: 329 | """ 330 | Mirakurun のチャンネル設定ファイルとしてフォーマットする 331 | 332 | Returns: 333 | str: フォーマットされた文字列 334 | """ 335 | 336 | # 各 TS 情報を物理チャンネル昇順でソートして結合 337 | ## この時点で処理対象が地上波チューナーなら BS/CS の TS 情報、衛星チューナーなら地上波の TS 情報として空のリストが渡されているはず 338 | terrestrial_ts_infos = sorted(self._terrestrial_ts_infos, key=lambda x: x.physical_channel) 339 | bs_ts_infos = sorted(self._bs_ts_infos, key=lambda x: x.physical_channel) 340 | cs_ts_infos = sorted(self._cs_ts_infos, key=lambda x: x.physical_channel) 341 | ts_infos = terrestrial_ts_infos + bs_ts_infos + cs_ts_infos 342 | 343 | # Mirakurun のチャンネル設定ファイル用のデータ構造に変換 344 | mirakurun_channels: list[MirakurunChannel] = [] 345 | for ts_info in ts_infos: 346 | if ts_info.broadcast_type == 'Terrestrial': 347 | mirakurun_name = ts_info.network_name 348 | mirakurun_type = 'GR' 349 | else: 350 | mirakurun_name = ts_info.physical_channel 351 | mirakurun_type = 'BS' if ts_info.broadcast_type == 'BS' else 'CS' 352 | # 有料放送を除外する場合で、TS 内のサービスが空 (= TS 内に無料放送サービスが存在しない) ならチャンネル自体を登録しない 353 | # (有料放送を除外する場合は、この時点ですでに各 TS 情報のサービス情報から有料放送が除外されている) 354 | ## 正確には有料放送の TS に無料独立データ放送が含まれる場合もあるので (WOWOW など) 、それらも除外してから判定する 355 | ## 独立データ放送の service_type は 0xC0 なので、それ以外のサービスが空かどうかで判定する 356 | if self._exclude_pay_tv is True and len([service for service in ts_info.services if service.service_type != 0xC0]) == 0: 357 | continue 358 | if self._recpt1_compatible is True: 359 | # recpt1 互換の物理チャンネル指定フォーマット 360 | mirakurun_channel = ts_info.physical_channel_recpt1 361 | # 追加の引数は常に設定しない 362 | extra_args = '' 363 | else: 364 | # recisdb 互換の物理チャンネル指定フォーマット 365 | mirakurun_channel = ts_info.physical_channel_recisdb 366 | if ts_info.broadcast_type == 'BS': 367 | # BS のみ追加の引数として --tsid (BS チャンネルの TSID) を指定し、当該トランスポンダで送出中の TS を明示的に TSID で選局する 368 | ## 実際に発行されるチューナーコマンドは --channel BS23_2 --tsid 18803 のようになる 369 | ## (--tsid 指定時は物理チャンネル表記に含まれる相対 TS 番号は無視され、常に物理 BS-23ch 内で送出中の TSID が 18803 の TS が選局される) 370 | ## 地上波や CS にはスロットや相対 TS 番号の概念がないため、TSID を指定する必要はない 371 | extra_args = f' --tsid {ts_info.transport_stream_id} ' # 意図的に先頭と末尾に半角スペースを入れている 372 | else: 373 | # Mirakurun のプレースホルダーは単なる文字列置換で実装されているが、"satellite" が空だと条件分岐が成立せず 374 | # チューナーコマンド内の が置換されずに残ってしまうため、意図的に空文字列ではなく半角スペースを入れている 375 | # ref: https://github.com/Chinachu/Mirakurun/blob/3.9.0-rc.4/src/Mirakurun/TunerDevice.ts#L271-L274 376 | extra_args = ' ' 377 | channel: MirakurunChannel = { 378 | 'name': mirakurun_name, 379 | 'type': mirakurun_type, 380 | 'channel': mirakurun_channel, 381 | # 本来は SPHD 向けの受信衛星指定用パラメータだが、mirakc における extra-args の代わりに TSID 指定用のパラメータに転用している 382 | 'satellite': extra_args, 383 | 'isDisabled': False, 384 | } 385 | mirakurun_channels.append(channel) 386 | 387 | # YAML に変換 388 | string_io = StringIO() 389 | yaml = YAML() 390 | yaml.width = 1000 391 | yaml.preserve_quotes = True 392 | yaml.indent(mapping=2, sequence=4, offset=2) 393 | yaml.dump(mirakurun_channels, string_io) 394 | 395 | # StringIO の先頭にシークする 396 | string_io.seek(0) 397 | 398 | # メモリ上に保存した YAML を文字列として取得して返す 399 | return string_io.getvalue() 400 | 401 | 402 | MirakurunTuner = TypedDict( 403 | 'MirakurunTuner', 404 | { 405 | 'name': str, 406 | 'types': list[str], 407 | 'command': str, 408 | 'decoder': NotRequired[str], 409 | 'isDisabled': NotRequired[bool], 410 | }, 411 | ) 412 | 413 | 414 | class MirakurunTunersYmlFormatter(BaseFormatter): 415 | """ 416 | 取得したチューナー情報を Mirakurun のチューナー設定ファイルとして保存するフォーマッター 417 | """ 418 | 419 | def __init__( 420 | self, 421 | save_file_path: Path, 422 | isdbt_tuners: list[ISDBTuner], 423 | isdbs_tuners: list[ISDBTuner], 424 | multi_tuners: list[ISDBTuner], 425 | recpt1_compatible: bool = False, 426 | ) -> None: 427 | """ 428 | Args: 429 | save_file_path (Path): 保存先のファイルパス 430 | isdbt_tuners (list[ISDBTuner]): ISDB-T 専用チューナーのリスト 431 | isdbs_tuners (list[ISDBTuner]): ISDB-S 専用チューナーのリスト 432 | multi_tuners (list[ISDBTuner]): ISDB-T/ISDB-S 共用チューナーのリスト 433 | recpt1_compatible (bool): recpt1 と互換性のある物理チャンネル指定フォーマットで保存するか 434 | """ 435 | 436 | self._save_file_path = save_file_path 437 | self._isdbt_tuners = isdbt_tuners 438 | self._isdbs_tuners = isdbs_tuners 439 | self._multi_tuners = multi_tuners 440 | self._recpt1_compatible = recpt1_compatible 441 | 442 | def format(self) -> str: 443 | """ 444 | Mirakurun のチューナー設定ファイルとしてフォーマットする 445 | 446 | Returns: 447 | str: フォーマットされた文字列 448 | """ 449 | 450 | def get_tuner_command(tuner: ISDBTuner) -> str: 451 | if self._recpt1_compatible is True: 452 | return f'recpt1 --device {tuner.device_path} - -' 453 | else: 454 | # は mirakc における {{{extra_args}}} の代わりとして使っている 455 | # TSID 選局に対応している ISDB-S 対応チューナー・ISDB-T/ISDB-S 両対応チューナーでは、 456 | # BS でのみ が --tsid (BS チャンネルの TSID) に置換される 457 | if tuner.isTSIDSelectionSupported() is True and tuner.type in ['ISDB-S', 'ISDB-T/ISDB-S']: 458 | # Mirakurun のプレースホルダーは単なる文字列置換で実装されているが、"satellite" が空だと条件分岐が成立せず 459 | # チューナーコマンド内の が置換されずに残ってしまうため、地上波や CS の場合は空文字列ではなく半角スペースを入れている 460 | # しかし生成されたチューナーコマンドに複数の連続するスペースが含まれるとコマンド実行に失敗するため、 461 | # "", "", "-" (標準出力を表す) の間には敢えて半角スペースを入れないようにしている 462 | # ref: https://github.com/tsukumijima/ISDBScanner/issues/9 463 | return f'recisdb tune --device {tuner.device_path} --channel -' 464 | else: 465 | return f'recisdb tune --device {tuner.device_path} --channel -' 466 | 467 | # Mirakurun のチューナー設定ファイル用のデータ構造に変換 468 | mirakurun_tuners: list[MirakurunTuner] = [] 469 | for isdbt_tuner in self._isdbt_tuners: 470 | # recpt1 は DVB 版チューナーに非対応なのでスキップ 471 | if self._recpt1_compatible is True and isdbt_tuner.device_type == 'V4L-DVB': 472 | continue 473 | tuner: MirakurunTuner = { 474 | 'name': isdbt_tuner.name, 475 | 'types': ['GR'], 476 | 'command': get_tuner_command(isdbt_tuner), 477 | } 478 | if self._recpt1_compatible is True: 479 | # recpt1 にも B25 デコード機能はあるが、安定性に難があるらしいので arib-b25-stream-test を使用する 480 | # recisdb は既定で自動的にデコードを行うため、指定する必要はない 481 | tuner['decoder'] = 'arib-b25-stream-test' 482 | tuner['isDisabled'] = False 483 | mirakurun_tuners.append(tuner) 484 | for isdbs_tuner in self._isdbs_tuners: 485 | # recpt1 は DVB 版チューナーに非対応なのでスキップ 486 | if self._recpt1_compatible is True and isdbs_tuner.device_type == 'V4L-DVB': 487 | continue 488 | tuner: MirakurunTuner = { 489 | 'name': isdbs_tuner.name, 490 | 'types': ['BS', 'CS'], 491 | 'command': get_tuner_command(isdbs_tuner), 492 | } 493 | if self._recpt1_compatible is True: 494 | # recpt1 にも B25 デコード機能はあるが、安定性に難があるらしいので arib-b25-stream-test を使用する 495 | # recisdb は既定で自動的にデコードを行うため、指定する必要はない 496 | tuner['decoder'] = 'arib-b25-stream-test' 497 | tuner['isDisabled'] = False 498 | mirakurun_tuners.append(tuner) 499 | for multi_tuner in self._multi_tuners: 500 | # recpt1 は DVB 版チューナーに非対応なのでスキップ 501 | if self._recpt1_compatible is True and multi_tuner.device_type == 'V4L-DVB': 502 | continue 503 | tuner: MirakurunTuner = { 504 | 'name': multi_tuner.name, 505 | 'types': ['GR', 'BS', 'CS'], 506 | 'command': get_tuner_command(multi_tuner), 507 | } 508 | if self._recpt1_compatible is True: 509 | # recpt1 にも B25 デコード機能はあるが、安定性に難があるらしいので arib-b25-stream-test を使用する 510 | # recisdb は既定で自動的にデコードを行うため、指定する必要はない 511 | tuner['decoder'] = 'arib-b25-stream-test' 512 | tuner['isDisabled'] = False 513 | mirakurun_tuners.append(tuner) 514 | 515 | # YAML に変換 516 | string_io = StringIO() 517 | yaml = YAML() 518 | yaml.width = 1000 519 | yaml.preserve_quotes = True 520 | yaml.indent(mapping=2, sequence=4, offset=2) 521 | yaml.dump(mirakurun_tuners, string_io) 522 | 523 | # StringIO の先頭にシークする 524 | string_io.seek(0) 525 | 526 | # メモリ上に保存した YAML を文字列として取得して返す 527 | return string_io.getvalue() 528 | 529 | 530 | MirakcChannel = TypedDict( 531 | 'MirakcChannel', 532 | { 533 | 'name': str, 534 | 'type': str, 535 | 'channel': str, 536 | 'extra-args': str, 537 | 'disabled': NotRequired[bool], 538 | }, 539 | ) 540 | 541 | 542 | MirakcTuner = TypedDict( 543 | 'MirakcTuner', 544 | { 545 | 'name': str, 546 | 'types': list[str], 547 | # mirakc には decoder の項目がなく、別途 config.yml の filters.decode-filter.command として指定する 548 | 'command': str, 549 | 'disabled': NotRequired[bool], 550 | }, 551 | ) 552 | 553 | 554 | class MirakcConfigYmlFormatter(BaseFormatter): 555 | """ 556 | 取得したチューナー情報とスキャン解析結果である TS 情報を mirakc の設定ファイルとして保存するフォーマッター 557 | """ 558 | 559 | def __init__( 560 | self, 561 | save_file_path: Path, 562 | isdbt_tuners: list[ISDBTuner], 563 | isdbs_tuners: list[ISDBTuner], 564 | multi_tuners: list[ISDBTuner], 565 | terrestrial_ts_infos: list[TransportStreamInfo], 566 | bs_ts_infos: list[TransportStreamInfo], 567 | cs_ts_infos: list[TransportStreamInfo], 568 | exclude_pay_tv: bool = False, 569 | recpt1_compatible: bool = False, 570 | mirakc_config_template: dict[str, Any] = { 571 | 'server': { 572 | 'addrs': [ 573 | {'http': '0.0.0.0:40772'}, 574 | ], 575 | }, 576 | 'epg': { 577 | 'cache-dir': '/var/lib/mirakc/epg', 578 | }, 579 | 'channels': [], 580 | 'tuners': [], 581 | }, 582 | ) -> None: 583 | """ 584 | Args: 585 | save_file_path (Path): 保存先のファイルパス 586 | isdbt_tuners (list[ISDBTuner]): ISDB-T 専用チューナーのリスト 587 | isdbs_tuners (list[ISDBTuner]): ISDB-S 専用チューナーのリスト 588 | multi_tuners (list[ISDBTuner]): ISDB-T/ISDB-S 共用チューナーのリスト 589 | terrestrial_ts_infos (list[TransportStreamInfo]): スキャン結果の地上波の TS 情報 590 | bs_ts_infos (list[TransportStreamInfo]): スキャン結果の BS の TS 情報 591 | cs_ts_infos (list[TransportStreamInfo]): スキャン結果の CS の TS 情報 592 | exclude_pay_tv (bool): 有料放送 (+ショップチャンネル&QVC) を除外し、地上波と BS 無料放送のみを保存するか 593 | recpt1_compatible (bool): recpt1 と互換性のある物理チャンネル指定フォーマットで保存するか 594 | mirakc_config_template (dict[str, Any]): mirakc の設定ファイルのひな形 (channels と tuners は空のリストで初期化されている必要がある) 595 | """ 596 | 597 | self._isdbt_tuners = isdbt_tuners 598 | self._isdbs_tuners = isdbs_tuners 599 | self._multi_tuners = multi_tuners 600 | self._recpt1_compatible = recpt1_compatible 601 | super().__init__(save_file_path, terrestrial_ts_infos, bs_ts_infos, cs_ts_infos, exclude_pay_tv) 602 | 603 | assert 'channels' in mirakc_config_template and type(mirakc_config_template['channels']) is list 604 | assert 'tuners' in mirakc_config_template and type(mirakc_config_template['tuners']) is list 605 | self._mirakc_config_template = mirakc_config_template 606 | 607 | def format(self) -> str: 608 | """ 609 | mirakc の設定ファイルとしてフォーマットする 610 | 611 | Returns: 612 | str: フォーマットされた文字列 613 | """ 614 | 615 | # ひな形の設定データ 616 | mirakc_config = copy.deepcopy(self._mirakc_config_template) # ひな型が変更されないようにコピーする 617 | 618 | # recpt1 にも B25 デコード機能はあるが、安定性に難があるらしいので arib-b25-stream-test を使用する 619 | if self._recpt1_compatible is True: 620 | mirakc_config['filters'] = { 621 | 'decode-filter': { 622 | 'command': 'arib-b25-stream-test', 623 | }, 624 | } 625 | 626 | # 各 TS 情報を物理チャンネル昇順でソートして結合 627 | ## この時点で処理対象が地上波チューナーなら BS/CS の TS 情報、衛星チューナーなら地上波の TS 情報として空のリストが渡されているはず 628 | terrestrial_ts_infos = sorted(self._terrestrial_ts_infos, key=lambda x: x.physical_channel) 629 | bs_ts_infos = sorted(self._bs_ts_infos, key=lambda x: x.physical_channel) 630 | cs_ts_infos = sorted(self._cs_ts_infos, key=lambda x: x.physical_channel) 631 | ts_infos = terrestrial_ts_infos + bs_ts_infos + cs_ts_infos 632 | 633 | # mirakc のチャンネル設定ファイル用のデータ構造に変換 634 | for ts_info in ts_infos: 635 | if ts_info.broadcast_type == 'Terrestrial': 636 | mirakc_name = ts_info.network_name 637 | mirakc_type = 'GR' 638 | else: 639 | mirakc_name = ts_info.physical_channel 640 | mirakc_type = 'BS' if ts_info.broadcast_type == 'BS' else 'CS' 641 | # 有料放送を除外する場合で、TS 内のサービスが空 (= TS 内に無料放送サービスが存在しない) ならチャンネル自体を登録しない 642 | # (有料放送を除外する場合は、この時点ですでに各 TS 情報のサービス情報から有料放送が除外されている) 643 | ## 正確には有料放送の TS に無料独立データ放送が含まれる場合もあるので (WOWOW など) 、それらも除外してから判定する 644 | ## 独立データ放送の service_type は 0xC0 なので、それ以外のサービスが空かどうかで判定する 645 | if self._exclude_pay_tv is True and len([service for service in ts_info.services if service.service_type != 0xC0]) == 0: 646 | continue 647 | if self._recpt1_compatible is True: 648 | # recpt1 互換の物理チャンネル指定フォーマット 649 | mirakc_channel = ts_info.physical_channel_recpt1 650 | # 追加の引数は常に設定しない 651 | extra_args = '' 652 | else: 653 | # recisdb 互換の物理チャンネル指定フォーマット 654 | mirakc_channel = ts_info.physical_channel_recisdb 655 | ## BS のみ追加の引数として --tsid (BS チャンネルの TSID) を指定し、当該トランスポンダで送出中の TS を明示的に TSID で選局する 656 | ## 実際に発行されるチューナーコマンドは --channel BS23_2 --tsid 18803 のようになる 657 | ## (--tsid 指定時は物理チャンネル表記に含まれる相対 TS 番号は無視され、常に物理 BS-23ch 内で送出中の TSID が 18803 の TS が選局される) 658 | ## 地上波や CS にはスロットや相対 TS 番号の概念がないため、TSID を指定する必要はない 659 | if ts_info.broadcast_type == 'BS': 660 | extra_args = f'--tsid {ts_info.transport_stream_id}' 661 | else: 662 | # channel.extra-args のデフォルト値は空文字列なので、extra-args 自体の指定を省略しているのと同じ 663 | # mirakc は Mustache テンプレートを採用しているため、変換先が空文字列でも Mirakurun のようにプレースホルダーが残ることはない 664 | # ref: https://github.com/mirakc/mirakc/blob/main/docs/config.md 665 | extra_args = '' 666 | channel: MirakcChannel = { 667 | 'name': mirakc_name, 668 | 'type': mirakc_type, 669 | 'channel': mirakc_channel, 670 | 'extra-args': extra_args, 671 | 'disabled': False, 672 | } 673 | cast(list[MirakcChannel], mirakc_config['channels']).append(channel) 674 | 675 | def get_tuner_command(tuner: ISDBTuner) -> str: 676 | if self._recpt1_compatible is True: 677 | return f'recpt1 --device {tuner.device_path} ' + '{{{channel}}} - -' 678 | else: 679 | # TSID 選局に対応している ISDB-S 対応チューナー・ISDB-T/ISDB-S 両対応チューナーでは、 680 | # BS でのみ {{{extra_args}}} が --tsid (BS チャンネルの TSID) に置換される 681 | if tuner.isTSIDSelectionSupported() is True and tuner.type in ['ISDB-S', 'ISDB-T/ISDB-S']: 682 | return f'recisdb tune --device {tuner.device_path} --channel ' + '{{{channel}}} {{{extra_args}}} -' 683 | else: 684 | return f'recisdb tune --device {tuner.device_path} --channel ' + '{{{channel}}} -' 685 | 686 | # mirakc のチューナー設定ファイル用のデータ構造に変換 687 | for isdbt_tuner in self._isdbt_tuners: 688 | # recpt1 は DVB 版チューナーに非対応なのでスキップ 689 | if self._recpt1_compatible is True and isdbt_tuner.device_type == 'V4L-DVB': 690 | continue 691 | tuner: MirakcTuner = { 692 | 'name': isdbt_tuner.name, 693 | 'types': ['GR'], 694 | 'command': get_tuner_command(isdbt_tuner), 695 | 'disabled': False, 696 | } 697 | cast(list[MirakcTuner], mirakc_config['tuners']).append(tuner) 698 | for isdbs_tuner in self._isdbs_tuners: 699 | # recpt1 は DVB 版チューナーに非対応なのでスキップ 700 | if self._recpt1_compatible is True and isdbs_tuner.device_type == 'V4L-DVB': 701 | continue 702 | tuner: MirakcTuner = { 703 | 'name': isdbs_tuner.name, 704 | 'types': ['BS', 'CS'], 705 | 'command': get_tuner_command(isdbs_tuner), 706 | 'disabled': False, 707 | } 708 | cast(list[MirakcTuner], mirakc_config['tuners']).append(tuner) 709 | for multi_tuner in self._multi_tuners: 710 | # recpt1 は DVB 版チューナーに非対応なのでスキップ 711 | if self._recpt1_compatible is True and multi_tuner.device_type == 'V4L-DVB': 712 | continue 713 | tuner: MirakcTuner = { 714 | 'name': multi_tuner.name, 715 | 'types': ['GR', 'BS', 'CS'], 716 | 'command': get_tuner_command(multi_tuner), 717 | 'disabled': False, 718 | } 719 | cast(list[MirakcTuner], mirakc_config['tuners']).append(tuner) 720 | 721 | # YAML に変換 722 | string_io = StringIO() 723 | yaml = YAML() 724 | yaml.width = 1000 725 | yaml.preserve_quotes = True 726 | yaml.indent(mapping=2, sequence=4, offset=2) 727 | yaml.dump(mirakc_config, string_io) 728 | 729 | # StringIO の先頭にシークする 730 | string_io.seek(0) 731 | 732 | # メモリ上に保存した YAML を文字列として取得して返す 733 | return string_io.getvalue() 734 | -------------------------------------------------------------------------------- /isdb_scanner/tuner.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import ctypes 4 | import errno 5 | import fcntl 6 | import re 7 | import signal 8 | import subprocess 9 | import sys 10 | import threading 11 | import time 12 | from collections.abc import Iterator 13 | from enum import IntEnum 14 | from pathlib import Path 15 | from typing import Any, Literal, cast 16 | 17 | import libusb_package 18 | import usb.core 19 | from rich import print 20 | 21 | from isdb_scanner.constants import ( 22 | DVB_INTERFACE_TUNER_DEVICE_PATHS, 23 | ISDB_MULTI_TUNER_DEVICE_PATHS, 24 | ISDBS_TUNER_DEVICE_PATHS, 25 | ISDBT_TUNER_DEVICE_PATHS, 26 | DVBDeviceInfo, 27 | LNBVoltage, 28 | ) 29 | 30 | 31 | class ISDBTuner: 32 | """ISDB-T/ISDB-S チューナーデバイスを操作するクラス (recisdb のラッパー)""" 33 | 34 | def __init__(self, device_path: Path, lnb: LNBVoltage = LNBVoltage.LOW, output_recisdb_log: bool = False) -> None: 35 | """ 36 | ISDBTuner を初期化する 37 | 38 | Args: 39 | device_path (Path): デバイスファイルのパス 40 | lnb (LNBVoltage, optional): LNB 給電を行うかどうか. Defaults to LNBVoltage.LOW. 41 | output_recisdb_log (bool, optional): recisdb のログを出力するかどうか. Defaults to False. 42 | 43 | Raises: 44 | TunerNotSupportedError: チューナーがサポートされていない場合 (ISDB-T と ISDB-S の両方非対応の DVB 版チューナーなど) 45 | """ 46 | 47 | # 操作対象のデバイスファイルは最低でもキャラクタデバイスである必要がある 48 | # チューナードライバが chardev 版か DVB 版かに関わらず、デバイスファイルはキャラクタデバイスになる 49 | if device_path.exists() is False or device_path.is_char_device() is False: 50 | raise TunerNotSupportedError(f'Invalid tuner device: {device_path}') 51 | self.lnb = lnb 52 | self.output_recisdb_log = output_recisdb_log 53 | 54 | # 指定されたデバイスファイルに紐づくチューナーデバイスの情報を取得 55 | self._device_path = device_path 56 | self._device_type: Literal['Chardev', 'V4L-DVB'] 57 | self._type: Literal['ISDB-T', 'ISDB-S', 'ISDB-T/ISDB-S'] 58 | self._name: str 59 | self._device_type, self._type, self._name = self.__getTunerDeviceInfo() 60 | 61 | # 前回チューナーオープンが失敗した (TunerOpeningError が発生した) かどうか 62 | self._last_tuner_opening_failed = False 63 | 64 | # 読み取り専用プロパティ (インスタンス外部からの変更を禁止) 65 | @property 66 | def device_path(self) -> Path: 67 | return self._device_path 68 | 69 | @property 70 | def device_type(self) -> Literal['Chardev', 'V4L-DVB']: 71 | return self._device_type 72 | 73 | @property 74 | def type(self) -> Literal['ISDB-T', 'ISDB-S', 'ISDB-T/ISDB-S']: 75 | return self._type 76 | 77 | @property 78 | def name(self) -> str: 79 | return self._name 80 | 81 | @property 82 | def last_tuner_opening_failed(self) -> bool: 83 | return self._last_tuner_opening_failed 84 | 85 | def __getTunerDeviceInfo( 86 | self, 87 | ) -> tuple[Literal['Chardev', 'V4L-DVB'], Literal['ISDB-T', 'ISDB-S', 'ISDB-T/ISDB-S'], str]: 88 | """ 89 | チューナーデバイスの種類と名前を取得する 90 | 91 | Returns: 92 | tuple[Literal['Chardev', 'V4L-DVB'], Literal['ISDB-T', 'ISDB-S', 'ISDB-T/ISDB-S'], str]: チューナーデバイスの種類と名前 93 | """ 94 | 95 | # /dev/pt1videoX・/dev/pt3videoX・/dev/px4videoX の X の部分を取得して、チューナーの種類と番号を返す共通処理 96 | def GetPT1PT3PX4VideoDeviceInfo() -> tuple[Literal['ISDB-T', 'ISDB-S'], int]: 97 | # デバイスパスから数字部分を抽出 98 | if str(self._device_path).startswith('/dev/pt1video'): 99 | device_number = int(str(self._device_path).split('pt1video')[-1]) 100 | elif str(self._device_path).startswith('/dev/pt3video'): 101 | device_number = int(str(self._device_path).split('pt3video')[-1]) 102 | elif str(self._device_path).startswith('/dev/px4video'): 103 | device_number = int(str(self._device_path).split('px4video')[-1]) 104 | else: 105 | assert False # 通常到達しない 106 | 107 | # デバイスタイプとインデックスを自動判定 108 | # ISDB-T: 2,3,6,7,10,11,14,15 ... (2個おき) 109 | # ISDB-S: 0,1,4,5,8,9,12,13 ... (2個おき) 110 | remainder = device_number % 4 111 | if remainder in [0, 1]: 112 | tuner_type = 'ISDB-S' 113 | tuner_number = device_number // 4 * 2 + 1 114 | elif remainder in [2, 3]: 115 | tuner_type = 'ISDB-T' 116 | tuner_number = (device_number - 2) // 4 * 2 + 1 117 | else: 118 | assert False # 通常到達しない 119 | if remainder in [1, 3]: 120 | tuner_number += 1 121 | 122 | return tuner_type, tuner_number 123 | 124 | # V4L-DVB 版ドライバのチューナーデバイス 125 | if str(self._device_path).startswith('/dev/dvb/adapter'): 126 | # システムで利用可能な DVB デバイスの中から、デバイスパスが一致するデバイス情報を探す 127 | for device_info in ISDBTuner.getAvailableDVBDeviceInfos(): 128 | if device_info.device_path == self._device_path: 129 | return ('V4L-DVB', device_info.tuner_type, device_info.tuner_name) 130 | 131 | # ISDB-T と ISDB-S の両方非対応の DVB 版チューナーの場合は TunerNotSupportedError を送出 132 | ## ここに到達した時点で、ISDB-T か ISDB-S に対応した利用可能な DVB 版チューナーではないことが確定している 133 | raise TunerNotSupportedError(f'Unsupported tuner device (V4L-DVB): {self._device_path}') 134 | 135 | # ***** ここからは chardev 版ドライバのチューナーデバイス ***** 136 | 137 | # Earthsoft PT1 / PT2 138 | if str(self._device_path).startswith('/dev/pt1video'): 139 | tuner_type, tuner_number = GetPT1PT3PX4VideoDeviceInfo() 140 | return ( 141 | 'Chardev', 142 | tuner_type, 143 | f'Earthsoft PT1 / PT2 ({self.tunerTypeToPretty(tuner_type)}) #{tuner_number}', 144 | ) 145 | 146 | # Earthsoft PT3 147 | if str(self._device_path).startswith('/dev/pt3video'): 148 | tuner_type, tuner_number = GetPT1PT3PX4VideoDeviceInfo() 149 | return ( 150 | 'Chardev', 151 | tuner_type, 152 | f'Earthsoft PT3 ({self.tunerTypeToPretty(tuner_type)}) #{tuner_number}', 153 | ) 154 | 155 | # PLEX PX-W3U4/PX-Q3U4/PX-W3PE4/PX-Q3PE4/PX-W3PE5/PX-Q3PE5 (PX4/PX5 Series) 156 | if str(self._device_path).startswith('/dev/px4video'): 157 | tuner_type, tuner_number = GetPT1PT3PX4VideoDeviceInfo() 158 | 159 | # PX4/PX5 チューナーの製造元の Digibest の Vendor ID 160 | VENDOR_ID = 0x0511 161 | 162 | # PX4/PX5 チューナーの Product ID とチューナー名の対応表 163 | # ref: https://github.com/tsukumijima/px4_drv/blob/develop/driver/px4_usb.h 164 | PRODUCT_ID_TO_TUNER_NAME = { 165 | 0x083F: 'PX-W3U4', 166 | 0x084A: 'PX-Q3U4', 167 | 0x023F: 'PX-W3PE4', 168 | 0x024A: 'PX-Q3PE4', 169 | 0x073F: 'PX-W3PE5', 170 | 0x074A: 'PX-Q3PE5', 171 | } 172 | 173 | # 接続されている USB デバイスの中から PX4/PX5 チューナーを探す 174 | backend = libusb_package.get_libusb1_backend() 175 | devices = usb.core.find(find_all=True, idVendor=VENDOR_ID, backend=backend) 176 | assert devices is not None, 'Failed to find USB devices.' 177 | tuner_names: list[str] = [] 178 | for device in devices: 179 | if hasattr(device, 'idProduct') is False: # 念のため 180 | continue 181 | product_id = cast(Any, device).idProduct 182 | if product_id in PRODUCT_ID_TO_TUNER_NAME: 183 | tuner_names.append(PRODUCT_ID_TO_TUNER_NAME[product_id]) 184 | 185 | # 重複するチューナー名を削除し、チューナー名を連結 186 | ## 同一機種を複数接続している場合はチューナー名が重複するため、重複を削除 187 | tuner_name = ' / '.join(list(set(tuner_names))) 188 | 189 | return ( 190 | 'Chardev', 191 | tuner_type, 192 | f'PLEX {tuner_name} ({self.tunerTypeToPretty(tuner_type)}) #{tuner_number}', 193 | ) 194 | 195 | # PLEX PX-S1UR 196 | if str(self._device_path).startswith('/dev/pxs1urvideo'): 197 | return ( 198 | 'Chardev', 199 | 'ISDB-T', 200 | f'PLEX PX-S1UR #{int(str(self._device_path).split("pxs1urvideo")[-1]) + 1}', 201 | ) 202 | 203 | # PLEX PX-M1UR 204 | if str(self._device_path).startswith('/dev/pxm1urvideo'): 205 | return ( 206 | 'Chardev', 207 | 'ISDB-T/ISDB-S', 208 | f'PLEX PX-M1UR #{int(str(self._device_path).split("pxm1urvideo")[-1]) + 1}', 209 | ) 210 | 211 | # PLEX PX-MLT5PE 212 | if str(self._device_path).startswith('/dev/pxmlt5video'): 213 | return ( 214 | 'Chardev', 215 | 'ISDB-T/ISDB-S', 216 | f'PLEX PX-MLT5PE #{int(str(self._device_path).split("pxmlt5video")[-1]) + 1}', 217 | ) 218 | 219 | # PLEX PX-MLT8PE 220 | if str(self._device_path).startswith('/dev/pxmlt8video'): 221 | return ( 222 | 'Chardev', 223 | 'ISDB-T/ISDB-S', 224 | f'PLEX PX-MLT8PE #{int(str(self._device_path).split("pxmlt8video")[-1]) + 1}', 225 | ) 226 | 227 | # e-better DTV02A-4TS-P 228 | if str(self._device_path).startswith('/dev/isdb6014video'): 229 | return ( 230 | 'Chardev', 231 | 'ISDB-T/ISDB-S', 232 | f'e-better DTV02A-4TS-P #{int(str(self._device_path).split("isdb6014video")[-1]) + 1}', 233 | ) 234 | 235 | # e-better DTV02A-1T1S-U 236 | if str(self._device_path).startswith('/dev/isdb2056video'): 237 | return ( 238 | 'Chardev', 239 | 'ISDB-T/ISDB-S', 240 | f'e-better DTV02A-1T1S-U #{int(str(self._device_path).split("isdb2056video")[-1]) + 1}', 241 | ) 242 | 243 | # e-better DTV03A-1TU 244 | if str(self._device_path).startswith('/dev/isdbt2071video'): 245 | return ( 246 | 'Chardev', 247 | 'ISDB-T', 248 | f'e-better DTV03A-1TU #{int(str(self._device_path).split("isdbt2071video")[-1]) + 1}', 249 | ) 250 | 251 | # 対応していない (定義されていない) chardev 版チューナーの場合は TunerNotSupportedError を送出 252 | ## 現状すべて網羅しているつもりだが、念のため 253 | raise TunerNotSupportedError(f'Unsupported tuner device (Chardev): {self._device_path}') 254 | 255 | def isTSIDSelectionSupported(self) -> bool: 256 | """ 257 | BS チャンネルの TSID 選局に対応しているチューナーかどうかを返す 258 | 259 | V4L-DVB デバイスは全て TSID 選局に対応している 260 | Chardev デバイスでは、Earthsoft PT1/PT2/PT3 ドライバは TSID 選局に対応していないが、それ以外は対応している 261 | 262 | Returns: 263 | bool: BS チャンネルの TSID 選局に対応しているチューナーかどうか 264 | """ 265 | 266 | # V4L-DVB デバイスは全て TSID 選局に対応している 267 | if self._device_type == 'V4L-DVB': 268 | return True 269 | 270 | # Chardev デバイスでは、特定のドライバを除いてサポートされる 271 | elif self._device_type == 'Chardev': 272 | # Earthsoft PT1 / PT2 ドライバは TSID 選局に対応していない 273 | if str(self._device_path).startswith('/dev/pt1video'): 274 | return False 275 | # Earthsoft PT3 ドライバは TSID 選局に対応していない 276 | if str(self._device_path).startswith('/dev/pt3video'): 277 | return False 278 | # それ以外の Chardev デバイスは TSID 選局に対応している 279 | ## 正確には tsukumijima/px4_drv でしか対応していない (本家 nns779/px4_drv は対応していない) が、 280 | ## 本家は長らく更新が止まっており最新のカーネルにも対応していないので、大半が移行済みのはず… 281 | return True 282 | 283 | assert False # 通常到達しない 284 | 285 | def isBusy(self) -> bool: 286 | """ 287 | チューナーデバイスが使用中かどうかを取得する 288 | 289 | Returns: 290 | bool: チューナーデバイスが使用中かどうか 291 | """ 292 | 293 | # チューナーデバイスが使用中かどうかを取得 294 | ## チューナーが使用中の場合は errno が EALREADY (Chardev) EBUSY (V4L-DVB) になる 295 | try: 296 | # 書き込みモードで開くことで、V4L-DVB チューナーが使用中かどうかを判定できる 297 | with open(self._device_path, 'rb+'): 298 | pass 299 | except OSError as ex: 300 | if ex.errno == errno.EALREADY or ex.errno == errno.EBUSY: 301 | return True 302 | 303 | # チューナーデバイスが使用中でない場合は False を返す 304 | return False 305 | 306 | def isBusyFromLsof(self) -> bool: 307 | """ 308 | lsof コマンドからチューナーデバイスが使用中かどうかを取得する 309 | isBusy() と異なりチューナーデバイスを開かないが、root 以外では他ユーザーのチューナーデバイスの使用を検出できない制限がある 310 | lsof コマンドがインストールされていない場合は False を返す 311 | 312 | Returns: 313 | bool: チューナーデバイスが使用中かどうか 314 | """ 315 | 316 | try: 317 | subprocess.run(['lsof', str(self._device_path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True) 318 | except FileNotFoundError: 319 | return False 320 | except subprocess.CalledProcessError: 321 | # チューナーデバイスが使用中でない場合は exit-code が 0 以外になり、CalledProcessError が発生する 322 | return False 323 | 324 | return True 325 | 326 | def tune(self, physical_channel_recisdb: str, recording_time: float = 10.0, tune_timeout: float = 7.0) -> bytearray: 327 | """ 328 | チューナーデバイスから指定された物理チャンネルを受信し、選局/受信できなかった場合は例外を送出する 329 | 録画時間にはチューナーオープンに掛かった時間を含まない 330 | 選局タイムアウト発生時、チューナーのクローズに時間がかかる関係で最小でも合計 7 秒程度の時間が掛かる 331 | 332 | Args: 333 | physical_channel_recisdb (str): recisdb が受け付けるフォーマットの物理チャンネル (ex: "T13", "BS23_3", "CS04") 334 | recording_time (float, optional): 録画時間 (秒). Defaults to 10.0. 335 | tune_timeout (float, optional): 選局 (チューナーオープン) のタイムアウト時間 (秒). Defaults to 7.0. 336 | 337 | Returns: 338 | bytearray: 受信したデータ 339 | 340 | Raises: 341 | TunerOpeningError: チューナーをオープンできなかった場合 342 | TunerTuningError: チャンネルを選局できなかった場合 343 | TunerOutputError: 受信したデータが小さすぎる場合 344 | """ 345 | 346 | self._last_tuner_opening_failed = False 347 | 348 | # BS・CS チャンネルのみ、設定に応じて LNB 電源を出力 349 | command = [ 350 | 'recisdb', 351 | 'tune', 352 | '--device', 353 | str(self._device_path), 354 | '--channel', 355 | physical_channel_recisdb, 356 | '--time', 357 | str(recording_time), 358 | ] 359 | if self.lnb is not None and (physical_channel_recisdb.startswith('BS') or physical_channel_recisdb.startswith('CS')): 360 | command.extend(['--lnb', str(self.lnb)]) 361 | command.extend(['-']) # 受信データを標準出力に出力 362 | 363 | # recisdb (チューナープロセス) を起動 364 | process = subprocess.Popen( 365 | command, 366 | stdout=subprocess.PIPE, 367 | stderr=subprocess.PIPE, 368 | ) 369 | 370 | # それぞれ別スレッドで標準出力と標準エラー出力の読み込みを開始 371 | stdout: bytearray = bytearray() 372 | is_stdout_arrived = False 373 | 374 | def stdout_thread_func(): 375 | nonlocal stdout, is_stdout_arrived 376 | assert process.stdout is not None 377 | while True: 378 | data = process.stdout.read(188) 379 | is_stdout_arrived = True 380 | if len(data) == 0: 381 | break 382 | stdout.extend(data) 383 | 384 | stderr: bytes = b'' 385 | 386 | def stderr_thread_func(): 387 | nonlocal stderr 388 | assert process.stderr is not None 389 | while True: 390 | data = process.stderr.read(1) 391 | if len(data) == 0: 392 | break 393 | stderr += data 394 | if self.output_recisdb_log is True: 395 | sys.stderr.buffer.write(data) 396 | sys.stderr.buffer.flush() 397 | 398 | stdout_thread = threading.Thread(target=stdout_thread_func) 399 | stderr_thread = threading.Thread(target=stderr_thread_func) 400 | stdout_thread.start() 401 | stderr_thread.start() 402 | 403 | # プロセスが終了するか、選局 (チューナーオープン) のタイムアウト秒数に達するまで待機 404 | # 標準出力から TS ストリームが出力されるようになったらタイムアウト秒数のカウントを停止 405 | tune_timeout_count = 0 406 | while process.poll() is None and tune_timeout_count < tune_timeout: 407 | time.sleep(0.01) 408 | if is_stdout_arrived is False: 409 | tune_timeout_count += 0.01 410 | 411 | # この時点でプロセスが終了しておらず、標準出力からまだ TS ストリームを受け取っていない場合 412 | # プロセスを終了 (Ctrl+C を送信) し、タイムアウトエラーを送出する 413 | if process.poll() is None and is_stdout_arrived is False: 414 | process.send_signal(signal.SIGINT) 415 | # ここでプロセスが完全に終了するまで待機しないと、続けて別のチャンネルを選局する際にデバイス使用中エラーが発生してしまう 416 | process.wait() 417 | raise TunerTuningError('Channel selection timed out.') 418 | 419 | # プロセスと標準エラー出力スレッドの終了を待機 420 | process.wait() 421 | stderr_thread.join() 422 | 423 | # この時点でリターンコードが 0 でなければ選局または受信に失敗している 424 | if process.returncode != 0: 425 | # エラメッセージを正規表現で取得 426 | result = re.search(r'ERROR:\s+(.+)', stderr.decode('utf-8')) 427 | if result is not None: 428 | error_message = result.group(1) 429 | else: 430 | error_message = 'Channel selection failed due to an unknown error.' 431 | 432 | # チューナーオープン時のエラー 433 | if error_message in [ 434 | 'The tuner device does not exist.', 435 | 'The tuner device is already in use.', 436 | 'The tuner device is busy.', 437 | 'The tuner device does not support the ioctl system call.', 438 | ] or error_message.startswith('Cannot open the device.'): 439 | self._last_tuner_opening_failed = True 440 | raise TunerOpeningError(error_message) 441 | 442 | # それ以外は選局/受信時のエラーと判断 443 | raise TunerTuningError(error_message) 444 | 445 | # 受信していれば(チューナーオープン時間を含めても)100KB 以上のデータが得られるはず 446 | # それ未満の場合は選局に失敗している 447 | if len(stdout) < 100 * 1024: 448 | raise TunerOutputError('The tuner output is too small.') 449 | 450 | # 受信したデータを返す 451 | return stdout 452 | 453 | def getSignalLevel(self, physical_channel_recisdb: str) -> tuple[subprocess.Popen[bytes], Iterator[float]]: 454 | """ 455 | チューナーデバイスから指定された物理チャンネルを受信し、イテレータで信号レベルを返す 456 | この関数はイテレータを呼び終わってもプロセスを終了しないので、呼び出し側で明示的にプロセスを終了する必要がある 457 | 458 | Args: 459 | physical_channel_recisdb (str): recisdb が受け付けるフォーマットの物理チャンネル (ex: "T13", "BS23_3", "CS04") 460 | 461 | Returns: 462 | tuple[subprocess.Popen, Iterator[float]]: チューナープロセスと信号レベルを返すイテレータ 463 | """ 464 | 465 | # BS・CS チャンネルのみ、設定に応じて LNB 電源を出力 466 | command = ['recisdb', 'checksignal', '--device', str(self._device_path), '--channel', physical_channel_recisdb] 467 | if self.lnb is not None and (physical_channel_recisdb.startswith('BS') or physical_channel_recisdb.startswith('CS')): 468 | command.extend(['--lnb', str(self.lnb)]) 469 | 470 | # recisdb (チューナープロセス) を起動 471 | process = subprocess.Popen( 472 | command, 473 | stdout=subprocess.PIPE, 474 | stderr=None if self.output_recisdb_log is True else subprocess.DEVNULL, 475 | ) 476 | 477 | # 標準出力に一行ずつ受信感度が "30.00dB" のように出力されるので、随時パースしてイテレータで返す 478 | ## 選局/受信に失敗したか、あるいはユーザーが手動でプロセスを終了させた場合は StopIteration が発生する 479 | def iterator() -> Iterator[float]: 480 | assert process.stdout is not None 481 | while True: 482 | # \r が出力されるまで 1 バイトずつ読み込む 483 | line = b'' 484 | while True: 485 | char = process.stdout.read(1) 486 | if char == b'\r' or char == b'': 487 | break 488 | line += char 489 | 490 | # プロセスが終了していたら終了 491 | if process.poll() is not None: 492 | process.send_signal(signal.SIGINT) 493 | process.wait() 494 | raise StopIteration 495 | 496 | # 信号レベルをパースして随時返す 497 | result = re.search(r'(\d+\.\d+)dB', line.decode('utf-8').strip()) 498 | if result is None: 499 | continue 500 | yield float(result.group(1)) 501 | 502 | return process, iterator() 503 | 504 | def getSignalLevelMean(self, physical_channel_recisdb: str) -> float | None: 505 | """ 506 | チューナーデバイスから指定された物理チャンネルを受信し、5回の平均信号レベルを返す 507 | 508 | Args: 509 | physical_channel_recisdb (str): recisdb が受け付けるフォーマットの物理チャンネル (ex: "T13", "BS23_3", "CS04") 510 | 511 | Returns: 512 | float | None: 平均信号レベル (選局失敗時は None) 513 | """ 514 | 515 | # 信号レベルを取得するイテレータを取得 516 | process, iterator = self.getSignalLevel(physical_channel_recisdb) 517 | 518 | # 5回分の信号レベルを取得 519 | # もし信号レベルの取得中にプロセスが終了した場合は選局に失敗しているので None を返す 520 | signal_levels: list[float] = [] 521 | for _ in range(5): 522 | try: 523 | signal_levels.append(next(iterator)) 524 | except RuntimeError: 525 | return None 526 | 527 | # プロセスを終了 528 | process.send_signal(signal.SIGINT) 529 | process.wait() 530 | 531 | # 平均信号レベルを返す 532 | return sum(signal_levels) / len(signal_levels) 533 | 534 | @staticmethod 535 | def tunerTypeToPretty( 536 | tuner_type: Literal['ISDB-T', 'ISDB-S', 'ISDB-T/ISDB-S'], 537 | ) -> Literal['Terrestrial', 'Satellite', 'Multi']: 538 | """ 539 | チューナータイプを Pretty な文字列に変換する 540 | 541 | Returns: 542 | Literal['Terrestrial', 'Satellite', 'Multi']: チューナータイプの Pretty な文字列 543 | """ 544 | if tuner_type == 'ISDB-T': 545 | return 'Terrestrial' 546 | elif tuner_type == 'ISDB-S': 547 | return 'Satellite' 548 | elif tuner_type == 'ISDB-T/ISDB-S': 549 | return 'Multi' 550 | else: 551 | assert False, f'Unknown tuner type: {tuner_type}' 552 | 553 | @staticmethod 554 | def getDVBDeviceInfoFromDVBv5(device_path: Path) -> DVBDeviceInfo | None: 555 | """ 556 | システムの DVBv5 ioctl API から DVB デバイスの情報を取得する 557 | 通常は libdvbv5 経由で取得するが、実行環境にインストールされていない可能性も考慮し Linux カーネルの ioctl を直接呼び出している 558 | いい感じの土台コードを書いてくれた GPT-4 先生に感謝! 559 | 560 | Args: 561 | device_path (Path): デバイスファイルのパス 562 | 563 | Returns: 564 | DVBDeviceInfo | None: DVB デバイスの情報 (取得できなかった場合は None) 565 | """ 566 | 567 | # 以下は linux/dvb/frontend.h から抜粋/移植した定数・構造体・列挙型 568 | 569 | # ioctl API コマンドの定数 (実測値) 570 | FE_GET_INFO = 0x80A86F3D 571 | FE_GET_PROPERTY = 0x80106F53 572 | 573 | # ioctl API から返される構造体 574 | class DvbFrontendInfo(ctypes.Structure): 575 | _fields_ = [ 576 | ('name', ctypes.c_char * 128), 577 | ('type', ctypes.c_uint), 578 | ('frequency_min', ctypes.c_uint32), 579 | ('frequency_max', ctypes.c_uint32), 580 | ('frequency_stepsize', ctypes.c_uint32), 581 | ('frequency_tolerance', ctypes.c_uint32), 582 | ('symbol_rate_min', ctypes.c_uint32), 583 | ('symbol_rate_max', ctypes.c_uint32), 584 | ('symbol_rate_tolerance', ctypes.c_uint32), 585 | ('notifier_delay', ctypes.c_uint32), 586 | ('caps', ctypes.c_uint), 587 | ] 588 | 589 | class DtvProperty(ctypes.Structure): 590 | class _u(ctypes.Union): 591 | class _buffer(ctypes.Structure): 592 | _fields_ = [ 593 | ('data', ctypes.c_uint8 * 32), 594 | ('len', ctypes.c_uint32), 595 | ('reserved1', ctypes.c_uint32 * 3), 596 | ('reserved2', ctypes.c_void_p), 597 | ] 598 | 599 | _fields_ = [('data', ctypes.c_uint32), ('buffer', _buffer)] 600 | 601 | _fields_ = [ 602 | ('cmd', ctypes.c_uint32), 603 | ('reserved', ctypes.c_uint32 * 3), 604 | ('u', _u), 605 | ('result', ctypes.c_int), 606 | ] 607 | 608 | class DtvProperties(ctypes.Structure): 609 | _fields_ = [('num', ctypes.c_uint32), ('props', ctypes.POINTER(DtvProperty))] 610 | 611 | # DVBv5 プロパティコマンドの定数 612 | DTV_ENUM_DELSYS = 44 613 | 614 | # 配信システムの列挙型 615 | ## ref: https://github.com/torvalds/linux/blob/v6.10/include/uapi/linux/dvb/frontend.h#L676-L697 616 | class fe_delivery_system(IntEnum): 617 | SYS_UNDEFINED = 0 618 | SYS_DVBC_ANNEX_A = 1 619 | SYS_DVBC_ANNEX_B = 2 620 | SYS_DVBT = 3 621 | SYS_DSS = 4 622 | SYS_DVBS = 5 623 | SYS_DVBS2 = 6 624 | SYS_DVBH = 7 625 | SYS_ISDBT = 8 626 | SYS_ISDBS = 9 627 | SYS_ISDBC = 10 628 | SYS_ATSC = 11 629 | SYS_ATSCMH = 12 630 | SYS_DTMB = 13 631 | SYS_CMMB = 14 632 | SYS_DAB = 15 633 | SYS_DVBT2 = 16 634 | SYS_TURBO = 17 635 | SYS_DVBC_ANNEX_C = 18 636 | SYS_DVBC2 = 19 637 | 638 | # DVB フロントエンドデバイスを開く 639 | try: 640 | fe_fd = open(device_path, 'rb', buffering=0) 641 | except Exception as e: 642 | print(f'[red]Failed to open tuner device: {e}[/red]') 643 | return None 644 | 645 | # チューナー名を取得 646 | fe_info = DvbFrontendInfo() 647 | try: 648 | if fcntl.ioctl(fe_fd, FE_GET_INFO, fe_info) == -1: 649 | raise OSError(-1, '', '') # 便宜上 OSError でエラーを表現 650 | except OSError as ex: 651 | print(f'[red]Failed to ioctl: FE_GET_INFO (errno: {ex.errno})[/red]') 652 | fe_fd.close() 653 | return None 654 | tuner_name = fe_info.name.decode('utf-8') 655 | 656 | # DVB フロントエンドデバイスでサポートされている配信システムのリストを取得 657 | dtv_prop = DtvProperty(cmd=DTV_ENUM_DELSYS) 658 | dtv_props = DtvProperties(num=1, props=ctypes.pointer(dtv_prop)) 659 | try: 660 | if fcntl.ioctl(fe_fd, FE_GET_PROPERTY, dtv_props) == -1: 661 | raise OSError(-1, '', '') # 便宜上 OSError でエラーを表現 662 | except OSError as ex: 663 | print(f'[red]Failed to ioctl: FE_GET_PROPERTY (errno: {ex.errno})[/red]') 664 | fe_fd.close() 665 | return None 666 | 667 | # DVB フロントエンドデバイスを閉じる 668 | fe_fd.close() 669 | 670 | # Linux カーネルでは様々な配信方式がサポートされているが、ここでは ISDB-T/ISDB-S に対応しているかだけを調べる 671 | isdbt_supported: bool = False 672 | isdbs_supported: bool = False 673 | for i in range(dtv_prop.u.buffer.len): 674 | delivery_system = fe_delivery_system(dtv_prop.u.buffer.data[i]) 675 | if delivery_system == fe_delivery_system.SYS_ISDBT: 676 | isdbt_supported = True 677 | elif delivery_system == fe_delivery_system.SYS_ISDBS: 678 | isdbs_supported = True 679 | 680 | # チューナータイプを決定 681 | # ISDB-T/ISDB-S のいずれにも対応していない場合は None を返す 682 | if isdbt_supported is True and isdbs_supported is True: 683 | tuner_type = 'ISDB-T/ISDB-S' 684 | elif isdbt_supported is True: 685 | tuner_type = 'ISDB-T' 686 | elif isdbs_supported is True: 687 | tuner_type = 'ISDB-S' 688 | else: 689 | return None 690 | 691 | # DVBDeviceInfo を返す 692 | return DVBDeviceInfo( 693 | device_path=device_path, 694 | tuner_type=tuner_type, 695 | tuner_name=tuner_name, 696 | ) 697 | 698 | @staticmethod 699 | def getAvailableDVBDeviceInfos() -> list[DVBDeviceInfo]: 700 | """ 701 | 利用可能で ISDB-T か ISDB-S に対応している DVB デバイスの情報を取得する 702 | システムで認識されていても ISDB-T と ISDB-S の両方非対応の DVB デバイスは除外される 703 | 704 | Returns: 705 | list[DVBDeviceInfo]: 利用可能で ISDB-T か ISDB-S に対応している DVB デバイスの情報 706 | """ 707 | 708 | device_infos: list[DVBDeviceInfo] = [] 709 | for device_path in DVB_INTERFACE_TUNER_DEVICE_PATHS: 710 | # /dev/dvb/adapter0/frontend0 のようなパスから、DVB デバイス番号 (adapterX) を取得 711 | search = re.search(r'\/dev\/dvb\/adapter(\d+)\/frontend0', str(device_path)) 712 | assert search is not None, f'Unknown DVB device path: {device_path}' 713 | adapter_number = int(search.group(1)) 714 | 715 | # システムの DVBv5 ioctl API から DVB デバイスの情報を取得 716 | ## DVBv5 ioctl API や ISDB-T と ISDB-S の両方非対応の DVB デバイスでは None が返される 717 | device_info = ISDBTuner.getDVBDeviceInfoFromDVBv5(device_path) 718 | if device_info is None: 719 | continue # ISDB-T と ISDB-S の両方非対応の DVB デバイスはスキップ 720 | 721 | # 一般にシステムから取得できるチューナー名は不正確なことが多いため、別途 PCI ID や USB ID からチューナー名を特定する 722 | 723 | # 既知の USB チューナーデバイス 724 | if ( 725 | Path(f'/sys/class/dvb/dvb{adapter_number}.frontend0/device/idVendor').exists() 726 | and Path(f'/sys/class/dvb/dvb{adapter_number}.frontend0/device/idProduct').exists() 727 | ): 728 | with open(f'/sys/class/dvb/dvb{adapter_number}.frontend0/device/idVendor') as f: 729 | vendor_id = int(f.read().strip(), 16) 730 | with open(f'/sys/class/dvb/dvb{adapter_number}.frontend0/device/idProduct') as f: 731 | product_id = int(f.read().strip(), 16) 732 | 733 | # USB ID の参考資料 734 | # ref: https://github.com/torvalds/linux/blob/v6.5/drivers/media/usb/siano/smsusb.c#L622-L711 735 | # ref: https://aqua-linux.blog.ss-blog.jp/2014-08-02 736 | 737 | # MyGica S270 (旧ロット?) / MyGica S880i (Terrestrial × 1) 738 | ## 数年前まで販売されていた VASTDTV のパッケージになる前の製品と思われる 739 | if vendor_id == 0x187F and product_id == 0x0600: 740 | device_info.tuner_name = 'MyGica S270 / S880i' 741 | 742 | # PLEX PX-S1UD / VASTDTV VT20 (Terrestrial × 1) / PLEX PX-Q1UD (Terrestrial × 4) 743 | ## PX-S1UD と、VASTDTV VT20 として売られているチューナーは USB ID 含めパッケージ以外は同一の製品 744 | ## VASTDTV VT20 が MyGica S270 として販売されている場合もあって謎…… (おそらく MyGica も VASTDTV も Geniatech のブランド名) 745 | ## PLEX PX-Q1UD は PLEX PX-S1UD が内部 USB ハブで4つ接続されているだけのもので、USB ID もドライバも同一 (さすがに USB ID は分けろよ…) 746 | if vendor_id == 0x3275 and product_id == 0x0080: 747 | device_info.tuner_name = 'PLEX PX-S1UD / PX-Q1UD / VASTDTV VT20' 748 | 749 | # PLEX PX-BCUD (廃番) 750 | elif vendor_id == 0x3275 and product_id == 0x0085: 751 | device_info.tuner_name = 'PLEX PX-BCUD' 752 | 753 | # 既知の PCI(e) チューナーデバイス 754 | elif ( 755 | Path(f'/sys/class/dvb/dvb{adapter_number}.frontend0/device/vendor').exists() 756 | and Path(f'/sys/class/dvb/dvb{adapter_number}.frontend0/device/device').exists() 757 | and Path(f'/sys/class/dvb/dvb{adapter_number}.frontend0/device/subsystem_vendor').exists() 758 | and Path(f'/sys/class/dvb/dvb{adapter_number}.frontend0/device/subsystem_device').exists() 759 | ): 760 | with open(f'/sys/class/dvb/dvb{adapter_number}.frontend0/device/vendor') as f: 761 | vendor_id = int(f.read().strip(), 16) 762 | with open(f'/sys/class/dvb/dvb{adapter_number}.frontend0/device/device') as f: 763 | device_id = int(f.read().strip(), 16) 764 | with open(f'/sys/class/dvb/dvb{adapter_number}.frontend0/device/subsystem_vendor') as f: 765 | subsystem_vendor_id = int(f.read().strip(), 16) 766 | with open(f'/sys/class/dvb/dvb{adapter_number}.frontend0/device/subsystem_device') as f: 767 | subsystem_device_id = int(f.read().strip(), 16) 768 | 769 | # PCI ID の参考資料 770 | # PT1/PT2/PT3 の PCI vendor_id は FPGA 回路のメーカーのものが使われているみたい 771 | # ref: https://cateee.net/lkddb/web-lkddb/DVB_PT1.html 772 | # ref: https://cateee.net/lkddb/web-lkddb/DVB_PT3.html 773 | # ref: https://github.com/DigitalDevices/dddvb/blob/master/ddbridge/ddbridge-hw.c#L861-L929 774 | 775 | # Earthsoft PT1 (Terrestrial × 2 + Satellite × 2) 776 | if vendor_id == 0x10EE and device_id == 0x211A: 777 | if device_info.tuner_type == 'ISDB-T': 778 | device_info.tuner_name = 'Earthsoft PT1 (Terrestrial)' 779 | elif device_info.tuner_type == 'ISDB-S': 780 | device_info.tuner_name = 'Earthsoft PT1 (Satellite)' 781 | 782 | # Earthsoft PT2 (Terrestrial × 2 + Satellite × 2) 783 | elif vendor_id == 0x10EE and device_id == 0x222A: 784 | if device_info.tuner_type == 'ISDB-T': 785 | device_info.tuner_name = 'Earthsoft PT2 (Terrestrial)' 786 | elif device_info.tuner_type == 'ISDB-S': 787 | device_info.tuner_name = 'Earthsoft PT2 (Satellite)' 788 | 789 | # Earthsoft PT3 (Terrestrial × 2 + Satellite × 2) 790 | elif vendor_id == 0x1172 and device_id == 0x4C15 and subsystem_vendor_id == 0xEE8D and subsystem_device_id == 0x0368: 791 | if device_info.tuner_type == 'ISDB-T': 792 | device_info.tuner_name = 'Earthsoft PT3 (Terrestrial)' 793 | elif device_info.tuner_type == 'ISDB-S': 794 | device_info.tuner_name = 'Earthsoft PT3 (Satellite)' 795 | 796 | # Digital Devices 797 | elif vendor_id == 0xDD01: 798 | # DD Max M4 (Terrestrial/Satellite × 4) 799 | ## ISDB-T/ISDB-S 以外の DVB などの放送方式も受信できるが、ISDBScanner では ISDB-T/ISDB-S 以外をサポートしないため、 800 | ## ISDB-T/ISDB-S 共用チューナーとして扱う 801 | if device_id == 0x000A and subsystem_device_id == 0x0050: 802 | device_info.tuner_name = 'Digital Devices DD Max M4' 803 | 804 | # DD Max M8 (未発売: Terrestrial/Satellite × 8) 805 | ## ISDB-T/ISDB-S 以外の DVB などの放送方式も受信できるが、ISDBScanner では ISDB-T/ISDB-S 以外をサポートしないため、 806 | ## ISDB-T/ISDB-S 共用チューナーとして扱う 807 | elif device_id == 0x0022 and subsystem_device_id == 0x0052: 808 | device_info.tuner_name = 'Digital Devices DD Max M8' 809 | 810 | # DD Max M8A (未発売: Terrestrial/Satellite × 8) 811 | ## ISDB-T/ISDB-S 以外の DVB などの放送方式も受信できるが、ISDBScanner では ISDB-T/ISDB-S 以外をサポートしないため、 812 | ## ISDB-T/ISDB-S 共用チューナーとして扱う 813 | elif device_id == 0x0024 and subsystem_device_id == 0x0053: 814 | device_info.tuner_name = 'Digital Devices DD Max M8A' 815 | 816 | # DD Max A8i (終売: Terrestrial × 8) 817 | ## ISDB-T 以外の DVB などの放送方式も受信できるが、ISDBScanner では ISDB-T/ISDB-S 以外をサポートしないため、 818 | ## ISDB-T 専用チューナーとして扱う 819 | elif device_id == 0x0008 and subsystem_device_id == 0x0036: 820 | device_info.tuner_name = 'Digital Devices DD Max A8i' 821 | 822 | # USB でも PCI(e) でもないデバイスは存在しないはず 823 | else: 824 | assert False, f'Not USB or PCI(e) device: {device_path}' 825 | 826 | # DVB デバイスの情報をリストに追加 827 | ## ここまでにチューナー名が特定できなかった場合は、DVBv5 ioctl API から取得したチューナー名をそのまま利用する 828 | device_infos.append(device_info) 829 | 830 | # 同一チューナー名ごとにグループ化し、それぞれ DVB デバイス番号昇順でソートして #1, #2, ... の suffix を付ける 831 | device_infos_grouped: dict[str, list[DVBDeviceInfo]] = {} 832 | for device_info in device_infos: 833 | if device_info.tuner_name not in device_infos_grouped: 834 | device_infos_grouped[device_info.tuner_name] = [] 835 | device_infos_grouped[device_info.tuner_name].append(device_info) 836 | for device_infos_in_group in device_infos_grouped.values(): 837 | device_infos_in_group.sort(key=lambda x: x.device_path) 838 | for i, device_info in enumerate(device_infos_in_group): 839 | device_info.tuner_name += f' #{i + 1}' 840 | 841 | return device_infos 842 | 843 | @staticmethod 844 | def getAvailableISDBTTuners(lnb: LNBVoltage = LNBVoltage.LOW, output_recisdb_log: bool = False) -> list[ISDBTuner]: 845 | """ 846 | 利用可能な ISDB-T チューナーのリストを取得する 847 | ISDB-T 専用チューナーと ISDB-T/ISDB-S 共用チューナーの両方が含まれる 848 | 849 | Args: 850 | lnb (LNBVoltage, optional): LNB 給電を行うかどうか. Defaults to LNBVoltage.LOW. 851 | output_recisdb_log (bool, optional): recisdb のログを出力するかどうか. Defaults to False. 852 | 853 | Returns: 854 | list[ISDBTuner]: 利用可能な ISDB-T チューナーのリスト 855 | """ 856 | 857 | # ISDB-T 専用チューナーと ISDB-T/ISDB-S 共用チューナーの両方を含む 858 | return ISDBTuner.getAvailableISDBTOnlyTuners(lnb, output_recisdb_log) + ISDBTuner.getAvailableMultiTuners(lnb, output_recisdb_log) 859 | 860 | @staticmethod 861 | def getAvailableISDBTOnlyTuners(lnb: LNBVoltage = LNBVoltage.LOW, output_recisdb_log: bool = False) -> list[ISDBTuner]: 862 | """ 863 | 利用可能な ISDB-T チューナーのリストを取得する 864 | ISDB-T 専用チューナーのみが含まれる 865 | 866 | Args: 867 | lnb (LNBVoltage, optional): LNB 給電を行うかどうか. Defaults to LNBVoltage.LOW. 868 | output_recisdb_log (bool, optional): recisdb のログを出力するかどうか. Defaults to False. 869 | 870 | Returns: 871 | list[ISDBTuner]: 利用可能な ISDB-T 専用チューナーのリスト 872 | """ 873 | 874 | # 存在するデバイスのパスを取得し、ISDBTuner を初期化してリストに追加 875 | # chardev デバイスを優先し、V4L-DVB デバイスは後から追加する 876 | tuners: list[ISDBTuner] = [] 877 | for device_path in ISDBT_TUNER_DEVICE_PATHS + DVB_INTERFACE_TUNER_DEVICE_PATHS: 878 | # キャラクタデバイスファイルかつ ISDB-T 専用チューナーであればリストに追加 879 | if device_path.exists() and device_path.is_char_device(): 880 | try: 881 | tuner = ISDBTuner(device_path, lnb=lnb, output_recisdb_log=output_recisdb_log) 882 | if tuner.type == 'ISDB-T': 883 | tuners.append(tuner) 884 | # ISDBScanner でサポートされていないチューナー 885 | ## DVB 版ドライバでは /dev 以下にデバイスファイルが存在していても ISDB-T/ISDB-S に対応しているとは限らない 886 | except TunerNotSupportedError: 887 | continue 888 | 889 | return tuners 890 | 891 | @staticmethod 892 | def getAvailableISDBSTuners(lnb: LNBVoltage = LNBVoltage.LOW, output_recisdb_log: bool = False) -> list[ISDBTuner]: 893 | """ 894 | 利用可能な ISDB-S チューナーのリストを取得する 895 | ISDB-S 専用チューナーと ISDB-T/ISDB-S 共用チューナーの両方が含まれる 896 | 897 | Args: 898 | lnb (LNBVoltage, optional): LNB 給電を行うかどうか. Defaults to LNBVoltage.LOW. 899 | output_recisdb_log (bool, optional): recisdb のログを出力するかどうか. Defaults to False. 900 | 901 | Returns: 902 | list[ISDBTuner]: 利用可能な ISDB-S チューナーのリスト 903 | """ 904 | 905 | # ISDB-S 専用チューナーと ISDB-T/ISDB-S 共用チューナーの両方を含む 906 | return ISDBTuner.getAvailableISDBSOnlyTuners(lnb, output_recisdb_log) + ISDBTuner.getAvailableMultiTuners(lnb, output_recisdb_log) 907 | 908 | @staticmethod 909 | def getAvailableISDBSOnlyTuners(lnb: LNBVoltage = LNBVoltage.LOW, output_recisdb_log: bool = False) -> list[ISDBTuner]: 910 | """ 911 | 利用可能な ISDB-S チューナーのリストを取得する 912 | ISDB-S 専用チューナーのみが含まれる 913 | 914 | Args: 915 | lnb (LNBVoltage, optional): LNB 給電を行うかどうか. Defaults to LNBVoltage.LOW. 916 | output_recisdb_log (bool, optional): recisdb のログを出力するかどうか. Defaults to False. 917 | 918 | Returns: 919 | list[ISDBTuner]: 利用可能な ISDB-S 専用チューナーのリスト 920 | """ 921 | 922 | # 存在するデバイスのパスを取得し、ISDBTuner を初期化してリストに追加 923 | # chardev デバイスを優先し、V4L-DVB デバイスは後から追加する 924 | tuners: list[ISDBTuner] = [] 925 | for device_path in ISDBS_TUNER_DEVICE_PATHS + DVB_INTERFACE_TUNER_DEVICE_PATHS: 926 | # キャラクタデバイスファイルかつ ISDB-S 専用チューナーであればリストに追加 927 | if device_path.exists() and device_path.is_char_device(): 928 | try: 929 | tuner = ISDBTuner(device_path, lnb=lnb, output_recisdb_log=output_recisdb_log) 930 | if tuner.type == 'ISDB-S': 931 | tuners.append(tuner) 932 | # ISDBScanner でサポートされていないチューナー 933 | ## DVB 版ドライバでは /dev 以下にデバイスファイルが存在していても ISDB-T/ISDB-S に対応しているとは限らない 934 | except TunerNotSupportedError: 935 | continue 936 | 937 | return tuners 938 | 939 | @staticmethod 940 | def getAvailableMultiTuners(lnb: LNBVoltage = LNBVoltage.LOW, output_recisdb_log: bool = False) -> list[ISDBTuner]: 941 | """ 942 | 利用可能な ISDB-T/ISDB-S 共用チューナーのリストを取得する 943 | 944 | Args: 945 | lnb (LNBVoltage, optional): LNB 給電を行うかどうか. Defaults to LNBVoltage.LOW. 946 | output_recisdb_log (bool, optional): recisdb のログを出力するかどうか. Defaults to False. 947 | 948 | Returns: 949 | list[ISDBTuner]: 利用可能な ISDB-T/ISDB-S 共用チューナーのリスト 950 | """ 951 | 952 | # 存在するデバイスのパスを取得し、ISDBTuner を初期化してリストに追加 953 | # chardev デバイスを優先し、V4L-DVB デバイスは後から追加する 954 | tuners: list[ISDBTuner] = [] 955 | for device_path in ISDB_MULTI_TUNER_DEVICE_PATHS + DVB_INTERFACE_TUNER_DEVICE_PATHS: 956 | # キャラクタデバイスファイルかつ ISDB-T/ISDB-S 共用チューナーであればリストに追加 957 | if device_path.exists() and device_path.is_char_device(): 958 | try: 959 | tuner = ISDBTuner(device_path, lnb=lnb, output_recisdb_log=output_recisdb_log) 960 | if tuner.type == 'ISDB-T/ISDB-S': 961 | tuners.append(tuner) 962 | # ISDBScanner でサポートされていないチューナー 963 | ## DVB 版ドライバでは /dev 以下にデバイスファイルが存在していても ISDB-T/ISDB-S に対応しているとは限らない 964 | except TunerNotSupportedError: 965 | continue 966 | 967 | return tuners 968 | 969 | 970 | class TunerNotSupportedError(Exception): 971 | """ISDBScanner でサポートされていないチューナーであることを表す例外""" 972 | 973 | pass 974 | 975 | 976 | class TunerOpeningError(Exception): 977 | """チューナーのオープンに失敗したことを表す例外""" 978 | 979 | pass 980 | 981 | 982 | class TunerTuningError(Exception): 983 | """チューナーの選局に失敗したことを表す例外""" 984 | 985 | pass 986 | 987 | 988 | class TunerOutputError(Exception): 989 | """チューナーから出力されたデータが不正なことを表す例外""" 990 | 991 | pass 992 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "altgraph" 5 | version = "0.17.4" 6 | description = "Python graph (network) package" 7 | optional = false 8 | python-versions = "*" 9 | files = [ 10 | {file = "altgraph-0.17.4-py2.py3-none-any.whl", hash = "sha256:642743b4750de17e655e6711601b077bc6598dbfa3ba5fa2b2a35ce12b508dff"}, 11 | {file = "altgraph-0.17.4.tar.gz", hash = "sha256:1b5afbb98f6c4dcadb2e2ae6ab9fa994bbb8c1d75f4fa96d340f9437ae454406"}, 12 | ] 13 | 14 | [[package]] 15 | name = "annotated-types" 16 | version = "0.7.0" 17 | description = "Reusable constraint types to use with typing.Annotated" 18 | optional = false 19 | python-versions = ">=3.8" 20 | files = [ 21 | {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, 22 | {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, 23 | ] 24 | 25 | [[package]] 26 | name = "ariblib" 27 | version = "0.1.4" 28 | description = "python implementation of arib-std-b10 and arib-std-b24" 29 | optional = false 30 | python-versions = "*" 31 | files = [ 32 | {file = "ariblib-0.1.4-py3-none-any.whl", hash = "sha256:ac3bb62ef2998811c23b00d6d9648dcde4c1df9d4e0c5a79385cb04b0bfb2310"}, 33 | ] 34 | 35 | [package.source] 36 | type = "url" 37 | url = "https://github.com/tsukumijima/ariblib/releases/download/v0.1.4/ariblib-0.1.4-py3-none-any.whl" 38 | 39 | [[package]] 40 | name = "asttokens" 41 | version = "2.4.1" 42 | description = "Annotate AST trees with source code positions" 43 | optional = false 44 | python-versions = "*" 45 | files = [ 46 | {file = "asttokens-2.4.1-py2.py3-none-any.whl", hash = "sha256:051ed49c3dcae8913ea7cd08e46a606dba30b79993209636c4875bc1d637bc24"}, 47 | {file = "asttokens-2.4.1.tar.gz", hash = "sha256:b03869718ba9a6eb027e134bfdf69f38a236d681c83c160d510768af11254ba0"}, 48 | ] 49 | 50 | [package.dependencies] 51 | six = ">=1.12.0" 52 | 53 | [package.extras] 54 | astroid = ["astroid (>=1,<2)", "astroid (>=2,<4)"] 55 | test = ["astroid (>=1,<2)", "astroid (>=2,<4)", "pytest"] 56 | 57 | [[package]] 58 | name = "click" 59 | version = "8.2.1" 60 | description = "Composable command line interface toolkit" 61 | optional = false 62 | python-versions = ">=3.10" 63 | files = [ 64 | {file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"}, 65 | {file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"}, 66 | ] 67 | 68 | [package.dependencies] 69 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 70 | 71 | [[package]] 72 | name = "colorama" 73 | version = "0.4.6" 74 | description = "Cross-platform colored terminal text." 75 | optional = false 76 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 77 | files = [ 78 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 79 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 80 | ] 81 | 82 | [[package]] 83 | name = "devtools" 84 | version = "0.12.2" 85 | description = "Python's missing debug print command, and more." 86 | optional = false 87 | python-versions = ">=3.7" 88 | files = [ 89 | {file = "devtools-0.12.2-py3-none-any.whl", hash = "sha256:c366e3de1df4cdd635f1ad8cbcd3af01a384d7abda71900e68d43b04eb6aaca7"}, 90 | {file = "devtools-0.12.2.tar.gz", hash = "sha256:efceab184cb35e3a11fa8e602cc4fadacaa2e859e920fc6f87bf130b69885507"}, 91 | ] 92 | 93 | [package.dependencies] 94 | asttokens = ">=2.0.0,<3.0.0" 95 | executing = ">=1.1.1" 96 | pygments = ">=2.15.0" 97 | 98 | [[package]] 99 | name = "executing" 100 | version = "2.2.0" 101 | description = "Get the currently executing AST node of a frame, and other information" 102 | optional = false 103 | python-versions = ">=3.8" 104 | files = [ 105 | {file = "executing-2.2.0-py2.py3-none-any.whl", hash = "sha256:11387150cad388d62750327a53d3339fad4888b39a6fe233c3afbb54ecffd3aa"}, 106 | {file = "executing-2.2.0.tar.gz", hash = "sha256:5d108c028108fe2551d1a7b2e8b713341e2cb4fc0aa7dcf966fa4327a5226755"}, 107 | ] 108 | 109 | [package.extras] 110 | tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich"] 111 | 112 | [[package]] 113 | name = "importlib-resources" 114 | version = "6.5.2" 115 | description = "Read resources from Python packages" 116 | optional = false 117 | python-versions = ">=3.9" 118 | files = [ 119 | {file = "importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec"}, 120 | {file = "importlib_resources-6.5.2.tar.gz", hash = "sha256:185f87adef5bcc288449d98fb4fba07cea78bc036455dd44c5fc4a2fe78fed2c"}, 121 | ] 122 | 123 | [package.extras] 124 | check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] 125 | cover = ["pytest-cov"] 126 | doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] 127 | enabler = ["pytest-enabler (>=2.2)"] 128 | test = ["jaraco.test (>=5.4)", "pytest (>=6,!=8.1.*)", "zipp (>=3.17)"] 129 | type = ["pytest-mypy"] 130 | 131 | [[package]] 132 | name = "libusb-package" 133 | version = "1.0.26.3" 134 | description = "Package containing libusb so it can be installed via Python package managers" 135 | optional = false 136 | python-versions = ">=3.7" 137 | files = [ 138 | {file = "libusb_package-1.0.26.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2ffa01d1db3ef9e7faa62b03f409cd077232885ae3fab6f95912db78035a41db"}, 139 | {file = "libusb_package-1.0.26.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:95bbf9872674318a23450cd92053eee01683eeae6b6aa76eba30ee5f37c3765b"}, 140 | {file = "libusb_package-1.0.26.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:36a1c48779c8763fc6bc0331fda668c93b58d55934236d0393d3ec026875f7cd"}, 141 | {file = "libusb_package-1.0.26.3-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:209efb9a78ac652afc2332b0a63ef2e423202fa3a1bebe5fe3c499e0922afc03"}, 142 | {file = "libusb_package-1.0.26.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:433e89dd1f9f9a4149b975247cf1d493170454945fec54b4db9fe61c9e6b861f"}, 143 | {file = "libusb_package-1.0.26.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7e6fcad72db04b30c8495ac0df6a9b1a4ec8705930bfa2160cc9b018f14101a1"}, 144 | {file = "libusb_package-1.0.26.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:65ee502c4999ded1c71e38769b0a89152c1e03e43b0d35919f3e32a8cbc7cd99"}, 145 | {file = "libusb_package-1.0.26.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1f6d012df4942c91e6833dd251bd90c1242496a30c81020e43b98df85c66fa30"}, 146 | {file = "libusb_package-1.0.26.3-cp310-cp310-win32.whl", hash = "sha256:55c3988f622745a4874ac4face117da19969b82d51250e5334cd176f516bcb57"}, 147 | {file = "libusb_package-1.0.26.3-cp310-cp310-win_amd64.whl", hash = "sha256:ba5e87e70833e5fff977d7bf12b7107df427ee21a8021d59520e1fdf14a32368"}, 148 | {file = "libusb_package-1.0.26.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:60e15d7d3e4aab31794da95641bc28c4ffec9e24f50891ce33f75794b8f531f3"}, 149 | {file = "libusb_package-1.0.26.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d93a6137609cf72dc5db69bc337ddf96520231e395beeff69fa77a923090003"}, 150 | {file = "libusb_package-1.0.26.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fafb69c5fd42b241fbd20493d014328c507d34e1b7ceb883a20ef14565b26898"}, 151 | {file = "libusb_package-1.0.26.3-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4b0c206cd8a30565a0cede3ba426929e70a37e7b769e41a5ac7f00ca6737dc5d"}, 152 | {file = "libusb_package-1.0.26.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80a2041331c087d5887969405837f86c8422120fe9ba3e6faa44bf4810f07b71"}, 153 | {file = "libusb_package-1.0.26.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:48b536a1279ee0dbf70b898cffd16cd661774d2c8bbec8ff7178a5bc20196af3"}, 154 | {file = "libusb_package-1.0.26.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:3f273e33ff1810242f81ea3a0286e25887d99d99019ba83e08be0d1ca456cc05"}, 155 | {file = "libusb_package-1.0.26.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:67476093601e1ea58a6130426795b906acd8d18d51c84e29a3a69548a5dfcf5d"}, 156 | {file = "libusb_package-1.0.26.3-cp311-cp311-win32.whl", hash = "sha256:8f3eed2852ee4f08847a221749a98d0f4f3962f8bed967e2253327db1171ba60"}, 157 | {file = "libusb_package-1.0.26.3-cp311-cp311-win_amd64.whl", hash = "sha256:b48b5f5b17c7ac5e315e233f9ee801f730aac6183eb53a3226b01245d7bcfe00"}, 158 | {file = "libusb_package-1.0.26.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c9404298485762a4e73b416e8a3208d33aa3274fb9b870c2a1cacba7e2918f19"}, 159 | {file = "libusb_package-1.0.26.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8126f6711318dad4cb2805ea20cd47b895a847207087d8fdb032e082dd7a2e24"}, 160 | {file = "libusb_package-1.0.26.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:11c219366e4a2368117b9a9807261f3506b5623531f8b8ce41af5bbaec8156a0"}, 161 | {file = "libusb_package-1.0.26.3-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8809a50d8ab84297344c54e862027090c0d73b14abef843a8b5f783313f49457"}, 162 | {file = "libusb_package-1.0.26.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a83067c3dfdbb3856badb4532eaea22e8502b52ce4245f5ab46acf93d7fbd471"}, 163 | {file = "libusb_package-1.0.26.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b56be087ea9cde8e50fb02740a4f0cefb6f63c61ac2e7812a9244487614a3973"}, 164 | {file = "libusb_package-1.0.26.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:ea0f6bf40e54b1671e763e40c9dbed46bf7f596a4cd98b7c827e147f176d8c97"}, 165 | {file = "libusb_package-1.0.26.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b40f77df991c6db8621de9575504886eca03a00277e521a4d64b66cbef8f6997"}, 166 | {file = "libusb_package-1.0.26.3-cp312-cp312-win32.whl", hash = "sha256:6eee99c9fde137443869c8604d0c01b2127a9545ebc59d06a3376cf1d891e786"}, 167 | {file = "libusb_package-1.0.26.3-cp312-cp312-win_amd64.whl", hash = "sha256:5e09c0b6b3cd475841cffe78e46e91df58f0c6c02ea105ea1a4d0755a07c8006"}, 168 | {file = "libusb_package-1.0.26.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:04c4505e2ca68d3dc6938f116ff9bf82daffb06c1a97aba08293a84715a998da"}, 169 | {file = "libusb_package-1.0.26.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4961cdb3c622aa9f858d3e4f99a58ce5e822a97c22abc77040fd806cb5fa4c66"}, 170 | {file = "libusb_package-1.0.26.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16182670e0c23235521b447054c5a01600bd8f1eed3bb08eedbb0d9f8a43249f"}, 171 | {file = "libusb_package-1.0.26.3-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:75ea57b2cc903d28ec1d4b909902df442cbf21949d80d5b3d8b9dac36ac45d1a"}, 172 | {file = "libusb_package-1.0.26.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10d30b51b128ef5112fff73268b4696fea00b5676b3f39a5ee859bd76cb3ace5"}, 173 | {file = "libusb_package-1.0.26.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5c098dcfcfa8000cab42f33e19628c8fdb16111670db381048b2993651f2413b"}, 174 | {file = "libusb_package-1.0.26.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:93169aeab0657255fe6c9f757cf408f559db13827a1d122fc89239994d7d51f1"}, 175 | {file = "libusb_package-1.0.26.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:63257653ee1ee06aa836e942f4bb89a1d7a0c6ae3d6183647a9011e585ffa1e3"}, 176 | {file = "libusb_package-1.0.26.3-cp313-cp313-win32.whl", hash = "sha256:05db4cc801db2e6373a808725748a701509f9450fecf393fbebab61c45d50b50"}, 177 | {file = "libusb_package-1.0.26.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cd4aec825dac2b4fa5d23b37f6d72e63a1127987e5a073dabeb7b73528623a3"}, 178 | {file = "libusb_package-1.0.26.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:f2fb1a997dc3512d1bb0ad5bad68bf3fee94e182473a6c170f75d774ba493c9f"}, 179 | {file = "libusb_package-1.0.26.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1367718f1e296c4ab3e09ed673927d65e881e2a965a5306398cd515bab399666"}, 180 | {file = "libusb_package-1.0.26.3-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1dba47fd6d9c8e263aec5345bae4c151e36cbc697932de5b72f6c213a2d406cb"}, 181 | {file = "libusb_package-1.0.26.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31ed641754466b6158c7119850096b5b9aa713eaa785df40d1de985279cb9f2b"}, 182 | {file = "libusb_package-1.0.26.3-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:29b9c0e1a81478b28995d5fd1ed36d0c86e276762a60160ca90c817ada37476d"}, 183 | {file = "libusb_package-1.0.26.3-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:6c8a78c053e629f40b9e1a0b12a8d771434fd27ef313433d47f29bd434beab43"}, 184 | {file = "libusb_package-1.0.26.3-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:7f36cb42bae67d41c4d211102800051b9ad00061673a548297a973b4a8dd90f3"}, 185 | {file = "libusb_package-1.0.26.3-cp37-cp37m-win32.whl", hash = "sha256:39cd768c2302970d0cc04ac040f512bab9378a058e5de4e82d23a02963d9f13d"}, 186 | {file = "libusb_package-1.0.26.3-cp37-cp37m-win_amd64.whl", hash = "sha256:254cb05b878daf806162cc1f61d21d1e0be027096c23a829a56ad01756e3f9db"}, 187 | {file = "libusb_package-1.0.26.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a435d4533ddfb0cf0d1c388affe55ff617d953f2e2d6b8df22e22c49057735bc"}, 188 | {file = "libusb_package-1.0.26.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:d42a35077ad565a8f6604e8f5ea9c48e086df0b75b53545315a4611267d70652"}, 189 | {file = "libusb_package-1.0.26.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e78235f7aeab7fba7e7c18169f429a9e940a1ec4ea4af5390396186e31aebea"}, 190 | {file = "libusb_package-1.0.26.3-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:078cf60419f7f71cd2e9bb6c44fef214b8fbd99b6053db711e5252e7ff3cd076"}, 191 | {file = "libusb_package-1.0.26.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a1be28cb94a08afae0ef413bc0c159912ab5328d714a20a104eb97372f8edac"}, 192 | {file = "libusb_package-1.0.26.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:774b0714d0258a700fd7a4bc684e1c975407d0570bd2fc054eb25d289b7d4a1c"}, 193 | {file = "libusb_package-1.0.26.3-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:1f1ae16116a231a477662c1097c5f1c02dfb3be74faf8f8619ebd7f366440c3c"}, 194 | {file = "libusb_package-1.0.26.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:8d1a6416efbc61d3f496a1c625becf4507e46bcfc3c3b6c747b49bbf19f10c7d"}, 195 | {file = "libusb_package-1.0.26.3-cp38-cp38-win32.whl", hash = "sha256:e794543186e3ccdcc26412470aa662bc32460d6daf0f29bd45c35b164751b359"}, 196 | {file = "libusb_package-1.0.26.3-cp38-cp38-win_amd64.whl", hash = "sha256:7f14dc9ebc29f4599dc277d8b322620dd5d6bb4e278f432cbcf6601b2d69994d"}, 197 | {file = "libusb_package-1.0.26.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f1d4d23e02000b779fc2fa9eca37354c31316ff8c7545f48ecfbb800e086c700"}, 198 | {file = "libusb_package-1.0.26.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:47a042fc9d704b3fbf2ea563f06e459a96feb9436fd4701a3906dee6c60347d6"}, 199 | {file = "libusb_package-1.0.26.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d9f396ef872fe9ed61f1891f0252df1988f2548e1bf9b38c99933624a068ca7"}, 200 | {file = "libusb_package-1.0.26.3-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5340f231d427bdf13b0a3f0f9911277981ba087560ffa655e5d694c2982f37ee"}, 201 | {file = "libusb_package-1.0.26.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba0f04df25340349137ac3e857a9221ecc189941c36cb103d988bf2cac8bb8d9"}, 202 | {file = "libusb_package-1.0.26.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:86f1ce2ea380f8e09f48707abfcd3f98dbbc119f2baee4432dfdb00c8f70cc7d"}, 203 | {file = "libusb_package-1.0.26.3-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:7e41bd31166ae8408ddf2e1e631dff51ee6cc583ecbbd5e5ba010043e0239636"}, 204 | {file = "libusb_package-1.0.26.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2028f90e111a3883bcd0a4f805b5a64af03f869be4f5c8625d507b5a53d8d9bb"}, 205 | {file = "libusb_package-1.0.26.3-cp39-cp39-win32.whl", hash = "sha256:96e9652d4eef8aec32c67bb3167516d31b226b19dae208272af951bd398e3899"}, 206 | {file = "libusb_package-1.0.26.3-cp39-cp39-win_amd64.whl", hash = "sha256:a54918413f91ba66c303e79ba3b41ae65893187b0ee58ca2fb41e675587f9909"}, 207 | {file = "libusb_package-1.0.26.3-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6a62bf7fa20fe704ed0413e74d620b37bdfe6b084478d23cc85b1f10708f2596"}, 208 | {file = "libusb_package-1.0.26.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:ab40d6b295bcfbe37f280268ea0d0a1ef4b1d795025fe41b3dda48e07eb0fc8e"}, 209 | {file = "libusb_package-1.0.26.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a125d72cca545950ae357aa6d7f0f33dfb39f16b895691cf3f8c9b772bc7e31"}, 210 | {file = "libusb_package-1.0.26.3-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:229d9f26af5828512828154d6bae4214ef5016a9401dd022477e06d0df5153e7"}, 211 | {file = "libusb_package-1.0.26.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b8b862f190c244f29699d783f044e3d816fed84e39bca9a3c3731140f0b1b39"}, 212 | {file = "libusb_package-1.0.26.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:fadaad1181784713948f9cbb7ad1cab8f2b307e784e2e162ed80ba5d2f745901"}, 213 | {file = "libusb_package-1.0.26.3-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:90c309132cf6320d3bf7960896640d333e0d1a922535786092942e662481f156"}, 214 | {file = "libusb_package-1.0.26.3-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0576252cdbb36d6982bff3a1df46ed473beee210cc883b90503ec8f7d1e8a591"}, 215 | {file = "libusb_package-1.0.26.3-pp37-pypy37_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4584c9719702bdaf1b670a7e2561047fbe10fcae0a61f2c0b8347946bf4762aa"}, 216 | {file = "libusb_package-1.0.26.3-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ead32e94fe9304012d238d5643194fbe3816acd2302e7de849f63d0451fc932b"}, 217 | {file = "libusb_package-1.0.26.3-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:f342f619901edeb7fede67f2b15af5e9d1d9eb3181e1751d7ba0356011239cd9"}, 218 | {file = "libusb_package-1.0.26.3-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:00bb8c6574b9e95a9f5803166f617bddced0d891c0491171cd4496c2fed6570c"}, 219 | {file = "libusb_package-1.0.26.3-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:1dfc40e98cef34cb4129d711bf42fbb2e9faadb84f555ba63e3447f76cb3d735"}, 220 | {file = "libusb_package-1.0.26.3-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a913cf025d8a843ac3b61c83d38f34b269775f8e9345c97f8590cdbf548d97c0"}, 221 | {file = "libusb_package-1.0.26.3-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d3ffc0a8bae0115d58f00439f48497f1c04034bd36341b6bafd495ea3e5839da"}, 222 | {file = "libusb_package-1.0.26.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16d453cd1ee9cbb1e195594dfe0b7cf35397f13384ac2e9e0e2722c92c5d52d2"}, 223 | {file = "libusb_package-1.0.26.3-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:a70482e3068c5157777ad449ef44849ef5c136c135e80474ffecae71f0d1c2f0"}, 224 | {file = "libusb_package-1.0.26.3-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ef4c4eeadb5edca9d5106afe137a8838adc424df1ae838b32df583aaa75d3fdc"}, 225 | {file = "libusb_package-1.0.26.3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:e83ee3965155d9ffcd10dd0d781727176236d30628613e0effb60437c8858015"}, 226 | {file = "libusb_package-1.0.26.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ac1fdc6faedeff81a870bb5012688ff5b1dc9bd81fb9cebcbf0a4184ef367d2"}, 227 | {file = "libusb_package-1.0.26.3-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:81c24852567ab5fc950e8f4c04c5190a35d5b50cb971dee8c0e874993cd5f19b"}, 228 | {file = "libusb_package-1.0.26.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8df0264d3419523cbad6e8f01386132c3629b5c53e8ab076dafa425840646c7"}, 229 | {file = "libusb_package-1.0.26.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:16ee99d0b7765074413fe4275a5230d36762cb3e8b6d9ca2016825ef0c13023d"}, 230 | ] 231 | 232 | [package.dependencies] 233 | importlib_resources = "*" 234 | 235 | [[package]] 236 | name = "macholib" 237 | version = "1.16.3" 238 | description = "Mach-O header analysis and editing" 239 | optional = false 240 | python-versions = "*" 241 | files = [ 242 | {file = "macholib-1.16.3-py2.py3-none-any.whl", hash = "sha256:0e315d7583d38b8c77e815b1ecbdbf504a8258d8b3e17b61165c6feb60d18f2c"}, 243 | {file = "macholib-1.16.3.tar.gz", hash = "sha256:07ae9e15e8e4cd9a788013d81f5908b3609aa76f9b1421bae9c4d7606ec86a30"}, 244 | ] 245 | 246 | [package.dependencies] 247 | altgraph = ">=0.17" 248 | 249 | [[package]] 250 | name = "markdown-it-py" 251 | version = "3.0.0" 252 | description = "Python port of markdown-it. Markdown parsing, done right!" 253 | optional = false 254 | python-versions = ">=3.8" 255 | files = [ 256 | {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, 257 | {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, 258 | ] 259 | 260 | [package.dependencies] 261 | mdurl = ">=0.1,<1.0" 262 | 263 | [package.extras] 264 | benchmarking = ["psutil", "pytest", "pytest-benchmark"] 265 | code-style = ["pre-commit (>=3.0,<4.0)"] 266 | compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] 267 | linkify = ["linkify-it-py (>=1,<3)"] 268 | plugins = ["mdit-py-plugins"] 269 | profiling = ["gprof2dot"] 270 | rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] 271 | testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] 272 | 273 | [[package]] 274 | name = "mdurl" 275 | version = "0.1.2" 276 | description = "Markdown URL utilities" 277 | optional = false 278 | python-versions = ">=3.7" 279 | files = [ 280 | {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, 281 | {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, 282 | ] 283 | 284 | [[package]] 285 | name = "mslex" 286 | version = "1.3.0" 287 | description = "shlex for windows" 288 | optional = false 289 | python-versions = ">=3.5" 290 | files = [ 291 | {file = "mslex-1.3.0-py3-none-any.whl", hash = "sha256:c7074b347201b3466fc077c5692fbce9b5f62a63a51f537a53fbbd02eff2eea4"}, 292 | {file = "mslex-1.3.0.tar.gz", hash = "sha256:641c887d1d3db610eee2af37a8e5abda3f70b3006cdfd2d0d29dc0d1ae28a85d"}, 293 | ] 294 | 295 | [[package]] 296 | name = "packaging" 297 | version = "25.0" 298 | description = "Core utilities for Python packages" 299 | optional = false 300 | python-versions = ">=3.8" 301 | files = [ 302 | {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, 303 | {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, 304 | ] 305 | 306 | [[package]] 307 | name = "pefile" 308 | version = "2023.2.7" 309 | description = "Python PE parsing module" 310 | optional = false 311 | python-versions = ">=3.6.0" 312 | files = [ 313 | {file = "pefile-2023.2.7-py3-none-any.whl", hash = "sha256:da185cd2af68c08a6cd4481f7325ed600a88f6a813bad9dea07ab3ef73d8d8d6"}, 314 | {file = "pefile-2023.2.7.tar.gz", hash = "sha256:82e6114004b3d6911c77c3953e3838654b04511b8b66e8583db70c65998017dc"}, 315 | ] 316 | 317 | [[package]] 318 | name = "psutil" 319 | version = "6.1.1" 320 | description = "Cross-platform lib for process and system monitoring in Python." 321 | optional = false 322 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" 323 | files = [ 324 | {file = "psutil-6.1.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:9ccc4316f24409159897799b83004cb1e24f9819b0dcf9c0b68bdcb6cefee6a8"}, 325 | {file = "psutil-6.1.1-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:ca9609c77ea3b8481ab005da74ed894035936223422dc591d6772b147421f777"}, 326 | {file = "psutil-6.1.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:8df0178ba8a9e5bc84fed9cfa61d54601b371fbec5c8eebad27575f1e105c0d4"}, 327 | {file = "psutil-6.1.1-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:1924e659d6c19c647e763e78670a05dbb7feaf44a0e9c94bf9e14dfc6ba50468"}, 328 | {file = "psutil-6.1.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:018aeae2af92d943fdf1da6b58665124897cfc94faa2ca92098838f83e1b1bca"}, 329 | {file = "psutil-6.1.1-cp27-none-win32.whl", hash = "sha256:6d4281f5bbca041e2292be3380ec56a9413b790579b8e593b1784499d0005dac"}, 330 | {file = "psutil-6.1.1-cp27-none-win_amd64.whl", hash = "sha256:c777eb75bb33c47377c9af68f30e9f11bc78e0f07fbf907be4a5d70b2fe5f030"}, 331 | {file = "psutil-6.1.1-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:fc0ed7fe2231a444fc219b9c42d0376e0a9a1a72f16c5cfa0f68d19f1a0663e8"}, 332 | {file = "psutil-6.1.1-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:0bdd4eab935276290ad3cb718e9809412895ca6b5b334f5a9111ee6d9aff9377"}, 333 | {file = "psutil-6.1.1-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b6e06c20c05fe95a3d7302d74e7097756d4ba1247975ad6905441ae1b5b66003"}, 334 | {file = "psutil-6.1.1-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97f7cb9921fbec4904f522d972f0c0e1f4fabbdd4e0287813b21215074a0f160"}, 335 | {file = "psutil-6.1.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:33431e84fee02bc84ea36d9e2c4a6d395d479c9dd9bba2376c1f6ee8f3a4e0b3"}, 336 | {file = "psutil-6.1.1-cp36-cp36m-win32.whl", hash = "sha256:384636b1a64b47814437d1173be1427a7c83681b17a450bfc309a1953e329603"}, 337 | {file = "psutil-6.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:8be07491f6ebe1a693f17d4f11e69d0dc1811fa082736500f649f79df7735303"}, 338 | {file = "psutil-6.1.1-cp37-abi3-win32.whl", hash = "sha256:eaa912e0b11848c4d9279a93d7e2783df352b082f40111e078388701fd479e53"}, 339 | {file = "psutil-6.1.1-cp37-abi3-win_amd64.whl", hash = "sha256:f35cfccb065fff93529d2afb4a2e89e363fe63ca1e4a5da22b603a85833c2649"}, 340 | {file = "psutil-6.1.1.tar.gz", hash = "sha256:cf8496728c18f2d0b45198f06895be52f36611711746b7f30c464b422b50e2f5"}, 341 | ] 342 | 343 | [package.extras] 344 | dev = ["abi3audit", "black", "check-manifest", "coverage", "packaging", "pylint", "pyperf", "pypinfo", "pytest-cov", "requests", "rstcheck", "ruff", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "virtualenv", "vulture", "wheel"] 345 | test = ["pytest", "pytest-xdist", "setuptools"] 346 | 347 | [[package]] 348 | name = "pydantic" 349 | version = "2.11.5" 350 | description = "Data validation using Python type hints" 351 | optional = false 352 | python-versions = ">=3.9" 353 | files = [ 354 | {file = "pydantic-2.11.5-py3-none-any.whl", hash = "sha256:f9c26ba06f9747749ca1e5c94d6a85cb84254577553c8785576fd38fa64dc0f7"}, 355 | {file = "pydantic-2.11.5.tar.gz", hash = "sha256:7f853db3d0ce78ce8bbb148c401c2cdd6431b3473c0cdff2755c7690952a7b7a"}, 356 | ] 357 | 358 | [package.dependencies] 359 | annotated-types = ">=0.6.0" 360 | pydantic-core = "2.33.2" 361 | typing-extensions = ">=4.12.2" 362 | typing-inspection = ">=0.4.0" 363 | 364 | [package.extras] 365 | email = ["email-validator (>=2.0.0)"] 366 | timezone = ["tzdata"] 367 | 368 | [[package]] 369 | name = "pydantic-core" 370 | version = "2.33.2" 371 | description = "Core functionality for Pydantic validation and serialization" 372 | optional = false 373 | python-versions = ">=3.9" 374 | files = [ 375 | {file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"}, 376 | {file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"}, 377 | {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d"}, 378 | {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572"}, 379 | {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02"}, 380 | {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b"}, 381 | {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2"}, 382 | {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a"}, 383 | {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac"}, 384 | {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a"}, 385 | {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b"}, 386 | {file = "pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22"}, 387 | {file = "pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640"}, 388 | {file = "pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7"}, 389 | {file = "pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246"}, 390 | {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f"}, 391 | {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc"}, 392 | {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de"}, 393 | {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a"}, 394 | {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef"}, 395 | {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e"}, 396 | {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d"}, 397 | {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30"}, 398 | {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf"}, 399 | {file = "pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51"}, 400 | {file = "pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab"}, 401 | {file = "pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65"}, 402 | {file = "pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc"}, 403 | {file = "pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7"}, 404 | {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025"}, 405 | {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011"}, 406 | {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f"}, 407 | {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88"}, 408 | {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1"}, 409 | {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b"}, 410 | {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1"}, 411 | {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6"}, 412 | {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea"}, 413 | {file = "pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290"}, 414 | {file = "pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2"}, 415 | {file = "pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab"}, 416 | {file = "pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f"}, 417 | {file = "pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6"}, 418 | {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef"}, 419 | {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a"}, 420 | {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916"}, 421 | {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a"}, 422 | {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d"}, 423 | {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56"}, 424 | {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5"}, 425 | {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e"}, 426 | {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162"}, 427 | {file = "pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849"}, 428 | {file = "pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9"}, 429 | {file = "pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9"}, 430 | {file = "pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac"}, 431 | {file = "pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5"}, 432 | {file = "pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9"}, 433 | {file = "pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d"}, 434 | {file = "pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954"}, 435 | {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb"}, 436 | {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7"}, 437 | {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4"}, 438 | {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b"}, 439 | {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3"}, 440 | {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a"}, 441 | {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782"}, 442 | {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9"}, 443 | {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e"}, 444 | {file = "pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9"}, 445 | {file = "pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3"}, 446 | {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa"}, 447 | {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29"}, 448 | {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d"}, 449 | {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e"}, 450 | {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c"}, 451 | {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec"}, 452 | {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052"}, 453 | {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c"}, 454 | {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808"}, 455 | {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8"}, 456 | {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593"}, 457 | {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612"}, 458 | {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7"}, 459 | {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e"}, 460 | {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8"}, 461 | {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf"}, 462 | {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb"}, 463 | {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1"}, 464 | {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101"}, 465 | {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64"}, 466 | {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d"}, 467 | {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535"}, 468 | {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d"}, 469 | {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6"}, 470 | {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca"}, 471 | {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039"}, 472 | {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27"}, 473 | {file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"}, 474 | ] 475 | 476 | [package.dependencies] 477 | typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" 478 | 479 | [[package]] 480 | name = "pygments" 481 | version = "2.19.1" 482 | description = "Pygments is a syntax highlighting package written in Python." 483 | optional = false 484 | python-versions = ">=3.8" 485 | files = [ 486 | {file = "pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c"}, 487 | {file = "pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f"}, 488 | ] 489 | 490 | [package.extras] 491 | windows-terminal = ["colorama (>=0.4.6)"] 492 | 493 | [[package]] 494 | name = "pyinstaller" 495 | version = "6.14.0" 496 | description = "PyInstaller bundles a Python application and all its dependencies into a single package." 497 | optional = false 498 | python-versions = "<3.14,>=3.8" 499 | files = [ 500 | {file = "pyinstaller-6.14.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:20b4dcaf17a27cf5d5417f9dc53e81adf417d22e4d8c4afe50ae20dacdc1cde6"}, 501 | {file = "pyinstaller-6.14.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:57982d0ebeb39e9a5dd8722bed871873ec59161613d8b09ca638adbd4fb4e592"}, 502 | {file = "pyinstaller-6.14.0-py3-none-manylinux2014_i686.whl", hash = "sha256:d75492d4a7ece299b580837cb027cf7742cea5e92dfec0bb4c6064816c009b59"}, 503 | {file = "pyinstaller-6.14.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:ca5ad60e210a7eb1c968d09deb85b645963bf24dcb8ed4af93c293ea526a08e6"}, 504 | {file = "pyinstaller-6.14.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:af8c973543e976bd660f83a43089cabd3c41e98398f53fa179ef7eee4d5fe3b0"}, 505 | {file = "pyinstaller-6.14.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:640f68e50d22684aa5970c2f3a6490d2e7c16bd5c20c63f5541c0e50e5bd2391"}, 506 | {file = "pyinstaller-6.14.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:3c2c88a355223d13704b5153dc341e02699523363cc18c49d872c8e82b5c6063"}, 507 | {file = "pyinstaller-6.14.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:9af257dc72d6b2027ab8a4c217eda07a6e6efa8ae9223953fd721aa9baec9106"}, 508 | {file = "pyinstaller-6.14.0-py3-none-win32.whl", hash = "sha256:083d97ee52077bc21a8e8beaede394dfd8d19da8bafc03ebc6734949d63d74a1"}, 509 | {file = "pyinstaller-6.14.0-py3-none-win_amd64.whl", hash = "sha256:c62c3e0f768d4f90c0329c5e2616d8fff5c041dc4864a28e74d653d0e77aff1a"}, 510 | {file = "pyinstaller-6.14.0-py3-none-win_arm64.whl", hash = "sha256:adf130c72e98ced09df5c43d7ca271d701a730036980da75cae056325cbc2dcd"}, 511 | {file = "pyinstaller-6.14.0.tar.gz", hash = "sha256:cc55cdc21491722d74133e35ab363a88679b37ee2d76f9d80adcbc0ae862d630"}, 512 | ] 513 | 514 | [package.dependencies] 515 | altgraph = "*" 516 | macholib = {version = ">=1.8", markers = "sys_platform == \"darwin\""} 517 | packaging = ">=22.0" 518 | pefile = {version = ">=2022.5.30,<2024.8.26 || >2024.8.26", markers = "sys_platform == \"win32\""} 519 | pyinstaller-hooks-contrib = ">=2025.4" 520 | pywin32-ctypes = {version = ">=0.2.1", markers = "sys_platform == \"win32\""} 521 | setuptools = ">=42.0.0" 522 | 523 | [package.extras] 524 | completion = ["argcomplete"] 525 | hook-testing = ["execnet (>=1.5.0)", "psutil", "pytest (>=2.7.3)"] 526 | 527 | [[package]] 528 | name = "pyinstaller-hooks-contrib" 529 | version = "2025.4" 530 | description = "Community maintained hooks for PyInstaller" 531 | optional = false 532 | python-versions = ">=3.8" 533 | files = [ 534 | {file = "pyinstaller_hooks_contrib-2025.4-py3-none-any.whl", hash = "sha256:6c2d73269b4c484eb40051fc1acee0beb113c2cfb3b37437b8394faae6f0d072"}, 535 | {file = "pyinstaller_hooks_contrib-2025.4.tar.gz", hash = "sha256:5ce1afd1997b03e70f546207031cfdf2782030aabacc102190677059e2856446"}, 536 | ] 537 | 538 | [package.dependencies] 539 | packaging = ">=22.0" 540 | setuptools = ">=42.0.0" 541 | 542 | [[package]] 543 | name = "pyusb" 544 | version = "1.3.1" 545 | description = "Easy USB access for Python" 546 | optional = false 547 | python-versions = ">=3.9.0" 548 | files = [ 549 | {file = "pyusb-1.3.1-py3-none-any.whl", hash = "sha256:bf9b754557af4717fe80c2b07cc2b923a9151f5c08d17bdb5345dac09d6a0430"}, 550 | {file = "pyusb-1.3.1.tar.gz", hash = "sha256:3af070b607467c1c164f49d5b0caabe8ac78dbed9298d703a8dbf9df4052d17e"}, 551 | ] 552 | 553 | [[package]] 554 | name = "pywin32-ctypes" 555 | version = "0.2.3" 556 | description = "A (partial) reimplementation of pywin32 using ctypes/cffi" 557 | optional = false 558 | python-versions = ">=3.6" 559 | files = [ 560 | {file = "pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755"}, 561 | {file = "pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8"}, 562 | ] 563 | 564 | [[package]] 565 | name = "rich" 566 | version = "14.0.0" 567 | description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" 568 | optional = false 569 | python-versions = ">=3.8.0" 570 | files = [ 571 | {file = "rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0"}, 572 | {file = "rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725"}, 573 | ] 574 | 575 | [package.dependencies] 576 | markdown-it-py = ">=2.2.0" 577 | pygments = ">=2.13.0,<3.0.0" 578 | 579 | [package.extras] 580 | jupyter = ["ipywidgets (>=7.5.1,<9)"] 581 | 582 | [[package]] 583 | name = "ruamel-yaml" 584 | version = "0.18.13" 585 | description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" 586 | optional = false 587 | python-versions = ">=3.7" 588 | files = [ 589 | {file = "ruamel.yaml-0.18.13-py3-none-any.whl", hash = "sha256:cf9628cfdfe9d88b78429cd093aa766e9a4c69242f9f3c86ac1d9e56437e5572"}, 590 | {file = "ruamel.yaml-0.18.13.tar.gz", hash = "sha256:b0d5ac0a2b0b4e39d87aed00ddff26e795de6750b064da364a8d009b97ce5f26"}, 591 | ] 592 | 593 | [package.dependencies] 594 | "ruamel.yaml.clib" = {version = ">=0.2.7", markers = "platform_python_implementation == \"CPython\" and python_version < \"3.14\""} 595 | 596 | [package.extras] 597 | docs = ["mercurial (>5.7)", "ryd"] 598 | jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"] 599 | 600 | [[package]] 601 | name = "ruamel-yaml-clib" 602 | version = "0.2.12" 603 | description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml" 604 | optional = false 605 | python-versions = ">=3.9" 606 | files = [ 607 | {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:11f891336688faf5156a36293a9c362bdc7c88f03a8a027c2c1d8e0bcde998e5"}, 608 | {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:a606ef75a60ecf3d924613892cc603b154178ee25abb3055db5062da811fd969"}, 609 | {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd5415dded15c3822597455bc02bcd66e81ef8b7a48cb71a33628fc9fdde39df"}, 610 | {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f66efbc1caa63c088dead1c4170d148eabc9b80d95fb75b6c92ac0aad2437d76"}, 611 | {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:22353049ba4181685023b25b5b51a574bce33e7f51c759371a7422dcae5402a6"}, 612 | {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:932205970b9f9991b34f55136be327501903f7c66830e9760a8ffb15b07f05cd"}, 613 | {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a52d48f4e7bf9005e8f0a89209bf9a73f7190ddf0489eee5eb51377385f59f2a"}, 614 | {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win32.whl", hash = "sha256:3eac5a91891ceb88138c113f9db04f3cebdae277f5d44eaa3651a4f573e6a5da"}, 615 | {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win_amd64.whl", hash = "sha256:ab007f2f5a87bd08ab1499bdf96f3d5c6ad4dcfa364884cb4549aa0154b13a28"}, 616 | {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:4a6679521a58256a90b0d89e03992c15144c5f3858f40d7c18886023d7943db6"}, 617 | {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:d84318609196d6bd6da0edfa25cedfbabd8dbde5140a0a23af29ad4b8f91fb1e"}, 618 | {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb43a269eb827806502c7c8efb7ae7e9e9d0573257a46e8e952f4d4caba4f31e"}, 619 | {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:811ea1594b8a0fb466172c384267a4e5e367298af6b228931f273b111f17ef52"}, 620 | {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cf12567a7b565cbf65d438dec6cfbe2917d3c1bdddfce84a9930b7d35ea59642"}, 621 | {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7dd5adc8b930b12c8fc5b99e2d535a09889941aa0d0bd06f4749e9a9397c71d2"}, 622 | {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1492a6051dab8d912fc2adeef0e8c72216b24d57bd896ea607cb90bb0c4981d3"}, 623 | {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win32.whl", hash = "sha256:bd0a08f0bab19093c54e18a14a10b4322e1eacc5217056f3c063bd2f59853ce4"}, 624 | {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win_amd64.whl", hash = "sha256:a274fb2cb086c7a3dea4322ec27f4cb5cc4b6298adb583ab0e211a4682f241eb"}, 625 | {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:20b0f8dc160ba83b6dcc0e256846e1a02d044e13f7ea74a3d1d56ede4e48c632"}, 626 | {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:943f32bc9dedb3abff9879edc134901df92cfce2c3d5c9348f172f62eb2d771d"}, 627 | {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95c3829bb364fdb8e0332c9931ecf57d9be3519241323c5274bd82f709cebc0c"}, 628 | {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:749c16fcc4a2b09f28843cda5a193e0283e47454b63ec4b81eaa2242f50e4ccd"}, 629 | {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bf165fef1f223beae7333275156ab2022cffe255dcc51c27f066b4370da81e31"}, 630 | {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:32621c177bbf782ca5a18ba4d7af0f1082a3f6e517ac2a18b3974d4edf349680"}, 631 | {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b82a7c94a498853aa0b272fd5bc67f29008da798d4f93a2f9f289feb8426a58d"}, 632 | {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win32.whl", hash = "sha256:e8c4ebfcfd57177b572e2040777b8abc537cdef58a2120e830124946aa9b42c5"}, 633 | {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win_amd64.whl", hash = "sha256:0467c5965282c62203273b838ae77c0d29d7638c8a4e3a1c8bdd3602c10904e4"}, 634 | {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4c8c5d82f50bb53986a5e02d1b3092b03622c02c2eb78e29bec33fd9593bae1a"}, 635 | {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:e7e3736715fbf53e9be2a79eb4db68e4ed857017344d697e8b9749444ae57475"}, 636 | {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b7e75b4965e1d4690e93021adfcecccbca7d61c7bddd8e22406ef2ff20d74ef"}, 637 | {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96777d473c05ee3e5e3c3e999f5d23c6f4ec5b0c38c098b3a5229085f74236c6"}, 638 | {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:3bc2a80e6420ca8b7d3590791e2dfc709c88ab9152c00eeb511c9875ce5778bf"}, 639 | {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e188d2699864c11c36cdfdada94d781fd5d6b0071cd9c427bceb08ad3d7c70e1"}, 640 | {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f6f3eac23941b32afccc23081e1f50612bdbe4e982012ef4f5797986828cd01"}, 641 | {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win32.whl", hash = "sha256:6442cb36270b3afb1b4951f060eccca1ce49f3d087ca1ca4563a6eb479cb3de6"}, 642 | {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3"}, 643 | {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:fc4b630cd3fa2cf7fce38afa91d7cfe844a9f75d7f0f36393fa98815e911d987"}, 644 | {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:bc5f1e1c28e966d61d2519f2a3d451ba989f9ea0f2307de7bc45baa526de9e45"}, 645 | {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a0e060aace4c24dcaf71023bbd7d42674e3b230f7e7b97317baf1e953e5b519"}, 646 | {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2f1c3765db32be59d18ab3953f43ab62a761327aafc1594a2a1fbe038b8b8a7"}, 647 | {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d85252669dc32f98ebcd5d36768f5d4faeaeaa2d655ac0473be490ecdae3c285"}, 648 | {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e143ada795c341b56de9418c58d028989093ee611aa27ffb9b7f609c00d813ed"}, 649 | {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2c59aa6170b990d8d2719323e628aaf36f3bfbc1c26279c0eeeb24d05d2d11c7"}, 650 | {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win32.whl", hash = "sha256:beffaed67936fbbeffd10966a4eb53c402fafd3d6833770516bf7314bc6ffa12"}, 651 | {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win_amd64.whl", hash = "sha256:040ae85536960525ea62868b642bdb0c2cc6021c9f9d507810c0c604e66f5a7b"}, 652 | {file = "ruamel.yaml.clib-0.2.12.tar.gz", hash = "sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f"}, 653 | ] 654 | 655 | [[package]] 656 | name = "ruff" 657 | version = "0.11.13" 658 | description = "An extremely fast Python linter and code formatter, written in Rust." 659 | optional = false 660 | python-versions = ">=3.7" 661 | files = [ 662 | {file = "ruff-0.11.13-py3-none-linux_armv6l.whl", hash = "sha256:4bdfbf1240533f40042ec00c9e09a3aade6f8c10b6414cf11b519488d2635d46"}, 663 | {file = "ruff-0.11.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aef9c9ed1b5ca28bb15c7eac83b8670cf3b20b478195bd49c8d756ba0a36cf48"}, 664 | {file = "ruff-0.11.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53b15a9dfdce029c842e9a5aebc3855e9ab7771395979ff85b7c1dedb53ddc2b"}, 665 | {file = "ruff-0.11.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab153241400789138d13f362c43f7edecc0edfffce2afa6a68434000ecd8f69a"}, 666 | {file = "ruff-0.11.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6c51f93029d54a910d3d24f7dd0bb909e31b6cd989a5e4ac513f4eb41629f0dc"}, 667 | {file = "ruff-0.11.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1808b3ed53e1a777c2ef733aca9051dc9bf7c99b26ece15cb59a0320fbdbd629"}, 668 | {file = "ruff-0.11.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d28ce58b5ecf0f43c1b71edffabe6ed7f245d5336b17805803312ec9bc665933"}, 669 | {file = "ruff-0.11.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55e4bc3a77842da33c16d55b32c6cac1ec5fb0fbec9c8c513bdce76c4f922165"}, 670 | {file = "ruff-0.11.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:633bf2c6f35678c56ec73189ba6fa19ff1c5e4807a78bf60ef487b9dd272cc71"}, 671 | {file = "ruff-0.11.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ffbc82d70424b275b089166310448051afdc6e914fdab90e08df66c43bb5ca9"}, 672 | {file = "ruff-0.11.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4a9ddd3ec62a9a89578c85842b836e4ac832d4a2e0bfaad3b02243f930ceafcc"}, 673 | {file = "ruff-0.11.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d237a496e0778d719efb05058c64d28b757c77824e04ffe8796c7436e26712b7"}, 674 | {file = "ruff-0.11.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26816a218ca6ef02142343fd24c70f7cd8c5aa6c203bca284407adf675984432"}, 675 | {file = "ruff-0.11.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:51c3f95abd9331dc5b87c47ac7f376db5616041173826dfd556cfe3d4977f492"}, 676 | {file = "ruff-0.11.13-py3-none-win32.whl", hash = "sha256:96c27935418e4e8e77a26bb05962817f28b8ef3843a6c6cc49d8783b5507f250"}, 677 | {file = "ruff-0.11.13-py3-none-win_amd64.whl", hash = "sha256:29c3189895a8a6a657b7af4e97d330c8a3afd2c9c8f46c81e2fc5a31866517e3"}, 678 | {file = "ruff-0.11.13-py3-none-win_arm64.whl", hash = "sha256:b4385285e9179d608ff1d2fb9922062663c658605819a6876d8beef0c30b7f3b"}, 679 | {file = "ruff-0.11.13.tar.gz", hash = "sha256:26fa247dc68d1d4e72c179e08889a25ac0c7ba4d78aecfc835d49cbfd60bf514"}, 680 | ] 681 | 682 | [[package]] 683 | name = "setuptools" 684 | version = "80.9.0" 685 | description = "Easily download, build, install, upgrade, and uninstall Python packages" 686 | optional = false 687 | python-versions = ">=3.9" 688 | files = [ 689 | {file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"}, 690 | {file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"}, 691 | ] 692 | 693 | [package.extras] 694 | check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.8.0)"] 695 | core = ["importlib_metadata (>=6)", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] 696 | cover = ["pytest-cov"] 697 | doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] 698 | enabler = ["pytest-enabler (>=2.2)"] 699 | test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] 700 | type = ["importlib_metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.14.*)", "pytest-mypy"] 701 | 702 | [[package]] 703 | name = "shellingham" 704 | version = "1.5.4" 705 | description = "Tool to Detect Surrounding Shell" 706 | optional = false 707 | python-versions = ">=3.7" 708 | files = [ 709 | {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, 710 | {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, 711 | ] 712 | 713 | [[package]] 714 | name = "six" 715 | version = "1.17.0" 716 | description = "Python 2 and 3 compatibility utilities" 717 | optional = false 718 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" 719 | files = [ 720 | {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, 721 | {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, 722 | ] 723 | 724 | [[package]] 725 | name = "taskipy" 726 | version = "1.14.1" 727 | description = "tasks runner for python projects" 728 | optional = false 729 | python-versions = "<4.0,>=3.6" 730 | files = [ 731 | {file = "taskipy-1.14.1-py3-none-any.whl", hash = "sha256:6e361520f29a0fd2159848e953599f9c75b1d0b047461e4965069caeb94908f1"}, 732 | {file = "taskipy-1.14.1.tar.gz", hash = "sha256:410fbcf89692dfd4b9f39c2b49e1750b0a7b81affd0e2d7ea8c35f9d6a4774ed"}, 733 | ] 734 | 735 | [package.dependencies] 736 | colorama = ">=0.4.4,<0.5.0" 737 | mslex = {version = ">=1.1.0,<2.0.0", markers = "sys_platform == \"win32\""} 738 | psutil = ">=5.7.2,<7" 739 | tomli = {version = ">=2.0.1,<3.0.0", markers = "python_version >= \"3.7\" and python_version < \"4.0\""} 740 | 741 | [[package]] 742 | name = "tomli" 743 | version = "2.2.1" 744 | description = "A lil' TOML parser" 745 | optional = false 746 | python-versions = ">=3.8" 747 | files = [ 748 | {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, 749 | {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, 750 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, 751 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, 752 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, 753 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, 754 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, 755 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, 756 | {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, 757 | {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, 758 | {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, 759 | {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, 760 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, 761 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, 762 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, 763 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, 764 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, 765 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, 766 | {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, 767 | {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, 768 | {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, 769 | {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, 770 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, 771 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, 772 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, 773 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, 774 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, 775 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, 776 | {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, 777 | {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, 778 | {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, 779 | {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, 780 | ] 781 | 782 | [[package]] 783 | name = "typer" 784 | version = "0.16.0" 785 | description = "Typer, build great CLIs. Easy to code. Based on Python type hints." 786 | optional = false 787 | python-versions = ">=3.7" 788 | files = [ 789 | {file = "typer-0.16.0-py3-none-any.whl", hash = "sha256:1f79bed11d4d02d4310e3c1b7ba594183bcedb0ac73b27a9e5f28f6fb5b98855"}, 790 | {file = "typer-0.16.0.tar.gz", hash = "sha256:af377ffaee1dbe37ae9440cb4e8f11686ea5ce4e9bae01b84ae7c63b87f1dd3b"}, 791 | ] 792 | 793 | [package.dependencies] 794 | click = ">=8.0.0" 795 | rich = ">=10.11.0" 796 | shellingham = ">=1.3.0" 797 | typing-extensions = ">=3.7.4.3" 798 | 799 | [[package]] 800 | name = "typing-extensions" 801 | version = "4.14.0" 802 | description = "Backported and Experimental Type Hints for Python 3.9+" 803 | optional = false 804 | python-versions = ">=3.9" 805 | files = [ 806 | {file = "typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af"}, 807 | {file = "typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4"}, 808 | ] 809 | 810 | [[package]] 811 | name = "typing-inspection" 812 | version = "0.4.1" 813 | description = "Runtime typing introspection tools" 814 | optional = false 815 | python-versions = ">=3.9" 816 | files = [ 817 | {file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"}, 818 | {file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"}, 819 | ] 820 | 821 | [package.dependencies] 822 | typing-extensions = ">=4.12.0" 823 | 824 | [metadata] 825 | lock-version = "2.0" 826 | python-versions = ">=3.11,<3.13" 827 | content-hash = "598c35cc1960f9f343a340de493b443c6fe91d7a8d39b43d7fe1a928017231c2" 828 | --------------------------------------------------------------------------------