├── .dockerignore ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml └── workflows │ ├── build.yml │ ├── codeql-analysis.yml │ ├── pull_request_description.yml │ └── pythonapp.yaml ├── .gitignore ├── Dockerfile ├── LICENSE ├── docs └── linux_systemd_installation.md ├── examples ├── .env.example └── docker-compose.yml.example ├── readme.md └── wyzesense2mqtt ├── bridge_tool_cli.py ├── requirements.txt ├── samples ├── config.yaml ├── logging.yaml └── sensors.yaml ├── service.sh ├── wyzesense.py ├── wyzesense2mqtt.py └── wyzesense2mqtt.service /.dockerignore: -------------------------------------------------------------------------------- 1 | # WyzeSense2MQTT files 2 | config/ 3 | logs/ 4 | 5 | # Docker files 6 | Dockerfile 7 | docker-compose.yml.sample 8 | .dockerignore 9 | docker/ 10 | 11 | # Git files 12 | .git/ 13 | .gitignore 14 | 15 | # Byte-compiled / optimized / DLL files 16 | __pycache__/ 17 | *.py[cod] 18 | *$py.class 19 | 20 | # C extensions 21 | *.so 22 | 23 | # Distribution / packaging 24 | .Python 25 | build/ 26 | develop-eggs/ 27 | dist/ 28 | downloads/ 29 | eggs/ 30 | .eggs/ 31 | lib/ 32 | lib64/ 33 | parts/ 34 | sdist/ 35 | var/ 36 | wheels/ 37 | pip-wheel-metadata/ 38 | share/python-wheels/ 39 | *.egg-info/ 40 | .installed.cfg 41 | *.egg 42 | MANIFEST 43 | 44 | # PyInstaller 45 | # Usually these files are written by a python script from a template 46 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 47 | *.manifest 48 | *.spec 49 | 50 | # Installer logs 51 | pip-log.txt 52 | pip-delete-this-directory.txt 53 | 54 | # Unit test / coverage reports 55 | htmlcov/ 56 | .tox/ 57 | .nox/ 58 | .coverage 59 | .coverage.* 60 | .cache 61 | nosetests.xml 62 | coverage.xml 63 | *.cover 64 | *.py,cover 65 | .hypothesis/ 66 | .pytest_cache/ 67 | 68 | # Translations 69 | *.mo 70 | *.pot 71 | 72 | # Django stuff: 73 | *.log 74 | local_settings.py 75 | db.sqlite3 76 | db.sqlite3-journal 77 | 78 | # Flask stuff: 79 | instance/ 80 | .webassets-cache 81 | 82 | # Scrapy stuff: 83 | .scrapy 84 | 85 | # Sphinx documentation 86 | docs/_build/ 87 | 88 | # PyBuilder 89 | target/ 90 | 91 | # Jupyter Notebook 92 | .ipynb_checkpoints 93 | 94 | # IPython 95 | profile_default/ 96 | ipython_config.py 97 | 98 | # pyenv 99 | .python-version 100 | 101 | # pipenv 102 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 103 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 104 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 105 | # install all needed dependencies. 106 | #Pipfile.lock 107 | 108 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 109 | __pypackages__/ 110 | 111 | # Celery stuff 112 | celerybeat-schedule 113 | celerybeat.pid 114 | 115 | # SageMath parsed files 116 | *.sage.py 117 | 118 | # Environments 119 | .env 120 | .venv 121 | env/ 122 | venv/ 123 | ENV/ 124 | env.bak/ 125 | venv.bak/ 126 | 127 | # Spyder project settings 128 | .spyderproject 129 | .spyproject 130 | 131 | # Rope project settings 132 | .ropeproject 133 | 134 | # mkdocs documentation 135 | /site 136 | 137 | # mypy 138 | .mypy_cache/ 139 | .dmypy.json 140 | dmypy.json 141 | 142 | # Pyre type checker 143 | .pyre/ 144 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Describe the Bug 11 | 12 | A clear and concise description of what the bug is. 13 | 14 | ### Steps To Reproduce 15 | 16 | Steps to reproduce the behavior: 17 | 18 | 1. Go to '...' 19 | 2. Click on '....' 20 | 3. Scroll down to '....' 21 | 4. See error 22 | 23 | ### Expected Behavior 24 | 25 | A clear and concise description of what you expected to happen. 26 | 27 | ### Screenshots 28 | 29 | If applicable, add screenshots to help explain your problem. 30 | 31 | ### Desktop 32 | 33 | Please complete the following information: 34 | 35 | - OS: [e.g. iOS] 36 | - Browser: [e.g. Chrome, Safari] 37 | - Version: [e.g. 22] 38 | 39 | ### Smartphone 40 | 41 | Please complete the following information: 42 | 43 | - Device: [e.g. iPhone 6] 44 | - OS: [e.g. iOS 8.1] 45 | - Browser: [e.g. Stock Browser, Safari] 46 | - Version [e.g. 22] 47 | 48 | ## Additional Context 49 | 50 | Add any other context about the problem here. 51 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Set update schedule for GitHub Actions 2 | 3 | version: 2 4 | updates: 5 | 6 | - package-ecosystem: "github-actions" 7 | directory: "/" 8 | schedule: 9 | # Check for updates to GitHub Actions every week 10 | interval: "weekly" 11 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Publish to container registries 2 | 3 | on: 4 | release: 5 | types: [ "published" ] 6 | push: 7 | branches: 8 | - devel 9 | pull_request: 10 | branches: 11 | - devel 12 | 13 | jobs: 14 | ghcr-io: 15 | runs-on: ubuntu-latest 16 | 17 | permissions: 18 | contents: read 19 | packages: write 20 | 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v4 24 | 25 | # https://github.com/docker/setup-qemu-action 26 | - name: Set up QEMU 27 | uses: docker/setup-qemu-action@v3 28 | 29 | # Set up BuildKit Docker container builder to be able to build multi-platform images and export cache 30 | # https://github.com/docker/setup-buildx-action 31 | - name: Set up Docker Buildx 32 | uses: docker/setup-buildx-action@v3 33 | 34 | # Extract metadata (tags, labels) for Docker 35 | # https://github.com/docker/metadata-action 36 | - name: Extract Docker metadata 37 | id: meta 38 | uses: docker/metadata-action@v5 39 | with: 40 | images: ghcr.io/${{ github.repository }} 41 | 42 | # Login against a Docker registry 43 | # https://github.com/docker/login-action 44 | - name: Log into registry 45 | uses: docker/login-action@v3 46 | with: 47 | registry: ghcr.io 48 | username: ${{ github.actor }} 49 | password: ${{ secrets.GITHUB_TOKEN }} 50 | 51 | # Build and push Docker image with Buildx (don't push on PR) 52 | # https://github.com/docker/build-push-action 53 | - name: Build and push Docker image 54 | id: build-and-push 55 | uses: docker/build-push-action@v6 56 | with: 57 | context: . 58 | platforms: linux/amd64,linux/arm64 59 | push: true 60 | tags: ${{ steps.meta.outputs.tags }} 61 | labels: ${{ steps.meta.outputs.labels }} 62 | annotations: ${{ steps.meta.outputs.annotations }} 63 | cache-from: type=gha 64 | cache-to: type=gha,mode=max 65 | docker-io: 66 | runs-on: ubuntu-latest 67 | 68 | permissions: 69 | contents: read 70 | 71 | steps: 72 | - name: Checkout repository 73 | uses: actions/checkout@v4 74 | 75 | # https://github.com/docker/setup-qemu-action 76 | - name: Set up QEMU 77 | uses: docker/setup-qemu-action@v3 78 | 79 | # Set up BuildKit Docker container builder to be able to build multi-platform images and export cache 80 | # https://github.com/docker/setup-buildx-action 81 | - name: Set up Docker Buildx 82 | uses: docker/setup-buildx-action@v3 83 | 84 | # Extract metadata (tags, labels) for Docker 85 | # https://github.com/docker/metadata-action 86 | - name: Extract Docker metadata 87 | id: meta 88 | uses: docker/metadata-action@v5 89 | with: 90 | images: ${{ github.repository }} 91 | 92 | # Login against a Docker registry 93 | # https://github.com/docker/login-action 94 | - name: Log into registry 95 | uses: docker/login-action@v3 96 | with: 97 | username: ${{ secrets.DOCKER_USERNAME }} 98 | password: ${{ secrets.DOCKER_PASSWORD }} 99 | 100 | # Build and push Docker image with Buildx (don't push on PR) 101 | # https://github.com/docker/build-push-action 102 | - name: Build and push Docker image 103 | id: build-and-push 104 | uses: docker/build-push-action@v6 105 | with: 106 | context: . 107 | platforms: linux/amd64,linux/arm64 108 | push: true 109 | tags: ${{ steps.meta.outputs.tags }} 110 | labels: ${{ steps.meta.outputs.labels }} 111 | annotations: ${{ steps.meta.outputs.annotations }} 112 | cache-from: type=gha 113 | cache-to: type=gha,mode=max 114 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master, devel ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master, devel ] 20 | schedule: 21 | - cron: '40 14 * * 1' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | language: [ 'python' ] 32 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 33 | # Learn more: 34 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 35 | 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@v4 39 | 40 | # Initializes the CodeQL tools for scanning. 41 | - name: Initialize CodeQL 42 | uses: github/codeql-action/init@v3 43 | with: 44 | languages: ${{ matrix.language }} 45 | # If you wish to specify custom queries, you can do so here or in a config file. 46 | # By default, queries listed here will override any specified in a config file. 47 | # Prefix the list here with "+" to use these queries and those in the config file. 48 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 49 | 50 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 51 | # If this step fails, then you should remove it and run the build manually (see below) 52 | - name: Autobuild 53 | uses: github/codeql-action/autobuild@v3 54 | 55 | # ℹ️ Command-line programs to run using the OS shell. 56 | # 📚 https://git.io/JvXDl 57 | 58 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 59 | # and modify them (or add more) to build your code if your project 60 | # uses a compiled language 61 | 62 | #- run: | 63 | # make bootstrap 64 | # make release 65 | 66 | - name: Perform CodeQL Analysis 67 | uses: github/codeql-action/analyze@v3 68 | -------------------------------------------------------------------------------- /.github/workflows/pull_request_description.yml: -------------------------------------------------------------------------------- 1 | name: Pull Request Description 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | update_pr_description: 8 | name: Update Pull Request Description 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | 13 | # https://github.com/octue/generate-pull-request-description 14 | # Use the lines below in the PR description to trigger 15 | # 16 | # 17 | - uses: octue/generate-pull-request-description@1.0.0.beta-2 18 | id: pr-description 19 | with: 20 | pull_request_url: ${{ github.event.pull_request.url }} 21 | api_token: ${{ secrets.GITHUB_TOKEN }} 22 | 23 | - name: Update pull request body 24 | uses: riskledger/update-pr-description@v2 25 | with: 26 | body: ${{ steps.pr-description.outputs.pull_request_description }} 27 | token: ${{ secrets.GITHUB_TOKEN }} 28 | -------------------------------------------------------------------------------- /.github/workflows/pythonapp.yaml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python Validation 5 | 6 | on: [push] 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Set up Python 14 | uses: actions/setup-python@v5 15 | with: 16 | python-version: 3.13.2 17 | - name: Install dependencies 18 | run: | 19 | python -m pip install --upgrade pip 20 | pip install -r wyzesense2mqtt/requirements.txt 21 | - name: Lint with flake8 22 | run: | 23 | pip install flake8 24 | # stop the build if there are Python syntax errors or undefined names 25 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 26 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 27 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 28 | # - name: Test with pytest 29 | # run: | 30 | # pip install pytest 31 | # pytest 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # WyzeSense2MQTT files 2 | config/ 3 | logs/ 4 | 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | pip-wheel-metadata/ 28 | share/python-wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | MANIFEST 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | *.py,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 | db.sqlite3-journal 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # PyBuilder 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # IPython 85 | profile_default/ 86 | ipython_config.py 87 | 88 | # pyenv 89 | .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 99 | __pypackages__/ 100 | 101 | # Celery stuff 102 | celerybeat-schedule 103 | celerybeat.pid 104 | 105 | # SageMath parsed files 106 | *.sage.py 107 | 108 | # Environments 109 | .env 110 | .venv 111 | env/ 112 | venv/ 113 | ENV/ 114 | env.bak/ 115 | venv.bak/ 116 | 117 | # Spyder project settings 118 | .spyderproject 119 | .spyproject 120 | 121 | # Rope project settings 122 | .ropeproject 123 | 124 | # mkdocs documentation 125 | /site 126 | 127 | # mypy 128 | .mypy_cache/ 129 | .dmypy.json 130 | dmypy.json 131 | 132 | # Pyre type checker 133 | .pyre/ 134 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM docker.io/python:alpine 2 | 3 | LABEL maintainer="Raetha" 4 | 5 | COPY wyzesense2mqtt /app/ 6 | 7 | # Install project dependencies and set permissions 8 | RUN apk add --no-cache tzdata \ 9 | && pip3 install --no-cache-dir --upgrade pip \ 10 | && pip3 install --no-cache-dir -r /app/requirements.txt \ 11 | && chmod +x /app/service.sh 12 | 13 | VOLUME /app/config /app/logs 14 | 15 | ENTRYPOINT ["/app/service.sh"] 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Elias Hunt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/linux_systemd_installation.md: -------------------------------------------------------------------------------- 1 | # Linux Systemd Installation 2 | 3 | The gateway can also be run as a systemd service for those not wanting to use Docker. Requires Python 3.6 or newer. You may need to do all commands as root, depending on filesystem permissions. This is NOT actively tested, please submit an issue or PR if you experience problems. 4 | 1. Plug the Wyze Sense Bridge into a USB port on the Linux host. 5 | 2. Pull down a copy of the repository 6 | ```bash 7 | cd /tmp 8 | git clone https://github.com/raetha/wyzesense2mqtt.git 9 | ``` 10 | 3. Create local application folders (Select a location that works for you, example uses /opt/wyzesense2mqtt) 11 | ```bash 12 | mv /tmp/wyzesense2mqtt/wyzesense2mqtt /opt/wyzesense2mqtt 13 | rm -rf /tmp/wyzesense2mqtt 14 | cd /opt/wyzesense2mqtt 15 | mkdir config 16 | mkdir logs 17 | ``` 18 | 4. Prepare config.yaml file. You must set MQTT host parameters! Username and password can be blank if unused. (see example below) 19 | ```bash 20 | cp samples/config.yaml config/config.yaml 21 | vim config/config.yaml 22 | ``` 23 | 5. Modify logging.yaml file if desired (optional) 24 | ```bash 25 | cp samples/logging.yaml config/logging.yaml 26 | vim config/logging.yaml 27 | ``` 28 | 6. If desired, pre-populate a sensors.yaml file with your existing sensors. This file will automatically be created if it doesn't exist. (see example below) (optional) 29 | ```bash 30 | cp samples/sensors.yaml config/sensors.yaml 31 | vim config/sensors.yaml 32 | ``` 33 | 7. Install dependencies 34 | ```bash 35 | sudo pip3 install -r requirements.txt 36 | ``` 37 | 8. Configure the service 38 | ```bash 39 | vim wyzesense2mqtt.service # Only modify if not using default application path 40 | sudo cp wyzesense2mqtt.service /etc/systemd/system/ 41 | sudo systemctl daemon-reload 42 | sudo systemctl start wyzesense2mqtt 43 | sudo systemctl status wyzesense2mqtt 44 | sudo systemctl enable wyzesense2mqtt # Enable start on reboot 45 | ``` 46 | 9. Pair sensors following the instructions at [Paring a Sensor](/readme.md#pairing-a-sensor). You do NOT need to re-pair sensors that were already paired, they should be found automatically on start and added to the config file with default values, though the sensor version will be unknown and the class will default to opening, i.e. a contact sensor. You should manually update these entries. 47 | -------------------------------------------------------------------------------- /examples/.env.example: -------------------------------------------------------------------------------- 1 | IMAGE_TAG=latest 2 | TZ=America/New_York 3 | MQTT_HOST= 4 | MQTT_PORT=1883 5 | MQTT_USERNAME= 6 | MQTT_PASSWORD= 7 | MQTT_CLIENT_ID=wyzesense2mqtt 8 | MQTT_CLEAN_SESSION=false 9 | MQTT_KEEPALIVE=60 10 | MQTT_QOS=0 11 | MQTT_RETAIN=true 12 | SELF_TOPIC_ROOT=wyzesense2mqtt 13 | HASS_TOPIC_ROOT=homeassistant 14 | HASS_DISCOVERY=true 15 | PUBLISH_SENSOR_NAME=true 16 | USB_DONGLE=auto 17 | DEV_WYZESENSE=/dev/hidraw0 18 | VOL_CONFIG=/docker/wyzesense2mqtt/config 19 | VOL_LOGS=/docker/wyzesense2mqtt/logs 20 | -------------------------------------------------------------------------------- /examples/docker-compose.yml.example: -------------------------------------------------------------------------------- 1 | services: 2 | wyzesense2mqtt: 3 | container_name: wyzesense2mqtt 4 | hostname: wyzesense2mqtt 5 | image: ghcr.io/raetha/wyzesense2mqtt:${IMAGE_TAG:-latest} 6 | network_mode: bridge 7 | restart: unless-stopped 8 | tty: true 9 | stop_signal: SIGINT 10 | environment: 11 | TZ: "${TZ:-UTC}" 12 | MQTT_HOST: "${MQTT_HOST}" 13 | MQTT_PORT: "${MQTT_PORT:-1883}" 14 | MQTT_USERNAME: "${MQTT_USERNAME}" 15 | MQTT_PASSWORD: "${MQTT_PASSWORD}" 16 | MQTT_CLIENT_ID: "${MQTT_CLIENT_ID:-wyzesense2mqtt}" 17 | MQTT_CLEAN_SESSION: "${MQTT_CLEAN_SESSION:-false}" 18 | MQTT_KEEPALIVE: "${MQTT_KEEPALIVE:-60}" 19 | MQTT_QOS: "${MQTT_QOS:-0}" 20 | MQTT_RETAIN: "${MQTT_RETAIN:-true}" 21 | SELF_TOPIC_ROOT: "${SELF_TOPIC_ROOT:-wyzesense2mqtt}" 22 | HASS_TOPIC_ROOT: "${HASS_TOPIC_ROOT:-homeassistant}" 23 | HASS_DISCOVERY: "${HASS_DISCOVERY:-true}" 24 | PUBLISH_SENSOR_NAME: "${PUBLISH_SENSOR_NAME:-true}" 25 | USB_DONGLE: "${USB_DONGLE:-auto}" 26 | devices: 27 | - "${DEV_WYZESENSE:-/dev/hidraw0}:/dev/hidraw0" 28 | volumes: 29 | - "${VOL_CONFIG}:/app/config" 30 | - "${VOL_LOGS}:/app/logs" 31 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # WyzeSense to MQTT Gateway 2 | 3 | [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com) 4 | [![GitHub License](https://img.shields.io/github/license/raetha/wyzesense2mqtt)](https://github.com/raetha/wyzesense2mqtt/blob/master/LICENSE) 5 | [![GitHub Issues](https://img.shields.io/github/issues/raetha/wyzesense2mqtt)](https://github.com/raetha/wyzesense2mqtt/issues) 6 | [![GitHub PRs](https://img.shields.io/github/issues-pr/raetha/wyzesense2mqtt)](https://github.com/raetha/wyzesense2mqtt/pulls) 7 | [![GitHub Release](https://img.shields.io/github/v/release/raetha/wyzesense2mqtt)](https://github.com/raetha/wyzesense2mqtt/releases) 8 | [![Python Validation](https://github.com/raetha/wyzesense2mqtt/workflows/Python%20Validation/badge.svg)](https://github.com/raetha/wyzesense2mqtt/actions?query=workflow%3A%22Python+Validation%22) 9 | 10 | Configurable WyzeSense to MQTT Gateway intended for use with Home Assistant or other platforms that use the same MQTT discovery mechanisms. The gateway allows direct local access to [Wyze Sense](https://wyze.com/wyze-sense.html) products without the need for a Wyze Cam or cloud services. This project and its dependencies have no relation to Wyze Labs Inc. 11 | 12 | Please submit pull requests against the devel branch. 13 | 14 | ## Special Thanks 15 | * [HcLX](https://hclxing.wordpress.com) for [WyzeSensePy](https://github.com/HclX/WyzeSensePy), the core library this project forked. 16 | * [Kevin Vincent](http://kevinvincent.me) for [HA-WyzeSense](https://github.com/kevinvincent/ha-wyzesense), the reference code I used to get things working right with the calls to WyzeSensePy. 17 | * [ozczecho](https://github.com/ozczecho) for [wyze-mqtt](https://github.com/ozczecho/wyze-mqtt), the inspiration for this project. 18 | 19 | ## Table of Contents 20 | - [WyzeSense to MQTT Gateway](#wyzesense-to-mqtt-gateway) 21 | - [Special Thanks](#special-thanks) 22 | - [Table of Contents](#table-of-contents) 23 | - [Installation](#installation) 24 | - [Docker](#docker) 25 | - [Linux Systemd](#linux-systemd) 26 | - [Configuration Files](#configuration-files) 27 | - [config.yaml](#configyaml) 28 | - [logging.yaml](#loggingyaml) 29 | - [sensors.yaml](#sensorsyaml) 30 | - [Usage](#usage) 31 | - [Pairing a Sensor](#pairing-a-sensor) 32 | - [Removing a Sensor](#removing-a-sensor) 33 | - [Reload Sensors](#reload-sensors) 34 | - [Command Line Tool](#command-line-tool) 35 | - [Home Assistant](#home-assistant) 36 | - [Compatible Hardware](#compatible-hardware) 37 | 38 | ## Installation 39 | 40 | ### Docker 41 | This is the most tested method of running the gateway. It allows for persistance and easy migration assuming the hardware dongle moves along with the configuration. All steps are performed from the Docker host, not the container. Images are published to GHCR and Docker Hub. 42 | 43 | 1. Plug the Wyze Sense Bridge into a USB port on the Docker host. Confirm that it shows up as /dev/hidraw0, if not, update the devices entry in the Docker Compose file with the correct device path. 44 | 2. Create a Docker Compose file and a .env file similar to the following. See [Docker Compose Docs](https://docs.docker.com/compose/) for more details on the file format and options. Example files for docker-compose.yml and .env are also included in the repository for easy copying. 45 | ```yaml 46 | ### Example docker-compose.yml ### 47 | services: 48 | wyzesense2mqtt: 49 | container_name: wyzesense2mqtt 50 | hostname: wyzesense2mqtt 51 | image: ghcr.io/raetha/wyzesense2mqtt:${IMAGE_TAG:-latest} 52 | network_mode: bridge 53 | restart: unless-stopped 54 | tty: true 55 | stop_signal: SIGINT 56 | environment: 57 | TZ: "${TZ:-UTC}" 58 | MQTT_HOST: "${MQTT_HOST}" 59 | MQTT_PORT: "${MQTT_PORT:-1883}" 60 | MQTT_USERNAME: "${MQTT_USERNAME}" 61 | MQTT_PASSWORD: "${MQTT_PASSWORD}" 62 | MQTT_CLIENT_ID: "${MQTT_CLIENT_ID:-wyzesense2mqtt}" 63 | MQTT_CLEAN_SESSION: "${MQTT_CLEAN_SESSION:-false}" 64 | MQTT_KEEPALIVE: "${MQTT_KEEPALIVE:-60}" 65 | MQTT_QOS: "${MQTT_QOS:-0}" 66 | MQTT_RETAIN: "${MQTT_RETAIN:-true}" 67 | SELF_TOPIC_ROOT: "${SELF_TOPIC_ROOT:-wyzesense2mqtt}" 68 | HASS_TOPIC_ROOT: "${HASS_TOPIC_ROOT:-homeassistant}" 69 | HASS_DISCOVERY: "${HASS_DISCOVERY:-true}" 70 | PUBLISH_SENSOR_NAME: "${PUBLISH_SENSOR_NAME:-true}" 71 | USB_DONGLE: "${USB_DONGLE:-auto}" 72 | devices: 73 | - "${DEV_WYZESENSE:-/dev/hidraw0}:/dev/hidraw0" 74 | volumes: 75 | - "${VOL_CONFIG}:/app/config" 76 | - "${VOL_LOGS}:/app/logs" 77 | ``` 78 | ```shell 79 | ### Example .env ### 80 | IMAGE_TAG=latest 81 | TZ=America/New_York 82 | MQTT_HOST= 83 | MQTT_PORT=1883 84 | MQTT_USERNAME= 85 | MQTT_PASSWORD= 86 | MQTT_CLIENT_ID=wyzesense2mqtt 87 | MQTT_CLEAN_SESSION=false 88 | MQTT_KEEPALIVE=60 89 | MQTT_QOS=0 90 | MQTT_RETAIN=true 91 | SELF_TOPIC_ROOT=wyzesense2mqtt 92 | HASS_TOPIC_ROOT=homeassistant 93 | HASS_DISCOVERY=true 94 | PUBLISH_SENSOR_NAME=true 95 | USB_DONGLE=auto 96 | DEV_WYZESENSE=/dev/hidraw0 97 | VOL_CONFIG=/docker/wyzesense2mqtt/config 98 | VOL_LOGS=/docker/wyzesense2mqtt/logs 99 | ``` 100 | 3. Create your local volume mounts. Use the same folders you entered in the Docker Compose files created above. 101 | ```bash 102 | mkdir /docker/wyzesense2mqtt/config 103 | mkdir /docker/wyzesense2mqtt/logs 104 | ``` 105 | 4. (Optional, when using Docker environment variables) Create or copy a config.yaml file into the config folder (see example below or copy from repository). The script will automatically create a default config.yaml if one is not found, but it will need to be modified with the correct MQTT details before things will work. 106 | 5. (Optional) Copy a logging.yaml file into the config folder (see example below or copy from repository). The script will automatically use the default logging.yaml if one does not exist. You only need to modify this if more complex logging is required. 107 | 6. (Optional) Pre-populate a sensors.yaml file into the config folder with your existing sensors. This file will automatically be created if it doesn't exist. (see example below or copy from repository) 108 | 7. Start the Docker container 109 | ```bash 110 | docker-compose up -d 111 | ``` 112 | 8. Pair sensors following [instructions below](#pairing-a-sensor). You do NOT need to re-pair sensors that were already paired, they should be found automatically on start and added to the config file with default values, though the sensor version will be unknown and the class will default to opening, i.e. a contact sensor. You should manually update these entries. 113 | 114 | ### Linux Systemd 115 | 116 | If you would like to use this project outside of docker, please follow the instructions at [Linux Systemd Installation](docs/linux_systemd_installation.md). This method is not actively tested and may require more knowledge to succesfully implement. 117 | 118 | ## Configuration Files 119 | The gateway uses three config files located in the config directory. Examples of each are below and in the repository. 120 | 121 | ### config.yaml 122 | This is the main configuration file. Aside from MQTT host, username, and password, the defaults should work for most people. A working configuration will be created automatically if ENV values are available for at least mqtt_host, mqtt_username, and mqtt_password. So it does not need to be created in advance. 123 | ```yaml 124 | mqtt_host: 125 | mqtt_port: 1883 126 | mqtt_username: 127 | mqtt_password: 128 | mqtt_client_id: wyzesense2mqtt 129 | mqtt_clean_session: false 130 | mqtt_keepalive: 60 131 | mqtt_qos: 2 132 | mqtt_retain: true 133 | self_topic_root: wyzesense2mqtt 134 | hass_topic_root: homeassistant 135 | hass_discovery: true 136 | publish_sensor_name: true 137 | usb_dongle: auto 138 | ``` 139 | 140 | ### logging.yaml 141 | This file contains a yaml dictionary for the logging.config module. Python docs at [logging configuration](https://docs.python.org/3/library/logging.config.html) 142 | ```yaml 143 | version: 1 144 | formatters: 145 | simple: 146 | format: '%(message)s' 147 | verbose: 148 | datefmt: '%Y-%m-%d %H:%M:%S' 149 | format: '%(asctime)s %(levelname)-8s %(name)-15s %(message)s' 150 | handlers: 151 | console: 152 | class: logging.StreamHandler 153 | formatter: simple 154 | level: DEBUG 155 | file: 156 | backupCount: 7 157 | class: logging.handlers.TimedRotatingFileHandler 158 | encoding: utf-8 159 | filename: logs/wyzesense2mqtt.log 160 | formatter: verbose 161 | level: INFO 162 | when: midnight 163 | root: 164 | handlers: 165 | - file 166 | - console 167 | level: DEBUG 168 | ``` 169 | 170 | ### sensors.yaml 171 | This file will store basic information about each sensor paired to the Wyse Sense Bridge. The entries can be modified to set the class type and sensor name as it will show in Home Assistant. Class types can be automatically filled for `opening`, `motion`, and `moisture`, depending on the type of sensor. Since this file can be automatically generated, Python may automatically quote the MACs or not depending on if they are fully numeric. Sensors that were previously linked and automatically added will default to class `opening` and will not have a "sw_version" set. For the original version 1 devices, the sw_version should be 19. For the newer version 2 devices, the sw_version should be 23. This will be automatically have the correct settings for devices added via a scan. A custom timeout for device availability can also be added per device by setting the "timeout" setting, in seconds. For version 1 devices, the default timeout is 8 hours and for version 2 device, the default timeout is 4 hours. 172 | ```yaml 173 | 'AAAAAAAA': 174 | class: door 175 | name: Entry Door 176 | invert_state: false 177 | sw_version: 19 178 | 'BBBBBBBB': 179 | class: window 180 | name: Office Window 181 | invert_state: false 182 | sw_version: 23 183 | timeout: 7200 184 | 'CCCCCCCC': 185 | class: opening 186 | name: Kitchen Fridge 187 | invert_state: false 188 | sw_version: 19 189 | 'DDDDDDDD': 190 | class: motion 191 | name: Hallway Motion 192 | invert_state: false 193 | sw_version: 19 194 | 'EEEEEEEE': 195 | class: moisture 196 | name: Basement Moisture 197 | invert_state: true 198 | sw_version: 19 199 | ``` 200 | 201 | ## Usage 202 | ### Pairing a Sensor 203 | At this time only a single sensor can be properly paired at once. So please repeat steps below for each sensor. 204 | 1. Publish a blank message to the MQTT topic "self_topic_root/scan" where self_topic_root is the value from the configuration file. The default MQTT topic would be "wyzesense2mqtt/scan" if you haven't changed the configuration. This can be performed via Home Assistant or any MQTT client. 205 | 2. Use the pin tool that came with your Wyze Sense sensors to press the reset switch on the side of the sensor to pair. Hold in until the red led blinks. 206 | 207 | ### Removing a Sensor 208 | 1. Publish a message containing the MAC to be removed to the MQTT topic "self_topic_root/remove" where self_topic_root is the value from the configuration file. The default MQTT topic would be "wyzesense2mqtt/remove" if you haven't changed the configuration. The payload should look like "AABBCCDD". This can be performed via Home Assistant or any MQTT client. 209 | 210 | ### Reload Sensors 211 | If you've changed your sensors.yaml file while the gateway is running, you can trigger a reload of the sensors.yaml file without restarting the gateway or Docker container. 212 | 1. Publish a blank message to the MQTT topic "self_topic_root/reload" where self_topic_root is the value from the configuration file. The default MQTT topic would be "wyzesense2mqtt/reload" if you haven't changed the configuration. This can be performed via Home Assistant or any MQTT client. 213 | 214 | ### Command Line Tool 215 | The bridge_tool_cli.py script can be used to interact with your bridge to perform a few simple functions. Make sure to specify the correct device for your environment. 216 | ```bash 217 | python3 bridge_tool_cli.py --device /dev/hidraw0 218 | ``` 219 | Once run it will present a menu of its functions: 220 | * L - List paired sensors 221 | * P - Pair new sensors 222 | * U - Unpair sensor (e.g. "U AABBCCDD") 223 | * F - Fix invalid sensors (Removes sensors with invalid MACs, common problem with broken sensors or low batteries) 224 | 225 | ## Home Assistant 226 | Home Assistant simply needs to be configured with the MQTT broker that the gateway publishes topics to. Once configured, the MQTT integration will automatically add devices for each sensor along with entites for the state, battery_level, and signal_strength. By default these entities will have a device_class of "opening" for contact sensors, "motion" for motion sensors, and "moisture" for leak sensors. They will be named for the sensor type and MAC, e.g. Wyze Sense Contact Sensor AABBCCDD. To adjust the device_class to "door" or "window", and set a custom name, update the sensors.yaml configuration file and replace the defaults, then restart WyzeSense2MQTT. For a comprehensive list of device classes that Home Assistant recognizes, see the [binary_sensor documentation](https://www.home-assistant.io/integrations/binary_sensor/). 227 | 228 | ## Compatible Hardware 229 | ### Bridge Devices 230 | * Wyze Sense Bridge (WHSB1) 231 | * Neos Smart Bridge (N-LSP-US1) - Untested, but theoretically compatible 232 | 233 | ### Sensors 234 | * Wyze Sense Bridge Sensors 235 | * Contact Sensor v1 236 | * Motion Sensor v1 237 | * Neos Smart Sensors - Untested, but theoretically compatible 238 | * Contact Sensor 239 | * Leak Sensor 240 | * Motion Sensor 241 | * Wyze Sense Hub Sensors - Requires installing the Wyze Sense Hub firmware onto a Wyze Sense Bridge (unsupported and untested) 242 | * Entry Sensor v2 (WSES2) 243 | * Motion Sensor v2 (WSMS2) 244 | * Climate Sensor (WSCS1) - Coming Soon Hopefully 245 | * Keypad (WSKP1) - Coming Soon Hopefully 246 | * Leak Sensor (WSLS1) - Coming Soon Hopefully 247 | -------------------------------------------------------------------------------- /wyzesense2mqtt/bridge_tool_cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Borrowed from https://github.com/HclX/WyzeSensePy/blob/master/sample.py with slight modifications 3 | 4 | """Example of using WyzeSense USB bridge tool 5 | 6 | **Usage:** :: 7 | bridge_tool_cli.py [options] 8 | 9 | **Options:** 10 | 11 | -d, --debug output debug log messages to stderr 12 | -v, --verbose print and log more information 13 | --device PATH USB device path [default: /dev/hidraw0] 14 | 15 | **Examples:** :: 16 | 17 | bridge_tool_cli.py --device /dev/hidraw0 18 | 19 | """ 20 | from __future__ import print_function 21 | 22 | from builtins import input 23 | 24 | import re 25 | import sys 26 | import logging 27 | import binascii 28 | import wyzesense 29 | 30 | 31 | def on_event(ws, e): 32 | s = f"[{e.Timestamp.strftime('%Y-%m-%d %H:%M:%S')}][{e.MAC}]" 33 | if e.Type == 'state': 34 | (s_type, s_state, s_battery, s_signal) = e.Data 35 | s += f"StateEvent: sensor_type={s_type}, state={s_state}, " \ 36 | f"battery={s_battery}, signal={s_signal}" 37 | else: 38 | s += f"RawEvent: type={e.Type}, data={e.Data}" 39 | print(s) 40 | 41 | 42 | def main(args): 43 | if args['--debug']: 44 | loglevel = logging.DEBUG - (1 if args['--verbose'] else 0) 45 | logging.getLogger("wyzesense").setLevel(loglevel) 46 | logging.getLogger().setLevel(loglevel) 47 | 48 | device = args['--device'] 49 | print(f"Opening wyzesense gateway [{device}]") 50 | try: 51 | ws = wyzesense.Open(device, on_event, logging.getLogger()) 52 | if not ws: 53 | print("Open wyzesense gateway failed") 54 | return 1 55 | print("Gateway info:") 56 | print(f"\tMAC:{ws.MAC}") 57 | print(f"\tVER:{ws.Version}") 58 | print(f"\tENR:{binascii.hexlify(ws.ENR)}") 59 | except IOError: 60 | print(f"No device found on path {device}") 61 | return 2 62 | 63 | def List(unused_args): 64 | result = ws.List() 65 | print(f"{len(result)} sensors paired:") 66 | logging.debug(f"{len(result)} sensors paired:") 67 | for mac in result: 68 | print(f"\tSensor: {mac}") 69 | logging.debug(f"\tSensor: {mac}") 70 | 71 | def Pair(unused_args): 72 | result = ws.Scan() 73 | (s_mac, s_type, s_version) = result 74 | if result: 75 | print(f"Sensor found: mac={s_mac}, type={s_type}, version={s_version}") 76 | logging.debug(f"Sensor found: mac={s_mac}, type={s_type}, version={s_version}") 77 | else: 78 | print("No sensor found!") 79 | logging.debug("No sensor found!") 80 | 81 | def Unpair(mac_list): 82 | for mac in mac_list: 83 | if len(mac) != 8: 84 | print(f"Invalid mac address, must be 8 characters: {mac}") 85 | logging.debug(f"Invalid mac address, must be 8 characters: {mac}") 86 | continue 87 | print(f"Un-pairing sensor {mac}:") 88 | logging.debug(f"Un-pairing sensor {mac}:") 89 | result = ws.Delete(mac) 90 | if result is not None: 91 | print(f"Result: {result}") 92 | logging.debug(f"Result: {result}") 93 | print(f"Sensor {mac} removed") 94 | logging.debug(f"Sensor {mac} removed") 95 | 96 | def Fix(unused_args): 97 | invalid_mac_list = [ 98 | "00000000", 99 | "\0\0\0\0\0\0\0\0", 100 | "\x00\x00\x00\x00\x00\x00\x00\x00" 101 | ] 102 | print("Un-pairing bad sensors") 103 | logging.debug("Un-pairing bad sensors") 104 | for mac in invalid_mac_list: 105 | result = ws.Delete(mac) 106 | if result is not None: 107 | print(f"Result: {result}") 108 | logging.debug(f"Result: {result}") 109 | print("Bad sensors removed") 110 | logging.debug("Bad sensors removed") 111 | 112 | def HandleCmd(): 113 | cmd_handlers = { 114 | 'L': ('L - List paired sensors', List), 115 | 'P': ('P - Pair new sensors', Pair), 116 | 'U': ('U - Unpair sensor', Unpair), 117 | 'F': ('F - Fix invalid sensors', Fix), 118 | 'X': ('X - Exit tool', None), 119 | } 120 | 121 | for v in list(cmd_handlers.values()): 122 | print(v[0]) 123 | 124 | cmd_and_args = input("Action:").strip().upper().split() 125 | if len(cmd_and_args) == 0: 126 | return True 127 | 128 | cmd = cmd_and_args[0] 129 | if cmd not in cmd_handlers: 130 | return True 131 | 132 | handler = cmd_handlers[cmd] 133 | if not handler[1]: 134 | return False 135 | 136 | print("------------------------") 137 | handler[1](cmd_and_args[1:]) 138 | print("------------------------") 139 | return True 140 | 141 | try: 142 | while HandleCmd(): 143 | pass 144 | finally: 145 | ws.Stop() 146 | 147 | return 0 148 | 149 | 150 | if __name__ == '__main__': 151 | logging.basicConfig(format='%(levelname)s %(asctime)s %(message)s') 152 | 153 | try: 154 | from docopt import docopt 155 | except ImportError: 156 | sys.exit("the 'docopt' module is needed to execute this program") 157 | 158 | # remove restructured text formatting before input to docopt 159 | usage = re.sub(r'(?<=\n)\*\*(\w+:)\*\*.*\n', r'\1', __doc__) 160 | sys.exit(main(docopt(usage))) 161 | -------------------------------------------------------------------------------- /wyzesense2mqtt/requirements.txt: -------------------------------------------------------------------------------- 1 | # Pip requirements file 2 | # 3 | 4 | # WyzeSense2MQTT requirements 5 | paho-mqtt < 3 6 | PyYAML 7 | retrying 8 | six 9 | 10 | # Now includes custom WyzeSensePy library to resolve assertion error, issues #12, #17 11 | #wyzesense 12 | 13 | # Bridge Tool CLI Requirements 14 | docopt 15 | -------------------------------------------------------------------------------- /wyzesense2mqtt/samples/config.yaml: -------------------------------------------------------------------------------- 1 | mqtt_host: 2 | mqtt_port: 1883 3 | mqtt_username: 4 | mqtt_password: 5 | mqtt_client_id: wyzesense2mqtt 6 | mqtt_clean_session: false 7 | mqtt_keepalive: 60 8 | mqtt_qos: 0 9 | mqtt_retain: true 10 | self_topic_root: wyzesense2mqtt 11 | hass_topic_root: homeassistant 12 | hass_discovery: true 13 | publish_sensor_name: true 14 | usb_dongle: auto 15 | -------------------------------------------------------------------------------- /wyzesense2mqtt/samples/logging.yaml: -------------------------------------------------------------------------------- 1 | version: 1 2 | formatters: 3 | simple: 4 | format: '%(message)s' 5 | verbose: 6 | datefmt: '%Y-%m-%d %H:%M:%S' 7 | format: '%(asctime)s %(levelname)-8s %(name)-15s %(message)s' 8 | handlers: 9 | console: 10 | class: logging.StreamHandler 11 | formatter: simple 12 | level: INFO 13 | file: 14 | backupCount: 7 15 | class: logging.handlers.TimedRotatingFileHandler 16 | encoding: utf-8 17 | filename: logs/wyzesense2mqtt.log 18 | formatter: verbose 19 | level: INFO 20 | when: midnight 21 | root: 22 | handlers: 23 | - file 24 | - console 25 | level: INFO 26 | -------------------------------------------------------------------------------- /wyzesense2mqtt/samples/sensors.yaml: -------------------------------------------------------------------------------- 1 | 'AAAAAAAA': 2 | class: door 3 | name: Entry Door 4 | invert_state: false 5 | 'BBBBBBBB': 6 | class: window 7 | name: Office Window 8 | invert_state: false 9 | 'CCCCCCCC': 10 | class: opening 11 | name: Kitchen Fridge 12 | invert_state: false 13 | 'DDDDDDDD': 14 | class: motion 15 | name: Hallway Motion 16 | invert_state: false 17 | 'EEEEEEEE': 18 | class: moisture 19 | name: Basement Moisture 20 | invert_state: true 21 | -------------------------------------------------------------------------------- /wyzesense2mqtt/service.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | SCRIPT_DIR=$( cd "$( dirname "$0" )" >/dev/null 2>&1 && pwd ) 4 | VIRTUAL_ENV=$SCRIPT_DIR/venv 5 | if [ -d "$VIRTUAL_ENV" ]; then 6 | export VIRTUAL_ENV 7 | PATH="$VIRTUAL_ENV/bin:$PATH" 8 | export PATH 9 | fi 10 | cd "$SCRIPT_DIR" 11 | python3 wyzesense2mqtt.py "$@" 12 | -------------------------------------------------------------------------------- /wyzesense2mqtt/wyzesense.py: -------------------------------------------------------------------------------- 1 | from builtins import bytes 2 | from builtins import str 3 | 4 | import os 5 | import time 6 | import struct 7 | import threading 8 | import datetime 9 | import binascii 10 | 11 | import logging 12 | 13 | 14 | def bytes_to_hex(s): 15 | if s: 16 | return binascii.hexlify(s) 17 | else: 18 | return "" 19 | 20 | 21 | def checksum_from_bytes(s): 22 | return sum(bytes(s)) & 0xFFFF 23 | 24 | 25 | TYPE_SYNC = 0x43 26 | TYPE_ASYNC = 0x53 27 | 28 | # {sensor_id: "sensor type", "states": ["off state", "on state"]} 29 | CONTACT_IDS = {0x01: "switch", 0x0E: "switchv2", "states": ["close", "open"]} 30 | MOTION_IDS = {0x02: "motion", 0x0F: "motionv2", "states": ["inactive", "active"]} 31 | LEAK_IDS = {0x03: "leak", "states": ["dry", "wet"]} 32 | 33 | 34 | def MAKE_CMD(type, cmd): 35 | return (type << 8) | cmd 36 | 37 | 38 | class Packet(object): 39 | _CMD_TIMEOUT = 5 40 | 41 | # Sync packets: 42 | # Commands initiated from host side 43 | CMD_GET_ENR = MAKE_CMD(TYPE_SYNC, 0x02) 44 | CMD_GET_MAC = MAKE_CMD(TYPE_SYNC, 0x04) 45 | CMD_GET_KEY = MAKE_CMD(TYPE_SYNC, 0x06) 46 | CMD_INQUIRY = MAKE_CMD(TYPE_SYNC, 0x27) 47 | CMD_UPDATE_CC1310 = MAKE_CMD(TYPE_SYNC, 0x12) 48 | CMD_SET_CH554_UPGRADE = MAKE_CMD(TYPE_SYNC, 0x0E) 49 | 50 | # Async packets: 51 | ASYNC_ACK = MAKE_CMD(TYPE_ASYNC, 0xFF) 52 | 53 | # Commands initiated from dongle side 54 | CMD_FINISH_AUTH = MAKE_CMD(TYPE_ASYNC, 0x14) 55 | CMD_GET_DONGLE_VERSION = MAKE_CMD(TYPE_ASYNC, 0x16) 56 | CMD_START_STOP_SCAN = MAKE_CMD(TYPE_ASYNC, 0x1C) 57 | CMD_GET_SENSOR_R1 = MAKE_CMD(TYPE_ASYNC, 0x21) 58 | CMD_VERIFY_SENSOR = MAKE_CMD(TYPE_ASYNC, 0x23) 59 | CMD_DEL_SENSOR = MAKE_CMD(TYPE_ASYNC, 0x25) 60 | CMD_GET_SENSOR_COUNT = MAKE_CMD(TYPE_ASYNC, 0x2E) 61 | CMD_GET_SENSOR_LIST = MAKE_CMD(TYPE_ASYNC, 0x30) 62 | 63 | # Notifications initiated from dongle side 64 | NOTIFY_SENSOR_ALARM = MAKE_CMD(TYPE_ASYNC, 0x19) 65 | NOTIFY_SENSOR_SCAN = MAKE_CMD(TYPE_ASYNC, 0x20) 66 | NOTIFY_SYNC_TIME = MAKE_CMD(TYPE_ASYNC, 0x32) 67 | NOTIFY_EVENT_LOG = MAKE_CMD(TYPE_ASYNC, 0x35) 68 | 69 | def __init__(self, cmd, payload=bytes()): 70 | self._cmd = cmd 71 | if self._cmd == self.ASYNC_ACK: 72 | assert isinstance(payload, int) 73 | else: 74 | assert isinstance(payload, bytes) 75 | self._payload = payload 76 | 77 | def __str__(self): 78 | if self._cmd == self.ASYNC_ACK: 79 | return "Packet: Cmd=%04X, Payload=ACK(%04X)" % (self._cmd, self._payload) 80 | else: 81 | return "Packet: Cmd=%04X, Payload=%s" % (self._cmd, bytes_to_hex(self._payload)) 82 | 83 | @property 84 | def Length(self): 85 | if self._cmd == self.ASYNC_ACK: 86 | return 7 87 | else: 88 | return len(self._payload) + 7 89 | 90 | @property 91 | def Cmd(self): 92 | return self._cmd 93 | 94 | @property 95 | def Payload(self): 96 | return self._payload 97 | 98 | def Send(self, fd): 99 | pkt = bytes() 100 | 101 | pkt += struct.pack(">HB", 0xAA55, self._cmd >> 8) 102 | if self._cmd == self.ASYNC_ACK: 103 | pkt += struct.pack("BB", (self._payload & 0xFF), self._cmd & 0xFF) 104 | else: 105 | pkt += struct.pack("BB", len(self._payload) + 3, self._cmd & 0xFF) 106 | if self._payload: 107 | pkt += self._payload 108 | 109 | checksum = checksum_from_bytes(pkt) 110 | pkt += struct.pack(">H", checksum) 111 | LOGGER.debug("Sending: %s", bytes_to_hex(pkt)) 112 | ss = os.write(fd, pkt) 113 | assert ss == len(pkt) 114 | 115 | @classmethod 116 | def Parse(cls, s): 117 | assert isinstance(s, bytes) 118 | 119 | if len(s) < 5: 120 | LOGGER.error("Invalid packet: %s", bytes_to_hex(s)) 121 | LOGGER.error("Invalid packet length: %d", len(s)) 122 | # This error can be corrected by waiting for additional data, throw an exception we can catch to handle differently 123 | raise EOFError 124 | 125 | magic, cmd_type, b2, cmd_id = struct.unpack_from(">HBBB", s) 126 | if magic != 0x55AA and magic != 0xAA55: 127 | LOGGER.error("Invalid packet: %s", bytes_to_hex(s)) 128 | LOGGER.error("Invalid packet magic: %4X", magic) 129 | return None 130 | 131 | cmd = MAKE_CMD(cmd_type, cmd_id) 132 | if cmd == cls.ASYNC_ACK: 133 | assert len(s) >= 7 134 | s = s[:7] 135 | payload = MAKE_CMD(cmd_type, b2) 136 | elif len(s) >= b2 + 4: 137 | s = s[: b2 + 4] 138 | payload = s[5:-2] 139 | else: 140 | LOGGER.error("Invalid packet: %s", bytes_to_hex(s)) 141 | LOGGER.error("Short packet: expected %d, got %d", (b2 + 4), len(s)) 142 | # This error can be corrected by waiting for additional data, throw an exception we can catch to handle differently 143 | raise EOFError 144 | 145 | cs_remote = (s[-2] << 8) | s[-1] 146 | cs_local = checksum_from_bytes(s[:-2]) 147 | if cs_remote != cs_local: 148 | LOGGER.error("Invalid packet: %s", bytes_to_hex(s)) 149 | LOGGER.error("Mismatched checksum, remote=%04X, local=%04X", cs_remote, cs_local) 150 | return None 151 | 152 | return cls(cmd, payload) 153 | 154 | @classmethod 155 | def GetVersion(cls): 156 | return cls(cls.CMD_GET_DONGLE_VERSION) 157 | 158 | @classmethod 159 | def Inquiry(cls): 160 | return cls(cls.CMD_INQUIRY) 161 | 162 | @classmethod 163 | def GetEnr(cls, r): 164 | assert isinstance(r, bytes) 165 | assert len(r) == 16 166 | return cls(cls.CMD_GET_ENR, r) 167 | 168 | @classmethod 169 | def GetMAC(cls): 170 | return cls(cls.CMD_GET_MAC) 171 | 172 | @classmethod 173 | def GetKey(cls): 174 | return cls(cls.CMD_GET_KEY) 175 | 176 | @classmethod 177 | def EnableScan(cls): 178 | return cls(cls.CMD_START_STOP_SCAN, b"\x01") 179 | 180 | @classmethod 181 | def DisableScan(cls): 182 | return cls(cls.CMD_START_STOP_SCAN, b"\x00") 183 | 184 | @classmethod 185 | def GetSensorCount(cls): 186 | return cls(cls.CMD_GET_SENSOR_COUNT) 187 | 188 | @classmethod 189 | def GetSensorList(cls, count): 190 | assert count <= 0xFF 191 | return cls(cls.CMD_GET_SENSOR_LIST, struct.pack("B", count)) 192 | 193 | @classmethod 194 | def FinishAuth(cls): 195 | return cls(cls.CMD_FINISH_AUTH, b"\xFF") 196 | 197 | @classmethod 198 | def DelSensor(cls, mac): 199 | assert isinstance(mac, str) 200 | assert len(mac) == 8 201 | return cls(cls.CMD_DEL_SENSOR, mac.encode('ascii')) 202 | 203 | @classmethod 204 | def GetSensorR1(cls, mac, r): 205 | assert isinstance(r, bytes) 206 | assert len(r) == 16 207 | assert isinstance(mac, str) 208 | assert len(mac) == 8 209 | return cls(cls.CMD_GET_SENSOR_R1, mac.encode('ascii') + r) 210 | 211 | @classmethod 212 | def VerifySensor(cls, mac): 213 | assert isinstance(mac, str) 214 | assert len(mac) == 8 215 | return cls(cls.CMD_VERIFY_SENSOR, mac.encode('ascii') + b"\xFF\x04") 216 | 217 | @classmethod 218 | def UpdateCC1310(cls): 219 | return cls(cls.CMD_UPDATE_CC1310) 220 | 221 | @classmethod 222 | def Ch554Upgrade(cls): 223 | return cls(cls.CMD_SET_CH554_UPGRADE) 224 | 225 | @classmethod 226 | def SyncTimeAck(cls): 227 | return cls(cls.NOTIFY_SYNC_TIME + 1, struct.pack(">Q", int(time.time() * 1000))) 228 | 229 | @classmethod 230 | def AsyncAck(cls, cmd): 231 | assert (cmd >> 0x8) == TYPE_ASYNC 232 | return cls(cls.ASYNC_ACK, cmd) 233 | 234 | 235 | class SensorEvent(object): 236 | def __init__(self, mac, timestamp, event_type, event_data): 237 | self.MAC = mac 238 | self.Timestamp = timestamp 239 | self.Type = event_type 240 | self.Data = event_data 241 | 242 | def __str__(self): 243 | if self.Type == 'alarm': 244 | return f"AlarmEvent [{self.MAC}]: time={self.Timestamp.strftime('%Y-%m-%d %H:%M:%S')}, sensor_type={self.Data[0]}, state={self.Data[1]}, battery={self.Data[2]}, signal={self.Data[3]}" 245 | elif self.Type == 'status': 246 | return f"StatusEvent [{self.MAC}]: time={self.Timestamp.strftime('%Y-%m-%d %H:%M:%S')}, sensor_type={self.Data[0]}, state={self.Data[1]}, battery={self.Data[2]}, signal={self.Data[3]}" 247 | else: 248 | return f"RawEvent [{self.MAC}]: time={self.Timestamp.strftime('%Y-%m-%d %H:%M:%S')}, event_type={self.Type}, data={bytes_to_hex(self.Data)}" 249 | 250 | 251 | class Dongle(object): 252 | _CMD_TIMEOUT = 2 253 | 254 | class CmdContext(object): 255 | def __init__(self, **kwargs): 256 | for key in kwargs: 257 | setattr(self, key, kwargs[key]) 258 | 259 | def _OnSensorAlarm(self, pkt): 260 | global CONTACT_IDS, MOTION_IDS, LEAK_IDS 261 | 262 | if len(pkt.Payload) < 18: 263 | LOGGER.info("Unknown alarm packet: %s", bytes_to_hex(pkt.Payload)) 264 | return 265 | 266 | timestamp, event, mac = struct.unpack_from(">QB8s", pkt.Payload) 267 | data = pkt.Payload[17:] 268 | timestamp = datetime.datetime.fromtimestamp(timestamp / 1000.0) 269 | mac = mac.decode('ascii') 270 | 271 | if event == 0xA2 or event == 0xA1: 272 | type, b1, battery, b2, state1, state2, counter, signal = struct.unpack_from(">BBBBBBHB", data) 273 | sensor = {} 274 | if type in CONTACT_IDS: 275 | sensor = CONTACT_IDS 276 | elif type in MOTION_IDS: 277 | sensor = MOTION_IDS 278 | elif type in LEAK_IDS: 279 | sensor = LEAK_IDS 280 | 281 | if sensor: 282 | sensor_type = sensor[type] 283 | sensor_state = sensor["states"][state2] 284 | else: 285 | sensor_type = f"unknown({type:02X})" 286 | sensor_state = f"unknown({state2:02X})" 287 | e = SensorEvent(mac, timestamp, ("alarm" if event == 0xA2 else "status"), (sensor_type, sensor_state, battery, signal)) 288 | elif event == 0xE8: 289 | type, b1, battery, b2, state1, state2, counter, signal = struct.unpack_from(">BBBBBBHB", data) 290 | if type == 0x03: 291 | sensor_type = "leak:temperature" 292 | sensor_state = "%d.%d" % (state1, state2) 293 | else: 294 | sensor_type = f"unknown({type:02X})" 295 | sensor_state = f"unknown({state2:02X})" 296 | e = SensorEvent(mac, timestamp, "status", (sensor_type, sensor_state, battery, signal)) 297 | else: 298 | e = SensorEvent(mac, timestamp, f"{event:02X}", data) 299 | 300 | self.__on_event(self, e) 301 | 302 | def _OnSyncTime(self, pkt): 303 | self._SendPacket(Packet.SyncTimeAck()) 304 | 305 | def _OnEventLog(self, pkt): 306 | # global CONTACT_IDS, MOTION_IDS, LEAK_IDS 307 | 308 | assert len(pkt.Payload) >= 9 309 | ts, msg_len = struct.unpack_from(">QB", pkt.Payload) 310 | tm = datetime.datetime.fromtimestamp(ts / 1000.0) 311 | msg = pkt.Payload[9:] 312 | LOGGER.info("LOG: time=%s, data=%s", tm.isoformat(), bytes_to_hex(msg)) 313 | if ts == 0: 314 | self.__on_event(self, "ERROR") 315 | # Check if we have a message after, length includes the msglen byte 316 | # if ((len(msg) + 1) >= msg_len and msg_len >= 13): 317 | # event, mac, type, state, counter = struct.unpack(">B8sBBH", msg) 318 | # TODO: What can we do with this? At the very least, we can update the last seen time for the sensor 319 | # and it appears that the log message happens before every alarm message, so doesn't really gain much of anything 320 | 321 | def __init__(self, device, event_handler): 322 | self.__lock = threading.Lock() 323 | self.__fd = os.open(device, os.O_RDWR | os.O_NONBLOCK) 324 | self.__sensors = {} 325 | self.__exit_event = threading.Event() 326 | self.__thread = threading.Thread(target=self._Worker) 327 | self.__on_event = event_handler 328 | self.__last_exception = None 329 | 330 | self.__handlers = { 331 | Packet.NOTIFY_SYNC_TIME: self._OnSyncTime, 332 | Packet.NOTIFY_SENSOR_ALARM: self._OnSensorAlarm, 333 | Packet.NOTIFY_EVENT_LOG: self._OnEventLog, 334 | } 335 | 336 | self._Start() 337 | 338 | def _ReadRawHID(self): 339 | try: 340 | s = os.read(self.__fd, 0x40) 341 | except OSError: 342 | return b"" 343 | 344 | if not s: 345 | LOGGER.info("Nothing read") 346 | return b"" 347 | 348 | s = bytes(s) 349 | length = s[0] 350 | assert length > 0 351 | if length > 0x3F: 352 | length = 0x3F 353 | LOGGER.warn("Shortening a packet") 354 | 355 | # LOGGER.debug("Raw HID packet: %s", bytes_to_hex(s)) 356 | assert len(s) >= length + 1 357 | return s[1: 1 + length] 358 | 359 | def _SetHandler(self, cmd, handler): 360 | with self.__lock: 361 | oldHandler = self.__handlers.pop(cmd, None) 362 | if handler: 363 | self.__handlers[cmd] = handler 364 | return oldHandler 365 | 366 | def _SendPacket(self, pkt): 367 | LOGGER.debug("===> Sending: %s", str(pkt)) 368 | pkt.Send(self.__fd) 369 | 370 | def _DefaultHandler(self, pkt): 371 | pass 372 | 373 | def _HandlePacket(self, pkt): 374 | LOGGER.debug("<=== Received: %s", str(pkt)) 375 | with self.__lock: 376 | handler = self.__handlers.get(pkt.Cmd, self._DefaultHandler) 377 | 378 | if (pkt.Cmd >> 8) == TYPE_ASYNC and pkt.Cmd != Packet.ASYNC_ACK: 379 | LOGGER.debug("Sending ACK packet for cmd %04X", pkt.Cmd) 380 | self._SendPacket(Packet.AsyncAck(pkt.Cmd)) 381 | handler(pkt) 382 | 383 | def _Worker(self): 384 | try: 385 | s = b"" 386 | while True: 387 | if self.__exit_event.isSet(): 388 | break 389 | 390 | s += self._ReadRawHID() 391 | # if s: 392 | # LOGGER.info("Incoming buffer: %s", bytes_to_hex(s)) 393 | 394 | # Look for the start of the next message, indicated by the magic bytes 0x55AA 395 | start = s.find(b"\x55\xAA") 396 | if start == -1: 397 | time.sleep(0.1) 398 | continue 399 | 400 | # Found the start of the next message, ideally this would be at the beginning of the buffer 401 | # but we could be tossing some bad data if a previous parse failed 402 | s = s[start:] 403 | LOGGER.debug("Trying to parse: %s", bytes_to_hex(s)) 404 | try: 405 | pkt = Packet.Parse(s) 406 | if not pkt: 407 | # Packet was invalid and couldn't be processed, remove the magic bytes and continue 408 | # looking for another start of message. This essentially tosses the bad message. 409 | LOGGER.error("Unable to parse message") 410 | s = s[2:] 411 | time.sleep(0.1) 412 | continue 413 | except EOFError: 414 | # Not enough data to parse a packet, keep the partial packet for now 415 | time.sleep(0.1) 416 | continue 417 | 418 | LOGGER.debug("Received: %s", bytes_to_hex(s[:pkt.Length])) 419 | s = s[pkt.Length:] 420 | self._HandlePacket(pkt) 421 | except Exception as e: 422 | LOGGER.error("Error occured in dongle worker thread", exc_info=True) 423 | self.__last_exception = e 424 | 425 | def _DoCommand(self, pkt, handler, timeout=_CMD_TIMEOUT): 426 | e = threading.Event() 427 | oldHandler = self._SetHandler(pkt.Cmd + 1, lambda pkt: handler(pkt, e)) 428 | self._SendPacket(pkt) 429 | result = e.wait(timeout) 430 | self._SetHandler(pkt.Cmd + 1, oldHandler) 431 | 432 | if not result: 433 | raise TimeoutError("_DoCommand") 434 | 435 | def _DoSimpleCommand(self, pkt, timeout=_CMD_TIMEOUT): 436 | ctx = self.CmdContext(result=None) 437 | 438 | def cmd_handler(pkt, e): 439 | ctx.result = pkt 440 | e.set() 441 | 442 | self._DoCommand(pkt, cmd_handler, timeout) 443 | return ctx.result 444 | 445 | def _Inquiry(self): 446 | LOGGER.debug("Start Inquiry...") 447 | resp = self._DoSimpleCommand(Packet.Inquiry()) 448 | 449 | assert len(resp.Payload) == 1 450 | result = resp.Payload[0] 451 | LOGGER.debug("Inquiry returns %d", result) 452 | 453 | assert result == 1, "Inquiry failed, result=%d" % result 454 | 455 | def _GetEnr(self, r): 456 | LOGGER.debug("Start GetEnr...") 457 | assert len(r) == 4 458 | assert all(isinstance(x, int) for x in r) 459 | r_string = bytes(struct.pack(" 0: 516 | LOGGER.info("%d sensors reported, waiting for each one to report...", count) 517 | 518 | def cmd_handler(pkt, e): 519 | assert len(pkt.Payload) == 8 520 | mac = pkt.Payload.decode('ascii') 521 | LOGGER.info("Sensor %d/%d, MAC:%s", ctx.index + 1, ctx.count, mac) 522 | 523 | ctx.sensors.append(mac) 524 | ctx.index += 1 525 | if ctx.index == ctx.count: 526 | e.set() 527 | 528 | self._DoCommand(Packet.GetSensorList(count), cmd_handler, timeout=10) 529 | else: 530 | LOGGER.info("No sensors bond yet...") 531 | return ctx.sensors 532 | 533 | def _FinishAuth(self): 534 | resp = self._DoSimpleCommand(Packet.FinishAuth()) 535 | assert len(resp.Payload) == 0 536 | 537 | def _Start(self): 538 | self.__thread.start() 539 | 540 | try: 541 | self._Inquiry() 542 | 543 | self.ENR = self._GetEnr([0x30303030] * 4) 544 | self.MAC = self._GetMac() 545 | LOGGER.info("Dongle MAC is [%s]", self.MAC) 546 | 547 | self.Version = self._GetVersion() 548 | LOGGER.info("Dongle version: %s", self.Version) 549 | 550 | self._FinishAuth() 551 | except: 552 | self.Stop() 553 | raise 554 | 555 | def List(self): 556 | sensors = self._GetSensors() 557 | return sensors 558 | 559 | def CheckError(self): 560 | if self.__last_exception: 561 | raise self.__last_exception 562 | 563 | def Stop(self, timeout=_CMD_TIMEOUT): 564 | self.__exit_event.set() 565 | self.__thread.join(timeout) 566 | os.close(self.__fd) 567 | self.__fd = None 568 | 569 | def Scan(self, timeout=60): 570 | LOGGER.info("Start Scan...") 571 | 572 | ctx = self.CmdContext(evt=threading.Event(), result=None) 573 | 574 | def scan_handler(pkt): 575 | assert len(pkt.Payload) == 11 576 | ctx.result = (pkt.Payload[1:9].decode('ascii'), pkt.Payload[9], pkt.Payload[10]) 577 | ctx.evt.set() 578 | 579 | old_handler = self._SetHandler(Packet.NOTIFY_SENSOR_SCAN, scan_handler) 580 | try: 581 | self._DoSimpleCommand(Packet.EnableScan()) 582 | 583 | if ctx.evt.wait(timeout): 584 | s_mac, s_type, s_ver = ctx.result 585 | LOGGER.info("Sensor found: mac=[%s], type=%d, version=%d", s_mac, s_type, s_ver) 586 | r1 = self._GetSensorR1(s_mac, b'Ok5HPNQ4lf77u754') 587 | LOGGER.debug("Sensor R1: %r", bytes_to_hex(r1)) 588 | else: 589 | LOGGER.info("Sensor discovery timeout...") 590 | 591 | self._DoSimpleCommand(Packet.DisableScan()) 592 | finally: 593 | self._SetHandler(Packet.NOTIFY_SENSOR_SCAN, old_handler) 594 | if ctx.result: 595 | s_mac, s_type, s_ver = ctx.result 596 | self._DoSimpleCommand(Packet.VerifySensor(s_mac), 10) 597 | return ctx.result 598 | 599 | def Delete(self, mac): 600 | resp = self._DoSimpleCommand(Packet.DelSensor(str(mac))) 601 | LOGGER.debug("CmdDelSensor returns %s", bytes_to_hex(resp.Payload)) 602 | assert len(resp.Payload) == 9 603 | ack_mac = resp.Payload[:8].decode('ascii') 604 | ack_code = resp.Payload[8] 605 | assert ack_code == 0xFF, "CmdDelSensor: Unexpected ACK code: 0x%02X" % ack_code 606 | assert ack_mac == mac, "CmdDelSensor: MAC mismatch, requested:%s, returned:%s" % (mac, ack_mac) 607 | LOGGER.info("CmdDelSensor: %s deleted", mac) 608 | 609 | 610 | def Open(device, event_handler, logger): 611 | global LOGGER 612 | if logger is not None: 613 | LOGGER = logger 614 | else: 615 | LOGGER = logging.getLogger(__name__) 616 | return Dongle(device, event_handler) 617 | -------------------------------------------------------------------------------- /wyzesense2mqtt/wyzesense2mqtt.py: -------------------------------------------------------------------------------- 1 | ''' 2 | WyzeSense to MQTT Gateway 3 | ''' 4 | import json 5 | import logging 6 | import logging.config 7 | import logging.handlers 8 | import os 9 | import shutil 10 | import subprocess 11 | import yaml 12 | import time 13 | 14 | import paho.mqtt.client as mqtt 15 | import wyzesense 16 | from retrying import retry 17 | 18 | 19 | # Configuration File Locations 20 | CONFIG_PATH = "config" 21 | SAMPLES_PATH = "samples" 22 | MAIN_CONFIG_FILE = "config.yaml" 23 | LOGGING_CONFIG_FILE = "logging.yaml" 24 | SENSORS_CONFIG_FILE = "sensors.yaml" 25 | SENSORS_STATE_FILE = "state.yaml" 26 | 27 | 28 | # Simplify mapping of device classes. 29 | # { **dict.fromkeys(['list', 'of', 'possible', 'identifiers'], 'device_class') } 30 | DEVICE_CLASSES = { 31 | **dict.fromkeys([0x01, 0x0E, 'switch', 'switchv2'], 'opening'), 32 | **dict.fromkeys([0x02, 0x0F, 'motion', 'motionv2'], 'motion'), 33 | **dict.fromkeys([0x03, 'leak'], 'moisture') 34 | } 35 | 36 | 37 | # List of states that correlate to ON. 38 | STATES_ON = ['active', 'open', 'wet'] 39 | 40 | # Oldest state data that is considered fresh, older state data is stale and ignored 41 | # 1 hour, converted to seconds 42 | STALE_STATE = 1*60*60 43 | 44 | # Keep persistant data about the sensors that isn't configurable in a seperate state variable 45 | # Read/write this file to try and maintain a consistent state 46 | SENSORS_STATE = {} 47 | 48 | 49 | # V1 sensors send state every 4 hours, V2 sensors send every 2 hours 50 | # For timeout of availability, use 2 times the report period to allow 51 | # for one missed message. If 2 are missed, then the sensor probably is 52 | # offline. 53 | DEFAULT_V1_TIMEOUT_HOURS = 8 54 | DEFAULT_V2_TIMEOUT_HOURS = 4 55 | 56 | 57 | # List of sw versions for V1 and V2 sensors, to determine which timeout to use by default 58 | V1_SW=[19] 59 | V2_SW=[23] 60 | 61 | INITIALIZED = False 62 | 63 | # Read data from YAML file 64 | def read_yaml_file(filename): 65 | try: 66 | with open(filename) as yaml_file: 67 | data = yaml.safe_load(yaml_file) 68 | return data 69 | except IOError as error: 70 | if (LOGGER is None): 71 | print(f"File error: {str(error)}") 72 | else: 73 | LOGGER.error(f"File error: {str(error)}") 74 | 75 | 76 | # Write data to YAML file 77 | def write_yaml_file(filename, data): 78 | try: 79 | with open(filename, 'w') as yaml_file: 80 | yaml_file.write(yaml.safe_dump(data)) 81 | except IOError as error: 82 | if (LOGGER is None): 83 | print(f"File error: {str(error)}") 84 | else: 85 | LOGGER.error(f"File error: {str(error)}") 86 | 87 | 88 | # Initialize logging 89 | def init_logging(): 90 | global LOGGER 91 | if (not os.path.isfile(os.path.join(CONFIG_PATH, LOGGING_CONFIG_FILE))): 92 | print("Copying default logging config file...") 93 | try: 94 | shutil.copy2(os.path.join(SAMPLES_PATH, LOGGING_CONFIG_FILE), CONFIG_PATH) 95 | except IOError as error: 96 | print(f"Unable to copy default logging config file. {str(error)}") 97 | logging_config = read_yaml_file(os.path.join(CONFIG_PATH, LOGGING_CONFIG_FILE)) 98 | 99 | log_path = os.path.dirname(logging_config['handlers']['file']['filename']) 100 | try: 101 | if (not os.path.exists(log_path)): 102 | os.makedirs(log_path) 103 | except IOError: 104 | print("Unable to create log folder") 105 | logging.config.dictConfig(logging_config) 106 | LOGGER = logging.getLogger("wyzesense2mqtt") 107 | LOGGER.info("Logging initialized...") 108 | 109 | 110 | # Initialize configuration 111 | def init_config(): 112 | global CONFIG 113 | LOGGER.info("Initializing configuration...") 114 | 115 | # Initialize CONFIG dictionary with default values 116 | # Allows for addition of new settings and ensures that missing values will have a default at runtime 117 | CONFIG = { 118 | 'mqtt_host': None, 119 | 'mqtt_port': 1883, 120 | 'mqtt_username': None, 121 | 'mqtt_password': None, 122 | 'mqtt_client_id': 'wyzesense2mqtt', 123 | 'mqtt_clean_session': False, 124 | 'mqtt_keepalive': 60, 125 | 'mqtt_qos': 0, 126 | 'mqtt_retain': True, 127 | 'self_topic_root': 'wyzesense2mqtt', 128 | 'hass_topic_root': 'homeassistant', 129 | 'hass_discovery': True, 130 | 'publish_sensor_name': True, 131 | 'usb_dongle': 'auto' 132 | } 133 | 134 | # load config file over default values 135 | config_from_file = None 136 | if (os.path.isfile(os.path.join(CONFIG_PATH, MAIN_CONFIG_FILE))): 137 | config_from_file = read_yaml_file(os.path.join(CONFIG_PATH, MAIN_CONFIG_FILE)) 138 | CONFIG.update(config_from_file) 139 | 140 | # load ENV supplied config over default values and config file values 141 | for key,value in os.environ.items(): 142 | key = str(key).lower() 143 | if key in CONFIG: 144 | if value.isnumeric(): 145 | value = int(value) 146 | elif value.lower() == 'true': 147 | value = True 148 | elif value.lower() == 'false': 149 | value = False 150 | elif value.lower() == 'none': 151 | value = None 152 | CONFIG.update({key: value}) 153 | 154 | # fail on no config 155 | if (CONFIG is None): 156 | LOGGER.error(f"Failed to load configuration, please configure.") 157 | exit(1) 158 | 159 | # write updated config file if needed 160 | if (config_from_file is None or CONFIG != config_from_file): 161 | LOGGER.info("Writing updated config file") 162 | write_yaml_file(os.path.join(CONFIG_PATH, MAIN_CONFIG_FILE), CONFIG) 163 | 164 | 165 | # Initialize MQTT client connection 166 | def init_mqtt_client(): 167 | global MQTT_CLIENT, CONFIG, LOGGER 168 | # Used for alternate MQTT connection method 169 | mqtt.Client.connected_flag = False 170 | 171 | # Configure MQTT Client 172 | if not hasattr(mqtt, "CallbackAPIVersion"): 173 | # paho-mqtt 1.x 174 | MQTT_CLIENT = mqtt.Client(client_id=CONFIG['mqtt_client_id'], clean_session=CONFIG['mqtt_clean_session']) 175 | else: 176 | # paho-mqtt 2.x 177 | MQTT_CLIENT = mqtt.Client(mqtt.CallbackAPIVersion.VERSION1,client_id=CONFIG['mqtt_client_id'], clean_session=CONFIG['mqtt_clean_session']) 178 | MQTT_CLIENT.username_pw_set(username=CONFIG['mqtt_username'], password=CONFIG['mqtt_password']) 179 | MQTT_CLIENT.reconnect_delay_set(min_delay=1, max_delay=120) 180 | MQTT_CLIENT.on_connect = on_connect 181 | MQTT_CLIENT.on_disconnect = on_disconnect 182 | MQTT_CLIENT.on_message = on_message 183 | MQTT_CLIENT.enable_logger(LOGGER) 184 | 185 | # Connect to MQTT 186 | LOGGER.info(f"Connecting to MQTT host {CONFIG['mqtt_host']}") 187 | MQTT_CLIENT.connect_async(CONFIG['mqtt_host'], port=CONFIG['mqtt_port'], keepalive=CONFIG['mqtt_keepalive']) 188 | 189 | # Used for alternate MQTT connection method 190 | MQTT_CLIENT.loop_start() 191 | while (not MQTT_CLIENT.connected_flag): 192 | time.sleep(1) 193 | 194 | # Make sure the service stays marked as offline until everything is initialized 195 | mqtt_publish(f"{CONFIG['self_topic_root']}/status", "offline", is_json=False) 196 | 197 | # Retry forever on IO Error 198 | def retry_if_io_error(exception): 199 | return isinstance(exception, IOError) 200 | 201 | 202 | # Initialize USB dongle 203 | @retry(wait_exponential_multiplier=1000, wait_exponential_max=30000, retry_on_exception=retry_if_io_error) 204 | def init_wyzesense_dongle(): 205 | global WYZESENSE_DONGLE, CONFIG, LOGGER 206 | if (CONFIG['usb_dongle'].lower() == "auto"): 207 | device_list = subprocess.check_output(["ls", "-la", "/sys/class/hidraw"]).decode("utf-8").lower() 208 | for line in device_list.split("\n"): 209 | if (("e024" in line) and ("1a86" in line)): 210 | for device_name in line.split(" "): 211 | if ("hidraw" in device_name): 212 | CONFIG['usb_dongle'] = f"/dev/{device_name}" 213 | break 214 | 215 | LOGGER.info(f"Connecting to dongle {CONFIG['usb_dongle']}") 216 | try: 217 | WYZESENSE_DONGLE = wyzesense.Open(CONFIG['usb_dongle'], on_event, LOGGER) 218 | LOGGER.info(f"Dongle {CONFIG['usb_dongle']}: [" 219 | f" MAC: {WYZESENSE_DONGLE.MAC}," 220 | f" VER: {WYZESENSE_DONGLE.Version}," 221 | f" ENR: {WYZESENSE_DONGLE.ENR}]") 222 | except IOError as error: 223 | LOGGER.error(f"No device found on path {CONFIG['usb_dongle']}: {str(error)}") 224 | 225 | 226 | # Initialize sensor configuration 227 | def init_sensors(wait=True): 228 | # Initialize sensor dictionary 229 | global SENSORS, SENSORS_STATE 230 | SENSORS = {} 231 | 232 | # Load config file 233 | if (os.path.isfile(os.path.join(CONFIG_PATH, SENSORS_CONFIG_FILE))): 234 | LOGGER.info("Reading sensors configuration...") 235 | SENSORS = read_yaml_file(os.path.join(CONFIG_PATH, SENSORS_CONFIG_FILE)) 236 | sensors_config_file_found = True 237 | else: 238 | LOGGER.warning("No sensors config file found.") 239 | sensors_config_file_found = False 240 | 241 | # Add invert_state value if missing 242 | for sensor_mac in SENSORS: 243 | if (SENSORS[sensor_mac].get('invert_state') is None): 244 | SENSORS[sensor_mac]['invert_state'] = False 245 | 246 | # Load previous known states 247 | if (os.path.isfile(os.path.join(CONFIG_PATH, SENSORS_STATE_FILE))): 248 | LOGGER.info("Reading sensors last known state...") 249 | SENSORS_STATE = read_yaml_file(os.path.join(CONFIG_PATH, SENSORS_STATE_FILE)) 250 | if (SENSORS_STATE.get('modified') is not None): 251 | if ((time.time() - SENSORS_STATE['modified']) > STALE_STATE): 252 | LOGGER.warning("Ignoring stale state data") 253 | SENSORS_STATE = {} 254 | else: 255 | # Remove this field so we don't get a bogus warning below 256 | del SENSORS_STATE['modified'] 257 | 258 | # Check config against linked sensors 259 | checked_linked = False 260 | try: 261 | LOGGER.info("Checking sensors against dongle list...") 262 | result = WYZESENSE_DONGLE.List() 263 | if (result): 264 | checked_linked = True 265 | 266 | for sensor_mac in result: 267 | if (valid_sensor_mac(sensor_mac)): 268 | if (SENSORS.get(sensor_mac) is None): 269 | add_sensor_to_config(sensor_mac, None, None) 270 | LOGGER.warning(f"Linked sensor with mac {sensor_mac} automatically added to sensors configuration") 271 | LOGGER.warning(f"Please update sensor configuration file {os.path.join(CONFIG_PATH, SENSORS_CONFIG_FILE)} restart the service/reload the sensors") 272 | 273 | # If not a configured sensor, then adding it will also add it to the state 274 | # So only check if in the state if it is a configured sensor 275 | elif (SENSORS_STATE.get(sensor_mac) is None): 276 | # Only track state for linked sensors 277 | # If it wasn't configured, it'd get added above, including in state 278 | # Intialize last seen time to now and start online 279 | SENSORS_STATE[sensor_mac] = { 280 | 'last_seen': time.time(), 281 | 'online': True 282 | } 283 | 284 | # We could save sensor state for sensors that aren't linked to the dongle if we fail 285 | # to check, then add a configured sensor to the state which gets written on stop. The 286 | # Next run it'll add the bad state mac. So to help with that, when we do check the 287 | # linked sensors, we should also remove anything in the state that wasn't linked 288 | delete = [sensor_mac for sensor_mac in SENSORS_STATE if sensor_mac not in result] 289 | for sensor_mac in delete: 290 | del SENSORS_STATE[sensor_mac] 291 | LOGGER.info(f"Removed unlinked sensor ({sensor_mac}) from state") 292 | 293 | else: 294 | LOGGER.warning(f"Sensor list failed with result: {result}") 295 | 296 | except TimeoutError: 297 | LOGGER.error("Dongle list timeout") 298 | pass 299 | 300 | if not checked_linked: 301 | # Unable to get linked sensors 302 | # Make sure all configured sensors have a state 303 | for sensor_mac in SENSORS: 304 | if (SENSORS_STATE.get(sensor_mac) is None): 305 | # Intialize last seen time to now and start online 306 | SENSORS_STATE[sensor_mac] = { 307 | 'last_seen': time.time(), 308 | 'online': True 309 | } 310 | 311 | 312 | # Save sensors file if didn't exist 313 | if (not sensors_config_file_found): 314 | LOGGER.info("Writing Sensors Config File") 315 | write_yaml_file(os.path.join(CONFIG_PATH, SENSORS_CONFIG_FILE), SENSORS) 316 | 317 | # Send discovery topics 318 | if(CONFIG['hass_discovery']): 319 | for sensor_mac in SENSORS_STATE: 320 | if (valid_sensor_mac(sensor_mac)): 321 | send_discovery_topics(sensor_mac, wait=wait) 322 | 323 | 324 | # Validate sensor MAC 325 | def valid_sensor_mac(sensor_mac): 326 | invalid_mac_list = [ 327 | "00000000", 328 | "\0\0\0\0\0\0\0\0", 329 | "\x00\x00\x00\x00\x00\x00\x00\x00" 330 | ] 331 | 332 | if ((len(str(sensor_mac)) == 8) and (sensor_mac not in invalid_mac_list)): 333 | return True 334 | else: 335 | LOGGER.warning(f"Unpairing bad MAC: {sensor_mac}") 336 | try: 337 | WYZESENSE_DONGLE.Delete(sensor_mac) 338 | clear_topics(sensor_mac) 339 | except TimeoutError: 340 | LOGGER.error("Timeout removing bad mac") 341 | return False 342 | 343 | 344 | # Add sensor to config 345 | def add_sensor_to_config(sensor_mac, sensor_type, sensor_version): 346 | global SENSORS, SENSORS_STATE 347 | LOGGER.info(f"Adding sensor to config: {sensor_mac}") 348 | SENSORS[sensor_mac] = { 349 | 'name': f"Wyze Sense {sensor_mac}", 350 | 'invert_state': False 351 | } 352 | 353 | SENSORS[sensor_mac]['class'] = "opening" if sensor_type is None else DEVICE_CLASSES.get(sensor_type) 354 | 355 | if (sensor_version is not None): 356 | SENSORS[sensor_mac]['sw_version'] = sensor_version 357 | 358 | # Intialize last seen time to now and start online 359 | SENSORS_STATE[sensor_mac] = { 360 | 'last_seen': time.time(), 361 | 'online': True 362 | } 363 | 364 | write_yaml_file(os.path.join(CONFIG_PATH, SENSORS_CONFIG_FILE), SENSORS) 365 | 366 | 367 | # Delete sensor from config 368 | def delete_sensor_from_config(sensor_mac): 369 | global SENSORS, SENSORS_STATE 370 | LOGGER.info(f"Deleting sensor from config: {sensor_mac}") 371 | try: 372 | del SENSORS[sensor_mac] 373 | write_yaml_file(os.path.join(CONFIG_PATH, SENSORS_CONFIG_FILE), SENSORS) 374 | del SENSORS_STATE[sensor_mac] 375 | except KeyError: 376 | LOGGER.error(f"{sensor_mac} not found in SENSORS") 377 | 378 | 379 | # Publish MQTT topic 380 | def mqtt_publish(mqtt_topic, mqtt_payload, is_json=True, wait=True): 381 | global MQTT_CLIENT, CONFIG 382 | payload = json.dumps(mqtt_payload) if is_json else mqtt_payload 383 | LOGGER.debug(f"Publishing, {mqtt_topic=}, {payload=}") 384 | mqtt_message_info = MQTT_CLIENT.publish( 385 | mqtt_topic, 386 | payload=payload, 387 | qos=CONFIG['mqtt_qos'], 388 | retain=CONFIG['mqtt_retain'] 389 | ) 390 | if (mqtt_message_info.rc == mqtt.MQTT_ERR_SUCCESS): 391 | if (wait): 392 | mqtt_message_info.wait_for_publish(2) 393 | return 394 | 395 | LOGGER.warning(f"MQTT publish error: {mqtt.error_string(mqtt_message_info.rc)}") 396 | 397 | 398 | # Send discovery topics 399 | def send_discovery_topics(sensor_mac, wait=True): 400 | global SENSORS, CONFIG, SENSORS_STATE 401 | 402 | LOGGER.info(f"Publishing discovery topics for {sensor_mac}") 403 | 404 | sensor_name = SENSORS[sensor_mac]['name'] 405 | sensor_class = SENSORS[sensor_mac]['class'] 406 | if (SENSORS[sensor_mac].get('sw_version') is not None): 407 | sensor_version = SENSORS[sensor_mac]['sw_version'] 408 | else: 409 | sensor_version = "" 410 | 411 | mac_topic = f"{CONFIG['self_topic_root']}/{sensor_mac}" 412 | 413 | entity_payloads = { 414 | 'state': { 415 | 'name': None, 416 | 'device_class': sensor_class, 417 | 'payload_on': "1", 418 | 'payload_off': "0", 419 | 'json_attributes_topic': mac_topic, 420 | 'device' : { 421 | 'identifiers': [f"wyzesense_{sensor_mac}", sensor_mac], 422 | 'manufacturer': "Wyze", 423 | 'model': ( 424 | "Sense Motion Sensor" if (sensor_class == "motion") else "Sense Contact Sensor" 425 | ), 426 | 'name': sensor_name, 427 | 'sw_version': sensor_version, 428 | 'via_device': "wyzesense2mqtt" 429 | } 430 | }, 431 | 'signal_strength': { 432 | 'device_class': "signal_strength", 433 | 'state_class': "measurement", 434 | 'unit_of_measurement': "%", 435 | 'entity_category': "diagnostic", 436 | 'device' : { 437 | 'identifiers': [f"wyzesense_{sensor_mac}", sensor_mac], 438 | 'name': sensor_name 439 | } 440 | }, 441 | 'battery': { 442 | 'device_class': "battery", 443 | 'state_class': "measurement", 444 | 'unit_of_measurement': "%", 445 | 'entity_category': "diagnostic", 446 | 'device' : { 447 | 'identifiers': [f"wyzesense_{sensor_mac}", sensor_mac], 448 | 'name': sensor_name 449 | } 450 | } 451 | } 452 | 453 | availability_topics = [ 454 | { 'topic': f"{CONFIG['self_topic_root']}/{sensor_mac}/status" }, 455 | { 'topic': f"{CONFIG['self_topic_root']}/status" } 456 | ] 457 | 458 | for entity, entity_payload in entity_payloads.items(): 459 | entity_payload['value_template'] = f"{{{{ value_json.{entity} }}}}" 460 | entity_payload['unique_id'] = f"wyzesense_{sensor_mac}_{entity}" 461 | entity_payload['state_topic'] = mac_topic 462 | entity_payload['availability'] = availability_topics 463 | entity_payload['availability_mode'] = "all" 464 | entity_payload['platform'] = "mqtt" 465 | 466 | entity_topic = f"{CONFIG['hass_topic_root']}/{'binary_sensor' if (entity == 'state') else 'sensor'}/wyzesense_{sensor_mac}/{entity}/config" 467 | mqtt_publish(entity_topic, entity_payload, wait=wait) 468 | 469 | LOGGER.info(f" {entity_topic}") 470 | LOGGER.info(f" {json.dumps(entity_payload)}") 471 | mqtt_publish(f"{CONFIG['self_topic_root']}/{sensor_mac}/status", "online" if SENSORS_STATE[sensor_mac]['online'] else "offline", is_json=False, wait=wait) 472 | 473 | # Clear any retained topics in MQTT 474 | def clear_topics(sensor_mac, wait=True): 475 | global CONFIG 476 | LOGGER.info("Clearing sensor topics") 477 | mqtt_publish(f"{CONFIG['self_topic_root']}/{sensor_mac}/status", None, wait=wait) 478 | mqtt_publish(f"{CONFIG['self_topic_root']}/{sensor_mac}", None, wait=wait) 479 | 480 | # clear discovery topics if configured 481 | if(CONFIG['hass_discovery']): 482 | entity_types = ['state', 'signal_strength', 'battery'] 483 | for entity_type in entity_types: 484 | sensor_type = ( 485 | "binary_sensor" if (entity_type == "state") 486 | else "sensor" 487 | ) 488 | mqtt_publish(f"{CONFIG['hass_topic_root']}/{sensor_type}/wyzesense_{sensor_mac}/{entity_type}/config", None, wait=wait) 489 | mqtt_publish(f"{CONFIG['hass_topic_root']}/{sensor_type}/wyzesense_{sensor_mac}/{entity_type}", None, wait=wait) 490 | mqtt_publish(f"{CONFIG['hass_topic_root']}/{sensor_type}/wyzesense_{sensor_mac}", None, wait=wait) 491 | 492 | 493 | def on_connect(MQTT_CLIENT, userdata, flags, rc): 494 | global CONFIG 495 | if rc == mqtt.MQTT_ERR_SUCCESS: 496 | MQTT_CLIENT.subscribe( 497 | [(SCAN_TOPIC, CONFIG['mqtt_qos']), 498 | (REMOVE_TOPIC, CONFIG['mqtt_qos']), 499 | (RELOAD_TOPIC, CONFIG['mqtt_qos'])] 500 | ) 501 | MQTT_CLIENT.message_callback_add(SCAN_TOPIC, on_message_scan) 502 | MQTT_CLIENT.message_callback_add(REMOVE_TOPIC, on_message_remove) 503 | MQTT_CLIENT.message_callback_add(RELOAD_TOPIC, on_message_reload) 504 | MQTT_CLIENT.connected_flag = True 505 | LOGGER.info(f"Connected to MQTT: {mqtt.error_string(rc)}") 506 | else: 507 | LOGGER.warning(f"Connection to MQTT failed: {mqtt.error_string(rc)}") 508 | 509 | 510 | def on_disconnect(MQTT_CLIENT, userdata, rc): 511 | MQTT_CLIENT.message_callback_remove(SCAN_TOPIC) 512 | MQTT_CLIENT.message_callback_remove(REMOVE_TOPIC) 513 | MQTT_CLIENT.message_callback_remove(RELOAD_TOPIC) 514 | MQTT_CLIENT.connected_flag = False 515 | LOGGER.info(f"Disconnected from MQTT: {mqtt.error_string(rc)}") 516 | 517 | 518 | # We don't handle any additional messages from MQTT, just log them 519 | def on_message(MQTT_CLIENT, userdata, msg): 520 | LOGGER.info(f"{msg.topic}: {str(msg.payload)}") 521 | 522 | 523 | # Process message to scan for new sensors 524 | def on_message_scan(MQTT_CLIENT, userdata, msg): 525 | global SENSORS, CONFIG 526 | result = None 527 | LOGGER.info(f"In on_message_scan: {msg.payload.decode()}") 528 | 529 | # The scan will do a couple additional calls even after the new sensor is found 530 | # These calls may time out, so catch it early so we can still add the sensor properly 531 | try: 532 | result = WYZESENSE_DONGLE.Scan() 533 | except TimeoutError: 534 | pass 535 | 536 | if (result): 537 | LOGGER.info(f"Scan result: {result}") 538 | sensor_mac, sensor_type, sensor_version = result 539 | if (valid_sensor_mac(sensor_mac)): 540 | if (SENSORS.get(sensor_mac)) is None: 541 | add_sensor_to_config(sensor_mac, sensor_type, sensor_version) 542 | if(CONFIG['hass_discovery']): 543 | # We are in a mqtt callback, so can not wait for new messages to publish 544 | send_discovery_topics(sensor_mac, wait=False) 545 | else: 546 | LOGGER.info(f"Invalid sensor found: {sensor_mac}") 547 | else: 548 | LOGGER.info("No new sensor found") 549 | 550 | 551 | # Process message to remove sensor 552 | def on_message_remove(MQTT_CLIENT, userdata, msg): 553 | sensor_mac = msg.payload.decode() 554 | LOGGER.info(f"In on_message_remove: {sensor_mac}") 555 | 556 | if (valid_sensor_mac(sensor_mac)): 557 | # Deleting from the dongle may timeout, but we still need to do 558 | # the rest so catch it early 559 | try: 560 | WYZESENSE_DONGLE.Delete(sensor_mac) 561 | except TimeoutError: 562 | pass 563 | # We are in a mqtt callback so cannot wait for new messages to publish 564 | clear_topics(sensor_mac, wait=False) 565 | delete_sensor_from_config(sensor_mac) 566 | else: 567 | LOGGER.info(f"Invalid mac address: {sensor_mac}") 568 | 569 | 570 | # Process message to reload sensors 571 | def on_message_reload(MQTT_CLIENT, userdata, msg): 572 | LOGGER.info(f"In on_message_reload: {msg.payload.decode()}") 573 | 574 | # Save off the last known state so we don't overwrite new state by re-reading the previously saved file 575 | LOGGER.info("Writing Sensors State File") 576 | write_yaml_file(os.path.join(CONFIG_PATH, SENSORS_STATE_FILE), SENSORS_STATE) 577 | 578 | # We are in a mqtt callback so cannot wait for new messages to publish 579 | init_sensors(wait=False) 580 | 581 | 582 | # Process event 583 | def on_event(WYZESENSE_DONGLE, event): 584 | global SENSORS, SENSORS_STATE 585 | 586 | if not INITIALIZED: 587 | return 588 | 589 | if event == "ERROR": 590 | mqtt_publish(f"{CONFIG['self_topic_root']}/status", "offline", is_json=False) 591 | 592 | if (valid_sensor_mac(event.MAC)): 593 | if (event.MAC not in SENSORS): 594 | add_sensor_to_config(event.MAC, None, None) 595 | if(CONFIG['hass_discovery']): 596 | send_discovery_topics(event.MAC) 597 | LOGGER.warning(f"Linked sensor with mac {event.MAC} automatically added to sensors configuration") 598 | LOGGER.warning(f"Please update sensor configuration file {os.path.join(CONFIG_PATH, SENSORS_CONFIG_FILE)} restart the service/reload the sensors") 599 | 600 | # Store last seen time for availability 601 | SENSORS_STATE[event.MAC]['last_seen'] = event.Timestamp.timestamp() 602 | 603 | mqtt_publish(f"{CONFIG['self_topic_root']}/{event.MAC}/status", "online", is_json=False) 604 | 605 | # Set back online if it was offline 606 | if not SENSORS_STATE[event.MAC]['online']: 607 | SENSORS_STATE[event.MAC]['online'] = True 608 | LOGGER.info(f"{event.MAC} is back online!") 609 | 610 | if (event.Type == "alarm") or (event.Type == "status"): 611 | LOGGER.debug(f"State event data: {event}") 612 | (sensor_type, sensor_state, sensor_battery, sensor_signal) = event.Data 613 | 614 | # Set state depending on state string and `invert_state` setting. 615 | # State ON ^ NOT Inverted = True 616 | # State OFF ^ NOT Inverted = False 617 | # State ON ^ Inverted = False 618 | # State OFF ^ Inverted = True 619 | sensor_state = int((sensor_state in STATES_ON) ^ (SENSORS[event.MAC].get('invert_state'))) 620 | 621 | # V2 sensors use a single 1.5v battery and reports half the battery level of other sensors with 3v batteries 622 | if sensor_type == "switchv2": 623 | sensor_battery = sensor_battery * 2 624 | 625 | # Adjust battery to max it at 100% 626 | sensor_battery = 100 if sensor_battery > 100 else sensor_battery 627 | 628 | # Negate signal strength to match dbm vs percent 629 | sensor_signal = sensor_signal * -1 630 | 631 | sensor_signal = min(max(2 * (sensor_signal + 115), 1), 100) 632 | 633 | # Build event payload 634 | event_payload = { 635 | 'mac': event.MAC, 636 | 'signal_strength': sensor_signal, 637 | 'battery': sensor_battery, 638 | 'state': sensor_state, 639 | 'last_seen': event.Timestamp.isoformat(), 640 | } 641 | 642 | if (CONFIG['publish_sensor_name']): 643 | event_payload['name'] = SENSORS[event.MAC]['name'] 644 | 645 | mqtt_publish(f"{CONFIG['self_topic_root']}/{event.MAC}", event_payload) 646 | 647 | LOGGER.info(f"{CONFIG['self_topic_root']}/{event.MAC}") 648 | LOGGER.info(event_payload) 649 | else: 650 | LOGGER.info(f"{event}") 651 | 652 | else: 653 | LOGGER.warning("!Invalid MAC detected!") 654 | LOGGER.warning(f"Event data: {event}") 655 | 656 | def Stop(): 657 | # Stop the dongle first, letting this thread finish anything it might be busy doing, like handling an event 658 | WYZESENSE_DONGLE.Stop() 659 | 660 | mqtt_publish(f"{CONFIG['self_topic_root']}/status", "offline", is_json=False) 661 | 662 | # All event handling should now be done, close the mqtt connection 663 | MQTT_CLIENT.loop_stop() 664 | MQTT_CLIENT.disconnect() 665 | 666 | # Save off the last known state 667 | LOGGER.info("Writing Sensors State File") 668 | SENSORS_STATE['modified'] = time.time() 669 | write_yaml_file(os.path.join(CONFIG_PATH, SENSORS_STATE_FILE), SENSORS_STATE) 670 | 671 | LOGGER.info("********************************** Wyzesense2mqtt stopped ***********************************") 672 | 673 | 674 | if __name__ == "__main__": 675 | # Initialize logging 676 | init_logging() 677 | 678 | print("********************************** Wyzesense2mqtt starting **********************************") 679 | 680 | # Initialize configuration 681 | init_config() 682 | 683 | # Set MQTT Topics 684 | SCAN_TOPIC = f"{CONFIG['self_topic_root']}/scan" 685 | REMOVE_TOPIC = f"{CONFIG['self_topic_root']}/remove" 686 | RELOAD_TOPIC = f"{CONFIG['self_topic_root']}/reload" 687 | 688 | # Initialize MQTT client connection 689 | init_mqtt_client() 690 | 691 | # Initialize USB dongle 692 | init_wyzesense_dongle() 693 | 694 | # Initialize sensor configuration 695 | init_sensors() 696 | 697 | # All initialized now, so set the flag to allow message event to be processed 698 | INITIALIZED = True 699 | 700 | # And mark the service as online 701 | mqtt_publish(f"{CONFIG['self_topic_root']}/status", "online", is_json=False) 702 | 703 | dongle_offline = False 704 | 705 | # Loop forever until keyboard interrupt or SIGINT 706 | try: 707 | loop_counter = 0 708 | while True: 709 | time.sleep(5) 710 | # Check if there is any exceptions in the dongle thread 711 | WYZESENSE_DONGLE.CheckError() 712 | 713 | # Skip everything while dongle is offline, service needs to be restarted 714 | if dongle_offline: 715 | continue 716 | 717 | loop_counter += 1 718 | 719 | if not MQTT_CLIENT.connected_flag: 720 | LOGGER.warning("Reconnecting MQTT...") 721 | MQTT_CLIENT.reconnect() 722 | 723 | if MQTT_CLIENT.connected_flag: 724 | mqtt_publish(f"{CONFIG['self_topic_root']}/status", "online", is_json=False) 725 | 726 | # Every minute, try to get the dongle mac address. Hopefully this will tell us if we are having trouble 727 | # communicating with the dongle. If so, set the service offline 728 | if loop_counter > 12: 729 | loop_counter = 0 730 | try: 731 | WYZESENSE_DONGLE._GetMac() 732 | except TimeoutError: 733 | LOGGER.error("Failed to communicate with dongle") 734 | dongle_offline = True 735 | mqtt_publish(f"{CONFIG['self_topic_root']}/status", "offline", is_json=False) 736 | 737 | # Check for availability of the devices 738 | now = time.time() 739 | for mac in SENSORS_STATE: 740 | if SENSORS_STATE[mac]['online']: 741 | LOGGER.debug(f"Checking availability of {mac}") 742 | # If there is a timeout configured, use that. Must be in seconds. 743 | # If no timeout configured, check if it's a V2 device (quicker reporting period) 744 | # Otherwise, use the longer V1 timeout period 745 | if (SENSORS[mac].get('timeout') is not None): 746 | timeout = SENSORS[mac]['timeout'] 747 | elif (SENSORS[mac].get('sw_version') is not None and SENSORS[mac]['sw_version'] in V2_SW): 748 | timeout = DEFAULT_V2_TIMEOUT_HOURS*60*60 749 | else: 750 | timeout = DEFAULT_V1_TIMEOUT_HOURS*60*60 751 | 752 | if ((now - SENSORS_STATE[mac]['last_seen']) > timeout): 753 | mqtt_publish(f"{CONFIG['self_topic_root']}/{mac}/status", "offline", is_json=False) 754 | LOGGER.warning(f"{mac} has gone offline!") 755 | SENSORS_STATE[mac]['online'] = False 756 | except KeyboardInterrupt: 757 | LOGGER.warning("User interrupted") 758 | except Exception as e: 759 | LOGGER.error("An error occurred", exc_info=True) 760 | finally: 761 | Stop() 762 | -------------------------------------------------------------------------------- /wyzesense2mqtt/wyzesense2mqtt.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=WyzeSense to MQTT Gateway 3 | Documentation=https://www.github.com/raetha/wyzesense2mqtt 4 | After=network.target 5 | 6 | [Service] 7 | Type=simple 8 | WorkingDirectory=/wyzesense2mqtt 9 | ExecStart=/wyzesense2mqtt/service.sh 10 | Restart=always 11 | KillSignal=SIGINT 12 | 13 | [Install] 14 | WantedBy=multi-user.target 15 | --------------------------------------------------------------------------------