onvif-python 0.1.2__py3-none-any.whl → 0.1.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- onvif/cli/interactive.py +231 -20
- onvif/cli/main.py +94 -21
- onvif/cli/utils.py +772 -0
- onvif/operator.py +6 -0
- {onvif_python-0.1.2.dist-info → onvif_python-0.1.4.dist-info}/METADATA +28 -10
- {onvif_python-0.1.2.dist-info → onvif_python-0.1.4.dist-info}/RECORD +10 -10
- {onvif_python-0.1.2.dist-info → onvif_python-0.1.4.dist-info}/WHEEL +0 -0
- {onvif_python-0.1.2.dist-info → onvif_python-0.1.4.dist-info}/entry_points.txt +0 -0
- {onvif_python-0.1.2.dist-info → onvif_python-0.1.4.dist-info}/licenses/LICENSE.md +0 -0
- {onvif_python-0.1.2.dist-info → onvif_python-0.1.4.dist-info}/top_level.txt +0 -0
onvif/cli/interactive.py
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
import cmd
|
|
4
4
|
import sys
|
|
5
5
|
import socket
|
|
6
|
+
import ssl
|
|
7
|
+
import os
|
|
6
8
|
import threading
|
|
7
9
|
import textwrap
|
|
8
10
|
from datetime import datetime
|
|
@@ -21,6 +23,7 @@ from .utils import (
|
|
|
21
23
|
format_capabilities_as_services,
|
|
22
24
|
format_services_list,
|
|
23
25
|
get_method_documentation,
|
|
26
|
+
get_operation_type_info,
|
|
24
27
|
)
|
|
25
28
|
|
|
26
29
|
|
|
@@ -61,6 +64,7 @@ class InteractiveShell(cmd.Cmd):
|
|
|
61
64
|
"pwd",
|
|
62
65
|
"shortcuts",
|
|
63
66
|
"desc",
|
|
67
|
+
"type",
|
|
64
68
|
]
|
|
65
69
|
|
|
66
70
|
# For background health check
|
|
@@ -179,7 +183,7 @@ class InteractiveShell(cmd.Cmd):
|
|
|
179
183
|
" / __ \\/ | / / | / / _/ ____/",
|
|
180
184
|
" / / / / |/ /| | / // // /_ ",
|
|
181
185
|
"/ /_/ / /| / | |/ // // __/ ",
|
|
182
|
-
"\\____/_/ |_/ |___/___/_/ v0.1.
|
|
186
|
+
"\\____/_/ |_/ |___/___/_/ v0.1.4",
|
|
183
187
|
" ",
|
|
184
188
|
]
|
|
185
189
|
|
|
@@ -218,22 +222,39 @@ class InteractiveShell(cmd.Cmd):
|
|
|
218
222
|
self._health_check_thread.start()
|
|
219
223
|
|
|
220
224
|
def _periodic_health_check(self):
|
|
221
|
-
"""Periodically checks device connection using
|
|
222
|
-
# Wait
|
|
225
|
+
"""Periodically checks device connection using TCP or TLS depending on mode."""
|
|
226
|
+
# Wait before first check to allow intro to finish
|
|
223
227
|
self._stop_health_check.wait(10.0)
|
|
228
|
+
|
|
224
229
|
while not self._stop_health_check.is_set():
|
|
225
230
|
sock = None
|
|
226
231
|
try:
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
232
|
+
sock = socket.create_connection(
|
|
233
|
+
(self.args.host, self.args.port), timeout=5.0
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# If HTTPS, perform a short TLS handshake check
|
|
237
|
+
if getattr(self.args, "https", False):
|
|
238
|
+
context = ssl.create_default_context()
|
|
239
|
+
context.check_hostname = False
|
|
240
|
+
context.verify_mode = ssl.CERT_NONE
|
|
241
|
+
|
|
242
|
+
with context.wrap_socket(
|
|
243
|
+
sock, server_hostname=self.args.host
|
|
244
|
+
) as ssock:
|
|
245
|
+
ssock.settimeout(5)
|
|
246
|
+
# Try a minimal handshake
|
|
247
|
+
ssock.do_handshake()
|
|
248
|
+
|
|
249
|
+
# If we reach here, the connection (and TLS if applicable) succeeded
|
|
250
|
+
pass
|
|
230
251
|
|
|
231
|
-
# Try to connect to the host and port
|
|
232
|
-
sock.connect((self.args.host, self.args.port))
|
|
233
252
|
except (
|
|
234
253
|
socket.timeout,
|
|
235
254
|
ConnectionRefusedError,
|
|
236
255
|
socket.gaierror,
|
|
256
|
+
ssl.SSLError,
|
|
257
|
+
ssl.SSLEOFError,
|
|
237
258
|
OSError,
|
|
238
259
|
) as e:
|
|
239
260
|
# Connection failed, trigger exit
|
|
@@ -242,7 +263,7 @@ class InteractiveShell(cmd.Cmd):
|
|
|
242
263
|
file=sys.stderr,
|
|
243
264
|
)
|
|
244
265
|
print(
|
|
245
|
-
f"{colorize('Error:', 'red')}
|
|
266
|
+
f"{colorize('Error:', 'red')} Health check failed: {e}",
|
|
246
267
|
file=sys.stderr,
|
|
247
268
|
)
|
|
248
269
|
print(
|
|
@@ -251,15 +272,13 @@ class InteractiveShell(cmd.Cmd):
|
|
|
251
272
|
)
|
|
252
273
|
# Forcibly exit the entire process. This is necessary to interrupt
|
|
253
274
|
# the blocking input() call in the main thread.
|
|
254
|
-
import os
|
|
255
|
-
|
|
256
275
|
os._exit(1)
|
|
257
276
|
finally:
|
|
258
277
|
# Ensure the socket is always closed
|
|
259
278
|
if sock:
|
|
260
279
|
sock.close()
|
|
261
280
|
|
|
262
|
-
# Wait
|
|
281
|
+
# Wait before next check or stop signal
|
|
263
282
|
self._stop_health_check.wait(5.0) # Check every 5 seconds
|
|
264
283
|
|
|
265
284
|
def _handle_connection_error(self, e):
|
|
@@ -766,8 +785,9 @@ class InteractiveShell(cmd.Cmd):
|
|
|
766
785
|
|
|
767
786
|
if not self.current_service:
|
|
768
787
|
print(
|
|
769
|
-
f"{colorize('Error:', 'red')}
|
|
788
|
+
f"{colorize('Error:', 'red')} You must be in a service mode to use 'desc'."
|
|
770
789
|
)
|
|
790
|
+
print(f"Enter a service first (e.g., {colorize('devicemgmt', 'yellow')})")
|
|
771
791
|
return
|
|
772
792
|
|
|
773
793
|
if not method_name:
|
|
@@ -808,8 +828,175 @@ class InteractiveShell(cmd.Cmd):
|
|
|
808
828
|
f"No documentation or parameter info found for method '{method_name}'."
|
|
809
829
|
)
|
|
810
830
|
|
|
811
|
-
def
|
|
812
|
-
|
|
831
|
+
def complete_desc(self, text, line, begidx, endidx):
|
|
832
|
+
"""Autocomplete method names for desc command"""
|
|
833
|
+
if not self.current_service:
|
|
834
|
+
return []
|
|
835
|
+
methods = get_service_methods(self.current_service)
|
|
836
|
+
return [m for m in methods if m.lower().startswith(text.lower())]
|
|
837
|
+
|
|
838
|
+
def do_type(self, line):
|
|
839
|
+
"""Show input and output types for an operation. Usage: type <operation_name>"""
|
|
840
|
+
operation_name = line.strip()
|
|
841
|
+
|
|
842
|
+
if not self.current_service:
|
|
843
|
+
print(
|
|
844
|
+
f"{colorize('Error:', 'red')} You must be in a service mode to use 'type'."
|
|
845
|
+
)
|
|
846
|
+
print(f"Enter a service first (e.g., {colorize('d', 'yellow')})")
|
|
847
|
+
return
|
|
848
|
+
|
|
849
|
+
if not operation_name:
|
|
850
|
+
print(f"Usage: {colorize('type <operation_name>', 'yellow')}")
|
|
851
|
+
return
|
|
852
|
+
|
|
853
|
+
if not hasattr(self.current_service, operation_name):
|
|
854
|
+
print(
|
|
855
|
+
f"{colorize('Error:', 'red')} Operation '{operation_name}' not found in service '{self.current_service_name}'."
|
|
856
|
+
)
|
|
857
|
+
print(f"Use {colorize('ls', 'yellow')} to see available operations.")
|
|
858
|
+
return
|
|
859
|
+
|
|
860
|
+
type_info = get_operation_type_info(self.current_service, operation_name)
|
|
861
|
+
|
|
862
|
+
if type_info:
|
|
863
|
+
# Helper function to display parameters recursively with tree-style indentation
|
|
864
|
+
def display_params(params, prefix_lines=None, is_root_level=False):
|
|
865
|
+
"""
|
|
866
|
+
Display parameters with tree-style formatting.
|
|
867
|
+
|
|
868
|
+
Args:
|
|
869
|
+
params: List of parameter dictionaries
|
|
870
|
+
prefix_lines: List of strings representing the tree prefix for each line
|
|
871
|
+
is_root_level: If True, don't show tree characters at this level
|
|
872
|
+
"""
|
|
873
|
+
if prefix_lines is None:
|
|
874
|
+
prefix_lines = []
|
|
875
|
+
|
|
876
|
+
for idx, param in enumerate(params):
|
|
877
|
+
is_last = idx == len(params) - 1
|
|
878
|
+
|
|
879
|
+
# Determine tree characters (only if not root level)
|
|
880
|
+
if is_root_level:
|
|
881
|
+
tree_branch = ""
|
|
882
|
+
tree_continue = ""
|
|
883
|
+
else:
|
|
884
|
+
if is_last:
|
|
885
|
+
tree_branch = "└── "
|
|
886
|
+
tree_continue = " "
|
|
887
|
+
else:
|
|
888
|
+
tree_branch = "├── "
|
|
889
|
+
tree_continue = "│ "
|
|
890
|
+
|
|
891
|
+
# Determine prefix symbol: + for attributes, - for elements
|
|
892
|
+
prefix_symbol = "+" if param.get("is_attribute", False) else "-"
|
|
893
|
+
|
|
894
|
+
# Format occurrences based on type
|
|
895
|
+
occurs = ""
|
|
896
|
+
if param.get("is_attribute", False):
|
|
897
|
+
# For attributes: only show "required" if use="required"
|
|
898
|
+
# Default for attributes is optional (no label needed)
|
|
899
|
+
if param["minOccurs"] == "1": # This means use="required"
|
|
900
|
+
occurs = f" - {colorize('required', 'yellow')}"
|
|
901
|
+
# If minOccurs == "0", it's optional (default), so no label
|
|
902
|
+
else:
|
|
903
|
+
# For elements: show unbounded, optional, or required
|
|
904
|
+
if param["maxOccurs"] == "unbounded":
|
|
905
|
+
occurs = f" - {colorize('unbounded', 'magenta')}"
|
|
906
|
+
elif param["minOccurs"] == "0":
|
|
907
|
+
occurs = f" - {colorize('optional', 'green')}"
|
|
908
|
+
else:
|
|
909
|
+
occurs = f" - {colorize('required', 'yellow')}"
|
|
910
|
+
|
|
911
|
+
# Format type
|
|
912
|
+
type_str = f"[{param['type']}]" if param["type"] else ""
|
|
913
|
+
|
|
914
|
+
# Build the current line prefix from all previous levels
|
|
915
|
+
current_prefix = "".join(prefix_lines)
|
|
916
|
+
|
|
917
|
+
# Add spacing for root level items
|
|
918
|
+
if is_root_level:
|
|
919
|
+
current_prefix = " "
|
|
920
|
+
|
|
921
|
+
# Display parameter name with tree structure, prefix, occurrence, and type
|
|
922
|
+
# Only add semicolon separator if there's an occurrence label
|
|
923
|
+
separator = ";" if occurs else ""
|
|
924
|
+
param_line = f"{current_prefix}{tree_branch}{prefix_symbol}{colorize(param['name'], 'white')}{occurs}{separator} {colorize(type_str, 'cyan')}"
|
|
925
|
+
print(param_line)
|
|
926
|
+
|
|
927
|
+
# Display documentation if available
|
|
928
|
+
if param.get("documentation"):
|
|
929
|
+
doc_lines = param["documentation"].split("\n")
|
|
930
|
+
for doc_line in doc_lines:
|
|
931
|
+
if doc_line.strip():
|
|
932
|
+
# Add tree continuation for documentation
|
|
933
|
+
if is_root_level:
|
|
934
|
+
doc_prefix = " "
|
|
935
|
+
else:
|
|
936
|
+
doc_prefix = current_prefix + tree_continue
|
|
937
|
+
wrapped_doc = textwrap.fill(
|
|
938
|
+
doc_line.strip(),
|
|
939
|
+
width=96, # Slightly less to account for tree chars
|
|
940
|
+
initial_indent=doc_prefix + " ",
|
|
941
|
+
subsequent_indent=doc_prefix + " ",
|
|
942
|
+
)
|
|
943
|
+
print(colorize(wrapped_doc, "reset"))
|
|
944
|
+
|
|
945
|
+
# Display children recursively with updated prefix
|
|
946
|
+
if param.get("children"):
|
|
947
|
+
if is_root_level:
|
|
948
|
+
# Start tree structure from children
|
|
949
|
+
new_prefix_lines = [" "]
|
|
950
|
+
else:
|
|
951
|
+
new_prefix_lines = prefix_lines + [tree_continue]
|
|
952
|
+
display_params(
|
|
953
|
+
param["children"], new_prefix_lines, is_root_level=False
|
|
954
|
+
)
|
|
955
|
+
|
|
956
|
+
# Display Input
|
|
957
|
+
if type_info["input"]:
|
|
958
|
+
input_msg = type_info["input"]
|
|
959
|
+
print(f"\n{colorize('Input:', 'cyan')}")
|
|
960
|
+
msg_name = f"[{input_msg['name']}]"
|
|
961
|
+
print(f"{colorize(msg_name, 'yellow')}")
|
|
962
|
+
|
|
963
|
+
if input_msg["parameters"]:
|
|
964
|
+
display_params(input_msg["parameters"], is_root_level=True)
|
|
965
|
+
else:
|
|
966
|
+
print(f" {colorize('(no parameters)', 'reset')}")
|
|
967
|
+
else:
|
|
968
|
+
print(
|
|
969
|
+
f"\n{colorize('Input:', 'cyan')} {colorize('(not defined)', 'reset')}"
|
|
970
|
+
)
|
|
971
|
+
|
|
972
|
+
# Display Output
|
|
973
|
+
if type_info["output"]:
|
|
974
|
+
output_msg = type_info["output"]
|
|
975
|
+
print(f"\n{colorize('Output:', 'cyan')}")
|
|
976
|
+
msg_name = f"[{output_msg['name']}]"
|
|
977
|
+
print(f"{colorize(msg_name, 'yellow')}")
|
|
978
|
+
|
|
979
|
+
if output_msg["parameters"]:
|
|
980
|
+
display_params(output_msg["parameters"], is_root_level=True)
|
|
981
|
+
else:
|
|
982
|
+
print(f" {colorize('(no parameters)', 'reset')}")
|
|
983
|
+
else:
|
|
984
|
+
print(
|
|
985
|
+
f"\n{colorize('Output:', 'cyan')} {colorize('(not defined)', 'reset')}"
|
|
986
|
+
)
|
|
987
|
+
|
|
988
|
+
print() # Add newline for spacing
|
|
989
|
+
else:
|
|
990
|
+
print(
|
|
991
|
+
f"{colorize('Error:', 'red')} Could not retrieve type information for '{operation_name}'."
|
|
992
|
+
)
|
|
993
|
+
|
|
994
|
+
def complete_type(self, text, line, begidx, endidx):
|
|
995
|
+
"""Autocomplete method names for type command"""
|
|
996
|
+
if not self.current_service:
|
|
997
|
+
return []
|
|
998
|
+
methods = get_service_methods(self.current_service)
|
|
999
|
+
return [m for m in methods if m.lower().startswith(text.lower())]
|
|
813
1000
|
|
|
814
1001
|
def do_cd(self, line):
|
|
815
1002
|
"""Change to service directory (alias for entering service)"""
|
|
@@ -821,6 +1008,11 @@ class InteractiveShell(cmd.Cmd):
|
|
|
821
1008
|
return
|
|
822
1009
|
return self.do_enter_service(line)
|
|
823
1010
|
|
|
1011
|
+
def complete_cd(self, text, line, begidx, endidx):
|
|
1012
|
+
"""Autocomplete service names for cd command"""
|
|
1013
|
+
services = get_device_available_services(self.client)
|
|
1014
|
+
return [s for s in services if s.lower().startswith(text.lower())]
|
|
1015
|
+
|
|
824
1016
|
def do_pwd(self, line):
|
|
825
1017
|
"""Show current service context"""
|
|
826
1018
|
if self.current_service_name:
|
|
@@ -844,7 +1036,11 @@ class InteractiveShell(cmd.Cmd):
|
|
|
844
1036
|
up - Go up one level
|
|
845
1037
|
pwd - Show current context
|
|
846
1038
|
clear - Clear terminal screen
|
|
847
|
-
|
|
1039
|
+
help <command> - Show help for a command
|
|
1040
|
+
|
|
1041
|
+
{colorize('Service Mode Commands:', 'yellow')}
|
|
1042
|
+
desc <method> - Show method documentation
|
|
1043
|
+
type <method> - Show input/output types from WSDL
|
|
848
1044
|
|
|
849
1045
|
{colorize('Quick Access:', 'yellow')}
|
|
850
1046
|
caps - Show capabilities (same as 'capabilities')
|
|
@@ -925,6 +1121,10 @@ class InteractiveShell(cmd.Cmd):
|
|
|
925
1121
|
else:
|
|
926
1122
|
print(f"{colorize('Error:', 'red')} No stored data named '{line}'")
|
|
927
1123
|
|
|
1124
|
+
def complete_rm(self, text, line, begidx, endidx):
|
|
1125
|
+
"""Autocomplete stored variable names for rm command"""
|
|
1126
|
+
return [name for name in self.stored_data.keys() if name.startswith(text)]
|
|
1127
|
+
|
|
928
1128
|
def do_show(self, line):
|
|
929
1129
|
"""Show stored data: show <name> or show <name>.<attribute> or show <name>[index]"""
|
|
930
1130
|
if not line:
|
|
@@ -947,10 +1147,17 @@ class InteractiveShell(cmd.Cmd):
|
|
|
947
1147
|
else:
|
|
948
1148
|
print(f"{colorize('Error:', 'red')} Cannot resolve '{line}'")
|
|
949
1149
|
|
|
1150
|
+
def complete_show(self, text, line, begidx, endidx):
|
|
1151
|
+
"""Autocomplete stored variable names for show command"""
|
|
1152
|
+
# Get the part being completed
|
|
1153
|
+
parts = line.split()
|
|
1154
|
+
if len(parts) <= 1 or (len(parts) == 2 and not line.endswith(" ")):
|
|
1155
|
+
# Completing the variable name
|
|
1156
|
+
return [name for name in self.stored_data.keys() if name.startswith(text)]
|
|
1157
|
+
return []
|
|
1158
|
+
|
|
950
1159
|
def do_clear(self, line):
|
|
951
1160
|
"""Clear terminal screen"""
|
|
952
|
-
import os
|
|
953
|
-
|
|
954
1161
|
# Clear screen for both Windows and Unix-like systems
|
|
955
1162
|
os.system("cls" if os.name == "nt" else "clear")
|
|
956
1163
|
|
|
@@ -1093,7 +1300,7 @@ class InteractiveShell(cmd.Cmd):
|
|
|
1093
1300
|
super().do_help(line)
|
|
1094
1301
|
else:
|
|
1095
1302
|
help_text = f"""
|
|
1096
|
-
{colorize('ONVIF Interactive Shell Commands -', 'cyan')}\n{colorize('https://github.com/nirsimetri/onvif-python', 'white')}
|
|
1303
|
+
{colorize('ONVIF Interactive Shell Commands - v0.1.4', 'cyan')}\n{colorize('https://github.com/nirsimetri/onvif-python', 'white')}
|
|
1097
1304
|
|
|
1098
1305
|
{colorize('Basic Commands:', 'yellow')}
|
|
1099
1306
|
capabilities, caps - Show device capabilities
|
|
@@ -1109,7 +1316,11 @@ class InteractiveShell(cmd.Cmd):
|
|
|
1109
1316
|
up - Exit current service mode (go up one level)
|
|
1110
1317
|
pwd - Show current service context
|
|
1111
1318
|
clear - Clear terminal screen
|
|
1112
|
-
|
|
1319
|
+
help <command> - Show help for a specific command
|
|
1320
|
+
|
|
1321
|
+
{colorize('Service Mode Commands:', 'yellow')}
|
|
1322
|
+
desc <method> - Show method documentation
|
|
1323
|
+
type <method> - Show input/output types from WSDL
|
|
1113
1324
|
|
|
1114
1325
|
{colorize('Method Execution:', 'yellow')}
|
|
1115
1326
|
<method> - Execute method without parameters
|
onvif/cli/main.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
4
|
import sys
|
|
5
|
+
import warnings
|
|
5
6
|
import getpass
|
|
6
7
|
from typing import Any, Optional, Tuple
|
|
7
8
|
|
|
@@ -15,7 +16,7 @@ def create_parser():
|
|
|
15
16
|
"""Create argument parser for ONVIF CLI"""
|
|
16
17
|
parser = argparse.ArgumentParser(
|
|
17
18
|
prog="onvif",
|
|
18
|
-
description=f"{colorize('ONVIF Terminal Client', 'yellow')}
|
|
19
|
+
description=f"{colorize('ONVIF Terminal Client', 'yellow')} — v0.1.4\nhttps://github.com/nirsimetri/onvif-python",
|
|
19
20
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
20
21
|
epilog=f"""
|
|
21
22
|
Examples:
|
|
@@ -106,11 +107,19 @@ Examples:
|
|
|
106
107
|
"params", nargs="*", help="Method parameters as Simple Parameter or JSON string"
|
|
107
108
|
)
|
|
108
109
|
|
|
110
|
+
# ONVI CLI
|
|
111
|
+
parser.add_argument(
|
|
112
|
+
"--version", "-v", action="store_true", help="Show ONVIF CLI version and exit"
|
|
113
|
+
)
|
|
114
|
+
|
|
109
115
|
return parser
|
|
110
116
|
|
|
111
117
|
|
|
112
118
|
def main():
|
|
113
119
|
"""Main CLI entry point"""
|
|
120
|
+
# Setup custom warning format for cleaner output
|
|
121
|
+
setup_warning_format()
|
|
122
|
+
|
|
114
123
|
parser = create_parser()
|
|
115
124
|
|
|
116
125
|
# Check if no arguments provided at all
|
|
@@ -120,6 +129,17 @@ def main():
|
|
|
120
129
|
|
|
121
130
|
args = parser.parse_intermixed_args()
|
|
122
131
|
|
|
132
|
+
# Show ONVIF CLI version
|
|
133
|
+
if args.version:
|
|
134
|
+
print(colorize("0.1.4", "yellow"))
|
|
135
|
+
sys.exit(0)
|
|
136
|
+
|
|
137
|
+
# Validate arguments early (before discovery)
|
|
138
|
+
if not args.interactive and (not args.service or not args.method):
|
|
139
|
+
parser.error(
|
|
140
|
+
f"Either {colorize('--interactive', 'white')}/{colorize('-i', 'white')} mode or {colorize('service/method', 'white')} must be specified"
|
|
141
|
+
)
|
|
142
|
+
|
|
123
143
|
# Handle discovery mode
|
|
124
144
|
if args.discover:
|
|
125
145
|
if args.host:
|
|
@@ -127,8 +147,8 @@ def main():
|
|
|
127
147
|
f"{colorize('--discover', 'white')} cannot be used with {colorize('--host', 'white')}"
|
|
128
148
|
)
|
|
129
149
|
|
|
130
|
-
# Discover devices
|
|
131
|
-
devices = discover_devices(timeout=4)
|
|
150
|
+
# Discover devices (pass --https flag to prioritize HTTPS XAddrs)
|
|
151
|
+
devices = discover_devices(timeout=4, prefer_https=args.https)
|
|
132
152
|
|
|
133
153
|
if not devices:
|
|
134
154
|
print(f"\n{colorize('No ONVIF devices discovered. Exiting.', 'red')}")
|
|
@@ -141,8 +161,11 @@ def main():
|
|
|
141
161
|
print(f"{colorize('Device selection cancelled.', 'cyan')}")
|
|
142
162
|
sys.exit(0)
|
|
143
163
|
|
|
144
|
-
# Set host and
|
|
145
|
-
args.host, args.port = selected
|
|
164
|
+
# Set host, port, and HTTPS from selected device
|
|
165
|
+
args.host, args.port, device_use_https = selected
|
|
166
|
+
|
|
167
|
+
# Use device's detected protocol (already filtered by prefer_https in discover_devices)
|
|
168
|
+
# No need to override - device info already has correct protocol based on --https flag
|
|
146
169
|
|
|
147
170
|
# Validate that host is provided (either via --host or --discover)
|
|
148
171
|
if not args.host:
|
|
@@ -168,12 +191,6 @@ def main():
|
|
|
168
191
|
print("\nPassword entry cancelled.")
|
|
169
192
|
sys.exit(1)
|
|
170
193
|
|
|
171
|
-
# Validate arguments
|
|
172
|
-
if not args.interactive and (not args.service or not args.method):
|
|
173
|
-
parser.error(
|
|
174
|
-
f"Either {colorize('--interactive', 'white')}/{colorize('-i', 'white')} mode or {colorize('service/method', 'white')} must be specified"
|
|
175
|
-
)
|
|
176
|
-
|
|
177
194
|
try:
|
|
178
195
|
# Create ONVIF client
|
|
179
196
|
client = ONVIFClient(
|
|
@@ -253,8 +270,16 @@ def execute_command(
|
|
|
253
270
|
return method(**params)
|
|
254
271
|
|
|
255
272
|
|
|
256
|
-
def discover_devices(timeout: int = 4) -> list:
|
|
257
|
-
"""Discover ONVIF devices on the network using WS-Discovery.
|
|
273
|
+
def discover_devices(timeout: int = 4, prefer_https: bool = False) -> list:
|
|
274
|
+
"""Discover ONVIF devices on the network using WS-Discovery.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
timeout: Discovery timeout in seconds
|
|
278
|
+
prefer_https: If True, prioritize HTTPS XAddrs when available
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
List of discovered devices with connection info
|
|
282
|
+
"""
|
|
258
283
|
import socket
|
|
259
284
|
import uuid
|
|
260
285
|
import xml.etree.ElementTree as ET
|
|
@@ -360,6 +385,7 @@ def discover_devices(timeout: int = 4) -> list:
|
|
|
360
385
|
"xaddrs": [],
|
|
361
386
|
"host": None,
|
|
362
387
|
"port": 80,
|
|
388
|
+
"use_https": False, # Auto-detect HTTPS from XAddrs
|
|
363
389
|
}
|
|
364
390
|
|
|
365
391
|
epr = probe_match.find(".//wsa:EndpointReference/wsa:Address", namespaces)
|
|
@@ -384,17 +410,39 @@ def discover_devices(timeout: int = 4) -> list:
|
|
|
384
410
|
if xaddrs_elem is not None and xaddrs_elem.text:
|
|
385
411
|
device_info["xaddrs"] = xaddrs_elem.text.split()
|
|
386
412
|
|
|
387
|
-
# Extract host and
|
|
413
|
+
# Extract host, port, and protocol from XAddrs
|
|
414
|
+
# Use prefer_https parameter to decide which XAddr to use
|
|
388
415
|
if device_info["xaddrs"]:
|
|
389
|
-
|
|
416
|
+
# Select XAddr based on prefer_https flag
|
|
417
|
+
if prefer_https:
|
|
418
|
+
# Try to find HTTPS XAddr first if prefer_https=True
|
|
419
|
+
https_xaddr = next(
|
|
420
|
+
(
|
|
421
|
+
x
|
|
422
|
+
for x in device_info["xaddrs"]
|
|
423
|
+
if x.startswith("https://")
|
|
424
|
+
),
|
|
425
|
+
None,
|
|
426
|
+
)
|
|
427
|
+
xaddr = https_xaddr or device_info["xaddrs"][0]
|
|
428
|
+
else:
|
|
429
|
+
# Use first XAddr (usually HTTP) if prefer_https=False
|
|
430
|
+
xaddr = device_info["xaddrs"][0]
|
|
431
|
+
|
|
390
432
|
if "://" in xaddr:
|
|
433
|
+
# Detect protocol
|
|
434
|
+
protocol = xaddr.split("://")[0]
|
|
435
|
+
device_info["use_https"] = protocol == "https"
|
|
436
|
+
|
|
437
|
+
# Extract host and port
|
|
391
438
|
parts = xaddr.split("://")[1].split("/")[0]
|
|
392
439
|
if ":" in parts:
|
|
393
440
|
device_info["host"] = parts.split(":")[0]
|
|
394
441
|
device_info["port"] = int(parts.split(":")[1])
|
|
395
442
|
else:
|
|
396
443
|
device_info["host"] = parts
|
|
397
|
-
|
|
444
|
+
# Set default port based on protocol
|
|
445
|
+
device_info["port"] = 443 if protocol == "https" else 80
|
|
398
446
|
|
|
399
447
|
if device_info["host"]:
|
|
400
448
|
devices.append(device_info)
|
|
@@ -405,8 +453,12 @@ def discover_devices(timeout: int = 4) -> list:
|
|
|
405
453
|
return devices
|
|
406
454
|
|
|
407
455
|
|
|
408
|
-
def select_device_interactive(devices: list) -> Optional[Tuple[str, int]]:
|
|
409
|
-
"""Display devices and allow user to select one interactively.
|
|
456
|
+
def select_device_interactive(devices: list) -> Optional[Tuple[str, int, bool]]:
|
|
457
|
+
"""Display devices and allow user to select one interactively.
|
|
458
|
+
|
|
459
|
+
Returns:
|
|
460
|
+
Tuple of (host, port, use_https) or None if cancelled
|
|
461
|
+
"""
|
|
410
462
|
if not devices:
|
|
411
463
|
print(f"\n{colorize('No ONVIF devices found.', 'red')}")
|
|
412
464
|
return None
|
|
@@ -415,8 +467,14 @@ def select_device_interactive(devices: list) -> Optional[Tuple[str, int]]:
|
|
|
415
467
|
|
|
416
468
|
for idx, device in enumerate(devices, 1):
|
|
417
469
|
idx_str = colorize(f"[{idx}]", "yellow")
|
|
470
|
+
protocol = "https" if device.get("use_https", False) else "http"
|
|
418
471
|
host_port = f"{device['host']}:{device['port']}"
|
|
419
|
-
|
|
472
|
+
protocol_indicator = (
|
|
473
|
+
colorize("🔒 HTTPS", "green")
|
|
474
|
+
if device.get("use_https", False)
|
|
475
|
+
else colorize("HTTP", "white")
|
|
476
|
+
)
|
|
477
|
+
print(f"\n{idx_str} {colorize(host_port, 'yellow')} ({protocol_indicator})")
|
|
420
478
|
print(f" [id] {device['epr']}")
|
|
421
479
|
|
|
422
480
|
if device["xaddrs"]:
|
|
@@ -454,11 +512,16 @@ def select_device_interactive(devices: list) -> Optional[Tuple[str, int]]:
|
|
|
454
512
|
idx = int(selection)
|
|
455
513
|
if 1 <= idx <= len(devices):
|
|
456
514
|
selected = devices[idx - 1]
|
|
515
|
+
protocol = "https" if selected.get("use_https", False) else "http"
|
|
457
516
|
host_port = f"{selected['host']}:{selected['port']}"
|
|
458
517
|
print(
|
|
459
|
-
f"\n{colorize('Selected:', 'green')} {colorize(host_port, 'yellow')}"
|
|
518
|
+
f"\n{colorize('Selected:', 'green')} {colorize(protocol, 'cyan')}://{colorize(host_port, 'yellow')}"
|
|
519
|
+
)
|
|
520
|
+
return (
|
|
521
|
+
selected["host"],
|
|
522
|
+
selected["port"],
|
|
523
|
+
selected.get("use_https", False),
|
|
460
524
|
)
|
|
461
|
-
return (selected["host"], selected["port"])
|
|
462
525
|
else:
|
|
463
526
|
print(f"{colorize('Invalid selection. Please try again.', 'red')}")
|
|
464
527
|
|
|
@@ -468,5 +531,15 @@ def select_device_interactive(devices: list) -> Optional[Tuple[str, int]]:
|
|
|
468
531
|
return None
|
|
469
532
|
|
|
470
533
|
|
|
534
|
+
def setup_warning_format():
|
|
535
|
+
"""Setup custom warning format to show clean, concise warnings"""
|
|
536
|
+
|
|
537
|
+
def custom_warning_format(message, category, filename, lineno, line=None):
|
|
538
|
+
# Show only the warning message without file path and line number
|
|
539
|
+
return f"{category.__name__}: {message}\n"
|
|
540
|
+
|
|
541
|
+
warnings.formatwarning = custom_warning_format
|
|
542
|
+
|
|
543
|
+
|
|
471
544
|
if __name__ == "__main__":
|
|
472
545
|
main()
|