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.
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/onvif-scanner.svg)](https://pypi.org/project/onvif-scanner/)
37
+ [![Python versions](https://img.shields.io/pypi/pyversions/onvif-scanner.svg)](https://pypi.org/project/onvif-scanner/)
38
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
39
+ [![CI](https://github.com/jawak/onvif-scanner/actions/workflows/publish.yml/badge.svg)](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
+ [![PyPI version](https://img.shields.io/pypi/v/onvif-scanner.svg)](https://pypi.org/project/onvif-scanner/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/onvif-scanner.svg)](https://pypi.org/project/onvif-scanner/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
6
+ [![CI](https://github.com/jawak/onvif-scanner/actions/workflows/publish.yml/badge.svg)](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,3 @@
1
+ """ONVIF Scanner — discover ONVIF cameras on local network via WS-Discovery."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ """Entry point for `python -m onvif_scanner`."""
2
+
3
+ from onvif_scanner.cli import cli
4
+
5
+ if __name__ == "__main__":
6
+ cli()
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/onvif-scanner.svg)](https://pypi.org/project/onvif-scanner/)
37
+ [![Python versions](https://img.shields.io/pypi/pyversions/onvif-scanner.svg)](https://pypi.org/project/onvif-scanner/)
38
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
39
+ [![CI](https://github.com/jawak/onvif-scanner/actions/workflows/publish.yml/badge.svg)](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,2 @@
1
+ [console_scripts]
2
+ onvif-scan = onvif_scanner.cli:cli
@@ -0,0 +1,8 @@
1
+ onvif-zeep>=0.2.12
2
+ wsdiscovery>=0.1.0
3
+ click>=8.1
4
+ rich>=13.0
5
+
6
+ [dev]
7
+ pytest>=7.0
8
+ pytest-cov
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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