asterism 0.7.0 → 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: 54a2e8d7f4b96b04dc56b290714d60a4a8d10cdc87d789ccf01bbb2b474f0459
4
- data.tar.gz: be9f2f6c820e67ea513ea19a3d6f88437589cb2020615bc558b553f83e882673
3
+ metadata.gz: 6ee81a1950e5fffa0f5d299999d7878834a79d897e3b4cbf879e293fc6b00531
4
+ data.tar.gz: 05a88c6ad24fafe262668cb11f4a48e7e5357678c1903207aae004ac0e518311
5
5
  SHA512:
6
- metadata.gz: cb166817303dfd933dd1fd6a4444aecd966ce06b66347a4b058759c49dc0b0cd1397bbef60d05753c72ccf511a8f70ea5c49c8ffdfaa591eb2d2cf2255d8b1da
7
- data.tar.gz: d0d0549e336a50af534813aa4ab74c068952bf3d43148cc7c2032c0f4447df0aae73c9d1d813f8aa99819310c13ae3e220d59376276195fed408506b34144399
6
+ metadata.gz: 4c6e015f5bd202a1cf44dc3d6ee59fe8af406cb899735df5ee828610cb82de5aa67035be2b5c9cbf1b4c603e0477eaba1e9e503f3ed901e81308ea1b98c91fbc
7
+ data.tar.gz: 717902fdcce318aad8a78776f0f72b4063241868dbc8b50def6a0386f2868f764dbf356403b0ef1cbd727c5dc8df6818ced7d465cfb23338452e892d3e0c6dc1
data/CHANGELOG.md CHANGED
@@ -6,6 +6,84 @@ 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
+
65
+ ## 0.7.1
66
+
67
+ Needs asterism-zenoh 0.4.1 or later (`Session#reconnecting?`).
68
+
69
+
70
+ - `credentials:` on CRuby (`Asterism.connect`, `Asterism::Zenoh.open`,
71
+ `Asterism::ROS.connect`): the one `.tar` an Asterism console's
72
+ certificate page saves (key, certificate, CA, `asterism.json`). It is read
73
+ in memory (`Asterism::Credentials`, no temporary files) and handed to
74
+ zenoh as `transport/link/tls/*_base64` with `enable_mtls`; the locator and
75
+ `node:` default to the router and the name in `asterism.json` (given ones
76
+ win). A tar the group or others may read is warned about. `rake
77
+ test:credentials` (docker) connects two nodes to a mutual-TLS zenohd with
78
+ a tar each.
79
+
80
+ - A client of a router keeps its connection across a router restart: the
81
+ bindings reopen it and declare everything again, and Asterism no longer
82
+ closes the connection on a Zenoh error while the session is reconnecting.
83
+ `Asterism.reconnecting?` (and `net.reconnecting?`) is true meanwhile; the
84
+ profile is told again once it is back. `rake test:reconnect` (docker)
85
+ restarts a zenohd under a CRuby and an mruby node.
86
+
9
87
  ## 0.7.0
10
88
 
