├── www ├── css │ ├── index.css │ └── base.css ├── index.html └── js │ ├── rtspProxy.min.js │ ├── rtspProxy.js │ ├── index.js │ └── jquery.min.js ├── LICENSE ├── .gitignore ├── jfNet ├── __init__.py ├── CastSender.py ├── TcpClient.py ├── TcpServer.py ├── CastReceiver.py └── SSDP.py ├── README.md ├── cctv ├── __init__.py ├── agent.py ├── onvifAgent.py └── rtspProxy.py ├── cctvAgent.py └── webSvc.py /www/css/index.css: -------------------------------------------------------------------------------- 1 | .Panels { width: 100%; height: 100%; float: left; } 2 | .FourPanel { 3 | width: 50%; height: calc(50% - 1px); 4 | display: inline-block; border: 1px solid #202020; 5 | text-align: center; vertical-align: middle; 6 | position: relative; 7 | } 8 | .FourPanel::before, .CarBtn::before, .LayoutStyle div::before 9 | { content: ''; width: 0px; height: 100%; display: inline-block; vertical-align: middle; } 10 | .FourPanel img { float: left; min-inline-size: 100%; max-width: 100%; max-height: 100%; } 11 | .BoxingText { color: white; font-size: 24px; text-shadow: -1px 0 black, 0 1px black, 1px 0 black, 0 -1px black; } 12 | .BoxingText.Float { z-index: 99; position: absolute; } 13 | .BoxingText.Left { left: 10px; } 14 | .BoxingText.Right { right: 10px; } 15 | .BoxingText.Top { top: 8px; } 16 | .BoxingText.Bottom { bottom: 5px; } 17 | -------------------------------------------------------------------------------- /www/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | MCS CCTV Page 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 |
21 | 22 | -------------------------------------------------------------------------------- /www/css/base.css: -------------------------------------------------------------------------------- 1 | * { box-sizing: border-box; } 2 | html, body { background-color: black; color: white; width: 100%; height: 100%; font-size: 24px; overflow: hidden; margin: 0px; padding: 0px; font-family: Arial, Helvetica, sans-serif; } 3 | table { display: table; border-collapse: collapse; border-spacing: 0px; border: 0px; } 4 | tr { display: table-row; vertical-align: middle; border-color: inherit; } 5 | td { display: table-cell; vertical-align: middle; padding: 0px; } 6 | video { float: left; max-width: 100%; max-height: 100%; min-width: 100%; min-height: 100%; } 7 | div.vCenter { width:100%; height: 100%; text-align: center; } 8 | div.vCenter::before { content: ''; width: 0px; height: 100%; display: inline-block; vertical-align: middle; } 9 | #divDialog { display: none; } 10 | .ui-dialog .ui-dialog-titlebar { padding: .2em .4em !important; } 11 | .ui-dialog .ui-dialog-buttonpane { padding: 0px !important; } 12 | .ui-widget-overlay { background: black !important ; opacity: 0.5 !important; } 13 | 14 | /* 直式螢幕 */ 15 | @media screen and (orientation:portrait) { } 16 | /* 橫式螢幕 */ 17 | @media screen and (orientation:landscape){ } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Jaofeng Chen(怪頭) 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 | -------------------------------------------------------------------------------- /www/js/rtspProxy.min.js: -------------------------------------------------------------------------------- 1 | /* RTSP Streaming over WebSocket v1.0.0 by Chen Jaofeng */ 2 | "use strict";!function(){var e=/::(\d{1,})::/,o=/~(\d{1,})~/,t=!1,n=[],r={};function s(e){var o=$(e);return n.find(e=>$(e.target).is(o))}r.connectTo=function(c,i,a,u=0,l=0){var f=$(c),d=n.findIndex(e=>$(e.target).is(f));-1!=d&&(void 0!==n[d].socket&&null!=n[d].socket&&n[d].socket.close(),n.splice(d,1)),n.push(function(n,c,i,a,u){var l=s(n);if(void 0!==l)return l;l={socket:null,err:0,packages:0,buffer:[],host:c,target:$(n),rtsp:i,resolution:[a,u]};try{var f=new WebSocket("ws://"+c);l.socket=f,f.onopen=function(e){console.log("WebSocket opened"),f.send(JSON.stringify({act:"open",url:i,resolution:l.resolution}))},f.onmessage=function(t){if(void 0!==t&&void 0!==t.data)try{if(null!=(n=t.data.match(e)))l.packages=parseInt(n[1]),l.buffer=[];else{var n;if(null==(n=t.data.match(o)))return;var r=parseInt(n[1]);l.buffer[r-1]=t.data.substr(n[0].length),r==l.packages&&($(l.target).attr("src",l.buffer.join("")),l.buffer=[])}l.err=0}catch(e){console.log("onmessage error: "+e)}},f.onerror=function(e){},f.onclose=function(e){console.log("WebSocket closed"),l.socket=null,l.err++;var o=l.err>100?5e3:1;t||setTimeout(function(){r.connectTo(n,c,i,a,u)},o)}}catch(e){return console.error(e),null}return l.socket=f,l}(c,i,a,u,l))},r.stop=function(){t=!0,n.forEach(e=>e.socket.close()),n.splice(0,n.length)},r.find=s,r.resize=function(e,o,t){var n=s(e);if(void 0!==n&&(n.resolution=[o,t],null!=n.socket))try{n.socket.send(JSON.stringify({act:"resize",resolution:n.resolution}))}catch(e){console.error(e)}},window.rtspProxy=r}(),$(window).on("beforeunload",function(){rtspProxy.stop()}); -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | -------------------------------------------------------------------------------- /jfNet/__init__.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # -*- coding: UTF-8 -*- 3 | 4 | ######################################### 5 | # Custom Socket Server and Client 6 | # Author : Jaofeng Chen 7 | ######################################### 8 | 9 | import socket 10 | from enum import Enum, unique 11 | from typing import List 12 | 13 | __all__ = [ 14 | 'EventTypes', 'SocketError', 'getLocalIPs' 15 | ] 16 | # jfSocket.SocketError 錯誤代碼清單 17 | errcode: dict = { 18 | 1000: '連線已存在', 19 | 1001: '遠端連線已斷開,或尚未連線', 20 | 1002: '位址已存在', 21 | 1003: '位址不存在', 22 | 1004: '多播(Multicast)位址不正確,應為 224.0.0.0 ~ 239.255.255.255', 23 | 1005: '[多播]此位址已在使用中,請使用 reuseAddr 與 reusePort', 24 | } 25 | 26 | 27 | @unique 28 | class EventTypes(Enum): 29 | """ 30 | 事件代碼列舉 31 | 提供 `jfSocket` 所有類別回呼用事件的鍵值 32 | """ 33 | CONNECTED = 'onConnected' 34 | DISCONNECT = 'onDisconnect' 35 | RECEIVED = 'onReceived' 36 | SENDED = 'onSended' 37 | SENDFAIL = 'onSendFail' 38 | STARTED = 'onStarted' 39 | STOPED = 'onStoped' 40 | JOINED_GROUP = 'onJoinedGroup' 41 | LOGGING = 'onLogging' 42 | 43 | 44 | class SocketError(Exception): 45 | """ 46 | 自訂錯誤類別 47 | 傳入參數: 48 | `errno` `int` - 錯誤代碼 49 | 具名參數: 50 | `err` `Exception` - 內部引發的錯誤 51 | """ 52 | 53 | def __init__(self, errno, err=None): 54 | self.errno = errno 55 | self.innererr = err 56 | 57 | def __str__(self): 58 | return f'[ErrNo:{self.errno}] {self.message}' 59 | 60 | @property 61 | def message(self) -> str: 62 | """ 63 | 錯誤說明文字 64 | 回傳: `str` 65 | """ 66 | return errcode[self.errno] 67 | 68 | 69 | def getLocalIPs() -> List[str]: 70 | _, _, ips = socket.gethostbyname_ex(socket.gethostname()) 71 | return ips 72 | -------------------------------------------------------------------------------- /jfNet/CastSender.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # # -*- coding: UTF-8 -*- 3 | 4 | import struct, socket 5 | from typing import Optional 6 | from . import EventTypes, SocketError 7 | 8 | 9 | class CastSender: 10 | """建立一個發送 Multicast 多播的連線類別 11 | 傳入參數: 12 | `evts` `dict{str:def,...}` -- 回呼事件定義,預設為 `None` 13 | """ 14 | def __init__(self, ttl: int = 4): 15 | self.__events: dict = { 16 | EventTypes.SENDED: None, 17 | EventTypes.SENDFAIL: None 18 | } 19 | self.__socket: socket.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) 20 | self.__socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, struct.pack('b', ttl)) 21 | 22 | def bind(self, key:str, evt=None): 23 | """綁定回呼(callback)函式 24 | 傳入參數: 25 | `key` `str` -- 回呼事件代碼;為避免錯誤,建議使用 *EventTypes* 列舉值 26 | `evt` `def` -- 回呼(callback)函式 27 | 引發錯誤: 28 | `KeyError` -- 回呼事件代碼錯誤 29 | `TypeError` -- 型別錯誤,必須為可呼叫執行的函式 30 | """ 31 | if key not in self.__events: 32 | raise KeyError(f'key:\'{key}\' not found!') 33 | if evt is not None and not callable(evt): 34 | raise TypeError(f'evt:\'{evt}\' is not a function!') 35 | self.__events[key] = evt 36 | 37 | def send(self, remote:tuple, data, waitback: bool = False) -> Optional[bytes]: 38 | """發送資料至多播位址 39 | 傳入參數: 40 | `remote` `tuple(ip, port)` -- 多播位址 41 | `data` `str or bytearray` -- 欲傳送的資料 42 | `waitback` `bool` -- 是否等待遠端回覆資料,最長等待 1 秒 43 | 引發錯誤: 44 | `jfSocket.SocketError` -- 多播位址不正確 45 | `Exception` -- 回呼的錯誤函式 46 | """ 47 | v = socket.inet_aton(remote[0])[0] 48 | if isinstance(v, str): 49 | v = ord(v) 50 | if v not in range(224, 240): 51 | raise SocketError(1004) 52 | ba = None 53 | if isinstance(data, str): 54 | data = data.encode('utf-8') 55 | ba = bytearray(data) 56 | elif isinstance(data, bytearray) or isinstance(data, bytes): 57 | ba = data[:] 58 | try: 59 | self.__socket.sendto(ba, (remote[0], int(remote[1]))) 60 | except Exception as e: 61 | if self.__events[EventTypes.SENDFAIL]: 62 | self.__events[EventTypes.SENDFAIL](self, ba, remote, e) 63 | else: 64 | if self.__events[EventTypes.SENDED]: 65 | self.__events[EventTypes.SENDED](self, ba, remote) 66 | if waitback: 67 | sto = self.__socket.gettimeout() 68 | self.__socket.settimeout(1) 69 | try: 70 | rec, addr = self.__socket.recvfrom(1024) 71 | except socket.timeout: 72 | pass 73 | else: 74 | if rec and len(rec) != 0: 75 | return rec 76 | else: 77 | return None 78 | finally: 79 | self.__socket.settimeout(sto) 80 | -------------------------------------------------------------------------------- /www/js/rtspProxy.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | (function () { 4 | 'use strict'; 5 | var reHead = /::(\d{1,})::/, 6 | reCont = /~(\d{1,})~/, 7 | isExit = false, 8 | clients = []; 9 | var _ = {}; 10 | 11 | function _stop() { 12 | isExit = true; 13 | clients.forEach(clt => clt.socket.close()); 14 | clients.splice(0, clients.length); 15 | } 16 | function _find(target) { 17 | var img = $(target); 18 | return clients.find(clt => $(clt.target).is(img)); 19 | } 20 | function _connect(target, host, rtsp, width, height) { 21 | var clt = _find(target); 22 | if (typeof clt != 'undefined') 23 | return clt; 24 | clt = { 25 | socket: null, 26 | err: 0, 27 | packages: 0, 28 | buffer: [], 29 | host: host, 30 | target: $(target), 31 | rtsp: rtsp, 32 | resolution: [width, height] 33 | }; 34 | try { 35 | var ws = new WebSocket('ws://' + host); 36 | clt.socket = ws; 37 | ws.onopen = function (event) { 38 | console.log('WebSocket opened'); 39 | ws.send(JSON.stringify({ 40 | 'act': 'open', 41 | 'url': rtsp, 42 | 'resolution': clt.resolution 43 | })); 44 | }; 45 | ws.onmessage = function (event) { 46 | if (typeof event == 'undefined' || typeof event.data == 'undefined') 47 | return; 48 | try { 49 | var tmp = event.data.match(reHead) 50 | if (tmp != null) { 51 | clt.packages = parseInt(tmp[1]); 52 | clt.buffer = [] 53 | } else { 54 | var tmp = event.data.match(reCont); 55 | if (tmp == null) 56 | return 57 | var idx = parseInt(tmp[1]) 58 | clt.buffer[idx - 1] = event.data.substr(tmp[0].length); 59 | if (idx == clt.packages) { 60 | $(clt.target).attr('src', clt.buffer.join('')); 61 | clt.buffer = [] 62 | } 63 | } 64 | clt.err = 0; 65 | } catch (ex) { 66 | console.log('onmessage error: ' + ex); 67 | } 68 | }; 69 | ws.onerror = function (event) { 70 | }; 71 | ws.onclose = function (event) { 72 | console.log('WebSocket closed'); 73 | clt.socket = null; 74 | clt.err++; 75 | var wait = (clt.err > 100) ? 5000 : 1; 76 | if (isExit) return; 77 | setTimeout(function () { 78 | _.connectTo(target, host, rtsp, width, height); 79 | }, wait) 80 | }; 81 | } catch (ex) { 82 | console.error(ex); 83 | return null; 84 | } 85 | clt.socket = ws; 86 | return clt; 87 | } 88 | _.connectTo = function (target, host, rtsp, width = 0, height = 0) { 89 | var img = $(target); 90 | var idx = clients.findIndex(clt => $(clt.target).is(img)); 91 | if (idx != -1) { 92 | if (typeof clients[idx].socket != 'undefined' && clients[idx].socket != null) 93 | clients[idx].socket.close(); 94 | clients.splice(idx, 1); 95 | } 96 | clients.push(_connect(target, host, rtsp, width, height)); 97 | } 98 | _.stop = _stop; 99 | _.find = _find; 100 | _.resize = function (target, width, height) { 101 | var clt = _find(target); 102 | if (typeof clt == 'undefined') 103 | return; 104 | clt.resolution = [width, height]; 105 | if (clt.socket != null) { 106 | try { 107 | clt.socket.send(JSON.stringify({ 108 | 'act': 'resize', 109 | 'resolution': clt.resolution 110 | })); 111 | } catch (ex) { 112 | console.error(ex); 113 | } 114 | } 115 | } 116 | 117 | window.rtspProxy = _; 118 | return _; 119 | })(); 120 | 121 | $(window).on('beforeunload', function () { 122 | rtspProxy.stop(); 123 | }); 124 | 125 | -------------------------------------------------------------------------------- /www/js/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var cctv = { 4 | ProxyHost: 'localhost:8001', 5 | Panels: [ 6 | { 'ID': 'A-1', 'OSD': 'OSD 顯示', 'resolution':[640, 480], 'Type': 'ws' }, 7 | { 'ID': 'A-1', 'OSD': 'OSD 顯示', 'resolution':[640, 480], 'Type': 'mjpeg' }, 8 | { 'ID': 'A-1', 'OSD': 'OSD 顯示', 'Type': 'ws' }, 9 | { 'ID': 'A-1', 'OSD': 'OSD 顯示', 'Type': 'mjpeg' } 10 | ], 11 | Stream: [ 12 | { 'ID': 'A-1', 'IP': '172.18.0.87', 'Url': 'rtsp://172.18.0.87/onvif-media/media.amp?streamprofile=Profile2&audio=0' }, 13 | ], 14 | } 15 | var selectedCar = 1, 16 | selectedPanels = 1, 17 | usePanelStyle = 'OnePanel'; 18 | 19 | $(document).ready(function () { 20 | setPanels(cctv.Panels); 21 | useRtspProxy(); 22 | useHttpMJpegPuller(); 23 | }); 24 | 25 | function setPanels(panels) { 26 | var canvas = $('.Panels'); 27 | if (panels.length >= 2 && panels.length <= 4) { 28 | selectedPanels = 4; 29 | usePanelStyle = 'FourPanel'; 30 | } else if (panels.length >= 5 && panels.length <= 9) { 31 | selectedPanels = 9; 32 | usePanelStyle = 'NicePanel'; 33 | } else { 34 | selectedPanels = 1; 35 | usePanelStyle = 'OnePanel'; 36 | } 37 | for (var i = 0; i < selectedPanels; i++) { 38 | var div = $('
').addClass(usePanelStyle); 39 | div.resize(function () { 40 | var lab = $(this).find('label'); 41 | if (typeof lab != 'undefined') 42 | lab.text($(this).width() + ',' + $(this).height()); 43 | }) 44 | div.appendTo(canvas); 45 | div.attr({ 'id': 'dCam-' + (i + 1), 'data-no': i }); 46 | var pan = panels[i]; 47 | if (typeof pan != 'undefined' && pan != null) { 48 | var player = $(''); 49 | player.attr({ 'id': 'view-' + (i + 1), 'data-id': pan['ID'], 'data-type': pan['Type'] }) 50 | .addClass('VideoFrame') 51 | .appendTo(div); 52 | if (typeof pan.resolution != 'undefined' && pan.resolution.length == 2) 53 | player.attr({'data-resolution': pan.resolution.join('x')}) 54 | var info = cctv.Stream.find(item => item.ID == pan['ID']) 55 | if (typeof info != 'undefined') { 56 | player.attr({ 'data-rtsp': info.Url }); 57 | $('