├── .python-version ├── .github ├── FUNDING.yml └── workflows │ ├── docker.yaml │ └── docker-miloco.yaml ├── micam ├── __main__.py └── __init__.py ├── .env.example ├── .gitignore ├── Dockerfile ├── pyproject.toml ├── docker-compose.yml ├── Dockerfile.miloco ├── README.md └── LICENSE /.python-version: -------------------------------------------------------------------------------- 1 | 3.13 -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: 2 | - al-one 3 | -------------------------------------------------------------------------------- /micam/__main__.py: -------------------------------------------------------------------------------- 1 | from . import main 2 | 3 | main() 4 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | MILOCO_BASE_URL= 2 | MILOCO_PASSWORD= 3 | 4 | CAMERA_ID=1234567890 5 | RTSP_URL=rtsp://192.168.1.11:8554/your_stream_name 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python-generated files 2 | __pycache__/ 3 | *.py[oc] 4 | build/ 5 | dist/ 6 | wheels/ 7 | *.egg-info 8 | 9 | # Virtual environments 10 | .venv 11 | 12 | .env 13 | .idea 14 | .cache 15 | .DS_Store 16 | output.txt 17 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/astral-sh/uv:python3.13-alpine 2 | 3 | ENV PYTHONUNBUFFERED=1 \ 4 | UV_COMPILE_BYTECODE=1 \ 5 | UV_LINK_MODE=copy \ 6 | PATH="/app/.venv/bin:/root/.local/bin:$PATH" 7 | 8 | RUN apk add --no-cache bash ffmpeg 9 | 10 | WORKDIR /app 11 | COPY . . 12 | RUN --mount=type=cache,target=/root/.cache/uv \ 13 | uv sync --locked --no-dev 14 | 15 | CMD micam 16 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "micam" 3 | version = "0.1.0" 4 | description = "WebSocket to RTSP bridge for Xiaomi Miloco" 5 | readme = "README.md" 6 | requires-python = ">=3.8" 7 | dependencies = [ 8 | "aiohttp>=3.8.0", 9 | ] 10 | 11 | [project.urls] 12 | Repository = "https://github.com/miiot/micam" 13 | 14 | [project.scripts] 15 | micam = "micam:main" 16 | 17 | [build-system] 18 | requires = ["hatchling"] 19 | build-backend = "hatchling.build" 20 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | 3 | # https://github.com/XiaoMi/xiaomi-miloco 4 | miloco: 5 | container_name: miloco 6 | # image: ghcr.nju.edu.cn/xiaomi/miloco-backend # Official image 7 | image: ghcr.nju.edu.cn/miiot/miloco:main # Modified image to using HD stream 8 | network_mode: host 9 | environment: 10 | BACKEND_PORT: ${MILOCO_PORT:-8000} 11 | BACKEND_LOG_LEVEL: ${MILOCO_LOG_LEVEL:-warning} 12 | TZ: ${TZ:-Asia/Shanghai} 13 | volumes: 14 | - ./miloco:/app/miloco_server/.temp 15 | # NOTICE: Mount configuration files, if you want to use your own configuration files, please mount them here. 16 | # - ./miloco/server_config.yaml:/app/config/server_config.yaml 17 | # - ./miloco/prompt_config.yaml:/app/config/prompt_config.yaml 18 | restart: unless-stopped 19 | 20 | # https://github.com/AlexxIT/go2rtc 21 | go2rtc: 22 | container_name: go2rtc 23 | image: ghcr.nju.edu.cn/alexxit/go2rtc 24 | network_mode: host # important for WebRTC, HomeKit, UDP cameras 25 | privileged: true # only for FFmpeg hardware transcoding 26 | restart: unless-stopped # autorestart on fail or config change from WebUI 27 | environment: 28 | TZ: ${TZ:-Asia/Shanghai} 29 | volumes: 30 | - ./go2rtc:/config # folder for go2rtc.yaml file (edit from WebUI) 31 | 32 | micam1: &micam 33 | image: ghcr.nju.edu.cn/miiot/micam:main 34 | depends_on: [miloco, go2rtc] 35 | environment: &micam_envs 36 | MILOCO_BASE_URL: ${MILOCO_BASE_URL:-https://miloco:8000} 37 | MILOCO_PASSWORD: ${MILOCO_PASSWORD} 38 | CAMERA_ID: ${CAMERA_ID} 39 | RTSP_URL: ${RTSP_URL} 40 | VIDEO_CODEC: ${VIDEO_CODEC:-hevc} 41 | STREAM_CHANNEL: ${STREAM_CHANNEL:-0} 42 | TZ: ${TZ:-Asia/Shanghai} 43 | restart: always 44 | extra_hosts: 45 | - "miloco:${MILOCO_HOST_IP:-host-gateway}" 46 | 47 | # More Cameras ... 48 | micam2: 49 | <<: *micam 50 | scale: 0 # Disabled by default, set 1 to enable 51 | environment: 52 | <<: *micam_envs 53 | CAMERA_ID: ${CAMERA_2_ID} 54 | RTSP_URL: ${CAMERA_2_RTSP_URL} 55 | -------------------------------------------------------------------------------- /Dockerfile.miloco: -------------------------------------------------------------------------------- 1 | ################################################ 2 | # Frontend Builder 3 | ################################################ 4 | FROM node:20-slim AS frontend-builder 5 | 6 | ARG VERSION=main 7 | ARG GITHUB_DOMAIN=github.com 8 | 9 | WORKDIR /app 10 | RUN set -eux; \ 11 | apt update && apt install -y wget; \ 12 | wget https://${GITHUB_DOMAIN}/XiaoMi/xiaomi-miloco/archive/$VERSION.tar.gz -O- | tar zxvf - --strip 1 -C .; \ 13 | sed -i 's/enable_audio: bool = False,/enable_audio: bool = True,/' miot_kit/miot/camera.py; \ 14 | sed -i 's/= MIoTCameraVideoQuality.LOW,/= MIoTCameraVideoQuality.HIGH,/' miot_kit/miot/camera.py; \ 15 | cp -rf /app/web_ui / 16 | 17 | WORKDIR /web_ui 18 | RUN npm install 19 | RUN npm run build 20 | 21 | 22 | ################################################ 23 | # Backend 24 | ################################################ 25 | FROM python:3.12-slim AS backend 26 | 27 | ENV TZ=Asia/Shanghai 28 | RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone 29 | 30 | # Set working directory. 31 | WORKDIR /app 32 | 33 | # Copy app files. 34 | COPY --from=frontend-builder /app/miloco_server/pyproject.toml /app/miloco_server/pyproject.toml 35 | COPY --from=frontend-builder /app/miot_kit/pyproject.toml /app/miot_kit/pyproject.toml 36 | 37 | # Install dependencies 38 | RUN pip install --upgrade pip setuptools wheel \ 39 | && pip install --no-build-isolation /app/miloco_server \ 40 | && pip install --no-build-isolation /app/miot_kit \ 41 | && rm -rf /app/miloco_server \ 42 | && rm -rf /app/miot_kit 43 | 44 | # Copy app files. 45 | COPY --from=frontend-builder /app/miloco_server /app/miloco_server 46 | COPY --from=frontend-builder /app/config/server_config.yaml /app/config/server_config.yaml 47 | COPY --from=frontend-builder /app/config/prompt_config.yaml /app/config/prompt_config.yaml 48 | COPY --from=frontend-builder /app/scripts/start_server.py /app/start_server.py 49 | COPY --from=frontend-builder /app/miot_kit /app/miot_kit 50 | 51 | # Install project. 52 | RUN pip install --no-build-isolation -e /app/miloco_server \ 53 | && pip install --no-build-isolation -e /app/miot_kit \ 54 | && rm -rf /app/miloco_server/static \ 55 | && rm -rf /app/miloco_server/.temp \ 56 | && rm -rf /app/miloco_server/.log 57 | 58 | # Update frontend dist. 59 | COPY --from=frontend-builder /web_ui/dist/ /app/miloco_server/static/ 60 | 61 | 62 | ENV BACKEND_PORT=8000 \ 63 | BACKEND_LOG_LEVEL=warning 64 | 65 | RUN apt update && apt install -y bash netcat-openbsd; 66 | 67 | CMD ["python3", "start_server.py"] 68 | HEALTHCHECK --interval=1m --start-period=30s CMD nc -zn 0.0.0.0 ${BACKEND_PORT:-8000} || exit 1 69 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🎦 Xiaomi Camera Streamer 2 | 3 | 4 | ## Install / 安装 5 | 6 | ### 🐳 Docker compose 7 | ```shell 8 | mkdir /opt/micam 9 | cd /opt/micam 10 | wget https://raw.githubusercontent.com/miiot/micam/refs/heads/main/docker-compose.yml 11 | docker compose up -d 12 | ``` 13 | 14 | > 此命令会通过docker部署Miloco、Go2rtc及RTSP转发服务。如果需要添加多个摄像头,需要编辑`docker-compose.yml`运行多个micam服务。 15 | > 16 | > 部署的Miloco为基础版,不带AI引擎,无GPU算力要求,大部分机器都能运行。本项目基于官方镜像修改,添加了arm64支持,并默认获取高清流和音频流。 17 | 18 | 19 | ## 💻 Usage / 使用 20 | 21 | ### [Miloco](https://github.com/XiaoMi/xiaomi-miloco) 22 | 23 | > 🏠 你也可以选择通过[HAOS加载项](https://gitee.com/hasscc/addons)来部署Miloco,[一键添加](https://my.home-assistant.io/redirect/supervisor_add_addon_repository/?repository_url=https%3A%2F%2Fgitee.com%2Fhasscc%2Faddons)加载项仓库。 24 | 25 | 1. Open Miloco WebUI / 打开Miloco网页: `https://192.168.1.xx:8000` 26 | > 🔐 Miloco使用了自签证书,请通过 **https** 访问,并忽略证书错误。 27 | 2. Set miloco password / 设置Miloco密码 28 | 3. Bind your Xiaomi account / 绑定小米账号 29 | 4. Camera offline ? [[Xiaomi Miloco Q&A]](https://github.com/XiaoMi/xiaomi-miloco/issues/56) 30 | 31 | 32 | ### [Go2rtc](https://github.com/AlexxIT/go2rtc) 33 | 34 | > 🏠 你也可以选择通过[HAOS加载项](https://github.com/AlexxIT/hassio-addons)来部署Go2rtc 35 | 36 | 1. Open Go2rtc WebUI / 访问Go2rtc网页: `http://192.168.1.xx:1984/config.html` 37 | 2. Config empty streams / 配置空视频流: 38 | ```yaml 39 | streams: 40 | your_stream1: 41 | your_stream2: 42 | ``` 43 | 3. Save & Restart / 保存并重启 44 | 45 | 46 | ### [Micam](https://zread.ai/miiot/micam) 47 | 48 | 1. Set environment variables / 设置环境变量: 49 | ```shell 50 | cat << EOF > .env 51 | MILOCO_PASSWORD=your_miloco_password_md5 52 | CAMERA_ID=1234567890 # your camera did 53 | RTSP_URL=rtsp://192.168.1.xx:8554/your_stream1 54 | EOF 55 | ``` 56 | 2. Restart micam / 重启转发服务: `docker compose restart micam1` 57 | 58 | 59 | ## ⚙️ Configuration / 配置 60 | 61 | ### Environments / 环境变量 62 | 63 | > [!Note] 64 | > 建议所有的环境变量配置在`.env`文件中,并使用`docker compose up -d`命令使其生效,不建议直接修改`docker-compose.yml`中的环境变量。 65 | 66 | 1. Micam: 67 | - `MILOCO_BASE_URL`: Miloco Base URL, Default: `https://miloco:8000` 68 | > 如果通过[HAOS加载项](https://gitee.com/hasscc/addons)部署,则应配置为`https://homeassistant.local:28800` 69 | - `MILOCO_PASSWORD`: Miloco WebUI Password (md5/lower), Required 70 | - `CAMERA_ID`: Camera DID, Required 71 | > 可在Miloco网页中通过F12开发者工具的网络请求日志查看 72 | - `RTSP_URL`: RTSP URL, Required 73 | > 转推RTSP流地址,如: `rtsp://192.168.1.xx:8554/your_stream1`,8554为Go2rtc提供的RTSP服务 74 | - `VIDEO_CODEC`: Video Codec of the camera, `hevc`(default) or `h264` 75 | - `STREAM_CHANNEL`: Stream Channel of the camera, Default: `0` 76 | 77 | 2. Miloco: 78 | - `MILOCO_PORT`: Miloco listen port, Default: `8000` 79 | > 如果与其他服务端口冲突,请修改此端口,并修改`MILOCO_BASE_URL` 80 | - `MILOCO_HOST`: Miloco listen host, Default: `0.0.0.0`, Don't change 81 | - `MILOCO_LOG_LEVEL`: Miloco log level, Default: `warning` 82 | 83 | 84 | ## 🧩 Integrations / 集成 85 | - [Home Assistant: Generic Camera](https://www.home-assistant.io/integrations/generic) 86 | - [Frigate NVR](https://github.com/blakeblackshear/frigate): [HAOS Add-on](https://github.com/blakeblackshear/frigate-hass-addons) 87 | - [Scrypted](https://github.com/koush/scrypted): [HAOS Add-on](https://github.com/koush/scrypted/wiki/Installation:-Home-Assistant-OS) 88 | 89 | 90 | ## 🔗 Links / 相关链接 91 | - [详细部署文档 & AI问答](https://zread.ai/miiot/micam) 92 | - [Xiaomi Miloco](https://github.com/XiaoMi/xiaomi-miloco) 93 | - [AlexxIT Go2rtc](https://github.com/AlexxIT/go2rtc) 94 | -------------------------------------------------------------------------------- /.github/workflows/docker.yaml: -------------------------------------------------------------------------------- 1 | name: Build Docker Image 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: [published] 7 | push: 8 | branches: [main] 9 | paths-ignore: 10 | - .github/** 11 | 12 | env: 13 | GITHUB_CR_REPO: ghcr.io/${{ github.repository }} 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-latest 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | platform: 22 | - linux/amd64 23 | - linux/arm64 24 | steps: 25 | - name: Prepare 26 | run: | 27 | platform=${{ matrix.platform }} 28 | echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV 29 | 30 | - name: Docker meta 31 | id: meta 32 | uses: docker/metadata-action@v5 33 | with: 34 | images: | 35 | ${{ env.GITHUB_CR_REPO }} 36 | 37 | - name: Login to GHCR 38 | uses: docker/login-action@v3 39 | with: 40 | registry: ghcr.io 41 | username: ${{ github.repository_owner }} 42 | password: ${{ secrets.GITHUB_TOKEN }} 43 | 44 | - name: Set up QEMU 45 | uses: docker/setup-qemu-action@v3 46 | 47 | - name: Set up Docker Buildx 48 | uses: docker/setup-buildx-action@v3 49 | 50 | - name: Build and push by digest 51 | id: build 52 | uses: docker/build-push-action@v6 53 | with: 54 | platforms: ${{ matrix.platform }} 55 | labels: ${{ steps.meta.outputs.labels }} 56 | tags: | 57 | ${{ env.GITHUB_CR_REPO }} 58 | outputs: type=image,push-by-digest=true,name-canonical=true,push=true 59 | 60 | - name: Export digest 61 | run: | 62 | mkdir -p ${{ runner.temp }}/digests 63 | digest="${{ steps.build.outputs.digest }}" 64 | touch "${{ runner.temp }}/digests/${digest#sha256:}" 65 | 66 | - name: Upload digest 67 | uses: actions/upload-artifact@v4 68 | with: 69 | name: digests-${{ env.PLATFORM_PAIR }} 70 | path: ${{ runner.temp }}/digests/* 71 | if-no-files-found: error 72 | retention-days: 1 73 | 74 | merge: 75 | runs-on: ubuntu-latest 76 | needs: 77 | - build 78 | steps: 79 | - name: Download digests 80 | uses: actions/download-artifact@v4 81 | with: 82 | path: ${{ runner.temp }}/digests 83 | pattern: digests-* 84 | merge-multiple: true 85 | 86 | - name: Login to GHCR 87 | uses: docker/login-action@v3 88 | with: 89 | registry: ghcr.io 90 | username: ${{ github.repository_owner }} 91 | password: ${{ secrets.GITHUB_TOKEN }} 92 | 93 | - name: Set up Docker Buildx 94 | uses: docker/setup-buildx-action@v3 95 | 96 | - name: Docker meta 97 | id: meta 98 | uses: docker/metadata-action@v5 99 | with: 100 | images: | 101 | ${{ env.GITHUB_CR_REPO }} 102 | tags: | 103 | type=ref,event=branch 104 | type=ref,event=pr 105 | type=semver,pattern={{version}} 106 | type=semver,pattern={{major}}.{{minor}} 107 | 108 | - name: Docker tags 109 | run: | 110 | tags=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") 111 | if [ -z "$tags" ]; then 112 | echo "DOCKER_METADATA_OUTPUT_VERSION=${{ github.ref_name }}" >> $GITHUB_ENV 113 | tags="-t ${{ env.GITHUB_CR_REPO }}:${{ github.ref_name }}" 114 | fi 115 | echo "DOCKER_METADATA_TAGS=$tags" >> $GITHUB_ENV 116 | 117 | - name: Create manifest list and push 118 | working-directory: ${{ runner.temp }}/digests 119 | run: | 120 | docker buildx imagetools create ${{ env.DOCKER_METADATA_TAGS }} \ 121 | $(printf '${{ env.GITHUB_CR_REPO }}@sha256:%s ' *) 122 | 123 | - name: Inspect image 124 | run: | 125 | docker buildx imagetools inspect ${{ env.GITHUB_CR_REPO }}:${{ env.DOCKER_METADATA_OUTPUT_VERSION }} 126 | -------------------------------------------------------------------------------- /.github/workflows/docker-miloco.yaml: -------------------------------------------------------------------------------- 1 | name: Build Miloco Image 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: Version 8 | required: false 9 | default: '' 10 | schedule: 11 | - cron: "0 23 * * *" 12 | 13 | env: 14 | GITHUB_CR_REPO: ghcr.io/${{ github.repository_owner }}/miloco 15 | BUILD_VERSION: ${{ github.event.inputs.version || 'main' }} 16 | 17 | jobs: 18 | build: 19 | runs-on: ubuntu-latest 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | platform: 24 | - linux/amd64 25 | - linux/arm64 26 | steps: 27 | - name: Prepare 28 | run: | 29 | platform=${{ matrix.platform }} 30 | echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV 31 | 32 | - name: Docker meta 33 | id: meta 34 | uses: docker/metadata-action@v5 35 | with: 36 | images: | 37 | ${{ env.GITHUB_CR_REPO }} 38 | 39 | - name: Login to GHCR 40 | uses: docker/login-action@v3 41 | with: 42 | registry: ghcr.io 43 | username: ${{ github.repository_owner }} 44 | password: ${{ secrets.GITHUB_TOKEN }} 45 | 46 | - name: Set up QEMU 47 | uses: docker/setup-qemu-action@v3 48 | 49 | - name: Set up Docker Buildx 50 | uses: docker/setup-buildx-action@v3 51 | 52 | - name: Build and push by digest 53 | id: build 54 | uses: docker/build-push-action@v6 55 | with: 56 | file: Dockerfile.miloco 57 | platforms: ${{ matrix.platform }} 58 | labels: ${{ steps.meta.outputs.labels }} 59 | tags: | 60 | ${{ env.GITHUB_CR_REPO }} 61 | build-args: | 62 | VERSION=${{ env.BUILD_VERSION == 'latest' && 'main' || env.BUILD_VERSION }} 63 | outputs: type=image,push-by-digest=true,name-canonical=true,push=true 64 | 65 | - name: Export digest 66 | run: | 67 | mkdir -p ${{ runner.temp }}/digests 68 | digest="${{ steps.build.outputs.digest }}" 69 | touch "${{ runner.temp }}/digests/${digest#sha256:}" 70 | 71 | - name: Upload digest 72 | uses: actions/upload-artifact@v4 73 | with: 74 | name: digests-${{ env.PLATFORM_PAIR }} 75 | path: ${{ runner.temp }}/digests/* 76 | if-no-files-found: error 77 | retention-days: 1 78 | 79 | merge: 80 | runs-on: ubuntu-latest 81 | needs: 82 | - build 83 | steps: 84 | - name: Download digests 85 | uses: actions/download-artifact@v4 86 | with: 87 | path: ${{ runner.temp }}/digests 88 | pattern: digests-* 89 | merge-multiple: true 90 | 91 | - name: Login to GHCR 92 | uses: docker/login-action@v3 93 | with: 94 | registry: ghcr.io 95 | username: ${{ github.repository_owner }} 96 | password: ${{ secrets.GITHUB_TOKEN }} 97 | 98 | - name: Set up Docker Buildx 99 | uses: docker/setup-buildx-action@v3 100 | 101 | - name: Docker meta 102 | id: meta 103 | uses: docker/metadata-action@v5 104 | with: 105 | images: | 106 | ${{ env.GITHUB_CR_REPO }} 107 | tags: | 108 | ${{ env.BUILD_VERSION }} 109 | type=schedule,pattern={{date 'YYMMDD' tz='Asia/Shanghai'}} 110 | type=sha 111 | 112 | - name: Docker tags 113 | run: | 114 | tags=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") 115 | if [ -z "$tags" ]; then 116 | echo "DOCKER_METADATA_OUTPUT_VERSION=${{ github.ref_name }}" >> $GITHUB_ENV 117 | tags="-t ${{ env.GITHUB_CR_REPO }}:${{ github.ref_name }}" 118 | fi 119 | echo "DOCKER_METADATA_TAGS=$tags" >> $GITHUB_ENV 120 | 121 | - name: Create manifest list and push 122 | working-directory: ${{ runner.temp }}/digests 123 | run: | 124 | docker buildx imagetools create ${{ env.DOCKER_METADATA_TAGS }} \ 125 | $(printf '${{ env.GITHUB_CR_REPO }}@sha256:%s ' *) 126 | 127 | - name: Inspect image 128 | run: | 129 | docker buildx imagetools inspect ${{ env.GITHUB_CR_REPO }}:${{ env.DOCKER_METADATA_OUTPUT_VERSION }} 130 | -------------------------------------------------------------------------------- /micam/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import asyncio 3 | import aiohttp 4 | import argparse 5 | import logging 6 | import subprocess 7 | from typing import Optional 8 | 9 | # Configure logging 10 | logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class RTSPBridge: 15 | def __init__( 16 | self, 17 | base_url: str, 18 | username: str, 19 | password: str, 20 | camera_id: str, 21 | rtsp_url: str, 22 | video_codec='hevc', 23 | channel=0, 24 | ): 25 | self.base_url = base_url 26 | self.username = username 27 | self.password = password 28 | self.camera_id = camera_id 29 | self.channel = channel 30 | self.video_codec = video_codec 31 | self.rtsp_url = rtsp_url 32 | self.process: Optional[subprocess.Popen] = None 33 | self.session: Optional[aiohttp.ClientSession] = None 34 | self.waiting_for_keyframe = True 35 | 36 | async def _login(self) -> bool: 37 | """Login and retrieve access token.""" 38 | login_url = f"{self.base_url}/api/auth/login" 39 | payload = {"username": self.username, "password": self.password} 40 | 41 | try: 42 | async with self.session.post(login_url, json=payload, ssl=False) as response: 43 | if response.status == 200: 44 | data = await response.json() 45 | logger.info(f"Login successful. %s", data) 46 | 47 | # Call login_status to ensure session is active 48 | status_url = f"{self.base_url}/api/miot/login_status" 49 | async with self.session.get(status_url, ssl=False) as status_resp: 50 | if status_resp.status == 200: 51 | return True 52 | else: 53 | logger.error(f"Login status check failed: {status_resp.status}") 54 | return False 55 | else: 56 | logger.error(f"Login failed: {response.status} - {await response.text()}") 57 | return False 58 | except Exception as e: 59 | logger.error(f"Login exception: {e}") 60 | await asyncio.sleep(3) 61 | return False 62 | 63 | def _start_ffmpeg(self): 64 | """Start FFmpeg process.""" 65 | # FFmpeg command to read from stdin and publish to RTSP 66 | ffmpeg_cmd = [ 67 | 'ffmpeg', 68 | '-y', 69 | '-v', 'error', 70 | '-hide_banner', 71 | '-use_wallclock_as_timestamps', '1', # Generate timestamps from arrival time 72 | '-analyzeduration', '20000000', # 20 seconds 73 | '-probesize', '20000000', # 20 MB 74 | '-f', self.video_codec, # Input format 75 | '-i', 'pipe:0', # Read from stdin 76 | '-c:v', 'copy', # Copy video stream 77 | '-c:a', 'copy', # Copy audio stream 78 | '-f', 'rtsp', # Output format 79 | '-rtsp_transport', 'tcp', # Use TCP for RTSP 80 | self.rtsp_url, 81 | ] 82 | 83 | logger.info(f"Starting FFmpeg: {' '.join(ffmpeg_cmd)}") 84 | self.process = subprocess.Popen( 85 | ffmpeg_cmd, 86 | stdin=subprocess.PIPE, 87 | stdout=subprocess.DEVNULL, # Suppress FFmpeg stdout 88 | stderr=subprocess.PIPE, # Capture stderr for debugging if needed 89 | ) 90 | 91 | def _stop_ffmpeg(self): 92 | """Stop FFmpeg process.""" 93 | if self.process: 94 | if self.process.poll() is None: 95 | self.process.terminate() 96 | try: 97 | self.process.wait(timeout=5) 98 | except subprocess.TimeoutExpired: 99 | self.process.kill() 100 | self.process = None 101 | 102 | async def run(self): 103 | """Main loop to connect to WebSocket and pipe data.""" 104 | self._start_ffmpeg() 105 | 106 | jar = aiohttp.CookieJar(unsafe=True) 107 | async with aiohttp.ClientSession(cookie_jar=jar) as session: 108 | self.session = session 109 | if not await self._login(): 110 | self._stop_ffmpeg() 111 | return 112 | 113 | protocol = "wss" if self.base_url.startswith("https") else "ws" 114 | host = self.base_url.split("://")[1] 115 | ws_url = f"{protocol}://{host}/api/miot/ws/video_stream?camera_id={self.camera_id}&channel={self.channel}" 116 | logger.info(f"Connecting to WebSocket: {ws_url}") 117 | 118 | try: 119 | async with session.ws_connect(ws_url, ssl=False) as ws: 120 | logger.info("WebSocket connected. Streaming data...") 121 | 122 | while True: 123 | try: 124 | msg = await asyncio.wait_for(ws.receive(), timeout=60.0) 125 | except asyncio.TimeoutError: 126 | logger.error("Data received timeout. Exiting.") 127 | break 128 | 129 | if msg.type == aiohttp.WSMsgType.BINARY: 130 | try: 131 | data_len = len(msg.data) 132 | if data_len >= 100: 133 | logger.debug("Received binary data: %s", data_len) 134 | 135 | if self.waiting_for_keyframe: 136 | if self._is_keyframe(msg.data): 137 | logger.info("Keyframe detected! Starting stream...") 138 | self.waiting_for_keyframe = False 139 | else: 140 | logger.debug("Skipping non-keyframe data...") 141 | continue 142 | await asyncio.wait_for(self.process_write(msg.data), timeout=30.0) 143 | except asyncio.TimeoutError: 144 | logger.error("Write data to process timeout.") 145 | await self.process_stderr() 146 | break 147 | except BrokenPipeError as e: 148 | logger.error("FFmpeg process terminated unexpectedly. %s", e) 149 | await self.process_stderr() 150 | break 151 | elif msg.type == aiohttp.WSMsgType.ERROR: 152 | logger.error(f"WebSocket connection closed with error {ws.exception()}") 153 | break 154 | elif msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSING, aiohttp.WSMsgType.CLOSED): 155 | logger.info("WebSocket connection close(%s)", msg.type) 156 | break 157 | else: 158 | logger.info(f"Unexpected WebSocket message type: {msg.type}") 159 | except Exception as e: 160 | logger.error(f"Streaming error", exc_info=True) 161 | finally: 162 | self._stop_ffmpeg() 163 | logger.info("Stream finished") 164 | 165 | def _is_keyframe(self, data: bytes) -> bool: 166 | if self.video_codec == 'h264': 167 | i = 0 168 | while i < len(data) - 4: 169 | if ( 170 | data[i] == 0x00 and data[i + 1] == 0x00 and 171 | ((data[i + 2] == 0x00 and data[i + 3] == 0x01) or data[i + 2] == 0x01) 172 | ): 173 | nal_unit_type = (data[i + 3] & 0x1f) if data[i + 2] == 0x01 else (data[i + 4] & 0x1f) 174 | return nal_unit_type == 5 175 | i += 1 176 | return False 177 | elif self.video_codec == 'hevc': 178 | i = 0 179 | while i < len(data) - 6: 180 | if ( 181 | data[i] == 0x00 and data[i + 1] == 0x00 and 182 | ((data[i + 2] == 0x00 and data[i + 3] == 0x01) or data[i + 2] == 0x01) 183 | ): 184 | nal_start = i + 3 if data[i + 2] == 0x01 else i + 4 185 | nal_unit_type = (data[nal_start] >> 1) & 0x3f 186 | if nal_unit_type in [16, 17, 18, 19, 20]: 187 | return True 188 | i += 1 189 | return False 190 | return True 191 | 192 | async def process_write(self, data): 193 | if not self.process: 194 | raise RuntimeError("Process not started") 195 | if not self.process.stdin: 196 | raise RuntimeError("Process has no stdin") 197 | loop = asyncio.get_running_loop() 198 | await loop.run_in_executor(None, self._process_write, data) 199 | 200 | def _process_write(self, data): 201 | self.process.stdin.write(data) 202 | self.process.stdin.flush() 203 | 204 | async def process_stderr(self): 205 | if not self.process: 206 | raise RuntimeError("Process not started") 207 | if not self.process.stderr: 208 | return 209 | stderr = self.process.stderr.read().decode() 210 | if stderr: 211 | logger.error(f"FFmpeg stderr: %s", stderr) 212 | 213 | 214 | def main(): 215 | parser = argparse.ArgumentParser(description="Bridge WebSocket video stream to RTSP") 216 | parser.add_argument("--base-url", default="", help="Base URL of the Miloco server") 217 | parser.add_argument("--username", default="admin", help="Login username") 218 | parser.add_argument("--password", default="", help="Login password (MD5)") 219 | parser.add_argument("--camera-id", default="", help="Camera ID to stream") 220 | parser.add_argument("--channel", default="", help="Camera channel") 221 | parser.add_argument("--video-codec", default="hevc", help="Input video codec (hevc or h264)") 222 | parser.add_argument("--rtsp-url", default="", help="Target RTSP URL") 223 | 224 | args = parser.parse_args() 225 | 226 | password = args.password or os.getenv("MILOCO_PASSWORD", "") 227 | if not password: 228 | logger.error("Password is required") 229 | return 230 | 231 | camera_id = args.camera_id or os.getenv("CAMERA_ID", "") 232 | if not camera_id: 233 | logger.error("Camera ID is required") 234 | return 235 | 236 | bridge = RTSPBridge( 237 | base_url=args.base_url or os.getenv("MILOCO_BASE_URL", "https://miloco:8000"), 238 | username=args.username or os.getenv("MILOCO_USERNAME", "admin"), 239 | password=password, 240 | camera_id=camera_id, 241 | rtsp_url=args.rtsp_url or os.getenv("RTSP_URL", "rtsp://0.0.0.0:8554/live"), 242 | video_codec=args.video_codec or os.getenv("VIDEO_CODEC", "hevc"), 243 | channel=args.channel or os.getenv("STREAM_CHANNEL", "0"), 244 | ) 245 | 246 | try: 247 | asyncio.run(bridge.run()) 248 | except KeyboardInterrupt: 249 | logger.info("Stopped by user") 250 | 251 | 252 | if __name__ == "__main__": 253 | main() 254 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------