Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,6 @@
*.env
*.pyc
!/app/.env
wrapped
kinesis
demo
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,8 @@ The container can be run on its own, in [Portainer](https://github.com/mrlt8/doc
| Wyze Cam Outdoor v2 | HL_WCO2 | ✅ | 4.48.4.x |
| Wyze Cam Doorbell | WYZEDB3 | ✅ | 4.25.1.x |
| Wyze Cam Doorbell v2 [2K] | HL_DB2 | ✅ | 4.51.1.x |
| Wyze Cam Doorbell Pro 2 | AN_RDB1 | | - |
| Wyze Battery Cam Pro | AN_RSCW | [⚠️](https://github.com/mrlt8/docker-wyze-bridge/issues/1011) | - |
| Wyze Cam Doorbell Pro 2 | AN_RDB1 | | - |
| Wyze Battery Cam Pro | AN_RSCW | | - |
| Wyze Cam Flood Light Pro [2K] | LD_CFP | [⚠️](https://github.com/mrlt8/docker-wyze-bridge/issues/822) | - |

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In theory, this should work now as it uses a similar API? I will give it a shot and see how it goes 🙂

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should in theory work for all cameras using KVS directly, I haven't gone threw their official list in the web app/android app to see what this fixes.

| Wyze Cam Doorbell Pro | GW_BE1 | [⚠️](https://github.com/mrlt8/docker-wyze-bridge/issues/276) | - |
| Wyze Cam OG | GW_GC1 | [⚠️](https://github.com/mrlt8/docker-wyze-bridge/issues/677) | - |
Expand Down
15 changes: 15 additions & 0 deletions app/frontend.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
import subprocess
import time
from functools import wraps
from pathlib import Path
Expand All @@ -7,6 +8,7 @@
from flask import (
Flask,
Response,
abort,
make_response,
redirect,
render_template,
Expand All @@ -24,6 +26,11 @@ def create_app():
app = Flask(__name__)
wb = WyzeBridge()
try:
subprocess.Popen(

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not a huge fan of this. Can we start the Python app and the WHEP Proxy in the Dockerfile simultaneously? This could add extra latency to starting a camera stream.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't blocking the stream from starting, this starts up when flask starts.

["whep_proxy"],
stdout=None, # None means inherit from parent process
stderr=None # None means inherit from parent process
)
wb.start()
except RuntimeError as ex:
print(ex)
Expand Down Expand Up @@ -252,6 +259,14 @@ def iptv_playlist():
resp = make_response(render_template("m3u8.html", cameras=cameras))
resp.headers.set("content-type", "application/x-mpegURL")
return resp

@app.route("/start_mtx_proxy/<string:cam_name>")
def start_kvs_stream(cam_name: str):
status = wb.api.setup_mtx_proxy(cam_name)
if not status:
return abort(404)

return redirect(f"http://localhost:8080/whep/{cam_name}", code=201)

return app

Expand Down
2 changes: 1 addition & 1 deletion app/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@ pydantic==2.9.*
python-dotenv==1.0.*
requests==2.32.*
PyYAML==6.0.*
xxtea==3.3.*
xxtea==3.3.*
6 changes: 4 additions & 2 deletions app/wyze_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,10 @@ def setup_streams(self):
self.add_substream(cam, options)
stream = WyzeStream(cam, options)
stream.rtsp_fw_enabled = self.rtsp_fw_proxy(cam, stream)
if cam.is_kvs:
self.api.setup_mtx_proxy(cam_name=cam.name_uri, uri=stream.uri)

self.mtx.add_path(stream.uri, not options.reconnect)
self.mtx.add_path(stream.uri, not options.reconnect, cam.is_kvs)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be moved into the above if cam.is_kvs statement? It's likely I'm misunderstanding something here, though 🙂

Suggested change
self.mtx.add_path(stream.uri, not options.reconnect, cam.is_kvs)
if cam.is_kvs:
self.api.setup_mtx_proxy(cam_name=cam.name_uri, uri=stream.uri)
self.mtx.add_path(stream.uri, not options.reconnect, cam.is_kvs)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They are doing a couple different things, the setup call is calling the wyze API to get the KVS setup started in the background for the proxy. The add_path call needs the is_kvs bool so it knows that it has to add a source path to MTX for the proxy instead of using the event bus.

if env_cam("record", cam.name_uri):
self.mtx.record(stream.uri)
self.streams.add(stream)
Expand All @@ -95,7 +97,7 @@ def add_substream(self, cam: WyzeCamera, options: WyzeStreamOptions):
record = bool(env_cam("sub_record", cam.name_uri))
sub_opt = replace(options, substream=True, quality=quality, record=record)
sub = WyzeStream(cam, sub_opt)
self.mtx.add_path(sub.uri, not options.reconnect)
self.mtx.add_path(sub.uri, not options.reconnect, cam.is_kvs)
self.streams.add(sub)

def clean_up(self, *_):
Expand Down
3 changes: 2 additions & 1 deletion app/wyzebridge/mtx_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import os
import select

from wyzebridge.wyze_stream import WyzeStream
from wyzebridge.logging import logger
from wyzebridge.mqtt import update_mqtt_state

Expand All @@ -21,7 +22,7 @@ class RtspEvent:

def __init__(self, streams):
self.pipe = 0
self.streams = streams
self.streams: dict[str, WyzeStream] = streams
self.buf: str = ""
self.open_pipe()

Expand Down
23 changes: 15 additions & 8 deletions app/wyzebridge/mtx_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from wyzebridge.bridge_utils import env_bool
from wyzebridge.logging import logger

MTX_CONFIG = "/app/mediamtx.yml"
MTX_CONFIG = "./mediamtx.yml"

RECORD_LENGTH = env_bool("RECORD_LENGTH", "60s")
RECORD_KEEP = env_bool("RECORD_KEEP", "0s")
Expand Down Expand Up @@ -90,6 +90,7 @@ def _setup_path_defaults(self):
mtx.set("pathDefaults.recordPath", record_path)
mtx.set("pathDefaults.recordSegmentDuration", RECORD_LENGTH)
mtx.set("pathDefaults.recordDeleteAfter", RECORD_KEEP)
mtx.set("logLevel", "debug")

def setup_auth(self, api: Optional[str], stream: Optional[str]):
publisher = [
Expand All @@ -110,14 +111,18 @@ def setup_auth(self, api: Optional[str], stream: Optional[str]):
for client in parse_auth(stream):
mtx.add("authInternalUsers", client)

def add_path(self, uri: str, on_demand: bool = True):
def add_path(self, uri: str, on_demand: bool = True, is_kvs: bool = False):
with MtxInterface() as mtx:
if on_demand:
cmd = f"bash -c 'echo $MTX_PATH,{{}}! > /tmp/mtx_event'"
mtx.set(f"paths.{uri}.runOnDemand", cmd.format("start"))
mtx.set(f"paths.{uri}.runOnUnDemand", cmd.format("stop"))
if is_kvs:
mtx.set(f"paths.{uri}.source", f"whep://localhost:8080/whep/{uri}")
mtx.set(f"paths.{uri}.sourceOnDemand", True)
else:
mtx.set(f"paths.{uri}", {})
if on_demand:
cmd = f"bash -c 'echo $MTX_PATH,{{}}! > /tmp/mtx_event'"
mtx.set(f"paths.{uri}.runOnDemand", cmd.format("start"))
mtx.set(f"paths.{uri}.runOnUnDemand", cmd.format("stop"))
else:
mtx.set(f"paths.{uri}", {})

def add_source(self, uri: str, value: str):
with MtxInterface() as mtx:
Expand All @@ -137,7 +142,9 @@ def start(self):
if self.sub_process:
return
logger.info(f"[MTX] starting MediaMTX {getenv('MTX_TAG')}")
self.sub_process = Popen(["/app/mediamtx", "/app/mediamtx.yml"])
self.sub_process = Popen(
["./mediamtx", "./mediamtx.yml"], stdout=None, stderr=None

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason to add stdout=None and stderr=None, considering the fact it wasn't there originally?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, without that you don't see the output from MediaMTX in the docker output

)

def stop(self):
if not self.sub_process:
Expand Down
34 changes: 31 additions & 3 deletions app/wyzebridge/wyze_api.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import contextlib
import json
import pickle
import urllib
import requests
from datetime import datetime
from functools import wraps
from os import environ, utime
Expand All @@ -9,15 +11,21 @@
from time import sleep, time
from typing import Any, Callable, Optional
from urllib.parse import parse_qs, urlparse

from wyzecam.kinesis.wpk_stream_info_model import Stream
import wyzecam
from requests import get
from requests.exceptions import ConnectionError, HTTPError, RequestException
from wyzebridge.auth import get_secret
from wyzebridge.bridge_utils import env_bool, env_filter
from wyzebridge.config import IMG_PATH, MOTION, TOKEN_PATH
from wyzebridge.logging import logger
from wyzecam.api import RateLimitError, WyzeAPIError, post_device
from wyzecam.api import (
RateLimitError,
WyzeAPIError,
get_camera_stream,
post_device,
wakeup_kvs_camera,
)
from wyzecam.api_models import WyzeAccount, WyzeCamera, WyzeCredential


Expand Down Expand Up @@ -373,7 +381,7 @@ def set_device_info(self, cam: WyzeCamera, params: dict):
post_device(self.auth, "set_device_Info", params, api_version=1)
return {"status": "success", "response": "success"}
except ValueError as ex:
error = f'{ex.args[0].get("code")}: {ex.args[0].get("msg")}'
error = f"{ex.args[0].get('code')}: {ex.args[0].get('msg')}"
logger.error(f"[CONTROL] ERROR: {error}")
return {"status": "error", "response": f"{error}"}

Expand All @@ -393,6 +401,26 @@ def clear_cache(self, name: Optional[str] = None):
for token_file in Path(TOKEN_PATH).glob("*.pickle"):
token_file.unlink()

def setup_mtx_proxy(self, cam_name: str, uri: str) -> bool:
if not (cam := self.get_camera(cam_name, True)):
return False
logger.info(f"🎉 Starting KVS Stream for MTX - {cam.nickname}")
kvs_stream: Stream = get_camera_stream(
auth_info=self.auth,
camera=cam,
)
kvs_stream.params.signaling_url = urllib.parse.unquote(
kvs_stream.params.signaling_url
)
requests.post(
f"http://localhost:8080/websocket/{uri}",
json=kvs_stream.params.model_dump(),
headers={"Content-Type": "application/json"},
)
sleep(1)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why?
Not saying these needs to be removed - but does the camera need time to "wake up"? Surely there's a better way to tell if it's woken up instead of using a sleep() however. Being said, IoT devices never cease to amaze me when it comes to the strange ways in which they work 🙂

wakeup_kvs_camera(auth_info=self.auth, camera=cam)
return True


def url_timestamp(url: str) -> int:
try:
Expand Down
1 change: 1 addition & 0 deletions app/wyzebridge/wyze_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ def get_info(self, item: Optional[str] = None) -> dict:
"substream": self.options.substream,
"model_name": self.camera.model_name,
"is_2k": self.camera.is_2k,
"is_kvs": self.camera.is_kvs,
"rtsp_fw": self.camera.rtsp_fw,
"rtsp_fw_enabled": self.rtsp_fw_enabled,
"is_battery": self.camera.is_battery,
Expand Down
53 changes: 52 additions & 1 deletion app/wyzecam/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from typing import Any, Optional

from requests import PreparedRequest, Response, get, post
from wyzecam.kinesis.wpk_stream_info_model import Stream
from wyzecam.api_models import WyzeAccount, WyzeCamera, WyzeCredential

IOS_VERSION = getenv("IOS_VERSION")
Expand All @@ -17,6 +18,9 @@
AUTH_API = "https://auth-prod.api.wyze.com"
WYZE_API = "https://api.wyzecam.com/app"
CLOUD_API = "https://app-core.cloud.wyze.com/app"
NEW_WYZE_API = "https://app.wyzecam.com/app"
KVS_API = "https://kvs-service.wyzecam.com/app"
DEICEMANAGEMENT_API = "https://devicemgmt-service.wyze.com"
SC_SV = {
"default": {
"sc": "9f275790cab94a72bd206c8876429f3c",
Expand All @@ -39,7 +43,10 @@
"sv": "e8e1db44128f4e31a2047a8f5f80b2bd",
},
}
APP_KEY = {"9319141212m2ik": "wyze_app_secret_key_132"}
APP_KEY = {
"9319141212m2ik": "wyze_app_secret_key_132",
"strv_e7f78e9e7738dc50": "gbJojEBViLklgwyyDikx5ztSvKBXI5oU",
}


class AccessTokenError(Exception):
Expand Down Expand Up @@ -250,6 +257,49 @@ def post_device(
return validate_resp(resp)


def wakeup_kvs_camera(auth_info: WyzeCredential, camera: WyzeCamera):
url = f"{DEICEMANAGEMENT_API}/device-management/api/action/run_action"
payload = {
"targetInfo": {
"id": camera.mac,
"type": "DEVICE",
"productModel": camera.product_model,
},
"capabilities": [
{
"name": "iot-device",
"functions": [{"name": "wakeup", "in": {"wakeup-live-view": True}}],
}
],
"nonce": int(time.time() * 1000),
"transactionId": uuid.uuid4().hex,
}

payload = sort_dict(payload)
headers = sign_payload(auth_info, "9319141212m2ik", payload)
resp = post(url, data=payload, headers=headers)
validate_resp(resp)

def get_camera_stream(auth_info: WyzeCredential, camera: WyzeCamera) -> Stream:
"""Get the camera stream."""
url = f"{NEW_WYZE_API}/v4/camera/get_streams"
payload = {
"device_list": [
{
"device_id": camera.mac,
"device_model": camera.product_model,
"provider": "webrtc",
"parameters": {"use_trickle": True},
}
],
"nonce": int(time.time() * 1000),
}
payload = sort_dict(payload)
headers = sign_payload(auth_info, "9319141212m2ik", payload)
resp = post(url, data=payload, headers=headers)
return Stream(**validate_resp(resp)[0])


def get_cam_webrtc(auth_info: WyzeCredential, mac_id: str) -> dict:
"""Get webrtc for camera."""
if not auth_info.access_token:
Expand Down Expand Up @@ -350,6 +400,7 @@ def sign_payload(auth_info: WyzeCredential, app_id: str, payload: str) -> dict:
"appinfo": f"wyze_ios_{APP_VERSION}",
"appversion": APP_VERSION,
"access_token": auth_info.access_token,
"authorization": auth_info.access_token,
"appid": app_id,
"env": "prod",
"signature2": sign_msg(app_id, payload, auth_info.access_token),
Expand Down
8 changes: 6 additions & 2 deletions app/wyzecam/api_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,14 @@
"HL_PANP",
"WVOD1",
"HL_WCO2",
"AN_RSCW",
"WYZEDB3",
"HL_DB2",
"GW_BE1",
"AN_RDB1",
}


KVS_CAMS = {"AN_RSCW", "AN_RDB1", "HL_CAM4", "WYZE_CAKP2JFUS"}

# known 2k cameras
PRO_CAMS = {"HL_CAM3P", "HL_PANP", "HL_CAM4", "HL_DB2", "HL_CFL2"}

Expand Down Expand Up @@ -163,6 +163,10 @@ def webrtc_support(self) -> bool:
@property
def is_2k(self) -> bool:
return self.product_model in PRO_CAMS or self.model_name.endswith("Pro")

@property
def is_kvs(self) -> bool:
return self.product_model in KVS_CAMS

@property
def is_floodlight(self) -> bool:
Expand Down
2 changes: 1 addition & 1 deletion app/wyzecam/iotc.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import time
import warnings
from ctypes import CDLL, c_int
from typing import Any, Iterator, Optional, Union
from typing import Iterator, Optional, Union

from wyzecam.api_models import WyzeAccount, WyzeCamera
from wyzecam.tutk import tutk, tutk_ioctl_mux, tutk_protocol
Expand Down
36 changes: 36 additions & 0 deletions app/wyzecam/kinesis/wpk_stream_info_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from typing import Dict, List, Optional
from pydantic import BaseModel, Field


class IceServer(BaseModel):
url: str
username: str = ""
credential: str = ""


class ParamsBean(BaseModel):
signaling_url: str = ""
auth_token: str = ""
ice_servers: List[IceServer] = Field(default_factory=list)


class PropertyBean(BaseModel):
property_data: Dict[str, int] = Field(default_factory=dict, alias="property")


class Stream(BaseModel):
property: PropertyBean
device_id: str
provider: str
params: ParamsBean


class WpkStreamInfo(BaseModel):
code: str
ts: int
msg: str
data: List[Stream]
traceId: Optional[str] = None

class Config:
allow_population_by_field_name = True
Loading