diff --git a/.gitignore b/.gitignore index e040bb04..81ecabb9 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,6 @@ docker-compose.yml *.pickle *.env mfa_token +totp .vscode +tokens/ \ No newline at end of file diff --git a/README.md b/README.md index e6c367de..dbf38459 100644 --- a/README.md +++ b/README.md @@ -9,19 +9,16 @@ Docker container to expose a local RTMP, RTSP, and HLS stream for all your Wyze 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). -## Changes in v0.5.18 +## Changes in v0.6.0 -- New: `API_THUMB` ENV parameter to save a thumbnail from the Wyze API -- New: Show warnings on frame drops -- šŸ  Home Assistant: Improved config option compatibility - -## Changes in v0.5.17 - -- ARM Only: Switch to debian buster base image to avoid libseccomp2 related issues -- Fix: Additional checks for stale data -- šŸ  Home Assistant: Force refresh of cameras from wyze api to pull new thumbnails -- šŸ  Home Assistant: Add hass.io labels to docker image -- šŸ  Home Assistant: Add schema for *some* config options +- šŸ’„ BREAKING: Renamed `FILTER_MODE` to `FILTER_BLOCK` and will be disabled if blank or set to false. +- šŸ’„ BREAKING: Renamed `FILTER_MODEL` to `FILTER_MODELS` +- šŸ”Ø Reworked auth, caching, and other other code refactoring +- āœØ NEW: Refresh token when token expires - no need to 2FA when your session expires! +- āœØ NEW: Use seed to generate TOTP +- āœØ NEW: `DEBUG_FRAMES` ENV parameter to show all dropped frames +- āŖ CHANGE: Only show first lost/incomplete frame warning +- šŸ§ CHANGE: Switch all base images to debian buster for consistency [View older changes](https://github.com/mrlt8/docker-wyze-bridge/releases) @@ -155,13 +152,13 @@ environment: - Blacklisting: -You can reverse any of these whitelists into blacklists by adding _block, blacklist, exclude, ignore, or reverse_ to `FILTER_MODE`. +You can reverse any of these whitelists into blacklists by setting `FILTER_BLOCK`. ```yaml environment: .. - FILTER_NAMES=Bedroom - - FILTER_MODE=BLOCK + - FILTER_BLOCK=true ``` ## Multi-Factor Authentication @@ -248,6 +245,10 @@ Additional info: - SD - HD60 - HD - HD120 +## Still Image + +If you require a still image from the stream, you can configure the `API_THUMB` ENV option to grab a thumbnail from the wyze api which will be save to `/img/cam-name.jpg` on standard docker installs or `/config/www/cam-name.jpg` in Home Assistant mode. + ## Custom FFmpeg Commands You can pass a custom [command](https://ffmpeg.org/ffmpeg.html) to FFmpeg by using `FFMPEG_CMD` in your docker-compose.yml: diff --git a/app/CHANGELOG.md b/app/CHANGELOG.md index f6065f5b..0672d261 100755 --- a/app/CHANGELOG.md +++ b/app/CHANGELOG.md @@ -1,13 +1,10 @@ -# Changes in v0.5.18 +## Changes in v0.6.0 -- New: `API_THUMB` ENV parameter to save a thumbnail from the Wyze API -- New: Show warnings on frame drops -- šŸ  Home Assistant: Improved config option compatibility - -# Changes in v0.5.17 - -- ARM Only: Switch to debian buster base image to avoid libseccomp2 related issues -- Fix: Additional checks for stale data -- šŸ  Home Assistant: Force refresh of cameras from wyze api to pull new thumbnails -- šŸ  Home Assistant: Add hass.io labels to docker image -- šŸ  Home Assistant: Add schema for *some* config options +- šŸ’„ BREAKING: Renamed `FILTER_MODE` to `FILTER_BLOCK` and will be disabled if blank or set to false. +- šŸ’„ BREAKING: Renamed `FILTER_MODEL` to `FILTER_MODELS` +- šŸ”Ø Reworked auth and caching and other other code refactoring +- āœØ NEW: Use refresh token when token expires - no need to 2FA when your session expires! +- āœØ NEW: Use seed to generate TOTP +- āœØ NEW: `DEBUG_FRAMES` ENV parameter to show all dropped frames +- āŖ CHANGE: Only show first lost/incomplete frame warning +- šŸ§ CHANGE: Switch all base images to debian buster for consistency \ No newline at end of file diff --git a/app/DOCS.md b/app/DOCS.md index b511cd3d..4c41dfa2 100755 --- a/app/DOCS.md +++ b/app/DOCS.md @@ -2,4 +2,34 @@ (coming soon) +## URIs + +`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. + +e.g. 'Front Door' would be `/front-door` + +- RTMP: + +``` +rtmp://homeassistant.local:1935/camera-nickname +``` + +- RTSP: + +``` +rtsp://homeassistant.local:8554/camera-nickname +``` + +- HLS: + +``` +http://homeassistant.local:8888/camera-nickname/stream.m3u8 +``` + +- HLS can also be viewed in the browser using: + +``` +http://homeassistant.local:8888/camera-nickname +``` + Please visit [github.com/mrlt8/docker-wyze-bridge](https://github.com/mrlt8/docker-wyze-bridge) for additional information. diff --git a/app/Dockerfile b/app/Dockerfile index b4555e8c..48ceb787 100644 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -1,6 +1,6 @@ ARG ARM ARG ARCH=${ARM:+arm32v7} -FROM ${ARCH:-amd64}/python:3.9-slim as base +FROM ${ARCH:-amd64}/python:3.9-slim-buster as base FROM base as builder ENV PYTHONUNBUFFERED=1 @@ -13,7 +13,7 @@ RUN apt-get update \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* -RUN pip3 install --disable-pip-version-check --prefix=/build/usr/local requests supervisor https://github.com/mrlt8/wyzecam/archive/refs/heads/main.zip +RUN pip3 install --disable-pip-version-check --prefix=/build/usr/local mintotp requests supervisor https://github.com/mrlt8/wyzecam/archive/refs/heads/main.zip ADD https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-${FFMPEG_ARCH:-amd64}-static.tar.xz /tmp/ffmpeg.tar.xz ADD https://github.com/miguelangel-nubla/videoP2Proxy/archive/refs/heads/master.zip /tmp/tutk.zip RUN mkdir -p /build/app /build/tokens /build/img \ diff --git a/app/Dockerfile.arm b/app/Dockerfile.arm index c5fdbe88..6c9f553b 100644 --- a/app/Dockerfile.arm +++ b/app/Dockerfile.arm @@ -1,6 +1,6 @@ ARG ARM=1 ARG ARCH=${ARM:+arm32v7} -FROM ${ARCH:-amd64}/python:3.9-slim as base +FROM ${ARCH:-amd64}/python:3.9-slim-buster as base FROM base as builder ENV PYTHONUNBUFFERED=1 @@ -13,7 +13,7 @@ RUN apt-get update \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* -RUN pip3 install --disable-pip-version-check --prefix=/build/usr/local requests supervisor https://github.com/mrlt8/wyzecam/archive/refs/heads/main.zip +RUN pip3 install --disable-pip-version-check --prefix=/build/usr/local mintotp requests supervisor https://github.com/mrlt8/wyzecam/archive/refs/heads/main.zip ADD https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-${FFMPEG_ARCH:-amd64}-static.tar.xz /tmp/ffmpeg.tar.xz ADD https://github.com/miguelangel-nubla/videoP2Proxy/archive/refs/heads/master.zip /tmp/tutk.zip RUN mkdir -p /build/app /build/tokens /build/img \ @@ -27,6 +27,6 @@ RUN mkdir -p /build/app /build/tokens /build/img \ COPY wyze_bridge.py supervisord.conf /build/app/ FROM base -ENV PYTHONUNBUFFERED=1 RTSP_PROTOCOLS=tcp RTSP_READTIMEOUT=60s +ENV PYTHONUNBUFFERED=1 RTSP_PROTOCOLS=tcp RTSP_READTIMEOUT=30s RTSP_READBUFFERCOUNT=2048 COPY --from=builder /build / CMD [ "supervisord", "-c", "/app/supervisord.conf" ] \ No newline at end of file diff --git a/app/config.json b/app/config.json index 1a0ddca6..ca37dda3 100644 --- a/app/config.json +++ b/app/config.json @@ -4,7 +4,7 @@ "slug": "docker-wyze-bridge", "url": "http://github.com/mrlt8/docker-wyze-bridge", "image": "mrlt8/wyze-bridge", - "version": "0.5.18", + "version": "0.6.0", "arch": [ "armv7", "aarch64", @@ -43,12 +43,13 @@ "FRESH_DATA": "bool?", "IGNORE_OFFLINE": "bool?", "FILTER_NAMES": "str?", - "FILTER_MODEL": "str?", + "FILTER_MODELS": "str?", "FILTER_MACS": "str?", - "FILTER_MODE": "list(Include|Exclude)?", + "FILTER_BLOCK": "bool?", "FFMPEG_FLAGS": "str?", "FFMPEG_CMD": "str?", "QUALITY": "str?", + "DEBUG_FRAMES": "bool?", "DEBUG_FFMPEG": "bool?", "URI_SEPARATOR": "list(-|_|#)?", "DEBUG_LEVEL": "list(debug|info|warning|error)?", diff --git a/app/multi-arch.Dockerfile b/app/multi-arch.Dockerfile index 55bce9af..6d35b07c 100644 --- a/app/multi-arch.Dockerfile +++ b/app/multi-arch.Dockerfile @@ -1,9 +1,7 @@ -FROM amd64/python:3.9-slim as base_amd64 +FROM amd64/python:3.9-slim-buster as base_amd64 FROM arm32v7/python:3.9-slim-buster as base_arm ARG ARM=1 -FROM arm32v7/python:3.9-slim as base_arm64 -ARG ARM=1 -#FROM base_arm AS base_arm64 +FROM base_arm AS base_arm64 FROM base_$TARGETARCH as builder ENV PYTHONUNBUFFERED=1 @@ -16,7 +14,7 @@ RUN apt-get update \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* -RUN pip3 install --disable-pip-version-check --prefix=/build/usr/local requests supervisor https://github.com/mrlt8/wyzecam/archive/refs/heads/main.zip +RUN pip3 install --disable-pip-version-check --prefix=/build/usr/local mintotp requests supervisor https://github.com/mrlt8/wyzecam/archive/refs/heads/main.zip ADD https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-${FFMPEG_ARCH:-amd64}-static.tar.xz /tmp/ffmpeg.tar.xz ADD https://github.com/miguelangel-nubla/videoP2Proxy/archive/refs/heads/master.zip /tmp/tutk.zip RUN mkdir -p /build/app /build/tokens /build/img \ diff --git a/app/wyze_bridge.py b/app/wyze_bridge.py index 2a10b7b9..b88291f7 100644 --- a/app/wyze_bridge.py +++ b/app/wyze_bridge.py @@ -1,5 +1,6 @@ import gc import logging +import mintotp import os import pickle import subprocess @@ -20,22 +21,34 @@ class wyze_bridge: - def __init__(self): - print("\nšŸš€ STARTING DOCKER-WYZE-BRIDGE v0.5.18") + def __init__(self) -> None: self.token_path = "/tokens/" + + def run(self) -> None: + print("\nšŸš€ STARTING DOCKER-WYZE-BRIDGE v0.6.0") if os.environ.get("HASS"): print("\nšŸ  Home Assistant Mode") self.token_path = "/config/wyze-bridge/" os.makedirs("/config/www/", exist_ok=True) os.makedirs(self.token_path, exist_ok=True) - open(f"{self.token_path}mfa_token.txt", "w").close() - if self.env_bool("DEBUG_LEVEL"): - print(f'DEBUG_LEVEL set to {os.environ.get("DEBUG_LEVEL")}') - debug_level = getattr(logging, os.environ.get("DEBUG_LEVEL").upper(), 10) - logging.getLogger().setLevel(debug_level) - self.log = logging.getLogger("wyze_bridge") - self.log.setLevel(debug_level if "DEBUG_LEVEL" in os.environ else logging.INFO) + open(self.token_path + "mfa_token.txt", "w").close() + if self.env_bool("FILTER_MODE"): + print("\n\nāš ļø 'FILTER_MODE' DEPRECATED.\nUSE 'FILTER_BLOCK' INSTEAD\n") + if self.env_bool("FILTER_MODEL"): + print("\n\nāš ļø 'FILTER_MODEL' DEPRECATED.\nUSE 'FILTER_MODELS' INSTEAD\n") + self.user = self.get_wyze_data("user") + self.cameras = self.get_filtered_cams() + self.iotc = wyzecam.WyzeIOTC(max_num_av_channels=len(self.cameras)).__enter__() + # logging.debug(self.iotc.version) + for camera in self.cameras: + threading.Thread( + target=self.start_stream, + args=[camera], + name=camera.nickname, + ).start() + mode = {0: "P2P", 1: "RELAY", 2: "LAN"} + res = {"1": "1080p", "2": "360p", "3": "HD", "4": "SD"} model_names = { "WYZECP1_JEF": "PAN", "WYZEC1": "V1", @@ -44,140 +57,112 @@ def __init__(self): "WYZEDB3": "DOORBELL", "WVOD1": "OUTDOOR", } - res = {"1": "1080p", "2": "360p", "3": "HD", "4": "SD"} - def env_bool(self, env: str): + def env_bool(self, env: str) -> str: return os.environ.get(env, "").lower().replace("false", "") - def get_env(self, env: str): - return ( - [] - if not os.environ.get(env) - else [ + def env_list(self, env: str) -> list: + if "," in os.getenv(env, ""): + return [ x.strip("'\"\n ").upper().replace(":", "") - for x in os.environ[env].split(",") + for x in os.getenv(env).split(",") ] - if "," in os.environ[env] - else [os.environ[env].strip("'\"\n ").upper().replace(":", "")] - ) + return [os.getenv(env, "").strip("'\"\n ").upper().replace(":", "")] - def env_filter(self, cam): + def env_filter(self, cam) -> bool: return ( True - if cam.nickname.upper() in self.get_env("FILTER_NAMES") - or cam.mac in self.get_env("FILTER_MACS") - or cam.product_model in self.get_env("FILTER_MODEL") - or self.model_names.get(cam.product_model) in self.get_env("FILTER_MODEL") + if cam.nickname.upper() in self.env_list("FILTER_NAMES") + or cam.mac in self.env_list("FILTER_MACS") + or cam.product_model in self.env_list("FILTER_MODELS") + or self.model_names.get(cam.product_model) in self.env_list("FILTER_MODELS") else False ) def auth_wyze(self): - phone_id = str(wyzecam.api.uuid.uuid4()) - payload = { - "email": os.environ["WYZE_EMAIL"].strip("'\"\n "), - "password": wyzecam.api.triplemd5( - os.environ["WYZE_PASSWORD"].strip("'\"\n ") - ), - } - response = wyzecam.api.requests.post( - "https://auth-prod.api.wyze.com/user/login", - json=payload, - headers=wyzecam.api.get_headers(phone_id), - ) - response.raise_for_status() - if response.json()["mfa_options"] is not None: - mfa_token = f"{self.token_path}mfa_token" - mfa_token += ".txt" if os.getenv("HASS") else "" - self.log.warning( - f'šŸ” MFA Token ({response.json()["mfa_options"][0]}) Required\n\nšŸ“ Add verification code to {mfa_token}' - ) - while response.json()["access_token"] is None: - json_resp = response.json() - if "PrimaryPhone" in json_resp["mfa_options"]: - sms_resp = wyzecam.api.requests.post( - "https://auth-prod.api.wyze.com/user/login/sendSmsCode", - json={}, - params={ - "mfaPhoneType": "Primary", - "sessionId": json_resp["sms_session_id"], - "userId": json_resp["user_id"], - }, - headers=wyzecam.api.get_headers(phone_id), - ) - sms_resp.raise_for_status() - self.log.info(f"šŸ’¬ SMS code requested") - json_resp["mfa_options"] = "PrimaryPhone" - else: - json_resp["mfa_options"] = "TotpVerificationCode" - while True: - if os.path.exists(mfa_token) and os.path.getsize(mfa_token) > 0: - with open(mfa_token, "r+") as f: - verification_code = f.read().replace(" ", "").strip("'\"\n") - f.truncate(0) - self.log.info(f"šŸ”‘ Using {verification_code} for authentication") - try: - resp = wyzecam.api.requests.post( - "https://auth-prod.api.wyze.com/user/login", - json={ - "email": os.environ["WYZE_EMAIL"], - "password": wyzecam.api.triplemd5( - os.environ["WYZE_PASSWORD"] - ), - "mfa_type": json_resp["mfa_options"], - "verification_id": sms_resp.json()["session_id"] - if "sms_resp" in vars() - else json_resp["mfa_details"]["totp_apps"][0][ - "app_id" - ], - "verification_code": verification_code, - }, - headers=wyzecam.api.get_headers(phone_id), - ) - resp.raise_for_status() - if "access_token" in resp.json(): - response = resp - self.log.info(f"āœ… Verification code accepted!") - except Exception as ex: - if "400 Client Error" in str(ex): - self.log.warning("šŸš· Wrong Code?") - self.log.warning(f"Error: {ex}\n\nPlease try again!\n") - finally: - break + auth = wyzecam.login(os.environ["WYZE_EMAIL"], os.environ["WYZE_PASSWORD"]) + if not auth.mfa_options: + return auth + mfa_token = self.token_path + "mfa_token" + mfa_token += ".txt" if os.getenv("HASS") else "" + totp = self.token_path + "totp" + log.warning(f"šŸ” MFA Token Required") + while True: + if "PrimaryPhone" in auth.mfa_options: + mfa_type = "PrimaryPhone" + verification_id = wyzecam.api.send_sms_code(auth) + log.info("šŸ’¬ SMS code requested") + else: + mfa_type = "TotpVerificationCode" + verification_id = auth.mfa_details["totp_apps"][0]["app_id"] + if os.path.exists(totp) and os.path.getsize(totp) > 1: + with open(totp, "r") as f: + verification_code = mintotp.totp(f.read()) + else: + log.warning(f"\nšŸ“ Add verification code to {mfa_token}") + while not os.path.exists(mfa_token) or os.path.getsize(mfa_token) == 0: time.sleep(1) - return wyzecam.api_models.WyzeCredential.parse_obj( - dict(response.json(), phone_id=phone_id) - ) + with open(mfa_token, "r+") as f: + verification_code = f.read().replace(" ", "").strip("'\"\n") + f.truncate(0) + log.info(f"šŸ”‘ Using {verification_code} for authentication") + try: + mfa = wyzecam.api.requests.post( + "https://auth-prod.api.wyze.com/user/login", + json={ + "email": os.environ["WYZE_EMAIL"], + "password": wyzecam.api.triplemd5(os.environ["WYZE_PASSWORD"]), + "mfa_type": mfa_type, + "verification_id": verification_id, + "verification_code": verification_code, + }, + headers=wyzecam.api.get_headers(auth.phone_id), + ) + mfa.raise_for_status() + if "access_token" in mfa.json(): + log.info("āœ… Verification code accepted!") + return wyzecam.api_models.WyzeCredential.parse_obj( + dict(mfa.json(), phone_id=auth.phone_id) + ) + except Exception as ex: + if "400 Client Error" in str(ex): + log.warning("šŸš· Wrong Code?") + log.warning(f"Error: {ex}\n\nPlease try again!\n") - def get_wyze_data(self, name): - pkl_file = f"{self.token_path}{name}.pickle" + def get_wyze_data(self, name: str, refresh: bool = False): + pkl_file = self.token_path + name + ".pickle" try: + if "cam" in name and self.env_bool("API_THUMB"): + raise Exception("ā™»ļø Refreshing camera data for thumbnails") + if "auth" in name and refresh: + raise Exception("ā™»ļø Refresh auth tokens") with open(pkl_file, "rb") as f: pickle_data = pickle.load(f) - if self.env_bool("API_THUMB") and "cam" in name: - raise Exception("ā™»ļø Refreshing camera data for thumbnails") if self.env_bool("FRESH_DATA"): os.remove(pkl_file) - raise Exception(f"ā™»ļø FORCED REFRESH - Removing local '{name}' data") + raise Exception(f"ā™»ļø FORCED REFRESH - Removing local '{name}' data") if ( "user" in name and pickle_data.email.upper() != os.getenv("WYZE_EMAIL").upper() ): - for f in os.listdir(os.path.dirname(pkl_file)): - if f.endswith(os.path.splitext(pkl_file)[1]): - os.remove(os.path.dirname(pkl_file) + "/" + f) - raise Exception(f"šŸ•µļø Cached email doesn't match 'WYZE_EMAIL'") - self.log.info(f"šŸ“š Using '{name}' from local cache...") + for f in os.listdir(self.token_path): + if f.endswith("pickle"): + os.remove(self.token_path + f) + raise Exception("šŸ•µļø Cached email doesn't match 'WYZE_EMAIL'") + log.info(f"šŸ“š Using '{name}' from local cache...") return pickle_data except OSError: - self.log.info(f"šŸ” Could not find local cache for '{name}'") + log.info(f"šŸ” Could not find local cache for '{name}'") except Exception as ex: - self.log.warning(ex) + log.warning(ex) while True: if not hasattr(self, "auth") and "auth" not in name: self.auth = self.get_wyze_data("auth") try: - self.log.info(f"ā˜ļø Fetching '{name}' from the Wyze API...") - if "auth" in name: + log.info(f"ā˜ļø Fetching '{name}' from the Wyze API...") + if "auth" in name and refresh: + self.auth = data = wyzecam.api.refresh_token(self.auth) + elif "auth" in name: self.auth = data = self.auth_wyze() if "user" in name: data = wyzecam.get_user_info(self.auth) @@ -185,158 +170,114 @@ def get_wyze_data(self, name): data = wyzecam.get_camera_list(self.auth) with open(pkl_file, "wb") as f: pickle.dump(data, f) - self.log.info(f"šŸ’¾ Saving '{name}' to local cache...") + log.info(f"šŸ’¾ Saving '{name}' to local cache...") return data except AssertionError: - del self.auth - # ADD METHOD TO USE refresh_token - os.remove(f"{self.token_path}auth.pickle") - self.log.warning(f"āš ļø Error getting {name} - Removing auth data") - time.sleep(5) + log.warning(f"āš ļø Error getting {name} - Expired token?") + self.get_wyze_data("auth", True) except Exception as ex: if "400 Client Error" in str(ex): - self.log.warning("šŸš· Invalid credentials?") - self.log.warning(f"{ex}\nSleeping for 10s...") + log.warning("šŸš· Invalid credentials?") + log.warning(f"{ex}\nSleeping for 10s...") time.sleep(10) - def get_filtered_cams(self): + def clean_name(self, name: str) -> str: + uri_sep = "-" + if os.getenv("URI_SEPARATOR") in ("-", "_", "#"): + uri_sep = os.getenv("URI_SEPARATOR") + return name.replace(" ", uri_sep).replace("#", "").replace("'", "").upper() + + def save_api_thumb(self, camera) -> None: + try: + with wyzecam.api.requests.get(camera.thumbnail) as thumb: + thumb.raise_for_status() + log.info(f'ā˜ļø Pulling "{camera.nickname}" thumbnail') + p = "/" + "config/www" if os.getenv("HASS") else "img" + "/" + with open(p + self.clean_name(camera.nickname).lower() + ".jpg", "wb") as f: + f.write(thumb.content) + except Exception as ex: + log.warning(f"[{camera.nickname}] {ex}") + + def save_stream_still(self, sess) -> None: + log.info("Attempting to save still from stream...") + + def get_filtered_cams(self) -> list: cams = self.get_wyze_data("cameras") - cams = [ - cam for cam in cams if cam.__getattribute__("product_model") != "WVODB1" - ] for cam in cams: - if hasattr(cam, "dtls") and cam.dtls > 0: - self.log.warning( - f"šŸ’” DTLS enabled on FW: {cam.firmware_ver}. {cam.nickname} will be disabled." - ) + if getattr(cam, "dtls") is not None and getattr(cam, "dtls", 0) > 0: + log.warning(f"šŸ’” DTLS on {cam.nickname} FW:{cam.firmware_ver}") cams.remove(cam) - if os.getenv("FILTER_MODE", "").upper() in ( - "BLOCK", - "BLACKLIST", - "EXCLUDE", - "IGNORE", - "REVERSE", - ): + if cam.product_model == "WVOD1" or cam.product_model == "WYZEC1": + log.warning(f"šŸ’” {cam.product_model} not fully supported yet") + if self.env_bool("IGNORE_OFFLINE"): + cams.remove(cam) + total = len(cams) + if self.env_bool("FILTER_BLOCK") or self.env_bool("FILTER_MODE"): filtered = list(filter(lambda cam: not self.env_filter(cam), cams)) if len(filtered) > 0: - print( - f"\nšŸŖ„ BLACKLIST MODE ON \nšŸ STARTING {len(filtered)} OF {len(cams)} CAMERAS" - ) - return filtered - if any(key.startswith("FILTER_") for key in os.environ): + print("\nšŸŖ„ BLACKLIST MODE ON") + cams = filtered + elif any(key.startswith("FILTER_") for key in os.environ): filtered = list(filter(self.env_filter, cams)) if len(filtered) > 0: - print( - f"šŸŖ„ WHITELIST MODE ON \nšŸ STARTING {len(filtered)} OF {len(cams)} CAMERAS" - ) - return filtered - if len(cams) == 0: - print(f"\n\n āŒ COULD NOT FIND ANY CAMERAS!") - os.remove(f"{self.token_path}cameras.pickle") + print("šŸŖ„ WHITELIST MODE ON") + cams = filtered + if total == 0: + print("\n\n āŒ COULD NOT FIND ANY CAMERAS!") + os.remove(self.token_path + "cameras.pickle") time.sleep(30) sys.exit() - print(f"\nšŸ STARTING ALL ({len(cams)}) CAMERAS") + msg = f"{len(cams)} OF" if len(cams) < total else "ALL" + print(f"\nšŸŽ¬ STARTING {msg} {total} CAMERAS") return cams - def start_stream(self, camera): + def start_stream(self, camera) -> None: + uri = self.clean_name(camera.nickname) + iotc = [self.iotc.tutk_platform_lib, self.user, camera] + resolution = 3 if camera.product_model in "WYZEDB3" else 0 + bitrate = 120 + res = "HD" + if self.env_bool("API_THUMB") and getattr(camera, "thumbnail", False): + self.save_api_thumb(camera) + if self.env_bool("QUALITY"): + quality = os.environ["QUALITY"] + if "SD" in quality[:2].upper(): + resolution += 1 + res = "SD" + if quality[2:].isdigit() and 30 <= int(quality[2:]) <= 255: + bitrate = int(quality[2:]) + iotc.extend((resolution, bitrate)) while True: try: - if camera.product_model == "WVOD1" or camera.product_model == "WYZEC1": - self.log.warning( - f"Wyze {camera.product_model} may not be fully supported yet" - ) - if self.env_bool("IGNORE_OFFLINE"): - sys.exit() - self.log.info( - f"Use a custom filter to block or IGNORE_OFFLINE to ignore this camera" - ) - time.sleep(60) - iotc = [self.iotc.tutk_platform_lib, self.user, camera] - resolution = 3 if camera.product_model in "WYZEDB3" else 0 - bitrate = 120 - res = "HD" - if self.env_bool("QUALITY"): - if "SD" in os.environ["QUALITY"][:2].upper(): - resolution += 1 - res = "SD" - if ( - os.environ["QUALITY"][2:].isdigit() - and 30 <= int(os.environ["QUALITY"][2:]) <= 255 - ): - bitrate = int(os.environ["QUALITY"][2:]) - iotc.extend((resolution, bitrate)) with wyzecam.iotc.WyzeIOTCSession(*iotc) as sess: if sess.session_check().mode != 2: if self.env_bool("LAN_ONLY"): - raise Exception("ā˜ļø NON-LAN MODE - Will try again...") - self.log.warning( - f'ā˜ļø WARNING: Camera is connected via "{"P2P" if sess.session_check().mode ==0 else "Relay" if sess.session_check().mode == 1 else "LAN" if sess.session_check().mode == 2 else "Other ("+sess.session_check().mode+")" } mode". Stream may consume additional bandwidth!' + raise Exception("ā˜ļø NON-LAN MODE. WILL try again...") + log.warning( + f'ā˜ļø WARNING: Camera is connected via "{self.mode.get(sess.session_check().mode,f"UNKNOWN ({sess.session_check().mode})")} mode". Stream may consume additional bandwidth!' ) - if sess.camera.camera_info["videoParm"]: + if self.env_bool("STREAM_THUMB"): + self.save_stream_still(sess) + if sess.camera.camera_info.get("videoParm", False): + vidparm = sess.camera.camera_info["videoParm"] if self.env_bool("DEBUG_LEVEL"): - self.log.info( - f"[videoParm] {sess.camera.camera_info['videoParm']}" - ) - stream = ( - ( - self.res[ - sess.camera.camera_info["videoParm"]["resolution"] - ] - if sess.camera.camera_info["videoParm"]["resolution"] - in self.res - else f"RES-{sess.camera.camera_info['videoParm']['resolution']}" - ) - + f" {sess.camera.camera_info['videoParm']['bitRate']}kb/s Stream" + log.info(f"[videoParm] {vidparm}") + res = self.res.get( + vidparm["resolution"], f"RES-{vidparm['resolution']}" ) + stream = f"{res} {vidparm['bitRate']}kb/s Stream" elif self.env_bool("QUALITY"): stream = f"{res} {bitrate}kb/s Stream" else: stream = "Stream" - uri = self.clean_name(camera.nickname) - self.log.info( - f'šŸŽ‰ Starting {stream} for WyzeCam {self.model_names.get(camera.product_model) if self.model_names.get(camera.product_model) else camera.product_model} in "{"P2P" if sess.session_check().mode ==0 else "Relay" if sess.session_check().mode == 1 else "LAN" if sess.session_check().mode == 2 else "Other ("+sess.session_check().mode+")" } mode" FW: {sess.camera.camera_info["basicInfo"]["firmware"]} IP: {camera.ip} WiFi: {sess.camera.camera_info["basicInfo"]["wifidb"]}%' - ) - cmd = ( - (os.getenv(f"FFMPEG_CMD_{uri}").strip("'\"\n ")).split() - if f"FFMPEG_CMD_{uri}" in os.environ - else (os.environ["FFMPEG_CMD"].strip("'\"\n ")).split() - if self.env_bool("FFMPEG_CMD") - else ["-loglevel"] - + ( - ["verbose"] - if self.env_bool("DEBUG_FFMPEG") - else ["fatal", "-hide_banner", "-nostats"] - ) - + ( - os.getenv(f"FFMPEG_FLAGS_{uri}").split() - if f"FFMPEG_FLAGS_{uri}" in os.environ - else os.getenv("FFMPEG_FLAGS").split() - if self.env_bool("FFMPEG_FLAGS") - else [] - ) - + [ - "-i", - "-", - "-vcodec", - "copy", - "-rtsp_transport", - os.getenv("RTSP_PROTOCOLS", "tcp"), - "-f", - "rtsp", - "rtsp://" - + ( - "0.0.0.0" + os.getenv("RTSP_RTSPADDRESS") - if os.getenv("RTSP_RTSPADDRESS", "").startswith(":") - else os.getenv("RTSP_RTSPADDRESS") - if os.getenv("RTSP_RTSPADDRESS") - else "0.0.0.0:8554" - ), - ] + log.info( + f'šŸŽ‰ Starting {stream} for WyzeCam {self.model_names.get(camera.product_model,camera.product_model)} in "{self.mode.get(sess.session_check().mode,f"UNKNOWN ({sess.session_check().mode})")} mode" FW: {sess.camera.camera_info["basicInfo"]["firmware"]} IP: {camera.ip} WiFi: {sess.camera.camera_info["basicInfo"]["wifidb"]}%' ) + cmd = self.get_ffmpeg_cmd(uri) if "ffmpeg" not in cmd[0].lower(): cmd.insert(0, "ffmpeg") if self.env_bool("DEBUG_FFMPEG"): - self.log.info(f"[FFMPEG_CMD] {' '.join(cmd)}") + log.info(f"[FFMPEG_CMD] {' '.join(cmd)}") cmd[-1] = ( cmd[-1] + ("" if cmd[-1][-1] == "/" else "/") + uri.lower() ) @@ -347,83 +288,74 @@ def start_stream(self, camera): raise Exception("FFMPEG closed") ffmpeg.stdin.write(frame) except Exception as ex: - self.log.info(f"Closing FFMPEG...") - ffmpeg.terminate() - time.sleep(0.5) raise Exception(f"[FFMPEG] {ex}") except Exception as ex: - self.log.info(f"{ex}") + log.info(ex) if str(ex) in "Authentication did not succeed! {'connectionRes': '2'}": - self.log.warning("Expired ENR? Removing local 'cameras' cache...") - os.remove(f"{self.token_path}cameras.pickle") - self.log.warning( - "Restart container to fetch new data or use 'FRESH_DATA' if error persists" - ) - time.sleep(10) + log.warning("Expired ENR? Removing local 'cameras' cache...") + os.remove(self.token_path + "cameras.pickle") sys.exit() if str(ex) in "IOTC_ER_CAN_NOT_FIND_DEVICE": - self.log.info(f"Camera firmware may be incompatible.") + log.info("Camera firmware may be incompatible") if self.env_bool("IGNORE_OFFLINE"): sys.exit() time.sleep(60) if str(ex) in "IOTC_ER_DEVICE_OFFLINE": if self.env_bool("IGNORE_OFFLINE"): - self.log.info( - f"šŸŖ¦ Camera is offline. Will NOT try again until container restarts." - ) + log.info("šŸŖ¦ Camera is offline. Will NOT try again.") sys.exit() offline_time = ( (offline_time + 10 if offline_time < 600 else 30) if "offline_time" in vars() else 10 ) - self.log.info( - f"šŸ’€ Camera is offline. Will retry again in {offline_time}s." - ) + log.info(f"šŸ‘» Camera offline. WILL retry in {offline_time}s.") time.sleep(offline_time) finally: while "ffmpeg" in locals() and ffmpeg.poll() is None: - self.log.info(f"Cleaning up FFMPEG...") + log.info("Cleaning up FFMPEG...") ffmpeg.kill() - time.sleep(0.5) ffmpeg.wait() gc.collect() - def clean_name(self, name: str): - uri_sep = "-" - if os.getenv("URI_SEPARATOR") in ("-", "_", "#"): - uri_sep = os.getenv("URI_SEPARATOR") - return name.replace(" ", uri_sep).replace("#", "").replace("'", "").upper() - - def run(self): - self.user = self.get_wyze_data("user") - self.cameras = self.get_filtered_cams() - self.iotc = wyzecam.WyzeIOTC(max_num_av_channels=len(self.cameras)).__enter__() - # logging.debug(self.iotc.version) - for camera in self.cameras: - if ( - self.env_bool("API_THUMB") - and hasattr(camera, "thumbnail") - and camera.thumbnail is not None - ): - try: - with wyzecam.api.requests.get(camera.thumbnail) as thumb: - thumb.raise_for_status() - self.log.info( - f"ā˜ļø Pulling thumbnail for {camera.nickname} from the Wyze API..." - ) - with open( - f"/{'config/www' if os.getenv('HASS') else 'img' }/{self.clean_name(camera.nickname).lower()}.jpg", - "wb", - ) as f: - f.write(thumb.content) - except Exception as ex: - self.log.warning(f"[{camera.nickname}] {ex}") - threading.Thread( - target=self.start_stream, - args=[camera], - name=camera.nickname, - ).start() + def get_ffmpeg_cmd(self, uri): + return ( + (os.getenv(f"FFMPEG_CMD_{uri}").strip("'\"\n ")).split() + if f"FFMPEG_CMD_{uri}" in os.environ + else (os.environ["FFMPEG_CMD"].strip("'\"\n ")).split() + if self.env_bool("FFMPEG_CMD") + else ["-loglevel"] + + ( + ["verbose"] + if self.env_bool("DEBUG_FFMPEG") + else ["fatal", "-hide_banner", "-nostats"] + ) + + ( + os.getenv(f"FFMPEG_FLAGS_{uri}").split() + if f"FFMPEG_FLAGS_{uri}" in os.environ + else os.getenv("FFMPEG_FLAGS").split() + if self.env_bool("FFMPEG_FLAGS") + else [] + ) + + [ + "-i", + "-", + "-vcodec", + "copy", + "-rtsp_transport", + os.getenv("RTSP_PROTOCOLS", "tcp"), + "-f", + "rtsp", + "rtsp://" + + ( + "0.0.0.0" + os.getenv("RTSP_RTSPADDRESS") + if os.getenv("RTSP_RTSPADDRESS", "").startswith(":") + else os.getenv("RTSP_RTSPADDRESS") + if os.getenv("RTSP_RTSPADDRESS") + else "0.0.0.0:8554" + ), + ] + ) if __name__ == "__main__": @@ -435,7 +367,14 @@ def run(self): stream=sys.stdout, level=logging.WARNING, ) - warnings.simplefilter("always") + if wyze_bridge().env_bool("DEBUG_LEVEL"): + debug_level = getattr(logging, os.getenv("DEBUG_LEVEL").upper(), 10) + logging.getLogger().setLevel(debug_level) + log = logging.getLogger("wyze_bridge") + log.setLevel(debug_level if "DEBUG_LEVEL" in os.environ else logging.INFO) + + if wyze_bridge().env_bool("DEBUG_FRAMES"): + warnings.simplefilter("always") warnings.formatwarning = lambda msg, *args, **kwargs: f"WARNING: {msg}" logging.captureWarnings(True)