labrat-lab 0.1.0__py3-none-any.whl
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.
- labrat/__init__.py +2 -0
- labrat/cli.py +665 -0
- labrat/client.py +236 -0
- labrat/config.py +110 -0
- labrat/detect.py +43 -0
- labrat/device.py +312 -0
- labrat/discovery.py +135 -0
- labrat/drivers/__init__.py +59 -0
- labrat/drivers/camera.py +140 -0
- labrat/drivers/display.py +152 -0
- labrat/drivers/genicam.py +120 -0
- labrat/drivers/gpio.py +52 -0
- labrat/drivers/http.py +131 -0
- labrat/drivers/i2c.py +153 -0
- labrat/drivers/mapped.py +105 -0
- labrat/drivers/mqtt.py +135 -0
- labrat/drivers/nut.py +133 -0
- labrat/drivers/scpi.py +415 -0
- labrat/drivers/serialport.py +60 -0
- labrat/drivers/sim.py +87 -0
- labrat/experiment.py +295 -0
- labrat/http.py +189 -0
- labrat/hub.py +446 -0
- labrat/ident.py +180 -0
- labrat/mcp.py +169 -0
- labrat/message.py +160 -0
- labrat/nest.py +327 -0
- labrat/py.typed +0 -0
- labrat/rat.py +115 -0
- labrat/record.py +306 -0
- labrat/store.py +176 -0
- labrat/term.py +177 -0
- labrat/users.py +101 -0
- labrat_lab-0.1.0.dist-info/METADATA +238 -0
- labrat_lab-0.1.0.dist-info/RECORD +40 -0
- labrat_lab-0.1.0.dist-info/WHEEL +5 -0
- labrat_lab-0.1.0.dist-info/entry_points.txt +2 -0
- labrat_lab-0.1.0.dist-info/licenses/LICENSE +177 -0
- labrat_lab-0.1.0.dist-info/licenses/NOTICE +4 -0
- labrat_lab-0.1.0.dist-info/top_level.txt +1 -0
labrat/__init__.py
ADDED
labrat/cli.py
ADDED
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
"""The labrat command."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import secrets
|
|
9
|
+
import signal
|
|
10
|
+
import socket
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
import threading
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from . import __version__
|
|
17
|
+
from . import config as conf
|
|
18
|
+
from .client import Client
|
|
19
|
+
from .hub import token_hash
|
|
20
|
+
from .message import ROLES, LabratError
|
|
21
|
+
|
|
22
|
+
log = logging.getLogger("labrat")
|
|
23
|
+
|
|
24
|
+
UNIT = """[Unit]
|
|
25
|
+
Description=LABRAT {kind}
|
|
26
|
+
Documentation=https://github.com/reinhardjvv/labrat
|
|
27
|
+
Wants=network-online.target
|
|
28
|
+
After=network-online.target
|
|
29
|
+
|
|
30
|
+
[Service]
|
|
31
|
+
Type=simple
|
|
32
|
+
User=labrat
|
|
33
|
+
Group=labrat
|
|
34
|
+
SupplementaryGroups={groups}
|
|
35
|
+
ExecStart={exe} {kind} --config /etc/labrat/{kind}.toml
|
|
36
|
+
Restart=on-failure
|
|
37
|
+
RestartSec=3
|
|
38
|
+
StateDirectory=labrat
|
|
39
|
+
NoNewPrivileges=yes
|
|
40
|
+
ProtectSystem=strict
|
|
41
|
+
ProtectHome=yes
|
|
42
|
+
PrivateTmp=yes
|
|
43
|
+
|
|
44
|
+
[Install]
|
|
45
|
+
WantedBy=multi-user.target
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _token_entry(name: str, role: str) -> tuple[str, str]:
|
|
50
|
+
token = secrets.token_urlsafe(24)
|
|
51
|
+
return token, f'[[tokens]]\nname = "{name}"\nrole = "{role}"\nsha256 = "{token_hash(token)}"\n'
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _write_new(path: Path, text: str) -> None:
|
|
55
|
+
if path.exists():
|
|
56
|
+
raise LabratError("bad_request", f"{path} exists; not overwriting it")
|
|
57
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o640)
|
|
59
|
+
with os.fdopen(fd, "w") as f:
|
|
60
|
+
f.write(text)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _state_line() -> str:
|
|
64
|
+
"""A state_dir line when the service's state directory is known, so every user reads the same keys."""
|
|
65
|
+
d = os.environ.get("STATE_DIRECTORY")
|
|
66
|
+
return f'state_dir = "{d}"\n' if d else ""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _default_new_path(kind: str) -> Path:
|
|
70
|
+
return conf.default_paths(kind)[0 if os.geteuid() == 0 else 1]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# -- running a nest or a rat -------------------------------------------------------------------------------------
|
|
74
|
+
def serve(kind: str, cfg: dict, hub_factory) -> int:
|
|
75
|
+
from .discovery import Advertiser
|
|
76
|
+
from .http import Server
|
|
77
|
+
|
|
78
|
+
if kind == "nest" and not cfg.get("tokens") and not conf.is_loopback(cfg["host"]):
|
|
79
|
+
raise LabratError("bad_request", "a nest on the network needs at least one [[tokens]] entry (labrat token)")
|
|
80
|
+
hub = hub_factory(cfg)
|
|
81
|
+
server = Server(hub, cfg["host"], int(cfg["port"])).start()
|
|
82
|
+
if hub.recorder is not None:
|
|
83
|
+
from .hub import Principal
|
|
84
|
+
hub.recorder.autostart(Principal("recorder", "operator"))
|
|
85
|
+
ad = Advertiser(kind, hub.name, server.port, getattr(hub, "fingerprint", "")) if cfg.get("discovery") else None
|
|
86
|
+
log.info("%s %s serving %d device(s) on %s:%d", kind, hub.name, len(hub.devices), cfg["host"], server.port)
|
|
87
|
+
for d in hub.devices.values():
|
|
88
|
+
if not d.safe and any(a.access == "rw" for a in d.attrs.values()) and not getattr(d, "safe_commands", None):
|
|
89
|
+
log.warning("%s can be changed but has no safe state: stop and the watchdog leave it as it is", d.name)
|
|
90
|
+
for who in hub.tokens.values():
|
|
91
|
+
if who.god:
|
|
92
|
+
log.warning("token %s is in god mode: the lab's limits and approval rules do not apply to it", who.name)
|
|
93
|
+
if kind == "rat":
|
|
94
|
+
log.info("rat key fingerprint %s (labrat rat key shows the key)", hub.fingerprint)
|
|
95
|
+
if getattr(hub, "allow_god_mode", False):
|
|
96
|
+
log.warning("this rat lets the nest's god-mode tokens past its limits")
|
|
97
|
+
done = threading.Event()
|
|
98
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
99
|
+
signal.signal(sig, lambda *_: done.set())
|
|
100
|
+
done.wait()
|
|
101
|
+
log.info("shutting down: putting every device in its safe state")
|
|
102
|
+
if ad:
|
|
103
|
+
ad.close()
|
|
104
|
+
server.close()
|
|
105
|
+
hub.shutdown()
|
|
106
|
+
hub.close()
|
|
107
|
+
return 0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def cmd_nest(a) -> int:
|
|
111
|
+
from .nest import Nest
|
|
112
|
+
if a.action == "init":
|
|
113
|
+
path = Path(a.config).expanduser() if a.config else _default_new_path("nest")
|
|
114
|
+
token, entry = _token_entry(a.admin, "admin")
|
|
115
|
+
name = socket.gethostname().split(".")[0] + "-nest"
|
|
116
|
+
_write_new(path, f"# LABRAT nest configuration. Reference: docs/configuration.md\nname = \"{name}\"\nport = {conf.NEST_PORT}\n{_state_line()}\n"
|
|
117
|
+
f"{entry}\n# Devices plugged into the nest itself, if any:\n# [devices.psu]\n# driver = \"sim-supply\"\n")
|
|
118
|
+
print(f"wrote {path}\n\nadmin token for {a.admin} (shown once, keep it safe):\n {token}\n\n"
|
|
119
|
+
f"export LABRAT_URL=http://{socket.gethostname()}:{conf.NEST_PORT}\nexport LABRAT_TOKEN={token}")
|
|
120
|
+
return 0
|
|
121
|
+
return serve("nest", conf.load("nest", conf.find("nest", a.config)), Nest)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def cmd_rat(a) -> int:
|
|
125
|
+
from .detect import rat_config, resources
|
|
126
|
+
from .rat import Rat, fingerprint, key_path, load_or_create_key
|
|
127
|
+
if a.action == "init":
|
|
128
|
+
path = Path(a.config).expanduser() if a.config else _default_new_path("rat")
|
|
129
|
+
_write_new(path, rat_config(socket.gethostname().split(".")[0], _state_line()))
|
|
130
|
+
print(f"wrote {path}; edit it to add the devices on this rat. Found:")
|
|
131
|
+
for kind, items in resources().items():
|
|
132
|
+
print(f" {kind}: {', '.join(items) or 'none'}")
|
|
133
|
+
return 0
|
|
134
|
+
if a.action == "key":
|
|
135
|
+
cfg = conf.load("rat", conf.find("rat", a.config))
|
|
136
|
+
if not key_path(cfg["state_dir"]).is_file():
|
|
137
|
+
raise LabratError("not_found", f"no key in {cfg['state_dir']} yet; the rat makes one when it first starts")
|
|
138
|
+
key = load_or_create_key(cfg["state_dir"])
|
|
139
|
+
print(f"rat {cfg['name']}\nkey {key}\nfingerprint {fingerprint(key)}\nstored in {key_path(cfg['state_dir'])}\n\n"
|
|
140
|
+
f"to adopt it: labrat adopt {cfg['name']} (asks for the key, or reads LABRAT_RAT_KEY)")
|
|
141
|
+
return 0
|
|
142
|
+
return serve("rat", conf.load("rat", conf.find("rat", a.config)), Rat)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def cmd_demo(a) -> int:
|
|
146
|
+
"""A nest on this computer with simulated devices, to try everything without hardware."""
|
|
147
|
+
from .nest import Nest
|
|
148
|
+
token = secrets.token_urlsafe(16)
|
|
149
|
+
state = Path(tempfile.mkdtemp(prefix="labrat-demo-"))
|
|
150
|
+
path = state / "nest.toml"
|
|
151
|
+
path.write_text(f'name = "demo-nest"\nhost = "127.0.0.1"\nport = {a.port}\ndiscovery = false\nstate_dir = "{state}"\n'
|
|
152
|
+
f'[[tokens]]\nname = "demo"\nrole = "admin"\nsha256 = "{token_hash(token)}"\n'
|
|
153
|
+
'[devices.psu]\ndriver = "sim-supply"\ndescription = "simulated two-channel supply"\n'
|
|
154
|
+
'[devices.psu.limits.VoltageLevel]\nmax = 12\n'
|
|
155
|
+
'[devices.relays]\ndriver = "sim-gpio"\noutputs = { heater = 17, lamp = 27 }\ninputs = { door = 22 }\n'
|
|
156
|
+
'safe = { heater = false }\n')
|
|
157
|
+
print(f"demo nest on http://127.0.0.1:{a.port}, simulated devices psu and relays\n\n"
|
|
158
|
+
f"export LABRAT_URL=http://127.0.0.1:{a.port}\nexport LABRAT_TOKEN={token}\nlabrat devices\n"
|
|
159
|
+
"labrat set psu VoltageLevel 5 --channel 1\n\nCtrl-C stops it.\n", flush=True)
|
|
160
|
+
return serve("nest", conf.load("nest", path), Nest)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# -- talking to a nest or a rat ----------------------------------------------------------------------------------
|
|
164
|
+
def _client(a) -> Client:
|
|
165
|
+
return Client(a.url, a.token)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _show(value) -> None:
|
|
169
|
+
if isinstance(value, str):
|
|
170
|
+
print(value)
|
|
171
|
+
return
|
|
172
|
+
short = json.dumps(value, default=str)
|
|
173
|
+
print(short if len(short) <= 100 else json.dumps(value, indent=2, default=str))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _value(text: str):
|
|
177
|
+
try:
|
|
178
|
+
return json.loads(text)
|
|
179
|
+
except ValueError:
|
|
180
|
+
return text
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _st():
|
|
184
|
+
from .term import Style, colour_wanted
|
|
185
|
+
return Style(colour_wanted())
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _reply(a, msg: dict) -> dict:
|
|
189
|
+
"""The whole reply (value and unit), or the error raised as a LabratError."""
|
|
190
|
+
r = _client(a).send({k: v for k, v in msg.items() if v is not None})
|
|
191
|
+
if not r.get("ok"):
|
|
192
|
+
err = r.get("error") or {}
|
|
193
|
+
raise LabratError(err.get("code", "internal"), err.get("message", "no reason given"),
|
|
194
|
+
**{k: v for k, v in err.items() if k not in ("code", "message")})
|
|
195
|
+
return r
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _print_value(r: dict, json_out: bool = False) -> None:
|
|
199
|
+
from .term import quantity
|
|
200
|
+
v = r.get("value")
|
|
201
|
+
if json_out or isinstance(v, (dict, list)):
|
|
202
|
+
return _show(v)
|
|
203
|
+
print(quantity(v, r.get("unit", ""), _st()))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def cmd_devices(a):
|
|
207
|
+
from .term import table
|
|
208
|
+
rows = _client(a).devices(" ".join(a.select) or None, a.by or a.sort)
|
|
209
|
+
if a.json:
|
|
210
|
+
return _show(rows)
|
|
211
|
+
st = _st()
|
|
212
|
+
if not rows:
|
|
213
|
+
return print("no devices match" if a.select else "no devices")
|
|
214
|
+
|
|
215
|
+
def row(r):
|
|
216
|
+
if r.get("online") is False:
|
|
217
|
+
state = st.bad("offline")
|
|
218
|
+
elif r.get("duplicate_of"):
|
|
219
|
+
state = st.warn(f"same instrument as {r['duplicate_of']}")
|
|
220
|
+
elif r.get("locked_by"):
|
|
221
|
+
state = st.warn(f"locked by {r['locked_by']}")
|
|
222
|
+
else:
|
|
223
|
+
state = st.good("ready")
|
|
224
|
+
return [st.bold(r["device"]), r.get("kind", ""), r.get("manufacturer", ""), r.get("model", ""),
|
|
225
|
+
st.dim(r.get("serial", "")), state, st.dim(r.get("location") or r.get("description", ""))]
|
|
226
|
+
head = ["DEVICE", "KIND", "MANUFACTURER", "MODEL", "SERIAL", "STATE", "WHERE / WHAT"]
|
|
227
|
+
from .term import width
|
|
228
|
+
cells = [row(r) for r in rows]
|
|
229
|
+
keep = [i for i in range(len(head)) if i in (0, 1, 5) or any(width(c[i]) for c in cells)] # no empty columns
|
|
230
|
+
head = [head[i] for i in keep]
|
|
231
|
+
row_all = row
|
|
232
|
+
|
|
233
|
+
def row(r): # noqa: F811
|
|
234
|
+
c = row_all(r)
|
|
235
|
+
return [c[i] for i in keep]
|
|
236
|
+
if not a.by:
|
|
237
|
+
return print(table(head, [row(r) for r in rows], st, indent=0))
|
|
238
|
+
groups: dict[str, list] = {}
|
|
239
|
+
for r in rows:
|
|
240
|
+
groups.setdefault(str(r.get(a.by) or "unknown"), []).append(r)
|
|
241
|
+
for i, (g, rs) in enumerate(groups.items()):
|
|
242
|
+
print(("\n" if i else "") + st.accent(g) + st.dim(f" {len(rs)}"))
|
|
243
|
+
print(table(head, [row(r) for r in rs], st))
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _readings(client, device: str, d: dict, only: list[str] | None = None) -> dict:
|
|
247
|
+
"""Every readable attribute (or the named ones) on every channel, in one batch: {(name, channel): reply}."""
|
|
248
|
+
asks = []
|
|
249
|
+
for name, spec in (d.get("attributes") or {}).items():
|
|
250
|
+
if only and name not in only:
|
|
251
|
+
continue
|
|
252
|
+
for ch in spec.get("channels") or [None]:
|
|
253
|
+
asks.append((name, ch))
|
|
254
|
+
if not asks:
|
|
255
|
+
return {}
|
|
256
|
+
replies = client.send([{"op": "get", "device": device, "name": n, **({"channel": c} if c else {})} for n, c in asks])
|
|
257
|
+
return dict(zip(asks, replies, strict=True)) if isinstance(replies, list) else {}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def cmd_describe(a):
|
|
261
|
+
from .term import quantity, span, table
|
|
262
|
+
client, st = _client(a), _st()
|
|
263
|
+
d = client.describe(a.device)
|
|
264
|
+
if a.json:
|
|
265
|
+
return _show(d)
|
|
266
|
+
idn = d.get("identity") or {}
|
|
267
|
+
made = " ".join(x for x in (idn.get("manufacturer"), idn.get("model")) if x)
|
|
268
|
+
print(f"{st.bold(a.device)} {d.get('kind', '')} {made} {st.dim('#' + idn['serial']) if idn.get('serial') else ''}"
|
|
269
|
+
f" {st.dim('firmware ' + idn['firmware']) if idn.get('firmware') else ''}".rstrip().replace(" ", " "))
|
|
270
|
+
if d.get("description"):
|
|
271
|
+
print(f" {d['description']}")
|
|
272
|
+
now = _readings(client, a.device, d)
|
|
273
|
+
rows = []
|
|
274
|
+
for name, spec in (d.get("attributes") or {}).items():
|
|
275
|
+
for ch in spec.get("channels") or [None]:
|
|
276
|
+
r = now.get((name, ch), {})
|
|
277
|
+
val = quantity(r["value"], r.get("unit", ""), st) if r.get("ok") else st.dim(r.get("error", {}).get("code", ""))
|
|
278
|
+
rows.append([st.bold(name), ch or "", val, span(spec), "read/write" if spec.get("access") == "rw" else st.dim("read")])
|
|
279
|
+
if rows:
|
|
280
|
+
print("\n" + table(["ATTRIBUTE", "CH", "NOW", "ALLOWED", "ACCESS"], rows, st))
|
|
281
|
+
fns = d.get("functions") or {}
|
|
282
|
+
if fns:
|
|
283
|
+
print("\n" + table(["FUNCTION", "ARGUMENTS", ""], [[st.bold(n), ", ".join(f"{k}: {t}" for k, t in (f.get("args") or {}).items()),
|
|
284
|
+
st.dim(f.get("doc", ""))] for n, f in fns.items()], st))
|
|
285
|
+
if d.get("streams"):
|
|
286
|
+
print("\n" + table(["STREAM", "CONTENT", ""], [[st.bold(n), x["content_type"].split(";")[0], st.dim(x.get("doc", ""))]
|
|
287
|
+
for n, x in d["streams"].items()], st))
|
|
288
|
+
if d.get("safe"):
|
|
289
|
+
print("\n " + st.dim("safe state ") + ", ".join(f"{k} {quantity(v, '', st)}" for k, v in d["safe"].items()))
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def cmd_watch(a):
|
|
293
|
+
"""Readings refreshed in place until Ctrl-C."""
|
|
294
|
+
import time
|
|
295
|
+
|
|
296
|
+
from .term import quantity, table
|
|
297
|
+
client, st = _client(a), _st()
|
|
298
|
+
d = client.describe(a.device)
|
|
299
|
+
try:
|
|
300
|
+
while True:
|
|
301
|
+
now = _readings(client, a.device, d, a.names or None)
|
|
302
|
+
rows = [[st.bold(n), c or "", quantity(r["value"], r.get("unit", ""), st) if r.get("ok") else st.bad(r["error"]["code"])]
|
|
303
|
+
for (n, c), r in now.items()]
|
|
304
|
+
text = f"{st.bold(a.device)} {st.dim(time.strftime('%H:%M:%S'))} {st.dim('Ctrl-C to stop')}\n" + table(["ATTRIBUTE", "CH", "NOW"], rows, st)
|
|
305
|
+
if sys.stdout.isatty():
|
|
306
|
+
print("\x1b[H\x1b[J" + text, flush=True)
|
|
307
|
+
else:
|
|
308
|
+
print(text, flush=True)
|
|
309
|
+
time.sleep(a.interval)
|
|
310
|
+
except KeyboardInterrupt:
|
|
311
|
+
print()
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def cmd_stream(a):
|
|
315
|
+
"""A device's stream to stdout: lines as they come, or bytes to a file or pipe."""
|
|
316
|
+
import urllib.request
|
|
317
|
+
from urllib.parse import quote
|
|
318
|
+
client = _client(a)
|
|
319
|
+
q = f"?channel={quote(a.channel)}" if a.channel else ""
|
|
320
|
+
req = urllib.request.Request(f"{client.url}/api/v1/streams/{quote(a.device)}/{quote(a.name)}{q}",
|
|
321
|
+
headers={"Authorization": f"Bearer {client.token}"})
|
|
322
|
+
try:
|
|
323
|
+
with urllib.request.urlopen(req, timeout=30) as r:
|
|
324
|
+
kind = r.headers.get("Content-Type", "")
|
|
325
|
+
text = kind.startswith(("application/x-ndjson", "text/"))
|
|
326
|
+
if not text and sys.stdout.isatty():
|
|
327
|
+
raise LabratError("bad_request", f"{a.name} sends {kind.split(';')[0]}; send it to a file: labrat stream ... > out.mjpeg")
|
|
328
|
+
out = sys.stdout if text else sys.stdout.buffer
|
|
329
|
+
while chunk := r.read1(65536):
|
|
330
|
+
out.write(chunk.decode(errors="replace") if text else chunk)
|
|
331
|
+
out.flush()
|
|
332
|
+
except urllib.error.HTTPError as e:
|
|
333
|
+
err = json.loads(e.read()).get("error", {})
|
|
334
|
+
raise LabratError(err.get("code", "device"), err.get("message", f"HTTP {e.code}")) from None
|
|
335
|
+
except KeyboardInterrupt:
|
|
336
|
+
pass
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def cmd_stop(a):
|
|
340
|
+
st, r = _st(), _client(a).stop(a.reason)
|
|
341
|
+
if a.json:
|
|
342
|
+
return _show(r)
|
|
343
|
+
if r.get("safe"):
|
|
344
|
+
print(st.good("every device is in its safe state"))
|
|
345
|
+
return 0
|
|
346
|
+
print(st.bad("not every device reached its safe state:"))
|
|
347
|
+
for p in r.get("problems", []):
|
|
348
|
+
print(f" {p}")
|
|
349
|
+
return 2
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def cmd_record(a):
|
|
353
|
+
from .term import table
|
|
354
|
+
client, st = _client(a), _st()
|
|
355
|
+
if a.action == "list" or not a.name:
|
|
356
|
+
r = client.recordings()
|
|
357
|
+
if a.json:
|
|
358
|
+
return _show(r)
|
|
359
|
+
print(st.dim("stores ") + (", ".join(f"{n} {st.dim(p)}" for n, p in r["stores"].items()) or "none"))
|
|
360
|
+
rows = []
|
|
361
|
+
for x in r["recordings"]:
|
|
362
|
+
run = x.get("run") or {}
|
|
363
|
+
what = ", ".join(x.get("read") or []) or x.get("call") or x.get("stream") or ""
|
|
364
|
+
state = st.good("recording") if run.get("running") else (st.bad("error") if run.get("error") else st.dim("idle"))
|
|
365
|
+
rows.append([st.bold(x["recording"]), x.get("device", ""), what, state, str(run.get("readings", "")), st.dim(run.get("file", ""))])
|
|
366
|
+
return print(table(["RECORDING", "DEVICE", "WHAT", "STATE", "READINGS", "FILE"], rows, st, indent=0) if rows else "no recordings")
|
|
367
|
+
spec = {k: getattr(a, k) for k in ("device", "read", "call", "stream", "every_s", "duration_s", "count", "store", "format", "channel")
|
|
368
|
+
if getattr(a, k, None) is not None} if a.action == "start" else {}
|
|
369
|
+
r = client.record(a.name, a.action, **spec)
|
|
370
|
+
if a.json:
|
|
371
|
+
return _show(r)
|
|
372
|
+
state = st.good("recording") if r.get("running") else st.dim("stopped")
|
|
373
|
+
print(f"{st.bold(r['recording'])} {state} {r.get('readings', 0)} readings {st.dim(r.get('file', ''))}"
|
|
374
|
+
+ (f" {st.bad(r['error'])}" if r.get("error") else ""))
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def cmd_experiment(a):
|
|
378
|
+
from .term import table
|
|
379
|
+
client, st = _client(a), _st()
|
|
380
|
+
if a.action == "list":
|
|
381
|
+
rows = client.experiments(a.person)
|
|
382
|
+
if a.json:
|
|
383
|
+
return _show(rows)
|
|
384
|
+
if not rows:
|
|
385
|
+
return print("no experiments yet")
|
|
386
|
+
state = {"open": st.good("open"), "ended": st.dim("ended")}
|
|
387
|
+
return print(table(["EXPERIMENT", "PERSON", "STATE", "STARTED", "BY", "FOLDER", "ABOUT"],
|
|
388
|
+
[[st.bold(e["experiment"]), e.get("person", ""), state.get(e.get("state"), e.get("state", "")),
|
|
389
|
+
e.get("started", "")[:16].replace("T", " "), st.dim(e.get("started_by", "")), st.dim(e.get("folder", "")),
|
|
390
|
+
e.get("description", "")] for e in rows], st, indent=0))
|
|
391
|
+
if a.action == "start":
|
|
392
|
+
if not a.name:
|
|
393
|
+
raise LabratError("bad_request", "labrat experiment start <name> [description]")
|
|
394
|
+
r = client.start_experiment(a.name, " ".join(a.text))
|
|
395
|
+
elif a.action == "stop":
|
|
396
|
+
r = client.stop_experiment()
|
|
397
|
+
elif a.action == "note":
|
|
398
|
+
r = client.note(" ".join(([a.name] if a.name else []) + a.text))
|
|
399
|
+
else:
|
|
400
|
+
r = client.experiment(a.name)
|
|
401
|
+
if a.json:
|
|
402
|
+
return _show(r)
|
|
403
|
+
state = st.good("open") if r.get("state") == "open" else st.dim(r.get("state", ""))
|
|
404
|
+
print(f"{st.bold(r['experiment'])} {state} {st.dim(r.get('folder', ''))}")
|
|
405
|
+
if r.get("description"):
|
|
406
|
+
print(f" {r['description']}")
|
|
407
|
+
for n in r.get("notes") or []:
|
|
408
|
+
print(f" {st.dim(n['time'][11:16])} {n['text']}")
|
|
409
|
+
for x in r.get("recordings") or []:
|
|
410
|
+
print(f" {st.accent(x['recording'])} {x.get('readings', 0)} readings {st.dim(Path(x.get('file', '')).name)}")
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def cmd_files(a):
|
|
414
|
+
from .term import table
|
|
415
|
+
client, st = _client(a), _st()
|
|
416
|
+
if a.get:
|
|
417
|
+
print(client.download(a.path, a.output, a.person))
|
|
418
|
+
return
|
|
419
|
+
r = client.request("files", name=a.path or None, args={"person": a.person} if a.person else None)
|
|
420
|
+
if "files" not in r: # a file: print it, as text
|
|
421
|
+
if a.json:
|
|
422
|
+
return _show(r)
|
|
423
|
+
if "base64" in r:
|
|
424
|
+
raise LabratError("bad_request", f"{a.path} is binary; fetch it with: labrat files {a.path} --get")
|
|
425
|
+
sys.stdout.write(r["text"])
|
|
426
|
+
offset = r["offset"] + r["length"]
|
|
427
|
+
while r.get("more"):
|
|
428
|
+
r = client.request("files", name=a.path, args={"offset": offset, **({"person": a.person} if a.person else {})})
|
|
429
|
+
sys.stdout.write(r.get("text", ""))
|
|
430
|
+
offset += r["length"]
|
|
431
|
+
return
|
|
432
|
+
if a.json:
|
|
433
|
+
return _show(r["files"])
|
|
434
|
+
|
|
435
|
+
def size(n):
|
|
436
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
437
|
+
if n < 1024 or unit == "GB":
|
|
438
|
+
return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
|
|
439
|
+
n /= 1024
|
|
440
|
+
rows = [[st.accent(f["name"] + "/") if f["folder"] else f["name"], "" if f["folder"] else size(f["size"]),
|
|
441
|
+
st.dim(f["modified"][:16].replace("T", " "))] for f in r["files"]]
|
|
442
|
+
print(table(["NAME", "SIZE", "MODIFIED"], rows, st, indent=0) if rows else "empty")
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _ago(stamp: str) -> str:
|
|
446
|
+
import time
|
|
447
|
+
try:
|
|
448
|
+
t = time.mktime(time.strptime(stamp[:19], "%Y-%m-%dT%H:%M:%S"))
|
|
449
|
+
except ValueError:
|
|
450
|
+
return stamp
|
|
451
|
+
s = max(0, time.time() - t)
|
|
452
|
+
return "just now" if s < 60 else f"{s / 60:.0f} min ago" if s < 3600 else f"{s / 3600:.0f} h ago" if s < 86400 else stamp[:10]
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def cmd_who(a):
|
|
456
|
+
from .term import table
|
|
457
|
+
st, r = _st(), _client(a).who()
|
|
458
|
+
if a.json:
|
|
459
|
+
return _show(r)
|
|
460
|
+
rows = []
|
|
461
|
+
for d in r["devices"]:
|
|
462
|
+
held = st.warn(f"{d['locked_by']} ({d['minutes_left']:g} min left)") if d.get("locked_by") else st.dim("free")
|
|
463
|
+
c = d.get("last_change") or {}
|
|
464
|
+
what = " ".join(str(c[k]) for k in ("name", "channel", "value") if c.get(k) is not None)
|
|
465
|
+
rows.append([st.bold(d["device"]), held, c.get("who", ""), what, st.dim(_ago(c["time"])) if c else ""])
|
|
466
|
+
print(table(["DEVICE", "HELD BY", "LAST CHANGED BY", "CHANGE", "WHEN"], rows, st, indent=0) if rows
|
|
467
|
+
else "nobody holds or has changed a device yet")
|
|
468
|
+
if r.get("require_lock"):
|
|
469
|
+
print(st.dim(f"\nchanges need a lock first on: {', '.join(r['require_lock'])}"))
|
|
470
|
+
if r["people"]:
|
|
471
|
+
print("\n" + table(["PERSON", "LAST ACTIVE"], [[p["name"], st.dim(_ago(p["last_active"]))] for p in r["people"]], st, indent=0))
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def cmd_users(a):
|
|
475
|
+
from .term import table
|
|
476
|
+
st, rows = _st(), _client(a).users()
|
|
477
|
+
if a.json:
|
|
478
|
+
return _show(rows)
|
|
479
|
+
print(table(["USER", "ROLE", "FOR", "ADDED", "LAST ACTIVE"],
|
|
480
|
+
[[st.bold(u["name"]) + (st.warn(" god mode") if u.get("god_mode") else ""), u["role"], u.get("for", ""),
|
|
481
|
+
st.dim(u.get("added_by", u["from"]) if u["from"] == "nest.toml" else f"by {u['added_by']} {u['added'][:10]}"),
|
|
482
|
+
st.dim(_ago(u["last_active"])) if u.get("last_active") else st.dim("not yet")] for u in rows], st, indent=0))
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def cmd_user(a):
|
|
486
|
+
client, st = _client(a), _st()
|
|
487
|
+
if a.action == "add":
|
|
488
|
+
r = client.add_user(a.name, a.role, a.for_)
|
|
489
|
+
print(f"added {st.bold(r['name'])} ({r['role']}{', for ' + r['for'] if r.get('for') else ''}). Their token, shown once:\n\n"
|
|
490
|
+
f" {r['token']}\n\nexport LABRAT_URL={client.url}\nexport LABRAT_TOKEN={r['token']}")
|
|
491
|
+
else:
|
|
492
|
+
client.remove_user(a.name)
|
|
493
|
+
print(f"removed {st.bold(a.name)}: their token no longer works, their locks are released")
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def cmd_approvals(a):
|
|
497
|
+
from .term import table
|
|
498
|
+
st, rows = _st(), _client(a).approvals()
|
|
499
|
+
if not rows:
|
|
500
|
+
return print("nothing waits for approval")
|
|
501
|
+
print(table(["ID", "WHO", "REQUEST", "ASKED"], [[st.bold(p["id"]), p["by"],
|
|
502
|
+
" ".join(str(p["request"].get(k)) for k in ("op", "device", "name", "channel", "value") if p["request"].get(k) is not None),
|
|
503
|
+
st.dim(p["asked"])] for p in rows], st, indent=0))
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def cmd_shell(a):
|
|
507
|
+
from .term import shell
|
|
508
|
+
base = [x for k, v in (("--url", a.url), ("--token", a.token)) if v for x in (k, v)]
|
|
509
|
+
return shell(_client(a), lambda args: main(args + base))
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def cmd_find(a):
|
|
513
|
+
from .discovery import scan
|
|
514
|
+
found = scan(a.seconds)
|
|
515
|
+
if not found:
|
|
516
|
+
print("nothing found (is multicast DNS allowed on this network?)")
|
|
517
|
+
for f in found:
|
|
518
|
+
print(f"{f.role:<5} {f.name:<24} {f.url:<28} {f.version:<8} {f.fingerprint}")
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def cmd_adopt(a):
|
|
522
|
+
import getpass
|
|
523
|
+
key = os.environ.get("LABRAT_RAT_KEY") or getpass.getpass(f"key of rat {a.name}: ") # kept out of shell history
|
|
524
|
+
_show(_client(a).adopt(a.name, key.strip(), a.at))
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def cmd_token(a):
|
|
528
|
+
token, entry = _token_entry(a.name, a.role)
|
|
529
|
+
print(f"token for {a.name} ({a.role}), shown once:\n {token}\n\nadd to the nest or rat configuration:\n\n{entry}")
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _group_exists(grp, name: str) -> bool:
|
|
533
|
+
try:
|
|
534
|
+
grp.getgrnam(name)
|
|
535
|
+
return True
|
|
536
|
+
except KeyError:
|
|
537
|
+
return False
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def cmd_service(a):
|
|
541
|
+
exe = Path(sys.argv[0]).resolve() if Path(sys.argv[0]).name == "labrat" else Path(sys.executable).with_name("labrat")
|
|
542
|
+
import grp
|
|
543
|
+
wanted = ("dialout", "gpio", "video", "plugdev") if a.kind == "rat" else ()
|
|
544
|
+
groups = " ".join(g for g in wanted if _group_exists(grp, g))
|
|
545
|
+
print(UNIT.format(kind=a.kind, exe=exe, groups=groups).replace("SupplementaryGroups=\n", ""), end="")
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def main(argv=None) -> int:
|
|
549
|
+
p = argparse.ArgumentParser(prog="labrat", description="A universal interface for lab automation.")
|
|
550
|
+
p.add_argument("--version", action="version", version=f"labrat {__version__}")
|
|
551
|
+
p.add_argument("-v", "--verbose", action="store_true")
|
|
552
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
553
|
+
|
|
554
|
+
s = sub.add_parser("nest", help="run the nest (the lab's server), or 'nest init' to make its configuration")
|
|
555
|
+
s.add_argument("action", nargs="?", choices=["run", "init"], default="run")
|
|
556
|
+
s.add_argument("--config")
|
|
557
|
+
s.add_argument("--admin", default=os.environ.get("SUDO_USER") or os.environ.get("USER") or "admin",
|
|
558
|
+
help="name for the first admin token (init)")
|
|
559
|
+
s.set_defaults(fn=cmd_nest)
|
|
560
|
+
s = sub.add_parser("rat", help="run a rat, 'rat init' to make its configuration, 'rat key' to show its key")
|
|
561
|
+
s.add_argument("action", nargs="?", choices=["run", "init", "key"], default="run")
|
|
562
|
+
s.add_argument("--config")
|
|
563
|
+
s.set_defaults(fn=cmd_rat)
|
|
564
|
+
s = sub.add_parser("demo", help="a demo lab with simulated instruments")
|
|
565
|
+
s.add_argument("--port", type=int, default=conf.NEST_PORT)
|
|
566
|
+
s.set_defaults(fn=cmd_demo)
|
|
567
|
+
s = sub.add_parser("find", help="list the nests and rats on the network")
|
|
568
|
+
s.add_argument("--seconds", type=float, default=3.0)
|
|
569
|
+
s.set_defaults(fn=cmd_find)
|
|
570
|
+
s = sub.add_parser("token", help="make a token and the line that grants it")
|
|
571
|
+
s.add_argument("--name", required=True)
|
|
572
|
+
s.add_argument("--role", choices=ROLES, default="operator")
|
|
573
|
+
s.set_defaults(fn=cmd_token)
|
|
574
|
+
s = sub.add_parser("service", help="print a systemd unit for a nest or rat")
|
|
575
|
+
s.add_argument("kind", choices=["nest", "rat"])
|
|
576
|
+
s.set_defaults(fn=cmd_service)
|
|
577
|
+
s = sub.add_parser("drivers", help="the device drivers this installation has")
|
|
578
|
+
s.set_defaults(fn=lambda a: [print(f"{k:<14} {v}") for k, v in sorted(__import__("labrat.drivers", fromlist=["available"]).available().items())])
|
|
579
|
+
s = sub.add_parser("mcp", help="serve the lab to an AI agent over MCP (stdio)")
|
|
580
|
+
s.set_defaults(fn=lambda a: __import__("labrat.mcp", fromlist=["run"]).run(_client(a)))
|
|
581
|
+
|
|
582
|
+
def client_cmd(name, help_, fn, *args):
|
|
583
|
+
c = sub.add_parser(name, help=help_)
|
|
584
|
+
for arg in args:
|
|
585
|
+
if isinstance(arg, tuple):
|
|
586
|
+
c.add_argument(*arg[0], **arg[1])
|
|
587
|
+
else:
|
|
588
|
+
c.add_argument(arg)
|
|
589
|
+
c.set_defaults(fn=fn)
|
|
590
|
+
return c
|
|
591
|
+
|
|
592
|
+
ch = (("--channel", "-c"), {})
|
|
593
|
+
js = (("--json",), {"action": "store_true", "help": "print JSON"})
|
|
594
|
+
client_cmd("devices", "list the devices, filtered by a selector such as: oscilloscope tektronix/*", cmd_devices,
|
|
595
|
+
(("select",), {"nargs": "*", "help": "kind, manufacturer/model, #serial, @firmware, field=pattern"}),
|
|
596
|
+
(("--by",), {"choices": ["kind", "manufacturer", "model", "location", "rat"], "help": "group by"}),
|
|
597
|
+
(("--sort",), {"choices": ["device", "kind", "manufacturer", "model", "serial", "location", "rat"]}), js)
|
|
598
|
+
client_cmd("kinds", "the standard device kinds", lambda a: [print(f"{k:<24} {v[0]}" + (f" ({v[1]})" if v[1] else ""))
|
|
599
|
+
for k, v in __import__("labrat.ident", fromlist=["KINDS"]).KINDS.items()])
|
|
600
|
+
client_cmd("describe", "what a device offers, with its current readings", cmd_describe, "device", js)
|
|
601
|
+
client_cmd("get", "read an attribute", lambda a: _print_value(_reply(a, {"op": "get", "device": a.device, "name": a.name, "channel": a.channel}), a.json),
|
|
602
|
+
"device", "name", ch, js)
|
|
603
|
+
client_cmd("set", "set an attribute", lambda a: _print_value(_reply(a, {"op": "set", "device": a.device, "name": a.name, "channel": a.channel,
|
|
604
|
+
"value": _value(a.value)}), a.json),
|
|
605
|
+
"device", "name", "value", ch, js)
|
|
606
|
+
client_cmd("watch", "live readings of a device, refreshed in place", cmd_watch, "device", (("names",), {"nargs": "*"}),
|
|
607
|
+
(("--interval", "-n"), {"type": float, "default": 1.0}))
|
|
608
|
+
client_cmd("stream", "a device's stream to the terminal, a file or a pipe", cmd_stream, "device", "name", ch)
|
|
609
|
+
client_cmd("shell", "an interactive prompt with tab completion", cmd_shell)
|
|
610
|
+
client_cmd("call", "run a function, arguments as key=value",
|
|
611
|
+
lambda a: _show(_client(a).call(a.device, a.name, a.channel, **{k: _value(v) for k, v in (x.split("=", 1) for x in a.args)})),
|
|
612
|
+
"device", "name", (("args",), {"nargs": "*"}), ch)
|
|
613
|
+
client_cmd("lock", "take a device", lambda a: _show(_client(a).lock(a.device, a.minutes)), "device",
|
|
614
|
+
(("--minutes",), {"type": float}))
|
|
615
|
+
client_cmd("unlock", "release a device", lambda a: _show(_client(a).unlock(a.device)), "device")
|
|
616
|
+
client_cmd("stop", "put every device in its safe state", cmd_stop,
|
|
617
|
+
(("--reason",), {"default": "labrat stop"}), js)
|
|
618
|
+
client_cmd("rats", "rats adopted and seen", lambda a: _show(_client(a).rats()))
|
|
619
|
+
client_cmd("approvals", "requests waiting for a person", cmd_approvals)
|
|
620
|
+
client_cmd("record", "recordings: list, or start, stop or status of one", cmd_record,
|
|
621
|
+
(("action",), {"nargs": "?", "choices": ["list", "start", "stop", "status"], "default": "list"}),
|
|
622
|
+
(("name",), {"nargs": "?"}), (("--device",), {}), (("--read",), {"nargs": "+"}), (("--call",), {}),
|
|
623
|
+
(("--stream",), {}), (("--every",), {"type": float, "dest": "every_s"}), (("--for",), {"type": float, "dest": "duration_s"}),
|
|
624
|
+
(("--count",), {"type": int}), (("--store",), {}), (("--format",), {"choices": ["csv", "jsonl"]}), ch, js)
|
|
625
|
+
client_cmd("experiment", "experiments: list yours, or start, stop, note or status", cmd_experiment,
|
|
626
|
+
(("action",), {"nargs": "?", "choices": ["list", "start", "stop", "note", "status"], "default": "status"}),
|
|
627
|
+
(("name",), {"nargs": "?"}), (("text",), {"nargs": "*", "help": "a description (start) or a note"}),
|
|
628
|
+
(("--person",), {"help": "another person's (admins), or * for everyone's"}), js)
|
|
629
|
+
client_cmd("experiments", "your experiments, newest first", lambda a: cmd_experiment(argparse.Namespace(**vars(a), action="list", name=None, text=[])),
|
|
630
|
+
(("--person",), {}), js)
|
|
631
|
+
client_cmd("files", "the files of your experiments: list a folder, print a file, or --get to download it", cmd_files,
|
|
632
|
+
(("path",), {"nargs": "?", "default": ""}), (("--get",), {"action": "store_true"}), (("--output", "-o"), {}),
|
|
633
|
+
(("--person",), {}), js)
|
|
634
|
+
client_cmd("who", "who holds each device, who changed it last, who was active", cmd_who, js)
|
|
635
|
+
client_cmd("users", "the users of this nest", cmd_users, js)
|
|
636
|
+
client_cmd("user", "add or remove a user (admins), with no restart", cmd_user,
|
|
637
|
+
(("action",), {"choices": ["add", "remove"]}), "name",
|
|
638
|
+
(("--role",), {"choices": ROLES, "default": "operator"}),
|
|
639
|
+
(("--for",), {"dest": "for_", "help": "the person this user (an AI agent, say) works for"}))
|
|
640
|
+
client_cmd("approve", "approve a waiting request (it then runs)", lambda a: _show(_client(a).approve(a.id)), "id")
|
|
641
|
+
client_cmd("deny", "deny a waiting request", lambda a: _show(_client(a).deny(a.id)), "id")
|
|
642
|
+
client_cmd("adopt", "adopt a rat; the key is asked for, or read from LABRAT_RAT_KEY", cmd_adopt, "name",
|
|
643
|
+
(("--at",), {"help": "the rat's URL, when discovery cannot see it"}))
|
|
644
|
+
client_cmd("forget", "forget an adopted rat", lambda a: _show(_client(a).forget(a.name)), "name")
|
|
645
|
+
for c in sub.choices.values():
|
|
646
|
+
c.add_argument("--url", default=None, help="nest or rat URL (default LABRAT_URL)")
|
|
647
|
+
c.add_argument("--token", default=None, help="token (default LABRAT_TOKEN)")
|
|
648
|
+
|
|
649
|
+
a = p.parse_args(argv)
|
|
650
|
+
logging.basicConfig(level=logging.DEBUG if a.verbose else logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
|
651
|
+
try:
|
|
652
|
+
r = a.fn(a)
|
|
653
|
+
return r if isinstance(r, int) else 0
|
|
654
|
+
except LabratError as e:
|
|
655
|
+
from .term import Style, colour_wanted
|
|
656
|
+
st = Style(colour_wanted(sys.stderr))
|
|
657
|
+
if e.code == "pending":
|
|
658
|
+
print(f"{st.warn('waiting for approval')} {e.message}. Approve with: labrat approve {e.extra.get('approval', '')}", file=sys.stderr)
|
|
659
|
+
return 3
|
|
660
|
+
print(f"{st.bad(e.code)}{st.dim(':')} {e.message}", file=sys.stderr)
|
|
661
|
+
return 2
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
if __name__ == "__main__":
|
|
665
|
+
sys.exit(main())
|