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 ADDED
@@ -0,0 +1,2 @@
1
+ """LABRAT: a universal interface for lab automation, served by a nest and its rats."""
2
+ __version__ = "0.1.0"
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())