📹

監視カメラビューアをPySimpleGuiでつくる

2021/12/05に公開
18

ネットワークWi-Fiカメラ - Tapo C200を購入しました。
ビューアアプリを作ったので、紹介します。

💡作成するアプリ

Image from Gyazo

以下が概要です。

  1. 接続情報を入力する
  2. "Connect"を押すと、接続情報に従い接続
    • カメラ画面が表示される
  3. 矢印ボタンでパンチルトを操作
  4. "Disconnect"を押すと、切断する

⚙事前準備

アカウントの設定

購入したカメラは、↓となります。

https://www.amazon.co.jp/gp/product/B07YG7RNF2/ref=ppx_yo_dt_b_asin_title_o04_s00?ie=UTF8&psc=1

本カメラは、スマホの専用アプリより映像を見たり、パンチルトの操作ができます。

カメラの映像配信には、ユーザ名とパスワードが必要になります。
ユーザ名とパスワードのアカウント設定は、下記の公式サイトのFAQの

https://www.tapo.com/jp/faq/34/

ステップ1: Tapoアプリでアカウントを作成

を参照願います。

💻環境

開発環境

  • windows10 64bit Home
    • Python 3.8.10
    • Python 3.10.2

Pythonモジュール

Pythonに依存するモジュールは以下となります。

> python -m venv env
(env) > pip install rtsp
(env) > pip install pysimplegui
(env) > pip install --upgrade onvif_zeep

📝手順

  • カメラ映像を受信する
  • パンチルト対応を行う
  • パンチルト対応を行う(AbsoluteMove)

カメラ映像を受信する

RTSPライブストリームURL

TapoカメラのRTSPライブストリームURLは以下の様になっています。

  • 高画質な1080P (1920*1080) ストリームの場合
    • rtsp://username:password@IP Address:554/stream1
  • 低画質な360P (640*360) ストリームの場合
    • rtsp://username:password@IP Address:554/stream2

コード

コードは以下となります。

view.py
import PySimpleGUI as sg
import rtsp
from PIL import Image, ImageTk

sg.theme('Dark Brown')

USER = "username"    # <=自分の環境に合わせる
PASS = "password"    # <=自分の環境に合わせる
IPADDR = "192.168.11.xx"    # <=自分の環境に合わせる
PORT = "554"
STREAM = "stream2"
ONVIF_PORT = "2020"

layout = [
        # カメラ接続情報
        [   sg.Text('IPADDR: ', size=(12, 1)), sg.InputText(default_text=IPADDR,  size=(20, 1),key='-ipaddr-'),
            sg.Text('PORT: ', size=(12, 1)), sg.InputText(default_text=PORT,  size=(20, 1),key='-port-'),
            sg.Text('STREAM: ', size=(12, 1)), sg.InputText(default_text=STREAM,  size=(20, 1),key='-stream-')],
        [   sg.Text('ONVIF_PORT: ', size=(12, 1)), sg.InputText(default_text=ONVIF_PORT,  size=(20, 1),key='-onvif_port-')],
        [   sg.Text('USER: ', size=(12, 1)), sg.InputText(default_text=USER,  size=(20, 1),key='-user-'),
            sg.Text('PASS: ', size=(12, 1)), sg.InputText(default_text=PASS,  size=(20, 1),key='-pass-')],

        # 画面表示
        [   sg.Image(filename='', key='image')],

        # 接続, 切断
        [   sg.Button('Connect', size=(10, 1), key ='-start-'),
            sg.Button('Disconnect', size=(10, 1), key = '-stop-')],
]

is_streaming = False
window = sg.Window('cam viewer', layout, location=(32, 32))

while True:
    event, values = window.read(timeout=20)
    if event in (None, '-exit-'):
        break

    elif event == '-start-':
        rtsp_url = f"rtsp://{values['-user-']}:{values['-pass-']}@{values['-ipaddr-']}:{values['-port-']}/{values['-stream-']}"
        client = rtsp.Client(rtsp_server_uri=rtsp_url, verbose=True)
        is_streaming = True

    elif event == '-stop-':
        is_streaming = False
        client.close()
        img = Image.new("RGB", (640, 360), color=0)
        window['image'].update(data=ImageTk.PhotoImage(img))

    if is_streaming:
        frame = client.read()
        if frame is not None:
            window['image'].update(data=ImageTk.PhotoImage(frame))
        else:
            print("none")

window.close()

実行手順

(env) > python view.py

"Connect"ボタンで映像配信ができます。

パンチルト対応を行う

パンチルト操作

パンチルトは、ONVIF - PTZサービスを利用します。

Pythonモジュールのonvif_zeepを使用します。

コード

UIの部分とカメラ部分をわけて記載します。

  • UI部分
ptz_viewer.py
import PySimpleGUI as sg
from camera import CamPtz
from PIL import Image, ImageTk

sg.theme('Dark Brown')

USER = "username"    # <=自分の環境に合わせる
PASS = "password"    # <=自分の環境に合わせる
IPADDR = "192.168.11.xx"    # <=自分の環境に合わせる
PORT = "554"
STREAM = "stream2"
ONVIF_PORT = "2020"

layout = [
        # カメラ接続情報
        [   sg.Text('IPADDR: ', size=(12, 1)), sg.InputText(default_text=IPADDR,  size=(20, 1),key='-ipaddr-'),
            sg.Text('PORT: ', size=(12, 1)), sg.InputText(default_text=PORT,  size=(20, 1),key='-port-'),
            sg.Text('STREAM: ', size=(12, 1)), sg.InputText(default_text=STREAM,  size=(20, 1),key='-stream-')],
        [   sg.Text('ONVIF_PORT: ', size=(12, 1)), sg.InputText(default_text=ONVIF_PORT,  size=(20, 1),key='-onvif_port-')],
        [   sg.Text('USER: ', size=(12, 1)), sg.InputText(default_text=USER,  size=(20, 1),key='-user-'),
            sg.Text('PASS: ', size=(12, 1)), sg.InputText(default_text=PASS,  size=(20, 1),key='-pass-')],

        # 画面表示
        [   sg.Image(filename='', key='image')],

        # 接続, 切断
        [   sg.Button('Connect', size=(10, 1), key ='-start-'),
            sg.Button('Disconnect', size=(10, 1), key = '-stop-')],

        # パンチルト制御
        [   sg.Button('↖', size=(4, 1), font='Helvetica 14',key ='-pt_topleft-'),
            sg.Button('↑', size=(4, 1), font='Helvetica 14',key = '-pt_topcenter-'),
            sg.Button('↗', size=(4, 1), font='Helvetica 14', key='-pt_topright-'), ],
        [   sg.Button('←', size=(4, 1), font='Helvetica 14',key ='-pt_left-'),
            sg.Button('〇', size=(4, 1), font='Helvetica 14',key = '-pt_center-'),
            sg.Button('→', size=(4, 1), font='Helvetica 14', key='-pt_right-'), ],
        [   sg.Button('↙', size=(4, 1), font='Helvetica 14',key ='-pt_btmleft-'),
            sg.Button('↓', size=(4, 1), font='Helvetica 14',key = '-pt_btmcenter-'),
            sg.Button('↘', size=(4, 1), font='Helvetica 14', key='-pt_btmright-'), ],
]

is_streaming = False
window = sg.Window('cam viewer', layout, location=(32, 32))

