Skip to content

Commit

Permalink
Compatibility with scrypted (#1066)
Browse files Browse the repository at this point in the history
* add support for passing in key id and api key via login

* add support for custom project root via env

* add support for audio data iteration

* add frame size 2k

* refactor get_audio_codec

* refactor on top of new audio generator

* Use os.getenv instead of os.environ

---------

Co-authored-by: mrlt8 <67088095+mrlt8@users.noreply.github.com>
  • Loading branch information
koush and mrlt8 authored Dec 24, 2023
1 parent bc76e99 commit 2f36455
Show file tree
Hide file tree
Showing 5 changed files with 94 additions and 63 deletions.
29 changes: 19 additions & 10 deletions app/wyzecam/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ def login(
password: str,
phone_id: Optional[str] = None,
mfa: Optional[dict] = None,
api_key: Optional[str] = None,
key_id: Optional[str] = None,
) -> WyzeCredential:
"""Authenticate with Wyze.
Expand All @@ -92,14 +94,16 @@ def login(
[get_camera_list()][wyzecam.api.get_camera_list].
"""
phone_id = phone_id or str(uuid.uuid4())
headers = _headers(phone_id)
key_id = key_id or getenv("API_ID")
api_key = api_key or getenv("API_KEY")
headers = _headers(phone_id, key_id=key_id, api_key=api_key)
headers["content-type"] = "application/json"

payload = sort_dict(
{"email": email.strip(), "password": hash_password(password), **(mfa or {})}
)
api_version = "v2"
if getenv("API_ID") and getenv("API_KEY"):
if key_id and api_key:
api_version = "api"
elif getenv("v3"):
api_version = "v3"
Expand All @@ -109,7 +113,10 @@ def login(
resp = post(f"{AUTH_API}/{api_version}/user/login", data=payload, headers=headers)
resp.raise_for_status()

return WyzeCredential.model_validate(dict(resp.json(), phone_id=phone_id))
credential = WyzeCredential.model_validate(dict(resp.json(), phone_id=phone_id))
credential.key_id = key_id
credential.api_key = api_key
return credential


def send_sms_code(auth_info: WyzeCredential, phone: str = "Primary") -> str:
Expand All @@ -130,7 +137,7 @@ def send_sms_code(auth_info: WyzeCredential, phone: str = "Primary") -> str:
"sessionId": auth_info.sms_session_id,
"userId": auth_info.user_id,
},
headers=_headers(auth_info.phone_id),
headers=_headers(auth_info=auth_info),
)
resp.raise_for_status()

Expand All @@ -154,7 +161,7 @@ def send_email_code(auth_info: WyzeCredential) -> str:
"userId": auth_info.user_id,
"sessionId": auth_info.email_session_id,
},
headers=_headers(auth_info.phone_id),
headers=_headers(auth_info=auth_info),
)
resp.raise_for_status()

Expand Down Expand Up @@ -361,14 +368,16 @@ def _payload(
}


def _headers(phone_id: Optional[str] = None) -> dict[str, str]:
def _headers(phone_id: Optional[str] = None, key_id: Optional[str] = None, api_key: Optional[str] = None, auth_info: Optional[WyzeCredential] = None) -> dict[str, str]:
phone_id = phone_id or (auth_info and auth_info.phone_id)
if not phone_id:
return {"user-agent": SCALE_USER_AGENT}
id, key = getenv("API_ID"), getenv("API_KEY")
if id and key:
key_id = key_id or (auth_info and auth_info.api_key) or getenv("API_ID")
api_key = api_key or (auth_info and auth_info.key_id) or getenv("API_KEY")
if key_id and api_key:
return {
"apikey": key,
"keyid": id,
"apikey": api_key,
"keyid": key_id,
"user-agent": f"docker-wyze-bridge/{getenv('VERSION')}",
}

Expand Down
2 changes: 2 additions & 0 deletions app/wyzecam/api_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ class WyzeCredential(BaseModel):
sms_session_id: Optional[str] = None
email_session_id: Optional[str] = None
phone_id: Optional[str] = str(uuid.uuid4())
key_id: Optional[str] = None
api_key: Optional[str] = None


