asterism 0.8.0 → 0.9.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: 6ee81a1950e5fffa0f5d299999d7878834a79d897e3b4cbf879e293fc6b00531
4
- data.tar.gz: 05a88c6ad24fafe262668cb11f4a48e7e5357678c1903207aae004ac0e518311
3
+ metadata.gz: 8c3c929e3018c538fab3983c9fffce4fd997bfa8b37cad43fbaa6c0eb5890d65
4
+ data.tar.gz: b4fc43d2d5d286139eb700c5711786827aab41aa8a7628686b45270a321f1807
5
5
  SHA512:
6
- metadata.gz: 4c6e015f5bd202a1cf44dc3d6ee59fe8af406cb899735df5ee828610cb82de5aa67035be2b5c9cbf1b4c603e0477eaba1e9e503f3ed901e81308ea1b98c91fbc
7
- data.tar.gz: 717902fdcce318aad8a78776f0f72b4063241868dbc8b50def6a0386f2868f764dbf356403b0ef1cbd727c5dc8df6818ced7d465cfb23338452e892d3e0c6dc1
6
+ metadata.gz: f971a84780f4a1ce1e24554530201f6bccaba7f8de6ec03c5b2dc6742c6c370e2567af5c82e4609fd82f582d32cbe314f82bc5c60d5815b5c33e805980b14a58
7
+ data.tar.gz: c355695ad9b52f7513f791191eeae8ded62011cdc011c50d1e921f6fa1e58190f491cc3adc0b367fa0087def3cfd6cd03729a43143b19f608b3f3a5603879f3c
data/CHANGELOG.md CHANGED
@@ -6,6 +6,50 @@ 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.9.0
10
+
11
+ The command line only; the library and the wire are as in 0.8.0 (0.8.1
12
+ was not published: its changes are here).
13
+
14
+ - The command line's commands are a noun and a verb, as ros2's are:
15
+ `node list` / `node info NODE`, `app list` / `app info NODE/APP`,
16
+ `object list [PATTERN]` / `object info PATH` / `object find METHOD`
17
+ (the objects that have a method, `*` as a wildcard), `status get` /
18
+ `status echo` / `status hz NODE/APP`. The lists print the names only, a
19
+ name a line, sorted (for grep and xargs). `asterism NOUN` lists its
20
+ verbs; `asterism --help` is grouped by noun. `call`, `ping`, `watch`,
21
+ `tree` and `mcp` are as they were, and the older `nodes`, `objects` and
22
+ `echo` stay with the same output. README "The command line".
23
+ - `status echo NODE/APP`: the status, then each change as it comes (the
24
+ text tells what changed, as `watch` does; `--once`: the status now,
25
+ once). `status hz NODE/APP`: how often the status changes, as
26
+ `ros2 topic hz` (the average rate and the intervals' min, max and
27
+ standard deviation, every `--period`; `--window`).
28
+ - `-o json` / `-o yaml` on every command that reads (YAML with the
29
+ standard library, the same content as the JSON). `--json` is `-o json`;
30
+ its output is as it was, byte for byte.
31
+ - Tab completion: `asterism completion bash` / `zsh` prints a script
32
+ (`source <(asterism completion bash)`). The commands, verbs and options
33
+ complete without the network; the nodes, `node/app`, `node/app/object`
34
+ (a part at a time) and an object's methods come from the network named
35
+ on the line or in the environment, asked for 1.5 s at most and
36
+ remembered for 20 s in `$XDG_CACHE_HOME/asterism/` (names only; never a
37
+ key or a certificate). README "Tab completion".
38
+ - The command line's text is easier to read (`--json` is unchanged): a
39
+ status is shown as indented `key=value` lines instead of one line of
40
+ JSON (`tree`, `nodes`, the first status of `watch`); `tree` shows how
41
+ many methods each object has and `tree --methods` lists them, wrapped to
42
+ the width; `objects` wraps the objects' and methods' descriptions to the
43
+ width in one column, the signatures in bold; `watch` shows only what
44
+ changed in a status (`~ status games/games mahjong1.members 0→1`, keys
45
+ that came with `+`, keys that went with `-`). On a terminal the text has
46
+ colours and the terminal's width; to a pipe or a file it is plain and 80
47
+ columns wide (or `COLUMNS`); `NO_COLOR` turns the colours off. Standard
48
+ library only. README "The command line".
49
+ - The command line takes `ASTERISM_CREDENTIALS` and `ASTERISM_ROUTER`
50
+ from the environment when none of `--router`, `--credentials`, `--peer`
51
+ is given (all or nothing). README "The command line".
52
+
9
53
  ## 0.8.0
