onvif-scanner 0.1.0__tar.gz
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_scanner-0.1.0/LICENSE +21 -0
- onvif_scanner-0.1.0/PKG-INFO +159 -0
- onvif_scanner-0.1.0/README.md +126 -0
- onvif_scanner-0.1.0/onvif_scanner/__init__.py +3 -0
- onvif_scanner-0.1.0/onvif_scanner/__main__.py +6 -0
- onvif_scanner-0.1.0/onvif_scanner/cli.py +182 -0
- onvif_scanner-0.1.0/onvif_scanner/device.py +111 -0
- onvif_scanner-0.1.0/onvif_scanner/discovery.py +91 -0
- onvif_scanner-0.1.0/onvif_scanner/models.py +37 -0
- onvif_scanner-0.1.0/onvif_scanner.egg-info/PKG-INFO +159 -0
- onvif_scanner-0.1.0/onvif_scanner.egg-info/SOURCES.txt +16 -0
- onvif_scanner-0.1.0/onvif_scanner.egg-info/dependency_links.txt +1 -0
- onvif_scanner-0.1.0/onvif_scanner.egg-info/entry_points.txt +2 -0
- onvif_scanner-0.1.0/onvif_scanner.egg-info/requires.txt +8 -0
- onvif_scanner-0.1.0/onvif_scanner.egg-info/top_level.txt +1 -0
- onvif_scanner-0.1.0/pyproject.toml +52 -0
- onvif_scanner-0.1.0/setup.cfg +4 -0
- onvif_scanner-0.1.0/tests/test_discovery.py +30 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Urban Parking
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: onvif-scanner
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI tool to scan ONVIF cameras on local network via WS-Discovery
|
|
5
|
+
Author-email: Riki Handoyo <jawakeror@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/jawak/onvif-scanner
|
|
8
|
+
Project-URL: Repository, https://github.com/jawak/onvif-scanner
|
|
9
|
+
Project-URL: Issues, https://github.com/jawak/onvif-scanner/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/jawak/onvif-scanner/releases
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: System Administrators
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Multimedia :: Video
|
|
21
|
+
Classifier: Topic :: System :: Networking :: Monitoring
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: onvif-zeep>=0.2.12
|
|
26
|
+
Requires-Dist: wsdiscovery>=0.1.0
|
|
27
|
+
Requires-Dist: click>=8.1
|
|
28
|
+
Requires-Dist: rich>=13.0
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
31
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# ONVIF Scanner
|
|
35
|
+
|
|
36
|
+
[](https://pypi.org/project/onvif-scanner/)
|
|
37
|
+
[](https://pypi.org/project/onvif-scanner/)
|
|
38
|
+
[](LICENSE)
|
|
39
|
+
[](https://github.com/jawak/onvif-scanner/actions/workflows/publish.yml)
|
|
40
|
+
|
|
41
|
+
A simple CLI tool to scan for ONVIF cameras on the local network. It uses
|
|
42
|
+
WS-Discovery (UDP multicast) to discover devices, then queries the ONVIF
|
|
43
|
+
Device Service to retrieve: manufacturer, model, serial number, firmware, and
|
|
44
|
+
the **RTSP URL**.
|
|
45
|
+
|
|
46
|
+
## Features
|
|
47
|
+
|
|
48
|
+
- **WS-Discovery multicast** on the local subnet (automatic, no manual IP input)
|
|
49
|
+
- **Device query** — GetDeviceInformation + GetStreamUri (RTSP URL)
|
|
50
|
+
- Output as a **table** (pretty, default) or **JSON** (stdout / file)
|
|
51
|
+
- `--rtsp-only` filter — only show cameras that successfully returned an RTSP URL
|
|
52
|
+
- Authenticated scan (`--username` / `--password`) for cameras that require auth
|
|
53
|
+
- Per-device error isolation — a single camera failing does not break the entire scan
|
|
54
|
+
|
|
55
|
+
## Install
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
cd onvif-scanner
|
|
59
|
+
|
|
60
|
+
# Development mode (recommended)
|
|
61
|
+
pip install -e .
|
|
62
|
+
|
|
63
|
+
# Or run directly from source without installing
|
|
64
|
+
python -m onvif-scanner --help
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Requires Python 3.10+.
|
|
68
|
+
|
|
69
|
+
## Usage
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
# Quick scan, pretty table output (default)
|
|
73
|
+
onvif-scan
|
|
74
|
+
|
|
75
|
+
# JSON to stdout
|
|
76
|
+
onvif-scan --output json
|
|
77
|
+
|
|
78
|
+
# Save to file
|
|
79
|
+
onvif-scan --output-file cameras.json
|
|
80
|
+
|
|
81
|
+
# Longer timeout (large / slow networks)
|
|
82
|
+
onvif-scan --timeout 10
|
|
83
|
+
|
|
84
|
+
# Authenticated scan (to get RTSP URLs from cameras that require auth)
|
|
85
|
+
onvif-scan --username admin --password secret
|
|
86
|
+
|
|
87
|
+
# Only show cameras that have an RTSP URL
|
|
88
|
+
onvif-scan --rtsp-only
|
|
89
|
+
|
|
90
|
+
# Debug logging (WS-Discovery + zeep)
|
|
91
|
+
onvif-scan --verbose
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## JSON Output
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
{
|
|
98
|
+
"scan_time": "2026-07-19T10:00:00+07:00",
|
|
99
|
+
"scan_duration_seconds": 3.2,
|
|
100
|
+
"timeout_seconds": 5,
|
|
101
|
+
"camera_count": 2,
|
|
102
|
+
"cameras": [
|
|
103
|
+
{
|
|
104
|
+
"address": "192.168.1.100",
|
|
105
|
+
"device_url": "http://192.168.1.100/onvif/device_service",
|
|
106
|
+
"manufacturer": "Hikvision",
|
|
107
|
+
"model": "DS-2CD2143G2",
|
|
108
|
+
"firmware_version": "V5.6.5",
|
|
109
|
+
"serial_number": "DS-2CD2143G2-...-VVV111100",
|
|
110
|
+
"hardware_id": "880901",
|
|
111
|
+
"rtsp_url": "rtsp://192.168.1.100:554/Streaming/Channels/101",
|
|
112
|
+
"mac": null,
|
|
113
|
+
"scopes": ["onvif://www.onvif.org/Profile/Streaming"],
|
|
114
|
+
"authenticated": false,
|
|
115
|
+
"error": null
|
|
116
|
+
}
|
|
117
|
+
]
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Troubleshooting
|
|
122
|
+
|
|
123
|
+
| Symptom | Cause | Solution |
|
|
124
|
+
|---------|-------|----------|
|
|
125
|
+
| `No ONVIF devices discovered` | Multicast UDP blocked by firewall / different VLAN | Make sure UDP port 3702 (multicast 239.255.255.250:3702) is allowed. The scan must run on the same subnet as the cameras. |
|
|
126
|
+
| Camera discovered but `rtsp_url: null` | Camera requires auth for the Media service | Use `--username admin --password <pass>` |
|
|
127
|
+
| Camera not discovered despite being online | ONVIF disabled in the camera's web UI | Enable ONVIF (Profile S) in the camera settings |
|
|
128
|
+
| `ImportError: wsdiscovery` / `onvif-zeep` | Dependencies not installed | Re-run `pip install -e .` |
|
|
129
|
+
| Scan is slow (>5s) | Large network / many devices | Increase `--timeout 10`, or add `--rtsp-only` to skip device queries |
|
|
130
|
+
| zeep error: WSDL download | onvif-zeep requires internet access on first run (to download WSDL) | Ensure internet connectivity, or bundle the WSDL locally and pass `wsdl_dir` in `device.py` |
|
|
131
|
+
|
|
132
|
+
## Compatible Devices
|
|
133
|
+
|
|
134
|
+
Tested with: Hikvision, Dahua, generic ONVIF Profile S (Arducam, Reolink, Amcrest).
|
|
135
|
+
|
|
136
|
+
Standards: ONVIF Core Spec + Profile S (Streaming).
|
|
137
|
+
|
|
138
|
+
## Architecture
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
onvif_scanner/
|
|
142
|
+
├── cli.py # Click CLI + rich output (table/json/file)
|
|
143
|
+
├── discovery.py # WS-Discovery multicast via wsdiscovery lib
|
|
144
|
+
├── device.py # ONVIF Device Service query via onvif-zeep
|
|
145
|
+
└── models.py # dataclass DiscoveredDevice + Camera
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Flow: `cli.py` → `discovery.discover_cameras()` (UDP multicast) → `device.query_device()` (HTTP SOAP per camera) → output (table/json/file).
|
|
149
|
+
|
|
150
|
+
## Limitations (future work)
|
|
151
|
+
|
|
152
|
+
- Multicast only (no unicast IP range probe for cross-VLAN discovery)
|
|
153
|
+
- Single username/password (no multi-credential support)
|
|
154
|
+
- No auto-registration to the parking DB (`cameras` table) — a `register` subcommand could be added
|
|
155
|
+
- No streaming preview (RTSP URL only, no frame preview)
|
|
156
|
+
|
|
157
|
+
## License
|
|
158
|
+
|
|
159
|
+
MIT
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# ONVIF Scanner
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/onvif-scanner/)
|
|
4
|
+
[](https://pypi.org/project/onvif-scanner/)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](https://github.com/jawak/onvif-scanner/actions/workflows/publish.yml)
|
|
7
|
+
|
|
8
|
+
A simple CLI tool to scan for ONVIF cameras on the local network. It uses
|
|
9
|
+
WS-Discovery (UDP multicast) to discover devices, then queries the ONVIF
|
|
10
|
+
Device Service to retrieve: manufacturer, model, serial number, firmware, and
|
|
11
|
+
the **RTSP URL**.
|
|
12
|
+
|
|
13
|
+
## Features
|
|
14
|
+
|
|
15
|
+
- **WS-Discovery multicast** on the local subnet (automatic, no manual IP input)
|
|
16
|
+
- **Device query** — GetDeviceInformation + GetStreamUri (RTSP URL)
|
|
17
|
+
- Output as a **table** (pretty, default) or **JSON** (stdout / file)
|
|
18
|
+
- `--rtsp-only` filter — only show cameras that successfully returned an RTSP URL
|
|
19
|
+
- Authenticated scan (`--username` / `--password`) for cameras that require auth
|
|
20
|
+
- Per-device error isolation — a single camera failing does not break the entire scan
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
cd onvif-scanner
|
|
26
|
+
|
|
27
|
+
# Development mode (recommended)
|
|
28
|
+
pip install -e .
|
|
29
|
+
|
|
30
|
+
# Or run directly from source without installing
|
|
31
|
+
python -m onvif-scanner --help
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Requires Python 3.10+.
|
|
35
|
+
|
|
36
|
+
## Usage
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# Quick scan, pretty table output (default)
|
|
40
|
+
onvif-scan
|
|
41
|
+
|
|
42
|
+
# JSON to stdout
|
|
43
|
+
onvif-scan --output json
|
|
44
|
+
|
|
45
|
+
# Save to file
|
|
46
|
+
onvif-scan --output-file cameras.json
|
|
47
|
+
|
|
48
|
+
# Longer timeout (large / slow networks)
|
|
49
|
+
onvif-scan --timeout 10
|
|
50
|
+
|
|
51
|
+
# Authenticated scan (to get RTSP URLs from cameras that require auth)
|
|
52
|
+
onvif-scan --username admin --password secret
|
|
53
|
+
|
|
54
|
+
# Only show cameras that have an RTSP URL
|
|
55
|
+
onvif-scan --rtsp-only
|
|
56
|
+
|
|
57
|
+
# Debug logging (WS-Discovery + zeep)
|
|
58
|
+
onvif-scan --verbose
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## JSON Output
|
|
62
|
+
|
|
63
|
+
```json
|
|
64
|
+
{
|
|
65
|
+
"scan_time": "2026-07-19T10:00:00+07:00",
|
|
66
|
+
"scan_duration_seconds": 3.2,
|
|
67
|
+
"timeout_seconds": 5,
|
|
68
|
+
"camera_count": 2,
|
|
69
|
+
"cameras": [
|
|
70
|
+
{
|
|
71
|
+
"address": "192.168.1.100",
|
|
72
|
+
"device_url": "http://192.168.1.100/onvif/device_service",
|
|
73
|
+
"manufacturer": "Hikvision",
|
|
74
|
+
"model": "DS-2CD2143G2",
|
|
75
|
+
"firmware_version": "V5.6.5",
|
|
76
|
+
"serial_number": "DS-2CD2143G2-...-VVV111100",
|
|
77
|
+
"hardware_id": "880901",
|
|
78
|
+
"rtsp_url": "rtsp://192.168.1.100:554/Streaming/Channels/101",
|
|
79
|
+
"mac": null,
|
|
80
|
+
"scopes": ["onvif://www.onvif.org/Profile/Streaming"],
|
|
81
|
+
"authenticated": false,
|
|
82
|
+
"error": null
|
|
83
|
+
}
|
|
84
|
+
]
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Troubleshooting
|
|
89
|
+
|
|
90
|
+
| Symptom | Cause | Solution |
|
|
91
|
+
|---------|-------|----------|
|
|
92
|
+
| `No ONVIF devices discovered` | Multicast UDP blocked by firewall / different VLAN | Make sure UDP port 3702 (multicast 239.255.255.250:3702) is allowed. The scan must run on the same subnet as the cameras. |
|
|
93
|
+
| Camera discovered but `rtsp_url: null` | Camera requires auth for the Media service | Use `--username admin --password <pass>` |
|
|
94
|
+
| Camera not discovered despite being online | ONVIF disabled in the camera's web UI | Enable ONVIF (Profile S) in the camera settings |
|
|
95
|
+
| `ImportError: wsdiscovery` / `onvif-zeep` | Dependencies not installed | Re-run `pip install -e .` |
|
|
96
|
+
| Scan is slow (>5s) | Large network / many devices | Increase `--timeout 10`, or add `--rtsp-only` to skip device queries |
|
|
97
|
+
| zeep error: WSDL download | onvif-zeep requires internet access on first run (to download WSDL) | Ensure internet connectivity, or bundle the WSDL locally and pass `wsdl_dir` in `device.py` |
|
|
98
|
+
|
|
99
|
+
## Compatible Devices
|
|
100
|
+
|
|
101
|
+
Tested with: Hikvision, Dahua, generic ONVIF Profile S (Arducam, Reolink, Amcrest).
|
|
102
|
+
|
|
103
|
+
Standards: ONVIF Core Spec + Profile S (Streaming).
|
|
104
|
+
|
|
105
|
+
## Architecture
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
onvif_scanner/
|
|
109
|
+
├── cli.py # Click CLI + rich output (table/json/file)
|
|
110
|
+
├── discovery.py # WS-Discovery multicast via wsdiscovery lib
|
|
111
|
+
├── device.py # ONVIF Device Service query via onvif-zeep
|
|
112
|
+
└── models.py # dataclass DiscoveredDevice + Camera
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Flow: `cli.py` → `discovery.discover_cameras()` (UDP multicast) → `device.query_device()` (HTTP SOAP per camera) → output (table/json/file).
|
|
116
|
+
|
|
117
|
+
## Limitations (future work)
|
|
118
|
+
|
|
119
|
+
- Multicast only (no unicast IP range probe for cross-VLAN discovery)
|
|
120
|
+
- Single username/password (no multi-credential support)
|
|
121
|
+
- No auto-registration to the parking DB (`cameras` table) — a `register` subcommand could be added
|
|
122
|
+
- No streaming preview (RTSP URL only, no frame preview)
|
|
123
|
+
|
|
124
|
+
## License
|
|
125
|
+
|
|
126
|
+
MIT
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""CLI entry point for the ONVIF scanner."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from datetime import datetime, timezone, timedelta
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.logging import RichHandler
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
|
|
17
|
+
from onvif_scanner.device import query_device
|
|
18
|
+
from onvif_scanner.discovery import discover_cameras
|
|
19
|
+
|
|
20
|
+
WIB = timezone(timedelta(hours=7))
|
|
21
|
+
|
|
22
|
+
console = Console()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _setup_logging(verbose: bool) -> None:
|
|
26
|
+
level = logging.DEBUG if verbose else logging.WARNING
|
|
27
|
+
logging.basicConfig(
|
|
28
|
+
level=level,
|
|
29
|
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
30
|
+
datefmt="%H:%M:%S",
|
|
31
|
+
handlers=[RichHandler(console=console)],
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _format_scan_time() -> tuple[str, str]:
|
|
36
|
+
now = datetime.now(WIB)
|
|
37
|
+
return now.isoformat(timespec="seconds"), now.strftime("%Y-%m-%d %H:%M:%S WIB")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _emit_table(cameras: list[dict]) -> None:
|
|
41
|
+
table = Table(title=f"ONVIF Camera Scan — {len(cameras)} device(s) discovered")
|
|
42
|
+
table.add_column("Address", style="cyan", no_wrap=True)
|
|
43
|
+
table.add_column("Manufacturer", style="magenta")
|
|
44
|
+
table.add_column("Model")
|
|
45
|
+
table.add_column("Serial", overflow="fold")
|
|
46
|
+
table.add_column("RTSP URL", style="green", overflow="fold")
|
|
47
|
+
|
|
48
|
+
for c in cameras:
|
|
49
|
+
if c.get("error") and not c.get("manufacturer"):
|
|
50
|
+
table.add_row(
|
|
51
|
+
c["address"],
|
|
52
|
+
"[red]ERROR[/red]",
|
|
53
|
+
"",
|
|
54
|
+
"",
|
|
55
|
+
c["error"][:80],
|
|
56
|
+
)
|
|
57
|
+
else:
|
|
58
|
+
table.add_row(
|
|
59
|
+
c["address"],
|
|
60
|
+
c.get("manufacturer") or "-",
|
|
61
|
+
c.get("model") or "-",
|
|
62
|
+
(c.get("serial_number") or "-")[:24],
|
|
63
|
+
c.get("rtsp_url") or "[red]none[/red]",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
console.print(table)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _emit_json(payload: dict) -> None:
|
|
70
|
+
click.echo(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _write_file(payload: dict, path: Path) -> None:
|
|
74
|
+
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
75
|
+
console.print(f"[green]Saved to[/green] {path}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@click.command()
|
|
79
|
+
@click.option(
|
|
80
|
+
"--timeout",
|
|
81
|
+
default=5,
|
|
82
|
+
show_default=True,
|
|
83
|
+
type=click.IntRange(min=1, max=60),
|
|
84
|
+
help="WS-Discovery listen window (seconds).",
|
|
85
|
+
)
|
|
86
|
+
@click.option(
|
|
87
|
+
"--output",
|
|
88
|
+
"output_format",
|
|
89
|
+
default="table",
|
|
90
|
+
show_default=True,
|
|
91
|
+
type=click.Choice(["table", "json"]),
|
|
92
|
+
help="Output format. 'json' prints to stdout.",
|
|
93
|
+
)
|
|
94
|
+
@click.option(
|
|
95
|
+
"--output-file",
|
|
96
|
+
"output_file",
|
|
97
|
+
type=click.Path(dir_okay=False, writable=True, path_type=Path),
|
|
98
|
+
default=None,
|
|
99
|
+
help="Save JSON scan result to file (always JSON, regardless of --output).",
|
|
100
|
+
)
|
|
101
|
+
@click.option("--username", default=None, help="ONVIF username (for authenticated query).")
|
|
102
|
+
@click.option(
|
|
103
|
+
"--password",
|
|
104
|
+
default=None,
|
|
105
|
+
help="ONVIF password. If --username set and this is empty, you'll be prompted.",
|
|
106
|
+
prompt=False,
|
|
107
|
+
hide_input=True,
|
|
108
|
+
)
|
|
109
|
+
@click.option(
|
|
110
|
+
"--rtsp-only",
|
|
111
|
+
is_flag=True,
|
|
112
|
+
default=False,
|
|
113
|
+
help="Only show cameras that returned an RTSP URL.",
|
|
114
|
+
)
|
|
115
|
+
@click.option("--verbose", "-v", is_flag=True, default=False, help="Enable debug logging.")
|
|
116
|
+
def cli(
|
|
117
|
+
timeout: int,
|
|
118
|
+
output_format: str,
|
|
119
|
+
output_file: Path | None,
|
|
120
|
+
username: str | None,
|
|
121
|
+
password: str | None,
|
|
122
|
+
rtsp_only: bool,
|
|
123
|
+
verbose: bool,
|
|
124
|
+
) -> None:
|
|
125
|
+
"""Scan the local subnet for ONVIF cameras (WS-Discovery + device query)."""
|
|
126
|
+
_setup_logging(verbose)
|
|
127
|
+
|
|
128
|
+
# Prompt for password silently if username given without password
|
|
129
|
+
if username and not password:
|
|
130
|
+
password = click.prompt("Password", hide_input=True, default="", show_default=False)
|
|
131
|
+
|
|
132
|
+
scan_time_iso, scan_time_display = _format_scan_time()
|
|
133
|
+
console.print(f"[bold]Scan started:[/bold] {scan_time_display}")
|
|
134
|
+
start = time.time()
|
|
135
|
+
|
|
136
|
+
# 1. Discovery
|
|
137
|
+
try:
|
|
138
|
+
discovered = discover_cameras(timeout=timeout, verbose=verbose)
|
|
139
|
+
except RuntimeError as e:
|
|
140
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
141
|
+
sys.exit(2)
|
|
142
|
+
except Exception as e:
|
|
143
|
+
console.print(f"[red]Discovery failed:[/red] {e}")
|
|
144
|
+
sys.exit(1)
|
|
145
|
+
|
|
146
|
+
if not discovered:
|
|
147
|
+
console.print("[yellow]No ONVIF devices discovered.[/yellow]")
|
|
148
|
+
|
|
149
|
+
# 2. Query each device
|
|
150
|
+
cameras = []
|
|
151
|
+
for d in discovered:
|
|
152
|
+
cam = query_device(d, username=username, password=password, verbose=verbose)
|
|
153
|
+
cameras.append(cam.to_dict())
|
|
154
|
+
|
|
155
|
+
# 3. Post-filter
|
|
156
|
+
if rtsp_only:
|
|
157
|
+
cameras = [c for c in cameras if c.get("rtsp_url")]
|
|
158
|
+
|
|
159
|
+
duration = round(time.time() - start, 2)
|
|
160
|
+
payload = {
|
|
161
|
+
"scan_time": scan_time_iso,
|
|
162
|
+
"scan_duration_seconds": duration,
|
|
163
|
+
"timeout_seconds": timeout,
|
|
164
|
+
"camera_count": len(cameras),
|
|
165
|
+
"cameras": cameras,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
# 4. Output
|
|
169
|
+
if output_file:
|
|
170
|
+
_write_file(payload, output_file)
|
|
171
|
+
elif output_format == "json":
|
|
172
|
+
_emit_json(payload)
|
|
173
|
+
else:
|
|
174
|
+
_emit_table(cameras)
|
|
175
|
+
console.print(
|
|
176
|
+
f"\n[dim]Scanned in {duration}s · {len(cameras)} camera(s) "
|
|
177
|
+
f"(discovered {len(discovered)} device(s))[/dim]"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
if __name__ == "__main__":
|
|
182
|
+
cli()
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Query ONVIF device service for manufacturer, model, serial, and RTSP URL."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
|
|
8
|
+
from onvif_scanner.models import Camera, DiscoveredDevice
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def query_device(
|
|
14
|
+
device: DiscoveredDevice,
|
|
15
|
+
username: str | None = None,
|
|
16
|
+
password: str | None = None,
|
|
17
|
+
verbose: bool = False,
|
|
18
|
+
) -> Camera:
|
|
19
|
+
"""Query a single ONVIF device for device info + RTSP stream URI.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
device: discovered device (from discovery.discover_cameras).
|
|
23
|
+
username: ONVIF username (optional — some cameras expose info without auth).
|
|
24
|
+
password: ONVIF password.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Camera dataclass. If query fails, `error` field is set and other fields stay None.
|
|
28
|
+
"""
|
|
29
|
+
if verbose:
|
|
30
|
+
logging.getLogger("zeep").setLevel(logging.DEBUG)
|
|
31
|
+
|
|
32
|
+
parsed = urlparse(device.device_url)
|
|
33
|
+
host = parsed.hostname or device.address
|
|
34
|
+
port = parsed.port or 80
|
|
35
|
+
|
|
36
|
+
cam = Camera(
|
|
37
|
+
address=host,
|
|
38
|
+
device_url=device.device_url,
|
|
39
|
+
scopes=list(device.scopes),
|
|
40
|
+
authenticated=bool(username and password),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
from onvif import ONVIFCamera
|
|
45
|
+
except ImportError as e:
|
|
46
|
+
raise RuntimeError(
|
|
47
|
+
"onvif-zeep is not installed. Run: pip install onvif-zeep"
|
|
48
|
+
) from e
|
|
49
|
+
|
|
50
|
+
logger.info("Querying %s (auth=%s)", host, cam.authenticated)
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
# onvif-zeep auto-downloads WSDL if wsdl_dir is not provided.
|
|
54
|
+
# For offline ops, bundle WSDL in a known dir and pass wsdl_dir=...
|
|
55
|
+
client = ONVIFCamera(
|
|
56
|
+
host,
|
|
57
|
+
port,
|
|
58
|
+
username or "",
|
|
59
|
+
password or "",
|
|
60
|
+
no_cache=False,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# 1. GetDeviceInformation
|
|
64
|
+
try:
|
|
65
|
+
mgmt = client.create_devicemgmt_service()
|
|
66
|
+
info = mgmt.GetDeviceInformation()
|
|
67
|
+
cam.manufacturer = getattr(info, "Manufacturer", None)
|
|
68
|
+
cam.model = getattr(info, "Model", None)
|
|
69
|
+
cam.firmware_version = getattr(info, "FirmwareVersion", None)
|
|
70
|
+
cam.serial_number = getattr(info, "SerialNumber", None)
|
|
71
|
+
cam.hardware_id = getattr(info, "HardwareId", None)
|
|
72
|
+
except Exception as e:
|
|
73
|
+
logger.warning("GetDeviceInformation failed for %s: %s", host, e)
|
|
74
|
+
cam.error = f"GetDeviceInformation failed: {e}"
|
|
75
|
+
|
|
76
|
+
# 2. GetStreamUri (requires Media service + a profile token)
|
|
77
|
+
try:
|
|
78
|
+
media = client.create_media_service()
|
|
79
|
+
profiles = media.GetProfiles()
|
|
80
|
+
if not profiles:
|
|
81
|
+
logger.warning("No media profiles for %s", host)
|
|
82
|
+
else:
|
|
83
|
+
token = profiles[0].token
|
|
84
|
+
request = media.create_type("GetStreamUri")
|
|
85
|
+
request.ProfileToken = token
|
|
86
|
+
request.StreamSetup = {
|
|
87
|
+
"Stream": "RTP-Unicast",
|
|
88
|
+
"Transport": {"Protocol": "RTSP"},
|
|
89
|
+
}
|
|
90
|
+
stream_uri = media.GetStreamUri(request)
|
|
91
|
+
if stream_uri and hasattr(stream_uri, "Uri"):
|
|
92
|
+
cam.rtsp_url = stream_uri.Uri
|
|
93
|
+
elif isinstance(stream_uri, str):
|
|
94
|
+
cam.rtsp_url = stream_uri
|
|
95
|
+
except Exception as e:
|
|
96
|
+
logger.warning("GetStreamUri failed for %s: %s", host, e)
|
|
97
|
+
if not cam.error:
|
|
98
|
+
cam.error = f"GetStreamUri failed: {e}"
|
|
99
|
+
|
|
100
|
+
logger.info(
|
|
101
|
+
"OK %s: %s %s rtsp=%s",
|
|
102
|
+
host,
|
|
103
|
+
cam.manufacturer,
|
|
104
|
+
cam.model,
|
|
105
|
+
cam.rtsp_url,
|
|
106
|
+
)
|
|
107
|
+
except Exception as e:
|
|
108
|
+
logger.error("Device query failed for %s: %s", host, e)
|
|
109
|
+
cam.error = f"Device query failed: {e}"
|
|
110
|
+
|
|
111
|
+
return cam
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""WS-Discovery multicast scanner for ONVIF devices."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
|
|
8
|
+
from onvif_scanner.models import DiscoveredDevice
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
# WS-Discovery WITHOUT scope filter — `wsdiscovery` 2.1.2 has a QName bug
|
|
13
|
+
# (`getQuotedValue`) on Python 3.13 when scopes are passed. We instead filter
|
|
14
|
+
# ONVIF devices client-side by checking the xaddr contains "/onvif/".
|
|
15
|
+
ONVIF_PATH_MARKER = "/onvif/"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def discover_cameras(timeout: int = 5, verbose: bool = False) -> list[DiscoveredDevice]:
|
|
19
|
+
"""Run WS-Discovery multicast on the local subnet and return ONVIF devices.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
timeout: how long to listen for ProbeMatches (seconds).
|
|
23
|
+
verbose: enable detailed logging from WSDiscovery.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
List of DiscoveredDevice (deduped by device_url), filtered to ONVIF only.
|
|
27
|
+
"""
|
|
28
|
+
if verbose:
|
|
29
|
+
logging.getLogger("WSDiscovery").setLevel(logging.DEBUG)
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
from wsdiscovery.discovery import ThreadedWSDiscovery
|
|
33
|
+
except ImportError as e:
|
|
34
|
+
raise RuntimeError(
|
|
35
|
+
"wsdiscovery is not installed. Run: pip install wsdiscovery"
|
|
36
|
+
) from e
|
|
37
|
+
|
|
38
|
+
logger.info("Starting WS-Discovery (timeout=%ss, no scope filter)", timeout)
|
|
39
|
+
wsd = ThreadedWSDiscovery()
|
|
40
|
+
wsd.start()
|
|
41
|
+
try:
|
|
42
|
+
services = wsd.searchServices(timeout=timeout)
|
|
43
|
+
finally:
|
|
44
|
+
wsd.stop()
|
|
45
|
+
|
|
46
|
+
logger.info("WS-Discovery returned %d raw service(s)", len(services))
|
|
47
|
+
|
|
48
|
+
devices: dict[str, DiscoveredDevice] = {}
|
|
49
|
+
for svc in services:
|
|
50
|
+
xaddrs = list(svc.getXAddrs())
|
|
51
|
+
if not xaddrs:
|
|
52
|
+
logger.debug("Skipping service without XAddrs: %s", svc.getEPR())
|
|
53
|
+
continue
|
|
54
|
+
|
|
55
|
+
# Filter: only keep ONVIF device service endpoints
|
|
56
|
+
onvif_xaddrs = [x for x in xaddrs if ONVIF_PATH_MARKER in x.lower()]
|
|
57
|
+
if not onvif_xaddrs:
|
|
58
|
+
logger.debug("Skipping non-ONVIF service: %s", xaddrs)
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
# Use the first onvif xaddr — typically http://<ip>/onvif/device_service
|
|
62
|
+
device_url = onvif_xaddrs[0]
|
|
63
|
+
try:
|
|
64
|
+
host = urlparse(device_url).hostname or ""
|
|
65
|
+
except Exception:
|
|
66
|
+
host = ""
|
|
67
|
+
|
|
68
|
+
# Scopes returned by wsdiscovery may be QName objects — convert to str
|
|
69
|
+
raw_scopes: list[str] = []
|
|
70
|
+
for s in svc.getScopes():
|
|
71
|
+
try:
|
|
72
|
+
raw_scopes.append(str(s))
|
|
73
|
+
except Exception:
|
|
74
|
+
raw_scopes.append(repr(s))
|
|
75
|
+
|
|
76
|
+
# Deduplicate by device_url (some cameras advertise multiple XAddrs)
|
|
77
|
+
if device_url in devices:
|
|
78
|
+
existing = devices[device_url]
|
|
79
|
+
existing.scopes = list({*existing.scopes, *raw_scopes})
|
|
80
|
+
existing.xaddrs = list({*existing.xaddrs, *onvif_xaddrs})
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
devices[device_url] = DiscoveredDevice(
|
|
84
|
+
address=host,
|
|
85
|
+
device_url=device_url,
|
|
86
|
+
scopes=raw_scopes,
|
|
87
|
+
xaddrs=onvif_xaddrs,
|
|
88
|
+
)
|
|
89
|
+
logger.debug("Discovered ONVIF device: %s (%s)", host, device_url)
|
|
90
|
+
|
|
91
|
+
return list(devices.values())
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Data models for discovered ONVIF cameras."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field, asdict
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class DiscoveredDevice:
|
|
11
|
+
"""Raw result from WS-Discovery (before device query)."""
|
|
12
|
+
|
|
13
|
+
address: str
|
|
14
|
+
device_url: str
|
|
15
|
+
scopes: list[str] = field(default_factory=list)
|
|
16
|
+
xaddrs: list[str] = field(default_factory=list)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Camera:
|
|
21
|
+
"""Fully-queried ONVIF camera with device info + RTSP URL."""
|
|
22
|
+
|
|
23
|
+
address: str
|
|
24
|
+
device_url: str
|
|
25
|
+
manufacturer: Optional[str] = None
|
|
26
|
+
model: Optional[str] = None
|
|
27
|
+
firmware_version: Optional[str] = None
|
|
28
|
+
serial_number: Optional[str] = None
|
|
29
|
+
hardware_id: Optional[str] = None
|
|
30
|
+
rtsp_url: Optional[str] = None
|
|
31
|
+
mac: Optional[str] = None
|
|
32
|
+
scopes: list[str] = field(default_factory=list)
|
|
33
|
+
authenticated: bool = False
|
|
34
|
+
error: Optional[str] = None
|
|
35
|
+
|
|
36
|
+
def to_dict(self) -> dict:
|
|
37
|
+
return asdict(self)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: onvif-scanner
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI tool to scan ONVIF cameras on local network via WS-Discovery
|
|
5
|
+
Author-email: Riki Handoyo <jawakeror@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/jawak/onvif-scanner
|
|
8
|
+
Project-URL: Repository, https://github.com/jawak/onvif-scanner
|
|
9
|
+
Project-URL: Issues, https://github.com/jawak/onvif-scanner/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/jawak/onvif-scanner/releases
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: System Administrators
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Multimedia :: Video
|
|
21
|
+
Classifier: Topic :: System :: Networking :: Monitoring
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: onvif-zeep>=0.2.12
|
|
26
|
+
Requires-Dist: wsdiscovery>=0.1.0
|
|
27
|
+
Requires-Dist: click>=8.1
|
|
28
|
+
Requires-Dist: rich>=13.0
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
31
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# ONVIF Scanner
|
|
35
|
+
|
|
36
|
+
[](https://pypi.org/project/onvif-scanner/)
|
|
37
|
+
[](https://pypi.org/project/onvif-scanner/)
|
|
38
|
+
[](LICENSE)
|
|
39
|
+
[](https://github.com/jawak/onvif-scanner/actions/workflows/publish.yml)
|
|
40
|
+
|
|
41
|
+
A simple CLI tool to scan for ONVIF cameras on the local network. It uses
|
|
42
|
+
WS-Discovery (UDP multicast) to discover devices, then queries the ONVIF
|
|
43
|
+
Device Service to retrieve: manufacturer, model, serial number, firmware, and
|
|
44
|
+
the **RTSP URL**.
|
|
45
|
+
|
|
46
|
+
## Features
|
|
47
|
+
|
|
48
|
+
- **WS-Discovery multicast** on the local subnet (automatic, no manual IP input)
|
|
49
|
+
- **Device query** — GetDeviceInformation + GetStreamUri (RTSP URL)
|
|
50
|
+
- Output as a **table** (pretty, default) or **JSON** (stdout / file)
|
|
51
|
+
- `--rtsp-only` filter — only show cameras that successfully returned an RTSP URL
|
|
52
|
+
- Authenticated scan (`--username` / `--password`) for cameras that require auth
|
|
53
|
+
- Per-device error isolation — a single camera failing does not break the entire scan
|
|
54
|
+
|
|
55
|
+
## Install
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
cd onvif-scanner
|
|
59
|
+
|
|
60
|
+
# Development mode (recommended)
|
|
61
|
+
pip install -e .
|
|
62
|
+
|
|
63
|
+
# Or run directly from source without installing
|
|
64
|
+
python -m onvif-scanner --help
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Requires Python 3.10+.
|
|
68
|
+
|
|
69
|
+
## Usage
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
# Quick scan, pretty table output (default)
|
|
73
|
+
onvif-scan
|
|
74
|
+
|
|
75
|
+
# JSON to stdout
|
|
76
|
+
onvif-scan --output json
|
|
77
|
+
|
|
78
|
+
# Save to file
|
|
79
|
+
onvif-scan --output-file cameras.json
|
|
80
|
+
|
|
81
|
+
# Longer timeout (large / slow networks)
|
|
82
|
+
onvif-scan --timeout 10
|
|
83
|
+
|
|
84
|
+
# Authenticated scan (to get RTSP URLs from cameras that require auth)
|
|
85
|
+
onvif-scan --username admin --password secret
|
|
86
|
+
|
|
87
|
+
# Only show cameras that have an RTSP URL
|
|
88
|
+
onvif-scan --rtsp-only
|
|
89
|
+
|
|
90
|
+
# Debug logging (WS-Discovery + zeep)
|
|
91
|
+
onvif-scan --verbose
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## JSON Output
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
{
|
|
98
|
+
"scan_time": "2026-07-19T10:00:00+07:00",
|
|
99
|
+
"scan_duration_seconds": 3.2,
|
|
100
|
+
"timeout_seconds": 5,
|
|
101
|
+
"camera_count": 2,
|
|
102
|
+
"cameras": [
|
|
103
|
+
{
|
|
104
|
+
"address": "192.168.1.100",
|
|
105
|
+
"device_url": "http://192.168.1.100/onvif/device_service",
|
|
106
|
+
"manufacturer": "Hikvision",
|
|
107
|
+
"model": "DS-2CD2143G2",
|
|
108
|
+
"firmware_version": "V5.6.5",
|
|
109
|
+
"serial_number": "DS-2CD2143G2-...-VVV111100",
|
|
110
|
+
"hardware_id": "880901",
|
|
111
|
+
"rtsp_url": "rtsp://192.168.1.100:554/Streaming/Channels/101",
|
|
112
|
+
"mac": null,
|
|
113
|
+
"scopes": ["onvif://www.onvif.org/Profile/Streaming"],
|
|
114
|
+
"authenticated": false,
|
|
115
|
+
"error": null
|
|
116
|
+
}
|
|
117
|
+
]
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Troubleshooting
|
|
122
|
+
|
|
123
|
+
| Symptom | Cause | Solution |
|
|
124
|
+
|---------|-------|----------|
|
|
125
|
+
| `No ONVIF devices discovered` | Multicast UDP blocked by firewall / different VLAN | Make sure UDP port 3702 (multicast 239.255.255.250:3702) is allowed. The scan must run on the same subnet as the cameras. |
|
|
126
|
+
| Camera discovered but `rtsp_url: null` | Camera requires auth for the Media service | Use `--username admin --password <pass>` |
|
|
127
|
+
| Camera not discovered despite being online | ONVIF disabled in the camera's web UI | Enable ONVIF (Profile S) in the camera settings |
|
|
128
|
+
| `ImportError: wsdiscovery` / `onvif-zeep` | Dependencies not installed | Re-run `pip install -e .` |
|
|
129
|
+
| Scan is slow (>5s) | Large network / many devices | Increase `--timeout 10`, or add `--rtsp-only` to skip device queries |
|
|
130
|
+
| zeep error: WSDL download | onvif-zeep requires internet access on first run (to download WSDL) | Ensure internet connectivity, or bundle the WSDL locally and pass `wsdl_dir` in `device.py` |
|
|
131
|
+
|
|
132
|
+
## Compatible Devices
|
|
133
|
+
|
|
134
|
+
Tested with: Hikvision, Dahua, generic ONVIF Profile S (Arducam, Reolink, Amcrest).
|
|
135
|
+
|
|
136
|
+
Standards: ONVIF Core Spec + Profile S (Streaming).
|
|
137
|
+
|
|
138
|
+
## Architecture
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
onvif_scanner/
|
|
142
|
+
├── cli.py # Click CLI + rich output (table/json/file)
|
|
143
|
+
├── discovery.py # WS-Discovery multicast via wsdiscovery lib
|
|
144
|
+
├── device.py # ONVIF Device Service query via onvif-zeep
|
|
145
|
+
└── models.py # dataclass DiscoveredDevice + Camera
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Flow: `cli.py` → `discovery.discover_cameras()` (UDP multicast) → `device.query_device()` (HTTP SOAP per camera) → output (table/json/file).
|
|
149
|
+
|
|
150
|
+
## Limitations (future work)
|
|
151
|
+
|
|
152
|
+
- Multicast only (no unicast IP range probe for cross-VLAN discovery)
|
|
153
|
+
- Single username/password (no multi-credential support)
|
|
154
|
+
- No auto-registration to the parking DB (`cameras` table) — a `register` subcommand could be added
|
|
155
|
+
- No streaming preview (RTSP URL only, no frame preview)
|
|
156
|
+
|
|
157
|
+
## License
|
|
158
|
+
|
|
159
|
+
MIT
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
onvif_scanner/__init__.py
|
|
5
|
+
onvif_scanner/__main__.py
|
|
6
|
+
onvif_scanner/cli.py
|
|
7
|
+
onvif_scanner/device.py
|
|
8
|
+
onvif_scanner/discovery.py
|
|
9
|
+
onvif_scanner/models.py
|
|
10
|
+
onvif_scanner.egg-info/PKG-INFO
|
|
11
|
+
onvif_scanner.egg-info/SOURCES.txt
|
|
12
|
+
onvif_scanner.egg-info/dependency_links.txt
|
|
13
|
+
onvif_scanner.egg-info/entry_points.txt
|
|
14
|
+
onvif_scanner.egg-info/requires.txt
|
|
15
|
+
onvif_scanner.egg-info/top_level.txt
|
|
16
|
+
tests/test_discovery.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
onvif_scanner
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "onvif-scanner"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "CLI tool to scan ONVIF cameras on local network via WS-Discovery"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Riki Handoyo", email = "jawakeror@gmail.com" }]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"onvif-zeep>=0.2.12",
|
|
15
|
+
"wsdiscovery>=0.1.0",
|
|
16
|
+
"click>=8.1",
|
|
17
|
+
"rich>=13.0",
|
|
18
|
+
]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 4 - Beta",
|
|
21
|
+
"Environment :: Console",
|
|
22
|
+
"Intended Audience :: System Administrators",
|
|
23
|
+
"License :: OSI Approved :: MIT License",
|
|
24
|
+
"Operating System :: OS Independent",
|
|
25
|
+
"Programming Language :: Python :: 3",
|
|
26
|
+
"Programming Language :: Python :: 3.10",
|
|
27
|
+
"Programming Language :: Python :: 3.11",
|
|
28
|
+
"Programming Language :: Python :: 3.12",
|
|
29
|
+
"Topic :: Multimedia :: Video",
|
|
30
|
+
"Topic :: System :: Networking :: Monitoring",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/jawak/onvif-scanner"
|
|
35
|
+
Repository = "https://github.com/jawak/onvif-scanner"
|
|
36
|
+
Issues = "https://github.com/jawak/onvif-scanner/issues"
|
|
37
|
+
Changelog = "https://github.com/jawak/onvif-scanner/releases"
|
|
38
|
+
|
|
39
|
+
[project.optional-dependencies]
|
|
40
|
+
dev = [
|
|
41
|
+
"pytest>=7.0",
|
|
42
|
+
"pytest-cov",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
[project.scripts]
|
|
46
|
+
onvif-scan = "onvif_scanner.cli:cli"
|
|
47
|
+
|
|
48
|
+
[tool.setuptools.packages.find]
|
|
49
|
+
include = ["onvif_scanner*"]
|
|
50
|
+
|
|
51
|
+
[tool.pytest.ini_options]
|
|
52
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Basic unit tests for discovery (no real network needed)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from onvif_scanner.models import Camera, DiscoveredDevice
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_discovered_device_defaults():
|
|
9
|
+
d = DiscoveredDevice(address="192.168.1.10", device_url="http://192.168.1.10/onvif/device_service")
|
|
10
|
+
assert d.address == "192.168.1.10"
|
|
11
|
+
assert d.scopes == []
|
|
12
|
+
assert d.xaddrs == []
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_camera_to_dict_roundtrip():
|
|
16
|
+
cam = Camera(
|
|
17
|
+
address="192.168.1.10",
|
|
18
|
+
device_url="http://192.168.1.10/onvif/device_service",
|
|
19
|
+
manufacturer="Hikvision",
|
|
20
|
+
model="DS-2CD2143G2",
|
|
21
|
+
rtsp_url="rtsp://192.168.1.10:554/Streaming/Channels/101",
|
|
22
|
+
authenticated=True,
|
|
23
|
+
)
|
|
24
|
+
d = cam.to_dict()
|
|
25
|
+
assert d["address"] == "192.168.1.10"
|
|
26
|
+
assert d["manufacturer"] == "Hikvision"
|
|
27
|
+
assert d["rtsp_url"].startswith("rtsp://")
|
|
28
|
+
assert d["authenticated"] is True
|
|
29
|
+
assert d["scopes"] == []
|
|
30
|
+
assert d["error"] is None
|