├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ └── bug_report.yml ├── dependabot.yml └── workflows │ ├── docker-image.yml │ └── version-check.yml ├── .gitignore ├── LICENSE ├── README.md ├── app ├── .env ├── frontend.py ├── lib │ ├── lib.amd64 │ ├── lib.arm │ └── lib.arm64 ├── requirements.txt ├── static │ ├── bulma-toast.js │ ├── bulma.css │ ├── loading.svg │ ├── notavailable.svg │ ├── site.css │ ├── site.js │ └── webrtc.js ├── templates │ ├── base.html │ ├── index.html │ ├── login.html │ ├── m3u8.html │ └── webrtc.html ├── wyze_bridge.py ├── wyzebridge │ ├── __init__.py │ ├── auth.py │ ├── bridge_utils.py │ ├── config.py │ ├── ffmpeg.py │ ├── hass.py │ ├── logging.py │ ├── mqtt.py │ ├── mtx_event.py │ ├── mtx_server.py │ ├── stream.py │ ├── web_ui.py │ ├── webhooks.py │ ├── wyze_api.py │ ├── wyze_commands.py │ ├── wyze_control.py │ ├── wyze_events.py │ └── wyze_stream.py └── wyzecam │ ├── __init__.py │ ├── api.py │ ├── api_models.py │ ├── iotc.py │ ├── py.typed │ └── tutk │ ├── __init__.py │ ├── device_config.json │ ├── tutk.py │ ├── tutk_ioctl_mux.py │ └── tutk_protocol.py ├── docker-compose.ovpn.yml ├── docker-compose.sample.yml ├── docker-compose.tailscale.yml ├── docker ├── .dockerignore ├── Dockerfile ├── Dockerfile.hwaccel ├── Dockerfile.multiarch └── Dockerfile.qsv ├── home_assistant ├── CHANGELOG.md ├── DOCS.md ├── README.md ├── config.yml ├── dev │ ├── Dockerfile │ ├── config.yml │ ├── icon.png │ └── translations │ │ └── en.yml ├── edge │ ├── Dockerfile │ ├── config.yml │ ├── icon.png │ └── translations │ │ └── en.yml ├── hw │ ├── Dockerfile │ ├── config.yml │ └── translations │ │ └── en.yml ├── icon.png ├── previous │ ├── Dockerfile │ ├── config.yml │ ├── icon.png │ └── translations │ │ └── en.yml └── translations │ └── en.yml ├── repository.json └── unraid └── docker-wyze-bridge.xml /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: mrlt8 # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Help improve the bridge by reporting any bugs 3 | title: "BUG: " 4 | labels: ["bug"] 5 | body: 6 | - type: textarea 7 | id: description 8 | attributes: 9 | label: Describe the bug 10 | description: Provide a clear and concise description of the issue and include relevant logs if applicable. 11 | validations: 12 | required: true 13 | - type: markdown 14 | attributes: 15 | value: Additional information to help resolve the issue 16 | - type: input 17 | id: version 18 | attributes: 19 | label: Affected Bridge Version 20 | description: Please include the image tag if applicable 21 | placeholder: e.g. v1.9.10 22 | validations: 23 | required: true 24 | - type: dropdown 25 | id: type 26 | attributes: 27 | label: Bridge type 28 | multiple: true 29 | options: 30 | - Docker Run/Compose 31 | - Home Assistant 32 | - Other 33 | validations: 34 | required: true 35 | - type: input 36 | id: cameras 37 | attributes: 38 | label: Affected Camera(s) 39 | - type: input 40 | id: firmware 41 | attributes: 42 | label: Affected Camera Firmware 43 | - type: textarea 44 | id: config 45 | attributes: 46 | label: docker-compose or config (if applicable) 47 | description: Please be sure to remove any credentials or sensitive information! 48 | render: yaml -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" 9 | directory: "/app" 10 | schedule: 11 | interval: "daily" 12 | - package-ecosystem: "github-actions" 13 | directory: "/" 14 | schedule: 15 | interval: "daily" 16 | -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | on: 4 | schedule: 5 | - cron: '31 22 * * 0' 6 | push: 7 | branches: [main, dev] 8 | # Publish semver tags as releases. 9 | tags: ['v*.*.*'] 10 | pull_request: 11 | branches: [main] 12 | workflow_dispatch: 13 | 14 | jobs: 15 | prebuild: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Should build? 19 | run: | 20 | if [ -z "${{ secrets.DOCKERHUB_USERNAME }}" ]; then 21 | echo "The DOCKERHUB_USERNAME secret is missing." 22 | exit 1 23 | fi 24 | 25 | build: 26 | needs: [prebuild] 27 | runs-on: ubuntu-latest 28 | permissions: 29 | contents: write 30 | packages: write 31 | 32 | strategy: 33 | matrix: 34 | dockerfile: ['multiarch', 'hwaccel', 'qsv'] 35 | 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@v4 39 | 40 | - name: matrix image type 41 | id: image_type 42 | run: | 43 | echo "suffix=${{ matrix.dockerfile == 'hwaccel' && '-hw' || matrix.dockerfile == 'qsv' && '-qsv' ||'' }}" >> $GITHUB_OUTPUT 44 | echo "platforms=${{ matrix.dockerfile == 'multiarch' && 'linux/amd64,linux/arm64,linux/arm/v7' || 'linux/amd64' }}" >> $GITHUB_OUTPUT 45 | echo "arch=${{ matrix.dockerfile == 'multiarch' && 'amd64,armhf,aarch64' || 'amd64' }}" >> $GITHUB_OUTPUT 46 | 47 | - name: Set up QEMU 48 | uses: docker/setup-qemu-action@v3 49 | with: 50 | platforms: ${{ steps.image_type.outputs.platforms }} 51 | 52 | - name: Set up Docker Buildx 53 | id: buildx 54 | uses: docker/setup-buildx-action@master 55 | with: 56 | platforms: ${{ steps.image_type.outputs.platforms }} 57 | 58 | - name: Login to DockerHub 59 | if: github.event_name != 'pull_request' 60 | uses: docker/login-action@v3 61 | with: 62 | username: ${{ secrets.DOCKERHUB_USERNAME }} 63 | password: ${{ secrets.DOCKERHUB_TOKEN }} 64 | 65 | - name: Log into registry ghcr.io 66 | if: github.event_name != 'pull_request' 67 | uses: docker/login-action@v3 68 | with: 69 | registry: ghcr.io 70 | username: ${{ github.repository_owner }} 71 | password: ${{ secrets.GITHUB_TOKEN }} 72 | 73 | - name: Extract Docker metadata 74 | id: meta 75 | uses: docker/metadata-action@v5 76 | with: 77 | images: | 78 | ${{ github.repository_owner }}/wyze-bridge 79 | ghcr.io/${{ github.repository }} 80 | flavor: | 81 | latest=auto 82 | suffix=${{ steps.image_type.outputs.suffix }},onlatest=true 83 | tags: | 84 | type=schedule,suffix=${{ steps.image_type.outputs.suffix }} 85 | type=semver,pattern={{ version }},suffix=${{ steps.image_type.outputs.suffix }} 86 | type=edge,branch=main,enable=${{ github.event_name == 'push' }},suffix=${{ steps.image_type.outputs.suffix }} 87 | type=ref,event=branch,enable=${{ contains(github.ref,'dev') }},suffix=${{ steps.image_type.outputs.suffix }} 88 | 89 | - name: Update Release Version 90 | id: version_bump 91 | if: startsWith(github.ref, 'refs/tags/v') 92 | run: | 93 | TAG_NAME=${GITHUB_REF##*/v} 94 | echo "TAG_NAME: $TAG_NAME" 95 | if [[ $TAG_NAME =~ ^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$ ]]; then 96 | sed -i "s/^VERSION=.*/VERSION=${TAG_NAME}/" ./app/.env 97 | echo "Updated VERSION in app/.env to $TAG_NAME" 98 | fi 99 | 100 | - name: Build and push a Docker image 101 | uses: docker/build-push-action@v6 102 | with: 103 | builder: ${{ steps.buildx.outputs.name }} 104 | push: ${{ github.event_name != 'pull_request' }} 105 | context: . 106 | file: ./docker/Dockerfile.${{ matrix.dockerfile }} 107 | platforms: ${{ steps.image_type.outputs.platforms }} 108 | build-args: | 109 | BUILD=${{ steps.meta.outputs.VERSION }} 110 | BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }} 111 | GITHUB_SHA=${{ github.sha }} 112 | labels: | 113 | ${{ steps.meta.outputs.labels }} 114 | io.hass.name=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.title'] }} 115 | io.hass.description=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.description'] }} 116 | io.hass.version=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }} 117 | io.hass.type=addon 118 | io.hass.arch=${{ steps.image_type.outputs.arch }} 119 | tags: ${{ steps.meta.outputs.tags }} 120 | cache-from: type=gha,scope=${{ matrix.dockerfile }} 121 | cache-to: type=gha,mode=max,scope=${{ matrix.dockerfile }} 122 | provenance: false 123 | 124 | version_bump: 125 | needs: [build] 126 | runs-on: ubuntu-latest 127 | steps: 128 | - uses: actions/checkout@v4 129 | - name: Update Release Version 130 | id: version_bump 131 | if: startsWith(github.ref, 'refs/tags/v') 132 | run: | 133 | TAG_NAME=${GITHUB_REF##*/v} 134 | if [[ $TAG_NAME =~ ^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$ ]]; then 135 | sed -i "s/^VERSION=.*/VERSION=${TAG_NAME}/" ./app/.env 136 | sed -i "s/^version: .*/version: ${TAG_NAME}/" ./home_assistant/config.yml 137 | echo "tag=${TAG_NAME}" >> $GITHUB_OUTPUT 138 | fi 139 | - name: Commit and push changes 140 | uses: stefanzweifel/git-auto-commit-action@v5 141 | with: 142 | branch: main 143 | commit_message: 'Bump Version to v${{ steps.version_bump.outputs.tag }}' 144 | file_pattern: 'app/.env home_assistant/config.yml' 145 | -------------------------------------------------------------------------------- /.github/workflows/version-check.yml: -------------------------------------------------------------------------------- 1 | name: Version Check 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | workflow_dispatch: 7 | 8 | jobs: 9 | update-media-mtx: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Get Latest MediaMTX Release 16 | id: media_mtx 17 | run: | 18 | release_tag=$(curl -s https://api.github.com/repos/bluenviron/mediamtx/releases/latest | jq -r '.tag_name' | sed 's/^v//') 19 | if [[ ! $release_tag =~ ^[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]; then 20 | echo "Invalid version format: ${release_tag}. Exiting workflow." 21 | exit 1 22 | fi 23 | current_tag=$(cat ./app/.env | grep MTX_TAG | cut -d'=' -f2) 24 | sed -i "s/MTX_TAG=.*/MTX_TAG=${release_tag}/" ./app/.env 25 | echo "release_tag=$release_tag" >> $GITHUB_OUTPUT 26 | echo "current_tag=$current_tag" >> $GITHUB_OUTPUT 27 | 28 | - name: Create Pull Request 29 | uses: peter-evans/create-pull-request@v7 30 | if: steps.media_mtx.outputs.current_tag != '' && steps.media_mtx.outputs.current_tag != steps.media_mtx.outputs.release_tag 31 | with: 32 | title: 'Bump MediaMTX to v${{ steps.media_mtx.outputs.release_tag }}' 33 | commit-message: 'Update MediaMTX version from v${{ steps.media_mtx.outputs.current_tag }} to v${{ steps.media_mtx.outputs.release_tag }}' 34 | branch: mtx-version 35 | body: | 36 | This pull request updates MediaMTX to the latest version: [v${{ steps.media_mtx.outputs.release_tag }}](https://github.com/bluenviron/mediamtx/releases/tag/v${{ steps.media_mtx.outputs.release_tag }}) 37 | add-paths: app/.env 38 | base: main 39 | delete-branch: true 40 | 41 | update-wyze-ios: 42 | runs-on: ubuntu-latest 43 | 44 | steps: 45 | - uses: actions/checkout@v4 46 | 47 | - name: Get Latest Wyze Release 48 | id: wyze_ios 49 | run: | 50 | release_tag=$(curl -s 'http://itunes.apple.com/lookup?id=1288415553' | jq -r '.results[0].version') 51 | if [[ ! $release_tag =~ ^[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]; then 52 | echo "Invalid version format: ${release_tag}. Exiting workflow." 53 | exit 1 54 | fi 55 | current_tag=$(cat ./app/.env | grep APP_VERSION | cut -d'=' -f2) 56 | sed -i "s/APP_VERSION=.*/APP_VERSION=${release_tag}/" ./app/.env 57 | echo "release_tag=$release_tag" >> $GITHUB_OUTPUT 58 | echo "current_tag=$current_tag" >> $GITHUB_OUTPUT 59 | 60 | - name: Create Pull Request 61 | uses: peter-evans/create-pull-request@v7 62 | if: steps.wyze_ios.outputs.current_tag != '' && steps.wyze_ios.outputs.current_tag != steps.wyze_ios.outputs.release_tag 63 | with: 64 | title: 'Bump Wyze App version to v${{ steps.wyze_ios.outputs.release_tag }}' 65 | commit-message: 'Update Wyze iOS App version from v${{ steps.wyze_ios.outputs.current_tag }} to v${{ steps.wyze_ios.outputs.release_tag }}' 66 | branch: wyze-version 67 | body: | 68 | This pull request updates the Wyze iOS App version to the latest version: [v${{ steps.wyze_ios.outputs.release_tag }}](https://apps.apple.com/us/app/wyze-make-your-home-smarter/id1288415553) 69 | add-paths: app/.env 70 | base: main 71 | delete-branch: true 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/.DS_Store 2 | **/docker-compose.yml 3 | **/mfa_token 4 | **/totp 5 | **/.vscode 6 | **/tokens/ 7 | *.pickle 8 | *.env 9 | *.pyc 10 | !/app/.env 11 | -------------------------------------------------------------------------------- /app/.env: -------------------------------------------------------------------------------- 1 | VERSION=2.10.3 2 | MTX_TAG=1.9.1 3 | IOS_VERSION=17.1.1 4 | APP_VERSION=3.1.6.1 5 | MTX_HLSVARIANT=mpegts 6 | MTX_PROTOCOLS=tcp 7 | MTX_READTIMEOUT=20s 8 | MTX_LOGLEVEL=warn 9 | MTX_WRITEQUEUESIZE=1024 10 | MTX_WEBRTCICEUDPMUXADDRESS=:8189 11 | SDK_KEY=AQAAAIZ44fijz5pURQiNw4xpEfV9ZysFH8LYBPDxiONQlbLKaDeb7n26TSOPSGHftbRVo25k3uz5of06iGNB4pSfmvsCvm/tTlmML6HKS0vVxZnzEuK95TPGEGt+aE15m6fjtRXQKnUav59VSRHwRj9Z1Kjm1ClfkSPUF5NfUvsb3IAbai0WlzZE1yYCtks7NFRMbTXUMq3bFtNhEERD/7oc504b 12 | -------------------------------------------------------------------------------- /app/frontend.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | from functools import wraps 4 | from pathlib import Path 5 | from urllib.parse import quote_plus 6 | 7 | from flask import ( 8 | Flask, 9 | Response, 10 | make_response, 11 | redirect, 12 | render_template, 13 | request, 14 | send_from_directory, 15 | ) 16 | from werkzeug.exceptions import NotFound 17 | from wyze_bridge import WyzeBridge 18 | from wyzebridge import config, web_ui 19 | from wyzebridge.auth import WbAuth 20 | from wyzebridge.web_ui import url_for 21 | 22 | 23 | def create_app(): 24 | app = Flask(__name__) 25 | wb = WyzeBridge() 26 | try: 27 | wb.start() 28 | except RuntimeError as ex: 29 | print(ex) 30 | print("Please ensure your host is up to date.") 31 | exit() 32 | 33 | def auth_required(view): 34 | @wraps(view) 35 | def wrapped_view(*args, **kwargs): 36 | if not wb.api.auth: 37 | return redirect(url_for("wyze_login")) 38 | return web_ui.auth.login_required(view)(*args, **kwargs) 39 | 40 | return wrapped_view 41 | 42 | @app.route("/login", methods=["GET", "POST"]) 43 | def wyze_login(): 44 | if wb.api.auth: 45 | return redirect(url_for("index")) 46 | if request.method == "GET": 47 | return render_template( 48 | "login.html", 49 | api=WbAuth.api, 50 | version=config.VERSION, 51 | ) 52 | 53 | tokens = request.form.get("tokens") 54 | refresh = request.form.get("refresh") 55 | 56 | if tokens or refresh: 57 | wb.api.token_auth(tokens=tokens, refresh=refresh) 58 | return {"status": "success"} 59 | 60 | credentials = { 61 | "email": request.form.get("email"), 62 | "password": request.form.get("password"), 63 | "key_id": request.form.get("keyId"), 64 | "api_key": request.form.get("apiKey"), 65 | } 66 | 67 | if all(credentials.values()): 68 | wb.api.creds.update(**credentials) 69 | return {"status": "success"} 70 | 71 | return {"status": "missing credentials"} 72 | 73 | @app.route("/") 74 | @auth_required 75 | def index(): 76 | if not (columns := request.args.get("columns")): 77 | columns = request.cookies.get("number_of_columns", "2") 78 | if not (refresh := request.args.get("refresh")): 79 | refresh = request.cookies.get("refresh_period", "30") 80 | number_of_columns = int(columns) if columns.isdigit() else 0 81 | refresh_period = int(refresh) if refresh.isdigit() else 0 82 | show_video = bool(request.cookies.get("show_video")) 83 | autoplay = bool(request.cookies.get("autoplay")) 84 | if "autoplay" in request.args: 85 | autoplay = True 86 | if "video" in request.args: 87 | show_video = True 88 | elif "snapshot" in request.args: 89 | show_video = False 90 | 91 | video_format = request.cookies.get("video", "webrtc") 92 | if req_video := ({"webrtc", "hls", "kvs"} & set(request.args)): 93 | video_format = req_video.pop() 94 | resp = make_response( 95 | render_template( 96 | "index.html", 97 | cam_data=web_ui.all_cams(wb.streams, wb.api.total_cams), 98 | number_of_columns=number_of_columns, 99 | refresh_period=refresh_period, 100 | api=WbAuth.api, 101 | version=config.VERSION, 102 | webrtc=bool(config.BRIDGE_IP), 103 | show_video=show_video, 104 | video_format=video_format.lower(), 105 | autoplay=autoplay, 106 | ) 107 | ) 108 | 109 | resp.set_cookie("number_of_columns", str(number_of_columns)) 110 | resp.set_cookie("refresh_period", str(refresh_period)) 111 | resp.set_cookie("show_video", "1" if show_video else "") 112 | resp.set_cookie("video", video_format) 113 | fullscreen = "fullscreen" in request.args or bool( 114 | request.cookies.get("fullscreen") 115 | ) 116 | resp.set_cookie("fullscreen", "1" if fullscreen else "") 117 | if order := request.args.get("order"): 118 | resp.set_cookie("camera_order", quote_plus(order)) 119 | 120 | return resp 121 | 122 | @app.route("/api/sse_status") 123 | @auth_required 124 | def sse_status(): 125 | """Server sent event for camera status.""" 126 | return Response( 127 | web_ui.sse_generator(wb.streams.get_sse_status), 128 | mimetype="text/event-stream", 129 | ) 130 | 131 | @app.route("/api") 132 | @auth_required 133 | def api_all_cams(): 134 | return web_ui.all_cams(wb.streams, wb.api.total_cams) 135 | 136 | @app.route("/api/") 137 | @auth_required 138 | def api_cam(cam_name: str): 139 | if cam := wb.streams.get_info(cam_name): 140 | return cam | web_ui.format_stream(cam_name) 141 | return {"error": f"Could not find camera [{cam_name}]"} 142 | 143 | @app.route("/api//", methods=["GET", "PUT", "POST"]) 144 | @app.route("/api///") 145 | @auth_required 146 | def api_cam_control(cam_name: str, cam_cmd: str, payload: str | dict = ""): 147 | """API Endpoint to send tutk commands to the camera.""" 148 | if not payload and (args := request.values.to_dict()): 149 | args.pop("api", None) 150 | payload = next(iter(args.values())) if len(args) == 1 else args 151 | if not payload and request.is_json: 152 | json = request.get_json() 153 | if isinstance(json, dict): 154 | payload = json if len(json) > 1 else list(json.values())[0] 155 | else: 156 | payload = json 157 | elif not payload and request.data: 158 | payload = request.data.decode() 159 | 160 | return wb.streams.send_cmd(cam_name, cam_cmd.lower(), payload) 161 | 162 | @app.route("/signaling/") 163 | @auth_required 164 | def webrtc_signaling(name): 165 | if "kvs" in request.args: 166 | return wb.api.get_kvs_signal(name) 167 | return web_ui.get_webrtc_signal(name, WbAuth.api) 168 | 169 | @app.route("/webrtc/") 170 | @auth_required 171 | def webrtc(name): 172 | """View WebRTC direct from camera.""" 173 | if (webrtc := wb.api.get_kvs_signal(name)).get("result") == "ok": 174 | return make_response(render_template("webrtc.html", webrtc=webrtc)) 175 | return webrtc 176 | 177 | @app.route("/snapshot/") 178 | @auth_required 179 | def rtsp_snapshot(img_file: str): 180 | """Use ffmpeg to take a snapshot from the rtsp stream.""" 181 | if wb.streams.get_rtsp_snap(Path(img_file).stem): 182 | return send_from_directory(config.IMG_PATH, img_file) 183 | return thumbnail(img_file) 184 | 185 | @app.route("/img/") 186 | @auth_required 187 | def img(img_file: str): 188 | """ 189 | Serve an existing local image or take a new snapshot from the rtsp stream. 190 | 191 | Use the exp parameter to fetch a new snapshot if the existing one is too old. 192 | """ 193 | try: 194 | if exp := request.args.get("exp"): 195 | created_at = os.path.getmtime(config.IMG_PATH + img_file) 196 | if time.time() - created_at > int(exp): 197 | raise NotFound 198 | return send_from_directory(config.IMG_PATH, img_file) 199 | except (NotFound, FileNotFoundError, ValueError): 200 | return rtsp_snapshot(img_file) 201 | 202 | @app.route("/thumb/") 203 | @auth_required 204 | def thumbnail(img_file: str): 205 | if wb.api.save_thumbnail(Path(img_file).stem): 206 | return send_from_directory(config.IMG_PATH, img_file) 207 | return redirect("/static/notavailable.svg", code=307) 208 | 209 | @app.route("/photo/") 210 | @auth_required 211 | def boa_photo(img_file: str): 212 | """Take a photo on the camera and grab it over the boa http server.""" 213 | uri = Path(img_file).stem 214 | if not (cam := wb.streams.get(uri)): 215 | return redirect("/static/notavailable.svg", code=307) 216 | if photo := web_ui.boa_snapshot(cam): 217 | return send_from_directory(config.IMG_PATH, f"{uri}_{photo[0]}") 218 | return redirect(f"/img/{img_file}", code=307) 219 | 220 | @app.route("/restart/") 221 | @auth_required 222 | def restart_bridge(restart_cmd: str): 223 | """ 224 | Restart parts of the wyze-bridge. 225 | 226 | /restart/cameras: Restart camera connections. 227 | /restart/rtsp_server: Restart rtsp-simple-server. 228 | /restart/all: Restart camera connections and rtsp-simple-server. 229 | """ 230 | if restart_cmd == "cameras": 231 | wb.streams.stop_all() 232 | wb.streams.monitor_streams(wb.mtx.health_check) 233 | elif restart_cmd == "rtsp_server": 234 | wb.mtx.restart() 235 | elif restart_cmd == "cam_data": 236 | wb.refresh_cams() 237 | restart_cmd = "cameras" 238 | elif restart_cmd == "all": 239 | wb.restart(fresh_data=True) 240 | restart_cmd = "cameras,rtsp_server" 241 | else: 242 | return {"result": "error"} 243 | return {"result": "ok", "restart": restart_cmd.split(",")} 244 | 245 | @app.route("/cams.m3u8") 246 | @auth_required 247 | def iptv_playlist(): 248 | """ 249 | Generate an m3u8 playlist with all enabled cameras. 250 | """ 251 | cameras = web_ui.format_streams(wb.streams.get_all_cam_info()) 252 | resp = make_response(render_template("m3u8.html", cameras=cameras)) 253 | resp.headers.set("content-type", "application/x-mpegURL") 254 | return resp 255 | 256 | return app 257 | 258 | 259 | if __name__ == "__main__": 260 | app = create_app() 261 | app.run(debug=False, host="0.0.0.0", port=5000) 262 | -------------------------------------------------------------------------------- /app/lib/lib.amd64: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrlt8/docker-wyze-bridge/bf893749b748f142199c9bce14fac44f8a661d6e/app/lib/lib.amd64 -------------------------------------------------------------------------------- /app/lib/lib.arm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrlt8/docker-wyze-bridge/bf893749b748f142199c9bce14fac44f8a661d6e/app/lib/lib.arm -------------------------------------------------------------------------------- /app/lib/lib.arm64: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrlt8/docker-wyze-bridge/bf893749b748f142199c9bce14fac44f8a661d6e/app/lib/lib.arm64 -------------------------------------------------------------------------------- /app/requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==3.0.* 2 | Flask-HTTPAuth==4.8.* 3 | paho-mqtt== 2.1.* 4 | pydantic==2.9.* 5 | python-dotenv==1.0.* 6 | requests==2.32.* 7 | PyYAML==6.0.* 8 | xxtea==3.3.* 9 | -------------------------------------------------------------------------------- /app/static/bulma-toast.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * bulma-toast 2.4.2 3 | * (c) 2018-present @rfoel 4 | * Released under the MIT License. 5 | */ 6 | (function(a,b){"object"==typeof exports&&"undefined"!=typeof module?b(exports):"function"==typeof define&&define.amd?define(["exports"],b):(a="undefined"==typeof globalThis?a||self:globalThis,b(a.bulmaToast={}))})(this,function(a){'use strict';function b(a,b){var c=Object.keys(a);if(Object.getOwnPropertySymbols){var d=Object.getOwnPropertySymbols(a);b&&(d=d.filter(function(b){return Object.getOwnPropertyDescriptor(a,b).enumerable})),c.push.apply(c,d)}return c}function c(a){for(var c,d=1;d=a.children.length&&a.remove()}},{key:"onAnimationEnd",value:function(){var a=0 3 | 5 | 7 | 8 | -------------------------------------------------------------------------------- /app/static/notavailable.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Preview Not Available 6 | -------------------------------------------------------------------------------- /app/static/site.css: -------------------------------------------------------------------------------- 1 | html { 2 | color: #fff; 3 | } 4 | 5 | body { 6 | display: flex; 7 | min-height: 100vh; 8 | flex-direction: column; 9 | } 10 | 11 | section { 12 | flex: 1; 13 | } 14 | 15 | .preview-toggle .button, 16 | .card-footer-item .button, 17 | .card, 18 | .box, 19 | .box .label, 20 | .navbar-menu, 21 | .footer { 22 | background-color: #363636; 23 | color: #fff; 24 | } 25 | 26 | .card .content { 27 | margin-bottom: 0; 28 | } 29 | 30 | .dropdown-content, 31 | .dropdown-menu, 32 | .content table { 33 | color: #fff; 34 | background-color: rgba(16, 16, 16, 0.5); 35 | z-index: 100; 36 | } 37 | 38 | .preview-toggle .button { 39 | border-color: #363636; 40 | } 41 | 42 | .card .card-footer, 43 | .card .card-footer-item, 44 | .input { 45 | border-color: #222; 46 | } 47 | 48 | .card-header-title, 49 | .table thead th, 50 | .dropdown-item { 51 | color: #fff; 52 | display: block; 53 | flex-grow: 0; 54 | } 55 | 56 | .dropdown-content .dropdown-item.is-active { 57 | background-color: rgba(110, 118, 129, 0.4); 58 | } 59 | 60 | 61 | a { 62 | color: #1abc9c; 63 | } 64 | 65 | a:hover { 66 | color: #1dd2af; 67 | } 68 | 69 | .preview-toggle .button:not(.is-active):hover, 70 | .card-footer-item:hover { 71 | background-color: #292929; 72 | border-color: #292929; 73 | color: #009670; 74 | } 75 | 76 | .preview-toggle .button.is-active { 77 | background-color: #00bc8c; 78 | border-color: #00bc8c 79 | } 80 | 81 | .preview-toggle .button.is-active:hover { 82 | background-color: #00a077; 83 | } 84 | 85 | code { 86 | background-color: rgba(110, 118, 129, 0.4); 87 | color: rgb(255, 123, 114); 88 | -webkit-user-select: all; 89 | -ms-user-select: all; 90 | user-select: all; 91 | } 92 | 93 | .input, 94 | .input::placeholder, 95 | .table.is-hoverable tbody tr:not(.is-selected):hover { 96 | background-color: #444; 97 | color: #adb5bd; 98 | } 99 | 100 | .input:hover, 101 | .input:active, 102 | .input:focus, 103 | .control.has-icons-left .input:focus~.icon { 104 | color: #fff; 105 | } 106 | 107 | video { 108 | position: relative; 109 | width: 100%; 110 | overflow: hidden; 111 | } 112 | 113 | .ghost { 114 | border: 1px dashed #000; 115 | background-color: #444; 116 | } 117 | 118 | .ghost * { 119 | visibility: hidden; 120 | } 121 | 122 | .custom-drag-ghost { 123 | /* The original cloned element must not take place up in the page and must not be visible */ 124 | position: absolute; 125 | top: -99999px; 126 | left: -99999px; 127 | /* Just for appearance */ 128 | background-color: #edb458; 129 | border: 1px solid #e8871e; 130 | } 131 | 132 | .hidden-drag-ghost { 133 | opacity: 0; 134 | } 135 | 136 | .transition { 137 | transition: all 2s ease-out 0.5s; 138 | top: 0; 139 | } 140 | 141 | [data-enabled=True] video.placeholder, 142 | .status .fa-circle-play, 143 | .status .fa-circle-pause, 144 | .status .fa-satellite-dish, 145 | .fa-arrows-rotate, 146 | .card-header-title .icon.battery { 147 | cursor: pointer; 148 | } 149 | 150 | video, 151 | video+.fas { 152 | display: block; 153 | } 154 | 155 | figure.image:hover>video.placeholder { 156 | transition: .3s ease; 157 | background-color: transparent; 158 | opacity: 0.8; 159 | } 160 | 161 | video+i::before { 162 | color: white; 163 | font-size: 4rem; 164 | position: absolute; 165 | top: 50%; 166 | left: 50%; 167 | transform: translate(-50%, -50%); 168 | text-shadow: 2px 2px #444; 169 | } 170 | 171 | video.lost+i::before { 172 | content: "\e560"; 173 | } 174 | 175 | video.lost, 176 | video.lost+i { 177 | cursor: default; 178 | } 179 | 180 | .card-image:has([src$=".svg"]) { 181 | background-color: #000; 182 | } 183 | 184 | .is-overlay button, 185 | .card:hover .age, 186 | button:not(.offline):disabled+.age { 187 | display: none; 188 | } 189 | 190 | .card:hover .is-overlay button { 191 | display: inline-block; 192 | } 193 | 194 | .fs-mode .card-header-title { 195 | opacity: 0.8; 196 | transition: 0.5s; 197 | } 198 | 199 | .is-overlay .age, 200 | .card:hover>.fs-mode .card-header-title { 201 | transition: 0.5s; 202 | text-shadow: #000 0px 0px 2px; 203 | opacity: 1; 204 | } 205 | 206 | 207 | .drag_handle { 208 | cursor: move; 209 | flex-grow: 2; 210 | } 211 | 212 | .card { 213 | border-radius: 0.75rem; 214 | transition: box-shadow 0.5s ease-in-out; 215 | } 216 | 217 | .drag_hover { 218 | box-shadow: 0 0 20px 1px #ffffffb1; 219 | } 220 | 221 | @media (prefers-color-scheme: dark) { 222 | .has-background-black-ter { 223 | background-color: #0a0a0a !important; 224 | } 225 | } 226 | 227 | .fullscreen button { 228 | display: inline-block; 229 | position: fixed; 230 | bottom: 30px; 231 | right: 30px; 232 | z-index: 1000; 233 | opacity: 0.25; 234 | transition: 0.5s; 235 | } 236 | 237 | .fullscreen button:hover { 238 | opacity: 1; 239 | } 240 | 241 | .card-content { 242 | padding: 1rem; 243 | } 244 | 245 | .fs-mode { 246 | visibility: hidden; 247 | height: 0; 248 | } 249 | 250 | .fs-mode .card-header-title { 251 | visibility: visible; 252 | z-index: 100; 253 | margin: 0; 254 | padding: 1.5rem 1rem; 255 | } 256 | 257 | nav.fs-mode, 258 | footer.fs-mode { 259 | display: none; 260 | } 261 | 262 | /* Fix for Jittery Video in Firefox #1025 */ 263 | @-moz-document url-prefix() { 264 | body::after { 265 | width: 1px; 266 | height: 1px; 267 | position: fixed; 268 | backdrop-filter: blur(0.01rem); 269 | content: ""; 270 | } 271 | } -------------------------------------------------------------------------------- /app/static/webrtc.js: -------------------------------------------------------------------------------- 1 | const restartPause = 2000; 2 | 3 | const parseOffer = (offer) => { 4 | const ret = { 5 | iceUfrag: '', 6 | icePwd: '', 7 | medias: [], 8 | }; 9 | 10 | offer.split('\r\n').forEach((line) => { 11 | if (line.startsWith('m=')) { 12 | ret.medias.push(line.slice('m='.length)); 13 | } else if (ret.iceUfrag === '' && line.startsWith('a=ice-ufrag:')) { 14 | ret.iceUfrag = line.slice('a=ice-ufrag:'.length); 15 | } else if (ret.icePwd === '' && line.startsWith('a=ice-pwd:')) { 16 | ret.icePwd = line.slice('a=ice-pwd:'.length); 17 | } 18 | }); 19 | 20 | return ret; 21 | }; 22 | 23 | const generateSdpFragment = (offerData, candidates) => { 24 | const candidatesByMedia = {}; 25 | for (const candidate of candidates) { 26 | const mid = candidate.sdpMLineIndex; 27 | if (!candidatesByMedia.hasOwnProperty(mid)) { 28 | candidatesByMedia[mid] = []; 29 | } 30 | candidatesByMedia[mid].push(candidate); 31 | } 32 | 33 | let frag = `a=ice-ufrag:${offerData.iceUfrag}\r\na=ice-pwd:${offerData.icePwd}\r\n`; 34 | let mid = 0; 35 | for (const media of offerData.medias) { 36 | if (candidatesByMedia.hasOwnProperty(mid)) { 37 | frag += `m=${media}\r\na=mid:${mid}\r\n`; 38 | 39 | for (const candidate of candidatesByMedia[mid]) { 40 | frag += `a=${candidate.candidate}\r\n`; 41 | } 42 | } 43 | mid++; 44 | } 45 | 46 | return frag; 47 | }; 48 | 49 | class Receiver { 50 | constructor(signalJson) { 51 | if (signalJson.result !== "ok") { return console.error("signaling json not ok"); } 52 | this.signalJson = signalJson; 53 | this.whep = !!("whep" in this.signalJson); 54 | this.restartTimeout = null; 55 | this.queuedCandidates = []; 56 | this.ws = null; 57 | this.pc = null; 58 | this.sessionUrl = ''; 59 | this.iceConnectionTimer; 60 | this.start(); 61 | } 62 | start() { 63 | if (this.whep) { return this.onOpen(); } 64 | this.ws = new WebSocket(this.signalJson.signalingUrl); 65 | this.ws.onopen = () => this.onOpen(); 66 | this.ws.onmessage = (msg) => this.onWsMessage(msg); 67 | this.ws.onerror = (err) => this.onError(err); 68 | this.ws.onclose = () => this.onError(); 69 | } 70 | 71 | onOpen() { 72 | const direction = this.whep ? "sendrecv" : "recvonly"; 73 | this.pc = new RTCPeerConnection({ iceServers: this.signalJson.servers, sdpSemantics: 'unified-plan' }); 74 | this.pc.addTransceiver("video", { direction }); 75 | this.pc.addTransceiver("audio", { direction }); 76 | this.pc.ontrack = (evt) => this.onTrack(evt); 77 | this.pc.onicecandidate = (evt) => this.onIceCandidate(evt); 78 | this.pc.oniceconnectionstatechange = () => this.onConnectionStateChange(); 79 | this.pc.createOffer().then((desc) => this.createOffer(desc)); 80 | } 81 | createOffer(desc) { 82 | this.pc.setLocalDescription(desc); 83 | if (!this.whep) { return this.sendToServer("SDP_OFFER", desc); } 84 | this.offerData = parseOffer(desc.sdp); 85 | const headers = this.authHeaders(); 86 | headers['Content-Type'] = 'application/sdp' 87 | fetch(this.signalJson.whep, { 88 | method: 'POST', 89 | headers: headers, 90 | body: desc.sdp, 91 | }) 92 | .then((res) => { 93 | if (res.status !== 201) { throw new Error('Bad status code'); } 94 | this.sessionUrl = new URL(res.headers.get('location'), this.signalJson.whep).toString(); 95 | return res.text(); 96 | }) 97 | .then((sdp) => this.onRemoteDescription(sdp)) 98 | .catch((err) => this.onError(err)); 99 | } 100 | authHeaders() { 101 | const server = this.signalJson.servers && this.signalJson.servers.length > 0 ? this.signalJson.servers[0] : null; 102 | if (server && server.credential && server.username) { 103 | return { 'Authorization': 'Basic ' + btoa(server.username + ':' + server.credential) }; 104 | } 105 | return {} 106 | } 107 | 108 | sendToServer(action, payload) { 109 | this.ws.send(JSON.stringify({ "action": action, "messagePayload": btoa(JSON.stringify(payload)), "recipientClientId": this.signalJson.ClientId })); 110 | } 111 | sendLocalCandidates(candidates) { 112 | const headers = this.authHeaders(); 113 | headers['Content-Type'] = 'application/trickle-ice-sdpfrag' 114 | headers['If-Match'] = '*' 115 | 116 | fetch(this.sessionUrl, { 117 | method: 'PATCH', 118 | headers: headers, 119 | body: generateSdpFragment(this.offerData, candidates), 120 | }) 121 | .then((res) => { 122 | switch (res.status) { 123 | case 204: 124 | break; 125 | case 404: 126 | throw new Error('stream not found'); 127 | default: 128 | throw new Error(`bad status code ${res.status}`); 129 | } 130 | }) 131 | .catch((err) => { 132 | this.onError(err.toString()); 133 | }); 134 | } 135 | 136 | onTrack(event) { 137 | let vid = document.querySelector(`video[data-cam='${this.signalJson.cam}']`); 138 | vid.srcObject = event.streams[0]; 139 | vid.autoplay = true; 140 | vid.play().catch((err) => { 141 | console.info('play() error:', err); 142 | }); 143 | } 144 | 145 | onConnectionStateChange() { 146 | clearTimeout(this.iceConnectionTimer); 147 | if (this.restartTimeout !== null) { return; } 148 | switch (this.pc.iceConnectionState) { 149 | case 'disconnected': 150 | case 'failed': 151 | this.onError() 152 | break; 153 | } 154 | } 155 | 156 | onRemoteDescription(sdp) { 157 | if (this.restartTimeout !== null) { return; } 158 | 159 | this.pc.setRemoteDescription(new RTCSessionDescription({ 160 | type: 'answer', 161 | sdp, 162 | })); 163 | 164 | if (this.queuedCandidates.length !== 0) { 165 | this.sendLocalCandidates(this.queuedCandidates); 166 | this.queuedCandidates = []; 167 | } 168 | } 169 | 170 | onWsMessage(msg) { 171 | if (this.pc === null || this.ws === null || msg.data === '') { return; } 172 | const eventData = JSON.parse(msg.data); 173 | const payload = JSON.parse(atob(eventData.messagePayload)); 174 | switch (eventData.messageType) { 175 | case 'SDP_OFFER': 176 | case 'SDP_ANSWER': 177 | this.pc.setRemoteDescription(new RTCSessionDescription(payload)); 178 | break; 179 | case 'ICE_CANDIDATE': 180 | if ('candidate' in payload) { 181 | this.pc.addIceCandidate(payload); 182 | } 183 | break; 184 | } 185 | } 186 | 187 | onIceCandidate(evt) { 188 | if (this.restartTimeout !== null || evt.candidate === null) { return; } 189 | if (this.whep) { 190 | if (this.sessionUrl === '') { 191 | this.queuedCandidates.push(evt.candidate); 192 | } else { 193 | this.sendLocalCandidates([evt.candidate]); 194 | } 195 | } else { 196 | this.sendToServer('ICE_CANDIDATE', evt.candidate); 197 | if (!this.iceConnectionTimer) { 198 | this.iceConnectionTimer = setTimeout(() => { 199 | if (this.pc.iceConnectionState !== 'start') { 200 | this.pc.close(); 201 | this.onError("ICE connection timeout") 202 | } 203 | }, 30000); 204 | } 205 | } 206 | } 207 | refreshSignal() { 208 | fetch(new URL(`signaling/${this.signalJson.cam}?${this.whep ? 'webrtc' : 'kvs'}`, window.location.href)) 209 | .then((resp) => resp.json()) 210 | .then((signalJson) => { 211 | if (signalJson.result !== "ok") { return console.error("signaling json not ok"); } 212 | this.signalJson = signalJson; 213 | }); 214 | } 215 | 216 | onError(err = undefined) { 217 | if (this.restartTimeout !== null) { 218 | return; 219 | } 220 | if (err !== undefined) { console.error('Error:', err.toString()); } 221 | clearTimeout(this.iceConnectionTimer); 222 | this.iceConnectionTimer = null; 223 | 224 | if (this.ws !== null) { 225 | this.ws.close(); 226 | this.ws = null; 227 | } 228 | if (this.pc !== null) { 229 | this.pc.close(); 230 | this.pc = null; 231 | } 232 | const connection = document.getElementById("connection-lost"); 233 | const offline = connection && connection.style.display === "block"; 234 | 235 | this.restartTimeout = window.setTimeout(() => { 236 | this.restartTimeout = null; 237 | if (offline) { 238 | this.onError() 239 | } else { 240 | this.refreshSignal(); 241 | this.start(); 242 | } 243 | }, restartPause); 244 | 245 | if (this.sessionUrl !== '' && !offline) { 246 | fetch(this.sessionUrl, { 247 | method: 'DELETE', 248 | headers: this.authHeaders(), 249 | }).catch(() => { }); 250 | } 251 | this.sessionUrl = ''; 252 | this.queuedCandidates = []; 253 | } 254 | }; 255 | -------------------------------------------------------------------------------- /app/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Wyze-Bridge 8 | 9 | 10 | 11 | {% block stylesheet %} 12 | {% endblock %} 13 | 14 | 15 | 16 |
17 | {% block content %} 18 | {% endblock %} 19 |
20 | 37 | 38 | {% block javascript %} 39 | {% endblock %} 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /app/templates/login.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block content %} 4 |
5 |
6 |