10
54
 
11
55
  Needs asterism-zenoh 0.4.1 or later, as 0.7.1. Wire protocol 1 as before:
data/README.md CHANGED
@@ -101,7 +101,7 @@ build but not yet tested. To install from the repositories instead:
101
101
 
102
102
  ```
103
103
  (cd ../asterism-zenoh && rake gem) && rake gem
104
- gem install --local ../asterism-zenoh/pkg/asterism-zenoh-0.4.1.gem pkg/asterism-0.8.0.gem
104
+ gem install --local ../asterism-zenoh/pkg/asterism-zenoh-0.4.1.gem pkg/asterism-0.9.0.gem
105
105
  ```
106
106
 
107
107
  From the working trees, without installing:
@@ -130,7 +130,7 @@ rake # every suite
130
130
  rake test:msgs # generator, type hashes, CDR, bundled types (CRuby only; no Zenoh, no docker)
131
131
  rake test:objects # objects and Asterism::ROS between CRuby sessions over a local peer link
132
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)
133
+ rake test:cli # the command line (exe/asterism), its Tab completion and its MCP bridge (the mcp gem's client; skipped without it)
134
134
  ASTERISM_TEST_ROUTER=tcp/127.0.0.1:7447 rake test:objects test:api # the same through a zenohd router
135
135
  rake test:mruby # the shared layer on mruby (builds mruby; not part of `rake`)
136
136
  rake test:protocol # the wire protocol's conformance samples and driver against CRuby (docs/protocol.md)
@@ -415,31 +415,101 @@ What may be shared between threads:
415
415
  The gem has a command, `asterism`, that looks at a network from a terminal
416
416
  (CRuby). It is one more node: it connects, reads the liveliness tokens,
417
417
  the meta, the profiles and the statuses, and calls a method only when told
418
- to with `call`.
418
+ to with `call`. Its commands are a noun and a verb, as ros2's are
419
+ (`asterism node` alone lists the node's verbs, `asterism --help` all of
420
+ them):
419
421
 
420
422
  ```
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}'
423
+ asterism node list --router tcp/192.0.2.2:7447 # the nodes, a name a line
424
+ asterism node info games # one node: profile, apps, statuses, objects
425
+ asterism app list # node/app, a line each
426
+ asterism app info games/games # its status and its objects
427
+ asterism object list 'games/games/*' # node/app/object, a line each
428
+ asterism object info games/games/othello1 # its description and its methods
429
+ asterism object find join # the objects that have join (* as a wildcard)
430
+ asterism status get games/games # the status now
431
+ asterism status echo games/games # the status, then each change (--once: now, once)
432
+ asterism status hz games/games # how often it changes
433
+ asterism call games/games/othello1 join '"Alice"'
434
+ asterism call linux/demo/screen say hello --kwargs '{color: 3}'
435
+ asterism ping games/games/othello1 # round trips (its meta; no method runs)
436
+ asterism ping games/games/othello1 --call view '"<token>"' # round trips of a call
437
+ asterism watch # nodes, apps and objects coming and going, statuses
438
+ asterism tree --credentials admin.tar # nodes > apps > objects (--methods: the methods)
439
+ asterism completion bash # Tab completion (below)
429
440
  ```
