├── .gitignore ├── LICENSE ├── README.md ├── cam.cfg.example ├── camtest.py ├── docs └── cgi-notes.txt ├── foscontrol └── __init__.py ├── lowlevel ├── FoscDecoder.py ├── LowlevelProtocol.md ├── __init__.py ├── camSniffer.py └── ticklecam.py ├── requirements.txt ├── setup.py ├── snapshot.py └── tests ├── __init__.py └── test_foscamdecoder.py /.gitignore: -------------------------------------------------------------------------------- 1 | ## generic files to ignore 2 | *~ 3 | *.lock 4 | *.DS_Store 5 | *.swp 6 | *.out 7 | *.orig 8 | *.pid 9 | .sass_cache 10 | 11 | #python specific 12 | *.pyc 13 | 14 | #data 15 | *.dat 16 | 17 | # dirs 18 | .idea 19 | .vagrant 20 | cam.cfg -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | {description} 294 | Copyright (C) {year} {fullname} 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | {signature of Ty Coon}, 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | pyFosControl 2 | ============ 3 | 4 | Python interface to Foscam CGI API for HD models 5 | 6 | Introduction 7 | ------------ 8 | 9 | The Foscam cameras can be controlled via a web interface. There are browser plugins available for Firefox, 10 | Chrome and IE, which are bundled with the camera firmware and can be downloaded using the cameras web interface. 11 | 12 | However, these plugins are Windows only. Without them only a few basic configuration options are available (network, 13 | user accounts, firewall, etc.). The bulk of the functionality including the display of the camera pictures, 14 | controlling the ptz movements, motion detection, are not available on a Linux computer. 15 | 16 | There is a [SDK](http://foscam.us/forum/cgi-sdk-for-hd-camera-t6045.html#p28979 "SDK for HD cameras") available 17 | describing a CGI interface which seems to make most of these functions available. pyFosControl is intended as an 18 | python interface. 19 | 20 | Getting started 21 | --------------- 22 | 23 | Create a new `cam.cfg` file using `cam.cfg.example` as template. 24 | 25 | Run `camtest.py` from the command line to get some basic information (like model info, firmware and hardware version). 26 | 27 | Please note 28 | ----------- 29 | 30 | * This interface is far from complete. 31 | * It's *mostly* tested on a FI9821W V2. 32 | * The SDK documentation is inaccurate in places. 33 | * The non HD cameras use a different set of CGI commands and are not covered in this implementation. 34 | * The behaviour of the camera changes slightly with each new firmware version. Please include model and firmware version when sending bug reports (run `camtest.py` from the command line). 35 | 36 | Certificate checking 37 | ------------------- 38 | 39 | Since version 2.7.9 Python is checking certificates used in https connections. 40 | 41 | This works fine with most sites on the internet because their certificates are signed by major 42 | certificate authorities and Python has the means to verify their signatures. 43 | 44 | However, most cameras use self-signed certificates which will fail this check and throw an exception. 45 | 46 | The certificate checking is controlled by the parameter `context.` See `camtest.py` for an example. 47 | This [blog entry](http://tuxpool.blogspot.de/2016/05/accessing-servers-with-self-signed.html) shows how to create a context that fits your camera. 48 | 49 | Unfortunately the `context` parameter was first added in Python 3.4.3. Between Python 2.7.9 and 3.4.3 50 | you either have to refrain from using https with self-signed certs or you have to tweak your system 51 | (i.e. install the camera certificate yourself in the system, change the host file, etc) so that the check is 52 | successful without using `context`. 53 | -------------------------------------------------------------------------------- /cam.cfg.example: -------------------------------------------------------------------------------- 1 | [general] 2 | protocol=https 3 | host=192.168.0.103 4 | port=443 5 | user=admin 6 | password=12345 7 | 8 | -------------------------------------------------------------------------------- /camtest.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | from __future__ import print_function 5 | 6 | from foscontrol import Cam 7 | import sys 8 | 9 | try: # PY3 10 | from configparser import ConfigParser 11 | except ImportError: 12 | from ConfigParser import SafeConfigParser as ConfigParser 13 | 14 | ################################ 15 | # Don't forget to edit cam.cfg # 16 | # to reflect you setup! # 17 | ################################ 18 | 19 | if __name__ == "__main__": 20 | config = ConfigParser() 21 | 22 | # see cam.cfg.example 23 | config.read(['cam.cfg']) 24 | prot = config.get('general', 'protocol') 25 | host = config.get('general', 'host') 26 | port = config.get('general', 'port') 27 | user = config.get('general', 'user') 28 | passwd = config.get('general', 'password') 29 | 30 | if sys.hexversion < 0x03040300: 31 | # parameter context not available 32 | ctx = None 33 | else: 34 | # disable cert checking 35 | # see also http://tuxpool.blogspot.de/2016/05/accessing-servers-with-self-signed.html 36 | import ssl 37 | 38 | ctx = ssl.create_default_context() 39 | ctx.check_hostname = False 40 | ctx.verify_mode = ssl.CERT_NONE 41 | 42 | # connection to the camera 43 | do = Cam(prot, host, port, user, passwd, context=ctx) 44 | 45 | # display basic camera info 46 | res = do.getDevInfo() 47 | if res.result == 0: # quick check 48 | print("""product name: %s 49 | serial number: %s 50 | camera name: %s 51 | firmware version: %s 52 | hardware version: %s""" % (res.productName, res.serialNo, res.devName, res.firmwareVer, res.hardwareVer)) 53 | else: 54 | print(res._result) 55 | -------------------------------------------------------------------------------- /docs/cgi-notes.txt: -------------------------------------------------------------------------------- 1 | Notes and changes to Foscam CGI documentation v. 1.0.4: 2 | ======================================================= 3 | 4 | getWifiConfig: 5 | - is admin only 6 | - result of PSK is urlencoded (since 1.11.1.18, I think) 7 | 8 | snapPicture2 9 | - max. pic size is 512000 bytes 10 | Workaround: decrease quality setting, or use snapPicture and follow the link 11 | 12 | getPTZspeed/setPTZspeed 13 | - contrary to doc: 0: very fast, 4: very slow 14 | 15 | ptzAddPresetPoint 16 | - additional result field: 17 | - addResult 2: point already exists 18 | 19 | ptzDeletePresetPoint 20 | - additional result field: 21 | - deleteResult 1: point does not exist 22 | 4: point not deleted (used in cruise) 23 | 24 | ptzDeletePresetPoint 25 | - additional result field: 26 | - delResult 1: cruise does not exist 27 | 28 | setOSDSetting 29 | - parameter `isEnableOSDMask` not working 30 | 31 | setOSDMask 32 | - undocumented CGI call 33 | - parameter: isEnableOSDMask 34 | 35 | getOSDMask 36 | - undocumented CGI call 37 | - same reseult as getOSDSetting 38 | 39 | 40 | OSD masks: 41 | - The dimensions use by masks seem to be 1/10000 of the picture width/height. 42 | - Does not work with snapshots. 43 | 44 | importConfig 45 | - undocumented return parameter: importResult 46 | 47 | setAlarmRecordConfig 48 | - check return result: -1 = error 49 | - preRecordSecs seems to be limited to 5 50 | - alarmRecordSecs seems to be limited to 60 51 | 52 | getLog 53 | - call without parameters returns position 1..10 54 | - IP address is stored in little Endian (NOT the normal network order) 55 | Log type: 56 | 0 System startup 57 | 1 ? 58 | 2 ? 59 | 3 Login 60 | 4 Logout 61 | 5 User offline 62 | 63 | getPortInfo 64 | - additional result: onvifPort 65 | 66 | setPortInfo 67 | - additional parameter: onvifPort (default 888) 68 | 69 | xxxSMTPConfig 70 | - Note: Misspelling: "reciever" 71 | 72 | smtpTest 73 | - additional parameter "errorMsg" 74 | 75 | setSystemTime 76 | - it seems you can set everything 77 | - note sign change in the time zone: GMT+1 = -3600 78 | -------------------------------------------------------------------------------- /foscontrol/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import datetime 5 | import re 6 | import socket 7 | import struct 8 | import sys 9 | import xml.dom.minidom 10 | 11 | try: 12 | from urlparse import urlsplit, urljoin 13 | except ImportError: 14 | from urllib.parse import urlsplit, urljoin 15 | 16 | try: 17 | from urllib2 import urlopen, Request 18 | except ImportError: 19 | from urllib.request import urlopen, Request 20 | 21 | try: 22 | from urllib import urlencode, unquote, urlopen 23 | except: 24 | from urllib.parse import urlencode, unquote 25 | 26 | def my_urlopen(url, data=None, context=None): 27 | if sys.hexversion < 0x03040300: 28 | # context not implemented 29 | return urlopen(url, data=data) 30 | else: 31 | return urlopen(url, data=data, context=context) 32 | 33 | def encode_multipart(fields, files, boundary=None): 34 | """ 35 | Encodes a file in order to send it as an answer to a form 36 | """ 37 | import mimetypes 38 | import random 39 | import string 40 | 41 | _BOUNDARY_CHARS = string.digits + string.ascii_letters 42 | 43 | # see http://code.activestate.com/recipes/578668-encode-multipart-form-data-for-uploading-files-via/ 44 | def escape_quote(s): 45 | return s.replace('"', '\\"') 46 | 47 | if boundary is None: 48 | boundary = ''.join(random.choice(_BOUNDARY_CHARS) for i in range(30)) 49 | lines = [] 50 | 51 | for name, value in fields.items(): 52 | lines.extend(( 53 | '--{0}'.format(boundary), 54 | 'Content-Disposition: form-data; name="{0}"'.format(escape_quote(name)), 55 | '', 56 | str(value), 57 | )) 58 | 59 | for name, value in files.items(): 60 | filename = value['filename'] 61 | if 'mimetype' in value: 62 | mimetype = value['mimetype'] 63 | else: 64 | mimetype = mimetypes.guess_type(filename)[0] or 'application/octet-stream' 65 | lines.extend(( 66 | '--{0}'.format(boundary), 67 | 'Content-Disposition: form-data; name="{0}"; filename="{1}"'.format( 68 | escape_quote(name), escape_quote(filename)), 69 | 'Content-Type: {0}'.format(mimetype), 70 | '', 71 | value['content'], 72 | )) 73 | 74 | lines.extend(( 75 | '--{0}--'.format(boundary), 76 | '', 77 | )) 78 | body = '\r\n'.join(lines) 79 | 80 | headers = { 81 | 'Content-Type': 'multipart/form-data; boundary={0}'.format(boundary), 82 | 'Content-Length': str(len(body)), 83 | } 84 | 85 | return (body, headers) 86 | 87 | 88 | class DictBits(object): 89 | """ Helper class for bit mappings 90 | 91 | >>> test = DictBits( {0:"zero", 1:"one", 2:"two", 3:"three"} ) 92 | >>> test.toArray(5) 93 | ['zero', 'two'] 94 | >>> test.toInt( ["two","three"] ) 95 | 12 96 | >>> test.toArray(100) 97 | ... 98 | KeyError: 5 99 | >>> test.toInt( ["one","abcde"] ) 100 | ... 101 | ValueError: option abcde not found 102 | """ 103 | 104 | def __init__(self, dict): 105 | """ 106 | :param dict: dictionary {bitpos1: "label1", bitpos2: "label2", ... } 107 | """ 108 | self.dict = dict 109 | self.values = dict.values() 110 | self.items = dict.items() 111 | 112 | def toInt(self, value): 113 | """ generate bitmask from labels 114 | 115 | :param value: array with labels to convert 116 | :returns: bitmask 117 | :throws: ValueError, if label is not in dict 118 | """ 119 | res = 0 120 | for v in value: 121 | if not v in self.values: 122 | raise ValueError("option %s not found" % v) 123 | k = [key for key, value in self.items if value == v][0] 124 | res |= (1 << k) 125 | return res 126 | 127 | def toArray(self, value): 128 | """ create array of labels from bitmask 129 | 130 | :param value: integer with bitmask 131 | :returns: array of labels 132 | :throws: KeyError, if a bit is set in value whose position is not mentioned in the dict 133 | """ 134 | res = [] 135 | pos = 0 136 | while value != 0: 137 | if value & 1: 138 | res.append(self.dict[pos]) 139 | value >>= 1 140 | pos += 1 141 | return res 142 | 143 | 144 | # same for: motion detection, IO alarm 145 | BD_alarmAction = DictBits({0: "ring", 1: "mail", 2: "picture", 3: "video"}) 146 | 147 | 148 | class DictChar(object): 149 | def __init__(self, dict): 150 | self.dict = dict 151 | self.keys = dict.keys() 152 | self.values = dict.values() 153 | self.items = dict.items() 154 | 155 | def get(self, char, default=None): 156 | return self.dict.get(char, default) 157 | 158 | def lookup(self, v): 159 | """ lookup value in keys and items of the dict and return the key 160 | :param v: value to look up 161 | :returns: the key 162 | :throws: ValueError if value could not be found 163 | . note:: this way the URL parameter could be set by either cleartext or the key 164 | """ 165 | if v in self.keys: return v 166 | if not v in self.values: 167 | raise ValueError("option %s not found" % v) 168 | k = [key for key, value in self.items if value == v][0] 169 | return k 170 | 171 | 172 | DC_WifiEncryption = DictChar({"0": "Open Mode", "1": "WEP", "2": "WPA", "3": "WPA2", "4": "WPA/WPA2"}) 173 | DC_WifiAuth = DictChar({"0": "Open Mode", "1": "Shared key", "2": "Auto mode"}) 174 | DC_motionDetectSensitivity = DictChar({"0": "low", "1": "normal", "2": "high", "3": "lower", "4": "lowest"}) 175 | DC_ddnsServer = DictChar({"0": "Factory DDNS", "1": "Oray", "2": "3322", "3": "no-ip", "4": "dyndns"}) 176 | DC_ptzSpeedList = DictChar({"4": 'very slow', "3": 'slow', "2": 'normal speed', "1": 'fast', "0": 'very fast'}) 177 | DC_logtype = DictChar({"0": "System startup", "3": "Login", "4": "Logout", "5": "User offline"}) 178 | DC_FtpMode = DictChar({"0": "PASV", "1": "PORT"}) 179 | DC_SmtpTlsMode = DictChar({"0": "None", "1": "TLS", "2": "STARTTLS"}) 180 | DC_timeSource = DictChar({"0": "NTP server", "1": "manually"}) 181 | DC_timeDateFormat = DictChar({"0": "YYYY-MM-DD", "1": "DD/MM/YYYY", "2": "MM/DD/YYYY"}) 182 | DC_timeFormat = DictChar({"0": "12 hours", "1": "24 hours"}) 183 | DC_infraLedMode = DictChar({"0": "auto", "1": "manuel"}) 184 | 185 | def array2dict(source, keyprefix, convertFunc=None): 186 | """ convert an array to dict 187 | :param keyprefix: key prefix used in the dict 188 | :param convertFunc: function to be called to convert value prior to storing it 189 | .. note:: used to create param dicts for sendcommand 190 | """ 191 | res = {} 192 | count = 0 193 | for s in source: 194 | if not convertFunc is None: 195 | s = convertFunc(s) 196 | res["%s%s" % (keyprefix, count)] = s 197 | count += 1 198 | return res 199 | 200 | 201 | def arrayTransform(source, convertFunc): 202 | res = [] 203 | for x in source: 204 | res.append(convertFunc(x)) 205 | return res 206 | 207 | 208 | def binaryarray2int(source): 209 | """ helper to convert array with binary strings (e.g. schedules) to integer 210 | 211 | :param source: the array with binary strings to convert 212 | :returns array with integers 213 | """ 214 | return arrayTransform(source, lambda x: int(x, 2)) 215 | 216 | 217 | def ip2long(ip): 218 | """ Convert an IP string to long 219 | """ 220 | return struct.unpack(" 431 | .... 432 | .... 433 | ... 434 | 435 | 436 | .. Note:: Firmware versions before 1.11.1.18 did not escape special chars and 437 | could result in malformed XML files. 438 | Since then, special chars are urlencoded 439 | """ 440 | res = {} 441 | 442 | dom = xml.dom.minidom.parseString(xmldata) 443 | xmldata = dom.getElementsByTagName("CGI_Result") 444 | assert len(xmldata) == 1, "only one CGI_Result tag allowed" 445 | root = xmldata[0] 446 | for ele in root.childNodes: 447 | if ele.nodeType == ele.ELEMENT_NODE: 448 | xmldata = "" 449 | for sele in ele.childNodes: 450 | if sele.nodeType == sele.TEXT_NODE: 451 | xmldata = xmldata + sele.nodeValue 452 | xmldata = unquote(xmldata) 453 | res[ele.nodeName] = xmldata 454 | 455 | if not doBool is None: 456 | for p in doBool: 457 | if p in res: 458 | if res[p] == "1": res[p] = True 459 | if res[p] == "0": res[p] = False 460 | 461 | return res 462 | 463 | def sendcommand(self, cmd, param=None, raw=False, doBool=None, headers=None, data=None): 464 | """ send command to camera and return result 465 | 466 | :param cmd: command without parameters 467 | :param param:dictionary of parameter, e.g. {key1: value1, key2: value2, ...} 468 | if a value is None, it will not be encoded 469 | :param raw: if raw, return result as is, not decoded as :class:resultObj 470 | :param doBool: array of names 471 | if results contains these settings, try to convert them to boolean values 472 | if param contains these settings, convert bool to "1"/"0" 473 | :param headers: headers of the request (used in POST) 474 | :param data: data used for POST 475 | :return: resultObj with decoded data or raw data 476 | """ 477 | 478 | if param is None: param = {} 479 | 480 | # convert boolean to "0"/"1" 481 | if not doBool is None: 482 | for p in param: 483 | if p in doBool: 484 | if param[p] is True: param[p] = "1" 485 | if param[p] is False: param[p] = "0" 486 | 487 | pa = {"cmd": cmd, "usr": self.user, "pwd": self.password} 488 | 489 | # add params not set to None 490 | for p in param: 491 | if not param[p] is None: 492 | pa[p] = param[p] 493 | 494 | ps = urlencode(pa) 495 | 496 | if self.consoleDump: 497 | print("%s?%s\n\n" % (self.base, ps)) 498 | if not self.debugfile is None: self.debugfile.write("%s?%s\n\n" % (self.base, ps)) 499 | url = self.base + "?" + ps 500 | 501 | if headers is None: 502 | retdata = my_urlopen(url, data=data, context=self.context).read() 503 | else: 504 | request = Request(url, data=data, headers=headers) 505 | retdata = my_urlopen(request, context=self.context).read() 506 | 507 | if self.consoleDump: 508 | print("%s\n\n" % retdata) 509 | if not self.debugfile is None: self.debugfile.write("%s\n\n" % (retdata)) 510 | 511 | if raw: 512 | return retdata 513 | 514 | res = self.decodeResult(retdata, doBool=doBool) 515 | reso = ResultObj(res) 516 | return reso 517 | 518 | # image settings 519 | def getImageSetting(self): 520 | return self.sendcommand("getImageSetting") 521 | 522 | def setBrightness(self, brightness): 523 | return self.sendcommand("setBrightness", {'brightness': brightness}) 524 | 525 | def setContrast(self, contrast): 526 | return self.sendcommand("setContrast", {'contrast': contrast}) 527 | 528 | def setHue(self, hue): 529 | return self.sendcommand("setHue", {'hue': hue}) 530 | 531 | def setSaturation(self, saturation): 532 | return self.sendcommand("setSaturation", {'saturation': saturation}) 533 | 534 | def setSharpness(self, sharpness): 535 | return self.sendcommand("setSharpness", {'sharpness': sharpness}) 536 | 537 | def resetImageSetting(self): 538 | return self.sendcommand("resetImageSetting") 539 | 540 | def getMirrorAndFlipSetting(self): 541 | return self.sendcommand("getMirrorAndFlipSetting", doBool=['isMirror', 'isFlip']) 542 | 543 | def mirrorVideo(self, isMirror): 544 | return self.sendcommand("mirrorVideo", {'isMirror': isMirror}, doBool=["isMirror"]) 545 | 546 | def flipVideo(self, isFlip): 547 | return self.sendcommand("flipVideo", {'isFlip': isFlip}, doBool=["isFlip"]) 548 | 549 | def setPwrFreq(self, is50hz): 550 | """ set power frequency of sensor 551 | ;param is50hz: True: 50 Hz, False: 60 Hz 552 | """ 553 | return self.sendcommand("setPwrFreq", {'freq': is50hz}, doBool=["freq"]) 554 | 555 | def getVideoStreamParam(self): 556 | """ 557 | isVBR not yet implemented by firmware 558 | """ 559 | return self.sendcommand("getVideoStreamParam", doBool=['isVBR']) 560 | 561 | def setVideoStreamParam(self, streamType, bitRate, frameRate, GOP, isVBR): 562 | """ 563 | isVBR not yet implemented by firmware 564 | """ 565 | return self.sendcommand("setVideoStreamParam", 566 | {'streamType': streamType, 'bitRate': bitRate, ' frameRate': frameRate, 'GOP': GOP, 567 | 'isVBR': isVBR}) 568 | 569 | def getMainVideoStreamType(self): 570 | return self.sendcommand("getMainVideoStreamType") 571 | 572 | def getSubVideoStreamType(self): 573 | return self.sendcommand("getSubVideoStreamType", doBool=["isVBR0", "isVBR1", "isVBR2", "isVBR3"]) 574 | 575 | def setMainVideoStreamType(self, streamType): 576 | return self.sendcommand("setMainVideoStreamType", {'streamType': streamType}) 577 | 578 | def setSubVideoStreamType(self, format): 579 | """ format: 0: H264, 1=MJpeg 580 | """ 581 | return self.sendcommand("setSubVideoStreamType", {'format': format}) 582 | 583 | def getMJStream(self): 584 | """ 585 | :returns: URL of MJPEG-Stream 586 | .. note: URL will return error 500 if substream has not been switched to MJPEG 587 | """ 588 | return self.MJStreamURL 589 | 590 | def getRTSPStream(self): 591 | return self.RTSPStreamURL 592 | 593 | def getOsdSetting(self): 594 | return self.sendcommand("getOSDSetting", doBool=["isEnableTimeStamp", "isEnableDevName", "isEnableOSDMask"]) 595 | 596 | def setOsdSetting(self, isEnableTimeStamp, isEnableDevName, dispPos): 597 | """ 598 | .. note: The parameter isEnableOSDMask which is described in the API has no effect. See setOsdMask 599 | """ 600 | return self.sendcommand("setOSDSetting", 601 | param={'isEnableTimeStamp': isEnableTimeStamp, 'isEnableDevName': isEnableDevName, 602 | 'dispPos': dispPos}, 603 | doBool=["isEnableTimeStamp", "isEnableDevName"]) 604 | 605 | def setOsdMask(self, isEnableOSDMask): 606 | """ set/reset para,eter isEnableOSDMask 607 | .. note: This is an undocumented CGI command 608 | """ 609 | return self.sendcommand("setOSDMask", 610 | param={'isEnableOSDMask': isEnableOSDMask}, 611 | doBool=["isEnableOSDMask"]) 612 | 613 | def getOsdMask(self): 614 | """ 615 | .. note: This is an undocumented CGI command 616 | """ 617 | return self.sendcommand("getOSDMask", doBool=["isEnableTimeStamp", "isEnableDevName", "isEnableOSDMask"]) 618 | 619 | def getOsdMaskArea(self): 620 | w = self.sendcommand("getOsdMaskArea") 621 | cnt = 0 622 | areas = {} 623 | error = False 624 | while True: 625 | p1 = w.get("x1_%s" % cnt) 626 | p2 = w.get("y1_%s" % cnt) 627 | p3 = w.get("x2_%s" % cnt) 628 | p4 = w.get("y2_%s" % cnt) 629 | 630 | if not (p1 is None or p2 is None or p3 is None or p4 is None): 631 | try: 632 | areas[cnt] = (int(p1), int(p2), int(p3), int(p4)) 633 | except ValueError: 634 | error = True # something is seriously wrong (new firmware?) 635 | else: 636 | break 637 | cnt += 1 638 | 639 | if not error: w.set("decoded_areas", areas) 640 | return w 641 | 642 | def setOsdMaskArea(self, areas): 643 | """ set OSD areas 644 | 645 | convert the following structure to the cameras notation: 646 | - there are 4 areas available 647 | - each area is defined by the coordinates of the upper left und bottom right point 648 | - Encoded as: {0: (Xtl, Ytl, Xbr, Xby), 1: ..., 3: None) 649 | - None means: (0,0,0,0) 650 | """ 651 | maxareas = 4 652 | 653 | # make sure all areas are covered 654 | for a in range(maxareas): # 0,1,2,3 655 | if not a in areas: 656 | areas[a] = None 657 | 658 | # convert None to (0,0,0,0) 659 | for a in areas: 660 | if areas[a] is None: 661 | areas[a] = (0, 0, 0, 0) 662 | 663 | # construct parameters 664 | params = {} 665 | for a in range(maxareas): # 0..3 666 | params["x1_%s" % a] = areas[a][0] 667 | params["y1_%s" % a] = areas[a][1] 668 | params["x2_%s" % a] = areas[a][2] 669 | params["y2_%s" % a] = areas[a][3] 670 | 671 | return self.sendcommand("setOsdMaskArea", param=params) 672 | 673 | def getMotionDetectConfig(self): 674 | return self.sendcommand("getMotionDetectConfig", doBool=["isEnable"]) 675 | 676 | def setMotionDetectConfig(self, isEnable, linkage, snapInterval, triggerInterval, sensitivity, schedules, areas): 677 | param = {"isEnable": isEnable, 678 | "linkage": linkage, 679 | "snapInterval": snapInterval, 680 | "triggerInterval": triggerInterval, 681 | "sensitivity": sensitivity} 682 | for day in range(7): 683 | param["schedule%s" % day] = schedules[day] 684 | for row in range(10): 685 | param["area%s" % row] = areas[row] 686 | 687 | return self.sendcommand("setMotionDetectConfig", param=param, doBool=["isEnable"]) 688 | 689 | # ptz commands 690 | def ptzReset(self): 691 | return self.sendcommand("ptzReset") 692 | 693 | def ptzMoveDown(self): 694 | return self.sendcommand("ptzMoveDown") 695 | 696 | def ptzMoveUp(self): 697 | return self.sendcommand("ptzMoveUp") 698 | 699 | def ptzMoveLeft(self): 700 | return self.sendcommand("ptzMoveLeft") 701 | 702 | def ptzMoveTopLeft(self): 703 | return self.sendcommand("ptzMoveTopLeft") 704 | 705 | def ptzMoveBottomLeft(self): 706 | return self.sendcommand("ptzMoveBottomLeft") 707 | 708 | def ptzMoveRight(self): 709 | return self.sendcommand("ptzMoveRight") 710 | 711 | def ptzMoveTopRight(self): 712 | return self.sendcommand("ptzMoveTopRight") 713 | 714 | def ptzMoveBottomRight(self): 715 | return self.sendcommand("ptzMoveBottomRight") 716 | 717 | def ptzStopRun(self): 718 | return self.sendcommand("ptzStopRun") 719 | 720 | def getPTZPresetPointList(self): 721 | return self.sendcommand("getPTZPresetPointList") 722 | 723 | def getPTZSpeed(self): 724 | return self.sendcommand("getPTZSpeed") 725 | 726 | def setPTZSpeed(self, speed): 727 | return self.sendcommand("setPTZSpeed", {"speed": speed}) 728 | 729 | def getPTZSelfTestMode(self): 730 | return self.sendcommand("getPTZSelfTestMode") 731 | 732 | def setPTZSelfTestMode(self, mode): 733 | return self.sendcommand("setPTZSelfTestMode", param={"mode": mode}) 734 | 735 | def getPTZPrePointForSelfTest(self): 736 | return self.sendcommand("getPTZPrePointForSelfTest") 737 | 738 | def setPTZPrePointForSelfTest(self, name): 739 | return self.sendcommand("setPTZPrePointForSelfTest", param={"name": name}) 740 | 741 | def get485Info(self): 742 | return self.sendcommand("get485Info") 743 | 744 | def set485Info(self, rs485Protocol, rs485Addr, rs485Baud, rs485DataBit, rs485StopBit, rs485Check): 745 | param = {"rs485Protocol": rs485Protocol, 746 | "rs485Addr": rs485Addr, 747 | "rs485Baud": rs485Baud, 748 | "rs485DataBit": rs485DataBit, 749 | "rs485StopBit": rs485StopBit, 750 | "rs485Check": rs485Check} 751 | 752 | return self.sendcommand("set485Info", param=param) 753 | 754 | def getIPInfo(self): 755 | return self.sendcommand("getIPInfo", doBool=["isDHCP"]) 756 | 757 | def setIPInfo(self, isDHCP, ip, gate, mask, dns1, dns2): 758 | """ 759 | .. note:: system will reboot after successful completion 760 | """ 761 | param = {"isDHCP": isDHCP, 762 | "ip": ip, 763 | "gate": gate, 764 | "mask": mask, 765 | "dns1": dns1, 766 | "dns2": dns2} 767 | return self.sendcommand("setIpInfo", param=param, doBool=["isDHCP"]) 768 | 769 | def zoomIn(self): 770 | return self.sendcommand("zoomIn") 771 | 772 | def zoomOut(self): 773 | return self.sendcommand("zoomOut") 774 | 775 | def zoomStop(self): 776 | return self.sendcommand("zoomStop") 777 | 778 | def setSnapSetting(self, quality, location): 779 | return self.sendcommand("setSnapSetting", {"snapPicQuality": quality, "saveLocation": location}) 780 | 781 | def getWifiConfig(self): 782 | return self.sendcommand("getWifiConfig", doBool=["isEnable", "isUseWifi", "isConnected"]) 783 | 784 | def refreshWifiList(self): 785 | """ 786 | .. note:: action can take about 20 secs 787 | """ 788 | return self.sendcommand("refreshWifiList") 789 | 790 | def getWifiList(self, startNo=None): 791 | return self.sendcommand("getWifiList", param={"startNo": startNo}) 792 | 793 | def rebootSystem(self): 794 | return self.sendcommand("rebootSystem") 795 | 796 | def restoreToFactorySetting(self): 797 | return self.sendcommand("restoreToFactorySetting") 798 | 799 | def exportConfig(self): 800 | """ queries the camera for a blob with all settings 801 | 802 | :return: tuple with data and filename (provided by the camera) or None (in case of an error) 803 | """ 804 | w = self.sendcommand("exportConfig") 805 | 806 | if w.result == 0: 807 | link = "/configs/export/%s" % w.fileName 808 | link2 = urljoin(self.base, link) 809 | data = my_urlopen(link2, context=self.context).read() 810 | return (data, w.fileName) 811 | else: 812 | return None 813 | 814 | def importConfig(self, filedata, filename): 815 | """ send config file to camera 816 | :param filedata: binary content of the config file 817 | :param filename: filename of the config file 818 | .. note:: camera will reboot after successful upload and not be responsive for some time 819 | """ 820 | fields = {'submit': 'import'} 821 | files = {'file': {'filename': filename, 'content': filedata}} 822 | data, headers = encode_multipart(fields, files) 823 | return self.sendcommand("importConfig", headers=headers, data=data) 824 | 825 | def snapPicture(self): 826 | """ queries the camera for a snapshot 827 | 828 | :return: html file with link to image 829 | """ 830 | 831 | return self.sendcommand("snapPicture", raw=True) 832 | 833 | def snapPicture2(self): 834 | """ queries the camera for a snapshot 835 | 836 | :return: binary data 837 | 838 | The firmware function has a bug which cuts off the image after 512,000 bytes. 839 | """ 840 | return self.sendcommand("snapPicture2") 841 | 842 | def infraLed(self, state): 843 | """ switches the IR-LED on or off 844 | .. note:: Depending on the camera configuration, this may not be possible. 845 | :param state: on (true) or off (false) 846 | :return: resultObj 847 | """ 848 | if state: 849 | return self.sendcommand("openInfraLed") 850 | else: 851 | return self.sendcommand("closeInfraLed") 852 | 853 | def getInfraLedConfig(self): 854 | res = self.sendcommand("getInfraLedConfig") 855 | res.stringLookupConv(res.mode, DC_infraLedMode, "_mode") 856 | return res 857 | 858 | def setInfraLedConfig(self, auto): 859 | if auto: 860 | return self.sendcommand("setInfraLedConfig", {"mode": 0}) 861 | else: 862 | return self.sendcommand("setInfraLedConfig", {"mode": 1}) 863 | 864 | def getDevInfo(self): 865 | return self.sendcommand("getDevInfo") 866 | 867 | def getDevName(self): 868 | return self.sendcommand("getDevName") 869 | 870 | def setDevName(self, name): 871 | return self.sendcommand("setDevName", {"devName": name}) 872 | 873 | def setWifiSetting(self, enable, useWifi, ap, encr, psk, auth, 874 | defaultKey, key1, key2, key3, key4, key1len, key2len, key3len, key4len): 875 | self.sendcommand("setWifiSetting", { 876 | "isEnable": enable, 877 | "isUseWifi": useWifi, 878 | "ssid": ap, 879 | "netType": 0, 880 | "encryptType": encr, 881 | "psk": psk, 882 | "authMode": auth, 883 | "defaultKey": defaultKey, 884 | "key1": key1, 885 | "key2": key2, 886 | "key3": key3, 887 | "key4": key4, 888 | "key1len": key1len, 889 | "key2len": key2len, 890 | "key3len": key3len, 891 | "key4len": key4len 892 | }) 893 | 894 | def ptzAddPresetPoint(self, name): 895 | return self.sendcommand("ptzAddPresetPoint", {"name": name}) 896 | 897 | def ptzDeletePresetPoint(self, name): 898 | return self.sendcommand("ptzDeletePresetPoint", {"name": name}) 899 | 900 | def ptzGotoPresetPoint(self, name): 901 | return self.sendcommand("ptzGotoPresetPoint", {"name": name}) 902 | 903 | def ptzGetCruiseMapList(self): 904 | return self.sendcommand("ptzGetCruiseMapList") 905 | 906 | def ptzGetCruiseMapInfo(self, name): 907 | return self.sendcommand("ptzGetCruiseMapInfo", {"name": name}) 908 | 909 | def ptzSetCruiseMap(self, name, points): 910 | param = {"name": name} 911 | param.update(array2dict(points, "point")) 912 | return self.sendcommand("ptzSetCruiseMap", param=param) 913 | 914 | def ptzDelCruiseMap(self, name): 915 | return self.sendcommand("ptzDelCruiseMap", param={"name": name}) 916 | 917 | def ptzStartCruise(self, mapName): 918 | return self.sendcommand("ptzStartCruise", param={"mapName": mapName}) 919 | 920 | def ptzStopCruise(self): 921 | return self.sendcommand("ptzStopCruise") 922 | 923 | def getDevState(self): 924 | return self.sendcommand("getDevState") 925 | 926 | def getSnapConfig(self): 927 | return self.sendcommand("getSnapConfig") 928 | 929 | def setSnapConfig(self, quality, location): 930 | return self.sendcommand("setSnapConfig", {"snapPicQuality": quality, "saveLocation": location}) 931 | 932 | def getScheduleSnapConfig(self): 933 | return self.sendcommand("getScheduleSnapConfig", doBool=["isEnable"]) 934 | 935 | def setScheduleSnapConfig(self, isEnable, snapInterval, schedules): 936 | param = {"isEnable": isEnable, 937 | "snapInterval": snapInterval} 938 | for day in range(7): 939 | param["schedule%s" % day] = schedules[day] 940 | return self.sendcommand("setScheduleSnapConfig", param=param, doBool=["isEnable"]) 941 | 942 | def getRecordList(self, recordPath=None, startTime=None, endTime=None, recordType=None, startNo=None): 943 | param = {"recordPath": recordPath, 944 | "startTime": startTime, 945 | "endTime": endTime, 946 | "recordType": recordType, 947 | "startNo": startNo} 948 | return self.sendcommand("getRecordList", param=param) 949 | 950 | def getAlarmRecordConfig(self): 951 | return self.sendcommand("getAlarmRecordConfig", doBool=["isEnablePreRecord"]) 952 | 953 | def setAlarmRecordConfig(self, isEnablePreRecord, preRecordSecs, alarmRecordSecs): 954 | param = {"isEnablePreRecord": isEnablePreRecord, 955 | "preRecordSecs": preRecordSecs, 956 | "alarmRecordSecs": alarmRecordSecs} 957 | return self.sendcommand("setAlarmRecordConfig", param=param, doBool=["isEnablePreRecord"]) 958 | 959 | def getIOAlarmConfig(self): 960 | return self.sendcommand("getIOAlarmConfig", doBool=["isEnable"]) 961 | 962 | def setIOAlarmConfig(self, isEnable, linkage, alarmLevel, snapInterval, triggerInterval, schedules): 963 | param = {"isEnable": isEnable, 964 | "linkage": linkage, 965 | "alarmLevel": alarmLevel, 966 | "snapInterval": snapInterval, 967 | "triggerInterval": triggerInterval 968 | } 969 | param.update(array2dict(schedules, "schedule")) 970 | return self.sendcommand("setIOAlarmConfig", param=param, doBool=["isEnable"]) 971 | 972 | def clearIOAlarmOutput(self): 973 | return self.sendcommand("clearIOAlarmOutput") 974 | 975 | def getMultiDevList(self): 976 | return self.sendcommand("getMultiDevList") 977 | 978 | def getMultiDevDetailInfo(self, channel): 979 | return self.sendcommand("getMultiDevDetailInfo", param={"chnnl": channel}) 980 | 981 | def addMultiDev(self, channel, productType, ip, port, mediaPort, userName, passWord, devName): 982 | return self.sendcommand("addMultiDev", param={"chnnl": channel, 983 | "productType": productType, 984 | "ip": ip, 985 | "port": port, 986 | "mediaPort": mediaPort, 987 | "userName": userName, 988 | "passWord": passWord, 989 | "devName": devName}) 990 | 991 | def delMultiDev(self, channel): 992 | return self.sendcommand("delMultiDev", param={"chnnl": channel}) 993 | 994 | def addAccount(self, usrName, usrPwd, privilege): 995 | return self.sendcommand("addAccount", param={"usrName": usrName, "usrPwd": usrPwd, "privilege": privilege}) 996 | 997 | def delAccount(self, usrName): 998 | return self.sendcommand("delAccount", param={"usrName": usrName}) 999 | 1000 | def changePassword(self, usrName, oldPwd, newPwd): 1001 | return self.sendcommand("changePassword", param={"usrName": usrName, "oldPwd": oldPwd, "newPwd": newPwd}) 1002 | 1003 | def changeUserName(self, usrName, newUsrName): 1004 | return self.sendcommand("changeUserName", param={"usrName": usrName, "newUsrName": newUsrName}) 1005 | 1006 | def getSessionList(self): 1007 | return self.sendcommand("getSessionList") 1008 | 1009 | def getUserList(self): 1010 | return self.sendcommand("getUserList") 1011 | 1012 | def logIn(self, name, ip=None, groupId=None): 1013 | param = {"usrName": name} 1014 | if not ip is None: param["ip"] = ip 1015 | if not groupId is None: param["groupId"] = groupId 1016 | r = self.sendcommand("logIn", param) 1017 | if r.result == 0: 1018 | if not r.logInResult is None: 1019 | r.set("result", -int(r.logInResult)) 1020 | return r 1021 | 1022 | def logOut(self, name, ip=None, groupId=None): 1023 | param = {"usrName": name} 1024 | if not ip is None: param["ip"] = ip 1025 | if not groupId is None: param["groupId"] = groupId 1026 | r = self.sendcommand("logOut", param) 1027 | return r 1028 | 1029 | def usrBeatHeart(self, usrName, remoteIp=None, groupId=None): 1030 | return self.sendcommand("usrBeatHeart", param={"usrName": usrName, "remoteIp": remoteIp, "groupId": groupId}) 1031 | 1032 | def getFirewallConfig(self): 1033 | return self.sendcommand("getFirewallConfig", doBool=["isEnable"]) 1034 | 1035 | def setFirewallConfig(self, isEnable, rule, ipList): 1036 | param = {"isEnable": isEnable, 1037 | "rule": rule} 1038 | ips = array2dict(ipList, "ipList") 1039 | param.update(ips) 1040 | return self.sendcommand("setFirewallConfig", param=param, doBool=["isEnable"]) 1041 | 1042 | def getLog(self, offset=None, count=None): 1043 | return self.sendcommand("getLog", {"offset": offset, "count": count}) 1044 | 1045 | def getPortInfo(self): 1046 | return self.sendcommand("getPortInfo") 1047 | 1048 | def setPortInfo(self, webPort, mediaPort, httpsPort, onvifPort): 1049 | return self.sendcommand("setPortInfo", 1050 | param={"webPort": webPort, "mediaPort": mediaPort, "httpsPort": httpsPort, 1051 | "onvifPort": onvifPort}) 1052 | 1053 | def getUPnPConfig(self): 1054 | return self.sendcommand("getUPnPConfig", doBool=["isEnable"]) 1055 | 1056 | def setUPnPConfig(self, enable): 1057 | return self.sendcommand("setUPnPConfig", param={"isEnable": enable}, doBool=["isEnable"]) 1058 | 1059 | def getDDNSConfig(self): 1060 | return self.sendcommand("getDDNSConfig", doBool=["isEnable"]) 1061 | 1062 | def setDDNSConfig(self, isEnable, hostName, ddnsServer, user, password): 1063 | param = {"isEnable": isEnable, 1064 | "hostName": hostName, 1065 | "ddnsServer": ddnsServer, 1066 | "user": user, 1067 | "password": password} 1068 | return self.sendcommand("setDDNSConfig", param=param, doBool=["isEnable"]) 1069 | 1070 | def getFTPConfig(self): 1071 | return self.sendcommand("getFtpConfig") 1072 | 1073 | def setFTPConfig(self, ftpAddr, ftpPort, mode, userName, password): 1074 | param = {"ftpAddr": ftpAddr, 1075 | "ftpPort": ftpPort, 1076 | "mode": mode, 1077 | "userName": userName, 1078 | "password": password} 1079 | return self.sendcommand("setFtpConfig", param=param) 1080 | 1081 | def testFTPServer(self, ftpAddr, ftpPort, mode, userName, password): 1082 | param = {"ftpAddr": ftpAddr, 1083 | "ftpPort": ftpPort, 1084 | "mode": mode, 1085 | "userName": userName, 1086 | "password": password} 1087 | return self.sendcommand("testFtpServer", param=param) 1088 | 1089 | def getSMTPConfig(self): 1090 | return self.sendcommand("getSMTPConfig", doBool=["isEnable", "isNeedAuth"]) 1091 | 1092 | def setSMTPConfig(self, isEnable, server, port, isNeedAuth, tls, user, password, sender, receiver): 1093 | param = {"isEnable": isEnable, 1094 | "server": server, 1095 | "port": port, 1096 | "isNeedAuth": isNeedAuth, 1097 | "tls": tls, 1098 | "user": user, 1099 | "password": password, 1100 | "sender": sender, 1101 | "reciever": receiver} 1102 | return self.sendcommand("setSMTPConfig", param=param, doBool=["isEnable", "isNeedAuth"]) 1103 | 1104 | def SMTPTest(self, server, port, isNeedAuth, tls, user, password): 1105 | param = {"server": server, 1106 | "port": port, 1107 | "isNeedAuth": isNeedAuth, 1108 | "tls": tls, 1109 | "user": user, 1110 | "password": password} 1111 | return self.sendcommand("smtpTest", param=param, doBool=["isNeedAuth"]) 1112 | 1113 | def getSystemTime(self): 1114 | return self.sendcommand("getSystemTime", doBool=["isDst"]) 1115 | 1116 | def setSystemTime(self, timeSource, ntpServer, dateFormat, timeFormat, timeZone, isDst, dst, year, month, day, hour, 1117 | min, sec): 1118 | param = {"timeSource": timeSource, 1119 | "ntpServer": ntpServer, 1120 | "dateFormat": dateFormat, 1121 | "timeFormat": timeFormat, 1122 | "timeZone": timeZone, 1123 | "isDst": isDst, 1124 | "dst": dst, 1125 | "year": year, 1126 | "month": month, 1127 | "day": day, 1128 | "hour": hour, 1129 | "min": min, 1130 | "sec": sec} 1131 | return self.sendcommand("setSystemTime", param=param, doBool=["isDst"]) 1132 | 1133 | 1134 | class Cam(CamBase): 1135 | """ extended interface 1136 | 1137 | Some more conversions: 1138 | - Conversion are usually stored in a parameter with the same name prefixed by "_" 1139 | - Most "extended" functions use the name of the base functions followed by "_proc" 1140 | """ 1141 | 1142 | def ptzMove(self, direction): 1143 | """ move camera into given direction or (h)ome 1144 | :param direction: 1145 | :return: resultObj 1146 | 1147 | The directions are: 1148 | . n 1149 | . nw ne 1150 | . w h e 1151 | . sw se 1152 | . s 1153 | 1154 | """ 1155 | matrix = {'n': self.ptzMoveUp, 'ne': self.ptzMoveTopRight, 'e': self.ptzMoveRight, 1156 | 'se': self.ptzMoveBottomRight, 1157 | 's': self.ptzMoveDown, 'sw': self.ptzMoveBottomLeft, 'w': self.ptzMoveLeft, 'nw': self.ptzMoveTopLeft, 1158 | 'h': self.ptzReset} 1159 | fkt = matrix.get(direction.lower()) 1160 | assert not fkt is None, "Invalid ptz direction" 1161 | return fkt() 1162 | 1163 | def snapPicture(self): 1164 | """ gets a snapshot from the camera 1165 | :returns: (binary data, filename from html) or (None, None) on error 1166 | .. note:: This higher function uses the :func:`snapPicture` API call, as :func:`snapPicture2` is currently 1167 | limited to 512,000 bytes (bug in firmware) 1168 | """ 1169 | w = CamBase.snapPicture(self) 1170 | # 1171 | if sys.version_info.major > 2: 1172 | w = w.decode("utf8") # Python3: result are bytes 1173 | res = re.search("img src=\"(.+)\"", w) 1174 | if res is None: return (None, None) 1175 | 1176 | link = res.group(1) 1177 | ipath = urlsplit(link).path 1178 | p = ipath.rfind("/") 1179 | 1180 | if p == -1: return (None, None) 1181 | fname = ipath[p + 1:] 1182 | 1183 | link2 = urljoin(self.base, link) 1184 | 1185 | data = my_urlopen(link2, context=self.context).read() 1186 | return (data, fname) 1187 | 1188 | def getPTZSpeed(self): 1189 | res = CamBase.getPTZSpeed(self) 1190 | res.stringLookupConv(res.speed, DC_ptzSpeedList, "_speed") 1191 | return res 1192 | 1193 | def getPTZPresetPointList(self): 1194 | """ queries the device for a list of preset points 1195 | 1196 | :return: unsorted python string list 1197 | """ 1198 | res = [] 1199 | w = CamBase.getPTZPresetPointList(self) 1200 | 1201 | try: 1202 | poicnt = int(w.cnt) 1203 | except ValueError: 1204 | return [] 1205 | 1206 | for x in range(poicnt): 1207 | d = w.get("point%s" % x) 1208 | res.append(d) 1209 | return res 1210 | 1211 | def activateOsdMaskArea(self, areas): 1212 | """ activates OSD mask areas 1213 | :param areas: area definition {0: (x1,y2,x2,y2), 1: ..., 3: ...} 1214 | """ 1215 | res = self.setOsdMask(isEnableOSDMask=True) 1216 | if res.result == 0: 1217 | self.setOsdMaskArea(areas) 1218 | 1219 | def deactivateOsdmask(self): 1220 | """ deactivates the OsdMask(s) 1221 | """ 1222 | res = self.setOsdMask(isEnableOSDMask=False) 1223 | if res.result == 0: 1224 | # send the command twice 1225 | # a single call does not switch it off reliably 1226 | self.setOsdMask(isEnableOSDMask=False) 1227 | 1228 | def getWifiList(self): 1229 | def toBool(s): 1230 | return s != "0" 1231 | 1232 | def conv(s): 1233 | if s == "": return None 1234 | ma = re.search("(.+)\+(.+?)\+(\d+)\+(\d+)\+(\d+)$", s) 1235 | if ma is None: return s 1236 | 1237 | return { 1238 | "sid": ma.group(1), 1239 | "mac": ma.group(2), 1240 | "quality": int(ma.group(3)), 1241 | "encrypted": toBool(ma.group(4)), 1242 | "encryption": DC_WifiEncryption.get(ma.group(5), "enctype %s" % ma.group(5)) 1243 | } 1244 | 1245 | res = CamBase.getWifiList(self) 1246 | total = int(res.totalCnt) 1247 | offset = 0 1248 | bigarray = [] 1249 | while offset < total: 1250 | res.collectArray("ap", "_ap", convertFunc=conv) 1251 | bigarray += res._ap 1252 | offset += 10 1253 | res.stringLookupConv(res.encryptType, DC_WifiEncryption, "_encryptType") 1254 | res.stringLookupConv(res.authType, DC_WifiAuth, "_authType") 1255 | res = CamBase.getWifiList(self, startNo=offset) 1256 | res.set("_ap", bigarray) 1257 | 1258 | return res 1259 | 1260 | def getWifiConfig(self): 1261 | res = CamBase.getWifiConfig(self) 1262 | res.stringLookupConv(res.encryptType, DC_WifiEncryption, "_encryptType") 1263 | res.stringLookupConv(res.authMode, DC_WifiAuth, "_authMode") 1264 | return res 1265 | 1266 | # this function sets WPA config only 1267 | def setWifiSettingWPA(self, enable, useWifi, ap, encr, psk, auth): 1268 | self.setWifiSetting(enable, useWifi, ap, encr, psk, auth, 1269 | 1, "", "", "", "", 64, 64, 64, 64) 1270 | 1271 | def getMotionDetectConfig(self): 1272 | """ get motion detection configuration with decoded information 1273 | 1274 | The following information is decoded: 1275 | _areas: 10x10 active areas in frame -> array of 10 strings, each strings contains 10 times "1"/"0" for active/non active 1276 | _schedules: 7 strings of 48 chars, one for each day (starting with monday) 1277 | each string contains "1"/"0" for each half hour of the day 1278 | _linkage: array of alarm action 1279 | """ 1280 | 1281 | res = CamBase.getMotionDetectConfig(self) 1282 | res.stringLookupConv(res.sensitivity, DC_motionDetectSensitivity, "_sensitivity") 1283 | 1284 | res.collectBinaryArray("schedule", "_schedules", 48) 1285 | res.collectBinaryArray("area", "_areas", 10) 1286 | res.DB_convert2array("linkage", "_linkage", BD_alarmAction) 1287 | 1288 | return res 1289 | 1290 | def setMotionDetectConfig(self, isEnable, linkage, snapInterval, triggerInterval, sensitivity, schedules, areas): 1291 | CamBase.setMotionDetectConfig(self, 1292 | isEnable, 1293 | BD_alarmAction.toInt(linkage), 1294 | snapInterval, 1295 | triggerInterval, 1296 | DC_motionDetectSensitivity.lookup(sensitivity), 1297 | binaryarray2int(schedules), 1298 | binaryarray2int(areas)) 1299 | 1300 | def getSnapConfig(self): 1301 | res = CamBase.getSnapConfig(self) 1302 | res.stringLookupSet(res.snapPicQuality, 1303 | {"0": "low", "1": "normal", "2": "high"}, 1304 | "_snapPicQuality") 1305 | res.stringLookupSet(res.saveLocation, 1306 | {"0": "SD card", "1": "reserved", "2": "FTP"}, 1307 | "_saveLocation") 1308 | return res 1309 | 1310 | def getScheduleSnapConfig(self): 1311 | """ get snap schedule configuration with decoded information 1312 | 1313 | The following information is decoded: 1314 | _schedules: 7 strings of 48 chars, one for each day (starting with monday) 1315 | each string contains "1"/"0" for each half hour of the day 1316 | """ 1317 | 1318 | res = CamBase.getScheduleSnapConfig(self) 1319 | res.collectBinaryArray("schedule", "_schedules", 48) 1320 | 1321 | return res 1322 | 1323 | def setScheduleSnapConfig(self, isEnable, snapInterval, schedules): 1324 | CamBase.setScheduleSnapConfig(self, 1325 | isEnable, 1326 | snapInterval, 1327 | binaryarray2int(schedules)) 1328 | 1329 | def getIOAlarmConfig(self): 1330 | res = CamBase.getIOAlarmConfig(self) 1331 | res.collectBinaryArray("schedule", "_schedules", 48) 1332 | res.DB_convert2array("linkage", "_linkage", BD_alarmAction) 1333 | return res 1334 | 1335 | def setIOAlarmConfig(self, isEnable, linkage, alarmLevel, snapInterval, triggerInterval, schedules): 1336 | res = CamBase.setIOAlarmConfig(self, 1337 | isEnable, 1338 | BD_alarmAction.toInt(linkage), 1339 | alarmLevel, 1340 | snapInterval, 1341 | triggerInterval, 1342 | binaryarray2int(schedules)) 1343 | return res 1344 | 1345 | def getFirewallConfig(self): 1346 | res = CamBase.getFirewallConfig(self) 1347 | res.collectArray("ipList", "_ipList", convertFunc=lambda x: long2ip(int(x))) 1348 | return res 1349 | 1350 | def setFirewallConfig(self, isEnable, rule, ipList): 1351 | return CamBase.setFirewallConfig(self, isEnable, rule, arrayTransform(ipList, convertFunc=lambda x: ip2long(x))) 1352 | 1353 | def getLog(self): 1354 | def conv(s): 1355 | """ convert log entry 1356 | :param s: single entry from log 1357 | :returns: returns the decoded entry, or None if the entry is empty, or the 1358 | original line, if it doesn't fit the format 1359 | 1360 | Example: 1384857415+admin+1929423040+4 1361 | - the first number is a unix time stamp 1362 | - followed by the user name 1363 | - followed by the IP (stored in little endian) 1364 | - followed by log type 1365 | """ 1366 | 1367 | if s == "": return None 1368 | ma = re.search("^(\d+)\+(.+?)\+(\d+)\+(\d+)$", s) 1369 | if ma is None: return s 1370 | 1371 | return ( 1372 | datetime.datetime.fromtimestamp(int(ma.group(1))), 1373 | ma.group(2), 1374 | long2ip(int(ma.group(3))), 1375 | DC_logtype.get(ma.group(4), "type %s" % ma.group(4)) 1376 | ) 1377 | 1378 | res = CamBase.getLog(self) 1379 | total = int(res.totalCnt) 1380 | curcnt = int(res.curCnt) 1381 | offset = 0 1382 | bigarray = [] 1383 | while offset < total: 1384 | res.collectArray("log", "_log", convertFunc=conv) 1385 | bigarray += res._log 1386 | offset += 10 1387 | res = CamBase.getLog(self, offset=offset) 1388 | res.set("_log", bigarray) 1389 | return res 1390 | 1391 | def ptzAddPresetPoint(self, name): 1392 | res = CamBase.ptzAddPresetPoint(self, name) 1393 | res.extendedResult("addResult") 1394 | return res 1395 | 1396 | def ptzDeletePresetPoint(self, name): 1397 | res = CamBase.ptzDeletePresetPoint(self, name) 1398 | res.extendedResult("deleteResult") 1399 | return res 1400 | 1401 | def ptzGetCruiseMapList(self): 1402 | res = CamBase.ptzGetCruiseMapList(self) 1403 | res.collectArray("map", "_maps", convertFunc=emptyStringNone) 1404 | res.extendedResult("getResult") 1405 | return res 1406 | 1407 | def ptzGetCruiseMapInfo(self, name): 1408 | res = CamBase.ptzGetCruiseMapInfo(self, name) 1409 | res.collectArray("point", "_points", convertFunc=emptyStringNone) 1410 | res.extendedResult("getResult") 1411 | return res 1412 | 1413 | def ptzSetCruiseMap(self, name, points): 1414 | res = CamBase.ptzSetCruiseMap(self, name, points) 1415 | res.extendedResult("setResult") 1416 | return res 1417 | 1418 | def ptzDelCruiseMap(self, name): 1419 | res = CamBase.ptzDelCruiseMap(self, name) 1420 | res.extendedResult("delResult") 1421 | return res 1422 | 1423 | def ptzStartCruise(self, mapName): 1424 | res = CamBase.ptzStartCruise(self, mapName) 1425 | res.extendedResult("startResult") 1426 | return res 1427 | 1428 | def getDDNSConfig(self): 1429 | res = CamBase.getDDNSConfig(self) 1430 | res.stringLookupConv(res.ddnsServer, DC_ddnsServer, "_ddnsServer") 1431 | return res 1432 | 1433 | def setDDNSConfig(self, isEnable, hostName, ddnsServer, user, password): 1434 | return CamBase.setDDNSConfig(self, isEnable, hostName, DC_ddnsServer.lookup(ddnsServer), user, password) 1435 | 1436 | def getFTPConfig(self): 1437 | res = CamBase.getFTPConfig(self) 1438 | res.stringLookupConv(res.mode, DC_FtpMode, "_mode") 1439 | return res 1440 | 1441 | def setFTPConfig(self, ftpAddr, ftpPort, mode, userName, password): 1442 | return CamBase.setFTPConfig(self, ftpAddr, ftpPort, DC_FtpMode.lookup(mode), userName, password) 1443 | 1444 | def testFTPServer(self, ftpAddr, ftpPort, mode, userName, password): 1445 | res = CamBase.testFTPServer(self, ftpAddr, ftpPort, DC_FtpMode.lookup(mode), userName, password) 1446 | res.extendedResult("testResult") 1447 | return res 1448 | 1449 | def getSMTPConfig(self): 1450 | res = CamBase.getSMTPConfig(self) 1451 | res.stringLookupConv(res.tls, DC_SmtpTlsMode, "_tls") 1452 | return res 1453 | 1454 | def setSMTPConfig(self, isEnable, server, port, isNeedAuth, tls, user, password, sender, receiver): 1455 | if type(receiver) == list: 1456 | receiver = ";".join(receiver) 1457 | return CamBase.setSMTPConfig(self, isEnable, server, port, isNeedAuth, tls, user, password, sender, receiver) 1458 | 1459 | def SMTPTest(self, server, port, isNeedAuth, tls, user, password): 1460 | res = CamBase.SMTPTest(self, server, port, isNeedAuth, DC_SmtpTlsMode.lookup(tls), user, password) 1461 | res.extendedResult("testResult") 1462 | return res 1463 | 1464 | def getSystemTime(self): 1465 | res = CamBase.getSystemTime(self) 1466 | res.stringLookupConv(res.timeSource, DC_timeSource, "_timeSource") 1467 | res.stringLookupConv(res.dateFormat, DC_timeDateFormat, "_dateFormat") 1468 | res.stringLookupConv(res.timeFormat, DC_timeFormat, "_timeFormat") 1469 | return res 1470 | 1471 | def setSystemTime(self, timeSource, ntpServer, dateFormat, timeFormat, timeZone, isDst, dst, year, month, day, hour, 1472 | min, sec): 1473 | return CamBase.setSystemTime(self, 1474 | DC_timeSource.lookup(timeSource), 1475 | ntpServer, 1476 | DC_timeDateFormat.lookup(dateFormat), 1477 | DC_timeFormat.lookup(timeFormat), 1478 | timeZone, isDst, dst, year, month, day, hour, min, sec) 1479 | 1480 | 1481 | if __name__ == "__main__": 1482 | pass 1483 | -------------------------------------------------------------------------------- /lowlevel/FoscDecoder.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from __future__ import print_function 5 | 6 | import struct 7 | 8 | 9 | def printhex(data, info="", highlight=None): 10 | """ 11 | output string as hex and ASCII dump 12 | :param data: binary data 13 | :param info: info string to print in header 14 | :param highlight: if position (starting with 0) is in this array, print highlight 15 | """ 16 | HL_ON = '\033[43m' 17 | HL_OFF = '\033[0m' 18 | 19 | if type(highlight) != list: 20 | highlight = [] 21 | 22 | start = 0 23 | dlen = len(data) 24 | if info != "": 25 | info += " - " 26 | print("%slength: %s" % (info, dlen)) 27 | while start < dlen: 28 | sub = data[start:start + 16] 29 | slen = len(sub) 30 | 31 | if not highlight: 32 | xc = " ".join([c.encode("hex") for c in sub]) 33 | else: 34 | pos = 0 35 | w = [] 36 | ison = False 37 | for c in sub: 38 | st = "" 39 | hx = c.encode("hex") 40 | if (start + pos) in highlight: 41 | if not ison: 42 | st += HL_ON 43 | ison = True 44 | st += hx 45 | if (pos == 15) or not (start + 1 + pos) in highlight: 46 | st += HL_OFF 47 | ison = False 48 | w.append(st) 49 | else: 50 | w.append(hx) 51 | pos += 1 52 | xc = " ".join(w) 53 | 54 | padding = ((16 - slen) * 3) * " " 55 | cs = "".join(c if (ord(c) >= 32) and (ord(c) < 128) else '.' for c in sub) 56 | print("%04x: %s%s %s" % (start, xc, padding, cs)) 57 | 58 | start += 16 59 | 60 | 61 | class DataCompare(object): 62 | """ 63 | class to compare data blocks 64 | 65 | dc = datacompare() 66 | dc.put( datablock1 ) 67 | dc.put( datablock2 ) 68 | dc.put( datablock3 ) 69 | dc.stats() 70 | """ 71 | 72 | def __init__(self): 73 | self.basedata = None 74 | self.allequal = True 75 | self.count = 0 76 | 77 | def put(self, data): 78 | self.count += 1 79 | if self.basedata is None: 80 | self.basedata = data 81 | return [] 82 | if len(self.basedata) != len(data): 83 | self.allequal = False 84 | return -1 85 | if self.basedata == data: 86 | return [] 87 | self.allequal = False 88 | diff = [] 89 | for x in range(len(data)): 90 | if data[x] != self.basedata[x]: 91 | diff.append(x) 92 | return diff 93 | 94 | def stats(self): 95 | if self.count > 0: 96 | print("Number of data blocks: {}".format(self.count)) 97 | if self.basedata is not None: 98 | if self.allequal: 99 | print("*** All data blocks were identical") 100 | 101 | 102 | # The following functions test a value and raise a ValueError 103 | # if the result is not what we expect. 104 | 105 | def testValue(value, desired, hint): 106 | if value != desired: 107 | raise ValueError("%s: value is not %s: %s" % (hint, desired, value)) 108 | 109 | 110 | def testEmptyString(value, hint): 111 | if value != "": 112 | raise ValueError("%s: string is not empty" % hint) 113 | 114 | 115 | def testString(value, desired, hint): 116 | if value != desired: 117 | printhex(value, "value....") 118 | printhex(desired, "should be") 119 | raise ValueError("%s: string is not empty" % hint) 120 | 121 | 122 | def testNone(value, hint): 123 | if value is None: 124 | raise ValueError("%s: shouldn't be None" % hint) 125 | 126 | 127 | # Conversion functions 128 | # They raise Exceptions in case of errors 129 | 130 | def unpack(fmt, data): 131 | """ 132 | convenience unpack method 133 | :param fmt: struct format string 134 | :param data: "binary" data 135 | :returns: tuple with converted content 136 | .. note:: this functions cuts the data string according to the length required by the format string 137 | """ 138 | clen = struct.calcsize(fmt) 139 | return struct.unpack(fmt, data[:clen]) 140 | 141 | 142 | def toBool(s): 143 | """ 144 | convenience function to convert byte to Boolean 145 | :param s: input byte 146 | :returns: tuple (boolean, error) 147 | ..note:: throws ValueError in byte is not 0 or 1 148 | """ 149 | if s == 0: 150 | return False 151 | if s == 1: 152 | return True 153 | raise ValueError("invalid value for boolean: %s" % s) 154 | 155 | 156 | def toString(s, hint="", ignorepadding=False): 157 | """ 158 | function to extract a string from a buffer padded with zeroes 159 | :param s: input bytes 160 | :param ignorepadding: don't check padding 161 | :returns: cleaned string 162 | .. note:: throws ValueError, if padding is not zero (and not ignored) 163 | """ 164 | res = "" 165 | 166 | mode = 0 167 | for c in s: 168 | if mode == 0: 169 | if ord(c) == 0: 170 | if ignorepadding: 171 | break 172 | mode = 1 173 | else: 174 | res += c 175 | elif mode == 1: 176 | if ord(c) != 0: 177 | errormsg = "string padding not zero" 178 | if hint != "": 179 | errormsg += ", %s" % hint 180 | raise ValueError(errormsg) 181 | return res 182 | 183 | 184 | # Decoding functions 185 | # 186 | 187 | class FossCmdDecode(object): 188 | """ 189 | base decoder object 190 | 191 | The purpose of these objects is to 192 | - decode the obvious content 193 | - make sure that the rest remains at the value we sees so far, or raise an error if something has changed 194 | """ 195 | 196 | def __init__(self, cmdno, description): 197 | self.cmdno = cmdno 198 | self.descr = description 199 | 200 | def cmd_no(self): 201 | return self.cmdno 202 | 203 | def description(self): 204 | return self.descr 205 | 206 | def decode(self, data): 207 | """ 208 | decode data 209 | :returns: None, if decode was successful; errormsg: if there were problems 210 | """ 211 | # nothing yet, override me 212 | printhex(data) 213 | return None 214 | 215 | 216 | def unpad(s): 217 | """ 218 | unpad a string from trailing 0x00 219 | make sure that all trailing zeros are actually zeros 220 | :param s: source string 221 | :returns: unpadded string, error message (or None, if ok) 222 | 223 | """ 224 | res = "" 225 | error = None 226 | start = True 227 | for c in s: 228 | if start: 229 | if ord(c) == 0: 230 | start = False 231 | else: 232 | res += c 233 | else: 234 | if ord(c) != 0: 235 | error = "padding chars not zero %2x" % ord(c) 236 | return res, error 237 | 238 | 239 | class FossCmd0(FossCmdDecode): 240 | """ 241 | int32 command 242 | char4 FOSC 243 | int32 size 244 | byte videostream (0: main, 1:sub) 245 | char64 username 246 | char64 password 247 | int32 uid 248 | char28 unknown (zeros) 249 | """ 250 | 251 | def __init__(self): 252 | super(FossCmd0, self).__init__(0, "U+P+ID 0") 253 | 254 | def decode(self, data): 255 | cmd, magic, size, vstream, username, password, uid, padding = struct.unpack(" 48 + asize: 427 | print("MORE") 428 | printhex(data[48 + asize:]) 429 | 430 | 431 | class FossCmd29(FossCmdDecode): 432 | """ 433 | int32 command 434 | char4 FOSC 435 | int32 size 436 | int32 login result, 0 = ok, 1 = error 437 | """ 438 | 439 | def __init__(self): 440 | super(FossCmd29, self).__init__(29, "keep alive answer") 441 | 442 | def decode(self, data): 443 | cmd, magic, size, login = struct.unpack(" Camera 65 | 66 | | Hex | Dec | Description | 67 | | --: | --: | -------------------- | 68 | | 00 | 0 | video on | 69 | | 01 | 1 | close connection | 70 | | 02 | 2 | audio on (from cam) | 71 | | 03 | 3 | audio off (from cam) | 72 | | 04 | 4 | speaker on cmd | 73 | | 05 | 5 | speaker off cmd | 74 | | 06 | 6 | talk audio data | 75 | | 0c | 12 | Login | 76 | | 0f | 15 | Login check | 77 | 78 | N+P: name and password 79 | N+P+U: name, password, and UID 80 | nc: not checked 81 | 82 | ### Camera -> User 83 | 84 | | Hex | Dec | Description | 85 | | --: | --: | ----------------------------------- | 86 | | 10 | 16 | ??? | 87 | | 12 | 18 | ??? | 88 | | 14 | 20 | speaker on reply | 89 | | 15 | 21 | speaker off reply | 90 | | 1a | 26 | video data in | 91 | | 1b | 27 | audio data in | 92 | | 1d | 29 | Login check reply | 93 | | 64 | 100 | ptz info | 94 | | 6A | 106 | preset point unchanged | 95 | | 6B | 107 | cruises list changed | 96 | | 6C | 108 | show mirror/flip | 97 | | 6E | 110 | show color adjust values | 98 | | 6F | 111 | Motion detection alert | 99 | | 70 | 112 | show power freq: 50/60/outdoor mode | 100 | | 71 | 113 | stream select reply | 101 | 102 | # Description of the commands 103 | 104 | 105 | All commands start with the following header. 106 | 107 | | type | value | description | 108 | | ----- | ----: | ---------------------- | 109 | | int32 | X | command number | 110 | | char4 | FOSC | magic number | 111 | | int32 | X | size of the data block | 112 | 113 | The following part only describes the data section. 114 | 115 | * *Integers* are little endian. 116 | * *Character strings* are padded with zeros. 117 | * *Reserved* means: no idea 118 | 119 | **Type column** 120 | 121 | | type | meaning | 122 | | ----- | ------------------------------ | 123 | | int32 | 32 bit integer, little endian | 124 | | byte | byte, 8 bit | 125 | | charX | character string, X **bytes** | 126 | | resX | unknown data, X **bytes** | 127 | 128 | **Value column** 129 | 130 | | value | meaning | 131 | | ----: | ------------------------------------------- | 132 | | 0 | zero, zeroes (in case of strings) | 133 | | X | specific meaning detailed under description | 134 | | ? | unknown non-zero values | 135 | 136 | ## Packet 0 - Video on 137 | 138 | | type | value | description | 139 | | ------ | ----: | --------------------------- | 140 | | byte | 0 | videostream (0:main, 1:sub) | 141 | | char64 | X | username | 142 | | char64 | X | password | 143 | | int32 | X | UID | 144 | | res28 | 0 | ? | 145 | 146 | ## Packet 1 - close connection 147 | 148 | | type | value | description | 149 | | ------ | ----: | ---------------------- | 150 | | byte | 0 | ? | 151 | | char64 | X | username | 152 | | char64 | X | password | 153 | 154 | Closes the socket. You have to establish a new connection and start with **SERVERPUSH**. 155 | 156 | 157 | ## Packet 2 - Audio on (from cam) 158 | 159 | | type | value | description | 160 | | ------ | ----: | ---------------------- | 161 | | byte | 0 | ? | 162 | | char64 | X | username | 163 | | char64 | X | password | 164 | | res32 | 0 | ? | 165 | 166 | Camera starts to send audio in command 27 packets. 167 | Format: Raw, 8000 Hz, Signed 16 Bit PCM, Mono, Little Endian 168 | 169 | ## Packet 3 - Audio off (from cam) 170 | 171 | | type | value | description | 172 | | ------ | ----: | ---------------------- | 173 | | byte | 0 | ? | 174 | | char64 | X | username | 175 | | char64 | X | password | 176 | | res32 | 0 | ? | 177 | 178 | 179 | ## Packet 4 - Speaker on 180 | 181 | | type | value | description | 182 | | ------ | ----: | ---------------------- | 183 | | byte | 0 | ? | 184 | | char64 | X | username | 185 | | char64 | X | password | 186 | | int32 | X | UID | 187 | | res28 | 0 | ? | 188 | 189 | Informs the camera that talk data will follow. 190 | The camera acknowledges with command 20. 191 | Talk data will then be sent with command 6. 192 | 193 | ## Packet 5 - Speaker off 194 | 195 | | type | value | description | 196 | | ------ | ----: | ---------------------- | 197 | | byte | 0 | ? | 198 | | char64 | X | username | 199 | | char64 | X | password | 200 | | res32 | 0 | ? | 201 | 202 | Switches the camera speaker off. 203 | The camera acknowledges with command 21. 204 | 205 | ## Packet 6 - Talk data 206 | 207 | | type | value | description | 208 | | ------ | ----: | ---------------------- | 209 | | int32 | X | audiolen | 210 | | binary | ? | audio data | 211 | 212 | The captured data suggests that the binary data blog must <= 960 bytes. 213 | I'm not about the audio format. I presume: Raw 8000 Hz, 16 bit 214 | However, dumping a converted file to the camera with raw audio data did not work. 215 | It seems that there is little to no buffering in the camera before the data is sent 216 | to the loudspeaker. 217 | 218 | ## Packet 12 - Login 219 | 220 | | type | value | description | 221 | | ------ | ----: | ---------------------- | 222 | | byte | 0 | ? | 223 | | char64 | X | username | 224 | | char64 | X | password | 225 | | int32 | X | UID | 226 | | res32 | 0 | ? | 227 | 228 | This is usually the first command which is sent to the camera. 229 | The camera responds with packet 100 containing various information (points, cruises, etc.) 230 | If the login fails the camera still answers with packet 100, however with no data inside. 231 | 232 | Note: This command is 4 bytes longer than similar commands. 233 | 234 | ## Packet 15 - Login check 235 | 236 | | type | value | description | 237 | | ------ | ----: | ---------------------- | 238 | | int32 | X | UID | 239 | 240 | This command is used to check if a login is (still) valid. 241 | The camera answers with command 29. 242 | 243 | It is usually the first command after Login (command 12) to check if Login was successful. 244 | However, in the captured data the plugin sends this command in regular intervals. 245 | 246 | 247 | ## Packet 16 - Unknown 248 | 249 | 36 bytes with look similar (but not identical to reply 18). 250 | 251 | ## Packet 18 - Unknown 252 | 253 | 36 bytes with look similar (but not identical to reply 16). 254 | 255 | ## Packet 20 - Speaker on reply 256 | 257 | This packet is sent from the camera in reply to command 4. 258 | 36 bytes with resemblance to reply 21, 16 and 18. 259 | No idea what they mean, 260 | 261 | ## Packet 21 - Speaker off reply 262 | 263 | This packet is sent from the camera in reply to command 5. 264 | 36 bytes with resemblance to reply 20, 16 and 18. 265 | No idea what they mean, 266 | 267 | ## Packet 26 - Video data in 268 | 269 | No idea. 270 | 271 | ## Packet 27 - Audio data in 272 | 273 | | type | value | description | 274 | | ------ | ----: | ---------------------- | 275 | | int32 | X | audio data size | 276 | | binary | | audio data content | 277 | 278 | The *audio data size* is usually the *size of datablock* in the header minus 4. 279 | 280 | If you dump the audio data content into a file, you get a raw, 8000 Hz, signed 16 bit integer audio file. 281 | 282 | ## Packet 29 - Login reply 283 | 284 | | type | value | description | 285 | | ------ | ----: | ------------------------- | 286 | | int32 | 0 / 1 | login ok = 0 / failed = 1 | 287 | 288 | Reply to packet 15 (login check)- 289 | 290 | 291 | ## Packet 100 ptz info 292 | 293 | The camera sends this packet after receiving command 0. It contains the preset points, cruises, and some other information 294 | 295 | | type | value | description | 296 | | ---------- | ----: | ------------------------ | 297 | | char8 | 0 | ?? all zeros | 298 | | byte | X | number of preset points | 299 | | 16* char32 | X | name of the preset point | 300 | | res32 | 0 | ? all zeros | 301 | | byte | X | number of cruises | 302 | | 8* char32 | X | name of the cruise | 303 | | res32 | 0 | ? all zeros | 304 | | res92 | ? | ? | 305 | | char12 | X | camera id | 306 | | ... | ? | ? | 307 | 308 | The names are null-terminated strings. If a name is deleted, only the first byte is set to zero, i.e. the remaining 309 | name is still visible in a hexdump. 310 | 311 | FYI: The *web interface* imposes the following restrictions: 312 | * max. name length of preset point: 20 chars 313 | * max. number of preset points: 16 314 | * max. name length of cruise: 20 chars 315 | * max. number of cruises: 8 316 | * max. number of preset points per cruise: 8 317 | 318 | ## Packet 106 preset point unchanged 319 | 320 | After trying to delete a preset point that is still part of a cruise, 321 | I received this packet. See also packet 100. 322 | I only saw it when using the browser plugin. 323 | 324 | | type | value | description | 325 | | ---------- | ----: | ------------------------ | 326 | | byte | X | number of preset points | 327 | | 16* char32 | X | name of the preset point | 328 | | res32 | 0 | ? all zeros | 329 | 330 | ## Packet 107 cruises list changed 331 | 332 | After deleting a cruise, I received this packet. See also packet 100, 106. 333 | I only saw it when using the browser plugin. 334 | 335 | | type | value | description | 336 | | ---------- | ----: | ------------------------ | 337 | | byte | X | number of cruises | 338 | | 8* char32 | X | name of cruise | 339 | | res32 | 0 | ? all zeros | 340 | 341 | 342 | ## Packet 108 - Show mirror/flip 343 | 344 | | type | value | description | 345 | | ------ | ----: | ----------------------- | 346 | | int32 | 0 / 1 | mirror 0 = no / 1 = yes | 347 | | int32 | 0 / 1 | flip 0 = no / 1 = yes | 348 | 349 | This packet is sent when the user changes the settings via the CGI commamnds. 350 | 351 | ## Packet 110 - Show color adjustments values 352 | 353 | | type | value | description | 354 | | ------ | ----: | ----------------------- | 355 | | byte | X | brightness | 356 | | byte | X | contrast | 357 | | byte | X | hue | 358 | | byte | X | saturation | 359 | | byte | X | sharpness | 360 | | byte | 50 | not used (denoise?) | 361 | 362 | This packet is sent when the user changes these settings via the CGI commamnds. 363 | Range: 0 .. 100 364 | 365 | ## Packet 111 - Motion detection alert 366 | 367 | | type | value | description | 368 | | ------ | ----: | ----------------------- | 369 | | res4 | ? | 01 00 00 1e | 370 | 371 | This packet only occurs if motion detection is enabled. 372 | 373 | ## Packet 112 - Show power frequency 374 | 375 | | type | value | description | 376 | | ------ | ----: | --------------------------- | 377 | | int32 | 0..2 | 0=60 Hz, 1=50 Hz, 2=outdoor | 378 | 379 | This packet is sent when the user changes the setting via the CGI commamnd. 380 | 381 | ## Packet 113 - Show stream selection 382 | 383 | | type | value | description | 384 | | ------ | ----: | --------------------------- | 385 | | int32 | X | stream number | 386 | 387 | This packet is sent when the user changes the setting via the CGI commamnd. 388 | -------------------------------------------------------------------------------- /lowlevel/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MStrecke/pyFosControl/0b4b83a6526162416f620e3849a56197da02a8c7/lowlevel/__init__.py -------------------------------------------------------------------------------- /lowlevel/camSniffer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from __future__ import print_function 5 | 6 | import socket 7 | import sys 8 | import urllib 9 | 10 | import dpkt 11 | import pcap 12 | 13 | import FoscDecoder 14 | 15 | """ 16 | analyse a packet capture either live or from a file 17 | 18 | The main goal for this program is to analyse the traffic between the Foscam Windows 19 | browser plugin and a Foscam FI9821W V2. This is one of their cheaper HD H.264 cameras. 20 | 21 | On Linux this browser plugin does not work, and the remaining web interface only allows 22 | to change some basic camera settings. 23 | 24 | Most of the plugins functionality can be duplicated by a couple of CGI calls, which are 25 | documented in their SDK. However, some functions are only available by a low level 26 | protocol, e.g. the "talk function" (sending audio to the camera). 27 | 28 | For their older models Foscam has published the low level protocol. 29 | As of the time of writing (end 2013) the documents for this model are not (yet?) available. 30 | 31 | The "interesting" packets are TCP/IP packets send to and from the IP address of the camera. 32 | These are either HTTP requests or packets of the low level protocol. 33 | 34 | The structure of the low level packets is: 35 | 36 | int32 commmand (little endian) 37 | char4 magic number "FOSC" 38 | int32 datalen length of the data section 39 | 40 | The program allows to analyse either a live capture (and optionally dump the received packets) 41 | or the packet dump itself (see "main" below). 42 | 43 | Dependencies: 44 | - python-libpcap for the package capture 45 | - python-dpkt for easier access to the IP structure 46 | 47 | Note: dpkt doesn't do any stream reassembling, i.e. if can't handle data larger than one packet, 48 | e.g. audio or video streams. This also means, that this program does miss commands that do 49 | not start at the beginning of a TCP packet (which is rare, but it does happen). 50 | 51 | Possible live capture scenario: 52 | - Linux box used as router for a Windows computer 53 | - Linux host sniffing the packets 54 | or 55 | - Linux box with Virtualbox running Windows 56 | - Firefox with plugin running under Windows 57 | - Linux host sniffing the packets 58 | 59 | This program has not been tested under Windows, but I don't see large difficulties if the 60 | dependencies are satisfied. 61 | 62 | Live capture 63 | ============ 64 | - set-up the dump file (if needed, see section "main"). 65 | - start with the following command from the command line: 66 | sudo python camSniffer.py live 67 | 68 | 69 | If not started in live mode, the program analyses the file defined in recfile. 70 | """ 71 | 72 | 73 | def print_src_dest_ip(ip): 74 | """ 75 | output source and destination IP address (and ports, if possible) 76 | """ 77 | srcip = socket.inet_ntoa(ip.src) 78 | dstip = socket.inet_ntoa(ip.dst) 79 | 80 | # only TCP packets have ports 81 | if ip.p == dpkt.ip.IP_PROTO_TCP: 82 | srcip += ":%s" % ip.tcp.sport 83 | dstip += ":%s" % ip.tcp.dport 84 | print("%s -> %s" % (srcip, dstip)) 85 | 86 | 87 | class Analyser(object): 88 | """ 89 | analyser base object 90 | 91 | does some house keeping for the sub classes 92 | """ 93 | 94 | def __init__(self): 95 | self.firsttimestamp = None 96 | self.rel_timestamp = None 97 | self.count = 0 98 | self.count_shown = 0 99 | 100 | self.compdata = None 101 | self.compdata_allequal = True 102 | 103 | def process_packet(self, pktlen, data, timestamp): 104 | """ 105 | count packets and calculate relative timestamp 106 | 107 | .. note:: sub classes should call this one first 108 | """ 109 | self.count += 1 110 | 111 | if self.firsttimestamp is None: 112 | self.firsttimestamp = timestamp 113 | self.rel_timestamp = timestamp - self.firsttimestamp 114 | 115 | def count_as_shown(self): 116 | """ 117 | increase another counter for the final stats 118 | """ 119 | self.count_shown += 1 120 | 121 | def test_data(self, data): 122 | """ 123 | determine if the content of all packets handed to this function are equal 124 | .. note:: shows up in the final stats 125 | .. note:: useful to check if the content of a given command packet changes or not 126 | """ 127 | # check if the content of all packets is equal 128 | if self.compdata is None: 129 | self.compdata = data 130 | else: 131 | if self.compdata_allequal and (self.compdata != data): 132 | self.compdata_allequal = False 133 | 134 | def print_stat(self): 135 | """ 136 | give some stats 137 | """ 138 | print("Number of packets: {}".format(self.count)) 139 | print("........... shown: {}".format(self.count_shown)) 140 | if self.compdata_allequal and self.compdata is not None: 141 | print("all tested data packets were equal") 142 | 143 | 144 | class PacketSource(object): 145 | """ 146 | packet source base object 147 | """ 148 | 149 | def __init__(self, analyser): 150 | self.analyser = analyser() 151 | 152 | def loop(self): 153 | """ 154 | loop through all packets 155 | 156 | override me! 157 | """ 158 | pass 159 | 160 | def print_analyser_stat(self): 161 | self.analyser.print_stat() 162 | 163 | 164 | class LiveSource(PacketSource): 165 | """ 166 | a live capture packet source 167 | """ 168 | 169 | def __init__(self, analyser, device, filter_=None, filename=None): 170 | """ 171 | constructor 172 | :param analyser: the uninstantiated analyser class 173 | :param device: the device to listen to (e.g. "eth0", "wlan1") 174 | :param filter_: optional filter (pcap notation, e.g. "ip host 192.168.0.102", "UDP") 175 | """ 176 | 177 | self.p = pcap.pcapObject() 178 | self.p.open_live(device, 65535, 0, 100) # device, snaplength, promiscous_mode, timeout 179 | 180 | if filter_ is not None: 181 | self.p.setfilter(filter_, 0, 0) 182 | 183 | self.dumper = False 184 | if filename is not None: 185 | self.p.dump_open(filename) 186 | self.dumper = True 187 | super(LiveSource, self).__init__(analyser) 188 | 189 | def loop(self): 190 | """ 191 | loop intended for console, press ^C to stop 192 | """ 193 | try: 194 | while 1: 195 | self.p.dispatch(1, self.analyser.process_packet) 196 | if self.dumper: 197 | self.p.dispatch(1, None) 198 | except KeyboardInterrupt: 199 | print('%s' % sys.exc_type) 200 | print('shutting down') 201 | print('%d packets received, %d packets dropped, %d packets dropped by interface' % self.p.stats()) 202 | 203 | 204 | class FileSource(PacketSource): 205 | """ 206 | a packet source reading from a dump file 207 | """ 208 | 209 | def __init__(self, analyser, filename): 210 | """ 211 | constructor 212 | :param analyser: the uninstantiated analyser class 213 | :param filename: filename of the capture file 214 | """ 215 | super(FileSource, self).__init__(analyser) 216 | 217 | self.p = pcap.pcapObject() 218 | self.p.open_offline(filename) 219 | 220 | def loop(self): 221 | # "-1" = loop through all entries 222 | self.p.dispatch(-1, self.analyser.process_packet) 223 | 224 | 225 | class FoscAnalyser(Analyser): 226 | """ 227 | class to analyse the live or offline capture 228 | """ 229 | 230 | def __init__(self): 231 | super(FoscAnalyser, self).__init__() 232 | # Some additional general stats: 233 | # 234 | # remember: remember the order in which the commands are found 235 | # stat: count how many of each command were detected 236 | self.remember = [] 237 | self.stat = {} 238 | 239 | self.errors = [] 240 | 241 | self.descriptions = FoscDecoder.decoder_descriptions 242 | self.call = FoscDecoder.decoder_call 243 | 244 | def remember_me(self, cmd): 245 | self.remember.append(cmd) 246 | if cmd in self.stat: 247 | self.stat[cmd] += 1 248 | else: 249 | self.stat[cmd] = 1 250 | 251 | def print_stat(self): 252 | self.print_stat() 253 | print("Remember") 254 | print(self.remember) 255 | for x in sorted(self.stat): 256 | print("cmd %s: %s" % (x, self.stat[x])) 257 | if len(self.errors) > 0: 258 | print("Decoding errors in cmds:", self.errors) 259 | 260 | def process_packet(self, pktlen, data, timestamp): 261 | global verbose 262 | global camera_ip 263 | 264 | def possiblemeaning(no): 265 | return self.descriptions.get(no, "???") 266 | 267 | def possibledecode(no, data): 268 | func = self.call.get(cmd, FoscDecoder.printhex) 269 | try: 270 | error = func(ip.tcp.data) 271 | except BaseException as e: 272 | error = e.message 273 | print("*** Decode error: {}".format(e.message)) 274 | # Remember # of command for print_stats 275 | if no not in self.errors: 276 | self.errors.append(no) 277 | 278 | # call method for some housekeeping 279 | self.process_packet(pktlen, data, timestamp) 280 | 281 | # let dpkt analyse the packet 282 | ether = dpkt.ethernet.Ethernet(data) 283 | 284 | # is it an IP packet? 285 | if ether.type != dpkt.ethernet.ETH_TYPE_IP: 286 | return 287 | 288 | # get the content of the IP packet 289 | ip = ether.data 290 | 291 | # is it a TCP/IP packet? 292 | if ip.p != dpkt.ip.IP_PROTO_TCP: 293 | return 294 | 295 | # only traffic, from/to the camera 296 | if not (socket.inet_ntoa(ip.src) == camera_ip or socket.inet_ntoa(ip.dst) == camera_ip): 297 | return 298 | 299 | # check for HTTP traffic 300 | try: 301 | http_rq = dpkt.http.Request(ip.tcp.data) 302 | print("\nURL-Req: {}".format(urllib.unquote(http_rq.uri))) 303 | self.remember_me(http_rq.uri) 304 | except dpkt.dpkt.UnpackError: 305 | pass 306 | 307 | # check for "low/level" traffic 308 | # is the tcp data larger than 12 bytes? 309 | # 4 bytes length information, 4 bytes "FOSC", 4 bytes data len 310 | if len(ip.tcp.data) < 12: 311 | return 312 | 313 | # unpack those 8 bytes 314 | cmd, magic, datalen = FoscDecoder.unpack(" 10: return 342 | # if datalen <= 996: return # 1956 343 | 344 | # do some stats 345 | self.count_as_shown() 346 | self.test_data(ip.tcp.data) 347 | self.remember_me(cmd) 348 | 349 | print() 350 | print_src_dest_ip(ip) 351 | if socket.inet_ntoa(ip.src) == camera_ip: 352 | print("Camera -> User") 353 | if socket.inet_ntoa(ip.dst) == camera_ip: 354 | print("User -> Camera") 355 | 356 | print("#%s @ %s:" % (self.count, self.rel_timestamp)) # position in pcap file 357 | print("command %s: %s" % (cmd, possiblemeaning(cmd))) 358 | print("tcp data length: {}".format(len(ip.tcp.data))) 359 | print("datalen {}".format(datalen)) 360 | if (datalen + 12) != len(ip.tcp.data): 361 | print("Packet length mismatch! Multiple commands in one packet/one command in multiple packets?") 362 | possibledecode(cmd, ip.tcp.data) 363 | if (datalen + 12) < len(ip.tcp.data): 364 | print("Additional data: {}".format(FoscDecoder.printhex(ip.tcp.data[datalen + 12:]))) 365 | 366 | 367 | if __name__ == '__main__': 368 | 369 | # change according to your environment 370 | camera_ip = "192.168.0.102" 371 | # device to sniff 372 | pcap_device = "eth0" 373 | 374 | # file name to store captured file in live mode 375 | recfile = "test-106-107.pcap" 376 | # filename of capture file to analyse 377 | playfile = recfile 378 | # audio dump filename 379 | audiodumpfilename = None 380 | 381 | # if the first parameter on the command line is "live", switch to live mode 382 | live = False 383 | try: 384 | if sys.argv[1] == "live": 385 | live = True 386 | except IndexError: 387 | live = False 388 | 389 | verbose = not live 390 | # verbose = True 391 | 392 | if live: 393 | print("Entering live mode") 394 | if recfile is not None: 395 | print("dumping to: {}".format(recfile)) 396 | else: 397 | if playfile is not None: 398 | print("reading from: {}".format(playfile)) 399 | 400 | # open a file for the content of packet 27 401 | if audiodumpfilename is None: 402 | audiodump = None 403 | else: 404 | audiodump = open(audiodumpfilename, "wb") 405 | 406 | if live: 407 | # note: live_source usually needs root permissions 408 | ana = LiveSource(FoscAnalyser, 409 | device=pcap_device, # "wlan1", ... 410 | filter_=None, # "ip host 192.168.0.102", or None 411 | filename=recfile # dump to file, or None 412 | ) 413 | else: 414 | ana = FileSource(FoscAnalyser, recfile) 415 | 416 | ana.loop() 417 | print() 418 | ana.print_analyser_stat() 419 | 420 | FoscDecoder.datacomp.stats() 421 | 422 | if audiodump is not None: 423 | audiodump.close() 424 | -------------------------------------------------------------------------------- /lowlevel/ticklecam.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from __future__ import print_function 5 | 6 | import socket 7 | import struct 8 | import sys 9 | import time 10 | from threading import Thread 11 | 12 | import FoscDecoder 13 | 14 | sys.path.append("..") # only for pyFosControl in parent directory 15 | import pyFosControl 16 | 17 | 18 | class ReadThread(Thread): 19 | """ 20 | We use a persistent tcp connection and blocking read from a socket with a timeout of 1 sec. 21 | 22 | The packets have the following structure: 23 | 24 | int32 command 25 | char4 "FOSC" 26 | int32 size 27 | data block with "size" bytes 28 | 29 | The integers are little endian. 30 | """ 31 | 32 | def __init__(self, socket, name=None): 33 | self.socket = socket 34 | super(ReadThread, self).__init__() 35 | self.endflag = False 36 | if name is not None: 37 | self.setName(name) 38 | self.resync_count = 0 39 | self.read_sequence = [] 40 | self.decodeerror = [] 41 | 42 | def run(self): 43 | # Mode: 44 | # 0: awaiting header 45 | # 1: reading data 46 | # 2: try to resync 47 | mode = 0 48 | remaining = 0 49 | body = "" 50 | 51 | while not self.endflag: 52 | try: 53 | if mode == 0: 54 | data = self.socket.recv(12) 55 | if len(data) == 0: 56 | print("Connection closed by peer") 57 | self.endflag = True 58 | break 59 | 60 | cmd, magic, size = struct.unpack(" 0: 119 | print("Fallen out of sync %s time(s)" % self.resync_count) 120 | if len(self.decodeerror) > 0: 121 | print("%s error(s) during decoding:" % len(self.decodeerror)) 122 | for msg in self.decodeerror: 123 | print(msg) 124 | 125 | 126 | class TCPHandler(object): 127 | def __init__(self, host, port, name): 128 | # timeout in seconds 129 | timeout = 1 130 | socket.setdefaulttimeout(timeout) 131 | 132 | self.name = name 133 | self.ip = host 134 | self.port = port 135 | 136 | # open a tcp socket 137 | self.con = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 138 | self.con.connect((self.ip, self.port)) 139 | 140 | # create read thread and start it 141 | self.reader = ReadThread(self.con, name) 142 | self.reader.start() 143 | 144 | def close(self): 145 | print("%s: shutdown reader" % self.name) 146 | self.reader.stopit() 147 | print("%s: waiting" % self.name) 148 | self.reader.join() 149 | print("%s: shutdown complete" % self.name) 150 | self.reader.stats() 151 | self.con.close() 152 | 153 | def sendraw(self, data, crconv=False): 154 | """ Send "raw" text 155 | :param data: text to send 156 | :param crconv: convert LF -> CR LF 157 | 158 | crconv = True, to send HTML-Request (needs CRLF) from a Unix system (have only LF) 159 | """ 160 | print("send-raw:") 161 | print(data) 162 | if crconv: 163 | data = data.replace("\n", "\r\n") 164 | self.con.send(data) 165 | 166 | 167 | class CamHandler(TCPHandler): 168 | """ class to send commands to the cam 169 | """ 170 | 171 | def send_command(self, command, data, verbose=True): 172 | dt = struct.pack("