-
-
Notifications
You must be signed in to change notification settings - Fork 254
Add KVS support for currently unsupported cameras #1420
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
ff7b6f0
648182d
ccd5b7b
63a47af
dbf2024
399d27b
05af006
6736a3d
9478759
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,3 +8,6 @@ | |
| *.env | ||
| *.pyc | ||
| !/app/.env | ||
| wrapped | ||
| kinesis | ||
| demo | ||
| 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 | ||
|
|
@@ -7,6 +8,7 @@ | |
| from flask import ( | ||
| Flask, | ||
| Response, | ||
| abort, | ||
| make_response, | ||
| redirect, | ||
| render_template, | ||
|
|
@@ -24,6 +26,11 @@ def create_app(): | |
| app = Flask(__name__) | ||
| wb = WyzeBridge() | ||
| try: | ||
| subprocess.Popen( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
|
|
@@ -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 | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,4 +5,4 @@ pydantic==2.9.* | |
| python-dotenv==1.0.* | ||
| requests==2.32.* | ||
| PyYAML==6.0.* | ||
| xxtea==3.3.* | ||
| xxtea==3.3.* | ||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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) | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this be moved into the above
Suggested change
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||||||||||
| if env_cam("record", cam.name_uri): | ||||||||||
| self.mtx.record(stream.uri) | ||||||||||
| self.streams.add(stream) | ||||||||||
|
|
@@ -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, *_): | ||||||||||
|
|
||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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") | ||
|
|
@@ -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 = [ | ||
|
|
@@ -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: | ||
|
|
@@ -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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there a reason to add
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: | ||
|
|
||
| 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 | ||
|
|
@@ -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 | ||
|
|
||
|
|
||
|
|
@@ -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}"} | ||
|
|
||
|
|
@@ -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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why? |
||
| wakeup_kvs_camera(auth_info=self.auth, camera=cam) | ||
| return True | ||
|
|
||
|
|
||
| def url_timestamp(url: str) -> int: | ||
| try: | ||
|
|
||
| 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 |
There was a problem hiding this comment.
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 🙂
There was a problem hiding this comment.
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.