430
441
 
442
+ The older commands stay, with the same output: `nodes` (every node with
443
+ its applications and statuses, in one listing), `objects [PATTERN]` (each
444
+ object with its description and methods) and `echo` (`ping`).
445
+
431
446
  ```
432
- $ asterism tree --router tcp/192.0.2.2:7447
447
+ $ export ASTERISM_ROUTER=tcp/192.0.2.2:7447
448
+ $ asterism node list
449
+ games
450
+ linux
451
+ $ asterism object list
452
+ games/games/mahjong1
453
+ games/games/othello1
454
+ linux/demo/screen
455
+ $ asterism app info games/games
456
+ games/games
457
+ status:
458
+ mahjong1: st=wait members=0 humans=0 cpus=0 ais=0 away=0
459
+ othello1: st=wait members=0 humans=0 cpus=0 away=0
460
+ objects:
461
+ mahjong1 18 methods
462
+ A riichi mahjong table (four seats). From a program: call describe first,
463
+ then join, sit, and look / choose in a loop.
464
+ othello1 11 methods
465
+ An othello table (two seats). join, sit, then view / move in a loop.
466
+ $ asterism object info games/games/othello1
467
+ games/games/othello1
468
+ An othello table (two seats). join, sit, then view / move in a loop.
469
+ join(name) Joins the table as name (or gets this node's
470
+ membership back). Returns {"token", ...}: every later
471
+ call brings the token. {"error" => why} when the table
472
+ is full.
473
+ mine() This node's membership ({"token", ...}), or nil when
474
+ it has not joined.
475
+ ...
476
+ $ asterism object find join
477
+ games/games/mahjong1
478
+ games/games/othello1
479
+ $ asterism status get games/games -o yaml
480
+ ---
481
+ mahjong1:
482
+ st: wait
483
+ members: 0
484
+ ...
485
+ $ asterism status echo games/games
486
+ 12:00:01.204 games/games (now)
487
+ mahjong1: st=wait members=0 humans=0 cpus=0 ais=0 away=0
488
+ othello1: st=wait members=0 humans=0 cpus=0 away=0
489
+ 12:00:05.120 mahjong1.members 0→1
490
+ 12:00:05.133 mahjong1.humans 0→1
491
+ $ asterism status hz games/games
492
+ average rate: 0.409 Hz
493
+ min: 0.013 s max: 4.876 s std dev: 2.432 s window: 2
494
+ $ asterism tree
433
495
  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) ...
496
+ games
497
+ status:
498
+ mahjong1: st=wait members=0 humans=0 cpus=0 ais=0 away=0
499
+ othello1: st=wait members=0 humans=0 cpus=0 away=0
500
+ mahjong1 18 methods
501
+ othello1 11 methods
436
502
  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)
503
+ demo
504
+ screen 3 methods
505
+ $ asterism watch
506
+ 12:00:01.204 ~ status games/games (now)
507
+ mahjong1: st=wait members=0 humans=0 cpus=0 ais=0 away=0
508
+ othello1: st=wait members=0 humans=0 cpus=0 away=0
440
509
  12:00:04.881 + node wasm-1a2b3c
441
510
  12:00:04.881 + app wasm-1a2b3c/game
442
- 12:00:05.120 ~ status games/games {"othello1":{"st":"wait","members":1,...}}
511
+ 12:00:05.120 ~ status games/games mahjong1.members 0→1
512
+ 12:00:05.133 ~ status games/games mahjong1.humans 0→1
443
513
  ```
444
514
 
445
515
  - **Where the network is.** `--router tcp/HOST:7447` (a router without TLS),
@@ -447,7 +517,12 @@ $ asterism watch --router tcp/192.0.2.2:7447
447
517
  and its name; `--router` overrides the router). `--peer LOCATOR` links
448
518
  straight to a node listening there (no router). The node ID is the
