onvif-python 0.1.8__py3-none-any.whl → 0.2.0__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 +26 -11
- onvif/cli/main.py +347 -27
- onvif/cli/utils.py +2 -9
- onvif/client.py +90 -8
- onvif/db/companies.db +0 -0
- onvif/db/products.db +0 -0
- onvif/operator.py +159 -4
- onvif/services/analyticsdevice.py +4 -4
- onvif/utils/discovery.py +38 -6
- onvif/utils/error_handlers.py +26 -9
- onvif/utils/service.py +215 -16
- onvif/utils/wsdl.py +53 -44
- onvif/utils/xml_capture.py +33 -5
- onvif/utils/zeep.py +11 -0
- onvif/wsdl/LICENSE.md +232 -0
- {onvif_python-0.1.8.dist-info → onvif_python-0.2.0.dist-info}/METADATA +195 -23
- {onvif_python-0.1.8.dist-info → onvif_python-0.2.0.dist-info}/RECORD +21 -18
- {onvif_python-0.1.8.dist-info → onvif_python-0.2.0.dist-info}/WHEEL +0 -0
- {onvif_python-0.1.8.dist-info → onvif_python-0.2.0.dist-info}/entry_points.txt +0 -0
- {onvif_python-0.1.8.dist-info → onvif_python-0.2.0.dist-info}/licenses/LICENSE.md +0 -0
- {onvif_python-0.1.8.dist-info → onvif_python-0.2.0.dist-info}/top_level.txt +0 -0
onvif/cli/interactive.py
CHANGED
|
@@ -173,6 +173,9 @@ class InteractiveShell(cmd.Cmd):
|
|
|
173
173
|
if hasattr(args, "no_patch") and args.no_patch:
|
|
174
174
|
options_info.append(f" ZeepPatcher : {colorize('Disabled', 'red')}")
|
|
175
175
|
|
|
176
|
+
if hasattr(args, "wsdl") and args.wsdl:
|
|
177
|
+
options_info.append(f" Custom WSDL : {colorize(args.wsdl, 'yellow')}")
|
|
178
|
+
|
|
176
179
|
if (
|
|
177
180
|
hasattr(args, "health_check_interval") and args.health_check_interval != 10
|
|
178
181
|
): # 10 is default
|
|
@@ -191,7 +194,7 @@ class InteractiveShell(cmd.Cmd):
|
|
|
191
194
|
" / __ \\/ | / / | / / _/ ____/",
|
|
192
195
|
" / / / / |/ /| | / // // /_ ",
|
|
193
196
|
"/ /_/ / /| / | |/ // // __/ ",
|
|
194
|
-
"\\____/_/ |_/ |___/___/_/ v0.
|
|
197
|
+
"\\____/_/ |_/ |___/___/_/ v0.2.0",
|
|
195
198
|
" ",
|
|
196
199
|
]
|
|
197
200
|
|
|
@@ -602,6 +605,8 @@ class InteractiveShell(cmd.Cmd):
|
|
|
602
605
|
if self.current_service:
|
|
603
606
|
# In service mode - suggest methods
|
|
604
607
|
methods = get_service_methods(self.current_service)
|
|
608
|
+
# Add helper commands in service mode
|
|
609
|
+
methods.extend([colorize("type", "yellow"), colorize("desc", "yellow")])
|
|
605
610
|
for method in methods:
|
|
606
611
|
if method.lower().startswith(partial_cmd.lower()):
|
|
607
612
|
suggestions.append(method)
|
|
@@ -627,6 +632,8 @@ class InteractiveShell(cmd.Cmd):
|
|
|
627
632
|
if self.current_service:
|
|
628
633
|
# Complete method names in service mode
|
|
629
634
|
methods = get_service_methods(self.current_service)
|
|
635
|
+
# Add helper commands in service mode
|
|
636
|
+
methods.extend([colorize("type", "yellow"), colorize("desc", "yellow")])
|
|
630
637
|
completions = [
|
|
631
638
|
method for method in methods if method.lower().startswith(text.lower())
|
|
632
639
|
]
|
|
@@ -839,6 +846,8 @@ class InteractiveShell(cmd.Cmd):
|
|
|
839
846
|
if self.current_service:
|
|
840
847
|
# In service mode - show available methods
|
|
841
848
|
methods = get_service_methods(self.current_service)
|
|
849
|
+
# Add helper commands in service mode
|
|
850
|
+
methods.extend([colorize("type", "yellow"), colorize("desc", "yellow")])
|
|
842
851
|
if methods:
|
|
843
852
|
# Use the same display format as TAB completion
|
|
844
853
|
self._display_grid(methods)
|
|
@@ -875,6 +884,7 @@ class InteractiveShell(cmd.Cmd):
|
|
|
875
884
|
print(
|
|
876
885
|
f"{colorize('Error:', 'red')} Method '{method_name}' not found in service '{self.current_service_name}'."
|
|
877
886
|
)
|
|
887
|
+
print(f"Use {colorize('ls', 'yellow')} to see available methods.")
|
|
878
888
|
return
|
|
879
889
|
|
|
880
890
|
doc_info = get_method_documentation(self.current_service, method_name)
|
|
@@ -913,8 +923,8 @@ class InteractiveShell(cmd.Cmd):
|
|
|
913
923
|
return [m for m in methods if m.lower().startswith(text.lower())]
|
|
914
924
|
|
|
915
925
|
def do_type(self, line):
|
|
916
|
-
"""Show input and output types for
|
|
917
|
-
|
|
926
|
+
"""Show input and output types for a method. Usage: type <method_name>"""
|
|
927
|
+
method_name = line.strip()
|
|
918
928
|
|
|
919
929
|
if not self.current_service:
|
|
920
930
|
print(
|
|
@@ -923,18 +933,18 @@ class InteractiveShell(cmd.Cmd):
|
|
|
923
933
|
print(f"Enter a service first (e.g., {colorize('devicemgmt', 'yellow')})")
|
|
924
934
|
return
|
|
925
935
|
|
|
926
|
-
if not
|
|
927
|
-
print("Usage: type <
|
|
936
|
+
if not method_name:
|
|
937
|
+
print("Usage: type <method_name>")
|
|
928
938
|
return
|
|
929
939
|
|
|
930
|
-
if not hasattr(self.current_service,
|
|
940
|
+
if not hasattr(self.current_service, method_name):
|
|
931
941
|
print(
|
|
932
|
-
f"{colorize('Error:', 'red')}
|
|
942
|
+
f"{colorize('Error:', 'red')} Method '{method_name}' not found in service '{self.current_service_name}'."
|
|
933
943
|
)
|
|
934
|
-
print(f"Use {colorize('ls', 'yellow')} to see available
|
|
944
|
+
print(f"Use {colorize('ls', 'yellow')} to see available methods.")
|
|
935
945
|
return
|
|
936
946
|
|
|
937
|
-
type_info = get_operation_type_info(self.current_service,
|
|
947
|
+
type_info = get_operation_type_info(self.current_service, method_name)
|
|
938
948
|
|
|
939
949
|
if type_info:
|
|
940
950
|
# Helper function to display parameters recursively with tree-style indentation
|
|
@@ -1065,7 +1075,7 @@ class InteractiveShell(cmd.Cmd):
|
|
|
1065
1075
|
print() # Add newline for spacing
|
|
1066
1076
|
else:
|
|
1067
1077
|
print(
|
|
1068
|
-
f"{colorize('Error:', 'red')} Could not retrieve type information for '{
|
|
1078
|
+
f"{colorize('Error:', 'red')} Could not retrieve type information for '{method_name}'."
|
|
1069
1079
|
)
|
|
1070
1080
|
|
|
1071
1081
|
def complete_type(self, text, line, begidx, endidx):
|
|
@@ -1268,6 +1278,11 @@ class InteractiveShell(cmd.Cmd):
|
|
|
1268
1278
|
if hasattr(self.args, "no_patch") and self.args.no_patch:
|
|
1269
1279
|
options_info.append(f" ZeepPatcher : {colorize('Disabled', 'red')}")
|
|
1270
1280
|
|
|
1281
|
+
if hasattr(self.args, "wsdl") and self.args.wsdl:
|
|
1282
|
+
options_info.append(
|
|
1283
|
+
f" Custom WSDL : {colorize(self.args.wsdl, 'yellow')}"
|
|
1284
|
+
)
|
|
1285
|
+
|
|
1271
1286
|
if (
|
|
1272
1287
|
hasattr(self.args, "health_check_interval")
|
|
1273
1288
|
and self.args.health_check_interval != 10
|
|
@@ -1385,7 +1400,7 @@ class InteractiveShell(cmd.Cmd):
|
|
|
1385
1400
|
super().do_help(line)
|
|
1386
1401
|
else:
|
|
1387
1402
|
help_text = f"""
|
|
1388
|
-
{colorize('ONVIF Interactive Shell — v0.
|
|
1403
|
+
{colorize('ONVIF Interactive Shell — v0.2.0', 'cyan')}\n{colorize('https://github.com/nirsimetri/onvif-python', 'white')}
|
|
1389
1404
|
|
|
1390
1405
|
{colorize('Basic Commands:', 'yellow')}
|
|
1391
1406
|
capabilities, caps - Show device capabilities
|
onvif/cli/main.py
CHANGED
|
@@ -4,6 +4,10 @@ import argparse
|
|
|
4
4
|
import sys
|
|
5
5
|
import warnings
|
|
6
6
|
import getpass
|
|
7
|
+
import sqlite3
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
from datetime import datetime
|
|
7
11
|
from typing import Any, Optional, Tuple
|
|
8
12
|
|
|
9
13
|
from ..client import ONVIFClient
|
|
@@ -17,19 +21,24 @@ def create_parser():
|
|
|
17
21
|
"""Create argument parser for ONVIF CLI"""
|
|
18
22
|
parser = argparse.ArgumentParser(
|
|
19
23
|
prog="onvif",
|
|
20
|
-
description=f"{colorize('ONVIF Terminal Client', 'yellow')} — v0.
|
|
24
|
+
description=f"{colorize('ONVIF Terminal Client', 'yellow')} — v0.2.0\nhttps://github.com/nirsimetri/onvif-python",
|
|
21
25
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
22
26
|
epilog=f"""
|
|
23
27
|
Examples:
|
|
28
|
+
# Product search
|
|
29
|
+
{colorize('onvif', 'yellow')} --search c210
|
|
30
|
+
{colorize('onvif', 'yellow')} -s "axis camera"
|
|
31
|
+
{colorize('onvif', 'yellow')} --search hikvision --page 2 --per-page 5
|
|
32
|
+
|
|
24
33
|
# Discover ONVIF devices on network
|
|
25
34
|
{colorize('onvif', 'yellow')} --discover --username admin --password admin123 --interactive
|
|
26
35
|
{colorize('onvif', 'yellow')} media GetProfiles --discover --username admin
|
|
27
36
|
{colorize('onvif', 'yellow')} -d -i
|
|
28
37
|
|
|
29
38
|
# Discover with filtering
|
|
30
|
-
{colorize('onvif', 'yellow')} --discover --
|
|
31
|
-
{colorize('onvif', 'yellow')} -d -
|
|
32
|
-
{colorize('onvif', 'yellow')} -d -
|
|
39
|
+
{colorize('onvif', 'yellow')} --discover --filter ptz --interactive
|
|
40
|
+
{colorize('onvif', 'yellow')} -d -f "C210" -i
|
|
41
|
+
{colorize('onvif', 'yellow')} -d -f "audio_encoder" -u admin -p admin123 -i
|
|
33
42
|
|
|
34
43
|
# Direct command execution
|
|
35
44
|
{colorize('onvif', 'yellow')} devicemgmt GetCapabilities Category=All --host 192.168.1.17 --port 8000 --username admin --password admin123
|
|
@@ -66,10 +75,29 @@ Examples:
|
|
|
66
75
|
action="store_true",
|
|
67
76
|
help="Discover ONVIF devices on the network using WS-Discovery",
|
|
68
77
|
)
|
|
78
|
+
parser.add_argument(
|
|
79
|
+
"--filter",
|
|
80
|
+
"-f",
|
|
81
|
+
help="Filter discovered devices by types or scopes (case-insensitive substring match)",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Product search
|
|
69
85
|
parser.add_argument(
|
|
70
86
|
"--search",
|
|
71
87
|
"-s",
|
|
72
|
-
help="
|
|
88
|
+
help="Search ONVIF products database by model or company (e.g., 'c210', 'hikvision')",
|
|
89
|
+
)
|
|
90
|
+
parser.add_argument(
|
|
91
|
+
"--page",
|
|
92
|
+
type=int,
|
|
93
|
+
default=1,
|
|
94
|
+
help="Page number for search results (default: 1)",
|
|
95
|
+
)
|
|
96
|
+
parser.add_argument(
|
|
97
|
+
"--per-page",
|
|
98
|
+
type=int,
|
|
99
|
+
default=20,
|
|
100
|
+
help="Number of results per page (default: 20)",
|
|
73
101
|
)
|
|
74
102
|
|
|
75
103
|
# Connection options
|
|
@@ -133,6 +161,277 @@ Examples:
|
|
|
133
161
|
return parser
|
|
134
162
|
|
|
135
163
|
|
|
164
|
+
def search_products(search_term: str, page: int = 1, per_page: int = 20) -> None:
|
|
165
|
+
"""Search ONVIF products database and display results in table format with pagination.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
search_term: Search term to match against model, post_title, and company_name fields
|
|
169
|
+
page: Page number (1-based)
|
|
170
|
+
per_page: Number of results per page
|
|
171
|
+
"""
|
|
172
|
+
# Get the database path relative to the script location
|
|
173
|
+
current_dir = os.path.dirname(os.path.abspath(__file__))
|
|
174
|
+
db_path = os.path.join(current_dir, "..", "db", "products.db")
|
|
175
|
+
db_path = os.path.normpath(db_path)
|
|
176
|
+
|
|
177
|
+
if not os.path.exists(db_path):
|
|
178
|
+
print(f"{colorize('Error:', 'red')} Products database not found at {db_path}")
|
|
179
|
+
sys.exit(1)
|
|
180
|
+
|
|
181
|
+
# Validate pagination parameters
|
|
182
|
+
if page < 1:
|
|
183
|
+
print(f"{colorize('Error:', 'red')} Page number must be 1 or greater")
|
|
184
|
+
sys.exit(1)
|
|
185
|
+
|
|
186
|
+
if per_page < 1 or per_page > 100:
|
|
187
|
+
print(f"{colorize('Error:', 'red')} Per-page must be between 1 and 100")
|
|
188
|
+
sys.exit(1)
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
conn = sqlite3.connect(db_path)
|
|
192
|
+
cursor = conn.cursor()
|
|
193
|
+
|
|
194
|
+
# First, get total count for pagination info
|
|
195
|
+
count_query = """
|
|
196
|
+
SELECT COUNT(*)
|
|
197
|
+
FROM onvif_products
|
|
198
|
+
WHERE LOWER(model) LIKE LOWER(?)
|
|
199
|
+
OR LOWER(post_title) LIKE LOWER(?)
|
|
200
|
+
OR LOWER(company_name) LIKE LOWER(?)
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
search_pattern = f"%{search_term}%"
|
|
204
|
+
cursor.execute(count_query, (search_pattern, search_pattern, search_pattern))
|
|
205
|
+
total_count = cursor.fetchone()[0]
|
|
206
|
+
|
|
207
|
+
if total_count == 0:
|
|
208
|
+
print(
|
|
209
|
+
f"{colorize('No products found matching:', 'yellow')} {colorize(search_term, 'white')}"
|
|
210
|
+
)
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
# Calculate pagination
|
|
214
|
+
total_pages = (total_count + per_page - 1) // per_page # Ceiling division
|
|
215
|
+
if page > total_pages:
|
|
216
|
+
print(
|
|
217
|
+
f"{colorize('Error:', 'red')} Page {page} does not exist. Total pages: {total_pages}"
|
|
218
|
+
)
|
|
219
|
+
return
|
|
220
|
+
|
|
221
|
+
offset = (page - 1) * per_page
|
|
222
|
+
|
|
223
|
+
# Search query with pagination
|
|
224
|
+
query = """
|
|
225
|
+
SELECT ID, test_date, post_title, product_firmware_version,
|
|
226
|
+
product_profiles, product_category, type,
|
|
227
|
+
company_name
|
|
228
|
+
FROM onvif_products
|
|
229
|
+
WHERE LOWER(model) LIKE LOWER(?)
|
|
230
|
+
OR LOWER(post_title) LIKE LOWER(?)
|
|
231
|
+
OR LOWER(company_name) LIKE LOWER(?)
|
|
232
|
+
ORDER BY test_date DESC
|
|
233
|
+
LIMIT ? OFFSET ?
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
cursor.execute(
|
|
237
|
+
query, (search_pattern, search_pattern, search_pattern, per_page, offset)
|
|
238
|
+
)
|
|
239
|
+
results = cursor.fetchall()
|
|
240
|
+
|
|
241
|
+
# Display results in table format
|
|
242
|
+
start_result = offset + 1
|
|
243
|
+
end_result = min(offset + per_page, total_count)
|
|
244
|
+
print(
|
|
245
|
+
f"\n{colorize(f'Found {total_count} product(s) matching:', 'green')} {colorize(search_term, 'white')}"
|
|
246
|
+
)
|
|
247
|
+
print(
|
|
248
|
+
f"{colorize(f'Showing {start_result}-{end_result} of {total_count} results', 'cyan')}"
|
|
249
|
+
)
|
|
250
|
+
print()
|
|
251
|
+
|
|
252
|
+
# Table headers
|
|
253
|
+
headers = [
|
|
254
|
+
"ID",
|
|
255
|
+
"Test Date",
|
|
256
|
+
"Model",
|
|
257
|
+
"Firmware",
|
|
258
|
+
"Profiles",
|
|
259
|
+
"Category",
|
|
260
|
+
"Type",
|
|
261
|
+
"Company",
|
|
262
|
+
]
|
|
263
|
+
|
|
264
|
+
# Get terminal width for adaptive formatting
|
|
265
|
+
try:
|
|
266
|
+
terminal_width = shutil.get_terminal_size().columns
|
|
267
|
+
except Exception:
|
|
268
|
+
terminal_width = 120 # fallback width
|
|
269
|
+
|
|
270
|
+
# Calculate minimum column widths
|
|
271
|
+
min_col_widths = [max(len(str(header)), 8) for header in headers]
|
|
272
|
+
|
|
273
|
+
# Calculate actual content widths (without truncation first)
|
|
274
|
+
actual_widths = [0] * len(headers)
|
|
275
|
+
for row in results:
|
|
276
|
+
for i, value in enumerate(row):
|
|
277
|
+
if value:
|
|
278
|
+
if i == 1: # Date column - calculate formatted date width
|
|
279
|
+
str_value = str(value)
|
|
280
|
+
if "T" in str_value:
|
|
281
|
+
date_part = str_value.split("T")[0]
|
|
282
|
+
time_part = str_value.split("T")[1].split(".")[0]
|
|
283
|
+
if "+" in time_part:
|
|
284
|
+
time_part = time_part.split("+")[0]
|
|
285
|
+
elif "Z" in time_part:
|
|
286
|
+
time_part = time_part.replace("Z", "")
|
|
287
|
+
formatted_date = f"{date_part} {time_part}"
|
|
288
|
+
actual_widths[i] = max(
|
|
289
|
+
actual_widths[i], len(formatted_date)
|
|
290
|
+
)
|
|
291
|
+
else:
|
|
292
|
+
actual_widths[i] = max(actual_widths[i], len(str_value))
|
|
293
|
+
else:
|
|
294
|
+
actual_widths[i] = max(actual_widths[i], len(str(value)))
|
|
295
|
+
|
|
296
|
+
# Combine minimum widths with actual content widths
|
|
297
|
+
col_widths = [
|
|
298
|
+
max(min_col_widths[i], actual_widths[i]) for i in range(len(headers))
|
|
299
|
+
]
|
|
300
|
+
|
|
301
|
+
# Calculate space needed for separators (3 chars per separator: " | ")
|
|
302
|
+
separator_space = (len(headers) - 1) * 3
|
|
303
|
+
total_content_width = sum(col_widths)
|
|
304
|
+
total_needed_width = total_content_width + separator_space
|
|
305
|
+
|
|
306
|
+
# If table is too wide for terminal, apply smart truncation
|
|
307
|
+
if total_needed_width > terminal_width:
|
|
308
|
+
available_width = terminal_width - separator_space
|
|
309
|
+
|
|
310
|
+
# Priority columns that should not be truncated (ID, Date)
|
|
311
|
+
protected_cols = {0, 1} # ID and Date columns
|
|
312
|
+
protected_width = sum(col_widths[i] for i in protected_cols)
|
|
313
|
+
|
|
314
|
+
# Width available for other columns
|
|
315
|
+
remaining_width = available_width - protected_width
|
|
316
|
+
|
|
317
|
+
# Columns that can be truncated
|
|
318
|
+
truncatable_cols = [
|
|
319
|
+
i for i in range(len(headers)) if i not in protected_cols
|
|
320
|
+
]
|
|
321
|
+
|
|
322
|
+
if remaining_width > 0 and truncatable_cols:
|
|
323
|
+
# Calculate proportional allocation for truncatable columns
|
|
324
|
+
current_truncatable_width = sum(col_widths[i] for i in truncatable_cols)
|
|
325
|
+
|
|
326
|
+
for i in truncatable_cols:
|
|
327
|
+
if current_truncatable_width > 0:
|
|
328
|
+
# Proportional allocation
|
|
329
|
+
proportion = col_widths[i] / current_truncatable_width
|
|
330
|
+
new_width = int(remaining_width * proportion)
|
|
331
|
+
|
|
332
|
+
# Ensure minimum width
|
|
333
|
+
col_widths[i] = max(new_width, min_col_widths[i])
|
|
334
|
+
else:
|
|
335
|
+
col_widths[i] = min_col_widths[i]
|
|
336
|
+
|
|
337
|
+
# Print header
|
|
338
|
+
header_line = " | ".join(
|
|
339
|
+
header.ljust(col_widths[i]) for i, header in enumerate(headers)
|
|
340
|
+
)
|
|
341
|
+
print(colorize(header_line, "yellow"))
|
|
342
|
+
print(colorize("-" * len(header_line), "white"))
|
|
343
|
+
|
|
344
|
+
# Print data rows
|
|
345
|
+
for row in results:
|
|
346
|
+
formatted_row = []
|
|
347
|
+
for i, value in enumerate(row):
|
|
348
|
+
if value is None:
|
|
349
|
+
formatted_value = ""
|
|
350
|
+
else:
|
|
351
|
+
str_value = str(value)
|
|
352
|
+
|
|
353
|
+
# Special formatting for date column (index 1)
|
|
354
|
+
if i == 1 and value: # Date column
|
|
355
|
+
try:
|
|
356
|
+
# Handle ISO format with timezone
|
|
357
|
+
if "T" in str_value:
|
|
358
|
+
# Parse ISO format: 2024-08-15T17:53:12.9154121+08:00
|
|
359
|
+
# Extract just the date and time part before timezone
|
|
360
|
+
date_part = str_value.split("T")[0]
|
|
361
|
+
time_part = str_value.split("T")[1].split(".")[
|
|
362
|
+
0
|
|
363
|
+
] # Remove microseconds
|
|
364
|
+
if "+" in time_part:
|
|
365
|
+
time_part = time_part.split("+")[0]
|
|
366
|
+
elif "Z" in time_part:
|
|
367
|
+
time_part = time_part.replace("Z", "")
|
|
368
|
+
formatted_value = f"{date_part} {time_part}"
|
|
369
|
+
elif (
|
|
370
|
+
len(str_value) == 19 and " " in str_value
|
|
371
|
+
): # Already in correct format
|
|
372
|
+
formatted_value = str_value
|
|
373
|
+
elif len(str_value) == 10: # Just date, add time
|
|
374
|
+
formatted_value = f"{str_value} 00:00:00"
|
|
375
|
+
else:
|
|
376
|
+
# Try to parse common formats
|
|
377
|
+
try:
|
|
378
|
+
parsed_date = datetime.strptime(
|
|
379
|
+
str_value, "%Y-%m-%d %H:%M:%S"
|
|
380
|
+
)
|
|
381
|
+
formatted_value = parsed_date.strftime(
|
|
382
|
+
"%Y-%m-%d %H:%M:%S"
|
|
383
|
+
)
|
|
384
|
+
except ValueError:
|
|
385
|
+
try:
|
|
386
|
+
parsed_date = datetime.strptime(
|
|
387
|
+
str_value, "%Y-%m-%d"
|
|
388
|
+
)
|
|
389
|
+
formatted_value = parsed_date.strftime(
|
|
390
|
+
"%Y-%m-%d 00:00:00"
|
|
391
|
+
)
|
|
392
|
+
except ValueError:
|
|
393
|
+
formatted_value = (
|
|
394
|
+
str_value # Keep original if parsing fails
|
|
395
|
+
)
|
|
396
|
+
except Exception:
|
|
397
|
+
formatted_value = str_value # Keep original if any error
|
|
398
|
+
else:
|
|
399
|
+
# Apply truncation based on calculated column width
|
|
400
|
+
max_width = col_widths[i]
|
|
401
|
+
if len(str_value) > max_width:
|
|
402
|
+
formatted_value = str_value[: max_width - 3] + "..."
|
|
403
|
+
else:
|
|
404
|
+
formatted_value = str_value
|
|
405
|
+
|
|
406
|
+
formatted_row.append(formatted_value.ljust(col_widths[i]))
|
|
407
|
+
|
|
408
|
+
print(" | ".join(formatted_row))
|
|
409
|
+
|
|
410
|
+
# Display pagination information
|
|
411
|
+
print()
|
|
412
|
+
newline = "\n" if total_pages == 1 else ""
|
|
413
|
+
print(f"{colorize(f'Page {page} of {total_pages}', 'cyan')} {newline}")
|
|
414
|
+
|
|
415
|
+
# Show navigation hints
|
|
416
|
+
nav_hints = []
|
|
417
|
+
if page > 1:
|
|
418
|
+
nav_hints.append(f"Previous: --page {page - 1}")
|
|
419
|
+
if page < total_pages:
|
|
420
|
+
nav_hints.append(f"Next: --page {page + 1}")
|
|
421
|
+
|
|
422
|
+
if nav_hints:
|
|
423
|
+
print(f"{colorize('Navigation:', 'white')} {' | '.join(nav_hints)}\n")
|
|
424
|
+
|
|
425
|
+
conn.close()
|
|
426
|
+
|
|
427
|
+
except sqlite3.Error as e:
|
|
428
|
+
print(f"{colorize('Database error:', 'red')} {e}")
|
|
429
|
+
sys.exit(1)
|
|
430
|
+
except Exception as e:
|
|
431
|
+
print(f"{colorize('Error:', 'red')} {e}")
|
|
432
|
+
sys.exit(1)
|
|
433
|
+
|
|
434
|
+
|
|
136
435
|
def main():
|
|
137
436
|
"""Main CLI entry point"""
|
|
138
437
|
# Setup custom warning format for cleaner output
|
|
@@ -149,11 +448,21 @@ def main():
|
|
|
149
448
|
|
|
150
449
|
# Show ONVIF CLI version
|
|
151
450
|
if args.version:
|
|
152
|
-
print(colorize("0.
|
|
451
|
+
print(colorize("0.2.0", "yellow"))
|
|
452
|
+
sys.exit(0)
|
|
453
|
+
|
|
454
|
+
# Handle product search
|
|
455
|
+
if args.search:
|
|
456
|
+
search_products(args.search, args.page, args.per_page)
|
|
153
457
|
sys.exit(0)
|
|
154
458
|
|
|
155
459
|
# Validate arguments early (before discovery)
|
|
156
|
-
|
|
460
|
+
# Skip validation if search mode is active
|
|
461
|
+
if (
|
|
462
|
+
not args.search
|
|
463
|
+
and not args.interactive
|
|
464
|
+
and (not args.service or not args.method)
|
|
465
|
+
):
|
|
157
466
|
parser.error(
|
|
158
467
|
f"Either {colorize('--interactive', 'white')}/{colorize('-i', 'white')} mode or {colorize('service/method', 'white')} must be specified"
|
|
159
468
|
)
|
|
@@ -165,15 +474,15 @@ def main():
|
|
|
165
474
|
f"{colorize('--discover', 'white')} cannot be used with {colorize('--host', 'white')}"
|
|
166
475
|
)
|
|
167
476
|
|
|
168
|
-
# Discover devices (pass --https flag to prioritize HTTPS XAddrs and
|
|
477
|
+
# Discover devices (pass --https flag to prioritize HTTPS XAddrs and filter term)
|
|
169
478
|
devices = discover_devices(
|
|
170
|
-
timeout=4, prefer_https=args.https,
|
|
479
|
+
timeout=4, prefer_https=args.https, filter_term=args.filter
|
|
171
480
|
)
|
|
172
481
|
|
|
173
482
|
if not devices:
|
|
174
|
-
if args.
|
|
483
|
+
if args.filter:
|
|
175
484
|
print(
|
|
176
|
-
f"{colorize('No devices found matching
|
|
485
|
+
f"{colorize('No devices found matching filter:', 'red')} {colorize(args.filter, 'white')}"
|
|
177
486
|
)
|
|
178
487
|
else:
|
|
179
488
|
print(colorize("No ONVIF devices discovered. Exiting.", "red"))
|
|
@@ -192,22 +501,22 @@ def main():
|
|
|
192
501
|
# Use device's detected protocol (already filtered by prefer_https in discover_devices)
|
|
193
502
|
# No need to override - device info already has correct protocol based on --https flag
|
|
194
503
|
|
|
195
|
-
# Validate that host is provided (either via --host or --discover)
|
|
196
|
-
if not args.host:
|
|
504
|
+
# Validate that host is provided (either via --host or --discover) unless using --search
|
|
505
|
+
if not args.search and not args.host:
|
|
197
506
|
parser.error(
|
|
198
507
|
f"Either {colorize('--host', 'white')} or {colorize('--discover', 'white')} must be specified"
|
|
199
508
|
)
|
|
200
509
|
|
|
201
|
-
# Handle username prompt
|
|
202
|
-
if not args.username:
|
|
510
|
+
# Handle username prompt (skip for search mode)
|
|
511
|
+
if not args.search and not args.username:
|
|
203
512
|
try:
|
|
204
513
|
args.username = input("Enter username: ")
|
|
205
514
|
except (EOFError, KeyboardInterrupt):
|
|
206
515
|
print("\nUsername entry cancelled.")
|
|
207
516
|
sys.exit(1)
|
|
208
517
|
|
|
209
|
-
# Handle password securely if not provided
|
|
210
|
-
if not args.password:
|
|
518
|
+
# Handle password securely if not provided (skip for search mode)
|
|
519
|
+
if not args.search and not args.password:
|
|
211
520
|
try:
|
|
212
521
|
args.password = getpass.getpass(
|
|
213
522
|
f"Enter password for {colorize(f'{args.username}@{args.host}', 'yellow')}: "
|
|
@@ -216,6 +525,10 @@ def main():
|
|
|
216
525
|
print("\nPassword entry cancelled.")
|
|
217
526
|
sys.exit(1)
|
|
218
527
|
|
|
528
|
+
# Skip ONVIF client creation for search mode
|
|
529
|
+
if args.search:
|
|
530
|
+
return
|
|
531
|
+
|
|
219
532
|
try:
|
|
220
533
|
# Create ONVIF client
|
|
221
534
|
client = ONVIFClient(
|
|
@@ -296,14 +609,14 @@ def execute_command(
|
|
|
296
609
|
|
|
297
610
|
|
|
298
611
|
def discover_devices(
|
|
299
|
-
timeout: int = 4, prefer_https: bool = False,
|
|
612
|
+
timeout: int = 4, prefer_https: bool = False, filter_term: Optional[str] = None
|
|
300
613
|
) -> list:
|
|
301
614
|
"""Discover ONVIF devices on the network using WS-Discovery.
|
|
302
615
|
|
|
303
616
|
Args:
|
|
304
617
|
timeout: Discovery timeout in seconds
|
|
305
618
|
prefer_https: If True, prioritize HTTPS XAddrs when available
|
|
306
|
-
|
|
619
|
+
filter_term: Optional search term to filter devices by types or scopes
|
|
307
620
|
|
|
308
621
|
Returns:
|
|
309
622
|
List of discovered devices with connection info
|
|
@@ -315,11 +628,11 @@ def discover_devices(
|
|
|
315
628
|
print(f"\n{colorize('Discovering ONVIF devices on network...', 'yellow')}")
|
|
316
629
|
print(f"Network interface: {colorize(discovery._get_local_ip(), 'white')}")
|
|
317
630
|
print(f"Timeout: {timeout}s")
|
|
318
|
-
if
|
|
319
|
-
print(f"Filter: {colorize(
|
|
631
|
+
if filter_term:
|
|
632
|
+
print(f"Filter: {colorize(filter_term, 'yellow')}")
|
|
320
633
|
print()
|
|
321
634
|
|
|
322
|
-
devices = discovery.discover(prefer_https=prefer_https, search=
|
|
635
|
+
devices = discovery.discover(prefer_https=prefer_https, search=filter_term)
|
|
323
636
|
|
|
324
637
|
return devices
|
|
325
638
|
|
|
@@ -346,15 +659,22 @@ def select_device_interactive(devices: list) -> Optional[Tuple[str, int, bool]]:
|
|
|
346
659
|
else colorize("HTTP", "white")
|
|
347
660
|
)
|
|
348
661
|
print(f"\n{idx_str} {colorize(host_port, 'yellow')} ({protocol_indicator})")
|
|
349
|
-
|
|
662
|
+
|
|
663
|
+
# Remove uuid: or urn:uuid: prefix from EPR
|
|
664
|
+
epr_display = device["epr"]
|
|
665
|
+
if epr_display.startswith("urn:uuid:"):
|
|
666
|
+
epr_display = epr_display.replace("urn:uuid:", "")
|
|
667
|
+
elif epr_display.startswith("uuid:"):
|
|
668
|
+
epr_display = epr_display.replace("uuid:", "")
|
|
669
|
+
print(f" [id] {epr_display}")
|
|
350
670
|
|
|
351
671
|
if device["xaddrs"]:
|
|
352
|
-
|
|
353
|
-
print(f" [xaddrs] {
|
|
672
|
+
xaddrs_parts = [f"[{xaddr}]" for xaddr in device["xaddrs"]]
|
|
673
|
+
print(f" [xaddrs] {' '.join(xaddrs_parts)}")
|
|
354
674
|
|
|
355
675
|
if device["types"]:
|
|
356
|
-
|
|
357
|
-
print(f" [types] {
|
|
676
|
+
types_parts = [f"[{t}]" for t in device["types"]]
|
|
677
|
+
print(f" [types] {' '.join(types_parts)}")
|
|
358
678
|
|
|
359
679
|
if device["scopes"]:
|
|
360
680
|
scope_parts = []
|
onvif/cli/utils.py
CHANGED
|
@@ -213,8 +213,8 @@ def get_service_methods(service_obj) -> list:
|
|
|
213
213
|
methods = []
|
|
214
214
|
for attr_name in dir(service_obj):
|
|
215
215
|
if not attr_name.startswith("_") and callable(getattr(service_obj, attr_name)):
|
|
216
|
-
# Skip
|
|
217
|
-
if attr_name
|
|
216
|
+
# Skip helper methods
|
|
217
|
+
if attr_name not in ["type", "desc", "operations"]:
|
|
218
218
|
methods.append(attr_name)
|
|
219
219
|
return sorted(methods)
|
|
220
220
|
|
|
@@ -354,13 +354,6 @@ def get_method_documentation(service_obj, method_name: str) -> Optional[Dict[str
|
|
|
354
354
|
return None
|
|
355
355
|
|
|
356
356
|
|
|
357
|
-
def truncate_output(text: str, max_length: int = 1000) -> str:
|
|
358
|
-
"""Truncate output if too long"""
|
|
359
|
-
if len(text) <= max_length:
|
|
360
|
-
return text
|
|
361
|
-
return text[:max_length] + f"\n... (truncated, {len(text)} total chars)"
|
|
362
|
-
|
|
363
|
-
|
|
364
357
|
def colorize(text: str, color: str) -> str:
|
|
365
358
|
"""Add color to text for terminal output"""
|
|
366
359
|
# Enable ANSI colors on Windows
|