Wyze credentials required to complete authentication.

7 | 17 |
18 |
19 |
20 | Wyze API Key and ID can be obtained from 21 | the Wyze 22 | Developer Portal. 23 |
24 |
25 | 26 |
27 | 28 | 29 | 30 | 31 |
32 |
33 |
34 | 35 |
36 | 37 | 38 | 39 | 40 |
41 |
42 |
43 | 44 |
45 | 47 | 48 | 49 | 50 |
51 |
52 |
53 | 54 |
55 | 57 | 58 | 59 | 60 |
61 |
62 |
63 | 66 |
67 |
68 | 94 |
95 |
96 | {% endblock %} 97 | 98 | {% block javascript %} 99 | 135 | {% endblock %} -------------------------------------------------------------------------------- /app/templates/m3u8.html: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | {% for name,camera in cameras.items() %}{% if camera.enabled %} 3 | #EXTINF:-1 channel-id="{{name}}" tvc-guide-genres="{{camera.product_model}}", {{name}} 4 | {{ camera.hls_url }}stream.m3u8 5 | {% endif %}{% endfor %} -------------------------------------------------------------------------------- /app/templates/webrtc.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 21 | 22 | 23 | 24 | 25 | 26 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /app/wyze_bridge.py: -------------------------------------------------------------------------------- 1 | import signal 2 | import sys 3 | from dataclasses import replace 4 | from threading import Thread 5 | 6 | from wyzebridge import config 7 | from wyzebridge.auth import STREAM_AUTH, WbAuth 8 | from wyzebridge.bridge_utils import env_bool, env_cam, is_livestream 9 | from wyzebridge.logging import logger 10 | from wyzebridge.mtx_server import MtxServer 11 | from wyzebridge.stream import StreamManager 12 | from wyzebridge.wyze_api import WyzeApi 13 | from wyzebridge.wyze_stream import WyzeStream, WyzeStreamOptions 14 | from wyzecam.api_models import WyzeCamera 15 | 16 | 17 | class WyzeBridge(Thread): 18 | __slots__ = "api", "streams", "mtx" 19 | 20 | def __init__(self) -> None: 21 | Thread.__init__(self) 22 | for sig in {"SIGTERM", "SIGINT"}: 23 | signal.signal(getattr(signal, sig), self.clean_up) 24 | print(f"\n🚀 DOCKER-WYZE-BRIDGE v{config.VERSION} {config.BUILD_STR}\n") 25 | self.api: WyzeApi = WyzeApi() 26 | self.streams: StreamManager = StreamManager() 27 | self.mtx: MtxServer = MtxServer() 28 | self.mtx.setup_webrtc(config.BRIDGE_IP) 29 | if config.LLHLS: 30 | self.mtx.setup_llhls(config.TOKEN_PATH, bool(config.HASS_TOKEN)) 31 | 32 | def run(self, fresh_data: bool = False) -> None: 33 | self._initialize(fresh_data) 34 | 35 | def _initialize(self, fresh_data: bool = False) -> None: 36 | self.api.login(fresh_data=fresh_data) 37 | WbAuth.set_email(email=self.api.get_user().email, force=fresh_data) 38 | self.mtx.setup_auth(WbAuth.api, STREAM_AUTH) 39 | self.setup_streams() 40 | if self.streams.total < 1: 41 | return signal.raise_signal(signal.SIGINT) 42 | self.mtx.start() 43 | self.streams.monitor_streams(self.mtx.health_check) 44 | 45 | def restart(self, fresh_data: bool = False) -> None: 46 | self.mtx.stop() 47 | self.streams.stop_all() 48 | self._initialize(fresh_data) 49 | 50 | def refresh_cams(self) -> None: 51 | self.mtx.stop() 52 | self.streams.stop_all() 53 | self.api.get_cameras(fresh_data=True) 54 | self._initialize(False) 55 | 56 | def setup_streams(self): 57 | """Gather and setup streams for each camera.""" 58 | WyzeStream.user = self.api.get_user() 59 | WyzeStream.api = self.api 60 | 61 | for cam in self.api.filtered_cams(): 62 | logger.info(f"[+] Adding {cam.nickname} [{cam.product_model}]") 63 | if config.SNAPSHOT_TYPE == "api": 64 | self.api.save_thumbnail(cam.name_uri) 65 | options = WyzeStreamOptions( 66 | quality=env_cam("quality", cam.name_uri), 67 | audio=bool(env_cam("enable_audio", cam.name_uri)), 68 | record=bool(env_cam("record", cam.name_uri)), 69 | reconnect=is_livestream(cam.name_uri) or not config.ON_DEMAND, 70 | ) 71 | self.add_substream(cam, options) 72 | stream = WyzeStream(cam, options) 73 | stream.rtsp_fw_enabled = self.rtsp_fw_proxy(cam, stream) 74 | 75 | self.mtx.add_path(stream.uri, not options.reconnect) 76 | if env_cam("record", cam.name_uri): 77 | self.mtx.record(stream.uri) 78 | self.streams.add(stream) 79 | 80 | def rtsp_fw_proxy(self, cam: WyzeCamera, stream: WyzeStream) -> bool: 81 | if rtsp_fw := env_bool("rtsp_fw").lower(): 82 | if rtsp_path := stream.check_rtsp_fw(rtsp_fw == "force"): 83 | rtsp_uri = f"{cam.name_uri}-fw" 84 | logger.info(f"Adding /{rtsp_uri} as a source") 85 | self.mtx.add_source(rtsp_uri, rtsp_path) 86 | return True 87 | return False 88 | 89 | def add_substream(self, cam: WyzeCamera, options: WyzeStreamOptions): 90 | """Setup and add substream if enabled for camera.""" 91 | if env_bool(f"SUBSTREAM_{cam.name_uri}") or ( 92 | env_bool("SUBSTREAM") and cam.can_substream 93 | ): 94 | quality = env_cam("sub_quality", cam.name_uri, "sd30") 95 | record = bool(env_cam("sub_record", cam.name_uri)) 96 | sub_opt = replace(options, substream=True, quality=quality, record=record) 97 | sub = WyzeStream(cam, sub_opt) 98 | self.mtx.add_path(sub.uri, not options.reconnect) 99 | self.streams.add(sub) 100 | 101 | def clean_up(self, *_): 102 | """Stop all streams and clean up before shutdown.""" 103 | if self.streams.stop_flag: 104 | sys.exit(0) 105 | if self.streams: 106 | self.streams.stop_all() 107 | self.mtx.stop() 108 | logger.info("👋 goodbye!") 109 | sys.exit(0) 110 | 111 | 112 | if __name__ == "__main__": 113 | wb = WyzeBridge() 114 | wb.run() 115 | sys.exit(0) 116 | -------------------------------------------------------------------------------- /app/wyzebridge/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrlt8/docker-wyze-bridge/bf893749b748f142199c9bce14fac44f8a661d6e/app/wyzebridge/__init__.py -------------------------------------------------------------------------------- /app/wyzebridge/auth.py: -------------------------------------------------------------------------------- 1 | import os 2 | from base64 import urlsafe_b64encode 3 | from hashlib import sha256 4 | from typing import Optional 5 | 6 | from werkzeug.security import generate_password_hash 7 | from wyzebridge.bridge_utils import env_bool 8 | from wyzebridge.config import TOKEN_PATH 9 | from wyzebridge.logging import logger 10 | 11 | 12 | def get_secret(name: str, default: str = "") -> str: 13 | if not name: 14 | return "" 15 | try: 16 | with open(f"/run/secrets/{name.upper()}", "r") as f: 17 | return f.read().strip("'\" \n\t\r") 18 | except FileNotFoundError: 19 | return env_bool(name, default, style="original") 20 | 21 | 22 | def get_credential(file_name: str) -> str: 23 | if env_pass := get_secret(file_name): 24 | return env_pass 25 | 26 | file_path = f"{TOKEN_PATH}{file_name}" 27 | if os.path.exists(file_path) and os.path.getsize(file_path) > 0: 28 | with open(file_path, "r") as file: 29 | logger.info(f"[AUTH] Using {file_name} from {file_path}") 30 | return file.read().strip() 31 | return "" 32 | 33 | 34 | def clear_local_creds(): 35 | for file in ["wb_password", "wb_api"]: 36 | file_path = f"{TOKEN_PATH}{file}" 37 | if os.path.exists(file_path): 38 | logger.info(f"[AUTH] Clearing local auth data [{file_path=}]") 39 | os.remove(file_path) 40 | 41 | 42 | def gen_api_key(email): 43 | hash_bytes = sha256(email.encode()).digest() 44 | return urlsafe_b64encode(hash_bytes).decode()[:40] 45 | 46 | 47 | class WbAuth: 48 | enabled: bool = bool(env_bool("WB_AUTH") if os.getenv("WB_AUTH") else True) 49 | username: str = get_secret("wb_username", "wbadmin") 50 | api: str = "" 51 | _pass: str = get_credential("wb_password") 52 | _hashed_pass: Optional[str] = None 53 | 54 | @classmethod 55 | def hashed_password(cls) -> str: 56 | if cls._hashed_pass: 57 | return cls._hashed_pass 58 | 59 | cls._hashed_pass = generate_password_hash(cls._pass) 60 | return cls._hashed_pass 61 | 62 | @classmethod 63 | def set_email(cls, email: str, force: bool = False): 64 | logger.info(f"[AUTH] WB_AUTH={cls.enabled}") 65 | if not cls.enabled: 66 | return 67 | 68 | cls._update_credentials(email, force) 69 | 70 | logger.info(f"[AUTH] WB_USERNAME={cls.username}") 71 | logger.info(f"[AUTH] WB_PASSWORD={redact_password(cls._pass)}") 72 | logger.info(f"[AUTH] WB_API={cls.api}") 73 | 74 | @classmethod 75 | def _update_credentials(cls, email: str, force: bool = False) -> None: 76 | if force or env_bool("FRESH_DATA"): 77 | clear_local_creds() 78 | 79 | if not get_credential("wb_password"): 80 | cls._pass = email.partition("@")[0] 81 | cls._hashed_pass = generate_password_hash(cls._pass) 82 | 83 | cls.api = get_credential("wb_api") or gen_api_key(email) 84 | 85 | 86 | def redact_password(password: Optional[str]): 87 | return f"{password[0]}{'*' * (len(password) - 1)}" if password else "NOT SET" 88 | 89 | 90 | STREAM_AUTH: str = env_bool("STREAM_AUTH", style="original") 91 | -------------------------------------------------------------------------------- /app/wyzebridge/bridge_utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | from typing import Any 4 | 5 | from wyzecam.api_models import WyzeCamera 6 | 7 | LIVESTREAM_PLATFORMS = { 8 | "YouTube": "rtmp://a.rtmp.youtube.com/live2/", 9 | "Facebook": "rtmps://live-api-s.facebook.com:443/rtmp/", 10 | "RestreamIO": "rtmp://live.restream.io/live/", 11 | "Livestream": "", 12 | } 13 | 14 | 15 | def env_cam(env: str, uri: str, default="", style="") -> str: 16 | return env_bool( 17 | f"{env}_{uri}", 18 | env_bool(env, env_bool(f"{env}_all", default, style=style), style=style), 19 | style=style, 20 | ) 21 | 22 | 23 | def env_bool(env: str, false="", true="", style="") -> Any: 24 | """Return env variable or empty string if the variable contains 'false' or is empty.""" 25 | value = os.getenv(env.upper().replace("-", "_"), "").strip("'\" \n\t\r") 26 | if value.lower() in {"no", "none", "false"}: 27 | value = "" 28 | if style.lower() == "bool": 29 | return bool(value or false) 30 | if style.lower() == "int": 31 | return int("".join(filter(str.isdigit, value or str(false))) or 0) 32 | if style.lower() == "float": 33 | try: 34 | return float(value) if value.replace(".", "").isdigit() else float(false) 35 | except ValueError: 36 | return 0 37 | if style.lower() == "upper" and value: 38 | return value.upper() 39 | if style.lower() == "original" and value: 40 | return value 41 | return true if true and value else value.lower() or false 42 | 43 | 44 | def env_list(env: str) -> list: 45 | """Return env values as a list.""" 46 | return [ 47 | x.strip("'\"\n ").upper().replace(":", "") 48 | for x in os.getenv(env.upper(), "").split(",") 49 | ] 50 | 51 | 52 | def env_filter(cam: WyzeCamera) -> bool: 53 | """Check if cam is being filtered in any env.""" 54 | if not cam.nickname: 55 | return False 56 | return ( 57 | cam.nickname.upper().strip() in env_list("FILTER_NAMES") 58 | or cam.mac in env_list("FILTER_MACS") 59 | or cam.product_model in env_list("FILTER_MODELS") 60 | or cam.model_name.upper() in env_list("FILTER_MODELS") 61 | ) 62 | 63 | 64 | def split_int_str(env_value: str, min: int = 0, default: int = 0) -> tuple[str, int]: 65 | string_value = "".join(filter(str.isalpha, env_value)) 66 | int_value = int("".join(filter(str.isnumeric, env_value)) or default) 67 | return string_value, max(int_value, min) 68 | 69 | 70 | def is_livestream(uri: str) -> bool: 71 | return any(env_bool(f"{service}_{uri}") for service in LIVESTREAM_PLATFORMS) 72 | 73 | 74 | def migrate_path(old: str, new: str): 75 | if not os.path.exists(old): 76 | return 77 | 78 | print(f"CLEANUP: MIGRATING {old=} to {new=}") 79 | 80 | if not os.path.exists(new): 81 | os.makedirs(new) 82 | for item in os.listdir(old): 83 | new_file = os.path.join(new, os.path.relpath(os.path.join(old, item), old)) 84 | if os.path.exists(new_file): 85 | new_file += ".old" 86 | shutil.move(os.path.join(old, item), new_file) 87 | 88 | os.rmdir(old) 89 | -------------------------------------------------------------------------------- /app/wyzebridge/config.py: -------------------------------------------------------------------------------- 1 | from os import environ, getenv, makedirs 2 | from platform import machine 3 | 4 | from dotenv import load_dotenv 5 | from wyzebridge.bridge_utils import env_bool, migrate_path, split_int_str 6 | from wyzebridge.hass import setup_hass 7 | 8 | load_dotenv() 9 | load_dotenv("/.build_date") 10 | 11 | VERSION: str = f'{getenv("VERSION", "DEV")}' 12 | ARCH = machine().upper() 13 | BUILD = env_bool("BUILD", "local") 14 | BUILD_DATE = env_bool("BUILD_DATE") 15 | GITHUB_SHA = env_bool("GITHUB_SHA") 16 | BUILD_STR = ARCH 17 | if BUILD != VERSION: 18 | BUILD_STR += f" {BUILD.upper()} BUILD [{BUILD_DATE}] {GITHUB_SHA:.7}" 19 | 20 | HASS_TOKEN: str = getenv("SUPERVISOR_TOKEN", "") 21 | setup_hass(HASS_TOKEN) 22 | MQTT_DISCOVERY = env_bool("MQTT_DTOPIC") 23 | MQTT_TOPIC = env_bool("MQTT_TOPIC", "wyzebridge").strip("/") 24 | ON_DEMAND: bool = bool(env_bool("on_demand") if getenv("ON_DEMAND") else True) 25 | CONNECT_TIMEOUT: int = env_bool("CONNECT_TIMEOUT", 20, style="int") 26 | 27 | # TODO: change TOKEN_PATH to /config for all: 28 | TOKEN_PATH: str = "/config/" if HASS_TOKEN else "/tokens/" 29 | IMG_PATH: str = f'/{env_bool("IMG_DIR", "img").strip("/")}/' 30 | 31 | SNAPSHOT_TYPE, SNAPSHOT_INT = split_int_str(env_bool("SNAPSHOT"), min=15, default=180) 32 | SNAPSHOT_FORMAT: str = env_bool("SNAPSHOT_FORMAT", style="original").strip("/") 33 | 34 | 35 | BRIDGE_IP: str = env_bool("WB_IP") 36 | HLS_URL: str = env_bool("WB_HLS_URL").strip("/") 37 | RTMP_URL = env_bool("WB_RTMP_URL").strip("/") 38 | RTSP_URL = env_bool("WB_RTSP_URL").strip("/") 39 | WEBRTC_URL = env_bool("WB_WEBRTC_URL").strip("/") 40 | LLHLS: bool = env_bool("LLHLS", style="bool") 41 | COOLDOWN = env_bool("OFFLINE_TIME", "10", style="int") 42 | 43 | 44 | BOA_INTERVAL: int = env_bool("boa_interval", "20", style="int") 45 | BOA_COOLDOWN: int = env_bool("boa_cooldown", "20", style="int") 46 | 47 | MOTION: bool = env_bool("motion_api", style="bool") 48 | MOTION_INT: int = max(env_bool("motion_int", "1.5", style="float"), 1.1) 49 | MOTION_START: bool = env_bool("motion_start", style="bool") 50 | 51 | WB_AUTH: bool = bool(env_bool("WB_AUTH") if getenv("WB_AUTH") else True) 52 | STREAM_AUTH: str = env_bool("STREAM_AUTH", style="original") 53 | 54 | makedirs(TOKEN_PATH, exist_ok=True) 55 | makedirs(IMG_PATH, exist_ok=True) 56 | 57 | for key, value in environ.items(): 58 | if key.startswith("WEB_"): 59 | new_key = key.replace("WEB", "WB") 60 | print(f"\n[!] WARNING: {key} is deprecated! Please use {new_key} instead\n") 61 | environ.pop(key, None) 62 | environ[new_key] = value 63 | 64 | if HASS_TOKEN: 65 | migrate_path("/config/wyze-bridge/", "/config/") 66 | 67 | for key in environ: 68 | if not MOTION and key.startswith("MOTION_WEBHOOKS"): 69 | print(f"[!] WARNING: {key} will not trigger because MOTION_API is not set") 70 | 71 | DEPRECATED = {"DEBUG_FFMPEG", "OFFLINE_IFTTT", "TOTP_KEY", "MFA_TYPE"} 72 | 73 | for env in DEPRECATED: 74 | if getenv(env): 75 | print(f"\n\n[!] WARNING: {env} is deprecated\n\n") 76 | -------------------------------------------------------------------------------- /app/wyzebridge/ffmpeg.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | from datetime import datetime, timedelta 4 | from pathlib import Path 5 | from typing import Optional 6 | 7 | from wyzebridge.bridge_utils import LIVESTREAM_PLATFORMS, env_bool, env_cam 8 | from wyzebridge.config import IMG_PATH, SNAPSHOT_FORMAT 9 | from wyzebridge.logging import logger 10 | 11 | 12 | def get_ffmpeg_cmd( 13 | uri: str, vcodec: str, audio: dict, is_vertical: bool = False 14 | ) -> list[str]: 15 | """ 16 | Return the ffmpeg cmd with options from the env. 17 | 18 | Parameters: 19 | - uri (str): Used to identify the stream and lookup ENV settings. 20 | - vcodec (str): The source video codec. Most likely h264. 21 | - audio (dict): a dictionary containing the audio source codec, 22 | sample rate, and output audio codec: 23 | 24 | - "codec": str, source audio codec 25 | - "rate": int, source audio sample rate 26 | - "codec_out": str, output audio codec 27 | 28 | - is_vertical (bool, optional): Specify if the source video is vertical. 29 | 30 | Returns: 31 | - list of str: complete ffmpeg command that is ready to run as subprocess. 32 | """ 33 | 34 | flags = "-fflags +flush_packets+nobuffer -flags +low_delay" 35 | livestream = get_livestream_cmd(uri) 36 | audio_in = "-f lavfi -i anullsrc=cl=mono" if livestream else "" 37 | audio_out = "aac" 38 | thread_queue = "-thread_queue_size 8 -analyzeduration 32 -probesize 32" 39 | if audio and "codec" in audio: 40 | # `Option sample_rate not found.` if we try to specify -ar for aac: 41 | rate = "" if audio["codec"] == "aac" else f" -ar {audio['rate']} -ac 1" 42 | audio_in = f"{thread_queue} -f {audio['codec']}{rate} -i /tmp/{uri}_audio.pipe" 43 | audio_out = audio["codec_out"] or "copy" 44 | a_filter = env_bool("AUDIO_FILTER", "volume=5") + ",adelay=0|0" 45 | a_options = ["-filter:a", a_filter] 46 | if audio_out.lower() == "libopus": 47 | a_options += ["-compression_level", "4", "-frame_duration", "10"] 48 | if audio_out.lower() not in {"libopus", "aac"}: 49 | a_options += ["-ar", "8000"] 50 | rtsp_transport = "udp" if "udp" in env_bool("MTX_PROTOCOLS") else "tcp" 51 | fio_cmd = r"use_fifo=1:fifo_options=attempt_recovery=1\\\:drop_pkts_on_overflow=1:" 52 | rss_cmd = f"[{fio_cmd}{{}}f=rtsp:{rtsp_transport=:}]rtsp://0.0.0.0:8554/{uri}" 53 | rtsp_ss = rss_cmd.format("") 54 | if env_cam("AUDIO_STREAM", uri, style="original") and audio: 55 | rtsp_ss += "|" + rss_cmd.format("select=a:") + "_audio" 56 | h264_enc = env_bool("h264_enc").partition("_")[2] 57 | 58 | cmd = env_cam("FFMPEG_CMD", uri, style="original").format( 59 | cam_name=uri, CAM_NAME=uri.upper(), audio_in=audio_in 60 | ).split() or ( 61 | ["-hide_banner", "-loglevel", get_log_level()] 62 | + env_cam("FFMPEG_FLAGS", uri, flags).strip("'\"\n ").split() 63 | + thread_queue.split() 64 | + (["-hwaccel", h264_enc] if h264_enc in {"vaapi", "qsv"} else []) 65 | + ["-f", vcodec, "-i", "pipe:0"] 66 | + audio_in.split() 67 | + ["-map", "0:v", "-c:v"] 68 | + re_encode_video(uri, is_vertical) 69 | + (["-map", "1:a", "-c:a", audio_out] if audio_in else []) 70 | + (a_options if audio and audio_out != "copy" else []) 71 | + ["-fps_mode", "passthrough", "-flush_packets", "1"] 72 | + ["-rtbufsize", "1", "-copyts", "-copytb", "1"] 73 | + ["-f", "tee"] 74 | + [rtsp_ss + livestream] 75 | ) 76 | if "ffmpeg" not in cmd[0].lower(): 77 | cmd.insert(0, "ffmpeg") 78 | if env_bool("FFMPEG_LOGLEVEL") in {"info", "verbose", "debug"}: 79 | logger.info(f"[FFMPEG_CMD] {' '.join(cmd)}") 80 | return cmd 81 | 82 | 83 | def get_log_level(): 84 | level = env_bool("FFMPEG_LOGLEVEL", "fatal").lower() 85 | 86 | if level in { 87 | "quiet", 88 | "panic", 89 | "fatal", 90 | "error", 91 | "warning", 92 | "info", 93 | "verbose", 94 | "debug", 95 | }: 96 | return level 97 | 98 | return "verbose" 99 | 100 | 101 | def re_encode_video(uri: str, is_vertical: bool) -> list[str]: 102 | """ 103 | Check if stream needs to be re-encoded. 104 | 105 | Parameters: 106 | - uri (str): uri of the stream used to lookup ENV parameters. 107 | - is_vertical (bool): indicate if the original stream is vertical. 108 | 109 | Returns: 110 | - list of str: ffmpeg compatible list to be used as a value for `-c:v`. 111 | 112 | 113 | ENV Parameters: 114 | - ENV ROTATE_DOOR: Rotate and re-encode WYZEDB3 cameras. 115 | - ENV ROTATE_CAM_: Rotate and re-encode cameras that match. 116 | - ENV FORCE_ENCODE: Force all cameras to be re-encoded. 117 | - ENV H264_ENC: Change default codec used for re-encode. 118 | 119 | """ 120 | h264_enc: str = env_bool("h264_enc", "libx264") 121 | custom_filter = env_cam("FFMPEG_FILTER", uri) 122 | filter_complex = env_cam("FFMPEG_FILTER_COMPLEX", uri) 123 | v_filter = [] 124 | transpose = "clock" 125 | if (env_bool("ROTATE_DOOR") and is_vertical) or env_bool(f"ROTATE_CAM_{uri}"): 126 | if os.getenv(f"ROTATE_CAM_{uri}") in {"0", "1", "2", "3"}: 127 | # Numerical values are deprecated, and should be dropped 128 | # in favor of symbolic constants. 129 | transpose = os.environ[f"ROTATE_CAM_{uri}"] 130 | 131 | v_filter = ["-filter:v", f"transpose={transpose}"] 132 | if h264_enc == "h264_vaapi": 133 | v_filter[1] = f"transpose_vaapi={transpose}" 134 | elif h264_enc == "h264_qsv": 135 | v_filter[1] = f"vpp_qsv=transpose={transpose}" 136 | 137 | if not (env_bool("FORCE_ENCODE") or v_filter or custom_filter or filter_complex): 138 | return ["copy"] 139 | 140 | logger.info( 141 | f"Re-encoding using {h264_enc}{f' [{transpose=}]' if v_filter else '' }" 142 | ) 143 | if custom_filter: 144 | v_filter = [ 145 | "-filter:v", 146 | f"{v_filter[1]},{custom_filter}" if v_filter else custom_filter, 147 | ] 148 | 149 | return ( 150 | [h264_enc] 151 | + v_filter 152 | + (["-filter_complex", filter_complex, "-map", "[v]"] if filter_complex else []) 153 | + ["-b:v", "3000k", "-coder", "1", "-bufsize", "3000k"] 154 | + ["-profile:v", "77" if h264_enc == "h264_v4l2m2m" else "main"] 155 | + ["-preset", "fast" if h264_enc in {"h264_nvenc", "h264_qsv"} else "ultrafast"] 156 | + ["-forced-idr", "1", "-force_key_frames", "expr:gte(t,n_forced*2)"] 157 | ) 158 | 159 | 160 | def get_livestream_cmd(uri: str) -> str: 161 | 162 | flv = "|[f=flv:flvflags=no_duration_filesize:use_fifo=1:fifo_options=attempt_recovery=1\\\:drop_pkts_on_overflow=1:onfail=abort]" 163 | 164 | for platform, api in LIVESTREAM_PLATFORMS.items(): 165 | key = env_bool(f"{platform}_{uri}", style="original") 166 | if len(key) > 5: 167 | logger.info(f"📺 Livestream to {platform if api else key} enabled") 168 | return f"{flv}{api}{key}" 169 | 170 | return "" 171 | 172 | 173 | def purge_old(base_path: str, extension: str, keep_time: Optional[timedelta]): 174 | if not keep_time: 175 | return 176 | threshold = datetime.now() - keep_time 177 | for filepath in Path(base_path).rglob(f"*{extension}"): 178 | if filepath.stat().st_mtime > threshold.timestamp(): 179 | continue 180 | filepath.unlink() 181 | logger.debug(f"[ffmpeg] Deleted: {filepath}") 182 | 183 | if not any(filepath.parent.iterdir()): 184 | shutil.rmtree(filepath.parent) 185 | logger.debug(f"[ffmpeg] Deleted empty directory: {filepath.parent}") 186 | 187 | 188 | def parse_timedelta(env_key: str) -> Optional[timedelta]: 189 | value = env_bool(env_key) 190 | if not value: 191 | return 192 | 193 | time_map = {"s": "seconds", "m": "minutes", "h": "hours", "d": "days", "w": "weeks"} 194 | if value.isdigit(): 195 | value += "s" 196 | 197 | try: 198 | amount, unit = int(value[:-1]), value[-1] 199 | if unit not in time_map or amount < 1: 200 | return 201 | return timedelta(**{time_map[unit]: amount}) 202 | except (ValueError, TypeError): 203 | return 204 | 205 | 206 | def rtsp_snap_cmd(cam_name: str, interval: bool = False): 207 | ext = env_bool("IMG_TYPE", "jpg") 208 | img = f"{IMG_PATH}{cam_name}.{ext}" 209 | 210 | if interval and SNAPSHOT_FORMAT: 211 | file = datetime.now().strftime(f"{IMG_PATH}{SNAPSHOT_FORMAT}") 212 | base, _ext = os.path.splitext(file) 213 | ext = _ext.lstrip(".") or ext 214 | img = f"{base}.{ext}".format(cam_name=cam_name, CAM_NAME=cam_name.upper()) 215 | os.makedirs(os.path.dirname(img), exist_ok=True) 216 | 217 | keep_time = parse_timedelta("SNAPSHOT_KEEP") 218 | if keep_time and SNAPSHOT_FORMAT: 219 | purge_old(IMG_PATH, ext, keep_time) 220 | 221 | rotation = [] 222 | if rotate_img := env_bool(f"ROTATE_IMG_{cam_name}"): 223 | transpose = rotate_img if rotate_img in {"0", "1", "2", "3"} else "clock" 224 | rotation = ["-filter:v", f"{transpose=}"] 225 | 226 | return ( 227 | ["ffmpeg", "-loglevel", "fatal", "-analyzeduration", "0", "-probesize", "32"] 228 | + ["-f", "rtsp", "-rtsp_transport", "tcp", "-thread_queue_size", "500"] 229 | + ["-i", f"rtsp://0.0.0.0:8554/{cam_name}", "-map", "0:v:0"] 230 | + rotation 231 | + ["-f", "image2", "-frames:v", "1", "-y", img] 232 | ) 233 | -------------------------------------------------------------------------------- /app/wyzebridge/hass.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from os import environ, makedirs 4 | from sys import stdout 5 | from typing import Optional 6 | 7 | import requests 8 | import wyzecam 9 | from wyzebridge.logging import format_logging, logger 10 | 11 | 12 | def setup_hass(hass_token: Optional[str]) -> None: 13 | """Home Assistant related config.""" 14 | if not hass_token: 15 | return 16 | 17 | logger.info("🏠 Home Assistant Mode") 18 | 19 | with open("/data/options.json") as f: 20 | conf = json.load(f) 21 | 22 | auth = {"Authorization": f"Bearer {hass_token}"} 23 | try: 24 | assert "WB_IP" not in conf, f"Using WB_IP={conf['WB_IP']} from config" 25 | net_info = requests.get("http://supervisor/network/info", headers=auth).json() 26 | for i in net_info["data"]["interfaces"]: 27 | if i["primary"]: 28 | environ["WB_IP"] = i["ipv4"]["address"][0].split("/")[0] 29 | except Exception as e: 30 | logger.error(f"WEBRTC SETUP: {e}") 31 | 32 | mqtt_conf = requests.get("http://supervisor/services/mqtt", headers=auth).json() 33 | if "ok" in mqtt_conf.get("result") and (data := mqtt_conf.get("data")): 34 | environ["MQTT_HOST"] = f'{data["host"]}:{data["port"]}' 35 | environ["MQTT_AUTH"] = f'{data["username"]}:{data["password"]}' 36 | 37 | if cam_options := conf.pop("CAM_OPTIONS", None): 38 | for cam in cam_options: 39 | if not (cam_name := wyzecam.clean_name(cam.get("CAM_NAME", ""))): 40 | continue 41 | if "AUDIO" in cam: 42 | environ[f"ENABLE_AUDIO_{cam_name}"] = str(cam["AUDIO"]) 43 | if "FFMPEG" in cam: 44 | environ[f"FFMPEG_CMD_{cam_name}"] = str(cam["FFMPEG"]) 45 | if "NET_MODE" in cam: 46 | environ[f"NET_MODE_{cam_name}"] = str(cam["NET_MODE"]) 47 | if "ROTATE" in cam: 48 | environ[f"ROTATE_CAM_{cam_name}"] = str(cam["ROTATE"]) 49 | if "ROTATE_IMG" in cam: 50 | environ[f"ROTATE_IMG_{cam_name}"] = str(cam["ROTATE_IMG"]) 51 | if "QUALITY" in cam: 52 | environ[f"QUALITY_{cam_name}"] = str(cam["QUALITY"]) 53 | if "SUB_QUALITY" in cam: 54 | environ[f"SUB_QUALITY_{cam_name}"] = str(cam["SUB_QUALITY"]) 55 | if "FORCE_FPS" in cam: 56 | environ[f"FORCE_FPS_{cam_name}"] = str(cam["FORCE_FPS"]) 57 | if "LIVESTREAM" in cam: 58 | environ[f"LIVESTREAM_{cam_name}"] = str(cam["LIVESTREAM"]) 59 | if "RECORD" in cam: 60 | environ[f"RECORD_{cam_name}"] = str(cam["RECORD"]) 61 | if "SUB_RECORD" in cam: 62 | environ[f"SUB_RECORD_{cam_name}"] = str(cam["SUB_RECORD"]) 63 | if "SUBSTREAM" in cam: 64 | environ[f"SUBSTREAM_{cam_name}"] = str(cam["SUBSTREAM"]) 65 | if "MOTION_WEBHOOKS" in cam: 66 | environ[f"MOTION_WEBHOOKS_{cam_name}"] = str(cam["MOTION_WEBHOOKS"]) 67 | 68 | if mtx_options := conf.pop("MEDIAMTX", None): 69 | for opt in mtx_options: 70 | if (split_opt := opt.split("=", 1)) and len(split_opt) == 2: 71 | key = split_opt[0].strip().upper() 72 | key = key if key.startswith("MTX_") else f"MTX_{key}" 73 | environ[key] = split_opt[1].strip() 74 | 75 | for k, v in conf.items(): 76 | environ.update({k.replace(" ", "_").upper(): str(v)}) 77 | 78 | if not conf.get("MQTT"): 79 | logger.warning("MQTT IS DISABLED") 80 | environ.pop("MQTT_HOST", None) 81 | 82 | log_time = "%X" if conf.get("LOG_TIME") else "" 83 | log_level = conf.get("LOG_LEVEL", "") 84 | if log_level or log_time: 85 | log_level = getattr(logging, log_level.upper(), 20) 86 | format_logging(logging.StreamHandler(stdout), log_level, log_time) 87 | if conf.get("LOG_FILE"): 88 | log_path = "/config/logs/" 89 | log_file = f"{log_path}wyze-bridge.log" 90 | logger.info(f"Logging to file: {log_file}") 91 | makedirs(log_path, exist_ok=True) 92 | format_logging(logging.FileHandler(log_file), logging.DEBUG, "%Y/%m/%d %X") 93 | -------------------------------------------------------------------------------- /app/wyzebridge/logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import multiprocessing 3 | import warnings 4 | from os import makedirs 5 | from sys import stdout 6 | 7 | from wyzebridge.bridge_utils import env_bool 8 | 9 | log_level: int = getattr(logging, env_bool("LOG_LEVEL").upper(), 20) 10 | log_time = "%X" if env_bool("LOG_TIME") else "" 11 | 12 | multiprocessing.current_process().name = "WyzeBridge" 13 | logger: logging.Logger = logging.getLogger("WyzeBridge") 14 | logger.setLevel(logging.DEBUG) 15 | 16 | warnings.formatwarning = lambda msg, *args, **kwargs: f"WARNING: {msg}" 17 | logging.captureWarnings(True) 18 | 19 | 20 | def clear_handler(handler: logging.Handler): 21 | for logger_name in ("WyzeBridge", "", "werkzeug", "py.warnings"): 22 | target_logger = logging.getLogger(logger_name) 23 | for existing_handler in target_logger.handlers: 24 | if type(existing_handler) == type(handler): 25 | target_logger.removeHandler(existing_handler) 26 | 27 | 28 | def format_logging(handler: logging.Handler, level: int, date_format: str = ""): 29 | clear_handler(handler) 30 | log_format = "[%(processName)s] %(message)s" 31 | if level < logging.INFO: 32 | target_logger = logging.getLogger() 33 | log_format = f"[%(levelname)s]{log_format}" 34 | warnings.simplefilter("always") 35 | else: 36 | target_logger = logging.getLogger("WyzeBridge") 37 | logging.getLogger("werkzeug").addHandler(handler) 38 | logging.getLogger("wyzecam.iotc").addHandler(handler) 39 | logging.getLogger("py.warnings").addHandler(handler) 40 | 41 | date_format = "%X" if not date_format and level < 20 else date_format 42 | log_format = f"%(asctime)s {log_format}" if date_format else log_format 43 | handler.setFormatter(logging.Formatter(log_format, date_format)) 44 | target_logger.addHandler(handler) 45 | target_logger.setLevel(level) 46 | 47 | 48 | format_logging(logging.StreamHandler(stdout), log_level, log_time) 49 | 50 | 51 | if env_bool("LOG_FILE"): 52 | log_path = "/logs/" 53 | log_file = f"{log_path}debug.log" 54 | logger.info(f"Logging to file: {log_file}") 55 | makedirs(log_path, exist_ok=True) 56 | format_logging(logging.FileHandler(log_file), logging.DEBUG, "%Y/%m/%d %X") 57 | -------------------------------------------------------------------------------- /app/wyzebridge/mtx_event.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module handles stream and client events from MediaMTX. 3 | """ 4 | 5 | import contextlib 6 | import errno 7 | import os 8 | import select 9 | 10 | from wyzebridge.logging import logger 11 | from wyzebridge.mqtt import update_mqtt_state 12 | 13 | 14 | class RtspEvent: 15 | """ 16 | Reads from the `/tmp/mtx_event` named pipe and logs events. 17 | """ 18 | 19 | FIFO = "/tmp/mtx_event" 20 | __slots__ = "pipe", "streams", "buf" 21 | 22 | def __init__(self, streams): 23 | self.pipe = 0 24 | self.streams = streams 25 | self.buf: str = "" 26 | self.open_pipe() 27 | 28 | def open_pipe(self): 29 | if self.pipe: 30 | return 31 | with contextlib.suppress(FileExistsError): 32 | os.mkfifo(self.FIFO) 33 | self.pipe = os.open(self.FIFO, os.O_RDWR | os.O_NONBLOCK) 34 | 35 | def read(self, timeout: int = 1): 36 | self.open_pipe() 37 | try: 38 | if select.select([self.pipe], [], [], timeout)[0]: 39 | if data := os.read(self.pipe, 128): 40 | self.process_data(data.decode()) 41 | except OSError as ex: 42 | self.pipe = 0 43 | if ex.errno != errno.EBADF: 44 | logger.error(ex) 45 | except Exception as ex: 46 | logger.error(f"Error reading from pipe: {ex}") 47 | 48 | def process_data(self, data): 49 | messages = data.split("!") 50 | if self.buf: 51 | messages[0] = self.buf + messages[0] 52 | self.buf = "" 53 | for msg in messages[:-1]: 54 | self.log_event(msg.strip()) 55 | 56 | self.buf = messages[-1].strip() 57 | 58 | def log_event(self, event_data: str): 59 | try: 60 | uri, event = event_data.split(",") 61 | except ValueError: 62 | logger.error(f"Error parsing {event_data=}") 63 | return 64 | 65 | event = event.lower().strip() 66 | 67 | if event == "start": 68 | self.streams.get(uri).start() 69 | elif event == "stop": 70 | self.streams.get(uri).stop() 71 | elif event in {"read", "unread"}: 72 | read_event(uri, event) 73 | elif event in {"ready", "notready"}: 74 | if event == "notready": 75 | self.streams.get(uri).stop() 76 | ready_event(uri, event) 77 | 78 | 79 | def read_event(camera: str, status: str): 80 | msg = f"📕 Client stopped reading from {camera}" 81 | if status == "read": 82 | msg = f"📖 New client reading from {camera}" 83 | logger.info(msg) 84 | 85 | 86 | def ready_event(camera: str, status: str): 87 | msg = f"❌ '/{camera}' stream is down" 88 | state = "disconnected" 89 | if status == "ready": 90 | msg = f"✅ '/{camera} stream is UP! (3/3)" 91 | state = "online" 92 | 93 | update_mqtt_state(camera, state) 94 | logger.info(msg) 95 | -------------------------------------------------------------------------------- /app/wyzebridge/mtx_server.py: -------------------------------------------------------------------------------- 1 | from os import getenv 2 | from pathlib import Path 3 | from signal import SIGKILL 4 | from subprocess import DEVNULL, Popen 5 | from typing import Optional 6 | 7 | import yaml 8 | from wyzebridge.bridge_utils import env_bool 9 | from wyzebridge.logging import logger 10 | 11 | MTX_CONFIG = "/app/mediamtx.yml" 12 | 13 | RECORD_LENGTH = env_bool("RECORD_LENGTH", "60s") 14 | RECORD_KEEP = env_bool("RECORD_KEEP", "0s") 15 | rec_file = env_bool("RECORD_FILE_NAME", style="original").strip("/") 16 | rec_path = env_bool("RECORD_PATH", "record/%path/%Y-%m-%d_%H-%M-%S", style="original") 17 | RECORD_PATH = f"{Path('/') / Path(rec_path) / Path(rec_file)}".removesuffix(".mp4") 18 | 19 | 20 | class MtxInterface: 21 | __slots__ = "data", "_modified" 22 | 23 | def __init__(self): 24 | self.data = {} 25 | self._modified = False 26 | 27 | def __enter__(self): 28 | self._load_config() 29 | return self 30 | 31 | def __exit__(self, exc_type, exc_value, traceback): 32 | if self._modified: 33 | self._save_config() 34 | 35 | def _load_config(self): 36 | with open(MTX_CONFIG, "r") as f: 37 | self.data = yaml.safe_load(f) or {} 38 | 39 | def _save_config(self): 40 | with open(MTX_CONFIG, "w") as f: 41 | yaml.safe_dump(self.data, f) 42 | 43 | def get(self, path: str): 44 | keys = path.split(".") 45 | current = self.data 46 | for key in keys: 47 | if current is None: 48 | return None 49 | current = current.get(key) 50 | return current 51 | 52 | def set(self, path: str, value): 53 | keys = path.split(".") 54 | current = self.data 55 | for key in keys[:-1]: 56 | current = current.setdefault(key, {}) 57 | current[keys[-1]] = value 58 | self._modified = True 59 | 60 | def add(self, path: str, value): 61 | if not isinstance(value, list): 62 | value = [value] 63 | current = self.data.get(path) 64 | if isinstance(current, list): 65 | current.extend([item for item in value if item not in current]) 66 | else: 67 | self.data[path] = value 68 | self._modified = True 69 | 70 | 71 | class MtxServer: 72 | """Setup and interact with the backend mediamtx.""" 73 | 74 | __slots__ = "sub_process" 75 | 76 | def __init__(self) -> None: 77 | self.sub_process: Optional[Popen] = None 78 | self._setup_path_defaults() 79 | 80 | def _setup_path_defaults(self): 81 | record_path = RECORD_PATH.format(cam_name="%path", CAM_NAME="%path") 82 | 83 | with MtxInterface() as mtx: 84 | mtx.set("paths", {}) 85 | for event in {"Read", "Unread", "Ready", "NotReady"}: 86 | bash_cmd = f"echo $MTX_PATH,{event}! > /tmp/mtx_event;" 87 | mtx.set(f"pathDefaults.runOn{event}", f"bash -c '{bash_cmd}'") 88 | mtx.set("pathDefaults.runOnDemandStartTimeout", "30s") 89 | mtx.set("pathDefaults.runOnDemandCloseAfter", "60s") 90 | mtx.set("pathDefaults.recordPath", record_path) 91 | mtx.set("pathDefaults.recordSegmentDuration", RECORD_LENGTH) 92 | mtx.set("pathDefaults.recordDeleteAfter", RECORD_KEEP) 93 | 94 | def setup_auth(self, api: Optional[str], stream: Optional[str]): 95 | publisher = [ 96 | { 97 | "ips": ["127.0.0.1"], 98 | "permissions": [{"action": "read"}, {"action": "publish"}], 99 | } 100 | ] 101 | with MtxInterface() as mtx: 102 | mtx.set("authInternalUsers", publisher) 103 | if api or not stream: 104 | client: dict = {"permissions": [{"action": "read"}]} 105 | if api: 106 | client.update({"user": "wb", "pass": api}) 107 | mtx.add("authInternalUsers", client) 108 | if stream: 109 | logger.info("[MTX] Custom stream auth enabled") 110 | for client in parse_auth(stream): 111 | mtx.add("authInternalUsers", client) 112 | 113 | def add_path(self, uri: str, on_demand: bool = True): 114 | with MtxInterface() as mtx: 115 | if on_demand: 116 | cmd = f"bash -c 'echo $MTX_PATH,{{}}! > /tmp/mtx_event'" 117 | mtx.set(f"paths.{uri}.runOnDemand", cmd.format("start")) 118 | mtx.set(f"paths.{uri}.runOnUnDemand", cmd.format("stop")) 119 | else: 120 | mtx.set(f"paths.{uri}", {}) 121 | 122 | def add_source(self, uri: str, value: str): 123 | with MtxInterface() as mtx: 124 | mtx.set(f"paths.{uri}.source", value) 125 | 126 | def record(self, uri: str): 127 | record_path = RECORD_PATH.replace("%path", uri).format( 128 | cam_name=uri.lower(), CAM_NAME=uri.upper() 129 | ) 130 | 131 | logger.info(f"[MTX] 📹 Will record {RECORD_LENGTH} clips to {record_path}.mp4") 132 | with MtxInterface() as mtx: 133 | mtx.set(f"paths.{uri}.record", True) 134 | mtx.set(f"paths.{uri}.recordPath", record_path) 135 | 136 | def start(self): 137 | if self.sub_process: 138 | return 139 | logger.info(f"[MTX] starting MediaMTX {getenv('MTX_TAG')}") 140 | self.sub_process = Popen(["/app/mediamtx", "/app/mediamtx.yml"]) 141 | 142 | def stop(self): 143 | if not self.sub_process: 144 | return 145 | if self.sub_process.poll() is None: 146 | logger.info("[MTX] Stopping MediaMTX...") 147 | self.sub_process.send_signal(SIGKILL) 148 | self.sub_process.communicate() 149 | self.sub_process = None 150 | 151 | def restart(self): 152 | self.stop() 153 | self.start() 154 | 155 | def health_check(self): 156 | if self.sub_process and self.sub_process.poll() is not None: 157 | logger.error(f"[MediaMTX] Process exited with {self.sub_process.poll()}") 158 | self.restart() 159 | 160 | def setup_webrtc(self, bridge_ip: Optional[str]): 161 | if not bridge_ip: 162 | logger.warning("SET WB_IP to allow WEBRTC connections.") 163 | return 164 | ips = bridge_ip.split(",") 165 | logger.debug(f"Using {' and '.join(ips)} for webrtc") 166 | with MtxInterface() as mtx: 167 | mtx.add("webrtcAdditionalHosts", ips) 168 | 169 | def setup_llhls(self, token_path: str = "/tokens/", hass: bool = False): 170 | logger.info("[MTX] Configuring LL-HLS") 171 | with MtxInterface() as mtx: 172 | mtx.set("hlsVariant", "lowLatency") 173 | mtx.set("hlsEncryption", True) 174 | if env_bool("mtx_hlsServerKey"): 175 | return 176 | 177 | key = "/ssl/privkey.pem" 178 | cert = "/ssl/fullchain.pem" 179 | if hass and Path(key).is_file() and Path(cert).is_file(): 180 | logger.info( 181 | "[MTX] 🔐 Using existing SSL certificate from Home Assistant" 182 | ) 183 | mtx.set("hlsServerKey", key) 184 | mtx.set("hlsServerCert", cert) 185 | return 186 | 187 | cert_path = f"{token_path}hls_server" 188 | generate_certificates(cert_path) 189 | mtx.set("hlsServerKey", f"{cert_path}.key") 190 | mtx.set("hlsServerCert", f"{cert_path}.crt") 191 | 192 | 193 | def mtx_version() -> str: 194 | try: 195 | with open("/MTX_TAG", "r") as tag: 196 | return tag.read().strip() 197 | except FileNotFoundError: 198 | return "" 199 | 200 | 201 | def generate_certificates(cert_path): 202 | if not Path(f"{cert_path}.key").is_file(): 203 | logger.info("[MTX] 🔐 Generating key for LL-HLS") 204 | Popen( 205 | ["openssl", "genrsa", "-out", f"{cert_path}.key", "2048"], 206 | stdout=DEVNULL, 207 | stderr=DEVNULL, 208 | ).wait() 209 | if not Path(f"{cert_path}.crt").is_file(): 210 | logger.info("[MTX] 🔏 Generating certificate for LL-HLS") 211 | dns = getenv("SUBJECT_ALT_NAME") 212 | Popen( 213 | ["openssl", "req", "-new", "-x509", "-sha256"] 214 | + ["-key", f"{cert_path}.key"] 215 | + ["-subj", "/C=US/ST=WA/L=Kirkland/O=WYZE BRIDGE/CN=wyze-bridge"] 216 | + (["-addext", f"subjectAltName = DNS:{dns}"] if dns else []) 217 | + ["-out", f"{cert_path}.crt"] 218 | + ["-days", "3650"], 219 | stdout=DEVNULL, 220 | stderr=DEVNULL, 221 | ).wait() 222 | 223 | 224 | def parse_auth(auth: str) -> list[dict[str, str]]: 225 | entries = [] 226 | for entry in auth.split("|"): 227 | creds, *endpoints = entry.split("@") 228 | if ":" not in creds: 229 | continue 230 | user, password, *ips = creds.split(":", 2) 231 | if ips: 232 | ips = ips[0].split(",") 233 | data = {"user": user or "any", "pass": password, "ips": ips, "permissions": []} 234 | if endpoints: 235 | paths = [] 236 | for endpoint in endpoints[0].split(","): 237 | paths.append(endpoint) 238 | data["permissions"].append({"action": "read", "path": endpoint}) 239 | else: 240 | paths = "all" 241 | data["permissions"].append({"action": "read"}) 242 | logger.info(f"[MTX] Auth [{data['user']}:{data['pass']}] {paths=}") 243 | entries.append(data) 244 | return entries 245 | -------------------------------------------------------------------------------- /app/wyzebridge/stream.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import json 3 | import time 4 | from subprocess import Popen, TimeoutExpired 5 | from threading import Thread 6 | from typing import Any, Callable, Optional, Protocol 7 | 8 | from wyzebridge.config import MOTION, MQTT_DISCOVERY, SNAPSHOT_INT, SNAPSHOT_TYPE 9 | from wyzebridge.ffmpeg import rtsp_snap_cmd 10 | from wyzebridge.logging import logger 11 | from wyzebridge.mqtt import bridge_status, cam_control, publish_topic, update_preview 12 | from wyzebridge.mtx_event import RtspEvent 13 | from wyzebridge.wyze_events import WyzeEvents 14 | 15 | 16 | class Stream(Protocol): 17 | camera: Any 18 | options: Any 19 | start_time: float 20 | state: Any 21 | uri: str 22 | 23 | @property 24 | def connected(self) -> bool: ... 25 | 26 | @property 27 | def enabled(self) -> bool: ... 28 | 29 | @property 30 | def motion(self) -> bool: ... 31 | 32 | def start(self) -> bool: ... 33 | 34 | def stop(self) -> bool: ... 35 | 36 | def enable(self) -> bool: ... 37 | 38 | def disable(self) -> bool: ... 39 | 40 | def health_check(self) -> int: ... 41 | 42 | def get_info(self, item: Optional[str] = None) -> dict: ... 43 | 44 | def status(self) -> str: ... 45 | 46 | def send_cmd(self, cmd: str, payload: str | list | dict = "") -> dict: ... 47 | 48 | 49 | class StreamManager: 50 | __slots__ = "stop_flag", "streams", "rtsp_snapshots", "last_snap", "thread" 51 | 52 | def __init__(self): 53 | self.stop_flag: bool = False 54 | self.streams: dict[str, Stream] = {} 55 | self.rtsp_snapshots: dict[str, Popen] = {} 56 | self.last_snap: float = 0 57 | self.thread: Optional[Thread] = None 58 | 59 | @property 60 | def total(self): 61 | return len(self.streams) 62 | 63 | @property 64 | def active(self): 65 | return len([s for s in self.streams.values() if s.enabled]) 66 | 67 | def add(self, stream: Stream) -> str: 68 | uri = stream.uri 69 | self.streams[uri] = stream 70 | return uri 71 | 72 | def get(self, uri: str) -> Optional[Stream]: 73 | return self.streams.get(uri) 74 | 75 | def get_info(self, uri: str) -> dict: 76 | return stream.get_info() if (stream := self.get(uri)) else {} 77 | 78 | def get_all_cam_info(self) -> dict: 79 | return {uri: s.get_info() for uri, s in self.streams.items()} 80 | 81 | def stop_all(self) -> None: 82 | logger.info(f"Stopping {self.total} stream{'s'[:self.total^1]}") 83 | self.stop_flag = True 84 | for stream in self.streams.values(): 85 | stream.stop() 86 | if self.thread and self.thread.is_alive(): 87 | with contextlib.suppress(AttributeError): 88 | self.thread.join() 89 | 90 | def monitor_streams(self, mtx_health: Callable) -> None: 91 | self.stop_flag = False 92 | if MQTT_DISCOVERY: 93 | self.thread = Thread(target=self.monitor_snapshots) 94 | self.thread.start() 95 | mqtt = cam_control(self.streams, self.send_cmd) 96 | logger.info(f"🎬 {self.total} stream{'s'[:self.total^1]} enabled") 97 | event = RtspEvent(self.streams) 98 | events = WyzeEvents(self.streams) if MOTION else None 99 | while not self.stop_flag: 100 | event.read(timeout=1) 101 | self.snap_all(self.active_streams()) 102 | if events: 103 | events.check_motion() 104 | if int(time.time()) % 15 == 0: 105 | mtx_health() 106 | bridge_status(mqtt) 107 | if mqtt: 108 | mqtt.loop_stop() 109 | logger.info("Stream monitoring stopped") 110 | 111 | def monitor_snapshots(self) -> None: 112 | for cam in self.streams: 113 | update_preview(cam) 114 | while not self.stop_flag: 115 | for cam, ffmpeg in list(self.rtsp_snapshots.items()): 116 | if (returncode := ffmpeg.returncode) is not None: 117 | if returncode == 0: 118 | update_preview(cam) 119 | del self.rtsp_snapshots[cam] 120 | time.sleep(1) 121 | 122 | def active_streams(self) -> list[str]: 123 | """ 124 | Health check on all streams and return a list of enabled 125 | streams that are NOT battery powered. 126 | 127 | Returns: 128 | - list(str): uri-friendly name of streams that are enabled. 129 | """ 130 | if self.stop_flag: 131 | return [] 132 | return [cam for cam, s in self.streams.items() if s.health_check() > 0] 133 | 134 | def snap_all(self, cams: Optional[list[str]] = None, force: bool = False): 135 | """ 136 | Take an rtsp snapshot of the streams in the list. 137 | 138 | Args: 139 | - cams (list[str], optional): names of the streams to take a snapshot of. 140 | - force (bool, optional): Ignore interval and force snapshot. Defaults to False. 141 | """ 142 | if force or self._should_snap(): 143 | self.last_snap = time.time() 144 | for cam in cams or self.active_streams(): 145 | stop_subprocess(self.rtsp_snapshots.get(cam)) 146 | self.rtsp_snap_popen(cam, True) 147 | 148 | def _should_snap(self): 149 | return SNAPSHOT_TYPE == "rtsp" and time.time() - self.last_snap >= SNAPSHOT_INT 150 | 151 | def get_sse_status(self) -> dict: 152 | return { 153 | uri: {"status": cam.status(), "motion": cam.motion} 154 | for uri, cam in self.streams.items() 155 | } 156 | 157 | def send_cmd( 158 | self, cam_name: str, cmd: str, payload: str | list | dict = "" 159 | ) -> dict: 160 | """ 161 | Send a command directly to the camera and wait for a response. 162 | 163 | Parameters: 164 | - cam_name (str): uri-friendly name of the camera. 165 | - cmd (str): The camera/tutk command to send. 166 | - payload (str): value for the tutk command. 167 | 168 | Returns: 169 | - dictionary: Results that can be converted to JSON. 170 | """ 171 | resp = {"status": "error", "command": cmd, "payload": payload} 172 | 173 | if cam_name == "all" and cmd == "update_snapshot": 174 | self.snap_all(force=True) 175 | return resp | {"status": "success"} 176 | 177 | if not (stream := self.get(cam_name)): 178 | return resp | {"response": "Camera not found"} 179 | 180 | if cam_resp := stream.send_cmd(cmd, payload): 181 | status = cam_resp.get("value") if cam_resp.get("status") == "success" else 0 182 | if isinstance(status, dict): 183 | status = json.dumps(status) 184 | 185 | if "update_snapshot" in cam_resp: 186 | on_demand = not stream.connected 187 | snap = self.get_rtsp_snap(cam_name) 188 | if on_demand: 189 | stream.stop() 190 | publish_topic(f"{cam_name}/{cmd}", int(time.time()) if snap else 0) 191 | return dict(resp, status="success", value=snap, response=snap) 192 | 193 | publish_topic(f"{cam_name}/{cmd}", status) 194 | 195 | return cam_resp if "status" in cam_resp else resp | cam_resp 196 | 197 | def rtsp_snap_popen(self, cam_name: str, interval: bool = False) -> Optional[Popen]: 198 | if not (stream := self.get(cam_name)): 199 | return 200 | stream.start() 201 | ffmpeg = self.rtsp_snapshots.get(cam_name) 202 | if not ffmpeg or ffmpeg.poll() is not None: 203 | ffmpeg = Popen(rtsp_snap_cmd(cam_name, interval)) 204 | self.rtsp_snapshots[cam_name] = ffmpeg 205 | return ffmpeg 206 | 207 | def get_rtsp_snap(self, cam_name: str) -> bool: 208 | if not (stream := self.get(cam_name)) or stream.health_check() < 1: 209 | return False 210 | if not (ffmpeg := self.rtsp_snap_popen(cam_name)): 211 | return False 212 | try: 213 | if ffmpeg.wait(timeout=15) == 0: 214 | return True 215 | except TimeoutExpired: 216 | logger.error(f"[{cam_name}] Snapshot timed out") 217 | except Exception as ex: 218 | logger.error(ex) 219 | stop_subprocess(ffmpeg) 220 | 221 | return False 222 | 223 | 224 | def stop_subprocess(ffmpeg: Optional[Popen]): 225 | if ffmpeg and ffmpeg.poll() is None: 226 | ffmpeg.kill() 227 | ffmpeg.communicate() 228 | -------------------------------------------------------------------------------- /app/wyzebridge/web_ui.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from time import sleep 4 | from typing import Callable, Generator, Optional 5 | from urllib.parse import urlparse 6 | 7 | from flask import request 8 | from flask import url_for as _url_for 9 | from flask_httpauth import HTTPBasicAuth 10 | from werkzeug.security import check_password_hash 11 | from wyzebridge import config 12 | from wyzebridge.auth import WbAuth 13 | from wyzebridge.bridge_utils import env_bool 14 | from wyzebridge.logging import logger 15 | from wyzebridge.stream import Stream, StreamManager 16 | 17 | auth = HTTPBasicAuth() 18 | 19 | API_ENDPOINTS = "/api", "/img", "/snapshot", "/thumb", "/photo" 20 | 21 | 22 | @auth.verify_password 23 | def verify_password(username, password): 24 | if config.HASS_TOKEN and request.remote_addr == "172.30.32.2": 25 | return True 26 | if WbAuth.api in (request.args.get("api"), request.headers.get("api")): 27 | return request.path.startswith(API_ENDPOINTS) 28 | if username == WbAuth.username: 29 | return check_password_hash(WbAuth.hashed_password(), password) 30 | return WbAuth.enabled == False 31 | 32 | 33 | @auth.error_handler 34 | def unauthorized(): 35 | return {"error": "Unauthorized"}, 401 36 | 37 | 38 | def url_for(endpoint, **values): 39 | proxy = ( 40 | request.headers.get("X-Ingress-Path") 41 | or request.headers.get("X-Forwarded-Prefix") 42 | or "" 43 | ).rstrip("/") 44 | return proxy + _url_for(endpoint, **values) 45 | 46 | 47 | def sse_generator(sse_status: Callable) -> Generator[str, str, str]: 48 | """Generator to return the status for enabled cameras.""" 49 | cameras = {} 50 | while True: 51 | if cameras != (cameras := sse_status()): 52 | yield f"data: {json.dumps(cameras)}\n\n" 53 | sleep(1) 54 | 55 | 56 | def mfa_generator(mfa_req: Callable) -> Generator[str, str, str]: 57 | if mfa_req(): 58 | yield f"event: mfa\ndata: {mfa_req()}\n\n" 59 | while mfa_req(): 60 | sleep(1) 61 | while True: 62 | yield "event: mfa\ndata: clear\n\n" 63 | sleep(30) 64 | 65 | 66 | def set_mfa(mfa_code: str) -> bool: 67 | """Set MFA code from WebUI.""" 68 | mfa_file = f"{config.TOKEN_PATH}mfa_token.txt" 69 | try: 70 | with open(mfa_file, "w") as f: 71 | f.write(mfa_code) 72 | while os.path.getsize(mfa_file) != 0: 73 | sleep(1) 74 | return True 75 | except Exception as ex: 76 | logger.error(ex) 77 | return False 78 | 79 | 80 | def get_webrtc_signal(cam_name: str, api_key: str) -> dict: 81 | """Generate signaling for MediaMTX webrtc.""" 82 | hostname = env_bool("DOMAIN", urlparse(request.root_url).hostname or "localhost") 83 | ssl = "s" if env_bool("MTX_WEBRTCENCRYPTION") else "" 84 | webrtc = config.WEBRTC_URL.lstrip("http") or f"{ssl}://{hostname}:8889" 85 | wep = {"result": "ok", "cam": cam_name, "whep": f"http{webrtc}/{cam_name}/whep"} 86 | 87 | if ice_server := validate_ice(env_bool("MTX_WEBRTCICESERVERS")): 88 | return wep | {"servers": ice_server} 89 | 90 | ice_server = { 91 | "credentialType": "password", 92 | "urls": ["stun:stun.l.google.com:19302"], 93 | } 94 | if api_key: 95 | ice_server |= { 96 | "username": "wb", 97 | "credential": api_key, 98 | "credentialType": "password", 99 | } 100 | return wep | {"servers": [ice_server]} 101 | 102 | 103 | def validate_ice(data: str) -> Optional[list[dict]]: 104 | if not data: 105 | return 106 | try: 107 | json_data = json.loads(data) 108 | if "urls" in json_data: 109 | return [json_data] 110 | except ValueError: 111 | return 112 | 113 | 114 | def format_stream(name_uri: str) -> dict: 115 | """ 116 | Format stream with hostname. 117 | 118 | Parameters: 119 | - name_uri (str): camera name. 120 | 121 | Returns: 122 | - dict: Can be merged with camera info. 123 | """ 124 | hostname = env_bool("DOMAIN", urlparse(request.root_url).hostname or "localhost") 125 | img = f"{name_uri}.{env_bool('IMG_TYPE','jpg')}" 126 | try: 127 | img_time = int(os.path.getmtime(config.IMG_PATH + img) * 1000) 128 | except FileNotFoundError: 129 | img_time = None 130 | 131 | webrtc_url = (config.WEBRTC_URL or f"http://{hostname}:8889") + f"/{name_uri}/" 132 | data = { 133 | "hls_url": (config.HLS_URL or f"http://{hostname}:8888") + f"/{name_uri}/", 134 | "webrtc_url": webrtc_url if config.BRIDGE_IP else None, 135 | "rtmp_url": (config.RTMP_URL or f"rtmp://{hostname}:1935") + f"/{name_uri}", 136 | "rtsp_url": (config.RTSP_URL or f"rtsp://{hostname}:8554") + f"/{name_uri}", 137 | "img_url": f"img/{img}" if img_time else None, 138 | "snapshot_url": f"snapshot/{img}", 139 | "thumbnail_url": f"thumb/{img}", 140 | "img_time": img_time, 141 | } 142 | if config.LLHLS: 143 | data["hls_url"] = data["hls_url"].replace("http:", "https:") 144 | return data 145 | 146 | 147 | def format_streams(cams: dict) -> dict[str, dict]: 148 | """ 149 | Format info for multiple streams with hostname. 150 | 151 | Parameters: 152 | - cams (dict): get_all_cam_info from StreamManager. 153 | 154 | Returns: 155 | - dict: cam info with hostname. 156 | """ 157 | return {uri: cam | format_stream(uri) for uri, cam in cams.items()} 158 | 159 | 160 | def all_cams(streams: StreamManager, total: int) -> dict: 161 | return { 162 | "total": total, 163 | "available": streams.total, 164 | "enabled": streams.active, 165 | "cameras": format_streams(streams.get_all_cam_info()), 166 | } 167 | 168 | 169 | def boa_snapshot(stream: Stream) -> Optional[dict]: 170 | """Take photo.""" 171 | stream.send_cmd("take_photo") 172 | if boa_info := stream.get_info("boa_info"): 173 | return boa_info.get("last_photo") 174 | -------------------------------------------------------------------------------- /app/wyzebridge/webhooks.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import requests 4 | from wyzebridge.bridge_utils import env_cam 5 | from wyzebridge.config import VERSION 6 | from wyzebridge.logging import logger 7 | 8 | 9 | def send_webhook(event: str, camera: str, msg: str, img: Optional[str] = None) -> None: 10 | if not (url := env_cam(f"{event}_webhooks", camera, style="original")): 11 | return 12 | 13 | header = { 14 | "user-agent": f"wyzebridge/{VERSION}", 15 | "X-Title": f"{event} event".title(), 16 | "X-Attach": img, 17 | "X-Tags": f"{camera},{event}", 18 | "X-Camera": camera, 19 | "X-Event": event, 20 | } 21 | 22 | logger.debug(f"[WEBHOOKS] 📲 Triggering {event.upper()} event for {camera}") 23 | try: 24 | resp = requests.post(url, headers=header, data=msg, verify=False) 25 | resp.raise_for_status() 26 | except Exception as ex: 27 | print(f"[WEBHOOKS] {ex}") 28 | -------------------------------------------------------------------------------- /app/wyzebridge/wyze_commands.py: -------------------------------------------------------------------------------- 1 | GET_CMDS = { 2 | "state": None, 3 | "power": None, 4 | "notifications": None, 5 | "update_snapshot": None, 6 | "motion_detection": None, 7 | "take_photo": "K10058TakePhoto", 8 | "irled": "K10044GetIRLEDStatus", 9 | "night_vision": "K10040GetNightVisionStatus", 10 | "status_light": "K10030GetNetworkLightStatus", 11 | "osd_timestamp": "K10070GetOSDStatus", 12 | "osd_logo": "K10074GetOSDLogoStatus", 13 | "camera_time": "K10090GetCameraTime", 14 | "night_switch": "K10624GetAutoSwitchNightType", 15 | "alarm": "K10632GetAlarmFlashing", 16 | "start_boa": "K10148StartBoa", 17 | "cruise_points": "K11010GetCruisePoints", 18 | "pan_cruise": "K11014GetCruise", 19 | "ptz_position": "K11006GetCurCruisePoint", 20 | "motion_tracking": "K11020GetMotionTracking", 21 | "motion_tagging": "K10290GetMotionTagging", 22 | "camera_info": "K10020CheckCameraInfo", 23 | "battery_usage": "K10448GetBatteryUsage", 24 | "rtsp": "K10604GetRtspParam", 25 | "accessories": "K10720GetAccessoriesInfo", 26 | "floodlight": "K10788GetIntegratedFloodlightInfo", 27 | "whitelight": "K10820GetWhiteLightInfo", 28 | "param_info": "K10020CheckCameraParams", # Requires a Payload 29 | "_bitrate": "K10050GetVideoParam", # Only works on newer firmware 30 | } 31 | 32 | # These GET_CMDS can include a payload: 33 | GET_PAYLOAD = {"param_info"} 34 | 35 | SET_CMDS = { 36 | "state": None, 37 | "power": None, 38 | "time_zone": None, 39 | "cruise_point": None, 40 | "fps": None, 41 | "bitrate": None, 42 | "notifications": None, 43 | "motion_detection": None, 44 | "irled": "K10046SetIRLEDStatus", 45 | "night_vision": "K10042SetNightVisionStatus", 46 | "status_light": "K10032SetNetworkLightStatus", 47 | "osd_timestamp": "K10072SetOSDStatus", 48 | "osd_logo": "K10076SetOSDLogoStatus", 49 | "camera_time": "K10092SetCameraTime", 50 | "night_switch": "K10626SetAutoSwitchNightType", 51 | "alarm": "K10630SetAlarmFlashing", 52 | "rotary_action": "K11002SetRotaryByAction", 53 | "rotary_degree": "K11000SetRotaryByDegree", 54 | "reset_rotation": "K11004ResetRotatePosition", 55 | "cruise_points": "K11012SetCruisePoints", 56 | "pan_cruise": "K11016SetCruise", 57 | "ptz_position": "K11018SetPTZPosition", 58 | "motion_tracking": "K11022SetMotionTracking", 59 | "motion_tagging": "K10292SetMotionTagging", 60 | "hor_flip": "K10052HorizontalFlip", 61 | "ver_flip": "K10052VerticalFlip", 62 | "rtsp": "K10600SetRtspSwitch", 63 | "quick_response": "K11635ResponseQuickMessage", 64 | "spotlight": "K10646SetSpotlightStatus", 65 | "floodlight": "K12060SetFloodLightSwitch", 66 | "format_sd": "K10242FormatSDCard", 67 | } 68 | 69 | CMD_VALUES = { 70 | "on": 1, 71 | "off": 2, 72 | "auto": 3, 73 | "true": 1, 74 | "false": 2, 75 | "left": (-90, 0), 76 | "right": (90, 0), 77 | "up": (0, 90), 78 | "down": (0, -90), 79 | } 80 | 81 | PARAMS = { 82 | "status_light": "1", 83 | "night_vision": "2", 84 | "bitrate": "3", 85 | "res": "4", 86 | "fps": "5", 87 | "hor_flip": "6", 88 | "ver_flip": "7", 89 | "motion_detection": "13", # K10200GetMotionAlarm 90 | "motion_tagging": "21", 91 | "time_zone": "22", 92 | "motion_tracking": "27", 93 | "irled": "50", 94 | } 95 | -------------------------------------------------------------------------------- /app/wyzebridge/wyze_events.py: -------------------------------------------------------------------------------- 1 | import time 2 | from collections import deque 3 | from datetime import datetime 4 | from typing import Any 5 | 6 | from wyzebridge.config import MOTION_INT, MOTION_START 7 | from wyzebridge.logging import logger 8 | from wyzebridge.mqtt import update_preview 9 | from wyzebridge.webhooks import send_webhook 10 | from wyzebridge.wyze_stream import WyzeStream 11 | 12 | 13 | class WyzeEvents: 14 | __slots__ = "api", "streams", "events", "last_check", "last_ts" 15 | 16 | def __init__(self, streams: dict[str, WyzeStream | Any]): 17 | self.streams = streams 18 | self.api = next(iter(streams.values())).api 19 | self.events: deque[str] = deque(maxlen=20) 20 | self.last_check: float = 0 21 | self.last_ts: int = 0 22 | logger.info(f"API Motion Events Enabled [interval={MOTION_INT}]") 23 | 24 | def enabled_cams(self) -> list: 25 | return [s.camera.mac for s in self.streams.values() if s.enabled] 26 | 27 | def get_events(self) -> list: 28 | if time.time() - self.last_check < MOTION_INT: 29 | return [] 30 | self.last_check, resp = self.api.get_events(self.enabled_cams(), self.last_ts) 31 | if resp: 32 | logger.debug(f"[MOTION] Got {len(resp)} events") 33 | return resp 34 | 35 | def set_motion(self, mac: str, files: list) -> None: 36 | for stream in self.streams.values(): 37 | if stream.camera.mac == mac and not stream.options.substream: 38 | if img := next((f["url"] for f in files if f["type"] == 1), None): 39 | stream.camera.thumbnail = img 40 | stream.motion = self.last_ts 41 | event_time = datetime.fromtimestamp(self.last_ts) 42 | msg = f"Motion detected on {stream.uri} at {event_time: %H:%M:%S}" 43 | logger.info(f"[MOTION] {msg}") 44 | send_webhook("motion", stream.uri, msg, img) 45 | if MOTION_START: 46 | stream.start() 47 | if img and self.api.save_thumbnail(stream.camera.name_uri, img): 48 | update_preview(stream.camera.name_uri) 49 | 50 | def process_event(self, event: dict): 51 | if event["event_id"] in self.events: 52 | return 53 | logger.debug(f"[MOTION] New motion event: {event['event_id']}") 54 | self.events.append(event["event_id"]) 55 | self.last_ts = int(event["event_ts"] / 1000) 56 | if time.time() - self.last_ts < 30: 57 | # v2 uses device_mac and v4 uses device_id 58 | self.set_motion(event["device_id"], event["file_list"]) 59 | 60 | def check_motion(self): 61 | if time.time() - self.last_check < MOTION_INT: 62 | return 63 | for event in self.get_events(): 64 | self.process_event(event) 65 | -------------------------------------------------------------------------------- /app/wyzecam/__init__.py: -------------------------------------------------------------------------------- 1 | # type: ignore[attr-defined] 2 | """Python package for communicating with wyze cameras over the local network.""" 3 | 4 | # This is a modified library based on kroo/wyzecam v1.2.0: https://github.com/kroo/wyzecam 5 | 6 | # Copyright (c) 2021 kroo 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the "Software"), to deal 9 | # in the Software without restriction, including without limitation the rights 10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | # copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | 14 | # The above copyright notice and this permission notice shall be included in all 15 | # copies or substantial portions of the Software. 16 | 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 18 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 19 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 20 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 21 | # DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 22 | # OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | 25 | 26 | try: 27 | from importlib.metadata import PackageNotFoundError, version 28 | except ImportError: # pragma: no cover 29 | from importlib_metadata import PackageNotFoundError, version 30 | 31 | try: 32 | __version__ = version(__name__) 33 | except PackageNotFoundError: # pragma: no cover 34 | __version__ = "unknown" 35 | 36 | from wyzecam.api import get_camera_list, get_user_info, login, refresh_token 37 | from wyzecam.api_models import WyzeAccount, WyzeCamera, WyzeCredential, clean_name 38 | from wyzecam.iotc import WyzeIOTC, WyzeIOTCSession, WyzeIOTCSessionState 39 | from wyzecam.tutk import tutk_protocol 40 | from wyzecam.tutk.tutk import TutkError 41 | -------------------------------------------------------------------------------- /app/wyzecam/api.py: -------------------------------------------------------------------------------- 1 | import hmac 2 | import json 3 | import time 4 | import urllib.parse 5 | import uuid 6 | from datetime import datetime 7 | from hashlib import md5 8 | from os import getenv 9 | from typing import Any, Optional 10 | 11 | from requests import PreparedRequest, Response, get, post 12 | from wyzecam.api_models import WyzeAccount, WyzeCamera, WyzeCredential 13 | 14 | IOS_VERSION = getenv("IOS_VERSION") 15 | APP_VERSION = getenv("APP_VERSION") 16 | SCALE_USER_AGENT = f"Wyze/{APP_VERSION} (iPhone; iOS {IOS_VERSION}; Scale/3.00)" 17 | AUTH_API = "https://auth-prod.api.wyze.com" 18 | WYZE_API = "https://api.wyzecam.com/app" 19 | CLOUD_API = "https://app-core.cloud.wyze.com/app" 20 | SC_SV = { 21 | "default": { 22 | "sc": "9f275790cab94a72bd206c8876429f3c", 23 | "sv": "e1fe392906d54888a9b99b88de4162d7", 24 | }, 25 | "run_action": { 26 | "sc": "01dd431d098546f9baf5233724fa2ee2", 27 | "sv": "2c0edc06d4c5465b8c55af207144f0d9", 28 | }, 29 | "get_device_Info": { 30 | "sc": "01dd431d098546f9baf5233724fa2ee2", 31 | "sv": "0bc2c3bedf6c4be688754c9ad42bbf2e", 32 | }, 33 | "get_event_list": { 34 | "sc": "9f275790cab94a72bd206c8876429f3c", 35 | "sv": "782ced6909a44d92a1f70d582bbe88be", 36 | }, 37 | "set_device_Info": { 38 | "sc": "01dd431d098546f9baf5233724fa2ee2", 39 | "sv": "e8e1db44128f4e31a2047a8f5f80b2bd", 40 | }, 41 | } 42 | APP_KEY = {"9319141212m2ik": "wyze_app_secret_key_132"} 43 | 44 | 45 | class AccessTokenError(Exception): 46 | pass 47 | 48 | 49 | class RateLimitError(Exception): 50 | def __init__(self, resp: Response): 51 | self.remaining: int = self.parse_remaining(resp) 52 | reset_by: str = resp.headers.get("X-RateLimit-Reset-By", "") 53 | self.reset_by: int = self.get_reset_time(reset_by) 54 | super().__init__(f"{self.remaining} requests remaining until {reset_by}") 55 | 56 | @staticmethod 57 | def parse_remaining(resp: Response) -> int: 58 | try: 59 | return int(resp.headers.get("X-RateLimit-Remaining", 0)) 60 | except Exception: 61 | return 0 62 | 63 | @staticmethod 64 | def get_reset_time(reset_by: str) -> int: 65 | ts_format = "%a %b %d %H:%M:%S %Z %Y" 66 | try: 67 | return int(datetime.strptime(reset_by, ts_format).timestamp()) 68 | except Exception: 69 | return 0 70 | 71 | 72 | class WyzeAPIError(Exception): 73 | def __init__(self, code, msg: str, req: PreparedRequest): 74 | self.code = code 75 | self.msg = msg 76 | super().__init__(f"{code=} {msg=} method={req.method} path={req.path_url}") 77 | 78 | 79 | def login( 80 | email: str, password: str, api_key: str, key_id: str, phone_id: Optional[str] = None 81 | ) -> WyzeCredential: 82 | """Authenticate with Wyze. 83 | 84 | This method calls out to the `/user/login` endpoint of 85 | `auth-prod.api.wyze.com` (using https), and retrieves an access token 86 | necessary to retrieve other information from the wyze server. 87 | 88 | :param email: Email address used to log into wyze account 89 | :param password: Password used to log into wyze account. This is used to 90 | authenticate with the wyze API server, and return a credential. 91 | :param phone_id: the ID of the device to emulate when talking to wyze. This is 92 | safe to leave as None (in which case a random phone id will be 93 | generated) 94 | 95 | :returns: a [WyzeCredential][wyzecam.api.WyzeCredential] with the access information, suitable 96 | for passing to [get_user_info()][wyzecam.api.get_user_info], or 97 | [get_camera_list()][wyzecam.api.get_camera_list]. 98 | """ 99 | phone_id = phone_id or str(uuid.uuid4()) 100 | headers = _headers(phone_id, key_id=key_id, api_key=api_key) 101 | payload = {"email": email.strip(), "password": hash_password(password)} 102 | 103 | resp = post(f"{AUTH_API}/api/user/login", json=payload, headers=headers) 104 | resp_json = validate_resp(resp) 105 | resp_json["phone_id"] = phone_id 106 | 107 | return WyzeCredential.model_validate(resp_json) 108 | 109 | 110 | def refresh_token(auth_info: WyzeCredential) -> WyzeCredential: 111 | """Refresh Auth Token. 112 | 113 | This method calls out to the `/app/user/refresh_token` endpoint of 114 | `api.wyze.com` (using https), and renews the access token necessary 115 | to retrieve other information from the wyze server. 116 | 117 | :param auth_info: the result of a [`login()`][wyzecam.api.login] call. 118 | :returns: a [WyzeCredential][wyzecam.api.WyzeCredential] with the access information, suitable 119 | for passing to [get_user_info()][wyzecam.api.get_user_info], or 120 | [get_camera_list()][wyzecam.api.get_camera_list]. 121 | 122 | """ 123 | payload = _payload(auth_info) 124 | payload["refresh_token"] = auth_info.refresh_token 125 | 126 | resp = post(f"{WYZE_API}/user/refresh_token", json=payload, headers=_headers()) 127 | 128 | resp_json = validate_resp(resp) 129 | resp_json["user_id"] = auth_info.user_id 130 | resp_json["phone_id"] = auth_info.phone_id 131 | 132 | return WyzeCredential.model_validate(resp_json) 133 | 134 | 135 | def get_user_info(auth_info: WyzeCredential) -> WyzeAccount: 136 | """Get Wyze Account Information. 137 | 138 | This method calls out to the `/app/user/get_user_info` 139 | endpoint of `api.wyze.com` (using https), and retrieves the 140 | account details of the authenticated user. 141 | 142 | :param auth_info: the result of a [`login()`][wyzecam.api.login] call. 143 | :returns: a [WyzeAccount][wyzecam.api.WyzeAccount] with the user's info, suitable 144 | for passing to [`WyzeIOTC.connect_and_auth()`][wyzecam.iotc.WyzeIOTC.connect_and_auth]. 145 | 146 | """ 147 | resp = post( 148 | f"{WYZE_API}/user/get_user_info", json=_payload(auth_info), headers=_headers() 149 | ) 150 | 151 | resp_json = validate_resp(resp) 152 | resp_json["phone_id"] = auth_info.phone_id 153 | 154 | return WyzeAccount.model_validate(resp_json) 155 | 156 | 157 | def get_homepage_object_list(auth_info: WyzeCredential) -> dict[str, Any]: 158 | """Get all homepage objects.""" 159 | resp = post( 160 | f"{WYZE_API}/v2/home_page/get_object_list", 161 | json=_payload(auth_info), 162 | headers=_headers(), 163 | ) 164 | 165 | return validate_resp(resp) 166 | 167 | 168 | def get_camera_list(auth_info: WyzeCredential) -> list[WyzeCamera]: 169 | """Return a list of all cameras on the account.""" 170 | data = get_homepage_object_list(auth_info) 171 | result = [] 172 | for device in data["device_list"]: 173 | if device["product_type"] != "Camera": 174 | continue 175 | 176 | device_params = device.get("device_params", {}) 177 | p2p_id: Optional[str] = device_params.get("p2p_id") 178 | p2p_type: Optional[int] = device_params.get("p2p_type") 179 | ip: Optional[str] = device_params.get("ip") 180 | enr: Optional[str] = device.get("enr") 181 | mac: Optional[str] = device.get("mac") 182 | product_model: Optional[str] = device.get("product_model") 183 | nickname: Optional[str] = device.get("nickname") 184 | timezone_name: Optional[str] = device.get("timezone_name") 185 | firmware_ver: Optional[str] = device.get("firmware_ver") 186 | dtls: Optional[int] = device_params.get("dtls") 187 | parent_dtls: Optional[int] = device_params.get("main_device_dtls") 188 | parent_enr: Optional[str] = device.get("parent_device_enr") 189 | parent_mac: Optional[str] = device.get("parent_device_mac") 190 | thumbnail: Optional[str] = device_params.get("camera_thumbnails").get( 191 | "thumbnails_url" 192 | ) 193 | 194 | if not mac: 195 | continue 196 | if not product_model: 197 | continue 198 | 199 | result.append( 200 | WyzeCamera( 201 | p2p_id=p2p_id, 202 | p2p_type=p2p_type, 203 | ip=ip, 204 | enr=enr, 205 | mac=mac, 206 | product_model=product_model, 207 | nickname=nickname, 208 | timezone_name=timezone_name, 209 | firmware_ver=firmware_ver, 210 | dtls=dtls, 211 | parent_dtls=parent_dtls, 212 | parent_enr=parent_enr, 213 | parent_mac=parent_mac, 214 | thumbnail=thumbnail, 215 | ) 216 | ) 217 | return result 218 | 219 | 220 | def run_action(auth_info: WyzeCredential, camera: WyzeCamera, action: str): 221 | """Send run_action commands to the camera.""" 222 | payload = dict( 223 | _payload(auth_info, "run_action"), 224 | action_params={}, 225 | action_key=action, 226 | instance_id=camera.mac, 227 | provider_key=camera.product_model, 228 | custom_string="", 229 | ) 230 | resp = post(f"{WYZE_API}/v2/auto/run_action", json=payload, headers=_headers()) 231 | 232 | return validate_resp(resp) 233 | 234 | 235 | def post_device( 236 | auth_info: WyzeCredential, endpoint: str, params: dict, api_version: int = 1 237 | ) -> dict: 238 | """Post data to the Wyze device API.""" 239 | api_endpoints = {1: WYZE_API, 2: f"{WYZE_API}/v2", 4: f"{CLOUD_API}/v4"} 240 | device_url = f"{api_endpoints.get(api_version)}/device/{endpoint}" 241 | 242 | if api_version == 4: 243 | payload = sort_dict(params) 244 | headers = sign_payload(auth_info, "9319141212m2ik", payload) 245 | resp = post(device_url, data=payload, headers=headers) 246 | else: 247 | params |= _payload(auth_info, endpoint) 248 | resp = post(device_url, json=params, headers=_headers()) 249 | 250 | return validate_resp(resp) 251 | 252 | 253 | def get_cam_webrtc(auth_info: WyzeCredential, mac_id: str) -> dict: 254 | """Get webrtc for camera.""" 255 | if not auth_info.access_token: 256 | raise AccessTokenError() 257 | 258 | ui_headers = _headers() 259 | ui_headers["content-type"] = "application/json" 260 | ui_headers["authorization"] = f"Bearer {auth_info.access_token}" 261 | resp = get( 262 | f"https://webrtc.api.wyze.com/signaling/device/{mac_id}?use_trickle=true", 263 | headers=ui_headers, 264 | ) 265 | resp_json = validate_resp(resp) 266 | for s in resp_json["results"]["servers"]: 267 | if "url" in s: 268 | s["urls"] = s.pop("url") 269 | 270 | return { 271 | "ClientId": auth_info.phone_id, 272 | "signalingUrl": urllib.parse.unquote(resp_json["results"]["signalingUrl"]), 273 | "servers": resp_json["results"]["servers"], 274 | } 275 | 276 | 277 | def validate_resp(resp: Response) -> dict: 278 | if int(resp.headers.get("X-RateLimit-Remaining", 100)) <= 10: 279 | raise RateLimitError(resp) 280 | 281 | resp_json = resp.json() 282 | resp_code = str(resp_json.get("code", resp_json.get("errorCode", 0))) 283 | if resp_code == "2001": 284 | raise AccessTokenError() 285 | 286 | if resp_code not in {"1", "0"}: 287 | msg = resp_json.get("msg", resp_json.get("description", resp_code)) 288 | raise WyzeAPIError(resp_code, msg, resp.request) 289 | 290 | resp.raise_for_status() 291 | 292 | return resp_json.get("data", resp_json) 293 | 294 | 295 | def _payload(auth_info: WyzeCredential, endpoint: str = "default") -> dict: 296 | values = SC_SV.get(endpoint, SC_SV["default"]) 297 | return { 298 | "sc": values["sc"], 299 | "sv": values["sv"], 300 | "app_ver": f"com.hualai.WyzeCam___{APP_VERSION}", 301 | "app_version": APP_VERSION, 302 | "app_name": "com.hualai.WyzeCam", 303 | "phone_system_type": 1, 304 | "ts": int(time.time() * 1000), 305 | "access_token": auth_info.access_token, 306 | "phone_id": auth_info.phone_id, 307 | } 308 | 309 | 310 | def _headers( 311 | phone_id: Optional[str] = None, 312 | key_id: Optional[str] = None, 313 | api_key: Optional[str] = None, 314 | ) -> dict[str, str]: 315 | """Format headers for api requests. 316 | 317 | key_id and api_key are only needed when making a request to the /api/user/login endpoint. 318 | 319 | phone_id is required for other login-related endpoints. 320 | """ 321 | if not phone_id: 322 | return { 323 | "user-agent": SCALE_USER_AGENT, 324 | "appversion": f"{APP_VERSION}", 325 | "env": "prod", 326 | } 327 | 328 | if key_id and api_key: 329 | return { 330 | "apikey": api_key, 331 | "keyid": key_id, 332 | "user-agent": f"docker-wyze-bridge/{getenv('VERSION')}", 333 | } 334 | 335 | return { 336 | "x-api-key": "WMXHYf79Nr5gIlt3r0r7p9Tcw5bvs6BB4U8O8nGJ", 337 | "phone-id": phone_id, 338 | "user-agent": f"wyze_ios_{APP_VERSION}", 339 | } 340 | 341 | 342 | def sign_payload(auth_info: WyzeCredential, app_id: str, payload: str) -> dict: 343 | if not auth_info.access_token: 344 | raise AccessTokenError() 345 | 346 | return { 347 | "content-type": "application/json", 348 | "phoneid": auth_info.phone_id, 349 | "user-agent": f"wyze_ios_{APP_VERSION}", 350 | "appinfo": f"wyze_ios_{APP_VERSION}", 351 | "appversion": APP_VERSION, 352 | "access_token": auth_info.access_token, 353 | "appid": app_id, 354 | "env": "prod", 355 | "signature2": sign_msg(app_id, payload, auth_info.access_token), 356 | } 357 | 358 | 359 | def hash_password(password: str) -> str: 360 | """Run hashlib.md5() algorithm 3 times.""" 361 | encoded = password.strip() 362 | 363 | for ex in {"hashed:", "md5:"}: 364 | if encoded.lower().startswith(ex): 365 | return encoded[len(ex) :] 366 | 367 | for _ in range(3): 368 | encoded = md5(encoded.encode("ascii")).hexdigest() # nosec 369 | return encoded 370 | 371 | 372 | def sort_dict(payload: dict) -> str: 373 | return json.dumps(payload, separators=(",", ":"), sort_keys=True) 374 | 375 | 376 | def sign_msg(app_id: str, msg: str | dict, token: str = "") -> str: 377 | secret = getenv(app_id, APP_KEY.get(app_id, app_id)) 378 | key = md5((token + secret).encode()).hexdigest().encode() 379 | if isinstance(msg, dict): 380 | msg = sort_dict(msg) 381 | 382 | return hmac.new(key, msg.encode(), md5).hexdigest() 383 | -------------------------------------------------------------------------------- /app/wyzecam/api_models.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import uuid 4 | from typing import Any, Optional 5 | 6 | from pydantic import BaseModel 7 | 8 | MODEL_NAMES = { 9 | "WYZEC1": "V1", 10 | "WYZEC1-JZ": "V2", 11 | "WYZE_CAKP2JFUS": "V3", 12 | "HL_CAM4": "V4", 13 | "HL_CAM3P": "V3 Pro", 14 | "WYZECP1_JEF": "Pan", 15 | "HL_PAN2": "Pan V2", 16 | "HL_PAN3": "Pan V3", 17 | "HL_PANP": "Pan Pro", 18 | "HL_CFL2": "Floodlight V2", 19 | "WYZEDB3": "Doorbell", 20 | "HL_DB2": "Doorbell V2", 21 | "GW_BE1": "Doorbell Pro", 22 | "AN_RDB1": "Doorbell Pro 2", 23 | "GW_GC1": "OG", 24 | "GW_GC2": "OG 3X", 25 | "WVOD1": "Outdoor", 26 | "HL_WCO2": "Outdoor V2", 27 | "AN_RSCW": "Battery Cam Pro", 28 | "LD_CFP": "Floodlight Pro", 29 | } 30 | 31 | # These cameras don't seem to support WebRTC 32 | NO_WEBRTC = { 33 | "WYZEC1", 34 | "HL_PANP", 35 | "WVOD1", 36 | "HL_WCO2", 37 | "AN_RSCW", 38 | "WYZEDB3", 39 | "HL_DB2", 40 | "GW_BE1", 41 | "AN_RDB1", 42 | } 43 | 44 | 45 | # known 2k cameras 46 | PRO_CAMS = {"HL_CAM3P", "HL_PANP", "HL_CAM4", "HL_DB2", "HL_CFL2"} 47 | 48 | PAN_CAMS = {"WYZECP1_JEF", "HL_PAN2", "HL_PAN3", "HL_PANP"} 49 | 50 | BATTERY_CAMS = {"WVOD1", "HL_WCO2", "AN_RSCW"} 51 | 52 | AUDIO_16k = {"WYZE_CAKP2JFUS", "HL_CAM3P", "MODEL_HL_PANP"} 53 | # Doorbells 54 | DOORBELL = {"WYZEDB3", "HL_DB2"} 55 | 56 | FLOODLIGHT_CAMS = {"HL_CFL2"} 57 | 58 | VERTICAL_CAMS = {"WYZEDB3", "GW_BE1", "AN_RDB1"} 59 | # Minimum known firmware version that supports multiple streams 60 | SUBSTREAM_FW = {"WYZEC1-JZ": "4.9.9", "WYZE_CAKP2JFUS": "4.36.10", "HL_CAM3P": "4.58.0"} 61 | 62 | RTSP_FW = {"4.19.", "4.20.", "4.28.", "4.29.", "4.61."} 63 | 64 | 65 | class WyzeCredential(BaseModel): 66 | """Authenticated credentials; see [wyzecam.api.login][]. 67 | 68 | :var access_token: Access token used to authenticate other API calls 69 | :var refresh_token: Refresh token used to refresh the access_token if it expires 70 | :var user_id: Wyze user id of the authenticated user 71 | :var mfa_options: Additional options for 2fa support 72 | :var mfa_details: Additional details for 2fa support 73 | :var sms_session_id: Additional details for SMS support 74 | :var email_session_id: Additional details for email support 75 | :var phone_id: The phone id passed to [login()][wyzecam.api.login] 76 | """ 77 | 78 | access_token: Optional[str] = None 79 | refresh_token: Optional[str] = None 80 | user_id: Optional[str] = None 81 | mfa_options: Optional[list] = None 82 | mfa_details: Optional[dict[str, Any]] = None 83 | sms_session_id: Optional[str] = None 84 | email_session_id: Optional[str] = None 85 | phone_id: Optional[str] = str(uuid.uuid4()) 86 | 87 | 88 | class WyzeAccount(BaseModel): 89 | """User profile information; see [wyzecam.api.get_user_info][]. 90 | 91 | :var phone_id: The phone id passed to [login()][wyzecam.api.login] 92 | :var logo: URL to a profile photo of the user 93 | :var nickname: nickname of the user 94 | :var email: email of the user 95 | :var user_code: code of the user 96 | :var user_center_id: center id of the user 97 | :var open_user_id: open id of the user (used for authenticating with newer firmwares; important!) 98 | """ 99 | 100 | phone_id: str 101 | logo: str 102 | nickname: str 103 | email: str 104 | user_code: str 105 | user_center_id: str 106 | open_user_id: str 107 | 108 | 109 | class WyzeCamera(BaseModel): 110 | """Wyze camera device information; see [wyzecam.api.get_camera_list][]. 111 | 112 | :var p2p_id: the p2p id of the camera, used for identifying the camera to tutk. 113 | :var enr: the enr of the camera, used for signing challenge requests from cameras during auth. 114 | :var mac: the mac address of the camera. 115 | :var product_model: the product model (or type) of camera 116 | :var camera_info: populated as a result of authenticating with a camera 117 | using a [WyzeIOTCSession](../../iotc_session/). 118 | :var nickname: the user specified 'nickname' of the camera 119 | :var timezone_name: the timezone of the camera 120 | 121 | """ 122 | 123 | p2p_id: Optional[str] 124 | p2p_type: Optional[int] 125 | ip: Optional[str] 126 | enr: Optional[str] 127 | mac: str 128 | product_model: str 129 | camera_info: Optional[dict[str, Any]] = None 130 | nickname: Optional[str] 131 | timezone_name: Optional[str] 132 | firmware_ver: Optional[str] 133 | dtls: Optional[int] 134 | parent_dtls: Optional[int] 135 | parent_enr: Optional[str] 136 | parent_mac: Optional[str] 137 | thumbnail: Optional[str] 138 | 139 | def set_camera_info(self, info: dict[str, Any]) -> None: 140 | # Called internally as part of WyzeIOTC.connect_and_auth() 141 | self.camera_info = info 142 | 143 | @property 144 | def name_uri(self) -> str: 145 | """Return a URI friendly name by removing special characters and spaces.""" 146 | uri_sep = "-" 147 | if os.getenv("URI_SEPARATOR") in {"-", "_", "#"}: 148 | uri_sep = os.getenv("URI_SEPARATOR", uri_sep) 149 | uri = clean_name(self.nickname or self.mac, uri_sep).lower() 150 | if os.getenv("URI_MAC", "").lower() == "true" and (self.mac or self.parent_mac): 151 | uri += uri_sep + (self.mac or self.parent_mac or "")[-4:] 152 | return uri 153 | 154 | @property 155 | def model_name(self) -> str: 156 | return MODEL_NAMES.get(self.product_model, self.product_model) 157 | 158 | @property 159 | def webrtc_support(self) -> bool: 160 | """Check if camera model is known to support WebRTC.""" 161 | return self.product_model not in NO_WEBRTC 162 | 163 | @property 164 | def is_2k(self) -> bool: 165 | return self.product_model in PRO_CAMS or self.model_name.endswith("Pro") 166 | 167 | @property 168 | def is_floodlight(self) -> bool: 169 | return self.product_model in FLOODLIGHT_CAMS 170 | 171 | @property 172 | def default_sample_rate(self) -> int: 173 | return 16000 if self.product_model in AUDIO_16k else 8000 174 | 175 | @property 176 | def is_gwell(self) -> bool: 177 | return self.product_model.startswith("GW_") 178 | 179 | @property 180 | def is_battery(self) -> bool: 181 | return self.product_model in BATTERY_CAMS 182 | 183 | @property 184 | def is_vertical(self) -> bool: 185 | return self.product_model in VERTICAL_CAMS 186 | 187 | @property 188 | def is_pan_cam(self) -> bool: 189 | return self.product_model in PAN_CAMS 190 | 191 | @property 192 | def can_substream(self) -> bool: 193 | if self.rtsp_fw: 194 | return False 195 | min_ver = SUBSTREAM_FW.get(self.product_model) 196 | return is_min_version(self.firmware_ver, min_ver) 197 | 198 | @property 199 | def rtsp_fw(self) -> bool: 200 | return bool(self.firmware_ver and self.firmware_ver[:5] in RTSP_FW) 201 | 202 | 203 | def clean_name(name: str, uri_sep: str = "_") -> str: 204 | """Return a URI friendly name by removing special characters and spaces.""" 205 | return ( 206 | re.sub(r"[^\-\w+]", "", name.strip().replace(" ", uri_sep)) 207 | .encode("ascii", "ignore") 208 | .decode() 209 | ).upper() 210 | 211 | 212 | def is_min_version(version: Optional[str], min_version: Optional[str]) -> bool: 213 | if not version or not min_version: 214 | return False 215 | version_parts = list(map(int, version.split("."))) 216 | min_version_parts = list(map(int, min_version.split("."))) 217 | return (version_parts >= min_version_parts) or ( 218 | version_parts == min_version_parts and version >= min_version 219 | ) 220 | -------------------------------------------------------------------------------- /app/wyzecam/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrlt8/docker-wyze-bridge/bf893749b748f142199c9bce14fac44f8a661d6e/app/wyzecam/py.typed -------------------------------------------------------------------------------- /app/wyzecam/tutk/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrlt8/docker-wyze-bridge/bf893749b748f142199c9bce14fac44f8a661d6e/app/wyzecam/tutk/__init__.py -------------------------------------------------------------------------------- /app/wyzecam/tutk/tutk_ioctl_mux.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import logging 3 | import threading 4 | import time 5 | from collections import defaultdict 6 | from ctypes import CDLL, c_int 7 | from queue import Empty, Queue 8 | from typing import Any, DefaultDict, Optional, Union 9 | 10 | from . import tutk, tutk_protocol 11 | from .tutk_protocol import TutkWyzeProtocolMessage 12 | 13 | STOP_SENTINEL = object() 14 | CONTROL_CHANNEL = "CONTROL" 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | class TutkIOCtrlFuture: 20 | """ 21 | Holds the result of a message sent over a TutkIOCtrlMux; a TutkIOCtrlFuture 22 | is returned by `[TutkIOCtrlMux.send_ioctl][wyzecam.tutk.tutk_ioctrl_mox.TutkIOCtrlMux.send_ioctl]`, 23 | and represents the value of a future response from the camera. The actual contents 24 | of this response should be retrieved by calling `result()`, below. 25 | 26 | :var req: The message sent to the camera that we are waiting for a response from 27 | :var errcode: The resultant error code associated with this response 28 | :var resp_protocol: The 2-byte protocol version of the header of the response 29 | :var resp_data: The raw message sent from the camera to the client 30 | """ 31 | 32 | def __init__( 33 | self, 34 | req: TutkWyzeProtocolMessage, 35 | queue: Optional[Queue[Union[object, tuple[int, int, int, bytes]]]] = None, 36 | errcode: Optional[c_int] = None, 37 | ): 38 | self.req: TutkWyzeProtocolMessage = req 39 | self.queue = queue 40 | self.expected_response_code = req.expected_response_code 41 | self.errcode: Optional[c_int] = errcode 42 | self.io_ctl_type: Optional[int] = None 43 | self.resp_protocol: Optional[int] = None 44 | self.resp_data: Optional[bytes] = None 45 | 46 | def result(self, block: bool = True, timeout: int = 10000) -> Optional[Any]: 47 | """ 48 | Wait until the camera has responded to our message, and return the result. 49 | 50 | :param block: wait until the camera has responded, or the timeout has been reached. 51 | if False, returns immediately if we have already received a response, 52 | otherwise raises queue.Empty. 53 | :param timeout: the maximum number of milliseconds to wait for the response 54 | from the camera, after which queue.Empty will be raised. 55 | :returns: the result of [`TutkWyzeProtocolMessage.parse_response`][wyzecam.tutk.tutk_protocol.TutkWyzeProtocolMessage.parse_response] 56 | for the appropriate message. 57 | """ 58 | if self.resp_data is not None: 59 | return self.req.parse_response(self.resp_data) 60 | if self.errcode: 61 | raise tutk.TutkError(self.errcode) 62 | if self.expected_response_code is None: 63 | logger.warning("no response code!") 64 | return 65 | assert self.queue is not None, "Future created without error nor queue!" 66 | 67 | msg = self.queue.get(block=block, timeout=timeout) 68 | assert isinstance(msg, tuple), "Expected a iotc result, instead got sentinel!" 69 | actual_len, io_ctl_type, resp_protocol, data = msg 70 | 71 | if actual_len < 0: 72 | raise tutk.TutkError(self.errcode) 73 | 74 | self.io_ctl_type = io_ctl_type 75 | self.resp_protocol = resp_protocol 76 | self.resp_data = data 77 | 78 | return self.req.parse_response(data) 79 | 80 | def __repr__(self): 81 | errcode_str = f" errcode={self.errcode}" if self.errcode else "" 82 | data_str = f" resp_data={repr(self.resp_data)}" if self.resp_data else "" 83 | return f"" 84 | 85 | 86 | class TutkIOCtrlMux: 87 | """ 88 | An "IO Ctrl" interface for sending and receiving data over a control channel 89 | built into an IOTC session with a particular device. 90 | 91 | Use this to send and receive configuration data from the camera. There are 92 | many, many commands supported by the wyze camera over this interface, though 93 | just a fraction of them have been reverse engineered at this point. See 94 | [TutkWyzeProtocolMessage][wyzecam.tutk.tutk_protocol.TutkWyzeProtocolMessage] 95 | and its subclasses for the supported commands. 96 | 97 | This channel is used to authenticate the client with the camera prior to 98 | streaming audio or video data. 99 | 100 | See: [wyzecam.iotc.WyzeIOTCSession.iotctrl_mux][] 101 | """ 102 | 103 | __slots__ = "tutk_platform_lib", "av_chan_id", "queues", "listener", "block" 104 | _context_lock = threading.Lock() 105 | 106 | def __init__( 107 | self, tutk_platform_lib: CDLL, av_chan_id: c_int, block: bool = True 108 | ) -> None: 109 | """Initialize the mux channel. 110 | 111 | :param tutk_platform_lib: the underlying c library used to communicate with the wyze 112 | device; see [tutk.load_library][wyzecam.tutk.tutk.load_library]. 113 | :param av_chan_id: the channel id of the session this mux is created on. 114 | """ 115 | self.tutk_platform_lib = tutk_platform_lib 116 | self.av_chan_id = av_chan_id 117 | self.queues: DefaultDict[ 118 | Union[str, int], Queue[Union[object, tuple[int, int, int, bytes]]] 119 | ] = defaultdict(Queue) 120 | self.listener = TutkIOCtrlMuxListener( 121 | tutk_platform_lib, av_chan_id, self.queues 122 | ) 123 | self.block = block 124 | 125 | def start_listening(self) -> None: 126 | """Start a separate thread listening for responses from the camera. 127 | 128 | This is generally called by using the TutkIOCtrlMux as a context manager: 129 | 130 | ```python 131 | with session.ioctrl_mux() as mux: 132 | ... 133 | ``` 134 | 135 | If this method is called explicitly, remember to call `stop_listening` when 136 | finished. 137 | 138 | See: [wyzecam.tutk.tutk_ioctl_mux.TutkIOCtrlMux.stop_listening][] 139 | """ 140 | timeout = {"timeout": 2} if self.block else {} 141 | if not TutkIOCtrlMux._context_lock.acquire(blocking=self.block, **timeout): 142 | raise tutk.TutkError(-20021) 143 | self.listener.start() 144 | 145 | def stop_listening(self) -> None: 146 | """ 147 | Shuts down the separate thread used for listening for responses to the camera 148 | 149 | See: [wyzecam.tutk.tutk_ioctl_mux.TutkIOCtrlMux.start_listening][] 150 | """ 151 | self.queues[CONTROL_CHANNEL].put(STOP_SENTINEL) 152 | self.listener.join() 153 | TutkIOCtrlMux._context_lock.release() 154 | 155 | def __enter__(self): 156 | self.start_listening() 157 | return self 158 | 159 | def __exit__(self, exc_type, exc_val, exc_tb): 160 | self.stop_listening() 161 | 162 | def send_ioctl( 163 | self, 164 | msg: TutkWyzeProtocolMessage, 165 | ctrl_type: int = tutk.IOTYPE_USER_DEFINED_START, 166 | ) -> TutkIOCtrlFuture: 167 | """ 168 | Send a [TutkWyzeProtocolMessage][wyzecam.tutk.tutk_protocol.TutkWyzeProtocolMessage] 169 | to the camera. 170 | 171 | This should be called after the listener has been started, by using the mux as a context manager: 172 | 173 | ```python 174 | with session.ioctrl_mux() as mux: 175 | result = mux.send_ioctl(msg) 176 | ``` 177 | 178 | :param msg: The message to send to the client. See 179 | [tutk_protocol.py Commands](../tutk_protocol_commands/) 180 | :param ctrl_type: used internally by the iotc library, should always be 181 | `tutk.IOTYPE_USER_DEFINED_START`. 182 | 183 | :returns: a future promise of a response from the camera. See [wyzecam.tutk.tutk_ioctl_mux.TutkIOCtrlFuture][] 184 | """ 185 | encoded_msg = msg.encode() 186 | encoded_msg_header = tutk_protocol.TutkWyzeProtocolHeader.from_buffer_copy( 187 | encoded_msg[0:16] 188 | ) 189 | logger.debug("SEND %s %s %s", msg, encoded_msg_header, encoded_msg[16:]) 190 | errcode = tutk.av_send_io_ctrl( 191 | self.tutk_platform_lib, self.av_chan_id, ctrl_type, encoded_msg 192 | ) 193 | if errcode: 194 | return TutkIOCtrlFuture(msg, errcode=errcode) 195 | if not msg.expected_response_code: 196 | logger.warning("no expected response code found") 197 | return TutkIOCtrlFuture(msg) 198 | 199 | return TutkIOCtrlFuture(msg, self.queues[msg.expected_response_code]) 200 | 201 | def waitfor( 202 | self, 203 | futures: Union[TutkIOCtrlFuture, list[TutkIOCtrlFuture]], 204 | timeout: Optional[int] = None, 205 | ) -> Union[Any, list[Any]]: 206 | """Wait for the responses of one or more `TutkIOCtrlFuture`s. 207 | 208 | ```python 209 | with session.ioctrl_mux() as mux: 210 | f1 = mux.send_ioctl(msg) 211 | f2 = mux.send_ioctl(msg2) 212 | 213 | resp1, resp2 = mux.waitfor([f1, f2]) 214 | ``` 215 | 216 | This allows you to wait for a set of `TutkIOCtrlFuture`s to respond in 217 | any order, and allows you to send multiple commands to the camera without 218 | waiting for each one to return before sending another. 219 | 220 | If you are sending one command at a time, consider using 221 | `TutkIOCtrlFuture.result()` directly: 222 | 223 | ```python 224 | with session.ioctrl_mux() as mux: 225 | f1 = mux.send_ioctl(msg) 226 | resp1 = f1.result() 227 | f2 = mux.send_ioctl(msg2) 228 | resp2 = f2.result() 229 | ``` 230 | """ 231 | unwrap_single_item = False 232 | if isinstance(futures, TutkIOCtrlFuture): 233 | futures = [futures] 234 | unwrap_single_item = True 235 | results = [None] * len(futures) 236 | start = time.time() 237 | while (timeout is None or time.time() - start <= timeout) and any( 238 | result is None for result in results 239 | ): 240 | all_success = True 241 | for i, future in enumerate(futures): 242 | if results[i] is not None: 243 | continue 244 | 245 | try: 246 | result = future.result(block=False) 247 | results[i] = result 248 | except Empty: 249 | all_success = False 250 | # if we don't get all of them this pass, wait a short period before checking again 251 | if not all_success: 252 | time.sleep(0.1) 253 | 254 | if unwrap_single_item: 255 | return results[0] 256 | else: 257 | return results 258 | 259 | 260 | class TutkIOCtrlMuxListener(threading.Thread): 261 | __slots__ = "tutk_platform_lib", "av_chan_id", "queues", "exception" 262 | 263 | def __init__( 264 | self, 265 | tutk_platform_lib: CDLL, 266 | av_chan_id: c_int, 267 | queues: DefaultDict[ 268 | Union[int, str], Queue[Union[object, tuple[int, int, int, bytes]]] 269 | ], 270 | ): 271 | super().__init__() 272 | self.tutk_platform_lib = tutk_platform_lib 273 | self.av_chan_id = av_chan_id 274 | self.queues = queues 275 | self.exception: Optional[tutk.TutkError] = None 276 | 277 | def join(self, timeout=None): 278 | super().join(timeout) 279 | if self.exception: 280 | raise self.exception 281 | 282 | def run(self) -> None: 283 | timeout_ms = 1000 284 | logger.debug(f"Now listening on channel id {self.av_chan_id}") 285 | 286 | while True: 287 | with contextlib.suppress(Empty): 288 | control_channel_command = self.queues[CONTROL_CHANNEL].get_nowait() 289 | if control_channel_command == STOP_SENTINEL: 290 | logger.debug(f"No longer listening on channel id {self.av_chan_id}") 291 | return 292 | actual_len, io_ctl_type, data = tutk.av_recv_io_ctrl( 293 | self.tutk_platform_lib, self.av_chan_id, timeout_ms 294 | ) 295 | if actual_len == tutk.AV_ER_TIMEOUT: 296 | continue 297 | elif actual_len == tutk.AV_ER_SESSION_CLOSE_BY_REMOTE: 298 | logger.warning("Connection closed by remote. Closing connection.") 299 | break 300 | elif actual_len == tutk.AV_ER_REMOTE_TIMEOUT_DISCONNECT: 301 | logger.warning("Connection closed because of no response from remote.") 302 | break 303 | elif actual_len < 0: 304 | self.exception = tutk.TutkError(actual_len) 305 | break 306 | 307 | header, payload = tutk_protocol.decode(data) 308 | logger.debug(f"RECV {header}: {repr(payload)}") 309 | 310 | self.queues[header.code].put( 311 | (actual_len, io_ctl_type, header.protocol, payload) 312 | ) 313 | -------------------------------------------------------------------------------- /docker-compose.ovpn.yml: -------------------------------------------------------------------------------- 1 | services: 2 | openvpn: 3 | image: dperson/openvpn-client 4 | container_name: openvpn 5 | restart: unless-stopped 6 | ports: # Configure all ports here: 7 | - 1935:1935 # RTMP 8 | - 8554:8554 # RTSP 9 | - 8888:8888 # HLS 10 | - 8889:8889 #WebRTC 11 | - 8189:8189/udp # WebRTC/ICE 12 | - 5000:5000 # WEB-UI 13 | dns: [8.8.8.8] 14 | cap_add: [NET_ADMIN] 15 | command: '-f /vpn/config.ovpn' 16 | volumes: 17 | # Set path to your ovpn config 18 | - ${PWD}/config.ovpn:/vpn/config.ovpn 19 | wyze-bridge: 20 | container_name: wyze-bridge 21 | restart: unless-stopped 22 | image: mrlt8/wyze-bridge:latest 23 | depends_on: [openvpn] 24 | network_mode: service:openvpn 25 | environment: 26 | # [OPTIONAL] Credentials can be set in the WebUI 27 | # API Key and ID can be obtained from the wyze dev portal: 28 | # https://developer-api-console.wyze.com/#/apikey/view 29 | - WYZE_EMAIL= 30 | - WYZE_PASSWORD= 31 | - API_ID= 32 | - API_KEY= 33 | # [OPTIONAL] IP Address of the host to enable WebRTC e.g.,: 34 | # - WB_IP=192.168.1.122 35 | # WebUI and Stream authentication: 36 | - WB_AUTH=True # Set to false to disable web and stream auth. 37 | # WB_USERNAME= 38 | # WB_PASSWORD= 39 | -------------------------------------------------------------------------------- /docker-compose.sample.yml: -------------------------------------------------------------------------------- 1 | services: 2 | wyze-bridge: 3 | container_name: wyze-bridge 4 | restart: unless-stopped 5 | image: mrlt8/wyze-bridge:latest 6 | ports: 7 | - 1935:1935 # RTMP 8 | - 8554:8554 # RTSP 9 | - 8888:8888 # HLS 10 | - 8889:8889 #WebRTC 11 | - 8189:8189/udp # WebRTC/ICE 12 | - 5000:5000 # WEB-UI 13 | environment: 14 | # [OPTIONAL] Credentials can be set in the WebUI 15 | # API Key and ID can be obtained from the wyze dev portal: 16 | # https://developer-api-console.wyze.com/#/apikey/view 17 | - WYZE_EMAIL= 18 | - WYZE_PASSWORD= 19 | - API_ID= 20 | - API_KEY= 21 | # [OPTIONAL] IP Address of the host to enable WebRTC e.g.,: 22 | # - WB_IP=192.168.1.122 23 | # WebUI and Stream authentication: 24 | - WB_AUTH=True # Set to false to disable web and stream auth. 25 | # WB_USERNAME= 26 | # WB_PASSWORD= -------------------------------------------------------------------------------- /docker-compose.tailscale.yml: -------------------------------------------------------------------------------- 1 | services: 2 | tailscale: 3 | image: tailscale/tailscale:latest 4 | container_name: tailscale 5 | hostname: wyze-bridge # For tailscale 6 | restart: unless-stopped 7 | ports: 8 | - 8554:8554 # RTSP 9 | - 8889:8889 #WebRTC 10 | - 8189:8189/udp # WebRTC/ICE 11 | - 5000:5000 # WEB-UI 12 | cap_add: [NET_ADMIN] 13 | environment: 14 | - TS_AUTHKEY=tskey-client-notAReal-OAuthClientSecret1Atawk 15 | - TS_EXTRA_ARGS=--accept-routes 16 | - TS_USERSPACE=false 17 | volumes: 18 | - /dev/net/tun:/dev/net/tun 19 | wyze-bridge: 20 | container_name: wyze-bridge 21 | restart: unless-stopped 22 | image: mrlt8/wyze-bridge:latest 23 | depends_on: [tailscale] 24 | network_mode: service:tailscale 25 | environment: 26 | # [OPTIONAL] Credentials can be set in the WebUI 27 | # API Key and ID can be obtained from the wyze dev portal: 28 | # https://developer-api-console.wyze.com/#/apikey/view 29 | - WYZE_EMAIL= 30 | - WYZE_PASSWORD= 31 | - API_ID= 32 | - API_KEY= 33 | # [OPTIONAL] IP Address of the host to enable WebRTC e.g.,: 34 | # - WB_IP=192.168.1.122 35 | # WebUI and Stream authentication: 36 | - WB_AUTH=True # Set to false to disable web and stream auth. 37 | # WB_USERNAME= 38 | # WB_PASSWORD= -------------------------------------------------------------------------------- /docker/.dockerignore: -------------------------------------------------------------------------------- 1 | **/.DS_Store 2 | Dockerfile* 3 | __pycache__ 4 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG BUILD 2 | ARG BUILD_DATE 3 | ARG GITHUB_SHA 4 | FROM python:3.12-slim-bookworm AS base 5 | 6 | FROM base AS builder 7 | ARG BUILD_DATE 8 | RUN apt-get update \ 9 | && apt-get install -y curl tar gcc \ 10 | && apt-get clean \ 11 | && rm -rf /var/lib/apt/lists/* 12 | COPY ../app/ /build/app/ 13 | RUN pip3 install --disable-pip-version-check --prefix=/build/usr/local -r /build/app/requirements.txt 14 | RUN echo "BUILDING IMAGE FOR $(uname -m)" && \ 15 | if [ "$(uname -m)" = "armv7l" ]; then \ 16 | LIB_ARCH=arm; FFMPEG_ARCH=arm32v7; MTX_ARCH=armv7; \ 17 | elif [ "$(uname -m)" = "aarch64" ]; then \ 18 | LIB_ARCH=arm64; FFMPEG_ARCH=aarch64; MTX_ARCH=arm64v8; \ 19 | else \ 20 | LIB_ARCH=amd64; FFMPEG_ARCH=x86_64; MTX_ARCH=amd64; \ 21 | fi && \ 22 | cd /build \ 23 | && . app/.env \ 24 | && mkdir -p tokens img \ 25 | && curl -SL https://github.com/homebridge/ffmpeg-for-homebridge/releases/latest/download/ffmpeg-alpine-${FFMPEG_ARCH}.tar.gz \ 26 | | tar xzf - -C . \ 27 | && curl -SL https://github.com/bluenviron/mediamtx/releases/download/v${MTX_TAG}/mediamtx_v${MTX_TAG}_linux_${MTX_ARCH}.tar.gz \ 28 | | tar xzf - -C app \ 29 | && cp app/lib/lib.${LIB_ARCH} usr/local/lib/libIOTCAPIs_ALL.so \ 30 | && rm -rf app/*.txt app/lib/ \ 31 | && if [ -z "${BUILD_DATE}" ]; then echo BUILD_DATE=$(date) > .build_date; else echo BUILD_DATE=${BUILD_DATE} > .build_date; fi 32 | 33 | 34 | FROM base 35 | ARG BUILD 36 | ARG GITHUB_SHA 37 | COPY --from=builder /build / 38 | ENV PYTHONUNBUFFERED=1 FLASK_APP=frontend BUILD=$BUILD GITHUB_SHA=$GITHUB_SHA 39 | WORKDIR /app 40 | CMD ["flask", "run", "--host=0.0.0.0"] -------------------------------------------------------------------------------- /docker/Dockerfile.hwaccel: -------------------------------------------------------------------------------- 1 | # Use the build args QSV=1 to build with intel drivers. 2 | ARG BUILD 3 | ARG BUILD_DATE 4 | ARG GITHUB_SHA 5 | ARG QSV 6 | FROM amd64/python:3.12-slim-bookworm AS base 7 | 8 | FROM base AS builder 9 | ARG QSV 10 | ARG BUILD_DATE 11 | RUN if [ -n "$QSV" ]; then echo 'deb http://deb.debian.org/debian bookworm main contrib non-free non-free-firmware' >/etc/apt/sources.list.d/debian-testing.list; fi \ 12 | && apt-get update \ 13 | && apt-get install -y curl tar xz-utils \ 14 | ${QSV:+i965-va-driver intel-media-va-driver-non-free libmfx1 libva-drm2 libx11-6 && apt-get install i965-va-driver-shaders} \ 15 | && apt-get clean \ 16 | && rm -rf /var/lib/apt/lists/* 17 | COPY /app/ /build/app/ 18 | RUN pip3 install --disable-pip-version-check --prefix=/build/usr/local -r /build/app/requirements.txt 19 | RUN cd /build \ 20 | && . app/.env \ 21 | && mkdir -p tokens img ${QSV:+usr/lib} \ 22 | && curl -SL https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-n6.1-latest-linux64-gpl-6.1.tar.xz \ 23 | | tar -Jxf - --strip-components=1 -C usr/local --wildcards '*ffmpeg' \ 24 | && curl -SL https://github.com/bluenviron/mediamtx/releases/download/v${MTX_TAG}/mediamtx_v${MTX_TAG}_linux_amd64.tar.gz \ 25 | | tar -xzf - -C app --wildcards 'mediamtx*' \ 26 | && cp app/lib/lib.amd64 usr/local/lib/libIOTCAPIs_ALL.so \ 27 | && if [ -n "$QSV" ]; then cp -R /usr/lib/x86_64-linux-gnu/ usr/lib/; fi \ 28 | && rm -rf app/*.txt app/lib/ \ 29 | && if [ -z "${BUILD_DATE}" ]; then echo BUILD_DATE=$(date) > .build_date; else echo BUILD_DATE=${BUILD_DATE} > .build_date; fi 30 | 31 | FROM base 32 | ARG BUILD 33 | ARG GITHUB_SHA 34 | COPY --from=builder /build / 35 | ENV PYTHONUNBUFFERED=1 FLASK_APP=frontend BUILD=$BUILD GITHUB_SHA=$GITHUB_SHA 36 | WORKDIR /app 37 | CMD ["flask", "run", "--host=0.0.0.0"] -------------------------------------------------------------------------------- /docker/Dockerfile.multiarch: -------------------------------------------------------------------------------- 1 | ARG BUILD 2 | ARG BUILD_DATE 3 | ARG GITHUB_SHA 4 | FROM amd64/python:3.12-slim-bookworm AS base_amd64 5 | FROM arm32v7/python:3.12-slim-bookworm AS base_arm 6 | FROM arm64v8/python:3.12-slim-bookworm AS base_arm64 7 | 8 | FROM base_$TARGETARCH AS builder 9 | ARG TARGETARCH 10 | ARG BUILD_DATE 11 | RUN apt-get update \ 12 | && apt-get install -y curl tar gcc \ 13 | && apt-get clean \ 14 | && rm -rf /var/lib/apt/lists/* 15 | COPY /app/ /build/app/ 16 | RUN pip3 install --disable-pip-version-check --prefix=/build/usr/local -r /build/app/requirements.txt 17 | RUN echo "BUILDING IMAGE FOR ${TARGETARCH}" && \ 18 | if [ "${TARGETARCH}" = "arm" ]; \ 19 | then FFMPEG_ARCH=arm32v7; MTX_ARCH=armv7; \ 20 | elif [ "${TARGETARCH}" = "arm64" ]; \ 21 | then FFMPEG_ARCH=aarch64; MTX_ARCH=arm64v8; \ 22 | else FFMPEG_ARCH=x86_64; MTX_ARCH=amd64; \ 23 | fi && \ 24 | cd /build \ 25 | && . app/.env \ 26 | && mkdir -p tokens img \ 27 | && curl -SL https://github.com/homebridge/ffmpeg-for-homebridge/releases/latest/download/ffmpeg-alpine-${FFMPEG_ARCH}.tar.gz \ 28 | | tar xzf - -C . \ 29 | && curl -SL https://github.com/bluenviron/mediamtx/releases/download/v${MTX_TAG}/mediamtx_v${MTX_TAG}_linux_${MTX_ARCH}.tar.gz \ 30 | | tar xzf - -C app \ 31 | && cp app/lib/lib.${TARGETARCH} usr/local/lib/libIOTCAPIs_ALL.so \ 32 | && rm -rf app/*.txt app/lib/ \ 33 | && if [ -z "${BUILD_DATE}" ]; then echo BUILD_DATE=$(date) > .build_date; else echo BUILD_DATE=${BUILD_DATE} > .build_date; fi 34 | 35 | FROM base_$TARGETARCH 36 | ARG BUILD 37 | ARG GITHUB_SHA 38 | COPY --from=builder /build / 39 | ENV PYTHONUNBUFFERED=1 FLASK_APP=frontend BUILD=$BUILD GITHUB_SHA=$GITHUB_SHA 40 | WORKDIR /app 41 | CMD ["flask", "run", "--host=0.0.0.0"] -------------------------------------------------------------------------------- /docker/Dockerfile.qsv: -------------------------------------------------------------------------------- 1 | ARG BUILD 2 | ARG BUILD_DATE 3 | ARG GITHUB_SHA 4 | ARG QSV=1 5 | FROM amd64/python:3.12-slim-bookworm AS base 6 | 7 | FROM base AS builder 8 | ARG QSV 9 | ARG BUILD_DATE 10 | RUN if [ -n "$QSV" ]; then echo 'deb http://deb.debian.org/debian bookworm main contrib non-free non-free-firmware' >/etc/apt/sources.list.d/debian-testing.list; fi \ 11 | && apt-get update \ 12 | && apt-get install -y curl tar xz-utils \ 13 | ${QSV:+i965-va-driver intel-gpu-tools intel-media-va-driver-non-free intel-opencl-icd libmfx1 libva-drm2 libx11-6 vainfo} \ 14 | && if [ -n "$QSV" ]; then apt-get install -y i965-va-driver-shaders; fi \ 15 | && apt-get clean \ 16 | && rm -rf /var/lib/apt/lists/* 17 | COPY /app/ /build/app/ 18 | RUN pip3 install --disable-pip-version-check --prefix=/build/usr/local -r /build/app/requirements.txt 19 | RUN cd /build \ 20 | && . app/.env \ 21 | && mkdir -p tokens img ${QSV:+usr/lib} \ 22 | && curl -SL https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-n6.1-latest-linux64-gpl-6.1.tar.xz \ 23 | | tar -Jxf - --strip-components=1 -C usr/local --wildcards '*ffmpeg' \ 24 | && curl -SL https://github.com/bluenviron/mediamtx/releases/download/v${MTX_TAG}/mediamtx_v${MTX_TAG}_linux_amd64.tar.gz \ 25 | | tar -xzf - -C app --wildcards 'mediamtx*' \ 26 | && cp app/lib/lib.amd64 usr/local/lib/libIOTCAPIs_ALL.so \ 27 | && if [ -n "$QSV" ]; then cp -R /usr/lib/x86_64-linux-gnu/ usr/lib/ && cp -R /usr/bin/ usr/bin; fi \ 28 | && rm -rf app/*.txt app/lib/ \ 29 | && if [ -z "${BUILD_DATE}" ]; then echo BUILD_DATE=$(date) > .build_date; else echo BUILD_DATE=${BUILD_DATE} > .build_date; fi 30 | 31 | FROM base 32 | ARG BUILD 33 | ARG GITHUB_SHA 34 | COPY --from=builder /build / 35 | ENV PYTHONUNBUFFERED=1 FLASK_APP=frontend BUILD=$BUILD GITHUB_SHA=$GITHUB_SHA 36 | WORKDIR /app 37 | CMD ["flask", "run", "--host=0.0.0.0"] -------------------------------------------------------------------------------- /home_assistant/DOCS.md: -------------------------------------------------------------------------------- 1 | # Docker Wyze Bridge 2 | 3 | ## Wyze Authentication 4 | 5 | As of April 2024, you will need to supply your own API Key and API ID along with your Wyze email and password. 6 | 7 | See the official help documentation on how to generate your developer keys: https://support.wyze.com/hc/en-us/articles/16129834216731. 8 | 9 | ## Stream and API Authentication 10 | 11 | Note that all streams and the REST API will necessitate authentication when WebUI Auth `WB_AUTH` is enabled. 12 | 13 | - REST API will require an `api` query parameter. 14 | - Example: `http://homeassistant.local:5000/api//state?api=` 15 | - Streams will also require authentication. 16 | - username: `wb` 17 | - password: your unique wb api key 18 | 19 | Please double check your router/firewall and do NOT forward ports or enable DMZ access to your bridge/server unless you know what you are doing! 20 | 21 | 22 | ## Camera Specific Options 23 | 24 | Camera specific options can now be passed to the bridge using `CAM_OPTIONS`. To do so you, will need to specify the `CAM_NAME` and the option(s) that you want to pass to the camera. 25 | 26 | `CAM_OPTIONS`: 27 | 28 | ```YAML 29 | - CAM_NAME: Front 30 | AUDIO: true 31 | ROTATE: true 32 | - CAM_NAME: Back door 33 | QUALITY: SD50 34 | ``` 35 | 36 | Available options: 37 | 38 | - `AUDIO` - Enable audio for this camera. 39 | - `FFMPEG` - Use a custom ffmpeg command for this camera. 40 | - `LIVESTREAM` - Specify a rtmp url to livestream to for this camera. 41 | - `NET_MODE` - Change the allowed net mode for this camera only. 42 | - `QUALITY` - Adjust the quality for this camera only. 43 | - `SUBSTREAM` - Enable a substream for this camera. 44 | - `SUB_QUALITY` - Adjust the quality for this substream. 45 | - `RECORD` - Enable recording for this camera. 46 | - `ROTATE` - Rotate this camera 90 degrees clockwise. 47 | - `MOTION_WEBHOOKS` - Specify a url to POST to when motion is detected. 48 | 49 | ## URIs 50 | 51 | `camera-nickname` is the name of the camera set in the Wyze app and are converted to lower case with hyphens in place of spaces. 52 | 53 | e.g. 'Front Door' would be `/front-door` 54 | 55 | - RTMP: 56 | 57 | ``` 58 | rtmp://homeassistant.local:1935/camera-nickname 59 | ``` 60 | 61 | - RTSP: 62 | 63 | ``` 64 | rtsp://homeassistant.local:8554/camera-nickname 65 | ``` 66 | 67 | - HLS: 68 | 69 | ``` 70 | http://homeassistant.local:8888/camera-nickname/stream.m3u8 71 | ``` 72 | 73 | - HLS can also be viewed in the browser using: 74 | 75 | ``` 76 | http://homeassistant.local:8888/camera-nickname 77 | ``` 78 | 79 | Please visit [github.com/mrlt8/docker-wyze-bridge](https://github.com/mrlt8/docker-wyze-bridge) for additional information. 80 | -------------------------------------------------------------------------------- /home_assistant/README.md: -------------------------------------------------------------------------------- 1 | [![Docker](https://github.com/mrlt8/docker-wyze-bridge/actions/workflows/docker-image.yml/badge.svg)](https://github.com/mrlt8/docker-wyze-bridge/actions/workflows/docker-image.yml) 2 | [![GitHub release (latest by date)](https://img.shields.io/github/v/release/mrlt8/docker-wyze-bridge?logo=github)](https://github.com/mrlt8/docker-wyze-bridge/releases/latest) 3 | [![Docker Image Size (latest semver)](https://img.shields.io/docker/image-size/mrlt8/wyze-bridge?sort=semver&logo=docker&logoColor=white)](https://hub.docker.com/r/mrlt8/wyze-bridge) 4 | [![Docker Pulls](https://img.shields.io/docker/pulls/mrlt8/wyze-bridge?logo=docker&logoColor=white)](https://hub.docker.com/r/mrlt8/wyze-bridge) 5 | ![GitHub Repo stars](https://img.shields.io/github/stars/mrlt8/docker-wyze-bridge?style=social) 6 | # WebRTC/RTMP/RTSP/HLS Bridge for Wyze Cam 7 | 8 | ![479shots_so](https://user-images.githubusercontent.com/67088095/224595527-05242f98-c4ab-4295-b9f5-07051ced1008.png) 9 | 10 | 11 | 12 | Create a local WebRTC, RTSP, RTMP, or HLS/Low-Latency HLS stream for most of your Wyze cameras including the outdoor, doorbell, and 2K cams. 13 | 14 | No third-party or special firmware required. 15 | 16 | It just works! 17 | 18 | Streams direct from camera without additional bandwidth or subscriptions. 19 | 20 | 21 | Based on [@noelhibbard's script](https://gist.github.com/noelhibbard/03703f551298c6460f2fd0bfdbc328bd#file-readme-md) with [kroo/wyzecam](https://github.com/kroo/wyzecam) and [aler9/rtsp-simple-server](https://github.com/aler9/rtsp-simple-server). 22 | 23 | Please consider [supporting](https://ko-fi.com/mrlt8) this project if you found it useful, or use our [affiliate link](https://amzn.to/3NLnbvt) if shopping on amazon! 24 | 25 | ## System Compatibility 26 | 27 | ![Supports arm32v7 Architecture](https://img.shields.io/badge/arm32v7-yes-success.svg) 28 | ![Supports arm64v8 Architecture](https://img.shields.io/badge/arm64v8-yes-success.svg) 29 | ![Supports amd64 Architecture](https://img.shields.io/badge/amd64-yes-success.svg) 30 | ![Supports Apple Silicon Architecture](https://img.shields.io/badge/apple_silicon-yes-success.svg) 31 | [![Home Assistant Add-on](https://img.shields.io/badge/home_assistant-add--on-blue.svg?logo=homeassistant&logoColor=white)](https://github.com/mrlt8/docker-wyze-bridge/wiki/Home-Assistant) 32 | 33 | Should work on most x64 systems as well as on most modern arm-based systems like the Raspberry Pi 3/4/5 or Apple Silicon M1/M2/M3. 34 | 35 | ## Supported Cameras 36 | 37 | ![Wyze Cam V1](https://img.shields.io/badge/wyze_v1-yes-success.svg) 38 | ![Wyze Cam V2](https://img.shields.io/badge/wyze_v2-yes-success.svg) 39 | ![Wyze Cam V3](https://img.shields.io/badge/wyze_v3-yes-success.svg) 40 | ![Wyze Cam V3 Pro](https://img.shields.io/badge/wyze_v3_pro-yes-success.svg) 41 | ![Wyze Cam V4](https://img.shields.io/badge/wyze_v4-yes-success.svg) 42 | ![Wyze Cam Floodlight](https://img.shields.io/badge/wyze_floodlight-yes-success.svg) 43 | ![Wyze Cam Floodlight V2](https://img.shields.io/badge/wyze_floodlight_v2-yes-success.svg) 44 | ![Wyze Cam Pan](https://img.shields.io/badge/wyze_pan-yes-success.svg) 45 | ![Wyze Cam Pan V2](https://img.shields.io/badge/wyze_pan_v2-yes-success.svg) 46 | ![Wyze Cam Pan V3](https://img.shields.io/badge/wyze_pan_v3-yes-success.svg) 47 | ![Wyze Cam Pan Pro](https://img.shields.io/badge/wyze_pan_pro-yes-success.svg) 48 | ![Wyze Cam Outdoor](https://img.shields.io/badge/wyze_outdoor-yes-success.svg) 49 | ![Wyze Cam Outdoor V2](https://img.shields.io/badge/wyze_outdoor_v2-yes-success.svg) 50 | ![Wyze Cam Doorbell](https://img.shields.io/badge/wyze_doorbell-yes-success.svg) 51 | ![Wyze Cam Doorbell V2](https://img.shields.io/badge/wyze_doorbell_v2-yes-success.svg) 52 | 53 | --- 54 | [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/J3J85TD3K) -------------------------------------------------------------------------------- /home_assistant/config.yml: -------------------------------------------------------------------------------- 1 | name: Docker Wyze Bridge 2 | description: WebRTC/RTSP/RTMP/LL-HLS bridge for Wyze cams in a docker container 3 | slug: docker_wyze_bridge 4 | url: https://github.com/mrlt8/docker-wyze-bridge 5 | image: mrlt8/wyze-bridge 6 | version: 2.10.3 7 | stage: stable 8 | arch: 9 | - armv7 10 | - aarch64 11 | - amd64 12 | startup: application 13 | boot: auto 14 | apparmor: false 15 | hassio_api: true 16 | ports: 17 | 1935/tcp: 1935 18 | 8554/tcp: 8554 19 | 8888/tcp: 8888 20 | 8189/udp: 8189 21 | 8889/tcp: 8889 22 | 5000/tcp: 5000 23 | ports_description: 24 | 1935/tcp: RTMP streams 25 | 8554/tcp: RTSP streams 26 | 8888/tcp: HLS streams 27 | 8189/udp: WebRTC ICE 28 | 8889/tcp: WebRTC streams 29 | 5000/tcp: Web-UI/Snapshots 30 | environment: 31 | IMG_DIR: media/wyze/img/ 32 | RECORD_PATH: media/wyze/{CAM_NAME} 33 | MQTT_DTOPIC: homeassistant 34 | map: 35 | - addon_config:rw 36 | - media:rw 37 | - ssl:ro 38 | services: 39 | - mqtt:want 40 | ingress: true 41 | ingress_port: 5000 42 | panel_icon: mdi:bridge 43 | options: 44 | ENABLE_AUDIO: true 45 | ON_DEMAND: true 46 | WB_AUTH: true 47 | MOTION_API: true 48 | MQTT: true 49 | CAM_OPTIONS: [] 50 | schema: 51 | WYZE_EMAIL: email? 52 | WYZE_PASSWORD: password? 53 | API_ID: match(\s*[a-fA-F0-9-]{36}\s*)? 54 | API_KEY: match(\s*[a-zA-Z0-9]{60}\s*)? 55 | WB_IP: str? 56 | REFRESH_TOKEN: str? 57 | ACCESS_TOKEN: str? 58 | NET_MODE: list(LAN|P2P|ANY)? 59 | SNAPSHOT: list(API|RTSP|RTSP15|RTSP30|RTSP60|RTSP180|RTSP300|Disable)? 60 | SNAPSHOT_FORMAT: str? 61 | SNAPSHOT_KEEP: str? 62 | IMG_TYPE: list(jpg|png)? 63 | IMG_DIR: str? 64 | ENABLE_AUDIO: bool? 65 | ON_DEMAND: bool? 66 | MOTION_API: bool? 67 | MOTION_INT: float(1.1,)? 68 | MOTION_START: bool? 69 | MOTION_WEBHOOKS: str? 70 | SUBSTREAM: bool? 71 | AUDIO_CODEC: list(COPY|AAC|LIBOPUS|MP3|PCM_MULAW|PCM_ALAW)? 72 | AUDIO_FILTER: str? 73 | LLHLS: bool? 74 | DISABLE_CONTROL: bool? 75 | RTSP_FW: bool? 76 | RECORD_ALL: bool? 77 | RECORD_LENGTH: int? 78 | RECORD_PATH: str? 79 | RECORD_FILE_NAME: str? 80 | MQTT: bool 81 | MQTT_HOST: str? 82 | MQTT_AUTH: str? 83 | MQTT_TOPIC: str? 84 | MQTT_DTOPIC: str? 85 | MQTT_RETRIES: int? 86 | FILTER_NAMES: str? 87 | FILTER_MODELS: str? 88 | FILTER_MACS: str? 89 | FILTER_BLOCK: bool? 90 | ROTATE_DOOR: bool? 91 | H264_ENC: str? 92 | FORCE_ENCODE: bool? 93 | IGNORE_OFFLINE: bool? 94 | OFFLINE_WEBHOOKS: bool? 95 | FRESH_DATA: bool? 96 | URI_MAC: bool? 97 | URI_SEPARATOR: list(-|_|#)? 98 | QUALITY: str? 99 | SUB_QUALITY: str? 100 | FORCE_FPS: int? 101 | SUB_RECORD: bool? 102 | FFMPEG_FLAGS: str? 103 | FFMPEG_CMD: str? 104 | LOG_LEVEL: list(INFO|DEBUG)? 105 | LOG_FILE: bool? 106 | LOG_TIME: bool? 107 | FFMPEG_LOGLEVEL: list(quiet|panic|fatal|error|warning|info|verbose|debug)? 108 | IGNORE_RES: int? 109 | BOA_ENABLED: bool? 110 | BOA_INTERVAL: int? 111 | BOA_TAKE_PHOTO: bool? 112 | BOA_PHOTO: bool? 113 | BOA_ALARM: bool? 114 | BOA_MOTION: str? 115 | BOA_COOLDOWN: int? 116 | CAM_OPTIONS: 117 | - CAM_NAME: str? 118 | AUDIO: bool? 119 | FFMPEG: str? 120 | LIVESTREAM: str? 121 | NET_MODE: str? 122 | ROTATE: bool? 123 | QUALITY: str? 124 | SUB_QUALITY: str? 125 | FORCE_FPS: int? 126 | RECORD: bool? 127 | SUB_RECORD: bool? 128 | SUBSTREAM: bool? 129 | MOTION_WEBHOOKS: str? 130 | MEDIAMTX: 131 | - match(^\w+=.*)? 132 | WB_HLS_URL: str? 133 | WB_RTMP_URL: str? 134 | WB_RTSP_URL: str? 135 | WB_WEBRTC_URL: str? 136 | WB_AUTH: bool? 137 | WB_USERNAME: str? 138 | WB_PASSWORD: str? 139 | STREAM_AUTH: str? 140 | -------------------------------------------------------------------------------- /home_assistant/dev/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mrlt8/wyze-bridge:dev 2 | -------------------------------------------------------------------------------- /home_assistant/dev/config.yml: -------------------------------------------------------------------------------- 1 | name: Docker Wyze Bridge (DEV branch) 2 | description: DEV build. Use the REBUILD button to pull latest image. 3 | slug: docker_wyze_bridge_dev 4 | url: "https://github.com/mrlt8/docker-wyze-bridge" 5 | version: dev 6 | stage: experimental 7 | arch: 8 | - armv7 9 | - aarch64 10 | - amd64 11 | startup: application 12 | boot: auto 13 | apparmor: false 14 | hassio_api: true 15 | ports: 16 | 1935/tcp: 1935 17 | 8554/tcp: 8554 18 | 8888/tcp: 8888 19 | 8189/udp: 8189 20 | 8889/tcp: 8889 21 | 5000/tcp: 5000 22 | ports_description: 23 | 1935/tcp: RTMP streams 24 | 8554/tcp: RTSP streams 25 | 8888/tcp: HLS streams 26 | 8189/udp: WebRTC ICE 27 | 8889/tcp: WebRTC streams 28 | 5000/tcp: Web-UI/Snapshots 29 | environment: 30 | IMG_DIR: media/wyze/img/ 31 | RECORD_PATH: media/wyze/{CAM_NAME} 32 | MQTT_DTOPIC: homeassistant 33 | map: 34 | - addon_config:rw 35 | - media:rw 36 | - ssl:ro 37 | services: 38 | - mqtt:want 39 | video: true 40 | ingress: true 41 | ingress_port: 5000 42 | panel_icon: mdi:bridge 43 | options: 44 | ENABLE_AUDIO: true 45 | ON_DEMAND: true 46 | WB_AUTH: true 47 | MOTION_API: true 48 | MQTT: true 49 | CAM_OPTIONS: [] 50 | schema: 51 | WYZE_EMAIL: email? 52 | WYZE_PASSWORD: password? 53 | API_ID: match(\s*[a-fA-F0-9-]{36}\s*)? 54 | API_KEY: match(\s*[a-zA-Z0-9]{60}\s*)? 55 | WB_IP: str? 56 | REFRESH_TOKEN: str? 57 | ACCESS_TOKEN: str? 58 | NET_MODE: list(LAN|P2P|ANY)? 59 | SNAPSHOT: list(API|RTSP|RTSP15|RTSP30|RTSP60|RTSP180|RTSP300|Disable)? 60 | SNAPSHOT_FORMAT: str? 61 | SNAPSHOT_KEEP: str? 62 | IMG_TYPE: list(jpg|png)? 63 | IMG_DIR: str? 64 | ENABLE_AUDIO: bool? 65 | ON_DEMAND: bool? 66 | MOTION_API: bool? 67 | MOTION_INT: float(1.1,)? 68 | MOTION_START: bool? 69 | MOTION_WEBHOOKS: str? 70 | SUBSTREAM: bool? 71 | AUDIO_CODEC: list(COPY|AAC|LIBOPUS|MP3|PCM_MULAW|PCM_ALAW)? 72 | AUDIO_FILTER: str? 73 | LLHLS: bool? 74 | DISABLE_CONTROL: bool? 75 | RTSP_FW: bool? 76 | RECORD_ALL: bool? 77 | RECORD_LENGTH: int? 78 | RECORD_PATH: str? 79 | RECORD_FILE_NAME: str? 80 | MQTT: bool 81 | MQTT_HOST: str? 82 | MQTT_AUTH: str? 83 | MQTT_TOPIC: str? 84 | MQTT_DTOPIC: str? 85 | MQTT_RETRIES: int? 86 | FILTER_NAMES: str? 87 | FILTER_MODELS: str? 88 | FILTER_MACS: str? 89 | FILTER_BLOCK: bool? 90 | ROTATE_DOOR: bool? 91 | H264_ENC: str? 92 | FORCE_ENCODE: bool? 93 | IGNORE_OFFLINE: bool? 94 | OFFLINE_WEBHOOKS: bool? 95 | FRESH_DATA: bool? 96 | URI_MAC: bool? 97 | URI_SEPARATOR: list(-|_|#)? 98 | QUALITY: str? 99 | SUB_QUALITY: str? 100 | FORCE_FPS: int? 101 | SUB_RECORD: bool? 102 | FFMPEG_FLAGS: str? 103 | FFMPEG_CMD: str? 104 | LOG_LEVEL: list(INFO|DEBUG)? 105 | LOG_FILE: bool? 106 | LOG_TIME: bool? 107 | FFMPEG_LOGLEVEL: list(quiet|panic|fatal|error|warning|info|verbose|debug)? 108 | IGNORE_RES: int? 109 | BOA_ENABLED: bool? 110 | BOA_INTERVAL: int? 111 | BOA_TAKE_PHOTO: bool? 112 | BOA_PHOTO: bool? 113 | BOA_ALARM: bool? 114 | BOA_MOTION: str? 115 | BOA_COOLDOWN: int? 116 | CAM_OPTIONS: 117 | - CAM_NAME: str? 118 | AUDIO: bool? 119 | FFMPEG: str? 120 | LIVESTREAM: str? 121 | NET_MODE: str? 122 | ROTATE: bool? 123 | QUALITY: str? 124 | SUB_QUALITY: str? 125 | FORCE_FPS: int? 126 | RECORD: bool? 127 | SUB_RECORD: bool? 128 | SUBSTREAM: bool? 129 | MOTION_WEBHOOKS: str? 130 | MEDIAMTX: 131 | - match(^\w+=.*)? 132 | WB_HLS_URL: str? 133 | WB_RTMP_URL: str? 134 | WB_RTSP_URL: str? 135 | WB_WEBRTC_URL: str? 136 | WB_AUTH: bool? 137 | WB_USERNAME: str? 138 | WB_PASSWORD: str? 139 | STREAM_AUTH: str? 140 | -------------------------------------------------------------------------------- /home_assistant/dev/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrlt8/docker-wyze-bridge/bf893749b748f142199c9bce14fac44f8a661d6e/home_assistant/dev/icon.png -------------------------------------------------------------------------------- /home_assistant/dev/translations/en.yml: -------------------------------------------------------------------------------- 1 | configuration: 2 | API_ID: 3 | name: Key ID 4 | description: Optional, but must be used in combination with the API Key. 5 | API_KEY: 6 | name: API Key 7 | description: Optional, but must be used in combination with the Key ID. 8 | REFRESH_TOKEN: 9 | name: Refresh Token 10 | description: Use existing refresh token for authentication. 11 | ACCESS_TOKEN: 12 | name: Access Token 13 | description: Use existing access token for authentication. 14 | NET_MODE: 15 | name: Allowed Net Modes 16 | SNAPSHOT: 17 | name: Snapshot Mode 18 | ENABLE_AUDIO: 19 | name: Enable Audio For All Cameras 20 | SUBSTREAM: 21 | name: Enable sub-stream 22 | description: Create a secondary SD30 stream on "/cam-name-sub" 23 | FILTER_NAMES: 24 | name: Filter cameras by name 25 | FILTER_MODELS: 26 | name: Filter cameras by camera model 27 | FILTER_MACS: 28 | name: Filter cameras by MAC address 29 | FILTER_BLOCK: 30 | name: Invert Filter 31 | description: Block cameras in filter list 32 | AUDIO_CODEC: 33 | name: Audio Codec 34 | description: Cams with PCM audio will be re-encoded to AAC by default for RTSP compatibility. 35 | AUDIO_FILTER: 36 | name: FFmpeg Audio Filter 37 | description: Requires audio codec to be set to AAC or MP3. 38 | OFFLINE_IFTTT: 39 | name: IFTTT Webhook 40 | description: e.g. `EventName:Webhooks_Key`. 41 | ROTATE_DOOR: 42 | name: Rotate Doorbells 43 | description: Will rotate video 90 degrees clockwise. 44 | CAM_OPTIONS: 45 | name: Camera Specific Options 46 | FRESH_DATA: 47 | name: Pull Fresh Data From Wyze API 48 | description: Ignore local cache and pull fresh data from the wyze API. 49 | WEBRTC: 50 | name: Print WebRTC Credentials 51 | WB_SHOW_VIDEO: 52 | name: HLS Player in Web-UI 53 | MEDIAMTX: 54 | name: MediaMTX config 55 | description: Use `=` to specify the value and use `_` in place of spaces. 56 | BOA_ENABLED: 57 | name: Enable Boa HTTP* 58 | description: Enable http webserver on select cameras. *Req LAN and SD Card. 59 | BOA_INTERVAL: 60 | name: Boa Keep Alive 61 | description: The number of seconds between photos/keep alive. 62 | BOA_TAKE_PHOTO: 63 | name: Interval Photo 64 | description: Take a photo on the camera on Boa Keep Alive Interval. 65 | BOA_PHOTO: 66 | name: Pull Photo (MQTT Motion Alerts) 67 | description: Pull the HQ photo from the SD card via Boa. 68 | BOA_ALARM: 69 | name: Pull Alarm (MQTT Motion Alerts) 70 | description: Pull alarm/motion image from the SD card via Boa. 71 | BOA_COOLDOWN: 72 | name: Motion Cooldown 73 | description: Number of seconds to keep the motion flag set to true before resetting it. 74 | BOA_MOTION: 75 | name: Webhook on Boa Motion 76 | description: Make a Webhook/HTTP request to any url. e.g., http://localhost/motion?cam={cam_name} 77 | WB_HLS_URL: 78 | name: Custom HLS url 79 | WB_RTMP_URL: 80 | name: Custom RTMP url 81 | WB_RTSP_URL: 82 | name: Custom RTSP url 83 | WB_WEBRTC_URL: 84 | name: Custom WebRTC url 85 | RTSP_FW: 86 | name: Firmware RTSP 87 | description: Proxy additional RTSP stream from official RTSP firmware. 88 | DISABLE_CONTROL: 89 | name: Disable Control 90 | description: Disable camera control from the API/mqtt. 91 | WB_IP: 92 | name: Bridge IP 93 | description: Home Assistant IP for WebRTC ICE traffic. 94 | TOTP_KEY: 95 | name: TOTP Key 96 | description: Used to auto generate a TOTP code when needed. 97 | WB_AUTH: 98 | name: WebUI Auth 99 | description: Enable authentication for WebUI 100 | WB_USERNAME: 101 | name: WebUI Username 102 | WB_PASSWORD: 103 | name: WebUI Password 104 | -------------------------------------------------------------------------------- /home_assistant/edge/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mrlt8/wyze-bridge:edge 2 | -------------------------------------------------------------------------------- /home_assistant/edge/config.yml: -------------------------------------------------------------------------------- 1 | name: Docker Wyze Bridge (EDGE branch) 2 | description: EDGE build. Use the REBUILD button to pull latest image. 3 | slug: docker_wyze_bridge_edge 4 | url: "https://github.com/mrlt8/docker-wyze-bridge" 5 | version: edge 6 | stage: experimental 7 | arch: 8 | - armv7 9 | - aarch64 10 | - amd64 11 | startup: application 12 | boot: auto 13 | apparmor: false 14 | hassio_api: true 15 | ports: 16 | 1935/tcp: 1935 17 | 8554/tcp: 8554 18 | 8888/tcp: 8888 19 | 8189/udp: 8189 20 | 8889/tcp: 8889 21 | 5000/tcp: 5000 22 | ports_description: 23 | 1935/tcp: RTMP streams 24 | 8554/tcp: RTSP streams 25 | 8888/tcp: HLS streams 26 | 8189/udp: WebRTC ICE 27 | 8889/tcp: WebRTC streams 28 | 5000/tcp: Web-UI/Snapshots 29 | environment: 30 | IMG_DIR: media/wyze/img/ 31 | RECORD_PATH: media/wyze/{CAM_NAME} 32 | MQTT_DTOPIC: homeassistant 33 | map: 34 | - addon_config:rw 35 | - media:rw 36 | - ssl:ro 37 | services: 38 | - mqtt:want 39 | ingress: true 40 | ingress_port: 5000 41 | panel_icon: mdi:bridge 42 | options: 43 | ENABLE_AUDIO: true 44 | ON_DEMAND: true 45 | WB_AUTH: true 46 | MOTION_API: true 47 | MQTT_DTOPIC: homeassistant 48 | CAM_OPTIONS: [] 49 | schema: 50 | WYZE_EMAIL: email? 51 | WYZE_PASSWORD: password? 52 | API_ID: match(\s*[a-fA-F0-9-]{36}\s*)? 53 | API_KEY: match(\s*[a-zA-Z0-9]{60}\s*)? 54 | WB_IP: str? 55 | REFRESH_TOKEN: str? 56 | ACCESS_TOKEN: str? 57 | NET_MODE: list(LAN|P2P|ANY)? 58 | SNAPSHOT: list(API|RTSP|RTSP15|RTSP30|RTSP60|RTSP180|RTSP300|Disable)? 59 | SNAPSHOT_FORMAT: str? 60 | IMG_TYPE: list(jpg|png)? 61 | IMG_DIR: str? 62 | ENABLE_AUDIO: bool? 63 | ON_DEMAND: bool? 64 | MOTION_API: bool? 65 | MOTION_INT: float(1.1,)? 66 | MOTION_START: bool? 67 | MOTION_WEBHOOKS: str? 68 | SUBSTREAM: bool? 69 | AUDIO_CODEC: list(COPY|AAC|MP3|LIBOPUS)? 70 | AUDIO_FILTER: str? 71 | LLHLS: bool? 72 | DISABLE_CONTROL: bool? 73 | RTSP_FW: bool? 74 | RECORD_ALL: bool? 75 | RECORD_LENGTH: int? 76 | RECORD_PATH: str? 77 | RECORD_FILE_NAME: str? 78 | MQTT: bool 79 | MQTT_HOST: str? 80 | MQTT_AUTH: str? 81 | MQTT_TOPIC: str? 82 | MQTT_DTOPIC: str? 83 | MQTT_RETRIES: int? 84 | FILTER_NAMES: str? 85 | FILTER_MODELS: str? 86 | FILTER_MACS: str? 87 | FILTER_BLOCK: bool? 88 | ROTATE_DOOR: bool? 89 | H264_ENC: str? 90 | FORCE_ENCODE: bool? 91 | IGNORE_OFFLINE: bool? 92 | OFFLINE_WEBHOOKS: bool? 93 | FRESH_DATA: bool? 94 | URI_MAC: bool? 95 | URI_SEPARATOR: list(-|_|#)? 96 | QUALITY: str? 97 | SUB_QUALITY: str? 98 | FORCE_FPS: int? 99 | SUB_RECORD: bool? 100 | FFMPEG_FLAGS: str? 101 | FFMPEG_CMD: str? 102 | LOG_LEVEL: list(INFO|DEBUG)? 103 | LOG_FILE: bool? 104 | LOG_TIME: bool? 105 | FFMPEG_LOGLEVEL: list(quiet|panic|fatal|error|warning|info|verbose|debug)? 106 | IGNORE_RES: int? 107 | BOA_ENABLED: bool? 108 | BOA_INTERVAL: int? 109 | BOA_TAKE_PHOTO: bool? 110 | BOA_PHOTO: bool? 111 | BOA_ALARM: bool? 112 | BOA_MOTION: str? 113 | BOA_COOLDOWN: int? 114 | CAM_OPTIONS: 115 | - CAM_NAME: str? 116 | AUDIO: bool? 117 | FFMPEG: str? 118 | LIVESTREAM: str? 119 | NET_MODE: str? 120 | ROTATE: bool? 121 | QUALITY: str? 122 | SUB_QUALITY: str? 123 | FORCE_FPS: int? 124 | RECORD: bool? 125 | SUB_RECORD: bool? 126 | SUBSTREAM: bool? 127 | MOTION_WEBHOOKS: str? 128 | MEDIAMTX: 129 | - match(^\w+=.*)? 130 | WB_HLS_URL: str? 131 | WB_RTMP_URL: str? 132 | WB_RTSP_URL: str? 133 | WB_WEBRTC_URL: str? 134 | WB_AUTH: bool? 135 | WB_USERNAME: str? 136 | WB_PASSWORD: str? 137 | -------------------------------------------------------------------------------- /home_assistant/edge/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrlt8/docker-wyze-bridge/bf893749b748f142199c9bce14fac44f8a661d6e/home_assistant/edge/icon.png -------------------------------------------------------------------------------- /home_assistant/edge/translations/en.yml: -------------------------------------------------------------------------------- 1 | configuration: 2 | API_ID: 3 | name: Key ID 4 | description: Optional, but must be used in combination with the API Key. 5 | API_KEY: 6 | name: API Key 7 | description: Optional, but must be used in combination with the Key ID. 8 | REFRESH_TOKEN: 9 | name: Refresh Token 10 | description: Use existing refresh token for authentication. 11 | ACCESS_TOKEN: 12 | name: Access Token 13 | description: Use existing access token for authentication. 14 | NET_MODE: 15 | name: Allowed Net Modes 16 | SNAPSHOT: 17 | name: Snapshot Mode 18 | ENABLE_AUDIO: 19 | name: Enable Audio For All Cameras 20 | SUBSTREAM: 21 | name: Enable sub-stream 22 | description: Create a secondary SD30 stream on "/cam-name-sub" 23 | FILTER_NAMES: 24 | name: Filter cameras by name 25 | FILTER_MODELS: 26 | name: Filter cameras by camera model 27 | FILTER_MACS: 28 | name: Filter cameras by MAC address 29 | FILTER_BLOCK: 30 | name: Invert Filter 31 | description: Block cameras in filter list 32 | AUDIO_CODEC: 33 | name: Audio Codec 34 | description: Cams with PCM audio will be re-encoded to AAC by default for RTSP compatibility. 35 | AUDIO_FILTER: 36 | name: FFmpeg Audio Filter 37 | description: Requires audio codec to be set to AAC or MP3. 38 | OFFLINE_IFTTT: 39 | name: IFTTT Webhook 40 | description: e.g. `EventName:Webhooks_Key`. 41 | ROTATE_DOOR: 42 | name: Rotate Doorbells 43 | description: Will rotate video 90 degrees clockwise. 44 | CAM_OPTIONS: 45 | name: Camera Specific Options 46 | FRESH_DATA: 47 | name: Pull Fresh Data From Wyze API 48 | description: Ignore local cache and pull fresh data from the wyze API. 49 | WEBRTC: 50 | name: Print WebRTC Credentials 51 | WB_SHOW_VIDEO: 52 | name: HLS Player in Web-UI 53 | MEDIAMTX: 54 | name: MediaMTX config 55 | description: Use `=` to specify the value and use `_` in place of spaces. 56 | BOA_ENABLED: 57 | name: Enable Boa HTTP* 58 | description: Enable http webserver on select cameras. *Req LAN and SD Card. 59 | BOA_INTERVAL: 60 | name: Boa Keep Alive 61 | description: The number of seconds between photos/keep alive. 62 | BOA_TAKE_PHOTO: 63 | name: Interval Photo 64 | description: Take a photo on the camera on Boa Keep Alive Interval. 65 | BOA_PHOTO: 66 | name: Pull Photo (MQTT Motion Alerts) 67 | description: Pull the HQ photo from the SD card via Boa. 68 | BOA_ALARM: 69 | name: Pull Alarm (MQTT Motion Alerts) 70 | description: Pull alarm/motion image from the SD card via Boa. 71 | BOA_COOLDOWN: 72 | name: Motion Cooldown 73 | description: Number of seconds to keep the motion flag set to true before resetting it. 74 | BOA_MOTION: 75 | name: Webhook on Boa Motion 76 | description: Make a Webhook/HTTP request to any url. e.g., http://localhost/motion?cam={cam_name} 77 | WB_HLS_URL: 78 | name: Custom HLS url 79 | WB_RTMP_URL: 80 | name: Custom RTMP url 81 | WB_RTSP_URL: 82 | name: Custom RTSP url 83 | WB_WEBRTC_URL: 84 | name: Custom WebRTC url 85 | RTSP_FW: 86 | name: Firmware RTSP 87 | description: Proxy additional RTSP stream from official RTSP firmware. 88 | DISABLE_CONTROL: 89 | name: Disable Control 90 | description: Disable camera control from the API/mqtt. 91 | WB_IP: 92 | name: Bridge IP 93 | description: Home Assistant IP for WebRTC ICE traffic. 94 | TOTP_KEY: 95 | name: TOTP Key 96 | description: Used to auto generate a TOTP code when needed. 97 | WB_AUTH: 98 | name: WebUI Auth 99 | description: Enable authentication for WebUI 100 | WB_USERNAME: 101 | name: WebUI Username 102 | WB_PASSWORD: 103 | name: WebUI Password 104 | -------------------------------------------------------------------------------- /home_assistant/hw/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mrlt8/wyze-bridge:latest-hw 2 | -------------------------------------------------------------------------------- /home_assistant/hw/config.yml: -------------------------------------------------------------------------------- 1 | name: Docker Wyze Bridge (HW branch) 2 | description: HW build. Use the REBUILD button to pull latest image. 3 | slug: docker_wyze_bridge_hw 4 | url: "https://github.com/mrlt8/docker-wyze-bridge" 5 | version: hw 6 | stage: experimental 7 | arch: 8 | - amd64 9 | startup: application 10 | boot: auto 11 | apparmor: false 12 | hassio_api: true 13 | ports: 14 | 1935/tcp: 1935 15 | 8554/tcp: 8554 16 | 8888/tcp: 8888 17 | 8189/udp: 8189 18 | 8889/tcp: 8889 19 | 5000/tcp: 5000 20 | ports_description: 21 | 1935/tcp: RTMP streams 22 | 8554/tcp: RTSP streams 23 | 8888/tcp: HLS streams 24 | 8189/udp: WebRTC ICE 25 | 8889/tcp: WebRTC streams 26 | 5000/tcp: Web-UI/Snapshots 27 | environment: 28 | IMG_DIR: media/wyze/img/ 29 | RECORD_PATH: media/wyze/{CAM_NAME} 30 | MQTT_DTOPIC: homeassistant 31 | map: 32 | - addon_config:rw 33 | - media:rw 34 | - ssl:ro 35 | services: 36 | - mqtt:want 37 | video: true 38 | ingress: true 39 | ingress_port: 5000 40 | panel_icon: mdi:bridge 41 | options: 42 | ENABLE_AUDIO: true 43 | ON_DEMAND: true 44 | WB_AUTH: true 45 | MOTION_API: true 46 | MQTT: true 47 | CAM_OPTIONS: [] 48 | schema: 49 | WYZE_EMAIL: email? 50 | WYZE_PASSWORD: password? 51 | API_ID: match(\s*[a-fA-F0-9-]{36}\s*)? 52 | API_KEY: match(\s*[a-zA-Z0-9]{60}\s*)? 53 | WB_IP: str? 54 | REFRESH_TOKEN: str? 55 | ACCESS_TOKEN: str? 56 | NET_MODE: list(LAN|P2P|ANY)? 57 | SNAPSHOT: list(API|RTSP|RTSP15|RTSP30|RTSP60|RTSP180|RTSP300|Disable)? 58 | SNAPSHOT_FORMAT: str? 59 | SNAPSHOT_KEEP: str? 60 | IMG_TYPE: list(jpg|png)? 61 | IMG_DIR: str? 62 | ENABLE_AUDIO: bool? 63 | ON_DEMAND: bool? 64 | MOTION_API: bool? 65 | MOTION_INT: float(1.1,)? 66 | MOTION_START: bool? 67 | MOTION_WEBHOOKS: str? 68 | SUBSTREAM: bool? 69 | AUDIO_CODEC: list(COPY|AAC|LIBOPUS|MP3|PCM_MULAW|PCM_ALAW)? 70 | AUDIO_FILTER: str? 71 | LLHLS: bool? 72 | DISABLE_CONTROL: bool? 73 | RTSP_FW: bool? 74 | RECORD_ALL: bool? 75 | RECORD_LENGTH: int? 76 | RECORD_PATH: str? 77 | RECORD_FILE_NAME: str? 78 | MQTT: bool 79 | MQTT_HOST: str? 80 | MQTT_AUTH: str? 81 | MQTT_TOPIC: str? 82 | MQTT_DTOPIC: str? 83 | MQTT_RETRIES: int? 84 | FILTER_NAMES: str? 85 | FILTER_MODELS: str? 86 | FILTER_MACS: str? 87 | FILTER_BLOCK: bool? 88 | ROTATE_DOOR: bool? 89 | H264_ENC: str? 90 | FORCE_ENCODE: bool? 91 | IGNORE_OFFLINE: bool? 92 | OFFLINE_WEBHOOKS: bool? 93 | FRESH_DATA: bool? 94 | URI_MAC: bool? 95 | URI_SEPARATOR: list(-|_|#)? 96 | QUALITY: str? 97 | SUB_QUALITY: str? 98 | FORCE_FPS: int? 99 | SUB_RECORD: bool? 100 | FFMPEG_FLAGS: str? 101 | FFMPEG_CMD: str? 102 | LOG_LEVEL: list(INFO|DEBUG)? 103 | LOG_FILE: bool? 104 | LOG_TIME: bool? 105 | FFMPEG_LOGLEVEL: list(quiet|panic|fatal|error|warning|info|verbose|debug)? 106 | IGNORE_RES: int? 107 | BOA_ENABLED: bool? 108 | BOA_INTERVAL: int? 109 | BOA_TAKE_PHOTO: bool? 110 | BOA_PHOTO: bool? 111 | BOA_ALARM: bool? 112 | BOA_MOTION: str? 113 | BOA_COOLDOWN: int? 114 | CAM_OPTIONS: 115 | - CAM_NAME: str? 116 | AUDIO: bool? 117 | FFMPEG: str? 118 | LIVESTREAM: str? 119 | NET_MODE: str? 120 | ROTATE: bool? 121 | QUALITY: str? 122 | SUB_QUALITY: str? 123 | FORCE_FPS: int? 124 | RECORD: bool? 125 | SUB_RECORD: bool? 126 | SUBSTREAM: bool? 127 | MOTION_WEBHOOKS: str? 128 | MEDIAMTX: 129 | - match(^\w+=.*)? 130 | WB_HLS_URL: str? 131 | WB_RTMP_URL: str? 132 | WB_RTSP_URL: str? 133 | WB_WEBRTC_URL: str? 134 | WB_AUTH: bool? 135 | WB_USERNAME: str? 136 | WB_PASSWORD: str? 137 | STREAM_AUTH: str? 138 | -------------------------------------------------------------------------------- /home_assistant/hw/translations/en.yml: -------------------------------------------------------------------------------- 1 | configuration: 2 | API_ID: 3 | name: Key ID 4 | description: Optional, but must be used in combination with the API Key. 5 | API_KEY: 6 | name: API Key 7 | description: Optional, but must be used in combination with the Key ID. 8 | REFRESH_TOKEN: 9 | name: Refresh Token 10 | description: Use existing refresh token for authentication. 11 | ACCESS_TOKEN: 12 | name: Access Token 13 | description: Use existing access token for authentication. 14 | NET_MODE: 15 | name: Allowed Net Modes 16 | SNAPSHOT: 17 | name: Snapshot Mode 18 | ENABLE_AUDIO: 19 | name: Enable Audio For All Cameras 20 | SUBSTREAM: 21 | name: Enable sub-stream 22 | description: Create a secondary SD30 stream on "/cam-name-sub" 23 | FILTER_NAMES: 24 | name: Filter cameras by name 25 | FILTER_MODELS: 26 | name: Filter cameras by camera model 27 | FILTER_MACS: 28 | name: Filter cameras by MAC address 29 | FILTER_BLOCK: 30 | name: Invert Filter 31 | description: Block cameras in filter list 32 | AUDIO_CODEC: 33 | name: Audio Codec 34 | description: Cams with PCM audio will be re-encoded to AAC by default for RTSP compatibility. 35 | AUDIO_FILTER: 36 | name: FFmpeg Audio Filter 37 | description: Requires audio codec to be set to AAC or MP3. 38 | OFFLINE_IFTTT: 39 | name: IFTTT Webhook 40 | description: e.g. `EventName:Webhooks_Key`. 41 | ROTATE_DOOR: 42 | name: Rotate Doorbells 43 | description: Will rotate video 90 degrees clockwise. 44 | CAM_OPTIONS: 45 | name: Camera Specific Options 46 | FRESH_DATA: 47 | name: Pull Fresh Data From Wyze API 48 | description: Ignore local cache and pull fresh data from the wyze API. 49 | WEBRTC: 50 | name: Print WebRTC Credentials 51 | WB_SHOW_VIDEO: 52 | name: HLS Player in Web-UI 53 | MEDIAMTX: 54 | name: MediaMTX config 55 | description: Use `=` to specify the value and use `_` in place of spaces. 56 | BOA_ENABLED: 57 | name: Enable Boa HTTP* 58 | description: Enable http webserver on select cameras. *Req LAN and SD Card. 59 | BOA_INTERVAL: 60 | name: Boa Keep Alive 61 | description: The number of seconds between photos/keep alive. 62 | BOA_TAKE_PHOTO: 63 | name: Interval Photo 64 | description: Take a photo on the camera on Boa Keep Alive Interval. 65 | BOA_PHOTO: 66 | name: Pull Photo (MQTT Motion Alerts) 67 | description: Pull the HQ photo from the SD card via Boa. 68 | BOA_ALARM: 69 | name: Pull Alarm (MQTT Motion Alerts) 70 | description: Pull alarm/motion image from the SD card via Boa. 71 | BOA_COOLDOWN: 72 | name: Motion Cooldown 73 | description: Number of seconds to keep the motion flag set to true before resetting it. 74 | BOA_MOTION: 75 | name: Webhook on Boa Motion 76 | description: Make a Webhook/HTTP request to any url. e.g., http://localhost/motion?cam={cam_name} 77 | WB_HLS_URL: 78 | name: Custom HLS url 79 | WB_RTMP_URL: 80 | name: Custom RTMP url 81 | WB_RTSP_URL: 82 | name: Custom RTSP url 83 | WB_WEBRTC_URL: 84 | name: Custom WebRTC url 85 | RTSP_FW: 86 | name: Firmware RTSP 87 | description: Proxy additional RTSP stream from official RTSP firmware. 88 | DISABLE_CONTROL: 89 | name: Disable Control 90 | description: Disable camera control from the API/mqtt. 91 | WB_IP: 92 | name: Bridge IP 93 | description: Home Assistant IP for WebRTC ICE traffic. 94 | TOTP_KEY: 95 | name: TOTP Key 96 | description: Used to auto generate a TOTP code when needed. 97 | WB_AUTH: 98 | name: WebUI Auth 99 | description: Enable authentication for WebUI 100 | WB_USERNAME: 101 | name: WebUI Username 102 | WB_PASSWORD: 103 | name: WebUI Password 104 | -------------------------------------------------------------------------------- /home_assistant/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrlt8/docker-wyze-bridge/bf893749b748f142199c9bce14fac44f8a661d6e/home_assistant/icon.png -------------------------------------------------------------------------------- /home_assistant/previous/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mrlt8/wyze-bridge:2.6.0 2 | -------------------------------------------------------------------------------- /home_assistant/previous/config.yml: -------------------------------------------------------------------------------- 1 | name: Docker Wyze Bridge (2.9.12 build) 2 | description: Previous build. Use the REBUILD button to pull latest image. 3 | slug: docker_wyze_bridge_previous 4 | url: https://github.com/mrlt8/docker-wyze-bridge 5 | image: mrlt8/wyze-bridge 6 | version: 2.9.12 7 | stage: deprecated 8 | arch: 9 | - armv7 10 | - aarch64 11 | - amd64 12 | startup: application 13 | boot: auto 14 | apparmor: false 15 | hassio_api: true 16 | ports: 17 | 1935/tcp: 1935 18 | 8554/tcp: 8554 19 | 8888/tcp: 8888 20 | 8189/udp: 8189 21 | 8889/tcp: 8889 22 | 5000/tcp: 5000 23 | ports_description: 24 | 1935/tcp: RTMP streams 25 | 8554/tcp: RTSP streams 26 | 8888/tcp: HLS streams 27 | 8189/udp: WebRTC ICE 28 | 8889/tcp: WebRTC streams 29 | 5000/tcp: Web-UI/Snapshots 30 | environment: 31 | IMG_DIR: media/wyze/img/ 32 | RECORD_PATH: media/wyze/{CAM_NAME} 33 | MQTT_DTOPIC: homeassistant 34 | map: 35 | - addon_config:rw 36 | - media:rw 37 | - ssl:ro 38 | services: 39 | - mqtt:want 40 | ingress: true 41 | ingress_port: 5000 42 | panel_icon: mdi:bridge 43 | options: 44 | ENABLE_AUDIO: true 45 | ON_DEMAND: true 46 | WB_AUTH: true 47 | MOTION_API: true 48 | MQTT: true 49 | CAM_OPTIONS: [] 50 | schema: 51 | WYZE_EMAIL: email? 52 | WYZE_PASSWORD: password? 53 | API_ID: match(\s*[a-fA-F0-9-]{36}\s*)? 54 | API_KEY: match(\s*[a-zA-Z0-9]{60}\s*)? 55 | WB_IP: str? 56 | REFRESH_TOKEN: str? 57 | ACCESS_TOKEN: str? 58 | NET_MODE: list(LAN|P2P|ANY)? 59 | SNAPSHOT: list(API|RTSP|RTSP15|RTSP30|RTSP60|RTSP180|RTSP300|Disable)? 60 | SNAPSHOT_FORMAT: str? 61 | IMG_TYPE: list(jpg|png)? 62 | IMG_DIR: str? 63 | ENABLE_AUDIO: bool? 64 | ON_DEMAND: bool? 65 | MOTION_API: bool? 66 | MOTION_INT: float(1.1,)? 67 | MOTION_START: bool? 68 | MOTION_WEBHOOKS: str? 69 | SUBSTREAM: bool? 70 | AUDIO_CODEC: list(COPY|AAC|LIBOPUS|MP3|PCM_MULAW|PCM_ALAW)? 71 | AUDIO_FILTER: str? 72 | LLHLS: bool? 73 | DISABLE_CONTROL: bool? 74 | RTSP_FW: bool? 75 | RECORD_ALL: bool? 76 | RECORD_LENGTH: int? 77 | RECORD_PATH: str? 78 | RECORD_FILE_NAME: str? 79 | MQTT: bool 80 | MQTT_HOST: str? 81 | MQTT_AUTH: str? 82 | MQTT_TOPIC: str? 83 | MQTT_DTOPIC: str? 84 | MQTT_RETRIES: int? 85 | FILTER_NAMES: str? 86 | FILTER_MODELS: str? 87 | FILTER_MACS: str? 88 | FILTER_BLOCK: bool? 89 | ROTATE_DOOR: bool? 90 | H264_ENC: str? 91 | FORCE_ENCODE: bool? 92 | IGNORE_OFFLINE: bool? 93 | OFFLINE_WEBHOOKS: bool? 94 | FRESH_DATA: bool? 95 | URI_MAC: bool? 96 | URI_SEPARATOR: list(-|_|#)? 97 | QUALITY: str? 98 | SUB_QUALITY: str? 99 | FORCE_FPS: int? 100 | SUB_RECORD: bool? 101 | FFMPEG_FLAGS: str? 102 | FFMPEG_CMD: str? 103 | LOG_LEVEL: list(INFO|DEBUG)? 104 | LOG_FILE: bool? 105 | LOG_TIME: bool? 106 | FFMPEG_LOGLEVEL: list(quiet|panic|fatal|error|warning|info|verbose|debug)? 107 | IGNORE_RES: int? 108 | BOA_ENABLED: bool? 109 | BOA_INTERVAL: int? 110 | BOA_TAKE_PHOTO: bool? 111 | BOA_PHOTO: bool? 112 | BOA_ALARM: bool? 113 | BOA_MOTION: str? 114 | BOA_COOLDOWN: int? 115 | CAM_OPTIONS: 116 | - CAM_NAME: str? 117 | AUDIO: bool? 118 | FFMPEG: str? 119 | LIVESTREAM: str? 120 | NET_MODE: str? 121 | ROTATE: bool? 122 | QUALITY: str? 123 | SUB_QUALITY: str? 124 | FORCE_FPS: int? 125 | RECORD: bool? 126 | SUB_RECORD: bool? 127 | SUBSTREAM: bool? 128 | MOTION_WEBHOOKS: str? 129 | MEDIAMTX: 130 | - match(^\w+=.*)? 131 | WB_HLS_URL: str? 132 | WB_RTMP_URL: str? 133 | WB_RTSP_URL: str? 134 | WB_WEBRTC_URL: str? 135 | WB_AUTH: bool? 136 | WB_USERNAME: str? 137 | WB_PASSWORD: str? 138 | -------------------------------------------------------------------------------- /home_assistant/previous/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrlt8/docker-wyze-bridge/bf893749b748f142199c9bce14fac44f8a661d6e/home_assistant/previous/icon.png -------------------------------------------------------------------------------- /home_assistant/previous/translations/en.yml: -------------------------------------------------------------------------------- 1 | configuration: 2 | API_ID: 3 | name: Key ID 4 | description: Optional, but must be used in combination with the API Key. 5 | API_KEY: 6 | name: API Key 7 | description: Optional, but must be used in combination with the Key ID. 8 | REFRESH_TOKEN: 9 | name: Refresh Token 10 | description: Use existing refresh token for authentication. 11 | ACCESS_TOKEN: 12 | name: Access Token 13 | description: Use existing access token for authentication. 14 | NET_MODE: 15 | name: Allowed Net Modes 16 | SNAPSHOT: 17 | name: Snapshot Mode 18 | ENABLE_AUDIO: 19 | name: Enable Audio For All Cameras 20 | SUBSTREAM: 21 | name: Enable sub-stream 22 | description: Create a secondary SD30 stream on "/cam-name-sub" 23 | FILTER_NAMES: 24 | name: Filter cameras by name 25 | FILTER_MODELS: 26 | name: Filter cameras by camera model 27 | FILTER_MACS: 28 | name: Filter cameras by MAC address 29 | FILTER_BLOCK: 30 | name: Invert Filter 31 | description: Block cameras in filter list 32 | AUDIO_CODEC: 33 | name: Audio Codec 34 | description: Cams with PCM audio will be re-encoded to AAC by default for RTSP compatibility. 35 | AUDIO_FILTER: 36 | name: FFmpeg Audio Filter 37 | description: Requires audio codec to be set to AAC or MP3. 38 | OFFLINE_IFTTT: 39 | name: IFTTT Webhook 40 | description: e.g. `EventName:Webhooks_Key`. 41 | ROTATE_DOOR: 42 | name: Rotate Doorbells 43 | description: Will rotate video 90 degrees clockwise. 44 | CAM_OPTIONS: 45 | name: Camera Specific Options 46 | FRESH_DATA: 47 | name: Pull Fresh Data From Wyze API 48 | description: Ignore local cache and pull fresh data from the wyze API. 49 | WEBRTC: 50 | name: Print WebRTC Credentials 51 | WB_SHOW_VIDEO: 52 | name: HLS Player in Web-UI 53 | MEDIAMTX: 54 | name: MediaMTX config 55 | description: Use `=` to specify the value and use `_` in place of spaces. 56 | BOA_ENABLED: 57 | name: Enable Boa HTTP* 58 | description: Enable http webserver on select cameras. *Req LAN and SD Card. 59 | BOA_INTERVAL: 60 | name: Boa Keep Alive 61 | description: The number of seconds between photos/keep alive. 62 | BOA_TAKE_PHOTO: 63 | name: Interval Photo 64 | description: Take a photo on the camera on Boa Keep Alive Interval. 65 | BOA_PHOTO: 66 | name: Pull Photo (MQTT Motion Alerts) 67 | description: Pull the HQ photo from the SD card via Boa. 68 | BOA_ALARM: 69 | name: Pull Alarm (MQTT Motion Alerts) 70 | description: Pull alarm/motion image from the SD card via Boa. 71 | BOA_COOLDOWN: 72 | name: Motion Cooldown 73 | description: Number of seconds to keep the motion flag set to true before resetting it. 74 | BOA_MOTION: 75 | name: Webhook on Boa Motion 76 | description: Make a Webhook/HTTP request to any url. e.g., http://localhost/motion?cam={cam_name} 77 | WB_HLS_URL: 78 | name: Custom HLS url 79 | WB_RTMP_URL: 80 | name: Custom RTMP url 81 | WB_RTSP_URL: 82 | name: Custom RTSP url 83 | WB_WEBRTC_URL: 84 | name: Custom WebRTC url 85 | RTSP_FW: 86 | name: Firmware RTSP 87 | description: Proxy additional RTSP stream from official RTSP firmware. 88 | DISABLE_CONTROL: 89 | name: Disable Control 90 | description: Disable camera control from the API/mqtt. 91 | WB_IP: 92 | name: Bridge IP 93 | description: Home Assistant IP for WebRTC ICE traffic. 94 | TOTP_KEY: 95 | name: TOTP Key 96 | description: Used to auto generate a TOTP code when needed. 97 | -------------------------------------------------------------------------------- /home_assistant/translations/en.yml: -------------------------------------------------------------------------------- 1 | configuration: 2 | API_ID: 3 | name: Key ID 4 | description: Optional, but must be used in combination with the API Key. 5 | API_KEY: 6 | name: API Key 7 | description: Optional, but must be used in combination with the Key ID. 8 | REFRESH_TOKEN: 9 | name: Refresh Token 10 | description: Use existing refresh token for authentication. 11 | ACCESS_TOKEN: 12 | name: Access Token 13 | description: Use existing access token for authentication. 14 | NET_MODE: 15 | name: Allowed Net Modes 16 | SNAPSHOT: 17 | name: Snapshot Mode 18 | ENABLE_AUDIO: 19 | name: Enable Audio For All Cameras 20 | SUBSTREAM: 21 | name: Enable sub-stream 22 | description: Create a secondary SD30 stream on "/cam-name-sub" 23 | FILTER_NAMES: 24 | name: Filter cameras by name 25 | FILTER_MODELS: 26 | name: Filter cameras by camera model 27 | FILTER_MACS: 28 | name: Filter cameras by MAC address 29 | FILTER_BLOCK: 30 | name: Invert Filter 31 | description: Block cameras in filter list 32 | AUDIO_CODEC: 33 | name: Audio Codec 34 | description: Cams with PCM audio will be re-encoded to AAC by default for RTSP compatibility. 35 | AUDIO_FILTER: 36 | name: FFmpeg Audio Filter 37 | description: Requires audio codec to be set to AAC or MP3. 38 | OFFLINE_IFTTT: 39 | name: IFTTT Webhook 40 | description: e.g. `EventName:Webhooks_Key`. 41 | ROTATE_DOOR: 42 | name: Rotate Doorbells 43 | description: Will rotate video 90 degrees clockwise. 44 | CAM_OPTIONS: 45 | name: Camera Specific Options 46 | FRESH_DATA: 47 | name: Pull Fresh Data From Wyze API 48 | description: Ignore local cache and pull fresh data from the wyze API. 49 | WEBRTC: 50 | name: Print WebRTC Credentials 51 | WB_SHOW_VIDEO: 52 | name: HLS Player in Web-UI 53 | MEDIAMTX: 54 | name: MediaMTX config 55 | description: Use `=` to specify the value and use `_` in place of spaces. 56 | BOA_ENABLED: 57 | name: Enable Boa HTTP* 58 | description: Enable http webserver on select cameras. *Req LAN and SD Card. 59 | BOA_INTERVAL: 60 | name: Boa Keep Alive 61 | description: The number of seconds between photos/keep alive. 62 | BOA_TAKE_PHOTO: 63 | name: Interval Photo 64 | description: Take a photo on the camera on Boa Keep Alive Interval. 65 | BOA_PHOTO: 66 | name: Pull Photo (MQTT Motion Alerts) 67 | description: Pull the HQ photo from the SD card via Boa. 68 | BOA_ALARM: 69 | name: Pull Alarm (MQTT Motion Alerts) 70 | description: Pull alarm/motion image from the SD card via Boa. 71 | BOA_COOLDOWN: 72 | name: Motion Cooldown 73 | description: Number of seconds to keep the motion flag set to true before resetting it. 74 | BOA_MOTION: 75 | name: Webhook on Boa Motion 76 | description: Make a Webhook/HTTP request to any url. e.g., http://localhost/motion?cam={cam_name} 77 | WB_HLS_URL: 78 | name: Custom HLS url 79 | WB_RTMP_URL: 80 | name: Custom RTMP url 81 | WB_RTSP_URL: 82 | name: Custom RTSP url 83 | WB_WEBRTC_URL: 84 | name: Custom WebRTC url 85 | RTSP_FW: 86 | name: Firmware RTSP 87 | description: Proxy additional RTSP stream from official RTSP firmware. 88 | DISABLE_CONTROL: 89 | name: Disable Control 90 | description: Disable camera control from the API/mqtt. 91 | WB_IP: 92 | name: Bridge IP 93 | description: Home Assistant IP for WebRTC ICE traffic. 94 | TOTP_KEY: 95 | name: TOTP Key 96 | description: Used to auto generate a TOTP code when needed. 97 | WB_AUTH: 98 | name: WebUI Auth 99 | description: Enable authentication for WebUI 100 | WB_USERNAME: 101 | name: WebUI Username 102 | WB_PASSWORD: 103 | name: WebUI Password 104 | -------------------------------------------------------------------------------- /repository.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docker-wyze-bridge", 3 | "url": "https://github.com/mrlt8/docker-wyze-bridge" 4 | } -------------------------------------------------------------------------------- /unraid/docker-wyze-bridge.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | docker-wyze-bridge 4 | mrlt8/wyze-bridge 5 | https://hub.docker.com/r/mrlt8/wyze-bridge 6 | 7 | latest 8 | Latest stable release. 9 | 10 | 11 | latest-hw 12 | Latest stable release for amd64 with additinal drivers for harware accelerated encoding. 13 | 14 | 15 | latest-qsv 16 | Latest stable release for amd64 with additinal drivers for QSV accelerated encoding. 17 | 18 | 19 | dev 20 | Latest development release for testing future changes. 21 | 22 | bridge 23 | https://github.com/mrlt8/docker-wyze-bridge 24 | https://github.com/mrlt8/docker-wyze-bridge 25 | WebRTC/RTSP/RTMP/LL-HLS bridge for Wyze cams in a docker container. 26 | As of April 2024, you will need to supply your own API Key and ID: 27 | https://support.wyze.com/hc/en-us/articles/16129834216731-Creating-an-API-Key 28 | 29 | HomeAutomation: 30 | http://[IP]:[PORT:5000] 31 | https://raw.githubusercontent.com/selfhosters/unRAID-CA-templates/master/templates/docker-wyze-bridge.xml 32 | https://raw.githubusercontent.com/selfhosters/unRAID-CA-templates/master/templates/img/wyze.png 33 | ko-fi 34 | https://ko-fi.com/mrlt8 35 | A unique API_KEY and API_ID which can be generated on the Wyze Developer Portal: 36 | https://developer-api-console.wyze.com/#/apikey/view 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 8554 46 | 1935 47 | 8888 48 | 8889 49 | 8189 50 | 5000 51 | ANY 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | True 60 | 61 | 62 | 63 | 64 | 65 | 66 | --------------------------------------------------------------------------------