From c23123b00622b34771c5201cd8fbc35f029526c3 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 12 Feb 2026 23:28:04 -0500 Subject: [PATCH 1/3] Add SiLA 2 device discovery via mDNS Co-Authored-By: Claude Opus 4.6 --- docs/api/pylabrobot.rst | 1 + docs/api/pylabrobot.sila.rst | 17 +++ .../sila-discovery.ipynb | 120 ++++++++++++++++++ pylabrobot/sila/__init__.py | 3 + pylabrobot/sila/discovery.py | 86 +++++++++++++ 5 files changed, 227 insertions(+) create mode 100644 docs/api/pylabrobot.sila.rst create mode 100644 docs/user_guide/machine-agnostic-features/sila-discovery.ipynb create mode 100644 pylabrobot/sila/__init__.py create mode 100644 pylabrobot/sila/discovery.py diff --git a/docs/api/pylabrobot.rst b/docs/api/pylabrobot.rst index abc2f483893..455100e5177 100644 --- a/docs/api/pylabrobot.rst +++ b/docs/api/pylabrobot.rst @@ -19,6 +19,7 @@ Subpackages pylabrobot.only_fans pylabrobot.resources pylabrobot.scales + pylabrobot.sila pylabrobot.shaking pylabrobot.temperature_controlling pylabrobot.thermocycling diff --git a/docs/api/pylabrobot.sila.rst b/docs/api/pylabrobot.sila.rst new file mode 100644 index 00000000000..10a7ff5b1fc --- /dev/null +++ b/docs/api/pylabrobot.sila.rst @@ -0,0 +1,17 @@ +.. currentmodule:: pylabrobot.sila + +pylabrobot.sila package +======================= + +This package provides utilities for working with `SiLA 2 `_ instruments. + +Discovery +--------- + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + discovery.SiLADevice + discovery.discover diff --git a/docs/user_guide/machine-agnostic-features/sila-discovery.ipynb b/docs/user_guide/machine-agnostic-features/sila-discovery.ipynb new file mode 100644 index 00000000000..5c7e6b6347d --- /dev/null +++ b/docs/user_guide/machine-agnostic-features/sila-discovery.ipynb @@ -0,0 +1,120 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# SiLA 2 Device Discovery\n", + "\n", + "[SiLA 2](https://sila-standard.com/) is a communication standard used by many lab instruments. Instruments that speak SiLA 2 advertise themselves on the local network via mDNS (the `_sila._tcp.local.` service type), so you can find them without knowing their IP address upfront.\n", + "\n", + "PyLabRobot provides a simple discovery function for this." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Requirements\n", + "\n", + "```bash\n", + "pip install zeroconf\n", + "```\n", + "\n", + "Your computer must be on the same network (or VLAN) as the instruments you want to discover." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Usage" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.sila import discover\n", + "\n", + "devices = await discover(timeout=5.0)\n", + "print(f\"Found {len(devices)} device(s)\")\n", + "for device in devices:\n", + " print(device)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "`discover()` listens for mDNS responses for `timeout` seconds and returns a list of `SiLADevice` objects. Each device has three attributes:\n", + "\n", + "| Attribute | Type | Description |\n", + "|-----------|-------|---------------------------------|\n", + "| `host` | `str` | IP address (e.g. `192.168.1.42`)|\n", + "| `port` | `int` | gRPC port (e.g. `8091`) |\n", + "| `name` | `str` | mDNS server name |" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Example: connecting to the first device found\n", + "\n", + "You can use the discovered host and port directly when creating a backend:\n", + "\n", + "```python\n", + "from pylabrobot.sila import discover\n", + "\n", + "devices = await discover()\n", + "assert len(devices) > 0, \"No SiLA devices found\"\n", + "device = devices[0]\n", + "\n", + "# Pass device.host and device.port to your backend\n", + "backend = SomeBackend(host=device.host, port=device.port)\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Command-line tool\n", + "\n", + "You can also discover devices from the terminal:\n", + "\n", + "```bash\n", + "python -m pylabrobot.sila.discovery\n", + "```\n", + "\n", + "Use `-t` to change the timeout (default 5 seconds):\n", + "\n", + "```bash\n", + "python -m pylabrobot.sila.discovery -t 10\n", + "```\n", + "\n", + "Output is tab-delimited (`host`, `port`, `name`), one device per line:\n", + "\n", + "```\n", + "192.168.1.42\t8091\tImageXpress-Pico._sila._tcp.local.\n", + "```" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.10.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/pylabrobot/sila/__init__.py b/pylabrobot/sila/__init__.py new file mode 100644 index 00000000000..bc6febb5b65 --- /dev/null +++ b/pylabrobot/sila/__init__.py @@ -0,0 +1,3 @@ +from .discovery import SiLADevice, discover + +__all__ = ["SiLADevice", "discover"] diff --git a/pylabrobot/sila/discovery.py b/pylabrobot/sila/discovery.py new file mode 100644 index 00000000000..48888f4e6f1 --- /dev/null +++ b/pylabrobot/sila/discovery.py @@ -0,0 +1,86 @@ +"""SiLA 2 device discovery via mDNS. + +SiLA 2 instruments advertise themselves as ``_sila._tcp.local.`` services. +This module provides a simple way to find them on the local network. + +Example: + >>> from pylabrobot.sila import discover + >>> devices = await discover() + >>> for d in devices: + ... print(d.host, d.port, d.name) +""" + +import asyncio +import dataclasses +import socket +from typing import List + +from zeroconf import ServiceBrowser, Zeroconf + + +@dataclasses.dataclass(frozen=True) +class SiLADevice: + """A SiLA 2 device found on the network.""" + + host: str + port: int + name: str + + def __str__(self) -> str: + return f"{self.name} @ {self.host}:{self.port}" + + +SILA_MDNS_TYPE = "_sila._tcp.local." + + +async def discover(timeout: float = 5.0) -> List[SiLADevice]: + """Discover SiLA 2 devices on the local network via mDNS. + + Args: + timeout: How long to listen for responses, in seconds. + + Returns: + List of discovered devices. + + """ + + devices: List[SiLADevice] = [] + + class _Listener: + def add_service(self, zc: Zeroconf, type_: str, name: str) -> None: + info = zc.get_service_info(type_, name) + if info and info.addresses: + host = socket.inet_ntoa(info.addresses[0]) + devices.append(SiLADevice(host=host, port=info.port, name=info.server)) + + def remove_service(self, zc: Zeroconf, type_: str, name: str) -> None: + pass + + def update_service(self, zc: Zeroconf, type_: str, name: str) -> None: + pass + + zc = Zeroconf() + try: + ServiceBrowser(zc, SILA_MDNS_TYPE, _Listener()) + await asyncio.sleep(timeout) + finally: + zc.close() + + return devices + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="Discover SiLA 2 devices on the local network") + parser.add_argument( + "-t", "--timeout", type=float, default=5.0, help="discovery timeout in seconds (default: 5)" + ) + args = parser.parse_args() + + devices = asyncio.run(discover(args.timeout)) + if not devices: + print("No SiLA 2 devices found.") + else: + for d in devices: + print(f"{d.host}\t{d.port}\t{d.name}") From b8558ec2af1c0f12d49f1d747d237470a9af527c Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 12 Feb 2026 23:31:39 -0500 Subject: [PATCH 2/3] Lazy-import zeroconf to fix CI type checking and docs build Co-Authored-By: Claude Opus 4.6 --- pylabrobot/sila/discovery.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/pylabrobot/sila/discovery.py b/pylabrobot/sila/discovery.py index 48888f4e6f1..da6f8c3e476 100644 --- a/pylabrobot/sila/discovery.py +++ b/pylabrobot/sila/discovery.py @@ -10,12 +10,22 @@ ... print(d.host, d.port, d.name) """ +from __future__ import annotations + import asyncio import dataclasses import socket -from typing import List +from typing import TYPE_CHECKING, List + +try: + from zeroconf import ServiceBrowser, Zeroconf -from zeroconf import ServiceBrowser, Zeroconf + HAS_ZEROCONF = True +except ImportError: + HAS_ZEROCONF = False + +if TYPE_CHECKING: + from zeroconf import Zeroconf @dataclasses.dataclass(frozen=True) @@ -44,6 +54,9 @@ async def discover(timeout: float = 5.0) -> List[SiLADevice]: """ + if not HAS_ZEROCONF: + raise ImportError("zeroconf is required for SiLA discovery: pip install zeroconf") + devices: List[SiLADevice] = [] class _Listener: From bbe68cf32ac0174042fd071907c4a84af075c600 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 12 Feb 2026 23:32:23 -0500 Subject: [PATCH 3/3] Add zeroconf as optional sila dependency in pyproject.toml Co-Authored-By: Claude Opus 4.6 --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 1a444938617..1d1c8d0682c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ opentrons = ["opentrons-http-api-client"] server = ["flask[async]==3.1.2"] inheco = ["hid==1.0.8"] agrow = ["pymodbus==3.6.8"] +sila = ["zeroconf>=0.131.0"] dev = [ "PyLabRobot[fw,http,plate_reading,websockets,visualizer,opentrons,server,inheco,agrow]", "pytest==8.4.2",