├── .github ├── dependabot.yml └── workflows │ ├── codeql.yml │ ├── lintPR.yaml │ ├── python-tests.yml │ └── release.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .python-version ├── AUTHORS.rst ├── CONTRIBUTING.rst ├── HISTORY.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── requirements.txt ├── requirements_dev.txt ├── setup.py ├── tests ├── YotoAPI_test.py ├── YotoManager_test.py └── __init__.py └── yoto_api ├── Card.py ├── Family.py ├── Token.py ├── YotoAPI.py ├── YotoMQTTClient.py ├── YotoManager.py ├── YotoPlayer.py ├── __init__.py ├── const.py ├── exceptions.py └── utils.py /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Enable version updates for Python 4 | - package-ecosystem: "pip" 5 | directory: "/" 6 | # Check for updates once a week 7 | schedule: 8 | interval: "weekly" 9 | - package-ecosystem: "github-actions" 10 | directory: "/" 11 | # Check for updates once a week 12 | schedule: 13 | interval: "weekly" 14 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: ["master"] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: ["master"] 20 | schedule: 21 | - cron: "28 14 * * 1" 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: ["python"] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Use only 'java' to analyze code written in Java, Kotlin or both 38 | # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both 39 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 40 | 41 | steps: 42 | - name: Checkout repository 43 | uses: actions/checkout@v5 44 | 45 | # Initializes the CodeQL tools for scanning. 46 | - name: Initialize CodeQL 47 | uses: github/codeql-action/init@v4 48 | with: 49 | languages: ${{ matrix.language }} 50 | # If you wish to specify custom queries, you can do so here or in a config file. 51 | # By default, queries listed here will override any specified in a config file. 52 | # Prefix the list here with "+" to use these queries and those in the config file. 53 | 54 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 55 | # queries: security-extended,security-and-quality 56 | 57 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). 58 | # If this step fails, then you should remove it and run the build manually (see below) 59 | - name: Autobuild 60 | uses: github/codeql-action/autobuild@v4 61 | 62 | # ℹ️ Command-line programs to run using the OS shell. 63 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 64 | 65 | # If the Autobuild fails above, remove it and uncomment the following three lines. 66 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 67 | 68 | # - run: | 69 | # echo "Run, Build Application using script" 70 | # ./location_of_script_within_repo/buildscript.sh 71 | 72 | - name: Perform CodeQL Analysis 73 | uses: github/codeql-action/analyze@v4 74 | with: 75 | category: "/language:${{matrix.language}}" 76 | -------------------------------------------------------------------------------- /.github/workflows/lintPR.yaml: -------------------------------------------------------------------------------- 1 | name: "Lint PR" 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | 10 | jobs: 11 | main: 12 | runs-on: ubuntu-latest 13 | steps: 14 | # Please look up the latest version from 15 | # https://github.com/amannn/action-semantic-pull-request/releases 16 | - uses: amannn/action-semantic-pull-request@v6.1.1 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | -------------------------------------------------------------------------------- /.github/workflows/python-tests.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Test Builds 5 | 6 | on: 7 | pull_request_target: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | python-version: ["3.10"] 18 | 19 | steps: 20 | - uses: actions/checkout@v5 21 | with: 22 | ref: ${{github.event.pull_request.head.ref}} 23 | repository: ${{github.event.pull_request.head.repo.full_name}} 24 | - name: Set up Python ${{ matrix.python-version }} 25 | uses: actions/setup-python@v6 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | - name: Install dependencies 29 | run: | 30 | python -m pip install --upgrade pip 31 | python -m pip install flake8 pytest 32 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 33 | - name: Lint with flake8 34 | run: | 35 | # stop the build if there are Python syntax errors or undefined names 36 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 37 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 38 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 39 | - name: Test with pytest 40 | env: 41 | CDNNINJA_USERNAME: ${{ secrets.CDNNINJA_USERNAME }} 42 | CDNNINJA_PASSWORD: ${{ secrets.CDNNINJA_PASSWORD }} 43 | run: | 44 | pytest 45 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | # Controls when the workflow will run 3 | on: 4 | # Triggers the workflow on push or pull request events but only for the master branch 5 | push: 6 | branches: [master] 7 | workflow_dispatch: 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v5 13 | - name: Gets semantic release info 14 | id: semantic_release_info 15 | uses: jossef/action-semantic-release-info@v3.0.0 16 | env: 17 | GITHUB_TOKEN: ${{ github.token }} 18 | - name: Update Version and Commit 19 | if: ${{steps.semantic_release_info.outputs.version != ''}} 20 | run: | 21 | echo "Version: ${{steps.semantic_release_info.outputs.version}}" 22 | sed -i "s/version=\".*\",/version=\"${{steps.semantic_release_info.outputs.version}}\",/g" setup.py 23 | git config --local user.email "action@github.com" 24 | git config --local user.name "GitHub Action" 25 | git add -A 26 | git commit -m "chore: bumping version to ${{steps.semantic_release_info.outputs.version}}" 27 | git tag ${{ steps.semantic_release_info.outputs.git_tag }} 28 | - name: Push changes 29 | if: ${{steps.semantic_release_info.outputs.version != ''}} 30 | uses: ad-m/github-push-action@v1.0.0 31 | with: 32 | github_token: ${{ github.token }} 33 | tags: true 34 | - name: Create GitHub Release 35 | if: ${{steps.semantic_release_info.outputs.version != ''}} 36 | uses: actions/create-release@v1 37 | env: 38 | GITHUB_TOKEN: ${{ github.token }} 39 | with: 40 | tag_name: ${{ steps.semantic_release_info.outputs.git_tag }} 41 | release_name: ${{ steps.semantic_release_info.outputs.git_tag }} 42 | body: ${{ steps.semantic_release_info.outputs.notes }} 43 | draft: false 44 | prerelease: false 45 | - name: Install dependencies 46 | if: ${{steps.semantic_release_info.outputs.version != ''}} 47 | run: | 48 | python -m pip install --upgrade pip 49 | pip install build 50 | - name: Build package 51 | if: ${{steps.semantic_release_info.outputs.version != ''}} 52 | run: python -m build 53 | - name: Publish package to PyPi Test 54 | if: ${{steps.semantic_release_info.outputs.version != ''}} 55 | uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e 56 | with: 57 | user: __token__ 58 | password: ${{ secrets.PYPI_API_TEST }} 59 | repository_url: https://test.pypi.org/legacy/ 60 | - name: Publish package to PyPi Live 61 | if: ${{steps.semantic_release_info.outputs.version != ''}} 62 | uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e 63 | with: 64 | user: __token__ 65 | password: ${{ secrets.PYPI_API_TOKEN }} 66 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | cover/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | db.sqlite3-journal 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | .pybuilder/ 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | # For a library or package, you might want to ignore these files since the code is 89 | # intended to run in multiple environments; otherwise, check them in: 90 | # .python-version 91 | 92 | # pipenv 93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 96 | # install all needed dependencies. 97 | #Pipfile.lock 98 | 99 | # poetry 100 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 101 | # This is especially recommended for binary packages to ensure reproducibility, and is more 102 | # commonly ignored for libraries. 103 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 104 | #poetry.lock 105 | 106 | # pdm 107 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 108 | #pdm.lock 109 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 110 | # in version control. 111 | # https://pdm.fming.dev/#use-with-ide 112 | .pdm.toml 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # PyCharm 158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 160 | # and can be added to the global gitignore or merged into this file. For a more nuclear 161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 162 | #.idea/ 163 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | ci: 3 | autoupdate_commit_msg: "chore: pre-commit autoupdate" 4 | repos: 5 | - repo: https://github.com/astral-sh/ruff-pre-commit 6 | rev: v0.14.0 7 | hooks: 8 | - id: ruff 9 | args: 10 | - --fix 11 | - id: ruff-format 12 | - repo: https://github.com/codespell-project/codespell 13 | rev: v2.4.1 14 | hooks: 15 | - id: codespell 16 | args: 17 | - --ignore-words-list=fro,hass 18 | - --skip="./.*,*.csv,*.json,*.ambr" 19 | - --quiet-level=2 20 | exclude_types: [csv, json] 21 | - repo: https://github.com/pre-commit/pre-commit-hooks 22 | rev: v6.0.0 23 | hooks: 24 | - id: trailing-whitespace 25 | - id: end-of-file-fixer 26 | - id: check-executables-have-shebangs 27 | stages: [manual] 28 | - id: check-json 29 | exclude: (.vscode|.devcontainer) 30 | - repo: https://github.com/asottile/pyupgrade 31 | rev: v3.21.0 32 | hooks: 33 | - id: pyupgrade 34 | - repo: https://github.com/adrienverge/yamllint.git 35 | rev: v1.37.1 36 | hooks: 37 | - id: yamllint 38 | exclude: (.github|.vscode|.devcontainer) 39 | - repo: https://github.com/pre-commit/mirrors-prettier 40 | rev: v4.0.0-alpha.8 41 | hooks: 42 | - id: prettier 43 | - repo: https://github.com/cdce8p/python-typing-update 44 | rev: v0.7.3 45 | hooks: 46 | # Run `python-typing-update` hook manually from time to time 47 | # to update python typing syntax. 48 | # Will require manual work, before submitting changes! 49 | # pre-commit run --hook-stage manual python-typing-update --all-files 50 | - id: python-typing-update 51 | stages: [manual] 52 | args: 53 | - --py311-plus 54 | - --force 55 | - --keep-updates 56 | files: ^(/.+)?[^/]+\.py$ 57 | - repo: https://github.com/pre-commit/mirrors-mypy 58 | rev: v1.18.2 59 | hooks: 60 | - id: mypy 61 | args: [--strict, --ignore-missing-imports] 62 | files: ^(/.+)?[^/]+\.py$ 63 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.12 2 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | Development Lead 6 | ---------------- 7 | 8 | * 9 | 10 | Contributors 11 | ------------ 12 | 13 | None yet. Why not be the first? 14 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Contributing 5 | ============ 6 | 7 | Contributions are welcome, and they are greatly appreciated! Every little bit 8 | helps, and credit will always be given. 9 | 10 | You can contribute in many ways: 11 | 12 | Types of Contributions 13 | ---------------------- 14 | 15 | Report Bugs 16 | ~~~~~~~~~~~ 17 | 18 | Report bugs at https://github.com/cdnninja/yoto_api/issues. 19 | 20 | If you are reporting a bug, please include: 21 | 22 | * Your operating system name and version. 23 | * Any details about your local setup that might be helpful in troubleshooting. 24 | * Detailed steps to reproduce the bug. 25 | 26 | Fix Bugs 27 | ~~~~~~~~ 28 | 29 | Look through the GitHub issues for bugs. Anything tagged with "bug" and "help 30 | wanted" is open to whoever wants to implement it. 31 | 32 | Implement Features 33 | ~~~~~~~~~~~~~~~~~~ 34 | 35 | Look through the GitHub issues for features. Anything tagged with "enhancement" 36 | and "help wanted" is open to whoever wants to implement it. 37 | 38 | Write Documentation 39 | ~~~~~~~~~~~~~~~~~~~ 40 | 41 | Hyundai / Kia Connect could always use more documentation, whether as part of the 42 | official Hyundai / Kia Connect docs, in docstrings, or even on the web in blog posts, 43 | articles, and such. 44 | 45 | Submit Feedback 46 | ~~~~~~~~~~~~~~~ 47 | 48 | The best way to send feedback is to file an issue at https://github.com/cdnninja/yoto_api/issues. 49 | 50 | If you are proposing a feature: 51 | 52 | * Explain in detail how it would work. 53 | * Keep the scope as narrow as possible, to make it easier to implement. 54 | * Remember that this is a volunteer-driven project, and that contributions 55 | are welcome :) 56 | 57 | Get Started! 58 | ------------ 59 | 60 | Ready to contribute? Here's how to set up `yoto_api` for local development. 61 | 62 | 1. Fork the `yoto_api` repo on GitHub. 63 | 2. Clone your fork locally:: 64 | 65 | $ git clone git@github.com:your_name_here/yoto_api.git 66 | 67 | 3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: 68 | 69 | $ mkvirtualenv yoto_api 70 | $ cd yoto_api/ 71 | $ python setup.py develop 72 | 73 | 4. Create a branch for local development:: 74 | 75 | $ git checkout -b name-of-your-bugfix-or-feature 76 | 77 | Now you can make your changes locally. 78 | 79 | 5. When you're done making changes, check that your changes pass flake8 and the 80 | tests, including testing other Python versions with tox:: 81 | 82 | $ flake8 yoto_api tests 83 | $ python setup.py test or pytest 84 | $ tox 85 | 86 | To get flake8 and tox, just pip install them into your virtualenv. 87 | 88 | 6. Commit your changes and push your branch to GitHub:: 89 | 90 | $ git add . 91 | $ git commit -m "Your detailed description of your changes." 92 | $ git push origin name-of-your-bugfix-or-feature 93 | 94 | 7. Submit a pull request through the GitHub website. 95 | 96 | Pull Request Guidelines 97 | ----------------------- 98 | 99 | Before you submit a pull request, check that it meets these guidelines: 100 | 101 | 1. The pull request should include tests. 102 | 2. If the pull request adds functionality, the docs should be updated. Put 103 | your new functionality into a function with a docstring, and add the 104 | feature to the list in README.rst. 105 | 3. The pull request should work for Python 3.5, 3.6, 3.7 and 3.8, and for PyPy. Check 106 | https://travis-ci.com/cdnninja/yoto_api/pull_requests 107 | and make sure that the tests pass for all supported Python versions. 108 | 109 | Tips 110 | ---- 111 | 112 | To run a subset of tests:: 113 | 114 | $ pytest tests.test_hyundai_kia_connect_api 115 | 116 | 117 | Deploying 118 | --------- 119 | 120 | A reminder for the maintainers on how to deploy. 121 | Make sure all your changes are committed (including an entry in HISTORY.rst). 122 | Then run:: 123 | 124 | $ bump2version patch # possible: major / minor / patch 125 | $ git push 126 | $ git push --tags 127 | 128 | Travis will then deploy to PyPI if tests pass. 129 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | no history 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.rst 2 | include CONTRIBUTING.rst 3 | include HISTORY.rst 4 | include LICENSE 5 | include README.rst 6 | include requirements.txt 7 | 8 | recursive-include tests * 9 | recursive-exclude * __pycache__ 10 | recursive-exclude * *.py[co] 11 | 12 | recursive-include docs *.rst conf.py Makefile make.bat *.jpg *.png *.gif 13 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Introduction 2 | ============ 3 | 4 | This is a Python wrapper for the Yoto API. It allows you to interact with the Yoto API to control your Yoto players, update your library, and get information about your players and cards. 5 | 6 | You need a client ID to use this API. You can get this from here: https://yoto.dev/get-started/start-here/. 7 | 8 | Credit 9 | ====== 10 | 11 | A big thank you to @buzzeddesign for helping to sniff some of the API and make sense of it. Thank you to @fuatakgun for creating the core architecture which is based on kia_uvo. 12 | 13 | Example Test Code 14 | ================= 15 | 16 | To run this code for test I am doing:: 17 | 18 | from pathlib import Path 19 | import logging 20 | import sys 21 | import os 22 | 23 | path_root = r"C:path to files GitHub\main\yoto_api" 24 | sys.path.append(str(path_root)) 25 | from yoto_api import * 26 | 27 | logging.basicConfig(stream=sys.stdout, level=logging.DEBUG, format='%(asctime)s %(name)s %(levelname)s:%(message)s') 28 | logger = logging.getLogger(__name__) 29 | 30 | ym = YotoManager(client_id="clientID") 31 | print(ym.device_code_flow_start()) 32 | #complete the link, present to user 33 | time.sleep(15) 34 | ym.device_code_flow_complete() 35 | ym.update_player_status() 36 | print (ym.players) 37 | ym.connect_to_events() 38 | # Pauses the first player 39 | ym.pause_player(next(iter(ym.players))) 40 | # Sleep will let the terminal show events coming back. For dev today. 41 | time.sleep(60) 42 | 43 | # If you have already linked save the token. 44 | refresh_token = ym.token.refresh_token 45 | 46 | instead of device code flow user: 47 | ym.set_refresh_token(refresh_token) 48 | #Refresh token - maybe it is old. Auto run by set refresh token 49 | ym.check_and_refresh_token() 50 | 51 | Usage 52 | ===== 53 | 54 | For additional methods not mentioned below follow the file here for all functionality: 55 | https://github.com/cdnninja/yoto_api/blob/master/yoto_api/YotoManager.py 56 | 57 | To use this API you need to create a YotoManager object with your client ID. You can get this from the Yoto app. It is in the URL when you log in. It is the long string after "client_id=". 58 | 59 | ym = YotoManager(client_id="your_client_id") 60 | 61 | Start the device code flow. This will return a dictionary with the device code and other information. You will need to present this to the user to complete the login. :: 62 | 63 | ym.device_code_flow_start() 64 | 65 | Complete the device code flow. This will poll the API for the token. You will need to wait a few seconds before calling this after presenting the device code to the user. :: 66 | ym.device_code_flow_complete() 67 | 68 | If you have a token already you can set it directly. This is useful if you have already logged in and want to use the API without going through the device code flow again. :: 69 | 70 | ym.set_token(token: Token) 71 | 72 | 73 | Check and refresh token will pull the first set of data. It also should be run regularly if you keep your code running for days. It will check if the token is valid. If it isn't it will refresh the token. If this is first run of the command and no data has been pulled it will also run update_player_status() and update_cards() for you. :: 74 | 75 | ym.check_and_refresh_token() 76 | 77 | Check and refresh token will pull the first set of data. It also should be run regularly if you keep your code running for days. It will check if the token is valid. If it isn't it will refresh the token. If this is first run of the command and no data has been pulled it will also run update_player_status() and update_cards() for you. :: 78 | 79 | ym.update_player_status() 80 | 81 | Connects to the MQTT broker. This must be run before any command and also get get useful data. :: 82 | 83 | ym.connect_to_events() 84 | 85 | Pauses the player for the player ID sent. ID can be found in ym.players.keys() :: 86 | 87 | ym.pause_player(player_id: str) 88 | 89 | Updates the library of cards. This is done as part of check_refresh_token so only needed if data is stale. :: 90 | 91 | ym.update_cards() 92 | 93 | Contains player object with data values you can access. :: 94 | 95 | ym.players 96 | 97 | Contains the library of cards. Each card being an object with the data values you can use. :: 98 | 99 | ym.library 100 | 101 | Get Set Up For Development 102 | ========================== 103 | 104 | Set up pyenv:: 105 | 106 | pyenv install 107 | 108 | Install the dependencies:: 109 | 110 | pip install -r requirements.txt 111 | pip install -r requirements_dev.txt 112 | 113 | Tests 114 | ===== 115 | 116 | Create a .env file in the root of the project with the following content:: 117 | 118 | YOTO_USERNAME=your_username 119 | YOTO_PASSWORD=your_password 120 | 121 | Run the tests with:: 122 | 123 | python -m pytest 124 | 125 | Other Notes 126 | =========== 127 | 128 | This is not associated or affiliated with yoto play in any way. 129 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pytz 2 | requests 3 | paho-mqtt 4 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | pytz 2 | requests 3 | paho-mqtt 4 | pytest 5 | python-dotenv 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """The setup script.""" 4 | 5 | from setuptools import setup, find_packages 6 | 7 | with open("README.rst") as readme_file: 8 | readme = readme_file.read() 9 | 10 | with open("HISTORY.rst") as history_file: 11 | history = history_file.read() 12 | 13 | with open("requirements.txt") as f: 14 | requirements = f.read().splitlines() 15 | 16 | long_description = readme + "\n\n" + history 17 | long_description = readme 18 | 19 | test_requirements = [ 20 | "pytest>=3", 21 | ] 22 | 23 | setup( 24 | author="cdnninja", 25 | author_email="", 26 | python_requires=">=3.9", 27 | classifiers=[ 28 | "Development Status :: 2 - Pre-Alpha", 29 | "Intended Audience :: Developers", 30 | "License :: OSI Approved :: MIT License", 31 | "Natural Language :: English", 32 | "Programming Language :: Python :: 3.9", 33 | ], 34 | description="A python package that makes it a bit easier to work with the yoto play API. Not associated with Yoto in any way.", 35 | install_requires=requirements, 36 | license="MIT license", 37 | long_description=long_description, 38 | include_package_data=True, 39 | keywords="yoto_api", 40 | name="yoto_api", 41 | packages=find_packages(include=["yoto_api", "yoto_api.*"]), 42 | test_suite="tests", 43 | tests_require=test_requirements, 44 | url="https://github.com/cdnninja/yoto_api", 45 | version="2.1.2", 46 | zip_safe=False, 47 | ) 48 | -------------------------------------------------------------------------------- /tests/YotoAPI_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from dotenv import load_dotenv 3 | import os 4 | import pytz 5 | from yoto_api.YotoAPI import YotoAPI 6 | from datetime import datetime 7 | 8 | 9 | class login(unittest.TestCase): 10 | @classmethod 11 | def setUpClass(cls): 12 | load_dotenv() 13 | 14 | cls.token = YotoAPI().login( 15 | os.getenv("YOTO_USERNAME"), os.getenv("YOTO_PASSWORD") 16 | ) 17 | 18 | def test_access_token(self): 19 | self.assertIsNotNone(self.token.access_token) 20 | 21 | def test_refresh_token(self): 22 | self.assertIsNotNone(self.token.refresh_token) 23 | 24 | def test_token_type(self): 25 | self.assertIsNotNone(self.token.token_type) 26 | 27 | def test_scope(self): 28 | self.assertIsNotNone(self.token.scope) 29 | 30 | def test_valid_until_is_greater_than_now(self): 31 | self.assertGreater(self.token.valid_until, datetime.now(pytz.utc)) 32 | 33 | 34 | class login_invalid(unittest.TestCase): 35 | def test_it_throws_an_error(self): 36 | api = YotoAPI() 37 | 38 | with self.assertRaises(Exception) as error: 39 | api.login("invalid", "invalid") 40 | 41 | self.assertEqual(str(error.exception), "Wrong email or password.") 42 | 43 | 44 | class get_family(unittest.TestCase): 45 | @classmethod 46 | def setUpClass(cls): 47 | load_dotenv() 48 | api = YotoAPI() 49 | token = api.login(os.getenv("YOTO_USERNAME"), os.getenv("YOTO_PASSWORD")) 50 | cls.family = api.get_family(token) 51 | 52 | def test_it_has_members(self): 53 | self.assertIsNotNone(self.family.members) 54 | 55 | def test_it_has_devices(self): 56 | self.assertIsNotNone(self.family.devices) 57 | 58 | 59 | if __name__ == "__main__": 60 | unittest.main() 61 | -------------------------------------------------------------------------------- /tests/YotoManager_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from dotenv import load_dotenv 3 | import os 4 | import pytz 5 | from yoto_api.YotoManager import YotoManager 6 | from datetime import datetime 7 | 8 | 9 | class login(unittest.TestCase): 10 | @classmethod 11 | def setUpClass(cls): 12 | load_dotenv() 13 | 14 | cls.ym = YotoManager(os.getenv("YOTO_USERNAME"), os.getenv("YOTO_PASSWORD")) 15 | cls.ym.initialize() 16 | 17 | def test_access_token(self): 18 | self.assertIsNotNone(self.ym.token.access_token) 19 | 20 | def test_refresh_token(self): 21 | self.assertIsNotNone(self.ym.token.refresh_token) 22 | 23 | def test_token_type(self): 24 | self.assertIsNotNone(self.ym.token.token_type) 25 | 26 | def test_scope(self): 27 | self.assertIsNotNone(self.ym.token.scope) 28 | 29 | def test_valid_until_is_greater_than_now(self): 30 | self.assertGreater(self.ym.token.valid_until, datetime.now(pytz.utc)) 31 | 32 | 33 | class login_invalid(unittest.TestCase): 34 | def test_it_throws_an_error(self): 35 | with self.assertRaises(Exception) as error: 36 | ym = YotoManager("invalid", "invalid") 37 | ym.initialize() 38 | 39 | self.assertEqual(str(error.exception), "Wrong email or password.") 40 | 41 | 42 | class update_family(unittest.TestCase): 43 | @classmethod 44 | def setUpClass(cls): 45 | load_dotenv() 46 | cls.ym = YotoManager(os.getenv("YOTO_USERNAME"), os.getenv("YOTO_PASSWORD")) 47 | cls.ym.initialize() 48 | cls.ym.update_family() 49 | 50 | def test_it_has_members(self): 51 | self.assertIsNotNone(self.ym.family.members) 52 | 53 | def test_it_has_devices(self): 54 | self.assertIsNotNone(self.ym.family.devices) 55 | 56 | 57 | class update_players_status(unittest.TestCase): 58 | @classmethod 59 | def setUpClass(cls): 60 | load_dotenv() 61 | cls.ym = YotoManager(os.getenv("YOTO_USERNAME"), os.getenv("YOTO_PASSWORD")) 62 | cls.ym.initialize() 63 | cls.ym.update_players_status() 64 | 65 | def test_it_has_players(self): 66 | self.assertIsNotNone(self.ym.players) 67 | 68 | def test_it_has_player_configs(self): 69 | for player in self.ym.players.values(): 70 | self.assertIsNotNone(player.config) 71 | 72 | 73 | class update_library(unittest.TestCase): 74 | @classmethod 75 | def setUpClass(cls): 76 | load_dotenv() 77 | cls.ym = YotoManager(os.getenv("YOTO_USERNAME"), os.getenv("YOTO_PASSWORD")) 78 | cls.ym.initialize() 79 | cls.ym.update_library() 80 | 81 | def test_it_has_players(self): 82 | self.assertIsNotNone(self.ym.library) 83 | 84 | 85 | if __name__ == "__main__": 86 | unittest.main() 87 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cdnninja/yoto_api/c44c9dd266daf7da9bd9d9d8b586987eacbaf785/tests/__init__.py -------------------------------------------------------------------------------- /yoto_api/Card.py: -------------------------------------------------------------------------------- 1 | """Card class""" 2 | 3 | from dataclasses import dataclass 4 | 5 | 6 | @dataclass 7 | class Track: 8 | icon: str = None # $.card.content.chapters[0].tracks[0].display.icon16x16 e.g. "https://card-content.yotoplay.com/yoto/SwcetJ..." 9 | title: str = None # $.card.content.chapters[0].tracks[0].title e.g. "Introduction" 10 | duration: int = None # $.card.content.chapters[0].tracks[0].duration e.g. 349 11 | key: str = None # $.card.content.chapters[0].tracks[0].key e.g. "01-INT" 12 | format: str = None # $.card.content.chapters[0].tracks[0].format e.g. "aac" 13 | channels: str = None # $.card.content.chapters[0].tracks[0].channels e.g. "mono" 14 | trackUrl: str = None # $.card.content.chapters[0].tracks[0].trackUrl e.g. "https://secure-media.yotoplay.com/yoto/mYZ6T..." 15 | type: str = None # $.card.content.chapters[0].tracks[0].type e.g. "audio" 16 | 17 | 18 | @dataclass 19 | class Chapter: 20 | icon: str = None # $.card.content.chapters[0].display.icon16x16 e.g. "https://card-content.yotoplay.com/yoto/SwcetJ..." 21 | title: str = None # $.card.content.chapters[0].title e.g. "Introduction" 22 | duration: int = None # $.card.content.chapters[0].duration e.g. 349 23 | key: str = None # $.card.content.chapters[0].key e.g. "01-INT" 24 | tracks: list[Track] = None # $.card.content.chapters[0].tracks 25 | 26 | 27 | @dataclass 28 | class Card: 29 | id: str = None # $.card.cardId e.g. "iYIMF" 30 | title: str = None # $.card.title e.g. "Ladybird Audio Adventures - Outer Space" 31 | description: str = None # $.card.metadata.description e.g. "The sky’s the limit for imaginations when it comes to..." 32 | category: str = None # $.card.metadata.category e.g. "stories" 33 | author: str = None # $.card.metadata.author e.g. "Ladybird Audio Adventures" 34 | cover_image_large: str = None # $.card.metadata.cover.imageL e.g. "https://card-content.yotoplay.com/yoto/pub/WgoJMZ..." 35 | series_title: str = ( 36 | None # $.card.metadata.seriestitle e.g. "Ladybird Audio Adventures Volume 1" 37 | ) 38 | series_order: int = None # $.card.metadata.seriesorder e.g. 4 39 | chapters: list[Chapter] = None # $.card.content.chapters 40 | -------------------------------------------------------------------------------- /yoto_api/Family.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from datetime import datetime 3 | from typing import List, Dict 4 | 5 | 6 | @dataclass 7 | class Device: 8 | deviceId: str 9 | addedAt: datetime 10 | 11 | 12 | @dataclass 13 | class Member: 14 | createdAt: datetime 15 | email: str 16 | emailVerified: bool 17 | lastIp: str 18 | lastLoginAt: datetime 19 | loginsCount: int 20 | picture: str 21 | userId: str 22 | addedAt: datetime 23 | 24 | 25 | @dataclass 26 | class Family: 27 | familyId: str 28 | createdAt: datetime 29 | devices: List[Device] 30 | members: List[Member] 31 | country: str 32 | 33 | def __init__(self, data: Dict) -> None: 34 | self.familyId = data["familyId"] 35 | self.createdAt = datetime.fromisoformat(data["createdAt"].rstrip("Z")) 36 | 37 | self.devices = [ 38 | Device( 39 | deviceId=device["deviceId"], 40 | addedAt=datetime.fromisoformat(device["addedAt"].rstrip("Z")), 41 | ) 42 | for device in data["devices"] 43 | ] 44 | 45 | self.members = [ 46 | Member( 47 | createdAt=datetime.fromisoformat(member["createdAt"].rstrip("Z")), 48 | email=member["email"], 49 | emailVerified=member["emailVerified"], 50 | lastIp=member["lastIp"], 51 | lastLoginAt=datetime.fromisoformat(member["lastLoginAt"].rstrip("Z")), 52 | loginsCount=member["loginsCount"], 53 | picture=member["picture"], 54 | userId=member["userId"], 55 | addedAt=datetime.fromisoformat(member["addedAt"].rstrip("Z")), 56 | ) 57 | for member in data["members"] 58 | ] 59 | 60 | self.country = data["country"] 61 | -------------------------------------------------------------------------------- /yoto_api/Token.py: -------------------------------------------------------------------------------- 1 | """Token.py""" 2 | 3 | from dataclasses import dataclass 4 | import datetime as dt 5 | 6 | 7 | @dataclass 8 | class Token: 9 | """Token""" 10 | 11 | access_token: str = None 12 | refresh_token: str = None 13 | id_token: str = None 14 | scope: str = None 15 | valid_until: dt.datetime = dt.datetime.min 16 | token_type: str = None 17 | -------------------------------------------------------------------------------- /yoto_api/YotoAPI.py: -------------------------------------------------------------------------------- 1 | """API Methods""" 2 | 3 | import requests 4 | import logging 5 | import datetime 6 | import json 7 | import time 8 | 9 | from datetime import timedelta 10 | import pytz 11 | from .const import DOMAIN, POWER_SOURCE 12 | from .Token import Token 13 | from .Card import Card, Chapter, Track 14 | from .Family import Family 15 | from .YotoPlayer import YotoPlayer, YotoPlayerConfig, Alarm 16 | from .utils import get_child_value 17 | from .exceptions import AuthenticationError 18 | 19 | 20 | _LOGGER = logging.getLogger(__name__) 21 | 22 | 23 | class YotoAPI: 24 | def __init__(self, client_id) -> None: 25 | self.BASE_URL: str = "https://api.yotoplay.com" 26 | self.AUTH_URL: str = "https://login.yotoplay.com/oauth/device/code" 27 | self.TOKEN_URL: str = "https://login.yotoplay.com/oauth/token" 28 | self.CLIENT_ID: str = client_id 29 | 30 | def refresh_token(self, token: Token) -> Token: 31 | data = { 32 | "client_id": self.CLIENT_ID, 33 | "grant_type": "refresh_token", 34 | "refresh_token": token.refresh_token, 35 | "audience": self.BASE_URL, 36 | } 37 | headers = {"Content-Type": "application/x-www-form-urlencoded"} 38 | 39 | response = requests.post(self.TOKEN_URL, data=data, headers=headers).json() 40 | _LOGGER.debug(f"{DOMAIN} - Refresh Token Response {response}") 41 | if response.get("error"): 42 | raise AuthenticationError("Refresh token invalid") 43 | valid_until = datetime.datetime.now(pytz.utc) + timedelta( 44 | seconds=response["expires_in"] 45 | ) 46 | 47 | return Token( 48 | access_token=response["access_token"], 49 | refresh_token=response["refresh_token"], 50 | token_type=response["token_type"], 51 | scope=token.scope, 52 | valid_until=valid_until, 53 | ) 54 | 55 | def get_family(self, token: Token) -> dict: 56 | url = self.BASE_URL + "/user/family" 57 | headers = self._get_authenticated_headers(token) 58 | response = requests.get(url, headers=headers).json() 59 | 60 | _LOGGER.debug(f"{DOMAIN} - Get Family Response: {response}") 61 | return Family(response["family"]) 62 | 63 | def update_players(self, token: Token, players: list[YotoPlayer]) -> None: 64 | response = self._get_devices(token) 65 | for item in response["devices"]: 66 | if get_child_value(item, "deviceId") not in players: 67 | player: YotoPlayer = YotoPlayer( 68 | id=get_child_value(item, "deviceId"), 69 | ) 70 | players[player.id] = player 71 | deviceId = get_child_value(item, "deviceId") 72 | players[deviceId].name = get_child_value(item, "name") 73 | players[deviceId].device_type = get_child_value(item, "deviceType") 74 | players[deviceId].online = get_child_value(item, "online") 75 | 76 | # Should we call here or make this a separate call from YM? This could help us reduce API calls. 77 | player_status_response = self._get_device_status(token, deviceId) 78 | players[deviceId].last_updated_api = datetime.datetime.now(pytz.utc) 79 | if get_child_value(player_status_response, "activeCard") != "none": 80 | players[deviceId].is_playing = True 81 | else: 82 | players[deviceId].is_playing = False 83 | players[deviceId].active_card = get_child_value( 84 | player_status_response, "activeCard" 85 | ) 86 | players[deviceId].ambient_light_sensor_reading = get_child_value( 87 | player_status_response, "ambientLightSensorReading" 88 | ) 89 | players[deviceId].battery_level_percentage = get_child_value( 90 | player_status_response, "batteryLevelPercentage" 91 | ) 92 | players[deviceId].day_mode_on = get_child_value( 93 | player_status_response, "dayMode" 94 | ) 95 | players[deviceId].user_volume = get_child_value( 96 | player_status_response, "userVolumePercentage" 97 | ) 98 | players[deviceId].system_volume = get_child_value( 99 | player_status_response, "systemVolumePercentage" 100 | ) 101 | if ( 102 | get_child_value(player_status_response, "temperatureCelcius") 103 | != "notSupported" 104 | ): 105 | if ( 106 | int(get_child_value(player_status_response, "temperatureCelcius")) 107 | != 0 108 | ): 109 | players[deviceId].temperature_celcius = get_child_value( 110 | player_status_response, "temperatureCelcius" 111 | ) 112 | players[deviceId].bluetooth_audio_connected = get_child_value( 113 | player_status_response, "isBluetoothAudioConnected" 114 | ) 115 | players[deviceId].charging = get_child_value( 116 | player_status_response, "isCharging" 117 | ) 118 | players[deviceId].audio_device_connected = get_child_value( 119 | player_status_response, "isAudioDeviceConnected" 120 | ) 121 | players[deviceId].firmware_version = get_child_value( 122 | player_status_response, "firmwareVersion" 123 | ) 124 | players[deviceId].wifi_strength = get_child_value( 125 | player_status_response, "wifiStrength" 126 | ) 127 | players[deviceId].playing_source = get_child_value( 128 | player_status_response, "playingSource" 129 | ) 130 | players[deviceId].night_light_mode = get_child_value( 131 | player_status_response, "nightlightMode" 132 | ) 133 | 134 | players[deviceId].power_source = POWER_SOURCE[ 135 | get_child_value(player_status_response, "powerSource") 136 | ] 137 | 138 | player_config = self._get_device_config(token, deviceId) 139 | if players[deviceId].config is None: 140 | players[deviceId].config = YotoPlayerConfig() 141 | time = get_child_value(player_config, "device.config.dayTime") 142 | players[deviceId].config.day_mode_time = datetime.datetime.strptime( 143 | time, "%H:%M" 144 | ).time() 145 | players[deviceId].config.day_display_brightness = get_child_value( 146 | player_config, "device.config.dayDisplayBrightness" 147 | ) 148 | players[deviceId].config.day_ambient_colour = get_child_value( 149 | player_config, "device.config.ambientColour" 150 | ) 151 | 152 | players[deviceId].config.day_max_volume_limit = get_child_value( 153 | player_config, "device.config.maxVolumeLimit" 154 | ) 155 | time = get_child_value(player_config, "device.config.nightTime") 156 | players[deviceId].config.night_mode_time = datetime.datetime.strptime( 157 | time, "%H:%M" 158 | ).time() 159 | players[deviceId].config.night_ambient_colour = get_child_value( 160 | player_config, "device.config.nightAmbientColour" 161 | ) 162 | 163 | players[deviceId].config.night_max_volume_limit = get_child_value( 164 | player_config, "device.config.nightMaxVolumeLimit" 165 | ) 166 | players[deviceId].config.night_display_brightness = get_child_value( 167 | player_config, "device.config.nightDisplayBrightness" 168 | ) 169 | alarms = get_child_value(player_config, "device.config.alarms") 170 | if players[deviceId].config.alarms is None: 171 | players[deviceId].config.alarms = [] 172 | for index in range(len(alarms)): 173 | values = alarms[index].split(",") 174 | if index > len(players[deviceId].config.alarms) - 1: 175 | # Sometimes the alarm list coming from API is shorter than it should be. This implies new enabled alarm that hasn't been toggled. 176 | if len(values) > 6: 177 | players[deviceId].config.alarms.append( 178 | Alarm( 179 | days_enabled=values[0], 180 | time=values[1], 181 | sound_id=values[2], 182 | volume=values[5], 183 | enabled=False if values[6] == "0" else True, 184 | ) 185 | ) 186 | else: 187 | players[deviceId].config.alarms.append( 188 | Alarm( 189 | days_enabled=values[0], 190 | time=values[1], 191 | sound_id=values[2], 192 | volume=values[5], 193 | enabled=True, 194 | ) 195 | ) 196 | else: 197 | players[deviceId].config.alarms[index].days_enabled = values[0] 198 | players[deviceId].config.alarms[index].time = values[1] 199 | players[deviceId].config.alarms[index].sound_id = values[2] 200 | players[deviceId].config.alarms[index].volume = values[5] 201 | if len(values) > 6: 202 | players[deviceId].config.alarms[index].enabled = ( 203 | False if values[6] == "0" else True 204 | ) 205 | 206 | players[deviceId].last_update_config = datetime.datetime.now(pytz.utc) 207 | players[deviceId].last_updated_at = datetime.datetime.now(pytz.utc) 208 | 209 | def update_library(self, token: Token, library: dict[Card]) -> None: 210 | response = self._get_cards(token) 211 | for item in response["cards"]: 212 | if get_child_value(item, "cardId") not in library: 213 | card: Card = Card( 214 | id=get_child_value(item, "cardId"), 215 | ) 216 | library[card.id] = card 217 | library[card.id].chapters = {} 218 | cardId = get_child_value(item, "cardId") 219 | library[cardId].title = get_child_value(item, "card.title") 220 | library[cardId].description = get_child_value( 221 | item, "card.metadata.description" 222 | ) 223 | library[cardId].author = get_child_value(item, "card.metadata.author") 224 | library[cardId].category = get_child_value(item, "card.metadata.stories") 225 | library[cardId].cover_image_large = get_child_value( 226 | item, "card.metadata.cover.imageL" 227 | ) 228 | library[cardId].series_order = get_child_value( 229 | item, "card.metadata.cover.seriesorder" 230 | ) 231 | library[cardId].series_title = get_child_value( 232 | item, "card.metadata.cover.seriestitle" 233 | ) 234 | 235 | def update_card_detail(self, token: Token, card: Card) -> None: 236 | card_detail_response = self._get_card_detail(token=token, cardid=card.id) 237 | for item in card_detail_response["card"]["content"]["chapters"]: 238 | # _LOGGER.debug(f"{DOMAIN} - Updating Details: {item}") 239 | if card.chapters is None: 240 | card.chapters = {} 241 | if get_child_value(item, "key") not in card.chapters: 242 | chapter: Chapter = Chapter( 243 | key=get_child_value(item, "key"), 244 | ) 245 | card.chapters[chapter.key] = chapter 246 | key = get_child_value(item, "key") 247 | card.chapters[key].icon = get_child_value(item, "display.icon16x16") 248 | card.chapters[key].title = get_child_value(item, "title") 249 | card.chapters[key].duration = get_child_value(item, "duration") 250 | for track_item in item["tracks"]: 251 | if card.chapters[key].tracks is None: 252 | card.chapters[key].tracks = {} 253 | if get_child_value(track_item, "key") not in card.chapters[key].tracks: 254 | track: Track = Track( 255 | key=get_child_value(track_item, "key"), 256 | ) 257 | # _LOGGER.debug(f"{DOMAIN} - track details: {track_item}") 258 | card.chapters[key].tracks[track.key] = track 259 | card.chapters[key].tracks[track.key].icon = get_child_value( 260 | track_item, "display.icon16x16" 261 | ) 262 | card.chapters[key].tracks[track.key].title = get_child_value( 263 | track_item, "title" 264 | ) 265 | card.chapters[key].tracks[track.key].duration = get_child_value( 266 | track_item, "duration" 267 | ) 268 | card.chapters[key].tracks[track.key].format = get_child_value( 269 | track_item, "format" 270 | ) 271 | card.chapters[key].tracks[track.key].channels = get_child_value( 272 | track_item, "channels" 273 | ) 274 | card.chapters[key].tracks[track.key].type = get_child_value( 275 | track_item, "type" 276 | ) 277 | card.chapters[key].tracks[track.key].trackUrl = get_child_value( 278 | track_item, "trackUrl" 279 | ) 280 | 281 | def set_player_config(self, token: Token, player_id: str, config: YotoPlayerConfig): 282 | url = f"{self.BASE_URL}/device-v2/{player_id}/config" 283 | config_payload = {} 284 | if config.day_mode_time: 285 | config_payload["dayTime"] = config.day_mode_time.strftime("%H:%M") 286 | if config.day_display_brightness is not None: 287 | config_payload["dayDisplayBrightness"] = str(config.day_display_brightness) 288 | if config.day_ambient_colour: 289 | config_payload["ambientColour"] = config.day_ambient_colour 290 | if config.day_max_volume_limit is not None: 291 | config_payload["maxVolumeLimit"] = str(config.day_max_volume_limit) 292 | if config.night_mode_time: 293 | config_payload["nightTime"] = config.night_mode_time.strftime("%H:%M") 294 | if config.night_display_brightness is not None: 295 | config_payload["nightDisplayBrightness"] = str( 296 | config.night_display_brightness 297 | ) 298 | if config.night_ambient_colour: 299 | config_payload["nightAmbientColour"] = config.night_ambient_colour 300 | if config.night_max_volume_limit is not None: 301 | config_payload["nightMaxVolumeLimit"] = str(config.night_max_volume_limit) 302 | if config.alarms: 303 | alarm_payload = [] 304 | for alarm in config.alarms: 305 | payload = ( 306 | str(alarm.days_enabled) 307 | + "," 308 | + str(alarm.time) 309 | + "," 310 | + str(alarm.sound_id) 311 | + ",,," 312 | + str(alarm.volume) 313 | + "," 314 | + str(int(alarm.enabled)) 315 | ) 316 | alarm_payload.append(payload) 317 | config_payload["alarms"] = alarm_payload 318 | data = {"deviceId": player_id, "config": config_payload} 319 | headers = self._get_authenticated_headers(token) 320 | response = requests.put(url, headers=headers, data=json.dumps(data)).json() 321 | _LOGGER.debug(f"{DOMAIN} - Set Device Config Payload: {data}") 322 | _LOGGER.debug(f"{DOMAIN} - Set Device Config Response: {response}") 323 | return response 324 | 325 | def get_authorization(self) -> dict: 326 | """Get authorization code and user instructions.""" 327 | data = { 328 | "audience": self.BASE_URL, 329 | "client_id": self.CLIENT_ID, 330 | "scope": "offline_access", 331 | } 332 | headers = {"Content-Type": "application/x-www-form-urlencoded"} 333 | 334 | response = requests.post(self.AUTH_URL, data=data, headers=headers) 335 | if not response.ok: 336 | raise AuthenticationError( 337 | f"Authorization failed: {response.status_code} {response.text}" 338 | ) 339 | 340 | return response.json() 341 | 342 | def poll_for_token(self, auth_result: dict) -> Token: 343 | code = auth_result["device_code"] 344 | interval = auth_result.get("interval", 5) 345 | expires_in = auth_result.get("expires_in", 300) 346 | 347 | # Calculate expiration time 348 | expiration_time = datetime.datetime.now() + datetime.timedelta( 349 | seconds=expires_in 350 | ) 351 | 352 | interval_ms = interval * 1000 353 | 354 | while datetime.datetime.now() < expiration_time: 355 | token_data = { 356 | "grant_type": "urn:ietf:params:oauth:grant-type:device_code", 357 | "device_code": code, 358 | "client_id": self.CLIENT_ID, 359 | "audience": self.BASE_URL, 360 | } 361 | headers = {"Content-Type": "application/x-www-form-urlencoded"} 362 | 363 | response = requests.post(self.TOKEN_URL, data=token_data, headers=headers) 364 | response_body = response.json() 365 | 366 | # Successful authentication 367 | if response.ok: 368 | _LOGGER.debug(f"{DOMAIN} - Authorization successful") 369 | 370 | valid_until = datetime.datetime.now(pytz.utc) + datetime.timedelta( 371 | seconds=response_body["expires_in"] 372 | ) 373 | 374 | return Token( 375 | access_token=response_body["access_token"], 376 | refresh_token=response_body["refresh_token"], 377 | token_type=response_body.get("token_type", "Bearer"), 378 | scope=response_body.get("scope", "openid profile offline_access"), 379 | valid_until=valid_until, 380 | ) 381 | 382 | # Handle OAuth2 errors 383 | if response.status_code == 403: 384 | error = response_body.get("error") 385 | if error == "authorization_pending": 386 | _LOGGER.debug(f"{DOMAIN} - Authorization pending, waiting...") 387 | time.sleep(interval) 388 | continue 389 | elif error == "slow_down": 390 | interval_ms += 5000 391 | interval = interval_ms // 1000 392 | _LOGGER.debug( 393 | f"{DOMAIN} - Received slow_down, increasing interval to {interval}s" 394 | ) 395 | time.sleep(interval) 396 | continue 397 | elif error == "expired_token": 398 | raise AuthenticationError( 399 | "Code has expired. Please restart the authentication process." 400 | ) 401 | else: 402 | raise AuthenticationError( 403 | response_body.get( 404 | "error_description", 405 | response_body.get("error", "Unknown error"), 406 | ) 407 | ) 408 | 409 | # Unexpected error 410 | raise AuthenticationError( 411 | f"Token request failed: {response.status_code} {response.text}" 412 | ) 413 | 414 | raise AuthenticationError("Authentication timed out. Please try again.") 415 | 416 | def _get_devices(self, token: Token) -> None: 417 | url = self.BASE_URL + "/device-v2/devices/mine" 418 | 419 | headers = self._get_authenticated_headers(token) 420 | 421 | response = requests.get(url, headers=headers).json() 422 | _LOGGER.debug(f"{DOMAIN} - Get Devices Response: {response}") 423 | return response 424 | 425 | def _get_device_status(self, token: Token, player_id: str) -> None: 426 | url = self.BASE_URL + "/device-v2/" + player_id + "/status" 427 | 428 | headers = self._get_authenticated_headers(token) 429 | 430 | response = requests.get(url, headers=headers).json() 431 | _LOGGER.debug(f"{DOMAIN} - Get Device {player_id} Status Response: {response}") 432 | return response 433 | 434 | def _get_device_config(self, token: Token, player_id: str) -> None: 435 | url = self.BASE_URL + "/device-v2/" + player_id + "/config" 436 | 437 | headers = self._get_authenticated_headers(token) 438 | 439 | response = requests.get(url, headers=headers).json() 440 | _LOGGER.debug(f"{DOMAIN} - Get Device {player_id} Config Response: {response}") 441 | return response 442 | # 2024-05-15 17:25:48,604 yoto_api.YotoAPI DEBUG:yoto_api - Get Device Config Response: {'device': {'deviceId': 'y23IBS76kCaOSrGlz29XhIFO', 'name': '', 'errorCode': None, 'fwVersion': 'v2.17.5-5', 'popCode': 'FAJKEH', 'releaseChannelId': 'prerelease', 'releaseChannelVersion': 'v2.17.5-5', 'activationPopCode': 'IBSKCAAA', 'registrationCode': 'IBSKCAAA', 'deviceType': 'v3', 'deviceFamily': 'v3', 'deviceGroup': '', 'mac': 'b4:8a:0a:92:7a:f4', 'online': True, 'geoTimezone': 'America/Edmonton', 'getPosix': 'MST7MDT,M3.2.0,M11.1.0', 'status': {'activeCard': 'none', 'aliveTime': None, 'als': 0, 'battery': None, 'batteryLevel': 100, 'batteryRemaining': None, 'bgDownload': 0, 'bluetoothHp': 0, 'buzzErrors': 0, 'bytesPS': 0, 'cardInserted': 0, 'chgStatLevel': None, 'charging': 0, 'day': 1, 'dayBright': None, 'dbatTimeout': None, 'dnowBrightness': None, 'deviceId': 'y23IBS76kCaOSrGlz29XhIFO', 'errorsLogged': 164, 'failData': None, 'failReason': None, 'free': None, 'free32': None, 'freeDisk': 30219824, 'freeDMA': None, 'fwVersion': 'v2.17.5-5', 'headphones': 0, 'lastSeenAt': None, 'missedLogs': None, 'nfcErrs': 'n/a', 'nightBright': None, 'nightlightMode': '0x194a55', 'playingStatus': 0, 'powerCaps': '0x02', 'powerSrc': 2, 'qiOtp': None, 'sd_info': None, 'shutDown': None, 'shutdownTimeout': None, 'ssid': 'speed', 'statusVersion': None, 'temp': '0:24', 'timeFormat': None, 'totalDisk': 31385600, 'twdt': 0, 'updatedAt': '2024-05-15T23:23:45.284Z', 'upTime': 159925, 'userVolume': 31, 'utcOffset': -21600, 'utcTime': 1715815424, 'volume': 34, 'wifiRestarts': None, 'wifiStrength': -54}, 'config': {'locale': 'en', 'bluetoothEnabled': '1', 'repeatAll': True, 'showDiagnostics': True, 'btHeadphonesEnabled': True, 'pauseVolumeDown': False, 'pausePowerButton': True, 'displayDimTimeout': '60', 'shutdownTimeout': '3600', 'headphonesVolumeLimited': False, 'dayTime': '06:30', 'maxVolumeLimit': '16', 'ambientColour': '#40bfd9', 'dayDisplayBrightness': 'auto', 'dayYotoDaily': '3nC80/daily/', 'dayYotoRadio': '3nC80/radio-day/01', 'daySoundsOff': '0', 'nightTime': '18:20', 'nightMaxVolumeLimit': '8', 'nightAmbientColour': '#f57399', 'nightDisplayBrightness': '100', 'nightYotoDaily': '0', 'nightYotoRadio': '0', 'nightSoundsOff': '1', 'hourFormat': '12', 'timezone': '', 'displayDimBrightness': '0', 'systemVolume': '87', 'volumeLevel': 'safe', 'clockFace': 'digital-sun', 'logLevel': 'none', 'alarms': []}, 'shortcuts': {'versionId': '36645a9463e038d6cb9923257b38d9d9df7a6509', 'modes': {'day': {'content': [{'cmd': 'track-play', 'params': {'card': '3nC80', 'chapter': 'daily', 'track': ''}}, {'cmd': 'track-play', 'params': {'card': '3nC80', 'chapter': 'radio-day', 'track': '01'}}]}, 'night': {'content': [{'cmd': 'track-play', 'params': {'card': '3nC80', 'chapter': 'daily', 'track': ''}}, {'cmd': 'track-play', 'params': {'card': '3nC80', 'chapter': 'radio-night', 'track': '01'}}]}}}}} 443 | 444 | def _get_cards(self, token: Token) -> dict: 445 | ############## ${BASE_URL}/card/family/library ############# 446 | url = self.BASE_URL + "/card/family/library" 447 | headers = self._get_authenticated_headers(token) 448 | 449 | response = requests.get(url, headers=headers).json() 450 | # _LOGGER.debug(f"{DOMAIN} - Get Card Library: {response}") 451 | return response 452 | 453 | # { 454 | # "cards": [ 455 | # { 456 | # "cardId": "g5tcK", 457 | # "reason": "physical-add", 458 | # "shareType": "yoto", 459 | # "familyId": "ksdlbksbdgklb", 460 | # "card": { 461 | # "cardId": "g5tcK", 462 | # "content": { 463 | # "activity": "yoto_Player", 464 | # "editSettings": { 465 | # "editKeys": false, 466 | # "autoOverlayLabels": "disabled" 467 | # }, 468 | # "config": { 469 | # "disableAutoOverlayLabels": false 470 | # }, 471 | # "availability": "", 472 | # "cover": { 473 | # "imageL": "https://card-content.yotoplay.com/yoto/pub/jbfaljsblajsfblj-wcAgqZMvA" 474 | # }, 475 | # "version": "1" 476 | # }, 477 | # "slug": "ladybird-audio-adventures-the-frozen-world", 478 | # "userId": "yoto", 479 | # "sortkey": "ladybird-audio-adventures-the-frozen-world", 480 | # "title": "Ladybird Audio Adventures: The Frozen World", 481 | # "updatedAt": "2022-07-21T14:30:22.231Z", 482 | # "createdAt": "2020-09-03T17:30:17.911Z", 483 | # "metadata": { 484 | # "category": "stories", 485 | # "author": "Ladybird", 486 | # "previewAudio": "shopify-slug", 487 | # "status": { 488 | # "name": "live", 489 | # "updatedAt": "2020-11-24T17:08:54.839Z" 490 | # }, 491 | # "seriestitle": "Ladybird Audio Adventures - Volume 2", 492 | # "media": { 493 | # "fileSize": 35189015, 494 | # "duration": 2883, 495 | # "hasStreams": false 496 | # }, 497 | # "description": "Join our intrepid adventurers Otto and Cassandra (and Missy, the smartest bird in the Universe) as they embark on a brand new Ladybird Audio Adventure!\n\nIn this adventure, Otto and Missy are off to explore the Frozen World. Setting course for the Arctic and Antarctica they discover penguins, orcas and seals, and a whole lot of snow! Now if they can just figure out how to get the heating going in Otto's teleporter they'll be able to get back home! \n\nThese audiobooks help children learn about their environment on journey of discovery with the narrators Ben Bailey Smith (aka Doc Brown, rapper, comedian and writer) and Sophie Aldred (best known for her role as Ace in Doctor Who).", 498 | # "cover": { 499 | # "imageL": "https://card-content.yotoplay.com/yoto/pub/lajsbfljabsfljabsfljbasfljbalsjf-wcAgqZMvA?width=250" 500 | # }, 501 | # "seriesorder": "2", 502 | # "languages": [ 503 | # "en" 504 | # ] 505 | # } 506 | # }, 507 | # "provenanceId": "kasfblasbflbaslkfl", 508 | # "inFamilyLibrary": true, 509 | # "updatedAt": "2024-04-10T03:58:16.732Z", 510 | # "createdAt": "2022-12-26T07:04:18.977Z", 511 | # "lastPlayedAt": "2024-04-11T04:30:49.402Z", 512 | # "masterUid": "asbkflbasflkblaksf" 513 | # }, 514 | # { 515 | # "cardId": "iYIMF", 516 | # "reason": "physical-add", 517 | # "shareType": "yoto", 518 | # "familyId": "ksdlbksbdgklb", 519 | # "card": { 520 | # "cardId": "iYIMF", 521 | # "content": { 522 | # "activity": "yoto_Player", 523 | # "editSettings": { 524 | # "editKeys": false, 525 | # "autoOverlayLabels": "chapters-offset-1" 526 | # }, 527 | # "config": { 528 | # "trackNumberOverlayTimeout": 0, 529 | # "disableAutoOverlayLabels": false 530 | # }, 531 | # "availability": "", 532 | # "cover": { 533 | # "imageL": "https://card-content.yotoplay.com/yoto/pub/kdsgblkjsbgjlslbj" 534 | # }, 535 | # "version": "1" 536 | # }, 537 | # "slug": "ladybird-audio-adventures-outer-space", 538 | # "userId": "yoto", 539 | # "sortkey": "ladybird-audio-adventures-outer-space", 540 | # "title": "Ladybird Audio Adventures - Outer Space", 541 | # "updatedAt": "2022-07-21T14:25:14.090Z", 542 | # "createdAt": "2019-12-04T00:14:57.438Z", 543 | # "metadata": { 544 | # "category": "stories", 545 | # "author": "Ladybird Audio Adventures", 546 | # "previewAudio": "shopify-slug", 547 | # "status": { 548 | # "name": "live", 549 | # "updatedAt": "2020-11-16T11:13:50.060Z" 550 | # }, 551 | # "seriestitle": "Ladybird Audio Adventures Volume 1", 552 | # "media": { 553 | # "fileSize": 27225336, 554 | # "duration": 3335, 555 | # "hasStreams": false 556 | # }, 557 | # "description": "The sky's the limit for imaginations when it comes to this audio adventure! Wave goodbye to Earth and blast off into the skies above to explore 'nearby' planets, stars and galaxies, alongside inventor Otto and Missy – the cleverest raven in the universe. So, hop aboard Otto's spacecraft and get ready for a story that's nothing short of out of this world!\n\nLadybird Audio Adventures is an original series for 4-to 7-year-olds; a new, entertaining and engaging way for children to learn about the world around them. These are special stories written exclusively for audio with fun sound and musical effects, perfect for listening at home, before bed and on long journeys. ", 558 | # "cover": { 559 | # "imageL": "https://card-content.yotoplay.com/yoto/pub/ksdlfbksdbgklsbdlgk?width=250" 560 | # }, 561 | # "seriesorder": "4", 562 | # "languages": [ 563 | # "en" 564 | # ] 565 | # } 566 | # }, 567 | # "provenanceId": "641352b283571a15872a37ca", 568 | # "inFamilyLibrary": true, 569 | # "updatedAt": "2024-04-05T04:03:55.198Z", 570 | # "createdAt": "2023-03-16T17:32:34.249Z", 571 | # "lastPlayedAt": "2024-04-05T06:15:11.308Z", 572 | # "masterUid": "04dedd46720000" 573 | # } 574 | # } 575 | 576 | def _get_card_detail(self, token: Token, cardid: str) -> dict: 577 | ############## Details below from snooping JSON requests of the app ###################### 578 | 579 | url = self.BASE_URL + "/card/" + cardid 580 | headers = self._get_authenticated_headers(token) 581 | 582 | response = requests.get(url, headers=headers).json() 583 | # _LOGGER.debug(f"{DOMAIN} - Get Card Detail: {response}") 584 | return response 585 | 586 | ############# ${BASE_URL}/card/details/abcABC ############# 587 | # { 588 | # "card": { 589 | # "cardId": "abcABC", #string 590 | # "content": { 591 | # "activity": "yoto_Player", 592 | # "version": "1", 593 | # "availability": "", 594 | # "editSettings": { 595 | # "autoOverlayLabels": "chapters-offset-1", 596 | # "editKeys": false 597 | # }, 598 | # "config": { 599 | # "trackNumberOverlayTimeout": 0, 600 | # "disableAutoOverlayLabels": false 601 | # }, 602 | # "cover": { 603 | # "imageL": "https://card-content.yotoplay.com/yoto/pub/WgoJMZiFdH35UbDAR_4z2k1vL0MufKLHfR4ULd6I" 604 | # }, 605 | # "chapters": [ 606 | # { 607 | # "overlayLabel": "", 608 | # "title": "Introduction", 609 | # "key": "01-INT", 610 | # "overlayLabelOverride": null, 611 | # "ambient": null, 612 | # "defaultTrackDisplay": null, 613 | # "defaultTrackAmbient": null, 614 | # "duration": 349, #int 615 | # "fileSize": 2915405, 616 | # "hasStreams": false, 617 | # "display": { 618 | # "icon16x16": "https://card-content.yotoplay.com/yoto/SwcetJ_c1xt9yN5jn2wdwMk4xupHLWONik-rzcBh" 619 | # }, 620 | # "tracks": [ 621 | # { 622 | # "overlayLabel": "", 623 | # "format": "aac", 624 | # "title": "Introduction", 625 | # "type": "audio", 626 | # "key": "01-INT", 627 | # "overlayLabelOverride": null, 628 | # "ambient": null, 629 | # "fileSize": 2915405, 630 | # "channels": "mono", 631 | # "duration": 349, 632 | # "transitions": {}, 633 | # "display": { 634 | # "icon16x16": "https://card-content.yotoplay.com/yoto/SwcetJ_c1xt9yN5jn2wdwMk4xupHLWONik-rzcBhkd4" 635 | # }, 636 | # "trackUrl": "https://secure-media.yotoplay.com/yoto/mYZ6TgL7VRAViZ_RQL5daYEdCBCCXjes?Expires=1712889341&Policy=eyJTdGF0ZnQiOlt7IlJlc291cmNlIjoiaHR0cHM6Ly9zZWN1cmUtbWVkaWEueW90b3BsYXkulvdG8vbVlaNlRnTDdWSTBxUkFWaVpfUlFMNWRhdtWS1aVElsWGplcyIsIkNvbmRpdGlvbiI6eyJEYXRlTGVzc1RoYW4iOnsiQVdTOkVwb2NoSI6MTcxMjg4OTM0fV19&Signature=EiZwaoCrCG7y-LgEECIxwrkGNZYzUeMOubfcDL1uuqamskan3wG8WYTe8CGOlsG9kvanhUFojuR-bnG~YqT0wPUkn6UUtR8KY9EOVUp~Gr8X9~yGE1I-klUGgykSRIXu1za6sGsF4KwQH2QUNPyS9yS8T50d09zEgAZlGYSDqcz1u1Rb7GZRm69bwtWr1PjLZLrWkV1C9~yV~4wwR17xdgT2JU20ZJ99kBWaTG1efjH9qBaQTkL1EvewHfJkYXFQs~o3mi1bp6d4LYzXa59yzb-f3-cRK~IWgMIRiKNY~0Mgx8S-VA__&Key-Pair-Id=K11LSW6MJ7KP#sha256=mYZ6TgL7VI0qRAViZ_RQL5daYEdCBCCWmY-ZTIlX" 637 | # } 638 | # ] 639 | # }, 640 | # { 641 | # "overlayLabel": "1", 642 | # "title": "Not the Moon", 643 | # "key": "02-1", 644 | # "overlayLabelOverride": null, 645 | # "ambient": null, 646 | # "defaultTrackDisplay": null, 647 | # "defaultTrackAmbient": null, 648 | # "duration": 140, 649 | # "fileSize": 1111649, 650 | # "hasStreams": false, 651 | # "display": { 652 | # "icon16x16": "https://card-content.yotoplay.com/yoto/XZOm4YE9ssAm_x2ykzasHyResnOWJzYIVe_hfc" 653 | # }, 654 | # "tracks": [ 655 | # { 656 | # "overlayLabel": "1", 657 | # "format": "aac", 658 | # "title": "Not the Moon", 659 | # "type": "audio", 660 | # "key": "02-1", 661 | # "overlayLabelOverride": null, 662 | # "ambient": null, 663 | # "fileSize": 1111649, 664 | # "channels": "mono", 665 | # "duration": 140, 666 | # "transitions": {}, 667 | # "display": { 668 | # "icon16x16": "https://card-content.yotoplay.com/yoto/XZOm4YE9ssAm_x2ykzasHyWJhf5RYzYIVe_hfc" 669 | # }, 670 | # "trackUrl": "long url" 671 | # } 672 | # ] 673 | # } 674 | # ] 675 | # }, 676 | # "createdAt": "2019-12-04T00:14:57.438Z", 677 | # "metadata": { 678 | # "description": "The sky's the limit for imaginations when it comes to this audio adventure! Wave goodbye to Earth and blast off into the skies above to explore 'nearby' planets, stars and galaxies, alongside inventor Otto and Missy – the cleverest raven in the universe. So, hop aboard Otto's spacecraft and get ready for a story that's nothing short of out of this world!\n\nLadybird Audio Adventures is an original series for 4-to 7-year-olds; a new, entertaining and engaging way for children to learn about the world around them. These are special stories written exclusively for audio with fun sound and musical effects, perfect for listening at home, before bed and on long journeys. ", 679 | # "category": "stories", 680 | # "author": "Ladybird Audio Adventures", 681 | # "previewAudio": "shopify-slug", 682 | # "seriestitle": "Ladybird Audio Adventures Volume 1", 683 | # "seriesorder": "4", 684 | # "cover": { 685 | # "imageL": "https://card-content.yotoplay.com/yoto/pub/WgoJMZiFdH35UbDAR_4z2k1vKLHfR4ULd6ItN4" 686 | # }, 687 | # "languages": [ 688 | # "en" 689 | # ], 690 | # "status": { 691 | # "name": "live", 692 | # "updatedAt": "2020-11-16T11:13:50.060Z" 693 | # }, 694 | # "media": { 695 | # "duration": 3335, 696 | # "fileSize": 27225336, 697 | # "hasStreams": false 698 | # } 699 | # }, 700 | # "slug": "ladybird-audio-adventures-outer-space", 701 | # "title": "Ladybird Audio Adventures - Outer Space", 702 | # "updatedAt": "2022-07-21T14:25:14.090Z", 703 | # "userId": "yoto", 704 | # "sortkey": "ladybird-audio-adventures-outer-space" 705 | # }, 706 | # "ownership": { 707 | # "canAccess": true, 708 | # "userHasRole": false, 709 | # "cardIsFree": false, 710 | # "cardIsMadeByUser": false, 711 | # "cardIsInFamilyLibrary": true, 712 | # "cardIsCreatedByFamily": false, 713 | # "isAccessibleUsingSubscription": false 714 | # } 715 | # } 716 | 717 | def _get_authenticated_headers(self, token: Token) -> dict: 718 | return { 719 | "User-Agent": "Yoto/2.73 (com.yotoplay.Yoto; build:10405; iOS 17.4.0) Alamofire/5.6.4", 720 | "Content-Type": "application/json", 721 | "Authorization": token.token_type + " " + token.access_token, 722 | } 723 | 724 | 725 | ######Endpoints: 726 | 727 | # api.yotoplay.com/device-v2/devices/mine 728 | # api.yotoplay.com/device-v2/$deviceid/status 729 | # api.yotoplay.com/media/displayIcons/user/me 730 | # api.yotoplay.com/user/details 731 | # api.yotoplay.com/user/family/mine?allowStub=true 732 | # api.yotoplay.com/card/mine 733 | # api.yotoplay.com/card/mine/user/family/mine?allowStub=true 734 | # api.yotoplay.com/card/family/library 735 | # api.yotoplay.com/card/library/free 736 | # api.yotoplay.com/card/library/club 737 | # api.yotoplay.com/card/family/library 738 | -------------------------------------------------------------------------------- /yoto_api/YotoMQTTClient.py: -------------------------------------------------------------------------------- 1 | """MQTT Client for Yoto""" 2 | 3 | import logging 4 | import paho.mqtt.client as mqtt 5 | import json 6 | import datetime 7 | import pytz 8 | 9 | 10 | from .const import DOMAIN, VOLUME_MAPPING_INVERTED 11 | from .Token import Token 12 | from .utils import get_child_value, take_closest 13 | 14 | _LOGGER = logging.getLogger(__name__) 15 | 16 | 17 | class YotoMQTTClient: 18 | def __init__(self) -> None: 19 | self.CLIENT_ID: str = "4P2do5RhHDXvCDZDZ6oti27Ft2XdRrzr" 20 | self.MQTT_AUTH_NAME: str = "PublicJWTAuthorizer" 21 | self.MQTT_URL: str = "aqrphjqbp3u2z-ats.iot.eu-west-2.amazonaws.com" 22 | self.client = None 23 | 24 | def connect_mqtt(self, token: Token, players: dict, callback): 25 | # mqtt.CallbackAPIVersion.VERSION1, 26 | userdata = (players, callback) 27 | self.client = mqtt.Client( 28 | client_id="YOTOAPI" + next(iter(players)).replace("-", ""), 29 | transport="websockets", 30 | userdata=userdata, 31 | ) 32 | self.client.username_pw_set( 33 | username="_?x-amz-customauthorizer-name=" + self.MQTT_AUTH_NAME, 34 | password=token.access_token, 35 | ) 36 | self.client.on_message = self._on_message 37 | self.client.on_connect = self._on_connect 38 | self.client.on_disconnect = self._on_disconnect 39 | self.client.tls_set() 40 | self.client.connect(host=self.MQTT_URL, port=443) 41 | self.client.loop_start() 42 | 43 | def disconnect_mqtt(self): 44 | self.client.loop_stop() 45 | self.client.disconnect() 46 | 47 | def _on_connect(self, client, userdata, flags, rc): 48 | players = userdata[0] 49 | for player in players: 50 | self.client.subscribe("device/" + player + "/events") 51 | self.client.subscribe("device/" + player + "/status") 52 | self.client.subscribe("device/" + player + "/response") 53 | _LOGGER.debug(f"{DOMAIN} - Connected and Subscribed to player: {player}") 54 | 55 | self.update_status(player) 56 | 57 | def _on_disconnect(self, client, userdata, rc): 58 | _LOGGER.debug(f"{DOMAIN} - {client._client_id} - MQTT Disconnected: {rc}") 59 | 60 | def update_status(self, deviceId): 61 | topic = f"device/{deviceId}/command/events" 62 | self.client.publish(topic) 63 | 64 | def set_volume(self, deviceId: str, volume: int): 65 | closest_volume = take_closest(VOLUME_MAPPING_INVERTED, volume) 66 | topic = f"device/{deviceId}/command/set-volume" 67 | payload = json.dumps({"volume": closest_volume}) 68 | self.client.publish(topic, str(payload)) 69 | self.update_status(deviceId) 70 | # {"status":{"set-volume":"OK","req_body":"{\"volume\":25,\"requestId\":\"39804a13-988d-43d2-b30f-1f3b9b5532f0\"}"}} 71 | 72 | def set_sleep(self, deviceId: str, seconds: int): 73 | topic = f"device/{deviceId}/command/sleep" 74 | payload = json.dumps({"seconds": seconds}) 75 | self.client.publish(topic, str(payload)) 76 | self.update_status(deviceId) 77 | 78 | def card_stop(self, deviceId): 79 | topic = f"device/{deviceId}/command/card-stop" 80 | self.client.publish(topic) 81 | self.update_status(deviceId) 82 | 83 | def card_pause(self, deviceId): 84 | topic = f"device/{deviceId}/command/card-pause" 85 | self.client.publish(topic) 86 | self.update_status(deviceId) 87 | 88 | def card_resume(self, deviceId): 89 | topic = f"device/{deviceId}/command/card-resume" 90 | self.client.publish(topic) 91 | self.update_status(deviceId) 92 | # MQTT Message: {"status":{"card-pause":"OK","req_body":""}} 93 | 94 | def card_play( 95 | self, 96 | deviceId, 97 | cardId: str, 98 | secondsIn: int = None, 99 | cutoff: int = None, 100 | chapterKey: str = None, 101 | trackKey: str = None, 102 | ): 103 | topic = f"device/{deviceId}/command/card-play" 104 | payload = {} 105 | payload["uri"] = f"https://yoto.io/{cardId}" 106 | 107 | if cutoff is not None: 108 | payload["cutOff"] = int(cutoff) 109 | if chapterKey is not None: 110 | payload["chapterKey"] = str(chapterKey) 111 | if trackKey is not None: 112 | payload["trackKey"] = str(trackKey) 113 | if secondsIn is not None: 114 | payload["secondsIn"] = int(secondsIn) 115 | json_payload = json.dumps(payload) 116 | _LOGGER.debug(f"{DOMAIN} - card-play payload: {json_payload}") 117 | self.client.publish(topic, json_payload) 118 | self.update_status(deviceId) 119 | # MQTT Message: {"status":{"card-play":"OK","req_body":"{\"uri\":\"https://yoto.io/7JtVV\",\"secondsIn\":0,\"cutOff\":0,\"chapterKey\":\"01\",\"trackKey\":\"01\",\"requestId\":\"5385910e-f853-4f34-99a4-d2ed94f02f6d\"}"}} 120 | 121 | def restart(self, deviceId): 122 | # restart the player 123 | 124 | topic = f"device/{deviceId}/command/restart" 125 | self.client.publish(topic) 126 | 127 | # control bluetooth on the player 128 | # action: "on" (turn on), "off" (turn off), "is-on" (check if bluetooth is on) 129 | # name: (optional) the name of the target device to connect to when action is "on" 130 | # mac: (optional) the MAC address of the target device to connect to when action is "on" 131 | def bluetooth(self, deviceId, action: str, name: str, mac: str): 132 | topic = f"device/{deviceId}/command/bt" 133 | payload = json.dumps( 134 | { 135 | "action": action, 136 | "name": name, 137 | "mac": mac, 138 | } 139 | ) 140 | self.client.publish(topic, str(payload)) 141 | 142 | # set the ambient light of the player 143 | # red, blue, green values of intensity from 0-255 144 | def set_ambients(self, deviceId: str, r: int, g: int, b: int): 145 | topic = f"device/{deviceId}/command/ambients" 146 | payload = json.dumps({"r": r, "g": g, "b": b}) 147 | self.client.publish(topic, str(payload)) 148 | 149 | def _parse_status_message(self, message, player): 150 | player.night_light_mode = ( 151 | get_child_value(message, "nightlightMode") or player.night_light_mode 152 | ) 153 | player.battery_level_percentage = ( 154 | get_child_value(message, "batteryLevel") or player.battery_level_percentage 155 | ) 156 | player.last_updated_at = datetime.datetime.now(pytz.utc) 157 | 158 | def _parse_events_message(self, message, player): 159 | if player.online is False: 160 | player.online = True 161 | player.repeat_all = get_child_value(message, "repeatAll") or player.repeat_all 162 | player.volume = get_child_value(message, "volume") or player.volume 163 | player.volume_max = get_child_value(message, "volumeMax") or player.volume_max 164 | player.online = get_child_value(message, "online") or player.online 165 | player.chapter_title = ( 166 | get_child_value(message, "chapterTitle") or player.chapter_title 167 | ) 168 | player.track_title = ( 169 | get_child_value(message, "trackTitle") or player.track_title 170 | ) 171 | player.track_length = ( 172 | get_child_value(message, "trackLength") or player.track_length 173 | ) 174 | player.track_position = ( 175 | get_child_value(message, "position") or player.track_position 176 | ) 177 | player.source = get_child_value(message, "source") or player.source 178 | player.playback_status = ( 179 | get_child_value(message, "playbackStatus") or player.playback_status 180 | ) 181 | if get_child_value(message, "sleepTimerActive") is not None: 182 | player.sleep_timer_active = get_child_value(message, "sleepTimerActive") 183 | 184 | player.sleep_timer_seconds_remaining = ( 185 | get_child_value(message, "sleepTimerSeconds") 186 | or player.sleep_timer_seconds_remaining 187 | ) 188 | if not player.sleep_timer_active: 189 | player.sleep_timer_seconds_remaining = 0 190 | player.card_id = get_child_value(message, "cardId") or player.card_id 191 | if player.card_id == "none": 192 | player.card_id = None 193 | player.track_key = get_child_value(message, "trackKey") or player.track_key 194 | player.chapter_key = ( 195 | get_child_value(message, "chapterKey") or player.chapter_key 196 | ) 197 | player.last_updated_at = datetime.datetime.now(pytz.utc) 198 | 199 | # {"trackLength":315,"position":0,"cardId":"7JtVV","repeatAll":true,"source":"remote","cardUpdatedAt":"2021-07-13T14:51:26.576Z","chapterTitle":"Snow and Tell","chapterKey":"03","trackTitle":"Snow and Tell","trackKey":"03","streaming":false,"volume":5,"volumeMax":8,"playbackStatus":"playing","playbackWait":false,"sleepTimerActive":false,"eventUtc":1715133271} 200 | 201 | def _on_message(self, client, userdata, message): 202 | # Process MQTT Message 203 | players = userdata[0] 204 | 205 | _LOGGER.debug(f"{DOMAIN} - MQTT Topic: {message.topic}") 206 | _LOGGER.debug(f"{DOMAIN} - MQTT Raw Payload: {message.payload}") 207 | # _LOGGER.debug(f"{DOMAIN} - MQTT QOS: {message.qos}") 208 | # _LOGGER.debug(f"{DOMAIN} - MQTT Retain: {message.retain}") 209 | callback = userdata[1] 210 | base, device, topic = message.topic.split("/") 211 | player = players[device] 212 | if topic == "status": 213 | self._parse_status_message( 214 | json.loads(str(message.payload.decode("utf-8"))), player 215 | ) 216 | if callback: 217 | callback() 218 | elif topic == "events": 219 | self._parse_events_message( 220 | json.loads(str(message.payload.decode("utf-8"))), player 221 | ) 222 | if callback: 223 | callback() 224 | -------------------------------------------------------------------------------- /yoto_api/YotoManager.py: -------------------------------------------------------------------------------- 1 | """YotoManager.py""" 2 | 3 | from datetime import datetime, timedelta 4 | import logging 5 | import pytz 6 | 7 | from .YotoAPI import YotoAPI 8 | from .YotoMQTTClient import YotoMQTTClient 9 | from .Family import Family 10 | from .Token import Token 11 | from .const import DOMAIN 12 | from .YotoPlayer import YotoPlayerConfig 13 | from .Card import Card 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | 18 | class YotoManager: 19 | def __init__(self, client_id: str) -> None: 20 | if not client_id: 21 | raise ValueError("A client_id must be provided") 22 | self.client_id: str = client_id 23 | self.api: YotoAPI = YotoAPI(client_id=self.client_id) 24 | self.players: dict = {} 25 | self.token: Token = None 26 | self.library: dict = {} 27 | self.mqtt_client: YotoMQTTClient = None 28 | self.callback: None 29 | self.family: Family = None 30 | self.auth_result: dict = None 31 | 32 | def set_refresh_token(self, refresh_token: str) -> None: 33 | self.token = Token(refresh_token=refresh_token) 34 | 35 | def device_code_flow_start(self) -> dict: 36 | self.auth_result = self.api.get_authorization() 37 | return self.auth_result 38 | 39 | def device_code_flow_complete(self) -> None: 40 | self.token = self.api.poll_for_token(self.auth_result) 41 | self.api.update_players(self.token, self.players) 42 | 43 | def update_players_status(self) -> None: 44 | # Updates the data with current player data. 45 | self.api.update_players(self.token, self.players) 46 | if self.mqtt_client: 47 | for player in self.players: 48 | self.mqtt_client.update_status(player) 49 | 50 | def connect_to_events(self, callback=None) -> None: 51 | # Starts and connects to MQTT. Runs a loop to receive events. Callback is called when event has been processed and player updated. 52 | self.callback = callback 53 | self.mqtt_client = YotoMQTTClient() 54 | self.mqtt_client.connect_mqtt(self.token, self.players, callback) 55 | 56 | def set_player_config(self, player_id: str, config: YotoPlayerConfig): 57 | self.api.set_player_config(token=self.token, player_id=player_id, config=config) 58 | self.update_players_status() 59 | 60 | def disconnect(self) -> None: 61 | # Should be used when shutting down 62 | if self.mqtt_client: 63 | self.mqtt_client.disconnect_mqtt() 64 | self.mqtt_client = None 65 | 66 | def update_library(self) -> None: 67 | # Updates library and all card data. Typically only required on startup. 68 | self.api.update_library(self.token, self.library) 69 | 70 | def update_family(self) -> None: 71 | # Updates the family object with family details 72 | self.family = self.api.get_family(self.token) 73 | 74 | def update_card_detail(self, cardId: str) -> None: 75 | # Used to get more details for a specific card. update_cards must be run first to get the basic library details. Could be called in a loop for all cards but this is a lot of API calls when the data may not be needed. 76 | if cardId not in self.library: 77 | self.library[cardId] = Card(id=cardId) 78 | self.api.update_card_detail(token=self.token, card=self.library[cardId]) 79 | 80 | def pause_player(self, player_id: str): 81 | self.mqtt_client.card_pause(deviceId=player_id) 82 | 83 | def stop_player(self, player_id: str): 84 | self.mqtt_client.card_stop(deviceId=player_id) 85 | 86 | def resume_player(self, player_id: str): 87 | self.mqtt_client.card_resume(deviceId=player_id) 88 | 89 | def play_card( 90 | self, 91 | player_id: str, 92 | card: str, 93 | secondsIn: int = None, 94 | cutoff: int = None, 95 | chapterKey: str = None, 96 | trackKey: str = None, 97 | ): 98 | self.mqtt_client.card_play( 99 | deviceId=player_id, 100 | cardId=card, 101 | secondsIn=secondsIn, 102 | cutoff=cutoff, 103 | chapterKey=chapterKey, 104 | trackKey=trackKey, 105 | ) 106 | 107 | def set_volume(self, player_id: str, volume: int): 108 | # Takes a range from 0-100. Maps it to the nearest 0-16 value from the constant file and sends that 109 | self.mqtt_client.set_volume(deviceId=player_id, volume=volume) 110 | 111 | def set_ambients_color(self, player_id: str, r: int, g: int, b: int): 112 | self.mqtt_client.set_ambients(deviceId=player_id, r=r, g=g, b=b) 113 | 114 | def set_sleep(self, player_id: str, seconds: int): 115 | # Set sleep time for playback. 0 Disables sleep. 116 | self.mqtt_client.set_sleep(deviceId=player_id, seconds=seconds) 117 | 118 | def check_and_refresh_token(self) -> Token: 119 | # Returns a new token, or current token if still valid. 120 | if self.token is None: 121 | raise ValueError("No token available, please authenticate first") 122 | if self.token.access_token is None: 123 | self.token = self.api.refresh_token(self.token) 124 | 125 | if self.token.valid_until - timedelta(hours=1) <= datetime.now(pytz.utc): 126 | _LOGGER.debug(f"{DOMAIN} - access token expired, refreshing") 127 | self.token: Token = self.api.refresh_token(self.token) 128 | if self.mqtt_client: 129 | self.disconnect() 130 | self.connect_to_events(self.callback) 131 | return self.token 132 | -------------------------------------------------------------------------------- /yoto_api/YotoPlayer.py: -------------------------------------------------------------------------------- 1 | """YotoPlayers class""" 2 | 3 | from dataclasses import dataclass 4 | import datetime 5 | 6 | 7 | @dataclass 8 | class Alarm: 9 | # raw api example. ['0000001,0700,4OD25,,,1,0'] 10 | days_enabled: int = None 11 | enabled: bool = None 12 | time: datetime.time = None 13 | volume: int = None 14 | sound_id: str = None 15 | 16 | 17 | @dataclass 18 | class YotoPlayerConfig: 19 | # Device Config 20 | day_mode_time: datetime.time = None 21 | # Auto, or value 22 | day_display_brightness: str = None 23 | # Values in HEX_COLORS in const 24 | day_ambient_colour: str = None 25 | day_max_volume_limit: int = None 26 | 27 | night_mode_time: datetime.time = None 28 | # Auto, or value 29 | night_display_brightness: str = None 30 | # Values in HEX_COLORS in const 31 | night_ambient_colour: str = None 32 | night_max_volume_limit: int = None 33 | alarms: list = None 34 | 35 | 36 | @dataclass 37 | class YotoPlayer: 38 | # Device API 39 | id: str = None 40 | name: str = None 41 | device_type: str = None 42 | online: bool = None 43 | last_updated_at: datetime.datetime = None 44 | 45 | # Status API 46 | active_card: str = None 47 | is_playing: bool = None 48 | playing_source: str = None 49 | ambient_light_sensor_reading: int = None 50 | battery_level_percentage: int = None 51 | day_mode_on: bool = None 52 | night_light_mode: str = None 53 | user_volume: int = None 54 | system_volume: int = None 55 | temperature_celcius: int = None 56 | bluetooth_audio_connected: bool = None 57 | charging: bool = None 58 | audio_device_connected: bool = None 59 | firmware_version: str = None 60 | wifi_strength: int = None 61 | power_source: str = None 62 | last_updated_api: datetime.datetime = None 63 | 64 | # Config 65 | config: YotoPlayerConfig = None 66 | last_update_config: datetime.datetime = None 67 | 68 | # MQTT 69 | card_id: str = None 70 | repeat_all: bool = None 71 | volume_max: int = None 72 | volume: int = None 73 | chapter_title: str = None 74 | chapter_key: str = None 75 | source: str = None 76 | track_title: str = None 77 | track_length: int = None 78 | track_position: int = None 79 | track_key: str = None 80 | playback_status: str = None 81 | sleep_timer_active: bool = False 82 | sleep_timer_seconds_remaining: int = 0 83 | 84 | 85 | # {'devices': [{'deviceId': 'XXXX', 'name': 'Yoto Player', 'description': 'nameless.limit', 'online': False, 'releaseChannel': 'general', 'deviceType': 'v3', 'deviceFamily': 'v3', 'deviceGroup': '', 'hasUserGivenName': False}]} 86 | # Device Status API: {'activeCard': 'none', 'ambientLightSensorReading': 0, 'averageDownloadSpeedBytesSecond': 0, 'batteryLevelPercentage': 100, 'buzzErrors': 0, 'cardInsertionState': 2, 'dayMode': 0, 'deviceId': 'XXXX', 'errorsLogged': 210, 'firmwareVersion': 'v2.17.5', 'freeDiskSpaceBytes': 30250544, 'isAudioDeviceConnected': False, 'isBackgroundDownloadActive': False, 'isBluetoothAudioConnected': False, 'isCharging': False, 'isOnline': True, 'networkSsid': 'XXXX', 'nightlightMode': '0x000000', 'playingSource': 0, 'powerCapabilities': '0x02', 'powerSource': 2, 'systemVolumePercentage': 47, 'taskWatchdogTimeoutCount': 0, 'temperatureCelcius': '20', 'totalDiskSpaceBytes': 31385600, 'updatedAt': '2024-04-23T01:26:19.927Z', 'uptime': 252342, 'userVolumePercentage': 50, 'utcOffsetSeconds': -21600, 'utcTime': 1713835609, 'wifiStrength': -61} 87 | # Mqtt response: 88 | -------------------------------------------------------------------------------- /yoto_api/__init__.py: -------------------------------------------------------------------------------- 1 | """Top-level package for Yoto API""" 2 | # flake8: noqa 3 | 4 | from .YotoPlayer import YotoPlayer, YotoPlayerConfig 5 | from .Family import Family 6 | from .YotoManager import YotoManager 7 | from .YotoAPI import YotoAPI 8 | from .Token import Token 9 | from .YotoMQTTClient import YotoMQTTClient 10 | from .const import LIGHT_COLORS, HEX_COLORS, VOLUME_MAPPING_INVERTED, POWER_SOURCE 11 | from .exceptions import AuthenticationError 12 | -------------------------------------------------------------------------------- /yoto_api/const.py: -------------------------------------------------------------------------------- 1 | """const.py""" 2 | 3 | DOMAIN: str = "yoto_api" 4 | 5 | # Blue night_light_mode 0x194a55 6 | # off is 0x000000 7 | # 0x643600 is a valid response too. I think this is day. 8 | 9 | LIGHT_COLORS = { 10 | None: None, 11 | "0x000000": "Off", 12 | "0x194a55": "On", 13 | "0x643600": "On Day", 14 | "off": "Off", 15 | "0x5a6400": "On Night", 16 | "0x640000": "Orange Peel", 17 | "0x602d3c": "Lilac", 18 | "0x641600": "", 19 | "0x646464": "White", 20 | } 21 | 22 | HEX_COLORS = { 23 | "#40bfd9": "Sky Blue", 24 | "#41c0f0": "Sky Blue", 25 | "#9eff00": "Apple Green", 26 | "#e6ff00": "Apple Green", 27 | "#f57399": "Lilac", 28 | "#f72a69": "Lilac", 29 | "#ff0000": "Tambourine Red", 30 | "#ff3900": "Orange Peel", 31 | "#ff8500": "Bumblebee Yellow", 32 | "#ff8c00": "Orange Peel", 33 | "#ffb800": "Bumblebee Yellow", 34 | "#ffffff": "white", 35 | "#0": "Off", 36 | } 37 | 38 | VOLUME_MAPPING_INVERTED = [ 39 | 0, 40 | 7, 41 | 13, 42 | 19, 43 | 25, 44 | 32, 45 | 38, 46 | 44, 47 | 50, 48 | 57, 49 | 63, 50 | 69, 51 | 75, 52 | 82, 53 | 88, 54 | 94, 55 | 100, 56 | ] 57 | 58 | 59 | POWER_SOURCE = { 60 | # Guessing on this. 61 | None: None, 62 | 0: "Battery Power", 63 | 1: "Battery Power", 64 | 2: "USB Power", 65 | 3: "USB Power", 66 | 4: "USB Power", 67 | 5: "USB Power", 68 | } 69 | -------------------------------------------------------------------------------- /yoto_api/exceptions.py: -------------------------------------------------------------------------------- 1 | """exceptions.py""" 2 | 3 | 4 | class YotoException(Exception): 5 | """ 6 | Generic YotoException exception. 7 | """ 8 | 9 | pass 10 | 11 | 12 | class AuthenticationError(YotoException): 13 | """ 14 | Raised upon receipt of an authentication error. 15 | """ 16 | 17 | pass 18 | -------------------------------------------------------------------------------- /yoto_api/utils.py: -------------------------------------------------------------------------------- 1 | """utils.py""" 2 | 3 | import datetime 4 | import re 5 | from bisect import bisect_left 6 | 7 | 8 | def get_child_value(data, key): 9 | value = data 10 | for x in key.split("."): 11 | try: 12 | value = value[x] 13 | except Exception: 14 | try: 15 | value = value[int(x)] 16 | except Exception: 17 | value = None 18 | return value 19 | 20 | 21 | def parse_datetime(value, timezone) -> datetime.datetime: 22 | if value is None: 23 | return datetime.datetime(2000, 1, 1, tzinfo=timezone) 24 | 25 | value = value.replace("-", "").replace("T", "").replace(":", "").replace("Z", "") 26 | m = re.match(r"(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})", value) 27 | return datetime.datetime( 28 | year=int(m.group(1)), 29 | month=int(m.group(2)), 30 | day=int(m.group(3)), 31 | hour=int(m.group(4)), 32 | minute=int(m.group(5)), 33 | second=int(m.group(6)), 34 | tzinfo=timezone, 35 | ) 36 | 37 | 38 | def take_closest(list, number): 39 | """ 40 | Assumes list is sorted. Returns closest value to number. Used for volume mapping. 41 | 42 | If two numbers are equally close, return the smallest number. 43 | """ 44 | pos = bisect_left(list, number) 45 | if pos == 0: 46 | return list[0] 47 | if pos == len(list): 48 | return list[-1] 49 | before = list[pos - 1] 50 | after = list[pos] 51 | if after - number < number - before: 52 | return after 53 | else: 54 | return before 55 | --------------------------------------------------------------------------------