class WyzeAccount(BaseModel):
Expand Down
109 changes: 60 additions & 49 deletions app/wyzecam/iotc.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,6 @@ class WyzeIOTCSessionState(enum.IntEnum):

FRAME_SIZE = {0: "HD", 1: "SD", 3: "2K"}


class WyzeIOTCSession:
"""An IOTC session object, used for communicating with Wyze cameras.
Expand Down Expand Up @@ -433,7 +432,7 @@ def recv_video_data(
bad_frames = 0
max_noready = int(os.getenv("MAX_NOREADY", 500))
while True:
errno, frame_data, frame_info = tutk.av_recv_frame_data(
errno, frame_data, frame_info, frame_index = tutk.av_recv_frame_data(
self.tutk_platform_lib, self.av_chan_id
)
if errno < 0:
Expand Down Expand Up @@ -553,6 +552,41 @@ def clear_local_buffer(self) -> None:
warnings.warn("clear buffer")
tutk.av_client_clean_local_buf(self.tutk_platform_lib, self.av_chan_id)

def recv_audio_data(self) -> Iterator[
tuple[bytes, Union[tutk.FrameInfoStruct, tutk.FrameInfo3Struct]]
]:
tutav = self.tutk_platform_lib, self.av_chan_id

sleep_interval = 1 / 20
try:
while (
self.state == WyzeIOTCSessionState.AUTHENTICATION_SUCCEEDED
and self.stream_state.value > 1
):
error_no, frame_data, frame_info = tutk.av_recv_audio_data(*tutav)

if not frame_data or error_no in {
tutk.AV_ER_DATA_NOREADY,
tutk.AV_ER_INCOMPLETE_FRAME,
tutk.AV_ER_LOSED_THIS_FRAME,
}:
time.sleep(sleep_interval)
continue

if error_no:
raise tutk.TutkError(error_no)
yield frame_data, frame_info

yield b"", None
except tutk.TutkError as ex:
warnings.warn(str(ex))
except IOError as ex:
if ex.errno != errno.EPIPE: # Broken pipe
warnings.warn(str(ex))
finally:
self.state = WyzeIOTCSessionState.CONNECTING_FAILED
warnings.warn("Audio pipe closed")

def recv_audio_frames(self, uri: str) -> None:
"""Write raw audio frames to a named pipe."""
FIFO = f"/tmp/{uri.lower()}_audio.pipe"
Expand All @@ -562,39 +596,13 @@ def recv_audio_frames(self, uri: str) -> None:
if e.errno != 17:
raise e

tutav = self.tutk_platform_lib, self.av_chan_id

sleep_interval = 1 / 20
try:
with open(FIFO, "wb") as audio_pipe:
while (
self.state == WyzeIOTCSessionState.AUTHENTICATION_SUCCEEDED
and self.stream_state.value > 1
):
error_no, frame_data, _ = tutk.av_recv_audio_data(*tutav)

if not frame_data or error_no in {
tutk.AV_ER_DATA_NOREADY,
tutk.AV_ER_INCOMPLETE_FRAME,
tutk.AV_ER_LOSED_THIS_FRAME,
}:
time.sleep(sleep_interval)
continue

if error_no:
raise tutk.TutkError(error_no)
for frame_data, _ in self.recv_audio_data():
audio_pipe.write(frame_data)

audio_pipe.write(b"")
except tutk.TutkError as ex:
warnings.warn(str(ex))
except IOError as ex:
if ex.errno != errno.EPIPE: # Broken pipe
warnings.warn(str(ex))
finally:
self.state = WyzeIOTCSessionState.CONNECTING_FAILED
os.unlink(FIFO)
warnings.warn("Audio pipe closed")

def get_audio_sample_rate(self) -> int:
"""Attempt to get the audio sample rate."""
Expand All @@ -603,33 +611,36 @@ def get_audio_sample_rate(self) -> int:
sample_rate = int(audio_param.get("sampleRate", sample_rate))
return sample_rate

def get_audio_codec_from_codec_id(self, codec_id: int):
sample_rate = self.get_audio_sample_rate()
codec = False
if codec_id == 137: # MEDIA_CODEC_AUDIO_G711_ULAW
codec = "mulaw"
elif codec_id == 140: # MEDIA_CODEC_AUDIO_PCM
codec = "s16le"
elif codec_id == 141: # MEDIA_CODEC_AUDIO_AAC
codec = "aac"
elif codec_id == 143: # MEDIA_CODEC_AUDIO_G711_ALAW
codec = "alaw"
elif codec_id == 144: # MEDIA_CODEC_AUDIO_AAC_ELD
codec = "aac_eld"
sample_rate = 16000
elif codec_id == 146: # MEDIA_CODEC_AUDIO_OPUS
codec = "opus"
sample_rate = 16000
else:
raise Exception(f"\nUnknown audio codec {codec_id=}\n")
logger.info(f"[AUDIO] {codec=} {sample_rate=} {codec_id=}")
return codec, sample_rate

def get_audio_codec(self, limit: int = 25) -> tuple[str, int]:
"""Identify audio codec."""
sample_rate = self.get_audio_sample_rate()
for _ in range(limit):
error_no, _, frame_info = tutk.av_recv_audio_data(
self.tutk_platform_lib, self.av_chan_id
)
if not error_no and (codec_id := frame_info.codec_id):
codec = False
if codec_id == 137: # MEDIA_CODEC_AUDIO_G711_ULAW
codec = "mulaw"
elif codec_id == 140: # MEDIA_CODEC_AUDIO_PCM
codec = "s16le"
elif codec_id == 141: # MEDIA_CODEC_AUDIO_AAC
codec = "aac"
elif codec_id == 143: # MEDIA_CODEC_AUDIO_G711_ALAW
codec = "alaw"
elif codec_id == 144: # MEDIA_CODEC_AUDIO_AAC_ELD
codec = "aac_eld"
sample_rate = 16000
elif codec_id == 146: # MEDIA_CODEC_AUDIO_OPUS
codec = "opus"
sample_rate = 16000
else:
raise Exception(f"\nUnknown audio codec {codec_id=}\n")
logger.info(f"[AUDIO] {codec=} {sample_rate=} {codec_id=}")
return codec, sample_rate
return self.get_audio_codec_from_codec_id(codec_id)
time.sleep(0.5)
raise Exception("Unable to identify audio.")

Expand Down
6 changes: 6 additions & 0 deletions app/wyzecam/tutk/tutk.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@
A bitrate higher than the "HD" setting in the app. Approx 240 KB/s.
"""

