├── 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 | $('').attr({ 'id': 'osdLT-' + (i + 1) })
58 | .addClass('BoxingText Float Left Top')
59 | .text(info.ID)
60 | .appendTo(div);
61 | $('').attr({ 'id': 'osdRT-' + (i + 1) })
62 | .addClass('BoxingText Float Right Top')
63 | .css({ 'color': 'magenta', 'font-size': '16px' })
64 | .appendTo(div);
65 | if (typeof pan.OSD != 'undefined' && pan.OSD.length != 0) {
66 | $('').attr({ 'id': 'osdRB-' + (i + 1) })
67 | .addClass('BoxingText Float Right Bottom')
68 | .css({ 'color': 'yellow', 'font-size': '16px' })
69 | .text(pan.OSD)
70 | .appendTo(div);
71 | }
72 | }
73 | } else {
74 | div.text('無設定影像來源');
75 | }
76 | }
77 | }
78 |
79 | function useRtspProxy() {
80 | // WebSocket Streaming
81 | if (typeof rtspProxy == 'undefined') {
82 | console.error('Not Import "rtsyProxy.js"');
83 | return;
84 | }
85 | $('img.VideoFrame[data-Type="ws"]').each(function () {
86 | var player = $(this);
87 | if (typeof player.attr('data-rtsp') == 'undefined' || player.attr('data-rtsp').length == 0)
88 | return;
89 | var rtsp = player.attr('data-rtsp');
90 | var resolution = player.attr('data-resolution');
91 | if (typeof resolution != 'undefined' && resolution.length != 0) {
92 | var wh = resolution.split('x');
93 | rtspProxy.connectTo(player, cctv.ProxyHost, rtsp, parseInt(wh[0]), parseInt(wh[1]));
94 | } else {
95 | rtspProxy.connectTo(player, cctv.ProxyHost, rtsp);
96 | }
97 | var no = parseInt(player.attr('id').split('-')[1]);
98 | var txt = 'WebSocket, '
99 | var resolution = player.attr('data-resolution')
100 | if (typeof resolution != 'undefined')
101 | txt += resolution;
102 | else
103 | txt += 'Default';
104 | player.parent().find('label[id="osdRT-' + no + '"]').html(txt);
105 | });
106 | }
107 |
108 | function useHttpMJpegPuller() {
109 | // HTTP M-Jpeg Streaming
110 | $('img.VideoFrame[data-Type="mjpeg"]').each(function () {
111 | var player = $(this);
112 | var resolution = player.attr('data-resolution');
113 | var url = '/live/' + player.attr('data-id')
114 | if (typeof resolution != 'undefined' && resolution.length != 0)
115 | url += '?size=' + resolution;
116 | player.attr({ 'src': url });
117 | var no = parseInt(player.attr('id').split('-')[1]);
118 | var txt = 'M-Jpeg, '
119 | var resolution = player.attr('data-resolution')
120 | if (typeof resolution != 'undefined')
121 | txt += resolution;
122 | else
123 | txt += 'Default';
124 | player.parent().find('label[id="osdRT-' + no + '"]').html(txt);
125 | });
126 | }
127 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # RTSP Over HTTP Solution
2 |
3 | ## *前言*
4 | 因工作上的需要,需要將 IP Cam 即時影像顯示在終端設備上,作為監控螢幕使用。
5 |
6 | 一般的作法是直接利用 NVR 廠商提供的 SDK/Player 進行顯示播放,但廠商提供的 SDK/Player 是 Windows base 的,而終端設備卻是 Linux 且 GUI 為 Web base,不得不自行從 IP Cam 接取 RTSP 影像串流。
7 |
8 | 雖然網路上有很多資源可以幾乎不花心力就可以做到所需功能,但還是想研究一下何謂 `SSPD`、`ONVIF`、`RTSP`、`Streaming` 等等相關知識,所以才會有這個專案。
9 |
10 | 本人非影像專業工程師,如有謬論或錯誤,懇請各位先進不吝告知
11 |
12 | ## *第三方模組*
13 | * WebSocket 使用 [websocket_server](https://github.com/Pithikos/python-websocket-server)
14 | `pip install git+https://github.com/Pithikos/python-websocket-server`
15 | * WS-Discovery 使用 [wsdiscovery](https://github.com/andreikop/python-ws-discovery)
16 | `pip install WSDiscovery`
17 | * ONVIF 相關功能使用 [python-onvif](http://github.com/rambo/python-onvif)
18 | `pip install onvif-py3`
19 | * RTSP 串流擷取使用 [OpenCV](https://github.com/skvark/opencv-python)
20 | `pip install opencv-python`
21 |
22 | ## *專案目錄結構*
23 | .
24 | ├─ cctv
25 | │ ├─ __init__.py
26 | │ ├─ agent.py
27 | │ ├─ onvifAgent.py
28 | │ └─ rtspProxy.py
29 | ├─ jfNet
30 | │ ├─ __init__.py
31 | │ ├─ CastReceiver.py
32 | │ ├─ CastSender.py
33 | │ ├─ SSDP.py
34 | │ ├─ TcpClient.py
35 | │ └─ TcpServer.py
36 | ├─ www
37 | │ ├─ css
38 | │ │ ├─ base.css
39 | │ │ └─ index.css
40 | │ ├─ js
41 | │ │ ├─ index.js
42 | │ │ ├─ jquery.min.js
43 | │ │ ├─ rtspProxy.js
44 | │ │ ├─ rtspProxy.min.js
45 | │ │ └─ index.css
46 | │ └─ index.html
47 | ├─ cctvAgent.py
48 | └─ webSvc.py
49 |
50 | ### *檔案說明*
51 | * cctv 目錄是本專案主要模組,其中包含:
52 | * agent.py
53 | 負責處理 IP Cam 探索,使用 `UPnP/SSDP` 與 `WS-Discovery` 兩種技術,如果不需要主動搜尋 IP Cam,可不使用此模組
54 | * onvifAgent.py
55 | `ONVIF` 協定相關資料取得,譬如 IP Cam 的 `Profile`、`串流網址`、`解析度`、`編碼模式`等
56 | * rtspProxy.py
57 | 使用 `OpenCV` 讀取 `RTSP` 串流,再以 `WebSocket` 或 `Motion JPEG(M-Jpeg) over HTTP` 串流輸出
58 | * www 是 HTML 網頁目錄
59 | * cctvAgent.py
60 | 程式進入點,執行後可使用 `help` 檢視可使用的指令
61 | * webSvc.py
62 | 繼承自 `BaseHTTPRequestHandler` 的 HTTP Web Server 模組
63 | * jfNet 模組是本人另一專案, 請參閱 [SocketTest](https://github.com/Jaofeng/SocketTest)
64 |
65 |
66 | ## *原理說明*
67 | RTSP over HTTP 的原理其實很簡單:
68 |
69 | 當終端要求取得影像時,讀取當下的影像(影格)並轉換成圖檔傳送終端
70 |
71 | 而 `rtspProxy.py` 使用 `OpenCV` 向 `RTSP` 來源拉流(讀取當下影像),再將讀取的影像經壓縮或調整後,使用 `WebSocket` 或原來的 `HTTP GET Connection` 送至終端瀏覽器,再經由 HTML `img` tag 顯示
72 |
73 | 以下將以三部分說明:
74 |
75 | ### *影像擷取*
76 | 1. 使用 `OpenCV` 的 `VideoCapture()` 函式開啟 RTSP 串流
77 | 2. 依參數進行解析度調整、圖像品質以 `OpenCV` 進行壓縮、放大
78 |
79 | ### *WebSocket* 傳輸方式
80 | 1. 使用 `python-websocket-server` 作為 `WebSocket` 伺服器
81 | 2. 終端使用 `rtstProxy(.min).js` 連線至伺服器,連線後,發送請求資料給伺服器
82 | 3. 伺服器在接取終端連線,並取得請求的資料後,開始使用 `OpenCV` 自以 `VideoCapture()` 函式所建立的 `camera` 物件中讀取影像(影格)
83 | 4. 取得影格後,調整解析度、品質後,再轉換成 JPEG 圖檔內容
84 | 5. 將 JPEG 圖檔內容轉換成 `Base64` 字串(*Bytes to Base64,原始大小如 30KBytes,會擴張成 40KBytes,請參閱[維基百科](https://zh.wikipedia.org/wiki/Base64)*)
85 | 6. 以 `32KBytes` 為一單位,切割字串內容
86 | 7. 傳送給 ***請求同一個 RTSP 的終端 JavaScript***
87 | 8. 各終端的 JavaScript 組合這些字串內容後,直接指給 `img.src`
88 |
89 | 開發過程中發現傳輸時,常常因為網路品質不佳等原因,容易產生終端(JavaScript WebSocket)解析封包長度錯誤,而造成畫面無法顯示、卡頓、斷線等狀況。
90 |
91 | 目前暫未研究的錯誤發生原因是因為 `python-websocket-server` 的問題,還是其他問題,所以目前的暫時的解法是:
92 | ***傳給使用者前,先把 `Base64` 字串以 `32KBytes` 為單位進行切割,到 JavaScript 後,再將之組合,最後指給 `img.src` 顯示***
93 |
94 | ### *M-JPEG 傳輸方式*
95 | 1. 伺服器取得終端的 `img.src` HTTP GET 請求後,先於 `HTTP Header` 中回應 `Content-Type: multipart/x-mixed-replace;boundary={自訂字串}`
96 | 2. 再自 `camera` 取得影格,並依傳入的 URL 參數,調整解析度、品質後,再轉換成 JPEG 圖檔內容
97 | 3. 於 `HTTP Header` 加上 `Content-Type: image/jpeg`、`Content-Length` 與 `boundary` 後,直接將 JPEG 圖像內容以 `Bytes` 方式傳送至終端
98 | 4. 瀏覽器會自動以 `boundary` 拆解圖像內容後餵給 `img`
99 |
100 |
101 | ## *使用說明*
102 | * IP Cam 的 IP 位址與 `Profile ID`,請於 `cctvAgent.py` 與 `index.js` 中設定,或請自行修改成讀取參數檔的方式載入
103 | * 使用 `WebSocket` 傳輸串流時,需搭配 `rtspProxy(.min).js` 使用
104 | * 如需將 `WebSocket` 串流方式提供給非本機連線,請自行將 `index.js` 內的 `cctv.ProxyHost` 修改成本機 IP
105 | * `rtspProxy(.min).js` 與 M-Jpeg 的使用方式,請參閱 `index.js` 內的 **`useRtspProxy()`** 與 **`useHttpMJpegPuller()`** 兩函式
106 | * 終端顯示順暢與否、是否會延遲,取決於原始 RTSP 串流解析度、網路品質、終端顯示解析度等等
107 | * 經在同一封閉網路的不負責實測:satisfied:,對終端設備負載較輕的方式是 M-Jpeg
108 | * 測試環境設備:
109 | * Server : MacBook Pro 13" / Mojave 10.14.5
110 | * Client : tBOX810-838-FL / Debian 10, Kernel 14.2
111 | * 測試方式:
112 | * 終端同時開 4 分割畫面,對伺服器請求同一個 IP Cam 影像
113 | * 測試結果:
114 | * 以終端顯示的 CPU Usage 而言,WebSocket 高於 M-Jpeg 約 `1.2 倍`
115 | * 以記憶體用量而言,兩者差不多
116 | * 以網路流量而言,WebSocket 高於 M-Jpeg 約 `1.4 倍`
117 |
118 |
119 | ## *參考資料*
120 | * 串流知識
121 | * HTML5 視頻直播(一)~(三)
122 | https://imququ.com/post/html5-live-player-1.html
123 | https://imququ.com/post/html5-live-player-2.html
124 | https://imququ.com/post/html5-live-player-3.html
125 | * WebSocket Streaming
126 | * 基于WebSocket的网页(JS)与服务器(Python)数据交互
127 | https://zhaoxuhui.top/blog/2018/05/05/WebSocket&Client&Server.html
128 | * html通過websocket與python播放rtsp視訊
129 | https://www.itread01.com/content/1547446926.html
130 | * Motion JPEG over HTTP
131 | * [stack overflow] How to parse mjpeg http stream from ip camera?
132 | https://stackoverflow.com/questions/21702477/how-to-parse-mjpeg-http-stream-from-ip-camera
133 | * C#开源实现MJPEG流传输
134 | https://www.cnblogs.com/gaochundong/p/csharp_mjpeg_streaming.html
135 | * OpenCV
136 | * OpenCV-Python Tutorials
137 | https://opencv-python-tutroals.readthedocs.io/en/latest/py_tutorials/py_tutorials.html
138 | * Python 與 OpenCV 基本讀取、顯示與儲存圖片教學
139 | https://blog.gtwang.org/programming/opencv-basic-image-read-and-write-tutorial/
--------------------------------------------------------------------------------
/cctv/__init__.py:
--------------------------------------------------------------------------------
1 | #! /usr/bin/env python3
2 | # -*- coding: UTF-8 -*-
3 |
4 | import re
5 | from io import StringIO
6 | from collections import defaultdict
7 | from xml.etree import ElementTree as ET
8 |
9 | __all__ = ['AttribDict', 'etree2Dict', 'etreeShortTag', 'xml2Dict']
10 |
11 | class AttribDict(dict):
12 | __slots__ = []
13 |
14 | def __init__(self, **fields):
15 | for k, v in fields.items():
16 | if isinstance(v, dict):
17 | self[k] = AttribDict(**v)
18 | elif isinstance(v, list):
19 | self[k] = self.__listDict(v)
20 | else:
21 | self[k] = v
22 | self.__slots__.append(k)
23 |
24 | def __listDict(self, lst):
25 | res = []
26 | for item in lst:
27 | if isinstance(item, list):
28 | res.append(self.__listDict)
29 | elif isinstance(item, dict):
30 | res.append(AttribDict(**item))
31 | else:
32 | res.append(item)
33 | return res
34 |
35 | def __getattr__(self, attr):
36 | if attr in self:
37 | return self[attr]
38 | else:
39 | raise AttributeError
40 |
41 | def __setattr__(self, attr, val):
42 | if attr in self:
43 | self[attr] = val
44 | else:
45 | raise AttributeError
46 |
47 | def clone(self):
48 | return AttribDict(**self)
49 |
50 | # XML Methods
51 | def etree2Dict(t):
52 | '''
53 | 將 xml.etree.Element 節點轉成 dict 型態, 包含屬性與子節點
54 |
55 | 傳入:
56 | `t` `xml.etree.Element` -- 欲轉換的節點
57 |
58 | 傳回:
59 | `dict` -- 轉換完成的 dict 類別
60 |
61 | 範例:
62 | >>> from xml.etree import ElementTree as ET
63 | >>> from io import StringIO
64 | >>> xml = """
65 | ... Text
66 | ... """
67 | >>> doc = ET.parse(StringIO(xml))
68 | >>> root = doc.getroot()
69 | >>> root
70 |
71 | >>> root[0]
72 |
73 | >>> etree2Dict(root)
74 | {'root': {'{http://a.b.c/Nodes}node': {'@{http://a.b.c/Attr}attr1': 'true', '@{http://a.b.c/Attr}attr2': '123', '#text': 'Text'}}}
75 | '''
76 | d = {t.tag: {} if t.attrib else None}
77 | children = list(t)
78 | if children:
79 | dd = defaultdict(list)
80 | for dc in map(etree2Dict, children):
81 | for k, v in dc.items():
82 | dd[k].append(v)
83 | d = {t.tag: {k:v[0] if len(v) == 1 else v for k, v in dd.items()}}
84 | if t.attrib:
85 | d[t.tag].update(('@' + k, v) for k, v in t.attrib.items())
86 | if t.text:
87 | text = t.text.strip()
88 | if children or t.attrib:
89 | if text:
90 | d[t.tag]['#text'] = text
91 | else:
92 | d[t.tag] = text
93 | return d
94 |
95 | def etreeShortTag(xml: str):
96 | '''
97 | 將 XML 格式字串轉換成短標籤格式並傳回 XML NameSpace 清單與 xml.etree.Element 類型
98 |
99 | 傳入:
100 | `xml` `str` -- 原始 XML 字串
101 |
102 | 傳回:
103 | `dict` -- XML NameSpace 內容
104 | `xml.etree.Element` -- XML 根節點
105 |
106 | 範例:
107 | >>> from xml.etree import ElementTree as ET
108 | >>> from io import StringIO
109 | >>> xml = """
110 | ... Text
111 | ... """
112 | >>> doc = ET.parse(StringIO(xml))
113 | >>> doc.getroot()[0].tag
114 | '{http://a.b.c/Nodes}node'
115 | >>> doc.getroot()[0].attrib
116 | {'{http://a.b.c/Attr}attr1': 'true', '{http://a.b.c/Attr}attr2': '123'}
117 | >>> ns, root = etreeShortTag(xml)
118 | >>> root[0].tag
119 | 'n:node'
120 | >>> root[0].attrib
121 | {'a:attr1': 'true', 'a:attr2': '123'}
122 | >>> ns
123 | {'n': 'http://a.b.c/Nodes', 'a': 'http://a.b.c/Attr'}
124 | '''
125 | xmlns = dict([(n[0], n[1]) for _, n in ET.iterparse(StringIO(xml), events=['start-ns'])])
126 | rens = {}
127 | for k, v in xmlns.items():
128 | rens[k] = re.compile(f'\\{{{v}\\}}')
129 | it = ET.iterparse(StringIO(xml))
130 | for _, t in it:
131 | for ns, reg in rens.items():
132 | if reg.match(t.tag):
133 | if ns:
134 | t.tag = reg.sub(f'{ns}:', t.tag)
135 | else:
136 | t.tag = reg.sub('', t.tag)
137 | break
138 | for ns, reg in rens.items():
139 | for k, v in t.attrib.items():
140 | if reg.match(k):
141 | t.attrib[reg.sub(f'{ns}:', k)] = v
142 | del t.attrib[k]
143 | return xmlns, it.root
144 |
145 | def xml2Dict(xml: str):
146 | '''
147 | 將 XML 格式字串轉換成短標籤(Tag)的 dict 類型
148 |
149 | 傳入:
150 | `xml` `str` -- 原始 XML 字串
151 |
152 | 傳回:
153 | `dict` -- dict 結構的 XML 內容
154 |
155 | 範例:
156 | >>> from xml.etree import ElementTree as ET
157 | >>> from io import StringIO
158 | >>> xml = """
159 | ... Text
160 | ... """
161 | >>> xml2Dict(xml)
162 | {'root': {'n:node': {'@a:attr1': 'true', '@a:attr2': '123', '#text': 'Text'}}}
163 | '''
164 | _, root = etreeShortTag(xml)
165 | return etree2Dict(root)
166 |
--------------------------------------------------------------------------------
/jfNet/TcpClient.py:
--------------------------------------------------------------------------------
1 | #! /usr/bin/env python3
2 | # # -*- coding: UTF-8 -*-
3 |
4 | import traceback
5 | import socket
6 | import threading
7 | from . import EventTypes, SocketError
8 |
9 |
10 | class TcpClient:
11 | """用於定義可回呼的 TCP 連線型態的 Socket Client
12 | 具名參數:
13 | `socket` `socket` -- 承接的 Socket 類別,預設為 `None`
14 | `evts` `dict{str:def,...}` -- 回呼事件定義,預設為 `None`
15 | """
16 | _socket:socket.socket = None
17 | _events:dict = {
18 | EventTypes.CONNECTED: None,
19 | EventTypes.DISCONNECT: None,
20 | EventTypes.RECEIVED: None,
21 | EventTypes.SENDED: None,
22 | EventTypes.SENDFAIL: None
23 | }
24 | _handler:threading.Thread = None
25 | _host:tuple = None
26 | _stop:bool = False
27 | _remote:tuple = None
28 | recvBuffer:input = 256
29 |
30 | def __init__(self, socket: socket.socket = None):
31 | if socket and isinstance(socket, socket.socket):
32 | self._assign(socket)
33 |
34 | def __del__(self):
35 | self.close()
36 |
37 | # Public Properties
38 | @property
39 | def isAlive(self) -> bool:
40 | """取得目前是否正處於連線中
41 | 回傳:
42 | `True` / `False`
43 | *True* : 連線中
44 | *False* : 連線已斷開
45 | """
46 | return self._handler and self._handler.isAlive()
47 |
48 | @property
49 | def host(self) -> tuple:
50 | """回傳本端的通訊埠號
51 | 回傳:
52 | `tuple(ip, port)`
53 | """
54 | return self._host
55 |
56 | @property
57 | def remote(self) -> tuple:
58 | """回傳遠端伺服器的通訊埠號
59 | 回傳:
60 | `tuple(ip, port)`
61 | """
62 | return self._remote
63 |
64 | # Public Methods
65 | def connect(self, host:tuple):
66 | """連線至遠端伺服器
67 | 傳入參數:
68 | `host` `tuple(ip, port)` - 遠端伺服器連線位址與通訊埠號
69 | 引發錯誤:
70 | `jfSocket.SocketError` -- 連線已存在
71 | `socket.error' -- 連線時引發的錯誤
72 | `Exception` -- 回呼的錯誤函式
73 | """
74 | assert isinstance(host, tuple) and isinstance(host[0], str) and isinstance(host[1], int),\
75 | 'host must be tuple(str, int) type!!'
76 | if self.isAlive:
77 | raise SocketError(1000)
78 | self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
79 | self._socket.connect(host)
80 | self._assign(self._socket)
81 | if self._events[EventTypes.CONNECTED]:
82 | self._events[EventTypes.CONNECTED](self, self._host, self._remote)
83 |
84 | def bind(self, key=None, evt=None):
85 | """綁定回呼(callback)函式
86 | 傳入參數:
87 | `key` `str` -- 回呼事件代碼;為避免錯誤,建議使用 *EventTypes* 列舉值
88 | `evt` `def` -- 回呼(callback)函式
89 | 引發錯誤:
90 | `KeyError` -- 回呼事件代碼錯誤
91 | `TypeError` -- 型別錯誤,必須為可呼叫執行的函式
92 | """
93 | if key not in self._events:
94 | raise KeyError('key:\'{}\' not found!'.format(key))
95 | if evt is not None and not callable(evt):
96 | raise TypeError('evt:\'{}\' is not a function!'.format(evt))
97 | self._events[key] = evt
98 |
99 | def close(self):
100 | """關閉與遠端伺服器的連線"""
101 | self._stop = True
102 | if self._socket:
103 | self._socket.close()
104 | del self._socket
105 | if self._handler:
106 | self._handler.join(2.5)
107 | del self._handler
108 |
109 | def send(self, data):
110 | """發送資料至遠端伺服器
111 | 傳入參數:
112 | `data` `str` -- 欲傳送到遠端的資料
113 | 引發錯誤:
114 | `jfSocket.SocketError` -- 遠端連線已斷開
115 | `Exception` -- 回呼的錯誤函式
116 | """
117 | if not self.isAlive:
118 | raise SocketError(1001)
119 | try:
120 | self._socket.send(data)
121 | except Exception as e:
122 | if self._events[EventTypes.SENDFAIL]:
123 | self._events[EventTypes.SENDFAIL](self, data, e)
124 | else:
125 | if self._events[EventTypes.SENDED]:
126 | self._events[EventTypes.SENDED](self, data)
127 |
128 | # Private Methods
129 | def _assign(self, socket:socket.socket):
130 | self._socket = socket
131 | self._host = socket.getsockname()
132 | self._remote = socket.getpeername()
133 | self._handler = threading.Thread(target=self._receiverHandler, args=(socket,))
134 | self._stop = False
135 | self._handler.daemon = True
136 | self._handler.start()
137 |
138 | def _receiverHandler(self, client):
139 | # 使用非阻塞方式等待資料,逾時時間為 2 秒
140 | client.settimeout(2)
141 | while not self._stop:
142 | try:
143 | data = client.recv(self.recvBuffer)
144 | except socket.timeout:
145 | # 等待資料逾時,再重新等待
146 | if self._stop:
147 | break
148 | else:
149 | continue
150 | except:
151 | # 先攔截並顯示,待未來確定可能會發生的錯誤再進行處理
152 | print(traceback.format_exc())
153 | break
154 | if not data:
155 | # 空資料,認定遠端已斷線
156 | break
157 | else:
158 | # Received Data
159 | if len(data) == 0:
160 | # 空資料,認定遠端已斷線
161 | break
162 | elif len([x for x in data if ord(x) == 0x04]) == len(data):
163 | # 收到 EOT(End Of Transmission, 傳輸結束),則表示已與遠端中斷連線
164 | break
165 | if self._events[EventTypes.RECEIVED]:
166 | self._events[EventTypes.RECEIVED](self, data)
167 | if self._events[EventTypes.DISCONNECT]:
168 | self._events[EventTypes.DISCONNECT](self, self.host, self.remote)
169 |
--------------------------------------------------------------------------------
/jfNet/TcpServer.py:
--------------------------------------------------------------------------------
1 | #! /usr/bin/env python3
2 | # -*- coding: UTF-8 -*-
3 |
4 | import time
5 | import traceback
6 | import threading
7 | import socket
8 | from . import EventTypes, SocketError
9 | import TcpClient
10 |
11 |
12 | class TcpServer:
13 | """以 TCP 為連線基礎的 Socket Server
14 | `host` : `tuple(ip, Port)` - 提供連線的 IPv4 位址與通訊埠號
15 | """
16 | _host:tuple = None
17 | _socket:socket.socket = None
18 | _acceptThread:threading.Thread = None
19 | _events:dict = {
20 | EventTypes.STARTED: None,
21 | EventTypes.STOPED: None,
22 | EventTypes.CONNECTED: None,
23 | EventTypes.DISCONNECT: None,
24 | EventTypes.RECEIVED: None,
25 | EventTypes.SENDED: None,
26 | EventTypes.SENDFAIL: None
27 | }
28 | _stop:bool = False
29 | _clients:dict = {}
30 | _name:str = ''
31 |
32 | def __init__(self, host:tuple):
33 | self._host = host
34 | self._name = '{}:{}'.format(*(host))
35 | self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
36 |
37 | # Public Properties
38 | @property
39 | def host(self) -> tuple:
40 | """回傳本端提供連線的通訊埠號
41 | 回傳:
42 | `tuple(ip, port)`
43 | """
44 | return self._host
45 |
46 | @property
47 | def isAlive(self) -> bool:
48 | """取得伺服器是否處於等待連線中
49 | 回傳:
50 | `True` / `False`
51 | *True* : 等待連線中
52 | *False* : 停止等待
53 | """
54 | return self._acceptThread and self._acceptThread.isAlive()
55 |
56 | @property
57 | def clients(self) -> dict:
58 | """傳回已連接的連線資訊
59 | 回傳:
60 | `dictionary{ tuple(ip, port) : , ... }`
61 | """
62 | return self._clients.copy()
63 |
64 | # Public Methods
65 | def start(self):
66 | """啟動 TcpServer 伺服器,開始等待遠端連線
67 | 引發錯誤:
68 | `Exception` -- 回呼的錯誤函式
69 | """
70 | try:
71 | self._socket.bind(self._host)
72 | except socket.error as ex:
73 | if ex.errno == 48:
74 | raise SocketError(1005)
75 | else:
76 | raise ex
77 | self._socket.listen(5)
78 | self._acceptThread = threading.Thread(target=self._accept_client)
79 | self._acceptThread.setDaemon(True)
80 | self._acceptThread.start()
81 | now = time.time()
82 | while not self._acceptThread.isAlive and (time.time() - now) <= 1:
83 | time.sleep(0.1)
84 | if self.isAlive and self._events[EventTypes.STARTED]:
85 | self._events[EventTypes.STARTED](self)
86 |
87 | def stop(self):
88 | """停止等待遠端連線
89 | """
90 | self._stop = True
91 | self.close()
92 | self._socket.close()
93 | self._socket = None
94 | if self._acceptThread:
95 | self._acceptThread.join(1.5)
96 |
97 | def bind(self, key:str, evt=None):
98 | """綁定回呼(callback)函式
99 | 具名參數:
100 | `key` `str` -- 回呼事件代碼;為避免錯誤,建議使用 *EventTypes* 列舉值
101 | `evt` `def` -- 回呼(callback)函式
102 | 引發錯誤:
103 | `KeyError` -- 回呼事件代碼錯誤
104 | `TypeError` -- 型別錯誤,必須為可呼叫執行的函式
105 | """
106 | if key not in self._events:
107 | raise KeyError('key:\'{}\' not found!'.format(key))
108 | if evt is not None and not callable(evt):
109 | raise TypeError('evt:\'{}\' is not a function!'.format(evt))
110 | self._events[key] = evt
111 |
112 | def send(self, data, remote=None):
113 | """發送資料至遠端
114 | 傳入參數:
115 | `data` `str` -- 欲傳送到遠端的資料
116 | 具名參數:
117 | `remote` `tuple(ip, port)` -- 欲傳送的遠端連線;未傳入時,則發送給所有連線
118 | 引發錯誤:
119 | `KeyError` -- 遠端連線不存在
120 | `TypeError` -- 遠端連線不存在
121 | `jfSocket.SocketError` -- 遠端連線已斷開
122 | `Exception` -- 其他錯誤
123 | """
124 | if remote:
125 | if remote not in self._clients:
126 | raise KeyError()
127 | elif self._clients[remote] is None:
128 | raise TypeError()
129 | elif not self._clients[remote].isAlive:
130 | raise SocketError(1001)
131 | self._clients[remote].send(data)
132 | else:
133 | for x in self._clients:
134 | self._clients[x].send(data)
135 |
136 | def close(self, remote=None):
137 | """關閉遠端連線
138 | 具名參數:
139 | `remote` `tuple(ip, port)` -- 欲關閉的遠端連線;未傳入時,則關閉所有連線
140 | """
141 | if remote is not None:
142 | if remote not in self._clients:
143 | return
144 | elif self._clients[remote] or not self._clients[remote].isAlive:
145 | del self._clients[remote]
146 | else:
147 | self._clients[remote].close()
148 | else:
149 | for x in self._clients:
150 | if self._clients[x]:
151 | self._clients[x].close()
152 | del self._clients[x]
153 |
154 | # Private Methods
155 | def _onClientDisconnect(self, *args):
156 | if self._clients[args[2]]:
157 | del self._clients[args[2]]
158 | if self._events[EventTypes.DISCONNECT]:
159 | self._events[EventTypes.DISCONNECT](*(args))
160 |
161 | def _accept_client(self):
162 | # 使用非阻塞方式等待連線,逾時時間為 1 秒
163 | self._socket.settimeout(1)
164 | while not self._stop:
165 | try:
166 | client, addr = self._socket.accept()
167 | except socket.timeout:
168 | # 等待連線逾時,再重新等待
169 | continue
170 | except:
171 | # except (socket.error, IOError) as ex:
172 | # 先攔截並顯示,待未來確定可能會發生的錯誤再進行處理
173 | print(traceback.format_exc())
174 | break
175 | if self._stop:
176 | try:
177 | client.close()
178 | except:
179 | pass
180 | break
181 | clk = TcpClient.TcpClient(client)
182 | clk.bind(key=EventTypes.RECEIVED, evt=self._events[EventTypes.RECEIVED])
183 | clk.bind(key=EventTypes.DISCONNECT, evt=self._onClientDisconnect)
184 | clk.bind(key=EventTypes.SENDED, evt=self._events[EventTypes.SENDED])
185 | clk.bind(key=EventTypes.SENDFAIL, evt=self._events[EventTypes.SENDFAIL])
186 | self._clients[addr] = clk
187 | if self._events[EventTypes.CONNECTED] is not None:
188 | self._events[EventTypes.CONNECTED](clk, self._host, addr)
189 | if self._events[EventTypes.STOPED] is not None:
190 | self._events[EventTypes.STOPED](self)
191 |
--------------------------------------------------------------------------------
/cctv/agent.py:
--------------------------------------------------------------------------------
1 | #! /usr/bin/env python3
2 | # -*- coding: UTF-8 -*-
3 |
4 | import time, re, types
5 | from threading import Lock
6 | from typing import Optional, List, Dict
7 | from enum import Enum, unique
8 | from urllib import request, error
9 | from http.client import BadStatusLine
10 | from jfNet import EventTypes
11 | from jfNet.SSDP import SsdpService, SsdpEvents, SsdpInfo
12 | from . import AttribDict, xml2Dict
13 | from .onvifAgent import OnvifAgent
14 |
15 | _epFilter = re.compile(r'(http://)?(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:?\d{0,5})\/?')
16 |
17 |
18 | @unique
19 | class AgentEvents(Enum):
20 | """
21 | 事件代碼列舉
22 | 提供 `CCTV_Worker` 所有類別回呼用事件的鍵值
23 | """
24 | FOUND = 'found'
25 | JOINED = 'joined'
26 | UPDATE = 'undated'
27 | ONLINE = 'online'
28 | OFFLINE = 'offline'
29 |
30 |
31 | class CCTV_Agent:
32 | def __init__(self, ipcams=None, log=None):
33 | '''建立 IP Cam 代理伺服器
34 |
35 | 傳入:
36 | ipcams : list[dict] -- IP Cam 攝影機清單, 其格式如下
37 | [{'ID':str, 'IP':str, 'Profile':str, 'User':str, 'Passwd':str}, ...]
38 | log : 已建立的 logging.logger
39 | '''
40 | self.__events: dict = {
41 | AgentEvents.FOUND: None,
42 | AgentEvents.JOINED: None,
43 | AgentEvents.UPDATE: None,
44 | AgentEvents.ONLINE: None,
45 | AgentEvents.OFFLINE: None,
46 | }
47 | self.__ssdp = SsdpService()
48 | self.__ssdp.bind(EventTypes.STARTED,self.__Started)
49 | self.__ssdp.bind(EventTypes.STOPED, self.__Stoped)
50 | self.__ssdp.bind(SsdpEvents.DEVICE_JOINED, self.__onJoined)
51 | self.__ssdp.bind(SsdpEvents.RECEIVED_BYEBYE, self.__onLeaved)
52 | self.__ssdp.setNotifyFilter(r'upnp_NetworkCamera')
53 | self.__onvif: OnvifAgent = OnvifAgent(ipcams=ipcams, log=log)
54 | self.__devs: List[Dict] = []
55 | self.__locker: Lock = Lock()
56 | if log:
57 | self.log = log
58 | else:
59 | self.log = types.ModuleType('nolog')
60 | nolog = types.MethodType(lambda msg, *args, **kwargs: None, self.log)
61 | self.log.debug = self.log.info = nolog
62 | self.log.warn = self.log.warning = nolog
63 | self.log.error = self.log.exception = nolog
64 |
65 | ipcams: List[Dict] = property(fget=lambda self: self.__devs, doc='''已探索到的 IP Cam 清單,
66 | 傳回''')
67 |
68 | def __getitem__(self, **kwargs) -> Optional[str]:
69 | for k in ['ip', 'name']:
70 | if k in kwargs:
71 | v = kwargs.get(k)
72 | return [ipc for ipc in self.__devs if ipc[k] == v]
73 | return None
74 |
75 | def start(self, search: bool = False):
76 | self.__ssdp.start_listen()
77 | self.__onvif.renewIpCamInfo()
78 | self.__devs = self.__onvif.seenServices
79 | if search: self.discoveryOnvif()
80 |
81 | def stop(self):
82 | self.__ssdp.stop_listen()
83 |
84 | def bind(self, key:str = None, evt=None):
85 | '''綁定回呼(callback)函式
86 | 傳入參數:
87 | `key` `str` -- 回呼事件代碼;為避免錯誤,建議使用 *EventTypes* 列舉值
88 | `evt` `def` -- 回呼(callback)函式
89 | 引發錯誤:
90 | `KeyError` -- 回呼事件代碼錯誤
91 | `TypeError` -- 型別錯誤,必須為可呼叫執行的函式
92 | '''
93 | if key not in self.__events:
94 | raise KeyError(f'key:"{key}" not found!')
95 | if evt is not None and not callable(evt):
96 | raise TypeError('"evt" is not a callable function!')
97 | self.__events[key] = evt
98 |
99 | def findDevices(self, **kwargs):
100 | for k in ['ip']:
101 | if k in kwargs:
102 | v = kwargs.get(k)
103 | return [d for d in self.__devs if d[k] and d[k] == v]
104 | return None
105 |
106 | def discovery(self):
107 | return self.__onvif.discovery()
108 |
109 | def discoveryOnvif(self, byProc=True):
110 | dl = self.__onvif.getOnvifInfoAfterDiscovery()
111 | if not dl:
112 | self.log.debug('Not found any IP Cam')
113 | if not byProc:
114 | print('Not found any IP Cam')
115 | return
116 | for dd in dl:
117 | if 'profiles' not in dd:
118 | continue
119 | d = AttribDict(**dd)
120 | fd = self.findDevices(ip=d.ip)
121 | if not fd or len(fd) == 0:
122 | if self.__events[AgentEvents.FOUND]:
123 | self.__events[AgentEvents.FOUND](d.ip, d.url)
124 | self.__appedIpCam({
125 | 'ip': d.ip, 'svcUrl': d.url, 'name': d.hostName,
126 | 'profiles': d.profiles,
127 | 'joinTime': time.time(), 'lastTime': time.time(),
128 | 'Alive': True
129 | })
130 | else:
131 | fd = fd[0]
132 | fd['lastTime'] = time.time()
133 | fd['alive'] = True
134 | fd['name'] = d.hostName
135 | if not fd['profiles'] or not byProc:
136 | fd['svcUrl'] = d.url
137 | fd['profiles'] = d.profiles
138 | self.log.debug(fd['profiles'])
139 | if self.__events[AgentEvents.UPDATE]:
140 | self.__events[AgentEvents.UPDATE](fd['ip'], fd['profiles'])
141 |
142 | def clear(self):
143 | self.__ssdp.clearDevices()
144 | with self.__locker:
145 | if not self.__devs or len(self.__devs) == 0:
146 | return
147 | d = self.__devs.pop()
148 | while d:
149 | del d
150 | if len(self.__devs) == 0:
151 | break
152 | d = self.__devs.pop()
153 |
154 | def getOnvifInfo(self, url, auths=None):
155 | return self.__onvif.getOnvifInfo(url, auths=auths)
156 |
157 | def __Started(self, svc):
158 | self.log.info('CCTV Monitor Agent Started')
159 |
160 | def __Stoped(self, svc):
161 | self.__devs = []
162 | self.log.warn('CCTV Monitor Agent Stoped!')
163 |
164 | def __onJoined(self, svc, di):
165 | '''收到新設備發送 SSDP NOTIFY 時產生此事件
166 | 傳入:
167 | `svc` `SSDPService` -- SSDPService
168 | `di` `SsdpInfo` -- SsdpInfo{ip:str, maxAge:int, lastTime:time, content=dict(SSDP Content)}
169 | '''
170 | self.log.debug(f'IP Cam @ \x1B[92m{di.ip}\x1B[39m(USN:\x1B[92m{di.content.USN}\x1B[39m) Joined')
171 | fd = self.findDevices(ip=di.ip)
172 | if not fd or len(fd) == 0:
173 | self.__appedIpCam(di)
174 | else:
175 | fd = fd[0]
176 | fd['alive'] = True
177 | fd['lastTime'] = time.time()
178 | if not fd['profiles']:
179 | url = fd['svcUrl']
180 | oi = self.__onvif.getOnvifInfo(url)
181 | if oi:
182 | fd['profiles'] = oi['profiles']
183 | fd['name'] = oi['hostName']
184 | if self.__events[AgentEvents.UPDATE]:
185 | self.__events[AgentEvents.UPDATE](fd['ip'], fd['profiles'])
186 |
187 | def __onLeaved(self, svc, di):
188 | pass
189 |
190 | def __appedIpCam(self, di):
191 | if isinstance(di, SsdpInfo):
192 | domain = di.ip
193 | name = ''
194 | loc = di.content.LOCATION if di.content.LOCATION else None
195 | if loc:
196 | ok, dev = self.__getDevInfoFromSsdp(loc)
197 | if ok:
198 | m = _epFilter.match(dev['presentationURL'])
199 | if m:
200 | domain = m.group(2)
201 | name = dev.get('friendlyName', None)
202 | else:
203 | self.log.warn(dev)
204 | ipc = {
205 | 'ip': di.ip, 'maxAge': di.maxAge, 'joinTime': di.lastTime,
206 | 'name': name, 'lastTime': di.lastTime,
207 | 'svcUrl': f'http://{domain}/onvif/device_service',
208 | 'profiles': None,
209 | 'alive': True
210 | }
211 | elif isinstance(di, dict) or isinstance(di, AttribDict):
212 | ipc = di
213 | else:
214 | return
215 | if ('profiles' not in ipc or ipc['profiles'] is None) and ipc['svcUrl']:
216 | url = ipc['svcUrl']
217 | self.log.debug(f'Get ONVIF info from: {url}')
218 | oi = self.__onvif.getOnvifInfo(ipc['svcUrl'])
219 | if oi: ipc['profiles'] = oi['profiles']
220 | with self.__locker:
221 | if isinstance(ipc, dict):
222 | self.__devs.append(AttribDict(**ipc))
223 | else:
224 | self.__devs.append(ipc)
225 | if self.__events[AgentEvents.JOINED]:
226 | self.__events[AgentEvents.JOINED](ipc)
227 |
228 | def __getDevInfoFromSsdp(self, url: str):
229 | buf = None
230 | self.log.info(f'Get Device information from \x1B[93m{url}\x1B[39m')
231 | try:
232 | req = request.Request(url)
233 | with request.urlopen(req) as res:
234 | buf = res.read()
235 | xml = str(buf, 'iso-8859-1')
236 | d = xml2Dict(xml)
237 | return True, d['root']['device']
238 | except BadStatusLine as ex:
239 | return False, f'\x1B[91mBadStatusLine!\x1B[39m url:\x1B[93m{url}\x1B[39m - \x1B[91m{ex}\x1B[39m'
240 | except error.URLError as ex:
241 | return False, f'\x1B[91mURLError!\x1B[39m url:\x1B[93m{url}\x1B[39m - \x1B[91m{ex}\x1B[39m'
242 | except ConnectionResetError as ex:
243 | return False, f'\x1B[91mConnection Reset!\x1B[39m url:\x1B[93m{url}\x1B[39m - \x1B[91m{ex}\x1B[39m'
244 | except Exception as ex:
245 | return False, f'\x1B[91mException!\x1B[39m url:\x1B[93m{url}\x1B[39m - {type(ex)} - \x1B[91m{ex}\x1B[39m'
246 |
--------------------------------------------------------------------------------
/jfNet/CastReceiver.py:
--------------------------------------------------------------------------------
1 | #! /usr/bin/env python3
2 | # # -*- coding: UTF-8 -*-
3 |
4 | import time, sys, errno, struct, threading, socket
5 | from . import EventTypes, SocketError
6 |
7 |
8 | class CastReceiver:
9 | '''建立多播監聽器(Multicast)類別
10 | 傳入參數:
11 | `port` `int` -- 欲監聽的通訊埠號
12 | '''
13 | def __init__(self, host):
14 | if isinstance(host, int):
15 | self.__host: tuple = ('', host)
16 | elif isinstance(host, tuple):
17 | self.__host: tuple = host
18 | self.__events: dict = {
19 | EventTypes.STARTED: None,
20 | EventTypes.STOPED: None,
21 | EventTypes.RECEIVED: None,
22 | EventTypes.JOINED_GROUP: None,
23 | EventTypes.SENDED: None,
24 | EventTypes.SENDFAIL: None
25 | }
26 | self.__socket: socket.socket = None
27 | self.__groups: list = []
28 | self.__stop = False
29 | self.__receiveHandler: threading.Thread = None
30 | self.__reuseAddr = True
31 | self.__reusePort = False
32 | self.recvBuffer = 256
33 |
34 | # Public Properties
35 | @property
36 | def groups(self) -> list:
37 | '''取得已註冊監聽的群組IP
38 | 回傳: `list(str, ...)` -- 已註冊的IP
39 | '''
40 | return self.__groups[:]
41 |
42 | @property
43 | def host(self) -> tuple:
44 | '''回傳本端的通訊埠號
45 | 回傳: `tuple(ip, port)`
46 | '''
47 | return self.__host
48 |
49 | @property
50 | def isAlive(self) -> bool:
51 | '''取得多播監聽器是否處於監聽中
52 | 回傳: `boolean`
53 | *True* : 等待連線中
54 | *False* : 停止等待
55 | '''
56 | return self.__receiveHandler and self.__receiveHandler.is_alive()
57 |
58 | @property
59 | def reuseAddr(self) -> bool:
60 | '''取得是否可重複使用 IP 位置
61 | 回傳: `boolean`
62 | *True* : 可重複使用
63 | *False* : 不可重複使用
64 | '''
65 | return self.__reuseAddr
66 |
67 | @reuseAddr.setter
68 | def reuseAddr(self, value: bool):
69 | '''設定是否可重複使用 IP 位置
70 | '''
71 | if not isinstance(value, bool):
72 | raise TypeError()
73 | self.__reuseAddr = value
74 | if self.__socket:
75 | self.__socket.setsockopt(
76 | socket.SOL_SOCKET, socket.SO_REUSEADDR, 1 if self.__reuseAddr else 0
77 | )
78 |
79 | @property
80 | def reusePort(self) -> bool:
81 | '''取得是否可重複使用通訊埠位
82 | 回傳: `boolean`
83 | *True* : 可重複使用
84 | *False* : 不可重複使用
85 | '''
86 | return self.__reusePort
87 |
88 | @reusePort.setter
89 | def reusePort(self, value:bool):
90 | '''設定是否可重複使用通訊埠位
91 | '''
92 | if not isinstance(value, bool):
93 | raise TypeError()
94 | self.__reusePort = value
95 | if self.__socket and not sys.platform.startswith('win'):
96 | self.__socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1 if self.__reusePort else 0)
97 |
98 | # Public Methods
99 | def start(self):
100 | '''啟動多播監聽伺服器
101 | 引發錯誤:
102 | `socket.error` -- 監聽 IP 設定錯誤
103 | `Exception` -- 回呼的錯誤函式
104 | '''
105 | self.__socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
106 | # self._socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, struct.pack('b', 32))
107 | self.__socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1 if self.__reuseAddr else 0)
108 | if not sys.platform.startswith('win'):
109 | self.__socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1 if self.__reusePort else 0)
110 | try:
111 | self.__socket.bind(self.__host)
112 | except socket.error as ex:
113 | if ex.errno == 48:
114 | raise SocketError(1005)
115 | else:
116 | raise ex
117 | self.__receiveHandler = threading.Thread(target=self.__receive_handler)
118 | self.__receiveHandler.setDaemon(True)
119 | self.__receiveHandler.start()
120 | now = time.time()
121 | while not self.__receiveHandler.isAlive and (time.time() - now) <= 1:
122 | time.sleep(0.1)
123 | for x in self.__groups:
124 | self.__doAddMembership(x)
125 | if self.isAlive and self.__events[EventTypes.STARTED]:
126 | self.__events[EventTypes.STARTED](self)
127 |
128 | def stop(self):
129 | '''停止監聽
130 | '''
131 | self.__stop = True
132 | if self.__socket:
133 | for x in self.__groups:
134 | self.__doDropMembership(x)
135 | self.__socket.close()
136 | self.__socket = None
137 | if self.__receiveHandler is not None:
138 | self.__receiveHandler.join(1)
139 | self.__receiveHandler = None
140 |
141 | def joinGroup(self, ips:list):
142 | '''加入監聽IP
143 | 傳入參數:
144 | `ips` `list(str, ...)` -- 欲監聽的 IP 陣列 list
145 | 引發錯誤:
146 | `SocketError` -- 監聽的 IP 錯誤或該 IP 已在監聽中
147 | `socket.error` -- 無法設定監聽 IP
148 | '''
149 | for x in ips:
150 | v = socket.inet_aton(x)[0]
151 | if isinstance(v, str):
152 | v = ord(v)
153 | if v not in range(224, 240):
154 | raise SocketError(1004)
155 | if x in self.__groups:
156 | raise SocketError(1002)
157 | self.__groups.append(x)
158 | if self.__socket:
159 | self.__doAddMembership(x)
160 |
161 | def dropGroup(self, ips:list):
162 | '''移除監聽清單中的 IP
163 | `注意`:如在監聽中移除IP,需重新啟動
164 | 傳入參數:
165 | `ips` `list(str, ...)` -- 欲移除監聽的 IP 陣列 list
166 | 引發錯誤:
167 | `SocketError` -- 欲移除的 IP 錯誤或該 IP 不存在
168 | '''
169 | for x in ips:
170 | v = socket.inet_aton(x)[0]
171 | if isinstance(v, str):
172 | v = ord(v)
173 | if v not in range(224, 240):
174 | raise SocketError(1004)
175 | if x not in self.__groups:
176 | raise SocketError(1003)
177 | self.__groups.remove(x)
178 | if self.__socket:
179 | self.__doDropMembership(x)
180 |
181 | def bind(self, key:str = None, evt=None):
182 | '''綁定回呼(callback)函式
183 | 傳入參數:
184 | `key` `str` -- 回呼事件代碼;為避免錯誤,建議使用 *EventTypes* 列舉值
185 | `evt` `def` -- 回呼(callback)函式
186 | 引發錯誤:
187 | `KeyError` -- 回呼事件代碼錯誤
188 | `TypeError` -- 型別錯誤,必須為可呼叫執行的函式
189 | '''
190 | if key not in self.__events:
191 | raise KeyError('key:"{}" not found!'.format(key))
192 | if evt is not None and not callable(evt):
193 | raise TypeError('evt:"{}" is not a function!'.format(evt))
194 | self.__events[key] = evt
195 |
196 | def send(self, remote:tuple, data):
197 | """以 UDP 方式發送資料
198 | 傳入參數:
199 | `remote` `tuple(ip, port)` -- 遠端位址
200 | `data` `str or bytearray` -- 欲傳送的資料
201 | 引發錯誤:
202 | `jfSocket.SocketError` -- 遠端連線已斷開
203 | `Exception` -- 回呼的錯誤函式
204 | """
205 | ba = None
206 | if isinstance(data, str):
207 | data = data.encode('utf-8')
208 | ba = bytearray(data)
209 | elif isinstance(data, bytearray) or isinstance(data, bytes):
210 | ba = data[:]
211 | try:
212 | self.__socket.sendto(ba, (remote[0], int(remote[1])))
213 | except Exception as e:
214 | if self.__events[EventTypes.SENDFAIL]:
215 | self.__events[EventTypes.SENDFAIL](self, ba, remote, e)
216 | else:
217 | if self.__events[EventTypes.SENDED]:
218 | self.__events[EventTypes.SENDED](self, ba, remote)
219 |
220 | # Private Methods
221 | def __receive_handler(self):
222 | # 使用非阻塞方式等待資料,逾時時間為 2 秒
223 | self.__socket.settimeout(0.5)
224 | while not self.__stop:
225 | try:
226 | data, addr = self.__socket.recvfrom(self.recvBuffer)
227 | except socket.timeout:
228 | # 等待資料逾時,再重新等待
229 | if self.__stop:
230 | break
231 | except OSError:
232 | break
233 | except Exception:
234 | # 先攔截並顯示,待未來確定可能會發生的錯誤再進行處理
235 | import traceback
236 | print(traceback.format_exc())
237 | break
238 | else:
239 | if data and len(data) != 0:
240 | # Received Data
241 | if self.__events[EventTypes.RECEIVED]:
242 | self.__events[EventTypes.RECEIVED](self, data, self.__socket.getsockname(), addr)
243 | if self.__events[EventTypes.STOPED]:
244 | self.__events[EventTypes.STOPED](self)
245 |
246 | def __doAddMembership(self, ip):
247 | try:
248 | mreq = struct.pack('4sL', socket.inet_aton(ip), socket.INADDR_ANY)
249 | self.__socket.setsockopt(
250 | socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq
251 | )
252 | except socket.error as err:
253 | if err.errno == errno.EADDRINUSE:
254 | # print(' -> In Use')
255 | pass
256 | else:
257 | # print(' -> error({})'.format(err.errno))
258 | raise
259 | else:
260 | if self.__events[EventTypes.JOINED_GROUP]:
261 | self.__events[EventTypes.JOINED_GROUP](self, ip)
262 |
263 | def __doDropMembership(self, ip):
264 | try:
265 | mreq = struct.pack('4sL', socket.inet_aton(ip), socket.INADDR_ANY)
266 | self.__socket.setsockopt(
267 | socket.IPPROTO_IP, socket.IP_DROP_MEMBERSHIP, mreq
268 | )
269 | except socket.error as err:
270 | if err.errno == errno.EADDRNOTAVAIL:
271 | # print(' -> Not In Use')
272 | pass
273 | else:
274 | # print(' -> error({})'.format(err.errno))
275 | raise
276 |
--------------------------------------------------------------------------------
/cctv/onvifAgent.py:
--------------------------------------------------------------------------------
1 | #! /usr/bin/env python3
2 | # -*- coding: UTF-8 -*-
3 |
4 | import threading, types
5 | from queue import Queue
6 | from wsdiscovery import WSDiscovery, QName
7 | from onvif import ONVIFCamera, ONVIFError
8 | from urllib.parse import urlparse
9 |
10 | ONVIF_TYPE_NVT = QName('http://www.onvif.org/ver10/network/wsdl', 'NetworkVideoTransmitter')
11 | DEF_AUTHS = [('', ''), ('admin', ''), ('admin', 'admin')]
12 |
13 | class OnvifAgent(object):
14 | def __init__(self, ipcams: list = None, log=None):
15 | '''CCTV 代理模組
16 |
17 | 傳入:
18 | ipcams : dict -- IP Cam 資料清單, 每一項目應至少包含以下資料
19 | {'IP':str, 'Port':int, 'Profile':str, 'User':str, 'Passwd':str}
20 | IP -- [必要] IP Cam ONVIF 使用的 IP 位址
21 | Port -- [非必要] IP Cam ONVIF 使用的通訊埠號, 預設值為 80
22 | Profile -- [必要] 使用的 Profile
23 | User -- [非必要] 認證帳號
24 | Passwd -- [非必要] 認證密碼
25 | log : logging.Logger
26 | '''
27 | if ipcams:
28 | self.ipcams = list(ipcams)
29 | else:
30 | self.ipcams = []
31 | for ipc in self.ipcams:
32 | ipc['Port'] = ipc.get('Port', 80)
33 | ipc['SvcUrl'] = 'http://{}{}/onvif/device_service'.format(
34 | ipc["IP"], f':{ipc["Port"]}' if ipc['Port'] and ipc['Port'] != 80 else '')
35 | self.__started = False
36 | self.__seenSvcs = []
37 | self.__camInfo = []
38 | if log:
39 | self.log = log
40 | else:
41 | self.log = types.ModuleType('nolog')
42 | nolog = types.MethodType(lambda msg, *args, **kwargs: None, self.log)
43 | self.log.debug = self.log.info = nolog
44 | self.log.warn = self.log.warning = nolog
45 | self.log.error = self.log.exception = nolog
46 |
47 | def discovery(self, timeout=3):
48 | '''以 WS-Discovery 方式探索 IP Cam
49 | 傳入:
50 | timeout : int -- 等待逾時時間, 單位秒, 預設 5 秒
51 | 傳回:
52 | list(str) -- 搜尋到的 IP Cam 的 ONVIF 服務網址清單
53 | '''
54 | svcs = []
55 | try:
56 | wsd = WSDiscovery()
57 | wsd.start()
58 | services = wsd.searchServices(types=[ONVIF_TYPE_NVT], timeout=timeout)
59 | except Exception as ex:
60 | self.log.error(f'WS-Discovery Error:{ex}')
61 | return svcs
62 | else:
63 | for service in services:
64 | url = service.getXAddrs()[0]
65 | if not list(filter(lambda s: s == url, svcs)):
66 | svcs.append(url)
67 | return svcs
68 | finally:
69 | wsd.stop()
70 |
71 | def getOnvifInfo(self, url, auths=None, queue: Queue = None):
72 | '''以 ONVIF 服務的網址、帳號與密碼, 取得 IP Cam 的 ONVIF 資料
73 |
74 | 傳入:
75 | url : str -- IP Cam ONVIF 服務的網址
76 | auths : list(tuple) -- 以 (帳號,密碼) 為 Tuple 的 List 清單
77 | queue : Queue -- 用以多執行緒時回傳取得的資料用
78 | 當未傳入此值時, 將直接回傳(return)
79 | 其回傳值型態如同傳回值
80 | 傳回:
81 | dict -- ONVIF 資料, 格式如下:
82 | {
83 | 'url':str, 'ip:str, 'port':int, 'hostName':str,
84 | 'user':str, 'pwd':str,
85 | 'source': {'name':str, 'resolution': {'width':int, 'height':int}}
86 | 'profiles': [
87 | {
88 | 'name':str, 'encoding':str,
89 | 'resolution': {'width':int, 'height':int},
90 | 'quality':int, 'frames':int, 'url':str,
91 | 'useit':bool
92 | }
93 | ]
94 | }
95 | '''
96 | if not url: return None
97 | parsed = urlparse(url)
98 | if parsed.scheme != 'http':
99 | self.log.warn(f'"url"({url}) is not a ONVIF Service URL!')
100 | return None
101 | parts = parsed.netloc.split('@')[-1].split(':')
102 | ip = parts[0]
103 | port = parts[1] if len(parts) > 1 else 80
104 | authed = False
105 | self.log.debug(f'Get ONVIF information from \x1B[92m{url}\x1B[39m')
106 | res = {'url': url, 'ip': ip, 'port': port, 'hostName': ''}
107 | if not auths and not parsed.username:
108 | auths = DEF_AUTHS
109 | elif parsed.username:
110 | auths = [(parsed.username, parsed.password)]
111 | for authinfo in auths:
112 | if authed: break
113 | # Try get Camera Info
114 | try:
115 | mycam = ONVIFCamera(ip, port, authinfo[0], authinfo[1])
116 | except:
117 | continue
118 | # Get Host Name
119 | authed, hostName = self.__getHostName(mycam)
120 | if not authed: continue
121 | res['hostName'] = hostName
122 | res['user'] = authinfo[0]
123 | res['pwd'] = authinfo[1]
124 | # Get Profiles
125 | try:
126 | svc = mycam.create_media_service()
127 | profiles = svc.GetProfiles()
128 | vsc = svc.GetVideoSourceConfigurations()
129 | if vsc and len(vsc) != 0:
130 | res['source'] = {
131 | 'name': vsc[0].Name,
132 | 'resolution': {'width': vsc[0].Bounds.width, 'height': vsc[0].Bounds.height}
133 | }
134 | except:
135 | continue
136 | res['profiles'] = []
137 | for pf in profiles:
138 | vec = pf.VideoEncoderConfiguration
139 | rs = vec.Resolution
140 | d = {
141 | 'name': pf.Name,
142 | 'encoding': vec.Encoding,
143 | 'resolution': {'width': rs.Width, 'height': rs.Height},
144 | 'quality': vec.Quality,
145 | 'frames': vec.RateControl.FrameRateLimit,
146 | 'useit': False
147 | }
148 | try:
149 | params = svc.create_type('GetStreamUri')
150 | params.ProfileToken = pf.token
151 | params.StreamSetup = {'Stream': 'RTP-Unicast', 'Transport': {'Protocol': 'RTSP'}}
152 | resp = svc.GetStreamUri(params)
153 | d['url'] = resp.Uri
154 | except:
155 | pass
156 | res['profiles'].append(d)
157 | if authed:
158 | self.log.debug(res)
159 | if queue:
160 | queue.put(res)
161 | else:
162 | return res
163 | else:
164 | return None
165 |
166 | def renewIpCamInfo(self):
167 | '''以初始化傳入的清單為基準, 更新 IP Cam 的相關資料, 後續可由 `seenServices` 屬性值取得'''
168 | if not self.ipcams:
169 | return
170 | _queue = Queue()
171 | thds = []
172 | for ipc in self.ipcams:
173 | thd = threading.Thread(target=self.getOnvifInfo,
174 | args=(ipc['SvcUrl'],
175 | [(ipc.get('User', ''), ipc.get('Passwd', '')),],
176 | _queue))
177 | thd.setDaemon(True)
178 | thds.append(thd)
179 | thd.start()
180 | for thd in thds:
181 | thd.join(5)
182 | while not _queue.empty():
183 | rd = _queue.get()
184 | ipcs = [ipc for ipc in self.ipcams if ipc['IP'] == rd['ip'] and ipc['Port'] == rd['port']]
185 | rd['id'] = ipcs[0]['ID'] if ipcs else ''
186 | for pf in rd['profiles']:
187 | pf['useit'] = (pf['name'] == ipcs[0]['Profile'])
188 | lf = list(filter(lambda d: d['url'] == rd['url'], self.__seenSvcs))
189 | if not lf:
190 | self.__seenSvcs.append(rd)
191 | print(f'\x1B[93m[#]\x1B[39m Append service: \x1B[92m{rd["url"]}\x1B[39m')
192 | elif lf[0] != rd:
193 | self.__seenSvcs.remove(lf[0])
194 | print(f'\x1B[93m[#]\x1B[39m Remove service: \x1B[92m{lf[0]["url"]}\x1B[39m')
195 | self.__seenSvcs.append(rd)
196 | print(f'\x1B[93m[#]\x1B[39m Append service: \x1B[92m{rd["url"]}\x1B[39m')
197 | else:
198 | print(f'\x1B[93m[#]\x1B[39m Service exists: \x1B[92m{rd["url"]}\x1B[39m')
199 |
200 | def getOnvifInfoAfterDiscovery(self):
201 | '''以 WS-Discovery 搜尋 IP Cam 後, 一併取得其 IP Cam 的 ONVIF 相關資訊, 同時更新至 `seenServices` 屬性值中
202 |
203 | 傳回:
204 | list(dict) -- 搜尋到的所有 IP Cam 的 ONVIF 資料, 格式如下:
205 | [
206 | {
207 | 'url':str, 'ip:str, 'port':int, 'hostName':str, 'id':str,
208 | 'user':str, 'pwd':str,
209 | 'source': {'name':str, 'resolution': {'width':int, 'height':int}}
210 | 'profiles': [
211 | {
212 | 'name':str, 'encoding':str,
213 | 'resolution': {'width':int, 'height':int},
214 | 'quality':int, 'frames':int, 'url':str,
215 | 'useit':bool
216 | }, ...
217 | ]
218 | }, ...
219 | ]
220 | '''
221 | svcs = self.discovery()
222 | if not svcs: return None
223 | _queue = Queue()
224 | thds = []
225 | for url in svcs:
226 | thd = threading.Thread(target=self.getOnvifInfo, args=(url, DEF_AUTHS, _queue))
227 | thd.setDaemon(True)
228 | thd.start()
229 | thds.append(thd)
230 | for thd in thds:
231 | thd.join()
232 | res = []
233 | while not _queue.empty():
234 | rd = _queue.get()
235 | ipcs = [ipc for ipc in self.ipcams if ipc['SvcUrl'] == rd['url']]
236 | if ipcs:
237 | rd['id'] = ipcs[0]['ID']
238 | for pf in rd['profiles']:
239 | pf['useit'] = (pf['name'] == ipcs[0]['Profile'])
240 | res.append(rd)
241 | lf = list(filter(lambda d: d['url'] == rd['url'], self.__seenSvcs))
242 | if not lf:
243 | self.__seenSvcs.append(rd)
244 | elif lf[0] != rd:
245 | self.__seenSvcs.remove(lf[0])
246 | self.__seenSvcs.append(rd)
247 | return res
248 |
249 | def __getHostName(self, mycam):
250 | # Host Name
251 | _auth = False
252 | _name = None
253 | try:
254 | resp = mycam.devicemgmt.GetHostname()
255 | if resp.Name:
256 | _name = str(resp.Name)
257 | _auth = True
258 | except ONVIFError as e:
259 | if 'not Authorized' in e.reason:
260 | _auth = False
261 | return _auth, _name
262 |
263 | isStarted = property(fget=lambda self: self.__started)
264 |
265 | @property
266 | def seenServices(self):
267 | '''傳回已取得 ONVIF 的資料清單
268 |
269 | 傳回:
270 | list(dict) -- 資料清單內容, 資料格式為:
271 | [
272 | {
273 | 'url':str, 'ip:str, 'port':int, 'hostName':str, 'id':str,
274 | 'user':str, 'pwd':str,
275 | 'source': {'name':str, 'resolution': {'width':int, 'height':int}}
276 | 'profiles': [
277 | {
278 | 'name':str, 'encoding':str,
279 | 'resolution': {'width':int, 'height':int},
280 | 'quality':int, 'frames':int, 'url':str,
281 | 'useit':bool
282 | }, ...
283 | ]
284 | }, ...
285 | ]
286 | '''
287 | return self.__seenSvcs
288 |
--------------------------------------------------------------------------------
/jfNet/SSDP.py:
--------------------------------------------------------------------------------
1 | #! /usr/bin/env python3
2 | # -*- coding: UTF-8 -*-
3 |
4 | import time, re
5 | from logging import ERROR, WARN, INFO
6 | from enum import Enum
7 | from threading import Thread, Lock, Event
8 | from . import EventTypes
9 | from .CastReceiver import CastReceiver
10 | from .CastSender import CastSender
11 |
12 | SSDP_TTL = 4
13 | SSDP_PORT = 1900
14 | SSDP_MULITCAST_IP = '239.255.255.250'
15 | SEARCH_RULE = re.compile(r'^M-SEARCH \* HTTP\/1\.1[.\n]*(?i)HOST:\W?239\.255\.255\.250:1900', flags=re.RegexFlag.IGNORECASE)
16 | NOTIFY_RULE = re.compile(r'^NOTIFY \* HTTP\/1\.1[.\n]*(?i)HOST:\W?239\.255\.255\.250:1900')
17 | MAC_RULE = re.compile(r'([0-9a-fA-F]{2}:){5}([0-9a-fA-F]{2})')
18 | MAX_AGE = re.compile(r'max-age\W?=\W?(\d{1,})')
19 |
20 | __all__ = ['SsdpEvents', 'SsdpInfo', 'SsdpContent', 'SsdpService']
21 |
22 |
23 | class SsdpEvents(Enum):
24 | RECEIVED_SEARCH = 'onGetSSDPSearch'
25 | RECEIVED_NOTIFY = 'onGetSSDPNotify'
26 | RECEIVED_BYEBYE = 'onGetSSDPByebye'
27 | SENDED_SEARCH = 'onSendSSDPSearch'
28 | SENDED_NOTIFY = 'onSendSSDPNotify'
29 | DEVICE_JOINED = 'onDeviceJoined'
30 | DEVICE_LEAVED = 'onDeviceLeaved'
31 | LOGGING = 'onLogging'
32 |
33 |
34 | class SsdpInfo(dict):
35 | __slots__ = []
36 |
37 | def __init__(self, **fields):
38 | for k, v in fields.items():
39 | self[k] = v
40 | self.__slots__.append(k)
41 |
42 | def __getattr__(self, attr):
43 | if attr in self:
44 | return self[attr]
45 | else:
46 | raise AttributeError
47 |
48 | def __setattr__(self, attr, val):
49 | if attr in self:
50 | self[attr] = val
51 | else:
52 | raise AttributeError
53 |
54 | def getFieldValue(self, attr):
55 | if attr in self:
56 | return self[attr]
57 | else:
58 | return None
59 |
60 | def clone(self):
61 | return SsdpInfo(**self)
62 |
63 |
64 | class SsdpContent(SsdpInfo):
65 | def __init__(self, request_text):
66 | fields = {'method':''}
67 | lines = str.splitlines(request_text)
68 | m = re.search(r'(.*)\W\*\WHTTP\/(\d\.\d)', request_text)
69 | reg = re.compile(r'([\w-]*):\W?(.*)')
70 | if m:
71 | fields['method'] = m.group(1)
72 | for line in lines:
73 | if len(line) == 0: continue
74 | m = reg.search(line)
75 | if not m: continue
76 | fields[m.group(1).upper()] = m.group(2)
77 | super().__init__(**fields)
78 |
79 |
80 | class SsdpService:
81 | def __init__(self):
82 | self.__rcv: CastReceiver = None
83 | self.__snd: CastSender = None
84 | self.__events: dict = {
85 | EventTypes.STARTED: None,
86 | EventTypes.STOPED: None,
87 | SsdpEvents.RECEIVED_SEARCH: None,
88 | SsdpEvents.RECEIVED_NOTIFY: None,
89 | SsdpEvents.RECEIVED_BYEBYE: None,
90 | SsdpEvents.SENDED_SEARCH: None,
91 | SsdpEvents.SENDED_NOTIFY: None,
92 | SsdpEvents.DEVICE_JOINED: None,
93 | SsdpEvents.DEVICE_LEAVED: None,
94 | EventTypes.LOGGING: None
95 | }
96 | self.__devices: list = []
97 | self.__st_rule = None
98 | self.__nt_rule = None
99 | self.__evt_stop_notify = Event()
100 | self.__evt_stop_search = Event()
101 | self.__evt_exit = Event()
102 | self.__listLocker = Lock()
103 | self.__createSender()
104 |
105 | def __del__(self):
106 | if not self.__evt_stop_notify.isSet:
107 | self.__evt_stop_notify.set()
108 | self.__evt_stop_notify = None
109 | if not self.__evt_stop_search.isSet:
110 | self.__evt_stop_search.set()
111 | self.__evt_stop_search = None
112 | if not self.__evt_exit.isSet:
113 | self.__evt_exit.set()
114 | self.__evt_exit = None
115 | self.__stopSender()
116 | self.__stopReceiver()
117 | self.__devices = None
118 | self.__events = None
119 |
120 | def __createReceiver(self, port, *ips):
121 | # args : Listen Port, Group Ip1, Group Ip2, ...
122 | self.__logMessage(INFO, f'Creating SSDP Listener @ Port: {port}')
123 | self.__rcv = CastReceiver(port)
124 | self.__rcv.bind(key=EventTypes.RECEIVED, evt=self.__dataReceived)
125 | self.__rcv.reusePort = True
126 | self.__rcv.reuseAddr = True
127 | self.__rcv.joinGroup(*ips)
128 | self.__rcv.recvBuffer = 1024
129 | self.__rcv.start()
130 |
131 | def __stopReceiver(self, *args):
132 | if self.__rcv and self.__rcv.isAlive:
133 | self.__logMessage(INFO, 'Stopping SSDP Listener...')
134 | self.__rcv.stop()
135 | self.__rcv = None
136 |
137 | def __createSender(self, *args):
138 | self.__logMessage(INFO, 'Creating SSDP Sender')
139 | self.__snd = CastSender(SSDP_TTL)
140 | self.__snd.bind(key=EventTypes.SENDED, evt=self.__dataSended)
141 |
142 | def __stopSender(self, *args):
143 | if self.__snd:
144 | self.__logMessage(INFO, 'Stopping Multicast Sender...')
145 | self.__snd = None
146 |
147 | def __dataReceived(self, *args):
148 | ipRemote, _ = args[3]
149 | dStr = str(args[1], 'iso-8859-1')
150 | cnt = SsdpContent(dStr)
151 | if cnt.method == 'M-SEARCH' and cnt.MAN == '"ssdp:discover"':
152 | self.__recSearch(ipRemote, cnt)
153 | elif cnt.method == 'NOTIFY' and cnt.NTS in ['ssdp:alive', 'ssdp:byebye']:
154 | self.__recNotify(ipRemote, cnt)
155 |
156 | def __dataSended(self, *args):
157 | ip, _ = args[2]
158 | dStr = args[1]
159 | if isinstance(dStr, bytearray) or isinstance(dStr, bytes):
160 | dStr = dStr.decode('utf-8')
161 | if SEARCH_RULE.match(dStr):
162 | evt = SsdpEvents.SENDED_SEARCH
163 | elif NOTIFY_RULE.match(dStr):
164 | evt = SsdpEvents.SENDED_NOTIFY
165 | if evt and self.__events[evt]:
166 | self.__events[evt](self, *args)
167 |
168 | def __recSearch(self, ip, cnt):
169 | if self.__st_rule:
170 | if (callable(self.__st_rule) and not self.__st_rule(cnt)) or\
171 | (isinstance(self.__st_rule, re.Pattern) and not self.__st_rule.search(cnt.ST)):
172 | return
173 | if self.__events[SsdpEvents.RECEIVED_SEARCH]:
174 | self.__events[SsdpEvents.RECEIVED_SEARCH](self, cnt)
175 |
176 | def __recNotify(self, ip, cnt):
177 | if self.__nt_rule:
178 | if (callable(self.__nt_rule) and not self.__nt_rule(cnt)) or\
179 | (isinstance(self.__nt_rule, re.Pattern) and not self.__nt_rule.search(cnt.USN)):
180 | return
181 | if cnt.NTS == 'ssdp:alive':
182 | m = MAX_AGE.search(cnt['CACHE-CONTROL'])
183 | if not m:
184 | self.__logMessage(WARN, 'Messing Content: max-age in Cache-Control!')
185 | return
186 | maxAge = int(m.group(1))
187 | isJoin = False
188 | with self.__listLocker:
189 | di = self.findDevices(ip=ip)
190 | if di and len(di) != 0:
191 | di = di[0]
192 | di.lastTime = time.time()
193 | else:
194 | di = SsdpInfo(
195 | ip=ip, maxAge=maxAge, lastTime=time.time(),
196 | content=cnt
197 | )
198 | self.__devices.append(di)
199 | isJoin = True
200 | if self.__events[SsdpEvents.RECEIVED_NOTIFY]:
201 | self.__events[SsdpEvents.RECEIVED_NOTIFY](self, di)
202 | if isJoin:
203 | if self.__events[SsdpEvents.DEVICE_JOINED]:
204 | self.__events[SsdpEvents.DEVICE_JOINED](self, di)
205 | elif cnt.NTS == 'ssdp:byebye':
206 | # NOTIFY - BYEBYE
207 | with self.__listLocker:
208 | di = self.findDevices(ip=ip)
209 | if di and len(di) != 0:
210 | self.__devices.remove(di[0])
211 | if di and len(di) != 0:
212 | if self.__events[SsdpEvents.RECEIVED_BYEBYE]:
213 | self.__events[SsdpEvents.RECEIVED_BYEBYE](self, di[0])
214 | if self.__events[SsdpEvents.DEVICE_LEAVED]:
215 | self.__events[SsdpEvents.DEVICE_LEAVED](self, di[0])
216 |
217 | # Private Event Methods
218 | def __logMessage(self, lv, msg):
219 | if self.__events[EventTypes.LOGGING]:
220 | self.__events[EventTypes.LOGGING](self, lv, msg)
221 |
222 | # Public Methods
223 | def start_listen(self):
224 | self.__evt_exit.clear()
225 | self.__createReceiver(SSDP_PORT, [SSDP_MULITCAST_IP])
226 | if self.__events[EventTypes.STARTED]:
227 | self.__events[EventTypes.STARTED](self)
228 |
229 | def stop_listen(self):
230 | self.__evt_exit.set()
231 | self.__stopReceiver()
232 | if self.__events[EventTypes.STOPED]:
233 | self.__events[EventTypes.STOPED](self)
234 |
235 | def bind(self, key:str = None, evt=None):
236 | '''綁定回呼(callback)函式
237 | 傳入參數:
238 | `key` `str` -- 回呼事件代碼;為避免錯誤,建議使用 *EventTypes* 列舉值
239 | `evt` `def` -- 回呼(callback)函式
240 | 引發錯誤:
241 | `KeyError` -- 回呼事件代碼錯誤
242 | `TypeError` -- 型別錯誤,必須為可呼叫執行的函式
243 | '''
244 | if key not in self.__events:
245 | raise KeyError('key:"{}" not found!'.format(key))
246 | if evt is not None and not callable(evt):
247 | raise TypeError('evt:"{}" is not a function!'.format(evt))
248 | self.__events[key] = evt
249 |
250 | def findDevices(self, **kwargs) -> SsdpInfo:
251 | for k in ['mac', 'ip', 'hostId']:
252 | if k in kwargs:
253 | v = kwargs.get(k)
254 | return [di for di in self.__devices if di.getFieldValue(k) == v]
255 | return None
256 |
257 | def createSearchContent(self, **kwargs):
258 | '''建立 M-SEARCH 用的 HTML 內容
259 |
260 | 傳入:
261 | `kwargs` `dict` -- 需包含以下內容
262 | `MX` `int` -- 遠端設備收到此通知時,需在此時間內回應,否則不於處理, 單位秒
263 | `ST` `str` -- 搜尋的目標識別字串
264 | '''
265 | if 'MX' not in kwargs or 'ST' not in kwargs:
266 | raise KeyError
267 | msg = 'M-SEARCH * HTTP/1.1\r\nHost: 239.255.255.250:1900\r\nMAN: "ssdp:discover"\r\n'
268 | for k in kwargs:
269 | if k.upper() == 'MAN' or k.upper() == 'HOST': continue
270 | msg += f'{k}: {kwargs.get(k)}\r\n'
271 | msg += '\r\n'
272 | return msg
273 |
274 | def search_forever(self, cycle, content):
275 | self.__evt_stop_search.clear()
276 | while not self.__evt_stop_search.wait(timeout=cycle):
277 | self.__snd.send((SSDP_MULITCAST_IP, SSDP_PORT), content)
278 |
279 | def search_once(self, content):
280 | self.__snd.send((SSDP_MULITCAST_IP, SSDP_PORT), content)
281 |
282 | def stop_search(self):
283 | self.__evt_stop_search.set()
284 |
285 | def setSearchFilter(self, rule):
286 | '''設定 M-SEARCH 用的設備過濾函式
287 |
288 | 傳入:
289 | `rule` `str` -- 過濾條件, 以 ST 的內容作為過濾內容. 可傳入 Regular Expression 字串
290 | `rule` `callable` -- 過濾用函式, 呼叫此函式時, 將會傳入 SSDP 的 HTML Hdader(SSDP_Content) 內容
291 | '''
292 | if callable(rule):
293 | self.__st_rule = rule
294 | elif isinstance(rule, str):
295 | self.__st_rule = re.compile(rule)
296 | else:
297 | raise TypeError
298 |
299 | def createNotifyContent(self, **kwargs):
300 | '''建立 NOTIFY 用的 HTML 內容
301 |
302 | 傳入:
303 | `kwargs` `dict` -- 需包含以下內容
304 | `max-age` `int` -- 本端發送 NOTIFY 的逾時認定時間, 單位秒. 建議使用 3 倍以上發送週期
305 | `LOCATION` `str` -- 本端設備的軟硬體資訊取得的網址或位置
306 | `NT` `str` -- 欲通知的遠端控制器的識別字串
307 | `USN` `str` -- 本端設備的軟硬體識別字串
308 | '''
309 | ks = ['max-age', 'LOCATION', 'NT', 'USN']
310 | if len([k for k in ks if k in kwargs]) != len(ks):
311 | return
312 | msg = 'NOTIFY * HTTP/1.1\r\nHost: 239.255.255.250:1900\r\nNTS: ssdp:alive\r\n'
313 | v = kwargs.get('max-age')
314 | msg += f'CACHE-CONTROL: max-age={v}\r\n'
315 | for k in kwargs:
316 | if k == 'max-age' or k.upper() == 'MAN' or k.upper() == 'NTS': continue
317 | msg += f'{k}: {kwargs.get(k)}\r\n'
318 | msg += '\r\n'
319 | return msg
320 |
321 | def notify_forever(self, cycle, content):
322 | self.__evt_stop_notify.clear()
323 | while not self.__evt_stop_notify.wait(timeout=cycle):
324 | try:
325 | self.__snd.send((SSDP_MULITCAST_IP, SSDP_PORT), content)
326 | except Exception as ex:
327 | self.__logMessage(ERROR, ex.msg)
328 |
329 | def notify_once(self, content):
330 | try:
331 | self.__snd.send((SSDP_MULITCAST_IP, SSDP_PORT), content)
332 | except Exception as ex:
333 | self.__logMessage(ERROR, ex.msg)
334 |
335 | def stop_notify(self):
336 | self.__evt_stop_notify.set()
337 |
338 | def setNotifyFilter(self, rule):
339 | '''設定 NOTIFY 用的設備過濾函式
340 |
341 | 傳入:
342 | `rule` `str` -- 過濾條件, 以 USN 的內容作為過濾內容. 可傳入 Regular Expression 字串
343 | `rule` `callable` -- 過濾用函式, 呼叫此函式時, 將會傳入 SSDP 的 HTML Hdader(SSDP_Content) 內容
344 | '''
345 | if callable(rule):
346 | self.__nt_rule = rule
347 | elif isinstance(rule, str):
348 | self.__nt_rule = re.compile(rule)
349 | else:
350 | raise TypeError
351 |
352 | def clearDevices(self):
353 | if not self.__devices or len(self.__devices) == 0:
354 | return
355 | d = self.__devices.pop()
356 | while d:
357 | del d
358 | if len(self.__devices) == 0:
359 | break
360 | d = self.__devices.pop()
361 |
--------------------------------------------------------------------------------
/cctv/rtspProxy.py:
--------------------------------------------------------------------------------
1 | #! /usr/bin/env python3
2 | # -*- coding: UTF-8 -*-
3 |
4 | # Ref.: https://www.itread01.com/content/1547446926.html
5 |
6 | import os, threading, time, cv2, base64, types, json, re
7 | from websocket_server import WebsocketServer, WebSocketHandler
8 | from socketserver import TCPServer
9 |
10 |
11 | __all__ = ['RtspProxy', 'HttpMJpegPusher']
12 | # 設定 OpenCV 的 VideoCapture() 拉 RTSP 流時,使用 UDP....... ?? (未驗證)
13 | os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = "rtsp_transport;udp"
14 |
15 |
16 | def _parse_headers(had):
17 | reg = re.compile(r'(\S*):\s?([^\r]*)\r?')
18 | return dict([(k.lower(), v) for k, v in reg.findall(had)])
19 |
20 |
21 | class _wsServer(WebsocketServer):
22 | def __init__(self, port, host='127.0.0.1'):
23 | self.port = port
24 | TCPServer.__init__(self, (host, port), _wsHandler)
25 |
26 |
27 | class _wsHandler(WebSocketHandler):
28 | def setup(self):
29 | '''覆寫自 websocket_server.WebSocketHandler.setup
30 |
31 | 增加 path 與 headers 兩屬性
32 | '''
33 | super(_wsHandler, self).setup()
34 | self.path = ''
35 | self.headers = {}
36 |
37 | def handshake(self):
38 | '''覆寫自 websocket_server.WebSocketHandler.handshake
39 |
40 | 增加 path 與 headers 兩屬性
41 | '''
42 | message = self.request.recv(1024).decode().strip()
43 | # 額外處理接到的內容,轉化成 Request Path & Headers
44 | hd = re.search(r'GET (.*) HTTP/\d\.\d', message)
45 | if not hd:
46 | self.keep_alive = False
47 | return
48 | self.path = hd.group(1)
49 | self.headers = _parse_headers(message)
50 | #
51 | upgrade = re.search(r'\nupgrade[\s]*:[\s]*websocket', message.lower())
52 | if not upgrade:
53 | self.keep_alive = False
54 | return
55 | key = re.search(r'\n[sS]ec-[wW]eb[sS]ocket-[kK]ey[\s]*:[\s]*(.*)\r\n', message)
56 | if key:
57 | key = key.group(1)
58 | else:
59 | print("Client tried to connect but was missing a key")
60 | self.keep_alive = False
61 | return
62 | response = self.make_handshake_response(key)
63 | self.handshake_done = self.request.send(response.encode())
64 | self.valid_client = True
65 | self.server._new_client_(self)
66 |
67 |
68 | class _Camera(threading.Thread):
69 | '''自訂 Camera 執行緒類別, 此類別僅供 RtspProxy 使用'''
70 | def __init__(self, svr, url):
71 | super(_Camera, self).__init__()
72 | self.daemon = True
73 | self.__evt_exit = threading.Event()
74 | self.__svr = svr
75 | self.__lock = threading.Lock()
76 | self.url = url
77 | self.clients = []
78 | self.camera = cv2.VideoCapture(url)
79 | if not self.camera.isOpened():
80 | self.camera.open()
81 | self.resolution = (
82 | int(self.camera.get(cv2.CAP_PROP_FRAME_WIDTH)),
83 | int(self.camera.get(cv2.CAP_PROP_FRAME_HEIGHT))
84 | )
85 | self.fps = int(self.camera.get(cv2.CAP_PROP_FPS))
86 |
87 | def __del__(self):
88 | self.clients = []
89 | if self.camera and self.camera.isOpened():
90 | self.camera.release()
91 |
92 | def run(self):
93 | self.__evt_exit.clear()
94 | while not self.__evt_exit.wait(timeout=0.05):
95 | ret, frame = self.camera.read()
96 | if ret:
97 | thds = []
98 | for clt in self.clients:
99 | if self.__evt_exit.isSet(): break
100 | pkgs = self.__encodingImage(frame, clt['resolution'])
101 | if not pkgs: continue
102 | thd = threading.Thread(target=self.__sendPackages, daemon=True, args=(clt, pkgs))
103 | thds.append(thd)
104 | [thd.start() for thd in thds]
105 | [thd.join() for thd in thds]
106 | else:
107 | # 讀取失敗,重置 IP Cam
108 | if self.__evt_exit.isSet(): break
109 | self.camera = cv2.VideoCapture(self.url)
110 | if not self.camera.isOpened(): self.camera.open()
111 |
112 | def stop(self):
113 | self.__evt_exit.set()
114 | time.sleep(0.1)
115 | if self.camera and self.camera.isOpened():
116 | self.camera.release()
117 |
118 | def appendClient(self, client):
119 | with self.__lock:
120 | ids = [c for c in self.clients if c['id'] == client['id']]
121 | if not ids:
122 | self.clients.append(client)
123 | else:
124 | ids[0].update(client)
125 |
126 | def removeClient(self, client):
127 | with self.__lock:
128 | [self.clients.remove(c) for c in self.clients if c['id'] == client['id']]
129 |
130 | def updateClient(self, client):
131 | with self.__lock:
132 | [c.update(client) for c in self.clients if c['id'] == client['id']]
133 |
134 | def __find(self, id):
135 | ids = [c for c in self.clients if c['id'] == id]
136 | return ids[0] if ids else None
137 |
138 | def __encodingImage(self, frame, resolution=(0, 0), quality=0, size=32 * 1024):
139 | '''將圖片依 resolution 調整解析度, 並編碼成可傳輸之 base64 字串
140 | 傳入:
141 | frame : cv2 image - 來自 OpenCV 的圖像(頁框)資料
142 | resolution: tuple - 欲調整的解析度, 格式為 (width, height)
143 | 未傳入或傳入 (0, 0) 時則表示不調整, 預設值不調整
144 | quality : int - 壓縮品質, 1~100, 預設值為 0 表示原畫質不壓縮
145 | size : int - 拆解的封包大小
146 | 傳回:
147 | list(str) - 拆解完成的字串列表
148 | '''
149 | frm = frame if resolution == (0, 0) or resolution == self.resolution else cv2.resize(frame, resolution)
150 | quality = 100 if quality > 100 else 0 if quality < 0 else quality
151 | if quality != 0:
152 | params = [int(cv2.IMWRITE_JPEG_QUALITY), quality]
153 | ret, image = cv2.imencode('.jpg', frm, params)
154 | else:
155 | ret, image = cv2.imencode('.jpg', frm)
156 | if not ret: return None
157 | base64_data = base64.b64encode(image)
158 | buf = f'data:image/jpeg;base64,{base64_data.decode()}'
159 | # # 拆解封包內容
160 | # # pks = len(buf) / size
161 | # # pks = int(pks) + 1 if int(pks) != pks else int(pks)
162 | # # 縮短為以下計算式
163 | # pks = int((len(buf) + (size - 1)) / size)
164 | return [f'~{int(i / size) + 1}~{buf[i:i + size]}' for i in range(0, len(buf), size)]
165 |
166 | def __sendPackages(self, client, pkgs):
167 | if not pkgs: return
168 | try:
169 | self.__svr.send_message(client, f"::{len(pkgs)}::")
170 | [self.__svr.send_message(client, pkg) for pkg in pkgs if not self.__evt_exit.isSet()]
171 | except:
172 | pass
173 |
174 |
175 | class RtspProxy(object):
176 | def __init__(self, host, log=None):
177 | self.clients = []
178 | self.cameras = []
179 | # 建立 Websocket Server
180 | self.__svr = _wsServer(host=host[0], port=host[1])
181 | self.host = self.__svr.server_address
182 | # 有裝置連線上時呼叫
183 | self.__svr.set_fn_new_client(self.__newClient)
184 | # 斷開連線時呼叫
185 | self.__svr.set_fn_client_left(self.__clientLeft)
186 | # 接收到資訊
187 | self.__svr.set_fn_message_received(self.__msgReceived)
188 | if log:
189 | self.log = log
190 | else:
191 | self.log = types.ModuleType('nolog')
192 | nolog = types.MethodType(lambda msg, *args, **kwargs: None, self.log)
193 | self.log.debug = self.log.info = nolog
194 | self.log.warn = self.log.warning = nolog
195 | self.log.error = self.log.exception = nolog
196 |
197 | def __newClient(self, client, server):
198 | '''
199 | client is a dict:
200 | {
201 | 'id' : id,
202 | 'handler' : handler,
203 | 'address' : (addr, port)
204 | }
205 | '''
206 | self.log.debug(f"New client connected, ID: \x1B[92m{client['id']}\x1B[39m")
207 | self.clients.append(client)
208 |
209 | def __clientLeft(self, client, server):
210 | self.log.debug(f"Client(\x1B[92m{client['id']}\x1B[39m) disconnected")
211 | [cam.removeClient(client) for cam in self.cameras if cam.url == client['url']]
212 | [self.clients.remove(c) for c in self.clients if c['id'] == client['id']]
213 |
214 | def __msgReceived(self, client, server, message):
215 | self.log.debug(f"Client(\x1B[92m{client['id']}\x1B[39m) said: \x1B[92m{message}\x1B[39m")
216 | d = json.loads(message)
217 | clts = [c for c in self.clients if c['id'] == client['id']]
218 | if not clts: return
219 | act = d.get('act', None)
220 | if not act: return
221 | if act == 'open':
222 | url = d.get('url', None)
223 | if not url: return
224 | clts[0]['resolution'] = tuple(d.get('resolution', (0, 0)))
225 | ourl = clts[0].get('url', '')
226 | if ourl != url:
227 | [cam.removeClient(clts[0]) for cam in self.cameras if cam.url == ourl]
228 | clts[0]['url'] = url
229 | # 原先連線的網址為空值或與現在要連線的網址不同
230 | cams = [cam for cam in self.cameras if cam.url == url]
231 | if not cams:
232 | cam = _Camera(self.__svr, url)
233 | self.cameras.append(cam)
234 | cam.start()
235 | else:
236 | cam = cams[0]
237 | cam.appendClient(clts[0])
238 | elif act == 'resize':
239 | clts[0]['resolution'] = tuple(d.get('resolution', (0, 0)))
240 | [cam.updateClient(clts[0]) for cam in self.cameras if cam.url == clts[0]['url']]
241 |
242 | def start(self):
243 | threading.Thread(target=self.__svr.run_forever, daemon=True).start()
244 | ip = '*' if not self.host[0] or self.host[0] == '0.0.0.0' else self.host[0]
245 | self.log.info(f'RTSP WebSocket Proxy Started @ \x1B[92mws://{ip}:{self.host[1]}/\x1B[39m')
246 |
247 | def stop(self):
248 | for cam in self.cameras:
249 | for c in self.clients:
250 | cam.removeClient(c)
251 | self.clients.remove(c)
252 | cam.stop()
253 | cam.join(0.1)
254 | self.cameras.remove(cam)
255 | self.__svr.server_close()
256 | self.log.warn(f'RTSP WebSocket Proxy Stoped')
257 |
258 |
259 | class HttpMJpegPusher(threading.Thread):
260 | BOUNDARY_KEY = '--jpgboundary'
261 |
262 | def __init__(self, handler, rtsp, size=(0, 0), quality=0):
263 | super(HttpMJpegPusher, self).__init__(daemon=True)
264 | self.size = size or (0, 0)
265 | self.quality = quality or 70
266 | self.daemon = True
267 | self.handler = handler
268 | self.rtsp = rtsp
269 | self.__evt_exit = threading.Event()
270 | self.camera = cv2.VideoCapture(rtsp)
271 | if not self.camera.isOpened():
272 | self.camera.open()
273 | self.resolution = (
274 | int(self.camera.get(cv2.CAP_PROP_FRAME_WIDTH)),
275 | int(self.camera.get(cv2.CAP_PROP_FRAME_HEIGHT))
276 | )
277 | self.fps = int(self.camera.get(cv2.CAP_PROP_FPS))
278 |
279 | def __del__(self):
280 | self.clients = []
281 | if self.camera and self.camera.isOpened():
282 | self.camera.release()
283 |
284 | def run(self):
285 | self.__evt_exit.clear()
286 | try:
287 | self.handler.send_response(200)
288 | self.handler.send_header('Content-type', f'multipart/x-mixed-replace;boundary={self.BOUNDARY_KEY}')
289 | self.handler.end_headers()
290 | time.sleep(0.2)
291 | except:
292 | return
293 | while not self.__evt_exit.wait(timeout=0.05):
294 | ret, frame = self.camera.read()
295 | if ret:
296 | frm = frame if (self.size == (0, 0) or self.size == self.resolution) else cv2.resize(frame, self.size)
297 | quality = 100 if self.quality > 100 else 0 if self.quality <= 0 else self.quality
298 | if quality == 0:
299 | params = [int(cv2.IMWRITE_JPEG_QUALITY), quality]
300 | ret, jpg = cv2.imencode('.jpg', frm, params)
301 | else:
302 | ret, jpg = cv2.imencode('.jpg', frm)
303 | try:
304 | self.handler.wfile.write(f'{self.BOUNDARY_KEY}\r\n'.encode('latin-1', 'strict'))
305 | self.handler.send_header('Content-type', 'image/jpeg')
306 | self.handler.send_header('Content-length', str(jpg.size))
307 | self.handler.end_headers()
308 | self.handler.wfile.write(jpg.tostring())
309 | self.handler.wfile.write(b'\r\n\r\n')
310 | self.handler.wfile.flush()
311 | except (OSError, ConnectionResetError):
312 | break
313 | except:
314 | pass
315 | else:
316 | # 讀取失敗,重置 IP Cam
317 | if self.__evt_exit.isSet(): break
318 | self.camera = cv2.VideoCapture(self.url)
319 | if not self.camera.isOpened(): self.camera.open()
320 |
321 | def stop(self):
322 | self.__evt_exit.set()
323 | time.sleep(0.1)
324 |
325 |
326 | if __name__ == "__main__":
327 | import sys
328 | args = sys.argv[1:]
329 | if not args:
330 | print('usage: python rtspProxy.py ip:port')
331 | sys.exit(0)
332 | proxy = RtspProxy(tuple(args[0].split(':')))
333 | proxy.start()
334 | ip = '*' if not proxy.host[0] or proxy.host[0] == '0.0.0.0' else proxy.host[0]
335 | print(f'RTSP WebSocket Proxy Started @ \x1B[92mws://{ip}:{proxy.host[1]}/\x1B[39m')
336 | cmd = ''
337 | while cmd != 'exit':
338 | try:
339 | cmd = input(': ')
340 | if len(cmd) == 0: continue
341 | cmds = cmd.split()
342 | if cmds[0].lower() == 'exit':
343 | proxy.stop()
344 | sys.exit(0)
345 | else:
346 | print('Unknow command! please use \'exit\' to exit...')
347 | except:
348 | pass
349 | print('\x1B[39;49m')
350 | sys.exit(0)
351 |
--------------------------------------------------------------------------------
/cctvAgent.py:
--------------------------------------------------------------------------------
1 | #! /usr/bin/env python3
2 | # -*- coding: UTF-8 -*-
3 |
4 | import sys, os, socket, readline
5 | from webSvc import HttpService, WebHandler, HttpEvents
6 | from cctv.agent import CCTV_Agent as CCTV, AgentEvents
7 | from cctv.rtspProxy import RtspProxy, HttpMJpegPusher
8 |
9 | class Completer:
10 | def __init__(self, words):
11 | '''在 Console 下可使用類似 Hotkey 的方法'''
12 | self.words = words
13 | self.prefix = None
14 |
15 | def complete(self, prefix, index):
16 | if prefix != self.prefix:
17 | # we have a new prefix!
18 | # find all words that start with this prefix
19 | self.matching_words = [w for w in self.words if w.startswith(prefix)]
20 | self.prefix = prefix
21 | try:
22 | return self.matching_words[index]
23 | except IndexError:
24 | return None
25 |
26 | # a set of more or less interesting words
27 | COMMANDS = 'exit', 'help', 'host', 'cctv'
28 | completer = Completer(COMMANDS)
29 | readline.parse_and_bind('tab: complete')
30 | readline.parse_and_bind('set editing-mode vi')
31 | readline.set_completer(completer.complete)
32 |
33 | _IpCams = [
34 | {"ID": "A-1", "IP": "172.18.0.87", "Profile": "OnvifProfile2", "User": "admin", "Passwd": ""}
35 | ]
36 | _HttpPort = 8000
37 | _ProxyPort = 8001
38 | _LocalDomain = []
39 | _Agent: CCTV = None
40 | _Proxy: RtspProxy = None
41 | _WebSvr: HttpService = None
42 | _MJpeg: HttpMJpegPusher = None
43 | _log = None
44 |
45 | _help_commands_ = '''usage: command [argument]
46 | commands and arguments:
47 | exit : Exit this application
48 | help : Print this help message
49 | cctv : Show CCTV Information(if installed)
50 | > discovery : Discovery all IP Cams in same network, and get profiles
51 | > search : Search IP Cams's ONVIF service ulr in same network
52 | > list : Display all IP Cams information
53 | > clear : Clear all discovered IP Cams
54 | > stream : Display all IP Cams stream url
55 | > get : Request ONVIF service information by ONVIF service Url
56 | >> url : ONVIF service Url
57 | >> user : User ID[opt], default ''
58 | >> passwd : Password[opt], default ''
59 | ext 1: cctv get http://192.168.0.10/onvif/device_service admin 1234
60 | ext 2: cctv get http://admin:1234@192.168.0.10/onvif/device_service
61 | > onvif : Display all IP Cams ONVIF service url
62 | > info : Display IP Cam detail information by ID
63 | >> id : IP Cam's ID
64 | > proxy :
65 | '''
66 |
67 | def _setLogger():
68 | '''產生類似 logging.logger 變數'''
69 | global _log
70 | import types
71 | _log = types.ModuleType('console')
72 | # _log.debug = types.MethodType(lambda self, msg, *args, **kwargs: print(f'\x1B[90m{msg}\x1B[39m'), _log)
73 | _log.debug = types.MethodType(lambda self, msg, *args, **kwargs: None, _log)
74 | _log.info = types.MethodType(lambda self, msg, *args, **kwargs: print(msg), _log)
75 | _log.warn = _log.warning = types.MethodType(lambda self, msg, *args, **kwargs: print(f'\x1B[93m{msg}\x1B[39m'), _log)
76 | _log.error = _log.exception = types.MethodType(lambda self, msg, *args, **kwargs: print(f'\x1B[91m{msg}\x1B[39m'), _log)
77 |
78 | def _entry():
79 | # 清除畫面
80 | if sys.platform.startswith('win'):
81 | _ = os.system("cls") # windows
82 | else:
83 | _ = os.system("clear") # linux
84 | print('\x1B[39;49m\x1B[3J')
85 | print('CCTV RTSP Streaming over HTTP v1.0.0')
86 | print(f'Run as Python \x1B[92mv{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}\x1B[39m')
87 | print('-' * 70)
88 | # Set Local Domain name
89 | global _LocalDomain, _IpCams, _Agent, _WebSvr, _Proxy
90 | _LocalDomain.append(socket.gethostname())
91 | _LocalDomain.append(socket.gethostbyname(socket.gethostname()))
92 | _setLogger()
93 | # Create CCTV Agent
94 | _Agent = CCTV(ipcams=_IpCams, log=_log)
95 | _Agent.bind(AgentEvents.FOUND, lambda ip, url: print(f'Found IP Cam(\x1b[92m{ip}\x1b[39m), Url:\x1b[92m{url}\x1b[39m'))
96 | _Agent.bind(AgentEvents.JOINED, _cctvJoined)
97 | _Agent.bind(AgentEvents.UPDATE, _cctvUpdate)
98 | _Agent.start()
99 | # Create HTTP Service
100 | WebHandler.remoteAccess = True
101 | if hasattr(WebHandler, 'events'):
102 | WebHandler.events[HttpEvents.GET] = _WebGET
103 | _WebSvr = HttpService(('', _HttpPort), 'www', WebHandler)
104 | _WebSvr.bind(HttpEvents.STARTED, lambda: _log.info(f'HTTP Server Starting @ Port: \x1B[92m{_WebSvr.port}\x1B[39m'))
105 | _WebSvr.bind(HttpEvents.STOPED, lambda: _log.warn(f'HTTP Server Stoped!'))
106 | _WebSvr.start()
107 | # Create RTSP Streaming Proxy over WebSocket
108 | _Proxy = RtspProxy(host=('', _ProxyPort), log=_log)
109 | _Proxy.start()
110 | # Console Wait Command Input
111 | _waitStdin()
112 |
113 | def _waitStdin():
114 | cmd = ''
115 | while cmd != 'exit':
116 | try:
117 | cmd = input('> ')
118 | if len(cmd) == 0: continue
119 | cmds = cmd.split()
120 | code = cmds[0].lower()
121 | try:
122 | if code == 'exit':
123 | _stopServer()
124 | elif code == 'help':
125 | print(_help_commands_)
126 | elif code == 'host':
127 | [print(f'{(i+1)}: {ip}') for i, ip in zip(range(0, len(_LocalDomain)), _LocalDomain)]
128 | elif code == 'cctv':
129 | if len(cmds) < 2: continue
130 | elif cmds[1] == 'discovery' and hasattr(_Agent, 'discoveryOnvif') and callable(_Agent.discoveryOnvif):
131 | _Agent.discoveryOnvif(False)
132 | elif cmds[1] == 'search' and hasattr(_Agent, 'discovery') and callable(_Agent.discovery):
133 | # 0 1 2 3 4 5 6 7 8
134 | # 012345678901234567890123456789012345678901234567890123456789012345678901234567890
135 | urls = _Agent.discovery()
136 | print('No. Host ONVIF Service Url')
137 | from urllib.parse import urlparse
138 | for no, url in zip(range(1, len(urls) + 1), urls):
139 | parsed = urlparse(url)
140 | print(f'{no:<3} {parsed.netloc:21} {url}')
141 | elif cmds[1] == 'clear' and hasattr(_Agent, 'clear') and callable(_Agent.clear):
142 | _Agent.clear()
143 | elif cmds[1] == 'list' and hasattr(_Agent, 'ipcams'):
144 | # 0 1 2 3 4 5 6 7 8 9
145 | # 0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890
146 | print('ID Host Name Profiles Encoding Resolution use')
147 | for ipc in _Agent.ipcams:
148 | host = ipc['ip'] + (':' + str(ipc['port']) if ipc['port'] != 80 else '')
149 | print(f"{ipc['id']:<8} {host:<17} {(ipc['hostName'] if ipc['hostName'] else ''):<20} ", end='')
150 | idx = 0
151 | if ipc['profiles'] is None: continue
152 | for pf in ipc['profiles']:
153 | if idx != 0: print(' ' * 48, end='')
154 | resol = f"{pf['resolution']['width']}x{pf['resolution']['height']}"
155 | print(f"{pf['name']:<18} {pf['encoding']:<8} {resol:<12} ", end='')
156 | print('*' if pf['useit'] else ' ')
157 | idx += 1
158 | elif cmds[1] == 'stream' and hasattr(_Agent, 'ipcams'):
159 | # 0 1 2 3 4 5 6 7 8
160 | # 012345678901234567890123456789012345678901234567890123456789012345678901234567890
161 | print('ID RTSP Streaming Url')
162 | for ipc in _Agent.ipcams:
163 | pfs = [pf for pf in ipc['profiles'] if pf['useit']]
164 | if not pfs: continue
165 | print(f"{ipc['id']:<8} {pfs[0]['url']}")
166 | elif cmds[1] == 'onvif' and hasattr(_Agent, 'ipcams'):
167 | # 0 1 2 3 4 5 6 7 8
168 | # 012345678901234567890123456789012345678901234567890123456789012345678901234567890
169 | print('ID ONVIF Service Url')
170 | for ipc in _Agent.ipcams:
171 | print(f"{ipc['id']:<8} {ipc['url']}")
172 | elif len(cmds) >= 3 and cmds[1] == 'get' and hasattr(_Agent, 'getOnvifInfo') and callable(_Agent.getOnvifInfo):
173 | if len(cmds) >= 5:
174 | auth = (cmds[3], cmds[4])
175 | elif len(cmds) >= 4:
176 | auth = (cmds[3], '')
177 | else:
178 | auth = ('', '')
179 | print(f'Get ONVIF Information from')
180 | print(f' > url: \x1B[92m{cmds[2]}\x1B[39m')
181 | print(f' > username: \x1B[92m{auth[0]}\x1B[39m, passwd: \x1B[92m{auth[1]}\x1B[39m')
182 | print(_Agent.getOnvifInfo(cmds[2], [auth]))
183 | elif len(cmds) >= 3 and cmds[1] == 'info' and hasattr(_Agent, 'ipcams'):
184 | ipcs = [ipc for ipc in _Agent.ipcams if ipc['id'] == cmds[2]]
185 | if not ipcs:
186 | print(f'\x1B[91mNot found IP Cam: \x1B[92m{cmds[2]}\x1B[39m')
187 | continue
188 | if len(cmds) >= 4 and cmds[3] == 'data':
189 | print(ipcs[0])
190 | continue
191 | ipc = ipcs[0]
192 | pf = ipcs[0]
193 | print(f"Basic Information:")
194 | print(f" ID : \x1B[92m{ipc['id']:<20}\x1B[39m", end='')
195 | print(f" Host : \x1B[92m{ipc['ip'] + (':' + str(ipc['port']) if ipc['port'] != 80 else '')}\x1B[39m")
196 | hn = ipc['hostName'] if ipc['hostName'] else ''
197 | print(f" Name : \x1B[92m{hn:<20}\x1B[39m", end='')
198 | print(f" ONVIF Url : \x1B[92m{ipc['url']}\x1B[39m")
199 | print(f" User ID : \x1B[92m{ipc['user']:<20}\x1B[39m", end='')
200 | print(f" Password : \x1B[92m{ipc['pwd']}\x1B[39m")
201 | print(f"Camera Source Information:")
202 | print(f" Name : \x1B[92m{ipc['source']['name']:<20}\x1B[39m", end='')
203 | print(f" Resolution : \x1B[92m{ipc['source']['resolution']['width']}\x1B[39m x \x1B[92m{ipc['source']['resolution']['height']}\x1B[39m")
204 | print(f"Profile Information:")
205 | for pf in ipc['profiles']:
206 | print(f" Name : ", end='')
207 | print('[\x1B[91mv\x1B[39m]' if pf['useit'] else '[ ]', end='')
208 | print(f" \x1B[92m{pf['name']}\x1B[39m ")
209 | print(f" Encoding : \x1B[92m{pf['encoding']:<20}\x1B[39m", end='')
210 | print(f" Resolution : \x1B[92m{pf['resolution']['width']}\x1B[39m x \x1B[92m{pf['resolution']['height']}\x1B[39m")
211 | print(f" Quality : \x1B[92m{pf['quality']:<20}\x1B[39m", end='')
212 | print(f" Frames/Sec : \x1B[92m{pf['frames']}\x1B[39m")
213 | print(f" Stream Url : \x1B[92m{pf['url']}\x1B[39m")
214 | elif len(cmds) >= 3 and cmds[1] == 'proxy':
215 | if cmds[2] == 'reset':
216 | _Proxy.stop()
217 | _Proxy.start()
218 | else:
219 | print('Unknow command!')
220 | except SystemExit:
221 | break
222 | except Exception:
223 | import traceback
224 | _log.error(traceback.format_exc())
225 | except KeyboardInterrupt:
226 | break
227 | print('\x1B[39;49m')
228 |
229 | def _stopServer():
230 | _Agent.stop()
231 | _WebSvr.stop()
232 | _Proxy.stop()
233 | raise SystemExit()
234 |
235 | def _cctvJoined(info):
236 | print(f'\x1B[92m[*]\x1B[39m CCTV Joined...')
237 | print(info)
238 |
239 | def _cctvUpdate(ip, info):
240 | print(f'\x1B[92m[*]\x1B[39m CCTV Information Updated...')
241 | print(f' IP Addr: \x1B[92m{ip}\x1B[39m')
242 | print(f' Update : \x1B[92m{info}\x1B[39m')
243 |
244 | def _rtspUrls():
245 | '''取得所有 IP Cam 的 RTSP 的網址
246 |
247 | 傳回:
248 | tuple(id:str, url:str)
249 | '''
250 | for ipc in _Agent.ipcams:
251 | pfs = [pf for pf in ipc['profiles'] if pf['useit']]
252 | if not pfs: continue
253 | yield (ipc['id'], pfs[0]['url'])
254 |
255 | def _WebGET(handler, cnt):
256 | if not CCTV: return
257 | ri = cnt['info']
258 | fds = ri.url.split('/')
259 | if len(fds) < 2 or fds[0].lower() != 'live':
260 | return
261 | urls = [url for id, url in _rtspUrls() if id.lower() == fds[1].lower()]
262 | if not urls:
263 | handler.send_error((404, 'Not Found', 'Nothing matches the given URI'), f'Not found ID:{fds[1]}')
264 | try:
265 | resolution = tuple([int(x) for x in ri.query['size'][0].split('x')]) if ri.query and 'size' in ri.query else(0, 0)
266 | except:
267 | resolution = (0, 0)
268 | try:
269 | quality = int(ri.query['q'][0]) if ri.query and 'q' in ri.query else 0
270 | except:
271 | quality = 0
272 | cnt['handled'] = True
273 | pxy = HttpMJpegPusher(handler, urls[0], resolution, quality)
274 | pxy.start()
275 |
276 |
277 |
278 | if __name__ == '__main__':
279 | try:
280 | _entry()
281 | except Exception as ex:
282 | print(f'\x1B[91m{ex}\x1B[39m')
283 | finally:
284 | sys.exit(0)
285 |
--------------------------------------------------------------------------------
/webSvc.py:
--------------------------------------------------------------------------------
1 | #! /usr/bin/env python3
2 | # # -*- coding: UTF-8 -*-
3 |
4 | import os, logging, threading, time, errno, json, cgi, re
5 | from enum import Enum
6 | from mimetypes import MimeTypes
7 | from collections import namedtuple
8 | from urllib.request import pathname2url
9 | from http import HTTPStatus
10 | from urllib import request
11 | from urllib.parse import urlparse, parse_qs, unquote
12 | from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
13 |
14 | __all__ = ['getMimeType', 'WebHandler']
15 |
16 | RequestInfo = namedtuple('RequestInfo',
17 | ['ip', 'url', 'query', 'path', 'file', 'isFolder',
18 | 'ctype', 'content', 'userAgent', 'isLocal'])
19 | vreg = re.compile(r'<%(.*)%>')
20 |
21 |
22 | def getMimeType(filename):
23 | '''傳回檔案的 MIMETYPE
24 |
25 | 傳入:
26 | `filename` `str` -- 欲取得 MIMETYPE 的檔案名稱
27 | '''
28 | mime = MimeTypes()
29 | url = pathname2url(filename)
30 | mime_type = mime.guess_type(url)
31 | return mime_type[0]
32 |
33 |
34 | class HttpEvents(Enum):
35 | STARTED = 'onStarted'
36 | STOPED = 'onStoped'
37 | GET = 'onGet'
38 | POST = 'onPort'
39 |
40 |
41 | class HttpService:
42 | logger = logging.getLogger(__name__)
43 | server_version = 'HttpService/1.0'
44 |
45 | def __init__(self, host: tuple, root: str, handler: BaseHTTPRequestHandler):
46 | '''建立網頁伺服器 Web Server'''
47 | self.__evts = {
48 | HttpEvents.STARTED: None,
49 | HttpEvents.STOPED: None,
50 | }
51 | self.__evt_exit = threading.Event()
52 | self.handler = handler
53 | self.handler.webRoot = root
54 | self.handler.logger = self.logger
55 | self.handler.server_version = self.server_version
56 | self.host = host
57 | self.__svr = ThreadingHTTPServer(self.host, self.handler)
58 | self.__svr.timeout = 0.5
59 | self.__thd = threading.Thread(target=self.__httpWeb_Proc, daemon=True, args=(self.__svr, ))
60 |
61 | port = property(fget=lambda self: self.__svr.server_port, doc='服務監聽的通訊埠號')
62 | started = property(fget=lambda self: self.__thd.isAlive(), doc='是否執行中')
63 |
64 | # Thread Methods
65 | def __httpWeb_Proc(self, svr):
66 | '''執行 HTTP Web 等待連線的程序'''
67 | while not self.__evt_exit.wait(0.05):
68 | try:
69 | svr.handle_request()
70 | except Exception:
71 | pass
72 | if self.__evts[HttpEvents.STOPED]:
73 | self.__evts[HttpEvents.STOPED]()
74 |
75 | def __fakeLink(self):
76 | '''建立一個假連線(HEAD Request),用以強制 HTTPServer 跳離 handle_request() 阻塞'''
77 | if self.__svr is None: return
78 | try:
79 | url = f'http://{self.__svr.server_name}:{self.__svr.server_port}/fake.link'
80 | req = request.Request(url)
81 | req.get_method = lambda: 'HEAD'
82 | request.urlopen(req)
83 | except Exception:
84 | pass
85 |
86 | def start(self):
87 | self.__evt_exit.clear()
88 | self.__thd.start()
89 | while not self.__thd.isAlive():
90 | time.sleep(0.1)
91 | if self.__evts[HttpEvents.STARTED]:
92 | self.__evts[HttpEvents.STARTED]()
93 |
94 | def stop(self):
95 | self.__evt_exit.set()
96 | self.__fakeLink()
97 | self.__thd.join()
98 | time.sleep(0.1)
99 | self.__svr.server_close()
100 |
101 | def bind(self, evt, callback):
102 | '''綁定回呼(callback)函式
103 | 傳入參數:
104 | `evt` `str` -- 回呼事件代碼;為避免錯誤,建議使用 *WorkerEvents* 列舉值
105 | `callback` `def` -- 回呼(callback)函式
106 | 引發錯誤:
107 | `KeyError` -- 回呼事件代碼錯誤
108 | `TypeError` -- 型別錯誤,必須為可呼叫執行的函式
109 | '''
110 | if evt not in self.__evts:
111 | raise KeyError(f'Event Key(evt):"{evt}" not found!')
112 | if callback is not None and not callable(callback):
113 | raise TypeError('"callback" not define or not a function!')
114 | self.__evts[evt] = callback
115 |
116 |
117 | class WebHandler(BaseHTTPRequestHandler):
118 | DEFAULT_ERROR_MESSAGE = """
119 |
120 |
121 |
122 | Error Response
123 |
124 |
125 |
126 | 發生錯誤!
127 | 錯誤代碼: %(code)d
128 | 錯誤訊息: %(message)s.
129 | %(code)s - %(explain)s.
130 |
131 |
132 | """
133 | error_message_format = DEFAULT_ERROR_MESSAGE
134 | server_version = 'HttpService/1.0'
135 | protocol_version = 'HTTP/1.1'
136 | events = {
137 | HttpEvents.GET: None,
138 | HttpEvents.POST: None,
139 | }
140 | webRoot: str = os.getcwd()
141 | remoteAccess = False
142 | localhost = ['localhost', '127.0.0.1']
143 | logger = logging.getLogger(__name__)
144 | deviceKeys = []
145 | dynamicVars = None
146 |
147 | # Orerride Methods
148 | def do_GET(self):
149 | """
150 | Override Method HTTP GET
151 | """
152 | try:
153 | ri = self._getRequestInfo()
154 | if not ri:
155 | self.send_error(HTTPStatus.BAD_REQUEST)
156 | return
157 | if self.events[HttpEvents.GET]:
158 | cnt = {'info': ri, 'handled': False}
159 | self.events[HttpEvents.GET](self, cnt)
160 | if cnt['handled']: return
161 | if ri.isFolder:
162 | # 目錄型 API => http://domain/folder or http://domain/folder?abc=123
163 | self._GET_folder(ri)
164 | else:
165 | if len(ri.query) == 0:
166 | fn = os.path.join(self.webRoot, ri.path, ri.file)
167 | if not os.path.exists(fn):
168 | self.send_error(HTTPStatus.NOT_FOUND, f'File Not Found: {self.path}')
169 | return
170 | mime = getMimeType(fn)
171 | if mime.split('/')[-1] in ['html', 'javascript']:
172 | self._responseDymanicPage(ri)
173 | else:
174 | self._responseFile(fn)
175 | else:
176 | self._GET_file(ri)
177 | except IOError as ex:
178 | if ex.errno == errno.EPIPE:
179 | # 遠端已斷線
180 | pass
181 | else:
182 | self.send_error(HTTPStatus.INTERNAL_SERVER_ERROR)
183 | except ConnectionResetError:
184 | pass
185 | except:
186 | self.logger.exception(f'do_GET Error!')
187 | self.send_error(HTTPStatus.INTERNAL_SERVER_ERROR)
188 |
189 | def do_POST(self):
190 | """
191 | Override Method HTTP POST
192 | """
193 | try:
194 | ri = self._getRequestInfo()
195 | if not ri:
196 | self.send_error(HTTPStatus.BAD_REQUEST)
197 | return
198 | if self.events[HttpEvents.POST]:
199 | cnt = {'info': ri, 'handled': False}
200 | self.events[HttpEvents.POST](self, cnt)
201 | if cnt['handled']: return
202 | if ri.isFolder:
203 | self._POST_folder(ri)
204 | else:
205 | self._POST_file(ri)
206 | except IOError as ex:
207 | if ex.errno == errno.EPIPE:
208 | # 遠端已斷線
209 | pass
210 | else:
211 | self.send_error(HTTPStatus.INTERNAL_SERVER_ERROR)
212 | except ConnectionResetError:
213 | pass
214 | except:
215 | self.send_error(HTTPStatus.INTERNAL_SERVER_ERROR)
216 |
217 | def do_HEAD(self):
218 | """
219 | Override Method HTTP HEAD
220 | """
221 | if self.path.lower().endswith('fake.link'):
222 | self.send_response(HTTPStatus.OK)
223 | return
224 | else:
225 | self.send_error(HTTPStatus.METHOD_NOT_ALLOWED)
226 | return
227 |
228 | def log_message(self, format, *args):
229 | """Override Method : Remove Console Messages"""
230 | pass
231 | # sys.stderr.write("%s - - [%s] %s\n" % (self.client_address[0], self.log_date_time_string(), format % args))
232 |
233 | def version_string(self):
234 | """Override Method : Return the server software version string."""
235 | return self.server_version
236 |
237 | def _getRequestInfo(self):
238 | '''取得 HTTP Request 內容
239 | 傳回 :
240 | namedtuple('RequestInfo') -- 請求內容, 格式如下:
241 | {
242 | 'ip':str, 'url':str, 'path':str, 'file':str, 'isFolder':bool,
243 | 'ctype':str, 'content':dict, 'userAgent':str, 'isLocal':bool
244 | }
245 | '''
246 | log = f'{self.client_address[0]}:{self.client_address[1]} - {self.request_version} {self.command}'
247 | userAgent = self.headers.get('User-Agent')
248 | isLocal = self.headers.get('Host').split(':')[0].lower() in self.localhost
249 | url = 'index.html' if self.path in ['', '/'] else self.path.strip('/')
250 | info = urlparse(unquote(url))
251 | log += f' - {info.path}'
252 | qs = parse_qs(info.query)
253 | content = {}
254 | ctype = self.headers.get('content-type')
255 | if not ctype:
256 | ctype, pdict = '', {}
257 | else:
258 | ctype, pdict = cgi.parse_header(ctype)
259 | log += f' - {ctype}' if ctype else ''
260 | if ctype == 'multipart/form-data':
261 | content = cgi.parse_multipart(self.rfile, pdict)
262 | elif ctype in ['application/x-www-form-urlencoded', 'application/json', 'application/soap+xml']:
263 | length = int(self.headers.get('content-length', 0))
264 | if length:
265 | ctx = self.rfile.read(length).decode('utf-8')
266 | if ctype == 'application/json':
267 | content = json.loads(ctx)
268 | elif ctype == 'application/soap+xml':
269 | content = ctx
270 | else:
271 | content = cgi.parse_qs(ctx, keep_blank_values=1)
272 | log += f' - {content}' if len(content) != 0 else ''
273 | self.logger.debug(log)
274 | return RequestInfo(
275 | ip=self.client_address[0],
276 | url=info.path.strip('/'),
277 | query=qs,
278 | path=os.path.dirname(info.path),
279 | file=os.path.basename(info.path),
280 | isFolder=(len(os.path.splitext(info.path)[1]) == 0),
281 | ctype=ctype,
282 | content=content,
283 | userAgent=userAgent,
284 | isLocal=isLocal)
285 |
286 | def _responseContent(self, mime, content):
287 | sc = content.encode('utf-8')
288 | self.send_response(HTTPStatus.OK)
289 | self.send_header('Content-type', f'{mime}; charset=utf-8')
290 | self.send_header('Content-Length', len(sc))
291 | self.send_header('Cache-Control', 'no-cache')
292 | self.end_headers()
293 | self.wfile.write(sc)
294 |
295 | def _responseFile(self, file):
296 | try:
297 | # 取得檔案 => http://domain/file.ext
298 | with open(file, 'rb', 4096) as f:
299 | fs = os.fstat(f.fileno())
300 | self.send_response(HTTPStatus.OK)
301 | self.send_header('Content-type', getMimeType(file))
302 | self.send_header('Content-Length', str(fs[6]))
303 | self.send_header('Last-Modified', self.date_time_string(fs.st_mtime))
304 | self.end_headers()
305 | buf = f.read()
306 | while buf:
307 | self.wfile.write(buf)
308 | buf = f.read()
309 | f.close()
310 | except FileNotFoundError:
311 | self.send_error(HTTPStatus.NOT_FOUND, f'File Not Found: {self.path}')
312 | except Exception as ex:
313 | self.logger.error(ex)
314 | self.send_error(HTTPStatus.INTERNAL_SERVER_ERROR, ex)
315 |
316 | def _responseDymanicPage(self, ri):
317 | fn = os.path.join(self.webRoot, ri.path, ri.file)
318 | if not os.path.exists(fn):
319 | self.send_error(HTTPStatus.NOT_FOUND, f'File Not Found: {self.path}')
320 | return
321 | with open(fn, 'r') as fs:
322 | cnt = fs.read()
323 | st = os.fstat(fs.fileno())
324 | m = vreg.search(cnt)
325 | while m:
326 | if self.dynamicVars and isinstance(self.dynamicVars, dict):
327 | if m.group(1) in self.shareData:
328 | mp = self.shareData[m.group(1)]
329 | rv = mp() if callable(mp) else mp
330 | else:
331 | rv = ''
332 | self.logger.warn(f'Unknow dynamic variable: {m.group(1)}')
333 | elif self.dynamicVars and callable(self.dynamicVars):
334 | rv = self.dynamicVars(m.group(1))
335 | else:
336 | rv = ''
337 | self.logger.warn(f'Can\'t replace dynamic variable: {m.group(1)}')
338 | cnt = cnt[:m.span()[0]] + str(rv) + cnt[m.span()[1]:]
339 | m = vreg.search(cnt)
340 | res = cnt.encode('utf-8')
341 | self.send_response(HTTPStatus.OK)
342 | self.send_header('Content-type', getMimeType(fn))
343 | self.send_header('Content-Length', len(res))
344 | # self.send_header('Cache-Control', 'no-cache')
345 | self.send_header('Last-Modified', self.date_time_string(st.st_mtime))
346 | self.end_headers()
347 | self.wfile.write(res)
348 |
349 | def _GET_folder(self, ri):
350 | """目錄型 GET API 用函式\n
351 | 如:http://domain/folder or http://domain/folder?abc=123\n
352 | 傳入:
353 | ri : RequestInfo -- Request Info
354 | """
355 | self.send_error(HTTPStatus.BAD_REQUEST)
356 |
357 | def _GET_file(self, info):
358 | """檔案型 GET API 用函式\n
359 | 如:http://domain/file.ext?abc=123\n
360 | 傳入:
361 | ri : RequestInfo -- Request Info
362 | """
363 | self.send_error(HTTPStatus.BAD_REQUEST)
364 |
365 | def _POST_folder(self, ri):
366 | """目錄型 POST API 用函式\n
367 | 如:http://domain/folder or http://domain/folder?abc=123\n
368 | 傳入:
369 | ri : RequestInfo -- Request Info
370 | """
371 | self.send_response(HTTPStatus.OK)
372 | self.end_headers()
373 | template = f'Folder POST OK
{ri.content}'
374 | self.wfile.write(template.encode('utf-8'))
375 |
376 | def _POST_file(self, ri):
377 | """檔案型 GET API 用函式\n
378 | 如:http://domain/file.ext?abc=123\n
379 | 傳入:
380 | ri : RequestInfo -- Request Info
381 | """
382 | self.send_response(HTTPStatus.OK)
383 | self.end_headers()
384 | template = f'File POST OK
{ri.content}'
385 | self.wfile.write(template.encode('utf-8'))
386 |
--------------------------------------------------------------------------------
/www/js/jquery.min.js:
--------------------------------------------------------------------------------
1 | /*! jQuery v3.1.1 | (c) jQuery Foundation | jquery.org/license */
2 | !function(a,b){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=a.document?b(a,!0):function(a){if(!a.document)throw new Error("jQuery requires a window with a document");return b(a)}:b(a)}("undefined"!=typeof window?window:this,function(a,b){"use strict";var c=[],d=a.document,e=Object.getPrototypeOf,f=c.slice,g=c.concat,h=c.push,i=c.indexOf,j={},k=j.toString,l=j.hasOwnProperty,m=l.toString,n=m.call(Object),o={};function p(a,b){b=b||d;var c=b.createElement("script");c.text=a,b.head.appendChild(c).parentNode.removeChild(c)}var q="3.1.1",r=function(a,b){return new r.fn.init(a,b)},s=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,t=/^-ms-/,u=/-([a-z])/g,v=function(a,b){return b.toUpperCase()};r.fn=r.prototype={jquery:q,constructor:r,length:0,toArray:function(){return f.call(this)},get:function(a){return null==a?f.call(this):a<0?this[a+this.length]:this[a]},pushStack:function(a){var b=r.merge(this.constructor(),a);return b.prevObject=this,b},each:function(a){return r.each(this,a)},map:function(a){return this.pushStack(r.map(this,function(b,c){return a.call(b,c,b)}))},slice:function(){return this.pushStack(f.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},eq:function(a){var b=this.length,c=+a+(a<0?b:0);return this.pushStack(c>=0&&c0&&b-1 in a)}var x=function(a){var b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u="sizzle"+1*new Date,v=a.document,w=0,x=0,y=ha(),z=ha(),A=ha(),B=function(a,b){return a===b&&(l=!0),0},C={}.hasOwnProperty,D=[],E=D.pop,F=D.push,G=D.push,H=D.slice,I=function(a,b){for(var c=0,d=a.length;c+~]|"+K+")"+K+"*"),S=new RegExp("="+K+"*([^\\]'\"]*?)"+K+"*\\]","g"),T=new RegExp(N),U=new RegExp("^"+L+"$"),V={ID:new RegExp("^#("+L+")"),CLASS:new RegExp("^\\.("+L+")"),TAG:new RegExp("^("+L+"|[*])"),ATTR:new RegExp("^"+M),PSEUDO:new RegExp("^"+N),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+K+"*(even|odd|(([+-]|)(\\d*)n|)"+K+"*(?:([+-]|)"+K+"*(\\d+)|))"+K+"*\\)|)","i"),bool:new RegExp("^(?:"+J+")$","i"),needsContext:new RegExp("^"+K+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+K+"*((?:-\\d)?\\d*)"+K+"*\\)|)(?=[^-]|$)","i")},W=/^(?:input|select|textarea|button)$/i,X=/^h\d$/i,Y=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,$=/[+~]/,_=new RegExp("\\\\([\\da-f]{1,6}"+K+"?|("+K+")|.)","ig"),aa=function(a,b,c){var d="0x"+b-65536;return d!==d||c?b:d<0?String.fromCharCode(d+65536):String.fromCharCode(d>>10|55296,1023&d|56320)},ba=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ca=function(a,b){return b?"\0"===a?"\ufffd":a.slice(0,-1)+"\\"+a.charCodeAt(a.length-1).toString(16)+" ":"\\"+a},da=function(){m()},ea=ta(function(a){return a.disabled===!0&&("form"in a||"label"in a)},{dir:"parentNode",next:"legend"});try{G.apply(D=H.call(v.childNodes),v.childNodes),D[v.childNodes.length].nodeType}catch(fa){G={apply:D.length?function(a,b){F.apply(a,H.call(b))}:function(a,b){var c=a.length,d=0;while(a[c++]=b[d++]);a.length=c-1}}}function ga(a,b,d,e){var f,h,j,k,l,o,r,s=b&&b.ownerDocument,w=b?b.nodeType:9;if(d=d||[],"string"!=typeof a||!a||1!==w&&9!==w&&11!==w)return d;if(!e&&((b?b.ownerDocument||b:v)!==n&&m(b),b=b||n,p)){if(11!==w&&(l=Z.exec(a)))if(f=l[1]){if(9===w){if(!(j=b.getElementById(f)))return d;if(j.id===f)return d.push(j),d}else if(s&&(j=s.getElementById(f))&&t(b,j)&&j.id===f)return d.push(j),d}else{if(l[2])return G.apply(d,b.getElementsByTagName(a)),d;if((f=l[3])&&c.getElementsByClassName&&b.getElementsByClassName)return G.apply(d,b.getElementsByClassName(f)),d}if(c.qsa&&!A[a+" "]&&(!q||!q.test(a))){if(1!==w)s=b,r=a;else if("object"!==b.nodeName.toLowerCase()){(k=b.getAttribute("id"))?k=k.replace(ba,ca):b.setAttribute("id",k=u),o=g(a),h=o.length;while(h--)o[h]="#"+k+" "+sa(o[h]);r=o.join(","),s=$.test(a)&&qa(b.parentNode)||b}if(r)try{return G.apply(d,s.querySelectorAll(r)),d}catch(x){}finally{k===u&&b.removeAttribute("id")}}}return i(a.replace(P,"$1"),b,d,e)}function ha(){var a=[];function b(c,e){return a.push(c+" ")>d.cacheLength&&delete b[a.shift()],b[c+" "]=e}return b}function ia(a){return a[u]=!0,a}function ja(a){var b=n.createElement("fieldset");try{return!!a(b)}catch(c){return!1}finally{b.parentNode&&b.parentNode.removeChild(b),b=null}}function ka(a,b){var c=a.split("|"),e=c.length;while(e--)d.attrHandle[c[e]]=b}function la(a,b){var c=b&&a,d=c&&1===a.nodeType&&1===b.nodeType&&a.sourceIndex-b.sourceIndex;if(d)return d;if(c)while(c=c.nextSibling)if(c===b)return-1;return a?1:-1}function ma(a){return function(b){var c=b.nodeName.toLowerCase();return"input"===c&&b.type===a}}function na(a){return function(b){var c=b.nodeName.toLowerCase();return("input"===c||"button"===c)&&b.type===a}}function oa(a){return function(b){return"form"in b?b.parentNode&&b.disabled===!1?"label"in b?"label"in b.parentNode?b.parentNode.disabled===a:b.disabled===a:b.isDisabled===a||b.isDisabled!==!a&&ea(b)===a:b.disabled===a:"label"in b&&b.disabled===a}}function pa(a){return ia(function(b){return b=+b,ia(function(c,d){var e,f=a([],c.length,b),g=f.length;while(g--)c[e=f[g]]&&(c[e]=!(d[e]=c[e]))})})}function qa(a){return a&&"undefined"!=typeof a.getElementsByTagName&&a}c=ga.support={},f=ga.isXML=function(a){var b=a&&(a.ownerDocument||a).documentElement;return!!b&&"HTML"!==b.nodeName},m=ga.setDocument=function(a){var b,e,g=a?a.ownerDocument||a:v;return g!==n&&9===g.nodeType&&g.documentElement?(n=g,o=n.documentElement,p=!f(n),v!==n&&(e=n.defaultView)&&e.top!==e&&(e.addEventListener?e.addEventListener("unload",da,!1):e.attachEvent&&e.attachEvent("onunload",da)),c.attributes=ja(function(a){return a.className="i",!a.getAttribute("className")}),c.getElementsByTagName=ja(function(a){return a.appendChild(n.createComment("")),!a.getElementsByTagName("*").length}),c.getElementsByClassName=Y.test(n.getElementsByClassName),c.getById=ja(function(a){return o.appendChild(a).id=u,!n.getElementsByName||!n.getElementsByName(u).length}),c.getById?(d.filter.ID=function(a){var b=a.replace(_,aa);return function(a){return a.getAttribute("id")===b}},d.find.ID=function(a,b){if("undefined"!=typeof b.getElementById&&p){var c=b.getElementById(a);return c?[c]:[]}}):(d.filter.ID=function(a){var b=a.replace(_,aa);return function(a){var c="undefined"!=typeof a.getAttributeNode&&a.getAttributeNode("id");return c&&c.value===b}},d.find.ID=function(a,b){if("undefined"!=typeof b.getElementById&&p){var c,d,e,f=b.getElementById(a);if(f){if(c=f.getAttributeNode("id"),c&&c.value===a)return[f];e=b.getElementsByName(a),d=0;while(f=e[d++])if(c=f.getAttributeNode("id"),c&&c.value===a)return[f]}return[]}}),d.find.TAG=c.getElementsByTagName?function(a,b){return"undefined"!=typeof b.getElementsByTagName?b.getElementsByTagName(a):c.qsa?b.querySelectorAll(a):void 0}:function(a,b){var c,d=[],e=0,f=b.getElementsByTagName(a);if("*"===a){while(c=f[e++])1===c.nodeType&&d.push(c);return d}return f},d.find.CLASS=c.getElementsByClassName&&function(a,b){if("undefined"!=typeof b.getElementsByClassName&&p)return b.getElementsByClassName(a)},r=[],q=[],(c.qsa=Y.test(n.querySelectorAll))&&(ja(function(a){o.appendChild(a).innerHTML="",a.querySelectorAll("[msallowcapture^='']").length&&q.push("[*^$]="+K+"*(?:''|\"\")"),a.querySelectorAll("[selected]").length||q.push("\\["+K+"*(?:value|"+J+")"),a.querySelectorAll("[id~="+u+"-]").length||q.push("~="),a.querySelectorAll(":checked").length||q.push(":checked"),a.querySelectorAll("a#"+u+"+*").length||q.push(".#.+[+~]")}),ja(function(a){a.innerHTML="";var b=n.createElement("input");b.setAttribute("type","hidden"),a.appendChild(b).setAttribute("name","D"),a.querySelectorAll("[name=d]").length&&q.push("name"+K+"*[*^$|!~]?="),2!==a.querySelectorAll(":enabled").length&&q.push(":enabled",":disabled"),o.appendChild(a).disabled=!0,2!==a.querySelectorAll(":disabled").length&&q.push(":enabled",":disabled"),a.querySelectorAll("*,:x"),q.push(",.*:")})),(c.matchesSelector=Y.test(s=o.matches||o.webkitMatchesSelector||o.mozMatchesSelector||o.oMatchesSelector||o.msMatchesSelector))&&ja(function(a){c.disconnectedMatch=s.call(a,"*"),s.call(a,"[s!='']:x"),r.push("!=",N)}),q=q.length&&new RegExp(q.join("|")),r=r.length&&new RegExp(r.join("|")),b=Y.test(o.compareDocumentPosition),t=b||Y.test(o.contains)?function(a,b){var c=9===a.nodeType?a.documentElement:a,d=b&&b.parentNode;return a===d||!(!d||1!==d.nodeType||!(c.contains?c.contains(d):a.compareDocumentPosition&&16&a.compareDocumentPosition(d)))}:function(a,b){if(b)while(b=b.parentNode)if(b===a)return!0;return!1},B=b?function(a,b){if(a===b)return l=!0,0;var d=!a.compareDocumentPosition-!b.compareDocumentPosition;return d?d:(d=(a.ownerDocument||a)===(b.ownerDocument||b)?a.compareDocumentPosition(b):1,1&d||!c.sortDetached&&b.compareDocumentPosition(a)===d?a===n||a.ownerDocument===v&&t(v,a)?-1:b===n||b.ownerDocument===v&&t(v,b)?1:k?I(k,a)-I(k,b):0:4&d?-1:1)}:function(a,b){if(a===b)return l=!0,0;var c,d=0,e=a.parentNode,f=b.parentNode,g=[a],h=[b];if(!e||!f)return a===n?-1:b===n?1:e?-1:f?1:k?I(k,a)-I(k,b):0;if(e===f)return la(a,b);c=a;while(c=c.parentNode)g.unshift(c);c=b;while(c=c.parentNode)h.unshift(c);while(g[d]===h[d])d++;return d?la(g[d],h[d]):g[d]===v?-1:h[d]===v?1:0},n):n},ga.matches=function(a,b){return ga(a,null,null,b)},ga.matchesSelector=function(a,b){if((a.ownerDocument||a)!==n&&m(a),b=b.replace(S,"='$1']"),c.matchesSelector&&p&&!A[b+" "]&&(!r||!r.test(b))&&(!q||!q.test(b)))try{var d=s.call(a,b);if(d||c.disconnectedMatch||a.document&&11!==a.document.nodeType)return d}catch(e){}return ga(b,n,null,[a]).length>0},ga.contains=function(a,b){return(a.ownerDocument||a)!==n&&m(a),t(a,b)},ga.attr=function(a,b){(a.ownerDocument||a)!==n&&m(a);var e=d.attrHandle[b.toLowerCase()],f=e&&C.call(d.attrHandle,b.toLowerCase())?e(a,b,!p):void 0;return void 0!==f?f:c.attributes||!p?a.getAttribute(b):(f=a.getAttributeNode(b))&&f.specified?f.value:null},ga.escape=function(a){return(a+"").replace(ba,ca)},ga.error=function(a){throw new Error("Syntax error, unrecognized expression: "+a)},ga.uniqueSort=function(a){var b,d=[],e=0,f=0;if(l=!c.detectDuplicates,k=!c.sortStable&&a.slice(0),a.sort(B),l){while(b=a[f++])b===a[f]&&(e=d.push(f));while(e--)a.splice(d[e],1)}return k=null,a},e=ga.getText=function(a){var b,c="",d=0,f=a.nodeType;if(f){if(1===f||9===f||11===f){if("string"==typeof a.textContent)return a.textContent;for(a=a.firstChild;a;a=a.nextSibling)c+=e(a)}else if(3===f||4===f)return a.nodeValue}else while(b=a[d++])c+=e(b);return c},d=ga.selectors={cacheLength:50,createPseudo:ia,match:V,attrHandle:{},find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(a){return a[1]=a[1].replace(_,aa),a[3]=(a[3]||a[4]||a[5]||"").replace(_,aa),"~="===a[2]&&(a[3]=" "+a[3]+" "),a.slice(0,4)},CHILD:function(a){return a[1]=a[1].toLowerCase(),"nth"===a[1].slice(0,3)?(a[3]||ga.error(a[0]),a[4]=+(a[4]?a[5]+(a[6]||1):2*("even"===a[3]||"odd"===a[3])),a[5]=+(a[7]+a[8]||"odd"===a[3])):a[3]&&ga.error(a[0]),a},PSEUDO:function(a){var b,c=!a[6]&&a[2];return V.CHILD.test(a[0])?null:(a[3]?a[2]=a[4]||a[5]||"":c&&T.test(c)&&(b=g(c,!0))&&(b=c.indexOf(")",c.length-b)-c.length)&&(a[0]=a[0].slice(0,b),a[2]=c.slice(0,b)),a.slice(0,3))}},filter:{TAG:function(a){var b=a.replace(_,aa).toLowerCase();return"*"===a?function(){return!0}:function(a){return a.nodeName&&a.nodeName.toLowerCase()===b}},CLASS:function(a){var b=y[a+" "];return b||(b=new RegExp("(^|"+K+")"+a+"("+K+"|$)"))&&y(a,function(a){return b.test("string"==typeof a.className&&a.className||"undefined"!=typeof a.getAttribute&&a.getAttribute("class")||"")})},ATTR:function(a,b,c){return function(d){var e=ga.attr(d,a);return null==e?"!="===b:!b||(e+="","="===b?e===c:"!="===b?e!==c:"^="===b?c&&0===e.indexOf(c):"*="===b?c&&e.indexOf(c)>-1:"$="===b?c&&e.slice(-c.length)===c:"~="===b?(" "+e.replace(O," ")+" ").indexOf(c)>-1:"|="===b&&(e===c||e.slice(0,c.length+1)===c+"-"))}},CHILD:function(a,b,c,d,e){var f="nth"!==a.slice(0,3),g="last"!==a.slice(-4),h="of-type"===b;return 1===d&&0===e?function(a){return!!a.parentNode}:function(b,c,i){var j,k,l,m,n,o,p=f!==g?"nextSibling":"previousSibling",q=b.parentNode,r=h&&b.nodeName.toLowerCase(),s=!i&&!h,t=!1;if(q){if(f){while(p){m=b;while(m=m[p])if(h?m.nodeName.toLowerCase()===r:1===m.nodeType)return!1;o=p="only"===a&&!o&&"nextSibling"}return!0}if(o=[g?q.firstChild:q.lastChild],g&&s){m=q,l=m[u]||(m[u]={}),k=l[m.uniqueID]||(l[m.uniqueID]={}),j=k[a]||[],n=j[0]===w&&j[1],t=n&&j[2],m=n&&q.childNodes[n];while(m=++n&&m&&m[p]||(t=n=0)||o.pop())if(1===m.nodeType&&++t&&m===b){k[a]=[w,n,t];break}}else if(s&&(m=b,l=m[u]||(m[u]={}),k=l[m.uniqueID]||(l[m.uniqueID]={}),j=k[a]||[],n=j[0]===w&&j[1],t=n),t===!1)while(m=++n&&m&&m[p]||(t=n=0)||o.pop())if((h?m.nodeName.toLowerCase()===r:1===m.nodeType)&&++t&&(s&&(l=m[u]||(m[u]={}),k=l[m.uniqueID]||(l[m.uniqueID]={}),k[a]=[w,t]),m===b))break;return t-=e,t===d||t%d===0&&t/d>=0}}},PSEUDO:function(a,b){var c,e=d.pseudos[a]||d.setFilters[a.toLowerCase()]||ga.error("unsupported pseudo: "+a);return e[u]?e(b):e.length>1?(c=[a,a,"",b],d.setFilters.hasOwnProperty(a.toLowerCase())?ia(function(a,c){var d,f=e(a,b),g=f.length;while(g--)d=I(a,f[g]),a[d]=!(c[d]=f[g])}):function(a){return e(a,0,c)}):e}},pseudos:{not:ia(function(a){var b=[],c=[],d=h(a.replace(P,"$1"));return d[u]?ia(function(a,b,c,e){var f,g=d(a,null,e,[]),h=a.length;while(h--)(f=g[h])&&(a[h]=!(b[h]=f))}):function(a,e,f){return b[0]=a,d(b,null,f,c),b[0]=null,!c.pop()}}),has:ia(function(a){return function(b){return ga(a,b).length>0}}),contains:ia(function(a){return a=a.replace(_,aa),function(b){return(b.textContent||b.innerText||e(b)).indexOf(a)>-1}}),lang:ia(function(a){return U.test(a||"")||ga.error("unsupported lang: "+a),a=a.replace(_,aa).toLowerCase(),function(b){var c;do if(c=p?b.lang:b.getAttribute("xml:lang")||b.getAttribute("lang"))return c=c.toLowerCase(),c===a||0===c.indexOf(a+"-");while((b=b.parentNode)&&1===b.nodeType);return!1}}),target:function(b){var c=a.location&&a.location.hash;return c&&c.slice(1)===b.id},root:function(a){return a===o},focus:function(a){return a===n.activeElement&&(!n.hasFocus||n.hasFocus())&&!!(a.type||a.href||~a.tabIndex)},enabled:oa(!1),disabled:oa(!0),checked:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&!!a.checked||"option"===b&&!!a.selected},selected:function(a){return a.parentNode&&a.parentNode.selectedIndex,a.selected===!0},empty:function(a){for(a=a.firstChild;a;a=a.nextSibling)if(a.nodeType<6)return!1;return!0},parent:function(a){return!d.pseudos.empty(a)},header:function(a){return X.test(a.nodeName)},input:function(a){return W.test(a.nodeName)},button:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&"button"===a.type||"button"===b},text:function(a){var b;return"input"===a.nodeName.toLowerCase()&&"text"===a.type&&(null==(b=a.getAttribute("type"))||"text"===b.toLowerCase())},first:pa(function(){return[0]}),last:pa(function(a,b){return[b-1]}),eq:pa(function(a,b,c){return[c<0?c+b:c]}),even:pa(function(a,b){for(var c=0;c=0;)a.push(d);return a}),gt:pa(function(a,b,c){for(var d=c<0?c+b:c;++d1?function(b,c,d){var e=a.length;while(e--)if(!a[e](b,c,d))return!1;return!0}:a[0]}function va(a,b,c){for(var d=0,e=b.length;d-1&&(f[j]=!(g[j]=l))}}else r=wa(r===g?r.splice(o,r.length):r),e?e(null,g,r,i):G.apply(g,r)})}function ya(a){for(var b,c,e,f=a.length,g=d.relative[a[0].type],h=g||d.relative[" "],i=g?1:0,k=ta(function(a){return a===b},h,!0),l=ta(function(a){return I(b,a)>-1},h,!0),m=[function(a,c,d){var e=!g&&(d||c!==j)||((b=c).nodeType?k(a,c,d):l(a,c,d));return b=null,e}];i1&&ua(m),i>1&&sa(a.slice(0,i-1).concat({value:" "===a[i-2].type?"*":""})).replace(P,"$1"),c,i0,e=a.length>0,f=function(f,g,h,i,k){var l,o,q,r=0,s="0",t=f&&[],u=[],v=j,x=f||e&&d.find.TAG("*",k),y=w+=null==v?1:Math.random()||.1,z=x.length;for(k&&(j=g===n||g||k);s!==z&&null!=(l=x[s]);s++){if(e&&l){o=0,g||l.ownerDocument===n||(m(l),h=!p);while(q=a[o++])if(q(l,g||n,h)){i.push(l);break}k&&(w=y)}c&&((l=!q&&l)&&r--,f&&t.push(l))}if(r+=s,c&&s!==r){o=0;while(q=b[o++])q(t,u,g,h);if(f){if(r>0)while(s--)t[s]||u[s]||(u[s]=E.call(i));u=wa(u)}G.apply(i,u),k&&!f&&u.length>0&&r+b.length>1&&ga.uniqueSort(i)}return k&&(w=y,j=v),t};return c?ia(f):f}return h=ga.compile=function(a,b){var c,d=[],e=[],f=A[a+" "];if(!f){b||(b=g(a)),c=b.length;while(c--)f=ya(b[c]),f[u]?d.push(f):e.push(f);f=A(a,za(e,d)),f.selector=a}return f},i=ga.select=function(a,b,c,e){var f,i,j,k,l,m="function"==typeof a&&a,n=!e&&g(a=m.selector||a);if(c=c||[],1===n.length){if(i=n[0]=n[0].slice(0),i.length>2&&"ID"===(j=i[0]).type&&9===b.nodeType&&p&&d.relative[i[1].type]){if(b=(d.find.ID(j.matches[0].replace(_,aa),b)||[])[0],!b)return c;m&&(b=b.parentNode),a=a.slice(i.shift().value.length)}f=V.needsContext.test(a)?0:i.length;while(f--){if(j=i[f],d.relative[k=j.type])break;if((l=d.find[k])&&(e=l(j.matches[0].replace(_,aa),$.test(i[0].type)&&qa(b.parentNode)||b))){if(i.splice(f,1),a=e.length&&sa(i),!a)return G.apply(c,e),c;break}}}return(m||h(a,n))(e,b,!p,c,!b||$.test(a)&&qa(b.parentNode)||b),c},c.sortStable=u.split("").sort(B).join("")===u,c.detectDuplicates=!!l,m(),c.sortDetached=ja(function(a){return 1&a.compareDocumentPosition(n.createElement("fieldset"))}),ja(function(a){return a.innerHTML="","#"===a.firstChild.getAttribute("href")})||ka("type|href|height|width",function(a,b,c){if(!c)return a.getAttribute(b,"type"===b.toLowerCase()?1:2)}),c.attributes&&ja(function(a){return a.innerHTML="",a.firstChild.setAttribute("value",""),""===a.firstChild.getAttribute("value")})||ka("value",function(a,b,c){if(!c&&"input"===a.nodeName.toLowerCase())return a.defaultValue}),ja(function(a){return null==a.getAttribute("disabled")})||ka(J,function(a,b,c){var d;if(!c)return a[b]===!0?b.toLowerCase():(d=a.getAttributeNode(b))&&d.specified?d.value:null}),ga}(a);r.find=x,r.expr=x.selectors,r.expr[":"]=r.expr.pseudos,r.uniqueSort=r.unique=x.uniqueSort,r.text=x.getText,r.isXMLDoc=x.isXML,r.contains=x.contains,r.escapeSelector=x.escape;var y=function(a,b,c){var d=[],e=void 0!==c;while((a=a[b])&&9!==a.nodeType)if(1===a.nodeType){if(e&&r(a).is(c))break;d.push(a)}return d},z=function(a,b){for(var c=[];a;a=a.nextSibling)1===a.nodeType&&a!==b&&c.push(a);return c},A=r.expr.match.needsContext,B=/^<([a-z][^\/\0>:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i,C=/^.[^:#\[\.,]*$/;function D(a,b,c){return r.isFunction(b)?r.grep(a,function(a,d){return!!b.call(a,d,a)!==c}):b.nodeType?r.grep(a,function(a){return a===b!==c}):"string"!=typeof b?r.grep(a,function(a){return i.call(b,a)>-1!==c}):C.test(b)?r.filter(b,a,c):(b=r.filter(b,a),r.grep(a,function(a){return i.call(b,a)>-1!==c&&1===a.nodeType}))}r.filter=function(a,b,c){var d=b[0];return c&&(a=":not("+a+")"),1===b.length&&1===d.nodeType?r.find.matchesSelector(d,a)?[d]:[]:r.find.matches(a,r.grep(b,function(a){return 1===a.nodeType}))},r.fn.extend({find:function(a){var b,c,d=this.length,e=this;if("string"!=typeof a)return this.pushStack(r(a).filter(function(){for(b=0;b1?r.uniqueSort(c):c},filter:function(a){return this.pushStack(D(this,a||[],!1))},not:function(a){return this.pushStack(D(this,a||[],!0))},is:function(a){return!!D(this,"string"==typeof a&&A.test(a)?r(a):a||[],!1).length}});var E,F=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]+))$/,G=r.fn.init=function(a,b,c){var e,f;if(!a)return this;if(c=c||E,"string"==typeof a){if(e="<"===a[0]&&">"===a[a.length-1]&&a.length>=3?[null,a,null]:F.exec(a),!e||!e[1]&&b)return!b||b.jquery?(b||c).find(a):this.constructor(b).find(a);if(e[1]){if(b=b instanceof r?b[0]:b,r.merge(this,r.parseHTML(e[1],b&&b.nodeType?b.ownerDocument||b:d,!0)),B.test(e[1])&&r.isPlainObject(b))for(e in b)r.isFunction(this[e])?this[e](b[e]):this.attr(e,b[e]);return this}return f=d.getElementById(e[2]),f&&(this[0]=f,this.length=1),this}return a.nodeType?(this[0]=a,this.length=1,this):r.isFunction(a)?void 0!==c.ready?c.ready(a):a(r):r.makeArray(a,this)};G.prototype=r.fn,E=r(d);var H=/^(?:parents|prev(?:Until|All))/,I={children:!0,contents:!0,next:!0,prev:!0};r.fn.extend({has:function(a){var b=r(a,this),c=b.length;return this.filter(function(){for(var a=0;a-1:1===c.nodeType&&r.find.matchesSelector(c,a))){f.push(c);break}return this.pushStack(f.length>1?r.uniqueSort(f):f)},index:function(a){return a?"string"==typeof a?i.call(r(a),this[0]):i.call(this,a.jquery?a[0]:a):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(a,b){return this.pushStack(r.uniqueSort(r.merge(this.get(),r(a,b))))},addBack:function(a){return this.add(null==a?this.prevObject:this.prevObject.filter(a))}});function J(a,b){while((a=a[b])&&1!==a.nodeType);return a}r.each({parent:function(a){var b=a.parentNode;return b&&11!==b.nodeType?b:null},parents:function(a){return y(a,"parentNode")},parentsUntil:function(a,b,c){return y(a,"parentNode",c)},next:function(a){return J(a,"nextSibling")},prev:function(a){return J(a,"previousSibling")},nextAll:function(a){return y(a,"nextSibling")},prevAll:function(a){return y(a,"previousSibling")},nextUntil:function(a,b,c){return y(a,"nextSibling",c)},prevUntil:function(a,b,c){return y(a,"previousSibling",c)},siblings:function(a){return z((a.parentNode||{}).firstChild,a)},children:function(a){return z(a.firstChild)},contents:function(a){return a.contentDocument||r.merge([],a.childNodes)}},function(a,b){r.fn[a]=function(c,d){var e=r.map(this,b,c);return"Until"!==a.slice(-5)&&(d=c),d&&"string"==typeof d&&(e=r.filter(d,e)),this.length>1&&(I[a]||r.uniqueSort(e),H.test(a)&&e.reverse()),this.pushStack(e)}});var K=/[^\x20\t\r\n\f]+/g;function L(a){var b={};return r.each(a.match(K)||[],function(a,c){b[c]=!0}),b}r.Callbacks=function(a){a="string"==typeof a?L(a):r.extend({},a);var b,c,d,e,f=[],g=[],h=-1,i=function(){for(e=a.once,d=b=!0;g.length;h=-1){c=g.shift();while(++h-1)f.splice(c,1),c<=h&&h--}),this},has:function(a){return a?r.inArray(a,f)>-1:f.length>0},empty:function(){return f&&(f=[]),this},disable:function(){return e=g=[],f=c="",this},disabled:function(){return!f},lock:function(){return e=g=[],c||b||(f=c=""),this},locked:function(){return!!e},fireWith:function(a,c){return e||(c=c||[],c=[a,c.slice?c.slice():c],g.push(c),b||i()),this},fire:function(){return j.fireWith(this,arguments),this},fired:function(){return!!d}};return j};function M(a){return a}function N(a){throw a}function O(a,b,c){var d;try{a&&r.isFunction(d=a.promise)?d.call(a).done(b).fail(c):a&&r.isFunction(d=a.then)?d.call(a,b,c):b.call(void 0,a)}catch(a){c.call(void 0,a)}}r.extend({Deferred:function(b){var c=[["notify","progress",r.Callbacks("memory"),r.Callbacks("memory"),2],["resolve","done",r.Callbacks("once memory"),r.Callbacks("once memory"),0,"resolved"],["reject","fail",r.Callbacks("once memory"),r.Callbacks("once memory"),1,"rejected"]],d="pending",e={state:function(){return d},always:function(){return f.done(arguments).fail(arguments),this},"catch":function(a){return e.then(null,a)},pipe:function(){var a=arguments;return r.Deferred(function(b){r.each(c,function(c,d){var e=r.isFunction(a[d[4]])&&a[d[4]];f[d[1]](function(){var a=e&&e.apply(this,arguments);a&&r.isFunction(a.promise)?a.promise().progress(b.notify).done(b.resolve).fail(b.reject):b[d[0]+"With"](this,e?[a]:arguments)})}),a=null}).promise()},then:function(b,d,e){var f=0;function g(b,c,d,e){return function(){var h=this,i=arguments,j=function(){var a,j;if(!(b=f&&(d!==N&&(h=void 0,i=[a]),c.rejectWith(h,i))}};b?k():(r.Deferred.getStackHook&&(k.stackTrace=r.Deferred.getStackHook()),a.setTimeout(k))}}return r.Deferred(function(a){c[0][3].add(g(0,a,r.isFunction(e)?e:M,a.notifyWith)),c[1][3].add(g(0,a,r.isFunction(b)?b:M)),c[2][3].add(g(0,a,r.isFunction(d)?d:N))}).promise()},promise:function(a){return null!=a?r.extend(a,e):e}},f={};return r.each(c,function(a,b){var g=b[2],h=b[5];e[b[1]]=g.add,h&&g.add(function(){d=h},c[3-a][2].disable,c[0][2].lock),g.add(b[3].fire),f[b[0]]=function(){return f[b[0]+"With"](this===f?void 0:this,arguments),this},f[b[0]+"With"]=g.fireWith}),e.promise(f),b&&b.call(f,f),f},when:function(a){var b=arguments.length,c=b,d=Array(c),e=f.call(arguments),g=r.Deferred(),h=function(a){return function(c){d[a]=this,e[a]=arguments.length>1?f.call(arguments):c,--b||g.resolveWith(d,e)}};if(b<=1&&(O(a,g.done(h(c)).resolve,g.reject),"pending"===g.state()||r.isFunction(e[c]&&e[c].then)))return g.then();while(c--)O(e[c],h(c),g.reject);return g.promise()}});var P=/^(Eval|Internal|Range|Reference|Syntax|Type|URI)Error$/;r.Deferred.exceptionHook=function(b,c){a.console&&a.console.warn&&b&&P.test(b.name)&&a.console.warn("jQuery.Deferred exception: "+b.message,b.stack,c)},r.readyException=function(b){a.setTimeout(function(){throw b})};var Q=r.Deferred();r.fn.ready=function(a){return Q.then(a)["catch"](function(a){r.readyException(a)}),this},r.extend({isReady:!1,readyWait:1,holdReady:function(a){a?r.readyWait++:r.ready(!0)},ready:function(a){(a===!0?--r.readyWait:r.isReady)||(r.isReady=!0,a!==!0&&--r.readyWait>0||Q.resolveWith(d,[r]))}}),r.ready.then=Q.then;function R(){d.removeEventListener("DOMContentLoaded",R),
3 | a.removeEventListener("load",R),r.ready()}"complete"===d.readyState||"loading"!==d.readyState&&!d.documentElement.doScroll?a.setTimeout(r.ready):(d.addEventListener("DOMContentLoaded",R),a.addEventListener("load",R));var S=function(a,b,c,d,e,f,g){var h=0,i=a.length,j=null==c;if("object"===r.type(c)){e=!0;for(h in c)S(a,b,h,c[h],!0,f,g)}else if(void 0!==d&&(e=!0,r.isFunction(d)||(g=!0),j&&(g?(b.call(a,d),b=null):(j=b,b=function(a,b,c){return j.call(r(a),c)})),b))for(;h1,null,!0)},removeData:function(a){return this.each(function(){W.remove(this,a)})}}),r.extend({queue:function(a,b,c){var d;if(a)return b=(b||"fx")+"queue",d=V.get(a,b),c&&(!d||r.isArray(c)?d=V.access(a,b,r.makeArray(c)):d.push(c)),d||[]},dequeue:function(a,b){b=b||"fx";var c=r.queue(a,b),d=c.length,e=c.shift(),f=r._queueHooks(a,b),g=function(){r.dequeue(a,b)};"inprogress"===e&&(e=c.shift(),d--),e&&("fx"===b&&c.unshift("inprogress"),delete f.stop,e.call(a,g,f)),!d&&f&&f.empty.fire()},_queueHooks:function(a,b){var c=b+"queueHooks";return V.get(a,c)||V.access(a,c,{empty:r.Callbacks("once memory").add(function(){V.remove(a,[b+"queue",c])})})}}),r.fn.extend({queue:function(a,b){var c=2;return"string"!=typeof a&&(b=a,a="fx",c--),arguments.length\x20\t\r\n\f]+)/i,ka=/^$|\/(?:java|ecma)script/i,la={option:[1,""],thead:[1,""],col:[2,""],tr:[2,""],td:[3,""],_default:[0,"",""]};la.optgroup=la.option,la.tbody=la.tfoot=la.colgroup=la.caption=la.thead,la.th=la.td;function ma(a,b){var c;return c="undefined"!=typeof a.getElementsByTagName?a.getElementsByTagName(b||"*"):"undefined"!=typeof a.querySelectorAll?a.querySelectorAll(b||"*"):[],void 0===b||b&&r.nodeName(a,b)?r.merge([a],c):c}function na(a,b){for(var c=0,d=a.length;c-1)e&&e.push(f);else if(j=r.contains(f.ownerDocument,f),g=ma(l.appendChild(f),"script"),j&&na(g),c){k=0;while(f=g[k++])ka.test(f.type||"")&&c.push(f)}return l}!function(){var a=d.createDocumentFragment(),b=a.appendChild(d.createElement("div")),c=d.createElement("input");c.setAttribute("type","radio"),c.setAttribute("checked","checked"),c.setAttribute("name","t"),b.appendChild(c),o.checkClone=b.cloneNode(!0).cloneNode(!0).lastChild.checked,b.innerHTML="",o.noCloneChecked=!!b.cloneNode(!0).lastChild.defaultValue}();var qa=d.documentElement,ra=/^key/,sa=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,ta=/^([^.]*)(?:\.(.+)|)/;function ua(){return!0}function va(){return!1}function wa(){try{return d.activeElement}catch(a){}}function xa(a,b,c,d,e,f){var g,h;if("object"==typeof b){"string"!=typeof c&&(d=d||c,c=void 0);for(h in b)xa(a,h,c,d,b[h],f);return a}if(null==d&&null==e?(e=c,d=c=void 0):null==e&&("string"==typeof c?(e=d,d=void 0):(e=d,d=c,c=void 0)),e===!1)e=va;else if(!e)return a;return 1===f&&(g=e,e=function(a){return r().off(a),g.apply(this,arguments)},e.guid=g.guid||(g.guid=r.guid++)),a.each(function(){r.event.add(this,b,e,d,c)})}r.event={global:{},add:function(a,b,c,d,e){var f,g,h,i,j,k,l,m,n,o,p,q=V.get(a);if(q){c.handler&&(f=c,c=f.handler,e=f.selector),e&&r.find.matchesSelector(qa,e),c.guid||(c.guid=r.guid++),(i=q.events)||(i=q.events={}),(g=q.handle)||(g=q.handle=function(b){return"undefined"!=typeof r&&r.event.triggered!==b.type?r.event.dispatch.apply(a,arguments):void 0}),b=(b||"").match(K)||[""],j=b.length;while(j--)h=ta.exec(b[j])||[],n=p=h[1],o=(h[2]||"").split(".").sort(),n&&(l=r.event.special[n]||{},n=(e?l.delegateType:l.bindType)||n,l=r.event.special[n]||{},k=r.extend({type:n,origType:p,data:d,handler:c,guid:c.guid,selector:e,needsContext:e&&r.expr.match.needsContext.test(e),namespace:o.join(".")},f),(m=i[n])||(m=i[n]=[],m.delegateCount=0,l.setup&&l.setup.call(a,d,o,g)!==!1||a.addEventListener&&a.addEventListener(n,g)),l.add&&(l.add.call(a,k),k.handler.guid||(k.handler.guid=c.guid)),e?m.splice(m.delegateCount++,0,k):m.push(k),r.event.global[n]=!0)}},remove:function(a,b,c,d,e){var f,g,h,i,j,k,l,m,n,o,p,q=V.hasData(a)&&V.get(a);if(q&&(i=q.events)){b=(b||"").match(K)||[""],j=b.length;while(j--)if(h=ta.exec(b[j])||[],n=p=h[1],o=(h[2]||"").split(".").sort(),n){l=r.event.special[n]||{},n=(d?l.delegateType:l.bindType)||n,m=i[n]||[],h=h[2]&&new RegExp("(^|\\.)"+o.join("\\.(?:.*\\.|)")+"(\\.|$)"),g=f=m.length;while(f--)k=m[f],!e&&p!==k.origType||c&&c.guid!==k.guid||h&&!h.test(k.namespace)||d&&d!==k.selector&&("**"!==d||!k.selector)||(m.splice(f,1),k.selector&&m.delegateCount--,l.remove&&l.remove.call(a,k));g&&!m.length&&(l.teardown&&l.teardown.call(a,o,q.handle)!==!1||r.removeEvent(a,n,q.handle),delete i[n])}else for(n in i)r.event.remove(a,n+b[j],c,d,!0);r.isEmptyObject(i)&&V.remove(a,"handle events")}},dispatch:function(a){var b=r.event.fix(a),c,d,e,f,g,h,i=new Array(arguments.length),j=(V.get(this,"events")||{})[b.type]||[],k=r.event.special[b.type]||{};for(i[0]=b,c=1;c=1))for(;j!==this;j=j.parentNode||this)if(1===j.nodeType&&("click"!==a.type||j.disabled!==!0)){for(f=[],g={},c=0;c-1:r.find(e,this,null,[j]).length),g[e]&&f.push(d);f.length&&h.push({elem:j,handlers:f})}return j=this,i\x20\t\r\n\f]*)[^>]*)\/>/gi,za=/