From b99d0fbf4d301ac9f2295b9ed1971968cd8f3c7d Mon Sep 17 00:00:00 2001 From: Gijs Molenaar Date: Thu, 19 Mar 2026 15:34:32 +0200 Subject: [PATCH 1/2] Add PROFINET DCP network discovery Wraps pnio-dcp library for discovering Siemens PLCs on the local network. Includes Device dataclass, discover() and identify() functions, and CLI entry point. Closes #622 Co-Authored-By: Claude Opus 4.6 --- pyproject.toml | 2 + snap7/discovery.py | 145 ++++++++++++++++++++++++++++++++++ tests/test_discovery.py | 167 ++++++++++++++++++++++++++++++++++++++++ uv.lock | 91 +++++++++++++++++++++- 4 files changed, 404 insertions(+), 1 deletion(-) create mode 100644 snap7/discovery.py create mode 100644 tests/test_discovery.py diff --git a/pyproject.toml b/pyproject.toml index 3e28ea7b..4950a7e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ Documentation = "https://python-snap7.readthedocs.io/en/latest/" test = ["pytest", "pytest-asyncio", "pytest-cov", "pytest-html", "mypy", "types-setuptools", "ruff", "tox", "tox-uv", "types-click", "uv"] cli = ["rich", "click" ] doc = ["sphinx", "sphinx_rtd_theme"] +discovery = ["pnio-dcp"] [tool.setuptools.package-data] snap7 = ["py.typed"] @@ -45,6 +46,7 @@ include = ["snap7*"] [project.scripts] snap7-server = "snap7.server:mainloop" s7 = "snap7.cli:main" +snap7-scan = "snap7.discovery:main" [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/snap7/discovery.py b/snap7/discovery.py new file mode 100644 index 00000000..e6786d1b --- /dev/null +++ b/snap7/discovery.py @@ -0,0 +1,145 @@ +""" +PROFINET DCP network discovery for finding Siemens PLCs. + +Uses the pnio-dcp library for the underlying DCP protocol. +Install with: pip install python-snap7[discovery] +""" + +from __future__ import annotations + +import dataclasses +import logging + +logger = logging.getLogger(__name__) + + +@dataclasses.dataclass(frozen=True) +class Device: + """A discovered PROFINET device on the network.""" + + name: str + ip: str + mac: str + netmask: str = "" + gateway: str = "" + family: str = "" + + def __str__(self) -> str: + return f"{self.name} ({self.ip}) [{self.mac}]" + + +def discover(ip: str, timeout: float = 5.0) -> list[Device]: + """Discover PROFINET devices on the network using DCP Identify All. + + Args: + ip: IP address of the local network interface to use for discovery. + timeout: How long to listen for responses in seconds (default 5.0). + + Returns: + List of discovered devices. + + Raises: + ImportError: If pnio-dcp is not installed. + NotImplementedError: If the current platform is not supported by pnio-dcp. + """ + try: + from pnio_dcp import DCP + except ImportError: + raise ImportError("pnio-dcp is required for network discovery. Install it with: pip install python-snap7[discovery]") + + dcp = DCP(ip) + dcp.identify_all_timeout = int(timeout) if timeout >= 1 else 1 + + raw_devices = dcp.identify_all(timeout=int(timeout) if timeout >= 1 else 1) + + devices = [] + for raw in raw_devices: + device = Device( + name=raw.name_of_station, + ip=raw.IP, + mac=raw.MAC, + netmask=getattr(raw, "netmask", ""), + gateway=getattr(raw, "gateway", ""), + family=getattr(raw, "family", ""), + ) + devices.append(device) + logger.debug(f"Discovered: {device}") + + logger.info(f"Discovery complete: found {len(devices)} device(s)") + return devices + + +def identify(ip: str, mac: str) -> Device: + """Identify a specific device by MAC address. + + Args: + ip: IP address of the local network interface to use. + mac: MAC address of the target device (colon-separated, e.g. "00:1b:1b:12:34:56"). + + Returns: + The identified device. + + Raises: + ImportError: If pnio-dcp is not installed. + TimeoutError: If the device does not respond. + """ + try: + from pnio_dcp import DCP, DcpTimeoutError + except ImportError: + raise ImportError("pnio-dcp is required for network discovery. Install it with: pip install python-snap7[discovery]") + + dcp = DCP(ip) + try: + raw = dcp.identify(mac) + except DcpTimeoutError: + raise TimeoutError(f"No response from device {mac}") + + return Device( + name=raw.name_of_station, + ip=raw.IP, + mac=raw.MAC, + netmask=getattr(raw, "netmask", ""), + gateway=getattr(raw, "gateway", ""), + family=getattr(raw, "family", ""), + ) + + +def main() -> None: + """CLI entry point for snap7-scan.""" + try: + import click + except ImportError: + print("CLI dependencies not installed. Try: pip install python-snap7[cli]") + raise + + @click.command() + @click.argument("ip") + @click.option("--timeout", type=float, default=5.0, help="Discovery timeout in seconds.") + def scan(ip: str, timeout: float) -> None: + """Discover PROFINET devices on the network. + + IP is the address of the local network interface to use for discovery. + """ + logging.basicConfig(format="[%(levelname)s]: %(message)s", level=logging.INFO) + try: + devices = discover(ip, timeout) + except ImportError as e: + click.echo(str(e), err=True) + raise SystemExit(1) + except NotImplementedError as e: + click.echo(f"Platform not supported: {e}", err=True) + raise SystemExit(1) + + if not devices: + click.echo("No devices found.") + return + + click.echo(f"Found {len(devices)} device(s):\n") + for device in devices: + click.echo(f" {device.name:<30s} {device.ip:<16s} {device.mac}") + + scan() + + +if __name__ == "__main__": + main() diff --git a/tests/test_discovery.py b/tests/test_discovery.py new file mode 100644 index 00000000..827d4d9d --- /dev/null +++ b/tests/test_discovery.py @@ -0,0 +1,167 @@ +"""Tests for PROFINET DCP network discovery.""" + +import dataclasses +from unittest.mock import MagicMock, patch + +import pytest + +from snap7.discovery import Device, discover, identify + + +@pytest.mark.util +class TestDevice: + def test_device_creation(self) -> None: + device = Device(name="plc-1", ip="192.168.1.10", mac="00:1b:1b:12:34:56") + assert device.name == "plc-1" + assert device.ip == "192.168.1.10" + assert device.mac == "00:1b:1b:12:34:56" + assert device.netmask == "" + assert device.gateway == "" + + def test_device_with_all_fields(self) -> None: + device = Device( + name="plc-2", + ip="10.0.0.1", + mac="AA:BB:CC:DD:EE:FF", + netmask="255.255.255.0", + gateway="10.0.0.254", + family="S7-1500", + ) + assert device.netmask == "255.255.255.0" + assert device.gateway == "10.0.0.254" + assert device.family == "S7-1500" + + def test_device_is_frozen(self) -> None: + device = Device(name="plc-1", ip="192.168.1.10", mac="00:00:00:00:00:00") + with pytest.raises(dataclasses.FrozenInstanceError): + device.name = "changed" # type: ignore[misc] + + def test_device_str(self) -> None: + device = Device(name="plc-1", ip="192.168.1.10", mac="00:1b:1b:12:34:56") + result = str(device) + assert "plc-1" in result + assert "192.168.1.10" in result + assert "00:1b:1b:12:34:56" in result + + +@pytest.mark.util +class TestDiscover: + def test_import_error_when_pnio_dcp_not_installed(self) -> None: + with patch.dict("sys.modules", {"pnio_dcp": None}): + with pytest.raises(ImportError, match="pnio-dcp is required"): + discover("192.168.1.1") + + def test_discover_returns_devices(self) -> None: + mock_raw_device = MagicMock() + mock_raw_device.name_of_station = "plc-1" + mock_raw_device.IP = "192.168.1.10" + mock_raw_device.MAC = "00:1b:1b:12:34:56" + mock_raw_device.netmask = "255.255.255.0" + mock_raw_device.gateway = "192.168.1.1" + mock_raw_device.family = "S7-1200" + + mock_dcp_class = MagicMock() + mock_dcp_instance = MagicMock() + mock_dcp_class.return_value = mock_dcp_instance + mock_dcp_instance.identify_all.return_value = [mock_raw_device] + + mock_module = MagicMock() + mock_module.DCP = mock_dcp_class + + with patch.dict("sys.modules", {"pnio_dcp": mock_module}): + devices = discover("192.168.1.1", timeout=3.0) + + assert len(devices) == 1 + assert devices[0].name == "plc-1" + assert devices[0].ip == "192.168.1.10" + assert devices[0].mac == "00:1b:1b:12:34:56" + assert devices[0].netmask == "255.255.255.0" + + def test_discover_empty_network(self) -> None: + mock_dcp_class = MagicMock() + mock_dcp_instance = MagicMock() + mock_dcp_class.return_value = mock_dcp_instance + mock_dcp_instance.identify_all.return_value = [] + + mock_module = MagicMock() + mock_module.DCP = mock_dcp_class + + with patch.dict("sys.modules", {"pnio_dcp": mock_module}): + devices = discover("192.168.1.1") + + assert devices == [] + + def test_discover_multiple_devices(self) -> None: + raw_devices = [] + for i in range(3): + mock = MagicMock() + mock.name_of_station = f"plc-{i}" + mock.IP = f"192.168.1.{10 + i}" + mock.MAC = f"00:1b:1b:12:34:{56 + i:02X}" + mock.netmask = "255.255.255.0" + mock.gateway = "192.168.1.1" + mock.family = "S7-1500" + raw_devices.append(mock) + + mock_dcp_class = MagicMock() + mock_dcp_instance = MagicMock() + mock_dcp_class.return_value = mock_dcp_instance + mock_dcp_instance.identify_all.return_value = raw_devices + + mock_module = MagicMock() + mock_module.DCP = mock_dcp_class + + with patch.dict("sys.modules", {"pnio_dcp": mock_module}): + devices = discover("192.168.1.1") + + assert len(devices) == 3 + assert devices[0].name == "plc-0" + assert devices[2].ip == "192.168.1.12" + + +@pytest.mark.util +class TestIdentify: + def test_import_error_when_pnio_dcp_not_installed(self) -> None: + with patch.dict("sys.modules", {"pnio_dcp": None}): + with pytest.raises(ImportError, match="pnio-dcp is required"): + identify("192.168.1.1", "00:1b:1b:12:34:56") + + def test_identify_returns_device(self) -> None: + mock_raw = MagicMock() + mock_raw.name_of_station = "plc-1" + mock_raw.IP = "192.168.1.10" + mock_raw.MAC = "00:1b:1b:12:34:56" + mock_raw.netmask = "255.255.255.0" + mock_raw.gateway = "192.168.1.1" + mock_raw.family = "S7-1200" + + mock_dcp_class = MagicMock() + mock_dcp_instance = MagicMock() + mock_dcp_class.return_value = mock_dcp_instance + mock_dcp_instance.identify.return_value = mock_raw + + mock_module = MagicMock() + mock_module.DCP = mock_dcp_class + mock_module.DcpTimeoutError = type("DcpTimeoutError", (Exception,), {}) + + with patch.dict("sys.modules", {"pnio_dcp": mock_module}): + device = identify("192.168.1.1", "00:1b:1b:12:34:56") + + assert device.name == "plc-1" + assert device.ip == "192.168.1.10" + + def test_identify_timeout(self) -> None: + mock_timeout_error = type("DcpTimeoutError", (Exception,), {}) + + mock_dcp_class = MagicMock() + mock_dcp_instance = MagicMock() + mock_dcp_class.return_value = mock_dcp_instance + mock_dcp_instance.identify.side_effect = mock_timeout_error() + + mock_module = MagicMock() + mock_module.DCP = mock_dcp_class + mock_module.DcpTimeoutError = mock_timeout_error + + with patch.dict("sys.modules", {"pnio_dcp": mock_module}): + with pytest.raises(TimeoutError, match="No response"): + identify("192.168.1.1", "00:1b:1b:12:34:56") diff --git a/uv.lock b/uv.lock index 38c470c2..611ac0c1 100644 --- a/uv.lock +++ b/uv.lock @@ -353,6 +353,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769, upload-time = "2022-07-01T12:21:02.467Z" }, ] +[[package]] +name = "importlib-metadata" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -656,6 +668,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pnio-dcp" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "psutil" }, + { name = "setuptools-scm" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/b7/26f8dcc07c4a46a76c4961611c7457b4522f1a584da95f690648eeaee7b1/pnio_dcp-1.2.0-py3-none-any.whl", hash = "sha256:8d7d63077838c416b3dc6e58ec3790ef422e13d8bdb38be59a2da8713e3e061a", size = 24541, upload-time = "2024-01-17T13:34:03.087Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -773,6 +826,9 @@ cli = [ { name = "click" }, { name = "rich" }, ] +discovery = [ + { name = "pnio-dcp" }, +] doc = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, @@ -797,6 +853,7 @@ test = [ requires-dist = [ { name = "click", marker = "extra == 'cli'" }, { name = "mypy", marker = "extra == 'test'" }, + { name = "pnio-dcp", marker = "extra == 'discovery'" }, { name = "pytest", marker = "extra == 'test'" }, { name = "pytest-asyncio", marker = "extra == 'test'" }, { name = "pytest-cov", marker = "extra == 'test'" }, @@ -811,7 +868,7 @@ requires-dist = [ { name = "types-setuptools", marker = "extra == 'test'" }, { name = "uv", marker = "extra == 'test'" }, ] -provides-extras = ["test", "cli", "doc"] +provides-extras = ["test", "cli", "doc", "discovery"] [[package]] name = "requests" @@ -875,6 +932,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3e/0a/9e1be9035b37448ce2e68c978f0591da94389ade5a5abafa4cf99985d1b2/ruff-0.15.4-py3-none-win_arm64.whl", hash = "sha256:60d5177e8cfc70e51b9c5fad936c634872a74209f934c1e79107d11787ad5453", size = 10966776, upload-time = "2026-02-26T20:03:56.908Z" }, ] +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + +[[package]] +name = "setuptools-scm" +version = "9.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "setuptools" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/b1/19587742aad604f1988a8a362e660e8c3ac03adccdb71c96d86526e5eb62/setuptools_scm-9.2.2.tar.gz", hash = "sha256:1c674ab4665686a0887d7e24c03ab25f24201c213e82ea689d2f3e169ef7ef57", size = 203385, upload-time = "2025-10-19T22:08:05.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ea/ac2bf868899d0d2e82ef72d350d97a846110c709bacf2d968431576ca915/setuptools_scm-9.2.2-py3-none-any.whl", hash = "sha256:30e8f84d2ab1ba7cb0e653429b179395d0c33775d54807fc5f1dd6671801aef7", size = 62975, upload-time = "2025-10-19T22:08:04.007Z" }, +] + [[package]] name = "snowballstemmer" version = "3.0.1" @@ -1239,3 +1319,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/ce/4f/d6a5ff3b020c801c8 wheels = [ { url = "https://files.pythonhosted.org/packages/29/d1/3f62e4f9577b28c352c11623a03fb916096d5c131303d4861b4914481b6b/virtualenv-21.0.0-py3-none-any.whl", hash = "sha256:d44e70637402c7f4b10f48491c02a6397a3a187152a70cba0b6bc7642d69fb05", size = 5817167, upload-time = "2026-02-25T20:21:05.476Z" }, ] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +] From 75df40bdc52395ec15befeb232d3e54e5307c2ec Mon Sep 17 00:00:00 2001 From: Gijs Molenaar Date: Thu, 19 Mar 2026 15:53:53 +0200 Subject: [PATCH 2/2] Export discover_command for s7 CLI integration Exports a reusable click command (discover_command) that the s7 CLI can auto-register as `s7 discover`. Removes the standalone snap7-scan entry point in favor of the unified s7 CLI. Co-Authored-By: Claude Opus 4.6 --- pyproject.toml | 1 - snap7/discovery.py | 19 ++++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4950a7e0..8e184dd6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,6 @@ include = ["snap7*"] [project.scripts] snap7-server = "snap7.server:mainloop" s7 = "snap7.cli:main" -snap7-scan = "snap7.discovery:main" [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/snap7/discovery.py b/snap7/discovery.py index e6786d1b..41005b9b 100644 --- a/snap7/discovery.py +++ b/snap7/discovery.py @@ -104,18 +104,13 @@ def identify(ip: str, mac: str) -> Device: ) -def main() -> None: - """CLI entry point for snap7-scan.""" - try: - import click - except ImportError: - print("CLI dependencies not installed. Try: pip install python-snap7[cli]") - raise +try: + import click @click.command() @click.argument("ip") @click.option("--timeout", type=float, default=5.0, help="Discovery timeout in seconds.") - def scan(ip: str, timeout: float) -> None: + def discover_command(ip: str, timeout: float) -> None: """Discover PROFINET devices on the network. IP is the address of the local network interface to use for discovery. @@ -138,7 +133,13 @@ def scan(ip: str, timeout: float) -> None: for device in devices: click.echo(f" {device.name:<30s} {device.ip:<16s} {device.mac}") - scan() +except ImportError: + pass + + +def main() -> None: + """Standalone CLI entry point for discovery.""" + discover_command() if __name__ == "__main__":