├── .github └── workflows │ ├── codeql.yml │ └── main.yml ├── .gitignore ├── AlarmServer.py ├── ArduinoOSD.cpp ├── DeviceManager.py ├── Dockerfile ├── LICENSE ├── NVR.py ├── NVRVideoDownloader.json ├── NVRVideoDownloader.py ├── README.md ├── asyncio_dvrip.py ├── connect.py ├── doc ├── Соглашение о интерфейсе цифрового видеорегистратора XiongmaiV1.0.doc ├── 码流帧格式文档.pdf ├── 配置交换格式V2.0.pdf ├── 雄迈数字视频录像机接口协议V1.0.doc └── 雄迈数字视频录像机接口协议_V1.0.0.pdf ├── download-local-files.py ├── dvrip.py ├── examples └── socketio │ ├── Dockerfile │ ├── README.md │ ├── app.py │ ├── client.py │ └── requirements.txt ├── images ├── osd-new.png ├── vixand.jpg └── xm.jpg ├── monitor.py ├── setup.py ├── solarcam.py └── telnet_opener.py /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "master" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "master" ] 20 | schedule: 21 | - cron: '15 1 * * 2' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} 27 | timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} 28 | permissions: 29 | actions: read 30 | contents: read 31 | security-events: write 32 | 33 | strategy: 34 | fail-fast: false 35 | matrix: 36 | language: [ 'python' ] 37 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby', 'swift' ] 38 | # Use only 'java' to analyze code written in Java, Kotlin or both 39 | # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both 40 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 41 | 42 | steps: 43 | - name: Checkout repository 44 | uses: actions/checkout@v3 45 | 46 | # Initializes the CodeQL tools for scanning. 47 | - name: Initialize CodeQL 48 | uses: github/codeql-action/init@v2 49 | with: 50 | languages: ${{ matrix.language }} 51 | # If you wish to specify custom queries, you can do so here or in a config file. 52 | # By default, queries listed here will override any specified in a config file. 53 | # Prefix the list here with "+" to use these queries and those in the config file. 54 | 55 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 56 | # queries: security-extended,security-and-quality 57 | 58 | 59 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). 60 | # If this step fails, then you should remove it and run the build manually (see below) 61 | - name: Autobuild 62 | uses: github/codeql-action/autobuild@v2 63 | 64 | # ℹ️ Command-line programs to run using the OS shell. 65 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 66 | 67 | # If the Autobuild fails above, remove it and uncomment the following three lines. 68 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 69 | 70 | # - run: | 71 | # echo "Run, Build Application using script" 72 | # ./location_of_script_within_repo/buildscript.sh 73 | 74 | - name: Perform CodeQL Analysis 75 | uses: github/codeql-action/analyze@v2 76 | with: 77 | category: "/language:${{matrix.language}}" 78 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | push: 4 | branches: 5 | - "*" 6 | workflow_dispatch: 7 | jobs: 8 | docker: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Set up QEMU 12 | uses: docker/setup-qemu-action@v1 13 | - name: Set up Docker Buildx 14 | uses: docker/setup-buildx-action@v1 15 | - name: Login to DockerHub 16 | uses: docker/login-action@v1 17 | with: 18 | username: ${{ secrets.DOCKERHUB_USERNAME }} 19 | password: ${{ secrets.DOCKERHUB_TOKEN }} 20 | - name: Build and push 21 | uses: docker/build-push-action@v2 22 | with: 23 | push: true 24 | tags: braunbearded/python-dvr:latest,braunbearded/python-dvr:${{ github.sha }} 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.old 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /AlarmServer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import os, sys, struct, json 5 | from time import sleep 6 | from socket import * 7 | from datetime import * 8 | 9 | if len(sys.argv) > 1: 10 | port = sys.argv[1] 11 | else: 12 | print("Usage: %s [Port]" % os.path.basename(sys.argv[0])) 13 | port = input("Port(default 15002): ") 14 | if port == "": 15 | port = "15002" 16 | server = socket(AF_INET, SOCK_STREAM) 17 | server.bind(("0.0.0.0", int(port))) 18 | # server.settimeout(0.5) 19 | server.listen(1) 20 | 21 | log = "info.txt" 22 | 23 | 24 | def tolog(s): 25 | logfile = open(datetime.now().strftime("%Y_%m_%d_") + log, "a+") 26 | logfile.write(s) 27 | logfile.close() 28 | 29 | 30 | def GetIP(s): 31 | return inet_ntoa(struct.pack(">>")) 45 | print(head, version, session, sequence_number, msgid, len_data) 46 | print(json.dumps(reply, indent=4, sort_keys=True)) 47 | print("<<<") 48 | tolog(repr(data) + "\r\n") 49 | except (KeyboardInterrupt, SystemExit): 50 | break 51 | # except: 52 | # e = 1 53 | # print "no" 54 | server.close() 55 | sys.exit(1) 56 | -------------------------------------------------------------------------------- /ArduinoOSD.cpp: -------------------------------------------------------------------------------- 1 | //заготовки 2 | //'{"EncryptType": "MD5", "LoginType": "DVRIP-Web", "PassWord": "00000000", "UserName": "admin"}' 3 | char login_packet_bytes[] = { 4 | 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 5 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe8, 0x03, 6 | 0x5f, 0x00, 0x00, 0x00, 0x7b, 0x22, 0x45, 0x6e, 7 | 0x63, 0x72, 0x79, 0x70, 0x74, 0x54, 0x79, 0x70, 8 | 0x65, 0x22, 0x3a, 0x20, 0x22, 0x4d, 0x44, 0x35, 9 | 0x22, 0x2c, 0x20, 0x22, 0x4c, 0x6f, 0x67, 0x69, 10 | 0x6e, 0x54, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x20, 11 | 0x22, 0x44, 0x56, 0x52, 0x49, 0x50, 0x2d, 0x57, 12 | 0x65, 0x62, 0x22, 0x2c, 0x20, 0x22, 0x50, 0x61, 13 | 0x73, 0x73, 0x57, 0x6f, 0x72, 0x64, 0x22, 0x3a, 14 | 0x20, 0x22, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 15 | 0x30, 0x30, 0x22, 0x2c, 0x20, 0x22, 0x55, 0x73, 16 | 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x22, 0x3a, 17 | 0x20, 0x22, 0x61, 0x64, 0x6d, 0x69, 0x6e, 0x22, 18 | 0x7d, 0x0a, 0x00 19 | }; 20 | //'{"Name": "fVideo.OSDInfo", "SessionID": "0x00000002", "fVideo.OSDInfo": {"OSDInfo": [{"Info": ["0", "0", "0"], "OSDInfoWidget": {"BackColor": "0x00000000", "EncodeBlend": true, "FrontColor": "0xF0FFFFFF", "PreviewBlend": true, "RelativePos": [6144, 6144, 8192, 8192]}}], "strEnc": "UTF-8"}}' 21 | char set_packet_bytes[] = { 22 | 0xff, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 23 | 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x04, 24 | 0x24, 0x01, 0x00, 0x00, 0x7b, 0x22, 0x4e, 0x61, 25 | 0x6d, 0x65, 0x22, 0x3a, 0x20, 0x22, 0x66, 0x56, 26 | 0x69, 0x64, 0x65, 0x6f, 0x2e, 0x4f, 0x53, 0x44, 27 | 0x49, 0x6e, 0x66, 0x6f, 0x22, 0x2c, 0x20, 0x22, 28 | 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 29 | 0x44, 0x22, 0x3a, 0x20, 0x22, 0x30, 0x78, 0x30, 30 | 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x32, 0x22, 31 | 0x2c, 0x20, 0x22, 0x66, 0x56, 0x69, 0x64, 0x65, 32 | 0x6f, 0x2e, 0x4f, 0x53, 0x44, 0x49, 0x6e, 0x66, 33 | 0x6f, 0x22, 0x3a, 0x20, 0x7b, 0x22, 0x4f, 0x53, 34 | 0x44, 0x49, 0x6e, 0x66, 0x6f, 0x22, 0x3a, 0x20, 35 | 0x5b, 0x7b, 0x22, 0x49, 0x6e, 0x66, 0x6f, 0x22, 36 | 0x3a, 0x20, 0x5b, 0x22, 0x30, 0x22, 0x2c, 0x20, 37 | 0x22, 0x30, 0x22, 0x2c, 0x20, 0x22, 0x30, 0x22, 38 | 0x5d, 0x2c, 0x20, 0x22, 0x4f, 0x53, 0x44, 0x49, 39 | 0x6e, 0x66, 0x6f, 0x57, 0x69, 0x64, 0x67, 0x65, 40 | 0x74, 0x22, 0x3a, 0x20, 0x7b, 0x22, 0x42, 0x61, 41 | 0x63, 0x6b, 0x43, 0x6f, 0x6c, 0x6f, 0x72, 0x22, 42 | 0x3a, 0x20, 0x22, 0x30, 0x78, 0x30, 0x30, 0x30, 43 | 0x30, 0x30, 0x30, 0x30, 0x30, 0x22, 0x2c, 0x20, 44 | 0x22, 0x45, 0x6e, 0x63, 0x6f, 0x64, 0x65, 0x42, 45 | 0x6c, 0x65, 0x6e, 0x64, 0x22, 0x3a, 0x20, 0x74, 46 | 0x72, 0x75, 0x65, 0x2c, 0x20, 0x22, 0x46, 0x72, 47 | 0x6f, 0x6e, 0x74, 0x43, 0x6f, 0x6c, 0x6f, 0x72, 48 | 0x22, 0x3a, 0x20, 0x22, 0x30, 0x78, 0x46, 0x30, 49 | 0x46, 0x46, 0x46, 0x46, 0x46, 0x46, 0x22, 0x2c, 50 | 0x20, 0x22, 0x50, 0x72, 0x65, 0x76, 0x69, 0x65, 51 | 0x77, 0x42, 0x6c, 0x65, 0x6e, 0x64, 0x22, 0x3a, 52 | 0x20, 0x74, 0x72, 0x75, 0x65, 0x2c, 0x20, 0x22, 53 | 0x52, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x76, 0x65, 54 | 0x50, 0x6f, 0x73, 0x22, 0x3a, 0x20, 0x5b, 0x36, 55 | 0x31, 0x34, 0x34, 0x2c, 0x20, 0x36, 0x31, 0x34, 56 | 0x34, 0x2c, 0x20, 0x38, 0x31, 0x39, 0x32, 0x2c, 57 | 0x20, 0x38, 0x31, 0x39, 0x32, 0x5d, 0x7d, 0x7d, 58 | 0x5d, 0x2c, 0x20, 0x22, 0x73, 0x74, 0x72, 0x45, 59 | 0x6e, 0x63, 0x22, 0x3a, 0x20, 0x22, 0x55, 0x54, 60 | 0x46, 0x2d, 0x38, 0x22, 0x7d, 0x7d, 0x0a, 0x00 61 | }; 62 | 63 | char str1[] = "Test: 1"; 64 | char str2[] = "Test: 2"; 65 | char str3[] = "Test: 3"; 66 | 67 | memcpy( &login_packet_bytes[83], "00000000", 8 );//set password hash(83..88) 68 | client.write(login_packet_bytes); 69 | char income[20] = client.read(20) 70 | int len = 289+sizeof(str1)+sizeof(str2)+sizeof(str3); 71 | char buff[len]; 72 | int offset = 0; 73 | memcpy( &buff[4], $income[4], 4 );//4...7 - session id 74 | memcpy( &buff[16], &len, 2);//set len 16..17 - bytes 75 | //TO DO: set session hex str 76 | //70...63 - hex string session 77 | memcpy( &buff[offset], &set_packet_bytes[0], 116); 78 | //116 str1 79 | //121 str2 80 | //126 str3 81 | offset += 116; 82 | memcpy( &buff[offset], &str1[0], sizeof(str1));//set str1 83 | offset +=sizeof(str1); 84 | memcpy( &buff[offset], &set_packet_bytes[117], 4); 85 | offset += 4; 86 | memcpy( &buff[offset], &str2[0], sizeof(str2));//set str2 87 | offset += sizeof(str2); 88 | memcpy( &buff[offset], &set_packet_bytes[117], 4); 89 | offset += 4; 90 | memcpy( &buff[offset], &str3[0], sizeof(str3));//set str3 91 | offset += sizeof(str3); 92 | memcpy( &buff[offset], &set_packet_bytes[127], 185); 93 | offset += 38; 94 | memcpy( &buff[offset], "00000000", 8 );//BG color 95 | offset += 41; 96 | memcpy( &buff[offset], "F0FFFFFF", 8 );//FG color 97 | //Serial.write(buff);//debug 98 | client.write(buff); 99 | client.close(); 100 | -------------------------------------------------------------------------------- /DeviceManager.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os, sys, struct, json 4 | from locale import getdefaultlocale 5 | from subprocess import check_output 6 | from socket import * 7 | import platform 8 | from datetime import * 9 | import hashlib, base64 10 | from dvrip import DVRIPCam 11 | 12 | try: 13 | try: 14 | from tkinter import * 15 | except: 16 | from Tkinter import * 17 | from tkinter.filedialog import asksaveasfilename, askopenfilename 18 | from tkinter.messagebox import showinfo, showerror 19 | from tkinter.ttk import * 20 | 21 | GUI_TK = True 22 | except: 23 | GUI_TK = False 24 | 25 | devices = {} 26 | log = "search.log" 27 | icon = "R0lGODlhIAAgAPcAAAAAAAkFAgwKBwQBABQNBRAQDQQFERAOFA4QFBcWFSAaFCYgGAoUMhwiMSUlJCsrKyooJy8wLjUxLjkzKTY1Mzw7OzY3OEpFPwsaSRsuTRUsWD4+QCo8XQAOch0nYB05biItaj9ARjdHYiRMfEREQ0hIR0xMTEdKSVNOQ0xQT0NEUVFNUkhRXlVVVFdYWFxdXFtZVV9wXGZjXUtbb19fYFRda19gYFZhbF5wfWRkZGVna2xsa2hmaHFtamV0Ynp2aHNzc3x8fHh3coF9dYJ+eH2Fe3K1YoGBfgIgigwrmypajDtXhw9FpxFFpSdVpzlqvFNzj0FvnV9zkENnpUh8sgdcxh1Q2jt3zThi0SJy0Dl81Rhu/g50/xp9/x90/zB35TJv8DJ+/EZqzj2DvlGDrlqEuHqLpHeQp26SuhqN+yiC6imH/zSM/yqa/zeV/zik/1aIwlmP0mmayWSY122h3VWb6kyL/1yP8UGU/UiW/VWd/miW+Eqp/12k/1Co/1yq/2Gs/2qr/WKh/nGv/3er9mK3/3K0/3e4+4ODg4uLi4mHiY+Qj5WTjo+PkJSUlJycnKGem6ShnY2ZrKOjo6urrKqqpLi0prS0tLu8vMO+tb+/wJrE+bzf/sTExMfIx8zMzMjIxtrWyM/Q0NXU1NfY193d3djY1uDf4Mnj+931/OTk5Ozs7O/v8PLy8gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAEAAAAALAAAAAAgACAAAAj+AAEIHEiwoMGDCBMqXMiwocOHECNKnEixosWLGDNq3Mgx4iVMnTyJInVKlclSpD550nRpUqKGmD59EjWqlMlVOFWdIgWq0iNNoBIhSujokidPn0aNKrmqVStWqjxRumTqyI5KOxI5OpiIkiakNG2yelqK5alKLSAJgbBBB6RIjArmCKLIkV1HjyZNpTTJFKgSQoI4cGBiBxBIR6QM6TGQxooWL3LwMBwkSJEcLUq8YATDAZAdMkKh+GGpAo0cL1wInJuokSNIeqdeCgLBAoVMR2CEMkHDzAcnTCzsCAKERwsXK3wYKYLIdd6pjh4guCGJw5IpT7R8CeNlCwsikx7+JTJ+PAZlRHXxOgqBAQMTLXj0AAKkJw+eJw6CXGqJyAWNyT8QgZ5rsD2igwYEOOEGH38EEoghgcQhQgJAxISJI/8ZNoQUijiX1yM7NIBAFm3wUcghh9yBhQcCFEBDJ6V8MskKhgERxBGMMILXI7AhsoAAGSgRBRlliLHHHlZgMAAJmLByCiUnfGajFEcgotVzjkhggAYjjBHFFISgkoodSDAwAyStqDIJAELs4CYQQxChVSRTQcJCFWmUyAcghmzCCRgdXCEHEU69VJiNdDmnV0s4rNHFGmzgkUcfhgiShAd0nNHDVAc9YIEFFWxAQgkVpKAGF1yw4UYdc6AhhQohJFiwQAIRPQCHFlRAccMJFCRAgAAVJXDBBAsQEEBHDwUEADs=" 28 | help = """ 29 | Usage: %s [-q] [-n] [Command];[Command];... 30 | -q No output 31 | -n No gui 32 | Command Description 33 | 34 | help This help 35 | echo Just echo 36 | log [filename] Set log file 37 | logLevel [0..100] Set log verbosity 38 | search [brand] Searching devices of [brand] or all 39 | table Table of devices 40 | json JSON String of devices 41 | device [MAC] JSON String of [MAC] 42 | config [MAC] [IP] [MASK] [GATE] [Pasword] - Configure searched divice 43 | """ % os.path.basename( 44 | sys.argv[0] 45 | ) 46 | lang, charset = getdefaultlocale() 47 | locale = { 48 | "ru_RU": { 49 | "Type help or ? to display help(q or quit to exit)": u"Введите help или ? для справки, для выхода q или quit", 50 | "Name": u"Наименование", 51 | "Vendor": u"Марка", 52 | "IP Address": u"IP Адрес", 53 | "Mask": "Маска сети", 54 | "Gateway": "Шлюз", 55 | "TCP Port": u"TCP Порт", 56 | "HTTP Port": u"HTTP Порт", 57 | "Port": u"Порт", 58 | "MAC Address": u"МАК Адрес", 59 | "SN": u"Серийный №", 60 | "As on PC": u"Как на ПК", 61 | "Password": u"Пароль", 62 | "Apply": u"Применить", 63 | "Search": u"Поиск", 64 | "Reset": u"Сброс", 65 | "Export": u"Экспорт", 66 | "Flash": u"Прошивка", 67 | "All files": u"Все файлы", 68 | "Text files": u"Текстовые файлы", 69 | "Searching %s, found %d devices": u"Поиск %s, нашли %d устройств", 70 | "Found %d devices": u"Найденно %d устройств", 71 | "All": "По всем", 72 | "Error": "Ошибка", 73 | }, 74 | } 75 | 76 | 77 | def _(msg): 78 | if lang in locale.keys(): 79 | if msg in locale[lang].keys(): 80 | return locale[lang][msg] 81 | return msg 82 | 83 | 84 | CODES = { 85 | 100: _("Success"), 86 | 101: _("Unknown error"), 87 | 102: _("Version not supported"), 88 | 103: _("Illegal request"), 89 | 104: _("User has already logged in"), 90 | 105: _("User is not logged in"), 91 | 106: _("Username or Password is incorrect"), 92 | 107: _("Insufficient permission"), 93 | 108: _("Timeout"), 94 | 109: _("Find failed, file not found"), 95 | 110: _("Find success, returned all files"), 96 | 111: _("Find success, returned part of files"), 97 | 112: _("User already exists"), 98 | 113: _("User does not exist"), 99 | 114: _("User group already exists"), 100 | 115: _("User group does not exist"), 101 | 116: _("Reserved"), 102 | 117: _("Message is malformed"), 103 | 118: _("No PTZ protocol is set"), 104 | 119: _("No query to file"), 105 | 120: _("Configured to be enabled"), 106 | 121: _("Digital channel is not enabled"), 107 | 150: _("Success, device restart required"), 108 | 202: _("User is not logged in"), 109 | 203: _("Incorrect password"), 110 | 204: _("User is illegal"), 111 | 205: _("User is locked"), 112 | 206: _("User is in the blacklist"), 113 | 207: _("User already logged in"), 114 | 208: _("Invalid input"), 115 | 209: _("User already exists"), 116 | 210: _("Object not found"), 117 | 211: _("Object does not exist"), 118 | 212: _("Account in use"), 119 | 213: _("Permission table error"), 120 | 214: _("Illegal password"), 121 | 215: _("Password does not match"), 122 | 216: _("Keep account number"), 123 | 502: _("Illegal command"), 124 | 503: _("Talk channel has ben opened"), 125 | 504: _("Talk channel is not open"), 126 | 511: _("Update started"), 127 | 512: _("Update did not start"), 128 | 513: _("Update data error"), 129 | 514: _("Update failed"), 130 | 515: _("Update succeeded"), 131 | 521: _("Failed to restore default config"), 132 | 522: _("Device restart required"), 133 | 523: _("Default config is illegal"), 134 | 602: _("Application restart required"), 135 | 603: _("System restart required"), 136 | 604: _("Write file error"), 137 | 605: _("Features are not supported"), 138 | 606: _("Verification failed"), 139 | 607: _("Configuration does not exist"), 140 | 608: _("Configuration parsing error"), 141 | } 142 | 143 | 144 | def tolog(s): 145 | print(s) 146 | if logLevel >= 20: 147 | logfile = open(log, "wb") 148 | logfile.write(bytes(s, "utf-8")) 149 | logfile.close() 150 | 151 | 152 | def get_nat_ip(): 153 | s = socket(AF_INET, SOCK_DGRAM) 154 | try: 155 | # doesn't even have to be reachable 156 | s.connect(("10.255.255.255", 1)) 157 | IP = s.getsockname()[0] 158 | except Exception: 159 | IP = "127.0.0.1" 160 | finally: 161 | s.close() 162 | return IP 163 | 164 | 165 | def local_ip(): 166 | ip = get_nat_ip() 167 | ipn = struct.unpack(">I", inet_aton(ip)) 168 | return ( 169 | inet_ntoa(struct.pack(">I", ipn[0] + 10)), 170 | "255.255.255.0", 171 | inet_ntoa(struct.pack(">I", (ipn[0] & 0xFFFFFF00) + 1)), 172 | ) 173 | 174 | 175 | def sofia_hash(self, password): 176 | md5 = hashlib.md5(bytes(password, "utf-8")).digest() 177 | chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" 178 | return "".join([chars[sum(x) % 62] for x in zip(md5[::2], md5[1::2])]) 179 | 180 | 181 | def GetIP(s): 182 | return inet_ntoa(struct.pack("I", int(s, 16))) 183 | 184 | 185 | def SetIP(ip): 186 | return "0x%08X" % struct.unpack("I", inet_aton(ip)) 187 | 188 | 189 | def GetAllAddr(): 190 | if os.name == "nt": 191 | return [ 192 | x.split(":")[1].strip() 193 | for x in str(check_output(["ipconfig"]), "866").split("\r\n") 194 | if "IPv4" in x 195 | ] 196 | else: 197 | iptool = ["ip", "address"] 198 | if platform.system() == "Darwin": 199 | iptool = ["ifconfig"] 200 | return [ 201 | x.split("/")[0].strip().split(" ")[1] 202 | for x in str(check_output(iptool), "ascii").split("\n") 203 | if "inet " in x and "127.0." not in x 204 | ] 205 | 206 | 207 | def SearchXM(devices): 208 | server = socket(AF_INET, SOCK_DGRAM) 209 | server.bind(("", 34569)) 210 | server.settimeout(1) 211 | server.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) 212 | server.setsockopt(SOL_SOCKET, SO_BROADCAST, 1) 213 | server.sendto( 214 | struct.pack("BBHIIHHI", 255, 0, 0, 0, 0, 0, 1530, 0), ("255.255.255.255", 34569) 215 | ) 216 | while True: 217 | data = server.recvfrom(1024) 218 | head, ver, typ, session, packet, info, msg, leng = struct.unpack( 219 | "BBHIIHHI", data[0][:20] 220 | ) 221 | if (msg == 1531) and leng > 0: 222 | answer = json.loads( 223 | data[0][20 : 20 + leng].replace(b"\x00", b"")) 224 | if answer["NetWork.NetCommon"]["MAC"] not in devices.keys(): 225 | devices[answer["NetWork.NetCommon"]["MAC"]] = answer[ 226 | "NetWork.NetCommon" 227 | ] 228 | devices[answer["NetWork.NetCommon"]["MAC"]][u"Brand"] = u"xm" 229 | server.close() 230 | return devices 231 | 232 | 233 | def SearchDahua(devices): 234 | server = socket(AF_INET, SOCK_DGRAM) 235 | server.bind(("", 5050)) 236 | server.settimeout(1) 237 | server.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) 238 | server.setsockopt(SOL_SOCKET, SO_BROADCAST, 1) 239 | server.sendto( 240 | b"\xa3\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", 241 | ("255.255.255.255", 5050), 242 | ) 243 | while True: 244 | try: 245 | data = server.recvfrom(1024) 246 | if data[0][0] == "\xb3" and len(data[0]) > 137: 247 | answer = {} 248 | answer[u"Brand"] = u"dahua" 249 | info, name = struct.unpack("8s16s", data[0][32:56]) 250 | answer[u"HostName"] = name.replace("\x00", "") 251 | ip, mask, gate, dns, answer[u"TCPPort"] = struct.unpack( 252 | " 0: 429 | # answer = json.loads(data[0][20:20+leng].replace(b'\x00',b'')) 430 | # if answer['NetWork.NetCommon']['MAC'] not in devices.keys(): 431 | # devices[answer['NetWork.NetCommon']['MAC']] = answer['NetWork.NetCommon'] 432 | # devices[answer['NetWork.NetCommon']['MAC']][u'Brand'] = u"xm" 433 | except: 434 | break 435 | server.close() 436 | return devices 437 | 438 | 439 | def ConfigXM(data): 440 | config = {} 441 | #TODO: may be just copy whwole devices[data[1]] to config? 442 | for k in [u"HostName",u"HttpPort",u"MAC",u"MaxBps",u"MonMode",u"SSLPort",u"TCPMaxConn",u"TCPPort",u"TransferPlan",u"UDPPort","UseHSDownLoad"]: 443 | if k in devices[data[1]]: 444 | config[k] = devices[data[1]][k] 445 | config[u"DvrMac"] = devices[data[1]][u"MAC"] 446 | config[u"EncryptType"] = 1 447 | config[u"GateWay"] = SetIP(data[4]) 448 | config[u"HostIP"] = SetIP(data[2]) 449 | config[u"Submask"] = SetIP(data[3]) 450 | config[u"Username"] = "admin" 451 | config[u"Password"] = sofia_hash(data[5]) 452 | devices[data[1]][u"GateWay"] = config[u"GateWay"] 453 | devices[data[1]][u"HostIP"] = config[u"HostIP"] 454 | devices[data[1]][u"Submask"] = config[u"Submask"] 455 | config = json.dumps( 456 | config, ensure_ascii=False, sort_keys=True, separators=(", ", " : ") 457 | ).encode("utf8") 458 | server = socket(AF_INET, SOCK_DGRAM) 459 | server.bind(("", 34569)) 460 | server.settimeout(1) 461 | server.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) 462 | server.setsockopt(SOL_SOCKET, SO_BROADCAST, 1) 463 | clen = len(config) 464 | server.sendto( 465 | struct.pack( 466 | "BBHIIHHI%ds2s" % clen, 467 | 255, 468 | 0, 469 | 254, 470 | 0, 471 | 0, 472 | 0, 473 | 1532, 474 | clen + 2, 475 | config, 476 | b"\x0a\x00", 477 | ), 478 | ("255.255.255.255", 34569), 479 | ) 480 | answer = {"Ret": 203} 481 | e = 0 482 | while True: 483 | try: 484 | data = server.recvfrom(1024) 485 | head, ver, typ, session, packet, info, msg, leng = struct.unpack( 486 | "BBHIIHHI", data[0][:20] 487 | ) 488 | if (msg == 1533) and leng > 0: 489 | answer = json.loads( 490 | data[0][20 : 20 + leng].replace(b"\x00", b"")) 491 | break 492 | except: 493 | e += 1 494 | if e > 3: 495 | break 496 | server.close() 497 | return answer 498 | 499 | 500 | def ConfigFros(data): 501 | devices[data[1]][u"GateWay"] = SetIP(data[4]) 502 | devices[data[1]][u"HostIP"] = SetIP(data[2]) 503 | devices[data[1]][u"Submask"] = SetIP(data[3]) 504 | client = socket(AF_INET, SOCK_DGRAM) 505 | client.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) 506 | client.setsockopt(SOL_SOCKET, SO_BROADCAST, 1) 507 | client.sendto( 508 | struct.pack( 509 | "<4sB10xB3xB6xB12sx12sx12sxIIIIxB", 510 | "MO_I", 511 | 2, 512 | 61, 513 | 61, 514 | 1, 515 | devices[data[1]][u"MAC"].replace(":", ""), 516 | "admin", 517 | data[5], 518 | int(SetIP(data[2]), 16), 519 | int(SetIP(data[3]), 16), 520 | int(SetIP(data[4]), 16), 521 | int(SetIP(data[4]), 16), 522 | 80, 523 | ), 524 | ("255.255.255.255", 10000), 525 | ) 526 | answer = {} 527 | while True: 528 | try: 529 | data = client.recvfrom(1024) 530 | if data[0][4] == "\x03": 531 | s, type, n, n, result = struct.unpack("<4sB10xB3xB3xBx", data[0]) 532 | if result == 0: 533 | answer[u"Ret"] = 100 534 | else: 535 | answer[u"Ret"] = 101 536 | break 537 | except: 538 | break 539 | e = 1 540 | client.close() 541 | return answer 542 | 543 | 544 | def ConfigWans(data): 545 | devices[data[1]][u"GateWay"] = SetIP(data[4]) 546 | devices[data[1]][u"HostIP"] = SetIP(data[2]) 547 | devices[data[1]][u"Submask"] = SetIP(data[3]) 548 | devices[data[1]][u"TCPPort"] = devices[data[1]][u"HttpPort"] 549 | client = socket(AF_INET, SOCK_DGRAM) 550 | # client.bind(('',8600)) 551 | client.settimeout(1) 552 | client.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) 553 | client.setsockopt(SOL_SOCKET, SO_BROADCAST, 1) 554 | mac = [int(x, 16) for x in data[1].split(":")] 555 | client.sendto( 556 | struct.pack( 557 | "2sBB16s16s16s16s16s6BH32s32s48x16s16s32s32sxB22x", 558 | "DH", 559 | 2, 560 | 1, 561 | data[2], 562 | data[3], 563 | data[4], 564 | "8.8.8.8", 565 | data[4], 566 | mac[0], 567 | mac[1], 568 | mac[2], 569 | mac[3], 570 | mac[4], 571 | mac[5], 572 | devices[data[1]][u"HttpPort"], 573 | devices[data[1]][u"SN"], 574 | devices[data[1]][u"HostName"], 575 | devices[data[1]][u"SwVer"], 576 | devices[data[1]][u"WebVer"], 577 | "admin", 578 | data[5], 579 | 0, 580 | ), 581 | ("255.255.255.255", 8600), 582 | ) 583 | answer = {} 584 | while True: 585 | try: 586 | data = client.recvfrom(1024) 587 | mac = [0, 0, 0, 0, 0, 0] 588 | ( 589 | head, 590 | pver, 591 | type, 592 | ip, 593 | mask, 594 | gate, 595 | dns2, 596 | dns, 597 | mac[0], 598 | mac[1], 599 | mac[2], 600 | mac[3], 601 | mac[4], 602 | mac[5], 603 | port, 604 | ser, 605 | name, 606 | ver, 607 | webver, 608 | user, 609 | passwd, 610 | dhcp, 611 | err, 612 | ) = struct.unpack( 613 | "2sBB16s16s16s16s16s6BH32s32s48x16s16s32s32sxB22xB", data[0][:325] 614 | ) 615 | mac = "%02x:%02x:%02x:%02x:%02x:%02x" % ( 616 | mac[0], 617 | mac[1], 618 | mac[2], 619 | mac[3], 620 | mac[4], 621 | mac[5], 622 | ) 623 | name, ser, ver, webver = ( 624 | name.replace("\x00", ""), 625 | ser.replace("\x00", ""), 626 | ver.replace("\x00", ""), 627 | webver.replace("\x00", ""), 628 | ) 629 | ip, mask, gate, dns = ( 630 | SetIP(ip.replace("\x00", "")), 631 | SetIP(mask.replace("\x00", "")), 632 | SetIP(gate.replace("\x00", "")), 633 | SetIP(dns.replace("\x00", "")), 634 | ) 635 | devices[mac] = { 636 | u"Brand": u"wans", 637 | u"GateWay": gate, 638 | u"DNS": dns, 639 | u"HostIP": ip, 640 | u"HostName": name, 641 | u"HttpPort": port, 642 | u"TCPPort": port, 643 | u"MAC": mac, 644 | u"MaxBps": 0, 645 | u"MonMode": u"HTTP", 646 | u"SN": ser, 647 | u"Submask": mask, 648 | u"SwVer": ver, 649 | u"WebVer": webver, 650 | } 651 | if err == 0: 652 | answer[u"Ret"] = 100 653 | else: 654 | answer[u"Ret"] = 101 655 | break 656 | except: 657 | break 658 | e = 1 659 | client.close() 660 | return answer 661 | 662 | 663 | def FlashXM(cmd): 664 | cam = DVRIPCam(GetIP(devices[cmd[1]]["HostIP"]), "admin", cmd[2]) 665 | if cam.login(): 666 | cmd[4](_("Auth success")) 667 | cam.upgrade(cmd[3], 0x4000, cmd[4]) 668 | else: 669 | cmd[4](_("Auth failed")) 670 | 671 | 672 | def ProcessCMD(cmd): 673 | global log, logLevel, devices, searchers, configure, flashers 674 | if logLevel == 20: 675 | tolog(datetime.now().strftime("[%Y-%m-%d %H:%M:%S] >") + " ".join(cmd)) 676 | if cmd[0].lower() == "q" or cmd[0].lower() == "quit": 677 | sys.exit(1) 678 | if cmd[0].lower() in ["help", "?", "/?", "-h", "--help"]: 679 | return help 680 | if cmd[0].lower() == "search": 681 | tolog("%s" % (_("Search"))) 682 | if len(cmd) > 1 and cmd[1].lower() in searchers.keys(): 683 | try: 684 | devices = searchers[cmd[1].lower()](devices) 685 | except Exception as error: 686 | print(" ".join([str(x) for x in list(error.args)])) 687 | print(_("Searching %s, found %d devices") % (cmd[1], len(devices))) 688 | else: 689 | for s in searchers: 690 | tolog(_("Search") + " %s\r" % s) 691 | try: 692 | devices = searchers[s](devices) 693 | except Exception as error: 694 | print(" ".join([str(x) for x in list(error.args)])) 695 | tolog(_("Found %d devices") % len(devices)) 696 | if len(devices) > 0: 697 | if logLevel > 0: 698 | cmd[0] = "table" 699 | print("") 700 | if cmd[0].lower() == "table": 701 | logs = ( 702 | _("Vendor") 703 | + "\t" 704 | + _("MAC Address") 705 | + "\t\t" 706 | + _("Name") 707 | + "\t" 708 | + _("IP Address") 709 | + "\t" 710 | + _("Port") 711 | + "\n" 712 | ) 713 | for dev in devices: 714 | logs += "%s\t%s\t%s\t%s\t%s\n" % ( 715 | devices[dev]["Brand"], 716 | devices[dev]["MAC"], 717 | devices[dev]["HostName"], 718 | GetIP(devices[dev]["HostIP"]), 719 | devices[dev]["TCPPort"], 720 | ) 721 | if logLevel >= 20: 722 | tolog(logs) 723 | if logLevel >= 10: 724 | return logs 725 | if cmd[0].lower() == "csv": 726 | logs = ( 727 | _("Vendor") 728 | + ";" 729 | + _("MAC Address") 730 | + ";" 731 | + _("Name") 732 | + ";" 733 | + _("IP Address") 734 | + ";" 735 | + _("Port") 736 | + ";" 737 | + _("SN") 738 | + "\n" 739 | ) 740 | for dev in devices: 741 | logs += "%s;%s;%s;%s;%s;%s\n" % ( 742 | devices[dev]["Brand"], 743 | devices[dev]["MAC"], 744 | devices[dev]["HostName"], 745 | GetIP(devices[dev]["HostIP"]), 746 | devices[dev]["TCPPort"], 747 | devices[dev]["SN"], 748 | ) 749 | if logLevel >= 20: 750 | tolog(logs) 751 | if logLevel >= 10: 752 | return logs 753 | if cmd[0].lower() == "html": 754 | logs = ( 755 | "\r\n" 768 | ) 769 | for dev in devices: 770 | logs += ( 771 | "\r\n" 772 | % ( 773 | devices[dev]["Brand"], 774 | devices[dev]["MAC"], 775 | devices[dev]["HostName"], 776 | GetIP(devices[dev]["HostIP"]), 777 | devices[dev]["TCPPort"], 778 | devices[dev]["SN"], 779 | ) 780 | ) 781 | logs += "
" 756 | + _("Vendor") 757 | + "" 758 | + _("MAC Address") 759 | + "" 760 | + _("Name") 761 | + "" 762 | + _("IP Address") 763 | + "" 764 | + _("Port") 765 | + "" 766 | + _("SN") 767 | + "
%s%s%s%s%s%s
\r\n" 782 | if logLevel >= 20: 783 | tolog(logs) 784 | if logLevel >= 10: 785 | return logs 786 | if cmd[0].lower() == "json": 787 | logs = json.dumps(devices) 788 | if logLevel >= 20: 789 | tolog(logs) 790 | if logLevel >= 10: 791 | return logs 792 | if cmd[0].lower() == "device": 793 | if len(cmd) > 1 and cmd[1] in devices.keys(): 794 | return json.dumps(devices[cmd[1]]) 795 | else: 796 | return "device [MAC]" 797 | if cmd[0].lower() == "config": 798 | if ( 799 | len(cmd) > 5 800 | and cmd[1] in devices.keys() 801 | and devices[cmd[1]]["Brand"] in configure.keys() 802 | ): 803 | return configure[devices[cmd[1]]["Brand"]](cmd) 804 | else: 805 | return "config [MAC] [IP] [MASK] [GATE] [Pasword]" 806 | if cmd[0].lower() == "flash": 807 | if ( 808 | len(cmd) > 3 809 | and cmd[1] in devices.key(s) 810 | and devices[cmd[1]]["Brand"] in flashers.keys() 811 | ): 812 | if len(cmd) == 4: 813 | cmd[4] = tolog 814 | return flashers[devices[cmd[1]]["Brand"]](cmd) 815 | else: 816 | return "flash [MAC] [password] [file]" 817 | if cmd[0].lower() == "loglevel": 818 | if len(cmd) > 1: 819 | logLevel = int(cmd[1]) 820 | else: 821 | return "loglevel [int]" 822 | if cmd[0].lower() == "log": 823 | if len(cmd) > 1: 824 | log = " ".join(cmd[1:]) 825 | else: 826 | return "log [filename]" 827 | if cmd[0].lower() == "echo": 828 | if len(cmd) > 1: 829 | return " ".join(cmd[1:]) 830 | return "" 831 | 832 | 833 | class GUITk: 834 | def __init__(self, root): 835 | self.root = root 836 | self.root.wm_title(_("Device Manager")) 837 | self.root.tk.call("wm", "iconphoto", root._w, PhotoImage(data=icon)) 838 | self.f = Frame(self.root) 839 | self.f.pack(fill=BOTH, expand=YES) 840 | 841 | self.f.columnconfigure(0, weight=1) 842 | self.f.rowconfigure(0, weight=1) 843 | 844 | self.fr = Frame(self.f) 845 | self.fr.grid(row=0, column=0, columnspan=3, sticky="nsew") 846 | self.fr_tools = Frame(self.f) 847 | self.fr_tools.grid(row=1, column=0, columnspan=6, sticky="ew") 848 | self.fr_config = Frame(self.f) 849 | self.fr_config.grid(row=0, column=5, sticky="nsew") 850 | 851 | self.fr.columnconfigure(0, weight=1) 852 | self.fr.rowconfigure(0, weight=1) 853 | 854 | self.table = Treeview(self.fr, show="headings", selectmode="browse", height=10) 855 | self.table.grid(column=0, row=0, sticky="nsew") 856 | self.table["columns"] = ("ID", "vendor", "addr", "port", "name", "mac", "sn") 857 | self.table["displaycolumns"] = ("vendor", "addr", "port", "name", "mac", "sn") 858 | 859 | self.table.heading("vendor", text=_("Vendor"), anchor="w") 860 | self.table.heading("addr", text=_("IP Address"), anchor="w") 861 | self.table.heading("port", text=_("Port"), anchor="w") 862 | self.table.heading("name", text=_("Name"), anchor="w") 863 | self.table.heading("mac", text=_("MAC Address"), anchor="w") 864 | self.table.heading("sn", text=_("SN"), anchor="w") 865 | 866 | self.table.column("vendor", stretch=0, width=50) 867 | self.table.column("addr", stretch=0, width=100) 868 | self.table.column("port", stretch=0, width=50) 869 | self.table.column("name", stretch=0, width=100) 870 | self.table.column("mac", stretch=0, width=110) 871 | self.table.column("sn", stretch=0, width=120) 872 | 873 | self.scrollY = Scrollbar(self.fr, orient=VERTICAL) 874 | self.scrollY.config(command=self.table.yview) 875 | self.scrollY.grid(row=0, column=1, sticky="ns") 876 | self.scrollX = Scrollbar(self.fr, orient=HORIZONTAL) 877 | self.scrollX.config(command=self.table.xview) 878 | self.scrollX.grid(row=1, column=0, sticky="ew") 879 | self.table.config( 880 | yscrollcommand=self.scrollY.set, xscrollcommand=self.scrollX.set 881 | ) 882 | 883 | self.table.bind("", self.select) 884 | self.popup_menu = Menu(self.table, tearoff=0) 885 | self.popup_menu.add_command( 886 | label="Copy SN", 887 | command=lambda: ( 888 | self.root.clipboard_clear() 889 | or self.root.clipboard_append( 890 | self.table.item(self.table.selection()[0], option="values")[6] 891 | ) 892 | ) 893 | if len(self.table.selection()) > 0 894 | else None, 895 | ) 896 | self.popup_menu.add_command( 897 | label="Copy line", 898 | command=lambda: ( 899 | self.root.clipboard_clear() 900 | or self.root.clipboard_append( 901 | "\t".join( 902 | self.table.item(self.table.selection()[0], option="values")[1:] 903 | ) 904 | ) 905 | ) 906 | if len(self.table.selection()) > 0 907 | else None, 908 | ) 909 | self.table.bind("", self.popup) 910 | 911 | self.l0 = Label(self.fr_config, text=_("Name")) 912 | self.l0.grid(row=0, column=0, pady=3, padx=5, sticky=W + N) 913 | self.name = Entry(self.fr_config, width=15, font="6") 914 | self.name.grid(row=0, column=1, pady=3, padx=5, sticky=W + N) 915 | self.l1 = Label(self.fr_config, text=_("IP Address")) 916 | self.l1.grid(row=1, column=0, pady=3, padx=5, sticky=W + N) 917 | self.addr = Entry(self.fr_config, width=15, font="6") 918 | self.addr.grid(row=1, column=1, pady=3, padx=5, sticky=W + N) 919 | self.l2 = Label(self.fr_config, text=_("Mask")) 920 | self.l2.grid(row=2, column=0, pady=3, padx=5, sticky=W + N) 921 | self.mask = Entry(self.fr_config, width=15, font="6") 922 | self.mask.grid(row=2, column=1, pady=3, padx=5, sticky=W + N) 923 | self.l3 = Label(self.fr_config, text=_("Gateway")) 924 | self.l3.grid(row=3, column=0, pady=3, padx=5, sticky=W + N) 925 | self.gate = Entry(self.fr_config, width=15, font="6") 926 | self.gate.grid(row=3, column=1, pady=3, padx=5, sticky=W + N) 927 | self.aspc = Button(self.fr_config, text=_("As on PC"), command=self.addr_pc) 928 | self.aspc.grid(row=4, column=1, pady=3, padx=5, sticky="ew") 929 | self.l4 = Label(self.fr_config, text=_("HTTP Port")) 930 | self.l4.grid(row=5, column=0, pady=3, padx=5, sticky=W + N) 931 | self.http = Entry(self.fr_config, width=5, font="6") 932 | self.http.grid(row=5, column=1, pady=3, padx=5, sticky=W + N) 933 | self.l5 = Label(self.fr_config, text=_("TCP Port")) 934 | self.l5.grid(row=6, column=0, pady=3, padx=5, sticky=W + N) 935 | self.tcp = Entry(self.fr_config, width=5, font="6") 936 | self.tcp.grid(row=6, column=1, pady=3, padx=5, sticky=W + N) 937 | self.l6 = Label(self.fr_config, text=_("Password")) 938 | self.l6.grid(row=7, column=0, pady=3, padx=5, sticky=W + N) 939 | self.passw = Entry(self.fr_config, width=15, font="6") 940 | self.passw.grid(row=7, column=1, pady=3, padx=5, sticky=W + N) 941 | self.aply = Button(self.fr_config, text=_("Apply"), command=self.setconfig) 942 | self.aply.grid(row=8, column=1, pady=3, padx=5, sticky="ew") 943 | 944 | self.l7 = Label(self.fr_tools, text=_("Vendor")) 945 | self.l7.grid(row=0, column=0, pady=3, padx=5, sticky="wns") 946 | self.ven = Combobox(self.fr_tools, width=10) 947 | self.ven.grid(row=0, column=1, padx=5, sticky="w") 948 | self.ven["values"] = [_("All"), "XM", "Dahua", "Fros", "Wans", "Beward"] 949 | self.ven.current(0) 950 | self.search = Button(self.fr_tools, text=_("Search"), command=self.search) 951 | self.search.grid(row=0, column=2, pady=5, padx=5, sticky=W + N) 952 | self.reset = Button(self.fr_tools, text=_("Reset"), command=self.clear) 953 | self.reset.grid(row=0, column=3, pady=5, padx=5, sticky=W + N) 954 | self.exp = Button(self.fr_tools, text=_("Export"), command=self.export) 955 | self.exp.grid(row=0, column=4, pady=5, padx=5, sticky=W + N) 956 | self.fl_state = StringVar(value=_("Flash")) 957 | self.fl = Button(self.fr_tools, textvar=self.fl_state, command=self.flash) 958 | self.fl.grid(row=0, column=5, pady=5, padx=5, sticky=W + N) 959 | 960 | def popup(self, event): 961 | try: 962 | self.popup_menu.tk_popup(event.x_root, event.y_root, 0) 963 | finally: 964 | self.popup_menu.grab_release() 965 | 966 | def addr_pc(self): 967 | _addr, _mask, _gate = local_ip() 968 | self.addr.delete(0, END) 969 | self.addr.insert(END, _addr) 970 | self.mask.delete(0, END) 971 | self.mask.insert(END, _mask) 972 | self.gate.delete(0, END) 973 | self.gate.insert(END, _gate) 974 | 975 | def search(self): 976 | self.clear() 977 | if self.ven["values"].index(self.ven.get()) == 0: 978 | ProcessCMD(["search"]) 979 | else: 980 | ProcessCMD(["search", self.ven.get()]) 981 | self.pop() 982 | 983 | def pop(self): 984 | for dev in devices: 985 | self.table.insert( 986 | "", 987 | "end", 988 | values=( 989 | dev, 990 | devices[dev]["Brand"], 991 | GetIP(devices[dev]["HostIP"]), 992 | devices[dev]["TCPPort"], 993 | devices[dev]["HostName"], 994 | devices[dev]["MAC"], 995 | devices[dev]["SN"], 996 | ), 997 | ) 998 | 999 | def clear(self): 1000 | global devices 1001 | for i in self.table.get_children(): 1002 | self.table.delete(i) 1003 | devices = {} 1004 | 1005 | def select(self, event): 1006 | if len(self.table.selection()) == 0: 1007 | return 1008 | dev = self.table.item(self.table.selection()[0], option="values")[0] 1009 | if logLevel >= 20: 1010 | print(json.dumps(devices[dev], indent=4, sort_keys=True)) 1011 | self.name.delete(0, END) 1012 | self.name.insert(END, devices[dev]["HostName"]) 1013 | self.addr.delete(0, END) 1014 | self.addr.insert(END, GetIP(devices[dev]["HostIP"])) 1015 | self.mask.delete(0, END) 1016 | self.mask.insert(END, GetIP(devices[dev]["Submask"])) 1017 | self.gate.delete(0, END) 1018 | self.gate.insert(END, GetIP(devices[dev]["GateWay"])) 1019 | self.http.delete(0, END) 1020 | self.http.insert(END, devices[dev]["HttpPort"]) 1021 | self.tcp.delete(0, END) 1022 | self.tcp.insert(END, devices[dev]["TCPPort"]) 1023 | 1024 | def setconfig(self): 1025 | dev = self.table.item(self.table.selection()[0], option="values")[0] 1026 | devices[dev][u"TCPPort"] = int(self.tcp.get()) 1027 | devices[dev][u"HttpPort"] = int(self.http.get()) 1028 | devices[dev][u"HostName"] = self.name.get() 1029 | result = ProcessCMD( 1030 | [ 1031 | "config", 1032 | dev, 1033 | self.addr.get(), 1034 | self.mask.get(), 1035 | self.gate.get(), 1036 | self.passw.get(), 1037 | ] 1038 | ) 1039 | if result["Ret"] == 100: 1040 | self.table.item( 1041 | self.table.selection()[0], 1042 | values=( 1043 | dev, 1044 | devices[dev]["Brand"], 1045 | GetIP(devices[dev]["HostIP"]), 1046 | devices[dev]["TCPPort"], 1047 | devices[dev]["HostName"], 1048 | devices[dev]["MAC"], 1049 | devices[dev]["SN"], 1050 | ), 1051 | ) 1052 | else: 1053 | showerror(_("Error"), CODES[result["Ret"]]) 1054 | 1055 | def export(self): 1056 | filename = asksaveasfilename( 1057 | filetypes=( 1058 | (_("JSON files"), "*.json"), 1059 | (_("HTML files"), "*.html;*.htm"), 1060 | (_("Text files"), "*.csv;*.txt"), 1061 | (_("All files"), "*.*"), 1062 | ) 1063 | ) 1064 | if filename == "": 1065 | return 1066 | ProcessCMD(["log", filename]) 1067 | ProcessCMD(["loglevel", str(100)]) 1068 | if ".json" in filename: 1069 | ProcessCMD(["json"]) 1070 | elif ".csv" in filename: 1071 | ProcessCMD(["csv"]) 1072 | elif ".htm" in filename: 1073 | ProcessCMD(["html"]) 1074 | else: 1075 | ProcessCMD(["table"]) 1076 | ProcessCMD(["loglevel", str(10)]) 1077 | 1078 | def flash(self): 1079 | self.fl_state.set("Processing...") 1080 | filename = askopenfilename( 1081 | filetypes=((_("Flash"), "*.bin"), (_("All files"), "*.*")) 1082 | ) 1083 | if filename == "": 1084 | return 1085 | if len(self.table.selection()) == 0: 1086 | _mac = "all" 1087 | else: 1088 | _mac = self.table.item(self.table.selection()[0], option="values")[4] 1089 | result = ProcessCMD( 1090 | ["flash", _mac, self.passw.get(), filename, self.fl_state.set] 1091 | ) 1092 | if ( 1093 | hasattr(result, "keys") 1094 | and "Ret" in result.keys() 1095 | and result["Ret"] in CODES.keys() 1096 | ): 1097 | showerror(_("Error"), CODES[result["Ret"]]) 1098 | 1099 | 1100 | searchers = { 1101 | "wans": SearchWans, 1102 | "xm": SearchXM, 1103 | "dahua": SearchDahua, 1104 | "fros": SearchFros, 1105 | "beward": SearchBeward, 1106 | } 1107 | configure = { 1108 | "wans": ConfigWans, 1109 | "xm": ConfigXM, 1110 | "fros": ConfigFros, 1111 | } # ,"dahua":ConfigDahua 1112 | flashers = {"xm": FlashXM} # ,"dahua":FlashDahua,"fros":FlashFros 1113 | logLevel = 30 1114 | if __name__ == "__main__": 1115 | if len(sys.argv) > 1: 1116 | cmds = " ".join(sys.argv[1:]) 1117 | if cmds.find("-q ") != -1: 1118 | cmds = cmds.replace("-q ", "").replace("-n ", "").strip() 1119 | logLevel = 0 1120 | for cmd in cmds.split(";"): 1121 | ProcessCMD(cmd.split(" ")) 1122 | if GUI_TK and "-n" not in sys.argv: 1123 | root = Tk() 1124 | app = GUITk(root) 1125 | if ( 1126 | "--theme" in sys.argv 1127 | ): # ('winnative', 'clam', 'alt', 'default', 'classic', 'vista', 'xpnative') 1128 | style = Style() 1129 | theme = [sys.argv.index("--theme") + 1] 1130 | if theme in style.theme_names(): 1131 | style.theme_use(theme) 1132 | root.mainloop() 1133 | sys.exit(1) 1134 | print(_("Type help or ? to display help(q or quit to exit)")) 1135 | while True: 1136 | data = input("> ").split(";") 1137 | for cmd in data: 1138 | result = ProcessCMD(cmd.split(" ")) 1139 | if hasattr(result, "keys") and "Ret" in result.keys(): 1140 | print(CODES[result["Ret"]]) 1141 | else: 1142 | print(result) 1143 | sys.exit(1) 1144 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:slim 2 | 3 | RUN apt-get update && \ 4 | apt-get upgrade -y && \ 5 | apt-get install -y \ 6 | ffmpeg 7 | 8 | WORKDIR /app 9 | 10 | COPY . . 11 | 12 | CMD [ "python3", "./download-local-files.py"] 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Eliot Kent Woodrich 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /NVR.py: -------------------------------------------------------------------------------- 1 | from time import sleep, monotonic 2 | from dvrip import DVRIPCam, SomethingIsWrongWithCamera 3 | from pathlib import Path 4 | import logging 5 | 6 | 7 | class NVR: 8 | nvr = None 9 | logger = None 10 | 11 | def __init__(self, host_ip, user, password, logger): 12 | self.logger = logger 13 | self.nvr = DVRIPCam( 14 | host_ip, 15 | user=user, 16 | password=password, 17 | ) 18 | if logger.level <= logging.DEBUG: 19 | self.nvr.debug() 20 | 21 | def login(self): 22 | try: 23 | self.logger.info(f"Connecting to NVR...") 24 | self.nvr.login() 25 | self.logger.info("Successfuly connected to NVR.") 26 | return 27 | except SomethingIsWrongWithCamera: 28 | self.logger.error("Can't connect to NVR") 29 | self.nvr.close() 30 | 31 | def logout(self): 32 | self.nvr.close() 33 | 34 | def get_channel_statuses(self): 35 | channel_statuses = self.nvr.get_channel_statuses() 36 | if 'Ret' in channel_statuses: 37 | return None 38 | 39 | channel_titles = self.nvr.get_channel_titles() 40 | if 'Ret' in channel_titles: 41 | return None 42 | 43 | for i in range(min(len(channel_statuses), len(channel_titles))): 44 | channel_statuses[i]['Title'] = channel_titles[i] 45 | channel_statuses[i]['Channel'] = i 46 | 47 | return [c for c in channel_statuses if c['Status'] != ''] 48 | 49 | def get_local_files(self, channel, start, end, filetype): 50 | return self.nvr.list_local_files(start, end, filetype, channel) 51 | 52 | def generateTargetFileName(self, filename): 53 | # My NVR's filename example: /idea0/2023-11-19/002/05.38.58-05.39.34[M][@69f17][0].h264 54 | # You should check file names in your NVR and review the transformation 55 | filenameSplit = filename.replace("][", "/").replace("[", "/").replace("]", "/").split("/") 56 | return f"{filenameSplit[3]}_{filenameSplit[2]}_{filenameSplit[4]}{filenameSplit[-1]}" 57 | 58 | def save_files(self, download_dir, files): 59 | self.logger.info(f"Files downloading: start") 60 | 61 | size_to_download = sum(int(f['FileLength'], 0) for f in files) 62 | 63 | for file in files: 64 | target_file_name = self.generateTargetFileName(file["FileName"]) 65 | target_file_path = f"{download_dir}/{target_file_name}" 66 | 67 | size = int(file['FileLength'], 0) 68 | size_to_download -= size 69 | 70 | if Path(f"{target_file_path}").is_file(): 71 | self.logger.info(f" {target_file_name} file already exists, skipping download") 72 | continue 73 | 74 | self.logger.info(f" {target_file_name} [{size/1024:.1f} MBytes] downloading...") 75 | time_dl = monotonic() 76 | self.nvr.download_file( 77 | file["BeginTime"], file["EndTime"], file["FileName"], target_file_path 78 | ) 79 | time_dl = monotonic() - time_dl 80 | speed = size / time_dl 81 | self.logger.info(f" Done [{speed:.1f} KByte/s] {size_to_download/1024:.1f} MBytes more to download") 82 | 83 | self.logger.info(f"Files downloading: done") 84 | 85 | def list_files(self, files): 86 | self.logger.info(f"Files listing: start") 87 | 88 | for file in files: 89 | target_file_name = self.generateTargetFileName(file["FileName"]) 90 | 91 | size = int(file['FileLength'], 0) 92 | self.logger.info(f" {target_file_name} [{size/1024:.1f} MBytes]") 93 | 94 | self.logger.info(f"Files listing: end") 95 | -------------------------------------------------------------------------------- /NVRVideoDownloader.json: -------------------------------------------------------------------------------- 1 | { 2 | "host_ip": "10.0.0.8", 3 | "user": "admin", 4 | "password": "mypassword", 5 | "channel": 0, 6 | "download_dir": "./download", 7 | "start": "2023-11-19 6:22:34", 8 | "end": "2023-11-19 6:23:09", 9 | "just_list_files": false, 10 | "log_level": "INFO" 11 | } 12 | -------------------------------------------------------------------------------- /NVRVideoDownloader.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import os 3 | import json 4 | import logging 5 | from collections import namedtuple 6 | from NVR import NVR 7 | 8 | 9 | def init_logger(log_level): 10 | logger = logging.getLogger(__name__) 11 | logger.setLevel(log_level) 12 | ch = logging.StreamHandler() 13 | formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s") 14 | ch.setFormatter(formatter) 15 | logger.addHandler(ch) 16 | return logger 17 | 18 | 19 | def load_config(): 20 | def config_decoder(config_dict): 21 | return namedtuple("X", config_dict.keys())(*config_dict.values()) 22 | 23 | config_path = os.environ.get("NVRVIDEODOWNLOADER_CFG") 24 | 25 | if config_path is None or not Path(config_path).exists(): 26 | config_path = "NVRVideoDownloader.json" 27 | 28 | if Path(config_path).exists(): 29 | with open(config_path, "r") as file: 30 | return json.loads(file.read(), object_hook=config_decoder) 31 | 32 | return { 33 | "host_ip": os.environ.get("IP_ADDRESS"), 34 | "user": os.environ.get("USER"), 35 | "password": os.environ.get("PASSWORD"), 36 | "channel": os.environ.get("CHANNEL"), 37 | "download_dir": os.environ.get("DOWNLOAD_DIR"), 38 | "start": os.environ.get("START"), 39 | "end": os.environ.get("END"), 40 | "just_list_files": os.environ.get("DUMP_LOCAL_FILES").lower() in ["true", "1", "y", "yes"], 41 | "log_level": "INFO" 42 | } 43 | 44 | 45 | def main(): 46 | config = load_config() 47 | logger = init_logger(config.log_level) 48 | channel = config.channel; 49 | start = config.start 50 | end = config.end 51 | just_list_files = config.just_list_files; 52 | 53 | nvr = NVR(config.host_ip, config.user, config.password, logger) 54 | 55 | try: 56 | nvr.login() 57 | 58 | channel_statuses = nvr.get_channel_statuses() 59 | if channel_statuses: 60 | channel_statuses_short = [{f"{c['Channel']}:{c['Title']}({c['ChnName']})"} 61 | for c in channel_statuses if c['Status'] != 'NoConfig'] 62 | logger.info(f"Configured channels in NVR: {channel_statuses_short}") 63 | 64 | videos = nvr.get_local_files(channel, start, end, "h264") 65 | if videos: 66 | size = sum(int(f['FileLength'], 0) for f in videos) 67 | logger.info(f"Video files found: {len(videos)}. Total size: {size/1024:.1f}M") 68 | Path(config.download_dir).parent.mkdir( 69 | parents=True, exist_ok=True 70 | ) 71 | if just_list_files: 72 | nvr.list_files(videos) 73 | else: 74 | nvr.save_files(config.download_dir, videos) 75 | else: 76 | logger.info(f"No video files found") 77 | 78 | nvr.logout() 79 | except ConnectionRefusedError: 80 | logger.error(f"Connection can't be established or got disconnected") 81 | except TypeError as e: 82 | print(e) 83 | logger.error(f"Error while downloading a file") 84 | except KeyError: 85 | logger.error(f"Error while getting the file list") 86 | 87 | 88 | if __name__ == "__main__": 89 | main() 90 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # python-dvr 2 | 3 | Python library for configuring a wide range of IP cameras that use the NETsurveillance ActiveX plugin 4 | XMeye SDK 5 | 6 | ![screenshot](images/xm.jpg) 7 | 8 | ## DeviceManager.py 9 | 10 | DeviceManager.py is a standalone Tkinter and console interface program such as the original DeviceManager.exe 11 | it possible to work on both systems, if there is no Tkinter it starts with a console interface 12 | 13 | ## DVR-IP, NetSurveillance or "Sofia" Protocol 14 | 15 | The NETSurveillance ActiveX plugin uses a TCP based protocol referred to simply as the "Digital Video Recorder Interface Protocol" by the "Hangzhou male Mai Information Co". 16 | 17 | There is very little software support or documentation other than through tools provided by the manufacturers of these cameras, which leaves many configuration options inaccessible. 18 | 19 | - [Command and response codes](https://gist.github.com/ekwoodrich/a6d7b8db8f82adf107c3c366e61fd36f) 20 | 21 | - [Xiongmai DVR API v1.0, Russian](https://github.com/OpenIPC/python-dvr/blob/master/doc/Соглашение%20о%20интерфейсе%20цифрового%20видеорегистратора%20XiongmaiV1.0.doc) 22 | 23 | - [Xiongmai DVR API, 2013-01-11, Chinese](doc/雄迈数字视频录像机接口协议_V1.0.0.pdf) 24 | 25 | - [DVR API, brief description, Chinese](doc/配置交换格式V2.0.pdf) 26 | 27 | - [NETIP video/audio payload protocol, Chinese](doc/码流帧格式文档.pdf) 28 | 29 | ### Similar projects 30 | 31 | - [sofiactl](https://github.com/667bdrm/sofiactl) 32 | 33 | - [DVRIP library and tools](https://github.com/alexshpilkin/dvrip) 34 | 35 | - [numenworld-ipcam](https://github.com/johndoe31415/numenworld-ipcam) 36 | 37 | ### Server implementations 38 | 39 | * [OpenIPC](https://openipc.org/firmware/) 40 | 41 | ## Basic usage 42 | 43 | ```python 44 | from dvrip import DVRIPCam 45 | from time import sleep 46 | 47 | host_ip = '192.168.1.10' 48 | 49 | cam = DVRIPCam(host_ip, user='admin', password='') 50 | if cam.login(): 51 | print("Success! Connected to " + host_ip) 52 | else: 53 | print("Failure. Could not connect.") 54 | 55 | print("Camera time:", cam.get_time()) 56 | 57 | # Reboot camera 58 | cam.reboot() 59 | sleep(60) # wait while camera starts 60 | 61 | # Login again 62 | cam.login() 63 | # Sync camera time with PC time 64 | cam.set_time() 65 | # Disconnect 66 | cam.close() 67 | ``` 68 | 69 | ## AsyncIO usage 70 | ```python 71 | from asyncio_dvrip import DVRIPCam 72 | import asyncio 73 | import traceback 74 | 75 | def stop(loop): 76 | tasks = asyncio.gather(*asyncio.Task.all_tasks(loop=loop), loop=loop, return_exceptions=True) 77 | tasks.add_done_callback(lambda t: loop.stop()) 78 | tasks.cancel() 79 | 80 | loop = asyncio.get_event_loop() 81 | 82 | def onAlert(event, sequence_number): 83 | print(event, sequence_number) 84 | 85 | async def some_test_worker(): 86 | while True: 87 | print("do some important work...") 88 | 89 | await asyncio.sleep(3) 90 | 91 | async def main(loop): 92 | host_ip = '192.168.1.10' 93 | cam = DVRIPCam(host_ip, user='admin', password='') 94 | try: 95 | if not await cam.login(): 96 | raise Exception("Failure. Could not connect.") 97 | 98 | # ------------------------------- 99 | 100 | # take snapshot 101 | image = await cam.snapshot() 102 | # save it 103 | with open("snap.jpeg", "wb") as fp: 104 | fp.write(image) 105 | 106 | # ------------------------------- 107 | 108 | # write video 109 | with open("datastream.h265", "wb") as f: 110 | await cam.start_monitor(lambda frame, meta, user: f.write(frame)) 111 | 112 | # ------------------------------- 113 | 114 | # or get alarms 115 | cam.setAlarm(onAlert) 116 | # will create new task 117 | await cam.alarmStart(loop) 118 | 119 | # so just wait or something else 120 | while True: 121 | await asyncio.sleep(1) 122 | 123 | # ------------------------------- 124 | 125 | except: 126 | pass 127 | finally: 128 | cam.close() 129 | 130 | try: 131 | loop.create_task(main(loop)) 132 | loop.create_task(some_test_worker()) 133 | 134 | loop.run_forever() 135 | except Exception as err: 136 | msg = ''.join(traceback.format_tb(err.__traceback__) + [str(err)]) 137 | print(msg) 138 | finally: 139 | cam.close() 140 | stop(loop) 141 | ``` 142 | 143 | ## Camera settings 144 | 145 | ```python 146 | params = cam.get_general_info() 147 | ``` 148 | 149 | Returns general camera information (timezones, formats, auto reboot policy, 150 | security options): 151 | 152 | ```json 153 | { 154 | "AppBindFlag": { 155 | "BeBinded": false 156 | }, 157 | "AutoMaintain": { 158 | "AutoDeleteFilesDays": 0, 159 | "AutoRebootDay": "Tuesday", 160 | "AutoRebootHour": 3 161 | }, 162 | "DSTState": { 163 | "InNormalState": true 164 | }, 165 | "General": { 166 | "AutoLogout": 0, 167 | "FontSize": 24, 168 | "IranCalendarEnable": 0, 169 | "LocalNo": 0, 170 | "MachineName": "LocalHost", 171 | "OverWrite": "OverWrite", 172 | "ScreenAutoShutdown": 10, 173 | "ScreenSaveTime": 0, 174 | "VideoOutPut": "Auto" 175 | }, 176 | "Location": { 177 | "DSTEnd": { 178 | "Day": 1, 179 | "Hour": 1, 180 | "Minute": 1, 181 | "Month": 10, 182 | "Week": 0, 183 | "Year": 2021 184 | }, 185 | "DSTRule": "Off", 186 | "DSTStart": { 187 | "Day": 1, 188 | "Hour": 1, 189 | "Minute": 1, 190 | "Month": 5, 191 | "Week": 0, 192 | "Year": 2021 193 | }, 194 | "DateFormat": "YYMMDD", 195 | "DateSeparator": "-", 196 | "IranCalendar": 0, 197 | "Language": "Russian", 198 | "TimeFormat": "24", 199 | "VideoFormat": "PAL", 200 | "Week": null, 201 | "WorkDay": 62 202 | }, 203 | "OneKeyMaskVideo": null, 204 | "PwdSafety": { 205 | "PwdReset": [ 206 | { 207 | "QuestionAnswer": "", 208 | "QuestionIndex": 0 209 | }, 210 | { 211 | "QuestionAnswer": "", 212 | "QuestionIndex": 0 213 | }, 214 | { 215 | "QuestionAnswer": "", 216 | "QuestionIndex": 0 217 | }, 218 | { 219 | "QuestionAnswer": "", 220 | "QuestionIndex": 0 221 | } 222 | ], 223 | "SecurityEmail": "", 224 | "TipPageHide": false 225 | }, 226 | "ResumePtzState": null, 227 | "TimingSleep": null 228 | } 229 | ``` 230 | 231 | ```python 232 | params = cam.get_system_info() 233 | ``` 234 | 235 | Returns hardware specific settings, camera serial number, current software 236 | version and firmware type: 237 | 238 | ```json 239 | { 240 | "AlarmInChannel": 2, 241 | "AlarmOutChannel": 1, 242 | "AudioInChannel": 1, 243 | "BuildTime": "2020-01-08 11:05:18", 244 | "CombineSwitch": 0, 245 | "DeviceModel": "HI3516EV300_85H50AI", 246 | "DeviceRunTime": "0x0001f532", 247 | "DigChannel": 0, 248 | "EncryptVersion": "Unknown", 249 | "ExtraChannel": 0, 250 | "HardWare": "HI3516EV300_85H50AI", 251 | "HardWareVersion": "Unknown", 252 | "SerialNo": "a166379674a3b447", 253 | "SoftWareVersion": "V5.00.R02.000529B2.10010.040600.0020000", 254 | "TalkInChannel": 1, 255 | "TalkOutChannel": 1, 256 | "UpdataTime": "", 257 | "UpdataType": "0x00000000", 258 | "VideoInChannel": 1, 259 | "VideoOutChannel": 1 260 | } 261 | ``` 262 | 263 | ```python 264 | params = cam.get_system_capabilities() 265 | ``` 266 | 267 | Returns capabilities for the camera software (alarms and detection, 268 | communication protocols and hardware specific features): 269 | 270 | ```json 271 | { 272 | "AlarmFunction": { 273 | "AlarmConfig": true, 274 | "BlindDetect": true, 275 | "HumanDection": true, 276 | "HumanPedDetection": true, 277 | "LossDetect": true, 278 | "MotionDetect": true, 279 | "NetAbort": true, 280 | "NetAlarm": true, 281 | "NetIpConflict": true, 282 | "NewVideoAnalyze": false, 283 | "PEAInHumanPed": true, 284 | "StorageFailure": true, 285 | "StorageLowSpace": true, 286 | "StorageNotExist": true, 287 | "VideoAnalyze": false 288 | }, 289 | "CommFunction": { 290 | "CommRS232": true, 291 | "CommRS485": true 292 | }, 293 | "EncodeFunction": { 294 | "DoubleStream": true, 295 | "SmartH264": true, 296 | "SmartH264V2": false, 297 | "SnapStream": true 298 | }, 299 | "NetServerFunction": { 300 | "IPAdaptive": true, 301 | "Net3G": false, 302 | "Net4GSignalLevel": false, 303 | "NetAlarmCenter": true, 304 | "NetDAS": false, 305 | "NetDDNS": false, 306 | "NetDHCP": true, 307 | "NetDNS": true, 308 | "NetEmail": true, 309 | "NetFTP": true, 310 | "NetIPFilter": true, 311 | "NetMutlicast": false, 312 | "NetNTP": true, 313 | "NetNat": true, 314 | "NetPMS": true, 315 | "NetPMSV2": true, 316 | "NetPPPoE": false, 317 | "NetRTSP": true, 318 | "NetSPVMN": false, 319 | "NetUPNP": true, 320 | "NetWifi": false, 321 | "OnvifPwdCheckout": true, 322 | "RTMP": false, 323 | "WifiModeSwitch": false, 324 | "WifiRouteSignalLevel": true 325 | }, 326 | "OtherFunction": { 327 | "NOHDDRECORD": false, 328 | "NoSupportSafetyQuestion": false, 329 | "NotSupportAutoAndIntelligent": false, 330 | "SupportAdminContactInfo": true, 331 | "SupportAlarmRemoteCall": false, 332 | "SupportAlarmVoiceTipInterval": true, 333 | "SupportAlarmVoiceTips": true, 334 | "SupportAlarmVoiceTipsType": true, 335 | "SupportAppBindFlag": true, 336 | "SupportBT": true, 337 | "SupportBallTelescopic": false, 338 | "SupportBoxCameraBulb": false, 339 | "SupportCamareStyle": true, 340 | "SupportCameraWhiteLight": false, 341 | "SupportCfgCloudupgrade": true, 342 | "SupportChangeLanguageNoReboot": true, 343 | "SupportCloseVoiceTip": false, 344 | "SupportCloudUpgrade": true, 345 | "SupportCommDataUpload": true, 346 | "SupportCorridorMode": false, 347 | "SupportCustomizeLpRect": false, 348 | "SupportDNChangeByImage": false, 349 | "SupportDimenCode": true, 350 | "SupportDoubleLightBoxCamera": false, 351 | "SupportDoubleLightBulb": false, 352 | "SupportElectronicPTZ": false, 353 | "SupportFTPTest": true, 354 | "SupportFaceDetectV2": false, 355 | "SupportFaceRecognition": false, 356 | "SupportMailTest": true, 357 | "SupportMusicBulb433Pair": false, 358 | "SupportMusicLightBulb": false, 359 | "SupportNetWorkMode": false, 360 | "SupportOSDInfo": false, 361 | "SupportOneKeyMaskVideo": false, 362 | "SupportPCSetDoubleLight": true, 363 | "SupportPTZDirectionControl": false, 364 | "SupportPTZTour": false, 365 | "SupportPWDSafety": true, 366 | "SupportParkingGuide": false, 367 | "SupportPtz360Spin": false, 368 | "SupportRPSVideo": false, 369 | "SupportSetBrightness": false, 370 | "SupportSetDetectTrackWatchPoint": false, 371 | "SupportSetHardwareAbility": false, 372 | "SupportSetPTZPresetAttribute": false, 373 | "SupportSetVolume": true, 374 | "SupportShowH265X": true, 375 | "SupportSnapCfg": false, 376 | "SupportSnapV2Stream": true, 377 | "SupportSnapshotConfigV2": false, 378 | "SupportSoftPhotosensitive": true, 379 | "SupportStatusLed": false, 380 | "SupportTextPassword": true, 381 | "SupportTimeZone": true, 382 | "SupportTimingSleep": false, 383 | "SupportWebRTCModule": false, 384 | "SupportWriteLog": true, 385 | "SuppportChangeOnvifPort": true 386 | }, 387 | "PreviewFunction": { 388 | "Talk": true, 389 | "Tour": false 390 | }, 391 | "TipShow": { 392 | "NoBeepTipShow": true 393 | } 394 | } 395 | ``` 396 | 397 | ## Camera video settings/modes 398 | 399 | ```python 400 | params = cam.get_info("Camera") 401 | # Returns data like this: 402 | # {'ClearFog': [{'enable': 0, 'level': 50}], 'DistortionCorrect': {'Lenstype': 0, 'Version': 0}, 403 | # 'FishLensParam': [{'CenterOffsetX': 300, 'CenterOffsetY': 300, 'ImageHeight': 720, 404 | # 'ImageWidth': 1280, 'LensType': 0, 'PCMac': '000000000000', 'Radius': 300, 'Version': 1, 405 | # 'ViewAngle': 0, 'ViewMode': 0, 'Zoom': 100}], 'FishViCut': [{'ImgHeight': 0, 'ImgWidth': 0, 406 | # 'Xoffset': 0, 'Yoffset': 0}], 'Param': [{'AeSensitivity': 5, 'ApertureMode': '0x00000000', 407 | # 'BLCMode': '0x00000000', 'DayNightColor': '0x00000000', 'Day_nfLevel': 3, 'DncThr': 30, 408 | # 'ElecLevel': 50, 'EsShutter': '0x00000002', 'ExposureParam': {'LeastTime': '0x00000100', 409 | # 'Level': 0, 'MostTime': '0x00010000'}, 'GainParam': {'AutoGain': 1, 'Gain': 50}, 410 | # 'IRCUTMode': 0, 'IrcutSwap': 0, 'Night_nfLevel': 3, 'PictureFlip': '0x00000000', 411 | # 'PictureMirror': '0x00000000', 'RejectFlicker': '0x00000000', 'WhiteBalance': '0x00000000'}], 412 | # 'ParamEx': [{'AutomaticAdjustment': 3, 'BroadTrends': {'AutoGain': 0, 'Gain': 50}, 413 | # 'CorridorMode': 0, 'ExposureTime': '0x100', 'LightRestrainLevel': 16, 'LowLuxMode': 0, 414 | # 'PreventOverExpo': 0, 'SoftPhotosensitivecontrol': 0, 'Style': 'type1'}], 'WhiteLight': 415 | # {'MoveTrigLight': {'Duration': 60, 'Level': 3}, 'WorkMode': 'Auto', 'WorkPeriod': 416 | # {'EHour': 6, 'EMinute': 0, 'Enable': 1, 'SHour': 18, 'SMinute': 0}}} 417 | 418 | # Get current encoding settings 419 | enc_info = cam.get_info("Simplify.Encode") 420 | # Returns data like this: 421 | # [{'ExtraFormat': {'AudioEnable': False, 'Video': {'BitRate': 552, 'BitRateControl': 'VBR', 422 | # 'Compression': 'H.265', 'FPS': 20, 'GOP': 2, 'Quality': 3, 'Resolution': 'D1'}, 423 | # 'VideoEnable': True}, 'MainFormat': {'AudioEnable': False, 'Video': {'BitRate': 2662, 424 | # 'BitRateControl': 'VBR', 'Compression': 'H.265', 'FPS': 25, 'GOP': 2, 'Quality': 4, 425 | # 'Resolution': '1080P'}, 'VideoEnable': True}}] 426 | 427 | # Change bitrate 428 | NewBitrate = 7000 429 | enc_info[0]['MainFormat']['Video']['BitRate'] = NewBitrate 430 | cam.set_info("Simplify.Encode", enc_info) 431 | 432 | # Get videochannel color parameters 433 | colors = cam.get_info("AVEnc.VideoColor.[0]") 434 | # Returns data like this: 435 | # [{'Enable': True, 'TimeSection': '0 00:00:00-24:00:00', 'VideoColorParam': {'Acutance': 3848, 436 | # 'Brightness': 50, 'Contrast': 50, 'Gain': 0, 'Hue': 50, 'Saturation': 50, 'Whitebalance': 128}}, 437 | # {'Enable': False, 'TimeSection': '0 00:00:00-24:00:00', 'VideoColorParam': {'Acutance': 3848, 438 | # 'Brightness': 50, 'Contrast': 50, 'Gain': 0, 'Hue': 50, 'Saturation': 50, 'Whitebalance': 128}}] 439 | 440 | # Change IR Cut 441 | cam.set_info("Camera.Param.[0]", { "IrcutSwap" : 0 }) 442 | 443 | # Change WDR settings 444 | WDR_mode = True 445 | cam.set_info("Camera.ParamEx.[0]", { "BroadTrends" : { "AutoGain" : int(WDR_mode) } }) 446 | 447 | # Get network settings 448 | net = cam.get_info("NetWork.NetCommon") 449 | # Turn on adaptive IP mode 450 | cam.set_info("NetWork.IPAdaptive", { "IPAdaptive": True }) 451 | # Set camera hostname 452 | cam.set_info("NetWork.NetCommon.HostName", "IVG-85HG50PYA-S") 453 | # Set DHCP mode (turn on in this case) 454 | dhcpst = cam.get_info("NetWork.NetDHCP") 455 | dhcpst[0]['Enable'] = True 456 | cam.set_info("NetWork.NetDHCP", dhcpst) 457 | 458 | # Enable/disable cloud support 459 | cloudEnabled = False 460 | cam.set_info("NetWork.Nat", { "NatEnable" : cloudEnabled }) 461 | ``` 462 | 463 | ## Add user and change password 464 | 465 | ```python 466 | #User "test2" with pssword "123123" 467 | cam.addUser("test2","123123") 468 | #Bad password, change it 469 | cam.changePasswd("321321",cam.sofia_hash("123123"),"test2") 470 | #And delete user "test2" 471 | if cam.delUser("test2"): 472 | print("User deleted") 473 | else: 474 | print("Can not delete it") 475 | #System users can not be deleted 476 | if cam.delUser("admin"): 477 | print("You do it! How?") 478 | else: 479 | print("It system reserved user") 480 | ``` 481 | 482 | ## Investigate more settings 483 | 484 | Suggested approach will help understand connections between camera UI and API 485 | settings. Fell free to send PR to the document to update information. 486 | 487 | ```python 488 | from deepdiff import DeepDiff 489 | from pprint import pprint 490 | 491 | latest = None 492 | while True: 493 | current = cam.get_info("Camera") # or "General", "Simplify.Encode", "NetWork" 494 | if latest: 495 | diff = DeepDiff(current, latest) 496 | if diff == {}: 497 | print("Nothing changed") 498 | else: 499 | pprint(diff['values_changed'], indent = 2) 500 | latest = current 501 | input("Change camera setting via UI and then press Enter," 502 | " or double Ctrl-C to exit\n") 503 | ``` 504 | 505 | ## Get JPEG snapshot 506 | 507 | ```python 508 | with open("snap.jpg", "wb") as f: 509 | f.write(cam.snapshot()) 510 | ``` 511 | 512 | ## Get video/audio bitstream 513 | 514 | Video-only writing to file (using simple lambda): 515 | 516 | ```python 517 | with open("datastream.h265", "wb") as f: 518 | cam.start_monitor(lambda frame, meta, user: f.write(frame)) 519 | ``` 520 | 521 | Writing datastream with additional filtering (capture first 100 frames): 522 | 523 | ```python 524 | class State: 525 | def __init__(self): 526 | self.counter = 0 527 | 528 | def count(self): 529 | return self.counter 530 | 531 | def inc(self): 532 | self.counter += 1 533 | 534 | with open("datastream.h265", "wb") as f: 535 | state = State() 536 | def receiver(frame, meta, state): 537 | if 'frame' in meta: 538 | f.write(frame) 539 | state.inc() 540 | print(state.count()) 541 | if state.count() == 100: 542 | cam.stop_monitor() 543 | 544 | cam.start_monitor(receiver, state) 545 | ``` 546 | 547 | ## Set camera title 548 | 549 | ```python 550 | # Simple way to change picture title 551 | cam.channel_title(["Backyard"]) 552 | 553 | # Use unicode font from host computer to compose bitmap for title 554 | from PIL import Image, ImageDraw, ImageFont 555 | 556 | w_disp = 128 557 | h_disp = 64 558 | fontsize = 32 559 | text = "Туалет" 560 | 561 | imageRGB = Image.new('RGB', (w_disp, h_disp)) 562 | draw = ImageDraw.Draw(imageRGB) 563 | font = ImageFont.truetype("/Library/Fonts/Arial Unicode.ttf", fontsize) 564 | w, h = draw.textsize(text, font=font) 565 | draw.text(((w_disp - w)/2, (h_disp - h)/2), text, font=font) 566 | image1bit = imageRGB.convert("1") 567 | data = image1bit.tobytes() 568 | cam.channel_bitmap(w_disp, h_disp, data) 569 | 570 | # Use your own logo on picture 571 | img = Image.open('vixand.png') 572 | width, height = img.size 573 | data = img.convert("1").tobytes() 574 | cam.channel_bitmap(width, height, data) 575 | ``` 576 | 577 | ![screenshot](images/vixand.jpg) 578 | 579 | ```sh 580 | # Show current temperature, velocity, GPS coordinates, etc 581 | # Use the same method to draw text to bitmap and transmit it to camera 582 | # but consider place internal bitmap storage to RAM: 583 | mount -t tmpfs -o size=100k tmpfs /mnt/mtd/tmpfs 584 | ln -sf /mnt/mtd/tmpfs/0.dot /mnt/mtd/Config/Dot/0.dot 585 | ``` 586 | 587 | ## OSD special text displaying 588 | 589 | ```python 590 | cam.set_info("fVideo.OSDInfo", {"Align": 2, "OSDInfo": [ 591 | { 592 | "Info": [ 593 | "АБВГДЕЁЖЗИКЛМНОПРСТУФХЦЧШЩЭЮЯ", 594 | "абвгдеёжзиклмеопрстуфхцчшщэюя", 595 | "ABCDEFGHIJKLMNOPQRSTUVWXYZ", 596 | "abcdefghijklmnopqrstuvwxyz", 597 | "«»©°\"'()[]{}$%^&*_+=0123456789" 598 | ], 599 | "OSDInfoWidget": { 600 | "BackColor": "0x00000000", 601 | "EncodeBlend": True, 602 | "FrontColor": "0xD000FF00", 603 | "PreviewBlend": True, 604 | "RelativePos": [20, 50, 0, 0] 605 | } 606 | } 607 | ], "strEnc": "UTF-8"}) 608 | ``` 609 | 610 | ![screenshot](images/osd-new.png) 611 | 612 | ## Upgrade camera firmware 613 | 614 | ```python 615 | # Optional: get information about upgrade parameters 616 | print(cam.get_upgrade_info()) 617 | 618 | # Do upgrade 619 | cam.upgrade("General_HZXM_IPC_HI3516CV300_50H20L_AE_S38_V4.03.R12.Nat.OnvifS.HIK.20181126_ALL.bin") 620 | ``` 621 | 622 | ## Monitor Script 623 | 624 | This script will persistently attempt to connect to camera at `CAMERA_IP`, will create a directory named `CAMERA_NAME` in `FILE_PATH` and start writing separate video and audio streams in files chunked in 10-minute clips, arranged in folders structured as `%Y/%m/%d`. It will also log what it does. 625 | 626 | ```sh 627 | ./monitor.py 628 | ``` 629 | 630 | ## OPFeederFunctions 631 | 632 | These functions are to handle the pet food dispenser when available. 633 | You can see it with : 634 | 635 | ```python 636 | >>> cam.get_system_capabilities()['OtherFunction']['SupportFeederFunction'] 637 | True 638 | ``` 639 | 640 |
641 | OPFeedManual 642 | 643 | ```python 644 | >>> cam.set_command("OPFeedManual", {"Servings": 1}) 645 | {'Name': 'OPFeedManual', 'OPFeedManual': {'Feeded': 1, 'NotFeeding': 0}, 'Ret': 100, 'SessionID': '0x38'} 646 | ``` 647 | 648 | Servings is the number of portions 649 | 650 |
651 | 652 |
653 | OPFeedBook 654 | 655 | ```python 656 | >>> cam.get_command("OPFeedBook") 657 | {'FeedBook': [{'Enable': 1, 'RecDate': '2018-04-01', 'RecTime': '12:19:18', 'Servings': 1, 'Time': '03:00:00'}, {'Enable': 1, 'RecDate': '2018-04-01', 'RecTime': '12:19:18', 'Servings': 1, 'Time': '09:00:00'}, {'Enable': 1, 'RecDate': '2018-04-01', 'RecTime': '12:19:18', 'Servings': 1, 'Time': '06:00:00'}, {'Enable': 1, 'RecDate': '2018-04-01', 'RecTime': '12:19:18', 'Servings': 1, 'Time': '15:00:00'}, {'Enable': 1, 'RecDate': '2018-04-01', 'RecTime': '12:19:18', 'Servings': 1, 'Time': '12:00:00'}, {'Enable': 1, 'RecDate': '2018-04-01', 'RecTime': '12:19:18', 'Servings': 1, 'Time': '21:00:00'}, {'Enable': 1, 'RecDate': '2018-04-01', 'RecTime': '12:19:18', 'Servings': 1, 'Time': '18:00:00'}, {'Enable': 1, 'RecDate': '2018-04-01', 'RecTime': '12:19:18', 'Servings': 1, 'Time': '00:00:00'}, {'Enable': 1, 'RecDate': '2018-04-01', 'RecTime': '12:19:18', 'Servings': 5, 'Time': '01:00:00'}]} 658 | ``` 659 | 660 | ```python 661 | >>> cam.set_command("OPFeedBook", {"Action": "Delete", "FeedBook": [{'Enable': 1, 'RecDate': '2018-04-01', 'RecTime': '12:19:18', 'Servings': 5, 'Time': '01:00:00'}]}) 662 | {'Name': 'OPFeedBook', 'Ret': 100, 'SessionID': '0x00000018'} 663 | ``` 664 | 665 | ```python 666 | >>> cam.set_command("OPFeedBook", {"Action": "Add", "FeedBook": [{'Enable': 1, 'RecDate': '2018-04-01', 'RecTime': '12:19:18', 'Servings': 5, 'Time': '01:00:00'}]}) 667 | {'Name': 'OPFeedBook', 'Ret': 100, 'SessionID': '0x00000018'} 668 | ``` 669 | 670 |
671 | 672 |
673 | OPFeedHistory 674 | 675 | ```python 676 | >>> cam.get_command("OPFeedHistory") 677 | {'FeedHistory': [{'Date': '2022-08-29', 'Servings': 1, 'Time': '18:49:45', 'Type': 2}, {'Date': '2022-08-26', 'Servings': 3, 'Time': '07:30:12', 'Type': 1}]} 678 | ``` 679 | 680 | Type 1 : automatic 681 | 682 | Type 2 : manual 683 | 684 | ```python 685 | >>> cam.set_command("OPFeedHistory", {"Action": "Delete", "FeedHistory": [{'Date': '2022-08-29', 'Servings': 1, 'Time': '19:40:01', 'Type': 2}]}) 686 | {'Name': 'OPFeedHistory', 'Ret': 100, 'SessionID': '0x00000027'} 687 | ``` 688 | 689 |
690 | 691 | ## Troubleshooting 692 | 693 | ```python 694 | cam.debug() 695 | # or to enable non-standard format 696 | cam.debug('%(asctime)s - %(name)s - %(levelname)s - %(message)s') 697 | ``` 698 | 699 | ## Acknowledgements 700 | 701 | _Telnet access creds from gabonator_ 702 | 703 | https://gist.github.com/gabonator/74cdd6ab4f733ff047356198c781f27d 704 | -------------------------------------------------------------------------------- /asyncio_dvrip.py: -------------------------------------------------------------------------------- 1 | import os 2 | import struct 3 | import json 4 | import hashlib 5 | import asyncio 6 | from datetime import * 7 | from re import compile 8 | import time 9 | import logging 10 | 11 | class SomethingIsWrongWithCamera(Exception): 12 | pass 13 | 14 | class DVRIPCam(object): 15 | DATE_FORMAT = "%Y-%m-%d %H:%M:%S" 16 | CODES = { 17 | 100: "OK", 18 | 101: "Unknown error", 19 | 102: "Unsupported version", 20 | 103: "Request not permitted", 21 | 104: "User already logged in", 22 | 105: "User is not logged in", 23 | 106: "Username or password is incorrect", 24 | 107: "User does not have necessary permissions", 25 | 203: "Password is incorrect", 26 | 511: "Start of upgrade", 27 | 512: "Upgrade was not started", 28 | 513: "Upgrade data errors", 29 | 514: "Upgrade error", 30 | 515: "Upgrade successful", 31 | } 32 | QCODES = { 33 | "AuthorityList": 1470, 34 | "Users": 1472, 35 | "Groups": 1474, 36 | "AddGroup": 1476, 37 | "ModifyGroup": 1478, 38 | "DelGroup": 1480, 39 | "AddUser": 1482, 40 | "ModifyUser": 1484, 41 | "DelUser": 1486, 42 | "ModifyPassword": 1488, 43 | "AlarmInfo": 1504, 44 | "AlarmSet": 1500, 45 | "ChannelTitle": 1046, 46 | "EncodeCapability": 1360, 47 | "General": 1042, 48 | "KeepAlive": 1006, 49 | "OPMachine": 1450, 50 | "OPMailTest": 1636, 51 | "OPMonitor": 1413, 52 | "OPNetKeyboard": 1550, 53 | "OPPTZControl": 1400, 54 | "OPSNAP": 1560, 55 | "OPSendFile": 0x5F2, 56 | "OPSystemUpgrade": 0x5F5, 57 | "OPTalk": 1434, 58 | "OPTimeQuery": 1452, 59 | "OPTimeSetting": 1450, 60 | "NetWork.NetCommon": 1042, 61 | "OPNetAlarm": 1506, 62 | "SystemFunction": 1360, 63 | "SystemInfo": 1020, 64 | } 65 | KEY_CODES = { 66 | "M": "Menu", 67 | "I": "Info", 68 | "E": "Esc", 69 | "F": "Func", 70 | "S": "Shift", 71 | "L": "Left", 72 | "U": "Up", 73 | "R": "Right", 74 | "D": "Down", 75 | } 76 | OK_CODES = [100, 515] 77 | PORTS = { 78 | "tcp": 34567, 79 | "udp": 34568, 80 | } 81 | 82 | def __init__(self, ip, **kwargs): 83 | self.logger = logging.getLogger(__name__) 84 | self.ip = ip 85 | self.user = kwargs.get("user", "admin") 86 | self.hash_pass = kwargs.get("hash_pass", self.sofia_hash(kwargs.get("password", ""))) 87 | self.proto = kwargs.get("proto", "tcp") 88 | self.port = kwargs.get("port", self.PORTS.get(self.proto)) 89 | self.socket_reader = None 90 | self.socket_writer = None 91 | self.packet_count = 0 92 | self.session = 0 93 | self.alive_time = 20 94 | self.alarm_func = None 95 | self.timeout = 10 96 | self.busy = asyncio.Lock() 97 | 98 | def debug(self, format=None): 99 | self.logger.setLevel(logging.DEBUG) 100 | ch = logging.StreamHandler() 101 | if format: 102 | formatter = logging.Formatter(format) 103 | ch.setFormatter(formatter) 104 | self.logger.addHandler(ch) 105 | 106 | async def connect(self, timeout=10): 107 | try: 108 | if self.proto == "tcp": 109 | self.socket_reader, self.socket_writer = await asyncio.wait_for(asyncio.open_connection(self.ip, self.port), timeout=timeout) 110 | self.socket_send = self.tcp_socket_send 111 | self.socket_recv = self.tcp_socket_recv 112 | elif self.proto == "udp": 113 | raise f"Unsupported protocol {self.proto} (yet)" 114 | else: 115 | raise f"Unsupported protocol {self.proto}" 116 | 117 | # it's important to extend timeout for upgrade procedure 118 | self.timeout = timeout 119 | except OSError: 120 | raise SomethingIsWrongWithCamera('Cannot connect to camera') 121 | 122 | def close(self): 123 | try: 124 | self.socket_writer.close() 125 | except: 126 | pass 127 | self.socket_writer = None 128 | 129 | def tcp_socket_send(self, bytes): 130 | try: 131 | return self.socket_writer.write(bytes) 132 | except: 133 | return None 134 | 135 | async def tcp_socket_recv(self, bufsize): 136 | try: 137 | return await self.socket_reader.read(bufsize) 138 | except: 139 | return None 140 | 141 | async def receive_with_timeout(self, length): 142 | received = 0 143 | buf = bytearray() 144 | start_time = time.time() 145 | 146 | while True: 147 | try: 148 | data = await asyncio.wait_for(self.socket_recv(length - received), timeout=self.timeout) 149 | buf.extend(data) 150 | received += len(data) 151 | if length == received: 152 | break 153 | elapsed_time = time.time() - start_time 154 | if elapsed_time > self.timeout: 155 | return None 156 | except asyncio.TimeoutError: 157 | return None 158 | return buf 159 | 160 | async def receive_json(self, length): 161 | data = await self.receive_with_timeout(length) 162 | if data is None: 163 | return {} 164 | 165 | self.packet_count += 1 166 | self.logger.debug("<= %s", data) 167 | reply = json.loads(data[:-2]) 168 | return reply 169 | 170 | async def send(self, msg, data={}, wait_response=True): 171 | if self.socket_writer is None: 172 | return {"Ret": 101} 173 | await self.busy.acquire() 174 | if hasattr(data, "__iter__"): 175 | data = bytes(json.dumps(data, ensure_ascii=False), "utf-8") 176 | pkt = ( 177 | struct.pack( 178 | "BB2xII2xHI", 179 | 255, 180 | 0, 181 | self.session, 182 | self.packet_count, 183 | msg, 184 | len(data) + 2, 185 | ) 186 | + data 187 | + b"\x0a\x00" 188 | ) 189 | self.logger.debug("=> %s", pkt) 190 | self.socket_send(pkt) 191 | if wait_response: 192 | reply = {"Ret": 101} 193 | data = await self.socket_recv(20) 194 | if data is None or len(data) < 20: 195 | return None 196 | ( 197 | head, 198 | version, 199 | self.session, 200 | sequence_number, 201 | msgid, 202 | len_data, 203 | ) = struct.unpack("BB2xII2xHI", data) 204 | reply = await self.receive_json(len_data) 205 | self.busy.release() 206 | return reply 207 | 208 | def sofia_hash(self, password=""): 209 | md5 = hashlib.md5(bytes(password, "utf-8")).digest() 210 | chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" 211 | return "".join([chars[sum(x) % 62] for x in zip(md5[::2], md5[1::2])]) 212 | 213 | async def login(self, loop): 214 | if self.socket_writer is None: 215 | await self.connect() 216 | data = await self.send( 217 | 1000, 218 | { 219 | "EncryptType": "MD5", 220 | "LoginType": "DVRIP-Web", 221 | "PassWord": self.hash_pass, 222 | "UserName": self.user, 223 | }, 224 | ) 225 | if data is None or data["Ret"] not in self.OK_CODES: 226 | return False 227 | self.session = int(data["SessionID"], 16) 228 | self.alive_time = data["AliveInterval"] 229 | self.keep_alive(loop) 230 | return data["Ret"] in self.OK_CODES 231 | 232 | async def getAuthorityList(self): 233 | data = await self.send(self.QCODES["AuthorityList"]) 234 | if data["Ret"] in self.OK_CODES: 235 | return data["AuthorityList"] 236 | else: 237 | return [] 238 | 239 | async def getGroups(self): 240 | data = await self.send(self.QCODES["Groups"]) 241 | if data["Ret"] in self.OK_CODES: 242 | return data["Groups"] 243 | else: 244 | return [] 245 | 246 | async def addGroup(self, name, comment="", auth=None): 247 | data = await self.set_command( 248 | "AddGroup", 249 | { 250 | "Group": { 251 | "AuthorityList": auth or await self.getAuthorityList(), 252 | "Memo": comment, 253 | "Name": name, 254 | }, 255 | }, 256 | ) 257 | return data["Ret"] in self.OK_CODES 258 | 259 | async def modifyGroup(self, name, newname=None, comment=None, auth=None): 260 | g = [x for x in await self.getGroups() if x["Name"] == name] 261 | if g == []: 262 | print(f'Group "{name}" not found!') 263 | return False 264 | g = g[0] 265 | data = await self.send( 266 | self.QCODES["ModifyGroup"], 267 | { 268 | "Group": { 269 | "AuthorityList": auth or g["AuthorityList"], 270 | "Memo": comment or g["Memo"], 271 | "Name": newname or g["Name"], 272 | }, 273 | "GroupName": name, 274 | }, 275 | ) 276 | return data["Ret"] in self.OK_CODES 277 | 278 | async def delGroup(self, name): 279 | data = await self.send( 280 | self.QCODES["DelGroup"], 281 | {"Name": name, "SessionID": "0x%08X" % self.session,}, 282 | ) 283 | return data["Ret"] in self.OK_CODES 284 | 285 | async def getUsers(self): 286 | data = await self.send(self.QCODES["Users"]) 287 | if data["Ret"] in self.OK_CODES: 288 | return data["Users"] 289 | else: 290 | return [] 291 | 292 | async def addUser( 293 | self, name, password, comment="", group="user", auth=None, sharable=True 294 | ): 295 | g = [x for x in await self.getGroups() if x["Name"] == group] 296 | if g == []: 297 | print(f'Group "{group}" not found!') 298 | return False 299 | g = g[0] 300 | data = await self.set_command( 301 | "AddUser", 302 | { 303 | "User": { 304 | "AuthorityList": auth or g["AuthorityList"], 305 | "Group": g["Name"], 306 | "Memo": comment, 307 | "Name": name, 308 | "Password": self.sofia_hash(password), 309 | "Reserved": False, 310 | "Sharable": sharable, 311 | }, 312 | }, 313 | ) 314 | return data["Ret"] in self.OK_CODES 315 | 316 | async def modifyUser( 317 | self, name, newname=None, comment=None, group=None, auth=None, sharable=None 318 | ): 319 | u = [x for x in self.getUsers() if x["Name"] == name] 320 | if u == []: 321 | print(f'User "{name}" not found!') 322 | return False 323 | u = u[0] 324 | if group: 325 | g = [x for x in await self.getGroups() if x["Name"] == group] 326 | if g == []: 327 | print(f'Group "{group}" not found!') 328 | return False 329 | u["AuthorityList"] = g[0]["AuthorityList"] 330 | data = await self.send( 331 | self.QCODES["ModifyUser"], 332 | { 333 | "User": { 334 | "AuthorityList": auth or u["AuthorityList"], 335 | "Group": group or u["Group"], 336 | "Memo": comment or u["Memo"], 337 | "Name": newname or u["Name"], 338 | "Password": "", 339 | "Reserved": u["Reserved"], 340 | "Sharable": sharable or u["Sharable"], 341 | }, 342 | "UserName": name, 343 | }, 344 | ) 345 | return data["Ret"] in self.OK_CODES 346 | 347 | async def delUser(self, name): 348 | data = await self.send( 349 | self.QCODES["DelUser"], 350 | {"Name": name, "SessionID": "0x%08X" % self.session,}, 351 | ) 352 | return data["Ret"] in self.OK_CODES 353 | 354 | async def changePasswd(self, newpass="", oldpass=None, user=None): 355 | data = await self.send( 356 | self.QCODES["ModifyPassword"], 357 | { 358 | "EncryptType": "MD5", 359 | "NewPassWord": self.sofia_hash(newpass), 360 | "PassWord": oldpass or self.password, 361 | "SessionID": "0x%08X" % self.session, 362 | "UserName": user or self.user, 363 | }, 364 | ) 365 | return data["Ret"] in self.OK_CODES 366 | 367 | async def channel_title(self, titles): 368 | if isinstance(titles, str): 369 | titles = [titles] 370 | await self.send( 371 | self.QCODES["ChannelTitle"], 372 | { 373 | "ChannelTitle": titles, 374 | "Name": "ChannelTitle", 375 | "SessionID": "0x%08X" % self.session, 376 | }, 377 | ) 378 | 379 | async def channel_bitmap(self, width, height, bitmap): 380 | header = struct.pack("HH12x", width, height) 381 | self.socket_send( 382 | struct.pack( 383 | "BB2xII2xHI", 384 | 255, 385 | 0, 386 | self.session, 387 | self.packet_count, 388 | 0x041A, 389 | len(bitmap) + 16, 390 | ) 391 | + header 392 | + bitmap 393 | ) 394 | reply, rcvd = await self.recv_json() 395 | if reply and reply["Ret"] != 100: 396 | return False 397 | return True 398 | 399 | async def reboot(self): 400 | await self.set_command("OPMachine", {"Action": "Reboot"}) 401 | self.close() 402 | 403 | def setAlarm(self, func): 404 | self.alarm_func = func 405 | 406 | def clearAlarm(self): 407 | self.alarm_func = None 408 | 409 | async def alarmStart(self, loop): 410 | loop.create_task(self.alarm_worker()) 411 | 412 | return await self.get_command("", self.QCODES["AlarmSet"]) 413 | 414 | async def alarm_worker(self): 415 | while self.socket_writer: 416 | await self.busy.acquire() 417 | try: 418 | ( 419 | head, 420 | version, 421 | session, 422 | sequence_number, 423 | msgid, 424 | len_data, 425 | ) = struct.unpack("BB2xII2xHI", await self.socket_recv(20)) 426 | await asyncio.sleep(0.1) # Just for receive whole packet 427 | reply = await self.socket_recv(len_data) 428 | self.packet_count += 1 429 | reply = json.loads(reply[:-2]) 430 | if msgid == self.QCODES["AlarmInfo"] and self.session == session: 431 | if self.alarm_func is not None: 432 | self.alarm_func(reply[reply["Name"]], sequence_number) 433 | except: 434 | pass 435 | finally: 436 | self.busy.release() 437 | 438 | async def set_remote_alarm(self, state): 439 | await self.set_command( 440 | "OPNetAlarm", {"Event": 0, "State": state}, 441 | ) 442 | 443 | async def keep_alive_workner(self): 444 | while self.socket_writer: 445 | 446 | ret = await self.send( 447 | self.QCODES["KeepAlive"], 448 | {"Name": "KeepAlive", "SessionID": "0x%08X" % self.session}, 449 | ) 450 | if ret is None: 451 | self.close() 452 | break 453 | 454 | await asyncio.sleep(self.alive_time) 455 | 456 | def keep_alive(self, loop): 457 | loop.create_task(self.keep_alive_workner()) 458 | 459 | async def keyDown(self, key): 460 | await self.set_command( 461 | "OPNetKeyboard", {"Status": "KeyDown", "Value": key}, 462 | ) 463 | 464 | async def keyUp(self, key): 465 | await self.set_command( 466 | "OPNetKeyboard", {"Status": "KeyUp", "Value": key}, 467 | ) 468 | 469 | async def keyPress(self, key): 470 | await self.keyDown(key) 471 | await asyncio.sleep(0.3) 472 | await self.keyUp(key) 473 | 474 | async def keyScript(self, keys): 475 | for k in keys: 476 | if k != " " and k.upper() in self.KEY_CODES: 477 | await self.keyPress(self.KEY_CODES[k.upper()]) 478 | else: 479 | await asyncio.sleep(1) 480 | 481 | async def ptz(self, cmd, step=5, preset=-1, ch=0): 482 | CMDS = [ 483 | "DirectionUp", 484 | "DirectionDown", 485 | "DirectionLeft", 486 | "DirectionRight", 487 | "DirectionLeftUp", 488 | "DirectionLeftDown", 489 | "DirectionRightUp", 490 | "DirectionRightDown", 491 | "ZoomTile", 492 | "ZoomWide", 493 | "FocusNear", 494 | "FocusFar", 495 | "IrisSmall", 496 | "IrisLarge", 497 | "SetPreset", 498 | "GotoPreset", 499 | "ClearPreset", 500 | "StartTour", 501 | "StopTour", 502 | ] 503 | # ptz_param = { "AUX" : { "Number" : 0, "Status" : "On" }, "Channel" : ch, "MenuOpts" : "Enter", "POINT" : { "bottom" : 0, "left" : 0, "right" : 0, "top" : 0 }, "Pattern" : "SetBegin", "Preset" : -1, "Step" : 5, "Tour" : 0 } 504 | ptz_param = { 505 | "AUX": {"Number": 0, "Status": "On"}, 506 | "Channel": ch, 507 | "MenuOpts": "Enter", 508 | "Pattern": "Start", 509 | "Preset": preset, 510 | "Step": step, 511 | "Tour": 1 if "Tour" in cmd else 0, 512 | } 513 | return await self.set_command( 514 | "OPPTZControl", {"Command": cmd, "Parameter": ptz_param}, 515 | ) 516 | 517 | async def set_info(self, command, data): 518 | return await self.set_command(command, data, 1040) 519 | 520 | async def set_command(self, command, data, code=None): 521 | if not code: 522 | code = self.QCODES[command] 523 | return await self.send( 524 | code, {"Name": command, "SessionID": "0x%08X" % self.session, command: data} 525 | ) 526 | 527 | async def get_info(self, command): 528 | return await self.get_command(command, 1042) 529 | 530 | async def get_command(self, command, code=None): 531 | if not code: 532 | code = self.QCODES[command] 533 | 534 | data = await self.send(code, {"Name": command, "SessionID": "0x%08X" % self.session}) 535 | if data["Ret"] in self.OK_CODES and command in data: 536 | return data[command] 537 | else: 538 | return data 539 | 540 | async def get_time(self): 541 | return datetime.strptime(await self.get_command("OPTimeQuery"), self.DATE_FORMAT) 542 | 543 | async def set_time(self, time=None): 544 | if time is None: 545 | time = datetime.now() 546 | return await self.set_command("OPTimeSetting", time.strftime(self.DATE_FORMAT)) 547 | 548 | async def get_netcommon(self): 549 | return await self.get_command("NetWork.NetCommon") 550 | 551 | async def get_system_info(self): 552 | return await self.get_command("SystemInfo") 553 | 554 | async def get_general_info(self): 555 | return await self.get_command("General") 556 | 557 | async def get_encode_capabilities(self): 558 | return await self.get_command("EncodeCapability") 559 | 560 | async def get_system_capabilities(self): 561 | return await self.get_command("SystemFunction") 562 | 563 | async def get_camera_info(self, default_config=False): 564 | """Request data for 'Camera' from the target DVRIP device.""" 565 | if default_config: 566 | code = 1044 567 | else: 568 | code = 1042 569 | return await self.get_command("Camera", code) 570 | 571 | async def get_encode_info(self, default_config=False): 572 | """Request data for 'Simplify.Encode' from the target DVRIP device. 573 | 574 | Arguments: 575 | default_config -- returns the default values for the type if True 576 | """ 577 | if default_config: 578 | code = 1044 579 | else: 580 | code = 1042 581 | return await self.get_command("Simplify.Encode", code) 582 | 583 | async def recv_json(self, buf=bytearray()): 584 | p = compile(b".*({.*})") 585 | 586 | packet = await self.socket_recv(0xFFFF) 587 | if not packet: 588 | return None, buf 589 | buf.extend(packet) 590 | m = p.search(buf) 591 | if m is None: 592 | return None, buf 593 | buf = buf[m.span(1)[1] :] 594 | return json.loads(m.group(1)), buf 595 | 596 | async def get_upgrade_info(self): 597 | return await self.get_command("OPSystemUpgrade") 598 | 599 | async def upgrade(self, filename="", packetsize=0x8000, vprint=None): 600 | if not vprint: 601 | vprint = lambda x: print(x) 602 | 603 | data = await self.set_command( 604 | "OPSystemUpgrade", {"Action": "Start", "Type": "System"}, 0x5F0 605 | ) 606 | if data["Ret"] not in self.OK_CODES: 607 | return data 608 | 609 | vprint("Ready to upgrade") 610 | blocknum = 0 611 | sentbytes = 0 612 | fsize = os.stat(filename).st_size 613 | rcvd = bytearray() 614 | with open(filename, "rb") as f: 615 | while True: 616 | bytes = f.read(packetsize) 617 | if not bytes: 618 | break 619 | header = struct.pack( 620 | "BB2xII2xHI", 255, 0, self.session, blocknum, 0x5F2, len(bytes) 621 | ) 622 | self.socket_send(header + bytes) 623 | blocknum += 1 624 | sentbytes += len(bytes) 625 | 626 | reply, rcvd = await self.recv_json(rcvd) 627 | if reply and reply["Ret"] != 100: 628 | vprint("Upgrade failed") 629 | return reply 630 | 631 | progress = sentbytes / fsize * 100 632 | vprint(f"Uploaded {progress:.2f}%") 633 | vprint("End of file") 634 | 635 | pkt = struct.pack("BB2xIIxBHI", 255, 0, self.session, blocknum, 1, 0x05F2, 0) 636 | self.socket_send(pkt) 637 | vprint("Waiting for upgrade...") 638 | while True: 639 | reply, rcvd = await self.recv_json(rcvd) 640 | print(reply) 641 | if not reply: 642 | return 643 | if reply["Name"] == "" and reply["Ret"] == 100: 644 | break 645 | 646 | while True: 647 | data, rcvd = await self.recv_json(rcvd) 648 | print(reply) 649 | if data is None: 650 | vprint("Done") 651 | return 652 | if data["Ret"] in [512, 514, 513]: 653 | vprint("Upgrade failed") 654 | return data 655 | if data["Ret"] == 515: 656 | vprint("Upgrade successful") 657 | self.close() 658 | return data 659 | vprint(f"Upgraded {data['Ret']}%") 660 | 661 | async def reassemble_bin_payload(self, metadata={}): 662 | def internal_to_type(data_type, value): 663 | if data_type == 0x1FC or data_type == 0x1FD: 664 | if value == 1: 665 | return "mpeg4" 666 | elif value == 2: 667 | return "h264" 668 | elif value == 3: 669 | return "h265" 670 | elif data_type == 0x1F9: 671 | if value == 1 or value == 6: 672 | return "info" 673 | elif data_type == 0x1FA: 674 | if value == 0xE: 675 | return "g711a" 676 | elif data_type == 0x1FE and value == 0: 677 | return "jpeg" 678 | return None 679 | 680 | def internal_to_datetime(value): 681 | second = value & 0x3F 682 | minute = (value & 0xFC0) >> 6 683 | hour = (value & 0x1F000) >> 12 684 | day = (value & 0x3E0000) >> 17 685 | month = (value & 0x3C00000) >> 22 686 | year = ((value & 0xFC000000) >> 26) + 2000 687 | return datetime(year, month, day, hour, minute, second) 688 | 689 | length = 0 690 | buf = bytearray() 691 | start_time = time.time() 692 | 693 | while True: 694 | data = await self.receive_with_timeout(20) 695 | ( 696 | head, 697 | version, 698 | session, 699 | sequence_number, 700 | total, 701 | cur, 702 | msgid, 703 | len_data, 704 | ) = struct.unpack("BB2xIIBBHI", data) 705 | packet = await self.receive_with_timeout(len_data) 706 | frame_len = 0 707 | if length == 0: 708 | media = None 709 | frame_len = 8 710 | (data_type,) = struct.unpack(">I", packet[:4]) 711 | if data_type == 0x1FC or data_type == 0x1FE: 712 | frame_len = 16 713 | (media, metadata["fps"], w, h, dt, length,) = struct.unpack( 714 | "BBBBII", packet[4:frame_len] 715 | ) 716 | metadata["width"] = w * 8 717 | metadata["height"] = h * 8 718 | metadata["datetime"] = internal_to_datetime(dt) 719 | if data_type == 0x1FC: 720 | metadata["frame"] = "I" 721 | elif data_type == 0x1FD: 722 | (length,) = struct.unpack("I", packet[4:frame_len]) 723 | metadata["frame"] = "P" 724 | elif data_type == 0x1FA: 725 | (media, samp_rate, length) = struct.unpack( 726 | "BBH", packet[4:frame_len] 727 | ) 728 | elif data_type == 0x1F9: 729 | (media, n, length) = struct.unpack("BBH", packet[4:frame_len]) 730 | # special case of JPEG shapshots 731 | elif data_type == 0xFFD8FFE0: 732 | return packet 733 | else: 734 | raise ValueError(data_type) 735 | if media is not None: 736 | metadata["type"] = internal_to_type(data_type, media) 737 | buf.extend(packet[frame_len:]) 738 | length -= len(packet) - frame_len 739 | if length == 0: 740 | return buf 741 | elapsed_time = time.time() - start_time 742 | if elapsed_time > self.timeout: 743 | return None 744 | 745 | async def snapshot(self, channel=0): 746 | command = "OPSNAP" 747 | await self.send( 748 | self.QCODES[command], 749 | { 750 | "Name": command, 751 | "SessionID": "0x%08X" % self.session, 752 | command: {"Channel": channel}, 753 | }, 754 | wait_response=False, 755 | ) 756 | packet = await self.reassemble_bin_payload() 757 | return packet 758 | 759 | async def start_monitor(self, frame_callback, user={}, stream="Main"): 760 | params = { 761 | "Channel": 0, 762 | "CombinMode": "NONE", 763 | "StreamType": stream, 764 | "TransMode": "TCP", 765 | } 766 | data = await self.set_command("OPMonitor", {"Action": "Claim", "Parameter": params}) 767 | if data["Ret"] not in self.OK_CODES: 768 | return data 769 | 770 | await self.send( 771 | 1410, 772 | { 773 | "Name": "OPMonitor", 774 | "SessionID": "0x%08X" % self.session, 775 | "OPMonitor": {"Action": "Start", "Parameter": params}, 776 | }, 777 | wait_response=False, 778 | ) 779 | self.monitoring = True 780 | while self.monitoring: 781 | meta = {} 782 | frame = await self.reassemble_bin_payload(meta) 783 | frame_callback(frame, meta, user) 784 | 785 | def stop_monitor(self): 786 | self.monitoring = False 787 | -------------------------------------------------------------------------------- /connect.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: UTF-8 -*- 3 | import sys 4 | from dvrip import DVRIPCam 5 | from time import sleep 6 | import json 7 | 8 | host_ip = "192.168.0.100" 9 | if len(sys.argv) > 1: 10 | host_ip = str(sys.argv[1]) 11 | 12 | cam = DVRIPCam(host_ip, user="admin", password="46216") 13 | 14 | if cam.login(): 15 | print("Success! Connected to " + host_ip) 16 | else: 17 | print("Failure. Could not connect.") 18 | 19 | info = cam.get_info("fVideo.OSDInfo") 20 | print(json.dumps(info, ensure_ascii=False)) 21 | info["OSDInfo"][0]["Info"] = [u"Тест00", "Test01", "Test02"] 22 | # info["OSDInfo"][0]["Info"][1] = "" 23 | # info["OSDInfo"][0]["Info"][2] = "" 24 | # info["OSDInfo"][0]["Info"][3] = "Test3" 25 | info["OSDInfo"][0]["OSDInfoWidget"]["EncodeBlend"] = True 26 | info["OSDInfo"][0]["OSDInfoWidget"]["PreviewBlend"] = True 27 | # info["OSDInfo"][0]["OSDInfoWidget"]["RelativePos"] = [6144,6144,8192,8192] 28 | cam.set_info("fVideo.OSDInfo", info) 29 | # enc_info = cam.get_info("Simplify.Encode") 30 | # Alarm example 31 | def alarm(content, ids): 32 | print(content) 33 | 34 | 35 | cam.setAlarm(alarm) 36 | cam.alarmStart() 37 | # cam.get_encode_info() 38 | # sleep(1) 39 | # cam.get_camera_info() 40 | # sleep(1) 41 | 42 | # enc_info[0]['ExtraFormat']['Video']['FPS'] = 20 43 | # cam.set_info("Simplify.Encode", enc_info) 44 | # sleep(2) 45 | # print(cam.get_info("Simplify.Encode")) 46 | # cam.close() 47 | -------------------------------------------------------------------------------- /doc/Соглашение о интерфейсе цифрового видеорегистратора XiongmaiV1.0.doc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenIPC/python-dvr/51de58c2a7576dfcec5cbe4b5d261414c9ec7328/doc/Соглашение о интерфейсе цифрового видеорегистратора XiongmaiV1.0.doc -------------------------------------------------------------------------------- /doc/码流帧格式文档.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenIPC/python-dvr/51de58c2a7576dfcec5cbe4b5d261414c9ec7328/doc/码流帧格式文档.pdf -------------------------------------------------------------------------------- /doc/配置交换格式V2.0.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenIPC/python-dvr/51de58c2a7576dfcec5cbe4b5d261414c9ec7328/doc/配置交换格式V2.0.pdf -------------------------------------------------------------------------------- /doc/雄迈数字视频录像机接口协议V1.0.doc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenIPC/python-dvr/51de58c2a7576dfcec5cbe4b5d261414c9ec7328/doc/雄迈数字视频录像机接口协议V1.0.doc -------------------------------------------------------------------------------- /doc/雄迈数字视频录像机接口协议_V1.0.0.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenIPC/python-dvr/51de58c2a7576dfcec5cbe4b5d261414c9ec7328/doc/雄迈数字视频录像机接口协议_V1.0.0.pdf -------------------------------------------------------------------------------- /download-local-files.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from time import sleep 3 | import os 4 | import json 5 | import logging 6 | from collections import namedtuple 7 | from solarcam import SolarCam 8 | 9 | 10 | def init_logger(): 11 | logger = logging.getLogger(__name__) 12 | logger.setLevel(logging.DEBUG) 13 | ch = logging.StreamHandler() 14 | formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s") 15 | ch.setFormatter(formatter) 16 | logger.addHandler(ch) 17 | return logger 18 | 19 | 20 | def load_config(): 21 | def config_decoder(config_dict): 22 | return namedtuple("X", config_dict.keys())(*config_dict.values()) 23 | 24 | config_path = os.environ.get("CONFIG_PATH") 25 | if Path(config_path).exists(): 26 | with open(config_path, "r") as file: 27 | return json.loads(file.read(), object_hook=config_decoder) 28 | 29 | return { 30 | "host_ip": os.environ.get("IP_ADDRESS"), 31 | "user": os.environ.get("USER"), 32 | "password": os.environ.get("PASSWORD"), 33 | "target_filetype_video": os.environ.get("target_filetype_video"), 34 | "download_dir_video": os.environ.get("DOWNLOAD_DIR_VIDEO"), 35 | "download_dir_picture": os.environ.get("DOWNLOAD_DIR_PICTURE"), 36 | "start": os.environ.get("START"), 37 | "end": os.environ.get("END"), 38 | "blacklist_path": os.environ.get("BLACKLIST_PATH"), 39 | "cooldown": int(os.environ.get("COOLDOWN")), 40 | "dump_local_files": ( 41 | os.environ.get("DUMP_LOCAL_FILES").lower() in ["true", "1", "y", "yes"] 42 | ), 43 | } 44 | 45 | 46 | def main(): 47 | logger = init_logger() 48 | config = load_config() 49 | start = config.start 50 | end = config.end 51 | cooldown = config.cooldown 52 | 53 | blacklist = None 54 | if Path(config.blacklist_path).exists(): 55 | with open(config.blacklist_path, "r") as file: 56 | blacklist = [line.rstrip() for line in file] 57 | 58 | while True: 59 | solarCam = SolarCam(config.host_ip, config.user, config.password, logger) 60 | 61 | try: 62 | solarCam.login() 63 | 64 | battery = solarCam.get_battery() 65 | logger.debug(f"Current battery status: {battery}") 66 | storage = solarCam.get_storage()[0] 67 | logger.debug(f"Current storage status: {storage}") 68 | 69 | logger.debug(f"Syncing time...") 70 | solarCam.set_time() # setting it to system clock 71 | logger.debug(f"Camera time is now {solarCam.get_time()}") 72 | 73 | sleep(5) # sleep some seconds so camera can get ready 74 | 75 | pics = solarCam.get_local_files(start, end, "jpg") 76 | 77 | if pics: 78 | Path(config.download_dir_picture).parent.mkdir( 79 | parents=True, exist_ok=True 80 | ) 81 | solarCam.save_files( 82 | config.download_dir_picture, pics, blacklist=blacklist 83 | ) 84 | 85 | videos = solarCam.get_local_files(start, end, "h264") 86 | if videos: 87 | Path(config.download_dir_video).parent.mkdir( 88 | parents=True, exist_ok=True 89 | ) 90 | solarCam.save_files( 91 | config.download_dir_video, 92 | videos, 93 | blacklist=blacklist, 94 | target_filetype=config.target_filetype_video, 95 | ) 96 | 97 | if config.dump_local_files: 98 | logger.debug(f"Dumping local files...") 99 | solarCam.dump_local_files( 100 | videos, 101 | config.blacklist_path, 102 | config.download_dir_video, 103 | target_filetype=config.target_filetype_video, 104 | ) 105 | solarCam.dump_local_files( 106 | pics, config.blacklist_path, config.download_dir_picture 107 | ) 108 | 109 | solarCam.logout() 110 | except ConnectionRefusedError: 111 | logger.debug(f"Connection could not be established or got disconnected") 112 | except TypeError as e: 113 | print(e) 114 | logger.debug(f"Error while downloading a file") 115 | except KeyError: 116 | logger.debug(f"Error while getting the file list") 117 | logger.debug(f"Sleeping for {cooldown} seconds...") 118 | sleep(cooldown) 119 | 120 | 121 | if __name__ == "__main__": 122 | main() 123 | 124 | # todo add flask api for moving cam 125 | # todo show current stream 126 | # todo show battery on webinterface and write it to mqtt topic 127 | # todo change camera name 128 | -------------------------------------------------------------------------------- /dvrip.py: -------------------------------------------------------------------------------- 1 | import os 2 | import struct 3 | import json 4 | from time import sleep 5 | import hashlib 6 | import threading 7 | from socket import socket, AF_INET, SOCK_STREAM, SOCK_DGRAM, SOL_SOCKET 8 | from datetime import * 9 | from re import compile 10 | import time 11 | import logging 12 | from pathlib import Path 13 | 14 | 15 | class SomethingIsWrongWithCamera(Exception): 16 | pass 17 | 18 | 19 | class DVRIPCam(object): 20 | DATE_FORMAT = "%Y-%m-%d %H:%M:%S" 21 | CODES = { 22 | 100: "OK", 23 | 101: "Unknown error", 24 | 102: "Unsupported version", 25 | 103: "Request not permitted", 26 | 104: "User already logged in", 27 | 105: "User is not logged in", 28 | 106: "Username or password is incorrect", 29 | 107: "User does not have necessary permissions", 30 | 203: "Password is incorrect", 31 | 511: "Start of upgrade", 32 | 512: "Upgrade was not started", 33 | 513: "Upgrade data errors", 34 | 514: "Upgrade error", 35 | 515: "Upgrade successful", 36 | } 37 | QCODES = { 38 | "AuthorityList": 1470, 39 | "Users": 1472, 40 | "Groups": 1474, 41 | "AddGroup": 1476, 42 | "ModifyGroup": 1478, 43 | "DelGroup": 1480, 44 | "User": 1482, 45 | "ModifyUser": 1484, 46 | "DelUser": 1486, 47 | "ModifyPassword": 1488, 48 | "AlarmInfo": 1504, 49 | "AlarmSet": 1500, 50 | "ChannelTitle": 1046, 51 | "EncodeCapability": 1360, 52 | "General": 1042, 53 | "KeepAlive": 1006, 54 | "OPMachine": 1450, 55 | "OPMailTest": 1636, 56 | "OPMonitor": 1413, 57 | "OPNetKeyboard": 1550, 58 | "OPPTZControl": 1400, 59 | "OPSNAP": 1560, 60 | "OPSendFile": 0x5F2, 61 | "OPSystemUpgrade": 0x5F5, 62 | "OPTalk": 1434, 63 | "OPTimeQuery": 1452, 64 | "OPTimeSetting": 1450, 65 | "NetWork.NetCommon": 1042, 66 | "OPNetAlarm": 1506, 67 | "SystemFunction": 1360, 68 | "SystemInfo": 1020, 69 | } 70 | OPFEED_QCODES = { 71 | "OPFeedBook": { 72 | "SET": 2300, 73 | "GET": 2302, 74 | }, 75 | "OPFeedManual": { 76 | "SET": 2304, 77 | }, 78 | "OPFeedHistory": { 79 | "GET": 2306, 80 | "SET": 2308, 81 | }, 82 | } 83 | KEY_CODES = { 84 | "M": "Menu", 85 | "I": "Info", 86 | "E": "Esc", 87 | "F": "Func", 88 | "S": "Shift", 89 | "L": "Left", 90 | "U": "Up", 91 | "R": "Right", 92 | "D": "Down", 93 | } 94 | OK_CODES = [100, 515] 95 | PORTS = { 96 | "tcp": 34567, 97 | "udp": 34568, 98 | } 99 | 100 | def __init__(self, ip, **kwargs): 101 | self.logger = logging.getLogger(__name__) 102 | self.ip = ip 103 | self.iface = kwargs.get("iface", None) 104 | self.user = kwargs.get("user", "admin") 105 | hash_pass = kwargs.get("hash_pass") 106 | self.hash_pass = kwargs.get( 107 | "hash_pass", self.sofia_hash(kwargs.get("password", "")) 108 | ) 109 | self.proto = kwargs.get("proto", "tcp") 110 | self.port = kwargs.get("port", self.PORTS.get(self.proto)) 111 | self.socket = None 112 | self.packet_count = 0 113 | self.session = 0 114 | self.alive_time = 20 115 | self.alive = None 116 | self.alarm = None 117 | self.alarm_func = None 118 | self.busy = threading.Condition() 119 | 120 | def debug(self, format=None): 121 | self.logger.setLevel(logging.DEBUG) 122 | ch = logging.StreamHandler() 123 | if format: 124 | formatter = logging.Formatter(format) 125 | ch.setFormatter(formatter) 126 | self.logger.addHandler(ch) 127 | 128 | def connect(self, timeout=10): 129 | try: 130 | if self.proto == "tcp": 131 | self.socket_send = self.tcp_socket_send 132 | self.socket_recv = self.tcp_socket_recv 133 | self.socket = socket(AF_INET, SOCK_STREAM) 134 | if self.iface: 135 | self.socket.setsockopt( 136 | SOL_SOCKET, 25, str(self.iface + '\0').encode()) 137 | self.socket.connect((self.ip, self.port)) 138 | elif self.proto == "udp": 139 | self.socket_send = self.udp_socket_send 140 | self.socket_recv = self.udp_socket_recv 141 | self.socket = socket(AF_INET, SOCK_DGRAM) 142 | else: 143 | raise f"Unsupported protocol {self.proto}" 144 | 145 | # it's important to extend timeout for upgrade procedure 146 | self.timeout = timeout 147 | self.socket.settimeout(timeout) 148 | except OSError: 149 | raise SomethingIsWrongWithCamera("Cannot connect to camera") 150 | 151 | def close(self): 152 | try: 153 | self.alive.cancel() 154 | self.socket.close() 155 | except: 156 | pass 157 | self.socket = None 158 | 159 | def udp_socket_send(self, bytes): 160 | return self.socket.sendto(bytes, (self.ip, self.port)) 161 | 162 | def udp_socket_recv(self, bytes): 163 | data, _ = self.socket.recvfrom(bytes) 164 | return data 165 | 166 | def tcp_socket_send(self, bytes): 167 | try: 168 | return self.socket.sendall(bytes) 169 | except: 170 | return None 171 | 172 | def tcp_socket_recv(self, bufsize): 173 | try: 174 | return self.socket.recv(bufsize) 175 | except: 176 | return None 177 | 178 | def receive_with_timeout(self, length): 179 | received = 0 180 | buf = bytearray() 181 | start_time = time.time() 182 | 183 | while True: 184 | data = self.socket_recv(length - received) 185 | buf.extend(data) 186 | received += len(data) 187 | if length == received: 188 | break 189 | elapsed_time = time.time() - start_time 190 | if elapsed_time > self.timeout: 191 | return None 192 | return buf 193 | 194 | def receive_json(self, length): 195 | data = self.receive_with_timeout(length) 196 | if data is None: 197 | return {} 198 | 199 | self.packet_count += 1 200 | self.logger.debug("<= %s", data) 201 | try: 202 | reply = json.loads(data[:-2]) 203 | return reply 204 | except: 205 | return data 206 | 207 | def send_custom( 208 | self, msg, data={}, wait_response=True, download=False, version=0 209 | ): 210 | if self.socket is None: 211 | return {"Ret": 101} 212 | # self.busy.wait() 213 | self.busy.acquire() 214 | if hasattr(data, "__iter__"): 215 | if version == 1: 216 | data["SessionID"] = f"{self.session:#0{12}x}" 217 | data = bytes( 218 | json.dumps(data, ensure_ascii=False, separators=(",", ":")), "utf-8" 219 | ) 220 | 221 | tail = b"\x00" 222 | if version == 0: 223 | tail = b"\x0a" + tail 224 | pkt = ( 225 | struct.pack( 226 | "BB2xII2xHI", 227 | 255, 228 | version, 229 | self.session, 230 | self.packet_count, 231 | msg, 232 | len(data) + len(tail), 233 | ) 234 | + data 235 | + tail 236 | ) 237 | self.logger.debug("=> %s", pkt) 238 | self.socket_send(pkt) 239 | if wait_response: 240 | reply = {"Ret": 101} 241 | data = self.socket_recv(20) 242 | if data is None or len(data) < 20: 243 | return None 244 | ( 245 | head, 246 | version, 247 | self.session, 248 | sequence_number, 249 | msgid, 250 | len_data, 251 | ) = struct.unpack("BB2xII2xHI", data) 252 | 253 | reply = None 254 | if download: 255 | reply = self.get_file(len_data) 256 | else: 257 | reply = self.get_specific_size(len_data) 258 | self.busy.release() 259 | return reply 260 | 261 | def send(self, msg, data={}, wait_response=True): 262 | if self.socket is None: 263 | return {"Ret": 101} 264 | # self.busy.wait() 265 | self.busy.acquire() 266 | if hasattr(data, "__iter__"): 267 | data = bytes(json.dumps(data, ensure_ascii=False), "utf-8") 268 | pkt = ( 269 | struct.pack( 270 | "BB2xII2xHI", 271 | 255, 272 | 0, 273 | self.session, 274 | self.packet_count, 275 | msg, 276 | len(data) + 2, 277 | ) 278 | + data 279 | + b"\x0a\x00" 280 | ) 281 | self.logger.debug("=> %s", pkt) 282 | self.socket_send(pkt) 283 | if wait_response: 284 | reply = {"Ret": 101} 285 | data = self.socket_recv(20) 286 | if data is None or len(data) < 20: 287 | return None 288 | ( 289 | head, 290 | version, 291 | self.session, 292 | sequence_number, 293 | msgid, 294 | len_data, 295 | ) = struct.unpack("BB2xII2xHI", data) 296 | reply = self.receive_json(len_data) 297 | self.busy.release() 298 | return reply 299 | 300 | def sofia_hash(self, password=""): 301 | md5 = hashlib.md5(bytes(password, "utf-8")).digest() 302 | chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" 303 | return "".join([chars[sum(x) % 62] for x in zip(md5[::2], md5[1::2])]) 304 | 305 | def login(self): 306 | if self.socket is None: 307 | self.connect() 308 | data = self.send( 309 | 1000, 310 | { 311 | "EncryptType": "MD5", 312 | "LoginType": "DVRIP-Web", 313 | "PassWord": self.hash_pass, 314 | "UserName": self.user, 315 | }, 316 | ) 317 | if data is None or data["Ret"] not in self.OK_CODES: 318 | if data["Ret"] in self.CODES: 319 | print(f'[{data["Ret"]}] {self.CODES[data["Ret"]]}') 320 | return False 321 | self.session = int(data["SessionID"], 16) 322 | self.alive_time = data["AliveInterval"] 323 | self.keep_alive() 324 | if not hasattr(self, 'devtype'): 325 | self.devtype = data["DeviceType "] 326 | return data["Ret"] in self.OK_CODES 327 | 328 | def getAuthorityList(self): 329 | data = self.send(self.QCODES["AuthorityList"]) 330 | if data["Ret"] in self.OK_CODES: 331 | return data["AuthorityList"] 332 | else: 333 | return [] 334 | 335 | def getGroups(self): 336 | data = self.send(self.QCODES["Groups"]) 337 | if data["Ret"] in self.OK_CODES: 338 | return data["Groups"] 339 | else: 340 | return [] 341 | 342 | def addGroup(self, name, comment="", auth=None): 343 | data = self.set_command( 344 | "AddGroup", 345 | { 346 | "Group": { 347 | "AuthorityList": auth or self.getAuthorityList(), 348 | "Memo": comment, 349 | "Name": name, 350 | }, 351 | }, 352 | ) 353 | return data["Ret"] in self.OK_CODES 354 | 355 | def modifyGroup(self, name, newname=None, comment=None, auth=None): 356 | g = [x for x in self.getGroups() if x["Name"] == name] 357 | if g == []: 358 | print(f'Group "{name}" not found!') 359 | return False 360 | g = g[0] 361 | data = self.send( 362 | self.QCODES["ModifyGroup"], 363 | { 364 | "Group": { 365 | "AuthorityList": auth or g["AuthorityList"], 366 | "Memo": comment or g["Memo"], 367 | "Name": newname or g["Name"], 368 | }, 369 | "GroupName": name, 370 | }, 371 | ) 372 | return data["Ret"] in self.OK_CODES 373 | 374 | def delGroup(self, name): 375 | data = self.send( 376 | self.QCODES["DelGroup"], 377 | { 378 | "Name": name, 379 | "SessionID": "0x%08X" % self.session, 380 | }, 381 | ) 382 | return data["Ret"] in self.OK_CODES 383 | 384 | def getUsers(self): 385 | data = self.send(self.QCODES["Users"]) 386 | if data["Ret"] in self.OK_CODES: 387 | return data["Users"] 388 | else: 389 | return [] 390 | 391 | def addUser( 392 | self, name, password, comment="", group="user", auth=None, sharable=True 393 | ): 394 | g = [x for x in self.getGroups() if x["Name"] == group] 395 | if g == []: 396 | print(f'Group "{group}" not found!') 397 | return False 398 | g = g[0] 399 | data = self.set_command( 400 | "User", 401 | { 402 | "AuthorityList": auth or g["AuthorityList"], 403 | "Group": g["Name"], 404 | "Memo": comment, 405 | "Name": name, 406 | "Password": self.sofia_hash(password), 407 | "Reserved": False, 408 | "Sharable": sharable, 409 | }, 410 | ) 411 | return data["Ret"] in self.OK_CODES 412 | 413 | def modifyUser( 414 | self, name, newname=None, comment=None, group=None, auth=None, sharable=None 415 | ): 416 | u = [x for x in self.getUsers() if x["Name"] == name] 417 | if u == []: 418 | print(f'User "{name}" not found!') 419 | return False 420 | u = u[0] 421 | if group: 422 | g = [x for x in self.getGroups() if x["Name"] == group] 423 | if g == []: 424 | print(f'Group "{group}" not found!') 425 | return False 426 | u["AuthorityList"] = g[0]["AuthorityList"] 427 | data = self.send( 428 | self.QCODES["ModifyUser"], 429 | { 430 | "User": { 431 | "AuthorityList": auth or u["AuthorityList"], 432 | "Group": group or u["Group"], 433 | "Memo": comment or u["Memo"], 434 | "Name": newname or u["Name"], 435 | "Password": "", 436 | "Reserved": u["Reserved"], 437 | "Sharable": sharable or u["Sharable"], 438 | }, 439 | "UserName": name, 440 | }, 441 | ) 442 | return data["Ret"] in self.OK_CODES 443 | 444 | def delUser(self, name): 445 | data = self.send( 446 | self.QCODES["DelUser"], 447 | { 448 | "Name": name, 449 | "SessionID": "0x%08X" % self.session, 450 | }, 451 | ) 452 | return data["Ret"] in self.OK_CODES 453 | 454 | def changePasswd(self, newpass="", oldpass=None, user=None): 455 | data = self.send( 456 | self.QCODES["ModifyPassword"], 457 | { 458 | "EncryptType": "MD5", 459 | "NewPassWord": self.sofia_hash(newpass), 460 | "PassWord": oldpass or self.hash_pass, 461 | "SessionID": "0x%08X" % self.session, 462 | "UserName": user or self.user, 463 | }, 464 | ) 465 | return data["Ret"] in self.OK_CODES 466 | 467 | def channel_title(self, titles): 468 | if isinstance(titles, str): 469 | titles = [titles] 470 | self.send( 471 | self.QCODES["ChannelTitle"], 472 | { 473 | "ChannelTitle": titles, 474 | "Name": "ChannelTitle", 475 | "SessionID": "0x%08X" % self.session, 476 | }, 477 | ) 478 | 479 | def channel_bitmap(self, width, height, bitmap): 480 | header = struct.pack("HH12x", width, height) 481 | self.socket_send( 482 | struct.pack( 483 | "BB2xII2xHI", 484 | 255, 485 | 0, 486 | self.session, 487 | self.packet_count, 488 | 0x041A, 489 | len(bitmap) + 16, 490 | ) 491 | + header 492 | + bitmap 493 | ) 494 | reply, rcvd = self.recv_json() 495 | if reply and reply["Ret"] != 100: 496 | return False 497 | return True 498 | 499 | def reboot(self): 500 | self.set_command("OPMachine", {"Action": "Reboot"}) 501 | self.close() 502 | 503 | def setAlarm(self, func): 504 | self.alarm_func = func 505 | 506 | def clearAlarm(self): 507 | self.alarm_func = None 508 | 509 | def alarmStart(self): 510 | self.alarm = threading.Thread( 511 | name="DVRAlarm%08X" % self.session, 512 | target=self.alarm_thread, 513 | args=[self.busy], 514 | ) 515 | res = self.get_command("", self.QCODES["AlarmSet"]) 516 | self.alarm.start() 517 | return res 518 | 519 | def alarm_thread(self, event): 520 | while True: 521 | event.acquire() 522 | try: 523 | ( 524 | head, 525 | version, 526 | session, 527 | sequence_number, 528 | msgid, 529 | len_data, 530 | ) = struct.unpack("BB2xII2xHI", self.socket_recv(20)) 531 | sleep(0.1) # Just for receive whole packet 532 | reply = self.socket_recv(len_data) 533 | self.packet_count += 1 534 | reply = json.loads(reply[:-2]) 535 | if msgid == self.QCODES["AlarmInfo"] and self.session == session: 536 | if self.alarm_func is not None: 537 | self.alarm_func(reply[reply["Name"]], sequence_number) 538 | except: 539 | pass 540 | finally: 541 | event.release() 542 | if self.socket is None: 543 | break 544 | 545 | def set_remote_alarm(self, state): 546 | self.set_command( 547 | "OPNetAlarm", 548 | {"Event": 0, "State": state}, 549 | ) 550 | 551 | def keep_alive(self): 552 | ret = self.send( 553 | self.QCODES["KeepAlive"], 554 | {"Name": "KeepAlive", "SessionID": "0x%08X" % self.session}, 555 | ) 556 | if ret is None: 557 | self.close() 558 | return 559 | self.alive = threading.Timer(self.alive_time, self.keep_alive) 560 | self.alive.daemon = True 561 | self.alive.start() 562 | 563 | def keyDown(self, key): 564 | self.set_command( 565 | "OPNetKeyboard", 566 | {"Status": "KeyDown", "Value": key}, 567 | ) 568 | 569 | def keyUp(self, key): 570 | self.set_command( 571 | "OPNetKeyboard", 572 | {"Status": "KeyUp", "Value": key}, 573 | ) 574 | 575 | def keyPress(self, key): 576 | self.keyDown(key) 577 | sleep(0.3) 578 | self.keyUp(key) 579 | 580 | def keyScript(self, keys): 581 | for k in keys: 582 | if k != " " and k.upper() in self.KEY_CODES: 583 | self.keyPress(self.KEY_CODES[k.upper()]) 584 | else: 585 | sleep(1) 586 | 587 | def ptz(self, cmd, step=5, preset=-1, ch=0): 588 | CMDS = [ 589 | "DirectionUp", 590 | "DirectionDown", 591 | "DirectionLeft", 592 | "DirectionRight", 593 | "DirectionLeftUp", 594 | "DirectionLeftDown", 595 | "DirectionRightUp", 596 | "DirectionRightDown", 597 | "ZoomTile", 598 | "ZoomWide", 599 | "FocusNear", 600 | "FocusFar", 601 | "IrisSmall", 602 | "IrisLarge", 603 | "SetPreset", 604 | "GotoPreset", 605 | "ClearPreset", 606 | "StartTour", 607 | "StopTour", 608 | ] 609 | # ptz_param = { "AUX" : { "Number" : 0, "Status" : "On" }, "Channel" : ch, "MenuOpts" : "Enter", "POINT" : { "bottom" : 0, "left" : 0, "right" : 0, "top" : 0 }, "Pattern" : "SetBegin", "Preset" : -1, "Step" : 5, "Tour" : 0 } 610 | ptz_param = { 611 | "AUX": {"Number": 0, "Status": "On"}, 612 | "Channel": ch, 613 | "MenuOpts": "Enter", 614 | "Pattern": "Start", 615 | "Preset": preset, 616 | "Step": step, 617 | "Tour": 1 if "Tour" in cmd else 0, 618 | } 619 | return self.set_command( 620 | "OPPTZControl", 621 | {"Command": cmd, "Parameter": ptz_param}, 622 | ) 623 | 624 | def set_info(self, command, data): 625 | return self.set_command(command, data, 1040) 626 | 627 | def set_command(self, command, data, code=None): 628 | if not code: 629 | code = self.OPFEED_QCODES.get(command) 630 | if code: 631 | code = code.get("SET") 632 | if not code: 633 | code = self.QCODES[command] 634 | return self.send( 635 | code, {"Name": command, "SessionID": "0x%08X" % self.session, command: data} 636 | ) 637 | 638 | def get_info(self, command): 639 | return self.get_command(command, 1042) 640 | 641 | def get_command(self, command, code=None): 642 | if not code: 643 | code = self.OPFEED_QCODES.get(command) 644 | if code: 645 | code = code.get("GET") 646 | if not code: 647 | code = self.QCODES[command] 648 | 649 | data = self.send(code, {"Name": command, "SessionID": "0x%08X" % self.session}) 650 | 651 | if isinstance(data, (bytes, bytearray)): 652 | data = bytes(b for b in data[:-2] if b >= 32 or b in (9, 10, 13)) 653 | data = json.loads(data) 654 | 655 | if data["Ret"] in self.OK_CODES and command in data: 656 | return data[command] 657 | else: 658 | return data 659 | 660 | def get_time(self): 661 | return datetime.strptime(self.get_command("OPTimeQuery"), self.DATE_FORMAT) 662 | 663 | def set_time(self, time=None): 664 | if time is None: 665 | time = datetime.now() 666 | return self.set_command("OPTimeSetting", time.strftime(self.DATE_FORMAT)) 667 | 668 | def get_netcommon(self): 669 | return self.get_command("NetWork.NetCommon") 670 | 671 | def get_system_info(self): 672 | return self.get_command("SystemInfo") 673 | 674 | def get_general_info(self): 675 | return self.get_command("General") 676 | 677 | def get_encode_capabilities(self): 678 | return self.get_command("EncodeCapability") 679 | 680 | def get_system_capabilities(self): 681 | return self.get_command("SystemFunction") 682 | 683 | def get_camera_info(self, default_config=False): 684 | """Request data for 'Camera' from the target DVRIP device.""" 685 | if default_config: 686 | code = 1044 687 | else: 688 | code = 1042 689 | return self.get_command("Camera", code) 690 | 691 | def get_encode_info(self, default_config=False): 692 | """Request data for 'Simplify.Encode' from the target DVRIP device. 693 | 694 | Arguments: 695 | default_config -- returns the default values for the type if True 696 | """ 697 | if default_config: 698 | code = 1044 699 | else: 700 | code = 1042 701 | return self.get_command("Simplify.Encode", code) 702 | 703 | def recv_json(self, buf=bytearray()): 704 | p = compile(b".*({.*})") 705 | 706 | packet = self.socket_recv(0xFFFF) 707 | if not packet: 708 | return None, buf 709 | buf.extend(packet) 710 | m = p.search(buf) 711 | if m is None: 712 | return None, buf 713 | buf = buf[m.span(1)[1] :] 714 | return json.loads(m.group(1)), buf 715 | 716 | def get_upgrade_info(self): 717 | return self.get_command("OPSystemUpgrade") 718 | 719 | def upgrade(self, filename="", packetsize=0x8000, vprint=None): 720 | if not vprint: 721 | vprint = lambda *args, **kwargs: print(*args, **kwargs) 722 | 723 | data = self.set_command( 724 | "OPSystemUpgrade", {"Action": "Start", "Type": "System"}, 0x5F0 725 | ) 726 | if data["Ret"] not in self.OK_CODES: 727 | return data 728 | 729 | self.logger.debug(f"Sending file: {filename}") 730 | blocknum = 0 731 | sentbytes = 0 732 | fsize = os.stat(filename).st_size 733 | rcvd = bytearray() 734 | with open(filename, "rb") as f: 735 | while True: 736 | bytes = f.read(packetsize) 737 | if not bytes: 738 | break 739 | header = struct.pack( 740 | "BB2xII2xHI", 255, 0, self.session, blocknum, 0x5F2, len(bytes) 741 | ) 742 | self.socket_send(header + bytes) 743 | blocknum += 1 744 | sentbytes += len(bytes) 745 | 746 | reply, rcvd = self.recv_json(rcvd) 747 | if reply and reply["Ret"] != 100: 748 | vprint("\nUpgrade failed") 749 | return reply 750 | 751 | progress = sentbytes / fsize * 100 752 | vprint(f"Uploading: {progress:.1f}%", end='\r') 753 | vprint() 754 | self.logger.debug("Upload complete") 755 | 756 | pkt = struct.pack("BB2xIIxBHI", 255, 0, self.session, blocknum, 1, 0x05F2, 0) 757 | self.socket_send(pkt) 758 | self.logger.debug("Starting upgrade...") 759 | while True: 760 | data, rcvd = self.recv_json(rcvd) 761 | self.logger.debug(reply) 762 | if data is None: 763 | vprint("\nDone") 764 | return 765 | if data["Ret"] in [512, 514, 513]: 766 | vprint("\nUpgrade failed") 767 | return data 768 | if data["Ret"] == 515: 769 | vprint("\nUpgrade successful") 770 | self.socket.close() 771 | return data 772 | vprint(f"Upgrading: {data['Ret']:>3}%", end='\r') 773 | vprint() 774 | 775 | def get_file(self, first_chunk_size): 776 | buf = bytearray() 777 | 778 | data = self.receive_with_timeout(first_chunk_size) 779 | buf.extend(data) 780 | 781 | while True: 782 | header = self.receive_with_timeout(20) 783 | len_data = struct.unpack("I", header[16:])[0] 784 | 785 | if len_data == 0: 786 | return buf 787 | 788 | data = self.receive_with_timeout(len_data) 789 | buf.extend(data) 790 | 791 | def get_specific_size(self, size): 792 | return self.receive_with_timeout(size) 793 | 794 | def reassemble_bin_payload(self, metadata={}): 795 | def internal_to_type(data_type, value): 796 | if data_type == 0x1FC or data_type == 0x1FD: 797 | if value == 1: 798 | return "mpeg4" 799 | elif value == 2: 800 | return "h264" 801 | elif value == 3: 802 | return "h265" 803 | elif data_type == 0x1F9: 804 | if value == 1 or value == 6: 805 | return "info" 806 | elif data_type == 0x1FA: 807 | if value == 0xE: 808 | return "g711a" 809 | elif data_type == 0x1FE and value == 0: 810 | return "jpeg" 811 | return None 812 | 813 | def internal_to_datetime(value): 814 | second = value & 0x3F 815 | minute = (value & 0xFC0) >> 6 816 | hour = (value & 0x1F000) >> 12 817 | day = (value & 0x3E0000) >> 17 818 | month = (value & 0x3C00000) >> 22 819 | year = ((value & 0xFC000000) >> 26) + 2000 820 | return datetime(year, month, day, hour, minute, second) 821 | 822 | length = 0 823 | buf = bytearray() 824 | start_time = time.time() 825 | 826 | while True: 827 | data = self.receive_with_timeout(20) 828 | ( 829 | head, 830 | version, 831 | session, 832 | sequence_number, 833 | total, 834 | cur, 835 | msgid, 836 | len_data, 837 | ) = struct.unpack("BB2xIIBBHI", data) 838 | packet = self.receive_with_timeout(len_data) 839 | frame_len = 0 840 | if length == 0: 841 | media = None 842 | frame_len = 8 843 | (data_type,) = struct.unpack(">I", packet[:4]) 844 | if data_type == 0x1FC or data_type == 0x1FE: 845 | frame_len = 16 846 | ( 847 | media, 848 | metadata["fps"], 849 | w, 850 | h, 851 | dt, 852 | length, 853 | ) = struct.unpack("BBBBII", packet[4:frame_len]) 854 | metadata["width"] = w * 8 855 | metadata["height"] = h * 8 856 | metadata["datetime"] = internal_to_datetime(dt) 857 | if data_type == 0x1FC: 858 | metadata["frame"] = "I" 859 | elif data_type == 0x1FD: 860 | (length,) = struct.unpack("I", packet[4:frame_len]) 861 | metadata["frame"] = "P" 862 | elif data_type == 0x1FA: 863 | (media, samp_rate, length) = struct.unpack( 864 | "BBH", packet[4:frame_len] 865 | ) 866 | elif data_type == 0x1F9: 867 | (media, n, length) = struct.unpack("BBH", packet[4:frame_len]) 868 | # special case of JPEG shapshots 869 | elif data_type == 0xFFD8FFE0: 870 | return packet 871 | else: 872 | raise ValueError(data_type) 873 | if media is not None: 874 | metadata["type"] = internal_to_type(data_type, media) 875 | buf.extend(packet[frame_len:]) 876 | length -= len(packet) - frame_len 877 | if length == 0: 878 | return buf 879 | elapsed_time = time.time() - start_time 880 | if elapsed_time > self.timeout: 881 | return None 882 | 883 | def snapshot(self, channel=0): 884 | command = "OPSNAP" 885 | self.send( 886 | self.QCODES[command], 887 | { 888 | "Name": command, 889 | "SessionID": "0x%08X" % self.session, 890 | command: {"Channel": channel}, 891 | }, 892 | wait_response=False, 893 | ) 894 | packet = self.reassemble_bin_payload() 895 | return packet 896 | 897 | def start_monitor(self, frame_callback, user={}, stream="Main"): 898 | params = { 899 | "Channel": 0, 900 | "CombinMode": "NONE", 901 | "StreamType": stream, 902 | "TransMode": "TCP", 903 | } 904 | data = self.set_command("OPMonitor", {"Action": "Claim", "Parameter": params}) 905 | if data["Ret"] not in self.OK_CODES: 906 | return data 907 | 908 | self.send( 909 | 1410, 910 | { 911 | "Name": "OPMonitor", 912 | "SessionID": "0x%08X" % self.session, 913 | "OPMonitor": {"Action": "Start", "Parameter": params}, 914 | }, 915 | wait_response=False, 916 | ) 917 | self.monitoring = True 918 | while self.monitoring: 919 | meta = {} 920 | frame = self.reassemble_bin_payload(meta) 921 | frame_callback(frame, meta, user) 922 | 923 | def stop_monitor(self): 924 | self.monitoring = False 925 | 926 | def list_local_files(self, startTime, endTime, filetype, channel = 0): 927 | # 1440 OPFileQuery 928 | result = [] 929 | data = self.send( 930 | 1440, 931 | { 932 | "Name": "OPFileQuery", 933 | "OPFileQuery": { 934 | "BeginTime": startTime, 935 | "Channel": channel, 936 | "DriverTypeMask": "0x0000FFFF", 937 | "EndTime": endTime, 938 | "Event": "*", 939 | "StreamType": "0x00000000", 940 | "Type": filetype, 941 | }, 942 | }, 943 | ) 944 | 945 | if data == None: 946 | self.logger.debug("Could not get files.") 947 | raise ConnectionRefusedError("Could not get files") 948 | 949 | # When no file can be found 950 | if data["Ret"] != 100: 951 | self.logger.debug( 952 | f"No files found for channel {channel} for this time range. Start: {startTime}, End: {endTime}" 953 | ) 954 | return [] 955 | 956 | # OPFileQuery only returns the first 64 items 957 | # we therefore need to add the results to a list, modify the starttime with the begintime value of the last item we received and query again 958 | # max number of results are 511 959 | result = data["OPFileQuery"] 960 | 961 | max_event = {"status": "init", "last_num_results": 0} 962 | while max_event["status"] == "init" or max_event["status"] == "limit": 963 | if max_event["status"] == "init": 964 | max_event["status"] = "run" 965 | while len(data["OPFileQuery"]) == 64 or max_event["status"] == "limit": 966 | newStartTime = data["OPFileQuery"][-1]["BeginTime"] 967 | data = self.send( 968 | 1440, 969 | { 970 | "Name": "OPFileQuery", 971 | "OPFileQuery": { 972 | "BeginTime": newStartTime, 973 | "Channel": channel, 974 | "DriverTypeMask": "0x0000FFFF", 975 | "EndTime": endTime, 976 | "Event": "*", 977 | "StreamType": "0x00000000", 978 | "Type": filetype, 979 | }, 980 | }, 981 | ) 982 | result += data["OPFileQuery"] 983 | max_event["status"] = "run" 984 | 985 | if len(result) % 511 == 0 or max_event["status"] == "limit": 986 | self.logger.debug("Max number of events reached...") 987 | if len(result) == max_event["last_num_results"]: 988 | self.logger.debug( 989 | "No new events since last run. All events queried" 990 | ) 991 | return result 992 | 993 | max_event["status"] = "limit" 994 | max_event["last_num_results"] = len(result) 995 | 996 | self.logger.debug(f"Found {len(result)} files.") 997 | return result 998 | 999 | def ptz_step(self, cmd, step=5): 1000 | # To do a single step the first message will just send a tilt command which last forever 1001 | # the second command will stop the tilt movement 1002 | # that means if second message does not arrive for some reason the camera will be keep moving in that direction forever 1003 | 1004 | parms_start = { 1005 | "AUX": {"Number": 0, "Status": "On"}, 1006 | "Channel": 0, 1007 | "MenuOpts": "Enter", 1008 | "POINT": {"bottom": 0, "left": 0, "right": 0, "top": 0}, 1009 | "Pattern": "SetBegin", 1010 | "Preset": 65535, 1011 | "Step": step, 1012 | "Tour": 0, 1013 | } 1014 | 1015 | self.set_command("OPPTZControl", {"Command": cmd, "Parameter": parms_start}) 1016 | 1017 | parms_end = { 1018 | "AUX": {"Number": 0, "Status": "On"}, 1019 | "Channel": 0, 1020 | "MenuOpts": "Enter", 1021 | "POINT": {"bottom": 0, "left": 0, "right": 0, "top": 0}, 1022 | "Pattern": "SetBegin", 1023 | "Preset": -1, 1024 | "Step": step, 1025 | "Tour": 0, 1026 | } 1027 | 1028 | self.set_command("OPPTZControl", {"Command": cmd, "Parameter": parms_end}) 1029 | 1030 | def download_file( 1031 | self, startTime, endTime, filename, targetFilePath, download=True 1032 | ): 1033 | Path(targetFilePath).parent.mkdir(parents=True, exist_ok=True) 1034 | 1035 | self.logger.debug(f"Downloading: {targetFilePath}") 1036 | 1037 | self.send( 1038 | 1424, 1039 | { 1040 | "Name": "OPPlayBack", 1041 | "OPPlayBack": { 1042 | "Action": "Claim", 1043 | "Parameter": { 1044 | "PlayMode": "ByName", 1045 | "FileName": filename, 1046 | "StreamType": 0, 1047 | "Value": 0, 1048 | "TransMode": "TCP", 1049 | # Maybe IntelligentPlayBack is needed in some edge case 1050 | # "IntelligentPlayBackEvent": "", 1051 | # "IntelligentPlayBackSpeed": 2031619, 1052 | }, 1053 | "StartTime": startTime, 1054 | "EndTime": endTime, 1055 | }, 1056 | }, 1057 | ) 1058 | 1059 | actionStart = "Start" 1060 | if download: 1061 | actionStart = f"Download{actionStart}" 1062 | 1063 | data = self.send_custom( 1064 | 1420, 1065 | { 1066 | "Name": "OPPlayBack", 1067 | "OPPlayBack": { 1068 | "Action": actionStart, 1069 | "Parameter": { 1070 | "PlayMode": "ByName", 1071 | "FileName": filename, 1072 | "StreamType": 0, 1073 | "Value": 0, 1074 | "TransMode": "TCP", 1075 | # Maybe IntelligentPlayBack is needed in some edge case 1076 | # "IntelligentPlayBackEvent": "", 1077 | # "IntelligentPlayBackSpeed": 0, 1078 | }, 1079 | "StartTime": startTime, 1080 | "EndTime": endTime, 1081 | }, 1082 | }, 1083 | download=True, 1084 | ) 1085 | 1086 | try: 1087 | with open(targetFilePath, "wb") as bin_data: 1088 | bin_data.write(data) 1089 | except TypeError: 1090 | Path(targetFilePath).unlink(missing_ok=True) 1091 | self.logger.debug(f"An error occured while downloading {targetFilePath}") 1092 | raise 1093 | 1094 | self.logger.debug(f"File successfully downloaded: {targetFilePath}") 1095 | 1096 | actionStop = "Stop" 1097 | if download: 1098 | actionStop = f"Download{actionStop}" 1099 | 1100 | self.send( 1101 | 1420, 1102 | { 1103 | "Name": "OPPlayBack", 1104 | "OPPlayBack": { 1105 | "Action": actionStop, 1106 | "Parameter": { 1107 | "FileName": filename, 1108 | "PlayMode": "ByName", 1109 | "StreamType": 0, 1110 | "TransMode": "TCP", 1111 | "Channel": 0, 1112 | "Value": 0, 1113 | # Maybe IntelligentPlayBack is needed in some edge case 1114 | # "IntelligentPlayBackEvent": "", 1115 | # "IntelligentPlayBackSpeed": 0, 1116 | }, 1117 | "StartTime": startTime, 1118 | "EndTime": endTime, 1119 | }, 1120 | }, 1121 | ) 1122 | return None 1123 | 1124 | def get_channel_titles(self): 1125 | return self.get_command("ChannelTitle", 1048) 1126 | 1127 | def get_channel_statuses(self): 1128 | return self.get_info("NetWork.ChnStatus") 1129 | -------------------------------------------------------------------------------- /examples/socketio/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10-slim-buster 2 | 3 | RUN apt-get update && \ 4 | apt-get upgrade -y && \ 5 | apt-get install -y \ 6 | git \ 7 | curl 8 | 9 | WORKDIR /app 10 | 11 | COPY . . 12 | 13 | RUN pip3 install -r requirements.txt 14 | 15 | EXPOSE 8888 16 | 17 | CMD [ "python3", "./app.py"] 18 | -------------------------------------------------------------------------------- /examples/socketio/README.md: -------------------------------------------------------------------------------- 1 | ### SocketIO example 2 | 3 | Build image 4 | ```bash 5 | docker build -t video-stream . 6 | ``` 7 | 8 | Run container 9 | ```bash 10 | docker run -d \ 11 | --restart always \ 12 | --network host \ 13 | --name video-stream \ 14 | video-stream 15 | ``` 16 | -------------------------------------------------------------------------------- /examples/socketio/app.py: -------------------------------------------------------------------------------- 1 | import socketio 2 | from asyncio_dvrip import DVRIPCam 3 | from aiohttp import web 4 | import asyncio 5 | import signal 6 | import traceback 7 | import base64 8 | 9 | loop = asyncio.get_event_loop() 10 | queue = asyncio.Queue() 11 | 12 | # socket clients 13 | clients = [] 14 | sio = socketio.AsyncServer() 15 | app = web.Application() 16 | sio.attach(app) 17 | 18 | @sio.event 19 | def connect(sid, environ): 20 | print("connect ", sid) 21 | clients.append(sid) 22 | 23 | @sio.event 24 | def my_message(sid, data): 25 | print('message ', data) 26 | 27 | @sio.event 28 | def disconnect(sid): 29 | print('disconnect ', sid) 30 | clients.remove(sid) 31 | 32 | def stop(loop): 33 | loop.remove_signal_handler(signal.SIGTERM) 34 | tasks = asyncio.gather(*asyncio.Task.all_tasks(loop=loop), loop=loop, return_exceptions=True) 35 | tasks.add_done_callback(lambda t: loop.stop()) 36 | tasks.cancel() 37 | 38 | async def stream(loop, queue): 39 | cam = DVRIPCam("192.168.0.100", port=34567, user="admin", password="") 40 | # login 41 | if not await cam.login(loop): 42 | raise Exception("Can't open cam") 43 | 44 | try: 45 | await cam.start_monitor(lambda frame, meta, user: queue.put_nowait(frame), stream="Main") 46 | except Exception as err: 47 | msg = ''.join(traceback.format_tb(err.__traceback__) + [str(err)]) 48 | print(msg) 49 | finally: 50 | cam.stop_monitor() 51 | cam.close() 52 | 53 | async def process(queue, lock): 54 | while True: 55 | frame = await queue.get() 56 | 57 | if frame: 58 | await lock.acquire() 59 | try: 60 | for sid in clients: 61 | await sio.emit('message', {'data': base64.b64encode(frame).decode("utf-8")}, room=sid) 62 | finally: 63 | lock.release() 64 | 65 | async def worker(loop, queue, lock): 66 | task = None 67 | 68 | # infinyty loop 69 | while True: 70 | await lock.acquire() 71 | 72 | try: 73 | # got clients and task not started 74 | if len(clients) > 0 and task is None: 75 | # create stream task 76 | task = loop.create_task(stream(loop, queue)) 77 | 78 | # no more clients, neet stop task 79 | if len(clients) == 0 and task is not None: 80 | # I don't like this way, maybe someone can do it better 81 | task.cancel() 82 | task = None 83 | await asyncio.sleep(0.1) 84 | except Exception as err: 85 | msg = ''.join(traceback.format_tb(err.__traceback__) + [str(err)]) 86 | print(msg) 87 | finally: 88 | lock.release() 89 | 90 | if __name__ == '__main__': 91 | try: 92 | lock = asyncio.Lock() 93 | 94 | # run wb application 95 | runner = web.AppRunner(app) 96 | loop.run_until_complete(runner.setup()) 97 | site = web.TCPSite(runner, host='0.0.0.0', port=8888) 98 | loop.run_until_complete(site.start()) 99 | 100 | # run worker 101 | loop.create_task(worker(loop, queue, lock)) 102 | loop.create_task(process(queue, lock)) 103 | 104 | # wait stop 105 | loop.run_forever() 106 | except: 107 | stop(loop) 108 | -------------------------------------------------------------------------------- /examples/socketio/client.py: -------------------------------------------------------------------------------- 1 | import socketio 2 | 3 | # standard Python 4 | sio = socketio.Client() 5 | 6 | @sio.event 7 | def connect(): 8 | print("I'm connected!") 9 | 10 | @sio.event 11 | def connect_error(): 12 | print("The connection failed!") 13 | 14 | @sio.on('message') 15 | def on_message(data): 16 | print('frame', data) 17 | 18 | sio.connect('http://localhost:8888') -------------------------------------------------------------------------------- /examples/socketio/requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp==3.8.5 2 | aiosignal==1.3.1 3 | async-timeout==4.0.2 4 | asyncio==3.4.3 5 | attrs==22.1.0 6 | bidict==0.22.0 7 | charset-normalizer==2.1.1 8 | frozenlist==1.3.3 9 | idna==3.4 10 | multidict==6.0.2 11 | python-dvr @ git+https://github.com/NeiroNx/python-dvr@06ff6dc0082767e7c9f23401f828533459f783a4 12 | python-engineio==4.3.4 13 | python-socketio==5.7.2 14 | yarl==1.8.1 15 | -------------------------------------------------------------------------------- /images/osd-new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenIPC/python-dvr/51de58c2a7576dfcec5cbe4b5d261414c9ec7328/images/osd-new.png -------------------------------------------------------------------------------- /images/vixand.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenIPC/python-dvr/51de58c2a7576dfcec5cbe4b5d261414c9ec7328/images/vixand.jpg -------------------------------------------------------------------------------- /images/xm.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenIPC/python-dvr/51de58c2a7576dfcec5cbe4b5d261414c9ec7328/images/xm.jpg -------------------------------------------------------------------------------- /monitor.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/python3 2 | from dvrip import DVRIPCam, SomethingIsWrongWithCamera 3 | from signal import signal, SIGINT, SIGTERM 4 | from sys import argv, stdout, exit 5 | from datetime import datetime 6 | from pathlib import Path 7 | from time import sleep, time 8 | import logging 9 | 10 | baseDir = argv[3] 11 | retryIn = 5 12 | rebootWait = 10 13 | camIp = argv[1] 14 | camName = argv[2] 15 | cam = None 16 | isShuttingDown = False 17 | chunkSize = 600 # new file every 10 minutes 18 | logFile = baseDir + '/' + camName + '/log.log' 19 | 20 | def log(str): 21 | logging.info(str) 22 | 23 | def mkpath(): 24 | path = baseDir + '/' + camName + "/" + datetime.today().strftime('%Y/%m/%d/%H.%M.%S') 25 | Path(path).parent.mkdir(parents=True, exist_ok=True) 26 | return path 27 | 28 | def shutDown(): 29 | global isShuttingDown 30 | isShuttingDown = True 31 | log('Shutting down...') 32 | try: 33 | cam.stop_monitor() 34 | close() 35 | except (RuntimeError, TypeError, NameError, Exception): 36 | pass 37 | log('done') 38 | exit(0) 39 | 40 | def handler(signum, b): 41 | log('Signal ' + str(signum) + ' received') 42 | shutDown() 43 | 44 | signal(SIGINT, handler) 45 | signal(SIGTERM, handler) 46 | 47 | def close(): 48 | cam.close() 49 | 50 | def theActualJob(): 51 | 52 | prevtime = 0 53 | video = None 54 | audio = None 55 | 56 | def receiver(frame, meta, user): 57 | nonlocal prevtime, video, audio 58 | if frame is None: 59 | log('Empty frame') 60 | else: 61 | tn = time() 62 | if tn - prevtime >= chunkSize: 63 | if video != None: 64 | video.close() 65 | audio.close() 66 | prevtime = tn 67 | path = mkpath() 68 | log('Starting files: ' + path) 69 | video = open(path + '.video', "wb") 70 | audio = open(path + '.audio', "wb") 71 | if 'type' in meta and meta["type"] == "g711a": audio.write(frame) 72 | elif 'frame' in meta: video.write(frame) 73 | 74 | log('Starting to grab streams...') 75 | cam.start_monitor(receiver) 76 | 77 | def syncTime(): 78 | log('Synching time...') 79 | cam.set_time() 80 | log('done') 81 | 82 | def jobWrapper(): 83 | global cam 84 | log('Logging in to camera ' + camIp + '...') 85 | cam = DVRIPCam(camIp) 86 | if cam.login(): 87 | log('done') 88 | else: 89 | raise SomethingIsWrongWithCamera('Cannot login') 90 | syncTime() 91 | theActualJob() 92 | 93 | def theJob(): 94 | while True: 95 | try: 96 | jobWrapper() 97 | except (TypeError, ValueError) as err: 98 | if isShuttingDown: 99 | exit(0) 100 | else: 101 | try: 102 | log('Error. Attempting to reboot camera...') 103 | cam.reboot() 104 | log('Waiting for ' + str(rebootWait) + 's for reboot...') 105 | sleep(rebootWait) 106 | except (UnicodeDecodeError, ValueError, TypeError): 107 | raise SomethingIsWrongWithCamera('Failed to reboot') 108 | 109 | def main(): 110 | Path(logFile).parent.mkdir(parents=True, exist_ok=True) 111 | logging.basicConfig(filename=logFile, level=logging.INFO, format='[%(asctime)s] %(message)s') 112 | while True: 113 | try: 114 | theJob() 115 | except SomethingIsWrongWithCamera as err: 116 | close() 117 | log(str(err) + '. Waiting for ' + str(retryIn) + ' seconds before trying again...') 118 | sleep(retryIn) 119 | 120 | if __name__ == "__main__": 121 | main() -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | import pathlib 3 | 4 | here = pathlib.Path(__file__).parent.resolve() 5 | 6 | # Get the long description from the README file 7 | long_description = (here / 'README.md').read_text(encoding='utf-8') 8 | 9 | setup( 10 | name='python-dvr', 11 | 12 | version='0.0.0', 13 | 14 | description='Python library for configuring a wide range of IP cameras which use the NETsurveillance ActiveX plugin XMeye SDK', 15 | 16 | long_description=long_description, 17 | long_description_content_type='text/markdown', 18 | 19 | url='https://github.com/NeiroNx/python-dvr/', 20 | 21 | author='NeiroN', 22 | 23 | classifiers=[ 24 | 'Development Status :: 3 - Alpha', 25 | 26 | 'Intended Audience :: Developers', 27 | 'Topic :: Multimedia :: Video :: Capture', 28 | 29 | 'License :: OSI Approved :: MIT License', 30 | 31 | 'Programming Language :: Python :: 3', 32 | 'Programming Language :: Python :: 3.6', 33 | 'Programming Language :: Python :: 3.7', 34 | 'Programming Language :: Python :: 3.8', 35 | 'Programming Language :: Python :: 3.9', 36 | 'Programming Language :: Python :: 3 :: Only', 37 | ], 38 | 39 | py_modules=["dvrip", "DeviceManager", "asyncio_dvrip"], 40 | 41 | python_requires='>=3.6', 42 | 43 | project_urls={ 44 | 'Bug Reports': 'https://github.com/NeiroNx/python-dvr/issues', 45 | 'Source': 'https://github.com/NeiroNx/python-dvr', 46 | }, 47 | ) 48 | -------------------------------------------------------------------------------- /solarcam.py: -------------------------------------------------------------------------------- 1 | from time import sleep 2 | from dvrip import DVRIPCam, SomethingIsWrongWithCamera 3 | from pathlib import Path 4 | import subprocess 5 | import json 6 | from datetime import datetime 7 | 8 | 9 | class SolarCam: 10 | cam = None 11 | logger = None 12 | 13 | def __init__(self, host_ip, user, password, logger): 14 | self.logger = logger 15 | self.cam = DVRIPCam( 16 | host_ip, 17 | user=user, 18 | password=password, 19 | ) 20 | 21 | def login(self, num_retries=10): 22 | for i in range(num_retries): 23 | try: 24 | self.logger.debug("Try login...") 25 | self.cam.login() 26 | self.logger.debug( 27 | f"Success! Connected to Camera. Waiting few seconds to let Camera fully boot..." 28 | ) 29 | # waiting until camera is ready 30 | sleep(10) 31 | return 32 | except SomethingIsWrongWithCamera: 33 | self.logger.debug("Could not connect...Camera could be offline") 34 | self.cam.close() 35 | 36 | if i == 9: 37 | raise ConnectionRefusedError( 38 | f"Could not connect {num_retries} times...aborting" 39 | ) 40 | sleep(2) 41 | 42 | def logout(self): 43 | self.cam.close() 44 | 45 | def get_time(self): 46 | return self.cam.get_time() 47 | 48 | def set_time(self, time=None): 49 | if time is None: 50 | time = datetime.now() 51 | return self.cam.set_time(time=time) 52 | 53 | def get_local_files(self, start, end, filetype): 54 | return self.cam.list_local_files(start, end, filetype) 55 | 56 | def dump_local_files( 57 | self, files, blacklist_path, download_dir, target_filetype=None 58 | ): 59 | with open(f"{blacklist_path}.dmp", "a") as outfile: 60 | for file in files: 61 | target_file_path = self.generateTargetFilePath( 62 | file["FileName"], download_dir 63 | ) 64 | outfile.write(f"{target_file_path}\n") 65 | 66 | if target_filetype: 67 | target_file_path_convert = self.generateTargetFilePath( 68 | file["FileName"], download_dir, extention=f"{target_filetype}" 69 | ) 70 | outfile.write(f"{target_file_path_convert}\n") 71 | 72 | def generateTargetFilePath(self, filename, downloadDir, extention=""): 73 | fileExtention = Path(filename).suffix 74 | filenameSplit = filename.split("/") 75 | filenameDisk = f"{filenameSplit[3]}_{filenameSplit[5][:8]}".replace(".", "-") 76 | targetPathClean = f"{downloadDir}/{filenameDisk}" 77 | 78 | if extention != "": 79 | return f"{targetPathClean}{extention}" 80 | 81 | return f"{targetPathClean}{fileExtention}" 82 | 83 | def convertFile(self, sourceFile, targetFile): 84 | if ( 85 | subprocess.run( 86 | f"ffmpeg -framerate 15 -i {sourceFile} -b:v 1M -c:v libvpx-vp9 -c:a libopus {targetFile}", 87 | stdout=subprocess.DEVNULL, 88 | stderr=subprocess.DEVNULL, 89 | shell=True, 90 | ).returncode 91 | != 0 92 | ): 93 | self.logger.debug(f"Error converting video. Check {sourceFile}") 94 | 95 | self.logger.debug(f"File successfully converted: {targetFile}") 96 | Path(sourceFile).unlink() 97 | self.logger.debug(f"Orginal file successfully deleted: {sourceFile}") 98 | 99 | def save_files(self, download_dir, files, blacklist=None, target_filetype=None): 100 | self.logger.debug(f"Start downloading files") 101 | 102 | for file in files: 103 | target_file_path = self.generateTargetFilePath( 104 | file["FileName"], download_dir 105 | ) 106 | 107 | target_file_path_convert = None 108 | if target_filetype: 109 | target_file_path_convert = self.generateTargetFilePath( 110 | file["FileName"], download_dir, extention=f"{target_filetype}" 111 | ) 112 | 113 | if Path(f"{target_file_path}").is_file(): 114 | self.logger.debug(f"File already exists: {target_file_path}") 115 | continue 116 | 117 | if ( 118 | target_file_path_convert 119 | and Path(f"{target_file_path_convert}").is_file() 120 | ): 121 | self.logger.debug( 122 | f"Converted file already exists: {target_file_path_convert}" 123 | ) 124 | continue 125 | 126 | if blacklist: 127 | if target_file_path in blacklist: 128 | self.logger.debug(f"File is on the blacklist: {target_file_path}") 129 | continue 130 | if target_file_path_convert and target_file_path_convert in blacklist: 131 | self.logger.debug( 132 | f"File is on the blacklist: {target_file_path_convert}" 133 | ) 134 | continue 135 | 136 | self.logger.debug(f"Downloading {target_file_path}...") 137 | self.cam.download_file( 138 | file["BeginTime"], file["EndTime"], file["FileName"], target_file_path 139 | ) 140 | self.logger.debug(f"Finished downloading {target_file_path}...") 141 | 142 | if target_file_path_convert: 143 | self.logger.debug(f"Converting {target_file_path_convert}...") 144 | self.convertFile(target_file_path, target_file_path_convert) 145 | self.logger.debug(f"Finished converting {target_file_path_convert}.") 146 | 147 | self.logger.debug(f"Finish downloading files") 148 | 149 | def move_cam(self, direction, step=5): 150 | match direction: 151 | case "up": 152 | self.cam.ptz_step("DirectionUp", step=step) 153 | case "down": 154 | self.cam.ptz_step("DirectionDown", step=step) 155 | case "left": 156 | self.cam.ptz_step("DirectionLeft", step=step) 157 | case "right": 158 | self.cam.ptz_step("DirectionRight", step=step) 159 | case _: 160 | self.logger.debug(f"No direction found") 161 | 162 | def mute_cam(self): 163 | print( 164 | self.cam.send( 165 | 1040, 166 | { 167 | "fVideo.Volume": [ 168 | {"AudioMode": "Single", "LeftVolume": 0, "RightVolume": 0} 169 | ], 170 | "Name": "fVideo.Volume", 171 | }, 172 | ) 173 | ) 174 | 175 | def set_volume(self, volume): 176 | print( 177 | self.cam.send( 178 | 1040, 179 | { 180 | "fVideo.Volume": [ 181 | { 182 | "AudioMode": "Single", 183 | "LeftVolume": volume, 184 | "RightVolume": volume, 185 | } 186 | ], 187 | "Name": "fVideo.Volume", 188 | }, 189 | ) 190 | ) 191 | 192 | def get_battery(self): 193 | data = self.cam.send_custom( 194 | 1610, 195 | {"Name": "OPTUpData", "OPTUpData": {"UpLoadDataType": 5}}, 196 | size=260, 197 | )[87:-2].decode("utf-8") 198 | json_data = json.loads(data) 199 | return { 200 | "BatteryPercent": json_data["Dev.ElectCapacity"]["percent"], 201 | "Charging": json_data["Dev.ElectCapacity"]["electable"], 202 | } 203 | 204 | def get_storage(self): 205 | # get available storage in gb 206 | storage_result = [] 207 | data = self.cam.send(1020, {"Name": "StorageInfo"}) 208 | for storage_index, storage in enumerate(data["StorageInfo"]): 209 | for partition_index, partition in enumerate(storage["Partition"]): 210 | s = { 211 | "Storage": storage_index, 212 | "Partition": partition_index, 213 | "RemainingSpace": int(partition["RemainSpace"], 0) / 1024, 214 | "TotalSpace": int(partition["TotalSpace"], 0) / 1024, 215 | } 216 | storage_result.append(s) 217 | return storage_result 218 | -------------------------------------------------------------------------------- /telnet_opener.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from dvrip import DVRIPCam 4 | from telnetlib import Telnet 5 | import argparse 6 | import datetime 7 | import json 8 | import os 9 | import socket 10 | import time 11 | import requests 12 | import zipfile 13 | 14 | TELNET_PORT = 4321 15 | ARCHIVE_URL = "https://github.com/widgetii/xmupdates/raw/main/archive" 16 | 17 | """ 18 | Tested on XM boards: 19 | IPG-53H20PL-S 53H20L_S39 00002532 20 | IPG-80H20PS-S 50H20L 00022520 21 | IVG-85HF20PYA-S HI3516EV200_50H20AI_S38 000559A7 22 | IVG-85HG50PYA-S HI3516EV300_85H50AI 000529B2 23 | 24 | Issues with: "armbenv: can't load library 'libdvr.so'" 25 | IPG-50HV20PES-S 50H20L_18EV200_S38 00018520 26 | """ 27 | 28 | # downgrade archive (mainly Yandex.Disk) 29 | # https://www.cctvsp.ru/articles/obnovlenie-proshivok-dlya-ip-kamer-ot-xiong-mai 30 | 31 | XMV4 = { 32 | "envtool": "XmEnv", 33 | "flashes": [ 34 | "0x00EF4017", 35 | "0x00EF4018", 36 | "0x00C22017", 37 | "0x00C22018", 38 | "0x00C22019", 39 | "0x00C84017", 40 | "0x00C84018", 41 | "0x001C7017", 42 | "0x001C7018", 43 | "0x00207017", 44 | "0x00207018", 45 | "0x000B4017", 46 | "0x000B4018", 47 | ], 48 | } 49 | 50 | 51 | def down(template, filename): 52 | t = template.copy() 53 | t['downgrade'] = filename 54 | return t 55 | 56 | 57 | # Borrowed from InstallDesc 58 | conf = { 59 | "000559A7": down(XMV4, "General_IPC_HI3516EV200_50H20AI_S38.Nat.dss.OnvifS.HIK_V5.00.R02.20200507_all.bin"), 60 | "000529B2": down(XMV4, "General_IPC_HI3516EV300_85H50AI_Nat_dss_OnvifS_HIK_V5_00_R02_20200507.bin"), 61 | "000529E9": down(XMV4, "hacked_from_HI3516EV300_85H50AI.bin"), 62 | } 63 | 64 | 65 | def add_flashes(desc, swver): 66 | board = conf.get(swver) 67 | if board is None: 68 | return 69 | 70 | fls = [] 71 | for i in board["flashes"]: 72 | fls.append({"FlashID": i}) 73 | desc["SupportFlashType"] = fls 74 | 75 | 76 | def get_envtool(swver): 77 | board = conf.get(swver) 78 | if board is None: 79 | return "armbenv" 80 | 81 | return board["envtool"] 82 | 83 | 84 | def make_zip(filename, data): 85 | zipf = zipfile.ZipFile(filename, "w", zipfile.ZIP_DEFLATED) 86 | zipf.writestr("InstallDesc", data) 87 | zipf.close() 88 | 89 | 90 | def check_port(host_ip, port): 91 | a_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 92 | result_of_check = a_socket.connect_ex((host_ip, port)) 93 | return result_of_check == 0 94 | 95 | 96 | def extract_gen(swver): 97 | return swver.split(".")[3] 98 | 99 | 100 | def cmd_armebenv(swver): 101 | envtool = get_envtool(swver) 102 | return { 103 | "Command": "Shell", 104 | "Script": f"{envtool} -s xmuart 0; {envtool} -s telnetctrl 1", 105 | } 106 | 107 | 108 | def cmd_telnetd(port): 109 | return { 110 | "Command": "Shell", 111 | "Script": f"busybox telnetd -F -p {port} -l /bin/sh", 112 | } 113 | 114 | 115 | def cmd_backup(): 116 | return [ 117 | { 118 | "Command": "Shell", 119 | "Script": "mount -o nolock 95.217.179.189:/srv/ro /utils/", 120 | }, 121 | {"Command": "Shell", "Script": "/utils/ipctool -w"}, 122 | ] 123 | 124 | 125 | def downgrade_old_version(cam, buildtime, swver): 126 | milestone = datetime.date(2020, 5, 7) 127 | dto = datetime.datetime.strptime(buildtime, "%Y-%m-%d %H:%M:%S") 128 | if dto.date() > milestone: 129 | print( 130 | f"Current firmware date {dto.date()}, but it needs to be no more than" 131 | f" {milestone}\nConsider downgrade and only then continue.\n\n" 132 | ) 133 | a = input("Are you sure to overwrite current firmware without backup (y/n)? ") 134 | if a == "y": 135 | board = conf.get(swver) 136 | if board is None: 137 | print(f"{swver} firmware is not supported yet") 138 | return False 139 | 140 | print("DOWNGRADING\n") 141 | url = f"{ARCHIVE_URL}/{swver}/{board['downgrade']}" 142 | print(f"Downloading {url}") 143 | r = requests.get(url, allow_redirects=True) 144 | if r.status_code != requests.codes.ok: 145 | print("Something went wrong") 146 | return False 147 | 148 | open('upgrade.bin', 'wb').write(r.content) 149 | print(f"Upgrading...") 150 | cam.upgrade('upgrade.bin') 151 | print("Completed. Wait a minute and then rerun") 152 | return False 153 | 154 | return False 155 | return True 156 | 157 | 158 | def open_telnet(host_ip, port, **kwargs): 159 | make_telnet = kwargs.get("telnet", False) 160 | make_backup = kwargs.get("backup", False) 161 | user = kwargs.get("username", "admin") 162 | password = kwargs.get("password", "") 163 | 164 | cam = DVRIPCam(host_ip, user=user, password=password) 165 | if not cam.login(): 166 | print(f"Cannot connect {host_ip}") 167 | return 168 | upinfo = cam.get_upgrade_info() 169 | hw = upinfo["Hardware"] 170 | sysinfo = cam.get_system_info() 171 | swver = extract_gen(sysinfo["SoftWareVersion"]) 172 | print(f"Modifying camera {hw}, firmware {swver}") 173 | if not downgrade_old_version(cam, sysinfo["BuildTime"], swver): 174 | cam.close() 175 | return 176 | 177 | print(f"Firmware generation {swver}") 178 | 179 | desc = { 180 | "Hardware": hw, 181 | "DevID": f"{swver}1001000000000000", 182 | "CompatibleVersion": 2, 183 | "Vendor": "General", 184 | "CRC": "1ce6242100007636", 185 | } 186 | upcmd = [] 187 | if make_telnet: 188 | upcmd.append(cmd_telnetd(port)) 189 | elif make_backup: 190 | upcmd = cmd_backup() 191 | else: 192 | upcmd.append(cmd_armebenv(swver)) 193 | desc["UpgradeCommand"] = upcmd 194 | add_flashes(desc, swver) 195 | 196 | zipfname = "upgrade.bin" 197 | make_zip(zipfname, json.dumps(desc, indent=2)) 198 | cam.upgrade(zipfname) 199 | cam.close() 200 | os.remove(zipfname) 201 | 202 | if make_backup: 203 | print("Check backup") 204 | return 205 | 206 | if not make_telnet: 207 | port = 23 208 | print("Waiting for camera is rebooting...") 209 | 210 | for i in range(10): 211 | time.sleep(4) 212 | if check_port(host_ip, port): 213 | tport = f" {port}" if port != 23 else "" 214 | print(f"Now use 'telnet {host_ip}{tport}' to login") 215 | return 216 | 217 | print("Something went wrong") 218 | return 219 | 220 | 221 | def main(): 222 | parser = argparse.ArgumentParser() 223 | parser.add_argument("hostname", help="Camera IP address or hostname") 224 | parser.add_argument( 225 | "-u", "--username", default="admin", help="Username for camera login" 226 | ) 227 | parser.add_argument( 228 | "-p", "--password", default="", help="Password for camera login" 229 | ) 230 | parser.add_argument( 231 | "-b", "--backup", action="store_true", help="Make backup to the cloud" 232 | ) 233 | parser.add_argument( 234 | "-t", 235 | "--telnet", 236 | action="store_true", 237 | help="Open telnet port without rebooting camera", 238 | ) 239 | args = parser.parse_args() 240 | open_telnet(args.hostname, TELNET_PORT, **vars(args)) 241 | 242 | 243 | if __name__ == "__main__": 244 | main() 245 | --------------------------------------------------------------------------------