11
89
  **This release breaks compatibility: it speaks wire protocol 1 only.** A
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) |
@@ -50,6 +51,9 @@ together. It adds who called (`Asterism.current_call`, `expose(...,
50
51
  caller: :from)`; [Who called](#who-called)), the kind of a remote error
51
52
  (`RemoteError#remote_kind`) and `proxy.asterism_call`.
52
53
 
54
+ A mechanism-by-mechanism comparison with dRuby and Rinda is in
55
+ [docs/comparison.md](docs/comparison.md).
56
+
53
57
  ## Which API?
54
58
 
55
59
  Asterism has two APIs over the same layers. Both are in the CRuby gem;
@@ -84,7 +88,7 @@ time is taken, on both APIs and every Ruby; giving one twice raises
84
88
  CRuby (needs CRuby 3.2+ and a C compiler):
85
89
 
86
90
  ```
87
- gem install asterism # also installs asterism-zenoh (~> 0.4.0) and msgpack
91
+ gem install asterism # also installs asterism-zenoh (>= 0.4.1, < 1.0) and msgpack
88
92
  ruby -e 'require "asterism"'
89
93
  ```
90
94
 
@@ -97,7 +101,7 @@ build but not yet tested. To install from the repositories instead:
97
101
 
98
102
  ```
99
103
  (cd ../asterism-zenoh && rake gem) && rake gem
100
- gem install --local ../asterism-zenoh/pkg/asterism-zenoh-0.4.0.gem pkg/asterism-0.7.0.gem
104
+ gem install --local ../asterism-zenoh/pkg/asterism-zenoh-0.4.1.gem pkg/asterism-0.8.0.gem
101
105
  ```
102
106
 
103
107
  From the working trees, without installing:
@@ -126,6 +130,7 @@ rake # every suite
126
130
  rake test:msgs # generator, type hashes, CDR, bundled types (CRuby only; no Zenoh, no docker)
127
131
  rake test:objects # objects and Asterism::ROS between CRuby sessions over a local peer link
128
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)
129
134
  ASTERISM_TEST_ROUTER=tcp/127.0.0.1:7447 rake test:objects test:api # the same through a zenohd router
130
135
  rake test:mruby # the shared layer on mruby (builds mruby; not part of `rake`)
131
136
  rake test:protocol # the wire protocol's conformance samples and driver against CRuby (docs/protocol.md)
@@ -160,7 +165,7 @@ ruby examples/node_polled.rb --router tcp/192.0.2.2:7447 # the same, polled
160
165
  ruby examples/ros2_talker.rb --router tcp/192.0.2.2:7447 [--service NAME]
161
166
 
162
167
  # ROS 2: drive a robot (made for family-mruby's MuJoCo rover,
163
- # 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,
164
169
  # compared with /ground_truth/odom; or the keyboard
165
170
  ruby examples/ros2_rover.rb --router tcp/192.0.2.2:7447 [--keys]
166
171
 
@@ -290,6 +295,35 @@ Asterism.connect("tls/router.example.org:7448", node: "cruby", app: "demo", conf
290
295
  end
291
296
  ```
292
297
 
298
+ With an Asterism console, the certificate page saves everything as one
299
+ file ("Save all (.tar)": the key, the certificate, the CA, an example
300
+ configuration and `asterism.json` with the name and the router). Connect
301
+ with that one file:
302
+
303
+ ```ruby
304
+ Asterism.connect(credentials: "alice.tar", app: "game") do |net|
305
+ net["fmruby-aaaaaa/game/board"].state
306
+ end
307
+ Asterism::Zenoh.open(credentials: "alice.tar") { |s| s.put("asterism/alice/game/hello", "hi") }
308
+ Asterism::ROS.connect(credentials: "alice.tar") { |ros| ros.node("ruby_talker") }
309
+ ```
310
+
311
+ The gem reads the tar in memory (nothing is written; `Asterism::Credentials`)
312
+ and gives zenoh the CA, the certificate and the key as
313
+ `transport/link/tls/root_ca_certificate_base64`,
314
+ `connect_certificate_base64` and `connect_private_key_base64`, with
315
+ `enable_mtls`. The locator defaults to the router in `asterism.json` and
316
+ `node:` to the certificate's name; give either to use another. `config:`
317
+ (a Hash) is laid over these keys. A tar that the group or others may read
318
+ is warned about, as ssh does with a key: `chmod 600 alice.tar`.
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
+
293
327
  The router checks the certificate against its CA, and its ACL can tell the
294
328
  nodes apart by the certificate's common name. The name in the locator must
295
329
  be one the router's certificate is valid for (zenoh checks it; it is
@@ -376,6 +410,95 @@ What may be shared between threads:
376
410
  | The boards | one thread (the application's update loop) |
377
411
  | Ractors | not supported |
378
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
+
379
502
  ## License
380
503
 
381
504
  MIT (LICENSE) for Asterism's code. The ROS 2 definitions in
@@ -427,11 +550,12 @@ Asterism.nodes # => ["linux", "fmruby-bbbbbb"]
427
550
 
428
551
  | Call | Returns | Raises / notes |
429
552
  |---|---|---|
430
- | `Asterism.connect(locator, node:, app:, mode: nil, listen: nil, config: nil, connect_timeout: nil, check_timeout: 1.5)` | `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). |
431
- | `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)). |
432
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)). |
433
556
  | `Asterism.unexpose(name)` | true / false | |
434
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` |
435
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). |
436
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. |
437
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`). |
@@ -443,11 +567,39 @@ Asterism.nodes # => ["linux", "fmruby-bbbbbb"]
443
567
  | `proxy.asterism_meta` / `proxy.asterism_path` | Hash, String | `{"methods" => [{"name" => name, "arity" => n}, ...]}` |
444
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. |
445
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). |
446
571
  | `Asterism.connected?` / `node_id` / `app` / `exposed` / `lost_reason` | | |
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)). |
447
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. |
448
574
  | `Asterism.close` | nil | Withdraws every exposed object. Idempotent. |
449
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). |
450
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.
451
603
 
452
604
  ## Profiles
453
605
 
@@ -502,6 +654,41 @@ Asterism.profile_of("an-older-node") # => nil
502
654
  an acceptable PNG (nil otherwise), for a tool that wants to check an icon
503
655
  first.
504
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
+
505
692
  ## One connection: `Asterism.session`
506
693
 
507
694
  An application that also puts, subscribes or runs a ROS 2 node uses the
@@ -666,8 +853,21 @@ a field, as before.
666
853
 
667
854
  ### When the connection is lost
668
855
 
669
- Asterism follows `Asterism::Zenoh`: the connection is closed when the router
670
- (or the only peer) goes away, and is not reopened by itself. From then on
856
+ A client of a router keeps its connection when the router goes away for a
857
+ while (it restarts, the line drops): the Zenoh binding opens the connection
858
+ again by itself (zenoh-c on CRuby; zenoh-pico built with reconnection on
859
+ the boards) and declares again everything that was declared, so the
860
+ application token, the exposed objects, the liveliness watch and what the
861
+ application declared on `Asterism.session` are all back without the
862
+ application doing anything. Meanwhile `Asterism.reconnecting?` is true,
863
+ `connected?` stays true, the other nodes leave (`on_leave`) and join again
864
+ after it (`on_join`), calls raise `Asterism::Disconnected` or time out, and
865
+ puts reach nobody. The profile is told again once it is back. Measured
866
+ with zenohd 1.10.1 restarted by `docker restart` (`rake test:reconnect`,
867
+ CRuby and mruby): back about 1 s after the router.
868
+
869
+ A peer connection (or a build without reconnection) is closed when the
870
+ peer (or the router) goes away, and is not reopened by itself. From then on
671
871
  `Asterism.poll` returns `false`, `connected?` is false, `lost_reason` says
672
872
  why, and calls raise `Asterism::Disconnected`. **`Asterism::Zenoh::Error`
673
873
  never comes out of Asterism; it is always wrapped in `Disconnected`** (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