├── .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("