├── .codecov.yml ├── .coveragerc ├── .devcontainer └── devcontainer.json ├── .editorconfig ├── .gitattributes ├── .github ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── feature_request.yml ├── dependabot.yml └── workflows │ ├── pages.yml │ ├── pr.yml │ ├── release.yml │ ├── stage.yml │ └── stale.yml ├── .gitignore ├── .vscode ├── launch.json └── settings.json ├── .yamllint ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── VERSION ├── app ├── __init__.py ├── tests │ ├── __init__.py │ ├── test_access_logger_implementation.py │ ├── test_serialization_helper.py │ └── test_web_app.py └── webapp.py ├── docs ├── device_types.md ├── endpoints_action.md ├── endpoints_all.md ├── endpoints_config.md ├── endpoints_schedule.md ├── endpoints_state.md ├── img │ ├── favicon.ico │ └── logo.png ├── index.md ├── query_params.md └── robots.txt ├── mkdocs.yml ├── requirements.txt ├── requirements_docs.txt ├── requirements_test.txt └── tox.ini /.codecov.yml: -------------------------------------------------------------------------------- 1 | --- 2 | coverage: 3 | status: 4 | patch: off 5 | changes: off 6 | project: 7 | default: 8 | branches: 9 | - dev 10 | 11 | comment: 12 | layout: diff 13 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = app 3 | omit = app/tests/* 4 | 5 | [report] 6 | fail_under = 85.00 7 | precision = 2 8 | # show_missing = true 9 | skip_covered = true 10 | exclude_lines = 11 | if __name__ == "__main__": 12 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Switcher WebAPI Dev", 3 | "context": "..", 4 | "dockerFile": "../Dockerfile", 5 | "postStartCommand": "python app/webapp.py -p 8000 -l DEBUG", 6 | "containerEnv": { 7 | "DEVCONTAINER": "1" 8 | }, 9 | "appPort": [ 10 | "8000:8000" 11 | ], 12 | "privileged": true 13 | } 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | 9 | [requirements.txt] 10 | end_of_line = lf 11 | trim_trailing_whitespace = false 12 | 13 | [**.py] 14 | indent_style = space 15 | indent_size = 4 16 | max_line_length = 88 17 | trim_trailing_whitespace = true 18 | 19 | [**.xml] 20 | indent_style = space 21 | indent_size = 2 22 | max_line_length = 80 23 | trim_trailing_whitespace = true 24 | 25 | [**.{yaml,yml}] 26 | indent_style = space 27 | indent_size = 2 28 | max_line_length = 100 29 | trim_trailing_whitespace = true 30 | 31 | [**.md] 32 | max_line_length = 120 33 | trim_trailing_whitespace = false 34 | 35 | [**.rst] 36 | end_of_line = lf 37 | max_line_length = 100 38 | trim_trailing_whitespace = true 39 | 40 | [**.{json,all-contributorsrc}] 41 | indent_size = 2 42 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Normalize text files (crlf -> lf) 2 | # * text=auto 3 | 4 | # Language specific 5 | *.py text eol=lf diff=python 6 | *.pyc text eol=lf diff=python 7 | *.cs text eol=crlf diff=csharp 8 | *.java text eol=lf diff=java 9 | 10 | # Documents 11 | *.csv text eol=crlf 12 | *.doc text eol=crlf diff=astextplain 13 | *.docx text eol=crlf diff=astextplain 14 | *.pdf text eol=crlf diff=astextplain 15 | *.rtf text eol=crlf diff=astextplain 16 | 17 | # Documentation 18 | *.markdown text eol=lf 19 | *.md text eol=lf 20 | *.mdtxt text eol=lf 21 | *.mdtext text eol=lf 22 | *.txt text eol=lf 23 | *.rst text eol=lf 24 | CHANGELOG text eol=lf 25 | CONTRIBUTING text eol=lf 26 | *COPYRIGHT* text eol=lf 27 | LICENSE text eol=lf 28 | *README* text eol=lf 29 | 30 | # Configs 31 | *.cnf text eol=lf 32 | *.conf text eol=lf 33 | *.config text eol=lf 34 | *.lock binary 35 | *.npmignore text eol=lf 36 | *.properties text eol=lf 37 | *.toml text eol=lf 38 | *.yaml text eol=lf 39 | *.yml text eol=lf 40 | .editorconfig text eol=lf 41 | .env text eol=lf 42 | package-lock.json binary 43 | Makefile text eol=lf 44 | makefile text eol=lf 45 | 46 | # Graphics 47 | *.bmp binary 48 | *.gif binary 49 | *.gifv binary 50 | *.jpg binary 51 | *.jpeg binary 52 | *.ico binary 53 | *.png binary 54 | *.svg text eol=lf 55 | *.svgz binary 56 | *.tif binary 57 | *.tiff binary 58 | *.wbmp binary 59 | *.webp binary 60 | 61 | # Scripts 62 | *.bash text eol=lf 63 | *.sh text eol=lf 64 | *.sql text eol=lf 65 | 66 | # Windows 67 | *.bat text eol=crlf 68 | *.cmd text eol=crlf 69 | *.ps1 text eol=crlf 70 | 71 | # Archives 72 | *.7z binary 73 | *.gz binary 74 | *.tar binary 75 | *.zip binary 76 | 77 | # Docker 78 | *.dockerignore text eol=lf 79 | Dockerfile text eol=lf 80 | 81 | # Git 82 | *.gitattributes text eol=lf 83 | .gitignore text eol=lf 84 | 85 | # Web files 86 | *.coffee text eol=lf 87 | *.css text eol=lf diff=css 88 | *.htm text eol=lf diff=html 89 | *.html text eol=lf diff=html 90 | *.ini text eol=lf 91 | *.js text eol=lf 92 | *.json text eol=lf 93 | *.jsp text eol=lf 94 | *.jspf text eol=lf 95 | *.jspx text eol=lf 96 | *.jsx text eol=lf 97 | *.less text eol=lf 98 | *.ls text eol=lf 99 | *.map binary 100 | *.php text eol=lf diff=php 101 | *.scss text eol=lf diff=css 102 | *.xml text eol=lf 103 | *.xhtml text eol=lf diff=html 104 | 105 | # Binary files 106 | *.class binary 107 | *.dll binary 108 | *.ear binary 109 | *.jar binary 110 | *.so binary 111 | *.war binary 112 | *.db binary 113 | *.p binary 114 | *.pkl binary 115 | *.pickle binary 116 | *.pyc binary 117 | *.pyd binary 118 | *.pyo binary 119 | 120 | # Visual studio specific 121 | *.sln text eol=crlf 122 | *.csproj text eol=crlf 123 | *.vbproj text eol=crlf 124 | *.vcxproj text eol=crlf 125 | *.vcproj text eol=crlf 126 | *.dbproj text eol=crlf 127 | *.fsproj text eol=crlf 128 | *.lsproj text eol=crlf 129 | *.wixproj text eol=crlf 130 | *.modelproj text eol=crlf 131 | *.sqlproj text eol=crlf 132 | *.wmaproj text eol=crlf 133 | *.xproj text eol=crlf 134 | *.props text eol=crlf 135 | *.filters text eol=crlf 136 | *.vcxitems text eol=crlf 137 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @TomerFi @dmatik @YogevBokobza 2 | .github @TomerFi 3 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socioeconomic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or advances 26 | - Trolling, insulting/derogatory comments, and personal or political attacks 27 | - Public or private harassment 28 | - Publishing others' private information, such as a physical or electronic address, without explicit 29 | permission 30 | - Other conduct which could reasonably be considered inappropriate in a professional setting 31 | 32 | ## Our Responsibilities 33 | 34 | Project maintainers are responsible for clarifying the standards of acceptable 35 | behavior and are expected to take appropriate and fair corrective action in 36 | response to any instances of unacceptable behavior. 37 | 38 | Project maintainers have the right and responsibility to remove, edit, or 39 | reject comments, commits, code, wiki edits, issues, and other contributions 40 | that are not aligned to this Code of Conduct, or to ban temporarily or 41 | permanently any contributor for other behaviors that they deem inappropriate, 42 | threatening, offensive, or harmful. 43 | 44 | ## Scope 45 | 46 | This Code of Conduct applies both within project spaces and in public spaces 47 | when an individual is representing the project or its community. Examples of 48 | representing a project or community include using an official project e-mail 49 | address, posting via an official social media account, or acting as an appointed 50 | representative at an online or offline event. Representation of a project may be 51 | further defined and clarified by project maintainers. 52 | 53 | ## Enforcement 54 | 55 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 56 | reported by contacting the project team at tomer@tomfi.info. All complaints will 57 | be reviewed and investigated and will result in a response that is deemed 58 | necessary and appropriate to the circumstances. The project team is obligated 59 | to maintain confidentiality with regard to the reporter of an incident. 60 | Further details of specific enforcement policies may be posted separately. 61 | 62 | ## Attribution 63 | 64 | This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), 65 | version 1.4, available [here](https://www.contributor-covenant.org/version/1/4/code-of-conduct.html). 66 | 67 | For answers to common questions about this code of conduct, see [faq](https://www.contributor-covenant.org/faq). 68 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | description: File a bug report 4 | labels: ["type: bug"] 5 | 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: Thanks for taking the time to fill out this bug report! 10 | 11 | - type: textarea 12 | id: what-happened 13 | attributes: 14 | label: What happened? 15 | description: Also share, what did you expect to happen? 16 | validations: 17 | required: true 18 | 19 | - type: input 20 | id: module-version 21 | attributes: 22 | label: Module Version 23 | description: What version of `switcher_webapo` are you using? 24 | placeholder: ex. 1.0.0 25 | validations: 26 | required: true 27 | 28 | - type: dropdown 29 | id: device-type 30 | attributes: 31 | label: Device Type 32 | description: What device are you trying to work with? 33 | options: 34 | - Switcher V2 35 | - Switcher Mini 36 | - Switcher Touch (V3) 37 | - Switcher V4 38 | - Switcher Power Plug 39 | validations: 40 | required: true 41 | 42 | - type: input 43 | id: firmware-version 44 | attributes: 45 | label: Firmware Version 46 | description: What the firmware version of the device in question? 47 | placeholder: ex. 3.2.1 48 | validations: 49 | required: true 50 | 51 | - type: textarea 52 | id: log-output 53 | attributes: 54 | label: Relevant log output 55 | description: Please provide any relevant log output. Check for private info before submitting. 56 | render: shell 57 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | blank_issues_enabled: false 3 | contact_links: 4 | - name: GitHub Discussions 5 | url: https://github.com/TomerFi/switcher_webapi/discussions/ 6 | about: You can also use Discussions for questions and ideas. 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | description: Suggest an idea for this project 4 | labels: ["type: enhancement"] 5 | 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: Thanks for taking the suggest an idea! 10 | 11 | - type: textarea 12 | id: feature-idea 13 | attributes: 14 | label: What did you have in mind? 15 | validations: 16 | required: true 17 | 18 | - type: textarea 19 | id: is-problem 20 | attributes: 21 | label: Are you trying to fix a problem? 22 | description: If so, please provide as much information as you can. 23 | 24 | - type: textarea 25 | id: implementation-idea 26 | attributes: 27 | label: Any lead on how this feature can be implemented? 28 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | - package-ecosystem: github-actions 5 | directory: /.github/workflows 6 | schedule: 7 | interval: weekly 8 | labels: 9 | - "type: dependencies" 10 | commit-message: 11 | prefix: "ci" 12 | rebase-strategy: disabled 13 | assignees: 14 | - "TomerFi" 15 | - "dmatik" 16 | - "YogevBokobza" 17 | 18 | - package-ecosystem: "pip" 19 | directory: "/" 20 | schedule: 21 | interval: "daily" 22 | labels: 23 | - "type: dependencies" 24 | commit-message: 25 | prefix: "build" 26 | include: "scope" 27 | rebase-strategy: disabled 28 | versioning-strategy: increase-if-necessary 29 | assignees: 30 | - "TomerFi" 31 | - "dmatik" 32 | - "YogevBokobza" 33 | 34 | - package-ecosystem: "docker" 35 | directory: "/" 36 | schedule: 37 | interval: "daily" 38 | labels: 39 | - "type: dependencies" 40 | commit-message: 41 | prefix: "build" 42 | rebase-strategy: disabled 43 | assignees: 44 | - "TomerFi" 45 | - "dmatik" 46 | - "YogevBokobza" 47 | -------------------------------------------------------------------------------- /.github/workflows/pages.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Pages Deploy 3 | 4 | on: 5 | release: 6 | types: [published] 7 | 8 | jobs: 9 | deploy-pages: 10 | runs-on: ubuntu-latest 11 | environment: github-pages 12 | if: ${{ github.ref != 'refs/tags/early-access' }} 13 | name: Build documentation site and deploy to GH-Pages 14 | steps: 15 | - name: Source checkout 16 | uses: actions/checkout@v4 17 | with: 18 | ref: ${{ github.ref }} 19 | 20 | - name: Setup Python 3.13 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: '3.13' 24 | cache: 'pip' 25 | cache-dependency-path: | 26 | requirements-dev.txt 27 | requirements_docs.txt 28 | 29 | - name: Prepare python environment 30 | run: | 31 | pip install -r requirements.txt -r requirements_docs.txt 32 | 33 | - name: Build documentation site 34 | run: mkdocs build 35 | 36 | - name: Deploy to GH-Pages 37 | uses: peaceiris/actions-gh-pages@v4.0.0 38 | with: 39 | github_token: ${{ secrets.GITHUB_TOKEN }} 40 | publish_dir: ./site 41 | cname: switcher-webapi.tomfi.info 42 | commit_message: 'docs: deployed to gh-pages for ${{ github.ref }}' 43 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Pull request build 3 | 4 | on: 5 | pull_request: 6 | # branches: 7 | # - dev 8 | 9 | jobs: 10 | build-app: 11 | runs-on: ubuntu-latest 12 | name: Build project 13 | permissions: 14 | checks: write 15 | pull-requests: write 16 | steps: 17 | - name: Checkout sources 18 | uses: actions/checkout@v4 19 | 20 | - name: Setup Python 3.13 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: '3.13' 24 | 25 | - name: Cache pip repository 26 | uses: actions/cache@v4 27 | with: 28 | path: ~/.cache/pip 29 | key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt', 'requirements_test.txt') }} 30 | 31 | - name: Prepare python environment 32 | run: | 33 | pip install --upgrade pip 34 | pip install -r requirements.txt -r requirements_test.txt 35 | 36 | - name: Lint project with python linters 37 | run: | 38 | black --check app/ 39 | flake8 --count --statistics app/ 40 | isort --check-only app/ 41 | mypy --ignore-missing-imports app/ 42 | yamllint --format colored --strict . 43 | 44 | - name: Test project 45 | run: pytest -v --cov --cov-report=xml:coverage.xml --junit-xml junit.xml 46 | 47 | - name: Report test summary 48 | uses: EnricoMi/publish-unit-test-result-action@v2 49 | if: always() 50 | with: 51 | test_changes_limit: 0 52 | files: ./junit.xml 53 | report_individual_runs: true 54 | 55 | - name: Push to CodeCov 56 | uses: codecov/codecov-action@v5 57 | with: 58 | token: ${{ secrets.CODECOV_TOKEN }} 59 | files: ./coverage.xml 60 | 61 | build-docker: 62 | runs-on: ubuntu-latest 63 | name: Build docker 64 | permissions: 65 | pull-requests: read 66 | steps: 67 | - name: Checkout sources 68 | uses: actions/checkout@v4 69 | with: 70 | fetch-depth: 0 71 | 72 | - name: Get current date 73 | id: getDate 74 | run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT 75 | 76 | - name: Cache Docker layers 77 | uses: actions/cache@v4 78 | with: 79 | path: /tmp/.buildx-cache 80 | key: ${{ runner.os }}-buildx-${{ hashFiles('**/Dockerfile') }} 81 | restore-keys: ${{ runner.os }}-buildx- 82 | 83 | - name: Set up QEMU 84 | uses: docker/setup-qemu-action@v3.6.0 85 | 86 | - name: Set up Docker Buildx 87 | uses: docker/setup-buildx-action@v3.10.0 88 | 89 | - name: Build docker image (no push) 90 | uses: docker/build-push-action@v6.18.0 91 | with: 92 | context: . 93 | platforms: linux/amd64,linux/arm/v7,linux/arm64/v8 94 | build-args: | 95 | VCS_REF=${{ github.sha }} 96 | BUILD_DATE=${{ steps.getDate.outputs.date }} 97 | VERSION=testing 98 | tags: tomerfi/switcher_webapi:testing 99 | cache-from: type=local,src=/tmp/.buildx-cache 100 | cache-to: type=local,dest=/tmp/.buildx-cache 101 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release 3 | 4 | on: 5 | workflow_dispatch: 6 | inputs: 7 | title: 8 | description: "Release title" 9 | required: false 10 | bump: 11 | description: "Bump type (major/minor/patch) defaults to auto" 12 | default: "auto" 13 | required: true 14 | 15 | jobs: 16 | release: 17 | runs-on: ubuntu-latest 18 | environment: deployment 19 | name: Build, publish, release, and announce 20 | steps: 21 | - name: Checkout sources 22 | uses: actions/checkout@v4 23 | with: 24 | fetch-depth: 0 25 | ssh-key: ${{ secrets.DEPLOY_KEY }} 26 | 27 | - name: Configure git 28 | run: | 29 | git config user.name "${{ github.actor }}" 30 | git config user.email "${{ github.actor }}@users.noreply.github.com" 31 | 32 | - name: Setup Python 3.13 33 | uses: actions/setup-python@v5 34 | with: 35 | python-version: '3.13' 36 | cache: 'pip' 37 | cache-dependency-path: | 38 | requirements-dev.txt 39 | requirements_docs.txt 40 | 41 | - name: Prepare python environment 42 | run: | 43 | pip install -r requirements.txt -r requirements_docs.txt 44 | 45 | - name: Build documentation site 46 | run: mkdocs build 47 | 48 | - name: Cache Docker layers 49 | uses: actions/cache@v4 50 | with: 51 | path: /tmp/.buildx-cache 52 | key: ${{ runner.os }}-buildx-${{ hashFiles('**/Dockerfile') }} 53 | 54 | - name: Set up QEMU 55 | uses: docker/setup-qemu-action@v3.6.0 56 | 57 | - name: Set up Docker Buildx 58 | uses: docker/setup-buildx-action@v3.10.0 59 | 60 | - name: Login to DockerHub 61 | uses: docker/login-action@v3.4.0 62 | with: 63 | username: ${{ secrets.DOCKERHUB_USERNAME }} 64 | password: ${{ secrets.DOCKERHUB_PASSWORD }} 65 | 66 | - name: Determine next SemVer 67 | id: bumper 68 | uses: tomerfi/version-bumper-action@2.0.4 69 | with: 70 | bump: '${{ github.event.inputs.bump }}' 71 | 72 | - name: Set new project version 73 | # yamllint disable rule:line-length 74 | # editorconfig-checker-disable 75 | run: | 76 | echo "${{ steps.bumper.outputs.next }}" > VERSION 77 | sed -i 's/ version: .*/ version: "${{ steps.bumper.outputs.next }}"/g' mkdocs.yml 78 | # yamllint enable rule:line-length 79 | # editorconfig-checker-enable 80 | 81 | - name: Commit, tag, and push 82 | run: | 83 | git add VERSION 84 | git add mkdocs.yml 85 | git commit -m "build: bump version to ${{ steps.bumper.outputs.next }}" 86 | git push 87 | 88 | - name: Get current date 89 | id: current_date 90 | run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT 91 | 92 | - name: Build images and push to DockerHub 93 | uses: docker/build-push-action@v6.18.0 94 | with: 95 | context: . 96 | push: true 97 | platforms: linux/amd64,linux/arm/v7,linux/arm64/v8 98 | tags: | 99 | tomerfi/switcher_webapi:latest 100 | tomerfi/switcher_webapi:${{ steps.bumper.outputs.next }} 101 | build-args: | 102 | VCS_REF=${{ github.sha }} 103 | BUILD_DATE=${{ steps.current_date.outputs.date }} 104 | VERSION=${{ steps.bumper.outputs.next }} 105 | cache-from: | 106 | type=local,src=/tmp/.buildx-cache 107 | ghcr.io/tomerfi/switcher_webapi:early-access 108 | cache-to: type=local,dest=/tmp/.buildx-cache 109 | 110 | - name: Create a release name 111 | id: release_name 112 | uses: actions/github-script@v7 113 | with: 114 | script: | 115 | var retval = '${{ steps.bumper.outputs.next }}' 116 | if ('${{ github.event.inputs.title }}') { 117 | retval = retval.concat(' - ${{ github.event.inputs.title }}') 118 | } 119 | core.setOutput('value', retval) 120 | 121 | - name: Create a release 122 | id: gh_release 123 | uses: actions/github-script@v7 124 | with: 125 | github-token: ${{ secrets.RELEASE_PAT }} 126 | script: | 127 | const repo_name = context.payload.repository.full_name 128 | const response = await github.request('POST /repos/' + repo_name + '/releases', { 129 | tag_name: '${{ steps.bumper.outputs.next }}', 130 | name: '${{ steps.release_name.outputs.value }}', 131 | generate_release_notes: true 132 | }) 133 | core.setOutput('html_url', response.data.html_url) 134 | 135 | - name: Set development project version 136 | run: echo "${{ steps.bumper.outputs.dev }}" > VERSION 137 | 138 | - name: Commit and push 139 | # yamllint disable rule:line-length 140 | # editorconfig-checker-disable 141 | run: | 142 | git add VERSION 143 | git commit -m "build: bump version to ${{ steps.bumper.outputs.dev }} [skip ci]" 144 | git push 145 | # yamllint enable rule:line-length 146 | # editorconfig-checker-enable 147 | -------------------------------------------------------------------------------- /.github/workflows/stage.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Stage 3 | 4 | on: 5 | workflow_dispatch: 6 | push: 7 | branches: 8 | - dev 9 | 10 | jobs: 11 | stage: 12 | runs-on: ubuntu-latest 13 | environment: staging 14 | name: Build and publish early access to GitHub 15 | steps: 16 | - name: Checkout sources 17 | uses: actions/checkout@v4 18 | 19 | - name: Setup Python 3.13 20 | uses: actions/setup-python@v5 21 | with: 22 | python-version: '3.13' 23 | cache: 'pip' 24 | cache-dependency-path: | 25 | requirements-dev.txt 26 | requirements_test.txt 27 | 28 | - name: Prepare python environment 29 | run: | 30 | pip install -r requirements.txt -r requirements_test.txt 31 | 32 | - name: Test project 33 | run: pytest -v --cov --cov-report=xml:coverage.xml 34 | 35 | - name: Cache Docker layers 36 | uses: actions/cache@v4 37 | with: 38 | path: /tmp/.buildx-cache 39 | key: ${{ runner.os }}-buildx-${{ hashFiles('**/Dockerfile') }} 40 | 41 | - name: Set up QEMU 42 | uses: docker/setup-qemu-action@v3.6.0 43 | 44 | - name: Set up Docker Buildx 45 | uses: docker/setup-buildx-action@v3.10.0 46 | 47 | - name: Login to GHCR 48 | uses: docker/login-action@v3.4.0 49 | with: 50 | registry: ghcr.io 51 | username: ${{ github.repository_owner }} 52 | password: ${{ secrets.GHCR_PAT }} 53 | 54 | - name: Get current date 55 | id: getDate 56 | run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT 57 | 58 | - name: Build images and push to GHCR 59 | uses: docker/build-push-action@v6.18.0 60 | with: 61 | context: . 62 | push: true 63 | platforms: linux/amd64,linux/arm/v7,linux/arm64/v8 64 | tags: ghcr.io/tomerfi/switcher_webapi:early-access 65 | build-args: | 66 | VCS_REF=${{ github.sha }} 67 | BUILD_DATE=${{ steps.getDate.outputs.date }} 68 | VERSION=early-access 69 | cache-from: | 70 | type=local,src=/tmp/.buildx-cache 71 | ghcr.io/tomerfi/switcher_webapi:early-access 72 | cache-to: type=local,dest=/tmp/.buildx-cache 73 | 74 | - name: Push coverage report to CodeCov 75 | uses: codecov/codecov-action@v5 76 | with: 77 | token: ${{ secrets.CODECOV_TOKEN }} 78 | files: coverage.xml 79 | fail_ci_if_error: true 80 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Close stale issues' 3 | on: 4 | schedule: 5 | - cron: '0 3 * * *' 6 | 7 | jobs: 8 | stale: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/stale@v9 12 | with: 13 | stale-issue-label: "status: stale" 14 | stale-issue-message: > 15 | This issue has been automatically marked as stale because it has not had 16 | recent activity. It will be closed if no further activity occurs. Thank you 17 | for your contributions. 18 | close-issue-message: Closing this issue. 19 | days-before-stale: 30 20 | exempt-issue-labels: | 21 | status: confirmed,status: on-hold,type: todo,type: wip,type: enhancement 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Project specific conf is at the bottom. 2 | 3 | ######################################################################## 4 | ### https://github.com/github/gitignore/blob/master/Python.gitignore ### 5 | ######################################################################## 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | pip-wheel-metadata/ 29 | share/python-wheels/ 30 | *.egg-info/ 31 | .installed.cfg 32 | *.egg 33 | MANIFEST 34 | 35 | # PyInstaller 36 | # Usually these files are written by a python script from a template 37 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 38 | *.manifest 39 | *.spec 40 | 41 | # Installer logs 42 | pip-log.txt 43 | pip-delete-this-directory.txt 44 | 45 | # Unit test / coverage reports 46 | htmlcov/ 47 | .tox/ 48 | .nox/ 49 | .coverage 50 | .coverage.* 51 | .cache 52 | nosetests.xml 53 | coverage.xml 54 | *.cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # mkdocs documentation 75 | */site/ 76 | 77 | # PyBuilder 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don’t work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # celery beat schedule file 98 | celerybeat-schedule 99 | 100 | # SageMath parsed files 101 | *.sage.py 102 | 103 | # Environments 104 | .env 105 | .venv 106 | env/ 107 | venv/ 108 | ENV/ 109 | env.bak/ 110 | venv.bak/ 111 | 112 | # Spyder project settings 113 | .spyderproject 114 | .spyproject 115 | 116 | # Rope project settings 117 | .ropeproject 118 | 119 | # mkdocs documentation 120 | /site 121 | 122 | # mypy 123 | .mypy_cache/ 124 | .dmypy.json 125 | dmypy.json 126 | 127 | # Pyre type checker 128 | .pyre/ 129 | 130 | ########################## 131 | ### Custom for project ### 132 | ########################## 133 | !.env 134 | .env_vars 135 | 136 | # coverage reports 137 | htmlcov 138 | coverage.xml 139 | junit.xml 140 | 141 | # vscode http extension requests file 142 | test_requests.http 143 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Switcher WebAPI: Attach Local", 6 | "type": "python", 7 | "request": "attach", 8 | "port": 8000, 9 | "host": "localhost", 10 | "pathMappings": [ 11 | { 12 | "localRoot": "${workspaceFolder}", 13 | "remoteRoot": "." 14 | } 15 | ] 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.defaultInterpreterPath": "${workspaceFolder}/.tox/dev/bin/python" 3 | } 4 | -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | --- 2 | extends: default 3 | 4 | locale: en_US.UTF-8 5 | 6 | ignore: | 7 | .git/ 8 | .mypy_cache/ 9 | .tox/ 10 | 11 | rules: 12 | line-length: 13 | max: 100 14 | level: warning 15 | truthy: 16 | allowed-values: ['false', 'off', 'no', 'on', 'true', 'yes'] 17 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to *switcher_webapi* 2 | 3 | :clap: First off, thank you for taking the time to contribute. :clap: 4 | 5 | - Fork the repository 6 | - Create a new branch on your fork 7 | - Commit your changes 8 | - Create a pull request against the `dev` branch 9 | 10 | ## Early-access 11 | 12 | Early-access image deployed to [GitHub container registry][ghcr]: 13 | 14 | ```shell 15 | docker run -d -p 8000:8000 --name switcher_webapi ghcr.io/tomerfi/switcher_webapi:early-access 16 | ``` 17 | 18 | ## Project 19 | 20 | [Docker][docker] multi-platform image running a [Python][python] web app. The doc site is built with [Mkdocs][mkdocs]. 21 | 22 | - [app/webapp.py](https://github.com/TomerFi/switcher_webapi/blob/dev/app/webapp.py) the application file 23 | - [app/tests/](https://github.com/TomerFi/switcher_webapi/tree/dev/app/tests) unit tests 24 | - [Dockerfile](https://github.com/TomerFi/switcher_webapi/blob/dev/Dockerfile) image instructions 25 | - [docs](https://github.com/TomerFi/switcher_webapi/tree/dev/docs) sources for the documentation site 26 | 27 | The released image is deployed to [Docker hub][docker_hub]. 28 | 29 | ## Develop 30 | 31 | Run commands using [tox][tox]: 32 | 33 | ```shell 34 | # Run linters and tests 35 | tox -e test 36 | # Generate the docs site 37 | tox -e docs 38 | # Serve the docs site 39 | tox -e docs-serve 40 | ``` 41 | 42 | ### Environment 43 | 44 | If you need to work inside the development environment, keep reading. 45 | 46 | Prepare the environment: 47 | 48 | ```shell 49 | tox 50 | ``` 51 | 52 | Update the environment: 53 | 54 | ```shell 55 | tox -r 56 | ``` 57 | 58 | Activate the environment: 59 | 60 | ```shell 61 | source .tox/dev/bin/activate 62 | ``` 63 | 64 | Deactivate the environment: 65 | 66 | ```shell 67 | deactivate 68 | ``` 69 | 70 | ### Code 71 | 72 | Running tests and linters is done using (pytest takes positional arguments): 73 | 74 | ```shell 75 | # run all tests 76 | tox -e test 77 | # run a specific test 78 | tox -e test -- -k "test_name_goes_here" 79 | ``` 80 | 81 | If you need to work inside the development environment, [prepare the environment](#environment). 82 | 83 | Run the various linters inside the environment: 84 | 85 | ```shell 86 | black --check app/ 87 | flake8 --count --statistics app/ 88 | isort --check-only app/ 89 | mypy --ignore-missing-imports app/ 90 | yamllint --format colored --strict . 91 | ``` 92 | 93 | Run tests inside the environment: 94 | 95 | ```shell 96 | # run all tests 97 | pytest -v 98 | # run a specific test 99 | tox -e test -- -k "test_name_goes_here" 100 | # run tests and print coverage summary to stdout 101 | pytest -v --cov --cov-report term-missing 102 | # run tests and create coverage report 103 | pytest -v --cov --cov-report=html 104 | ``` 105 | 106 | ### Docs 107 | 108 | Generating or serving the docs is done using: 109 | 110 | ```shell 111 | # generate the docs site 112 | tox -e docs 113 | # generate and serve the docs site 114 | tox -e docs-serve 115 | ``` 116 | 117 | If you need to work inside the development environment, [prepare the environment](#environment). 118 | 119 | Generate the site inside the environment: 120 | 121 | ```shell 122 | mkdocs build 123 | ``` 124 | 125 | Generate and serve the site from within the environment: 126 | 127 | ```shell 128 | tox -e docs-serve 129 | ``` 130 | 131 | ### Docker 132 | 133 | > Requires [make][make] and [npm][npm]. 134 | 135 | Lint the Dockerfile: 136 | 137 | ```shell 138 | make dockerfile-lint 139 | ``` 140 | 141 | Configure [qemu][qemu] for multi-platform builds: 142 | 143 | ```shell 144 | make enable-multiarch 145 | ``` 146 | 147 | Builds the multi-platform image using `docker buildx`: 148 | 149 | ```shell 150 | make build 151 | ``` 152 | 153 | 154 | [docker]: https://www.docker.com/ 155 | [docker_hub]: https://hub.docker.com/r/tomerfi/switcher_webapi 156 | [ghcr]: https://github.com/TomerFi/switcher_webapi/pkgs/container/switcher_webapi 157 | [make]: https://www.gnu.org/software/make/manual/make.html 158 | [mkdocs]: https://www.mkdocs.org/ 159 | [npm]: https://www.npmjs.com/ 160 | [python]: https://www.python.org/ 161 | [tox]: https://tox.readthedocs.io/ 162 | [qemu]: https://docs.docker.com/build/building/multi-platform/#qemu 163 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.13.4-slim 2 | 3 | ARG TIMEZONE="Asia/Jerusalem" 4 | 5 | RUN ln -fs /usr/share/zoneinfo/$TIMEZONE /etc/localtime 6 | 7 | RUN apt-get update && apt-get -y install build-essential && rm -rf /var/lib/apt/lists/* 8 | 9 | WORKDIR /usr/switcher_webapi 10 | 11 | COPY LICENSE app/webapp.py requirements.txt ./ 12 | 13 | RUN pip install -U pip 14 | 15 | RUN AIOHTTP_NO_EXTENSIONS=1 \ 16 | FROZENLIST_NO_EXTENSIONS=1 \ 17 | MULTIDICT_NO_EXTENSIONS=1 \ 18 | YARL_NO_EXTENSIONS=1 \ 19 | pip install -r requirements.txt 20 | 21 | EXPOSE 8000 22 | 23 | ENV LOG_LEVEL=INFO 24 | 25 | CMD ["/bin/sh", "-c", "python webapp.py -p 8000 -l $LOG_LEVEL"] 26 | 27 | ARG BUILD_DATE 28 | ARG VCS_REF 29 | ARG VERSION 30 | 31 | LABEL org.opencontainers.image.created=$BUILD_DATE \ 32 | org.opencontainers.image.authors="Tomer Figenblat " \ 33 | org.opencontainers.image.url="https://hub.docker.com/r/tomerfi/switcher_webapi" \ 34 | org.opencontainers.image.documentation="https://switcher-webapi.tomfi.info" \ 35 | org.opencontainers.image.source="https://github.com/TomerFi/switcher_webapi" \ 36 | org.opencontainers.image.version=$VERSION \ 37 | org.opencontainers.image.revision=$VCS_REF \ 38 | org.opencontainers.image.vendor="https://switcher.co.il/" \ 39 | org.opencontainers.image.licenses="Apache-2.0" \ 40 | org.opencontainers.image.ref.name=$VERSION \ 41 | org.opencontainers.image.title="tomerfi/switcher_webapi" \ 42 | org.opencontainers.image.description="Containerized web service integrating with Switcher devices" 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | default: build 2 | 3 | IMAGE_NAME = tomerfi/switcher_webapi 4 | GIT_COMMIT = $(strip $(shell git rev-parse --short HEAD)) 5 | CURRENT_DATE = $(strip $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")) 6 | CODE_VERSION = $(strip $(shell cat VERSION)) 7 | FULL_IMAGE_NAME = $(strip $(IMAGE_NAME):$(CODE_VERSION)) 8 | 9 | PLATFORMS = linux/amd64,linux/arm/v7,linux/arm64/v8 10 | 11 | ifndef CODE_VERSION 12 | $(error You need to create a VERSION file to build the image.) 13 | endif 14 | 15 | build: enable-multiarch 16 | docker buildx build \ 17 | --build-arg VCS_REF=$(GIT_COMMIT) \ 18 | --build-arg BUILD_DATE=$(CURRENT_DATE) \ 19 | --build-arg VERSION=$(CODE_VERSION) \ 20 | --platform $(PLATFORMS) \ 21 | --tag $(FULL_IMAGE_NAME) \ 22 | --tag $(IMAGE_NAME):latest . 23 | 24 | dockerfile-lint: 25 | npx dockerfilelint Dockerfile 26 | 27 | enable-multiarch: 28 | docker run --rm --privileged multiarch/qemu-user-static --reset -p yes 29 | 30 | .PHONY: build dockerfile-lint enable-multiarch 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Switcher Web API 2 | 3 | [![version-badge]][dockerhub] 4 | [![pulls-badge]][dockerhub] 5 | [![license-badge]][license]
6 | [![stage-badge]][stage] 7 | [![pages-badge]][pages] 8 | [![codecov-badge]][codecov] 9 | 10 | Gain access to your local [Switcher][switcher] smart devices. 11 | 12 | ```shell 13 | docker run -d -p 8000:8000 --name switcher_webapi tomerfi/switcher_webapi:latest 14 | ``` 15 | 16 | Check the docs: [https://switcher-webapi.tomfi.info][docs]. 17 | 18 | > [!IMPORTANT] 19 | > Since version 2, all endpoints require a device type. See [docs][docs]. 20 | 21 | 22 | [codecov]: https://codecov.io/gh/TomerFi/switcher_webapi 23 | [docs]: https://switcher-webapi.tomfi.info 24 | [dockerhub]: https://hub.docker.com/r/tomerfi/switcher_webapi 25 | [license]: https://github.com/TomerFi/switcher_webapi/blob/dev/LICENSE 26 | [pages]: https://github.com/TomerFi/switcher_webapi/actions/workflows/pages.yml 27 | [stage]: https://github.com/TomerFi/switcher_webapi/actions/workflows/stage.yml 28 | [switcher]: https://www.switcher.co.il/ 29 | 30 | [codecov-badge]: https://codecov.io/gh/TomerFi/switcher_webapi/graph/badge.svg 31 | [license-badge]: https://img.shields.io/github/license/tomerfi/switcher_webapi 32 | [pages-badge]: https://github.com/TomerFi/switcher_webapi/actions/workflows/pages.yml/badge.svg 33 | [pulls-badge]: https://img.shields.io/docker/pulls/tomerfi/switcher_webapi.svg?logo=docker&label=pulls 34 | [stage-badge]: https://github.com/TomerFi/switcher_webapi/actions/workflows/stage.yml/badge.svg 35 | [version-badge]: https://img.shields.io/docker/v/tomerfi/switcher_webapi?color=%230A6799&logo=docker 36 | 37 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 2.3.2-dev 2 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | """Aiohttp web service integrating with Switcher devices.""" 2 | -------------------------------------------------------------------------------- /app/tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Test cases for aiohttp web service integrating with Switcher devices.""" 2 | -------------------------------------------------------------------------------- /app/tests/test_access_logger_implementation.py: -------------------------------------------------------------------------------- 1 | """Test cases for access logger custom implementation.""" 2 | 3 | from unittest.mock import Mock, patch 4 | 5 | from pytest import fixture 6 | 7 | from ..webapp import CustomAccessLogger 8 | 9 | 10 | @fixture 11 | def mock_request(): 12 | request = Mock() 13 | request.remote = "127.0.0.1" 14 | request.method = "GET" 15 | request.path = "/switcher/get_state" 16 | return request 17 | 18 | 19 | @fixture 20 | def mock_response(): 21 | response = Mock() 22 | response.status = 200 23 | return response 24 | 25 | 26 | @fixture 27 | def mock_logger(): 28 | return Mock() 29 | 30 | 31 | def test_access_logger_implementation_writing_to_debug_level( 32 | mock_logger, mock_request, mock_response 33 | ): 34 | with patch.object(mock_logger, "debug") as mock_debug: 35 | sut_impl = CustomAccessLogger(mock_logger, "") 36 | sut_impl.log(mock_request, mock_response, 0.314159) 37 | 38 | mock_debug.assert_called_once_with( 39 | "127.0.0.1 GET /switcher/get_state done in 0.314159s: 200" 40 | ) 41 | -------------------------------------------------------------------------------- /app/tests/test_serialization_helper.py: -------------------------------------------------------------------------------- 1 | """Test cases for the serialization helper function.""" 2 | 3 | from enum import Enum, auto 4 | from unittest.mock import MagicMock 5 | 6 | from assertpy import assert_that 7 | from pytest import mark 8 | 9 | from ..webapp import _serialize_object 10 | 11 | 12 | class JustAnEnum(Enum): 13 | """Simple enum for testing serialization.""" 14 | 15 | MEMBER_ONE = auto() 16 | MEMBER_TWO = auto() 17 | 18 | 19 | @mark.parametrize( 20 | "sut_dict,expected_serialized_dict", 21 | [ 22 | # just primitives, not modification of the dict is required 23 | ( 24 | { 25 | "key_for_str": "stringstring", 26 | "key_for_int": 10, 27 | "key_for_set_str_to_list": {"anotherstring"}, 28 | }, 29 | { 30 | "key_for_str": "stringstring", 31 | "key_for_int": 10, 32 | "key_for_set_str_to_list": ["anotherstring"], 33 | }, 34 | ), 35 | # key unparsed_response should be removed 36 | ( 37 | { 38 | "key_for_str": "stringstring", 39 | "key_for_int": 10, 40 | "key_for_set_str_to_list": {"anotherstring"}, 41 | "unparsed_response": b"010101", 42 | }, 43 | { 44 | "key_for_str": "stringstring", 45 | "key_for_int": 10, 46 | "key_for_set_str_to_list": ["anotherstring"], 47 | }, 48 | ), 49 | # enum members should be replaced with thier names 50 | ( 51 | { 52 | "key_for_enum": JustAnEnum.MEMBER_ONE, 53 | "key_for_int": 10, 54 | "key_for_set_enum_to_list": {JustAnEnum.MEMBER_TWO}, 55 | "unparsed_response": b"010101", 56 | }, 57 | { 58 | "key_for_enum": "MEMBER_ONE", 59 | "key_for_int": 10, 60 | "key_for_set_enum_to_list": ["MEMBER_TWO"], 61 | }, 62 | ), 63 | ], 64 | ) 65 | def test_serialize_object(sut_dict, expected_serialized_dict): 66 | sut_obj = MagicMock() 67 | sut_obj.__dict__ = sut_dict 68 | 69 | assert_that(_serialize_object(sut_obj)).is_equal_to(expected_serialized_dict) 70 | -------------------------------------------------------------------------------- /app/tests/test_web_app.py: -------------------------------------------------------------------------------- 1 | """Test cases for the web application.""" 2 | 3 | from datetime import timedelta 4 | from unittest.mock import AsyncMock, Mock, patch 5 | 6 | import pytest_asyncio 7 | from aiohttp import web 8 | from aioswitcher.api import Command, DeviceState, ShutterChildLock 9 | from aioswitcher.schedule import Days 10 | from assertpy import assert_that 11 | from pytest import fixture, mark 12 | 13 | from .. import webapp 14 | 15 | pytestmark = mark.asyncio 16 | 17 | fake_devicetype_powerplug_qparams = f"{webapp.KEY_TYPE}=plug" 18 | fake_devicetype_touch_qparams = f"{webapp.KEY_TYPE}=touch" 19 | fake_devicetype_runner_qparams = f"{webapp.KEY_TYPE}=runner" 20 | fake_devicetype_single_runner_dual_light_qparams = f"{webapp.KEY_TYPE}=runners11" 21 | fake_devicetype_breeze_qparams = f"{webapp.KEY_TYPE}=breeze" 22 | fake_device_qparams = f"{webapp.KEY_ID}=ab1c2d&{webapp.KEY_IP}=1.2.3.4" 23 | fake_device_login_key_qparams = f"{webapp.KEY_LOGIN_KEY}=18" 24 | fake_device_index_qparams = f"{webapp.KEY_INDEX}=0" 25 | fake_device_token_qparams = f"{webapp.KEY_TOKEN}=zvVvd7JxtN7CgvkD1Psujw==" 26 | fake_serialized_data = {"fake": "return_dict"} 27 | 28 | # /switcher/get_state?id=ab1c2d&ip=1.2.3.4 29 | get_state_uri = f"{webapp.ENDPOINT_GET_STATE}?{fake_devicetype_powerplug_qparams}&{fake_device_qparams}" 30 | # /switcher/get_state?id=ab1c2d&ip=1.2.3.4&key=18 31 | get_state_uri2 = f"{webapp.ENDPOINT_GET_STATE}?{fake_devicetype_powerplug_qparams}&{fake_device_qparams}&{fake_device_login_key_qparams}" 32 | # /switcher/turn_on?id=ab1c2d&ip=1.2.3.4 33 | turn_on_uri = f"{webapp.ENDPOINT_TURN_ON}?{fake_devicetype_powerplug_qparams}&{fake_device_qparams}" 34 | # /switcher/turn_on?id=ab1c2d&ip=1.2.3.4&key=18 35 | turn_on_uri2 = f"{webapp.ENDPOINT_TURN_ON}?{fake_devicetype_powerplug_qparams}&{fake_device_qparams}&{fake_device_login_key_qparams}" 36 | # /switcher/turn_off?id=ab1c2d&ip=1.2.3.4 37 | turn_off_uri = f"{webapp.ENDPOINT_TURN_OFF}?{fake_devicetype_powerplug_qparams}&{fake_device_qparams}" 38 | # /switcher/turn_off?id=ab1c2d&ip=1.2.3.4&key=18 39 | turn_off_uri2 = f"{webapp.ENDPOINT_TURN_OFF}?{fake_devicetype_powerplug_qparams}&{fake_device_qparams}&{fake_device_login_key_qparams}" 40 | # /switcher/set_name?id=ab1c2d&ip=1.2.3.4 41 | set_name_uri = f"{webapp.ENDPOINT_SET_NAME}?{fake_devicetype_powerplug_qparams}&{fake_device_qparams}" 42 | # /switcher/set_name?id=ab1c2d&ip=1.2.3.4&key=18 43 | set_name_uri2 = f"{webapp.ENDPOINT_SET_NAME}?{fake_devicetype_powerplug_qparams}&{fake_device_qparams}&{fake_device_login_key_qparams}" 44 | # /switcher/set_auto_shutdown?id=ab1c2d&ip=1.2.3.4 45 | set_auto_shutdown_uri = f"{webapp.ENDPOINT_SET_AUTO_SHUTDOWN}?{fake_devicetype_touch_qparams}&{fake_device_qparams}" 46 | # /switcher/set_auto_shutdown?id=ab1c2d&ip=1.2.3.4&key=18 47 | set_auto_shutdown_uri2 = f"{webapp.ENDPOINT_SET_AUTO_SHUTDOWN}?{fake_devicetype_touch_qparams}&{fake_device_qparams}&{fake_device_login_key_qparams}" 48 | # /switcher/get_schedules?id=ab1c2d&ip=1.2.3.4 49 | get_schedules_uri = f"{webapp.ENDPOINT_GET_SCHEDULES}?{fake_devicetype_touch_qparams}&{fake_device_qparams}" 50 | # /switcher/get_schedules?id=ab1c2d&ip=1.2.3.4&key=18 51 | get_schedules_uri2 = f"{webapp.ENDPOINT_GET_SCHEDULES}?{fake_devicetype_touch_qparams}&{fake_device_qparams}&{fake_device_login_key_qparams}" 52 | # /switcher/delete_schedule?id=ab1c2d&ip=1.2.3.4 53 | delete_schedule_uri = f"{webapp.ENDPOINT_DELETE_SCHEDULE}?{fake_devicetype_touch_qparams}&{fake_device_qparams}" 54 | # /switcher/delete_schedule?id=ab1c2d&ip=1.2.3.4&key=18 55 | delete_schedule_uri2 = f"{webapp.ENDPOINT_DELETE_SCHEDULE}?{fake_devicetype_touch_qparams}&{fake_device_qparams}&{fake_device_login_key_qparams}" 56 | # /switcher/create_schedule?id=ab1c2d&ip=1.2.3.4 57 | create_schedule_uri = f"{webapp.ENDPOINT_CREATE_SCHEDULE}?{fake_devicetype_touch_qparams}&{fake_device_qparams}" 58 | # /switcher/create_schedule?id=ab1c2d&ip=1.2.3.4&key=18 59 | create_schedule_uri2 = f"{webapp.ENDPOINT_CREATE_SCHEDULE}?{fake_devicetype_touch_qparams}&{fake_device_qparams}&{fake_device_login_key_qparams}" 60 | # /switcher/set_shutter_position?id=ab1c2d&ip=1.2.3.4 61 | set_position_uri = f"{webapp.ENDPOINT_SET_POSITION}?{fake_devicetype_runner_qparams}&{fake_device_qparams}" 62 | # /switcher/set_shutter_position?id=ab1c2d&ip=1.2.3.4&key=18 63 | set_position_uri2 = f"{webapp.ENDPOINT_SET_POSITION}?{fake_devicetype_runner_qparams}&{fake_device_qparams}&{fake_device_login_key_qparams}" 64 | # /switcher/set_shutter_position?id=ab1c2d&ip=1.2.3.4&index=0&token=zvVvd7JxtN7CgvkD1Psujw== 65 | set_position_uri3 = f"{webapp.ENDPOINT_SET_POSITION}?{fake_devicetype_runner_qparams}&{fake_device_qparams}&{fake_device_index_qparams}&{fake_device_token_qparams}" 66 | # /switcher/turn_on_shutter_child_lock?id=ab1c2d&ip=1.2.3.4 67 | turn_on_shutter_child_lock_uri = f"{webapp.ENDPOINT_TURN_ON_SHUTTER_CHILD_LOCK}?{fake_devicetype_runner_qparams}&{fake_device_qparams}" 68 | # /switcher/turn_on_shutter_child_lock?id=ab1c2d&ip=1.2.3.4&key=18 69 | turn_on_shutter_child_lock_uri2 = f"{webapp.ENDPOINT_TURN_ON_SHUTTER_CHILD_LOCK}?{fake_devicetype_runner_qparams}&{fake_device_qparams}&{fake_device_login_key_qparams}" 70 | # /switcher/turn_on_shutter_child_lock?id=ab1c2d&ip=1.2.3.4&index=0&token=zvVvd7JxtN7CgvkD1Psujw== 71 | turn_on_shutter_child_lock_uri3 = f"{webapp.ENDPOINT_TURN_ON_SHUTTER_CHILD_LOCK}?{fake_devicetype_runner_qparams}&{fake_device_qparams}&{fake_device_index_qparams}&{fake_device_token_qparams}" 72 | # /switcher/turn_on_shutter_child_lock?id=ab1c2d&ip=1.2.3.4 73 | turn_off_shutter_child_lock_uri = f"{webapp.ENDPOINT_TURN_OFF_SHUTTER_CHILD_LOCK}?{fake_devicetype_runner_qparams}&{fake_device_qparams}" 74 | # /switcher/turn_off_shutter_child_lock?id=ab1c2d&ip=1.2.3.4&key=18 75 | turn_off_shutter_child_lock_uri2 = f"{webapp.ENDPOINT_TURN_OFF_SHUTTER_CHILD_LOCK}?{fake_devicetype_runner_qparams}&{fake_device_qparams}&{fake_device_login_key_qparams}" 76 | # /switcher/turn_off_shutter_child_lock?id=ab1c2d&ip=1.2.3.4&index=0&token=zvVvd7JxtN7CgvkD1Psujw== 77 | turn_off_shutter_child_lock_uri3 = f"{webapp.ENDPOINT_TURN_OFF_SHUTTER_CHILD_LOCK}?{fake_devicetype_runner_qparams}&{fake_device_qparams}&{fake_device_index_qparams}&{fake_device_token_qparams}" 78 | # /switcher/turn_on_light?id=ab1c2d&ip=1.2.3.4&index=0&token=zvVvd7JxtN7CgvkD1Psujw== 79 | turn_on_light_uri = f"{webapp.ENDPOINT_TURN_ON_LIGHT}?{fake_devicetype_runner_qparams}&{fake_device_qparams}&{fake_device_index_qparams}&{fake_device_token_qparams}" 80 | # /switcher/turn_off_light?id=ab1c2d&ip=1.2.3.4&index=0&token=zvVvd7JxtN7CgvkD1Psujw== 81 | turn_off_light_uri = f"{webapp.ENDPOINT_TURN_OFF_LIGHT}?{fake_devicetype_runner_qparams}&{fake_device_qparams}&{fake_device_index_qparams}&{fake_device_token_qparams}" 82 | # /switcher/get_breeze_state?id=ab1c2d&ip=1.2.3.4 83 | get_breeze_state_uri = f"{webapp.ENDPOINT_GET_BREEZE_STATE}?{fake_devicetype_breeze_qparams}&{fake_device_qparams}" 84 | # /switcher/get_breeze_state?id=ab1c2d&ip=1.2.3.4&key=18 85 | get_breeze_state_uri2 = f"{webapp.ENDPOINT_GET_BREEZE_STATE}?{fake_devicetype_breeze_qparams}&{fake_device_qparams}&{fake_device_login_key_qparams}" 86 | # /switcher/get_shutter_state?id=ab1c2d&ip=1.2.3.4 87 | get_shutter_state_uri = f"{webapp.ENDPOINT_GET_SHUTTER_STATE}?{fake_devicetype_runner_qparams}&{fake_device_qparams}" 88 | # /switcher/get_shutter_state?id=ab1c2d&ip=1.2.3.4&key=18 89 | get_shutter_state_uri2 = f"{webapp.ENDPOINT_GET_SHUTTER_STATE}?{fake_devicetype_runner_qparams}&{fake_device_qparams}&{fake_device_login_key_qparams}" 90 | # /switcher/get_shutter_state?id=ab1c2d&ip=1.2.3.4&index=0&token=zvVvd7JxtN7CgvkD1Psujw== 91 | get_shutter_state_uri3 = f"{webapp.ENDPOINT_GET_SHUTTER_STATE}?{fake_devicetype_runner_qparams}&{fake_device_qparams}&{fake_device_index_qparams}&{fake_device_token_qparams}" 92 | # /switcher/get_light_state?id=ab1c2d&ip=1.2.3.4&index=0&token=zvVvd7JxtN7CgvkD1Psujw== 93 | get_light_state_uri = f"{webapp.ENDPOINT_GET_LIGHT_STATE}?{fake_devicetype_runner_qparams}&{fake_device_qparams}&{fake_device_index_qparams}&{fake_device_token_qparams}" 94 | # /switcher/stop_shutter?id=ab1c2d&ip=1.2.3.4 95 | get_stop_shutter_uri = f"{webapp.ENDPOINT_POST_STOP_SHUTTER}?{fake_devicetype_runner_qparams}&{fake_device_qparams}" 96 | # /switcher/stop_shutter?id=ab1c2d&ip=1.2.3.4&key=18 97 | get_stop_shutter_uri2 = f"{webapp.ENDPOINT_POST_STOP_SHUTTER}?{fake_devicetype_runner_qparams}&{fake_device_qparams}&{fake_device_login_key_qparams}" 98 | # /switcher/stop_shutter?id=ab1c2d&ip=1.2.3.4&index=0&token=zvVvd7JxtN7CgvkD1Psujw== 99 | get_stop_shutter_uri3 = f"{webapp.ENDPOINT_POST_STOP_SHUTTER}?{fake_devicetype_runner_qparams}&{fake_device_qparams}&{fake_device_index_qparams}&{fake_device_token_qparams}" 100 | # /switcher/control_breeze_device?id=ab1c2d&ip=1.2.3.4 101 | set_control_breeze_device_uri = f"{webapp.ENDPOINT_CONTROL_BREEZE_DEVICE}?{fake_devicetype_breeze_qparams}&{fake_device_qparams}" 102 | # /switcher/control_breeze_device?id=ab1c2d&ip=1.2.3.4&key=18 103 | set_control_breeze_device_uri2 = f"{webapp.ENDPOINT_CONTROL_BREEZE_DEVICE}?{fake_devicetype_breeze_qparams}&{fake_device_qparams}&{fake_device_login_key_qparams}" 104 | 105 | 106 | @pytest_asyncio.fixture 107 | async def api_client(aiohttp_client): 108 | # create application 109 | app = web.Application(middlewares=[webapp.error_middleware]) 110 | app.add_routes(webapp.routes) 111 | # return client from application 112 | return await aiohttp_client(app) 113 | 114 | 115 | @fixture 116 | def api_connect(): 117 | with patch( 118 | "aioswitcher.api.SwitcherApi.connect", return_value=AsyncMock() 119 | ) as connect: 120 | yield connect 121 | 122 | 123 | @fixture 124 | def api_disconnect(): 125 | with patch("aioswitcher.api.SwitcherApi.disconnect") as disconnect: 126 | yield disconnect 127 | 128 | 129 | @fixture 130 | def response_serializer(): 131 | with patch.object( 132 | webapp, "_serialize_object", return_value=fake_serialized_data 133 | ) as serializer: 134 | yield serializer 135 | 136 | 137 | @fixture 138 | def response_mock(): 139 | return Mock() 140 | 141 | 142 | @mark.parametrize( 143 | "api_uri", 144 | [ 145 | (get_state_uri), 146 | (get_state_uri2), 147 | ], 148 | ) 149 | @patch("aioswitcher.api.SwitcherApi.get_state") 150 | async def test_successful_get_state_get_request( 151 | api_get_state, 152 | response_serializer, 153 | response_mock, 154 | api_connect, 155 | api_disconnect, 156 | api_client, 157 | api_uri, 158 | ): 159 | # stub api_get_state to return mock response 160 | api_get_state.return_value = response_mock 161 | # send get request for get_state endpoint 162 | response = await api_client.get(api_uri) 163 | # verify mocks calling 164 | api_connect.assert_called_once() 165 | api_get_state.assert_called_once_with() 166 | response_serializer.assert_called_once_with(response_mock) 167 | api_disconnect.assert_called_once() 168 | # assert the expected response 169 | assert_that(response.status).is_equal_to(200) 170 | assert_that(await response.json()).contains_entry(fake_serialized_data) 171 | 172 | 173 | @patch("aioswitcher.api.SwitcherApi.get_state", side_effect=Exception("blabla")) 174 | async def test_erroneous_get_state_get_request( 175 | api_get_state, response_serializer, api_connect, api_disconnect, api_client 176 | ): 177 | # send get request for get_state endpoint 178 | response = await api_client.get(get_state_uri) 179 | # verify mocks calling 180 | api_connect.assert_called_once() 181 | api_get_state.assert_called_once_with() 182 | response_serializer.assert_not_called() 183 | api_disconnect.assert_called_once() 184 | # assert the expected response 185 | assert_that(response.status).is_equal_to(500) 186 | assert_that(await response.json()).contains_entry({"error": "blabla"}) 187 | 188 | 189 | @mark.parametrize( 190 | "api_uri, json_body, expected_values", 191 | [ 192 | (turn_on_uri, dict(), (Command.ON, 0)), 193 | # &minutes=15 194 | (turn_on_uri, {webapp.KEY_MINUTES: "15"}, (Command.ON, 15)), 195 | (turn_on_uri2, dict(), (Command.ON, 0)), 196 | # &minutes=15 197 | (turn_on_uri2, {webapp.KEY_MINUTES: "15"}, (Command.ON, 15)), 198 | ], 199 | ) 200 | @patch("aioswitcher.api.SwitcherApi.control_device") 201 | async def test_successful_turn_on_post_request( 202 | api_control_device, 203 | response_serializer, 204 | response_mock, 205 | api_connect, 206 | api_disconnect, 207 | api_client, 208 | api_uri, 209 | json_body, 210 | expected_values, 211 | ): 212 | # stub api_control_device to return mock response 213 | api_control_device.return_value = response_mock 214 | # send post request for turn_on endpoint 215 | response = await api_client.post(api_uri, json=json_body) 216 | # verify mocks calling 217 | api_connect.assert_called_once() 218 | api_control_device.assert_called_once_with(expected_values[0], expected_values[1]) 219 | response_serializer.assert_called_once_with(response_mock) 220 | api_disconnect.assert_called_once() 221 | # assert the expected response 222 | assert_that(response.status).is_equal_to(200) 223 | assert_that(await response.json()).contains_entry(fake_serialized_data) 224 | 225 | 226 | @patch("aioswitcher.api.SwitcherApi.control_device", side_effect=Exception("blabla")) 227 | async def test_erroneous_turn_on_post_request( 228 | api_control_device, response_serializer, api_connect, api_disconnect, api_client 229 | ): 230 | # send post request for turn_on endpoint 231 | response = await api_client.post(turn_on_uri) 232 | # verify mocks calling 233 | api_connect.assert_called_once() 234 | api_control_device.assert_called_once_with(Command.ON, 0) 235 | response_serializer.assert_not_called() 236 | api_disconnect.assert_called_once() 237 | # assert the expected response 238 | assert_that(response.status).is_equal_to(500) 239 | assert_that(await response.json()).contains_entry({"error": "blabla"}) 240 | 241 | 242 | @mark.parametrize( 243 | "api_uri", 244 | [ 245 | (turn_off_uri), 246 | (turn_off_uri2), 247 | ], 248 | ) 249 | @patch("aioswitcher.api.SwitcherApi.control_device") 250 | async def test_successful_turn_off_post_request( 251 | api_control_device, 252 | response_serializer, 253 | response_mock, 254 | api_connect, 255 | api_disconnect, 256 | api_client, 257 | api_uri, 258 | ): 259 | # stub api_control_device to return mock response 260 | api_control_device.return_value = response_mock 261 | # send post request for turn_off endpoint 262 | response = await api_client.post(api_uri) 263 | # verify mocks calling 264 | api_connect.assert_called_once() 265 | api_control_device.assert_called_once_with(Command.OFF) 266 | response_serializer.assert_called_once_with(response_mock) 267 | api_disconnect.assert_called_once() 268 | # assert the expected response 269 | assert_that(response.status).is_equal_to(200) 270 | assert_that(await response.json()).contains_entry(fake_serialized_data) 271 | 272 | 273 | @patch("aioswitcher.api.SwitcherApi.control_device", side_effect=Exception("blabla")) 274 | async def test_erroneous_turn_off_post_request( 275 | api_control_device, response_serializer, api_connect, api_disconnect, api_client 276 | ): 277 | # send post request for turn_off endpoint 278 | response = await api_client.post(turn_off_uri) 279 | # verify mocks calling 280 | api_connect.assert_called_once() 281 | api_control_device.assert_called_once_with(Command.OFF) 282 | response_serializer.assert_not_called() 283 | api_disconnect.assert_called_once() 284 | # assert the expected response 285 | assert_that(response.status).is_equal_to(500) 286 | assert_that(await response.json()).contains_entry({"error": "blabla"}) 287 | 288 | 289 | @mark.parametrize( 290 | "api_uri", 291 | [ 292 | (set_name_uri), 293 | (set_name_uri2), 294 | ], 295 | ) 296 | @patch("aioswitcher.api.SwitcherApi.set_device_name") 297 | async def test_successful_set_name_patch_request( 298 | api_set_device_name, 299 | response_serializer, 300 | response_mock, 301 | api_connect, 302 | api_disconnect, 303 | api_client, 304 | api_uri, 305 | ): 306 | # stub api_set_device_name to return mock response 307 | api_set_device_name.return_value = response_mock 308 | # send patch request for set_name endpoint 309 | response = await api_client.patch(api_uri, json={webapp.KEY_NAME: "newFakedName"}) 310 | # verify mocks calling 311 | api_connect.assert_called_once() 312 | api_set_device_name.assert_called_once_with("newFakedName") 313 | response_serializer.assert_called_once_with(response_mock) 314 | api_disconnect.assert_called_once() 315 | # assert the expected response 316 | assert_that(response.status).is_equal_to(200) 317 | assert_that(await response.json()).contains_entry(fake_serialized_data) 318 | 319 | 320 | @patch("aioswitcher.api.SwitcherApi.set_device_name", side_effect=Exception("blabla")) 321 | async def test_erroneous_set_name_patch_request( 322 | api_set_device_name, response_serializer, api_connect, api_disconnect, api_client 323 | ): 324 | # send patch request for set_name endpoint 325 | response = await api_client.patch( 326 | set_name_uri, json={webapp.KEY_NAME: "newFakedName"} 327 | ) 328 | # verify mocks calling 329 | api_connect.assert_called_once() 330 | api_set_device_name.assert_called_once_with("newFakedName") 331 | response_serializer.assert_not_called() 332 | api_disconnect.assert_called_once() 333 | # assert the expected response 334 | assert_that(response.status).is_equal_to(500) 335 | assert_that(await response.json()).contains_entry({"error": "blabla"}) 336 | 337 | 338 | @patch("aioswitcher.api.SwitcherApi.set_device_name") 339 | async def test_set_name_faulty_no_name_patch_request( 340 | api_set_device_name, response_serializer, api_connect, api_disconnect, api_client 341 | ): 342 | # send patch request for set_name endpoint 343 | response = await api_client.patch(set_name_uri) 344 | # verify mocks calling 345 | api_connect.assert_not_called() 346 | api_set_device_name.assert_not_called() 347 | response_serializer.assert_not_called() 348 | api_disconnect.assert_not_called() 349 | # assert the expected response 350 | assert_that(response.status).is_equal_to(500) 351 | assert_that(await response.json()).contains_entry( 352 | {"error": "failed to get name from body as json"} 353 | ) 354 | 355 | 356 | @mark.parametrize( 357 | "api_uri, json_body, expected_timedelta", 358 | [ 359 | (set_auto_shutdown_uri, {webapp.KEY_HOURS: "2"}, timedelta(hours=2)), 360 | ( 361 | set_auto_shutdown_uri, 362 | {webapp.KEY_HOURS: "2", webapp.KEY_MINUTES: "30"}, 363 | timedelta(hours=2, minutes=30), 364 | ), 365 | (set_auto_shutdown_uri2, {webapp.KEY_HOURS: "2"}, timedelta(hours=2)), 366 | ( 367 | set_auto_shutdown_uri2, 368 | {webapp.KEY_HOURS: "2", webapp.KEY_MINUTES: "30"}, 369 | timedelta(hours=2, minutes=30), 370 | ), 371 | ], 372 | ) 373 | @patch("aioswitcher.api.SwitcherApi.set_auto_shutdown") 374 | async def test_successful_set_auto_shutdown_patch_request( 375 | api_set_auto_shutdown, 376 | response_serializer, 377 | response_mock, 378 | api_connect, 379 | api_disconnect, 380 | api_client, 381 | api_uri, 382 | json_body, 383 | expected_timedelta, 384 | ): 385 | # stub api_set_auto_shutdown to return mock response 386 | api_set_auto_shutdown.return_value = response_mock 387 | # send patch request for set_auto_shutdown endpoint 388 | response = await api_client.patch(api_uri, json=json_body) 389 | # verify mocks calling 390 | api_connect.assert_called_once() 391 | api_set_auto_shutdown.assert_called_once_with(expected_timedelta) 392 | response_serializer.assert_called_once_with(response_mock) 393 | api_disconnect.assert_called_once() 394 | # assert the expected response 395 | assert_that(response.status).is_equal_to(200) 396 | assert_that(await response.json()).contains_entry(fake_serialized_data) 397 | 398 | 399 | @patch("aioswitcher.api.SwitcherApi.set_auto_shutdown") 400 | async def test_set_auto_shutdown_with_faulty_no_hours_patch_request( 401 | api_set_auto_shutdown, response_serializer, api_connect, api_disconnect, api_client 402 | ): 403 | # send patch request for set_auto_shutdown endpoint 404 | response = await api_client.patch(set_auto_shutdown_uri) 405 | # verify mocks calling 406 | api_connect.assert_not_called() 407 | api_set_auto_shutdown.assert_not_called() 408 | response_serializer.assert_not_called() 409 | api_disconnect.assert_not_called() 410 | # assert the expected response 411 | assert_that(response.status).is_equal_to(500) 412 | assert_that(await response.json()).contains_entry( 413 | {"error": "failed to get hours from body as json"} 414 | ) 415 | 416 | 417 | @patch( 418 | "aioswitcher.api.SwitcherApi.set_auto_shutdown", 419 | side_effect=Exception("blabla"), 420 | ) 421 | async def test_erroneous_set_auto_shutdown_patch_request( 422 | api_set_auto_shutdown, response_serializer, api_connect, api_disconnect, api_client 423 | ): 424 | # send patch request for set_auto_shutdown endpoint 425 | response = await api_client.patch(set_auto_shutdown_uri, json={webapp.KEY_HOURS: 2}) 426 | # verify mocks calling 427 | api_connect.assert_called_once() 428 | api_set_auto_shutdown.assert_called_once_with(timedelta(hours=2)) 429 | response_serializer.assert_not_called() 430 | api_disconnect.assert_called_once() 431 | # assert the expected response 432 | assert_that(response.status).is_equal_to(500) 433 | assert_that(await response.json()).contains_entry({"error": "blabla"}) 434 | 435 | 436 | @mark.parametrize( 437 | "api_uri", 438 | [ 439 | (get_schedules_uri), 440 | (get_schedules_uri2), 441 | ], 442 | ) 443 | @patch("aioswitcher.api.SwitcherApi.get_schedules") 444 | async def test_successful_get_schedules_get_request( 445 | api_get_schedules, 446 | response_serializer, 447 | response_mock, 448 | api_connect, 449 | api_disconnect, 450 | api_client, 451 | api_uri, 452 | ): 453 | # stub mock response to return a set of two mock schedules 454 | schedule1 = schedule2 = Mock() 455 | response_mock.schedules = {schedule1, schedule2} 456 | # stub api_get_schedules to return mock response 457 | api_get_schedules.return_value = response_mock 458 | # send get request for get_schedules endpoint 459 | response = await api_client.get(api_uri) 460 | # verify mocks calling 461 | api_connect.assert_called_once() 462 | api_get_schedules.assert_called_once_with() 463 | response_serializer.assert_called_once_with(schedule1) 464 | response_serializer.assert_called_once_with(schedule2) 465 | api_disconnect.assert_called_once() 466 | # assert the expected response 467 | assert_that(response.status).is_equal_to(200) 468 | assert_that(await response.json()).contains(fake_serialized_data) 469 | 470 | 471 | @patch("aioswitcher.api.SwitcherApi.get_schedules", side_effect=Exception("blabla")) 472 | async def test_erroneous_get_schedules_get_request( 473 | api_get_schedules, response_serializer, api_connect, api_disconnect, api_client 474 | ): 475 | # send get request for get_schedules endpoint 476 | response = await api_client.get(get_schedules_uri) 477 | # verify mocks calling 478 | api_connect.assert_called_once() 479 | api_get_schedules.assert_called_once_with() 480 | response_serializer.assert_not_called() 481 | api_disconnect.assert_called_once() 482 | # assert the expected response 483 | assert_that(response.status).is_equal_to(500) 484 | assert_that(await response.json()).contains_entry({"error": "blabla"}) 485 | 486 | 487 | @mark.parametrize( 488 | "api_uri", 489 | [ 490 | (delete_schedule_uri), 491 | (delete_schedule_uri2), 492 | ], 493 | ) 494 | @patch("aioswitcher.api.SwitcherApi.delete_schedule") 495 | async def test_successful_delete_schedule_delete_request( 496 | api_delete_schedule, 497 | response_serializer, 498 | response_mock, 499 | api_connect, 500 | api_disconnect, 501 | api_client, 502 | api_uri, 503 | ): 504 | # stub api_delete_schedule to return mock response 505 | api_delete_schedule.return_value = response_mock 506 | # send delete request for delete_schedule endpoint 507 | response = await api_client.delete(api_uri, json={webapp.KEY_SCHEDULE: "5"}) 508 | # verify mocks calling 509 | api_connect.assert_called_once() 510 | api_delete_schedule.assert_called_once_with("5") 511 | response_serializer.assert_called_once_with(response_mock) 512 | api_disconnect.assert_called_once() 513 | # assert the expected response 514 | assert_that(response.status).is_equal_to(200) 515 | assert_that(await response.json()).contains_entry(fake_serialized_data) 516 | 517 | 518 | @patch("aioswitcher.api.SwitcherApi.delete_schedule") 519 | async def test_delete_schedule_with_faulty_no_schedule_delete_request( 520 | api_delete_schedule, response_serializer, api_connect, api_disconnect, api_client 521 | ): 522 | # send delete request for delete_schedule endpoint 523 | response = await api_client.delete(delete_schedule_uri) 524 | # verify mocks calling 525 | api_connect.assert_not_called() 526 | api_delete_schedule.assert_not_called() 527 | response_serializer.assert_not_called() 528 | api_disconnect.assert_not_called() 529 | # assert the expected response 530 | assert_that(response.status).is_equal_to(500) 531 | assert_that(await response.json()).contains_entry( 532 | {"error": "failed to get schedule from body as json"} 533 | ) 534 | 535 | 536 | @patch("aioswitcher.api.SwitcherApi.delete_schedule", side_effect=Exception("blabla")) 537 | async def test_errorneous_delete_schedule_delete_request( 538 | api_delete_schedule, response_serializer, api_connect, api_disconnect, api_client 539 | ): 540 | # send delete request for delete_schedule endpoint 541 | response = await api_client.delete( 542 | delete_schedule_uri, json={webapp.KEY_SCHEDULE: "5"} 543 | ) 544 | # verify mocks calling 545 | api_connect.assert_called_once() 546 | api_delete_schedule.assert_called_once_with("5") 547 | response_serializer.assert_not_called() 548 | api_disconnect.assert_called_once() 549 | # assert the expected response 550 | assert_that(response.status).is_equal_to(500) 551 | assert_that(await response.json()).contains_entry({"error": "blabla"}) 552 | 553 | 554 | @mark.parametrize( 555 | "api_uri, json_body, expected_values", 556 | [ 557 | ( 558 | create_schedule_uri, 559 | {webapp.KEY_START: "14:00", webapp.KEY_STOP: "15:30"}, 560 | ("14:00", "15:30", set()), 561 | ), 562 | ( 563 | create_schedule_uri, 564 | { 565 | webapp.KEY_START: "13:30", 566 | webapp.KEY_STOP: "14:00", 567 | webapp.KEY_DAYS: ["Sunday", "Monday", "Friday"], 568 | }, 569 | ("13:30", "14:00", {Days.SUNDAY, Days.MONDAY, Days.FRIDAY}), 570 | ), 571 | ( 572 | create_schedule_uri, 573 | { 574 | webapp.KEY_START: "18:15", 575 | webapp.KEY_STOP: "19:00", 576 | webapp.KEY_DAYS: [ 577 | "Sunday", 578 | "Monday", 579 | "Tuesday", 580 | "Wednesday", 581 | "Thursday", 582 | "Friday", 583 | "Saturday", 584 | ], 585 | }, 586 | ( 587 | "18:15", 588 | "19:00", 589 | { 590 | Days.SUNDAY, 591 | Days.MONDAY, 592 | Days.TUESDAY, 593 | Days.WEDNESDAY, 594 | Days.THURSDAY, 595 | Days.FRIDAY, 596 | Days.SATURDAY, 597 | }, 598 | ), 599 | ), 600 | ( 601 | create_schedule_uri2, 602 | {webapp.KEY_START: "14:00", webapp.KEY_STOP: "15:30"}, 603 | ("14:00", "15:30", set()), 604 | ), 605 | ( 606 | create_schedule_uri2, 607 | { 608 | webapp.KEY_START: "13:30", 609 | webapp.KEY_STOP: "14:00", 610 | webapp.KEY_DAYS: ["Sunday", "Monday", "Friday"], 611 | }, 612 | ("13:30", "14:00", {Days.SUNDAY, Days.MONDAY, Days.FRIDAY}), 613 | ), 614 | ( 615 | create_schedule_uri2, 616 | { 617 | webapp.KEY_START: "18:15", 618 | webapp.KEY_STOP: "19:00", 619 | webapp.KEY_DAYS: [ 620 | "Sunday", 621 | "Monday", 622 | "Tuesday", 623 | "Wednesday", 624 | "Thursday", 625 | "Friday", 626 | "Saturday", 627 | ], 628 | }, 629 | ( 630 | "18:15", 631 | "19:00", 632 | { 633 | Days.SUNDAY, 634 | Days.MONDAY, 635 | Days.TUESDAY, 636 | Days.WEDNESDAY, 637 | Days.THURSDAY, 638 | Days.FRIDAY, 639 | Days.SATURDAY, 640 | }, 641 | ), 642 | ), 643 | ], 644 | ) 645 | @patch("aioswitcher.api.SwitcherApi.create_schedule") 646 | async def test_successful_create_schedule_post_request( 647 | api_create_schedule, 648 | response_serializer, 649 | response_mock, 650 | api_connect, 651 | api_disconnect, 652 | api_client, 653 | api_uri, 654 | json_body, 655 | expected_values, 656 | ): 657 | # stub api_delete_schedule to return mock response 658 | api_create_schedule.return_value = response_mock 659 | # send post request for create schedule endpoint 660 | response = await api_client.post(api_uri, json=json_body) 661 | # verify mocks calling 662 | api_connect.assert_called_once() 663 | api_create_schedule.assert_called_once_with( 664 | expected_values[0], expected_values[1], expected_values[2] 665 | ) 666 | response_serializer.assert_called_once_with(response_mock) 667 | api_disconnect.assert_called_once() 668 | # assert expected response 669 | assert_that(response.status).is_equal_to(200) 670 | assert_that(await response.json()).contains_entry(fake_serialized_data) 671 | 672 | 673 | @mark.parametrize( 674 | "missing_key_json_body,expected_error_msg", 675 | [ 676 | ({webapp.KEY_START: "18:15"}, "'stop'"), 677 | ({webapp.KEY_STOP: "19:00"}, "'start'"), 678 | ], 679 | ) 680 | @patch("aioswitcher.api.SwitcherApi.create_schedule") 681 | async def test_create_schedule_with_faulty_missing_key_post_request( 682 | api_create_schedule, 683 | response_serializer, 684 | api_connect, 685 | api_disconnect, 686 | api_client, 687 | missing_key_json_body, 688 | expected_error_msg, 689 | ): 690 | # send post request for create schedule endpoint 691 | response = await api_client.post(create_schedule_uri, json=missing_key_json_body) 692 | # verify mocks calling 693 | api_connect.assert_not_called() 694 | api_create_schedule.assert_not_called() 695 | response_serializer.assert_not_called() 696 | api_disconnect.assert_not_called() 697 | # assert expected response 698 | assert_that(response.status).is_equal_to(500) 699 | assert_that(await response.json()).contains_entry({"error": expected_error_msg}) 700 | 701 | 702 | @patch("aioswitcher.api.SwitcherApi.create_schedule", side_effect=Exception("blabla")) 703 | async def test_errorneous_create_schedule( 704 | api_create_schedule, response_serializer, api_connect, api_disconnect, api_client 705 | ): 706 | json_body = {webapp.KEY_START: "11:00", webapp.KEY_STOP: "11:15"} 707 | # send post request for create schedule endpoint 708 | response = await api_client.post(create_schedule_uri, json=json_body) 709 | # verify mocks calling 710 | api_connect.assert_called_once() 711 | api_create_schedule.assert_called_once_with("11:00", "11:15", set()) 712 | response_serializer.assert_not_called() 713 | api_disconnect.assert_called_once() 714 | # assert the expected response 715 | assert_that(response.status).is_equal_to(500) 716 | assert_that(await response.json()).contains_entry({"error": "blabla"}) 717 | 718 | 719 | @mark.parametrize( 720 | "api_uri, json_body, expected_values", 721 | [ 722 | ( 723 | set_position_uri, 724 | {webapp.KEY_POSITION: "25"}, 725 | ( 726 | 25, 727 | 0, 728 | ), 729 | ), 730 | ( 731 | set_position_uri2, 732 | {webapp.KEY_POSITION: "25"}, 733 | ( 734 | 25, 735 | 0, 736 | ), 737 | ), 738 | ( 739 | set_position_uri3, 740 | {webapp.KEY_POSITION: "25"}, 741 | ( 742 | 25, 743 | 0, 744 | ), 745 | ), 746 | ], 747 | ) 748 | @patch("aioswitcher.api.SwitcherApi.set_position") 749 | async def test_set_position_post_request( 750 | set_position, 751 | response_serializer, 752 | api_connect, 753 | api_disconnect, 754 | api_client, 755 | api_uri, 756 | json_body, 757 | expected_values, 758 | ): 759 | # stub set_position to return mock response 760 | set_position.return_value = response_mock 761 | # send post request for create schedule endpoint 762 | response = await api_client.post(api_uri, json=json_body) 763 | # verify mocks calling 764 | api_connect.assert_called_once() 765 | set_position.assert_called_once_with(expected_values[0], expected_values[1]) 766 | response_serializer.assert_called_once_with(response_mock) 767 | api_disconnect.assert_called_once() 768 | # assert expected response 769 | assert_that(response.status).is_equal_to(200) 770 | assert_that(await response.json()).contains_entry(fake_serialized_data) 771 | 772 | 773 | @mark.parametrize( 774 | "api_uri, json_body, expected_values", 775 | [ 776 | (turn_on_shutter_child_lock_uri, dict(), (ShutterChildLock.ON, 0)), 777 | (turn_on_shutter_child_lock_uri2, dict(), (ShutterChildLock.ON, 0)), 778 | (turn_on_shutter_child_lock_uri3, dict(), (ShutterChildLock.ON, 0)), 779 | ], 780 | ) 781 | @patch("aioswitcher.api.SwitcherApi.set_shutter_child_lock") 782 | async def test_successful_turn_on_shutter_child_lock_post_request( 783 | set_shutter_child_lock, 784 | response_serializer, 785 | response_mock, 786 | api_connect, 787 | api_disconnect, 788 | api_client, 789 | api_uri, 790 | json_body, 791 | expected_values, 792 | ): 793 | # stub api_turn_on_shutter_child_lock to return mock response 794 | set_shutter_child_lock.return_value = response_mock 795 | # send post request for turn_on_shutter_child_lock endpoint 796 | response = await api_client.post(api_uri, json=json_body) 797 | # verify mocks calling 798 | api_connect.assert_called_once() 799 | set_shutter_child_lock.assert_called_once_with( 800 | expected_values[0], expected_values[1] 801 | ) 802 | response_serializer.assert_called_once_with(response_mock) 803 | api_disconnect.assert_called_once() 804 | # assert the expected response 805 | assert_that(response.status).is_equal_to(200) 806 | assert_that(await response.json()).contains_entry(fake_serialized_data) 807 | 808 | 809 | @mark.parametrize( 810 | "api_uri, json_body, expected_values", 811 | [ 812 | (turn_off_shutter_child_lock_uri, dict(), (ShutterChildLock.OFF, 0)), 813 | (turn_off_shutter_child_lock_uri2, dict(), (ShutterChildLock.OFF, 0)), 814 | (turn_off_shutter_child_lock_uri3, dict(), (ShutterChildLock.OFF, 0)), 815 | ], 816 | ) 817 | @patch("aioswitcher.api.SwitcherApi.set_shutter_child_lock") 818 | async def test_successful_turn_off_shutter_child_lock_post_request( 819 | set_shutter_child_lock, 820 | response_serializer, 821 | response_mock, 822 | api_connect, 823 | api_disconnect, 824 | api_client, 825 | api_uri, 826 | json_body, 827 | expected_values, 828 | ): 829 | # stub turn_off_shutter_child_lock to return mock response 830 | set_shutter_child_lock.return_value = response_mock 831 | # send post request for turn_off_shutter_child_lock endpoint 832 | response = await api_client.post(api_uri, json=json_body) 833 | # verify mocks calling 834 | api_connect.assert_called_once() 835 | set_shutter_child_lock.assert_called_once_with( 836 | expected_values[0], expected_values[1] 837 | ) 838 | response_serializer.assert_called_once_with(response_mock) 839 | api_disconnect.assert_called_once() 840 | # assert the expected response 841 | assert_that(response.status).is_equal_to(200) 842 | assert_that(await response.json()).contains_entry(fake_serialized_data) 843 | 844 | 845 | @mark.parametrize( 846 | "api_uri, json_body, expected_values", 847 | [ 848 | (turn_on_light_uri, dict(), (DeviceState.ON, 0)), 849 | ], 850 | ) 851 | @patch("aioswitcher.api.SwitcherApi.set_light") 852 | async def test_successful_turn_on_light_post_request( 853 | set_light, 854 | response_serializer, 855 | response_mock, 856 | api_connect, 857 | api_disconnect, 858 | api_client, 859 | api_uri, 860 | json_body, 861 | expected_values, 862 | ): 863 | # stub api_turn_on_light to return mock response 864 | set_light.return_value = response_mock 865 | # send post request for turn_on_light endpoint 866 | response = await api_client.post(api_uri, json=json_body) 867 | # verify mocks calling 868 | api_connect.assert_called_once() 869 | set_light.assert_called_once_with(expected_values[0], expected_values[1]) 870 | response_serializer.assert_called_once_with(response_mock) 871 | api_disconnect.assert_called_once() 872 | # assert the expected response 873 | assert_that(response.status).is_equal_to(200) 874 | assert_that(await response.json()).contains_entry(fake_serialized_data) 875 | 876 | 877 | @mark.parametrize( 878 | "api_uri, json_body, expected_values", 879 | [ 880 | (turn_off_light_uri, dict(), (DeviceState.OFF, 0)), 881 | ], 882 | ) 883 | @patch("aioswitcher.api.SwitcherApi.set_light") 884 | async def test_successful_turn_off_light_post_request( 885 | set_light, 886 | response_serializer, 887 | response_mock, 888 | api_connect, 889 | api_disconnect, 890 | api_client, 891 | api_uri, 892 | json_body, 893 | expected_values, 894 | ): 895 | # stub api_turn_off_light to return mock response 896 | set_light.return_value = response_mock 897 | # send post request for turn_off_light endpoint 898 | response = await api_client.post(api_uri, json=json_body) 899 | # verify mocks calling 900 | api_connect.assert_called_once() 901 | set_light.assert_called_once_with(expected_values[0], expected_values[1]) 902 | response_serializer.assert_called_once_with(response_mock) 903 | api_disconnect.assert_called_once() 904 | # assert the expected response 905 | assert_that(response.status).is_equal_to(200) 906 | assert_that(await response.json()).contains_entry(fake_serialized_data) 907 | 908 | 909 | @mark.parametrize( 910 | "api_uri", 911 | [ 912 | (get_breeze_state_uri), 913 | (get_breeze_state_uri2), 914 | ], 915 | ) 916 | @patch("aioswitcher.api.SwitcherApi.get_breeze_state") 917 | async def test_successful_get_breeze_state_get_request( 918 | get_breeze_state, 919 | response_serializer, 920 | response_mock, 921 | api_connect, 922 | api_disconnect, 923 | api_client, 924 | api_uri, 925 | ): 926 | # stub mock response to return a set mocked state 927 | state = Mock() 928 | response_mock = state 929 | # stub api_get_schedules to return mock response 930 | get_breeze_state.return_value = response_mock 931 | # send get request for get_schedules endpoint 932 | response = await api_client.get(api_uri) 933 | # verify mocks calling 934 | api_connect.assert_called_once() 935 | get_breeze_state.assert_called_once_with() 936 | response_serializer.assert_called_once_with(state) 937 | api_disconnect.assert_called_once() 938 | # assert the expected response 939 | assert_that(response.status).is_equal_to(200) 940 | assert_that(fake_serialized_data).is_subset_of(await response.json()) 941 | 942 | 943 | @mark.parametrize( 944 | "api_uri", 945 | [ 946 | (get_shutter_state_uri), 947 | (get_shutter_state_uri2), 948 | (get_shutter_state_uri3), 949 | ], 950 | ) 951 | @patch("aioswitcher.api.SwitcherApi.get_shutter_state") 952 | async def test_successful_get_shutter_state_get_request( 953 | get_shutter_state, 954 | response_serializer, 955 | response_mock, 956 | api_connect, 957 | api_disconnect, 958 | api_client, 959 | api_uri, 960 | ): 961 | # stub mock response to return a set mocked state 962 | state = Mock() 963 | response_mock = state 964 | # stub api_get_schedules to return mock response 965 | get_shutter_state.return_value = response_mock 966 | # send get request for get_schedules endpoint 967 | response = await api_client.get(api_uri) 968 | # verify mocks calling 969 | api_connect.assert_called_once() 970 | get_shutter_state.assert_called_once_with(0) 971 | response_serializer.assert_called_once_with(state) 972 | api_disconnect.assert_called_once() 973 | # assert the expected response 974 | assert_that(response.status).is_equal_to(200) 975 | assert_that(fake_serialized_data).is_subset_of(await response.json()) 976 | 977 | 978 | @mark.parametrize( 979 | "api_uri", 980 | [ 981 | (get_stop_shutter_uri), 982 | (get_stop_shutter_uri2), 983 | (get_stop_shutter_uri3), 984 | ], 985 | ) 986 | @patch("aioswitcher.api.SwitcherApi.stop_shutter") 987 | async def test_stop_shutter_post_request( 988 | stop_shutter, response_serializer, api_connect, api_disconnect, api_client, api_uri 989 | ): 990 | # stub set_position to return mock response 991 | stop_shutter.return_value = response_mock 992 | # send post request for create schedule endpoint 993 | response = await api_client.post(api_uri, json={}) 994 | # verify mocks calling 995 | api_connect.assert_called_once() 996 | stop_shutter.assert_called_once_with(0) 997 | response_serializer.assert_called_once_with(response_mock) 998 | api_disconnect.assert_called_once() 999 | # assert expected response 1000 | assert_that(response.status).is_equal_to(200) 1001 | assert_that(await response.json()).contains_entry(fake_serialized_data) 1002 | 1003 | 1004 | @mark.parametrize( 1005 | "api_uri", 1006 | [ 1007 | (set_control_breeze_device_uri), 1008 | (set_control_breeze_device_uri2), 1009 | ], 1010 | ) 1011 | @patch("aioswitcher.api.SwitcherApi.control_breeze_device") 1012 | async def test_control_breeze_device_patch_request( 1013 | control_breeze_device, 1014 | response_serializer, 1015 | response_mock, 1016 | api_connect, 1017 | api_disconnect, 1018 | api_client, 1019 | api_uri, 1020 | ): 1021 | # stub api_set_device_name to return mock response 1022 | control_breeze_device.return_value = response_mock 1023 | # send patch request for control_breeze_device endpoint 1024 | response = await api_client.patch( 1025 | api_uri, 1026 | json={ 1027 | webapp.KEY_DEVICE_STATE: "on", 1028 | webapp.KEY_THERMOSTAT_MODE: "auto", 1029 | webapp.KEY_TARGET_TEMP: 25, 1030 | webapp.KEY_FAN_LEVEL: "low", 1031 | webapp.KEY_THERMOSTAT_SWING: "off", 1032 | webapp.KEY_CURRENT_DEVICE_STATE: "off", 1033 | webapp.KEY_REMOTE_ID: "AUX07001", 1034 | }, 1035 | ) 1036 | # verify mocks calling 1037 | api_connect.assert_called_once() 1038 | control_breeze_device.assert_called_once() 1039 | response_serializer.assert_called_once_with(response_mock) 1040 | api_disconnect.assert_called_once() 1041 | # assert the expected response 1042 | assert_that(response.status).is_equal_to(200) 1043 | assert_that(await response.json()).contains_entry(fake_serialized_data) 1044 | 1045 | 1046 | @patch("aioswitcher.api.SwitcherApi.control_breeze_device") 1047 | async def test_control_breeze_device_patch_request_only_device_state( 1048 | control_breeze_device, 1049 | response_serializer, 1050 | response_mock, 1051 | api_connect, 1052 | api_disconnect, 1053 | api_client, 1054 | ): 1055 | # stub api_set_device_name to return mock response 1056 | control_breeze_device.return_value = response_mock 1057 | # send patch request for control_breeze_device endpoint 1058 | response = await api_client.patch( 1059 | set_control_breeze_device_uri, 1060 | json={webapp.KEY_DEVICE_STATE: "on", webapp.KEY_REMOTE_ID: "AUX07001"}, 1061 | ) 1062 | # verify mocks calling 1063 | api_connect.assert_called_once() 1064 | control_breeze_device.assert_called_once() 1065 | response_serializer.assert_called_once_with(response_mock) 1066 | api_disconnect.assert_called_once() 1067 | # assert the expected response 1068 | assert_that(response.status).is_equal_to(200) 1069 | assert_that(await response.json()).contains_entry(fake_serialized_data) 1070 | 1071 | 1072 | @patch("aioswitcher.api.SwitcherApi.control_breeze_device") 1073 | async def test_control_breeze_device_patch_request_only_target_temp( 1074 | control_breeze_device, 1075 | response_serializer, 1076 | response_mock, 1077 | api_connect, 1078 | api_disconnect, 1079 | api_client, 1080 | ): 1081 | # stub api_set_device_name to return mock response 1082 | control_breeze_device.return_value = response_mock 1083 | # send patch request for control_breeze_device endpoint 1084 | response = await api_client.patch( 1085 | set_control_breeze_device_uri, 1086 | json={webapp.KEY_TARGET_TEMP: 25, webapp.KEY_REMOTE_ID: "AUX07001"}, 1087 | ) 1088 | # verify mocks calling 1089 | api_connect.assert_called_once() 1090 | control_breeze_device.assert_called_once() 1091 | response_serializer.assert_called_once_with(response_mock) 1092 | api_disconnect.assert_called_once() 1093 | # assert the expected response 1094 | assert_that(response.status).is_equal_to(200) 1095 | assert_that(await response.json()).contains_entry(fake_serialized_data) 1096 | 1097 | 1098 | @mark.parametrize( 1099 | "api_uri", 1100 | [ 1101 | (get_light_state_uri), 1102 | ], 1103 | ) 1104 | @patch("aioswitcher.api.SwitcherApi.get_light_state") 1105 | async def test_successful_get_light_state_get_request( 1106 | get_light_state, 1107 | response_serializer, 1108 | response_mock, 1109 | api_connect, 1110 | api_disconnect, 1111 | api_client, 1112 | api_uri, 1113 | ): 1114 | # stub mock response to return a set mocked state 1115 | state = Mock() 1116 | response_mock = state 1117 | # stub api_get_light_state to return mock response 1118 | get_light_state.return_value = response_mock 1119 | # send get request for get_light_state endpoint 1120 | response = await api_client.get(api_uri) 1121 | # verify mocks calling 1122 | api_connect.assert_called_once() 1123 | get_light_state.assert_called_once_with(0) 1124 | response_serializer.assert_called_once_with(state) 1125 | api_disconnect.assert_called_once() 1126 | # assert the expected response 1127 | assert_that(response.status).is_equal_to(200) 1128 | assert_that(fake_serialized_data).is_subset_of(await response.json()) 1129 | -------------------------------------------------------------------------------- /app/webapp.py: -------------------------------------------------------------------------------- 1 | """Web service implemented with aiohttp for integrating the Switcher smart devices.""" 2 | 3 | from argparse import ArgumentParser 4 | from datetime import timedelta 5 | from enum import Enum 6 | from logging import config 7 | from typing import Callable, Dict, List, Set, Union 8 | 9 | from aiohttp import web 10 | from aiohttp.abc import AbstractAccessLogger 11 | from aiohttp.log import ( 12 | access_logger, 13 | client_logger, 14 | server_logger, 15 | web_logger, 16 | ws_logger, 17 | ) 18 | from aiohttp.web_request import BaseRequest 19 | from aiohttp.web_response import StreamResponse 20 | from aioswitcher.api import Command, SwitcherApi 21 | from aioswitcher.api.remotes import SwitcherBreezeRemoteManager 22 | from aioswitcher.device import ( 23 | DeviceState, 24 | DeviceType, 25 | ShutterChildLock, 26 | ThermostatFanLevel, 27 | ThermostatMode, 28 | ThermostatSwing, 29 | ) 30 | from aioswitcher.schedule import Days 31 | 32 | KEY_TYPE = "type" 33 | KEY_ID = "id" 34 | KEY_LOGIN_KEY = "key" 35 | KEY_IP = "ip" 36 | KEY_INDEX = "index" 37 | KEY_TOKEN = "token" 38 | KEY_NAME = "name" 39 | KEY_HOURS = "hours" 40 | KEY_MINUTES = "minutes" 41 | KEY_SCHEDULE = "schedule" 42 | KEY_START = "start" 43 | KEY_STOP = "stop" 44 | KEY_DAYS = "days" 45 | KEY_POSITION = "position" 46 | KEY_DEVICE_STATE = "device_state" 47 | KEY_THERMOSTAT_MODE = "thermostat_mode" 48 | KEY_TARGET_TEMP = "target_temp" 49 | KEY_FAN_LEVEL = "fan_level" 50 | KEY_THERMOSTAT_SWING = "thermostat_swing" 51 | KEY_CURRENT_DEVICE_STATE = "current_device_state" 52 | KEY_REMOTE_ID = "remote_id" 53 | 54 | ENDPOINT_GET_STATE = "/switcher/get_state" 55 | ENDPOINT_TURN_ON = "/switcher/turn_on" 56 | ENDPOINT_TURN_OFF = "/switcher/turn_off" 57 | ENDPOINT_SET_AUTO_SHUTDOWN = "/switcher/set_auto_shutdown" 58 | ENDPOINT_SET_NAME = "/switcher/set_name" 59 | ENDPOINT_GET_SCHEDULES = "/switcher/get_schedules" 60 | ENDPOINT_DELETE_SCHEDULE = "/switcher/delete_schedule" 61 | ENDPOINT_CREATE_SCHEDULE = "/switcher/create_schedule" 62 | ENDPOINT_SET_POSITION = "/switcher/set_shutter_position" 63 | ENDPOINT_TURN_ON_SHUTTER_CHILD_LOCK = "/switcher/turn_on_shutter_child_lock" 64 | ENDPOINT_TURN_OFF_SHUTTER_CHILD_LOCK = "/switcher/turn_off_shutter_child_lock" 65 | ENDPOINT_TURN_ON_LIGHT = "/switcher/turn_on_light" 66 | ENDPOINT_TURN_OFF_LIGHT = "/switcher/turn_off_light" 67 | ENDPOINT_GET_BREEZE_STATE = "/switcher/get_breeze_state" 68 | ENDPOINT_GET_SHUTTER_STATE = "/switcher/get_shutter_state" 69 | ENDPOINT_GET_LIGHT_STATE = "/switcher/get_light_state" 70 | ENDPOINT_POST_STOP_SHUTTER = "/switcher/stop_shutter" 71 | ENDPOINT_CONTROL_BREEZE_DEVICE = "/switcher/control_breeze_device" 72 | 73 | DEVICES = { 74 | "mini": DeviceType.MINI, 75 | "plug": DeviceType.POWER_PLUG, 76 | "touch": DeviceType.TOUCH, 77 | "v2esp": DeviceType.V2_ESP, 78 | "v2qual": DeviceType.V2_QCA, 79 | "v4": DeviceType.V4, 80 | "breeze": DeviceType.BREEZE, 81 | "runner": DeviceType.RUNNER, 82 | "runnermini": DeviceType.RUNNER_MINI, 83 | "runners11": DeviceType.RUNNER_S11, 84 | "runners12": DeviceType.RUNNER_S12, 85 | "light01": DeviceType.LIGHT_SL01, 86 | "light01mini": DeviceType.LIGHT_SL01_MINI, 87 | "light02": DeviceType.LIGHT_SL02, 88 | "light02mini": DeviceType.LIGHT_SL02_MINI, 89 | "light03": DeviceType.LIGHT_SL03, 90 | } 91 | 92 | parser = ArgumentParser( 93 | description="Start an aiohttp web service integrating with Switcher devices." 94 | ) 95 | 96 | parser.add_argument( 97 | "-p", 98 | "--port", 99 | type=int, 100 | default=3698, 101 | help="port for the server to run on, default is 3698", 102 | ) 103 | 104 | parser.add_argument( 105 | "-l", 106 | "--log-level", 107 | choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], 108 | default="INFO", 109 | help="log level for reporting", 110 | ) 111 | 112 | routes = web.RouteTableDef() 113 | 114 | 115 | def _serialize_object(obj: object) -> Dict[str, Union[List[str], str]]: 116 | """Use for converting enum to primitives and remove not relevant keys .""" 117 | serialized_dict = dict() # type: Dict[str, Union[List[str], str]] 118 | for k, v in obj.__dict__.items(): 119 | if not k == "unparsed_response": 120 | if isinstance(v, Enum): 121 | serialized_dict[k] = v.name 122 | elif isinstance(v, Set): 123 | serialized_dict[k] = [m.name if isinstance(m, Enum) else m for m in v] 124 | else: 125 | serialized_dict[k] = v 126 | return serialized_dict 127 | 128 | 129 | @routes.get(ENDPOINT_GET_STATE) 130 | async def get_state(request: web.Request) -> web.Response: 131 | """Use to get the current state of the device.""" 132 | device_type = DEVICES[request.query[KEY_TYPE]] 133 | if KEY_LOGIN_KEY in request.query: 134 | login_key = request.query[KEY_LOGIN_KEY] 135 | else: 136 | login_key = "00" 137 | async with SwitcherApi( 138 | device_type, request.query[KEY_IP], request.query[KEY_ID], login_key 139 | ) as swapi: 140 | return web.json_response(_serialize_object(await swapi.get_state())) 141 | 142 | 143 | @routes.post(ENDPOINT_TURN_ON) 144 | async def turn_on(request: web.Request) -> web.Response: 145 | """Use to turn on the device.""" 146 | if request.body_exists: 147 | body = await request.json() 148 | minutes = int(body[KEY_MINUTES]) if body.get(KEY_MINUTES) else 0 149 | else: 150 | minutes = 0 151 | device_type = DEVICES[request.query[KEY_TYPE]] 152 | if KEY_LOGIN_KEY in request.query: 153 | login_key = request.query[KEY_LOGIN_KEY] 154 | else: 155 | login_key = "00" 156 | async with SwitcherApi( 157 | device_type, request.query[KEY_IP], request.query[KEY_ID], login_key 158 | ) as swapi: 159 | return web.json_response( 160 | _serialize_object(await swapi.control_device(Command.ON, minutes)) 161 | ) 162 | 163 | 164 | @routes.post(ENDPOINT_TURN_OFF) 165 | async def turn_off(request: web.Request) -> web.Response: 166 | """Use to turn on the device.""" 167 | device_type = DEVICES[request.query[KEY_TYPE]] 168 | if KEY_LOGIN_KEY in request.query: 169 | login_key = request.query[KEY_LOGIN_KEY] 170 | else: 171 | login_key = "00" 172 | async with SwitcherApi( 173 | device_type, request.query[KEY_IP], request.query[KEY_ID], login_key 174 | ) as swapi: 175 | return web.json_response( 176 | _serialize_object(await swapi.control_device(Command.OFF)) 177 | ) 178 | 179 | 180 | @routes.patch(ENDPOINT_SET_NAME) 181 | async def set_name(request: web.Request) -> web.Response: 182 | """Use to set the device's name.""" 183 | try: 184 | body = await request.json() 185 | name = body[KEY_NAME] 186 | except Exception as exc: 187 | raise ValueError(f"failed to get {KEY_NAME} from body as json") from exc 188 | device_type = DEVICES[request.query[KEY_TYPE]] 189 | if KEY_LOGIN_KEY in request.query: 190 | login_key = request.query[KEY_LOGIN_KEY] 191 | else: 192 | login_key = "00" 193 | async with SwitcherApi( 194 | device_type, request.query[KEY_IP], request.query[KEY_ID], login_key 195 | ) as swapi: 196 | return web.json_response(_serialize_object(await swapi.set_device_name(name))) 197 | 198 | 199 | @routes.patch(ENDPOINT_SET_AUTO_SHUTDOWN) 200 | async def set_auto_shutdown(request: web.Request) -> web.Response: 201 | """Use to set the device's auto shutdown configuration value.""" 202 | try: 203 | body = await request.json() 204 | hours = body[KEY_HOURS] 205 | minutes = int(body[KEY_MINUTES]) if body.get(KEY_MINUTES) else 0 206 | except Exception as exc: 207 | raise ValueError("failed to get hours from body as json") from exc 208 | device_type = DEVICES[request.query[KEY_TYPE]] 209 | if KEY_LOGIN_KEY in request.query: 210 | login_key = request.query[KEY_LOGIN_KEY] 211 | else: 212 | login_key = "00" 213 | async with SwitcherApi( 214 | device_type, request.query[KEY_IP], request.query[KEY_ID], login_key 215 | ) as swapi: 216 | return web.json_response( 217 | _serialize_object( 218 | await swapi.set_auto_shutdown( 219 | timedelta(hours=int(hours), minutes=int(minutes) if minutes else 0) 220 | ) 221 | ) 222 | ) 223 | 224 | 225 | @routes.get(ENDPOINT_GET_SCHEDULES) 226 | async def get_schedules(request: web.Request) -> web.Response: 227 | """Use to get the current configured schedules on the device.""" 228 | device_type = DEVICES[request.query[KEY_TYPE]] 229 | if KEY_LOGIN_KEY in request.query: 230 | login_key = request.query[KEY_LOGIN_KEY] 231 | else: 232 | login_key = "00" 233 | async with SwitcherApi( 234 | device_type, request.query[KEY_IP], request.query[KEY_ID], login_key 235 | ) as swapi: 236 | response = await swapi.get_schedules() 237 | return web.json_response([_serialize_object(s) for s in response.schedules]) 238 | 239 | 240 | @routes.delete(ENDPOINT_DELETE_SCHEDULE) 241 | async def delete_schedule(request: web.Request) -> web.Response: 242 | """Use to delete an existing schedule.""" 243 | try: 244 | body = await request.json() 245 | schedule_id = body[KEY_SCHEDULE] 246 | except Exception as exc: 247 | raise ValueError("failed to get schedule from body as json") from exc 248 | device_type = DEVICES[request.query[KEY_TYPE]] 249 | if KEY_LOGIN_KEY in request.query: 250 | login_key = request.query[KEY_LOGIN_KEY] 251 | else: 252 | login_key = "00" 253 | async with SwitcherApi( 254 | device_type, request.query[KEY_IP], request.query[KEY_ID], login_key 255 | ) as swapi: 256 | return web.json_response( 257 | _serialize_object(await swapi.delete_schedule(schedule_id)) 258 | ) 259 | 260 | 261 | @routes.post(ENDPOINT_CREATE_SCHEDULE) 262 | async def create_schedule(request: web.Request) -> web.Response: 263 | """Use to create a new schedule.""" 264 | weekdays = dict(map(lambda d: (d.value, d), Days)) 265 | body = await request.json() 266 | start_time = body[KEY_START] 267 | stop_time = body[KEY_STOP] 268 | selected_days = ( 269 | set([weekdays[d] for d in body[KEY_DAYS]]) if body.get(KEY_DAYS) else set() 270 | ) 271 | device_type = DEVICES[request.query[KEY_TYPE]] 272 | if KEY_LOGIN_KEY in request.query: 273 | login_key = request.query[KEY_LOGIN_KEY] 274 | else: 275 | login_key = "00" 276 | async with SwitcherApi( 277 | device_type, request.query[KEY_IP], request.query[KEY_ID], login_key 278 | ) as swapi: 279 | return web.json_response( 280 | _serialize_object( 281 | await swapi.create_schedule(start_time, stop_time, selected_days) 282 | ) 283 | ) 284 | 285 | 286 | @routes.post(ENDPOINT_SET_POSITION) 287 | async def set_position(request: web.Request) -> web.Response: 288 | """Use for setting the shutter position of the Runner and Runner Mini devices.""" 289 | try: 290 | body = await request.json() 291 | position = int(body[KEY_POSITION]) 292 | except Exception as exc: 293 | raise ValueError("failed to get position from body as json") from exc 294 | device_type = DEVICES[request.query[KEY_TYPE]] 295 | if KEY_LOGIN_KEY in request.query: 296 | login_key = request.query[KEY_LOGIN_KEY] 297 | else: 298 | login_key = "00" 299 | if KEY_INDEX in request.query: 300 | index = int(request.query[KEY_INDEX]) 301 | else: 302 | index = 0 303 | if KEY_TOKEN in request.query: 304 | token = request.query[KEY_TOKEN] 305 | else: 306 | token = None 307 | async with SwitcherApi( 308 | device_type, request.query[KEY_IP], request.query[KEY_ID], login_key, token 309 | ) as swapi: 310 | return web.json_response( 311 | _serialize_object(await swapi.set_position(position, index)) 312 | ) 313 | 314 | 315 | @routes.post(ENDPOINT_TURN_ON_SHUTTER_CHILD_LOCK) 316 | async def turn_on_shutter_child_lock(request: web.Request) -> web.Response: 317 | """Use to turn on the shutter child lock.""" 318 | device_type = DEVICES[request.query[KEY_TYPE]] 319 | if KEY_LOGIN_KEY in request.query: 320 | login_key = request.query[KEY_LOGIN_KEY] 321 | else: 322 | login_key = "00" 323 | if KEY_INDEX in request.query: 324 | index = int(request.query[KEY_INDEX]) 325 | else: 326 | index = 0 327 | if KEY_TOKEN in request.query: 328 | token = request.query[KEY_TOKEN] 329 | else: 330 | token = None 331 | async with SwitcherApi( 332 | device_type, request.query[KEY_IP], request.query[KEY_ID], login_key, token 333 | ) as swapi: 334 | return web.json_response( 335 | _serialize_object( 336 | await swapi.set_shutter_child_lock(ShutterChildLock.ON, index) 337 | ) 338 | ) 339 | 340 | 341 | @routes.post(ENDPOINT_TURN_OFF_SHUTTER_CHILD_LOCK) 342 | async def turn_off_shutter_child_lock(request: web.Request) -> web.Response: 343 | """Use to turn off the shutter child lock.""" 344 | device_type = DEVICES[request.query[KEY_TYPE]] 345 | if KEY_LOGIN_KEY in request.query: 346 | login_key = request.query[KEY_LOGIN_KEY] 347 | else: 348 | login_key = "00" 349 | if KEY_INDEX in request.query: 350 | index = int(request.query[KEY_INDEX]) 351 | else: 352 | index = 0 353 | if KEY_TOKEN in request.query: 354 | token = request.query[KEY_TOKEN] 355 | else: 356 | token = None 357 | async with SwitcherApi( 358 | device_type, request.query[KEY_IP], request.query[KEY_ID], login_key, token 359 | ) as swapi: 360 | return web.json_response( 361 | _serialize_object( 362 | await swapi.set_shutter_child_lock(ShutterChildLock.OFF, index) 363 | ) 364 | ) 365 | 366 | 367 | @routes.post(ENDPOINT_TURN_ON_LIGHT) 368 | async def turn_on_light(request: web.Request) -> web.Response: 369 | """Use to turn on the light device.""" 370 | device_type = DEVICES[request.query[KEY_TYPE]] 371 | if KEY_LOGIN_KEY in request.query: 372 | login_key = request.query[KEY_LOGIN_KEY] 373 | else: 374 | login_key = "00" 375 | if KEY_INDEX in request.query: 376 | index = int(request.query[KEY_INDEX]) 377 | else: 378 | index = 0 379 | if KEY_TOKEN in request.query: 380 | token = request.query[KEY_TOKEN] 381 | else: 382 | token = None 383 | async with SwitcherApi( 384 | device_type, request.query[KEY_IP], request.query[KEY_ID], login_key, token 385 | ) as swapi: 386 | return web.json_response( 387 | _serialize_object(await swapi.set_light(DeviceState.ON, index)) 388 | ) 389 | 390 | 391 | @routes.post(ENDPOINT_TURN_OFF_LIGHT) 392 | async def turn_off_light(request: web.Request) -> web.Response: 393 | """Use to turn off the light device.""" 394 | device_type = DEVICES[request.query[KEY_TYPE]] 395 | if KEY_LOGIN_KEY in request.query: 396 | login_key = request.query[KEY_LOGIN_KEY] 397 | else: 398 | login_key = "00" 399 | if KEY_INDEX in request.query: 400 | index = int(request.query[KEY_INDEX]) 401 | else: 402 | index = 0 403 | if KEY_TOKEN in request.query: 404 | token = request.query[KEY_TOKEN] 405 | else: 406 | token = None 407 | async with SwitcherApi( 408 | device_type, request.query[KEY_IP], request.query[KEY_ID], login_key, token 409 | ) as swapi: 410 | return web.json_response( 411 | _serialize_object(await swapi.set_light(DeviceState.OFF, index)) 412 | ) 413 | 414 | 415 | @routes.get(ENDPOINT_GET_BREEZE_STATE) 416 | async def get_breeze_state(request: web.Request) -> web.Response: 417 | """Use for sending the get state packet to the Breeze device.""" 418 | device_type = DEVICES[request.query[KEY_TYPE]] 419 | if KEY_LOGIN_KEY in request.query: 420 | login_key = request.query[KEY_LOGIN_KEY] 421 | else: 422 | login_key = "00" 423 | token = None 424 | async with SwitcherApi( 425 | device_type, request.query[KEY_IP], request.query[KEY_ID], login_key, token 426 | ) as swapi: 427 | return web.json_response(_serialize_object(await swapi.get_breeze_state())) 428 | 429 | 430 | @routes.get(ENDPOINT_GET_SHUTTER_STATE) 431 | async def get_shutter_state(request: web.Request) -> web.Response: 432 | """Use for sending the get state packet to the Breeze device.""" 433 | device_type = DEVICES[request.query[KEY_TYPE]] 434 | if KEY_LOGIN_KEY in request.query: 435 | login_key = request.query[KEY_LOGIN_KEY] 436 | else: 437 | login_key = "00" 438 | if KEY_INDEX in request.query: 439 | index = int(request.query[KEY_INDEX]) 440 | else: 441 | index = 0 442 | if KEY_TOKEN in request.query: 443 | token = request.query[KEY_TOKEN] 444 | else: 445 | token = None 446 | async with SwitcherApi( 447 | device_type, request.query[KEY_IP], request.query[KEY_ID], login_key, token 448 | ) as swapi: 449 | return web.json_response( 450 | _serialize_object(await swapi.get_shutter_state(index)) 451 | ) 452 | 453 | 454 | @routes.get(ENDPOINT_GET_LIGHT_STATE) 455 | async def get_light_state(request: web.Request) -> web.Response: 456 | """Use for sending the get state packet to the Light device.""" 457 | device_type = DEVICES[request.query[KEY_TYPE]] 458 | if KEY_LOGIN_KEY in request.query: 459 | login_key = request.query[KEY_LOGIN_KEY] 460 | else: 461 | login_key = "00" 462 | if KEY_INDEX in request.query: 463 | index = int(request.query[KEY_INDEX]) 464 | else: 465 | index = 0 466 | if KEY_TOKEN in request.query: 467 | token = request.query[KEY_TOKEN] 468 | else: 469 | token = None 470 | async with SwitcherApi( 471 | device_type, request.query[KEY_IP], request.query[KEY_ID], login_key, token 472 | ) as swapi: 473 | return web.json_response(_serialize_object(await swapi.get_light_state(index))) 474 | 475 | 476 | @routes.post(ENDPOINT_POST_STOP_SHUTTER) 477 | async def stop_shutter(request: web.Request) -> web.Response: 478 | """Use for stopping the shutter.""" 479 | device_type = DEVICES[request.query[KEY_TYPE]] 480 | if KEY_LOGIN_KEY in request.query: 481 | login_key = request.query[KEY_LOGIN_KEY] 482 | else: 483 | login_key = "00" 484 | if KEY_INDEX in request.query: 485 | index = int(request.query[KEY_INDEX]) 486 | else: 487 | index = 0 488 | if KEY_TOKEN in request.query: 489 | token = request.query[KEY_TOKEN] 490 | else: 491 | token = None 492 | async with SwitcherApi( 493 | device_type, request.query[KEY_IP], request.query[KEY_ID], login_key, token 494 | ) as swapi: 495 | return web.json_response(_serialize_object(await swapi.stop_shutter(index))) 496 | 497 | 498 | @routes.patch(ENDPOINT_CONTROL_BREEZE_DEVICE) 499 | async def control_breeze_device(request: web.Request) -> web.Response: 500 | """Use for update breez device state.""" 501 | remote_manager = SwitcherBreezeRemoteManager() 502 | device_states = {s.display: s for s in DeviceState} 503 | thermostat_modes = {m.display: m for m in ThermostatMode} 504 | thermostat_fan_levels = {fl.display: fl for fl in ThermostatFanLevel} 505 | thermostat_swings = {sw.display: sw for sw in ThermostatSwing} 506 | body: dict = await request.json() 507 | try: 508 | device_state = device_states.get( 509 | body.get(KEY_DEVICE_STATE, None) # type: ignore[arg-type] 510 | ) 511 | thermostat_mode = thermostat_modes.get( 512 | body.get(KEY_THERMOSTAT_MODE, None) # type: ignore[arg-type] 513 | ) 514 | target_temp = int(body[KEY_TARGET_TEMP]) if body.get(KEY_TARGET_TEMP) else 0 515 | fan_level = thermostat_fan_levels.get( 516 | body.get(KEY_FAN_LEVEL, None) # type: ignore[arg-type] 517 | ) 518 | thermostat_swing = thermostat_swings.get( 519 | body.get(KEY_THERMOSTAT_SWING, None) # type: ignore[arg-type] 520 | ) 521 | remote_id = body[KEY_REMOTE_ID] 522 | except Exception as exc: 523 | raise ValueError( 524 | "failed to get commands from body as json, you might sent illegal value" 525 | ) from exc 526 | device_type = DEVICES[request.query[KEY_TYPE]] 527 | if KEY_LOGIN_KEY in request.query: 528 | login_key = request.query[KEY_LOGIN_KEY] 529 | else: 530 | login_key = "00" 531 | token = None 532 | async with SwitcherApi( 533 | device_type, 534 | request.query[KEY_IP], 535 | request.query[KEY_ID], 536 | login_key, 537 | token, 538 | ) as swapi: 539 | remote = remote_manager.get_remote(remote_id) 540 | return web.json_response( 541 | _serialize_object( 542 | await swapi.control_breeze_device( 543 | remote, 544 | device_state, 545 | thermostat_mode, 546 | target_temp, 547 | fan_level, 548 | thermostat_swing, 549 | ) 550 | ) 551 | ) 552 | 553 | 554 | @web.middleware 555 | async def error_middleware(request: web.Request, handler: Callable) -> web.Response: 556 | """Middleware for handling server exceptions.""" 557 | try: 558 | return await handler(request) 559 | except Exception as exc: 560 | server_logger.exception("caught exception while handing over to endpoint") 561 | return web.json_response({"error": str(exc)}, status=500) 562 | 563 | 564 | class CustomAccessLogger(AbstractAccessLogger): 565 | """Custom implementation of the aiohttp access logger.""" 566 | 567 | def log(self, request: BaseRequest, response: StreamResponse, time: float) -> None: 568 | """Log as debug instead origin info.""" 569 | remote = request.remote 570 | method = request.method 571 | path = request.path 572 | status = response.status 573 | self.logger.debug(f"{remote} {method} {path} done in {time}s: {status}") 574 | 575 | 576 | if __name__ == "__main__": 577 | args = parser.parse_args() 578 | 579 | loggingConfig = { 580 | "version": 1, 581 | "formatters": { 582 | "default": { 583 | "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s" 584 | } 585 | }, 586 | "handlers": { 587 | "stream": { 588 | "formatter": "default", 589 | "class": "logging.StreamHandler", 590 | }, 591 | }, 592 | "loggers": { 593 | "": {"handlers": ["stream"], "propagate": True}, 594 | "aioswitcher.api": {"level": "WARNING"}, 595 | access_logger.name: {"level": args.log_level}, 596 | client_logger.name: {"level": args.log_level}, 597 | server_logger.name: {"level": args.log_level}, 598 | web_logger.name: {"level": args.log_level}, 599 | ws_logger.name: {"level": args.log_level}, 600 | }, 601 | } 602 | 603 | config.dictConfig(loggingConfig) 604 | 605 | app = web.Application(middlewares=[error_middleware]) 606 | app.add_routes(routes) 607 | 608 | server_logger.info("starting server") 609 | web.run_app(app, port=args.port, access_log_class=CustomAccessLogger) 610 | server_logger.info("server stopped") 611 | -------------------------------------------------------------------------------- /docs/device_types.md: -------------------------------------------------------------------------------- 1 | | Type | Device | 2 | |:------------|:-----------------------------------------------------| 3 | | v2esp | [Switcher V2 ESP][switcher-v2] | 4 | | v2qual | [Switcher V2 Qualcomm][switcher-v2] | 5 | | mini | [Switcher Mini][switcher-mini] | 6 | | touch | [Switcher Touch (V3)][switcher-touch] | 7 | | v4 | [Switcher V4][switcher-v4] | 8 | | plug | [Switcher Power Plug][switcher-power-plug] | 9 | | breeze | [Switcher Breeze][switcher-breeze] | 10 | | runner | [Switcher Runner][switcher-runner] | 11 | | runnermini | [Switcher Runner Mini][switcher-runner-mini] | 12 | | runners11 | [Switcher Runner S11][switcher-runner-s11] | 13 | | runners12 | [Switcher Runner S12][switcher-runner-s12] | 14 | | light01 | [Switcher Light SL01][switcher-light-sl01] | 15 | | light01mini | [Switcher Light SL01 Mini][switcher-light-sl01-mini] | 16 | | light02 | [Switcher Light SL02][switcher-light-sl02] | 17 | | light02mini | [Switcher Light SL02 Mini][switcher-light-sl02-mini] | 18 | | light03 | [Switcher Light SL03][switcher-light-sl03] | 19 | 20 | [switcher-v2]: https://switcher.co.il/%D7%9E%D7%95%D7%A6%D7%A8/%d7%a1%d7%95%d7%95%d7%99%d7%a6%d7%a8/ 21 | [switcher-mini]: https://switcher.co.il/%D7%9E%D7%95%D7%A6%D7%A8/switcher-mini/ 22 | [switcher-touch]: https://switcher.co.il/%D7%9E%D7%95%D7%A6%D7%A8/%D7%A1%D7%95%D7%95%D7%99%D7%A6%D7%A8-touch/ 23 | [switcher-v4]: https://switcher.co.il/%D7%9E%D7%95%D7%A6%D7%A8/switcher-v4/ 24 | [switcher-power-plug]: https://switcher.co.il/%D7%9E%D7%95%D7%A6%D7%A8/%d7%a1%d7%95%d7%95%d7%99%d7%a6%d7%a8-smart-plug/ 25 | [switcher-breeze]: https://switcher.co.il/%D7%9E%D7%95%D7%A6%D7%A8/switcher-breeze/ 26 | [switcher-runner]: https://switcher.co.il/%D7%9E%D7%95%D7%A6%D7%A8/switcher-runner/ 27 | [switcher-runner-mini]: https://switcher.co.il/%D7%9E%D7%95%D7%A6%D7%A8/switcher-runner-55/ 28 | [switcher-runner-s11]: https://switcher.co.il/%D7%9E%D7%95%D7%A6%D7%A8/runner-lights-s11/ 29 | [switcher-runner-s12]: https://switcher.co.il/%D7%9E%D7%95%D7%A6%D7%A8/runner-lights-s12/ 30 | [switcher-light-sl01]: https://switcher.co.il/%D7%9E%D7%95%D7%A6%D7%A8/switcher-light-sl01/ 31 | [switcher-light-sl01-mini]: https://switcher.co.il/%D7%9E%D7%95%D7%A6%D7%A8/switcher-light-slmini01/ 32 | [switcher-light-sl02]: https://switcher.co.il/%D7%9E%D7%95%D7%A6%D7%A8/switcher-light-sl02/ 33 | [switcher-light-sl02-mini]: https://switcher.co.il/%D7%9E%D7%95%D7%A6%D7%A8/switcher-light-slmini02/ 34 | [switcher-light-sl03]: https://switcher.co.il/%D7%9E%D7%95%D7%A6%D7%A8/switcher-light-sl03/ 35 | -------------------------------------------------------------------------------- /docs/endpoints_action.md: -------------------------------------------------------------------------------- 1 | ## Action Endpoints 2 | 3 | ### ==Turn On== 4 | 5 | | Method | Endpoint | Description | 6 | |:-------|:------------------|:------------------------------------------------------------------------------------| 7 | | POST | /switcher/turn_on | Turn a device on, optionally setting a timer for turning it back off automatically. | 8 | 9 | --8<-- "query_params.md" 10 | 11 | **Body** 12 | 13 | | Key | Type | Required | Example | 14 | |:--------|:--------|:---------|---------| 15 | | minutes | integer | No | 90 | 16 | 17 | ### ==Turn Off== 18 | 19 | | Method | Endpoint | Description | 20 | |:-------|:-------------------|:-------------------| 21 | | POST | /switcher/turn_off | Turn a device off. | 22 | 23 | --8<-- "query_params.md" 24 | 25 | ### ==Turn On Shutter Child Lock== 26 | 27 | | Method | Endpoint | Description | 28 | |:-------|:-------------------------------------|:------------------------------| 29 | | POST | /switcher/turn_on_shutter_child_lock | Turn a shutter child lock on. | 30 | 31 | --8<-- "query_params.md" 32 | 33 | ### ==Turn Off Shutter Child Lock== 34 | 35 | | Method | Endpoint | Description | 36 | |:-------|:--------------------------------------|:-------------------------------| 37 | | POST | /switcher/turn_off_shutter_child_lock | Turn a shutter child lock off. | 38 | 39 | --8<-- "query_params.md" 40 | 41 | ### ==Turn On Light== 42 | 43 | | Method | Endpoint | Description | 44 | |:-------|:------------------------|:------------------------| 45 | | POST | /switcher/turn_on_light | Turn a light device on. | 46 | 47 | --8<-- "query_params.md" 48 | 49 | ### ==Turn Off Light== 50 | 51 | | Method | Endpoint | Description | 52 | |:-------|:-------------------------|:-------------------------| 53 | | POST | /switcher/turn_off_light | Turn a light device off. | 54 | 55 | --8<-- "query_params.md" 56 | 57 | ### ==Stop Shutter== 58 | 59 | | Method | Endpoint | Description | 60 | |:-------|:---------------------------|:-----------------------| 61 | | POST | /switcher/set_stop_shutter | Stop a shutter device. | 62 | 63 | --8<-- "query_params.md" 64 | 65 | ### ==Control Breeze== 66 | 67 | | Method | Endpoint | Description | 68 | |:-------|:--------------------------------|:----------------------------| 69 | | POST | /switcher/control_breeze_device | Control a breeze device. | 70 | 71 | --8<-- "query_params.md" 72 | 73 | **Body** 74 | 75 | | Key | Type | Required | Example | 76 | |:-----------------|:--------|:---------|----------| 77 | | device_state | string | No | on | 78 | | thermostat_mode | string | No | auto | 79 | | target_temp | integer | No | 25 | 80 | | fan_level | string | No | low | 81 | | thermostat_swing | string | No | off | 82 | | remote_id | string | Yes | DLK65863 | 83 | 84 | -------------------------------------------------------------------------------- /docs/endpoints_all.md: -------------------------------------------------------------------------------- 1 | --8<-- "endpoints_state.md" 2 | 3 | --8<-- "endpoints_action.md" 4 | 5 | --8<-- "endpoints_config.md" 6 | 7 | --8<-- "endpoints_schedule.md" 8 | -------------------------------------------------------------------------------- /docs/endpoints_config.md: -------------------------------------------------------------------------------- 1 | ## Configuration Endpoints 2 | 3 | ### ==Set Name== 4 | 5 | | Method | Endpoint | Description | 6 | |:-------|:-------------------|:--------------------------| 7 | | PATCH | /switcher/set_name | Set the name of a device. | 8 | 9 | --8<-- "query_params.md" 10 | 11 | **Body** 12 | 13 | | Key | Type | Required | Example | 14 | |:-----|:-------|:---------|------------------| 15 | | name | string | Yes | MySwitcherDevice | 16 | 17 | ### ==Set Auto Shutdown== 18 | 19 | | Method | Endpoint | Description | 20 | |:-------|:----------------------------|:--------------------------------------------------| 21 | | PATCH | /switcher/set_auto_shutdown | Set the auto shutdown configuration for a device. | 22 | 23 | --8<-- "query_params.md" 24 | 25 | **Body** 26 | 27 | | Key | Type | Required | Example | 28 | |:--------|:--------|:---------|---------| 29 | | hours | integer | Yes | 2 | 30 | | minutes | integer | No | 30 | 31 | 32 | ### ==Set Shutter Position== 33 | 34 | | Method | Endpoint | Description | 35 | |:-------|:-------------------------------|:----------------------------------------------------------------| 36 | | POST | /switcher/set_shutter_position | Set the shutter position of the Runner and Runner Mini devices. | 37 | 38 | --8<-- "query_params.md" 39 | 40 | **Body** 41 | 42 | | Key | Type | Required | Example | 43 | |:----------|:-------|:---------|---------| 44 | | position | string | Yes | 50 | 45 | -------------------------------------------------------------------------------- /docs/endpoints_schedule.md: -------------------------------------------------------------------------------- 1 | ## Schedule Endpoints 2 | 3 | ### ==Get Schedules== 4 | 5 | | Method | Endpoint | Description | 6 | |:-------|:------------------------|:----------------------------------------------------| 7 | | GET | /switcher/get_schedules | Returns an array of schedule objects from a device. | 8 | 9 | --8<-- "query_params.md" 10 | 11 | **Schedule Object** 12 | 13 | | Key | Type | Example | 14 | |:------------|:----------|:-------------------------| 15 | | schedule_id | string | 0 | 16 | | recurring | boolean | true | 17 | | days | [string] | [FRIDAY, SUNDAY, MONDAY] | 18 | | start_time | string | 23:30 | 19 | | duration | string | 0:30:00 | 20 | | display | string | Due next Friday at 23:00 | 21 | 22 | ### ==Delete a Schedule== 23 | 24 | | Method | Endpoint | Description | 25 | |:-------|:--------------------------|:---------------------------------------| 26 | | DELETE | /switcher/delete_schedule | Delete a known schedule from a device. | 27 | 28 | --8<-- "query_params.md" 29 | 30 | **Body** 31 | 32 | | Key | Type | Required | Example | 33 | |:----------|:-------|:---------|---------| 34 | | schedule | string | Yes | 7 | 35 | 36 | ### ==Create a Schedule== 37 | 38 | | Method | Endpoint | Description | 39 | |:-------|:--------------------------|:-----------------------------------| 40 | | POST | /switcher/create_schedule | Create a new schedule on a device. | 41 | 42 | --8<-- "query_params.md" 43 | 44 | **Body** 45 | 46 | | Key | Type | Required | Example | 47 | |:--------|:---------|:---------|-----------------------| 48 | | start | string | Yes | 17:00 | 49 | | stop | string | Yes | 18:30 | 50 | | days | [string] | No | [Wednesday, Saturday] | 51 | -------------------------------------------------------------------------------- /docs/endpoints_state.md: -------------------------------------------------------------------------------- 1 | ## State Endpoints 2 | 3 | ### ==Get State== 4 | 5 | | Method | Endpoint | Description | 6 | |:-------|:--------------------|:---------------------------------------| 7 | | GET | /switcher/get_state | Returns the current state of a device. | 8 | 9 | --8<-- "query_params.md" 10 | 11 | **State Response** 12 | 13 | | Key | Type | Example | 14 | |:------------------|:--------|:---------| 15 | | state | string | ON | 16 | | time_left | string | 01:15:32 | 17 | | time_on | string | 00:14:28 | 18 | | auto_shutdown | string | 02:30:00 | 19 | | power_consumption | string | 1274 | 20 | | electric_current | string | 16.4 | 21 | 22 | ### ==Get Breeze State== 23 | 24 | | Method | Endpoint | Description | 25 | |:-------|:---------------------------|:-----------------------------------------------| 26 | | GET | /switcher/get_breeze_state | Returns the current state of Breeze devices. | 27 | 28 | --8<-- "query_params.md" 29 | 30 | **Breeze State Response** 31 | 32 | | Key | Type | Example | 33 | |:-------------------|:--------|:---------| 34 | | state | string | ON | 35 | | mode | string | COOL | 36 | | fan_level | string | AUTO | 37 | | temperature | integer | 9.5 | 38 | | target_temperature | string | 0, | 39 | | swing | string | ON | 40 | | remote_id | string | DLK65863 | 41 | 42 | ### ==Get Shutter State== 43 | 44 | | Method | Endpoint | Description | 45 | |:-------|:----------------------------|:----------------------------------------------| 46 | | GET | /switcher/get_shutter_state | Returns the current state of Shutter devices. | 47 | 48 | --8<-- "query_params.md" 49 | 50 | ### ==Get Light State== 51 | 52 | | Method | Endpoint | Description | 53 | |:-------|:----------------------------|:----------------------------------------------| 54 | | GET | /switcher/get_light_state | Returns the current state of Light devices. | 55 | 56 | --8<-- "query_params.md" 57 | 58 | **Shutter State Response** 59 | 60 | | Key | Type | Example | 61 | |:----------|:--------|:-------------| 62 | | direction | string | SHUTTER_STOP | 63 | | position | integer | 95 | 64 | 65 | -------------------------------------------------------------------------------- /docs/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TomerFi/switcher_webapi/23042fd16416a27542a8d926fbc3b83c8b473fd3/docs/img/favicon.ico -------------------------------------------------------------------------------- /docs/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TomerFi/switcher_webapi/23042fd16416a27542a8d926fbc3b83c8b473fd3/docs/img/logo.png -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Switcher WebAPI Docs 2 | 3 | ```mermaid 4 | flowchart LR 5 | A([User]) -- HTTP --> B([Container]) -- TCP --> C([Device]) 6 | ``` 7 | 8 | ```shell 9 | docker run -d -p 8000:8000 --name switcher_webapi tomerfi/switcher_webapi:latest 10 | ``` 11 | ???- tip "Debugging issues? Set the log level." 12 | ```shell 13 | docker run -d -p 8000:8000 -e LOG_LEVEL=DEBUG --name switcher_webapi tomerfi/switcher_webapi:latest 14 | ``` 15 | Accepted values: DEBUG / **INFO** / WARNING / ERROR / CRITICAL 16 | 17 | ???- note "New Switcher devices require a token." 18 | Get the token at https://switcher.co.il/GetKey. 19 | 20 | ???- warning "Since version 2.x.x, all endpoints require a device type query param." 21 | --8<-- "device_types.md" 22 | 23 | --8<-- "endpoints_all.md" 24 | -------------------------------------------------------------------------------- /docs/query_params.md: -------------------------------------------------------------------------------- 1 | **Query Params** 2 | 3 | | Param | Type | Description | Required | Example | 4 | |:--------|:-------------------------------|:--------------------------------------|:---------|:-------------------------| 5 | | type | [Device Type](device_types.md) | the type of the selected device | Yes | mini | 6 | | id | string | the id of the selected device | Yes | ab1c2d | 7 | | key | string | the login key of the selected device | No | 18 | 8 | | ip | string | the ip address of the selected device | Yes | 10.0.0.1 | 9 | | index | integer | the circuit number to operate | No | 0 | 10 | | token | string | the user token from Switcher API | No | zvVvd7JxtN7CgvkD1Psujw== | 11 | 12 | -------------------------------------------------------------------------------- /docs/robots.txt: -------------------------------------------------------------------------------- 1 | User-Agent: * 2 | Allow: / 3 | 4 | Sitemap: https://switcher-webapi.tomfi.info/sitemap.xml 5 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | --- 2 | site_name: Switcher WebAPI 3 | site_url: https://switcher-webapi.tomfi.info 4 | site_author: Tomer Figenblat 5 | site_description: Switcher WebAPI Documentation 6 | 7 | repo_name: TomerFi/switcher_webapi 8 | repo_url: https://github.com/TomerFi/switcher_webapi 9 | edit_uri: "" 10 | 11 | nav: 12 | - Home: index.md 13 | - API Endpoints: 14 | - All Endpoints: endpoints_all.md 15 | - State Endpoints: endpoints_state.md 16 | - Action Endpoints: endpoints_action.md 17 | - Config Endpoints: endpoints_config.md 18 | - Schedule Endpoint: endpoints_schedule.md 19 | - Device Types: device_types.md 20 | 21 | markdown_extensions: 22 | - admonition 23 | - pymdownx.betterem 24 | - pymdownx.caret 25 | - pymdownx.critic 26 | - pymdownx.details 27 | - pymdownx.mark 28 | - pymdownx.smartsymbols 29 | - pymdownx.snippets: 30 | base_path: ["docs"] 31 | - tables 32 | - toc: 33 | permalink: ⚓︎ 34 | - pymdownx.superfences: 35 | custom_fences: 36 | - name: mermaid 37 | class: mermaid 38 | format: !!python/name:pymdownx.superfences.fence_code_format 39 | 40 | extra: 41 | version: "2.3.1" 42 | social: 43 | - icon: fontawesome/brands/github 44 | link: https://github.com/TomerFi 45 | name: TomerFi on GitHub 46 | - icon: fontawesome/brands/dev 47 | link: https://dev.to/tomerfi 48 | name: tomerfi on Dev.to 49 | - icon: fontawesome/brands/redhat 50 | link: https://developers.redhat.com/author/tomerfi 51 | name: tomerfi on Red Hat Developer 52 | - icon: fontawesome/brands/linkedin 53 | link: https://www.linkedin.com/in/tomerfi/ 54 | name: tomerfi on LinkedIn 55 | analytics: 56 | provider: google 57 | property: G-PMGFCZ93GB 58 | 59 | plugins: 60 | - git-revision-date 61 | - search 62 | 63 | theme: 64 | name: material 65 | logo: img/logo.png 66 | favicon: img/favicon.ico 67 | features: 68 | - content.code.annotate 69 | - header.autohide 70 | - navigation.indexes 71 | - navigation.instant 72 | - navigation.instant.progress 73 | - navigation.instant.preview 74 | - navigation.tracking 75 | - navigation.top 76 | - toc.follow 77 | - search.highlight 78 | - search.share 79 | - search.suggest 80 | font: 81 | code: Fira Code 82 | text: Open Sans 83 | palette: 84 | - media: "(prefers-color-scheme)" 85 | primary: red 86 | toggle: 87 | icon: material/brightness-auto 88 | name: Switch to system preference 89 | - media: "(prefers-color-scheme: light)" 90 | primary: red 91 | scheme: default 92 | toggle: 93 | icon: material/brightness-7 94 | name: Switch to dark mode 95 | - media: "(prefers-color-scheme: dark)" 96 | primary: red 97 | scheme: slate 98 | toggle: 99 | icon: material/brightness-4 100 | name: Switch to light mode 101 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aioswitcher==6.0.1 2 | aiohttp==3.12.12 3 | -------------------------------------------------------------------------------- /requirements_docs.txt: -------------------------------------------------------------------------------- 1 | mkdocs==1.6.1 2 | mkdocs-git-revision-date-plugin==0.3.2 3 | mkdocs-material==9.6.14 4 | -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | assertpy==1.1 2 | black==25.1.0 3 | flake8==7.2.0 4 | flake8-docstrings==1.7.0 5 | isort==6.0.1 6 | mypy==1.16.0 7 | pytest==8.4.0 8 | pytest-aiohttp==1.1.0 9 | pytest-asyncio==0.26.0 10 | pytest-cov==6.1.1 11 | pytest-sugar==1.0.0 12 | yamllint==1.37.1 13 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | env_list=dev 3 | no_package=True 4 | 5 | [testenv:dev] 6 | description: Prepare development environment 7 | deps = 8 | -r requirements.txt 9 | -r requirements_test.txt 10 | -r requirements_docs.txt 11 | 12 | [testenv:test] 13 | description: Run linters and tests 14 | deps = 15 | -r requirements.txt 16 | -r requirements_test.txt 17 | commands = 18 | black --check app/ 19 | flake8 --count --statistics app/ 20 | isort --check-only app/ 21 | mypy --ignore-missing-imports app/ 22 | pytest -v --cov --cov-report term-missing {posargs} 23 | yamllint --format colored --strict . 24 | 25 | [testenv:docs] 26 | description = Generate the docs site (use in CI) 27 | deps = 28 | -r requirements_docs.txt 29 | commands = 30 | mkdocs build 31 | 32 | # DO NOT RUN IN CI 33 | [testenv:docs-serve] 34 | description = Serve the docs site (don't use in CI) 35 | deps = 36 | -r requirements_docs.txt 37 | commands = 38 | mkdocs serve 39 | 40 | [flake8] 41 | max-line-length=88 42 | per-file-ignores=app/tests/*.py:E501,D103 43 | 44 | [isort] 45 | profile=black 46 | --------------------------------------------------------------------------------