diff --git a/README.md b/README.md index 688f0a5c..a65f2de7 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,15 @@ You can then use the web interface at `http://localhost:5000` where localhost is See [basic usage](#basic-usage) for additional information or visit the [wiki page](https://github.com/mrlt8/docker-wyze-bridge/wiki/Home-Assistant) for additional information on using the bridge as a Home Assistant Add-on. +## What's Changed in v2.9.2 + +- Improved video connection stability and audio sync. #1175 #1196 #1194 #1193 #1186 Thanks @vipergts450! +- FIX: Remove quotes from credentials #1158 +- NEW: `FORCE_FPS` option for all cameras #1161 +- Home Assistant: Add `FORCE_FPS` option #1161 +- Home Assistant: Ignore whitespaces in api key/id #1188 Thanks @richh1! + + ## What's Changed in v2.9.1 - FIX: Setting bitrate higher than 255 would not report correctly (#1185) Thanks @Anc0dia! @@ -122,7 +131,8 @@ See [basic usage](#basic-usage) for additional information or visit the [wiki pa [View previous changes](https://github.com/mrlt8/docker-wyze-bridge/releases) -> [!TIP] Home Assistant: you may need to re-add the repo if you cannot see the latest updates. +> [!TIP] +> Home Assistant: you may need to re-add the repo if you cannot see the latest updates. ## FAQ @@ -297,6 +307,7 @@ Video Streaming: * [gtxaspec/wz_mini_hacks](https://github.com/gtxaspec/wz_mini_hacks) - Firmware level modification for Ingenic based cameras with an RTSP server and [self-hosted mode](https://github.com/gtxaspec/wz_mini_hacks/wiki/Configuration-File#self-hosted--isolated-mode) to use the cameras without the wyze services. * [carTloyal123/cryze](https://github.com/carTloyal123/cryze) - Stream video from wyze cameras (Gwell cameras) that use the Iotvideo SDK from Tencent Cloud. +* [mnakada/atomcam_tools](https://github.com/mnakada/atomcam_tools) - Video streaming for Wyze v3. General Wyze: diff --git a/app/wyzebridge/ffmpeg.py b/app/wyzebridge/ffmpeg.py index 6c9aee29..de3de8d9 100644 --- a/app/wyzebridge/ffmpeg.py +++ b/app/wyzebridge/ffmpeg.py @@ -29,7 +29,7 @@ def get_ffmpeg_cmd( - list of str: complete ffmpeg command that is ready to run as subprocess. """ - flags = "-fflags +flush_packets+nobuffer+genpts -flags +low_delay -use_wallclock_as_timestamps 1" + flags = "-fflags +flush_packets+nobuffer+genpts -flags +low_delay" livestream = get_livestream_cmd(uri) audio_in = "-f lavfi -i anullsrc=cl=mono" if livestream else "" audio_out = "aac" @@ -38,7 +38,7 @@ def get_ffmpeg_cmd( audio_in = f"{thread_queue} -f {audio['codec']} -ac 1 -ar {audio['rate']} -i /tmp/{uri}_audio.pipe" audio_out = audio["codec_out"] or "copy" if audio and audio.get("codec", "").lower() == "aac_eld": - audio_in = f"{thread_queue} -f aac -ac 1 -i /tmp/{uri}_audio.pipe" + audio_in = f"{thread_queue} -f aac -ac 1 -re -i /tmp/{uri}_audio.pipe" a_filter = env_bool("AUDIO_FILTER", "volume=5") + ",adelay=0|0" a_options = ["-compression_level", "4", "-filter:a", a_filter] rtsp_transport = "udp" if "udp" in env_bool("MTX_PROTOCOLS") else "tcp" @@ -62,6 +62,9 @@ def get_ffmpeg_cmd( + re_encode_video(uri, is_vertical) + (["-map", "1:a", "-c:a", audio_out] if audio_in else []) + (a_options if audio and audio_out != "copy" else []) + + ["-fps_mode", "passthrough", "-async", "1", "-flush_packets", "1"] + + ["-muxdelay", "0"] + + ["-rtbufsize", "1", "-max_interleave_delta", "10"] + ["-f", "tee"] + [rtsp_ss + get_record_cmd(uri, audio_out, record) + livestream] ) diff --git a/app/wyzebridge/hass.py b/app/wyzebridge/hass.py index 3410b0b7..8a6b2159 100644 --- a/app/wyzebridge/hass.py +++ b/app/wyzebridge/hass.py @@ -52,6 +52,8 @@ def setup_hass(hass_token: Optional[str]) -> None: environ[f"QUALITY_{cam_name}"] = str(cam["QUALITY"]) if "SUB_QUALITY" in cam: environ[f"SUB_QUALITY_{cam_name}"] = str(cam["SUB_QUALITY"]) + if "FORCE_FPS" in cam: + environ[f"FORCE_FPS_{cam_name}"] = str(cam["FORCE_FPS"]) if "LIVESTREAM" in cam: environ[f"LIVESTREAM_{cam_name}"] = str(cam["LIVESTREAM"]) if "RECORD" in cam: diff --git a/app/wyzebridge/wyze_api.py b/app/wyzebridge/wyze_api.py index b45006cc..54880e35 100644 --- a/app/wyzebridge/wyze_api.py +++ b/app/wyzebridge/wyze_api.py @@ -77,10 +77,10 @@ class WyzeCredentials: __slots__ = "email", "password", "key_id", "api_key" def __init__(self) -> None: - self.email: str = getenv("WYZE_EMAIL", "").strip() - self.password: str = getenv("WYZE_PASSWORD", "").strip() - self.key_id: str = getenv("API_ID", "").strip() - self.api_key: str = getenv("API_KEY", "").strip() + self.email: str = getenv("WYZE_EMAIL", "").strip("'\" \n\t\r") + self.password: str = getenv("WYZE_PASSWORD", "").strip("'\" \n\t\r") + self.key_id: str = getenv("API_ID", "").strip("'\" \n\t\r") + self.api_key: str = getenv("API_KEY", "").strip("'\" \n\t\r") if not self.is_set: logger.warning("[WARN] Credentials are NOT set") @@ -215,7 +215,7 @@ def get_camera(self, uri: str, existing: bool = False) -> Optional[WyzeCamera]: return next((c for c in self.cameras if c.name_uri == uri)) too_old = time() - self._last_pull > 120 - with contextlib.suppress(TypeError): + with contextlib.suppress(TypeError, wyzecam.api.AccessTokenError): for cam in self.get_cameras(fresh_data=too_old): if cam.name_uri == uri: return cam diff --git a/app/wyzebridge/wyze_stream.py b/app/wyzebridge/wyze_stream.py index 09eeef0e..a375b83d 100644 --- a/app/wyzebridge/wyze_stream.py +++ b/app/wyzebridge/wyze_stream.py @@ -528,7 +528,7 @@ def get_video_params(sess: WyzeIOTCSession) -> tuple[str, int]: fps = int(video_param.get("fps", 0)) - if force_fps := int(env_bool(f"FORCE_FPS_{sess.camera.name_uri}", "0")): + if force_fps := int(env_cam("FORCE_FPS", sess.camera.name_uri, "0")): logger.info(f"Attempting to force fps={force_fps}") sess.update_frame_size_rate(fps=force_fps) fps = force_fps diff --git a/app/wyzecam/iotc.py b/app/wyzecam/iotc.py index fdfe192f..eb4c507e 100644 --- a/app/wyzecam/iotc.py +++ b/app/wyzecam/iotc.py @@ -312,13 +312,15 @@ def sleep_interval(self) -> float: return 0 if not self.frame_ts: - return 1 / 150 + return 1 / 100 - delta = max(time.time() - self.frame_ts, 0.0) + self._sleep_buffer + fps = 1 / self.preferred_frame_rate * 0.95 + delta = max(time.time() - self.frame_ts, 0.0) if self._sleep_buffer: - self._sleep_buffer = max(self._sleep_buffer - 0.05, 0) + delta += self._sleep_buffer + self._sleep_buffer = max(self._sleep_buffer - fps, 0) - return max((1 / self.preferred_frame_rate) - delta, 1 / 80) + return max(fps - delta, fps / 4) @property def pipe_name(self) -> str: @@ -442,8 +444,8 @@ def recv_bridge_data(self) -> Iterator[bytes]: have_key_frame = False continue - if have_key_frame and self._video_frame_slow(frame_info): - continue + if have_key_frame: + self._video_frame_slow(frame_info) if frame_info.is_keyframe: have_key_frame = True @@ -487,26 +489,22 @@ def _video_frame_slow(self, frame_info) -> Optional[bool]: self.frame_ts = time.time() return - frame_ts = float(f"{frame_info.timestamp}.{frame_info.timestamp_ms}") - gap = time.time() - frame_ts + self.frame_ts = float(f"{frame_info.timestamp}.{frame_info.timestamp_ms}") + gap = time.time() - self.frame_ts - if not frame_info.is_keyframe and gap > 3 and not self._sleep_buffer: + if not frame_info.is_keyframe and gap > 5: logger.warning("[video] super slow") self.clear_buffer() - - return True - - if gap >= 0.5: - logger.debug(f"[video] slow {gap=}") + if gap > 0: self._sleep_buffer += gap - return - - self.frame_ts = frame_ts + if gap >= 1: + logger.debug(f"[video] slow {gap=}") + self.flush_pipe("audio") def _handle_frame_error(self, err_no: int) -> None: """Handle errors that occur when receiving frame data.""" - time.sleep(1 / self.preferred_frame_rate * 0.8) + time.sleep(1 / 80) if err_no == tutk.AV_ER_DATA_NOREADY or err_no >= 0: return @@ -559,7 +557,7 @@ def clear_buffer(self) -> None: warnings.warn("clear buffer") self.flush_pipe("audio") self.sync_camera_time() - tutk.av_client_clean_buf(self.tutk_platform_lib, self.av_chan_id) + tutk.av_client_clean_local_buf(self.tutk_platform_lib, self.av_chan_id) def flush_pipe(self, pipe_type: str = "audio"): if pipe_type == "audio" and not self.audio_pipe_ready: @@ -611,7 +609,7 @@ def recv_audio_pipe(self) -> None: with contextlib.suppress(FileExistsError): os.mkfifo(fifo_path) try: - with open(fifo_path, "wb") as audio_pipe: + with open(fifo_path, "wb", buffering=0) as audio_pipe: set_non_blocking(audio_pipe) for frame_data, _ in self.recv_audio_data(): with contextlib.suppress(BlockingIOError): @@ -632,25 +630,19 @@ def _audio_frame_slow(self, frame_info) -> Optional[bool]: if frame_info.timestamp < 1591069888: return - gap = self.frame_ts - float(f"{frame_info.timestamp}.{frame_info.timestamp_ms}") + gap = float(f"{frame_info.timestamp}.{frame_info.timestamp_ms}") - self.frame_ts - if abs(gap) > 5: + if abs(gap) > 10: logger.debug(f"[audio] out of sync {gap=}") - self._sleep_buffer += abs(gap) self.clear_buffer() - return - if gap <= -1: - logger.debug(f"[audio] rushing ahead of video.. {gap=}") + if gap < -1: + logger.debug(f"[audio] behind video.. {gap=}") self.flush_pipe("audio") - self._sleep_buffer += abs(gap) - elif gap >= 1: - logger.debug(f"[audio] dragging behind video.. {gap=}") - self.flush_pipe("audio") - self.tutk_platform_lib.avClientCleanAudioBuf(self.av_chan_id) - - return True + if gap > 1: + logger.debug(f"[audio] ahead of video.. {gap=}") + time.sleep(gap * 0.5) def get_audio_sample_rate(self) -> int: """Attempt to get the audio sample rate.""" diff --git a/home_assistant/CHANGELOG.md b/home_assistant/CHANGELOG.md index 641db0a0..54b11dd4 100644 --- a/home_assistant/CHANGELOG.md +++ b/home_assistant/CHANGELOG.md @@ -1,3 +1,11 @@ +## What's Changed in v2.9.2 + +- Improved video connection stability and audio sync. #1175 #1196 #1194 #1193 #1186 Thanks @vipergts450! +- FIX: Remove quotes from credentials #1158 +- NEW: `FORCE_FPS` option for all cameras #1161 +- Home Assistant: Add `FORCE_FPS` option #1161 +- Home Assistant: Ignore whitespaces in api key/id #1188 Thanks @richh1! + ## What's Changed in v2.9.1 - FIX: Setting bitrate higher than 255 would not report correctly (#1185) Thanks @Anc0dia! diff --git a/home_assistant/config.yml b/home_assistant/config.yml index 50596e8d..b8275101 100644 --- a/home_assistant/config.yml +++ b/home_assistant/config.yml @@ -48,8 +48,8 @@ options: schema: WYZE_EMAIL: email? WYZE_PASSWORD: password? - API_ID: match([a-fA-F0-9-]{36})? - API_KEY: match([a-zA-Z0-9]{60})? + API_ID: match(\s*[a-fA-F0-9-]{36}\s*)? + API_KEY: match(\s*[a-zA-Z0-9]{60}\s*)? WB_IP: str? REFRESH_TOKEN: str? ACCESS_TOKEN: str? @@ -93,6 +93,7 @@ schema: URI_SEPARATOR: list(-|_|#)? QUALITY: str? SUB_QUALITY: str? + FORCE_FPS: int? SUB_RECORD: bool? FFMPEG_FLAGS: str? FFMPEG_CMD: str? @@ -117,6 +118,7 @@ schema: ROTATE: bool? QUALITY: str? SUB_QUALITY: str? + FORCE_FPS: int? RECORD: bool? SUB_RECORD: bool? SUBSTREAM: bool? diff --git a/home_assistant/dev/config.yml b/home_assistant/dev/config.yml index a6123ca1..40848732 100644 --- a/home_assistant/dev/config.yml +++ b/home_assistant/dev/config.yml @@ -47,8 +47,8 @@ options: schema: WYZE_EMAIL: email? WYZE_PASSWORD: password? - API_ID: match([a-fA-F0-9-]{36})? - API_KEY: match([a-zA-Z0-9]{60})? + API_ID: match(\s*[a-fA-F0-9-]{36}\s*)? + API_KEY: match(\s*[a-zA-Z0-9]{60}\s*)? WB_IP: str? REFRESH_TOKEN: str? ACCESS_TOKEN: str? @@ -92,6 +92,7 @@ schema: URI_SEPARATOR: list(-|_|#)? QUALITY: str? SUB_QUALITY: str? + FORCE_FPS: int? SUB_RECORD: bool? FFMPEG_FLAGS: str? FFMPEG_CMD: str? @@ -116,6 +117,7 @@ schema: ROTATE: bool? QUALITY: str? SUB_QUALITY: str? + FORCE_FPS: int? RECORD: bool? SUB_RECORD: bool? SUBSTREAM: bool?