while True:
    event, values = window.read(timeout=20)
    if event in (None, '-exit-'):
        break

    elif event == '-start-':
        cam_ptz = CamPtz(
            user = values['-user-'],
            pwd = values['-pass-'],
            ipaddr = values['-ipaddr-'],
            port = values['-port-'],
            stream = values['-stream-'],
            onvif_port = values['-onvif_port-']
        )
        # streaming
        cam_ptz.open()
        # onvif - move
        cam_ptz.setup_ptz()
        is_streaming = True

    elif event == '-stop-':
        is_streaming = False
        cam_ptz.close()
        img = Image.new("RGB", (640, 360), color=0)
        window['image'].update(data=ImageTk.PhotoImage(img))

    elif "-pt_" in event:
        x, y = 0, 0
        x = 1 if "right" in event else x
        x = -1 if "left" in event else x
        y = 1 if "top" in event else y
        y = -1 if "btm" in event else y
        print(event, "x:", x, "y:", y)
        if is_streaming:
            cam_ptz.move(x, y)

    if is_streaming:
        frame = cam_ptz.read()
        if frame is not None:
            window['image'].update(data=ImageTk.PhotoImage(frame))

window.close()
  • カメラ部分
camera.py
import rtsp
from onvif import ONVIFCamera
import time

class CamStream(object):
    def __init__(self, **kwargs):
        self.user = kwargs["user"]
        self.pwd = kwargs["pwd"]
        self.ipaddr = kwargs["ipaddr"]
        self.port = kwargs["port"]
        self.stream = kwargs["stream"]
        self.onvif_port = kwargs["onvif_port"]
        self.is_connect = False


    def open(self):
        rtsp_url = f"rtsp://{self.user}:{self.pwd}@{self.ipaddr}:{self.port}/{self.stream}"
        self.client = rtsp.Client(rtsp_server_uri=rtsp_url, verbose=True)
        self.is_connect = True


    def read(self):
        if self.is_connect is False:
            return None
        return self.client.read()


    def close(self):
        if self.is_connect:
            self.client.close()
            self.is_connect = False
        return



class CamPtz(CamStream):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        pass
        self.XMAX = 1
        self.XMIN = -1
        self.YMAX = 1
        self.YMIN = -1
        self.moverequest = None
        self.ptz = None
        self.is_ptz_active = False


    def setup_ptz(self):
        mycam = ONVIFCamera(self.ipaddr, self.onvif_port, self.user, self.pwd)
        # Create media service object
        media = mycam.create_media_service()
        # Create ptz service object
        self.ptz = mycam.create_ptz_service()
        # Get target profile
        media_profile = media.GetProfiles()[0]
        # Get PTZ configuration options for getting continuous move range
        request = self.ptz.create_type('GetConfigurationOptions')
        request.ConfigurationToken = media_profile.PTZConfiguration.token
        ptz_configuration_options = self.ptz.GetConfigurationOptions(request)

        self.moverequest = self.ptz.create_type('ContinuousMove')
        self.moverequest.ProfileToken = media_profile.token
        if self.moverequest.Velocity is None:
            self.moverequest.Velocity = self.ptz.GetStatus({'ProfileToken': media_profile.token}).Position

        # Get range of pan and tilt
        # NOTE: X and Y are velocity vector
        self.XMAX = ptz_configuration_options.Spaces.ContinuousPanTiltVelocitySpace[0].XRange.Max
        self.XMIN = ptz_configuration_options.Spaces.ContinuousPanTiltVelocitySpace[0].XRange.Min
        self.YMAX = ptz_configuration_options.Spaces.ContinuousPanTiltVelocitySpace[0].YRange.Max
        self.YMIN = ptz_configuration_options.Spaces.ContinuousPanTiltVelocitySpace[0].YRange.Min
        return


    def move(self, x, y):
        self.moverequest.Velocity.PanTilt.x = x
        self.moverequest.Velocity.PanTilt.y = y
        if self.is_ptz_active:
            self.ptz.Stop({'ProfileToken': self.moverequest.ProfileToken})
        active = True
        self.ptz.ContinuousMove(self.moverequest)

実行手順

(env) > python ptz_viewer.py

"Connect"ボタンで映像配信ができます。
矢印ボタンでパンチルト操作ができます。

パンチルト対応を行う(AbsoluteMove)

コメントで教えていただいた方法について記載します。

パンチルト操作

パンチルトは、ONVIF - AbsoluteMoveを使ってパンチルトを行います。

コード

UIの部分とカメラ部分をわけて記載します。

  • UI部分
ptz_viewer.py
import PySimpleGUI as sg
import rtsp
from PIL import Image, ImageTk

from onvifreq import OnvifRequest
import urllib.request


sg.theme('Dark Brown')

# 接続情報
USER = "username"    # <=自分の環境に合わせる
PASS = "password"    # <=自分の環境に合わせる
IPADDR = "192.168.11.xx"    # <=自分の環境に合わせる
PORT = "554"
STREAM = "stream2"
ONVIF_PORT = "2020"

# パンチルトパラメータ
X_MIN, X_MAX, X_STP = -170, 170, 10
Y_MIN, Y_MAX, Y_STP = -30, 30, 5
cur_x = 0
cur_y = 0
inited_ptz = False


layout = [
        # カメラ接続情報
        [   sg.Text('IPADDR: ', size=(12, 1)), sg.InputText(default_text=IPADDR,  size=(20, 1),key='-ipaddr-'),
            sg.Text('PORT: ', size=(12, 1)), sg.InputText(default_text=PORT,  size=(20, 1),key='-port-'),
            sg.Text('STREAM: ', size=(12, 1)), sg.InputText(default_text=STREAM,  size=(20, 1),key='-stream-')],
        [   sg.Text('ONVIF_PORT: ', size=(12, 1)), sg.InputText(default_text=ONVIF_PORT,  size=(20, 1),key='-onvif_port-')],
        [   sg.Text('USER: ', size=(12, 1)), sg.InputText(default_text=USER,  size=(20, 1),key='-user-'),
            sg.Text('PASS: ', size=(12, 1)), sg.InputText(default_text=PASS,  size=(20, 1),key='-pass-')],

        # 画面表示
        [   sg.Image(filename='', key='image')],

        # 接続, 切断
        [   sg.Button('Connect', size=(10, 1), key ='-start-'),
            sg.Button('Disconnect', size=(10, 1), key = '-stop-')],

        # パンチルト制御
        [   sg.Button('↖', size=(4, 1), font='Helvetica 14',key ='-pt_topleft-'),
            sg.Button('↑', size=(4, 1), font='Helvetica 14',key = '-pt_topcenter-'),
            sg.Button('↗', size=(4, 1), font='Helvetica 14', key='-pt_topright-'), ],
        [   sg.Button('←', size=(4, 1), font='Helvetica 14',key ='-pt_left-'),
            sg.Button('〇', size=(4, 1), font='Helvetica 14',key = '-pt_center-'),
            sg.Button('→', size=(4, 1), font='Helvetica 14', key='-pt_right-'), ],
        [   sg.Button('↙', size=(4, 1), font='Helvetica 14',key ='-pt_btmleft-'),
            sg.Button('↓', size=(4, 1), font='Helvetica 14',key = '-pt_btmcenter-'),
            sg.Button('↘', size=(4, 1), font='Helvetica 14', key='-pt_btmright-'), ],
]

