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 +4 -4
- data/CHANGELOG.md +78 -0
- data/README.md +208 -8
- data/exe/asterism +8 -0
- data/lib/asterism/cli/allow.rb +53 -0
- data/lib/asterism/cli/literal.rb +132 -0
- data/lib/asterism/cli/mcp.rb +272 -0
- data/lib/asterism/cli/survey.rb +204 -0
- data/lib/asterism/cli.rb +424 -0
- data/lib/asterism/cruby/credentials.rb +97 -0
- data/lib/asterism/cruby/objects.rb +72 -10
- data/lib/asterism/cruby/ros.rb +1 -1
- data/lib/asterism/cruby/zenoh.rb +13 -2
- data/lib/asterism/version.rb +1 -1
- data/lib/asterism.rb +2 -2
- data/mrblib/asterism.rb +242 -24
- data/mrblib/credentials.rb +197 -0
- data/mrblib/profile.rb +1 -1
- data/mrblib/status.rb +198 -0
- metadata +22 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6ee81a1950e5fffa0f5d299999d7878834a79d897e3b4cbf879e293fc6b00531
|
|
4
|
+
data.tar.gz: 05a88c6ad24fafe262668cb11f4a48e7e5357678c1903207aae004ac0e518311
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
| `
|
|
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 (
|
|
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.
|
|
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
|
|
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
|
-
|
|
670
|
-
(
|
|
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
|