FRAME_SIZE_2K = 3
"""
Represents the size of the video stream sent back from the server; 2K
or 2560x1440 pixels.
"""

FRAME_SIZE_1080P = 0
"""
Represents the size of the video stream sent back from the server; 1080P
Expand Down
11 changes: 7 additions & 4 deletions app/wyzecam/tutk/tutk_protocol.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import json
import logging
import pathlib
import time
from ctypes import LittleEndianStructure, c_char, c_uint16, c_uint32
from os import getenv
from pathlib import Path
from struct import pack, pack_into
from typing import Any, Optional

import xxtea

from . import tutk

project_root = pathlib.Path(__file__).parent
PROJECT_ROOT = Path(getenv("TUTK_PROJECT_ROOT", Path(__file__).parent))


logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -838,7 +840,8 @@ def __init__(self):

def encode(self) -> bytes:
return encode(self.code, bytes([0, 1, 0, 0, 0]))



class K10242FormatSDCard(TutkWyzeProtocolMessage):
"""
Format SD Card.
Expand Down Expand Up @@ -1346,7 +1349,7 @@ def respond_to_ioctrl_10001(


def supports(product_model, protocol, command):
with open(project_root / "device_config.json") as f:
with open(PROJECT_ROOT / "device_config.json") as f:
device_config = json.load(f)
commands_db = device_config["supportedCommands"]
supported_commands = []
Expand Down

0 comments on commit 2f36455

Please sign in to comment.