is_streaming = False
window = sg.Window('cam viewer', layout, location=(32, 32))

while True:
    event, values = window.read(timeout=20)
    if event in (None, '-exit-'):
        break

    elif event == '-start-':
        rtsp_url = f"rtsp://{values['-user-']}:{values['-pass-']}@{values['-ipaddr-']}:{values['-port-']}/{values['-stream-']}"
        client = rtsp.Client(rtsp_server_uri=rtsp_url, verbose=True)
        onvif_request = OnvifRequest(username=values['-user-'], password=values['-pass-'])
        url = f"http://{values['-ipaddr-']}:{values['-onvif_port-']}/"
        headers = {'Content-Type': 'text/xml; charset=utf-8'}
        is_streaming = True
        inited_ptz = True

    elif event == '-stop-':
        is_streaming = False
        img = Image.new("RGB", (640, 360), color=0)
        window['image'].update(data=ImageTk.PhotoImage(img))

    elif "-pt_" in event or inited_ptz:
        x, y = cur_x, cur_y
        x = (x + X_STP) if "right" in event else x
        x = (x - X_STP) if "left" in event else x
        y = (y + Y_STP) if "top" in event else y
        y = (y - Y_STP) if "btm" in event else y
        x = X_MAX if x > X_MAX else x
        cur_x = X_MIN if x < X_MIN else x
        y = Y_MAX if y > Y_MAX else y
        cur_y = Y_MIN if y < Y_MIN else y

        print(event, "x:", cur_x, "y:", cur_y)
        if is_streaming:
            oreq = onvif_request.absolute_move(cur_x, cur_y)
            req = urllib.request.Request(url, data=oreq.encode(), method='POST', headers=headers)
            try:
                with urllib.request.urlopen(req) as response:
                    pass
                    # obody = response.read()
                    # oheaders = response.getheaders()
                    # ostatus = response.getcode()
                    # print(oheaders)
                    # print(ostatus)
                    # print(obody)
            except urllib.error.URLError as e:
                print(e.reason)
            inited_ptz = False


    if is_streaming:
        frame = client.read()
        if frame is not None:
            window['image'].update(data=ImageTk.PhotoImage(frame))
        else:
            print("none")

window.close()
  • パンチルト部分
onvifreq.py
import hashlib
import os
import base64
from datetime import datetime


class OnvifRequest:
    def __init__(self, username, password):
        self.username = username
        self.password = password

    def absolute_move(self, x, y):
        command = """
        <AbsoluteMove xmlns="http://www.onvif.org/ver20/ptz/wsdl">
            <ProfileToken>profile_1</ProfileToken>
            <Position>
                <PanTilt x="{x}" y="{y}" xmlns="http://www.onvif.org/ver10/schema" space="http://www.onvif.org/ver10/tptz/PanTiltSpaces/PositionGenericSpace"></PanTilt>
                <Zoom x="0.0" xmlns="http://www.onvif.org/ver10/schema" space="http://www.onvif.org/ver10/tptz/ZoomSpaces/PositionGenericSpace"></Zoom>
            </Position>
            <Speed>
                <PanTilt x="1" y="1" xmlns="http://www.onvif.org/ver10/schema" space="http://www.onvif.org/ver10/tptz/PanTiltSpaces/GenericSpeedSpace"/>
                <Zoom x="1.0" xmlns="http://www.onvif.org/ver10/schema" space="http://www.onvif.org/ver10/tptz/ZoomSpaces/ZoomGenericSpeedSpace"/>
            </Speed>
        </AbsoluteMove>
        """
        return self.request(self.username, self.password, command.format(x=x, y=y))

    def request(self, username, password, command):
        created = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.000Z")
        raw_nonce = os.urandom(20)
        nonce = base64.b64encode(raw_nonce)
        sha1 = hashlib.sha1()
        sha1.update(raw_nonce + created.encode("utf8") + password.encode("utf8"))
        raw_digest = sha1.digest()
        digest = base64.b64encode(raw_digest)

        request = """
        <s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope">
        <s:Header>
            <Security xmlns="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" s:mustUnderstand="1">
            <UsernameToken>
                <Username>{username}</Username>
                <Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest">{digest}</Password>
                <Nonce EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">{nonce}</Nonce>
                <Created xmlns="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">{created}</Created>
            </UsernameToken>
            </Security>
        </s:Header>
        <s:Body xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
            {command}
        </s:Body>
        </s:Envelope>
        """
        return request.format(
            username=username,
            nonce=nonce.decode("utf8"),
            created=created,
            digest=digest.decode("utf8"),
            command=command,
        )

実行手順

(env) > python ptz_viewer.py

"Connect"ボタンで映像配信ができます。
矢印ボタンでパンチルト操作ができます。

さいごに

カメラの映像の取得やパンチルトができるといろいろできそうです。

GitHubで編集を提案

Discussion

太っ腹じじぃ太っ腹じじぃ

kotaprojさん、こんばんわ!
この記事、すごく参考になりました。ありがとうございました。
ただ、記事の手順どおりに進めると、以下のエラーで手詰まりましたw
zeepかどこかのバグですかねぇ?
何か対処方法をご存じだったら、教えていただけるとありがたいです。。。

