pax_global_header00006660000000000000000000000064136410034330014507gustar00rootroot0000000000000052 comment=340861d5c3d0dc50d416b7ee2610d3a0a60d22b6 ctalkington-python-directv-340861d/000077500000000000000000000000001364100343300172545ustar00rootroot00000000000000ctalkington-python-directv-340861d/.devcontainer/000077500000000000000000000000001364100343300220135ustar00rootroot00000000000000ctalkington-python-directv-340861d/.devcontainer/devcontainer.json000066400000000000000000000011131364100343300253630ustar00rootroot00000000000000{ "name": "Python DirecTV Dev", "context": "..", "dockerFile": "../Dockerfile.dev", "settings": { "terminal.integrated.shell.linux": "/bin/bash", "python.pythonPath": "/usr/local/bin/python", "python.linting.enabled": true, "python.linting.pylintEnabled": true, "python.linting.pylintPath": "/usr/local/bin/pylint", "python.formatting.provider": "black", "editor.formatOnPaste": false, "editor.formatOnSave": true, "editor.formatOnType": true, "files.trimTrailingWhitespace": true }, "extensions": [ "ms-python.python", "esbenp.prettier-vscode" ] } ctalkington-python-directv-340861d/.editorconfig000066400000000000000000000004671364100343300217400ustar00rootroot00000000000000root = true [*] charset = utf-8 end_of_line = lf indent_style = space insert_final_newline = true trim_trailing_whitespace = true ident_size = 4 [*.md] ident_size = 2 trim_trailing_whitespace = false [*.json] ident_size = 2 [{.gitignore,.gitkeep,.editorconfig}] ident_size = 2 [Makefile] ident_style = tab ctalkington-python-directv-340861d/.flake8000066400000000000000000000000501364100343300204220ustar00rootroot00000000000000[flake8] max-line-length=88 ignore=D202 ctalkington-python-directv-340861d/.github/000077500000000000000000000000001364100343300206145ustar00rootroot00000000000000ctalkington-python-directv-340861d/.github/release-drafter.yml000066400000000000000000000000561364100343300244050ustar00rootroot00000000000000template: | ## What’s Changed $CHANGES ctalkington-python-directv-340861d/.github/workflows/000077500000000000000000000000001364100343300226515ustar00rootroot00000000000000ctalkington-python-directv-340861d/.github/workflows/ci.yml000066400000000000000000000034541364100343300237750ustar00rootroot00000000000000--- name: Continuous Integration on: push: branches: - master pull_request: branches: - master jobs: linting: name: Linting runs-on: ubuntu-latest steps: - name: Checking out code from GitHub uses: actions/checkout@v1 - name: Set up Python 3.7 uses: actions/setup-python@v1 with: python-version: 3.7 - name: Install dependencies run: | python -m pip install --upgrade pip setuptools wheel pip install -r requirements_test.txt pip install -r requirements.txt pip install pre-commit pip list pre-commit --version - name: Run pre-commit on all files run: | pre-commit run --all-files --show-diff-on-failure test: name: Python ${{ matrix.python }} on ${{ matrix.os }} runs-on: ${{ matrix.os }}-latest needs: [linting] strategy: matrix: os: [ubuntu] python: [3.7, 3.8] steps: - name: Checking out code from GitHub uses: actions/checkout@v1 - name: Set up Python ${{ matrix.python }} uses: actions/setup-python@v1 with: python-version: ${{ matrix.python }} - name: Install dependencies run: | python -m pip install --upgrade pip setuptools wheel pip install -r requirements_test.txt pip install -r requirements.txt pip list - name: Pytest with coverage reporting run: pytest --cov=directv --cov-report=xml - name: Upload coverage to Codecov if: matrix.python == 3.8 && matrix.os == 'ubuntu' uses: codecov/codecov-action@v1.0.3 with: token: ${{ secrets.CODECOV_TOKEN }} file: ./coverage.xml flags: unittests name: codecov-umbrella ctalkington-python-directv-340861d/.github/workflows/publish.yml000066400000000000000000000015431364100343300250450ustar00rootroot00000000000000# This workflows will upload a Python Package using Twine when a release is created # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries name: Upload Python Package on: release: types: [published] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Set up Python uses: actions/setup-python@v1 with: python-version: '3.x' - name: Install dependencies run: | python -m pip install --upgrade pip pip install setuptools wheel twine - name: Build and publish env: TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} run: | python setup.py sdist bdist_wheel twine upload dist/* ctalkington-python-directv-340861d/.github/workflows/release-drafter.yml000066400000000000000000000004261364100343300264430ustar00rootroot00000000000000name: Release Drafter on: push: branches: - master jobs: update_release_draft: name: Update Release Draft runs-on: ubuntu-latest steps: - uses: release-drafter/release-drafter@v5 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ctalkington-python-directv-340861d/.gitignore000066400000000000000000000034071364100343300212500ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ ctalkington-python-directv-340861d/.isort.cfg000066400000000000000000000001321364100343300211470ustar00rootroot00000000000000[settings] multi_line_output=3 line_length=88 include_trailing_comma=True project=directv ctalkington-python-directv-340861d/.pre-commit-config.yaml000066400000000000000000000031771364100343300235450ustar00rootroot00000000000000--- repos: - repo: https://github.com/ambv/black rev: 19.10b0 hooks: - id: black args: [--safe, --quiet, --target-version, py36] - repo: https://github.com/asottile/blacken-docs rev: v1.6.0 hooks: - id: blacken-docs additional_dependencies: [black==19.3b0] - repo: https://github.com/pre-commit/pre-commit-hooks rev: v2.5.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-merge-conflict - id: debug-statements - id: check-docstring-first - id: check-json - id: check-yaml - id: requirements-txt-fixer - id: check-byte-order-marker - id: check-case-conflict - id: fix-encoding-pragma args: ["--remove"] - id: check-ast - id: detect-private-key - id: forbid-new-submodules - repo: https://github.com/pre-commit/pre-commit rev: v2.1.1 hooks: - id: validate_manifest - repo: https://github.com/pre-commit/mirrors-isort rev: v4.3.21 hooks: - id: isort - repo: https://github.com/pre-commit/mirrors-pylint rev: v2.4.4 hooks: - id: pylint - repo: https://github.com/pre-commit/mirrors-mypy rev: v0.761 hooks: - id: mypy - repo: https://gitlab.com/pycqa/flake8 rev: 3.7.9 hooks: - id: flake8 additional_dependencies: ["flake8-docstrings"] - repo: https://github.com/adrienverge/yamllint.git rev: v1.20.0 hooks: - id: yamllint exclude: ^\.github/workflows/*\.yml$ - repo: https://github.com/asottile/pyupgrade rev: v2.1.0 hooks: - id: pyupgrade args: [--py36-plus] ctalkington-python-directv-340861d/.yamllint000066400000000000000000000024031364100343300211050ustar00rootroot00000000000000--- ignore: | .github/ rules: braces: level: error min-spaces-inside: 0 max-spaces-inside: 1 min-spaces-inside-empty: -1 max-spaces-inside-empty: -1 brackets: level: error min-spaces-inside: 0 max-spaces-inside: 0 min-spaces-inside-empty: -1 max-spaces-inside-empty: -1 colons: level: error max-spaces-before: 0 max-spaces-after: 1 commas: level: error max-spaces-before: 0 min-spaces-after: 1 max-spaces-after: 1 comments: level: error require-starting-space: true min-spaces-from-content: 2 comments-indentation: level: error document-end: level: error present: false document-start: level: error present: true empty-lines: level: error max: 1 max-start: 0 max-end: 1 hyphens: level: error max-spaces-after: 1 indentation: level: error spaces: 2 indent-sequences: true check-multi-line-strings: false key-duplicates: level: error line-length: level: warning max: 120 allow-non-breakable-words: true allow-non-breakable-inline-mappings: true new-line-at-end-of-file: level: error new-lines: level: error type: unix trailing-spaces: level: error truthy: level: error ctalkington-python-directv-340861d/Dockerfile.dev000066400000000000000000000052221364100343300220240ustar00rootroot00000000000000#------------------------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. #------------------------------------------------------------------------------------------------------------- FROM python:3 # Avoid warnings by switching to noninteractive ENV DEBIAN_FRONTEND=noninteractive # This Dockerfile adds a non-root user with sudo access. Use the "remoteUser" # property in devcontainer.json to use it. On Linux, the container user's GID/UIDs # will be updated to match your local UID/GID (when using the dockerFile property). # See https://aka.ms/vscode-remote/containers/non-root-user for details. ARG USERNAME=vscode ARG USER_UID=1000 ARG USER_GID=$USER_UID # Uncomment the following COPY line and the corresponding lines in the `RUN` command if you wish to # include your requirements in the image itself. It is suggested that you only do this if your # requirements rarely (if ever) change. # COPY requirements.txt /tmp/pip-tmp/ # Configure apt and install packages RUN apt-get update \ && apt-get -y install --no-install-recommends apt-utils dialog 2>&1 \ # # Verify git, process tools, lsb-release (common in install instructions for CLIs) installed && apt-get -y install git openssh-client iproute2 procps lsb-release \ # # Install pylint && pip --disable-pip-version-check --no-cache-dir install pylint \ # # Update Python environment based on requirements.txt # && pip --disable-pip-version-check --no-cache-dir install -r /tmp/pip-tmp/requirements.txt \ # && rm -rf /tmp/pip-tmp \ # # Create a non-root user to use if preferred - see https://aka.ms/vscode-remote/containers/non-root-user. && groupadd --gid $USER_GID $USERNAME \ && useradd -s /bin/bash --uid $USER_UID --gid $USER_GID -m $USERNAME \ # [Optional] Add sudo support for the non-root user && apt-get install -y sudo \ && echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME\ && chmod 0440 /etc/sudoers.d/$USERNAME \ # # Clean up && apt-get autoremove -y \ && apt-get clean -y \ && rm -rf /var/lib/apt/lists/* # Switch back to dialog for any ad-hoc use of apt-get ENV DEBIAN_FRONTEND=dialog # Install Python dependencies from requirements COPY requirements.txt requirements_test.txt requirements_dev.txt ./ RUN pip3 install -r requirements_test.txt; \ pip3 install -r requirements_dev.txt; \ pip install -Ur requirements.txt; \ rm -f requirements.txt requirements_test.txt requirements_dev.txt ctalkington-python-directv-340861d/LICENSE000066400000000000000000000020611364100343300202600ustar00rootroot00000000000000MIT License Copyright (c) 2020 Chris Talkington 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: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 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. ctalkington-python-directv-340861d/MANIFEST.in000066400000000000000000000001201364100343300210030ustar00rootroot00000000000000include README.md include LICENSE.md graft directv recursive-exclude * *.py[co] ctalkington-python-directv-340861d/README.md000066400000000000000000000012441364100343300205340ustar00rootroot00000000000000# Python: DirecTV (SHEF) Client Asynchronous Python client for DirecTV receivers using the [SHEF](http://forums.solidsignal.com/docs/DTV-MD-0359-DIRECTV_SHEF_Command_Set-V1.3.C.pdf) protocol. ## Aboout This package allows you to monitor and control a DirecTV receiver and its associated client devices. ## Installation ```bash pip install directv ``` ## Usage ```python import asyncio from directv import DIRECTV async def main(): """Show example of connecting to your DIRECTV receiver.""" async with DIRECTV("192.168.1.100") as dtv: print(dtv) if __name__ == "__main__": loop = asyncio.get_event_loop() loop.run_until_complete(main()) ``` ctalkington-python-directv-340861d/directv/000077500000000000000000000000001364100343300207145ustar00rootroot00000000000000ctalkington-python-directv-340861d/directv/__init__.py000066400000000000000000000002471364100343300230300ustar00rootroot00000000000000"""Asynchronous Python client for DirecTV.""" from .directv import ( # noqa DIRECTV, DIRECTVAccessRestricted, DIRECTVConnectionError, DIRECTVError, ) ctalkington-python-directv-340861d/directv/__version__.py000066400000000000000000000001051364100343300235430ustar00rootroot00000000000000"""Asynchronous Python client for DirecTV.""" __version__ = "0.3.0" ctalkington-python-directv-340861d/directv/const.py000066400000000000000000000010721364100343300224140ustar00rootroot00000000000000"""Constants for DirecTV.""" VALID_REMOTE_KEYS = [ "power", "poweron", "poweroff", "format", "pause", "rew", "replay", "stop", "advance", "ffwd", "record", "play", "guide", "active", "list", "exit", "back", "menu", "info", "up", "down", "left", "right", "select", "red", "green", "yellow", "blue", "chanup", "chandown", "prev", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "dash", "enter", ] ctalkington-python-directv-340861d/directv/directv.py000066400000000000000000000170441364100343300227340ustar00rootroot00000000000000"""Asynchronous Python client for DirecTV.""" import asyncio import json from socket import gaierror as SocketGIAEroor from typing import Any, Mapping, Optional import aiohttp import async_timeout from yarl import URL from .__version__ import __version__ from .const import VALID_REMOTE_KEYS from .exceptions import DIRECTVAccessRestricted, DIRECTVConnectionError, DIRECTVError from .models import Device, Program, State from .utils import parse_channel_number class DIRECTV: """Main class for handling connections with DirecTV servers.""" _device: Optional[Device] = None def __init__( self, host: str, base_path: str = "/", password: str = None, port: int = 8080, request_timeout: int = 8, session: aiohttp.client.ClientSession = None, username: str = None, user_agent: str = None, ) -> None: """Initialize connection with receiver.""" self._session = session self._close_session = False self.base_path = base_path self.host = host self.password = password self.port = port self.request_timeout = request_timeout self.username = username self.user_agent = user_agent if user_agent is None: self.user_agent = f"PythonDirecTV/{__version__}" async def _request( self, uri: str = "", method: str = "GET", data: Optional[Any] = None, params: Optional[Mapping[str, str]] = None, ) -> Any: """Handle a request to a receiver.""" scheme = "http" url = URL.build( scheme=scheme, host=self.host, port=self.port, path=self.base_path ).join(URL(uri)) auth = None if self.username and self.password: auth = aiohttp.BasicAuth(self.username, self.password) headers = { "User-Agent": self.user_agent, "Accept": "application/json, text/plain, */*", } if self._session is None: self._session = aiohttp.ClientSession() self._close_session = True try: with async_timeout.timeout(self.request_timeout): response = await self._session.request( method, url, auth=auth, data=data, params=params, headers=headers, ) except asyncio.TimeoutError as exception: raise DIRECTVConnectionError( "Timeout occurred while connecting to receiver" ) from exception except (aiohttp.ClientError, SocketGIAEroor) as exception: raise DIRECTVConnectionError( "Error occurred while communicating with receiver" ) from exception if response.status == 403: raise DIRECTVAccessRestricted( "Access restricted. Please ensure external device access is allowed", {}, ) content_type = response.headers.get("Content-Type") if (response.status // 100) in [4, 5]: content = await response.read() response.close() if content_type == "application/json": raise DIRECTVError( f"HTTP {response.status}", json.loads(content.decode("utf8")) ) raise DIRECTVError( f"HTTP {response.status}", { "content-type": content_type, "message": content.decode("utf8"), "status-code": response.status, }, ) if "application/json" in content_type: data = await response.json() return data return await response.text() @property def device(self) -> Optional[Device]: """Return the cached Device object.""" return self._device async def update(self, full_update: bool = False) -> Device: """Get all information about the device in a single call.""" if self._device is None or full_update: info = await self._request("info/getVersion") if info is None: raise DIRECTVError("DirecTV device returned an empty API response") locations = await self._request("info/getLocations") if locations is None or "locations" not in locations: raise DIRECTVError("DirecTV device returned an empty API response") self._device = Device({"info": info, "locations": locations["locations"]}) return self._device self._device.update_from_dict({}) return self._device async def remote(self, key: str, client: str = "0") -> None: """Emulate pressing a key on the remote. Supported keys: power, poweron, poweroff, format, pause, rew, replay, stop, advance, ffwd, record, play, guide, active, list, exit, back, menu, info, up, down, left, right, select, red, green, yellow, blue, chanup, chandown, prev, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, dash, enter """ if not key.lower() in VALID_REMOTE_KEYS: raise DIRECTVError(f"Remote key is invalid: {key}") keypress = { "key": key, "hold": "keyPress", "clientAddr": client, } await self._request("remote/processKey", params=keypress) async def state(self, client: str = "0") -> State: """Get state of receiver client.""" authorized = True program = None try: mode = await self._request("info/mode", params={"clientAddr": client}) available = True standby = mode["mode"] == 1 except DIRECTVAccessRestricted: authorized = False available = False standby = True except DIRECTVError: available = False standby = True if not standby: try: program = await self.tuned(client) except DIRECTVAccessRestricted: authorized = False program = None except DIRECTVError: available = False program = None return State( authorized=authorized, available=available, standby=standby, program=program, ) async def status(self, client: str = "0") -> str: """Get basic status of receiver client.""" try: mode = await self._request("info/mode", params={"clientAddr": client}) return "standby" if mode["mode"] == 1 else "active" except DIRECTVAccessRestricted: return "unauthorized" except DIRECTVError: return "unavailable" async def tune(self, channel: str, client: str = "0") -> None: """Change the channel on the receiver.""" major, minor = parse_channel_number(channel) tune = { "major": major, "minor": minor, "clientAddr": client, } await self._request("tv/tune", params=tune) async def tuned(self, client: str = "0") -> Program: """Get currently tuned program.""" tuned = await self._request("tv/getTuned", params={"clientAddr": client}) return Program.from_dict(tuned) async def close(self) -> None: """Close open client session.""" if self._session and self._close_session: await self._session.close() async def __aenter__(self) -> "DIRECTV": """Async enter.""" return self async def __aexit__(self, *exc_info) -> None: """Async exit.""" await self.close() ctalkington-python-directv-340861d/directv/exceptions.py000066400000000000000000000004541364100343300234520ustar00rootroot00000000000000"""Exceptions for DirecTV.""" class DIRECTVError(Exception): """Generic DirecTV exception.""" pass class DIRECTVConnectionError(DIRECTVError): """DirecTV connection exception.""" pass class DIRECTVAccessRestricted(DIRECTVError): """DirecTV access restricted.""" pass ctalkington-python-directv-340861d/directv/models.py000066400000000000000000000107171364100343300225570ustar00rootroot00000000000000"""Models for DirecTV.""" from dataclasses import dataclass from datetime import datetime, timezone from typing import List, Optional from .exceptions import DIRECTVError from .utils import combine_channel_number @dataclass(frozen=True) class Info: """Object holding information from DirecTV.""" brand: str receiver_id: str version: str @staticmethod def from_dict(data: dict): """Return Info object from DirecTV API response.""" receiver_id = data.get("receiverId", "") return Info( brand="DirecTV", receiver_id="".join(receiver_id.split()), version=data.get("stbSoftwareVersion", "Unknown"), ) @dataclass(frozen=True) class Location: """Object holding all information of receiver client location.""" client: bool name: str address: str @staticmethod def from_dict(data: dict): """Return Info object from DirecTV API response.""" address = data.get("clientAddr", "") return Location( client=address != "0", name=data.get("locationName", "Receiver"), address=address, ) @dataclass(frozen=True) class Program: """Object holding all information of playing program.""" channel: str channel_name: str ondemand: bool recorded: bool recording: bool viewed: bool program_id: int program_type: str duration: int title: str episode_title: str music_title: str music_album: str music_artist: str partial: bool payperview: bool position: int purchased: bool rating: str start_time: datetime unique_id: int @staticmethod def from_dict(data: dict): """Return Info object from DirecTV API response.""" major = data.get("major", 0) minor = data.get("minor", 65535) episode_title = data.get("episodeTitle", None) music = data.get("music", {}) music_title = music.get("title", None) program_type = "movie" if episode_title is not None: program_type = "tvshow" elif music_title is not None: program_type = "music" start_time = data.get("startTime", None) if start_time: start_time = datetime.fromtimestamp(start_time, timezone.utc) unique_id = data.get("uniqueId", None) return Program( channel=combine_channel_number(major, minor), channel_name=data.get("callsign", None), program_id=data.get("programId", None), program_type=program_type, duration=data.get("duration", 0), title=data.get("title", None), episode_title=episode_title, music_title=music_title, music_album=music.get("cd", None), music_artist=music.get("by", None), ondemand=data.get("isVod", False), partial=data.get("isPartial", False), payperview=data.get("isPpv", False), position=data.get("offset", 0), purchased=data.get("isPurchased", False), rating=data.get("rating", None), recorded=(unique_id is not None), recording=data.get("isRecording", False), start_time=start_time, unique_id=unique_id, viewed=data.get("isViewed", False), ) @dataclass(frozen=True) class State: """Object holding all information of a single receiver client state.""" authorized: bool available: bool standby: bool program: Optional[Program] at: datetime = datetime.utcnow() class Device: """Object holding all information of receiver.""" info: Info locations: List[Location] = [] def __init__(self, data: dict): """Initialize an empty DirecTV device class.""" # Check if all elements are in the passed dict, else raise an Error if any(k not in data for k in ["locations", "info"]): raise DIRECTVError( "DirecTV data is incomplete, cannot construct device object" ) self.update_from_dict(data) def update_from_dict(self, data: dict) -> "Device": """Return Device object from DirecTV API response.""" if "info" in data and data["info"]: self.info = Info.from_dict(data["info"]) if "locations" in data and data["locations"]: locations = [Location.from_dict(location) for location in data["locations"]] self.locations = locations return self ctalkington-python-directv-340861d/directv/utils.py000066400000000000000000000010311364100343300224210ustar00rootroot00000000000000"""Helpers for DirecTV.""" from typing import Tuple def parse_channel_number(channel: str) -> Tuple[str, str]: """Convert a channel number into its major and minor.""" try: major, minor = channel.split("-") except ValueError: major = channel minor = "65535" return major, minor def combine_channel_number(major: int, minor: int) -> str: """Create a combined channel number from its major and minor.""" if minor == 65535: return str(major) return "%d-%d" % (major, minor) ctalkington-python-directv-340861d/pylintrc000066400000000000000000000260721364100343300210520ustar00rootroot00000000000000[MASTER] # Specify a configuration file. #rcfile= # Python code to execute, usually for sys.path manipulation such as # pygtk.require(). #init-hook= # Add files or directories to the blacklist. They should be base names, not # paths. ignore=tests # Pickle collected data for later comparisons. persistent=yes # List of plugins (as comma separated values of python modules names) to load, # usually to register additional checkers. load-plugins= # Use multiple processes to speed up Pylint. jobs=1 # Allow loading of arbitrary C extensions. Extensions are imported into the # active Python interpreter and may run arbitrary code. unsafe-load-any-extension=no # A comma-separated list of package or module names from where C extensions may # be loaded. Extensions are loading into the active Python interpreter and may # run arbitrary code extension-pkg-whitelist= [MESSAGES CONTROL] # Only show warnings with the listed confidence levels. Leave empty to show # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED confidence= # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option # multiple time. See also the "--disable" option for examples. #enable= # Disable the message, report, category or checker with the given id(s). You # can either give multiple identifiers separated by comma (,) or put this # option multiple times (only on the command line, not in the configuration # file where it should appear only once).You can also use "--disable=all" to # disable everything first and then reenable specific checks. For example, if # you want to run only the similarities checker, you can use "--disable=all # --enable=similarities". If you want to run only the classes checker, but have # no Warning level messages displayed, use"--disable=all --enable=classes # --disable=W" disable= attribute-defined-outside-init, duplicate-code, fixme, import-error, invalid-name, missing-docstring, protected-access, too-few-public-methods, too-many-arguments, too-many-branches, too-many-instance-attributes, too-many-lines, too-many-locals, too-many-public-methods, too-many-return-statements, too-many-statements, unnecessary-pass, # handled by black format [REPORTS] # Set the output format. Available formats are text, parseable, colorized, msvs # (visual studio) and html. You can also give a reporter class, eg # mypackage.mymodule.MyReporterClass. output-format=text # Put messages in a separate file for each module / package specified on the # command line instead of printing them on stdout. Reports (if any) will be # written in a file name "pylint_global.[txt|html]". files-output=no # Tells whether to display a full report or only the messages reports=no # Python expression which should return a note less than 10 (10 is the highest # note). You have access to the variables errors warning, statement which # respectively contain the number of errors / warnings messages and the total # number of statements analyzed. This is used by the global evaluation report # (RP0004). evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) # Template used to display messages. This is a python new-style format string # used to format the message information. See doc for all details #msg-template= [LOGGING] # Logging modules to check that the string format arguments are in logging # function parameter format logging-modules=logging [MISCELLANEOUS] # List of note tags to take in consideration, separated by a comma. notes=FIXME,XXX,TODO [SIMILARITIES] # Minimum lines number of a similarity. min-similarity-lines=4 # Ignore comments when computing similarities. ignore-comments=yes # Ignore docstrings when computing similarities. ignore-docstrings=yes # Ignore imports when computing similarities. ignore-imports=yes [VARIABLES] # Tells whether we should check for unused import in __init__ files. init-import=no # A regular expression matching the name of dummy variables (i.e. expectedly # not used). dummy-variables-rgx=_$|dummy # List of additional names supposed to be defined in builtins. Remember that # you should avoid defining new builtins when possible. additional-builtins= # List of strings which can identify a callback function by name. A callback # name must start or end with one of those strings. callbacks=cb_,_cb [FORMAT] # Maximum number of characters on a single line. max-line-length=88 # Regexp for a line that is allowed to be longer than the limit. ignore-long-lines=^\s*(# )??$ # Allow the body of an if to be on the same line as the test if there is no # else. single-line-if-stmt=no # List of optional constructs for which whitespace checking is disabled no-space-check=trailing-comma,dict-separator # Maximum number of lines in a module max-module-lines=2000 # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 # tab). indent-string=' ' # Number of spaces of indent required inside a hanging or continued line. indent-after-paren=4 # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. expected-line-ending-format= [BASIC] # List of builtins function names that should not be used, separated by a comma bad-functions=map,filter,input # Good variable names which should always be accepted, separated by a comma good-names=i,j,k,ex,Run,_ # Bad variable names which should always be refused, separated by a comma bad-names=foo,bar,baz,toto,tutu,tata # Colon-delimited sets of names that determine each other's naming style when # the name regexes allow several styles. name-group= # Include a hint for the correct naming format with invalid-name include-naming-hint=no # Regular expression matching correct function names function-rgx=[a-z_][a-z0-9_]{2,30}$ # Naming hint for function names function-name-hint=[a-z_][a-z0-9_]{2,30}$ # Regular expression matching correct variable names variable-rgx=[a-z_][a-z0-9_]{2,30}$ # Naming hint for variable names variable-name-hint=[a-z_][a-z0-9_]{2,30}$ # Regular expression matching correct constant names const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ # Naming hint for constant names const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ # Regular expression matching correct attribute names attr-rgx=[a-z_][a-z0-9_]{2,}$ # Naming hint for attribute names attr-name-hint=[a-z_][a-z0-9_]{2,}$ # Regular expression matching correct argument names argument-rgx=[a-z_][a-z0-9_]{2,30}$ # Naming hint for argument names argument-name-hint=[a-z_][a-z0-9_]{2,30}$ # Regular expression matching correct class attribute names class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ # Naming hint for class attribute names class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ # Regular expression matching correct inline iteration names inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ # Naming hint for inline iteration names inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ # Regular expression matching correct class names class-rgx=[A-Z_][a-zA-Z0-9]+$ # Naming hint for class names class-name-hint=[A-Z_][a-zA-Z0-9]+$ # Regular expression matching correct module names module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ # Naming hint for module names module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ # Regular expression matching correct method names method-rgx=[a-z_][a-z0-9_]{2,}$ # Naming hint for method names method-name-hint=[a-z_][a-z0-9_]{2,}$ # Regular expression which should only match function or class names that do # not require a docstring. no-docstring-rgx=__.*__ # Minimum line length for functions/classes that require docstrings, shorter # ones are exempt. docstring-min-length=-1 # List of decorators that define properties, such as abc.abstractproperty. property-classes=abc.abstractproperty [TYPECHECK] # Tells whether missing members accessed in mixin class should be ignored. A # mixin class is detected if its name ends with "mixin" (case insensitive). ignore-mixin-members=yes # List of module names for which member attributes should not be checked # (useful for modules/projects where namespaces are manipulated during runtime # and thus existing member attributes cannot be deduced by static analysis ignored-modules= # List of classes names for which member attributes should not be checked # (useful for classes with attributes dynamically set). ignored-classes= # List of members which are set dynamically and missed by pylint inference # system, and so shouldn't trigger E1101 when accessed. Python regular # expressions are accepted. generated-members= # List of decorators that create context managers from functions, such as # contextlib.contextmanager. contextmanager-decorators=contextlib.contextmanager [SPELLING] # Spelling dictionary name. Available dictionaries: none. To make it working # install python-enchant package. spelling-dict= # List of comma separated words that should not be checked. spelling-ignore-words= # A path to a file that contains private dictionary; one word per line. spelling-private-dict-file= # Tells whether to store unknown words to indicated private dictionary in # --spelling-private-dict-file option instead of raising a message. spelling-store-unknown-words=no [DESIGN] # Maximum number of arguments for function / method max-args=10 # Argument names that match this expression will be ignored. Default to name # with leading underscore ignored-argument-names=_.* # Maximum number of locals for function / method body max-locals=25 # Maximum number of return / yield for function / method body max-returns=11 # Maximum number of branch for function / method body max-branches=26 # Maximum number of statements in function / method body max-statements=100 # Maximum number of parents for a class (see R0901). max-parents=7 # Maximum number of attributes for a class (see R0902). max-attributes=11 # Minimum number of public methods for a class (see R0903). min-public-methods=2 # Maximum number of public methods for a class (see R0904). max-public-methods=25 [CLASSES] # List of method names used to declare (i.e. assign) instance attributes. defining-attr-methods=__init__,__new__,setUp # List of valid names for the first argument in a class method. valid-classmethod-first-arg=cls # List of valid names for the first argument in a metaclass class method. valid-metaclass-classmethod-first-arg=mcs # List of member names, which should be excluded from the protected access # warning. exclude-protected=_asdict,_fields,_replace,_source,_make [IMPORTS] # Deprecated modules which should not be used, separated by a comma deprecated-modules=regsub,TERMIOS,Bastion,rexec # Create a graph of every (i.e. internal and external) dependencies in the # given file (report RP0402 must not be disabled) import-graph= # Create a graph of external dependencies in the given file (report RP0402 must # not be disabled) ext-import-graph= # Create a graph of internal dependencies in the given file (report RP0402 must # not be disabled) int-import-graph= [EXCEPTIONS] # Exceptions that will emit a warning when being caught. Defaults to # "Exception" overgeneral-exceptions=Exception ctalkington-python-directv-340861d/requirements.txt000066400000000000000000000000331364100343300225340ustar00rootroot00000000000000aiohttp==3.6.2 yarl==1.4.2 ctalkington-python-directv-340861d/requirements_dev.txt000066400000000000000000000001551364100343300233770ustar00rootroot00000000000000black==19.10b0 blacken-docs==1.6.0 pip==20.0.2 pre-commit==2.1.1 twine==3.1.1 wheel==0.34.2 yamllint==1.20.0 ctalkington-python-directv-340861d/requirements_test.txt000066400000000000000000000002501364100343300235740ustar00rootroot00000000000000aresponses==1.1.2 coverage==5.0.3 flake8==3.7.9 flake8-docstrings==1.5.0 isort==4.3.21 mypy==0.761 pylint==2.4.4 pytest==5.3.5 pytest-asyncio==0.10.0 pytest-cov==2.8.1 ctalkington-python-directv-340861d/setup.py000066400000000000000000000032711364100343300207710ustar00rootroot00000000000000#!/usr/bin/env python """The setup script.""" import os import re import sys from setuptools import find_packages, setup def get_version(): """Get current version from code.""" regex = r"__version__\s=\s\"(?P[\d\.]+?)\"" path = ("directv", "__version__.py") return re.search(regex, read(*path)).group("version") def read(*parts): """Read file.""" filename = os.path.join(os.path.abspath(os.path.dirname(__file__)), *parts) sys.stdout.write(filename) with open(filename, encoding="utf-8", mode="rt") as fp: return fp.read() with open("README.md") as readme_file: readme = readme_file.read() setup( author="Chris Talkington", author_email="chris@talkingtontech.com", classifiers=[ "Development Status :: 4 - Beta", "Framework :: AsyncIO", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Natural Language :: English", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3", "Topic :: Software Development :: Libraries :: Python Modules", ], description="Asynchronous Python client for DirecTV (SHEF).", include_package_data=True, install_requires=list(val.strip() for val in open("requirements.txt")), keywords=["directv", "api", "async", "client", "shef"], license="MIT license", long_description_content_type="text/markdown", long_description=readme, name="directv", packages=find_packages(include=["directv"]), test_suite="tests", url="https://github.com/ctalkington/python-directv", version=get_version(), zip_safe=False, ) ctalkington-python-directv-340861d/tests/000077500000000000000000000000001364100343300204165ustar00rootroot00000000000000ctalkington-python-directv-340861d/tests/__init__.py000066400000000000000000000003341364100343300225270ustar00rootroot00000000000000"""Tests for DIRECTV.""" import os def load_fixture(filename): """Load a fixture.""" path = os.path.join(os.path.dirname(__file__), "fixtures", filename) with open(path) as fptr: return fptr.read() ctalkington-python-directv-340861d/tests/fixtures/000077500000000000000000000000001364100343300222675ustar00rootroot00000000000000ctalkington-python-directv-340861d/tests/fixtures/info-get-locations.json000066400000000000000000000004521364100343300266640ustar00rootroot00000000000000{ "locations": [ { "clientAddr": "0", "locationName": "Host" }, { "clientAddr": "2CA17D1CD30X", "locationName": "Client" } ], "status": { "code": 200, "commandResult": 0, "msg": "OK.", "query": "/info/getLocations?callback=jsonp" } } ctalkington-python-directv-340861d/tests/fixtures/info-get-version-error.json000066400000000000000000000002041364100343300275000ustar00rootroot00000000000000{ "status": { "code": 500, "commandResult": 1, "msg": "Internal Server Error.", "query": "/info/getVersion" } } ctalkington-python-directv-340861d/tests/fixtures/info-get-version.json000066400000000000000000000004101364100343300263500ustar00rootroot00000000000000{ "accessCardId": "0021-1495-6572", "receiverId": "0288 7745 5858", "status": { "code": 200, "commandResult": 0, "msg": "OK", "query": "/info/getVersion" }, "stbSoftwareVersion": "0x4ed7", "systemTime": 1281625203, "version": "1.2" } ctalkington-python-directv-340861d/tests/fixtures/info-mode-error.json000066400000000000000000000001761364100343300261720ustar00rootroot00000000000000{ "status": { "code": 500, "commandResult": 1, "msg": "Internal Server Error.", "query": "/info/mode" } } ctalkington-python-directv-340861d/tests/fixtures/info-mode-restricted.json000066400000000000000000000001621364100343300272040ustar00rootroot00000000000000{ "status": { "code": 403, "commandResult": 1, "msg": "Forbidden.", "query": "/info/mode" } } ctalkington-python-directv-340861d/tests/fixtures/info-mode-standby.json000066400000000000000000000001671364100343300265050ustar00rootroot00000000000000{ "mode": 1, "status": { "code": 200, "commandResult": 0, "msg": "OK", "query": "/info/mode" } } ctalkington-python-directv-340861d/tests/fixtures/info-mode.json000066400000000000000000000001671364100343300250430ustar00rootroot00000000000000{ "mode": 0, "status": { "code": 200, "commandResult": 0, "msg": "OK", "query": "/info/mode" } } ctalkington-python-directv-340861d/tests/fixtures/remote-process-key.json000066400000000000000000000002601364100343300267150ustar00rootroot00000000000000{ "hold": "keyPress", "key": "info", "status": { "code": 200, "commandResult": 0, "msg": "OK", "query": "/remote/processKey?key=info&hold=keyPress" } } ctalkington-python-directv-340861d/tests/fixtures/tv-get-prog-info.json000066400000000000000000000012341364100343300262660ustar00rootroot00000000000000{ "callsign": "FOODHD", "date": "20070324", "duration": 1791, "episodeTitle": "Spaghetti and Clam Sauce", "expiration": "0", "expiryTime": 0, "isOffAir": false, "isPartial": false, "isPclocked": 1, "isPpv": false, "isRecording": false, "isViewed": true, "isVod": false, "keepUntilFull": true, "major": 231, "minor": 65535, "offset": 263, "programId": "4405732", "rating": "No Rating", "recType": 3, "startTime": 1278342008, "stationId": 3900976, "status": { "code": 200, "commandResult": 0, "msg": "OK.", "query": "/tv/getProgInfo" }, "title": "Tyler's Ultimate", "uniqueId": "6728716739474078694" } ctalkington-python-directv-340861d/tests/fixtures/tv-get-tuned-error.json000066400000000000000000000001641364100343300266350ustar00rootroot00000000000000{ "status": { "code": 500, "commandResult": 1, "msg": "Forbidden.", "query": "/tv/getTuned" } } ctalkington-python-directv-340861d/tests/fixtures/tv-get-tuned-restricted.json000066400000000000000000000001641364100343300276540ustar00rootroot00000000000000{ "status": { "code": 403, "commandResult": 1, "msg": "Forbidden.", "query": "/tv/getTuned" } } ctalkington-python-directv-340861d/tests/fixtures/tv-get-tuned.json000066400000000000000000000012311364100343300255020ustar00rootroot00000000000000{ "callsign": "FOODHD", "date": "20070324", "duration": 1791, "episodeTitle": "Spaghetti and Clam Sauce", "expiration": "0", "expiryTime": 0, "isOffAir": false, "isPartial": false, "isPclocked": 1, "isPpv": false, "isRecording": false, "isViewed": true, "isVod": false, "keepUntilFull": true, "major": 231, "minor": 65535, "offset": 263, "programId": "4405732", "rating": "No Rating", "recType": 3, "startTime": 1278342008, "stationId": 3900976, "status": { "code": 200, "commandResult": 0, "msg": "OK.", "query": "/tv/getTuned" }, "title": "Tyler's Ultimate", "uniqueId": "6728716739474078694" } ctalkington-python-directv-340861d/tests/fixtures/tv-tune-conflict.json000066400000000000000000000001771364100343300263700ustar00rootroot00000000000000{ "status": { "code": 500, "commandResult": 1, "msg": "Request conflict.", "query": "/tv/tune?major=5" } } ctalkington-python-directv-340861d/tests/fixtures/tv-tune.json000066400000000000000000000001621364100343300245630ustar00rootroot00000000000000{ "status": { "code": 200, "commandResult": 0, "msg": "OK", "query": "/tv/tune?major=508" } } ctalkington-python-directv-340861d/tests/test_directv.py000066400000000000000000000141331364100343300234710ustar00rootroot00000000000000"""Tests for DIRECTV.""" import asyncio import pytest from aiohttp import ClientSession from directv import DIRECTV from directv.exceptions import ( DIRECTVAccessRestricted, DIRECTVConnectionError, DIRECTVError, ) from . import load_fixture HOST = "1.2.3.4" PORT = 8080 MATCH_HOST = f"{HOST}:{PORT}" NON_STANDARD_PORT = 3333 @pytest.mark.asyncio async def test_json_request(aresponses): """Test DIRECTV response is handled correctly.""" aresponses.add( MATCH_HOST, "/info/getVersion", "GET", aresponses.Response( status=200, headers={"Content-Type": "application/json"}, text='{"status": {"code": 200, "commandResult": 0}}', ), ) async with ClientSession() as session: dtv = DIRECTV(HOST, session=session) response = await dtv._request("/info/getVersion") assert response["status"]["code"] == 200 assert response["status"]["commandResult"] == 0 @pytest.mark.asyncio async def test_authenticated_request(aresponses): """Test authenticated JSON response is handled correctly.""" aresponses.add( MATCH_HOST, "/", "GET", aresponses.Response( status=200, headers={"Content-Type": "application/json"}, text='{"status": "ok"}', ), ) async with ClientSession() as session: dtv = DIRECTV(HOST, username="you", password="socool", session=session,) response = await dtv._request("/") assert response["status"] == "ok" @pytest.mark.asyncio async def test_text_request(aresponses): """Test non JSON response is handled correctly.""" aresponses.add( MATCH_HOST, "/", "GET", aresponses.Response(status=200, text="OK"), ) async with ClientSession() as session: dtv = DIRECTV(HOST, session=session) response = await dtv._request("/") assert response == "OK" @pytest.mark.asyncio async def test_internal_session(aresponses): """Test DIRECTV response is handled correctly.""" aresponses.add( MATCH_HOST, "/info/getVersion", "GET", aresponses.Response( status=200, headers={"Content-Type": "application/json"}, text='{"status": {"code": 200, "commandResult": 0}}', ), ) async with DIRECTV(HOST) as dtv: response = await dtv._request("/info/getVersion") assert response["status"]["code"] == 200 assert response["status"]["commandResult"] == 0 @pytest.mark.asyncio async def test_request_port(aresponses): """Test the DIRECTV server running on non-standard port.""" aresponses.add( f"{HOST}:{NON_STANDARD_PORT}", "/info/getVersion", "GET", aresponses.Response( status=200, headers={"Content-Type": "application/json"}, text='{"status": {"code": 200, "commandResult": 0}}', ), ) async with ClientSession() as session: dtv = DIRECTV(host=HOST, port=NON_STANDARD_PORT, session=session,) response = await dtv._request("/info/getVersion") assert response["status"]["code"] == 200 assert response["status"]["commandResult"] == 0 @pytest.mark.asyncio async def test_timeout(aresponses): """Test request timeout from the DIRECTV server.""" # Faking a timeout by sleeping async def response_handler(_): await asyncio.sleep(2) return aresponses.Response(body="Timeout!") aresponses.add( MATCH_HOST, "/info/getVersion", "GET", response_handler, ) async with ClientSession() as session: dtv = DIRECTV(HOST, session=session, request_timeout=1) with pytest.raises(DIRECTVConnectionError): assert await dtv._request("/info/getVersion") @pytest.mark.asyncio async def test_client_error(): """Test http client error.""" async with ClientSession() as session: dtv = DIRECTV("#", session=session) with pytest.raises(DIRECTVConnectionError): assert await dtv._request("/info/getVersion") @pytest.mark.asyncio async def test_http_error403(aresponses): """Test HTTP 403 response handling.""" aresponses.add( MATCH_HOST, "/tv/getTuned", "GET", aresponses.Response(text="Forbidden", status=403), ) async with ClientSession() as session: dtv = DIRECTV(HOST, session=session) with pytest.raises(DIRECTVAccessRestricted): assert await dtv._request("/tv/getTuned") @pytest.mark.asyncio async def test_http_error404(aresponses): """Test HTTP 404 response handling.""" aresponses.add( MATCH_HOST, "/info/getVersion", "GET", aresponses.Response(text="Not Found!", status=404), ) async with ClientSession() as session: dtv = DIRECTV(HOST, session=session) with pytest.raises(DIRECTVError): assert await dtv._request("/info/getVersion") @pytest.mark.asyncio async def test_http_error500(aresponses): """Test HTTP 500 response handling.""" aresponses.add( MATCH_HOST, "/info/getVersion", "GET", aresponses.Response(text="Internal Server Error", status=500), ) async with ClientSession() as session: dtv = DIRECTV(HOST, session=session) with pytest.raises(DIRECTVError): assert await dtv._request("/info/getVersion") @pytest.mark.asyncio async def test_http_error500_json(aresponses): """Test HTTP 500 json response handling.""" aresponses.add( MATCH_HOST, "/info/getVersion", "GET", aresponses.Response( status=500, headers={"Content-Type": "application/json"}, body=load_fixture("info-get-version-error.json"), ), ) async with ClientSession() as session: dtv = DIRECTV(HOST, session=session) with pytest.raises(DIRECTVError): response = await dtv._request("/info/getVersion") assert response assert response["status"] assert response["status"]["code"] == 500 assert response["status"]["commandResult"] == 1 ctalkington-python-directv-340861d/tests/test_interface.py000066400000000000000000000241511364100343300237720ustar00rootroot00000000000000"""Tests for DIRECTV.""" from typing import List import pytest from aiohttp import ClientSession from directv import DIRECTV, DIRECTVError from directv.models import Info, Program, State from . import load_fixture HOST = "1.2.3.4" PORT = 8080 MATCH_HOST = f"{HOST}:{PORT}" @pytest.mark.asyncio async def test_update(aresponses): """Test update is handled correctly.""" aresponses.add( MATCH_HOST, "/info/getVersion", "GET", aresponses.Response( status=200, headers={"Content-Type": "application/json"}, text=load_fixture("info-get-version.json"), ), ) aresponses.add( MATCH_HOST, "/info/getLocations", "GET", aresponses.Response( status=200, headers={"Content-Type": "application/json"}, text=load_fixture("info-get-locations.json"), ), ) async with ClientSession() as session: dtv = DIRECTV(HOST, session=session) response = await dtv.update() assert response assert isinstance(response.info, Info) assert isinstance(response.locations, List) response = await dtv.update() assert response assert response.info @pytest.mark.asyncio async def test_remote(aresponses): """Test remote is handled correctly.""" aresponses.add( MATCH_HOST, "/remote/processKey", "GET", aresponses.Response( status=200, headers={"Content-Type": "application/json"}, text=load_fixture("remote-process-key.json"), ), ) async with ClientSession() as session: dtv = DIRECTV(HOST, session=session) await dtv.remote("info") @pytest.mark.asyncio async def test_remote_invalid_key(): """Test remote with invalid key is handled correctly.""" async with ClientSession() as session: dtv = DIRECTV(HOST, session=session) with pytest.raises(DIRECTVError): await dtv.remote("super") @pytest.mark.asyncio async def test_state(aresponses): """Test active state is handled correctly.""" aresponses.add( MATCH_HOST, "/info/mode", "GET", aresponses.Response( status=200, headers={"Content-Type": "application/json"}, text=load_fixture("info-mode.json"), ), ) aresponses.add( MATCH_HOST, "/tv/getTuned", "GET", aresponses.Response( status=200, headers={"Content-Type": "application/json"}, text=load_fixture("tv-get-tuned.json"), ), ) async with ClientSession() as session: dtv = DIRECTV(HOST, session=session) response = await dtv.state() assert response assert isinstance(response, State) assert response.available assert not response.standby assert isinstance(response.program, Program) @pytest.mark.asyncio async def test_state_error_mode(aresponses): """Test state with generic mode error is handled correctly.""" aresponses.add( MATCH_HOST, "/info/mode", "GET", aresponses.Response( status=500, headers={"Content-Type": "application/json"}, text=load_fixture("info-mode-error.json"), ), ) async with ClientSession() as session: dtv = DIRECTV(HOST, session=session) response = await dtv.state() assert response assert isinstance(response, State) assert not response.available assert response.standby assert response.authorized assert response.program is None @pytest.mark.asyncio async def test_state_error_tuned(aresponses): """Test state with generic tuned error is handled correctly.""" aresponses.add( MATCH_HOST, "/info/mode", "GET", aresponses.Response( status=200, headers={"Content-Type": "application/json"}, text=load_fixture("info-mode.json"), ), ) aresponses.add( MATCH_HOST, "/tv/getTuned", "GET", aresponses.Response( status=500, headers={"Content-Type": "application/json"}, text=load_fixture("tv-get-tuned-error.json"), ), ) async with ClientSession() as session: dtv = DIRECTV(HOST, session=session) response = await dtv.state() assert response assert isinstance(response, State) assert not response.available assert not response.standby assert response.authorized assert response.program is None @pytest.mark.asyncio async def test_state_restricted_mode(aresponses): """Test standby state is handled correctly.""" aresponses.add( MATCH_HOST, "/info/mode", "GET", aresponses.Response( status=403, headers={"Content-Type": "application/json"}, text=load_fixture("info-mode-restricted.json"), ), ) async with ClientSession() as session: dtv = DIRECTV(HOST, session=session) response = await dtv.state() assert response assert isinstance(response, State) assert not response.available assert response.standby assert not response.authorized assert response.program is None @pytest.mark.asyncio async def test_state_restricted_tuned(aresponses): """Test standby state is handled correctly.""" aresponses.add( MATCH_HOST, "/info/mode", "GET", aresponses.Response( status=200, headers={"Content-Type": "application/json"}, text=load_fixture("info-mode.json"), ), ) aresponses.add( MATCH_HOST, "/tv/getTuned", "GET", aresponses.Response( status=403, headers={"Content-Type": "application/json"}, text=load_fixture("tv-get-tuned-restricted.json"), ), ) async with ClientSession() as session: dtv = DIRECTV(HOST, session=session) response = await dtv.state() assert response assert isinstance(response, State) assert response.available assert not response.standby assert not response.authorized assert response.program is None @pytest.mark.asyncio async def test_state_standby(aresponses): """Test restricted state is handled correctly.""" aresponses.add( MATCH_HOST, "/info/mode", "GET", aresponses.Response( status=200, headers={"Content-Type": "application/json"}, text=load_fixture("info-mode-standby.json"), ), ) async with ClientSession() as session: dtv = DIRECTV(HOST, session=session) response = await dtv.state() assert response assert isinstance(response, State) assert response.available assert response.standby assert response.program is None @pytest.mark.asyncio async def test_status(aresponses): """Test active state is handled correctly.""" aresponses.add( MATCH_HOST, "/info/mode", "GET", aresponses.Response( status=200, headers={"Content-Type": "application/json"}, text=load_fixture("info-mode.json"), ), ) async with ClientSession() as session: dtv = DIRECTV(HOST, session=session) response = await dtv.status() assert response == "active" @pytest.mark.asyncio async def test_status_access_restricted(aresponses): """Test unauthorized state is handled correctly.""" aresponses.add( MATCH_HOST, "/info/mode", "GET", aresponses.Response( status=403, headers={"Content-Type": "application/json"}, text=load_fixture("info-mode-restricted.json"), ), ) async with ClientSession() as session: dtv = DIRECTV(HOST, session=session) response = await dtv.status() assert response == "unauthorized" @pytest.mark.asyncio async def test_status_standby(aresponses): """Test standby status is handled correctly.""" aresponses.add( MATCH_HOST, "/info/mode", "GET", aresponses.Response( status=200, headers={"Content-Type": "application/json"}, text=load_fixture("info-mode-standby.json"), ), ) async with ClientSession() as session: dtv = DIRECTV(HOST, session=session) response = await dtv.status() assert response == "standby" @pytest.mark.asyncio async def test_status_unavailable(aresponses): """Test unavailable status is handled correctly.""" aresponses.add( MATCH_HOST, "/info/mode", "GET", aresponses.Response( status=500, headers={"Content-Type": "application/json"}, text=load_fixture("info-mode-error.json"), ), ) async with ClientSession() as session: dtv = DIRECTV(HOST, session=session) response = await dtv.status() assert response == "unavailable" @pytest.mark.asyncio async def test_tune(aresponses): """Test tune is handled correctly.""" aresponses.add( MATCH_HOST, "/tv/tune", "GET", aresponses.Response( status=200, headers={"Content-Type": "application/json"}, text=load_fixture("tv-tune.json"), ), ) async with ClientSession() as session: dtv = DIRECTV(HOST, session=session) await dtv.tune("231") @pytest.mark.asyncio async def test_tuned(aresponses): """Test tuned is handled correctly.""" aresponses.add( MATCH_HOST, "/tv/getTuned", "GET", aresponses.Response( status=200, headers={"Content-Type": "application/json"}, text=load_fixture("tv-get-tuned.json"), ), ) async with ClientSession() as session: dtv = DIRECTV(HOST, session=session) response = await dtv.tuned() assert response assert isinstance(response, Program) ctalkington-python-directv-340861d/tests/test_models.py000066400000000000000000000140211364100343300233100ustar00rootroot00000000000000"""Tests for DirecTV Models.""" from datetime import datetime, timezone import directv.models as models import pytest from directv import DIRECTVError INFO = { "accessCardId": "0021-1495-6572", "receiverId": "0288 7745 5858", "stbSoftwareVersion": "0x4ed7", "systemTime": 1281625203, "version": "1.2", } LOCATIONS = [ {"clientAddr": "0", "locationName": "Host"}, {"clientAddr": "2CA17D1CD30X", "locationName": "Client"}, ] DEVICE = {"info": INFO, "locations": LOCATIONS} PROGRAM = { "callsign": "FOODHD", "date": "20070324", "duration": 1791, "episodeTitle": "Spaghetti and Clam Sauce", "expiration": "0", "expiryTime": 0, "isOffAir": False, "isPartial": False, "isPclocked": 1, "isPpv": False, "isRecording": False, "isViewed": True, "isVod": False, "keepUntilFull": True, "major": 231, "minor": 65535, "offset": 263, "programId": "4405732", "rating": "No Rating", "recType": 3, "startTime": 1278342008, "stationId": 3900976, "title": "Tyler's Ultimate", "uniqueId": "6728716739474078694", } PROGRAM_MOVIE = { "callsign": "HALLHD", "date": "2013", "duration": 7200, "isOffAir": False, "isPclocked": 3, "isPpv": False, "isRecording": False, "isVod": False, "major": 312, "minor": 65535, "offset": 4437, "programId": "17016356", "rating": "TV-G", "startTime": 1584795600, "stationId": 6580971, "title": "Snow Bride", } PROGRAM_MUSIC = { "callsign": "MCSJ", "duration": 86400, "isOffAir": False, "isPclocked": 3, "isPpv": False, "isRecording": False, "isVod": False, "major": 851, "minor": 65535, "music": { "by": "Gerald Albright", "cd": "Slam Dunk (2014)", "title": "Sparkle In Your Eyes", }, "offset": 15050, "programId": "76917562", "rating": "TV-PG", "startTime": 1584784800, "stationId": 2872196, "title": "Smooth Jazz", } def test_device() -> None: """Test the Device model.""" device = models.Device(DEVICE) assert device assert device.info assert isinstance(device.info, models.Info) assert device.locations assert len(device.locations) == 2 assert isinstance(device.locations[0], models.Location) def test_device_no_data() -> None: """Test the Device model.""" with pytest.raises(DIRECTVError): models.Device({}) def test_info() -> None: """Test the Info model.""" info = models.Info.from_dict(INFO) assert info assert info.brand == "DirecTV" assert info.version == "0x4ed7" assert info.receiver_id == "028877455858" def test_location() -> None: """Test the Location model.""" location = models.Location.from_dict(LOCATIONS[0]) assert location assert not location.client assert location.name == "Host" assert location.address == "0" location = models.Location.from_dict(LOCATIONS[1]) assert location assert location.client assert location.name == "Client" assert location.address == "2CA17D1CD30X" def test_program() -> None: """Test the Program model.""" program = models.Program.from_dict(PROGRAM) assert program assert program.recorded assert program.viewed assert not program.ondemand assert not program.partial assert not program.payperview assert not program.purchased assert not program.recording assert program.channel == "231" assert program.channel_name == "FOODHD" assert program.program_id == "4405732" assert program.program_type == "tvshow" assert program.title == "Tyler's Ultimate" assert program.episode_title == "Spaghetti and Clam Sauce" assert program.rating == "No Rating" assert program.start_time == datetime(2010, 7, 5, 15, 0, 8, tzinfo=timezone.utc) assert program.duration == 1791 assert program.position == 263 assert program.unique_id == "6728716739474078694" def test_program_movie() -> None: """Test the Program model with movie.""" program = models.Program.from_dict(PROGRAM_MOVIE) assert program assert not program.recorded assert not program.viewed assert not program.ondemand assert not program.partial assert not program.payperview assert not program.purchased assert not program.recording assert program.channel == "312" assert program.channel_name == "HALLHD" assert program.program_id == "17016356" assert program.program_type == "movie" assert program.title == "Snow Bride" assert program.episode_title is None assert program.rating == "TV-G" assert program.start_time == datetime(2020, 3, 21, 13, 0, tzinfo=timezone.utc) assert program.duration == 7200 assert program.position == 4437 assert program.unique_id is None def test_program_music() -> None: """Test the Program model with music channel.""" program = models.Program.from_dict(PROGRAM_MUSIC) assert program assert not program.recorded assert not program.viewed assert not program.ondemand assert not program.partial assert not program.payperview assert not program.purchased assert not program.recording assert program.channel == "851" assert program.channel_name == "MCSJ" assert program.program_id == "76917562" assert program.program_type == "music" assert program.title == "Smooth Jazz" assert program.episode_title is None assert program.music_title == "Sparkle In Your Eyes" assert program.music_album == "Slam Dunk (2014)" assert program.music_artist == "Gerald Albright" assert program.rating == "TV-PG" assert program.start_time == datetime(2020, 3, 21, 10, 0, tzinfo=timezone.utc) assert program.duration == 86400 assert program.position == 15050 assert program.unique_id is None def test_state() -> None: """Test the State model.""" program = models.Program.from_dict(PROGRAM) state = models.State( authorized=True, available=True, standby=False, program=program, ) assert state assert isinstance(state.at, datetime) ctalkington-python-directv-340861d/tests/test_utils.py000066400000000000000000000006561364100343300231760ustar00rootroot00000000000000"""Tests for DirecTV Helpers.""" import directv.utils as utils def test_combine_channel_number() -> None: """Test the merging of channel numbers.""" assert utils.combine_channel_number(231, 65535) == "231" assert utils.combine_channel_number(231, 1) == "231-1" def test_parse_channel_number() -> None: """Test the parsing of channel numbers.""" assert utils.parse_channel_number("231") == ("231", "65535")