Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/api/pylabrobot.rst
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Subpackages
pylabrobot.only_fans
pylabrobot.resources
pylabrobot.scales
pylabrobot.sila
pylabrobot.shaking
pylabrobot.temperature_controlling
pylabrobot.thermocycling
Expand Down
17 changes: 17 additions & 0 deletions docs/api/pylabrobot.sila.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
.. currentmodule:: pylabrobot.sila

pylabrobot.sila package
=======================

This package provides utilities for working with `SiLA 2 <https://sila-standard.com/>`_ instruments.

Discovery
---------

.. autosummary::
:toctree: _autosummary
:nosignatures:
:recursive:

discovery.SiLADevice
discovery.discover
120 changes: 120 additions & 0 deletions docs/user_guide/machine-agnostic-features/sila-discovery.ipynb
Original file line number Diff line number Diff line change
@@ -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
}
3 changes: 3 additions & 0 deletions pylabrobot/sila/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .discovery import SiLADevice, discover

__all__ = ["SiLADevice", "discover"]
99 changes: 99 additions & 0 deletions pylabrobot/sila/discovery.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""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)
"""

from __future__ import annotations

import asyncio
import dataclasses
import socket
from typing import TYPE_CHECKING, List

try:
from zeroconf import ServiceBrowser, Zeroconf

HAS_ZEROCONF = True
except ImportError:
HAS_ZEROCONF = False

if TYPE_CHECKING:
from zeroconf import 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.

"""

if not HAS_ZEROCONF:
raise ImportError("zeroconf is required for SiLA discovery: pip install zeroconf")

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}")
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading