A Nano (or this computer, with simulated endpoints) served as a laboratory device over SiLA 2. The machine declares eight roles, one of each kind a SiLA client can use, and a Python gateway turns them into SiLA features: any SiLA client can read and command them without knowing OneMachine, the pins or the board.
SiLA 2 is a standard for lab instruments: a device is a server that publishes features (a feature has
properties you read or subscribe to, and commands you call, each with typed, constrained parameters) over gRPC, and every
feature carries an XML definition (FDL) a client can fetch. The gateway (gateway.py) reads the machine's description over the
serial link, writes one feature per role and serves them. It knows only the kinds in its table: a role of any other kind is
refused, naming the kind. The same machine is also served to AI assistants over MCP by examples/mcp.
Arduino Nano (ATmega328P).
| role | where | needs |
|---|---|---|
led |
the onboard LED, pin 13 | nothing |
lamp |
pin 9 (PWM) | an LED and a 330 ohm resistor to GND |
vin |
A0 | optional. Floating, it reads noise anywhere between 0 and 5 V; jumper it to GND, 3V3 or 5V to read about 0, 3.5 and 5 V |
step, mode, note, duty, ping |
RAM | nothing |
pio run -e nano -t upload
pip install -r requirements.txt # sila2 and pyserial, Python 3.10 or newer
python3 gateway.py --port /dev/ttyUSB0 # the Nano's port
The gateway generates the feature definitions at start (into a temporary directory, or --fdl-dir DIR; --generate writes
them and stops). It serves on 127.0.0.1:50052 without encryption (--address, --sila-port). Opening the serial port resets the
Nano, so all outputs start off at each start.
Without the board, the same machine on this computer (from this directory):
g++ -std=c++17 -I../../../HAPI/include -I../../include host/main.cpp -o host/machine
python3 gateway.py --sim host/machine
SIM_ADC=511 in the environment sets what the simulated vin reads (0 to 1023).
Any SiLA 2 client works. With the sila2 package that the gateway already needs:
import time
from sila2.client import SilaClient
c = SilaClient('127.0.0.1', 50052, insecure=True)
c.Led.SetOn(True) # the onboard LED
c.Note.SetText('hello')
c.Duty.SetValue(12.35)
c.Mode.SetValue('high')
time.sleep(0.4) # the gateway polls the machine every 0.1 s
print(c.Note.Text.get(), c.Duty.Value.get(), c.Mode.Value.get(), c.Vin.Voltage.get())
c.Duty.SetValue(100.01) # ValidationError: the server refuses it, nothing reaches the machine| role (kind) | OneMachine field | SiLA feature: property, command | SiLA type and constraint |
|---|---|---|---|
led (switch) |
on |
Led: On, SetOn(On) |
Boolean |
lamp (light) |
level, bounded by param lamp max |
Lamp: Level, SetLevel(Level) |
Integer, minimum 0, maximum max (200) |
step (discrete) |
value, one of the value lines |
Step: Value, SetValue(Value) |
Integer, Set {0, 10, 50, 100} |
ping (action) |
fire (command), fired (report) |
Ping: Fired, Fire() |
Fire has no parameter; Fired is an Integer count |
mode (select) |
index, each value line has a label |
Mode: Value, SetValue(Value) |
String, Set {off, low, high} |
vin (analog) |
raw, with a scale and a unit line |
Vin: Voltage (no command) |
Real, Unit V (kg m^2 s^-3 A^-1), Voltage = raw * 5 / 1023 |
note (text) |
text, a u8[16] array |
Note: Text, SetText(Text) |
String, MaximalLength 16, Pattern [ -~]* (printable ASCII) |
duty (scaled) |
raw, with max, a scale and a unit line |
Duty: Value, SetValue(Value) |
Real, Unit % (dimensionless, factor 0.01), minimum 0, maximum 100 = max * 1 / 10 |
The kinds light and switch are OneMachine's (role/kinds.h); the other six are in src/kinds.h. The rules any such consumer follows, and how to add a kind or another consumer, are in
docs/consumers.md. The lines a kind prints
in the machine description (value, scale, unit) are described in docs/role.md. One generated
definition (Mode, trimmed):
<Feature ... Category="select" ... Originator="io.github.internetofpins" ...>
<Identifier>Mode</Identifier>
<Command>
<Identifier>SetValue</Identifier>
<Observable>No</Observable>
<Parameter>
<Identifier>Value</Identifier>
<DataType><Constrained><DataType><Basic>String</Basic></DataType><Constraints><Set><Value>off</Value><Value>low</Value><Value>high</Value></Set></Constraints></Constrained></DataType>
</Parameter>
</Command>
<Property>
<Identifier>Value</Identifier>
<Observable>Yes</Observable>
<DataType><Constrained><DataType><Basic>String</Basic></DataType><Constraints><Set>...</Set></Constraints></Constrained></DataType>
</Property>
</Feature>The server's UUID is derived from the originator and the machine hash, so the same firmware is always the same server; a different description (a role, a label, a unit) is a different server.
- Unknown things are refused by name, at start. A role of a kind the gateway's table (
KINDSingateway.py) does not have, a tuned role, a description line the kind's row does not use, a unit symbol the unit table (UNITS:Vand%) does not have, a label used twice, or a scale whose bound is not a finite decimal: the gateway exits with a message that names the role and the thing. It never guesses. - Out of range is refused at the SiLA edge; the device is the backstop.
SetLevel(250),SetValue(7)onstep,SetValue('max')onmode, a 17-character or non-ASCII text,Duty.SetValue(100.01)or-0.1are validation errors from the server's constraint check: no frame goes to the board. A client that bypasses SiLA and sends such a value straight to the board gets what the kind does:lamp(alight) is clamped to its bound (200) and its report saysclamped;step,mode,noteanddutyignore the value, keep the previous one and report it. - A numeric command in presented units is rounded, and the report is the truth.
Duty.SetValue(12.35)becomes the raw value 124 (the client's decimal, times 10 / 1, to the nearest integer, halves away from zero) andDuty.Valuethen reads 12.4: the value the device holds. Enumerated fields (step,mode) are not rounded: anything not in the set is refused. - One frame per command, nothing resent. A command is sent once; a
Fire()sends its flag in exactly one frame. The gateway holds the serial port under one lock, so concurrent calls do not interleave.
The gateway on the Nano (trimmed):
$ python3 gateway.py --port /dev/ttyUSB0
... sila2.server.sila_server Starting SiLA server without encryption
... sila2.server.sila_server Server started
wrote /tmp/sila-fdl-qj4ng57l/Led.sila.xml
wrote /tmp/sila-fdl-qj4ng57l/Lamp.sila.xml
wrote /tmp/sila-fdl-qj4ng57l/Step.sila.xml
wrote /tmp/sila-fdl-qj4ng57l/Mode.sila.xml
wrote /tmp/sila-fdl-qj4ng57l/Vin.sila.xml
wrote /tmp/sila-fdl-qj4ng57l/Note.sila.xml
wrote /tmp/sila-fdl-qj4ng57l/Duty.sila.xml
wrote /tmp/sila-fdl-qj4ng57l/Ping.sila.xml
serving Led, Lamp, Step, Mode, Vin, Note, Duty, Ping on 127.0.0.1:50052, server 5e39722b-8dda-5d1f-9324-689f8ec8b1da, machine hash ba36fc2d
A client session on the same run (Vin floating, so its value is noise):
c.Led.On.get() False
c.Led.SetOn(True) ... True
c.Note.SetText('hello') ... 'hello'
c.Duty.SetValue(12.35) ... 12.4
c.Mode.SetValue('high') ... high
c.Vin.Voltage.get() 3.758553274682307
c.Duty.SetValue(100.01) ValidationError Parameter value rejected: Constraint violated: MaximalInclusive(100.0) (100.01)
Duty.SetValue(12.34) -> 12.3
Duty.SetValue(12.35) -> 12.4
Duty.SetValue(99.95) -> 100.0
Ping.Fire() twice: Fired 2
No timeout or safe fall (a value that was set stays set), no tuning (a tuned role is refused), no observable commands or command
completion (a command returns when the frame is sent, and its effect shows in the property), and no TLS or discovery beyond the
sila2 library's defaults (the server runs without encryption and without mDNS). vin is read with whatever noise the pin has:
every change of the reading is published.