asterism 0.7.1 → 0.8.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d729d6c233d712c647e7fb1168f2587fa0e81eb1b176a11f12ac7c349f61a592
4
- data.tar.gz: fce6e1102b0e4e2c404adf28a299c77efd548f6a07e92dc81a7b54a9efe1951e
3
+ metadata.gz: 6ee81a1950e5fffa0f5d299999d7878834a79d897e3b4cbf879e293fc6b00531
4
+ data.tar.gz: 05a88c6ad24fafe262668cb11f4a48e7e5357678c1903207aae004ac0e518311
5
5
  SHA512:
6
- metadata.gz: c2050d9890925ad4b0910f3e8063a7d2b6e9ef1e8f68ea3346bea26c02674cce7a6b0c2ee798d2940f2171d7c763c49dd8160a44b3d3204b13ccea813936430c
7
- data.tar.gz: 862531b730d5bc6f00c673271fb4d1d8db6ecaaab11969a4be95234c09be4ba6eceed245b24102f490fda6608f946b5d30e8ce3b960255b197b95bcd5a4aa550
6
+ metadata.gz: 4c6e015f5bd202a1cf44dc3d6ee59fe8af406cb899735df5ee828610cb82de5aa67035be2b5c9cbf1b4c603e0477eaba1e9e503f3ed901e81308ea1b98c91fbc
7
+ data.tar.gz: 717902fdcce318aad8a78776f0f72b4063241868dbc8b50def6a0386f2868f764dbf356403b0ef1cbd727c5dc8df6818ced7d465cfb23338452e892d3e0c6dc1
data/CHANGELOG.md CHANGED
@@ -6,6 +6,62 @@ the earlier ones are only in this repository. From 0.4.0 the two Zenoh
6
6
  bindings (asterism-zenoh, picoruby-asterism-zenoh) carry the same minor
7
7
  version (a patch release of one may come without the other).
8
8
 
9
+ ## 0.8.0
10
+
11
+ Needs asterism-zenoh 0.4.1 or later, as 0.7.1. Wire protocol 1 as before:
12
+ the statuses and the methods' texts in the meta are additions that older
13
+ nodes ignore. The command line comes with the gem (`exe/asterism`); its MCP
14
+ server needs the mcp gem, which is not a dependency.
15
+
16
+ - Application statuses (protocol 1, an addition: docs/protocol.md 8).
17
+ `Asterism.status(tables: 3)` / `Asterism.status({ ... })` sets this
18
+ application's status, a small Hash that changes while it runs (String
19
+ keys, nested 4 levels at most, 1024 bytes packed at most); it is put on
20
+ `asterism/<node>/<app>/@status` when it changes (the same status again
21
+ puts nothing), at connect and after a lost connection comes back, and a
22
+ get there answers it. `Asterism.status_of(node, app)` reads another
23
+ application's (nil when it has none). `Asterism::Status.check` /
24
+ `.parse` clean what comes in. CRuby: `net.status`, `net.status_of`,
25
+ `net.on_status(pattern) { |node, app, status| }`. Both on mruby too
26
+ (`rake test:mruby`). `@status` is a verbatim chunk like `@profile`:
27
+ `asterism/**` does not reach it. The conformance samples are version 3
28
+ (status queries, `status_of`, `status_puts`).
29
+ - `Asterism.apps(pattern = "*/*")`: the applications alive now
30
+ (`"<node>/<app>"`, from their liveliness tokens).
31
+ - The command line `asterism` (exe/asterism, CRuby): `nodes`, `objects`,
32
+ `tree`, `watch` (nodes, applications and objects coming and going, and
33
+ statuses), `ping` (alias `echo`: round trips of the meta, a profile or a
34
+ call), `call` (arguments as JSON or Ruby literals, read with Ripper), with
35
+ `--router` or `--credentials` (a console's `.tar`) and `--json`.
36
+ README "The command line". `rake test:cli`.
37
+ - `asterism mcp`: an MCP server on stdio (the mcp gem, not a dependency).
38
+ Read-only tools (`asterism_nodes`, `asterism_objects`, `asterism_status`)
39
+ always; a tool per method that `--allow` names, its inputs made from the
40
+ meta's arity; the list follows the network (tools/list_changed).
41
+ - The meta tells the arguments' names and what a method does (protocol 1,
42
+ an addition: docs/protocol.md 5). `expose` takes
43
+ `methods: { "move" => { args: %w[token n square], doc: "..." } }` next to
44
+ the numbers (`{ add: 2 }`), and `doc:` for the object itself; the names
45
+ give the number of arguments checked (`arity:` says otherwise, -1 for
46
+ any). Readers ignore the fields (old nodes and new ones mix). `asterism
47
+ mcp` makes the tools' inputs from the names and their descriptions from
48
+ the docs; `objects` and `tree` show them. The conformance samples are
49
+ version 4 (the object `other` of the node under test has them).
50
+ - `on_app_join { |node, app| }` / `on_app_leave { |node, app| }`: an
51
+ application of another node came or went (its token
52
+ `asterism/<node>/<app>`), while the node may stay with others; on mruby
53
+ too. CRuby: `net.on_app_join` / `net.on_app_leave`, and `off_*`.
54
+
55
+ - `credentials:` on the boards too: `Asterism::Credentials` moved to
56
+ `mrblib/credentials.rb` and reads the console's `.tar` in plain Ruby
57
+ (ustar taken apart with `byteslice` / `getbyte`, base64 without `pack`,
58
+ `asterism.json` with or without a JSON library). `Asterism.connect` takes
59
+ `credentials:` on mruby (with picoruby-asterism-zenoh's TLS, ESP-IDF
60
+ builds); `locator` and `node:` are optional when it is given. CRuby keeps
61
+ its reading (the standard library's tar reader, the file mode warning,
62
+ the key pair check) in a module prepended to the class. `rake test:mruby`
63
+ checks the board's reading against CRuby's.
64
+
9
65
  ## 0.7.1
