├── .devcontainer └── devcontainer.json ├── .editorconfig ├── .gitattributes ├── .github ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── FUNDING.yml ├── ISSUE_TEMPLATE.md ├── LICENSE.md ├── PULL_REQUEST_TEMPLATE.md ├── labels.yml ├── release-drafter.yml ├── renovate.json └── workflows │ ├── codeql.yaml │ ├── labels.yaml │ ├── linting.yaml │ ├── lock.yaml │ ├── pr-labels.yaml │ ├── release-drafter.yaml │ ├── release.yaml │ ├── stale.yaml │ ├── tests.yaml │ └── typing.yaml ├── .gitignore ├── .nvmrc ├── .pre-commit-config.yaml ├── .prettierignore ├── .yamllint ├── README.md ├── examples ├── __init__.py ├── change_name.py ├── control.py ├── mini.py └── ruff.toml ├── package-lock.json ├── package.json ├── poetry.lock ├── pyproject.toml ├── sonar-project.properties ├── src └── elgato │ ├── __init__.py │ ├── elgato.py │ ├── exceptions.py │ ├── models.py │ └── py.typed └── tests ├── __init__.py ├── fixtures ├── battery-info.json ├── info-key-light-air.json ├── info-key-light-mini.json ├── info-key-light.json ├── info-light-strip.json ├── settings-key-light-mini.json ├── settings-keylight.json ├── settings-strip.json ├── state-color.json └── state-temperature.json ├── ruff.toml ├── test_battery.py ├── test_elgato.py ├── test_identify.py ├── test_info.py ├── test_restart.py ├── test_settings.py └── test_state.py /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "containerEnv": { 3 | "POETRY_VIRTUALENVS_IN_PROJECT": "true" 4 | }, 5 | "customizations": { 6 | "codespaces": { 7 | "openFiles": ["README.md", "src/elgato/elgato.py", "src/elgato/models.py"] 8 | }, 9 | "vscode": { 10 | "extensions": [ 11 | "ms-python.python", 12 | "redhat.vscode-yaml", 13 | "esbenp.prettier-vscode", 14 | "GitHub.vscode-pull-request-github", 15 | "charliermarsh.ruff", 16 | "GitHub.vscode-github-actions", 17 | "ryanluker.vscode-coverage-gutters" 18 | ], 19 | "settings": { 20 | "[python]": { 21 | "editor.codeActionsOnSave": { 22 | "source.fixAll": true, 23 | "source.organizeImports": true 24 | } 25 | }, 26 | "coverage-gutters.customizable.context-menu": true, 27 | "coverage-gutters.customizable.status-bar-toggler-watchCoverageAndVisibleEditors-enabled": true, 28 | "coverage-gutters.showGutterCoverage": false, 29 | "coverage-gutters.showLineCoverage": true, 30 | "coverage-gutters.xmlname": "coverage.xml", 31 | "python.analysis.extraPaths": ["${workspaceFolder}/src"], 32 | "python.defaultInterpreterPath": ".venv/bin/python", 33 | "python.formatting.provider": "black", 34 | "python.linting.enabled": true, 35 | "python.linting.mypyEnabled": true, 36 | "python.linting.pylintEnabled": true, 37 | "python.testing.cwd": "${workspaceFolder}", 38 | "python.testing.pytestArgs": ["--cov-report=xml"], 39 | "python.testing.pytestEnabled": true, 40 | "ruff.importStrategy": "fromEnvironment", 41 | "ruff.interpreter": [".venv/bin/python"], 42 | "terminal.integrated.defaultProfile.linux": "zsh" 43 | } 44 | } 45 | }, 46 | "features": { 47 | "ghcr.io/devcontainers-contrib/features/poetry:2": {}, 48 | "ghcr.io/devcontainers/features/github-cli:1": {}, 49 | "ghcr.io/devcontainers/features/node:1": {}, 50 | "ghcr.io/devcontainers/features/python:1": { 51 | "installTools": false 52 | } 53 | }, 54 | "image": "mcr.microsoft.com/devcontainers/base:ubuntu", 55 | "name": "Asynchronous client for Elgato Lights", 56 | "updateContentCommand": ". ${NVM_DIR}/nvm.sh && nvm install && nvm use && npm install && poetry install && poetry run pre-commit install" 57 | } 58 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_style = space 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | indent_size = 2 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text eol=lf 2 | *.py whitespace=error 3 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | .github/* @frenck 2 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of conduct 2 | 3 | ## Our pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention 26 | or advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or 30 | electronic address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate 32 | in a professional setting 33 | 34 | ## Our responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project lead at frenck@addons.community. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project lead is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 71 | version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to this repository, please first discuss the change you wish 4 | to make via issue, email, or any other method with the owners of this repository 5 | before making a change. 6 | 7 | Please note we have a code of conduct, please follow it in all your interactions 8 | with the project. 9 | 10 | ## Issues and feature requests 11 | 12 | You've found a bug in the source code, a mistake in the documentation or maybe 13 | you'd like a new feature? You can help us by submitting an issue to our 14 | [GitHub Repository][github]. Before you create an issue, make sure you search 15 | the archive, maybe your question was already answered. 16 | 17 | Even better: You could submit a pull request with a fix / new feature! 18 | 19 | ## Pull request process 20 | 21 | 1. Search our repository for open or closed [pull requests][prs] that relates 22 | to your submission. You don't want to duplicate effort. 23 | 24 | 1. You may merge the pull request in once you have the sign-off of two other 25 | developers, or if you do not have permission to do that, you may request 26 | the second reviewer to merge it for you. 27 | 28 | [github]: https://github.com/frenck/python-elgato/issues 29 | [prs]: https://github.com/frenck/python-elgato/pulls 30 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | --- 2 | github: frenck 3 | patreon: frenck 4 | custom: https://frenck.dev/donate/ 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Problem/Motivation 2 | 3 | > (Why the issue was filed) 4 | 5 | ## Expected behavior 6 | 7 | > (What you expected to happen) 8 | 9 | ## Actual behavior 10 | 11 | > (What actually happened) 12 | 13 | ## Steps to reproduce 14 | 15 | > (How can someone else make/see it happen) 16 | 17 | ## Proposed changes 18 | 19 | > (If you have a proposed change, workaround or fix, 20 | > describe the rationale behind it) 21 | -------------------------------------------------------------------------------- /.github/LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2019-2024 Franck Nijhof 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Proposed Changes 2 | 3 | > (Describe the changes and rationale behind them) 4 | 5 | ## Related Issues 6 | 7 | > ([Github link][autolink-references] to related issues or pull requests) 8 | 9 | [autolink-references]: https://help.github.com/articles/autolinked-references-and-urls/ 10 | -------------------------------------------------------------------------------- /.github/labels.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: "breaking-change" 3 | color: ee0701 4 | description: "A breaking change for existing users." 5 | - name: "bugfix" 6 | color: ee0701 7 | description: "Inconsistencies or issues which will cause a problem for users or implementers." 8 | - name: "documentation" 9 | color: 0052cc 10 | description: "Solely about the documentation of the project." 11 | - name: "enhancement" 12 | color: 1d76db 13 | description: "Enhancement of the code, not introducing new features." 14 | - name: "refactor" 15 | color: 1d76db 16 | description: "Improvement of existing code, not introducing new features." 17 | - name: "performance" 18 | color: 1d76db 19 | description: "Improving performance, not introducing new features." 20 | - name: "new-feature" 21 | color: 0e8a16 22 | description: "New features or options." 23 | - name: "maintenance" 24 | color: 2af79e 25 | description: "Generic maintenance tasks." 26 | - name: "ci" 27 | color: 1d76db 28 | description: "Work that improves the continue integration." 29 | - name: "dependencies" 30 | color: 1d76db 31 | description: "Upgrade or downgrade of project dependencies." 32 | 33 | - name: "in-progress" 34 | color: fbca04 35 | description: "Issue is currently being resolved by a developer." 36 | - name: "stale" 37 | color: fef2c0 38 | description: "There has not been activity on this issue or PR for quite some time." 39 | - name: "no-stale" 40 | color: fef2c0 41 | description: "This issue or PR is exempted from the stable bot." 42 | 43 | - name: "security" 44 | color: ee0701 45 | description: "Marks a security issue that needs to be resolved asap." 46 | - name: "incomplete" 47 | color: fef2c0 48 | description: "Marks a PR or issue that is missing information." 49 | - name: "invalid" 50 | color: fef2c0 51 | description: "Marks a PR or issue that is missing information." 52 | 53 | - name: "beginner-friendly" 54 | color: 0e8a16 55 | description: "Good first issue for people wanting to contribute to the project." 56 | - name: "help-wanted" 57 | color: 0e8a16 58 | description: "We need some extra helping hands or expertise in order to resolve this." 59 | 60 | - name: "hacktoberfest" 61 | description: "Issues/PRs are participating in the Hacktoberfest." 62 | color: fbca04 63 | - name: "hacktoberfest-accepted" 64 | description: "Issues/PRs are participating in the Hacktoberfest." 65 | color: fbca04 66 | 67 | - name: "priority-critical" 68 | color: ee0701 69 | description: "This should be dealt with ASAP. Not fixing this issue would be a serious error." 70 | - name: "priority-high" 71 | color: b60205 72 | description: "After critical issues are fixed, these should be dealt with before any further issues." 73 | - name: "priority-medium" 74 | color: 0e8a16 75 | description: "This issue may be useful, and needs some attention." 76 | - name: "priority-low" 77 | color: e4ea8a 78 | description: "Nice addition, maybe... someday..." 79 | 80 | - name: "major" 81 | color: b60205 82 | description: "This PR causes a major version bump in the version number." 83 | - name: "minor" 84 | color: 0e8a16 85 | description: "This PR causes a minor version bump in the version number." 86 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name-template: "v$RESOLVED_VERSION" 3 | tag-template: "v$RESOLVED_VERSION" 4 | change-template: "- $TITLE @$AUTHOR (#$NUMBER)" 5 | sort-direction: ascending 6 | 7 | categories: 8 | - title: "🚨 Breaking changes" 9 | labels: 10 | - "breaking-change" 11 | - title: "✨ New features" 12 | labels: 13 | - "new-feature" 14 | - title: "🐛 Bug fixes" 15 | labels: 16 | - "bugfix" 17 | - title: "🚀 Enhancements" 18 | labels: 19 | - "enhancement" 20 | - "refactor" 21 | - "performance" 22 | - title: "🧰 Maintenance" 23 | labels: 24 | - "maintenance" 25 | - "ci" 26 | - title: "📚 Documentation" 27 | labels: 28 | - "documentation" 29 | - title: "⬆️ Dependency updates" 30 | labels: 31 | - "dependencies" 32 | 33 | version-resolver: 34 | major: 35 | labels: 36 | - "major" 37 | - "breaking-change" 38 | minor: 39 | labels: 40 | - "minor" 41 | - "new-feature" 42 | patch: 43 | labels: 44 | - "bugfix" 45 | - "chore" 46 | - "ci" 47 | - "dependencies" 48 | - "documentation" 49 | - "enhancement" 50 | - "performance" 51 | - "refactor" 52 | default: patch 53 | 54 | template: | 55 | ## What’s changed 56 | 57 | $CHANGES 58 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "rebaseWhen": "behind-base-branch", 4 | "dependencyDashboard": true, 5 | "labels": ["dependencies", "no-stale"], 6 | "lockFileMaintenance": { 7 | "enabled": true, 8 | "automerge": true 9 | }, 10 | "commitMessagePrefix": "⬆️", 11 | "packageRules": [ 12 | { 13 | "matchManagers": ["poetry"], 14 | "addLabels": ["python"] 15 | }, 16 | { 17 | "matchManagers": ["poetry"], 18 | "matchDepTypes": ["dev"], 19 | "rangeStrategy": "pin" 20 | }, 21 | { 22 | "matchManagers": ["poetry"], 23 | "matchUpdateTypes": ["minor", "patch"], 24 | "automerge": true 25 | }, 26 | { 27 | "matchManagers": ["npm", "nvm"], 28 | "addLabels": ["javascript"], 29 | "rangeStrategy": "pin" 30 | }, 31 | { 32 | "matchManagers": ["npm", "nvm"], 33 | "matchUpdateTypes": ["minor", "patch"], 34 | "automerge": true 35 | }, 36 | { 37 | "matchManagers": ["github-actions"], 38 | "addLabels": ["github_actions"], 39 | "rangeStrategy": "pin" 40 | }, 41 | { 42 | "matchManagers": ["github-actions"], 43 | "matchUpdateTypes": ["minor", "patch"], 44 | "automerge": true 45 | } 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "CodeQL" 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | push: 7 | branches: [main] 8 | pull_request: 9 | branches: [main] 10 | workflow_dispatch: 11 | schedule: 12 | - cron: "30 1 * * 0" 13 | 14 | jobs: 15 | codeql: 16 | name: Scanning 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: ⤵️ Check out code from GitHub 20 | uses: actions/checkout@v4.2.2 21 | - name: 🏗 Initialize CodeQL 22 | uses: github/codeql-action/init@v3.28.19 23 | - name: 🚀 Perform CodeQL Analysis 24 | uses: github/codeql-action/analyze@v3.28.19 25 | -------------------------------------------------------------------------------- /.github/workflows/labels.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Sync labels 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | push: 7 | branches: 8 | - main 9 | paths: 10 | - .github/labels.yml 11 | workflow_dispatch: 12 | 13 | jobs: 14 | labels: 15 | name: ♻️ Sync labels 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: ⤵️ Check out code from GitHub 19 | uses: actions/checkout@v4.2.2 20 | - name: 🚀 Run Label Syncer 21 | uses: micnncim/action-label-syncer@v1.3.0 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | -------------------------------------------------------------------------------- /.github/workflows/linting.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Linting 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | push: 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | env: 11 | DEFAULT_PYTHON: "3.11" 12 | 13 | jobs: 14 | codespell: 15 | name: codespell 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: ⤵️ Check out code from GitHub 19 | uses: actions/checkout@v4.2.2 20 | - name: 🏗 Set up Poetry 21 | run: pipx install poetry 22 | - name: 🏗 Set up Python ${{ env.DEFAULT_PYTHON }} 23 | id: python 24 | uses: actions/setup-python@v5.6.0 25 | with: 26 | python-version: ${{ env.DEFAULT_PYTHON }} 27 | cache: "poetry" 28 | - name: 🏗 Install workflow dependencies 29 | run: | 30 | poetry config virtualenvs.create true 31 | poetry config virtualenvs.in-project true 32 | - name: 🏗 Install Python dependencies 33 | run: poetry install --no-interaction 34 | - name: 🚀 Check code for common misspellings 35 | run: poetry run pre-commit run codespell --all-files 36 | 37 | ruff: 38 | name: Ruff 39 | runs-on: ubuntu-latest 40 | steps: 41 | - name: ⤵️ Check out code from GitHub 42 | uses: actions/checkout@v4.2.2 43 | - name: 🏗 Set up Poetry 44 | run: pipx install poetry 45 | - name: 🏗 Set up Python ${{ env.DEFAULT_PYTHON }} 46 | id: python 47 | uses: actions/setup-python@v5.6.0 48 | with: 49 | python-version: ${{ env.DEFAULT_PYTHON }} 50 | cache: "poetry" 51 | - name: 🏗 Install workflow dependencies 52 | run: | 53 | poetry config virtualenvs.create true 54 | poetry config virtualenvs.in-project true 55 | - name: 🏗 Install Python dependencies 56 | run: poetry install --no-interaction 57 | - name: 🚀 Run ruff linter 58 | run: poetry run ruff check --output-format=github . 59 | - name: 🚀 Run ruff formatter 60 | run: poetry run ruff format --check . 61 | 62 | pre-commit-hooks: 63 | name: pre-commit-hooks 64 | runs-on: ubuntu-latest 65 | steps: 66 | - name: ⤵️ Check out code from GitHub 67 | uses: actions/checkout@v4.2.2 68 | - name: 🏗 Set up Poetry 69 | run: pipx install poetry 70 | - name: 🏗 Set up Python ${{ env.DEFAULT_PYTHON }} 71 | id: python 72 | uses: actions/setup-python@v5.6.0 73 | with: 74 | python-version: ${{ env.DEFAULT_PYTHON }} 75 | cache: "poetry" 76 | - name: 🏗 Install workflow dependencies 77 | run: | 78 | poetry config virtualenvs.create true 79 | poetry config virtualenvs.in-project true 80 | - name: 🏗 Install Python dependencies 81 | run: poetry install --no-interaction 82 | - name: 🚀 Check Python AST 83 | run: poetry run pre-commit run check-ast --all-files 84 | - name: 🚀 Check for case conflicts 85 | run: poetry run pre-commit run check-case-conflict --all-files 86 | - name: 🚀 Check docstring is first 87 | run: poetry run pre-commit run check-docstring-first --all-files 88 | - name: 🚀 Check that executables have shebangs 89 | run: poetry run pre-commit run check-executables-have-shebangs --all-files 90 | - name: 🚀 Check JSON files 91 | run: poetry run pre-commit run check-json --all-files 92 | - name: 🚀 Check for merge conflicts 93 | run: poetry run pre-commit run check-merge-conflict --all-files 94 | - name: 🚀 Check for broken symlinks 95 | run: poetry run pre-commit run check-symlinks --all-files 96 | - name: 🚀 Check TOML files 97 | run: poetry run pre-commit run check-toml --all-files 98 | - name: 🚀 Check XML files 99 | run: poetry run pre-commit run check-xml --all-files 100 | - name: 🚀 Check YAML files 101 | run: poetry run pre-commit run check-yaml --all-files 102 | - name: 🚀 Check YAML files 103 | run: poetry run pre-commit run check-yaml --all-files 104 | - name: 🚀 Detect Private Keys 105 | run: poetry run pre-commit run detect-private-key --all-files 106 | - name: 🚀 Check End of Files 107 | run: poetry run pre-commit run end-of-file-fixer --all-files 108 | - name: 🚀 Trim Trailing Whitespace 109 | run: poetry run pre-commit run trailing-whitespace --all-files 110 | 111 | pylint: 112 | name: pylint 113 | runs-on: ubuntu-latest 114 | steps: 115 | - name: ⤵️ Check out code from GitHub 116 | uses: actions/checkout@v4.2.2 117 | - name: 🏗 Set up Poetry 118 | run: pipx install poetry 119 | - name: 🏗 Set up Python ${{ env.DEFAULT_PYTHON }} 120 | id: python 121 | uses: actions/setup-python@v5.6.0 122 | with: 123 | python-version: ${{ env.DEFAULT_PYTHON }} 124 | cache: "poetry" 125 | - name: 🏗 Install workflow dependencies 126 | run: | 127 | poetry config virtualenvs.create true 128 | poetry config virtualenvs.in-project true 129 | - name: 🏗 Install Python dependencies 130 | run: poetry install --no-interaction 131 | - name: 🚀 Run pylint 132 | run: poetry run pre-commit run pylint --all-files 133 | 134 | yamllint: 135 | name: yamllint 136 | runs-on: ubuntu-latest 137 | steps: 138 | - name: ⤵️ Check out code from GitHub 139 | uses: actions/checkout@v4.2.2 140 | - name: 🏗 Set up Poetry 141 | run: pipx install poetry 142 | - name: 🏗 Set up Python ${{ env.DEFAULT_PYTHON }} 143 | id: python 144 | uses: actions/setup-python@v5.6.0 145 | with: 146 | python-version: ${{ env.DEFAULT_PYTHON }} 147 | cache: "poetry" 148 | - name: 🏗 Install workflow dependencies 149 | run: | 150 | poetry config virtualenvs.create true 151 | poetry config virtualenvs.in-project true 152 | - name: 🏗 Install Python dependencies 153 | run: poetry install --no-interaction 154 | - name: 🚀 Run yamllint 155 | run: poetry run yamllint . 156 | 157 | prettier: 158 | name: Prettier 159 | runs-on: ubuntu-latest 160 | steps: 161 | - name: ⤵️ Check out code from GitHub 162 | uses: actions/checkout@v4.2.2 163 | - name: 🏗 Set up Poetry 164 | run: pipx install poetry 165 | - name: 🏗 Set up Python ${{ env.DEFAULT_PYTHON }} 166 | id: python 167 | uses: actions/setup-python@v5.6.0 168 | with: 169 | python-version: ${{ env.DEFAULT_PYTHON }} 170 | cache: "poetry" 171 | - name: 🏗 Install workflow dependencies 172 | run: | 173 | poetry config virtualenvs.create true 174 | poetry config virtualenvs.in-project true 175 | - name: 🏗 Install Python dependencies 176 | run: poetry install --no-interaction 177 | - name: 🏗 Set up Node.js 178 | uses: actions/setup-node@v4.4.0 179 | with: 180 | node-version-file: ".nvmrc" 181 | cache: "npm" 182 | - name: 🏗 Install NPM dependencies 183 | run: npm install 184 | - name: 🚀 Run prettier 185 | run: poetry run pre-commit run prettier --all-files 186 | -------------------------------------------------------------------------------- /.github/workflows/lock.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Lock 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | schedule: 7 | - cron: "0 9 * * *" 8 | workflow_dispatch: 9 | 10 | jobs: 11 | lock: 12 | name: 🔒 Lock closed issues and PRs 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: dessant/lock-threads@v5.0.1 16 | with: 17 | github-token: ${{ github.token }} 18 | issue-inactive-days: "30" 19 | issue-lock-reason: "" 20 | pr-inactive-days: "1" 21 | pr-lock-reason: "" 22 | -------------------------------------------------------------------------------- /.github/workflows/pr-labels.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: PR Labels 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | pull_request_target: 7 | types: 8 | - opened 9 | - labeled 10 | - unlabeled 11 | - synchronize 12 | workflow_call: 13 | 14 | jobs: 15 | pr_labels: 16 | name: Verify 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: 🏷 Verify PR has a valid label 20 | uses: jesusvasquez333/verify-pr-label-action@v1.4.0 21 | with: 22 | pull-request-number: "${{ github.event.pull_request.number }}" 23 | github-token: "${{ secrets.GITHUB_TOKEN }}" 24 | valid-labels: >- 25 | breaking-change, bugfix, documentation, enhancement, 26 | refactor, performance, new-feature, maintenance, ci, dependencies 27 | disable-reviews: true 28 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release Drafter 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | push: 7 | branches: 8 | - main 9 | workflow_dispatch: 10 | 11 | jobs: 12 | update_release_draft: 13 | name: ✏️ Draft release 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: 🚀 Run Release Drafter 17 | uses: release-drafter/release-drafter@v6.1.0 18 | env: 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | release: 7 | types: 8 | - published 9 | 10 | env: 11 | DEFAULT_PYTHON: "3.11" 12 | 13 | jobs: 14 | release: 15 | name: Releasing to PyPi 16 | runs-on: ubuntu-latest 17 | environment: 18 | name: release 19 | url: https://pypi.org/p/elgato 20 | permissions: 21 | contents: write 22 | id-token: write 23 | steps: 24 | - name: ⤵️ Check out code from GitHub 25 | uses: actions/checkout@v4.2.2 26 | - name: 🏗 Set up Poetry 27 | run: pipx install poetry 28 | - name: 🏗 Set up Python ${{ env.DEFAULT_PYTHON }} 29 | id: python 30 | uses: actions/setup-python@v5.6.0 31 | with: 32 | python-version: ${{ env.DEFAULT_PYTHON }} 33 | cache: "poetry" 34 | - name: 🏗 Install workflow dependencies 35 | run: | 36 | poetry config virtualenvs.create true 37 | poetry config virtualenvs.in-project true 38 | - name: 🏗 Install dependencies 39 | run: poetry install --no-interaction 40 | - name: 🏗 Set package version 41 | run: | 42 | version="${{ github.event.release.tag_name }}" 43 | version="${version,,}" 44 | version="${version#v}" 45 | poetry version --no-interaction "${version}" 46 | - name: 🏗 Build package 47 | run: poetry build --no-interaction 48 | - name: 🚀 Publish to PyPi 49 | uses: pypa/gh-action-pypi-publish@v1.12.4 50 | with: 51 | verbose: true 52 | print-hash: true 53 | - name: ✍️ Sign published artifacts 54 | uses: sigstore/gh-action-sigstore-python@v3.0.0 55 | with: 56 | inputs: ./dist/*.tar.gz ./dist/*.whl 57 | release-signing-artifacts: true 58 | -------------------------------------------------------------------------------- /.github/workflows/stale.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Stale 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | schedule: 7 | - cron: "0 8 * * *" 8 | workflow_dispatch: 9 | 10 | jobs: 11 | stale: 12 | name: 🧹 Clean up stale issues and PRs 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: 🚀 Run stale 16 | uses: actions/stale@v9.1.0 17 | with: 18 | repo-token: ${{ secrets.GITHUB_TOKEN }} 19 | days-before-stale: 30 20 | days-before-close: 7 21 | remove-stale-when-updated: true 22 | stale-issue-label: "stale" 23 | exempt-issue-labels: "no-stale,help-wanted" 24 | stale-issue-message: > 25 | There hasn't been any activity on this issue recently, so we 26 | clean up some of the older and inactive issues. 27 | 28 | Please make sure to update to the latest version and 29 | check if that solves the issue. Let us know if that works for you 30 | by leaving a comment 👍 31 | 32 | This issue has now been marked as stale and will be closed if no 33 | further activity occurs. Thanks! 34 | stale-pr-label: "stale" 35 | exempt-pr-labels: "no-stale" 36 | stale-pr-message: > 37 | There hasn't been any activity on this pull request recently. This 38 | pull request has been automatically marked as stale because of that 39 | and will be closed if no further activity occurs within 7 days. 40 | Thank you for your contributions. 41 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Testing 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | push: 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | env: 11 | DEFAULT_PYTHON: "3.11" 12 | 13 | jobs: 14 | pytest: 15 | name: Python ${{ matrix.python }} 16 | runs-on: ubuntu-latest 17 | strategy: 18 | matrix: 19 | python: ["3.11", "3.12", "3.13"] 20 | steps: 21 | - name: ⤵️ Check out code from GitHub 22 | uses: actions/checkout@v4.2.2 23 | - name: 🏗 Set up Poetry 24 | run: pipx install poetry 25 | - name: 🏗 Set up Python ${{ matrix.python }} 26 | id: python 27 | uses: actions/setup-python@v5.6.0 28 | with: 29 | python-version: ${{ matrix.python }} 30 | cache: "poetry" 31 | - name: 🏗 Install workflow dependencies 32 | run: | 33 | poetry config virtualenvs.create true 34 | poetry config virtualenvs.in-project true 35 | - name: 🏗 Install dependencies 36 | run: poetry install --no-interaction 37 | - name: 🚀 Run pytest 38 | run: poetry run pytest --cov elgato tests 39 | - name: ⬆️ Upload coverage artifact 40 | uses: actions/upload-artifact@v4.6.2 41 | with: 42 | name: coverage-${{ matrix.python }} 43 | include-hidden-files: true 44 | path: .coverage 45 | 46 | coverage: 47 | runs-on: ubuntu-latest 48 | needs: pytest 49 | steps: 50 | - name: ⤵️ Check out code from GitHub 51 | uses: actions/checkout@v4.2.2 52 | with: 53 | fetch-depth: 0 54 | - name: ⬇️ Download coverage data 55 | uses: actions/download-artifact@v4.3.0 56 | - name: 🏗 Set up Poetry 57 | run: pipx install poetry 58 | - name: 🏗 Set up Python ${{ env.DEFAULT_PYTHON }} 59 | id: python 60 | uses: actions/setup-python@v5.6.0 61 | with: 62 | python-version: ${{ env.DEFAULT_PYTHON }} 63 | cache: "poetry" 64 | - name: 🏗 Install workflow dependencies 65 | run: | 66 | poetry config virtualenvs.create true 67 | poetry config virtualenvs.in-project true 68 | - name: 🏗 Install dependencies 69 | run: poetry install --no-interaction 70 | - name: 🚀 Process coverage results 71 | run: | 72 | poetry run coverage combine coverage*/.coverage* 73 | poetry run coverage xml -i 74 | - name: 🚀 Upload coverage report 75 | uses: codecov/codecov-action@v5.4.3 76 | - name: SonarCloud Scan 77 | if: github.event.pull_request.head.repo.fork == false 78 | uses: SonarSource/sonarqube-scan-action@v5.2.0 79 | env: 80 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 81 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 82 | -------------------------------------------------------------------------------- /.github/workflows/typing.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Typing 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | push: 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | env: 11 | DEFAULT_PYTHON: "3.11" 12 | 13 | jobs: 14 | mypy: 15 | name: mypy 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: ⤵️ Check out code from GitHub 19 | uses: actions/checkout@v4.2.2 20 | - name: 🏗 Set up Poetry 21 | run: pipx install poetry 22 | - name: 🏗 Set up Python ${{ env.DEFAULT_PYTHON }} 23 | id: python 24 | uses: actions/setup-python@v5.6.0 25 | with: 26 | python-version: ${{ env.DEFAULT_PYTHON }} 27 | cache: "poetry" 28 | - name: 🏗 Install workflow dependencies 29 | run: | 30 | poetry config virtualenvs.create true 31 | poetry config virtualenvs.in-project true 32 | - name: 🏗 Install dependencies 33 | run: poetry install --no-interaction 34 | - name: 🚀 Run mypy 35 | run: poetry run mypy examples src tests 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # OSX useful to ignore 7 | *.DS_Store 8 | .AppleDouble 9 | .LSOverride 10 | 11 | # Thumbnails 12 | ._* 13 | 14 | # Files that might appear in the root of a volume 15 | .DocumentRevisions-V100 16 | .fseventsd 17 | .Spotlight-V100 18 | .TemporaryItems 19 | .Trashes 20 | .VolumeIcon.icns 21 | .com.apple.timemachine.donotpresent 22 | 23 | # Directories potentially created on remote AFP share 24 | .AppleDB 25 | .AppleDesktop 26 | Network Trash Folder 27 | Temporary Items 28 | .apdisk 29 | 30 | # C extensions 31 | *.so 32 | 33 | # Distribution / packaging 34 | .Python 35 | env/ 36 | build/ 37 | develop-eggs/ 38 | dist/ 39 | downloads/ 40 | eggs/ 41 | .eggs/ 42 | lib/ 43 | lib64/ 44 | parts/ 45 | sdist/ 46 | var/ 47 | *.egg-info/ 48 | .installed.cfg 49 | *.egg 50 | 51 | # PyInstaller 52 | # Usually these files are written by a python script from a template 53 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 54 | *.manifest 55 | *.spec 56 | 57 | # Installer logs 58 | pip-log.txt 59 | pip-delete-this-directory.txt 60 | 61 | # Unit test / coverage reports 62 | htmlcov/ 63 | .tox/ 64 | .coverage 65 | .coverage.* 66 | .cache 67 | nosetests.xml 68 | coverage.xml 69 | *,cover 70 | .hypothesis/ 71 | .pytest_cache/ 72 | 73 | # Translations 74 | *.mo 75 | *.pot 76 | 77 | # Django stuff: 78 | *.log 79 | 80 | # Sphinx documentation 81 | docs/_build/ 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # virtualenv 87 | .venv 88 | venv/ 89 | ENV/ 90 | 91 | # mypy 92 | .mypy_cache/ 93 | 94 | # ruff 95 | .ruff_cache 96 | 97 | # Visual Studio Code 98 | .vscode 99 | 100 | # IntelliJ Idea family of suites 101 | .idea 102 | *.iml 103 | 104 | ## File-based project format: 105 | *.ipr 106 | *.iws 107 | 108 | ## mpeltonen/sbt-idea plugin 109 | .idea_modules/ 110 | 111 | # PyBuilder 112 | target/ 113 | 114 | # Cookiecutter 115 | output/ 116 | python_boilerplate/ 117 | 118 | # Node 119 | node_modules/ 120 | 121 | # Deepcode AI 122 | .dccache 123 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22.16.0 2 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | repos: 3 | - repo: local 4 | hooks: 5 | - id: ruff-check 6 | name: 🐶 Ruff Linter 7 | language: system 8 | types: [python] 9 | entry: poetry run ruff check --fix 10 | require_serial: true 11 | stages: [commit, push, manual] 12 | - id: ruff-format 13 | name: 🐶 Ruff Formatter 14 | language: system 15 | types: [python] 16 | entry: poetry run ruff format 17 | require_serial: true 18 | stages: [commit, push, manual] 19 | - id: check-ast 20 | name: 🐍 Check Python AST 21 | language: system 22 | types: [python] 23 | entry: poetry run check-ast 24 | - id: check-case-conflict 25 | name: 🔠 Check for case conflicts 26 | language: system 27 | entry: poetry run check-case-conflict 28 | - id: check-docstring-first 29 | name: ℹ️ Check docstring is first 30 | language: system 31 | types: [python] 32 | entry: poetry run check-docstring-first 33 | - id: check-executables-have-shebangs 34 | name: 🧐 Check that executables have shebangs 35 | language: system 36 | types: [text, executable] 37 | entry: poetry run check-executables-have-shebangs 38 | stages: [commit, push, manual] 39 | - id: check-json 40 | name: { Check JSON files 41 | language: system 42 | types: [json] 43 | entry: poetry run check-json 44 | - id: check-merge-conflict 45 | name: 💥 Check for merge conflicts 46 | language: system 47 | types: [text] 48 | entry: poetry run check-merge-conflict 49 | - id: check-symlinks 50 | name: 🔗 Check for broken symlinks 51 | language: system 52 | types: [symlink] 53 | entry: poetry run check-symlinks 54 | - id: check-toml 55 | name: ✅ Check TOML files 56 | language: system 57 | types: [toml] 58 | entry: poetry run check-toml 59 | - id: check-xml 60 | name: ✅ Check XML files 61 | entry: check-xml 62 | language: system 63 | types: [xml] 64 | - id: check-yaml 65 | name: ✅ Check YAML files 66 | language: system 67 | types: [yaml] 68 | entry: poetry run check-yaml 69 | - id: codespell 70 | name: ✅ Check code for common misspellings 71 | language: system 72 | types: [text] 73 | exclude: ^poetry\.lock$ 74 | entry: poetry run codespell 75 | - id: detect-private-key 76 | name: 🕵️ Detect Private Keys 77 | language: system 78 | types: [text] 79 | entry: poetry run detect-private-key 80 | - id: end-of-file-fixer 81 | name: ⮐ Fix End of Files 82 | language: system 83 | types: [text] 84 | entry: poetry run end-of-file-fixer 85 | stages: [commit, push, manual] 86 | - id: mypy 87 | name: 🆎 Static type checking using mypy 88 | language: system 89 | types: [python] 90 | entry: poetry run mypy 91 | require_serial: true 92 | - id: no-commit-to-branch 93 | name: 🛑 Don't commit to main branch 94 | language: system 95 | entry: poetry run no-commit-to-branch 96 | pass_filenames: false 97 | always_run: true 98 | args: 99 | - --branch=main 100 | - id: poetry 101 | name: 📜 Check pyproject with Poetry 102 | language: system 103 | entry: poetry check 104 | pass_filenames: false 105 | always_run: true 106 | - id: prettier 107 | name: 💄 Ensuring files are prettier 108 | language: system 109 | types: [yaml, json, markdown] 110 | entry: npm run prettier 111 | pass_filenames: false 112 | - id: pylint 113 | name: 🌟 Starring code with pylint 114 | language: system 115 | types: [python] 116 | entry: poetry run pylint 117 | - id: pytest 118 | name: 🧪 Running tests and test coverage with pytest 119 | language: system 120 | types: [python] 121 | entry: poetry run pytest 122 | pass_filenames: false 123 | - id: trailing-whitespace 124 | name: ✄ Trim Trailing Whitespace 125 | language: system 126 | types: [text] 127 | entry: poetry run trailing-whitespace-fixer 128 | stages: [commit, push, manual] 129 | - id: yamllint 130 | name: 🎗 Check YAML files with yamllint 131 | language: system 132 | types: [yaml] 133 | entry: poetry run yamllint 134 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .gitignore -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | --- 2 | ignore: 3 | - .venv 4 | rules: 5 | braces: 6 | level: error 7 | min-spaces-inside: 0 8 | max-spaces-inside: 1 9 | min-spaces-inside-empty: -1 10 | max-spaces-inside-empty: -1 11 | brackets: 12 | level: error 13 | min-spaces-inside: 0 14 | max-spaces-inside: 0 15 | min-spaces-inside-empty: -1 16 | max-spaces-inside-empty: -1 17 | colons: 18 | level: error 19 | max-spaces-before: 0 20 | max-spaces-after: 1 21 | commas: 22 | level: error 23 | max-spaces-before: 0 24 | min-spaces-after: 1 25 | max-spaces-after: 1 26 | comments: 27 | level: error 28 | require-starting-space: true 29 | min-spaces-from-content: 1 30 | comments-indentation: 31 | level: error 32 | document-end: 33 | level: error 34 | present: false 35 | document-start: 36 | level: error 37 | present: true 38 | empty-lines: 39 | level: error 40 | max: 1 41 | max-start: 0 42 | max-end: 1 43 | hyphens: 44 | level: error 45 | max-spaces-after: 1 46 | indentation: 47 | level: error 48 | spaces: 2 49 | indent-sequences: true 50 | check-multi-line-strings: false 51 | key-duplicates: 52 | level: error 53 | line-length: 54 | level: warning 55 | max: 120 56 | allow-non-breakable-words: true 57 | allow-non-breakable-inline-mappings: true 58 | new-line-at-end-of-file: 59 | level: error 60 | new-lines: 61 | level: error 62 | type: unix 63 | trailing-spaces: 64 | level: error 65 | truthy: 66 | level: error 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python: Asynchronous client for Elgato Lights 2 | 3 | [![GitHub Release][releases-shield]][releases] 4 | [![Python Versions][python-versions-shield]][pypi] 5 | ![Project Stage][project-stage-shield] 6 | ![Project Maintenance][maintenance-shield] 7 | [![License][license-shield]](LICENSE.md) 8 | 9 | [![Build Status][build-shield]][build] 10 | [![Code Coverage][codecov-shield]][codecov] 11 | [![Quality Gate Status][sonarcloud-shield]][sonarcloud] 12 | [![Open in Dev Containers][devcontainer-shield]][devcontainer] 13 | 14 | [![Sponsor Frenck via GitHub Sponsors][github-sponsors-shield]][github-sponsors] 15 | 16 | [![Support Frenck on Patreon][patreon-shield]][patreon] 17 | 18 | Asynchronous Python client for Elgato Lights. 19 | 20 | ## About 21 | 22 | This package allows you to control and monitor Elgato Light devices 23 | programmatically. It is mainly created to allow third-party programs to automate 24 | the behavior of an Elgato Light device. 25 | 26 | Known compatible and tested Elgato devices: 27 | 28 | - Elgato Key Light 29 | - Elgato Key Light Air 30 | - Elgato Key Light Mini 31 | - Elgato Light Strip 32 | 33 | ## Installation 34 | 35 | ```bash 36 | pip install elgato 37 | ``` 38 | 39 | ## Usage 40 | 41 | ```python 42 | import asyncio 43 | 44 | from elgato import Elgato, State, Info 45 | 46 | 47 | async def main(): 48 | """Show example on controlling your Elgato Light device.""" 49 | async with Elgato("elgato-key-light.local") as elgato: 50 | info: Info = await elgato.info() 51 | print(info) 52 | 53 | state: State = await elgato.state() 54 | print(state) 55 | 56 | # Toggle the Light 57 | await elgato.light(on=(not state.on)) 58 | 59 | 60 | if __name__ == "__main__": 61 | asyncio.run(main()) 62 | ``` 63 | 64 | ## Changelog & Releases 65 | 66 | This repository keeps a change log using [GitHub's releases][releases] 67 | functionality. The format of the log is based on 68 | [Keep a Changelog][keepchangelog]. 69 | 70 | Releases are based on [Semantic Versioning][semver], and use the format 71 | of `MAJOR.MINOR.PATCH`. In a nutshell, the version will be incremented 72 | based on the following: 73 | 74 | - `MAJOR`: Incompatible or major changes. 75 | - `MINOR`: Backwards-compatible new features and enhancements. 76 | - `PATCH`: Backwards-compatible bugfixes and package updates. 77 | 78 | ## Contributing 79 | 80 | This is an active open-source project. We are always open to people who want to 81 | use the code or contribute to it. 82 | 83 | We've set up a separate document for our 84 | [contribution guidelines](CONTRIBUTING.md). 85 | 86 | Thank you for being involved! :heart_eyes: 87 | 88 | ## Setting up development environment 89 | 90 | The easiest way to start, is by opening a CodeSpace here on GitHub, or by using 91 | the [Dev Container][devcontainer] feature of Visual Studio Code. 92 | 93 | [![Open in Dev Containers][devcontainer-shield]][devcontainer] 94 | 95 | This Python project is fully managed using the [Poetry][poetry] dependency manager. But also relies on the use of NodeJS for certain checks during development. 96 | 97 | You need at least: 98 | 99 | - Python 3.11+ 100 | - [Poetry][poetry-install] 101 | - NodeJS 20+ (including NPM) 102 | 103 | To install all packages, including all development requirements: 104 | 105 | ```bash 106 | npm install 107 | poetry install 108 | ``` 109 | 110 | As this repository uses the [pre-commit][pre-commit] framework, all changes 111 | are linted and tested with each commit. You can run all checks and tests 112 | manually, using the following command: 113 | 114 | ```bash 115 | poetry run pre-commit run --all-files 116 | ``` 117 | 118 | To run just the Python tests: 119 | 120 | ```bash 121 | poetry run pytest 122 | ``` 123 | 124 | ## Authors & contributors 125 | 126 | The original setup of this repository is by [Franck Nijhof][frenck]. 127 | 128 | For a full list of all authors and contributors, 129 | check [the contributor's page][contributors]. 130 | 131 | ## License 132 | 133 | MIT License 134 | 135 | Copyright (c) 2019-2024 Franck Nijhof 136 | 137 | Permission is hereby granted, free of charge, to any person obtaining a copy 138 | of this software and associated documentation files (the "Software"), to deal 139 | in the Software without restriction, including without limitation the rights 140 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 141 | copies of the Software, and to permit persons to whom the Software is 142 | furnished to do so, subject to the following conditions: 143 | 144 | The above copyright notice and this permission notice shall be included in all 145 | copies or substantial portions of the Software. 146 | 147 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 148 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 149 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 150 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 151 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 152 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 153 | SOFTWARE. 154 | 155 | [build-shield]: https://github.com/frenck/python-elgato/actions/workflows/tests.yaml/badge.svg 156 | [build]: https://github.com/frenck/python-elgato/actions/workflows/tests.yaml 157 | [codecov-shield]: https://codecov.io/gh/frenck/python-elgato/branch/master/graph/badge.svg 158 | [codecov]: https://codecov.io/gh/frenck/python-elgato 159 | [contributors]: https://github.com/frenck/python-elgato/graphs/contributors 160 | [devcontainer-shield]: https://img.shields.io/static/v1?label=Dev%20Containers&message=Open&color=blue&logo=visualstudiocode 161 | [devcontainer]: https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/frenck/python-elgato 162 | [frenck]: https://github.com/frenck 163 | [github-sponsors-shield]: https://frenck.dev/wp-content/uploads/2019/12/github_sponsor.png 164 | [github-sponsors]: https://github.com/sponsors/frenck 165 | [keepchangelog]: http://keepachangelog.com/en/1.0.0/ 166 | [license-shield]: https://img.shields.io/github/license/frenck/python-elgato.svg 167 | [maintenance-shield]: https://img.shields.io/maintenance/yes/2024.svg 168 | [patreon-shield]: https://frenck.dev/wp-content/uploads/2019/12/patreon.png 169 | [patreon]: https://www.patreon.com/frenck 170 | [poetry-install]: https://python-poetry.org/docs/#installation 171 | [poetry]: https://python-poetry.org 172 | [pre-commit]: https://pre-commit.com/ 173 | [project-stage-shield]: https://img.shields.io/badge/project%20stage-production%20ready-brightgreen.svg 174 | [pypi]: https://pypi.org/project/elgato/ 175 | [python-versions-shield]: https://img.shields.io/pypi/pyversions/elgato 176 | [releases-shield]: https://img.shields.io/github/release/frenck/python-elgato.svg 177 | [releases]: https://github.com/frenck/python-elgato/releases 178 | [semver]: http://semver.org/spec/v2.0.0.html 179 | [sonarcloud-shield]: https://sonarcloud.io/api/project_badges/measure?project=frenck_python-elgato&metric=alert_status 180 | [sonarcloud]: https://sonarcloud.io/summary/new_code?id=frenck_python-elgato 181 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- 1 | """Examples for this library.""" 2 | -------------------------------------------------------------------------------- /examples/change_name.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=W0621 2 | """Asynchronous Python client for Elgato Lights.""" 3 | 4 | import asyncio 5 | 6 | from elgato import Elgato 7 | 8 | 9 | async def main() -> None: 10 | """Show example of programmatically change the display name of a Elgato Light.""" 11 | async with Elgato("elgato-key-light.local") as elgato: 12 | # Current name 13 | await elgato.info() 14 | 15 | # Change the name 16 | await elgato.display_name("New name") 17 | 18 | # New name 19 | await elgato.info() 20 | 21 | 22 | if __name__ == "__main__": 23 | asyncio.run(main()) 24 | -------------------------------------------------------------------------------- /examples/control.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=W0621 2 | """Asynchronous Python client for Elgato Lights.""" 3 | 4 | import asyncio 5 | 6 | from elgato import Elgato, State 7 | 8 | 9 | async def main() -> None: 10 | """Show example on controlling your Elgato Key device.""" 11 | async with Elgato("elgato-key-light.local") as elgato: 12 | print(await elgato.info()) 13 | 14 | print(await elgato.settings()) 15 | 16 | state: State = await elgato.state() 17 | 18 | # Toggle the light 19 | await elgato.light(on=(not state.on)) 20 | 21 | 22 | if __name__ == "__main__": 23 | asyncio.run(main()) 24 | -------------------------------------------------------------------------------- /examples/mini.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=W0621 2 | """Asynchronous Python client for Elgato Lights.""" 3 | 4 | import asyncio 5 | 6 | from elgato import Elgato 7 | 8 | 9 | async def main() -> None: 10 | """Show example on how to work with an battery powered device.""" 11 | async with Elgato("10.10.11.172") as elgato: 12 | # General device information 13 | print(await elgato.info()) 14 | print(settings := await elgato.settings()) 15 | print(await elgato.state()) 16 | 17 | # General battery information 18 | battery = await elgato.battery() 19 | print(f"Level: {battery.level} %") 20 | print(f"Power: {battery.charge_power}W") 21 | print(f"Voltage: {battery.charge_voltage}V") 22 | print(f"Current: {battery.charge_current}A") 23 | 24 | # Toggle the studio mode 25 | if settings.battery is None: 26 | raise RuntimeError 27 | await elgato.battery_bypass(on=(not settings.battery.bypass)) 28 | 29 | # Adjust energy saving settings 30 | await elgato.energy_saving( 31 | on=True, 32 | brightness=66, 33 | disable_wifi=True, 34 | minimum_battery_level=16, 35 | adjust_brightness=True, 36 | ) 37 | 38 | 39 | if __name__ == "__main__": 40 | asyncio.run(main()) 41 | -------------------------------------------------------------------------------- /examples/ruff.toml: -------------------------------------------------------------------------------- 1 | # This extend our general Ruff rules specifically for the examples 2 | extend = "../pyproject.toml" 3 | 4 | lint.extend-ignore = [ 5 | "T201", # Allow the use of print() in examples 6 | ] 7 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "elgato", 3 | "version": "0.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "elgato", 9 | "version": "0.0.0", 10 | "license": "MIT", 11 | "devDependencies": { 12 | "prettier": "3.5.3" 13 | } 14 | }, 15 | "node_modules/prettier": { 16 | "version": "3.5.3", 17 | "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", 18 | "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", 19 | "dev": true, 20 | "license": "MIT", 21 | "bin": { 22 | "prettier": "bin/prettier.cjs" 23 | }, 24 | "engines": { 25 | "node": ">=14" 26 | }, 27 | "funding": { 28 | "url": "https://github.com/prettier/prettier?sponsor=1" 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "elgato", 3 | "version": "0.0.0", 4 | "private": true, 5 | "description": "Asynchronous Python client for Elgato Lights", 6 | "scripts": { 7 | "prettier": "prettier --write **/*.{json,js,md,yml,yaml}" 8 | }, 9 | "author": "Franck Nijhof ", 10 | "license": "MIT", 11 | "devDependencies": { 12 | "prettier": "3.5.3" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | authors = ["Franck Nijhof "] 3 | classifiers = [ 4 | "Development Status :: 5 - Production/Stable", 5 | "Framework :: AsyncIO", 6 | "Intended Audience :: Developers", 7 | "Natural Language :: English", 8 | "Programming Language :: Python :: 3.11", 9 | "Programming Language :: Python :: 3.12", 10 | "Programming Language :: Python :: 3.13", 11 | "Programming Language :: Python :: 3", 12 | "Topic :: Software Development :: Libraries :: Python Modules", 13 | ] 14 | description = "Asynchronous Python client for Elgato Lights." 15 | documentation = "https://github.com/frenck/python-elgato" 16 | homepage = "https://github.com/frenck/python-elgato" 17 | keywords = ["elgato", "keylight", "api", "async", "client"] 18 | license = "MIT" 19 | maintainers = ["Franck Nijhof "] 20 | name = "elgato" 21 | packages = [ 22 | {include = "elgato", from = "src"}, 23 | ] 24 | readme = "README.md" 25 | repository = "https://github.com/frenck/python-elgato" 26 | version = "0.0.0" 27 | 28 | [tool.poetry.dependencies] 29 | aiohttp = ">=3.0.0" 30 | mashumaro = ">=3.10" 31 | orjson = ">=3.9.8" 32 | python = "^3.11" 33 | yarl = ">=1.6.0" 34 | 35 | [tool.poetry.urls] 36 | "Bug Tracker" = "https://github.com/frenck/python-elgato/issues" 37 | Changelog = "https://github.com/frenck/python-elgato/releases" 38 | 39 | [tool.poetry.group.dev.dependencies] 40 | aresponses = "3.0.0" 41 | codespell = "2.4.1" 42 | covdefaults = "2.3.0" 43 | coverage = {version = "7.8.2", extras = ["toml"]} 44 | mypy = "1.16.0" 45 | pre-commit = "4.2.0" 46 | pre-commit-hooks = "5.0.0" 47 | pylint = "3.3.7" 48 | pytest = "8.4.0" 49 | pytest-asyncio = "1.0.0" 50 | pytest-cov = "6.1.1" 51 | ruff = "0.11.13" 52 | safety = "3.5.2" 53 | yamllint = "1.37.1" 54 | 55 | [tool.coverage.run] 56 | plugins = ["covdefaults"] 57 | source = ["elgato"] 58 | 59 | [tool.coverage.report] 60 | show_missing = true 61 | 62 | [tool.mypy] 63 | # Specify the target platform details in config, so your developers are 64 | # free to run mypy on Windows, Linux, or macOS and get consistent 65 | # results. 66 | platform = "linux" 67 | python_version = "3.11" 68 | 69 | # show error messages from unrelated files 70 | follow_imports = "normal" 71 | 72 | # suppress errors about unsatisfied imports 73 | ignore_missing_imports = true 74 | 75 | # be strict 76 | check_untyped_defs = true 77 | disallow_any_generics = true 78 | disallow_incomplete_defs = true 79 | disallow_subclassing_any = true 80 | disallow_untyped_calls = true 81 | disallow_untyped_decorators = true 82 | disallow_untyped_defs = true 83 | no_implicit_optional = true 84 | no_implicit_reexport = true 85 | strict_optional = true 86 | warn_incomplete_stub = true 87 | warn_no_return = true 88 | warn_redundant_casts = true 89 | warn_return_any = true 90 | warn_unused_configs = true 91 | warn_unused_ignores = true 92 | 93 | [tool.pylint.MASTER] 94 | extension-pkg-whitelist = [ 95 | "pydantic", 96 | ] 97 | ignore = [ 98 | "tests", 99 | ] 100 | 101 | [tool.pylint.BASIC] 102 | good-names = [ 103 | "_", 104 | "ex", 105 | "fp", 106 | "i", 107 | "id", 108 | "j", 109 | "k", 110 | "on", 111 | "Run", 112 | "T", 113 | ] 114 | 115 | [tool.pylint.DESIGN] 116 | max-attributes = 8 117 | 118 | [tool.pylint."MESSAGES CONTROL"] 119 | disable = [ 120 | "duplicate-code", 121 | "format", 122 | "unsubscriptable-object", 123 | ] 124 | 125 | [tool.pylint.SIMILARITIES] 126 | ignore-imports = true 127 | 128 | [tool.pylint.FORMAT] 129 | max-line-length = 88 130 | 131 | [tool.pytest.ini_options] 132 | addopts = "--cov" 133 | asyncio_mode = "auto" 134 | 135 | [tool.ruff.lint] 136 | ignore = [ 137 | "ANN101", # Self... explanatory 138 | "ANN401", # Opinioated warning on disallowing dynamically typed expressions 139 | "D203", # Conflicts with other rules 140 | "D213", # Conflicts with other rules 141 | "D417", # False positives in some occasions 142 | "PLR2004", # Just annoying, not really useful 143 | 144 | # Conflicts with the Ruff formatter 145 | "COM812", 146 | "ISC001", 147 | ] 148 | 149 | select = ["ALL"] 150 | 151 | [tool.ruff.lint.flake8-pytest-style] 152 | fixture-parentheses = false 153 | mark-parentheses = false 154 | 155 | [tool.ruff.lint.isort] 156 | known-first-party = ["elgato"] 157 | 158 | [tool.ruff.lint.flake8-type-checking] 159 | runtime-evaluated-base-classes = ["mashumaro.mixins.orjson.DataClassORJSONMixin"] 160 | 161 | [tool.ruff.lint.mccabe] 162 | max-complexity = 25 163 | 164 | [build-system] 165 | build-backend = "poetry.core.masonry.api" 166 | requires = ["poetry-core>=1.0.0"] 167 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.organization=frenck 2 | sonar.projectKey=frenck_python-elgato 3 | sonar.projectName=Asynchronous Python client for Elgato Lights 4 | sonar.projectVersion=1.0 5 | 6 | sonar.links.homepage=https://github.com/frenck/python-elgato 7 | sonar.links.ci=https://github.com/frenck/python-elgato/actions 8 | sonar.links.issue=https://github.com/frenck/python-elgato/issues 9 | sonar.links.scm=https://github.com/frenck/python-elgato/tree/main 10 | 11 | sonar.language=py 12 | sonar.sourceEncoding=UTF-8 13 | sonar.sources=src 14 | sonar.tests=tests 15 | 16 | sonar.python.version=3.11, 3.12 17 | sonar.python.coverage.reportPaths=coverage.xml 18 | -------------------------------------------------------------------------------- /src/elgato/__init__.py: -------------------------------------------------------------------------------- 1 | """Asynchronous Python client for Elgato Lights.""" 2 | 3 | from .elgato import Elgato 4 | from .exceptions import ElgatoConnectionError, ElgatoError, ElgatoNoBatteryError 5 | from .models import ( 6 | BatteryInfo, 7 | BatterySettings, 8 | BatteryStatus, 9 | EnergySavingAdjustBrightnessSettings, 10 | EnergySavingSettings, 11 | Info, 12 | PowerOnBehavior, 13 | PowerSource, 14 | Settings, 15 | State, 16 | Wifi, 17 | ) 18 | 19 | __all__ = [ 20 | "BatteryInfo", 21 | "BatterySettings", 22 | "BatteryStatus", 23 | "Elgato", 24 | "ElgatoConnectionError", 25 | "ElgatoError", 26 | "ElgatoNoBatteryError", 27 | "EnergySavingAdjustBrightnessSettings", 28 | "EnergySavingSettings", 29 | "Info", 30 | "PowerOnBehavior", 31 | "PowerSource", 32 | "Settings", 33 | "State", 34 | "Wifi", 35 | ] 36 | -------------------------------------------------------------------------------- /src/elgato/elgato.py: -------------------------------------------------------------------------------- 1 | """Asynchronous Python client for Elgato Lights.""" 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | import socket 7 | from dataclasses import dataclass 8 | from typing import ( 9 | TYPE_CHECKING, 10 | Any, 11 | Concatenate, 12 | ParamSpec, 13 | Self, 14 | TypedDict, 15 | TypeVar, 16 | ) 17 | 18 | import orjson 19 | from aiohttp.client import ClientError, ClientSession 20 | from aiohttp.hdrs import METH_GET, METH_POST, METH_PUT 21 | from yarl import URL 22 | 23 | from .exceptions import ElgatoConnectionError, ElgatoError, ElgatoNoBatteryError 24 | from .models import BatteryInfo, BatterySettings, Info, PowerOnBehavior, Settings, State 25 | 26 | if TYPE_CHECKING: 27 | from collections.abc import Callable, Coroutine 28 | 29 | _ElgatoT = TypeVar("_ElgatoT", bound="Elgato") 30 | _R = TypeVar("_R") 31 | _P = ParamSpec("_P") 32 | 33 | 34 | def requires_battery( 35 | func: Callable[Concatenate[_ElgatoT, _P], Coroutine[Any, Any, _R]], 36 | ) -> Callable[Concatenate[_ElgatoT, _P], Coroutine[Any, Any, _R]]: 37 | """Decorate Elgato calls that require a device with a battery installed. 38 | 39 | A decorator that wraps and guards the passed in function, and checks if 40 | the device has a battery installed and only than calls the function. 41 | """ 42 | 43 | async def handler(self: _ElgatoT, *args: _P.args, **kwargs: _P.kwargs) -> _R: 44 | """Handle calls to devices that require a battery.""" 45 | if await self.has_battery() is False: 46 | raise ElgatoNoBatteryError 47 | return await func(self, *args, **kwargs) 48 | 49 | return handler 50 | 51 | 52 | @dataclass 53 | class Elgato: 54 | """Main class for handling connections with an Elgato Light.""" 55 | 56 | host: str 57 | port: int = 9123 58 | request_timeout: int = 8 59 | session: ClientSession | None = None 60 | 61 | _close_session: bool = False 62 | _has_battery: bool | None = None 63 | 64 | async def _request( 65 | self, 66 | uri: str, 67 | *, 68 | method: str = METH_GET, 69 | data: dict[str, Any] | None = None, 70 | ) -> str: 71 | """Handle a request to a Elgato Light device. 72 | 73 | A generic method for sending/handling HTTP requests done against 74 | the Elgato Light API. 75 | 76 | Args: 77 | ---- 78 | uri: Request URI, without '/elgato/', for example, 'info' 79 | method: HTTP Method to use. 80 | data: Dictionary of data to send to the Elgato Light. 81 | 82 | Returns: 83 | ------- 84 | A Python string (JSON) with the response from the Elgato Light API. 85 | 86 | Raises: 87 | ------ 88 | ElgatoConnectionError: An error occurred while communicating with 89 | the Elgato Light. 90 | ElgatoError: Received an unexpected response from the Elgato Light 91 | API. 92 | 93 | """ 94 | url = URL.build( 95 | scheme="http", 96 | host=self.host, 97 | port=self.port, 98 | path="/elgato/", 99 | ).join(URL(uri)) 100 | 101 | headers = { 102 | "User-Agent": "PythonElgato", 103 | "Accept": "application/json, text/plain, */*", 104 | } 105 | 106 | if self.session is None: 107 | self.session = ClientSession() 108 | self._close_session = True 109 | 110 | try: 111 | async with asyncio.timeout(self.request_timeout): 112 | response = await self.session.request( 113 | method, 114 | url, 115 | json=data, 116 | headers=headers, 117 | ) 118 | response.raise_for_status() 119 | except asyncio.TimeoutError as exception: 120 | msg = "Timeout occurred while connecting to Elgato Light device" 121 | raise ElgatoConnectionError(msg) from exception 122 | except ( 123 | ClientError, 124 | socket.gaierror, 125 | ) as exception: 126 | msg = "Error occurred while communicating with Elgato Light device" 127 | raise ElgatoConnectionError(msg) from exception 128 | 129 | return await response.text() 130 | 131 | async def has_battery(self) -> bool: 132 | """Check if the Elgato Light device has a battery. 133 | 134 | Returns 135 | ------- 136 | A boolean indicating if the Elgato Light device has a battery. 137 | 138 | """ 139 | if self._has_battery is None: 140 | settings = await self.settings() 141 | self._has_battery = settings.battery is not None 142 | return self._has_battery 143 | 144 | @requires_battery 145 | async def battery(self) -> BatteryInfo: 146 | """Get battery information from Elgato Light device. 147 | 148 | Returns 149 | ------- 150 | A BatteryInfo object, with information on the current battery state 151 | of the Elgato light. 152 | 153 | """ 154 | data = await self._request("battery-info") 155 | return BatteryInfo.from_json(data) 156 | 157 | @requires_battery 158 | async def battery_bypass(self, *, on: bool) -> None: 159 | """Change the bypass mode of the Elgato Light device. 160 | 161 | In the app this is also called "Studio mode". When the bypass mode is 162 | the battery isn't used and would only work when the device is plugged 163 | into mains. 164 | 165 | There is an odd bug in current versions of the Elgato Light Mini 166 | firmware, that turns the light on when the bypass mode is turned off; 167 | the device will still think it is turned off, but the light will be on. 168 | 169 | Args: 170 | ---- 171 | on: A boolean, true to turn on bypass, false otherwise. 172 | 173 | """ 174 | await self._request( 175 | "/elgato/lights/settings", 176 | method=METH_PUT, 177 | data={"battery": {"bypass": int(on)}}, 178 | ) 179 | 180 | async def battery_settings(self) -> BatterySettings: 181 | """Get device battery settings from Elgato Light device. 182 | 183 | Guarded version of `settings().battery`. 184 | 185 | Returns 186 | ------- 187 | A Battery settings object, with information about the Elgato Light device. 188 | 189 | """ 190 | settings = await self.settings() 191 | if settings.battery is None: 192 | raise ElgatoNoBatteryError 193 | return settings.battery 194 | 195 | @requires_battery 196 | # pylint: disable-next=too-many-arguments 197 | async def energy_saving( 198 | self, 199 | *, 200 | adjust_brightness: bool | None = None, 201 | brightness: int | None = None, 202 | disable_wifi: bool | None = None, 203 | minimum_battery_level: int | None = None, 204 | on: bool | None = None, 205 | ) -> None: 206 | """Change the energy saving mode of the Elgato Light device. 207 | 208 | Args: 209 | ---- 210 | adjust_brightness: Adjust the brightness of the light when it drops 211 | below the minimum battery level threshold. True to turn it on, 212 | false otherwise. 213 | brightness: The brightness to set the light to when energy savings 214 | kicks in. This is only used when adjust_brightness is True. 215 | disable_wifi: Disable the WiFi of the Elgato Light device when 216 | energy savings kicks in. True to turn it on, false otherwise. 217 | minimum_battery_level: The minimum battery level threshold to 218 | trigger energy savings. 219 | on: A boolean, true to turn on energy saving, false otherwise. 220 | 221 | """ 222 | current_settings = await self.battery_settings() 223 | data = current_settings.energy_saving.to_dict() 224 | 225 | if on is not None: 226 | data["enable"] = int(on) 227 | if minimum_battery_level is not None: 228 | data["minimumBatteryLevel"] = minimum_battery_level 229 | if disable_wifi is not None: 230 | data["disableWifi"] = int(disable_wifi) 231 | if adjust_brightness is not None: 232 | data["adjustBrightness"]["enable"] = int(adjust_brightness) 233 | if brightness is not None: 234 | data["adjustBrightness"]["brightness"] = brightness 235 | 236 | await self._request( 237 | "/elgato/lights/settings", 238 | method=METH_PUT, 239 | data={"battery": {"energySaving": data}}, 240 | ) 241 | 242 | async def info(self) -> Info: 243 | """Get devices information from Elgato Light device. 244 | 245 | Returns 246 | ------- 247 | A Info object, with information about the Elgato Light device. 248 | 249 | """ 250 | data = await self._request("accessory-info") 251 | return Info.from_json(data) 252 | 253 | async def settings(self) -> Settings: 254 | """Get device settings from Elgato Light device. 255 | 256 | Returns 257 | ------- 258 | A Settings object, with information about the Elgato Light device. 259 | 260 | """ 261 | data = await self._request("lights/settings") 262 | return Settings.from_json(data) 263 | 264 | async def state(self) -> State: 265 | """Get the current state of Elgato Light device. 266 | 267 | Returns 268 | ------- 269 | A State object, with the current Elgato Light state. 270 | 271 | """ 272 | data = await self._request("lights") 273 | # pylint: disable-next=no-member 274 | lights = orjson.loads(data)["lights"] 275 | return State.from_dict(lights[0]) 276 | 277 | async def identify(self) -> None: 278 | """Identify this Elgato Light device by making it blink.""" 279 | await self._request("identify", method=METH_POST) 280 | 281 | async def restart(self) -> None: 282 | """Restart the Elgato Light device.""" 283 | await self._request("restart", method=METH_POST) 284 | 285 | async def display_name(self, name: str) -> None: 286 | """Change the display name of an Elgato Light device. 287 | 288 | Args: 289 | ---- 290 | name: The name to give the Elgato Light device. 291 | 292 | """ 293 | await self._request( 294 | "/elgato/accessory-info", 295 | method=METH_PUT, 296 | data={"displayName": name}, 297 | ) 298 | 299 | # pylint: disable-next=too-many-arguments 300 | async def light( 301 | self, 302 | *, 303 | on: bool | None = None, 304 | brightness: int | None = None, 305 | hue: float | None = None, 306 | saturation: float | None = None, 307 | temperature: int | None = None, 308 | ) -> None: 309 | """Change state of an Elgato Light device. 310 | 311 | Args: 312 | ---- 313 | on: A boolean, true to turn the light on, false otherwise. 314 | brightness: The brightness of the light, between 0 and 255. 315 | hue: The hue range as a float from 0 to 360 degrees. 316 | saturation: The color saturation as a float from 0 to 100. 317 | temperature: The color temperature of the light, in mired. 318 | 319 | Raises: 320 | ------ 321 | ElgatoError: The provided values are invalid. 322 | 323 | """ 324 | if temperature and (hue or saturation): 325 | msg = "Cannot set temperature together with hue or saturation" 326 | raise ElgatoError(msg) 327 | 328 | class LightState(TypedDict, total=False): 329 | """Describe state dictionary that can be set on a light.""" 330 | 331 | brightness: int 332 | hue: float 333 | on: int 334 | saturation: float 335 | temperature: int 336 | 337 | state: LightState = {} 338 | 339 | if on is not None: 340 | state["on"] = int(on) 341 | 342 | if brightness is not None: 343 | if not 0 <= brightness <= 100: 344 | msg = "Brightness not between 0 and 100" 345 | raise ElgatoError(msg) 346 | state["brightness"] = brightness 347 | 348 | if hue is not None: 349 | if not 0 <= hue <= 360: 350 | msg = "Hue not between 0 and 360" 351 | raise ElgatoError(msg) 352 | state["hue"] = hue 353 | 354 | if saturation is not None: 355 | if not 0 <= saturation <= 100: 356 | msg = "Saturation not between 0 and 100" 357 | raise ElgatoError(msg) 358 | state["saturation"] = saturation 359 | 360 | if temperature is not None: 361 | if not 143 <= temperature <= 344: 362 | msg = "Color temperature out of range" 363 | raise ElgatoError(msg) 364 | state["temperature"] = temperature 365 | 366 | if not state: 367 | msg = "No parameters to set, light not adjusted" 368 | raise ElgatoError(msg) 369 | 370 | await self._request( 371 | "lights", 372 | method=METH_PUT, 373 | data={"numberOfLights": 1, "lights": [state]}, 374 | ) 375 | 376 | async def power_on_behavior( 377 | self, 378 | *, 379 | behavior: PowerOnBehavior | None = None, 380 | brightness: int | None = None, 381 | hue: float | None = None, 382 | temperature: int | None = None, 383 | ) -> None: 384 | """Change the power on behavior of the Elgato Light device. 385 | 386 | Args: 387 | ---- 388 | behavior: The power on behavior to set. 389 | brightness: The power on brightness of the light, between 0 and 255. 390 | hue: The power on hue range as a float from 0 to 360 degrees. 391 | temperature: The power on color temperature of the light, in mired. 392 | 393 | """ 394 | current_settings = await self.settings() 395 | if behavior is not None: 396 | current_settings.power_on_behavior = behavior 397 | if brightness is not None: 398 | current_settings.power_on_brightness = brightness 399 | if hue is not None: 400 | current_settings.power_on_hue = hue 401 | if temperature is not None: 402 | current_settings.power_on_temperature = temperature 403 | 404 | # Unset battery if present, needs special handling 405 | if current_settings.battery: 406 | current_settings.battery = None 407 | 408 | await self._request( 409 | "/elgato/lights/settings", 410 | method=METH_PUT, 411 | data=current_settings.to_dict(), 412 | ) 413 | 414 | async def close(self) -> None: 415 | """Close open client session.""" 416 | if self.session and self._close_session: 417 | await self.session.close() 418 | 419 | async def __aenter__(self) -> Self: 420 | """Async enter. 421 | 422 | Returns 423 | ------- 424 | The Elgato object. 425 | 426 | """ 427 | return self 428 | 429 | async def __aexit__(self, *_exc_info: object) -> None: 430 | """Async exit. 431 | 432 | Args: 433 | ---- 434 | _exc_info: Exec type. 435 | 436 | """ 437 | await self.close() 438 | -------------------------------------------------------------------------------- /src/elgato/exceptions.py: -------------------------------------------------------------------------------- 1 | """Exceptions for Elgato Lights.""" 2 | 3 | from typing import Any 4 | 5 | 6 | class ElgatoError(Exception): 7 | """Generic Elgato Light exception.""" 8 | 9 | 10 | class ElgatoConnectionError(ElgatoError): 11 | """Elgato Light connection exception.""" 12 | 13 | 14 | class ElgatoNoBatteryError(Exception): 15 | """Elgato light does not have a battery.""" 16 | 17 | def __init__(self, *args: Any, **kwargs: Any) -> None: 18 | """Initialize the ElgatoNoBatteryError.""" 19 | if not args: # pragma: no cover 20 | args = ("The Elgato light does not have a battery.",) 21 | super().__init__(*args, **kwargs) 22 | -------------------------------------------------------------------------------- /src/elgato/models.py: -------------------------------------------------------------------------------- 1 | """Asynchronous Python client for Elgato Lights.""" 2 | 3 | from __future__ import annotations 4 | 5 | from dataclasses import dataclass, field 6 | from enum import IntEnum 7 | 8 | from mashumaro import field_options 9 | from mashumaro.config import BaseConfig 10 | from mashumaro.mixins.orjson import DataClassORJSONMixin 11 | from mashumaro.types import SerializationStrategy 12 | 13 | 14 | class IntegerIsBoolean(SerializationStrategy): 15 | """Boolean serialization strategy for integers.""" 16 | 17 | def serialize(self, value: bool) -> int: # noqa: FBT001 18 | """Serialize a boolean to an integer.""" 19 | return int(value) 20 | 21 | def deserialize(self, value: int) -> bool: 22 | """Deserialize an integer to a boolean.""" 23 | return bool(value) 24 | 25 | 26 | class BaseModel(DataClassORJSONMixin): 27 | """Base model for all Elgato models.""" 28 | 29 | # pylint: disable-next=too-few-public-methods 30 | class Config(BaseConfig): 31 | """Mashumaro configuration.""" 32 | 33 | omit_none = True 34 | serialization_strategy = {bool: IntegerIsBoolean()} # noqa: RUF012 35 | serialize_by_alias = True 36 | 37 | 38 | @dataclass 39 | class EnergySavingAdjustBrightnessSettings(BaseModel): 40 | """Object holding the Elgato Light battery energy saving settings for brightness. 41 | 42 | This object holds information about the Elgato Light energy saving settings 43 | for saving battery when using the light regarding brightness limits. 44 | 45 | Only applies to Elgato devices with a battery of course, 46 | like the Key Light Mini. 47 | 48 | Attributes 49 | ---------- 50 | brightness: Adjusted brightness when energy saving is active. 51 | enabled: boolean 52 | 53 | """ 54 | 55 | brightness: int 56 | enabled: bool = field(metadata=field_options(alias="enable")) 57 | 58 | 59 | @dataclass 60 | class EnergySavingSettings(BaseModel): 61 | """Object holding the Elgato Light battery energy saving settings. 62 | 63 | This object holds information about the Elgato Light energy saving settings 64 | for saving battery when using the light. 65 | 66 | Only applies to Elgato devices with a battery of course, 67 | like the Key Light Mini. 68 | 69 | Attributes 70 | ---------- 71 | adjust_brightness: Adjust brightness when energy saving is active. 72 | disable_wifi: Disable Wi-Fi when energy saving is active. 73 | enabled: boolean 74 | minimum_battery_level: Use energy saving when battery level is below this value. 75 | 76 | """ 77 | 78 | adjust_brightness: EnergySavingAdjustBrightnessSettings = field( 79 | metadata=field_options(alias="adjustBrightness") 80 | ) 81 | disable_wifi: bool = field(metadata=field_options(alias="disableWifi")) 82 | enabled: bool = field(metadata=field_options(alias="enable")) 83 | minimum_battery_level: int = field( 84 | metadata=field_options(alias="minimumBatteryLevel") 85 | ) 86 | 87 | 88 | @dataclass 89 | class BatterySettings(BaseModel): 90 | """Object holding the Elgato Light battery information. 91 | 92 | This object holds information about the Elgato Light battery. 93 | 94 | Only applies to Elgato devices with a battery of course, 95 | like the Key Light Mini. 96 | 97 | Attributes 98 | ---------- 99 | energy_saving: Energy saving settings. 100 | bypass: If the battery is bypassed (studio mode). 101 | 102 | """ 103 | 104 | energy_saving: EnergySavingSettings = field( 105 | metadata=field_options(alias="energySaving") 106 | ) 107 | bypass: bool 108 | 109 | 110 | @dataclass 111 | class Wifi(BaseModel): 112 | """Object holding the Elgato device Wi-Fi information. 113 | 114 | This object holds wireles information about the Elgato device. 115 | 116 | Attributes 117 | ---------- 118 | frequency: The frequency in MHz of the Wi-Fi network connected. 119 | rssi: The signal strength in dBm of the Wi-Fi network connected. 120 | ssid: The SSID of the Wi-Fi network the device is connected to. 121 | 122 | """ 123 | 124 | frequency: int = field(metadata=field_options(alias="frequencyMHz")) 125 | rssi: int 126 | ssid: str 127 | 128 | 129 | class PowerSource(IntEnum): 130 | """Enum for the power source of the Elgato Light.""" 131 | 132 | UNKNOWN = 0 133 | MAINS = 1 134 | BATTERY = 2 135 | 136 | 137 | class BatteryStatus(IntEnum): 138 | """Enum for the battery status of the Elgato Light. 139 | 140 | Value "1" seems to be unused. I could not get it to show up, no 141 | matter if the device was charging or not, in saving mode or even bypass. 142 | """ 143 | 144 | DRAINING = 0 145 | CHARGING = 2 146 | CHARGED = 3 147 | 148 | 149 | @dataclass 150 | class BatteryInfo(BaseModel): 151 | """Object holding the Elgato Light device information. 152 | 153 | This object holds information about the Elgato Light. 154 | 155 | Attributes 156 | ---------- 157 | charge_current: The charge current in A. 158 | charge_power: The charge power in W. 159 | charge_voltage: The charge voltage in V. 160 | input_charge_current: The charge current in mA. 161 | input_charge_voltage: The charge voltage in mV. 162 | input_charge_voltage: The charge voltage in mV. 163 | level: The battery level of the device in %. 164 | power_source: The power source of the device. 165 | status: The battery status. 166 | voltage: The current battery voltage in mV. 167 | 168 | """ 169 | 170 | power_source: PowerSource = field(metadata=field_options(alias="powerSource")) 171 | level: float 172 | status: BatteryStatus 173 | voltage: int = field(metadata=field_options(alias="currentBatteryVoltage")) 174 | input_charge_voltage: int = field( 175 | metadata=field_options(alias="inputChargeVoltage") 176 | ) 177 | input_charge_current: int = field( 178 | metadata=field_options(alias="inputChargeCurrent") 179 | ) 180 | 181 | @property 182 | def input_charge_power(self) -> int: 183 | """Return the input charge power in mW.""" 184 | return round(self.input_charge_voltage * self.input_charge_current / 1_000) 185 | 186 | @property 187 | def charge_current(self) -> float: 188 | """Return the charge current in A.""" 189 | return round(self.input_charge_current / 1_000, 2) 190 | 191 | @property 192 | def charge_power(self) -> float: 193 | """Return the charge power in W.""" 194 | return round(self.input_charge_power / 1_000, 2) 195 | 196 | @property 197 | def charge_voltage(self) -> float: 198 | """Return the charge voltage in V.""" 199 | return round(self.input_charge_voltage / 1_000, 2) 200 | 201 | 202 | @dataclass 203 | # pylint: disable-next=too-many-instance-attributes 204 | class Info(BaseModel): 205 | """Object holding the Elgato Light device information. 206 | 207 | This object holds information about the Elgato Light. 208 | 209 | Attributes 210 | ---------- 211 | display_name: Configured display name of the Elgato Light. 212 | features: List of features this devices exposes. 213 | firmware_build_number: An integer with the build number of the firmware. 214 | firmware_version: String containing the firmware version. 215 | hardware_board_type: An integer indicating the board revision. 216 | product_name: The product name. 217 | serial_number: Serial number of the Elgato Light. 218 | 219 | """ 220 | 221 | features: list[str] 222 | firmware_build_number: int = field( 223 | metadata=field_options(alias="firmwareBuildNumber") 224 | ) 225 | firmware_version: str = field(metadata=field_options(alias="firmwareVersion")) 226 | hardware_board_type: int = field(metadata=field_options(alias="hardwareBoardType")) 227 | product_name: str = field(metadata=field_options(alias="productName")) 228 | serial_number: str = field(metadata=field_options(alias="serialNumber")) 229 | display_name: str = field( 230 | default="Elgato Light", metadata=field_options(alias="displayName") 231 | ) 232 | mac_address: str | None = field( 233 | default=None, metadata=field_options(alias="macAddress") 234 | ) 235 | wifi: Wifi | None = field(default=None, metadata=field_options(alias="wifi-info")) 236 | 237 | 238 | class PowerOnBehavior(IntEnum): 239 | """Enum for the power on behavior of the Elgato Light.""" 240 | 241 | UNKNOWN = 0 242 | RESTORE_LAST = 1 243 | USE_DEFAULTS = 2 244 | 245 | 246 | @dataclass 247 | # pylint: disable-next=too-many-instance-attributes 248 | class Settings(BaseModel): 249 | """Object holding the Elgato Light device settings. 250 | 251 | This object holds information about the Elgato Light settings. 252 | 253 | Attributes 254 | ---------- 255 | battery: Battery settings, if the device has a battery. 256 | color_change_duration: Transition time of color changes in milliseconds. 257 | power_on_behavior: 1 = Restore last, 2 = Use defaults. 258 | power_on_brightness: The brightness used as default. 259 | power_on_hue: The hue value used as default. 260 | power_on_saturation: The saturation level used as default. 261 | power_on_temperature: The temperature level used as default. 262 | switch_off_duration: Turn off transition time in milliseconds. 263 | switch_on_duration: Turn on transition time in milliseconds. 264 | 265 | """ 266 | 267 | color_change_duration: int = field( 268 | metadata=field_options(alias="colorChangeDurationMs") 269 | ) 270 | power_on_behavior: PowerOnBehavior = field( 271 | metadata=field_options(alias="powerOnBehavior") 272 | ) 273 | power_on_brightness: int = field(metadata=field_options(alias="powerOnBrightness")) 274 | switch_off_duration: int = field( 275 | metadata=field_options(alias="switchOffDurationMs") 276 | ) 277 | switch_on_duration: int = field(metadata=field_options(alias="switchOnDurationMs")) 278 | battery: BatterySettings | None = None 279 | power_on_hue: float | None = field( 280 | default=None, metadata=field_options(alias="powerOnHue") 281 | ) 282 | power_on_saturation: float | None = field( 283 | default=None, metadata=field_options(alias="powerOnSaturation") 284 | ) 285 | power_on_temperature: int | None = field( 286 | default=None, metadata=field_options(alias="powerOnTemperature") 287 | ) 288 | 289 | 290 | @dataclass 291 | class State(BaseModel): 292 | """Object holding the Elgato Light state. 293 | 294 | Represents a visible state of an Elgato Light. 295 | 296 | Attributes 297 | ---------- 298 | on: A boolean indicating the if the light if on or off. 299 | brightness: An integer between 0 and 255, representing the brightness. 300 | hue: 301 | saturation: 302 | temperature: An integer representing the color temperature in mireds. 303 | 304 | """ 305 | 306 | on: bool 307 | brightness: int 308 | hue: float | None = field(default=None) 309 | saturation: float | None = field(default=None) 310 | temperature: int | None = field(default=None) 311 | -------------------------------------------------------------------------------- /src/elgato/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frenck/python-elgato/5941e2a8797b857780270b43f8475021094e537d/src/elgato/py.typed -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Asynchronous Python client for Elgato Lights.""" 2 | 3 | from pathlib import Path 4 | 5 | 6 | def load_fixture(filename: str) -> str: 7 | """Load a fixture.""" 8 | path = Path(__file__).parent / "fixtures" / filename 9 | return path.read_text() 10 | -------------------------------------------------------------------------------- /tests/fixtures/battery-info.json: -------------------------------------------------------------------------------- 1 | { 2 | "powerSource": 1, 3 | "level": 78.57, 4 | "status": 2, 5 | "currentBatteryVoltage": 3860, 6 | "inputChargeVoltage": 4208, 7 | "inputChargeCurrent": 3008 8 | } 9 | -------------------------------------------------------------------------------- /tests/fixtures/info-key-light-air.json: -------------------------------------------------------------------------------- 1 | { 2 | "productName": "Elgato Key Light Air", 3 | "hardwareBoardType": 200, 4 | "firmwareBuildNumber": 195, 5 | "firmwareVersion": "1.0.3", 6 | "serialNumber": "CW44J2A03032", 7 | "displayName": "", 8 | "features": ["lights"] 9 | } 10 | -------------------------------------------------------------------------------- /tests/fixtures/info-key-light-mini.json: -------------------------------------------------------------------------------- 1 | { 2 | "productName": "Elgato Key Light Mini", 3 | "hardwareBoardType": 202, 4 | "hardwareRevision": 0.1, 5 | "macAddress": "11:22:33:44:55:66", 6 | "firmwareBuildNumber": 229, 7 | "firmwareVersion": "1.0.4", 8 | "serialNumber": "GW24L1A02987", 9 | "displayName": "Elgato Key Light Mini", 10 | "features": ["lights"], 11 | "wifi-info": { 12 | "ssid": "Frenck-IoT", 13 | "frequencyMHz": 2400, 14 | "rssi": -41 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tests/fixtures/info-key-light.json: -------------------------------------------------------------------------------- 1 | { 2 | "productName": "Elgato Key Light", 3 | "hardwareBoardType": 53, 4 | "hardwareRevision": 1, 5 | "macAddress": "AA:BB:CC:DD:EE:FF", 6 | "firmwareBuildNumber": 218, 7 | "firmwareVersion": "1.0.3", 8 | "serialNumber": "CN11A1A00001", 9 | "displayName": "Frenck", 10 | "features": ["lights"], 11 | "wifi-info": { 12 | "ssid": "Frenck-IoT", 13 | "frequencyMHz": 2400, 14 | "rssi": -48 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tests/fixtures/info-light-strip.json: -------------------------------------------------------------------------------- 1 | { 2 | "productName": "Elgato Light Strip", 3 | "hardwareBoardType": 70, 4 | "firmwareBuildNumber": 211, 5 | "firmwareVersion": "1.0.4", 6 | "serialNumber": "EW52J1A00082", 7 | "features": ["lights"] 8 | } 9 | -------------------------------------------------------------------------------- /tests/fixtures/settings-key-light-mini.json: -------------------------------------------------------------------------------- 1 | { 2 | "powerOnBehavior": 1, 3 | "powerOnBrightness": 20, 4 | "powerOnTemperature": 230, 5 | "switchOnDurationMs": 100, 6 | "switchOffDurationMs": 300, 7 | "colorChangeDurationMs": 100, 8 | "battery": { 9 | "energySaving": { 10 | "enable": 0, 11 | "minimumBatteryLevel": 15.0, 12 | "disableWifi": 0, 13 | "adjustBrightness": { 14 | "enable": 0, 15 | "brightness": 10.0 16 | } 17 | }, 18 | "bypass": 0 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/fixtures/settings-keylight.json: -------------------------------------------------------------------------------- 1 | { 2 | "powerOnBehavior": 1, 3 | "powerOnBrightness": 20, 4 | "powerOnTemperature": 213, 5 | "switchOnDurationMs": 100, 6 | "switchOffDurationMs": 300, 7 | "colorChangeDurationMs": 100 8 | } 9 | -------------------------------------------------------------------------------- /tests/fixtures/settings-strip.json: -------------------------------------------------------------------------------- 1 | { 2 | "powerOnBehavior": 2, 3 | "powerOnHue": 40.0, 4 | "powerOnSaturation": 15.0, 5 | "powerOnBrightness": 40, 6 | "powerOnTemperature": 0, 7 | "switchOnDurationMs": 150, 8 | "switchOffDurationMs": 400, 9 | "colorChangeDurationMs": 150 10 | } 11 | -------------------------------------------------------------------------------- /tests/fixtures/state-color.json: -------------------------------------------------------------------------------- 1 | { 2 | "numberOfLights": 1, 3 | "lights": [ 4 | { 5 | "on": 1, 6 | "hue": 358.0, 7 | "saturation": 6.0, 8 | "brightness": 50 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /tests/fixtures/state-temperature.json: -------------------------------------------------------------------------------- 1 | { 2 | "numberOfLights": 1, 3 | "lights": [ 4 | { 5 | "on": 1, 6 | "brightness": 21, 7 | "temperature": 297 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /tests/ruff.toml: -------------------------------------------------------------------------------- 1 | # This extend our general Ruff rules specifically for tests 2 | extend = "../pyproject.toml" 3 | 4 | lint.extend-select = [ 5 | "PT", # Use @pytest.fixture without parentheses 6 | ] 7 | 8 | lint.extend-ignore = [ 9 | "S101", # Use of assert detected. As these are tests... 10 | "SLF001", # Tests will access private/protected members... 11 | "TCH002", # pytest doesn't like this one... 12 | ] 13 | -------------------------------------------------------------------------------- /tests/test_battery.py: -------------------------------------------------------------------------------- 1 | """Tests for retrieving battery information from the Elgato Key Light device.""" 2 | 3 | import pytest 4 | from aiohttp import ClientResponse, ClientSession 5 | from aresponses import Response, ResponsesMockServer 6 | 7 | from elgato import BatteryInfo, BatteryStatus, Elgato, ElgatoNoBatteryError, PowerSource 8 | 9 | from . import load_fixture 10 | 11 | 12 | async def test_battery_info_no_battery(aresponses: ResponsesMockServer) -> None: 13 | """Test getting battery information from a Elgato device without battery.""" 14 | aresponses.add( 15 | "example.com:9123", 16 | "/elgato/lights/settings", 17 | "GET", 18 | aresponses.Response( 19 | status=200, 20 | headers={"Content-Type": "application/json"}, 21 | text=load_fixture("settings-keylight.json"), 22 | ), 23 | ) 24 | async with ClientSession() as session: 25 | elgato = Elgato("example.com", session=session) 26 | assert await elgato.has_battery() is False 27 | with pytest.raises( 28 | ElgatoNoBatteryError, 29 | match="The Elgato light does not have a battery.", 30 | ): 31 | await elgato.battery() 32 | 33 | 34 | async def test_battery_info(aresponses: ResponsesMockServer) -> None: 35 | """Test getting battery information from Elgato Key Light Mini device.""" 36 | aresponses.add( 37 | "example.com:9123", 38 | "/elgato/lights/settings", 39 | "GET", 40 | aresponses.Response( 41 | status=200, 42 | headers={"Content-Type": "application/json"}, 43 | text=load_fixture("settings-key-light-mini.json"), 44 | ), 45 | ) 46 | aresponses.add( 47 | "example.com:9123", 48 | "/elgato/battery-info", 49 | "GET", 50 | aresponses.Response( 51 | status=200, 52 | headers={"Content-Type": "application/json"}, 53 | text=load_fixture("battery-info.json"), 54 | ), 55 | ) 56 | async with ClientSession() as session: 57 | elgato = Elgato("example.com", session=session) 58 | battery: BatteryInfo = await elgato.battery() 59 | assert battery 60 | assert battery.charge_current == 3.01 61 | assert battery.charge_power == 12.66 62 | assert battery.charge_voltage == 4.21 63 | assert battery.input_charge_current == 3008 64 | assert battery.input_charge_power == 12658 65 | assert battery.input_charge_voltage == 4208 66 | assert battery.level == 78.57 67 | assert battery.power_source == PowerSource.MAINS 68 | assert battery.status == BatteryStatus.CHARGING 69 | 70 | 71 | async def test_battery_bypass_no_battery(aresponses: ResponsesMockServer) -> None: 72 | """Test enabling battery bypass on a Elgato device without battery.""" 73 | aresponses.add( 74 | "example.com:9123", 75 | "/elgato/lights/settings", 76 | "GET", 77 | aresponses.Response( 78 | status=200, 79 | headers={"Content-Type": "application/json"}, 80 | text=load_fixture("settings-keylight.json"), 81 | ), 82 | ) 83 | async with ClientSession() as session: 84 | elgato = Elgato("example.com", session=session) 85 | assert await elgato.has_battery() is False 86 | with pytest.raises( 87 | ElgatoNoBatteryError, 88 | match="The Elgato light does not have a battery.", 89 | ): 90 | await elgato.battery_bypass(on=True) 91 | 92 | 93 | async def test_battery_bypass(aresponses: ResponsesMockServer) -> None: 94 | """Test changing battery bypass / studio mode.""" 95 | aresponses.add( 96 | "example.com:9123", 97 | "/elgato/lights/settings", 98 | "GET", 99 | aresponses.Response( 100 | status=200, 101 | headers={"Content-Type": "application/json"}, 102 | text=load_fixture("settings-key-light-mini.json"), 103 | ), 104 | ) 105 | 106 | async def response_handler(request: ClientResponse) -> Response: 107 | """Response handler for this test.""" 108 | data = await request.json() 109 | assert data == {"battery": {"bypass": 1}} 110 | return aresponses.Response( 111 | status=200, 112 | headers={"Content-Type": "application/json"}, 113 | text="{}", 114 | ) 115 | 116 | aresponses.add( 117 | "example.com:9123", 118 | "/elgato/lights/settings", 119 | "PUT", 120 | response_handler, 121 | ) 122 | 123 | async with ClientSession() as session: 124 | elgato = Elgato("example.com", session=session) 125 | await elgato.battery_bypass(on=True) 126 | -------------------------------------------------------------------------------- /tests/test_elgato.py: -------------------------------------------------------------------------------- 1 | """Tests for the Elgato Lights Library.""" 2 | 3 | # pylint: disable=protected-access 4 | import asyncio 5 | 6 | import pytest 7 | from aiohttp import ClientResponse, ClientSession 8 | from aiohttp.hdrs import METH_POST, METH_PUT 9 | from aresponses import Response, ResponsesMockServer 10 | 11 | from elgato import Elgato 12 | from elgato.exceptions import ElgatoConnectionError, ElgatoError 13 | 14 | 15 | async def test_json_request(aresponses: ResponsesMockServer) -> None: 16 | """Test JSON response is handled correctly.""" 17 | aresponses.add( 18 | "example.com:9123", 19 | "/elgato/test", 20 | "GET", 21 | aresponses.Response( 22 | status=200, 23 | headers={"Content-Type": "application/json"}, 24 | text='{"status": "ok"}', 25 | ), 26 | ) 27 | async with ClientSession() as session: 28 | elgato = Elgato("example.com", session=session) 29 | response = await elgato._request("test") 30 | assert response == '{"status": "ok"}' 31 | await elgato.close() 32 | 33 | 34 | async def test_internal_session(aresponses: ResponsesMockServer) -> None: 35 | """Test JSON response is handled correctly.""" 36 | aresponses.add( 37 | "example.com:9123", 38 | "/elgato/test", 39 | "GET", 40 | aresponses.Response( 41 | status=200, 42 | headers={"Content-Type": "application/json"}, 43 | text='{"status": "ok"}', 44 | ), 45 | ) 46 | async with Elgato("example.com") as elgato: 47 | response = await elgato._request("test") 48 | assert response == '{"status": "ok"}' 49 | 50 | 51 | async def test_put_request(aresponses: ResponsesMockServer) -> None: 52 | """Test PUT requests are handled correctly.""" 53 | aresponses.add( 54 | "example.com:9123", 55 | "/elgato/test", 56 | "PUT", 57 | aresponses.Response( 58 | status=200, 59 | headers={"Content-Type": "application/json"}, 60 | text='{"status": "ok"}', 61 | ), 62 | ) 63 | async with ClientSession() as session: 64 | elgato = Elgato("example.com", session=session) 65 | response = await elgato._request("test", method=METH_PUT, data={}) 66 | assert response == '{"status": "ok"}' 67 | 68 | 69 | async def test_post_request(aresponses: ResponsesMockServer) -> None: 70 | """Test POST requests are handled correctly.""" 71 | aresponses.add( 72 | "example.com:9123", 73 | "/elgato/test", 74 | "POST", 75 | Response( 76 | status=200, 77 | headers={"Content-Type": "application/json"}, 78 | text='{"status": "ok"}', 79 | ), 80 | ) 81 | async with ClientSession() as session: 82 | elgato = Elgato("example.com", session=session) 83 | response = await elgato._request("test", method=METH_POST, data={}) 84 | assert response == '{"status": "ok"}' 85 | 86 | 87 | async def test_request_port(aresponses: ResponsesMockServer) -> None: 88 | """Test the Elgato Light running on non-standard port.""" 89 | aresponses.add( 90 | "example.com:3333", 91 | "/elgato/test", 92 | "GET", 93 | aresponses.Response( 94 | status=200, 95 | headers={"Content-Type": "application/json"}, 96 | text='{"status": "ok"}', 97 | ), 98 | ) 99 | 100 | async with ClientSession() as session: 101 | elgato = Elgato("example.com", port=3333, session=session) 102 | response = await elgato._request("test") 103 | assert response == '{"status": "ok"}' 104 | 105 | 106 | async def test_timeout(aresponses: ResponsesMockServer) -> None: 107 | """Test request timeout from the Elgato Light.""" 108 | 109 | # Faking a timeout by sleeping 110 | async def response_handler(_: ClientResponse) -> Response: 111 | """Response handler for this test.""" 112 | await asyncio.sleep(2) 113 | return aresponses.Response(body="Goodmorning!") 114 | 115 | aresponses.add("example.com:9123", "/elgato/test", "GET", response_handler) 116 | 117 | async with ClientSession() as session: 118 | elgato = Elgato("example.com", session=session, request_timeout=1) 119 | with pytest.raises(ElgatoConnectionError): 120 | assert await elgato._request("test") 121 | 122 | 123 | async def test_http_error400(aresponses: ResponsesMockServer) -> None: 124 | """Test HTTP 404 response handling.""" 125 | aresponses.add( 126 | "example.com:9123", 127 | "/elgato/test", 128 | "GET", 129 | aresponses.Response(text="OMG PUPPIES!", status=404), 130 | ) 131 | 132 | async with ClientSession() as session: 133 | elgato = Elgato("example.com", session=session) 134 | with pytest.raises(ElgatoError): 135 | assert await elgato._request("test") 136 | 137 | 138 | async def test_light_on(aresponses: ResponsesMockServer) -> None: 139 | """Test controlling a Elgato Light.""" 140 | 141 | # Handle to run asserts on request in 142 | async def response_handler(request: ClientResponse) -> Response: 143 | """Response handler for this test.""" 144 | data = await request.json() 145 | assert data == { 146 | "numberOfLights": 1, 147 | "lights": [{"brightness": 100, "temperature": 275, "on": 1}], 148 | } 149 | return aresponses.Response( 150 | status=200, 151 | headers={"Content-Type": "application/json"}, 152 | text='{"status": "ok"}', 153 | ) 154 | 155 | aresponses.add( 156 | "example.com:9123", 157 | "/elgato/lights", 158 | "PUT", 159 | response_handler, 160 | ) 161 | 162 | async with ClientSession() as session: 163 | elgato = Elgato("example.com", session=session) 164 | await elgato.light(on=True, brightness=100, temperature=275) 165 | 166 | 167 | async def test_light_off(aresponses: ResponsesMockServer) -> None: 168 | """Test turning off an Elgato Light.""" 169 | 170 | # Handle to run asserts on request in 171 | async def response_handler(request: ClientResponse) -> Response: 172 | """Response handler for this test.""" 173 | data = await request.json() 174 | assert data == { 175 | "numberOfLights": 1, 176 | "lights": [{"on": 0}], 177 | } 178 | return aresponses.Response( 179 | status=200, 180 | headers={"Content-Type": "application/json"}, 181 | text='{"status": "ok"}', 182 | ) 183 | 184 | aresponses.add( 185 | "example.com:9123", 186 | "/elgato/lights", 187 | "PUT", 188 | response_handler, 189 | ) 190 | 191 | async with ClientSession() as session: 192 | elgato = Elgato("example.com", session=session) 193 | await elgato.light(on=False) 194 | 195 | 196 | async def test_light_no_on_off(aresponses: ResponsesMockServer) -> None: 197 | """Test controlling an Elgato Light without turning it on/off.""" 198 | 199 | # Handle to run asserts on request in 200 | async def response_handler(request: ClientResponse) -> Response: 201 | """Response handler for this test.""" 202 | data = await request.json() 203 | assert data == { 204 | "numberOfLights": 1, 205 | "lights": [{"brightness": 50}], 206 | } 207 | return aresponses.Response( 208 | status=200, 209 | headers={"Content-Type": "application/json"}, 210 | text='{"status": "ok"}', 211 | ) 212 | 213 | aresponses.add( 214 | "example.com:9123", 215 | "/elgato/lights", 216 | "PUT", 217 | response_handler, 218 | ) 219 | 220 | async with ClientSession() as session: 221 | elgato = Elgato("example.com", session=session) 222 | await elgato.light(brightness=50) 223 | -------------------------------------------------------------------------------- /tests/test_identify.py: -------------------------------------------------------------------------------- 1 | """Tests for identifying the Elgato Light device.""" 2 | 3 | from aiohttp import ClientSession 4 | from aresponses import ResponsesMockServer 5 | 6 | from elgato import Elgato 7 | 8 | 9 | async def test_identify(aresponses: ResponsesMockServer) -> None: 10 | """Test identifying the Elgato Light.""" 11 | aresponses.add( 12 | "example.com:9123", 13 | "/elgato/identify", 14 | "POST", 15 | aresponses.Response( 16 | status=200, 17 | headers={"Content-Type": "application/json"}, 18 | text="", 19 | ), 20 | ) 21 | 22 | async with ClientSession() as session: 23 | elgato = Elgato("example.com", session=session) 24 | await elgato.identify() 25 | -------------------------------------------------------------------------------- /tests/test_info.py: -------------------------------------------------------------------------------- 1 | """Tests for retrieving information from the Elgato Key Light device.""" 2 | 3 | from aiohttp import ClientResponse, ClientSession 4 | from aresponses import Response, ResponsesMockServer 5 | 6 | from elgato import Elgato, Info 7 | 8 | from . import load_fixture 9 | 10 | 11 | async def test_info_key_light(aresponses: ResponsesMockServer) -> None: 12 | """Test getting Elgato Key Light device information.""" 13 | aresponses.add( 14 | "example.com:9123", 15 | "/elgato/accessory-info", 16 | "GET", 17 | aresponses.Response( 18 | status=200, 19 | headers={"Content-Type": "application/json"}, 20 | text=load_fixture("info-key-light.json"), 21 | ), 22 | ) 23 | async with ClientSession() as session: 24 | elgato = Elgato("example.com", session=session) 25 | info: Info = await elgato.info() 26 | assert info 27 | assert info.display_name == "Frenck" 28 | assert info.features == ["lights"] 29 | assert info.firmware_build_number == 218 30 | assert info.firmware_version == "1.0.3" 31 | assert info.hardware_board_type == 53 32 | assert info.mac_address == "AA:BB:CC:DD:EE:FF" 33 | assert info.product_name == "Elgato Key Light" 34 | assert info.serial_number == "CN11A1A00001" 35 | assert info.wifi 36 | assert info.wifi.frequency == 2400 37 | assert info.wifi.rssi == -48 38 | assert info.wifi.ssid == "Frenck-IoT" 39 | 40 | 41 | async def test_info_key_light_air(aresponses: ResponsesMockServer) -> None: 42 | """Test getting Elgato Key Light Air device information.""" 43 | aresponses.add( 44 | "example.com:9123", 45 | "/elgato/accessory-info", 46 | "GET", 47 | aresponses.Response( 48 | status=200, 49 | headers={"Content-Type": "application/json"}, 50 | text=load_fixture("info-key-light-air.json"), 51 | ), 52 | ) 53 | async with ClientSession() as session: 54 | elgato = Elgato("example.com", session=session) 55 | info: Info = await elgato.info() 56 | assert info 57 | assert not info.display_name 58 | assert info.features == ["lights"] 59 | assert info.firmware_build_number == 195 60 | assert info.firmware_version == "1.0.3" 61 | assert info.hardware_board_type == 200 62 | assert info.mac_address is None 63 | assert info.product_name == "Elgato Key Light Air" 64 | assert info.serial_number == "CW44J2A03032" 65 | assert info.wifi is None 66 | 67 | 68 | async def test_info_light_strip(aresponses: ResponsesMockServer) -> None: 69 | """Test getting Elgato Light Strip device information.""" 70 | aresponses.add( 71 | "example.com:9123", 72 | "/elgato/accessory-info", 73 | "GET", 74 | aresponses.Response( 75 | status=200, 76 | headers={"Content-Type": "application/json"}, 77 | text=load_fixture("info-light-strip.json"), 78 | ), 79 | ) 80 | async with ClientSession() as session: 81 | elgato = Elgato("example.com", session=session) 82 | info: Info = await elgato.info() 83 | assert info 84 | assert info.display_name == "Elgato Light" 85 | assert info.features == ["lights"] 86 | assert info.firmware_build_number == 211 87 | assert info.firmware_version == "1.0.4" 88 | assert info.hardware_board_type == 70 89 | assert info.mac_address is None 90 | assert info.product_name == "Elgato Light Strip" 91 | assert info.serial_number == "EW52J1A00082" 92 | assert info.wifi is None 93 | 94 | 95 | async def test_change_display_name(aresponses: ResponsesMockServer) -> None: 96 | """Test changing the display name of an Elgato Light.""" 97 | 98 | async def response_handler(request: ClientResponse) -> Response: 99 | """Response handler for this test.""" 100 | data = await request.json() 101 | assert data == {"displayName": "OMG PUPPIES"} 102 | return aresponses.Response( 103 | status=200, 104 | headers={"Content-Type": "application/json"}, 105 | text="", 106 | ) 107 | 108 | aresponses.add( 109 | "example.com:9123", 110 | "/elgato/accessory-info", 111 | "PUT", 112 | response_handler, 113 | ) 114 | 115 | async with ClientSession() as session: 116 | elgato = Elgato("example.com", session=session) 117 | await elgato.display_name("OMG PUPPIES") 118 | 119 | 120 | async def test_missing_display_name(aresponses: ResponsesMockServer) -> None: 121 | """Test ensure we can handle a missing display name.""" 122 | aresponses.add( 123 | "example.com:9123", 124 | "/elgato/accessory-info", 125 | "GET", 126 | aresponses.Response( 127 | status=200, 128 | headers={"Content-Type": "application/json"}, 129 | text=load_fixture("info-light-strip.json"), 130 | ), 131 | ) 132 | async with ClientSession() as session: 133 | elgato = Elgato("example.com", session=session) 134 | info: Info = await elgato.info() 135 | assert info 136 | assert info.display_name == "Elgato Light" 137 | -------------------------------------------------------------------------------- /tests/test_restart.py: -------------------------------------------------------------------------------- 1 | """Tests for restarting the Elgato Light device.""" 2 | 3 | from aiohttp import ClientSession 4 | from aresponses import ResponsesMockServer 5 | 6 | from elgato import Elgato 7 | 8 | 9 | async def test_restart(aresponses: ResponsesMockServer) -> None: 10 | """Test restarting the Elgato Light.""" 11 | aresponses.add( 12 | "example.com:9123", 13 | "/elgato/restart", 14 | "POST", 15 | aresponses.Response( 16 | status=200, 17 | headers={"Content-Type": "application/json"}, 18 | text="", 19 | ), 20 | ) 21 | 22 | async with ClientSession() as session: 23 | elgato = Elgato("example.com", session=session) 24 | await elgato.restart() 25 | -------------------------------------------------------------------------------- /tests/test_settings.py: -------------------------------------------------------------------------------- 1 | """Tests for retrieving information from the Elgato Light device.""" 2 | 3 | import pytest 4 | from aiohttp import ClientResponse, ClientSession 5 | from aresponses import Response, ResponsesMockServer 6 | 7 | from elgato import Elgato, ElgatoNoBatteryError, PowerOnBehavior, Settings 8 | 9 | from . import load_fixture 10 | 11 | 12 | async def test_settings_keylight(aresponses: ResponsesMockServer) -> None: 13 | """Test getting Elgato Light device settings.""" 14 | aresponses.add( 15 | "example.com:9123", 16 | "/elgato/lights/settings", 17 | "GET", 18 | aresponses.Response( 19 | status=200, 20 | headers={"Content-Type": "application/json"}, 21 | text=load_fixture("settings-keylight.json"), 22 | ), 23 | ) 24 | async with ClientSession() as session: 25 | elgato = Elgato("example.com", session=session) 26 | settings: Settings = await elgato.settings() 27 | assert settings 28 | assert settings.color_change_duration == 100 29 | assert settings.power_on_behavior == 1 30 | assert settings.power_on_brightness == 20 31 | assert settings.power_on_hue is None 32 | assert settings.power_on_saturation is None 33 | assert settings.power_on_temperature == 213 34 | assert settings.switch_off_duration == 300 35 | assert settings.switch_on_duration == 100 36 | assert settings.battery is None 37 | 38 | 39 | async def test_settings_led_strip(aresponses: ResponsesMockServer) -> None: 40 | """Test getting Elgato Led Strip device settings.""" 41 | aresponses.add( 42 | "example.com:9123", 43 | "/elgato/lights/settings", 44 | "GET", 45 | aresponses.Response( 46 | status=200, 47 | headers={"Content-Type": "application/json"}, 48 | text=load_fixture("settings-strip.json"), 49 | ), 50 | ) 51 | async with ClientSession() as session: 52 | elgato = Elgato("example.com", session=session) 53 | settings: Settings = await elgato.settings() 54 | assert settings 55 | assert settings.color_change_duration == 150 56 | assert settings.power_on_behavior == 2 57 | assert settings.power_on_brightness == 40 58 | assert settings.power_on_hue == 40.0 59 | assert settings.power_on_saturation == 15.0 60 | assert settings.power_on_temperature == 0 61 | assert settings.switch_off_duration == 400 62 | assert settings.switch_on_duration == 150 63 | assert settings.battery is None 64 | 65 | 66 | async def test_settings_key_light_mini(aresponses: ResponsesMockServer) -> None: 67 | """Test getting Elgato Light Mini device settings. 68 | 69 | This device has a battery 70 | """ 71 | aresponses.add( 72 | "example.com:9123", 73 | "/elgato/lights/settings", 74 | "GET", 75 | aresponses.Response( 76 | status=200, 77 | headers={"Content-Type": "application/json"}, 78 | text=load_fixture("settings-key-light-mini.json"), 79 | ), 80 | ) 81 | async with ClientSession() as session: 82 | elgato = Elgato("example.com", session=session) 83 | settings: Settings = await elgato.settings() 84 | assert settings 85 | assert settings.battery 86 | assert settings.battery.bypass is False 87 | assert settings.battery.energy_saving.disable_wifi is False 88 | assert settings.battery.energy_saving.enabled is False 89 | assert settings.battery.energy_saving.minimum_battery_level == 15 90 | assert settings.battery.energy_saving.adjust_brightness.brightness == 10 91 | assert settings.battery.energy_saving.adjust_brightness.enabled is False 92 | assert settings.color_change_duration == 100 93 | assert settings.power_on_behavior == 1 94 | assert settings.power_on_brightness == 20 95 | assert settings.power_on_hue is None 96 | assert settings.power_on_saturation is None 97 | assert settings.power_on_temperature == 230 98 | assert settings.switch_off_duration == 300 99 | assert settings.switch_on_duration == 100 100 | 101 | 102 | async def test_battery_settings_keylight(aresponses: ResponsesMockServer) -> None: 103 | """Test getting Elgato Light battery settings.""" 104 | aresponses.add( 105 | "example.com:9123", 106 | "/elgato/lights/settings", 107 | "GET", 108 | aresponses.Response( 109 | status=200, 110 | headers={"Content-Type": "application/json"}, 111 | text=load_fixture("settings-keylight.json"), 112 | ), 113 | ) 114 | async with ClientSession() as session: 115 | elgato = Elgato("example.com", session=session) 116 | with pytest.raises(ElgatoNoBatteryError): 117 | await elgato.battery_settings() 118 | 119 | 120 | async def test_battery_settings_key_light_mini(aresponses: ResponsesMockServer) -> None: 121 | """Test getting Elgato Light Mini device battery settings. 122 | 123 | This device has a battery 124 | """ 125 | aresponses.add( 126 | "example.com:9123", 127 | "/elgato/lights/settings", 128 | "GET", 129 | aresponses.Response( 130 | status=200, 131 | headers={"Content-Type": "application/json"}, 132 | text=load_fixture("settings-key-light-mini.json"), 133 | ), 134 | ) 135 | async with ClientSession() as session: 136 | elgato = Elgato("example.com", session=session) 137 | settings = await elgato.battery_settings() 138 | assert settings 139 | assert settings 140 | assert settings.bypass is False 141 | assert settings.energy_saving.disable_wifi is False 142 | assert settings.energy_saving.enabled is False 143 | assert settings.energy_saving.minimum_battery_level == 15 144 | assert settings.energy_saving.adjust_brightness.brightness == 10 145 | assert settings.energy_saving.adjust_brightness.enabled is False 146 | 147 | 148 | async def test_energy_savings_no_battery(aresponses: ResponsesMockServer) -> None: 149 | """Test adjusting energy saving settings on a Elgato device without battery.""" 150 | aresponses.add( 151 | "example.com:9123", 152 | "/elgato/lights/settings", 153 | "GET", 154 | aresponses.Response( 155 | status=200, 156 | headers={"Content-Type": "application/json"}, 157 | text=load_fixture("settings-keylight.json"), 158 | ), 159 | ) 160 | async with ClientSession() as session: 161 | elgato = Elgato("example.com", session=session) 162 | assert await elgato.has_battery() is False 163 | with pytest.raises( 164 | ElgatoNoBatteryError, 165 | match="The Elgato light does not have a battery.", 166 | ): 167 | await elgato.energy_saving(on=True) 168 | 169 | 170 | async def test_energy_savings_full(aresponses: ResponsesMockServer) -> None: 171 | """Test changing energy saving settings.""" 172 | aresponses.add( 173 | "example.com:9123", 174 | "/elgato/lights/settings", 175 | "GET", 176 | aresponses.Response( 177 | status=200, 178 | headers={"Content-Type": "application/json"}, 179 | text=load_fixture("settings-key-light-mini.json"), 180 | ), 181 | repeat=2, 182 | ) 183 | 184 | async def response_handler(request: ClientResponse) -> Response: 185 | """Response handler for this test.""" 186 | data = await request.json() 187 | assert data == { 188 | "battery": { 189 | "energySaving": { 190 | "adjustBrightness": {"brightness": 42, "enable": 1}, 191 | "disableWifi": 1, 192 | "enable": 1, 193 | "minimumBatteryLevel": 21, 194 | }, 195 | }, 196 | } 197 | return aresponses.Response( 198 | status=200, 199 | headers={"Content-Type": "application/json"}, 200 | text="{}", 201 | ) 202 | 203 | aresponses.add( 204 | "example.com:9123", 205 | "/elgato/lights/settings", 206 | "PUT", 207 | response_handler, 208 | ) 209 | 210 | async with ClientSession() as session: 211 | elgato = Elgato("example.com", session=session) 212 | await elgato.energy_saving( 213 | adjust_brightness=True, 214 | brightness=42, 215 | disable_wifi=True, 216 | minimum_battery_level=21, 217 | on=True, 218 | ) 219 | 220 | 221 | async def test_energy_savings_no_changes(aresponses: ResponsesMockServer) -> None: 222 | """Test changing energy saving settings.""" 223 | aresponses.add( 224 | "example.com:9123", 225 | "/elgato/lights/settings", 226 | "GET", 227 | aresponses.Response( 228 | status=200, 229 | headers={"Content-Type": "application/json"}, 230 | text=load_fixture("settings-key-light-mini.json"), 231 | ), 232 | repeat=2, 233 | ) 234 | 235 | async def response_handler(request: ClientResponse) -> Response: 236 | """Response handler for this test.""" 237 | data = await request.json() 238 | assert data == { 239 | "battery": { 240 | "energySaving": { 241 | "adjustBrightness": {"brightness": 10, "enable": False}, 242 | "disableWifi": False, 243 | "enable": False, 244 | "minimumBatteryLevel": 15, 245 | }, 246 | }, 247 | } 248 | return aresponses.Response( 249 | status=200, 250 | headers={"Content-Type": "application/json"}, 251 | text="{}", 252 | ) 253 | 254 | aresponses.add( 255 | "example.com:9123", 256 | "/elgato/lights/settings", 257 | "PUT", 258 | response_handler, 259 | ) 260 | 261 | async with ClientSession() as session: 262 | elgato = Elgato("example.com", session=session) 263 | await elgato.energy_saving() 264 | 265 | 266 | async def test_power_on_behavior_full(aresponses: ResponsesMockServer) -> None: 267 | """Test changing power on behavior settings.""" 268 | aresponses.add( 269 | "example.com:9123", 270 | "/elgato/lights/settings", 271 | "GET", 272 | aresponses.Response( 273 | status=200, 274 | headers={"Content-Type": "application/json"}, 275 | text=load_fixture("settings-keylight.json"), 276 | ), 277 | repeat=1, 278 | ) 279 | 280 | async def response_handler(request: ClientResponse) -> Response: 281 | """Response handler for this test.""" 282 | data = await request.json() 283 | assert data == { 284 | "colorChangeDurationMs": 100, 285 | "powerOnBehavior": 2, 286 | "powerOnBrightness": 42, 287 | "powerOnHue": 21.0, 288 | "powerOnTemperature": 242, 289 | "switchOffDurationMs": 300, 290 | "switchOnDurationMs": 100, 291 | } 292 | return aresponses.Response( 293 | status=200, 294 | headers={"Content-Type": "application/json"}, 295 | text="{}", 296 | ) 297 | 298 | aresponses.add( 299 | "example.com:9123", 300 | "/elgato/lights/settings", 301 | "PUT", 302 | response_handler, 303 | ) 304 | 305 | async with ClientSession() as session: 306 | elgato = Elgato("example.com", session=session) 307 | await elgato.power_on_behavior( 308 | behavior=PowerOnBehavior.USE_DEFAULTS, 309 | brightness=42, 310 | hue=21.0, 311 | temperature=242, 312 | ) 313 | 314 | 315 | async def test_power_on_behavior_no_changes(aresponses: ResponsesMockServer) -> None: 316 | """Test changing power on behavior settings.""" 317 | aresponses.add( 318 | "example.com:9123", 319 | "/elgato/lights/settings", 320 | "GET", 321 | aresponses.Response( 322 | status=200, 323 | headers={"Content-Type": "application/json"}, 324 | text=load_fixture("settings-key-light-mini.json"), 325 | ), 326 | repeat=2, 327 | ) 328 | 329 | async def response_handler(request: ClientResponse) -> Response: 330 | """Response handler for this test.""" 331 | data = await request.json() 332 | assert data == { 333 | "colorChangeDurationMs": 100, 334 | "powerOnBehavior": 1, 335 | "powerOnBrightness": 20, 336 | "powerOnTemperature": 230, 337 | "switchOffDurationMs": 300, 338 | "switchOnDurationMs": 100, 339 | } 340 | return aresponses.Response( 341 | status=200, 342 | headers={"Content-Type": "application/json"}, 343 | text="{}", 344 | ) 345 | 346 | aresponses.add( 347 | "example.com:9123", 348 | "/elgato/lights/settings", 349 | "PUT", 350 | response_handler, 351 | ) 352 | 353 | async with ClientSession() as session: 354 | elgato = Elgato("example.com", session=session) 355 | await elgato.power_on_behavior() 356 | -------------------------------------------------------------------------------- /tests/test_state.py: -------------------------------------------------------------------------------- 1 | """Tests for retrieving information from the Elgato Light device.""" 2 | 3 | from __future__ import annotations 4 | 5 | import pytest 6 | from aiohttp import ClientResponse, ClientSession 7 | from aresponses import Response, ResponsesMockServer 8 | 9 | from elgato import Elgato, ElgatoError, State 10 | 11 | from . import load_fixture 12 | 13 | 14 | async def test_state_temperature(aresponses: ResponsesMockServer) -> None: 15 | """Test getting Elgato Light state in temperature mode.""" 16 | aresponses.add( 17 | "example.com:9123", 18 | "/elgato/lights", 19 | "GET", 20 | aresponses.Response( 21 | status=200, 22 | headers={"Content-Type": "application/json"}, 23 | text=load_fixture("state-temperature.json"), 24 | ), 25 | ) 26 | async with ClientSession() as session: 27 | elgato = Elgato("example.com", session=session) 28 | state: State = await elgato.state() 29 | assert state 30 | assert state.brightness == 21 31 | assert state.hue is None 32 | assert state.on 33 | assert state.saturation is None 34 | assert state.temperature == 297 35 | 36 | 37 | async def test_state_color(aresponses: ResponsesMockServer) -> None: 38 | """Test getting Elgato Light state in color mode.""" 39 | aresponses.add( 40 | "example.com:9123", 41 | "/elgato/lights", 42 | "GET", 43 | aresponses.Response( 44 | status=200, 45 | headers={"Content-Type": "application/json"}, 46 | text=load_fixture("state-color.json"), 47 | ), 48 | ) 49 | async with ClientSession() as session: 50 | elgato = Elgato("example.com", session=session) 51 | state: State = await elgato.state() 52 | assert state 53 | assert state.brightness == 50 54 | assert state.hue == 358.0 55 | assert state.on 56 | assert state.saturation == 6.0 57 | assert state.temperature is None 58 | 59 | 60 | async def test_change_state_temperature(aresponses: ResponsesMockServer) -> None: 61 | """Test changing Elgato Light State in temperature mode.""" 62 | 63 | async def response_handler(request: ClientResponse) -> Response: 64 | """Response handler for this test.""" 65 | data = await request.json() 66 | assert data == { 67 | "numberOfLights": 1, 68 | "lights": [{"on": 1, "brightness": 100, "temperature": 200}], 69 | } 70 | return aresponses.Response( 71 | status=200, 72 | headers={"Content-Type": "application/json"}, 73 | text="{}", 74 | ) 75 | 76 | aresponses.add("example.com:9123", "/elgato/lights", "PUT", response_handler) 77 | 78 | async with ClientSession() as session: 79 | elgato = Elgato("example.com", session=session) 80 | await elgato.light(on=True, brightness=100, temperature=200) 81 | 82 | 83 | async def test_change_state_color(aresponses: ResponsesMockServer) -> None: 84 | """Test changing Elgato Light State in color mode.""" 85 | 86 | async def response_handler(request: ClientResponse) -> Response: 87 | """Response handler for this test.""" 88 | data = await request.json() 89 | assert data == { 90 | "numberOfLights": 1, 91 | "lights": [{"on": 1, "brightness": 100, "hue": 10.1, "saturation": 20.2}], 92 | } 93 | return aresponses.Response( 94 | status=200, 95 | headers={"Content-Type": "application/json"}, 96 | text="{}", 97 | ) 98 | 99 | aresponses.add("example.com:9123", "/elgato/lights", "PUT", response_handler) 100 | 101 | async with ClientSession() as session: 102 | elgato = Elgato("example.com", session=session) 103 | await elgato.light(on=True, brightness=100, hue=10.1, saturation=20.2) 104 | 105 | 106 | @pytest.mark.parametrize( 107 | ("state", "message"), 108 | [ 109 | ( 110 | {"hue": 10.1, "temperature": 10}, 111 | "Cannot set temperature together with hue or saturation", 112 | ), 113 | ( 114 | {"saturation": 10.1, "temperature": 10}, 115 | "Cannot set temperature together with hue or saturation", 116 | ), 117 | ( 118 | {"brightness": -1}, 119 | "Brightness not between 0 and 100", 120 | ), 121 | ( 122 | {"brightness": 101}, 123 | "Brightness not between 0 and 100", 124 | ), 125 | ( 126 | {"hue": -1}, 127 | "Hue not between 0 and 360", 128 | ), 129 | ( 130 | {"hue": 360.1}, 131 | "Hue not between 0 and 360", 132 | ), 133 | ( 134 | {"saturation": -1}, 135 | "Saturation not between 0 and 100", 136 | ), 137 | ( 138 | {"saturation": 100.1}, 139 | "Saturation not between 0 and 100", 140 | ), 141 | ( 142 | {"temperature": 142}, 143 | "Color temperature out of range", 144 | ), 145 | ( 146 | {"temperature": 345}, 147 | "Color temperature out of range", 148 | ), 149 | ( 150 | {}, 151 | "No parameters to set, light not adjusted", 152 | ), 153 | ], 154 | ) 155 | async def test_change_state_errors(state: dict[str, int | float], message: str) -> None: 156 | """Test changing Elgato Light State with invalid values.""" 157 | elgato = Elgato("example.com") 158 | with pytest.raises(ElgatoError, match=message): 159 | await elgato.light(**state) # type: ignore[arg-type] 160 | --------------------------------------------------------------------------------