diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index c5137c55..2d520026 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -12,12 +12,27 @@ on: workflow_dispatch: jobs: + prebuild: + runs-on: ubuntu-latest + steps: + - name: Should build? + run: | + if [ -z "${{ secrets.DOCKERHUB_USERNAME }}" ]; then + echo "The DOCKERHUB_USERNAME secret is missing." + exit 1 + fi + build: + needs: [prebuild] runs-on: ubuntu-latest permissions: contents: write packages: write + strategy: + matrix: + dockerfile: ['multiarch', 'hwaccel', 'qsv'] + steps: - name: Checkout repository uses: actions/checkout@v3 @@ -38,8 +53,6 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - # Login against a Docker registry except on PR - # https://github.com/docker/login-action - name: Log into registry ghcr.io if: github.event_name != 'pull_request' uses: docker/login-action@v2 @@ -48,74 +61,48 @@ jobs: username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - # Extract metadata (tags, labels) for Docker - # https://github.com/docker/metadata-action + - name: matrix image type + id: image_type + run: | + echo "suffix=${{ matrix.dockerfile == 'hwaccel' && '-hw' || matrix.dockerfile == 'qsv' && '-qsv' ||'' }}" >> $GITHUB_OUTPUT + echo "platforms=${{ matrix.dockerfile == 'multiarch' && 'linux/amd64,linux/arm64,linux/arm' || 'linux/amd64' }}" >> $GITHUB_OUTPUT + echo "arch=${{ matrix.dockerfile == 'multiarch' && 'amd64,armhf,aarch64' || 'amd64' }}" >> $GITHUB_OUTPUT + - name: Extract Docker metadata id: meta uses: docker/metadata-action@v4 - with: - images: | - ${{ github.repository_owner }}/wyze-bridge - ghcr.io/${{ github.repository }} - tags: | - type=schedule - type=semver,pattern={{ version }} - type=edge,branch=main,enable=${{ github.event_name == 'push' }} - type=ref,event=branch,enable=${{ contains(github.ref,'dev') }} - - # Extract metadata (tags, labels) for Docker - # https://github.com/docker/metadata-action - - name: Extract Docker metadata for hwaccel builds - id: hwmeta - uses: docker/metadata-action@v4 - with: - images: | - ${{ github.repository_owner }}/wyze-bridge - ghcr.io/${{ github.repository }} - flavor: | - latest=auto - suffix=-hw,onlatest=true - tags: | - type=schedule,suffix=-hw - type=semver,pattern={{ version }},suffix=-hw - type=edge,branch=main,enable=${{ github.event_name == 'push' }},suffix=-hw - type=ref,event=branch,enable=${{ contains(github.ref,'dev') }},suffix=-hw - - - name: Extract Docker metadata for qsv builds - id: qsvmeta - uses: docker/metadata-action@v4 with: images: | ${{ github.repository_owner }}/wyze-bridge ghcr.io/${{ github.repository }} flavor: | latest=auto - suffix=-qsv,onlatest=true + suffix=${{ steps.image_type.outputs.suffix }},onlatest=true tags: | - type=schedule,suffix=-qsv - type=semver,pattern={{ version }},suffix=-qsv - type=edge,branch=main,enable=${{ github.event_name == 'push' }},suffix=-qsv - type=ref,event=branch,enable=${{ contains(github.ref,'dev') }},suffix=-qsv + type=schedule,suffix=${{ steps.image_type.outputs.suffix }} + type=semver,pattern={{ version }},suffix=${{ steps.image_type.outputs.suffix }} + type=edge,branch=main,enable=${{ github.event_name == 'push' }},suffix=${{ steps.image_type.outputs.suffix }} + type=ref,event=branch,enable=${{ contains(github.ref,'dev') }},suffix=${{ steps.image_type.outputs.suffix }} - name: Update Release Version - if: steps.meta.outputs.VERSION != '' + id: version_bump + if: startsWith(github.ref, 'refs/tags/v') run: | - if [[ ${{ steps.meta.outputs.VERSION }} =~ ^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$ ]]; then - sed -i "s/^VERSION=.*/VERSION=${{ steps.meta.outputs.VERSION }}/" ./app/.env - jq --arg VERSION "${{ steps.meta.outputs.VERSION }}" '.version = $VERSION' ./app/config.json > updated.json + TAG_NAME=${GITHUB_REF##*/v} + if [[ $TAG_NAME =~ ^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$ ]]; then + sed -i "s/^VERSION=.*/VERSION=${TAG_NAME}/" ./app/.env + jq --arg VERSION "${TAG_NAME}" '.version = $VERSION' ./app/config.json > updated.json mv updated.json ./app/config.json fi - # Build and push Docker image with Buildx (don't push on PR) - # https://github.com/docker/build-push-action - - name: Build and push a multi-arch Docker image + - name: Build and push a Docker image uses: docker/build-push-action@v4 with: builder: ${{ steps.buildx.outputs.name }} context: ./app/ push: ${{ github.event_name != 'pull_request' }} - file: ./app/Dockerfile.multiarch - platforms: linux/amd64,linux/arm64,linux/arm + file: ./app/Dockerfile.${{ matrix.dockerfile }} + platforms: ${{ steps.image_type.outputs.platforms }} build-args: BUILD=${{ steps.meta.outputs.VERSION }} labels: | ${{ steps.meta.outputs.labels }} @@ -123,51 +110,30 @@ jobs: io.hass.description=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.description'] }} io.hass.version=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }} io.hass.type=addon - io.hass.arch=amd64,armhf,aarch64 + io.hass.arch=${{ steps.image_type.outputs.arch }} tags: ${{ steps.meta.outputs.tags }} + cache-from: type=gha + cache-to: type=gha,mode=max - # Build and push Docker image with Buildx (don't push on PR) - # https://github.com/docker/build-push-action - - name: Build and push an amd64 Docker image with hwaccel enabled - uses: docker/build-push-action@v4 - with: - builder: ${{ steps.buildx.outputs.name }} - context: ./app/ - push: ${{ github.event_name != 'pull_request' }} - file: ./app/Dockerfile.hwaccel - platforms: linux/amd64 - build-args: BUILD=${{ steps.meta.outputs.VERSION }} - labels: | - ${{ steps.meta.outputs.labels }} - io.hass.name=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.title'] }} - io.hass.description=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.description'] }} - io.hass.version=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }} - io.hass.type=addon - io.hass.arch=amd64 - tags: ${{ steps.hwmeta.outputs.tags }} - - - name: Build and push an amd64 Docker image with QSV drivers - uses: docker/build-push-action@v4 - with: - builder: ${{ steps.buildx.outputs.name }} - context: ./app/ - push: ${{ github.event_name != 'pull_request' }} - file: ./app/Dockerfile.hwaccel - build-args: | - BUILD=${{ steps.meta.outputs.VERSION }} - QSV=1 - labels: | - ${{ steps.meta.outputs.labels }} - io.hass.name=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.title'] }} - io.hass.description=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.description'] }} - io.hass.version=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }} - io.hass.type=addon - io.hass.arch=amd64 - tags: ${{ steps.qsvmeta.outputs.tags }} - + version_bump: + needs: [build] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Update Release Version + id: version_bump + if: startsWith(github.ref, 'refs/tags/v') + run: | + TAG_NAME=${GITHUB_REF##*/v} + if [[ $TAG_NAME =~ ^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$ ]]; then + sed -i "s/^VERSION=.*/VERSION=${TAG_NAME}/" ./app/.env + jq --arg VERSION "${TAG_NAME}" '.version = $VERSION' ./app/config.json > updated.json + mv updated.json ./app/config.json + echo "tag=${TAG_NAME}" >> $GITHUB_OUTPUT + fi - name: Commit and push changes uses: stefanzweifel/git-auto-commit-action@v4 with: branch: main - commit_message: 'Bump Version to v${{ steps.meta.outputs.VERSION }}' + commit_message: 'Bump Version to v${{ steps.version_bump.outputs.tag }}' file_pattern: 'app/.env app/config.json' diff --git a/README.md b/README.md index 9b2e0775..c46c158a 100644 --- a/README.md +++ b/README.md @@ -33,26 +33,26 @@ 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.3.10 - -* FIX: KeyError when upgrading with old cache data in v2.3.9 (#905) Thanks @itsamenathan! - * You should be able to remove or set `FRESH_DATA` back to false. -* MQTT: Update bridge status (#907) Thanks @giorgi1324! - -## What's Changed in v2.3.9 - -* NEW: ENV Options - token-based authentication (#876) - * `REFRESH_TOKEN` - Use a valid refresh token to request a *new* access token and refresh token. - * `ACCESS_TOKEN` - Use an existing valid access token too access the API. Will *not* be able to refresh the token once it expires. -* NEW: Docker "QSV" Images with basic support for QSV hardware accelerated encoding. (#736) Thanks @mitchross, @392media, @chris001, and everyone who helped! - * Use the `latest-qsv` tag (e.g., `mrlt8/wyze-bridge:latest-qsv`) along with the `H264_ENC=h264_qsv` ENV variable. +## What's Changed in v2.3.11 + +* NEW: + * Add more MQTT entities when using MQTT discovery. Thanks @jhansche! #921 #922 + * custom video filter - Use `FFMPEG_FILTER` or `FFMPEG_FILTER_CAM-NAME` to set custom ffmpeg video filters. #919 +* NEW MQTT/REST commands: + * **SET** topic: `cruise_point` | payload: (int) 1-4 - Pan to predefined cruise_point/waypoint. Thanks @jhansche! (#835). + * **SET** topic: `time_zone` | payload: (str) `Area/Location`, e.g. `America/New_York` - Change camera timezone. Thanks @DennisGarvey! (#916) + * **GET/SET** topic: `osd_timestamp` | payload: (bool/int) `on/off` - toggle timestamp on video. + * **GET/SET** topic: `osd_logo` | payload: (bool/int) `on/off` - toggle wyze logo on video. + * **SET** topic: `quick_reponse` | payload: (int) 1-3 - Doorbell quick response. * FIXES: - * Home Assistant: set max bitrate quality to 255 (#893) Thanks @gtxaspec! - * WebUI: email 2FA support. -* UPDATES: - * Docker base image: bullseye -> bookworm - * MediaMTX: v0.23.6 -> v0.23.7 - * Wyze App: v2.42.6.1 -> v2.43.0.12 + * Resend discovery message on HA online. Thanks @jhansche! #907 #920 + * Return json response/value for commands. Thanks @jhansche! #835 + * Fix threading issue on restart. Thanks @ZacTyAdams! #902 + * Catch and disable MQTT on name resolution error. + * Fix SET cruise_points over MQTT. +* Updates: + * Wyze iOS App version from v2.43.0.12 to v2.43.5.3 (#914) + * MediaMTX version from v0.23.7 to v0.23.8 (#925) [View previous changes](https://github.com/mrlt8/docker-wyze-bridge/releases) diff --git a/app/CHANGELOG.md b/app/CHANGELOG.md index 878a5a8e..374d92fd 100644 --- a/app/CHANGELOG.md +++ b/app/CHANGELOG.md @@ -1,3 +1,24 @@ +## What's Changed in v2.3.11 + +* NEW: + * Add more MQTT entities when using MQTT discovery. Thanks @jhansche! #921 #922 + * custom video filter - Use `FFMPEG_FILTER` or `FFMPEG_FILTER_CAM-NAME` to set custom ffmpeg video filters. #919 +* NEW MQTT/REST commands: + * **SET** topic: `cruise_point` | payload: (int) 1-4 - Pan to predefined cruise_point/waypoint. Thanks @jhansche! (#835). + * **SET** topic: `time_zone` | payload: (str) `Area/Location`, e.g. `America/New_York` - Change camera timezone. Thanks @DennisGarvey! (#916) + * **GET/SET** topic: `osd_timestamp` | payload: (bool/int) `on/off` - toggle timestamp on video. + * **GET/SET** topic: `osd_logo` | payload: (bool/int) `on/off` - toggle wyze logo on video. + * **SET** topic: `quick_reponse` | payload: (int) 1-3 - Doorbell quick response. +* FIXES: + * Resend discovery message on HA online. Thanks @jhansche! #907 #920 + * Return json response/value for commands. Thanks @jhansche! #835 + * Fix threading issue on restart. Thanks @ZacTyAdams! #902 + * Catch and disable MQTT on name resolution error. + * Fix SET cruise_points over MQTT. +* Updates: + * Wyze iOS App version from v2.43.0.12 to v2.43.5.3 (#914) + * MediaMTX version from v0.23.7 to v0.23.8 (#925) + ## What's Changed in v2.3.10 * FIX: KeyError when upgrading with old cache data in v2.3.9 (#905) Thanks @itsamenathan! diff --git a/app/frontend.py b/app/frontend.py index a4971b26..8fe38162 100644 --- a/app/frontend.py +++ b/app/frontend.py @@ -139,7 +139,7 @@ def api_cam(cam_name: str): return {"error": f"Could not find camera [{cam_name}]"} @app.route("/api//", methods=["GET", "PUT", "POST"]) - @app.route("/api///") + @app.route("/api///") def api_cam_control(cam_name: str, cam_cmd: str, payload: str | dict = ""): """API Endpoint to send tutk commands to the camera.""" if args := request.values: diff --git a/app/wyzebridge/ffmpeg.py b/app/wyzebridge/ffmpeg.py index 7e4ba26e..8b35a4df 100644 --- a/app/wyzebridge/ffmpeg.py +++ b/app/wyzebridge/ffmpeg.py @@ -91,7 +91,8 @@ def re_encode_video(uri: str, is_vertical: bool) -> list[str]: """ h264_enc: str = env_bool("h264_enc", "libx264") - rotation = [] + custom_filter = env_cam("FFMPEG_FILTER", uri) + v_filter = [] transpose = "clock" if (env_bool("ROTATE_DOOR") and is_vertical) or env_bool(f"ROTATE_CAM_{uri}"): if os.getenv(f"ROTATE_CAM_{uri}") in {"0", "1", "2", "3"}: @@ -99,22 +100,27 @@ def re_encode_video(uri: str, is_vertical: bool) -> list[str]: # in favor of symbolic constants. transpose = os.environ[f"ROTATE_CAM_{uri}"] - rotation = ["-filter:v", f"transpose={transpose}"] + v_filter = ["-filter:v", f"transpose={transpose}"] if h264_enc == "h264_vaapi": - rotation[1] = f"transpose_vaapi={transpose}" + v_filter[1] = f"transpose_vaapi={transpose}" elif h264_enc == "h264_qsv": - rotation[1] = f"vpp_qsv=transpose={transpose}" + v_filter[1] = f"vpp_qsv=transpose={transpose}" - if not env_bool("FORCE_ENCODE") and not rotation: + if not env_bool("FORCE_ENCODE") and not v_filter and not custom_filter: return ["copy"] logger.info( - f"Re-encoding using {h264_enc}{f' [{transpose=}]' if rotation else '' }" + f"Re-encoding using {h264_enc}{f' [{transpose=}]' if v_filter else '' }" ) + if custom_filter: + v_filter = [ + "-filter:v", + f"{v_filter[1]},{custom_filter}" if v_filter else custom_filter, + ] return ( [h264_enc] - + rotation + + v_filter + ["-b:v", "2000k", "-coder", "1", "-bufsize", "2000k"] + ["-profile:v", "77" if h264_enc == "h264_v4l2m2m" else "main"] + ["-preset", "fast" if h264_enc in {"h264_nvenc", "h264_qsv"} else "ultrafast"] diff --git a/app/wyzebridge/mqtt.py b/app/wyzebridge/mqtt.py index dd99ee03..7d836adf 100644 --- a/app/wyzebridge/mqtt.py +++ b/app/wyzebridge/mqtt.py @@ -2,6 +2,7 @@ import json from functools import wraps from os import getenv +from socket import gaierror from typing import Optional import paho.mqtt.client @@ -25,11 +26,8 @@ def wrapper(*args, **kwargs): return try: return func(*args, **kwargs) - except ConnectionRefusedError: - logger.error("[MQTT] connection refused. Disabling MQTT.") - MQTT_ENABLED = False - except TimeoutError: - logger.error("[MQTT] TimeoutError. Disabling MQTT.") + except (ConnectionRefusedError, TimeoutError, gaierror) as ex: + logger.error(f"[MQTT] {ex}. Disabling MQTT.") MQTT_ENABLED = False except Exception as ex: logger.error(f"[MQTT] {ex}") @@ -38,10 +36,10 @@ def wrapper(*args, **kwargs): @mqtt_enabled -def wyze_discovery(cam: WyzeCamera, cam_uri: str) -> None: - """Add Wyze camera to MQTT if enabled.""" - base = f"{MQTT_TOPIC}/{cam_uri or cam.name_uri}/" - msgs = [(f"{base}state", "stopped")] +def publish_discovery(cam_uri: str, cam: WyzeCamera, stopped: bool = True) -> None: + """Publish MQTT discovery message for camera.""" + base = f"{MQTT_TOPIC}/{cam_uri}/" + msgs = [(f"{base}state", "stopped")] if stopped else [] if MQTT_DISCOVERY: base_payload = { "device": { @@ -55,7 +53,7 @@ def wyze_discovery(cam: WyzeCamera, cam_uri: str) -> None: }, } - for entity, data in get_entities(base).items(): + for entity, data in get_entities(base, cam.is_pan_cam, cam.rtsp_fw).items(): topic = f"{MQTT_DISCOVERY}/{data['type']}/{cam.mac}/{entity}/config" if "availability_topic" not in data["payload"]: data["payload"]["availability_topic"] = f"{MQTT_TOPIC}/state" @@ -84,15 +82,8 @@ def mqtt_sub_topic(m_topics: list, callback) -> Optional[paho.mqtt.client.Client ) client.will_set(f"{MQTT_TOPIC}/state", payload="offline", qos=1, retain=True) client.connect(MQTT_HOST, int(MQTT_PORT or 1883), 30) - if MQTT_DISCOVERY: - client.subscribe(f"{MQTT_DISCOVERY}/status") - client.message_callback_add( - f"{MQTT_DISCOVERY}/status", - lambda mq_client, _, msg: bridge_status(mq_client, []) - if msg.payload.decode().lower() == "online" - else None, - ) client.loop_start() + return client @@ -149,17 +140,33 @@ def update_preview(cam_name: str): @mqtt_enabled -def cam_control(cam_names: dict, callback): +def cam_control(cams: dict, callback): topics = [] - for uri in cam_names: + for uri in cams: topics += [f"{uri.lower()}/{t}/set" for t in SET_CMDS] topics += [f"{uri.lower()}/{t}/get" for t in GET_CMDS | PARAMS] - if client := mqtt_sub_topic(topics, callback): + if MQTT_DISCOVERY: + uri_cams = {uri: cam.camera for uri, cam in cams.items()} + client.subscribe(f"{MQTT_DISCOVERY}/status") + client.message_callback_add( + f"{MQTT_DISCOVERY}/status", + lambda cc, _, msg: _mqtt_discovery(cc, uri_cams, msg), + ) client.on_message = _on_message + return client +def _mqtt_discovery(client, cams, msg): + if msg.payload.decode().lower() != "online" or not cams: + return + + bridge_status(client, []) + for uri, cam in cams.items(): + publish_discovery(uri, cam, False) + + def _on_message(client, callback, msg): msg_topic = msg.topic.split("/") if len(msg_topic) < 3: @@ -172,8 +179,8 @@ def _on_message(client, callback, msg): payload = msg.payload.decode() with contextlib.suppress(json.JSONDecodeError): json_msg = json.loads(payload) - if not isinstance(json_msg, dict): - raise json.JSONDecodeError("NOT a dictionary", payload, 0) + if not isinstance(json_msg, (dict, list)): + raise json.JSONDecodeError("NOT json", payload, 0) payload = json_msg if len(json_msg) > 1 else next(iter(json_msg.values())) resp = callback(cam, topic, payload if include_payload else "") @@ -181,8 +188,8 @@ def _on_message(client, callback, msg): logger.info(f"[MQTT] {resp}") -def get_entities(base_topic: str) -> dict: - return { +def get_entities(base_topic: str, pan_cam: bool = False, rtsp: bool = False) -> dict: + entities = { "snapshot": { "type": "camera", "payload": { @@ -219,6 +226,16 @@ def get_entities(base_topic: str) -> dict: "icon": "mdi:weather-night", }, }, + "alarm": { + "type": "switch", + "payload": { + "state_topic": f"{base_topic}alarm", + "command_topic": f"{base_topic}alarm/set", + "payload_on": 1, + "payload_off": 2, + "icon": "mdi:alarm-bell", + }, + }, "status_light": { "type": "switch", "payload": { @@ -230,6 +247,17 @@ def get_entities(base_topic: str) -> dict: "entity_category": "diagnostic", }, }, + "motion_tagging": { + "type": "switch", + "payload": { + "state_topic": f"{base_topic}motion_tagging", + "command_topic": f"{base_topic}motion_tagging/set", + "payload_on": 1, + "payload_off": 2, + "icon": "mdi:image-filter-center-focus", + "entity_category": "diagnostic", + }, + }, "bitrate": { "type": "number", "payload": { @@ -278,3 +306,41 @@ def get_entities(base_topic: str) -> dict: }, }, } + if pan_cam: + entities |= { + "pan_cruise": { + "type": "switch", + "payload": { + "state_topic": f"{base_topic}pan_cruise", + "command_topic": f"{base_topic}pan_cruise/set", + "payload_on": 1, + "payload_off": 2, + "icon": "mdi:rotate-right", + }, + }, + "motion_tracking": { + "type": "switch", + "payload": { + "state_topic": f"{base_topic}motion_tracking", + "command_topic": f"{base_topic}motion_tracking/set", + "payload_on": 1, + "payload_off": 2, + "icon": "mdi:motion-sensor", + }, + }, + } + if rtsp: + entities |= { + "rtsp": { + "type": "switch", + "payload": { + "state_topic": f"{base_topic}rtsp", + "command_topic": f"{base_topic}rtsp/set", + "payload_on": 1, + "payload_off": 2, + "icon": "mdi:motion-sensor", + }, + }, + } + + return entities diff --git a/app/wyzebridge/stream.py b/app/wyzebridge/stream.py index a163771b..618ca25b 100644 --- a/app/wyzebridge/stream.py +++ b/app/wyzebridge/stream.py @@ -61,9 +61,6 @@ def __init__(self): self.last_snap: float = 0 self.thread: Optional[Thread] = None - if MQTT_DISCOVERY: - self.thread = Thread(target=self.monior_snapshots) - @property def total(self): return len(self.streams) @@ -91,10 +88,13 @@ def stop_all(self) -> None: self.stop_flag = True for stream in self.streams.values(): stream.stop() + if self.thread and self.thread.is_alive(): + self.thread.join() def monitor_streams(self, mtx_health: Callable) -> None: self.stop_flag = False - if self.thread: + if MQTT_DISCOVERY: + self.thread = Thread(target=self.monior_snapshots) self.thread.start() mqtt = cam_control(self.streams, self.send_cmd) logger.info(f"🎬 {self.total} stream{'s'[:self.total^1]} enabled") diff --git a/app/wyzebridge/wyze_api.py b/app/wyzebridge/wyze_api.py index 24560c11..37f8b816 100644 --- a/app/wyzebridge/wyze_api.py +++ b/app/wyzebridge/wyze_api.py @@ -287,6 +287,21 @@ def get_pid_info(self, cam: WyzeCamera, pid: str = ""): return {"status": "success", "value": resp.get("value"), "response": resp} + @authenticated + def set_device_info(self, cam: WyzeCamera, params: dict): + if not isinstance(params, dict): + return {"status": "error", "response": f"invalid param type [{params=}]"} + try: + logger.info( + f"[CONTROL] ☁ Set Device Info {params} for {cam.name_uri} via Wyze API" + ) + wyzecam.api.set_device_info(self.auth, cam, params) + return {"status": "success", "response": "success"} + except ValueError as ex: + error = f'{ex.args[0].get("code")}: {ex.args[0].get("msg")}' + logger.error(f"ERROR - {error}") + return {"status": "error", "response": f"{error}"} + def clear_cache(self): logger.info("♻️ Clearing local cache...") self.auth = None diff --git a/app/wyzebridge/wyze_commands.py b/app/wyzebridge/wyze_commands.py index a7eaffc8..b7956090 100644 --- a/app/wyzebridge/wyze_commands.py +++ b/app/wyzebridge/wyze_commands.py @@ -5,6 +5,8 @@ "irled": "K10044GetIRLEDStatus", "night_vision": "K10040GetNightVisionStatus", "status_light": "K10030GetNetworkLightStatus", + "osd_timestamp": "K10070GetOSDStatus", + "osd_logo": "K10074GetOSDLogoStatus", "camera_time": "K10090GetCameraTime", "night_switch": "K10624GetAutoSwitchNightType", "alarm": "K10632GetAlarmFlashing", @@ -26,9 +28,13 @@ SET_CMDS = { "state": None, "power": None, + "time_zone": None, + "cruise_point": None, "irled": "K10046SetIRLEDStatus", "night_vision": "K10042SetNightVisionStatus", "status_light": "K10032SetNetworkLightStatus", + "osd_timestamp": "K10072SetOSDStatus", + "osd_logo": "K10076SetOSDLogoStatus", "camera_time": "K10092SetCameraTime", "night_switch": "K10626SetAutoSwitchNightType", "alarm": "K10630SetAlarmFlashing", @@ -43,6 +49,7 @@ "fps": "K10052SetFPS", "bitrate": "K10052SetBitrate", "rtsp": "K10600SetRtspSwitch", + "quick_reponse": "K11635ResponseQuickMessage", } CMD_VALUES = { @@ -64,6 +71,7 @@ "res": "4", "fps": "5", "motion_tagging": "21", + "time_zone": "22", "motion_tracking": "27", "irled": "50", } diff --git a/app/wyzebridge/wyze_control.py b/app/wyzebridge/wyze_control.py index 6e5f8f6c..7efe0def 100644 --- a/app/wyzebridge/wyze_control.py +++ b/app/wyzebridge/wyze_control.py @@ -163,6 +163,8 @@ def camera_control( "last_photo": boa["last_photo"], } resp = {topic: cam_info} + if topic == "cruise_point": + resp = {topic: pan_to_cruise_point(sess, cmd)} else: resp = send_tutk_msg(sess, cmd) if boa and cmd == "take_photo": @@ -177,6 +179,37 @@ def camera_control( camera_info.put(resp, block=False) +def pan_to_cruise_point(sess: WyzeIOTCSession, cmd): + """ + Pan to cruise point/waypoint. + """ + resp = {"command": "cruise_point", "status": "error", "value": None} + if not isinstance(cmd, tuple) or not str(cmd[1]).isdigit(): + return resp | {"response": f"Invalid cruise point: {cmd=}"} + + i = int(cmd[1]) + with sess.iotctrl_mux() as mux: + points = mux.send_ioctl(tutk_protocol.K11010GetCruisePoints()).result(timeout=5) + if not points or not isinstance(points, list): + return resp | {"response": f"Invalid cruise points: {points=}"} + + try: + waypoints = (points[i]["vertical"], points[i]["horizontal"]) + except IndexError: + return resp | {"response": f"Cruise point NOT found. {points=}"} + + logger.info(f"Pan to cruise_point={i} ({waypoints})") + res = mux.send_ioctl(tutk_protocol.K11018SetPTZPosition(*waypoints)).result( + timeout=5 + ) + + return resp | { + "status": "success", + "response": ",".join(map(str, res)) if isinstance(res, bytes) else res, + "value": i, + } + + def update_mqtt_values(topic: str, cam_name: str, resp: dict): base = f"{MQTT_TOPIC}/{cam_name}" if msgs := [(f"{base}/{k}", resp[v]) for k, v in PARAMS.items() if v in resp]: @@ -208,7 +241,7 @@ def send_tutk_msg(sess: WyzeIOTCSession, cmd: tuple | str, log: str = "info") -> logger.error(f"[CONTROL] ERROR - {ex} {cmd=}") return {topic: {"status": "error", "command": cmd, "response": str(ex)}} - resp = {"command": topic, "payload": payload} + resp = {"command": topic, "payload": payload, "value": None} try: with sess.iotctrl_mux() as mux: iotc = mux.send_ioctl(tutk_msg) @@ -218,8 +251,11 @@ def send_tutk_msg(sess: WyzeIOTCSession, cmd: tuple | str, log: str = "info") -> if tutk_msg.code == 10020: res = update_mqtt_values(topic, sess.camera.name_uri, res) params = None if isinstance(res, int) else params - value = res if isinstance(res, (dict, int)) else ",".join(map(str, res)) - resp |= {"status": "success", "response": value, "value": value} + if isinstance(res, bytes): + res = ",".join(map(str, res)) + if isinstance(res, str) and res.isdigit(): + res = int(res) + resp |= {"status": "success", "response": res, "value": res} except Empty: resp |= {"status": "success", "response": None} except Exception as ex: @@ -232,6 +268,7 @@ def send_tutk_msg(sess: WyzeIOTCSession, cmd: tuple | str, log: str = "info") -> else: resp["value"] = ",".join(map(str, params)) getattr(logger, log)(f"[CONTROL] Response: {resp}") + return {topic: resp} diff --git a/app/wyzebridge/wyze_stream.py b/app/wyzebridge/wyze_stream.py index 95de48e0..0c36f050 100644 --- a/app/wyzebridge/wyze_stream.py +++ b/app/wyzebridge/wyze_stream.py @@ -1,9 +1,11 @@ import contextlib import json import multiprocessing as mp +import zoneinfo from collections import namedtuple from ctypes import c_int from dataclasses import dataclass +from datetime import datetime from enum import IntEnum from queue import Empty, Full from subprocess import PIPE, Popen @@ -15,7 +17,7 @@ from wyzebridge.config import BRIDGE_IP, COOLDOWN, MQTT_TOPIC from wyzebridge.ffmpeg import get_ffmpeg_cmd from wyzebridge.logging import logger -from wyzebridge.mqtt import send_mqtt, update_mqtt_state, wyze_discovery +from wyzebridge.mqtt import publish_discovery, send_mqtt, update_mqtt_state from wyzebridge.webhooks import ifttt_webhook from wyzebridge.wyze_api import WyzeApi from wyzebridge.wyze_commands import GET_CMDS, PARAMS, SET_CMDS @@ -100,7 +102,7 @@ def setup(self): logger.error(f"{self.camera.nickname} may not support multiple streams!!") # self.state = StreamStatus.DISABLED self.options.update_quality(self.camera.is_2k) - wyze_discovery(self.camera, self.uri) + publish_discovery(self.uri, self.camera) @property def state(self): @@ -234,7 +236,7 @@ def get_info(self, item: Optional[str] = None) -> dict: self.update_cam_info() if self.camera.camera_info and "boa_info" in self.camera.camera_info: data["boa_url"] = f"http://{self.camera.ip}/cgi-bin/hello.cgi?name=/" - return data | self.camera.dict(exclude={"p2p_id", "enr", "parent_enr"}) + return data | self.camera.model_dump(exclude={"p2p_id", "enr", "parent_enr"}) def update_cam_info(self) -> None: if not self.connected: @@ -271,6 +273,18 @@ def send_cmd(self, cmd: str, payload: str | list | dict = "") -> dict: return self.api.get_pid_info(self.camera, "P3") run_cmd = payload if payload == "restart" else f"{cmd}_{payload}" return dict(self.api.run_action(self.camera, run_cmd), value=payload) + if cmd == "time_zone" and payload and isinstance(payload, str): + try: + zone = zoneinfo.ZoneInfo(payload) + except zoneinfo.ZoneInfoNotFoundError: + return {"response": "invalid time zone"} + if offset := datetime.now(zone).utcoffset(): + return dict( + self.api.set_device_info( + self.camera, {"device_timezone_city": zone.key} + ), + value=int(offset.total_seconds() / 3600), + ) if self.state < StreamStatus.STOPPED: return {"response": self.status()} diff --git a/app/wyzecam/api.py b/app/wyzecam/api.py index 5dac9913..931c7836 100644 --- a/app/wyzecam/api.py +++ b/app/wyzecam/api.py @@ -28,6 +28,10 @@ "sc": "01dd431d098546f9baf5233724fa2ee2", "sv": "0bc2c3bedf6c4be688754c9ad42bbf2e", }, + "set_device_Info": { + "sc": "01dd431d098546f9baf5233724fa2ee2", + "sv": "e8e1db44128f4e31a2047a8f5f80b2bd", + }, } @@ -284,6 +288,24 @@ def get_device_info(auth_info: WyzeCredential, camera: WyzeCamera) -> dict: return resp_json["data"] +def set_device_info( + auth_info: WyzeCredential, camera: WyzeCamera, params: dict +) -> dict: + """Get device info.""" + payload = dict( + _get_payload(auth_info.access_token, auth_info.phone_id, "set_device_Info"), + device_mac=camera.mac, + **params, + ) + resp = requests.post( + f"{WYZE_API}/device/set_device_info", json=payload, headers=get_headers() + ) + + resp_json = validate_resp(resp) + + return resp_json["data"] + + def get_cam_webrtc(auth_info: WyzeCredential, mac_id: str) -> dict: """Get webrtc for camera.""" ui_headers = get_headers() diff --git a/app/wyzecam/api_models.py b/app/wyzecam/api_models.py index c915398f..4e891fd1 100644 --- a/app/wyzecam/api_models.py +++ b/app/wyzecam/api_models.py @@ -42,6 +42,8 @@ # known 2k cameras PRO_CAMS = {"HL_CAM3P", "HL_PANP"} +PAN_CAMS = {"WYZECP1_JEF", "HL_PAN2", "HL_PAN3", "HL_PANP"} + BATTERY_CAMS = {"WVOD1", "HL_WCO2", "AN_RSCW"} # Doorbells @@ -166,6 +168,10 @@ def is_battery(self) -> bool: def is_vertical(self) -> bool: return self.product_model in VERTICAL_CAMS + @property + def is_pan_cam(self) -> bool: + return self.product_model in PAN_CAMS + @property def can_substream(self) -> bool: if self.rtsp_fw: diff --git a/app/wyzecam/tutk/tutk_protocol.py b/app/wyzecam/tutk/tutk_protocol.py index 5d03ba8a..6d4cd46a 100644 --- a/app/wyzecam/tutk/tutk_protocol.py +++ b/app/wyzecam/tutk/tutk_protocol.py @@ -184,6 +184,52 @@ def parse_response(self, resp_data): return json.loads(resp_data) +class K10006ConnectUserAuth(TutkWyzeProtocolMessage): + """ + New DB protocol version + """ + + def __init__( + self, + challenge_response: bytes, + phone_id: str, + open_userid: str, + open_video: bool = True, + open_audio: bool = True, + ) -> None: + super().__init__(10006) + + assert ( + len(challenge_response) == 16 + ), "expected challenge response to be 16 bytes long" + + if len(phone_id) < 4: + phone_id += "1234" + + self.challenge_response: bytes = challenge_response + self.username: bytes = phone_id.encode("utf-8") + self.open_userid: bytes = open_userid.encode("utf-8") + self.open_video: int = 1 if open_video else 0 + self.open_audio: int = 1 if open_audio else 0 + + def encode(self) -> bytes: + open_userid_len = len(self.open_userid) + encoded_msg = pack( + f"<16s4sbbb{open_userid_len}s", + self.challenge_response, + self.username, + self.open_video, + self.open_audio, + open_userid_len, + self.open_userid, + ) + + return encode(self.code, encoded_msg) + + def parse_response(self, resp_data): + return json.loads(resp_data) + + class K10008ConnectUserAuth(TutkWyzeProtocolMessage): """ The "challenge response" sent by a client to a camera as part of the authentication handshake when @@ -514,6 +560,68 @@ def encode(self) -> bytes: return encode(self.code, bytes([self.bitrate, 0, 0, 0, 0])) +class K10070GetOSDStatus(TutkWyzeProtocolMessage): + """ + A message used to check if the OSD timestamp is enabled. + + :return: the OSD timestamp status: + - 1: Enabled + - 2: Disabled + """ + + def __init__(self): + super().__init__(10070) + + +class K10072SetOSDStatus(TutkWyzeProtocolMessage): + """ + A message used to enable/disable the OSD timestamp. + + Parameters: + - value (int): 1 for on; 2 for off. + """ + + def __init__(self, value): + super().__init__(10072) + + assert 1 <= value <= 2, "value must be 1 or 2" + self.value = value + + def encode(self) -> bytes: + return encode(self.code, bytes([self.value])) + + +class K10074GetOSDLogoStatus(TutkWyzeProtocolMessage): + """ + A message used to check if the OSD logo is enabled. + + :return: the OSD logo status: + - 1: Enabled + - 2: Disabled + """ + + def __init__(self): + super().__init__(10074) + + +class K10076SetOSDLogoStatus(TutkWyzeProtocolMessage): + """ + A message used to enable/disable the OSD logo. + + Parameters: + - value (int): 1 for on; 2 for off. + """ + + def __init__(self, value): + super().__init__(10076) + + assert 1 <= value <= 2, "value must be 1 or 2" + self.value = value + + def encode(self) -> bytes: + return encode(self.code, bytes([self.value])) + + class K10090GetCameraTime(TutkWyzeProtocolMessage): """ A message used to get the current time on the camera. @@ -573,6 +681,24 @@ def encode(self) -> bytes: return encode(self.code, bytes([self.value])) +class K10302SetTimeZone(TutkWyzeProtocolMessage): + """ + A message used to set the time zone on the camera. + + Parameters: + - value (int): the time zone to set (-11 to 13). + """ + + def __init__(self, value: int): + super().__init__(10302) + assert -11 <= value <= 13, "value must be -11 to 13" + self.value: int = value + + def encode(self) -> bytes: + print(pack(" bytes: @@ -988,6 +1114,27 @@ def encode(self) -> bytes: return encode(self.code, bytes([self.value])) +class K11635ResponseQuickMessage(TutkWyzeProtocolMessage): + """ + A message used to send a quick response to the camera. + + Parameters: + - value (int): + - 1: db_response_1 (Can I help you?) + - 2: db_response_2 (Be there shortly) + - 3: db_response_3 (Leave package at door) + """ + + def __init__(self, value: int): + super().__init__(11635) + + assert 1 <= value <= 3, "value must be 1, 2 or 3" + self.value: int = value + + def encode(self) -> bytes: + return encode(self.code, bytes([self.value])) + + def encode(code: int, data: Optional[bytes]) -> bytes: data_len = 0 if data is None else len(data) encoded_msg = bytearray([0] * (16 + data_len)) @@ -1067,6 +1214,10 @@ def respond_to_ioctrl_10001( response: TutkWyzeProtocolMessage = K10008ConnectUserAuth( challenge_response, phone_id, open_userid, open_audio=enable_audio ) + elif supports(product_model, protocol, 10006): + response: TutkWyzeProtocolMessage = K10006ConnectUserAuth( + challenge_response, phone_id, open_userid, open_audio=enable_audio + ) else: response = K10002ConnectAuth(challenge_response, mac, open_audio=enable_audio) logger.debug(f"Sending response: {response}")