10
66
 
11
67
  Needs asterism-zenoh 0.4.1 or later (`Session#reconnecting?`).
data/README.md CHANGED
@@ -13,7 +13,8 @@ repository holds the pure Ruby layers, written once for every Ruby:
13
13
  | `data/icon.png` | CRuby's default profile icon (32x32 PNG; [Profiles](#profiles)) |
14
14
  | `data/msgs/` | ROS 2 message types generated from ROS 2 Jazzy's definitions (Apache-2.0; tf2_msgs BSD-3-Clause; see NOTICE) |
15
15
  | `tools/` | The type generator `asterism_msggen.rb`, the Jazzy definitions it reads, `ros2_types.rb` (refreshes them from a ROS 2 image) |
16
- | `test/` | `test/msgs` (types, CDR; CRuby only), `test_asterism.rb` (objects and ROS between CRuby sessions), `test_profile.rb` (node profiles), `test_session.rb` (`Asterism.session`, `on_join` / `on_leave`), `test_api_*.rb` (the CRuby API), `bench_poll.rb` (`rake bench:poll`) |
16
+ | `exe/asterism`, `lib/asterism/cli*` | The command line: look at a network, call an object, an MCP server ([The command line](#the-command-line); CRuby only) |
17
+ | `test/` | `test/msgs` (types, CDR; CRuby only), `test_asterism.rb` (objects and ROS between CRuby sessions), `test_profile.rb` (node profiles), `test_status.rb` (application statuses), `test_meta_apps.rb` (the meta's args / doc, on_app_join / on_app_leave), `test_cli.rb` (the command line), `test_session.rb` (`Asterism.session`, `on_join` / `on_leave`), `test_api_*.rb` (the CRuby API), `bench_poll.rb` (`rake bench:poll`) |
17
18
  | `test/protocol/` | the conformance samples of the wire protocol (`samples.json`), the driver that checks an implementation against them, and the Ruby node it checks (`rake test:protocol`, `rake test:protocol_mruby`; [docs/protocol.md](docs/protocol.md)) |
18
19
  | `host_test/` | `rake test:mruby`: the shared layer on mruby (profiles between two mruby processes; `Asterism.session`, `on_join` / `on_leave` and the time of a poll against `peer.rb`) |
19
20
  | `examples/` | CRuby: `node.rb` (objects with a board), `node_polled.rb` (the same with the portable API of the boards), `ros2_talker.rb` (topics, a timer, services with ROS 2), `ros2_rover.rb` (drive a ROS 2 robot: `/cmd_vel`, `/odom`, `/imu`), `zenoh.rb` (plain Zenoh), `mujoco/` (MuJoCo run from CRuby through Fiddle, its robot exposed as Asterism objects; see its README) |
@@ -100,7 +101,7 @@ build but not yet tested. To install from the repositories instead:
100
101
 
101
102
  ```
102
103
  (cd ../asterism-zenoh && rake gem) && rake gem
103
- gem install --local ../asterism-zenoh/pkg/asterism-zenoh-0.4.1.gem pkg/asterism-0.7.1.gem
104
+ gem install --local ../asterism-zenoh/pkg/asterism-zenoh-0.4.1.gem pkg/asterism-0.8.0.gem
104
105
  ```
105
106
 
106
107
  From the working trees, without installing:
@@ -129,6 +130,7 @@ rake # every suite
129
130
  rake test:msgs # generator, type hashes, CDR, bundled types (CRuby only; no Zenoh, no docker)
130
131
  rake test:objects # objects and Asterism::ROS between CRuby sessions over a local peer link
131
132
  rake test:api # the CRuby API (blocks, receiving threads, Enumerators)
133
+ rake test:cli # the command line (exe/asterism) and its MCP bridge (the mcp gem's client; skipped without it)
132
134
  ASTERISM_TEST_ROUTER=tcp/127.0.0.1:7447 rake test:objects test:api # the same through a zenohd router
133
135
  rake test:mruby # the shared layer on mruby (builds mruby; not part of `rake`)
134
136
  rake test:protocol # the wire protocol's conformance samples and driver against CRuby (docs/protocol.md)
@@ -163,7 +165,7 @@ ruby examples/node_polled.rb --router tcp/192.0.2.2:7447 # the same, polled
163
165
  ruby examples/ros2_talker.rb --router tcp/192.0.2.2:7447 [--service NAME]
164
166
 
165
167
  # ROS 2: drive a robot (made for family-mruby's MuJoCo rover,
166
- # docker-compose.mujoco.yml): 1 m forward and 90 degrees left by /odom,
168
+ # docker/asterism/docker-compose.mujoco.yml): 1 m forward and 90 degrees left by /odom,
167
169
  # compared with /ground_truth/odom; or the keyboard
168
170
  ruby examples/ros2_rover.rb --router tcp/192.0.2.2:7447 [--keys]
169
171
 
@@ -315,6 +317,13 @@ and gives zenoh the CA, the certificate and the key as
315
317
  (a Hash) is laid over these keys. A tar that the group or others may read
316
318
  is warned about, as ssh does with a key: `chmod 600 alice.tar`.
317
319
 
320
+ The boards (mruby / PicoRuby over picoruby-asterism-zenoh) take the same
321
+ file with `Asterism.connect(credentials: "/home/asterism/alice.tar", app: "game")`
322
+ when their Zenoh binding has TLS (`Asterism::Zenoh::TLS`; ESP-IDF builds).
323
+ There `Asterism::Credentials` reads the tar in plain Ruby (no Regexp, no
324
+ `pack`), the boards have no file modes to warn about, and the key pair is
325
+ left for the router to check; `config:` takes the TLS keys only.
326
+
318
327
  The router checks the certificate against its CA, and its ACL can tell the
319
328
  nodes apart by the certificate's common name. The name in the locator must
320
329
  be one the router's certificate is valid for (zenoh checks it; it is
@@ -401,6 +410,95 @@ What may be shared between threads:
401
410
  | The boards | one thread (the application's update loop) |
402
411
  | Ractors | not supported |
403
412
 
413
+ ## The command line
414
+
415
+ The gem has a command, `asterism`, that looks at a network from a terminal
416
+ (CRuby). It is one more node: it connects, reads the liveliness tokens,
417
+ the meta, the profiles and the statuses, and calls a method only when told
418
+ to with `call`.
419
+
420
+ ```
421
+ asterism nodes --router tcp/192.0.2.2:7447 # nodes, their apps (with their status), profiles
422
+ asterism objects --router tcp/192.0.2.2:7447 'games/games/*' # objects and their methods
423
+ asterism tree --credentials admin.tar # nodes > apps > objects, with methods and statuses
424
+ asterism watch --router tcp/192.0.2.2:7447 # nodes, apps and objects coming and going, statuses
425
+ asterism ping --router tcp/192.0.2.2:7447 games/games/othello1 # round trips (its meta; no method runs)
426
+ asterism ping ... games/games/othello1 --call view '"<token>"' # round trips of a call
427
+ asterism call --router tcp/192.0.2.2:7447 games/games/othello1 join '"Alice"'
428
+ asterism call ... linux/demo/screen say hello --kwargs '{color: 3}'
429
+ ```
430
+
431
+ ```
432
+ $ asterism tree --router tcp/192.0.2.2:7447
433
+ games (cruby) "asterism-games"
434
+ games status {"mahjong1":{"st":"wait","members":0,"humans":0,"cpus":0,"away":0}, ...}
435
+ mahjong1 join(1) mine(0) leave(1) sit(2) stand(1) ...
436
+ linux (family_mruby/sim)
437
+ game
438
+ $ asterism watch --router tcp/192.0.2.2:7447
439
+ 12:00:01.204 ~ status games/games {"othello1":{"st":"wait","members":0,...}} (now)
440
+ 12:00:04.881 + node wasm-1a2b3c
441
+ 12:00:04.881 + app wasm-1a2b3c/game
442
+ 12:00:05.120 ~ status games/games {"othello1":{"st":"wait","members":1,...}}
443
+ ```
444
+
445
+ - **Where the network is.** `--router tcp/HOST:7447` (a router without TLS),
446
+ or `--credentials FILE.tar` (a console's `.tar`: mutual TLS, its router
447
+ and its name; `--router` overrides the router). `--peer LOCATOR` links
448
+ straight to a node listening there (no router). The node ID is the
449
+ certificate's name, otherwise `cli-` and 6 hex digits (`--node`); the
450
+ application `cli-` and 4 (`--app`).
451
+ - **What it sees** is what the router lets it see. With a console's `.tar`
452
+ that is the row's roles: a role for looking only is in the console's
453
+ docs/g1_5.md ("Looking only: a viewer role"): it reads every node, object,
454
+ profile and status, and the router refuses its calls, so `asterism call`
455
+ says "nobody answers" and nothing runs. Something the router refuses is
456
+ left out of a listing; it is not reported as gone.
457
+ - **Arguments** of `call` (and `ping --call`): each is JSON, else a Ruby
458
+ literal (nil, true, false, numbers, strings, symbols, arrays, hashes; read
459
+ with Ripper, nothing is evaluated), else the text as written. `--kwargs`
460
+ is a JSON object or a Ruby Hash.
461
+ - **Output**: text, or `--json` (`watch --json`: one object a line, with
462
+ the time). `--timeout` (2.0 s) is each get's and call's limit, `--settle`
463
+ (1.5 s) how long it waits for the tokens after connecting.
464
+ - **watch** shows the applications and objects themselves (their tokens),
465
+ not only the nodes: a node with two applications stays while one closes,
466
+ and `watch` shows that one leave.
467
+
468
+ ### MCP: Asterism as tools of an AI assistant
469
+
470
+ `asterism mcp` is an MCP server on stdio (it needs the `mcp` gem, which
471
+ the asterism gem does not depend on: `gem install mcp`). It reads by
472
+ default, and calls only the methods `--allow` names:
473
+
474
+ ```
475
+ asterism mcp --router tcp/192.0.2.2:7447 --allow 'games/othello1.*'
476
+ claude mcp add asterism -- asterism mcp --credentials admin.tar --allow 'games/othello1.*'
477
+ ```
478
+
479
+ | Tool | What |
480
+ |---|---|
481
+ | `asterism_nodes` | the nodes, their profiles, applications (with their statuses) and objects |
482
+ | `asterism_objects` | the objects and their methods (meta); `callable_as` names the tool for an allowed method |
483
+ | `asterism_status` | the applications' statuses (`node` / `app` narrow it) |
484
+ | `<node>.<app>.<object>.<method>` | one per method an `--allow` pattern names, made from the meta: the arguments' names it tells are the inputs and its doc the description (with the object's doc after it); without names, arity n gives the inputs `arg1` .. `argN` (any JSON value), arity -1 an array `args`; `kwargs` is an object of keyword arguments. The answer is `{"value": ...}`, or an error with its kind |
485
+
486
+ - `--allow PATTERN` (repeatable): `[node/][app/]object.method`, `*` within
487
+ a part as a wildcard. A pattern with fewer parts matches the last parts
488
+ of the path (`games/othello1.*` is `*/games/othello1.*`); without
489
+ `.method` every method of the object.
490
+ - The call tools follow the network: when an allowed object appears or
491
+ goes, the list is made again and the client is told
492
+ (`notifications/tools/list_changed`).
493
+ - The calls are made as this node (`--node`, or the certificate's name), so
494
+ an object exposed with `caller: :from` sees who called; a game table
495
+ gives the MCP client its own seat. `--app` names this application (a
496
+ game host that follows its players by the application `game` sees an
497
+ MCP client started with `--app game` come and go).
498
+ - An object that describes its methods at `expose` (`args:` and `doc:`,
499
+ "Describing the methods" below) gives the AI assistant the names and the
500
+ texts; without them it sees only the number of arguments.
501
+
404
502
  ## License
405
503
 
406
504
  MIT (LICENSE) for Asterism's code. The ROS 2 definitions in
@@ -452,11 +550,12 @@ Asterism.nodes # => ["linux", "fmruby-bbbbbb"]
452
550
 
453
551
  | Call | Returns | Raises / notes |
454
552
  |---|---|---|
455
- | `Asterism.connect(locator, node:, app:, mode: nil, listen: nil, config: nil, connect_timeout: nil, check_timeout: 1.5)` (CRuby also `credentials:`, see [Over TLS](#over-tls-a-router-on-the-internet)) | `Asterism` | `locator`, `mode:`, `listen:` (and `config:` / `connect_timeout:` when given, CRuby only: TLS and the rest of zenoh's configuration; the boards raise `ArgumentError`) go to `Asterism::Zenoh::Session.open` (client of a router by default; `mode: :peer` with or without `listen:` without one). `node:` is this machine's ID, `app:` the application's name (one key chunk each: no `/ * $ ? #`, not starting with `@`; else `ArgumentError`). `Disconnected` when it cannot connect; `Error` when objects of the same `<node>/<app>` are already alive (a second copy of the application), or when already connected. Waits up to `check_timeout:` seconds for that check (a router answers at once; give more over a slow relay). Returns the module itself: on a board the module is the connection (one per application). The default is plain text without authentication; see [Over TLS](#over-tls-a-router-on-the-internet). |
456
- | `Asterism.expose(name, obj, methods:, caller: nil)` | `"<node>/<app>/<name>"` | `methods:` is an Array of names, or a Hash `name => number of arguments` (checked before the call; `-1` or the Array form: any). `ArgumentError` when `obj` has no such public method. Exposing a name again replaces it. `caller: :from` passes the calling node's ID to every method as the keyword `from:` and refuses a call that brings `from:` itself ([Who called](#who-called)). |
553
+ | `Asterism.connect(locator = nil, node: nil, app:, mode: nil, listen: nil, config: nil, connect_timeout: nil, check_timeout: 1.5, credentials: nil)` (`credentials:` see [Over TLS](#over-tls-a-router-on-the-internet)) | `Asterism` | `locator`, `mode:`, `listen:` (and `config:` / `connect_timeout:` when given: TLS and the rest of zenoh's configuration on CRuby; the boards take the TLS keys of `config:` only and raise `ArgumentError` for the rest) go to `Asterism::Zenoh::Session.open` (client of a router by default; `mode: :peer` with or without `listen:` without one). `node:` is this machine's ID, `app:` the application's name (one key chunk each: no `/ * $ ? #`, not starting with `@`; else `ArgumentError`). `Disconnected` when it cannot connect; `Error` when objects of the same `<node>/<app>` are already alive (a second copy of the application), or when already connected. Waits up to `check_timeout:` seconds for that check (a router answers at once; give more over a slow relay). Returns the module itself: on a board the module is the connection (one per application). The default is plain text without authentication; see [Over TLS](#over-tls-a-router-on-the-internet). |
554
+ | `Asterism.expose(name, obj, methods:, caller: nil, doc: nil)` | `"<node>/<app>/<name>"` | `methods:` is an Array of names, or a Hash `name => number of arguments` (checked before the call; `-1` or the Array form: any) or `name => { args: [names], doc: "text" }` ([Describing the methods](#describing-the-methods)); `doc:` the object's own text. `ArgumentError` when `obj` has no such public method. Exposing a name again replaces it. `caller: :from` passes the calling node's ID to every method as the keyword `from:` and refuses a call that brings `from:` itself ([Who called](#who-called)). |
457
555
  | `Asterism.current_call` | `Asterism::CurrentCall` or nil | While an exposed method runs for a call: `from` (the calling node's ID), `verified` (`:claimed`, or `:local` for a call from this application), `object`, `method`. nil outside a call ([Who called](#who-called)). |
458
556
  | `Asterism.unexpose(name)` | true / false | |
459
557
  | `Asterism.poll` | true / false | Call from the update loop: polls Zenoh, answers the calls that came in, follows who is alive and calls the `on_join` / `on_leave` blocks. `false` once the connection is closed or lost. Cheap when nothing came (0.6.0): each queue is looked at by its count, and the list of nodes is made again only after a liveliness change. |
558
+ | `Asterism.on_app_join { \|node, app\| }` / `Asterism.on_app_leave { \|node, app\| }` | `Asterism` | An application of another node appeared (its token `asterism/<node>/<app>`) / is gone. A node may run several: each comes and goes on its own while the node stays. Told like `on_join` (from `Asterism.poll`; at connect for those there; all leave when the connection is lost); `off_app_join(proc)` / `off_app_leave(proc)`; CRuby `net.on_app_join` / `net.on_app_leave` |
460
559
  | `Asterism.on_join { \|node\| }` / `Asterism.on_leave { \|node\| }` | `Asterism` | Another node appeared (one of its applications or objects) / is gone (no application and no object left). The nodes there already join on the first polls after connecting; when the connection is lost, every node known until then leaves (on the next `Asterism.poll`). The blocks run from `Asterism.poll` only, never from the polling inside a waiting call; what they raise comes out of `Asterism.poll`. Each call adds a block; `Asterism.off_join(proc)` / `off_leave(proc)` remove one; `Asterism.close` forgets them (a lost connection does not). |
461
560
  | `Asterism[path, timeout: 2.0]` | `Proxy` | `path` is `<node>/<app>/<object>` without wildcards (`ArgumentError`). `timeout:` (seconds) or `timeout_ms:` is the time limit of its calls. Nothing is sent until a method is called. |
462
561
  | `proxy.<method>(*args, **kw)` | the remote return value | Waits for the answer (the proxy's time limit). `RemoteError` when it raised there or could not be called (`remote_kind` says which: `raised`, or not exposed `not_exposed` (`NoMethodError`), wrong number of arguments `arguments` (`ArgumentError`), no such object `no_object` (`NameError`); [Errors](#errors)), `TimeoutError` when no answer came in time (also at once when nobody answers that key), `Disconnected`, `EncodeError` (before anything is sent) for a value MessagePack cannot carry. A block cannot be sent (`ArgumentError`). |
@@ -468,12 +567,39 @@ Asterism.nodes # => ["linux", "fmruby-bbbbbb"]
468
567
  | `proxy.asterism_meta` / `proxy.asterism_path` | Hash, String | `{"methods" => [{"name" => name, "arity" => n}, ...]}` |
469
568
  | `Asterism.each(pattern = "**") { \|proxy\| }` | count (Array without a block) | The exposed objects alive now (this application's own included) whose `<node>/<app>/<object>` matches; `*` is one chunk, `**` any number. 1.0 returns the proxies from the block form instead of their number (Ruby's `each` returns its receiver); write `Asterism.each(pattern).size` for the count. |
470
569
  | `Asterism.nodes` | `[String]` | Node IDs alive now, this one first. |
570
+ | `Asterism.apps(pattern = "*/*")` | `[String]` | The applications alive now, `"<node>/<app>"` (from their liveliness tokens; this one first). |
471
571
  | `Asterism.connected?` / `node_id` / `app` / `exposed` / `lost_reason` | | |
472
572
  | `Asterism.reconnecting?` | true / false | True while the router is away and the binding opens the connection again ([When the connection is lost](#when-the-connection-is-lost)). |
473
573
  | `Asterism.session` | `Asterism::Zenoh::Session` | The connection's own session, for put / subscribe / a ROS 2 node on the same connection ([One connection](#one-connection-asterismsession)). The same object until the connection closes. `Disconnected` when not connected. Its `close` does nothing (warns once); `Asterism.close` closes it. |
474
574
  | `Asterism.close` | nil | Withdraws every exposed object. Idempotent. |
475
575
  | `Asterism.profile(kind:, name:, host:, icon:)` / `Asterism.profile` | Hash | This node's profile: sets the fields given and returns it; without arguments, returns it. See [Profiles](#profiles). |
476
576
  | `Asterism.profile_of(node, icon: true, timeout: 2.0)` | Hash or nil | Another node's profile, nil when it does not answer (an older version, a ROS 2 node). |
577
+ | `Asterism.status(hash = nil, **fields)` / `Asterism.status` | Hash or nil | This application's status: a Hash is the whole status, keywords change those fields (nil removes one); put on the network when it changes. Without arguments, returns it. See [Status](#status). |
578
+ | `Asterism.status_of(node, app, timeout: 2.0)` | Hash or nil | Another application's status, nil when it has none or does not answer. |
579
+
580
+ ## Describing the methods
581
+
582
+ The meta (what `proxy.respond_to?` reads) can carry the arguments' names
583
+ and what each method does, for the people and the tools that show them
584
+ (`asterism mcp` turns them into an AI assistant's tool inputs and
585
+ descriptions; `asterism objects` prints them). Give them at `expose`, next
586
+ to the numbers:
587
+
588
+ ```ruby
589
+ Asterism.expose("othello1", table, caller: :from, doc: "An othello table. Call view for the board.",
590
+ methods: { join: 1, # a number, as before
591
+ "move" => { args: %w[token n square],
592
+ doc: "Plays square (\"d3\") on the board version n of view." },
593
+ view: { args: [:token], doc: "The board as this member sees it." } })
594
+ ```
595
+
596
+ - The names give the number of arguments checked (`args: %w[a b]` is 2);
597
+ `arity: -1` keeps it unchecked (the names are then the leading ones).
598
+ - The texts are at most 2048 bytes, the names at most 16 of 64 bytes; the
599
+ first line of a doc is its summary. They travel in every meta reply, so
600
+ keep them short on a board.
601
+ - Old readers ignore them and old nodes do not send them
602
+ (docs/protocol.md 5); a proxy works the same either way.
477
603
 
478
604
  ## Profiles
479
605
 
@@ -528,6 +654,41 @@ Asterism.profile_of("an-older-node") # => nil
528
654
  an acceptable PNG (nil otherwise), for a tool that wants to check an icon
529
655
  first.
530
656
 
657
+ ## Status
658
+
659
+ An application tells what changes while it runs: how many tables a game
660
+ host has open and who sits at them, a battery level, a mode. The profile
661
+ says once what a node is; the status is the application's and changes.
662
+
663
+ ```ruby
664
+ Asterism.status(tables: 3, humans: 2) # sets those fields, keeps the others
665
+ Asterism.status(humans: nil) # nil removes a field
666
+ Asterism.status({ "mahjong1" => { "st" => "play", "humans" => 1 } }) # a Hash: the whole status
667
+ Asterism.status # => {"mahjong1" => {...}} (nil when none was set)
668
+ Asterism.status_of("games", "games") # another application's, or nil
669
+ ```
670
+
671
+ - **On the network.** `asterism/<node>/<app>/@status`
672
+ ([docs/protocol.md](docs/protocol.md) 8): the application answers a get
673
+ there with its status, and puts it there when it changes (setting the
674
+ same status again puts nothing), at connect and after a lost connection
675
+ comes back. An application that never set one answers nothing.
676
+ - **The value.** A Hash with String (or Symbol) keys; the values are
677
+ anything a call can carry, nested at most 4 levels below the Hash, and
678
+ the whole packs into 1024 bytes at most. `ArgumentError` otherwise.
679
+ Whoever may read `asterism/*/*/@status` reads it: counts and states, not
680
+ secrets.
681
+ - **Following.** Subscribe to `asterism/*/*/@status` and read each put with
682
+ `Asterism::Status.parse(key, payload)` (`[node, app, status]` or nil); on
683
+ CRuby, `net.on_status("*/*") { |node, app, status| }`. Only changes are
684
+ put: ask `status_of` once for an application that appeared.
685
+ - **Behind access control.** `@status` is a verbatim chunk: `asterism/**`
686
+ does not match it. A router's rules need `asterism/*/*/@status` for a
687
+ reader (query, subscription) and `asterism/<node>/*/@status` for the
688
+ application (put, queryable, reply).
689
+ - Kept across `connect` and `close`, like the profile (it belongs to the
690
+ process, or the VM on a board).
691
+
531
692
  ## One connection: `Asterism.session`
532
693
 
533
694
  An application that also puts, subscribes or runs a ROS 2 node uses the
data/exe/asterism ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # The command line of the asterism gem: look at an Asterism network, call
5
+ # an object, or serve it to an MCP client (lib/asterism/cli.rb; `asterism
6
+ # help`).
7
+ require "asterism/cli"
8
+ exit Asterism::CLI.start(ARGV)
@@ -0,0 +1,53 @@
1
+ # The --allow patterns of `asterism mcp` (lib/asterism/cli/mcp.rb): which
2
+ # methods an MCP client may call.
3
+ module Asterism
4
+ class CLI
5
+ # The --allow patterns: [node/][app/]object.method, each part a glob
6
+ # (* any text within the part). A pattern with fewer parts matches the
7
+ # last parts of the path ("games/othello1.*" is "*/games/othello1.*";
8
+ # "othello1" alone is "*/*/othello1.*": a pattern without .method
9
+ # allows every method).
10
+ class Allow
11
+ def initialize(patterns)
12
+ @list = patterns.map { parse(_1) }
13
+ end
14
+
15
+ def empty? = @list.empty?
16
+
17
+ # Whether some pattern names this object (any of its methods).
18
+ def object?(path)
19
+ parts = path.split("/")
20
+ @list.any? { |chunks, _| chunks_match?(chunks, parts) }
21
+ end
22
+
23
+ def method?(path, method)
24
+ parts = path.split("/")
25
+ @list.any? { |chunks, m| chunks_match?(chunks, parts) && glob?(m, method.to_s) }
26
+ end
27
+
28
+ private
29
+
30
+ def parse(pat)
31
+ slash = pat.rindex("/") || -1
32
+ dot = pat.index(".", slash + 1)
33
+ path = dot ? pat[0...dot] : pat
34
+ meth = dot ? pat[(dot + 1)..] : "*"
35
+ chunks = path.split("/", -1)
36
+ if chunks.empty? || chunks.size > 3 || chunks.any?(&:empty?) || meth.empty?
37
+ raise ArgumentError, "--allow #{pat.inspect}: expected [node/][app/]object.method (globs allowed)"
38
+ end
39
+ [Array.new(3 - chunks.size, "*") + chunks, meth]
40
+ end
41
+
42
+ def chunks_match?(chunks, parts)
43
+ parts.size == 3 && chunks.each_with_index.all? { |c, i| glob?(c, parts[i]) }
44
+ end
45
+
46
+ # Only * is a wildcard (any text); ? and the rest are themselves (a
47
+ # method may be called ok?).
48
+ def glob?(pat, text)
49
+ Regexp.new("\\A#{pat.split('*', -1).map { Regexp.escape(_1) }.join('.*')}\\z").match?(text)
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,132 @@
1
+ # The arguments of `asterism call` (and of the MCP bridge's tests): each is
2
+ # read as JSON, else as a Ruby literal, else taken as the text it is.
3
+ #
4
+ # Literal.parse('{"seat": 1}') # => {"seat" => 1} (JSON)
5
+ # Literal.parse("[1, :a, nil]") # => [1, :a, nil] (Ruby)
6
+ # Literal.parse("{seat: 1}") # => {seat: 1} (Ruby)
7
+ # Literal.parse("hello") # => "hello" (text)
8
+ #
9
+ # The Ruby form is read with Ripper and only literals are taken: nil,
10
+ # true, false, integers, floats, strings without interpolation, symbols,
11
+ # arrays (also %w / %i) and hashes. Nothing is evaluated: anything else (a
12
+ # method call, a constant, a variable) makes it not a literal, and the
13
+ # argument is then the text as written.
14
+ require "json"
15
+ require "ripper"
16
+
17
+ module Asterism
18
+ class CLI
19
+ module Literal
20
+ class NotLiteral < StandardError; end
21
+
22
+ module_function
23
+
24
+ # The value of one argument (see above).
25
+ def parse(text)
26
+ json(text)
27
+ rescue JSON::ParserError
28
+ begin
29
+ ruby(text)
30
+ rescue NotLiteral
31
+ text
32
+ end
33
+ end
34
+
35
+ # JSON: a value of any kind at the top ("1", "\"x\"", "null" too).
36
+ def json(text)
37
+ JSON.parse(text)
38
+ end
39
+
40
+ # A Ruby literal, or NotLiteral.
41
+ def ruby(text)
42
+ tree = Ripper.sexp(text)
43
+ raise NotLiteral, "not Ruby" unless tree && tree[0] == :program
44
+ body = tree[1]
45
+ raise NotLiteral, "one value expected" unless body.is_a?(Array) && body.size == 1
46
+ node(body[0])
47
+ end
48
+
49
+ def node(n)
50
+ raise NotLiteral, "unexpected #{n.inspect}" unless n.is_a?(Array)
51
+ case n[0]
52
+ when :@int then Integer(n[1].delete("_"), 0)
53
+ when :@float then Float(n[1].delete("_"))
54
+ when :unary
55
+ raise NotLiteral, "only - before a number" unless n[1] == :-@ && %i[@int @float].include?(n[2][0])
56
+ -node(n[2])
57
+ when :var_ref, :@kw then keyword(n[0] == :var_ref ? n[1] : n)
58
+ when :string_literal then string(n[1])
59
+ when :symbol_literal then symbol(n[1])
60
+ when :dyna_symbol then string(n[1]).to_sym
61
+ when :array then array(n[1])
62
+ when :hash then hash(n[1])
63
+ when :bare_assoc_hash then pairs(n[1])
64
+ else raise NotLiteral, "not a literal: #{n[0]}"
65
+ end
66
+ end
67
+
68
+ def keyword(kw)
69
+ raise NotLiteral, "not a keyword" unless kw[0] == :@kw
70
+ case kw[1]
71
+ when "nil" then nil
72
+ when "true" then true
73
+ when "false" then false
74
+ else raise NotLiteral, "keyword #{kw[1]}"
75
+ end
76
+ end
77
+
78
+ # string_content: plain parts only (no #{...}).
79
+ def string(content)
80
+ return "" if content.nil?
81
+ parts = content[0] == :string_content ? content[1..] : content
82
+ parts.map do |p|
83
+ raise NotLiteral, "interpolation" unless p.is_a?(Array) && p[0] == :@tstring_content
84
+ unescape(p[1])
85
+ end.join
86
+ end
87
+
88
+ # The escapes of a double-quoted string (\n, \t, \", \\, \u...).
89
+ def unescape(s)
90
+ return s unless s.include?("\\")
91
+ "\"#{s}\"".undump
92
+ rescue RuntimeError
93
+ s
94
+ end
95
+
96
+ def symbol(sym)
97
+ raise NotLiteral, "symbol" unless sym[0] == :symbol
98
+ sym[1][1].to_sym
99
+ end
100
+
101
+ def array(items)
102
+ return [] if items.nil?
103
+ items.map do |e|
104
+ if e.is_a?(Array) && e[0] == :@tstring_content # %w[...]
105
+ e[1]
106
+ elsif e.is_a?(Array) && %i[qwords_add words_add qsymbols_add symbols_add].include?(e[0])
107
+ raise NotLiteral, "old Ripper form"
108
+ elsif e.is_a?(Array) && e[0] == :@ident # %i[...]
109
+ e[1].to_sym
110
+ else
111
+ node(e)
112
+ end
113
+ end
114
+ end
115
+
116
+ def hash(list)
117
+ return {} if list.nil?
118
+ raise NotLiteral, "hash" unless list[0] == :assoclist_from_args
119
+ pairs(list[1])
120
+ end
121
+
122
+ def pairs(assocs)
123
+ assocs.to_h do |a|
124
+ raise NotLiteral, "not a key => value" unless a[0] == :assoc_new
125
+ k = a[1]
126
+ key = k[0] == :@label ? k[1].chomp(":").to_sym : node(k)
127
+ [key, node(a[2])]
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end