Connected to video source rtsp://user:pass@192.168.179.237:554/stream2.
Traceback (most recent call last):
File "C:\Users\futopparagg\env\lib\site-packages\onvif\client.py", line 23, in wrapped
return func(*args, **kwargs)
File "C:\Users\futopparagg\env\lib\site-packages\onvif\client.py", line 153, in wrapped
return call(params, callback)
File "C:\Users\futopparagg\env\lib\site-packages\onvif\client.py", line 140, in call
ret = func(**params)
File "C:\Users\futopparagg\env\lib\site-packages\zeep\proxy.py", line 46, in call
return self._proxy._binding.send(
File "C:\Users\futopparagg\env\lib\site-packages\zeep\wsdl\bindings\soap.py", line 135, in send
return self.process_reply(client, operation_obj, response)
File "C:\Users\futopparagg\env\lib\site-packages\zeep\wsdl\bindings\soap.py", line 229, in process_reply
return self.process_error(doc, operation)
File "C:\Users\futopparagg\env\lib\site-packages\zeep\wsdl\bindings\soap.py", line 391, in process_error
raise Fault(
zeep.exceptions.Fault

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
File "C:\Users\futopparagg\ptz_viewer.py", line 62, in <module>
cam_ptz.setup_ptz()
File "C:\Users\futopparagg\camera.py", line 65, in setup_ptz
self.moverequest.Velocity = self.ptz.GetStatus({'ProfileToken': media_profile.token}).Position
File "C:\Users\futopparagg\venv\lib\site-packages\onvif\client.py", line 26, in wrapped
raise ONVIFError(err)
onvif.exceptions.ONVIFError: Unknown error:

kotaprojkotaproj

私の方で、さきほど手順を最初から確認してみましたが、問題なく実施することができました。
機材 or 各モジュールのバージョンの差異なのかもしれません。
特に、機材が異なる場合、onvifといっても方言があるので、その辺のエラーかもです。
参考になるかわかりませんが、細かく条件を記載しました。

  • 機材

    • Tapo C200
      • ハードウェアバージョン:1.0
      • ファームウェアバージョン:1.1.16
  • 環境

    • Python3.8.10(tags/v3.8.10:3d8993a, May 3 2021, 11:48:03)
    • 各モジュールのバージョン↓
> pip list
Package            Version
------------------ ---------
attrs              21.4.0
cached-property    1.5.2
certifi            2021.10.8
charset-normalizer 2.0.11
idna               3.3
isodate            0.6.1
lxml               4.7.1
MouseInfo          0.1.3
numpy              1.22.2
onvif-zeep         0.2.12
opencv-python      4.5.5.62
Pillow             9.0.1
pip                21.1.1
platformdirs       2.4.1
PyAutoGUI          0.9.53
PyGetWindow        0.0.9
PyMsgBox           1.0.9
pyperclip          1.8.2
PyRect             0.1.4
PySimpleGUI        4.56.0
pytweening         1.0.4
pytz               2021.3
requests           2.27.1
requests-file      1.5.1
requests-toolbelt  0.9.1
rtsp               1.1.12
setuptools         56.0.0
six                1.16.0
urllib3            1.26.8
zeep               4.1.0
太っ腹じじぃ太っ腹じじぃ

kotoprojさん、こんばんわ!
早速返信いただいたのに、応答できず、恐縮です。。。
検証して情報までご提示いただけて、本当にありがとうございます。
いま、細かい確認ができない場所にいるので申し訳ないですが、いただいた情報と突き合わせしてみます。
ちなみに、当方のカメラは、Tapo C210/Aなんですが、ONVIF Device ManagerからだとちゃんとPTが制御できたので、カメラ側の機種の問題ではないかも?とは思っていますが、1つ1つ切り分けてみますね。
まずは、御礼まで。

太っ腹じじぃ太っ腹じじぃ

kotoprojさん、pip以外のバージョンを合わせて実行してみましたが、やはりconnect時に同じエラーでプログラムが停止してしまいました。時間が作れたら、エラーのソースを追いかけてみます。
以下、ご参考まで。

・機材
 ・Tapo C210/A(Tapoアプリの表示では、型番:C210)
  ・ハードウェアバージョン:1.0
  ・ファームウェアバージョン:1.1.12 Build 211028 Rel.19131n(4A50)
   →Tapoアプリでアップデートの確認を押しても「最新のファームウェア」と表示されます。
 ・環境
   ・Python 3.10.2(python -Vの結果)
   ・各モジュールのバージョン↓

pip list
Package Version


attrs 21.4.0
cached-property 1.5.2
certifi 2021.10.8
charset-normalizer 2.0.11
idna 3.3
isodate 0.6.1
lxml 4.7.1
MouseInfo 0.1.3
numpy 1.22.2
onvif-zeep 0.2.12
opencv-python 4.5.5.62
Pillow 9.0.1
pip 22.0.3
platformdirs 2.4.1
PyAutoGUI 0.9.53
PyGetWindow 0.0.9
PyMsgBox 1.0.9
pyperclip 1.8.2
PyRect 0.1.4
PyScreeze 0.1.28
PySimpleGUI 4.56.0
pytweening 1.0.4
pytz 2021.3
requests 2.27.1
requests-file 1.5.1
requests-toolbelt 0.9.1
rtsp 1.1.12
setuptools 56.0.0
six 1.16.0
urllib3 1.26.8
zeep 4.1.0

kotaprojkotaproj

参考まで。

Python3.10.2だったため、こちらの環境でも3.10.2で実施しました。
→問題なく動作することができました。

C200とC210Aの差かもしれませんね。
エラーを見ると、onvif clientのunkownになっているため、
media_profileのところに差異があるのかもしれません。
こちらの環境で、camera.pyのsetup_ptz()にprint文を追加しました。

    def setup_ptz(self):
        mycam = ONVIFCamera(self.ipaddr, self.onvif_port, self.user, self.pwd)
        print("mycam:", mycam)
        # Create media service object
        media = mycam.create_media_service()
        print("media:", media)
        # Create ptz service object
        self.ptz = mycam.create_ptz_service()
        print("self.ptz:", self.ptz)
        # Get target profile
        media_profile = media.GetProfiles()[0]
        print("media_profile:", media_profile)  # <=add

正常時(C200)の結果が以下です。

media_profile: {
    'Name': 'mainStream',
    'VideoSourceConfiguration': {
        'Name': 'VideoSourceConfig',
        'UseCount': 2,
        'SourceToken': 'raw_vs1',
        'Bounds': {
            'x': 0,
            'y': 0,
            'width': 1280,
            'height': 720
        },
        '_value_1': None,
        'Extension': None,
        'token': 'vsconf',
        '_attr_1': {
    }
    },
    'AudioSourceConfiguration': {
        'Name': 'AudioSourceConfig',
        'UseCount': 2,
        'SourceToken': 'raw_as1',
        '_value_1': None,
        'token': 'asconf',
        '_attr_1': {
    }
    },
    'VideoEncoderConfiguration': {
        'Name': 'VideoEncoder_1',
        'UseCount': 1,
        'Encoding': 'H264',
        'Resolution': {
            'Width': 1920,
            'Height': 1080
        },
        'Quality': 3.0,
        'RateControl': {
            'FrameRateLimit': 15,
            'EncodingInterval': 1,
            'BitrateLimit': 1024
        },
        'MPEG4': None,
        'H264': {
            'GovLength': 25,
            'H264Profile': 'Main'
        },
        'Multicast': {
            'Address': {
                'Type': 'IPv4',
                'IPv4Address': '0.0.0.0',
                'IPv6Address': None
            },
            'Port': 0,
            'TTL': 0,
            'AutoStart': False,
            '_value_1': None,
            '_attr_1': None
        },
        'SessionTimeout': datetime.timedelta(seconds=65),
        '_value_1': None,
        'token': 'main',
        '_attr_1': {
    }
    },
    'AudioEncoderConfiguration': {
        'Name': 'AudioEncoder_1',
        'UseCount': 2,
        'Encoding': 'G711',
        'Bitrate': 131072,
        'SampleRate': 8000,
        'Multicast': {
            'Address': {
                'Type': 'IPv4',
                'IPv4Address': '0.0.0.0',
                'IPv6Address': None
            },
            'Port': 0,
            'TTL': 0,
            'AutoStart': False,
            '_value_1': None,
            '_attr_1': None
        },
        'SessionTimeout': datetime.timedelta(seconds=65),
        '_value_1': None,
        'token': 'microphone',
        '_attr_1': {
    }
    },
    'VideoAnalyticsConfiguration': {
        'Name': 'VideoAnalyticsName',
        'UseCount': 2,
        'AnalyticsEngineConfiguration': {
            'AnalyticsModule': [
                {
                    'Parameters': {
                        'SimpleItem': [
                            {
                                'Name': 'Sensitivity',
                                'Value': 'medium'
                            },
                            {
                                'Name': 'Enabled',
                                'Value': 'on'
                            }
                        ],
                        'ElementItem': [
                            {
                                '_value_1': <Element {http://www.onvif.org/ver10/schema}CellLayout at 0x2363b745780>,
                                'Name': 'Layout'
                            }
                        ],
                        'Extension': None,
                        '_attr_1': None
                    },
                    'Name': 'MyCellMotionModule',
                    'Type': 'tt:CellMotionEngine'
                },
                {
                    'Parameters': {
                        'SimpleItem': [
                            {
                                'Name': 'Sensitivity',
                                'Value': None
                            },
                            {
                                'Name': 'Enabled',
                                'Value': None
                            }
                        ],
                        'ElementItem': [],
                        'Extension': None,
                        '_attr_1': None
                    },
                    'Name': 'MyTamperDetecModule',
                    'Type': 'tt:TamperEngine'
                }
            ],
            'Extension': None,
            '_attr_1': None
        },
        'RuleEngineConfiguration': {
            'Rule': [
                {
                    'Parameters': {
                        'SimpleItem': [
                            {
                                'Name': 'ActiveCells',
                                'Value': '0P8A8A=='
                            },
                            {
                                'Name': 'MinCount',
                                'Value': '5'
                            },
                            {
                                'Name': 'AlarmOnDelay',
                                'Value': '1000'
                            },
                            {
                                'Name': 'AlarmOffDelay',
                                'Value': '1000'
                            }
                        ],
                        'ElementItem': [],
                        'Extension': None,
                        '_attr_1': None
                    },
                    'Name': 'MyMotionDetectorRule',
                    'Type': 'tt:CellMotionDetector'
                },
                {
                    'Parameters': None,
                    'Name': 'MyTamperDetectorRule',
                    'Type': 'tt:TamperDetector'
                }
            ],
            'Extension': None,
            '_attr_1': None
        },
        '_value_1': None,
        'token': 'VideoAnalyticsToken',
        '_attr_1': {
    }
    },
    'PTZConfiguration': {
        'Name': 'PTZConfiguration0',
        'UseCount': 0,
        'NodeToken': 'Node0',
        'DefaultAbsolutePantTiltPositionSpace': 'http://www.onvif.org/ver10/tptz/PanTiltSpaces/PositionGenericSpace',
        'DefaultAbsoluteZoomPositionSpace': None,
        'DefaultRelativePanTiltTranslationSpace': 'http://www.onvif.org/ver10/tptz/PanTiltSpaces/TranslationGenericSpace',
        'DefaultRelativeZoomTranslationSpace': None,
        'DefaultContinuousPanTiltVelocitySpace': 'http://www.onvif.org/ver10/tptz/PanTiltSpaces/VelocityGenericSpace',
        'DefaultContinuousZoomVelocitySpace': None,
        'DefaultPTZSpeed': {
            'PanTilt': {
                'x': 0.0,
                'y': 0.0,
                'space': 'http://www.onvif.org/ver10/tptz/PanTiltSpaces/GenericSpeedSpace'
            },
            'Zoom': None
        },
        'DefaultPTZTimeout': datetime.timedelta(seconds=20),
        'PanTiltLimits': {
            'Range': {
                'URI': None,
                'XRange': {
                    'Min': -170.0,
                    'Max': 170.0
                },
                'YRange': {
                    'Min': -32.0,
                    'Max': 35.0
                }
            }
        },
        'ZoomLimits': None,
        'Extension': None,
        'token': 'PTZConfiguration0',
        '_attr_1': {
    }
    },
    'MetadataConfiguration': None,
    'Extension': None,
    'token': 'profile_1',
    'fixed': True,
    '_attr_1': {
}
}

'PTZConfiguration'あたりに差異があるのかもです。
あと、onvifのポートが2020でない場合、同様のエラーになる可能性があります。

太っ腹じじぃ太っ腹じじぃ

kotaprojさん、こんばんわ!
返信&情報提供いただいてありがとうございます。
また、お返事に時間がかかってしまって大変恐縮です。
こちらも切り分けに進展がありました。
kotaprojさんに調べてばかりいただいて申し訳なかったので、なんとかC200を入手して、同じプログラムを実行したところ、こちらの環境でも例外が発生せずに接続できて、パン・チルトまで動作することが確認できました。
やはり、ご指摘いただいたように、C200とC210の何らかの差異による例外のようです。
画質向上して上位互換だろうと勝手に思い込んでいたのが裏目にでました。。。
ともあれ両方のハードがあるので、kotaprojさんの情報も参考にして、少し実験を進めてみます。
もし何かしら、差異がわかったら、共有させていただきますね。

本当にいろいろとありがとうございました。(- -)(_ _)ペコリ

太っ腹じじぃ太っ腹じじぃ

kotaprojさん、参考までにC210でmedia_profileをprintした結果を共有させていただきます。
最後のほう(ZoomLimitsの直下のExtension)に単純でない差分が少し見受けられましたが、理由については調査できていません。

media_profile: {
'Name': 'mainStream',
'VideoSourceConfiguration': {
'Name': 'VideoSourceConfig',
'UseCount': 2,
'SourceToken': 'raw_vs1',
'Bounds': {
'x': 0,
'y': 0,
'width': 2304,
'height': 1296
},
'_value_1': None,
'Extension': None,
'token': 'vsconf',
'_attr_1': {
}
},
'AudioSourceConfiguration': {
'Name': 'AudioSourceConfig',
'UseCount': 2,
'SourceToken': 'raw_as1',
'_value_1': None,
'token': 'asconf',
'_attr_1': {
}
},
'VideoEncoderConfiguration': {
'Name': 'VideoEncoder_1',
'UseCount': 1,
'Encoding': 'H264',
'Resolution': {
'Width': 1280,
'Height': 720
},
'Quality': 5.0,
'RateControl': {
'FrameRateLimit': 15,
'EncodingInterval': 1,
'BitrateLimit': 2048
},
'MPEG4': None,
'H264': {
'GovLength': 25,
'H264Profile': 'Main'
},
'Multicast': {
'Address': {
'Type': 'IPv4',
'IPv4Address': '0.0.0.0',
'IPv6Address': None
},
'Port': 0,
'TTL': 0,
'AutoStart': False,
'_value_1': None,
'_attr_1': None
},
'SessionTimeout': datetime.timedelta(seconds=65),
'_value_1': None,
'token': 'main',
'_attr_1': {
}
},
'AudioEncoderConfiguration': {
'Name': 'AudioEncoder_1',
'UseCount': 2,
'Encoding': 'G711',
'Bitrate': 128000,
'SampleRate': 8000,
'Multicast': {
'Address': {
'Type': 'IPv4',
'IPv4Address': '0.0.0.0',
'IPv6Address': None
},
'Port': 0,
'TTL': 0,
'AutoStart': False,
'_value_1': None,
'_attr_1': None
},
'SessionTimeout': datetime.timedelta(seconds=65),
'_value_1': None,
'token': 'microphone',
'_attr_1': {
}
},
'VideoAnalyticsConfiguration': {
'Name': 'VideoAnalyticsName',
'UseCount': 2,
'AnalyticsEngineConfiguration': {
'AnalyticsModule': [
{
'Parameters': {
'SimpleItem': [
{
'Name': 'Sensitivity',
'Value': 'medium'
},
{
'Name': 'Enabled',
'Value': 'on'
}
],
'ElementItem': [
{
'_value_1': <Element {http://www.onvif.org/ver10/schema}CellLayout at 0x1fe51b358c0>,
'Name': 'Layout'
}
],
'Extension': None,
'_attr_1': None
},
'Name': 'MyCellMotionModule',
'Type': 'tt:CellMotionEngine'
},
{
'Parameters': {
'SimpleItem': [
{
'Name': 'Sensitivity',
'Value': 'medium'
},
{
'Name': 'Enabled',
'Value': 'off'
}
],
'ElementItem': [],
'Extension': None,
'_attr_1': None
},
'Name': 'MyTamperDetecModule',
'Type': 'tt:TamperEngine'
}
],
'Extension': None,
'_attr_1': None
},
'RuleEngineConfiguration': {
'Rule': [
{
'Parameters': {
'SimpleItem': [
{
'Name': 'ActiveCells',
'Value': '0P8A8A=='
},
{
'Name': 'MinCount',
'Value': '5'
},
{
'Name': 'AlarmOnDelay',
'Value': '1000'
},
{
'Name': 'AlarmOffDelay',
'Value': '1000'
}
],
'ElementItem': [],
'Extension': None,
'_attr_1': None
},
'Name': 'MyMotionDetectorRule',
'Type': 'tt:CellMotionDetector'
},
{
'Parameters': None,
'Name': 'MyTamperDetectorRule',
'Type': 'tt:TamperDetector'
}
],
'Extension': None,
'_attr_1': None
},
'_value_1': None,
'token': 'VideoAnalyticsToken',
'_attr_1': {
}
},
'PTZConfiguration': {
'Name': 'PTZ',
'UseCount': 2,
'NodeToken': 'PTZNODETOKEN',
'DefaultAbsolutePantTiltPositionSpace': 'http://www.onvif.org/ver10/tptz/PanTiltSpaces/PositionGenericSpace',
'DefaultAbsoluteZoomPositionSpace': None,
'DefaultRelativePanTiltTranslationSpace': 'http://www.onvif.org/ver10/tptz/PanTiltSpaces/TranslationGenericSpace',
'DefaultRelativeZoomTranslationSpace': None,
'DefaultContinuousPanTiltVelocitySpace': 'http://www.onvif.org/ver10/tptz/PanTiltSpaces/VelocityGenericSpace',
'DefaultContinuousZoomVelocitySpace': None,
'DefaultPTZSpeed': {
'PanTilt': {
'x': 0.349999994,
'y': 0.349999994,
'space': 'http://www.onvif.org/ver10/tptz/PanTiltSpaces/GenericSpeedSpace'
},
'Zoom': None
},
'DefaultPTZTimeout': datetime.timedelta(seconds=180),
'PanTiltLimits': {
'Range': {
'URI': 'http://www.onvif.org/ver10/tptz/PanTiltSpaces/PositionGenericSpace',
'XRange': {
'Min': -1.0,
'Max': 1.0
},
'YRange': {
'Min': -1.0,
'Max': 1.0
}
}
},
'ZoomLimits': None,
'Extension': {
'_value_1': [
<Element {http://www.onvif.org/ver10/schema}PTControlDirection at 0x1fe519d7f40>
],
'PTControlDirection': None,
'Extension': None
},
'token': 'PTZTOKEN',
'_attr_1': {
}
},
'MetadataConfiguration': None,
'Extension': None,
'token': 'profile_1',
'fixed': True,
'_attr_1': {
}
}

kotaprojkotaproj

onvif自体の規格をよくわかっていないのですが、エラーは↓で上がっているようなので、print文をみると何かわかるかもしれませんね。

client.py
# Ensure methods to raise an ONVIFError Exception
# when some thing was wrong
def safe_func(func):
    def wrapped(*args, **kwargs):
        try:
            return func(*args, **kwargs)
        except Exception as err:
            #print('Ouuups: err =', err, ', func =', func, ', args =', args, ', kwargs =', kwargs)
            raise ONVIFError(err)
    return wrapped
太っ腹じじぃ太っ腹じじぃ

kotaprojさん、こんばんわ!

だいぶ間が空いてしまい大変恐縮ですが、C210でパンチルトのエラーが発生する件について、以下、わかったことを共有します。

C210の場合、どうしてもonvifパッケージでうまく動かせなかったため、知り合いの方に相談して、以下のようなソースを書くことで、AbsoluteMoveであれば、動かせることがわかりました。
残念ながら、自分の理解が追い付いておらず、どうすれば、ContinuousMoveで動かせるかまでには、至っていません。

ポイントは、PositionとSpeedのところのPatilt内に、xmlnsプロパティを明記することだそうです。
この指定がないと、lang=en は知らんというようなエラーが発生して、失敗するようです。ここも知り合いの方の受け売りなので、自分でもよく咀嚼できておらず、恐縮です。
ちなみに、このソースであれば、C200もC210もパンチルトすることは可能でした。

OnvifRequest(username = 'xxxxx', password = 'xxxxx')のxxxxxの部分と
url = 'http://xxxxxxxxx:2020/'のxxxxxxxxxの部分と
absolute_move(0, 1)のx、y(0と1)の部分を
環境や任意の値に合わせてもらうと、動きます。

同じようにハマってしまった方の参考になれば幸いです。

#!/usr/bin/env python3

import hashlib
import os
import base64
from datetime import datetime

class OnvifRequest:
def init(self, username, password):
self.username = username
self.password = password

 def absolute_move(self, x, y):
     command = """
     <AbsoluteMove xmlns="http://www.onvif.org/ver20/ptz/wsdl">
       <ProfileToken>profile_1</ProfileToken>
       <Position>
         <PanTilt x="{x}" y="{y}" xmlns="http://www.onvif.org/ver10/schema" space="http://www.onvif.org/ver10/tptz/PanTiltSpaces/PositionGenericSpace"></PanTilt>
         <Zoom x="0.0" xmlns="http://www.onvif.org/ver10/schema" space="http://www.onvif.org/ver10/tptz/ZoomSpaces/PositionGenericSpace"></Zoom>
       </Position>
       <Speed>
         <PanTilt x="1" y="1" xmlns="http://www.onvif.org/ver10/schema" space="http://www.onvif.org/ver10/tptz/PanTiltSpaces/GenericSpeedSpace"/>
         <Zoom x="1.0" xmlns="http://www.onvif.org/ver10/schema" space="http://www.onvif.org/ver10/tptz/ZoomSpaces/ZoomGenericSpeedSpace"/>
       </Speed>
     </AbsoluteMove>
     """
     return self.request(self.username, self.password, command.format(x=x, y=y))


 def request(self, username, password, command):
     created = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.000Z")
     raw_nonce = os.urandom(20)
     nonce = base64.b64encode(raw_nonce)
     sha1 = hashlib.sha1()
     sha1.update(raw_nonce + created.encode('utf8') + password.encode('utf8'))
     raw_digest = sha1.digest()
     digest = base64.b64encode(raw_digest)

     request = """
     <s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope">
       <s:Header>
         <Security xmlns="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" s:mustUnderstand="1">
           <UsernameToken>
             <Username>{username}</Username>
             <Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest">{digest}</Password>
             <Nonce EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">{nonce}</Nonce>
             <Created xmlns="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">{created}</Created>
           </UsernameToken>
         </Security>
       </s:Header>
       <s:Body xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
          {command}
       </s:Body>
     </s:Envelope>
     """
     return request.format(username=username, nonce=nonce.decode('utf8'), created=created, digest=digest.decode('utf8'), command=command)

################################################################

import urllib.request

url = 'http://xxxxxxxxx:2020/'
headers = {'Content-Type': 'text/xml; charset=utf-8'}
onvif_request = OnvifRequest(username = 'xxxxx', password = 'xxxxx').absolute_move(0, 1)

req = urllib.request.Request(url, data=onvif_request.encode(), method='POST', headers=headers)

try:
with urllib.request.urlopen(req) as response:
body = response.read()
headers = response.getheaders()
status = response.getcode()
print(headers)
print(status)
print(body)

except urllib.error.URLError as e:
print(e.reason)

以上、取り急ぎ、お知らせまで。

kotaprojkotaproj

AbsoluteMoveの説明、ありがとうございます。
自身での動作確認がてらに、記事を更新させていただきました。
こちらでも正しく制御できました。

詳しい知り合いの方と相談できるのが、うらやましいです。

よっし~よっし~

初めまして。
こちらの記事を参考に、PythonでTapo C210のカメラビューアを開発しています。

とても有益な記事を公開していただき、まずはありがとうございました。

私も記事前半のContinuousMoveを使う方法では太っ腹じじぃさんと同じ症状が出ました。
Tapo C210
 ハードウェアバージョン:2.0
 ファームウェアバージョン:1.3.6 (2023/08/17時点の最新版)
環境
 Windows11
 Python 3.8.9(64bit)
 onvif-zeep==0.2.12

また、AbsoluteMove版に変更しても、with urllib.request.urlopen(req) as response:部分で、
Bad Requestが返ってしまいます。

確認したところ、onvifreq.pyのabsolute_move()関数に渡すx, yの値が-1~1の間であれば動きました。
その範囲外になるとBad Requestを返すようです。
冒頭のパンチルトパラメータを以下のように書き換えるとPAN/TILTできました。

X_MIN, X_MAX, X_STP = -1, 1, 0.1
Y_MIN, Y_MAX, Y_STP = -1, 1, 0.1

また、absolute_move()に渡す際に-を付けないと、上下左右が逆転しました。

さらに、RelativeMove、ContinuousMoveも試しました。
AbsoluteMove版のonvifreq.pyに下記関数を追加します。

    def relative_move(self, x, y):
        command = """
        <RelativeMove xmlns="http://www.onvif.org/ver20/ptz/wsdl">
            <ProfileToken>profile_1</ProfileToken>
            <Translation>
                <PanTilt x="{x}" y="{y}" xmlns="http://www.onvif.org/ver10/schema" \
                    space="http://www.onvif.org/ver10/tptz/PanTiltSpaces/TranslationGenericSpace"/>
                <Zoom x="0" xmlns="http://www.onvif.org/ver10/schema" \
                    space="http://www.onvif.org/ver10/tptz/ZoomSpaces/TranslationGenericSpace"/>
            </Translation>
        </RelativeMove>
        """
        return self.request(self.username, self.password, command.format(x=x, y=y))

    def continuous_move(self, x, y):
        command = """
        <ContinuousMove xmlns="http://www.onvif.org/ver20/ptz/wsdl">
            <ProfileToken>profile_1</ProfileToken>
            <Velocity>
                <PanTilt x="{x}" y="{y}" xmlns="http://www.onvif.org/ver10/schema" \
                    space="http://www.onvif.org/ver10/tptz/PanTiltSpaces/VelocityGenericSpace"/>
                <Zoom x="0" xmlns="http://www.onvif.org/ver10/schema" \
                    space="http://www.onvif.org/ver10/tptz/ZoomSpaces/VelocityGenericSpace"/>
            </Velocity>
        </ContinuousMove>
        """
        return self.request(self.username, self.password, command.format(x=x, y=y))

relative_move()もx, yを-1~0の範囲で指定すると、良い感じに動きました。
こちらは、渡す際の-は不要でした。

relative_move()のx, yの値が範囲外(10や-5)などの場合、一度限界まで移動し、
さらに同じ方向に移動させようとするとBad Requestが返ります。
範囲外の数字は、Tapoアプリにある「垂直パトロール」「水平パトロール」の実現に使えるかもしれません。

私は猫を監視する目的でカメラを天井に設置しており、
Tapoアプリで「動作トラッキング」をONにしています。
猫を追ってカメラが動くので、RelativeMoveの方を採用しました。
その場合、X_MIN, X_MAX, Y_MIN, Y_MAXを使わずにPAN/TILT操作をして、
Bad Requestが返ったら「操作範囲外になったというメッセージを出力する」のがベストでした。

ContinuousMoveは私の状況では利用を想定しておらず、
relative_move()用のx, y値を渡してカメラが動く事だけテストしました。
こちらも、渡す際の-は不要でした。

よっし~よっし~

absolute_move()に渡すx, y値に-が必要と書き込んでしまった件で訂正します。

私は、カメラを天井に逆さに取り付けているため、「画像を反転」をONにしています。
そのため-がないと、移動方向が逆になっていました。

absolute_move()にrelative_move()渡すx, yに-を付けるかは、
以下のようにするのが良いようです。

Tapoアプリで「画像を反転」をONにしている場合、
 absolute_move()には-を付ける。
 relative_move()には-を付けない。
 continuous_move()には-を付けない。
Tapoアプリで「画像を反転」をOFFにしている場合、
 absolute_move()には-を付けない。
 relative_move()には-を付ける。
 continuous_move()には-を付ける。

また、今回の件で私が調べたことなどをこちらにまとめました。
https://zenn.dev/niku9mofumofu/articles/e4903b393a53b1
もし記事を修正される予定があれば、必要に応じて引用等してください。

くにまさくにまさ

tapoC210は製品の仕様上、公式ではiOSもしくはアンドロイドスマフォのみ対応しており、パソコンモニター上で、なんとかライブストリーミングできないか、しかも、既製品に頼ることなくブラウザを自作できないかと思いネットサーフィンをしていたところ、この記事を見つけました。
真似をしてみたいのですが、そもそも前提知識や経験がなく、環境構築から作業が必要です。可能な範囲で謝礼をお支払いしますので、記事の内容のトレースできるところまで、サポートを頂けないか、ご検討をお願いしたいです。

kotaprojkotaproj

そもそも前提知識や経験がなく、環境構築から作業が必要です

記事をトレースするには、以下の手順になると思われます。

  • Pythonのインストール
  • venvの作成、モジュールインストール
  • サンプルコードの実行

上記でわかるものはどれになりますか

くにまさくにまさ

お返事ありがとうございます。
ググりながら見様見真似で、tapoという名前の仮想環境venvの作成し、
サンプルコードの実行まで行いました。
実行したところ、

(tapo) C:\Users\user\tapo>python view.py
[ WARN:0@30.065] global cap_ffmpeg_impl.hpp:453 _opencv_ffmpeg_interrupt_callback Stream timeout triggered after 30059.696000 ms
Connected to video source rtsp://ユーザー名:パスワード@192.168.68.53:554/stream2.
none
none
none
none
none
none
none
none 以下、続く

というエラーが発生し、躓いているところです。

kotaprojkotaproj

環境構築には問題ないように見えます

下記のいずれかだと思われます

  • ユーザ名/パスワードが間違っている
  • カメラに対して設定していない
    • カメラに設定後、カメラ側を再起動する

カメラ側の設定 → "高度な設定" → "カメラのアカウント"から実施できます
また、実施後、再起動しないと反映されない現象に遭遇したことがあるので、
アプリから再起動させてみてください。

それで表示されるのではないでしょうか(Python3.12で問題ないことは確認済み)

もし表示されない場合は、vlc media playerを使って、
メニューバー "メディア" -> "ネットワークストリームを開く" にて、

rtsp://ユーザー名:パスワード@192.168.68.53:554/stream2

で表示されるか確認してください

表示されないようであれば、やはり認証の問題と思われます。
vlc(サードパーティでの表示)は公式も対応しているとのことなので。

くにまさくにまさ

無事に
tapoC210 ハードウェアバージョン2.0 ファームウェアバージョン1.3.11
で上記のサンプルコードを動作することができました。

先日まで実家に帰省しており、スマフォのテザリングで実行していたので、ネットワーク環境(ポートとかですかね?よく分かりませんが)の問題で、カメラを見つけることができなかったようです。

無事にサンプルコードを動作できたので、これをいろいろカスタマイズしていきたいです。
まず、2つやりたいことがあります。

1つ目は、複数のカメラを同時にライブビューしたい。一つのアプリ画面内で複数表示してもよいし、アプリを複数同時起動する、どちらでも構いません。
2つ目は、パソコンでライブビューしている動画を、パソコンに保存する機能をつけたいです。こちらは、最悪、パソコン画面のスクリーンショットを定期的に撮影することでまずは疑似的に、パソコンへの録画でも良いです。

何かしら、アドバイスやご提案頂けないでしょうか。全部、無料で教えてほしいという意図はないです。ご検討よろしくお願いします。

kotaprojkotaproj

無事に
tapoC210 ハードウェアバージョン2.0 ファームウェアバージョン1.3.11
で上記のサンプルコードを動作することができました。

それはよかったです。

無事にサンプルコードを動作できたので、これをいろいろカスタマイズしていきたいです。
まず、2つやりたいことがあります。

先にお伝えしておきますが、本記事の主旨はRTSPクライアントの使い方を理解し、簡単なビューアを作成することにあります。
したがって、カスタマイズやアプリケーションの拡張に関しては、ご自身で調査を進めていただくのが良いかと思います。

1つ目は、複数のカメラを同時にライブビューしたい。
2つ目は、パソコンでライブビューしている動画を、パソコンに保存する機能をつけたいです。

アプリケーションの拡張に関する返信はこれを最後にしてください。
以下で、上記の仕様は満足していると思います。

  • 事前準備
    • cameras.yamlにカメラの接続情報を記載する
    • cameras.yamlは、multiview.pyと同じディレクトリにする
  • 使い方
    • multiview.pyを起動する
    • "Connect"で接続する
    • 接続中に、"REC"を押すと記録が開始される
    • "REC"をもう一度押すと記録が停止し、動画ファイルが作成される
  • 見た目
  • 注意事項
    • ざっと動作は見ましたが、エラー処理や今後の拡張は自身でお願いします

カメラ情報の管理

cameras.yaml
cameras:
  - id: 1
    ipaddr: "192.168.11.xx"
    port: "554"
    stream: "stream2"
    onvif_port: "2020"
    user: "user"
    pass: "password"
  - id: 2
    ipaddr: "192.168.11.xx"
    port: "554"
    stream: "stream2"
    onvif_port: "2020"
    user: "user"
    pass: "password"

マルチビューアコード

multiview.py
import yaml
import PySimpleGUI as sg
import rtsp
from PIL import Image, ImageTk
import cv2

sg.theme('Dark Brown')

# YAMLファイルからカメラ接続情報を読み込む
with open("cameras.yaml", "r") as file:
    config = yaml.safe_load(file)

layout = []

# カメラ接続情報に基づいてレイアウトを横並びで動的に生成
camera_layouts = []
for camera in config['cameras']:
    camera_layouts.append([
        sg.Column([
            [sg.Text(f'Camera {camera["id"]} - IPADDR:', size=(12, 1)), sg.InputText(default_text=camera['ipaddr'], size=(20, 1), key=f'-ipaddr{camera["id"]}-')],
            [sg.Text('PORT:', size=(12, 1)), sg.InputText(default_text=camera['port'], size=(20, 1), key=f'-port{camera["id"]}-')],
            [sg.Text('STREAM:', size=(12, 1)), sg.InputText(default_text=camera['stream'], size=(20, 1), key=f'-stream{camera["id"]}-')],
            [sg.Text('ONVIF_PORT:', size=(12, 1)), sg.InputText(default_text=camera['onvif_port'], size=(20, 1), key=f'-onvif_port{camera["id"]}-')],
            [sg.Text('USER:', size=(12, 1)), sg.InputText(default_text=camera['user'], size=(20, 1), key=f'-user{camera["id"]}-')],
            [sg.Text('PASS:', size=(12, 1)), sg.InputText(default_text=camera['pass'], size=(20, 1), key=f'-pass{camera["id"]}-')],
            [sg.Button('Connect', size=(10, 1), key=f'-start{camera["id"]}-'), sg.Button('Disconnect', size=(10, 1), key=f'-stop{camera["id"]}-')],
            [sg.Button('REC', size=(10, 1), key=f'-rec{camera["id"]}-', button_color=('white', 'grey'))]
        ]),
        sg.Image(filename='', key=f'image{camera["id"]}')
    ])

layout = [camera_layouts]

window = sg.Window('Multi-Cam Viewer', layout, location=(32, 32), resizable=True)

clients = {}
is_streaming = {}
recording = {}
video_writers = {}

while True:
    event, values = window.read(timeout=20)
    if event in (None, '-exit-'):
        break

    for camera in config['cameras']:
        camera_id = camera['id']
        if event == f'-start{camera_id}-':
            rtsp_url = f"rtsp://{values[f'-user{camera_id}-']}:{values[f'-pass{camera_id}-']}@{values[f'-ipaddr{camera_id}-']}:{values[f'-port{camera_id}-']}/{values[f'-stream{camera_id}-']}"
            clients[camera_id] = rtsp.Client(rtsp_server_uri=rtsp_url, verbose=True)
            is_streaming[camera_id] = True

        elif event == f'-stop{camera_id}-':
            if camera_id in clients:
                is_streaming[camera_id] = False
                clients[camera_id].close()
                img = Image.new("RGB", (640, 360), color=0)
                window[f'image{camera_id}'].update(data=ImageTk.PhotoImage(img))

            if camera_id in recording and recording[camera_id]:
                recording[camera_id] = False
                video_writers[camera_id].release()
                window[f'-rec{camera_id}-'].update(button_color=('white', 'grey'))

        elif event == f'-rec{camera_id}-':
            if camera_id not in recording or not recording[camera_id]:
                # 録画開始
                recording[camera_id] = True
                window[f'-rec{camera_id}-'].update(button_color=('white', 'red'))

                # 録画用のVideoWriterを設定
                fourcc = cv2.VideoWriter_fourcc(*'XVID')
                video_writers[camera_id] = cv2.VideoWriter(f'camera_{camera_id}_record.avi', fourcc, 20.0, (640, 360))
            else:
                # 録画停止
                recording[camera_id] = False
                video_writers[camera_id].release()
                window[f'-rec{camera_id}-'].update(button_color=('white', 'grey'))

        if is_streaming.get(camera_id):
            frame = clients[camera_id].read(True)
            if frame is not None:
                window[f'image{camera_id}'].update(data=ImageTk.PhotoImage(Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))))
                # 録画中であれば、フレームを保存
                if recording.get(camera_id):
                    try:
                        video_writers[camera_id].write(frame)
                    except Exception as e:
                        print(f"Error processing frame for Camera {camera_id}: {e}")
            else:
                print(f"Camera {camera_id} - No frame received")

window.close()

# プログラム終了時に全てのVideoWriterを解放
for writer in video_writers.values():
    writer.release()