├── .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 ├── control.py ├── ruff.toml ├── upgrade.py └── websocket.py ├── package-lock.json ├── package.json ├── poetry.lock ├── pyproject.toml ├── sonar-project.properties ├── src └── wled │ ├── __init__.py │ ├── cli │ ├── __init__.py │ └── async_typer.py │ ├── const.py │ ├── exceptions.py │ ├── models.py │ ├── py.typed │ ├── utils.py │ └── wled.py └── tests ├── __init__.py ├── ruff.toml └── test_wled.py /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "containerEnv": { 3 | "POETRY_VIRTUALENVS_IN_PROJECT": "true" 4 | }, 5 | "customizations": { 6 | "codespaces": { 7 | "openFiles": ["README.md", "src/wled/wled.py", "src/wled/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": "ruff", 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 Python client for WLED", 56 | "updateContentCommand": ". ${NVM_DIR}/nvm.sh && nvm install && nvm use && npm install && poetry install --extras cli && 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 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socioeconomic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | frenck@frenck.dev. 64 | 65 | All complaints will be reviewed and investigated promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series 87 | of actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or 94 | permanent ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within 114 | the community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 119 | version 2.0, available at 120 | [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0]. 121 | 122 | Community Impact Guidelines were inspired by 123 | [Mozilla's code of conduct enforcement ladder][mozilla coc]. 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | [https://www.contributor-covenant.org/faq][faq]. Translations are available 127 | at [https://www.contributor-covenant.org/translations][translations]. 128 | 129 | [homepage]: https://www.contributor-covenant.org 130 | [v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html 131 | [mozilla coc]: https://github.com/mozilla/diversity 132 | [faq]: https://www.contributor-covenant.org/faq 133 | [translations]: https://www.contributor-covenant.org/translations 134 | -------------------------------------------------------------------------------- /.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-wled/issues 29 | [prs]: https://github.com/frenck/python-wled/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 | "schedule": ["before 2am"], 4 | "rebaseWhen": "behind-base-branch", 5 | "dependencyDashboard": true, 6 | "labels": ["dependencies", "no-stale"], 7 | "lockFileMaintenance": { 8 | "enabled": true, 9 | "automerge": true 10 | }, 11 | "commitMessagePrefix": "⬆️", 12 | "packageRules": [ 13 | { 14 | "matchManagers": ["poetry"], 15 | "addLabels": ["python"] 16 | }, 17 | { 18 | "matchManagers": ["poetry"], 19 | "matchDepTypes": ["dev"], 20 | "rangeStrategy": "pin" 21 | }, 22 | { 23 | "matchManagers": ["poetry"], 24 | "matchUpdateTypes": ["minor", "patch"], 25 | "automerge": true 26 | }, 27 | { 28 | "matchManagers": ["npm", "nvm"], 29 | "addLabels": ["javascript"], 30 | "rangeStrategy": "pin" 31 | }, 32 | { 33 | "matchManagers": ["npm", "nvm"], 34 | "matchUpdateTypes": ["minor", "patch"], 35 | "automerge": true 36 | }, 37 | { 38 | "matchManagers": ["github-actions"], 39 | "addLabels": ["github_actions"], 40 | "rangeStrategy": "pin" 41 | }, 42 | { 43 | "matchManagers": ["github-actions"], 44 | "matchUpdateTypes": ["minor", "patch"], 45 | "automerge": true 46 | } 47 | ] 48 | } 49 | -------------------------------------------------------------------------------- /.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 --extras cli --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 --extras cli --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 --extras cli --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 YAML files 99 | run: poetry run pre-commit run check-yaml --all-files 100 | - name: 🚀 Detect Private Keys 101 | run: poetry run pre-commit run detect-private-key --all-files 102 | - name: 🚀 Check End of Files 103 | run: poetry run pre-commit run end-of-file-fixer --all-files 104 | - name: 🚀 Trim Trailing Whitespace 105 | run: poetry run pre-commit run trailing-whitespace --all-files 106 | 107 | pylint: 108 | name: pylint 109 | runs-on: ubuntu-latest 110 | steps: 111 | - name: ⤵️ Check out code from GitHub 112 | uses: actions/checkout@v4.2.2 113 | - name: 🏗 Set up Poetry 114 | run: pipx install poetry 115 | - name: 🏗 Set up Python ${{ env.DEFAULT_PYTHON }} 116 | id: python 117 | uses: actions/setup-python@v5.6.0 118 | with: 119 | python-version: ${{ env.DEFAULT_PYTHON }} 120 | cache: "poetry" 121 | - name: 🏗 Install workflow dependencies 122 | run: | 123 | poetry config virtualenvs.create true 124 | poetry config virtualenvs.in-project true 125 | - name: 🏗 Install Python dependencies 126 | run: poetry install --extras cli --no-interaction 127 | - name: 🚀 Run pylint 128 | run: poetry run pre-commit run pylint --all-files 129 | 130 | yamllint: 131 | name: yamllint 132 | runs-on: ubuntu-latest 133 | steps: 134 | - name: ⤵️ Check out code from GitHub 135 | uses: actions/checkout@v4.2.2 136 | - name: 🏗 Set up Poetry 137 | run: pipx install poetry 138 | - name: 🏗 Set up Python ${{ env.DEFAULT_PYTHON }} 139 | id: python 140 | uses: actions/setup-python@v5.6.0 141 | with: 142 | python-version: ${{ env.DEFAULT_PYTHON }} 143 | cache: "poetry" 144 | - name: 🏗 Install workflow dependencies 145 | run: | 146 | poetry config virtualenvs.create true 147 | poetry config virtualenvs.in-project true 148 | - name: 🏗 Install Python dependencies 149 | run: poetry install --extras cli --no-interaction 150 | - name: 🚀 Run yamllint 151 | run: poetry run yamllint . 152 | 153 | prettier: 154 | name: Prettier 155 | runs-on: ubuntu-latest 156 | steps: 157 | - name: ⤵️ Check out code from GitHub 158 | uses: actions/checkout@v4.2.2 159 | - name: 🏗 Set up Poetry 160 | run: pipx install poetry 161 | - name: 🏗 Set up Python ${{ env.DEFAULT_PYTHON }} 162 | id: python 163 | uses: actions/setup-python@v5.6.0 164 | with: 165 | python-version: ${{ env.DEFAULT_PYTHON }} 166 | cache: "poetry" 167 | - name: 🏗 Install workflow dependencies 168 | run: | 169 | poetry config virtualenvs.create true 170 | poetry config virtualenvs.in-project true 171 | - name: 🏗 Install Python dependencies 172 | run: poetry install --extras cli --no-interaction 173 | - name: 🏗 Set up Node.js 174 | uses: actions/setup-node@v4.4.0 175 | with: 176 | node-version-file: ".nvmrc" 177 | cache: "npm" 178 | - name: 🏗 Install NPM dependencies 179 | run: npm install 180 | - name: 🚀 Run prettier 181 | run: poetry run pre-commit run prettier --all-files 182 | -------------------------------------------------------------------------------- /.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/wled 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 --extras cli --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 --extras cli --no-interaction 37 | - name: 🚀 Run pytest 38 | run: poetry run pytest --cov wled 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 --extras cli --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/sonarcloud-github-action@v5.0.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 --extras cli --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: [pre-commit, pre-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: [pre-commit, pre-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: [pre-commit, pre-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: [pre-commit, pre-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: [pre-commit, pre-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: WLED API Client 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 WLED. 19 | 20 | ## About 21 | 22 | This package allows you to control and monitor an WLED device 23 | programmatically. It is mainly created to allow third-party programs to automate 24 | the behavior of WLED. 25 | 26 | ## Installation 27 | 28 | ```bash 29 | pip install wled 30 | ``` 31 | 32 | ## Usage 33 | 34 | ```python 35 | import asyncio 36 | 37 | from wled import WLED 38 | 39 | 40 | async def main() -> None: 41 | """Show example on controlling your WLED device.""" 42 | async with WLED("wled-frenck.local") as led: 43 | device = await led.update() 44 | print(device.info.version) 45 | 46 | # Turn strip on, full brightness 47 | await led.master(on=True, brightness=255) 48 | 49 | 50 | if __name__ == "__main__": 51 | asyncio.run(main()) 52 | ``` 53 | 54 | ## Changelog & Releases 55 | 56 | This repository keeps a change log using [GitHub's releases][releases] 57 | functionality. 58 | 59 | Releases are based on [Semantic Versioning][semver], and use the format 60 | of `MAJOR.MINOR.PATCH`. In a nutshell, the version will be incremented 61 | based on the following: 62 | 63 | - `MAJOR`: Incompatible or major changes. 64 | - `MINOR`: Backwards-compatible new features and enhancements. 65 | - `PATCH`: Backwards-compatible bugfixes and package updates. 66 | 67 | ## Contributing 68 | 69 | This is an active open-source project. We are always open to people who want to 70 | use the code or contribute to it. 71 | 72 | We've set up a separate document for our 73 | [contribution guidelines](CONTRIBUTING.md). 74 | 75 | Thank you for being involved! :heart_eyes: 76 | 77 | ## Setting up development environment 78 | 79 | The easiest way to start, is by opening a CodeSpace here on GitHub, or by using 80 | the [Dev Container][devcontainer] feature of Visual Studio Code. 81 | 82 | [![Open in Dev Containers][devcontainer-shield]][devcontainer] 83 | 84 | This Python project is fully managed using the [Poetry][poetry] dependency 85 | manager. But also relies on the use of NodeJS for certain checks during 86 | development. 87 | 88 | You need at least: 89 | 90 | - Python 3.11+ 91 | - [Poetry][poetry-install] 92 | - NodeJS 20+ (including NPM) 93 | 94 | To install all packages, including all development requirements: 95 | 96 | ```bash 97 | npm install 98 | poetry install --extras cli 99 | ``` 100 | 101 | As this repository uses the [pre-commit][pre-commit] framework, all changes 102 | are linted and tested with each commit. You can run all checks and tests 103 | manually, using the following command: 104 | 105 | ```bash 106 | poetry run pre-commit run --all-files 107 | ``` 108 | 109 | To run just the Python tests: 110 | 111 | ```bash 112 | poetry run pytest 113 | ``` 114 | 115 | ## Authors & contributors 116 | 117 | The original setup of this repository is by [Franck Nijhof][frenck]. 118 | 119 | For a full list of all authors and contributors, 120 | check [the contributor's page][contributors]. 121 | 122 | ## License 123 | 124 | MIT License 125 | 126 | Copyright (c) 2019-2024 Franck Nijhof 127 | 128 | Permission is hereby granted, free of charge, to any person obtaining a copy 129 | of this software and associated documentation files (the "Software"), to deal 130 | in the Software without restriction, including without limitation the rights 131 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 132 | copies of the Software, and to permit persons to whom the Software is 133 | furnished to do so, subject to the following conditions: 134 | 135 | The above copyright notice and this permission notice shall be included in all 136 | copies or substantial portions of the Software. 137 | 138 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 139 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 140 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 141 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 142 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 143 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 144 | SOFTWARE. 145 | 146 | [build-shield]: https://github.com/frenck/python-wled/actions/workflows/tests.yaml/badge.svg 147 | [build]: https://github.com/frenck/python-wled/actions/workflows/tests.yaml 148 | [codecov-shield]: https://codecov.io/gh/frenck/python-wled/branch/main/graph/badge.svg 149 | [codecov]: https://codecov.io/gh/frenck/python-wled 150 | [contributors]: https://github.com/frenck/python-wled/graphs/contributors 151 | [devcontainer-shield]: https://img.shields.io/static/v1?label=Dev%20Containers&message=Open&color=blue&logo=visualstudiocode 152 | [devcontainer]: https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/frenck/python-wled 153 | [frenck]: https://github.com/frenck 154 | [github-sponsors-shield]: https://frenck.dev/wp-content/uploads/2019/12/github_sponsor.png 155 | [github-sponsors]: https://github.com/sponsors/frenck 156 | [license-shield]: https://img.shields.io/github/license/frenck/python-wled.svg 157 | [maintenance-shield]: https://img.shields.io/maintenance/yes/2024.svg 158 | [patreon-shield]: https://frenck.dev/wp-content/uploads/2019/12/patreon.png 159 | [patreon]: https://www.patreon.com/frenck 160 | [poetry-install]: https://python-poetry.org/docs/#installation 161 | [poetry]: https://python-poetry.org 162 | [pre-commit]: https://pre-commit.com/ 163 | [project-stage-shield]: https://img.shields.io/badge/project%20stage-experimental-yellow.svg 164 | [pypi]: https://pypi.org/project/wled/ 165 | [python-versions-shield]: https://img.shields.io/pypi/pyversions/wled 166 | [releases-shield]: https://img.shields.io/github/release/frenck/python-wled.svg 167 | [releases]: https://github.com/frenck/python-wled/releases 168 | [semver]: http://semver.org/spec/v2.0.0.html 169 | [sonarcloud-shield]: https://sonarcloud.io/api/project_badges/measure?project=frenck_python-wled&metric=alert_status 170 | [sonarcloud]: https://sonarcloud.io/summary/new_code?id=frenck_python-wled 171 | -------------------------------------------------------------------------------- /examples/control.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=W0621 2 | """Asynchronous Python client for WLED.""" 3 | 4 | import asyncio 5 | 6 | from wled import WLED 7 | 8 | 9 | async def main() -> None: 10 | """Show example on controlling your WLED device.""" 11 | async with WLED("10.10.11.31") as led: 12 | device = await led.update() 13 | print(device.info.version) 14 | print(device.state) 15 | 16 | if device.state.on: 17 | print("Turning off WLED....") 18 | await led.master(on=False) 19 | else: 20 | print("Turning on WLED....") 21 | await led.master(on=True) 22 | 23 | device = await led.update() 24 | print(device.state) 25 | 26 | 27 | if __name__ == "__main__": 28 | asyncio.run(main()) 29 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /examples/upgrade.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=W0621 2 | """Asynchronous Python client for WLED.""" 3 | 4 | import asyncio 5 | 6 | from wled import WLED, WLEDReleases 7 | 8 | 9 | async def main() -> None: 10 | """Show example on upgrade your WLED device.""" 11 | async with WLEDReleases() as releases: 12 | latest = await releases.releases() 13 | print(f"Latest stable version: {latest.stable}") 14 | print(f"Latest beta version: {latest.beta}") 15 | 16 | if not latest.stable: 17 | print("No stable version found") 18 | return 19 | 20 | async with WLED("10.10.11.54") as led: 21 | device = await led.update() 22 | print(f"Current version: {device.info.version}") 23 | 24 | print("Upgrading WLED....") 25 | await led.upgrade(version=latest.stable) 26 | 27 | print("Waiting for WLED to come back....") 28 | await asyncio.sleep(5) 29 | 30 | device = await led.update() 31 | print(f"Current version: {device.info.version}") 32 | 33 | 34 | if __name__ == "__main__": 35 | asyncio.run(main()) 36 | -------------------------------------------------------------------------------- /examples/websocket.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=W0621 2 | """Asynchronous Python client for WLED.""" 3 | 4 | import asyncio 5 | 6 | from wled import WLED, Device 7 | 8 | 9 | async def main() -> None: 10 | """Show example on WebSocket usage with WLED.""" 11 | async with WLED("10.10.11.135") as led: 12 | await led.connect() 13 | if led.connected: 14 | print("connected!") 15 | 16 | def something_updated(device: Device) -> None: 17 | """Call when WLED reports a state change.""" 18 | print("Received an update from WLED") 19 | print(device.state) 20 | print(device.info) 21 | 22 | # Start listening 23 | task = asyncio.create_task(led.listen(callback=something_updated)) 24 | 25 | # Now we stream for an hour 26 | await asyncio.sleep(3600) 27 | task.cancel() 28 | 29 | 30 | if __name__ == "__main__": 31 | asyncio.run(main()) 32 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wled", 3 | "version": "0.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "wled", 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": "wled", 3 | "version": "0.0.0", 4 | "private": true, 5 | "description": "Asynchronous Python client for WLED", 6 | "scripts": { 7 | "prettier": "prettier --write **/*.{json,js,md,yml,yaml} *.{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 :: 4 - Beta", 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 WLED." 15 | documentation = "https://github.com/frenck/python-wled" 16 | homepage = "https://github.com/frenck/python-wled" 17 | keywords = ["wled", "api", "async", "client"] 18 | license = "MIT" 19 | maintainers = ["Franck Nijhof "] 20 | name = "wled" 21 | packages = [ 22 | {include = "wled", from = "src"}, 23 | ] 24 | readme = "README.md" 25 | repository = "https://github.com/frenck/python-wled" 26 | version = "0.0.0" 27 | 28 | [tool.poetry.dependencies] 29 | aiohttp = ">=3.0.0" 30 | awesomeversion = ">=22.1.0" 31 | backoff = ">=2.2.0" 32 | cachetools = ">=4.0.0" 33 | mashumaro = "^3.13" 34 | orjson = ">=3.9.8" 35 | python = "^3.11" 36 | typer = {version = "^0.16.0", optional = true, extras = ["all"]} 37 | yarl = ">=1.6.0" 38 | zeroconf = {version = "^0.147.0", optional = true, extras = ["all"]} 39 | 40 | [tool.poetry.extras] 41 | cli = ["typer", "zeroconf"] 42 | 43 | [tool.poetry.scripts] 44 | wled = "wled.cli:cli" 45 | 46 | [tool.poetry.urls] 47 | "Bug Tracker" = "https://github.com/frenck/python-wled/issues" 48 | Changelog = "https://github.com/frenck/python-wled/releases" 49 | 50 | [tool.poetry.dev-dependencies] 51 | aresponses = "3.0.0" 52 | codespell = "2.4.1" 53 | covdefaults = "2.3.0" 54 | coverage = {version = "7.8.2", extras = ["toml"]} 55 | mypy = "1.16.0" 56 | pre-commit = "4.2.0" 57 | pre-commit-hooks = "5.0.0" 58 | pylint = "3.3.7" 59 | pytest = "8.4.0" 60 | pytest-asyncio = "0.26.0" 61 | pytest-cov = "6.1.1" 62 | ruff = "0.11.13" 63 | safety = "3.5.2" 64 | types-cachetools = "^5.3.0" 65 | yamllint = "1.37.1" 66 | 67 | [tool.coverage.run] 68 | plugins = ["covdefaults"] 69 | source = ["wled"] 70 | 71 | [tool.coverage.report] 72 | fail_under = 25 73 | show_missing = true 74 | omit = ["src/wled/cli/*"] 75 | 76 | [tool.mypy] 77 | # Specify the target platform details in config, so your developers are 78 | # free to run mypy on Windows, Linux, or macOS and get consistent 79 | # results. 80 | platform = "linux" 81 | python_version = "3.11" 82 | 83 | # show error messages from unrelated files 84 | follow_imports = "normal" 85 | 86 | # suppress errors about unsatisfied imports 87 | ignore_missing_imports = true 88 | 89 | # be strict 90 | check_untyped_defs = true 91 | disallow_any_generics = true 92 | disallow_incomplete_defs = true 93 | disallow_subclassing_any = true 94 | disallow_untyped_calls = true 95 | disallow_untyped_decorators = true 96 | disallow_untyped_defs = true 97 | no_implicit_optional = true 98 | no_implicit_reexport = true 99 | strict_optional = true 100 | warn_incomplete_stub = true 101 | warn_no_return = true 102 | warn_redundant_casts = true 103 | warn_return_any = true 104 | warn_unused_configs = true 105 | warn_unused_ignores = true 106 | 107 | [tool.pylint.MASTER] 108 | ignore = [ 109 | "tests", 110 | ] 111 | 112 | [tool.pylint.BASIC] 113 | good-names = [ 114 | "_", 115 | "ex", 116 | "fp", 117 | "i", 118 | "id", 119 | "j", 120 | "k", 121 | "on", 122 | "Run", 123 | "T", 124 | "wv", 125 | ] 126 | 127 | [tool.pylint."MESSAGES CONTROL"] 128 | disable = [ 129 | "too-few-public-methods", 130 | "duplicate-code", 131 | "format", 132 | "unsubscriptable-object", 133 | ] 134 | 135 | [tool.pylint.SIMILARITIES] 136 | ignore-imports = true 137 | 138 | [tool.pylint.FORMAT] 139 | max-line-length = 88 140 | 141 | [tool.pylint.DESIGN] 142 | max-attributes = 20 143 | 144 | [tool.pylint.TYPECHECK] 145 | ignored-modules = ["orjson"] 146 | 147 | [tool.pytest.ini_options] 148 | addopts = "--cov" 149 | asyncio_mode = "auto" 150 | 151 | [tool.ruff.lint] 152 | ignore = [ 153 | "ANN401", # Opinioated warning on disallowing dynamically typed expressions 154 | "D203", # Conflicts with other rules 155 | "D213", # Conflicts with other rules 156 | "D417", # False positives in some occasions 157 | "PLR2004", # Just annoying, not really useful 158 | 159 | # Conflicts with the Ruff formatter 160 | "COM812", 161 | "ISC001", 162 | ] 163 | select = ["ALL"] 164 | 165 | [tool.ruff.lint.flake8-pytest-style] 166 | fixture-parentheses = false 167 | mark-parentheses = false 168 | 169 | [tool.ruff.lint.isort] 170 | known-first-party = ["wled"] 171 | 172 | [tool.ruff.lint.flake8-type-checking] 173 | runtime-evaluated-base-classes = ["mashumaro.mixins.orjson.DataClassORJSONMixin"] 174 | 175 | [tool.ruff.lint.mccabe] 176 | max-complexity = 25 177 | 178 | [tool.codespell] 179 | ignore-words-list = "abl" 180 | 181 | [build-system] 182 | build-backend = "poetry.core.masonry.api" 183 | requires = ["poetry-core>=1.0.0"] 184 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.organization=frenck 2 | sonar.projectKey=frenck_python-wled 3 | sonar.projectName=Asynchronous Python client for WLED 4 | sonar.projectVersion=1.0 5 | 6 | sonar.links.homepage=https://github.com/frenck/python-wled 7 | sonar.links.ci=https://github.com/frenck/python-wled/actions 8 | sonar.links.issue=https://github.com/frenck/python-wled/issues 9 | sonar.links.scm=https://github.com/frenck/python-wled/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, 3.13 17 | sonar.python.coverage.reportPaths=coverage.xml 18 | -------------------------------------------------------------------------------- /src/wled/__init__.py: -------------------------------------------------------------------------------- 1 | """Asynchronous Python client for WLED.""" 2 | 3 | from .const import LightCapability, LiveDataOverride, NightlightMode, SyncGroup 4 | from .exceptions import ( 5 | WLEDConnectionClosedError, 6 | WLEDConnectionError, 7 | WLEDConnectionTimeoutError, 8 | WLEDError, 9 | WLEDUnsupportedVersionError, 10 | WLEDUpgradeError, 11 | ) 12 | from .models import ( 13 | Device, 14 | Effect, 15 | Info, 16 | Leds, 17 | Nightlight, 18 | Palette, 19 | Playlist, 20 | PlaylistEntry, 21 | Preset, 22 | Releases, 23 | Segment, 24 | State, 25 | UDPSync, 26 | ) 27 | from .wled import WLED, WLEDReleases 28 | 29 | __all__ = [ 30 | "WLED", 31 | "Device", 32 | "Effect", 33 | "Info", 34 | "Leds", 35 | "LightCapability", 36 | "LiveDataOverride", 37 | "Nightlight", 38 | "NightlightMode", 39 | "Palette", 40 | "Playlist", 41 | "PlaylistEntry", 42 | "Preset", 43 | "Releases", 44 | "Segment", 45 | "State", 46 | "SyncGroup", 47 | "UDPSync", 48 | "WLEDConnectionClosedError", 49 | "WLEDConnectionError", 50 | "WLEDConnectionTimeoutError", 51 | "WLEDError", 52 | "WLEDReleases", 53 | "WLEDUnsupportedVersionError", 54 | "WLEDUpgradeError", 55 | ] 56 | -------------------------------------------------------------------------------- /src/wled/cli/__init__.py: -------------------------------------------------------------------------------- 1 | """Asynchronous Python client for WLED.""" 2 | 3 | import asyncio 4 | import sys 5 | from typing import Annotated 6 | 7 | import typer 8 | from rich.console import Console 9 | from rich.live import Live 10 | from rich.panel import Panel 11 | from rich.table import Table 12 | from zeroconf import ServiceStateChange, Zeroconf 13 | from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf 14 | 15 | from wled import WLED, WLEDReleases 16 | from wled.exceptions import WLEDConnectionError, WLEDUnsupportedVersionError 17 | 18 | from .async_typer import AsyncTyper 19 | 20 | cli = AsyncTyper(help="WLED CLI", no_args_is_help=True, add_completion=False) 21 | console = Console() 22 | 23 | 24 | @cli.error_handler(WLEDConnectionError) 25 | def connection_error_handler(_: WLEDConnectionError) -> None: 26 | """Handle connection errors.""" 27 | message = """ 28 | Could not connect to the specified WLED device. Please make sure that 29 | the device is powered on, connected to the network and that you have 30 | specified the correct IP address or hostname. 31 | 32 | If you are not sure what the IP address or hostname of your WLED device 33 | is, you can use the scan command to find it: 34 | 35 | wled scan 36 | """ 37 | panel = Panel( 38 | message, 39 | expand=False, 40 | title="Connection error", 41 | border_style="red bold", 42 | ) 43 | console.print(panel) 44 | sys.exit(1) 45 | 46 | 47 | @cli.error_handler(WLEDUnsupportedVersionError) 48 | def unsupported_version_error_handler( 49 | _: WLEDUnsupportedVersionError, 50 | ) -> None: 51 | """Handle unsupported version errors.""" 52 | message = """ 53 | The specified WLED device is running an unsupported version. 54 | 55 | Currently only 0.14.0 and higher is supported 56 | """ 57 | panel = Panel( 58 | message, 59 | expand=False, 60 | title="Unsupported version", 61 | border_style="red bold", 62 | ) 63 | console.print(panel) 64 | sys.exit(1) 65 | 66 | 67 | @cli.command("info") 68 | async def command_info( 69 | host: Annotated[ 70 | str, 71 | typer.Option( 72 | help="WLED device IP address or hostname", 73 | prompt="Host address", 74 | show_default=False, 75 | ), 76 | ], 77 | ) -> None: 78 | """Show the information about the WLED device.""" 79 | with console.status( 80 | "[cyan]Fetching WLED device information...", spinner="toggle12" 81 | ): 82 | async with WLED(host) as led: 83 | device = await led.update() 84 | 85 | info_table = Table(title="\nWLED device information", show_header=False) 86 | info_table.add_column("Property", style="cyan bold") 87 | info_table.add_column("Value", style="green") 88 | 89 | info_table.add_row("Name", device.info.name) 90 | info_table.add_row("Brand", device.info.brand) 91 | info_table.add_row("Product", device.info.product) 92 | 93 | info_table.add_section() 94 | info_table.add_row("IP address", device.info.ip) 95 | info_table.add_row("MAC address", device.info.mac_address) 96 | if device.info.wifi: 97 | info_table.add_row("Wi-Fi BSSID", device.info.wifi.bssid) 98 | info_table.add_row("Wi-Fi channel", str(device.info.wifi.channel)) 99 | info_table.add_row("Wi-Fi RSSI", f"{device.info.wifi.rssi} dBm") 100 | info_table.add_row("Wi-Fi signal strength", f"{device.info.wifi.signal}%") 101 | 102 | info_table.add_section() 103 | info_table.add_row("Version", device.info.version) 104 | info_table.add_row("Build", str(device.info.build)) 105 | info_table.add_row("Architecture", device.info.architecture) 106 | info_table.add_row("Arduino version", device.info.arduino_core_version) 107 | 108 | info_table.add_section() 109 | info_table.add_row("Uptime", f"{int(device.info.uptime.total_seconds())} seconds") 110 | info_table.add_row("Free heap", f"{device.info.free_heap} bytes") 111 | info_table.add_row("Total storage", f"{device.info.filesystem.total} bytes") 112 | info_table.add_row("Used storage", f"{device.info.filesystem.used} bytes") 113 | info_table.add_row("% Used storage", f"{device.info.filesystem.used_percentage}%") 114 | 115 | info_table.add_section() 116 | info_table.add_row("Effect count", f"{device.info.effect_count} effects") 117 | info_table.add_row("Palette count", f"{device.info.palette_count} palettes") 118 | 119 | info_table.add_section() 120 | info_table.add_row("Sync UDP port", str(device.info.udp_port)) 121 | info_table.add_row( 122 | "WebSocket", 123 | "Disabled" 124 | if device.info.websocket is None 125 | else f"{device.info.websocket} client(s)", 126 | ) 127 | 128 | info_table.add_section() 129 | info_table.add_row("Live", "Yes" if device.info.live else "No") 130 | info_table.add_row("Live IP", device.info.live_ip) 131 | info_table.add_row("Live mode", device.info.live_mode) 132 | 133 | info_table.add_section() 134 | info_table.add_row("LED count", f"{device.info.leds.count} LEDs") 135 | info_table.add_row("LED power", f"{device.info.leds.power} mA") 136 | info_table.add_row("LED max power", f"{device.info.leds.max_power} mA") 137 | 138 | console.print(info_table) 139 | 140 | 141 | @cli.command("effects") 142 | async def command_effects( 143 | host: Annotated[ 144 | str, 145 | typer.Option( 146 | help="WLED device IP address or hostname", 147 | prompt="Host address", 148 | show_default=False, 149 | ), 150 | ], 151 | ) -> None: 152 | """Show the effects on the device.""" 153 | with console.status( 154 | "[cyan]Fetching WLED device information...", spinner="toggle12" 155 | ): 156 | async with WLED(host) as led: 157 | device = await led.update() 158 | 159 | table = Table(title="\nEffects on this WLED device", show_header=False) 160 | table.add_column("Effects", style="cyan bold") 161 | for effect in device.effects.values(): 162 | table.add_row(effect.name) 163 | 164 | console.print(table) 165 | 166 | 167 | @cli.command("palettes") 168 | async def command_palettes( 169 | host: Annotated[ 170 | str, 171 | typer.Option( 172 | help="WLED device IP address or hostname", 173 | prompt="Host address", 174 | show_default=False, 175 | ), 176 | ], 177 | ) -> None: 178 | """Show the palettes on the device.""" 179 | with console.status( 180 | "[cyan]Fetching WLED device information...", spinner="toggle12" 181 | ): 182 | async with WLED(host) as led: 183 | device = await led.update() 184 | 185 | table = Table(title="\nPalettes on this WLED device", show_header=False) 186 | table.add_column("Palette", style="cyan bold") 187 | for palettes in device.palettes.values(): 188 | table.add_row(palettes.name) 189 | 190 | console.print(table) 191 | 192 | 193 | @cli.command("playlists") 194 | async def command_playlists( 195 | host: Annotated[ 196 | str, 197 | typer.Option( 198 | help="WLED device IP address or hostname", 199 | prompt="Host address", 200 | show_default=False, 201 | ), 202 | ], 203 | ) -> None: 204 | """Show the playlists on the device.""" 205 | with console.status( 206 | "[cyan]Fetching WLED device information...", spinner="toggle12" 207 | ): 208 | async with WLED(host) as led: 209 | device = await led.update() 210 | 211 | if not device.playlists: 212 | console.print("🚫[red] This device has no playlists") 213 | return 214 | 215 | table = Table(title="\nPlaylists stored in the WLED device", show_header=False) 216 | table.add_column("Playlist", style="cyan bold") 217 | for playlist in device.playlists.values(): 218 | table.add_row(playlist.name) 219 | 220 | console.print(table) 221 | 222 | 223 | @cli.command("presets") 224 | async def command_presets( 225 | host: Annotated[ 226 | str, 227 | typer.Option( 228 | help="WLED device IP address or hostname", 229 | prompt="Host address", 230 | show_default=False, 231 | ), 232 | ], 233 | ) -> None: 234 | """Show the presets on the device.""" 235 | with console.status( 236 | "[cyan]Fetching WLED device information...", spinner="toggle12" 237 | ): 238 | async with WLED(host) as led: 239 | device = await led.update() 240 | 241 | if not device.presets: 242 | console.print("🚫[red] This device has no presets") 243 | return 244 | 245 | table = Table(title="\nPresets stored in the WLED device") 246 | table.add_column("Preset", style="cyan bold") 247 | table.add_column("Quick label", style="cyan bold") 248 | table.add_column("Active", style="green") 249 | for preset in device.presets.values(): 250 | table.add_row(preset.name, preset.quick_label, "Yes" if preset.on else "No") 251 | 252 | console.print(table) 253 | 254 | 255 | @cli.command("releases") 256 | async def command_releases() -> None: 257 | """Show the latest release information of WLED.""" 258 | with console.status( 259 | "[cyan]Fetching latest release information...", spinner="toggle12" 260 | ): 261 | async with WLEDReleases() as releases: 262 | latest = await releases.releases() 263 | 264 | table = Table( 265 | title="\n\nFound WLED Releases", header_style="cyan bold", show_lines=True 266 | ) 267 | table.add_column("Release channel") 268 | table.add_column("Latest version") 269 | table.add_column("Release notes") 270 | 271 | table.add_row( 272 | "Stable", 273 | latest.stable, 274 | f"https://github.com/Aircoookie/WLED/releases/v{latest.stable}", 275 | ) 276 | table.add_row( 277 | "Beta", 278 | latest.beta, 279 | f"https://github.com/Aircoookie/WLED/releases/v{latest.beta}", 280 | ) 281 | 282 | console.print(table) 283 | 284 | 285 | @cli.command("scan") 286 | async def command_scan() -> None: 287 | """Scan for WLED devices on the network.""" 288 | zeroconf = AsyncZeroconf() 289 | background_tasks = set() 290 | 291 | table = Table( 292 | title="\n\nFound WLED devices", header_style="cyan bold", show_lines=True 293 | ) 294 | table.add_column("Addresses") 295 | table.add_column("MAC Address") 296 | 297 | def async_on_service_state_change( 298 | zeroconf: Zeroconf, 299 | service_type: str, 300 | name: str, 301 | state_change: ServiceStateChange, 302 | ) -> None: 303 | """Handle service state changes.""" 304 | if state_change is not ServiceStateChange.Added: 305 | return 306 | 307 | future = asyncio.ensure_future( 308 | async_display_service_info(zeroconf, service_type, name) 309 | ) 310 | background_tasks.add(future) 311 | future.add_done_callback(background_tasks.discard) 312 | 313 | async def async_display_service_info( 314 | zeroconf: Zeroconf, service_type: str, name: str 315 | ) -> None: 316 | """Retrieve and display service info.""" 317 | info = AsyncServiceInfo(service_type, name) 318 | await info.async_request(zeroconf, 3000) 319 | if info is None: 320 | return 321 | 322 | console.print(f"[cyan bold]Found service {info.server}: is a WLED device 🎉") 323 | 324 | table.add_row( 325 | f"{str(info.server).rstrip('.')}\n" 326 | + ", ".join(info.parsed_scoped_addresses()), 327 | info.properties[b"mac"].decode(), # type: ignore[union-attr] 328 | ) 329 | 330 | console.print("[green]Scanning for WLED devices...") 331 | console.print("[red]Press Ctrl-C to exit\n") 332 | 333 | with Live(table, console=console, refresh_per_second=4): 334 | browser = AsyncServiceBrowser( 335 | zeroconf.zeroconf, 336 | "_wled._tcp.local.", 337 | handlers=[async_on_service_state_change], 338 | ) 339 | 340 | try: 341 | forever = asyncio.Event() 342 | await forever.wait() 343 | except KeyboardInterrupt: 344 | pass 345 | finally: 346 | console.print("\n[green]Control-C pressed, stopping scan") 347 | await browser.async_cancel() 348 | await zeroconf.async_close() 349 | 350 | 351 | if __name__ == "__main__": 352 | cli() 353 | -------------------------------------------------------------------------------- /src/wled/cli/async_typer.py: -------------------------------------------------------------------------------- 1 | """Asynchronous Python client for WLED. 2 | 3 | Adaptation of the snippet/code from: 4 | - https://github.com/tiangolo/typer/issues/88#issuecomment-1613013597 5 | - https://github.com/argilla-io/argilla/blob/e77ca86c629a492019f230ac55ebde207b280xc9c/src/argilla/cli/typer_ext.py 6 | """ 7 | 8 | # Copyright 2021-present, the Recognai S.L. team. 9 | # 10 | # Licensed under the Apache License, Version 2.0 (the "License"); 11 | # you may not use this file except in compliance with the License. 12 | # You may obtain a copy of the License at 13 | # 14 | # http://www.apache.org/licenses/LICENSE-2.0 15 | # 16 | # Unless required by applicable law or agreed to in writing, software 17 | # distributed under the License is distributed on an "AS IS" BASIS, 18 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 19 | # See the License for the specific language governing permissions and 20 | # limitations under the License. 21 | 22 | from __future__ import annotations 23 | 24 | import asyncio 25 | from functools import wraps 26 | from typing import ( 27 | TYPE_CHECKING, 28 | Any, 29 | Callable, 30 | ParamSpec, 31 | TypeVar, 32 | ) 33 | 34 | from typer import Exit 35 | from typer import Typer as SyncTyper 36 | 37 | if TYPE_CHECKING: 38 | from collections.abc import Coroutine 39 | 40 | from typer.core import TyperCommand, TyperGroup 41 | 42 | _P = ParamSpec("_P") 43 | _R = TypeVar("_R") 44 | 45 | HandleErrorFunc = Callable[[Any], None] 46 | 47 | 48 | class AsyncTyper(SyncTyper): 49 | """A Typer subclass that supports async.""" 50 | 51 | error_handlers: dict[type[Exception], HandleErrorFunc] 52 | 53 | # pylint: disable-next=too-many-arguments, too-many-locals 54 | def callback( # type: ignore[override] # noqa: PLR0913 55 | self, 56 | *, 57 | cls: type[TyperGroup] | None = None, 58 | invoke_without_command: bool = False, 59 | no_args_is_help: bool = False, 60 | subcommand_metavar: str | None = None, 61 | chain: bool = False, 62 | result_callback: Callable[..., Any] | None = None, 63 | context_settings: dict[Any, Any] | None = None, 64 | # pylint: disable-next=redefined-builtin 65 | help: str | None = None, # noqa: A002 66 | epilog: str | None = None, 67 | short_help: str | None = None, 68 | options_metavar: str = "[OPTIONS]", 69 | add_help_option: bool = True, 70 | hidden: bool = False, 71 | deprecated: bool = False, 72 | rich_help_panel: str | None = None, 73 | ) -> Callable[ 74 | [Callable[_P, Coroutine[Any, Any, _R]]], 75 | Callable[_P, Coroutine[Any, Any, _R]], 76 | ]: 77 | """Create a new typer callback.""" 78 | super_callback = super().callback( 79 | cls=cls, 80 | invoke_without_command=invoke_without_command, 81 | no_args_is_help=no_args_is_help, 82 | subcommand_metavar=subcommand_metavar, 83 | chain=chain, 84 | result_callback=result_callback, 85 | context_settings=context_settings, 86 | help=help, 87 | epilog=epilog, 88 | short_help=short_help, 89 | options_metavar=options_metavar, 90 | add_help_option=add_help_option, 91 | hidden=hidden, 92 | deprecated=deprecated, 93 | rich_help_panel=rich_help_panel, 94 | ) 95 | 96 | def decorator( 97 | func: Callable[_P, Coroutine[Any, Any, _R]], 98 | ) -> Callable[_P, Coroutine[Any, Any, _R]]: 99 | if asyncio.iscoroutinefunction(func): 100 | 101 | @wraps(func) 102 | def sync_func(*_args: _P.args, **_kwargs: _P.kwargs) -> _R: 103 | return asyncio.run(func(*_args, **_kwargs)) 104 | 105 | super_callback(sync_func) 106 | else: 107 | super_callback(func) 108 | 109 | return func 110 | 111 | return decorator 112 | 113 | # pylint: disable-next=too-many-arguments 114 | def command( # type: ignore[override] # noqa: PLR0913 115 | self, 116 | name: str | None = None, 117 | *, 118 | cls: type[TyperCommand] | None = None, 119 | context_settings: dict[Any, Any] | None = None, 120 | # pylint: disable-next=redefined-builtin 121 | help: str | None = None, # noqa: A002 122 | epilog: str | None = None, 123 | short_help: str | None = None, 124 | options_metavar: str = "[OPTIONS]", 125 | add_help_option: bool = True, 126 | no_args_is_help: bool = False, 127 | hidden: bool = False, 128 | deprecated: bool = False, 129 | # Rich settings 130 | rich_help_panel: str | None = None, 131 | ) -> Callable[ 132 | [Callable[_P, Coroutine[Any, Any, _R]]], 133 | Callable[_P, Coroutine[Any, Any, _R]], 134 | ]: 135 | """Create a new typer command.""" 136 | super_command = super().command( 137 | name, 138 | cls=cls, 139 | context_settings=context_settings, 140 | help=help, 141 | epilog=epilog, 142 | short_help=short_help, 143 | options_metavar=options_metavar, 144 | add_help_option=add_help_option, 145 | no_args_is_help=no_args_is_help, 146 | hidden=hidden, 147 | deprecated=deprecated, 148 | rich_help_panel=rich_help_panel, 149 | ) 150 | 151 | def decorator( 152 | func: Callable[_P, Coroutine[Any, Any, _R]], 153 | ) -> Callable[_P, Coroutine[Any, Any, _R]]: 154 | if asyncio.iscoroutinefunction(func): 155 | 156 | @wraps(func) 157 | def sync_func(*_args: _P.args, **_kwargs: _P.kwargs) -> _R: 158 | return asyncio.run(func(*_args, **_kwargs)) 159 | 160 | super_command(sync_func) 161 | else: 162 | super_command(func) 163 | 164 | return func 165 | 166 | return decorator 167 | 168 | def error_handler(self, exc: type[Exception]) -> Callable[[HandleErrorFunc], None]: 169 | """Register an error handler for a given exception.""" 170 | if not hasattr(self, "error_handlers"): 171 | self.error_handlers = {} 172 | 173 | def decorator(func: HandleErrorFunc) -> None: 174 | self.error_handlers[exc] = func 175 | 176 | return decorator 177 | 178 | def __call__(self, *args: Any, **kwargs: Any) -> Any: 179 | """Call the typer app.""" 180 | try: 181 | return super().__call__(*args, **kwargs) 182 | except Exit: 183 | raise 184 | # pylint: disable-next=broad-except 185 | except Exception as e: 186 | if ( 187 | not hasattr(self, "error_handlers") 188 | or (handler := self.error_handlers.get(type(e))) is None 189 | ): 190 | raise 191 | return handler(e) 192 | -------------------------------------------------------------------------------- /src/wled/const.py: -------------------------------------------------------------------------------- 1 | """Asynchronous Python client for WLED.""" 2 | 3 | from enum import IntEnum, IntFlag 4 | 5 | from awesomeversion import AwesomeVersion 6 | 7 | MIN_REQUIRED_VERSION = AwesomeVersion("0.14.0") 8 | 9 | 10 | class LightCapability(IntFlag): 11 | """Enumeration representing the capabilities of a light in WLED.""" 12 | 13 | NONE = 0 14 | RGB_COLOR = 1 15 | WHITE_CHANNEL = 2 16 | COLOR_TEMPERATURE = 4 17 | MANUAL_WHITE = 8 18 | 19 | # These are not used, but are reserved for future use. 20 | # WLED specifications documents we should expect them, 21 | # therefore, we include them here. 22 | RESERVED_2 = 16 23 | RESERVED_3 = 32 24 | RESERVED_4 = 64 25 | RESERVED_5 = 128 26 | 27 | 28 | class LiveDataOverride(IntEnum): 29 | """Enumeration representing live override mode from WLED.""" 30 | 31 | OFF = 0 32 | ON = 1 33 | OFF_UNTIL_REBOOT = 2 34 | 35 | 36 | class NightlightMode(IntEnum): 37 | """Enumeration representing nightlight mode from WLED.""" 38 | 39 | INSTANT = 0 40 | FADE = 1 41 | COLOR_FADE = 2 42 | SUNRISE = 3 43 | 44 | 45 | class SyncGroup(IntFlag): 46 | """Bitfield for udp sync groups 1-8.""" 47 | 48 | NONE = 0 49 | GROUP1 = 1 50 | GROUP2 = 2 51 | GROUP3 = 4 52 | GROUP4 = 8 53 | GROUP5 = 16 54 | GROUP6 = 32 55 | GROUP7 = 64 56 | GROUP8 = 128 57 | -------------------------------------------------------------------------------- /src/wled/exceptions.py: -------------------------------------------------------------------------------- 1 | """Exceptions for WLED.""" 2 | 3 | 4 | class WLEDError(Exception): 5 | """Generic WLED exception.""" 6 | 7 | 8 | class WLEDEmptyResponseError(Exception): 9 | """WLED empty API response exception.""" 10 | 11 | 12 | class WLEDConnectionError(WLEDError): 13 | """WLED connection exception.""" 14 | 15 | 16 | class WLEDConnectionTimeoutError(WLEDConnectionError): 17 | """WLED connection Timeout exception.""" 18 | 19 | 20 | class WLEDConnectionClosedError(WLEDConnectionError): 21 | """WLED WebSocket connection has been closed.""" 22 | 23 | 24 | class WLEDUnsupportedVersionError(WLEDError): 25 | """WLED version is unsupported.""" 26 | 27 | 28 | class WLEDUpgradeError(WLEDError): 29 | """WLED upgrade exception.""" 30 | -------------------------------------------------------------------------------- /src/wled/models.py: -------------------------------------------------------------------------------- 1 | """Models for WLED.""" 2 | 3 | from __future__ import annotations 4 | 5 | from dataclasses import dataclass, field 6 | from datetime import UTC, datetime, timedelta 7 | from functools import cached_property 8 | from typing import Any 9 | 10 | from awesomeversion import AwesomeVersion 11 | from mashumaro import field_options 12 | from mashumaro.config import BaseConfig 13 | from mashumaro.mixins.orjson import DataClassORJSONMixin 14 | from mashumaro.types import SerializableType, SerializationStrategy 15 | 16 | from .const import ( 17 | MIN_REQUIRED_VERSION, 18 | LightCapability, 19 | LiveDataOverride, 20 | NightlightMode, 21 | SyncGroup, 22 | ) 23 | from .exceptions import WLEDUnsupportedVersionError 24 | from .utils import get_awesome_version 25 | 26 | 27 | class AwesomeVersionSerializationStrategy(SerializationStrategy, use_annotations=True): 28 | """Serialization strategy for AwesomeVersion objects.""" 29 | 30 | def serialize(self, value: AwesomeVersion | None) -> str: 31 | """Serialize AwesomeVersion object to string.""" 32 | if value is None: 33 | return "" 34 | return str(value) 35 | 36 | def deserialize(self, value: str) -> AwesomeVersion | None: 37 | """Deserialize string to AwesomeVersion object.""" 38 | version = get_awesome_version(value) 39 | if not version.valid: 40 | return None 41 | return version 42 | 43 | 44 | class TimedeltaSerializationStrategy(SerializationStrategy, use_annotations=True): 45 | """Serialization strategy for timedelta objects.""" 46 | 47 | def serialize(self, value: timedelta) -> int: 48 | """Serialize timedelta object to seconds.""" 49 | return int(value.total_seconds()) 50 | 51 | def deserialize(self, value: int) -> timedelta: 52 | """Deserialize integer to timedelta object.""" 53 | return timedelta(seconds=value) 54 | 55 | 56 | class TimestampSerializationStrategy(SerializationStrategy, use_annotations=True): 57 | """Serialization strategy for datetime objects.""" 58 | 59 | def serialize(self, value: datetime) -> float: 60 | """Serialize datetime object to timestamp.""" 61 | return value.timestamp() 62 | 63 | def deserialize(self, value: float) -> datetime: 64 | """Deserialize timestamp to datetime object.""" 65 | return datetime.fromtimestamp(value, tz=UTC) 66 | 67 | 68 | @dataclass 69 | class Color(SerializableType): 70 | """Object holding color information in WLED.""" 71 | 72 | primary: tuple[int, int, int, int] | tuple[int, int, int] 73 | secondary: tuple[int, int, int, int] | tuple[int, int, int] | None = None 74 | tertiary: tuple[int, int, int, int] | tuple[int, int, int] | None = None 75 | 76 | def _serialize(self) -> list[tuple[int, int, int, int] | tuple[int, int, int]]: 77 | colors = [self.primary] 78 | if self.secondary is not None: 79 | colors.append(self.secondary) 80 | if self.tertiary is not None: 81 | colors.append(self.tertiary) 82 | return colors 83 | 84 | @classmethod 85 | def _deserialize( 86 | cls, value: list[tuple[int, int, int, int] | tuple[int, int, int] | str] 87 | ) -> Color: 88 | # Some values in the list can be strings, which indicates that the 89 | # color is a hex color value. 90 | return cls( 91 | *[ # type: ignore[arg-type] 92 | tuple(int(color[i : i + 2], 16) for i in (1, 3, 5)) 93 | if isinstance(color, str) 94 | else color 95 | for color in value 96 | ] 97 | ) 98 | 99 | 100 | class BaseModel(DataClassORJSONMixin): 101 | """Base model for all WLED models.""" 102 | 103 | # pylint: disable-next=too-few-public-methods 104 | class Config(BaseConfig): 105 | """Mashumaro configuration.""" 106 | 107 | omit_none = True 108 | serialization_strategy = { # noqa: RUF012 109 | AwesomeVersion: AwesomeVersionSerializationStrategy(), 110 | datetime: TimestampSerializationStrategy(), 111 | timedelta: TimedeltaSerializationStrategy(), 112 | } 113 | serialize_by_alias = True 114 | 115 | 116 | @dataclass(kw_only=True) 117 | class Nightlight(BaseModel): 118 | """Object holding nightlight state in WLED.""" 119 | 120 | duration: int = field(default=1, metadata=field_options(alias="dur")) 121 | """Duration of nightlight in minutes.""" 122 | 123 | mode: NightlightMode = field(default=NightlightMode.INSTANT) 124 | """Nightlight mode (available since 0.10.2).""" 125 | 126 | on: bool = field(default=False) 127 | """Nightlight currently active.""" 128 | 129 | target_brightness: int = field(default=0, metadata=field_options(alias="tbri")) 130 | """Target brightness of nightlight feature.""" 131 | 132 | 133 | @dataclass(kw_only=True) 134 | class UDPSync(BaseModel): 135 | """Object holding UDP sync state in WLED. 136 | 137 | Missing at this point, is the `nn` field. This field allows to skip 138 | sending a broadcast packet for the current API request; However, this field 139 | is only used for requests and not part of the state responses. 140 | """ 141 | 142 | receive: bool = field(default=False, metadata=field_options(alias="recv")) 143 | """Receive broadcast packets.""" 144 | 145 | receive_groups: SyncGroup = field( 146 | default=SyncGroup.NONE, metadata=field_options(alias="rgrp") 147 | ) 148 | """Groups to receive WLED broadcast packets from.""" 149 | 150 | send: bool = field(default=False, metadata=field_options(alias="send")) 151 | """Send WLED broadcast (UDP sync) packet on state change.""" 152 | 153 | send_groups: SyncGroup = field( 154 | default=SyncGroup.NONE, metadata=field_options(alias="sgrp") 155 | ) 156 | """Groups to send WLED broadcast packets to.""" 157 | 158 | 159 | @dataclass(frozen=True, kw_only=True) 160 | class Effect(BaseModel): 161 | """Object holding an effect in WLED.""" 162 | 163 | effect_id: int 164 | name: str 165 | 166 | 167 | @dataclass(frozen=True, kw_only=True) 168 | class Palette(BaseModel): 169 | """Object holding an palette in WLED. 170 | 171 | Args: 172 | ---- 173 | data: The data from the WLED device API. 174 | 175 | Returns: 176 | ------- 177 | A palette object. 178 | 179 | """ 180 | 181 | name: str 182 | palette_id: int 183 | 184 | 185 | @dataclass(kw_only=True) 186 | class Segment(BaseModel): 187 | """Object holding segment state in WLED. 188 | 189 | Args: 190 | ---- 191 | data: The data from the WLED device API. 192 | 193 | Returns: 194 | ------- 195 | A segment object. 196 | 197 | """ 198 | 199 | brightness: int = field(default=0, metadata=field_options(alias="bri")) 200 | """Brightness of the segment.""" 201 | 202 | clones: int = field(default=-1, metadata=field_options(alias="cln")) 203 | """The segment this segment clones.""" 204 | 205 | color: Color | None = field(default=None, metadata=field_options(alias="col")) 206 | """The primary, secondary (background) and tertiary colors of the segment. 207 | 208 | Each color is an tuple of 3 or 4 bytes, which represents a RGB(W) color, 209 | i.e. (255,170,0) or (64,64,64,64). 210 | 211 | WLED can also return hex color values as strings, this library will 212 | automatically convert those to RGB values to keep the data consistent. 213 | """ 214 | 215 | effect_id: int | str = field(default=0, metadata=field_options(alias="fx")) 216 | """ID of the effect. 217 | 218 | ~ to increment, ~- to decrement, or "r" for random. 219 | """ 220 | 221 | intensity: int | str = field(default=0, metadata=field_options(alias="ix")) 222 | """Intensity of the segment. 223 | 224 | Effect intensity. ~ to increment, ~- to decrement. ~10 to increment by 10, 225 | ~-10 to decrement by 10. 226 | """ 227 | 228 | length: int = field(default=0, metadata=field_options(alias="len")) 229 | """Length of the segment (stop - start). 230 | 231 | Stop has preference, so if it is included, length is ignored. 232 | """ 233 | 234 | on: bool | None = field(default=None) 235 | """The on/off state of the segment.""" 236 | 237 | palette_id: int | str = field(default=0, metadata=field_options(alias="pal")) 238 | """ID of the palette. 239 | 240 | ~ to increment, ~- to decrement, or r for random. 241 | """ 242 | 243 | reverse: bool = field(default=False, metadata=field_options(alias="rev")) 244 | """ 245 | Flips the segment (in horizontal dimension for 2D set-up), 246 | causing animations to change direction. 247 | """ 248 | 249 | segment_id: int | None = field(default=None, metadata=field_options(alias="id")) 250 | """The ID of the segment.""" 251 | 252 | selected: bool = field(default=False, metadata=field_options(alias="sel")) 253 | """ 254 | Indicates if the segment is selected. 255 | 256 | Selected segments will have their state (color/FX) updated by APIs that 257 | don't support segments (e.g. UDP sync, HTTP API). If no segment is selected, 258 | the first segment (id:0) will behave as if selected. 259 | 260 | WLED will report the state of the first (lowest id) segment that is selected 261 | to APIs (HTTP, MQTT, Blynk...), or mainseg in case no segment is selected 262 | and for the UDP API. 263 | 264 | Live data is always applied to all LEDs regardless of segment configuration. 265 | """ 266 | 267 | speed: int = field(default=0, metadata=field_options(alias="sx")) 268 | """Relative effect speed. 269 | 270 | ~ to increment, ~- to decrement. ~10 to increment by 10, ~-10 to decrement by 10. 271 | """ 272 | 273 | start: int = 0 274 | """LED the segment starts at. 275 | 276 | For 2D set-up it determines column where segment starts, 277 | from top-left corner of the matrix. 278 | """ 279 | 280 | stop: int = 0 281 | """LED the segment stops at, not included in range. 282 | 283 | If stop is set to a lower or equal value than start (setting to 0 is 284 | recommended), the segment is invalidated and deleted. 285 | 286 | For 2D set-up it determines column where segment stops, 287 | from top-left corner of the matrix. 288 | """ 289 | 290 | cct: int = field(default=0) 291 | """White spectrum color temperature. 292 | 293 | 0 indicates the warmest possible color temperature, 294 | 255 indicates the coldest temperature 295 | """ 296 | 297 | 298 | @dataclass(kw_only=True) 299 | class Leds: 300 | """Object holding leds info from WLED.""" 301 | 302 | count: int = 0 303 | """Total LED count.""" 304 | 305 | fps: int = 0 306 | """Current frames per second.""" 307 | 308 | light_capabilities: LightCapability = field( 309 | default=LightCapability.NONE, metadata=field_options(alias="lc") 310 | ) 311 | """Capabilities of the light.""" 312 | 313 | max_power: int = field(default=0, metadata=field_options(alias="maxpwr")) 314 | """Maximum power budget in milliamperes for the ABL. 0 if ABL is disabled.""" 315 | 316 | max_segments: int = field(default=0, metadata=field_options(alias="maxseg")) 317 | """Maximum number of segments supported by this version.""" 318 | 319 | power: int = field(default=0, metadata=field_options(alias="pwr")) 320 | """ 321 | Current LED power usage in milliamperes as determined by the ABL. 322 | 0 if ABL is disabled. 323 | """ 324 | 325 | segment_light_capabilities: list[LightCapability] = field( 326 | default_factory=list, metadata=field_options(alias="seglc") 327 | ) 328 | """Capabilities of each segment.""" 329 | 330 | 331 | @dataclass(kw_only=True) 332 | class Wifi(BaseModel): 333 | """Object holding Wi-Fi information from WLED. 334 | 335 | Args: 336 | ---- 337 | data: The data from the WLED device API. 338 | 339 | Returns: 340 | ------- 341 | A Wi-Fi object. 342 | 343 | """ 344 | 345 | bssid: str = "00:00:00:00:00:00" 346 | channel: int = 0 347 | rssi: int = 0 348 | signal: int = 0 349 | 350 | 351 | @dataclass(kw_only=True) 352 | class Filesystem(BaseModel): 353 | """Object holding Filesystem information from WLED. 354 | 355 | Args: 356 | ---- 357 | data: The data from the WLED device API. 358 | 359 | Returns: 360 | ------- 361 | A Filesystem object. 362 | 363 | """ 364 | 365 | last_modified: datetime | None = field( 366 | default=None, metadata=field_options(alias="pmt") 367 | ) 368 | """ 369 | Last modification of the presets.json file. Not accurate after boot or 370 | after using /edit. 371 | """ 372 | 373 | total: int = field(default=1, metadata=field_options(alias="t")) 374 | """Total space of the filesystem in kilobytes.""" 375 | 376 | used: int = field(default=1, metadata=field_options(alias="u")) 377 | """Used space of the filesystem in kilobytes.""" 378 | 379 | @cached_property 380 | def free(self) -> int: 381 | """Return the free space of the filesystem in kilobytes. 382 | 383 | Returns 384 | ------- 385 | The free space of the filesystem. 386 | 387 | """ 388 | return self.total - self.used 389 | 390 | @cached_property 391 | def free_percentage(self) -> int: 392 | """Return the free percentage of the filesystem. 393 | 394 | Returns 395 | ------- 396 | The free percentage of the filesystem. 397 | 398 | """ 399 | return round((self.free / self.total) * 100) 400 | 401 | @cached_property 402 | def used_percentage(self) -> int: 403 | """Return the used percentage of the filesystem. 404 | 405 | Returns 406 | ------- 407 | The used percentage of the filesystem. 408 | 409 | """ 410 | return round((self.used / self.total) * 100) 411 | 412 | 413 | @dataclass(kw_only=True) 414 | class Info(BaseModel): # pylint: disable=too-many-instance-attributes 415 | """Object holding information from WLED.""" 416 | 417 | architecture: str = field(default="unknown", metadata=field_options(alias="arch")) 418 | """Name of the platform.""" 419 | 420 | arduino_core_version: str = field( 421 | default="Unknown", metadata=field_options(alias="core") 422 | ) 423 | """Version of the underlying (Arduino core) SDK.""" 424 | 425 | brand: str = "WLED" 426 | """The producer/vendor of the light. Always WLED for standard installations.""" 427 | 428 | build: str = field(default="Unknown", metadata=field_options(alias="vid")) 429 | """Build ID (YYMMDDB, B = daily build index).""" 430 | 431 | effect_count: int = field(default=0, metadata=field_options(alias="fxcount")) 432 | """Number of effects included.""" 433 | 434 | filesystem: Filesystem = field(metadata=field_options(alias="fs")) 435 | """Info about the embedded LittleFS filesystem.""" 436 | 437 | free_heap: int = field(default=0, metadata=field_options(alias="freeheap")) 438 | """Bytes of heap memory (RAM) currently available. Problematic if <10k.""" 439 | 440 | ip: str = "" # pylint: disable=invalid-name 441 | """The IP address of this instance. Empty string if not connected.""" 442 | 443 | leds: Leds = field(default_factory=Leds) 444 | """Contains info about the LED setup.""" 445 | 446 | live_ip: str = field(default="Unknown", metadata=field_options(alias="lip")) 447 | """Realtime data source IP address.""" 448 | 449 | live_mode: str = field(default="Unknown", metadata=field_options(alias="lm")) 450 | """Info about the realtime data source.""" 451 | 452 | live: bool = False 453 | """Realtime data source active via UDP or E1.31.""" 454 | 455 | mac_address: str = field(default="", metadata=field_options(alias="mac")) 456 | """ 457 | The hexadecimal hardware MAC address of the light, 458 | lowercase and without colons. 459 | """ 460 | 461 | name: str = "WLED Light" 462 | """Friendly name of the light. Intended for display in lists and titles.""" 463 | 464 | palette_count: int = field(default=0, metadata=field_options(alias="palcount")) 465 | """Number of palettes configured.""" 466 | 467 | product: str = "DIY Light" 468 | """The product name. Always FOSS for standard installations.""" 469 | 470 | udp_port: int = field(default=0, metadata=field_options(alias="udpport")) 471 | """The UDP port for realtime packets and WLED broadcast.""" 472 | 473 | uptime: timedelta = timedelta(0) 474 | """Uptime of the device.""" 475 | 476 | version: AwesomeVersion | None = field( 477 | default=None, metadata=field_options(alias="ver") 478 | ) 479 | """Version of the WLED software.""" 480 | 481 | websocket: int | None = field(default=None, metadata=field_options(alias="ws")) 482 | """ 483 | Number of currently connected WebSockets clients. 484 | `None` indicates that WebSockets are unsupported in this build. 485 | """ 486 | 487 | wifi: Wifi | None = None 488 | """Info about the Wi-Fi connection.""" 489 | 490 | @classmethod 491 | def __post_deserialize__(cls, obj: Info) -> Info: 492 | """Post deserialize hook for Info object.""" 493 | # If the websocket is disabled in this build, the value will be -1. 494 | # We want to represent this as None. 495 | if obj.websocket == -1: 496 | obj.websocket = None 497 | 498 | # We want the architecture in lower case 499 | obj.architecture = obj.architecture.lower() 500 | 501 | # We can tweak the architecture name based on the filesystem size. 502 | if obj.filesystem is not None and obj.architecture == "esp8266": 503 | if obj.filesystem.total <= 256: 504 | obj.architecture = "esp01" 505 | elif obj.filesystem.total <= 512: 506 | obj.architecture = "esp02" 507 | 508 | return obj 509 | 510 | 511 | @dataclass(kw_only=True) 512 | class State(BaseModel): 513 | """Object holding the state of WLED.""" 514 | 515 | brightness: int = field(default=1, metadata=field_options(alias="bri")) 516 | """Brightness of the light. 517 | 518 | If on is false, contains last brightness when light was on (aka brightness 519 | when on is set to true). Setting bri to 0 is supported but it is 520 | recommended to use the range 1-255 and use on: false to turn off. 521 | 522 | The state response will never have the value 0 for bri. 523 | """ 524 | 525 | nightlight: Nightlight = field(metadata=field_options(alias="nl")) 526 | """Nightlight state.""" 527 | 528 | on: bool = False 529 | """The on/off state of the light.""" 530 | 531 | playlist_id: int | None = field(default=-1, metadata=field_options(alias="pl")) 532 | """ID of currently set playlist..""" 533 | 534 | preset_id: int | None = field(default=-1, metadata=field_options(alias="ps")) 535 | """ID of currently set preset.""" 536 | 537 | segments: dict[int, Segment] = field( 538 | default_factory=dict, metadata=field_options(alias="seg") 539 | ) 540 | """Segments are individual parts of the LED strip.""" 541 | 542 | sync: UDPSync = field(metadata=field_options(alias="udpn")) 543 | """UDP sync state.""" 544 | 545 | transition: int = 0 546 | """Duration of the crossfade between different colors/brightness levels. 547 | 548 | One unit is 100ms, so a value of 4 results in atransition of 400ms. 549 | """ 550 | 551 | live_data_override: LiveDataOverride = field(metadata=field_options(alias="lor")) 552 | """Live data override. 553 | 554 | 0 is off, 1 is override until live data ends, 2 is override until ESP reboot. 555 | """ 556 | 557 | @classmethod 558 | def __pre_deserialize__(cls, d: dict[Any, Any]) -> dict[Any, Any]: 559 | """Pre deserialize hook for State object.""" 560 | # Segments are not indexes, which is suboptimal for the user. 561 | # We will add the segment ID to the segment data and convert 562 | # the segments list to an indexed dict. 563 | d["seg"] = { 564 | segment_id: segment | {"id": segment_id} 565 | for segment_id, segment in enumerate(d.get("seg", [])) 566 | } 567 | return d 568 | 569 | @classmethod 570 | def __post_deserialize__(cls, obj: State) -> State: 571 | """Post deserialize hook for State object.""" 572 | # If no playlist is active, the value will be -1. We want to represent 573 | # this as None. 574 | if obj.playlist_id == -1: 575 | obj.playlist_id = None 576 | 577 | # If no preset is active, the value will be -1. We want to represent 578 | # this as None. 579 | if obj.preset_id == -1: 580 | obj.preset_id = None 581 | 582 | return obj 583 | 584 | 585 | @dataclass(kw_only=True) 586 | class Preset(BaseModel): 587 | """Object representing a WLED preset.""" 588 | 589 | preset_id: int 590 | """The ID of the preset.""" 591 | 592 | name: str = field(default="", metadata=field_options(alias="n")) 593 | """The name of the preset.""" 594 | 595 | quick_label: str | None = field(default=None, metadata=field_options(alias="ql")) 596 | """The quick label of the preset.""" 597 | 598 | on: bool = False 599 | """The on/off state of the preset.""" 600 | 601 | transition: int = 0 602 | """Duration of the crossfade between different colors/brightness levels. 603 | 604 | One unit is 100ms, so a value of 4 results in atransition of 400ms. 605 | """ 606 | 607 | main_segment_id: int = field(default=0, metadata=field_options(alias="mainseg")) 608 | """The main segment of the preset.""" 609 | 610 | segments: list[Segment] = field( 611 | default_factory=list, metadata=field_options(alias="seg") 612 | ) 613 | """Segments are individual parts of the LED strip.""" 614 | 615 | @classmethod 616 | def __pre_deserialize__(cls, d: dict[Any, Any]) -> dict[Any, Any]: 617 | """Pre deserialize hook for Preset object.""" 618 | # If the segment is a single value, we will convert it to a list. 619 | if "seg" in d and not isinstance(d["seg"], list): 620 | d["seg"] = [d["seg"]] 621 | 622 | return d 623 | 624 | @classmethod 625 | def __post_deserialize__(cls, obj: Preset) -> Preset: 626 | """Post deserialize hook for Preset object.""" 627 | # If name is empty, we will replace it with the playlist ID. 628 | if not obj.name: 629 | obj.name = str(obj.preset_id) 630 | return obj 631 | 632 | 633 | @dataclass(frozen=True, kw_only=True) 634 | class PlaylistEntry(BaseModel): 635 | """Object representing a entry in a WLED playlist.""" 636 | 637 | duration: int = field(metadata=field_options(alias="dur")) 638 | entry_id: int 639 | preset: int = field(metadata=field_options(alias="ps")) 640 | transition: int 641 | 642 | 643 | @dataclass(kw_only=True) 644 | class Playlist(BaseModel): 645 | """Object representing a WLED playlist.""" 646 | 647 | end_preset_id: int | None = field(default=None, metadata=field_options(alias="end")) 648 | """Single preset ID to apply after the playlist finished. 649 | 650 | Has no effect when an indefinite cycle is set. If not provided, 651 | the light will stay on the last preset of the playlist. 652 | """ 653 | 654 | entries: list[PlaylistEntry] 655 | """List of entries in the playlist.""" 656 | 657 | name: str = field(default="", metadata=field_options(alias="n")) 658 | """The name of the playlist.""" 659 | 660 | playlist_id: int 661 | """The ID of the playlist.""" 662 | 663 | repeat: int = 0 664 | """Number of times the playlist should repeat.""" 665 | 666 | shuffle: bool = field(default=False, metadata=field_options(alias="r")) 667 | """Shuffle the playlist entries.""" 668 | 669 | @classmethod 670 | def __pre_deserialize__(cls, d: dict[Any, Any]) -> dict[Any, Any]: 671 | """Pre deserialize hook for State object.""" 672 | d |= d["playlist"] 673 | # Duration, presets and transitions values are separate lists stored 674 | # in the playlist data. We will combine those into a list of 675 | # dictionaries, which will make it easier to work with the data. 676 | item_count = len(d.get("ps", [])) 677 | 678 | # If the duration is a single value, we will convert it to a list. 679 | # with the same length as the presets list. 680 | if not isinstance(d["dur"], list): 681 | d["dur"] = [d["dur"]] * item_count 682 | 683 | # If the transition value doesn't exists, we will set it to 0. 684 | if "transitions" not in d: 685 | d["transitions"] = [0] * item_count 686 | # If the transition is a single value, we will convert it to a list. 687 | # with the same length as the presets list. 688 | elif not isinstance(d["transitions"], list): 689 | d["transitions"] = [d["transitions"]] * item_count 690 | 691 | # Now we can easily combine the data into a list of dictionaries. 692 | d["entries"] = [ 693 | { 694 | "entry_id": entry_id, 695 | "ps": ps, 696 | "dur": dur, 697 | "transition": transition, 698 | } 699 | for entry_id, (ps, dur, transition) in enumerate( 700 | zip(d["ps"], d["dur"], d["transitions"]) 701 | ) 702 | ] 703 | 704 | return d 705 | 706 | @classmethod 707 | def __post_deserialize__(cls, obj: Playlist) -> Playlist: 708 | """Post deserialize hook for Playlist object.""" 709 | # If name is empty, we will replace it with the playlist ID. 710 | if not obj.name: 711 | obj.name = str(obj.playlist_id) 712 | return obj 713 | 714 | 715 | @dataclass(kw_only=True) 716 | class Device(BaseModel): 717 | """Object holding all information of WLED.""" 718 | 719 | info: Info 720 | state: State 721 | 722 | effects: dict[int, Effect] = field(default_factory=dict) 723 | palettes: dict[int, Palette] = field(default_factory=dict) 724 | playlists: dict[int, Playlist] = field(default_factory=dict) 725 | presets: dict[int, Preset] = field(default_factory=dict) 726 | 727 | @classmethod 728 | def __pre_deserialize__(cls, d: dict[Any, Any]) -> dict[Any, Any]: 729 | """Pre deserialize hook for Device object.""" 730 | if (version := d.get("info", {}).get("ver")) and version < MIN_REQUIRED_VERSION: 731 | msg = ( 732 | f"Unsupported firmware version {version}. " 733 | f"Minimum required version is {MIN_REQUIRED_VERSION}. " 734 | f"Please update your WLED device." 735 | ) 736 | raise WLEDUnsupportedVersionError(msg) 737 | 738 | if _effects := d.get("effects"): 739 | d["effects"] = { 740 | effect_id: {"effect_id": effect_id, "name": name} 741 | for effect_id, name in enumerate(_effects) 742 | } 743 | 744 | if _palettes := d.get("palettes"): 745 | d["palettes"] = { 746 | palette_id: {"palette_id": palette_id, "name": name} 747 | for palette_id, name in enumerate(_palettes) 748 | } 749 | elif _palettes is None: 750 | # Some less capable devices don't have palettes and 751 | # will return `null`. 752 | # Refs: 753 | # - https://github.com/home-assistant/core/issues/123506 754 | # - https://github.com/Aircoookie/WLED/issues/1974 755 | d["palettes"] = {} 756 | 757 | if _presets := d.get("presets"): 758 | _presets = _presets.copy() 759 | # The preset data contains both presets and playlists, 760 | # we split those out, so we can handle those correctly. 761 | d["presets"] = { 762 | int(preset_id): preset | {"preset_id": int(preset_id)} 763 | for preset_id, preset in _presets.items() 764 | if "playlist" not in preset 765 | or "ps" not in preset["playlist"] 766 | or not preset["playlist"]["ps"] 767 | } 768 | # Nobody cares about 0. 769 | d["presets"].pop(0, None) 770 | 771 | d["playlists"] = { 772 | int(playlist_id): playlist | {"playlist_id": int(playlist_id)} 773 | for playlist_id, playlist in _presets.items() 774 | if "playlist" in playlist 775 | and "ps" in playlist["playlist"] 776 | and playlist["playlist"]["ps"] 777 | } 778 | # Nobody cares about 0. 779 | d["playlists"].pop(0, None) 780 | 781 | return d 782 | 783 | def update_from_dict(self, data: dict[str, Any]) -> Device: 784 | """Return Device object from WLED API response. 785 | 786 | Args: 787 | ---- 788 | data: Update the device object with the data received from a 789 | WLED device API. 790 | 791 | Returns: 792 | ------- 793 | The updated Device object. 794 | 795 | """ 796 | if _effects := data.get("effects"): 797 | self.effects = { 798 | effect_id: Effect(effect_id=effect_id, name=name) 799 | for effect_id, name in enumerate(_effects) 800 | } 801 | 802 | if _palettes := data.get("palettes"): 803 | self.palettes = { 804 | palette_id: Palette(palette_id=palette_id, name=name) 805 | for palette_id, name in enumerate(_palettes) 806 | } 807 | 808 | if _presets := data.get("presets"): 809 | # The preset data contains both presets and playlists, 810 | # we split those out, so we can handle those correctly. 811 | self.presets = { 812 | int(preset_id): Preset.from_dict( 813 | preset | {"preset_id": int(preset_id)}, 814 | ) 815 | for preset_id, preset in _presets.items() 816 | if "playlist" not in preset 817 | or "ps" not in preset["playlist"] 818 | or not preset["playlist"]["ps"] 819 | } 820 | # Nobody cares about 0. 821 | self.presets.pop(0, None) 822 | 823 | self.playlists = { 824 | int(playlist_id): Playlist.from_dict( 825 | playlist | {"playlist_id": int(playlist_id)} 826 | ) 827 | for playlist_id, playlist in _presets.items() 828 | if "playlist" in playlist 829 | and "ps" in playlist["playlist"] 830 | and playlist["playlist"]["ps"] 831 | } 832 | # Nobody cares about 0. 833 | self.playlists.pop(0, None) 834 | 835 | if _info := data.get("info"): 836 | self.info = Info.from_dict(_info) 837 | 838 | if _state := data.get("state"): 839 | self.state = State.from_dict(_state) 840 | 841 | return self 842 | 843 | 844 | @dataclass(frozen=True, kw_only=True) 845 | class Releases(BaseModel): 846 | """Object holding WLED releases information.""" 847 | 848 | beta: AwesomeVersion | None 849 | stable: AwesomeVersion | None 850 | -------------------------------------------------------------------------------- /src/wled/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frenck/python-wled/25f5199b0a64e3cfe41e717a6330af0cfc1428e1/src/wled/py.typed -------------------------------------------------------------------------------- /src/wled/utils.py: -------------------------------------------------------------------------------- 1 | """Asynchronous Python client for WLED.""" 2 | 3 | from functools import lru_cache 4 | 5 | from awesomeversion import AwesomeVersion 6 | 7 | 8 | @lru_cache 9 | def get_awesome_version(version: str) -> AwesomeVersion: 10 | """Return a cached AwesomeVersion object.""" 11 | return AwesomeVersion(version) 12 | -------------------------------------------------------------------------------- /src/wled/wled.py: -------------------------------------------------------------------------------- 1 | """Asynchronous Python client for WLED.""" 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | import socket 7 | from dataclasses import dataclass 8 | from typing import TYPE_CHECKING, Any, Self 9 | 10 | import aiohttp 11 | import backoff 12 | import orjson 13 | from yarl import URL 14 | 15 | from .exceptions import ( 16 | WLEDConnectionClosedError, 17 | WLEDConnectionError, 18 | WLEDConnectionTimeoutError, 19 | WLEDEmptyResponseError, 20 | WLEDError, 21 | WLEDUpgradeError, 22 | ) 23 | from .models import Device, Playlist, Preset, Releases 24 | 25 | if TYPE_CHECKING: 26 | from collections.abc import Callable, Sequence 27 | 28 | from awesomeversion import AwesomeVersion 29 | 30 | from .const import LiveDataOverride 31 | 32 | 33 | @dataclass 34 | class WLED: 35 | """Main class for handling connections with WLED.""" 36 | 37 | host: str 38 | request_timeout: float = 8.0 39 | session: aiohttp.client.ClientSession | None = None 40 | 41 | _client: aiohttp.ClientWebSocketResponse | None = None 42 | _close_session: bool = False 43 | _device: Device | None = None 44 | 45 | @property 46 | def connected(self) -> bool: 47 | """Return if we are connect to the WebSocket of a WLED device. 48 | 49 | Returns 50 | ------- 51 | True if we are connected to the WebSocket of a WLED device, 52 | False otherwise. 53 | 54 | """ 55 | return self._client is not None and not self._client.closed 56 | 57 | async def connect(self) -> None: 58 | """Connect to the WebSocket of a WLED device. 59 | 60 | Raises 61 | ------ 62 | WLEDError: The configured WLED device, does not support WebSocket 63 | communications. 64 | WLEDConnectionError: Error occurred while communicating with 65 | the WLED device via the WebSocket. 66 | 67 | """ 68 | if self.connected: 69 | return 70 | 71 | if not self._device: 72 | await self.update() 73 | 74 | if not self.session or not self._device or self._device.info.websocket is None: 75 | msg = "The WLED device at {self.host} does not support WebSockets" 76 | raise WLEDError(msg) 77 | 78 | url = URL.build(scheme="ws", host=self.host, port=80, path="/ws") 79 | 80 | try: 81 | self._client = await self.session.ws_connect(url=url, heartbeat=30) 82 | except ( 83 | aiohttp.WSServerHandshakeError, 84 | aiohttp.ClientConnectionError, 85 | socket.gaierror, 86 | ) as exception: 87 | msg = ( 88 | "Error occurred while communicating with WLED device" 89 | f" on WebSocket at {self.host}" 90 | ) 91 | raise WLEDConnectionError(msg) from exception 92 | 93 | async def listen(self, callback: Callable[[Device], None]) -> None: 94 | """Listen for events on the WLED WebSocket. 95 | 96 | Args: 97 | ---- 98 | callback: Method to call when a state update is received from 99 | the WLED device. 100 | 101 | Raises: 102 | ------ 103 | WLEDError: Not connected to a WebSocket. 104 | WLEDConnectionError: An connection error occurred while connected 105 | to the WLED device. 106 | WLEDConnectionClosedError: The WebSocket connection to the remote WLED 107 | has been closed. 108 | 109 | """ 110 | if not self._client or not self.connected or not self._device: 111 | msg = "Not connected to a WLED WebSocket" 112 | raise WLEDError(msg) 113 | 114 | while not self._client.closed: 115 | message = await self._client.receive() 116 | 117 | if message.type == aiohttp.WSMsgType.ERROR: 118 | raise WLEDConnectionError(self._client.exception()) 119 | 120 | if message.type == aiohttp.WSMsgType.TEXT: 121 | message_data = message.json() 122 | device = self._device.update_from_dict(data=message_data) 123 | callback(device) 124 | 125 | if message.type in ( 126 | aiohttp.WSMsgType.CLOSE, 127 | aiohttp.WSMsgType.CLOSED, 128 | aiohttp.WSMsgType.CLOSING, 129 | ): 130 | msg = f"Connection to the WLED WebSocket on {self.host} has been closed" 131 | raise WLEDConnectionClosedError(msg) 132 | 133 | async def disconnect(self) -> None: 134 | """Disconnect from the WebSocket of a WLED device.""" 135 | if not self._client or not self.connected: 136 | return 137 | 138 | await self._client.close() 139 | 140 | @backoff.on_exception(backoff.expo, WLEDConnectionError, max_tries=3, logger=None) 141 | async def request( 142 | self, 143 | uri: str = "", 144 | method: str = "GET", 145 | data: dict[str, Any] | None = None, 146 | ) -> Any: 147 | """Handle a request to a WLED device. 148 | 149 | A generic method for sending/handling HTTP requests done gainst 150 | the WLED device. 151 | 152 | Args: 153 | ---- 154 | uri: Request URI, for example `/json/si`. 155 | method: HTTP method to use for the request.E.g., "GET" or "POST". 156 | data: Dictionary of data to send to the WLED device. 157 | 158 | Returns: 159 | ------- 160 | A Python dictionary (JSON decoded) with the response from the 161 | WLED device. 162 | 163 | Raises: 164 | ------ 165 | WLEDConnectionError: An error occurred while communication with 166 | the WLED device. 167 | WLEDConnectionTimeoutError: A timeout occurred while communicating 168 | with the WLED device. 169 | WLEDError: Received an unexpected response from the WLED device. 170 | 171 | """ 172 | url = URL.build(scheme="http", host=self.host, port=80, path=uri) 173 | 174 | headers = { 175 | "Accept": "application/json, text/plain, */*", 176 | } 177 | 178 | if self.session is None: 179 | self.session = aiohttp.ClientSession() 180 | self._close_session = True 181 | 182 | # If updating the state, always request for a state response 183 | if method == "POST" and uri == "/json/state" and data is not None: 184 | data["v"] = True 185 | 186 | try: 187 | async with asyncio.timeout(self.request_timeout): 188 | response = await self.session.request( 189 | method, 190 | url, 191 | json=data, 192 | headers=headers, 193 | ) 194 | 195 | content_type = response.headers.get("Content-Type", "") 196 | if response.status // 100 in [4, 5]: 197 | contents = await response.read() 198 | response.close() 199 | 200 | if content_type == "application/json": 201 | raise WLEDError( 202 | response.status, 203 | orjson.loads(contents), 204 | ) 205 | raise WLEDError( 206 | response.status, 207 | {"message": contents.decode("utf8")}, 208 | ) 209 | 210 | response_data = await response.text() 211 | if "application/json" in content_type: 212 | response_data = orjson.loads(response_data) 213 | 214 | except asyncio.TimeoutError as exception: 215 | msg = f"Timeout occurred while connecting to WLED device at {self.host}" 216 | raise WLEDConnectionTimeoutError(msg) from exception 217 | except (aiohttp.ClientError, socket.gaierror) as exception: 218 | msg = f"Error occurred while communicating with WLED device at {self.host}" 219 | raise WLEDConnectionError(msg) from exception 220 | 221 | if "application/json" in content_type and ( 222 | method == "POST" 223 | and uri == "/json/state" 224 | and self._device is not None 225 | and data is not None 226 | ): 227 | self._device.update_from_dict(data={"state": response_data}) 228 | 229 | return response_data 230 | 231 | @backoff.on_exception( 232 | backoff.expo, 233 | WLEDEmptyResponseError, 234 | max_tries=3, 235 | logger=None, 236 | ) 237 | async def update(self) -> Device: 238 | """Get all information about the device in a single call. 239 | 240 | This method updates all WLED information available with a single API 241 | call. 242 | 243 | Returns 244 | ------- 245 | WLED Device data. 246 | 247 | Raises 248 | ------ 249 | WLEDEmptyResponseError: The WLED device returned an empty response. 250 | 251 | """ 252 | if not (data := await self.request("/json")): 253 | msg = ( 254 | f"WLED device at {self.host} returned an empty API" 255 | " response on full update", 256 | ) 257 | raise WLEDEmptyResponseError(msg) 258 | 259 | if not (presets := await self.request("/presets.json")): 260 | msg = ( 261 | f"WLED device at {self.host} returned an empty API" 262 | " response on presets update", 263 | ) 264 | raise WLEDEmptyResponseError(msg) 265 | data["presets"] = presets 266 | 267 | if not self._device: 268 | self._device = Device.from_dict(data) 269 | else: 270 | self._device.update_from_dict(data) 271 | 272 | return self._device 273 | 274 | async def master( 275 | self, 276 | *, 277 | brightness: int | None = None, 278 | on: bool | None = None, 279 | transition: int | None = None, 280 | ) -> None: 281 | """Change master state of a WLED Light device. 282 | 283 | Args: 284 | ---- 285 | brightness: The brightness of the light master, between 0 and 255. 286 | on: A boolean, true to turn the master light on, false otherwise. 287 | transition: Duration of the crossfade between different 288 | colors/brightness levels. One unit is 100ms, so a value of 4 289 | results in a transition of 400ms. 290 | 291 | """ 292 | state: dict[str, bool | int] = {} 293 | 294 | if brightness is not None: 295 | state["bri"] = brightness 296 | 297 | if on is not None: 298 | state["on"] = on 299 | 300 | if transition is not None: 301 | state["tt"] = transition 302 | 303 | await self.request("/json/state", method="POST", data=state) 304 | 305 | # pylint: disable=too-many-locals, too-many-branches, too-many-arguments 306 | async def segment( # noqa: PLR0912, PLR0913 307 | self, 308 | segment_id: int, 309 | *, 310 | brightness: int | None = None, 311 | clones: int | None = None, 312 | color_primary: tuple[int, int, int, int] | tuple[int, int, int] | None = None, 313 | color_secondary: tuple[int, int, int, int] | tuple[int, int, int] | None = None, 314 | color_tertiary: tuple[int, int, int, int] | tuple[int, int, int] | None = None, 315 | effect: int | str | None = None, 316 | individual: Sequence[ 317 | int | Sequence[int] | tuple[int, int, int, int] | tuple[int, int, int] 318 | ] 319 | | None = None, 320 | intensity: int | None = None, 321 | length: int | None = None, 322 | on: bool | None = None, 323 | palette: int | str | None = None, 324 | reverse: bool | None = None, 325 | selected: bool | None = None, 326 | speed: int | None = None, 327 | start: int | None = None, 328 | stop: int | None = None, 329 | transition: int | None = None, 330 | cct: int | None = None, 331 | ) -> None: 332 | """Change state of a WLED Light segment. 333 | 334 | Args: 335 | ---- 336 | segment_id: The ID of the segment to adjust. 337 | brightness: The brightness of the segment, between 0 and 255. 338 | clones: Deprecated. 339 | color_primary: The primary color of this segment. 340 | color_secondary: The secondary color of this segment. 341 | color_tertiary: The tertiary color of this segment. 342 | effect: The effect number (or name) to use on this segment. 343 | individual: A list of colors to use for each LED in the segment. 344 | intensity: The effect intensity to use on this segment. 345 | length: The length of this segment. 346 | on: A boolean, true to turn this segment on, false otherwise. 347 | palette: the palette number or name to use on this segment. 348 | reverse: Flips the segment, causing animations to change direction. 349 | selected: Selected segments will have their state (color/FX) updated 350 | by APIs that don't support segments. 351 | speed: The relative effect speed, between 0 and 255. 352 | start: LED the segment starts at. 353 | stop: LED the segment stops at, not included in range. If stop is 354 | set to a lower or equal value than start (setting to 0 is 355 | recommended), the segment is invalidated and deleted. 356 | transition: Duration of the crossfade between different 357 | colors/brightness levels. One unit is 100ms, so a value of 4 358 | results in a transition of 400ms. 359 | cct: White spectrum color temperature. 360 | 361 | Raises: 362 | ------ 363 | WLEDError: Something went wrong setting the segment state. 364 | 365 | """ 366 | if self._device is None: 367 | await self.update() 368 | 369 | if self._device is None: 370 | msg = "Unable to communicate with WLED to get the current state" 371 | raise WLEDError(msg) 372 | 373 | state = {} # type: ignore[var-annotated] 374 | segment = { 375 | "bri": brightness, 376 | "cln": clones, 377 | "fx": effect, 378 | "i": individual, 379 | "ix": intensity, 380 | "len": length, 381 | "on": on, 382 | "pal": palette, 383 | "rev": reverse, 384 | "sel": selected, 385 | "start": start, 386 | "stop": stop, 387 | "sx": speed, 388 | "cct": cct, 389 | } 390 | 391 | # Find effect if it was based on a name 392 | if effect is not None and isinstance(effect, str): 393 | segment["fx"] = next( 394 | ( 395 | item.effect_id 396 | for item in self._device.effects.values() 397 | if item.name.lower() == effect.lower() 398 | ), 399 | None, 400 | ) 401 | 402 | # Find palette if it was based on a name 403 | if palette is not None and isinstance(palette, str): 404 | segment["pal"] = next( 405 | ( 406 | item.palette_id 407 | for item in self._device.palettes.values() 408 | if item.name.lower() == palette.lower() 409 | ), 410 | None, 411 | ) 412 | 413 | # Filter out not set values 414 | state = {k: v for k, v in state.items() if v is not None} 415 | segment = {k: v for k, v in segment.items() if v is not None} 416 | 417 | # Determine color set 418 | colors = [] 419 | if color_primary is not None: 420 | colors.append(color_primary) 421 | elif color_secondary is not None or color_tertiary is not None: 422 | if clrs := self._device.state.segments[segment_id].color: 423 | colors.append(clrs.primary) 424 | else: 425 | colors.append((0, 0, 0)) 426 | 427 | if color_secondary is not None: 428 | colors.append(color_secondary) 429 | elif color_tertiary is not None: 430 | if ( 431 | clrs := self._device.state.segments[segment_id].color 432 | ) and clrs.secondary: 433 | colors.append(clrs.secondary) 434 | else: 435 | colors.append((0, 0, 0)) 436 | 437 | if color_tertiary is not None: 438 | colors.append(color_tertiary) 439 | 440 | if colors: 441 | segment["col"] = colors 442 | 443 | if segment: 444 | segment["id"] = segment_id 445 | state["seg"] = [segment] 446 | 447 | if transition is not None: 448 | state["tt"] = transition 449 | 450 | await self.request("/json/state", method="POST", data=state) 451 | 452 | async def transition(self, transition: int) -> None: 453 | """Set the default transition time for manual control. 454 | 455 | Args: 456 | ---- 457 | transition: Duration of the default crossfade between different 458 | colors/brightness levels. One unit is 100ms, so a value of 4 459 | results in a transition of 400ms. 460 | 461 | """ 462 | await self.request( 463 | "/json/state", 464 | method="POST", 465 | data={"transition": transition}, 466 | ) 467 | 468 | async def preset(self, preset: int | str | Preset) -> None: 469 | """Set a preset on a WLED device. 470 | 471 | Args: 472 | ---- 473 | preset: The preset to activate on this WLED device. 474 | 475 | """ 476 | # Find preset if it was based on a name 477 | if self._device and self._device.presets and isinstance(preset, str): 478 | preset = next( 479 | ( 480 | item.preset_id 481 | for item in self._device.presets.values() 482 | if item.name.lower() == preset.lower() 483 | ), 484 | preset, 485 | ) 486 | 487 | if isinstance(preset, Preset): 488 | preset = preset.preset_id 489 | 490 | await self.request("/json/state", method="POST", data={"ps": preset}) 491 | 492 | async def playlist(self, playlist: int | str | Playlist) -> None: 493 | """Set a playlist on a WLED device. 494 | 495 | Args: 496 | ---- 497 | playlist: The playlist to activate on this WLED device. 498 | 499 | """ 500 | # Find playlist if it was based on a name 501 | if self._device and self._device.playlists and isinstance(playlist, str): 502 | playlist = next( 503 | ( 504 | item.playlist_id 505 | for item in self._device.playlists.values() 506 | if item.name.lower() == playlist.lower() 507 | ), 508 | playlist, 509 | ) 510 | 511 | if isinstance(playlist, Playlist): 512 | playlist = playlist.playlist_id 513 | 514 | await self.request("/json/state", method="POST", data={"ps": playlist}) 515 | 516 | async def live(self, live: LiveDataOverride) -> None: 517 | """Set the live override mode on a WLED device. 518 | 519 | Args: 520 | ---- 521 | live: The live override mode to set on this WLED device. 522 | 523 | """ 524 | await self.request("/json/state", method="POST", data={"lor": live.value}) 525 | 526 | async def sync( 527 | self, 528 | *, 529 | send: bool | None = None, 530 | receive: bool | None = None, 531 | ) -> None: 532 | """Set the sync status of the WLED device. 533 | 534 | Args: 535 | ---- 536 | send: Send WLED broadcast (UDP sync) packet on state change. 537 | receive: Receive broadcast packets. 538 | 539 | """ 540 | sync = {"send": send, "recv": receive} 541 | sync = {k: v for k, v in sync.items() if v is not None} 542 | await self.request("/json/state", method="POST", data={"udpn": sync}) 543 | 544 | async def nightlight( 545 | self, 546 | *, 547 | duration: int | None = None, 548 | fade: bool | None = None, 549 | on: bool | None = None, 550 | target_brightness: int | None = None, 551 | ) -> None: 552 | """Control the nightlight function of a WLED device. 553 | 554 | Args: 555 | ---- 556 | duration: Duration of nightlight in minutes. 557 | fade: If true, the light will gradually dim over the course of the 558 | nightlight duration. If false, it will instantly turn to the 559 | target brightness once the duration has elapsed. 560 | on: A boolean, true to turn the nightlight on, false otherwise. 561 | target_brightness: Target brightness of nightlight, between 0 and 255. 562 | 563 | """ 564 | nightlight = { 565 | "dur": duration, 566 | "fade": fade, 567 | "on": on, 568 | "tbri": target_brightness, 569 | } 570 | 571 | # Filter out not set values 572 | nightlight = {k: v for k, v in nightlight.items() if v is not None} 573 | await self.request("/json/state", method="POST", data={"nl": nightlight}) 574 | 575 | async def upgrade(self, *, version: str | AwesomeVersion) -> None: 576 | """Upgrades WLED device to the specified version. 577 | 578 | Args: 579 | ---- 580 | version: The version to upgrade to. 581 | 582 | Raises: 583 | ------ 584 | WLEDUpgradeError: If the upgrade has failed. 585 | WLEDConnectionTimeoutError: When a connection timeout occurs. 586 | WLEDConnectionError: When a connection error occurs. 587 | 588 | """ 589 | if self._device is None: 590 | await self.update() 591 | 592 | if self.session is None or self._device is None: 593 | msg = "Unexpected upgrade error; No session or device" 594 | raise WLEDUpgradeError(msg) 595 | 596 | if self._device.info.architecture not in { 597 | "esp01", 598 | "esp02", 599 | "esp32", 600 | "esp8266", 601 | "esp32-c3", 602 | "esp32-s2", 603 | "esp32-s3", 604 | }: 605 | msg = ( 606 | "Upgrade is only supported on ESP01, ESP02, ESP32, ESP8266, " 607 | "ESP32-C3, ESP32-S2, and ESP32-S3 devices" 608 | ) 609 | raise WLEDUpgradeError(msg) 610 | 611 | if not self._device.info.version: 612 | msg = "Current version is unknown, cannot perform upgrade" 613 | raise WLEDUpgradeError(msg) 614 | 615 | if self._device.info.version == version: 616 | msg = "Device already running the requested version" 617 | raise WLEDUpgradeError(msg) 618 | 619 | # Determine if this is an Ethernet board 620 | ethernet = "" 621 | if ( 622 | self._device.info.architecture == "esp32" 623 | and self._device.info.wifi is not None 624 | and not self._device.info.wifi.bssid 625 | and self._device.info.version 626 | and self._device.info.version >= "0.10.0" 627 | ): 628 | ethernet = "_Ethernet" 629 | 630 | # Determine if this is a 2M ESP8266 board. 631 | # See issue `https://github.com/Aircoookie/WLED/issues/3257` 632 | gzip = "" 633 | if self._device.info.architecture == "esp02": 634 | gzip = ".gz" 635 | 636 | url = URL.build(scheme="http", host=self.host, port=80, path="/update") 637 | architecture = self._device.info.architecture.upper() 638 | update_file = f"WLED_{version}_{architecture}{ethernet}.bin{gzip}" 639 | download_url = ( 640 | "https://github.com/Aircoookie/WLED/releases/download" 641 | f"/v{version}/{update_file}" 642 | ) 643 | 644 | try: 645 | async with ( 646 | asyncio.timeout( 647 | self.request_timeout * 10, 648 | ), 649 | self.session.get( 650 | download_url, 651 | raise_for_status=True, 652 | ) as download, 653 | ): 654 | form = aiohttp.FormData() 655 | form.add_field("file", await download.read(), filename=update_file) 656 | await self.session.post(url, data=form) 657 | except asyncio.TimeoutError as exception: 658 | msg = "Timeout occurred while fetching WLED version information from GitHub" 659 | raise WLEDConnectionTimeoutError(msg) from exception 660 | except aiohttp.ClientResponseError as exception: 661 | if exception.status == 404: 662 | msg = f"Requested WLED version '{version}' does not exists" 663 | raise WLEDUpgradeError(msg) from exception 664 | msg = ( 665 | f"Could not download requested WLED version '{version}'" 666 | f" from {download_url}" 667 | ) 668 | raise WLEDUpgradeError(msg) from exception 669 | except (aiohttp.ClientError, socket.gaierror) as exception: 670 | msg = ( 671 | "Timeout occurred while communicating with GitHub" 672 | " for WLED version information" 673 | ) 674 | raise WLEDConnectionError(msg) from exception 675 | 676 | async def reset(self) -> None: 677 | """Reboot WLED device.""" 678 | await self.request("/reset") 679 | 680 | async def close(self) -> None: 681 | """Close open client (WebSocket) session.""" 682 | await self.disconnect() 683 | if self.session and self._close_session: 684 | await self.session.close() 685 | 686 | async def __aenter__(self) -> Self: 687 | """Async enter. 688 | 689 | Returns 690 | ------- 691 | The WLED object. 692 | 693 | """ 694 | return self 695 | 696 | async def __aexit__(self, *_exc_info: object) -> None: 697 | """Async exit. 698 | 699 | Args: 700 | ---- 701 | _exc_info: Exec type. 702 | 703 | """ 704 | await self.close() 705 | 706 | 707 | @dataclass 708 | class WLEDReleases: 709 | """Get version information for WLED.""" 710 | 711 | request_timeout: float = 8.0 712 | session: aiohttp.client.ClientSession | None = None 713 | 714 | _client: aiohttp.ClientWebSocketResponse | None = None 715 | _close_session: bool = False 716 | 717 | @backoff.on_exception(backoff.expo, WLEDConnectionError, max_tries=3, logger=None) 718 | async def releases(self) -> Releases: 719 | """Fetch WLED version information from GitHub. 720 | 721 | Returns 722 | ------- 723 | A dictionary of WLED versions, with the key being the version type. 724 | 725 | Raises 726 | ------ 727 | WLEDConnectionTimeoutError: Timeout occurred while fetching WLED 728 | version information from GitHub. 729 | WLEDConnectionError: Timeout occurred while communicating with 730 | GitHub for WLED version information. 731 | WLEDError: Didn't get a JSON response from GitHub while retrieving 732 | version information. 733 | 734 | """ 735 | if self.session is None: 736 | self.session = aiohttp.ClientSession() 737 | self._close_session = True 738 | 739 | try: 740 | async with asyncio.timeout(self.request_timeout): 741 | response = await self.session.get( 742 | "https://api.github.com/repos/Aircoookie/WLED/releases", 743 | headers={"Accept": "application/json"}, 744 | ) 745 | except asyncio.TimeoutError as exception: 746 | msg = ( 747 | "Timeout occurred while fetching WLED releases information from GitHub" 748 | ) 749 | raise WLEDConnectionTimeoutError(msg) from exception 750 | except (aiohttp.ClientError, socket.gaierror) as exception: 751 | msg = "Timeout occurred while communicating with GitHub for WLED releases" 752 | raise WLEDConnectionError(msg) from exception 753 | 754 | content_type = response.headers.get("Content-Type", "") 755 | contents = await response.read() 756 | if response.status // 100 in [4, 5]: 757 | response.close() 758 | 759 | if content_type == "application/json": 760 | raise WLEDError(response.status, orjson.loads(contents)) 761 | raise WLEDError(response.status, {"message": contents.decode("utf8")}) 762 | 763 | if "application/json" not in content_type: 764 | msg = "No JSON response from GitHub while retrieving WLED releases" 765 | raise WLEDError(msg) 766 | 767 | releases = orjson.loads(contents) 768 | version_latest = None 769 | version_latest_beta = None 770 | for release in releases: 771 | if ( 772 | release["prerelease"] is False 773 | and "b" not in release["tag_name"].lower() 774 | and version_latest is None 775 | ): 776 | version_latest = release["tag_name"].lstrip("vV") 777 | if ( 778 | release["prerelease"] is True or "b" in release["tag_name"].lower() 779 | ) and version_latest_beta is None: 780 | version_latest_beta = release["tag_name"].lstrip("vV") 781 | if version_latest is not None and version_latest_beta is not None: 782 | break 783 | 784 | return Releases( 785 | beta=version_latest_beta, 786 | stable=version_latest, 787 | ) 788 | 789 | async def close(self) -> None: 790 | """Close open client session.""" 791 | if self.session and self._close_session: 792 | await self.session.close() 793 | 794 | async def __aenter__(self) -> Self: 795 | """Async enter. 796 | 797 | Returns 798 | ------- 799 | The WLEDReleases object. 800 | 801 | """ 802 | return self 803 | 804 | async def __aexit__(self, *_exc_info: object) -> None: 805 | """Async exit. 806 | 807 | Args: 808 | ---- 809 | _exc_info: Exec type. 810 | 811 | """ 812 | await self.close() 813 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Asynchronous Python client for WLED.""" 2 | -------------------------------------------------------------------------------- /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 | "TC002", # pytest doesn't like this one... 12 | ] 13 | -------------------------------------------------------------------------------- /tests/test_wled.py: -------------------------------------------------------------------------------- 1 | """Tests for `wled.WLED`.""" 2 | 3 | import asyncio 4 | 5 | import aiohttp 6 | import pytest 7 | from aresponses import Response, ResponsesMockServer 8 | 9 | from wled import WLED 10 | from wled.exceptions import WLEDConnectionError, WLEDError 11 | 12 | 13 | @pytest.mark.asyncio 14 | async def test_json_request(aresponses: ResponsesMockServer) -> None: 15 | """Test JSON response is handled correctly.""" 16 | aresponses.add( 17 | "example.com", 18 | "/", 19 | "GET", 20 | aresponses.Response( 21 | status=200, 22 | headers={"Content-Type": "application/json"}, 23 | text='{"status": "ok"}', 24 | ), 25 | ) 26 | async with aiohttp.ClientSession() as session: 27 | wled = WLED("example.com", session=session) 28 | response = await wled.request("/") 29 | assert response["status"] == "ok" 30 | 31 | 32 | @pytest.mark.asyncio 33 | async def test_text_request(aresponses: ResponsesMockServer) -> None: 34 | """Test non JSON response is handled correctly.""" 35 | aresponses.add( 36 | "example.com", 37 | "/", 38 | "GET", 39 | aresponses.Response(status=200, text="OK"), 40 | ) 41 | async with aiohttp.ClientSession() as session: 42 | wled = WLED("example.com", session=session) 43 | response = await wled.request("/") 44 | assert response == "OK" 45 | 46 | 47 | @pytest.mark.asyncio 48 | async def test_internal_session(aresponses: ResponsesMockServer) -> None: 49 | """Test JSON response is handled correctly.""" 50 | aresponses.add( 51 | "example.com", 52 | "/", 53 | "GET", 54 | aresponses.Response( 55 | status=200, 56 | headers={"Content-Type": "application/json"}, 57 | text='{"status": "ok"}', 58 | ), 59 | ) 60 | async with WLED("example.com") as wled: 61 | response = await wled.request("/") 62 | assert response["status"] == "ok" 63 | 64 | 65 | @pytest.mark.asyncio 66 | async def test_post_request(aresponses: ResponsesMockServer) -> None: 67 | """Test POST requests are handled correctly.""" 68 | aresponses.add( 69 | "example.com", 70 | "/", 71 | "POST", 72 | aresponses.Response(status=200, text="OK"), 73 | ) 74 | async with aiohttp.ClientSession() as session: 75 | wled = WLED("example.com", session=session) 76 | response = await wled.request("/", method="POST") 77 | assert response == "OK" 78 | 79 | 80 | @pytest.mark.asyncio 81 | async def test_backoff(aresponses: ResponsesMockServer) -> None: 82 | """Test requests are handled with retries.""" 83 | 84 | async def response_handler(_: aiohttp.ClientResponse) -> Response: 85 | """Response handler for this test.""" 86 | await asyncio.sleep(0.2) 87 | return aresponses.Response(body="Goodmorning!") 88 | 89 | aresponses.add( 90 | "example.com", 91 | "/", 92 | "GET", 93 | response_handler, 94 | repeat=2, 95 | ) 96 | aresponses.add( 97 | "example.com", 98 | "/", 99 | "GET", 100 | aresponses.Response(status=200, text="OK"), 101 | ) 102 | 103 | async with aiohttp.ClientSession() as session: 104 | wled = WLED("example.com", session=session, request_timeout=0.1) 105 | response = await wled.request("/") 106 | assert response == "OK" 107 | 108 | 109 | @pytest.mark.asyncio 110 | async def test_timeout(aresponses: ResponsesMockServer) -> None: 111 | """Test request timeout from WLED.""" 112 | 113 | # Faking a timeout by sleeping 114 | async def response_handler(_: aiohttp.ClientResponse) -> Response: 115 | """Response handler for this test.""" 116 | await asyncio.sleep(0.2) 117 | return aresponses.Response(body="Goodmorning!") 118 | 119 | # Backoff will try 3 times 120 | aresponses.add("example.com", "/", "GET", response_handler) 121 | aresponses.add("example.com", "/", "GET", response_handler) 122 | aresponses.add("example.com", "/", "GET", response_handler) 123 | 124 | async with aiohttp.ClientSession() as session: 125 | wled = WLED("example.com", session=session, request_timeout=0.1) 126 | with pytest.raises(WLEDConnectionError): 127 | assert await wled.request("/") 128 | 129 | 130 | @pytest.mark.asyncio 131 | async def test_http_error400(aresponses: ResponsesMockServer) -> None: 132 | """Test HTTP 404 response handling.""" 133 | aresponses.add( 134 | "example.com", 135 | "/", 136 | "GET", 137 | aresponses.Response(text="OMG PUPPIES!", status=404), 138 | ) 139 | 140 | async with aiohttp.ClientSession() as session: 141 | wled = WLED("example.com", session=session) 142 | with pytest.raises(WLEDError): 143 | assert await wled.request("/") 144 | 145 | 146 | @pytest.mark.asyncio 147 | async def test_http_error500(aresponses: ResponsesMockServer) -> None: 148 | """Test HTTP 500 response handling.""" 149 | aresponses.add( 150 | "example.com", 151 | "/", 152 | "GET", 153 | aresponses.Response( 154 | body=b'{"status":"nok"}', 155 | status=500, 156 | headers={"Content-Type": "application/json"}, 157 | ), 158 | ) 159 | 160 | async with aiohttp.ClientSession() as session: 161 | wled = WLED("example.com", session=session) 162 | with pytest.raises(WLEDError): 163 | assert await wled.request("/") 164 | --------------------------------------------------------------------------------