449
519
  certificate's name, otherwise `cli-` and 6 hex digits (`--node`); the
450
- application `cli-` and 4 (`--app`).
520
+ application `cli-` and 4 (`--app`). With none of `--router`,
521
+ `--credentials`, `--peer` on the line, `ASTERISM_CREDENTIALS` and
522
+ `ASTERISM_ROUTER` from the environment are used instead (all or nothing:
523
+ a router on the line never meets a `.tar` from the environment), so
524
+ `export ASTERISM_CREDENTIALS=~/me.tar` makes `asterism tree` enough, and
525
+ an MCP client can pass it in the server's environment.
451
526
  - **What it sees** is what the router lets it see. With a console's `.tar`
452
527
  that is the row's roles: a role for looking only is in the console's
453
528
  docs/g1_5.md ("Looking only: a viewer role"): it reads every node, object,
@@ -458,13 +533,60 @@ $ asterism watch --router tcp/192.0.2.2:7447
458
533
  literal (nil, true, false, numbers, strings, symbols, arrays, hashes; read
459
534
  with Ripper, nothing is evaluated), else the text as written. `--kwargs`
460
535
  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`
536
+ - **Lists** (`node list`, `app list`, `object list`, `object find`) print
537
+ the names only, a name a line, sorted, so they go to `grep` and `xargs`
538
+ as they are (on a terminal the parts are coloured; to a pipe they are
539
+ plain).
540
+ - **Output**: text, or `-o json` / `-o yaml` on every command that reads
541
+ (the YAML holds what the JSON does; `--json` is `-o json`, its output as
542
+ it always was). The streams (`watch`, `status echo`, `status hz`) give a
543
+ JSON object a line, or a YAML document each (`---`), with the time. The
544
+ text is for people: on a terminal it has colours (nodes, applications,
545
+ objects, the `+` `-` `~` of watch; `NO_COLOR=1` turns them off) and is
546
+ wrapped to the terminal's width; written to a pipe or a file it is plain
547
+ and 80 columns wide (or `COLUMNS`). A status is shown as `key=value`, a
548
+ line for each level that holds Hashes (a long text or array is cut with
549
+ `…`; `-o json` has it whole). `tree` tells how many methods an object
550
+ has, `tree --methods` lists them; `object info` (and `objects`) lists
551
+ each method with its description. `watch` and `status echo` show a
552
+ status whole the first time (and when its application comes back), then
553
+ only what changed: `a.b 0→1`, `+a.c=…` (a key came), `-a.d` (a key
554
+ went). `--timeout` (2.0 s) is each get's and call's limit, `--settle`
463
555
  (1.5 s) how long it waits for the tokens after connecting.
556
+ - **status hz** counts the changes of the status (a put of the same status
557
+ again is not one) and prints, every `--period` (1 s) in which a change
558
+ came, the average rate and the intervals' min, max and standard
559
+ deviation over the last `--window` (1000) intervals, as `ros2 topic hz`
560
+ does. An application puts its status when it changes, so this is how
561
+ often the application has something new to tell.
464
562
  - **watch** shows the applications and objects themselves (their tokens),
465
563
  not only the nodes: a node with two applications stays while one closes,
466
564
  and `watch` shows that one leave.
467
565
 
566
+ ### Tab completion
567
+
568
+ ```
569
+ source <(asterism completion bash) # or put this line in ~/.bashrc
570
+ source <(asterism completion zsh) # zsh, after compinit (~/.zshrc)
571
+ ```
572
+
573
+ The commands, the verbs and the options complete without the network. The
574
+ names on the network complete too: the nodes, `node/app`, `node/app/object`
575
+ a part at a time (`ga<Tab>` gives `games/`, `games/games/<Tab>` the
576
+ objects), and after `call PATH` (or `ping PATH --call`) the object's
577
+ methods. On each Tab the script runs `asterism __complete`, which asks the
578
+ network named on the line (`--router`, `--credentials`, `--peer`) or in
579
+ `ASTERISM_CREDENTIALS` / `ASTERISM_ROUTER`:
580
+
581
+ - briefly: what does not answer within 1.5 s gives no candidates, and the
582
+ shell is not kept waiting;
583
+ - remembered for 20 s, a file per network in `$XDG_CACHE_HOME/asterism/`
584
+ (`~/.cache/asterism/`; the directory 0700, the files 0600), named by a
585
+ digest of where the network is. It holds the names only, never a key or
586
+ a certificate. What did not answer is not remembered;
587
+ - as one more node for a moment (a `cli-` node, or the certificate's name),
588
+ so `asterism watch` shows it come and go.
589
+
468
590
  ### MCP: Asterism as tools of an AI assistant
469
591
 
470
592
  `asterism mcp` is an MCP server on stdio (it needs the `mcp` gem, which
@@ -0,0 +1,282 @@
1
+ # The shell's Tab completion: `asterism completion bash|zsh` prints a
2
+ # script, and the script calls the hidden `asterism __complete` on each
3
+ # Tab, which prints the candidates, one a line.
4
+ #
5
+ # The commands, the verbs and the options come from the command line's own
6
+ # tables (no network). The names on the network (nodes, node/app,
7
+ # node/app/object, an object's methods) come from the network named on the
8
+ # line (--router, --credentials, --peer) or in the environment, asked
9
+ # briefly (Complete::BUDGET: nothing comes back when it does not answer in
10
+ # time, the shell is not kept waiting) and remembered for a few seconds
11
+ # (Cache: a file per network under $XDG_CACHE_HOME/asterism, named by a
12
+ # digest of where the network is; it holds names only, never a key or a
13
+ # certificate). A name with "/" is completed a part at a time:
14
+ # `ga<Tab>` gives `games/`, `games/games/<Tab>` the objects.
15
+ require "json"
16
+ require "digest"
17
+ require "fileutils"
18
+ require "shellwords"
19
+
20
+ module Asterism
21
+ class CLI
22
+ class Complete
23
+ # Seconds the network may take to answer one Tab (connecting, the
24
+ # tokens, a meta get).
25
+ BUDGET = 1.5
26
+ # What a line of the output means "complete a file name here".
27
+ FILES = ":files"
28
+
29
+ # spec: the CLI (its command tables and option lists); source: what
30
+ # the network names (#names, #methods(path), #all_methods), each
31
+ # nil when it did not answer.
32
+ def initialize(spec, source)
33
+ @spec = spec
34
+ @source = source
35
+ end
36
+
37
+ # The candidates for the last word of words (the words after
38
+ # `asterism`, the last one the word being completed, "" for a new
39
+ # one), those that start with it, sorted.
40
+ def candidates(words)
41
+ words = words.dup
42
+ cur = words.pop || ""
43
+ pos = []
44
+ prev = nil
45
+ words.each do |w|
46
+ if prev
47
+ prev = nil
48
+ next
49
+ end
50
+ if w.start_with?("-") && w.size > 1
51
+ prev = w if @spec.option_takes_value?(w)
52
+ next
53
+ end
54
+ pos << w
55
+ end
56
+ key, args = @spec.command_key(pos)
57
+ list =
58
+ if prev then values(prev, key, args)
59
+ elsif cur.start_with?("-") then key ? @spec.options_for(key) : []
60
+ else positional(pos, key, args, cur)
61
+ end
62
+ return list if list == [FILES]
63
+ list.select { _1.start_with?(cur) }.uniq.sort
64
+ end
65
+
66
+ private
67
+
68
+ def values(opt, key, args)
69
+ case opt
70
+ when "-o", "--output" then %w[json yaml]
71
+ when "--credentials" then [FILES]
72
+ when "--call" then key == "ping" && args[0] ? methods_of(args[0]) : []
73
+ else []
74
+ end
75
+ end
76
+
77
+ def positional(pos, key, args, cur)
78
+ return @spec.command_words if pos.empty?
79
+ return @spec.verbs(pos[0]) if pos.size == 1 && @spec.noun?(pos[0])
80
+ return [] unless key
81
+ i = args.size
82
+ case [key, i]
83
+ when ["node info", 0] then nodes
84
+ when ["app info", 0], ["status get", 0], ["status echo", 0], ["status hz", 0] then staged(cur, apps, 2)
85
+ when ["object list", 0], ["object info", 0], ["objects", 0], ["call", 0] then staged(cur, objects, 3)
86
+ when ["ping", 0], ["echo", 0] then (cur.include?("/") ? [] : nodes) + staged(cur, objects, 3)
87
+ when ["object find", 0] then @source.all_methods || []
88
+ when ["call", 1] then methods_of(args[0])
89
+ when ["completion", 0] then %w[bash zsh]
90
+ when ["help", 0] then @spec.nouns
91
+ else []
92
+ end
93
+ end
94
+
95
+ def methods_of(path)
96
+ path.count("/") == 2 ? (@source.methods(path) || []) : []
97
+ end
98
+
99
+ def names
100
+ @names ||= @source.names || { "apps" => [], "objects" => [] }
101
+ end
102
+
103
+ def objects
104
+ names["objects"]
105
+ end
106
+
107
+ # The applications, with those that only objects tell of.
108
+ def apps
109
+ (names["apps"] + objects.map { _1.split("/")[0, 2].join("/") }).uniq
110
+ end
111
+
112
+ def nodes
113
+ (apps + objects).map { _1.split("/")[0] }.uniq
114
+ end
115
+
116
+ # The next part of the paths (depth parts each) that start with cur:
117
+ # the parts up to the one cur is in, with "/" after it when more come.
118
+ def staged(cur, paths, depth)
119
+ k = cur.count("/")
120
+ return [] if k >= depth
121
+ paths.filter_map do |p|
122
+ s = p.split("/")
123
+ next if s.size < depth
124
+ c = s[0..k].join("/")
125
+ c += "/" if k + 1 < depth
126
+ c if c.start_with?(cur)
127
+ end
128
+ end
129
+
130
+ public
131
+
132
+ # The words of a command line up to the cursor (bash's COMP_LINE cut
133
+ # at COMP_POINT): the words after the command, the last one the word
134
+ # being completed ("" after a space). An open quote is closed.
135
+ def self.split_line(line)
136
+ words = begin
137
+ Shellwords.split(line)
138
+ rescue ArgumentError
139
+ begin
140
+ Shellwords.split("#{line}\"")
141
+ rescue ArgumentError
142
+ Shellwords.split("#{line}'")
143
+ end
144
+ end
145
+ words << "" if line.match?(/\s\z/) || words.empty?
146
+ words.drop(1)
147
+ end
148
+
149
+ # Bash splits the word being completed at ":" and "=" (COMP_WORDBREAKS):
150
+ # the candidates without what comes before the last of them in cur.
151
+ def self.bash_trim(cands, cur)
152
+ cut = cur.rindex(/[:=]/)
153
+ return cands unless cut
154
+ cands.map { _1.start_with?(cur[0..cut]) ? _1[(cut + 1)..] : _1 }
155
+ end
156
+
157
+ # ------------------------------------------------------------ the cache
158
+
159
+ # What __complete remembers of a network for a few seconds: a JSON
160
+ # file per network ({entry => {"t" => seconds, "v" => value}}), the
161
+ # directory 0700 and the files 0600. The network's identity is a
162
+ # digest of where it is (router, the credentials file's path, peer);
163
+ # nothing else about it is written.
164
+ class Cache
165
+ TTL = 20
166
+
167
+ def initialize(dir:, network:, ttl: TTL, clock: -> { Time.now.to_f })
168
+ @dir = dir
169
+ @ttl = ttl
170
+ @clock = clock
171
+ id = network.map { _1.to_s }.join("\n")
172
+ @file = File.join(dir, "#{Digest::SHA256.hexdigest(id)[0, 24]}.json")
173
+ end
174
+
175
+ attr_reader :file
176
+
177
+ # The cache directory: $XDG_CACHE_HOME/asterism, else
178
+ # ~/.cache/asterism.
179
+ def self.dir(env)
180
+ base = env["XDG_CACHE_HOME"].to_s
181
+ base = File.join(Dir.home, ".cache") if base.empty?
182
+ File.join(base, "asterism")
183
+ rescue ArgumentError
184
+ nil
185
+ end
186
+
187
+ # The entry's value when it is fresh, else the block's (kept unless
188
+ # it is nil: what did not answer is asked again next time).
189
+ def fetch(entry)
190
+ data = read
191
+ e = data[entry]
192
+ now = @clock.call
193
+ return e["v"] if e.is_a?(Hash) && e["t"].is_a?(Numeric) && now - e["t"] < @ttl && now >= e["t"]
194
+ v = yield
195
+ return v if v.nil?
196
+ data = read.reject { |_, x| !x.is_a?(Hash) || !x["t"].is_a?(Numeric) || now - x["t"] >= @ttl }
197
+ data[entry] = { "t" => now, "v" => v }
198
+ write(data)
199
+ v
200
+ end
201
+
202
+ private
203
+
204
+ def read
205
+ h = JSON.parse(File.read(@file))
206
+ h.is_a?(Hash) ? h : {}
207
+ rescue StandardError
208
+ {}
209
+ end
210
+
211
+ def write(data)
212
+ FileUtils.mkdir_p(@dir, mode: 0o700)
213
+ tmp = "#{@file}.#{Process.pid}.tmp"
214
+ File.open(tmp, File::WRONLY | File::CREAT | File::TRUNC, 0o600) { _1.write(JSON.generate(data)) }
215
+ File.rename(tmp, @file)
216
+ rescue StandardError
217
+ begin
218
+ File.unlink(tmp) if tmp
219
+ rescue StandardError
220
+ nil
221
+ end
222
+ nil
223
+ end
224
+ end
225
+
226
+ # ------------------------------------------------------------ the scripts
227
+
228
+ BASH = <<~'BASH'
229
+ # asterism's Tab completion for bash: source <(asterism completion bash)
230
+ # (or put that line in ~/.bashrc). Each Tab runs `asterism __complete`;
231
+ # the names on the network come from the network named on the line
232
+ # (--router, --credentials, --peer) or in ASTERISM_CREDENTIALS /
233
+ # ASTERISM_ROUTER, asked briefly and remembered for a few seconds.
234
+ _asterism() {
235
+ local cur=${COMP_WORDS[COMP_CWORD]}
236
+ local IFS=$'\n'
237
+ local out
238
+ out=$(asterism __complete --bash "${COMP_LINE:0:COMP_POINT}" 2>/dev/null)
239
+ if [[ $out == ":files" ]]; then
240
+ compopt -o filenames 2>/dev/null
241
+ COMPREPLY=($(compgen -f -- "$cur"))
242
+ return 0
243
+ fi
244
+ COMPREPLY=($out)
245
+ local c
246
+ for c in "${COMPREPLY[@]}"; do
247
+ if [[ $c == */ ]]; then
248
+ compopt -o nospace 2>/dev/null
249
+ break
250
+ fi
251
+ done
252
+ return 0
253
+ }
254
+ complete -F _asterism asterism
255
+ BASH
256
+
257
+ ZSH = <<~'ZSH'
258
+ # asterism's Tab completion for zsh: source <(asterism completion zsh)
259
+ # after compinit (or put that line in ~/.zshrc). Each Tab runs
260
+ # `asterism __complete`; the names on the network come from the network
261
+ # named on the line (--router, --credentials, --peer) or in
262
+ # ASTERISM_CREDENTIALS / ASTERISM_ROUTER, asked briefly and remembered
263
+ # for a few seconds.
264
+ _asterism() {
265
+ local -a cands slash plain
266
+ cands=("${(@f)$(asterism __complete -- "${(@)words[2,CURRENT]}" 2>/dev/null)}")
267
+ cands=(${cands:#})
268
+ if [[ ${cands[1]} == ":files" ]]; then
269
+ _files
270
+ return
271
+ fi
272
+ slash=(${(M)cands:#*/})
273
+ plain=(${cands:#*/})
274
+ (( ${#plain} )) && compadd -- "${plain[@]}"
275
+ (( ${#slash} )) && compadd -S '' -- "${slash[@]}"
276
+ return 0
277
+ }
278
+ compdef _asterism asterism
279
+ ZSH
280
+ end
281
+ end
282
+ end
@@ -0,0 +1,77 @@
1
+ # `asterism status hz`: how often an application's status changes, as
2
+ # `ros2 topic hz` tells how often a topic is published. Each change is a
3
+ # time (a monotonic clock, given or Process.clock_gettime); the intervals
4
+ # between the last ones (a window of them) give the average rate and the
5
+ # intervals' min, max and standard deviation. A put that brings the same
6
+ # status again (as after a reconnection) is not a change.
7
+ module Asterism
8
+ class CLI
9
+ class Hz
10
+ attr_reader :count
11
+
12
+ # window: how many of the last intervals count. clock: a callable
13
+ # that returns the time in seconds (monotonic).
14
+ def initialize(window: 1000, clock: nil)
15
+ @window = window
16
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
17
+ @times = []
18
+ @last = nil
19
+ @seeded = false
20
+ @count = 0
21
+ @reported = 0
22
+ end
23
+
24
+ # The status there at the start: what the first put is compared with
25
+ # (not a change itself).
26
+ def seed(status)
27
+ @last = status
28
+ @seeded = true
29
+ end
30
+
31
+ # A put of the status: a change (true) when it differs from the last
32
+ # one seen (the first put without a seed is one).
33
+ def put(status)
34
+ return false if @seeded && status == @last
35
+ @last = status
36
+ @seeded = true
37
+ add(@clock.call)
38
+ true
39
+ end
40
+
41
+ # A change at time t (seconds).
42
+ def add(t)
43
+ @count += 1
44
+ @times << t
45
+ @times.shift while @times.size > @window + 1
46
+ end
47
+
48
+ # {"rate_hz", "min_s", "max_s", "std_s", "window", "count"} over the
49
+ # intervals in the window, or nil before two changes.
50
+ def stats
51
+ return nil if @times.size < 2
52
+ ints = @times.each_cons(2).map { |a, b| b - a }
53
+ avg = ints.sum / ints.size
54
+ var = ints.sum { (_1 - avg)**2 } / ints.size
55
+ { "rate_hz" => avg.positive? ? 1.0 / avg : nil, "min_s" => ints.min, "max_s" => ints.max,
56
+ "std_s" => Math.sqrt(var), "window" => ints.size, "count" => @count }
57
+ end
58
+
59
+ # stats when a change came since the last report (else nil): what a
60
+ # period prints.
61
+ def report
62
+ return nil if @count == @reported
63
+ s = stats
64
+ @reported = @count if s
65
+ s
66
+ end
67
+
68
+ # The lines of a report, as ros2 topic hz prints them.
69
+ def self.text(s)
70
+ rate = s["rate_hz"] ? format("%.3f", s["rate_hz"]) : "inf"
71
+ ["average rate: #{rate} Hz",
72
+ format(" min: %.3f s max: %.3f s std dev: %.3f s window: %d", s["min_s"], s["max_s"], s["std_s"],
73
+ s["window"])]
74
+ end
75
+ end
76
+ end
77
+ end