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
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# `asterism mcp`: Asterism as tools of an MCP client (an AI assistant),
|
|
2
|
+
# over stdio, with the mcp gem (an optional dependency: `gem install mcp`;
|
|
3
|
+
# the rest of the command line does not need it).
|
|
4
|
+
#
|
|
5
|
+
# asterism mcp --router tcp/192.0.2.2:7447 --allow 'games/othello1.*'
|
|
6
|
+
#
|
|
7
|
+
# Tools:
|
|
8
|
+
# asterism_nodes the nodes, their applications (with their statuses)
|
|
9
|
+
# and objects (always; reads)
|
|
10
|
+
# asterism_objects the objects and their methods (meta) (always; reads)
|
|
11
|
+
# asterism_status the applications' statuses (always; reads)
|
|
12
|
+
# <node>.<app>.<object>.<method>
|
|
13
|
+
# one per method that an --allow pattern names, made
|
|
14
|
+
# from the object's meta: the arguments' names the
|
|
15
|
+
# meta tells (docs/protocol.md 5) are the inputs and
|
|
16
|
+
# its doc the description; without names, arity n
|
|
17
|
+
# gives arg1 .. argN (any JSON value), arity -1 an
|
|
18
|
+
# array "args"; "kwargs" (an object) is the keyword
|
|
19
|
+
# arguments. Nothing is callable without --allow.
|
|
20
|
+
#
|
|
21
|
+
# The call tools follow the network: when objects appear or go, the list is
|
|
22
|
+
# made again and the client is told (notifications/tools/list_changed).
|
|
23
|
+
require "mcp"
|
|
24
|
+
|
|
25
|
+
module Asterism
|
|
26
|
+
class CLI
|
|
27
|
+
class MCPBridge
|
|
28
|
+
REFRESH_S = 2.0
|
|
29
|
+
INSTRUCTIONS = <<~TEXT.freeze
|
|
30
|
+
These tools look at an Asterism network: Ruby programs and boards that expose objects to each other over
|
|
31
|
+
Zenoh. Paths are <node>/<app>/<object>. asterism_nodes lists the nodes and their applications (with the
|
|
32
|
+
status each application tells about itself, a JSON object of its own), asterism_objects the objects and
|
|
33
|
+
their methods, asterism_status the statuses. The other tools call one method each, on the object their
|
|
34
|
+
name says (<node>.<app>.<object>.<method>); only the methods this server was started to allow are there.
|
|
35
|
+
A call's answer is {"value": ...}; an error says its kind (raised, not_exposed, arguments, ...).
|
|
36
|
+
TEXT
|
|
37
|
+
|
|
38
|
+
def initialize(net, survey, allow:, log: $stderr)
|
|
39
|
+
@net = net
|
|
40
|
+
@survey = survey
|
|
41
|
+
@allow = Allow.new(allow)
|
|
42
|
+
@log = log
|
|
43
|
+
@call_tools = {} # tool name => [path, method, arity, meta entry]
|
|
44
|
+
@signature = nil
|
|
45
|
+
@lock = Mutex.new
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def run
|
|
49
|
+
@server = ::MCP::Server.new(name: "asterism", version: ::Asterism::VERSION, instructions: INSTRUCTIONS,
|
|
50
|
+
tools: read_tools)
|
|
51
|
+
@transport = ::MCP::Server::Transports::StdioTransport.new(@server)
|
|
52
|
+
@server.transport = @transport
|
|
53
|
+
refresh
|
|
54
|
+
@log.puts "asterism mcp: #{@net.node_id}/#{@net.app}; #{@call_tools.size} call tools" \
|
|
55
|
+
"#{@allow.empty? ? ' (no --allow: read-only)' : ''}"
|
|
56
|
+
@refresher = Thread.new { refresh_loop }
|
|
57
|
+
@transport.open
|
|
58
|
+
ensure
|
|
59
|
+
@refresher&.kill
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# ------------------------------------------------------------ the list
|
|
63
|
+
|
|
64
|
+
def refresh_loop
|
|
65
|
+
loop do
|
|
66
|
+
sleep REFRESH_S
|
|
67
|
+
refresh
|
|
68
|
+
rescue StandardError => e
|
|
69
|
+
@log.puts "asterism mcp: refresh: #{e.class}: #{e.message}"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Makes the call tools again when the objects changed; tells the
|
|
74
|
+
# client when the list did.
|
|
75
|
+
def refresh
|
|
76
|
+
return if @allow.empty?
|
|
77
|
+
paths = @survey.objects.select { @allow.object?(_1) }
|
|
78
|
+
sig = paths.sort
|
|
79
|
+
return if sig == @signature
|
|
80
|
+
metas = @survey.metas(paths)
|
|
81
|
+
tools = {}
|
|
82
|
+
metas.sort.each do |path, methods|
|
|
83
|
+
methods.each do |m|
|
|
84
|
+
next unless @allow.method?(path, m["name"])
|
|
85
|
+
name = unique_name(tool_name(path, m["name"]), tools)
|
|
86
|
+
tools[name] = [path, m["name"], m["arity"], m]
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
# Keep trying the objects whose meta did not come.
|
|
90
|
+
@signature = (paths - metas.keys).empty? ? sig : nil
|
|
91
|
+
changed = @lock.synchronize do
|
|
92
|
+
next false if tools == @call_tools
|
|
93
|
+
@call_tools = tools
|
|
94
|
+
list = read_tools + tools.map { |name, (path, _method, _arity, m)| call_tool(name, path, m) }
|
|
95
|
+
@server.tools = list.to_h { [_1.name_value, _1] }
|
|
96
|
+
@server.instance_variable_set(:@tool_names, @server.tools.keys)
|
|
97
|
+
true
|
|
98
|
+
end
|
|
99
|
+
return unless changed
|
|
100
|
+
@log.puts "asterism mcp: call tools: #{tools.keys.join(', ')}"
|
|
101
|
+
@server.notify_tools_list_changed
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def tool_name(path, method)
|
|
105
|
+
"#{path.tr('/', '.')}.#{method}".gsub(/[^A-Za-z0-9_.-]/, "_")[0, 120]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def unique_name(name, taken)
|
|
109
|
+
return name unless taken.key?(name)
|
|
110
|
+
n = 2
|
|
111
|
+
n += 1 while taken.key?("#{name}_#{n}")
|
|
112
|
+
"#{name}_#{n}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# ------------------------------------------------------------ tools
|
|
116
|
+
|
|
117
|
+
def read_tools
|
|
118
|
+
sv = @survey
|
|
119
|
+
bridge = self
|
|
120
|
+
[
|
|
121
|
+
::MCP::Tool.define(
|
|
122
|
+
name: "asterism_nodes",
|
|
123
|
+
description: "The nodes of the Asterism network, each with its profile (what runs it, a display name), " \
|
|
124
|
+
"its applications with their statuses, and the names of their objects.",
|
|
125
|
+
input_schema: { properties: {} },
|
|
126
|
+
annotations: { read_only_hint: true }
|
|
127
|
+
) do |server_context:|
|
|
128
|
+
bridge.answer { sv.tree(meta: false) }
|
|
129
|
+
end,
|
|
130
|
+
::MCP::Tool.define(
|
|
131
|
+
name: "asterism_objects",
|
|
132
|
+
description: "The exposed objects (<node>/<app>/<object>) and their methods with the number of " \
|
|
133
|
+
"arguments (-1: any), and the arguments' names and what each method does when the object " \
|
|
134
|
+
"tells them (args, doc). callable_as names the tool that calls a method, when this server " \
|
|
135
|
+
"allows it.",
|
|
136
|
+
input_schema: { properties: { pattern: { type: "string",
|
|
137
|
+
description: "node/app/object with * (one part) and ** " \
|
|
138
|
+
"(any parts); default **" } } },
|
|
139
|
+
annotations: { read_only_hint: true }
|
|
140
|
+
) do |server_context:, pattern: "**"|
|
|
141
|
+
bridge.answer { bridge.objects(pattern) }
|
|
142
|
+
end,
|
|
143
|
+
::MCP::Tool.define(
|
|
144
|
+
name: "asterism_status",
|
|
145
|
+
description: "The statuses the applications tell about themselves (asterism/<node>/<app>/@status): " \
|
|
146
|
+
"{\"<node>/<app>\": {...}}. An application without one is not listed.",
|
|
147
|
+
input_schema: { properties: { node: { type: "string" }, app: { type: "string" } } },
|
|
148
|
+
annotations: { read_only_hint: true }
|
|
149
|
+
) do |server_context:, node: nil, app: nil|
|
|
150
|
+
bridge.answer do
|
|
151
|
+
sv.statuses.select do |k, _|
|
|
152
|
+
n, a = k.split("/")
|
|
153
|
+
(node.nil? || node == n) && (app.nil? || app == a)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def objects(pattern)
|
|
161
|
+
paths = @survey.objects(pattern)
|
|
162
|
+
metas = @survey.metas(paths)
|
|
163
|
+
by = @lock.synchronize { @call_tools.to_h { |name, (path, m, _)| [[path, m], name] } }
|
|
164
|
+
paths.map do |p|
|
|
165
|
+
methods = metas[p]&.map do |m|
|
|
166
|
+
name = by[[p, m["name"]]]
|
|
167
|
+
name ? m.merge("callable_as" => name) : m
|
|
168
|
+
end
|
|
169
|
+
h = { "path" => p, "methods" => methods }
|
|
170
|
+
h["doc"] = @survey.object_doc(p) if @survey.object_doc(p)
|
|
171
|
+
h
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def call_tool(name, path, meta)
|
|
176
|
+
method = meta["name"]
|
|
177
|
+
arity = meta["arity"]
|
|
178
|
+
names = input_names(meta)
|
|
179
|
+
props = {}
|
|
180
|
+
required = []
|
|
181
|
+
if names
|
|
182
|
+
names.each do |n|
|
|
183
|
+
props[n.to_sym] = { description: "the argument #{n}" }
|
|
184
|
+
required << n if arity >= 0
|
|
185
|
+
end
|
|
186
|
+
props[:args] = { type: "array", description: "more positional arguments, if any" } if arity.negative?
|
|
187
|
+
elsif arity.negative?
|
|
188
|
+
props[:args] = { type: "array", description: "the positional arguments (any number)" }
|
|
189
|
+
else
|
|
190
|
+
arity.times do |i|
|
|
191
|
+
props[:"arg#{i + 1}"] = { description: "argument #{i + 1} (any JSON value)" }
|
|
192
|
+
required << "arg#{i + 1}"
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
props[:kwargs] = { type: "object", description: "keyword arguments, if the method takes any" }
|
|
196
|
+
schema = { properties: props }
|
|
197
|
+
schema[:required] = required unless required.empty?
|
|
198
|
+
about = [meta["doc"], (d = @survey.object_doc(path)) && "(#{path}: #{d})"].compact
|
|
199
|
+
count = names ? "" : " (#{arity.negative? ? 'any number of' : arity} arguments)"
|
|
200
|
+
bridge = self
|
|
201
|
+
::MCP::Tool.define(
|
|
202
|
+
name: name,
|
|
203
|
+
description: "#{about.empty? ? '' : "#{about.join(' ')}\n"}Calls #{method} on the Asterism object " \
|
|
204
|
+
"#{path}#{count} and returns {\"value\": ...}.",
|
|
205
|
+
input_schema: schema
|
|
206
|
+
) do |server_context:, **args|
|
|
207
|
+
bridge.invoke(path, method, arity, args, names)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# The meta's argument names as inputs, or nil (none, or a name the
|
|
212
|
+
# inputs cannot take: "args", "kwargs", a repeated one).
|
|
213
|
+
def input_names(meta)
|
|
214
|
+
names = meta["args"]
|
|
215
|
+
return nil unless names.is_a?(Array) && !names.empty?
|
|
216
|
+
return nil if names.uniq.size != names.size || names.any? { %w[args kwargs].include?(_1) }
|
|
217
|
+
names
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# A call tool's call.
|
|
221
|
+
def invoke(path, method, arity, args, names = nil)
|
|
222
|
+
pos = if names
|
|
223
|
+
names.map { args[_1.to_sym] } + (arity.negative? ? Array(args[:args]) : [])
|
|
224
|
+
elsif arity.negative?
|
|
225
|
+
Array(args[:args])
|
|
226
|
+
else
|
|
227
|
+
(1..arity).map { args[:"arg#{_1}"] }
|
|
228
|
+
end
|
|
229
|
+
if names && arity.negative?
|
|
230
|
+
# Optional named arguments: the trailing ones not given are left out.
|
|
231
|
+
pos.pop while !pos.empty? && pos.last.nil? && args[:args].nil?
|
|
232
|
+
end
|
|
233
|
+
kw = (args[:kwargs] || {}).to_h.transform_keys(&:to_sym)
|
|
234
|
+
unless @lock.synchronize { @call_tools.any? { |_, t| t[0] == path && t[1] == method } }
|
|
235
|
+
return error("#{path}.#{method} is not callable here (not allowed, or gone)")
|
|
236
|
+
end
|
|
237
|
+
v = ::Asterism[path, timeout: @survey.timeout].asterism_call(method, *pos, **kw)
|
|
238
|
+
text({ "value" => v })
|
|
239
|
+
rescue ::Asterism::RemoteError => e
|
|
240
|
+
error({ "error" => { "kind" => e.remote_kind, "class" => e.remote_class, "message" => e.remote_message } })
|
|
241
|
+
rescue ::Asterism::Error => e
|
|
242
|
+
error({ "error" => { "kind" => "asterism", "class" => e.class.name, "message" => e.message } })
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# The value of the block as a JSON text; an Asterism error as an
|
|
246
|
+
# error result.
|
|
247
|
+
def answer
|
|
248
|
+
text(yield)
|
|
249
|
+
rescue ::Asterism::Error => e
|
|
250
|
+
error("#{e.class}: #{e.message}")
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def text(v)
|
|
254
|
+
::MCP::Tool::Response.new([{ type: "text", text: JSON.generate(json_safe(v)) }])
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def error(v)
|
|
258
|
+
::MCP::Tool::Response.new([{ type: "text", text: v.is_a?(String) ? v : JSON.generate(json_safe(v)) }],
|
|
259
|
+
error: true)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def json_safe(v)
|
|
263
|
+
case v
|
|
264
|
+
when Hash then v.to_h { |k, x| [k.to_s, json_safe(x)] }
|
|
265
|
+
when Array then v.map { json_safe(_1) }
|
|
266
|
+
when String then v.encoding == Encoding::BINARY && !v.ascii_only? ? "(#{v.bytesize} bytes)" : v
|
|
267
|
+
else v
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# What `asterism nodes / objects / tree` and the MCP bridge read from the
|
|
2
|
+
# network, on a connection that is already open (Asterism.connect): the
|
|
3
|
+
# applications and objects from the liveliness tokens, then the meta of
|
|
4
|
+
# the objects, the nodes' profiles and the applications' statuses
|
|
5
|
+
# (docs/protocol.md 5, 7, 8) with one get for each node or application.
|
|
6
|
+
#
|
|
7
|
+
# Not one wildcard get for all (asterism/*/*/@status): behind a router's
|
|
8
|
+
# access control a query must fit the rules of every node it would reach
|
|
9
|
+
# (a game host may answer asterism/games/*/@status, not asterism/*/*/@status),
|
|
10
|
+
# so a wildcard over the nodes is refused where the plain keys pass. The
|
|
11
|
+
# gets go out at once and are read as their replies come (zenoh-c fills
|
|
12
|
+
# them on its own threads), so a listing costs one time limit at most,
|
|
13
|
+
# whatever the number of nodes. What a router refuses is simply not there,
|
|
14
|
+
# and the listing says so instead of calling a node gone.
|
|
15
|
+
module Asterism
|
|
16
|
+
class CLI
|
|
17
|
+
class Survey
|
|
18
|
+
# What watch subscribes to (puts are routed per subscriber, so the
|
|
19
|
+
# wildcards pass where the rules let them through).
|
|
20
|
+
PROFILES_ALL = "asterism/*/@profile"
|
|
21
|
+
STATUS_ALL = "asterism/*/*/@status"
|
|
22
|
+
STEP = 0.005
|
|
23
|
+
|
|
24
|
+
attr_reader :timeout
|
|
25
|
+
|
|
26
|
+
# own: this connection's "<node>/<app>", left out of the listings.
|
|
27
|
+
def initialize(timeout: 2.0, own: nil)
|
|
28
|
+
@timeout = timeout
|
|
29
|
+
@own = own || "#{::Asterism.node_id}/#{::Asterism.app}"
|
|
30
|
+
@object_docs = {}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Waits for the tokens alive at connect to come in (they come in one
|
|
34
|
+
# burst from the router): until the applications stop changing for
|
|
35
|
+
# quiet seconds, at most max seconds.
|
|
36
|
+
def settle(max = 1.5, quiet: 0.3)
|
|
37
|
+
t0 = now
|
|
38
|
+
last = nil
|
|
39
|
+
since = now
|
|
40
|
+
loop do
|
|
41
|
+
cur = [::Asterism.apps, ::Asterism.each("**").size]
|
|
42
|
+
if cur != last
|
|
43
|
+
last = cur
|
|
44
|
+
since = now
|
|
45
|
+
end
|
|
46
|
+
break if now - since >= quiet || now - t0 >= max
|
|
47
|
+
sleep 0.02
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The applications alive, "<node>/<app>", sorted (this one left out).
|
|
52
|
+
def apps(pattern = "*/*")
|
|
53
|
+
(::Asterism.apps(pattern) - [@own]).sort
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The nodes that have an application or an object alive (sorted).
|
|
57
|
+
def nodes
|
|
58
|
+
(apps.map { _1.split("/")[0] } + objects.map { _1.split("/")[0] }).uniq.sort
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The objects alive, "<node>/<app>/<object>", sorted (this
|
|
62
|
+
# application's own left out).
|
|
63
|
+
def objects(pattern = "**")
|
|
64
|
+
::Asterism.each(pattern).map(&:asterism_path).reject { _1.start_with?("#{@own}/") }.sort
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# {path => [{"name", "arity"}, ...]} for the paths given (an object
|
|
68
|
+
# that does not answer its meta in time is left out). One get per
|
|
69
|
+
# application (asterism/<node>/<app>/*/meta), then one for each object
|
|
70
|
+
# it did not bring.
|
|
71
|
+
def metas(paths)
|
|
72
|
+
return {} if paths.empty?
|
|
73
|
+
out = {}
|
|
74
|
+
apps = paths.map { _1.split("/")[0, 2].join("/") }.uniq
|
|
75
|
+
gets(apps.map { "asterism/#{_1}/*/meta" }).each_value do |replies|
|
|
76
|
+
replies.each do |key, payload|
|
|
77
|
+
path = key.delete_prefix("asterism/").delete_suffix("/meta")
|
|
78
|
+
m = meta_list(payload, path)
|
|
79
|
+
out[path] = m if m && paths.include?(path)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
missing = paths - out.keys
|
|
83
|
+
unless missing.empty?
|
|
84
|
+
gets(missing.map { "asterism/#{_1}/meta" }).each do |key, replies|
|
|
85
|
+
path = key.delete_prefix("asterism/").delete_suffix("/meta")
|
|
86
|
+
m = replies.first && meta_list(replies.first[1], path)
|
|
87
|
+
out[path] = m if m
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
out
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# {node => profile without the icon} of the nodes that answered (one
|
|
94
|
+
# get per node, all at once).
|
|
95
|
+
def profiles(list = nodes)
|
|
96
|
+
out = {}
|
|
97
|
+
gets(list.map { "asterism/#{_1}/@profile" }, params: "icon=0").each do |key, replies|
|
|
98
|
+
pr = replies.first && ::Asterism::Profile.check(::Asterism::Codec.unpack(replies.first[1]))
|
|
99
|
+
out[key.split("/")[1]] = pr if pr
|
|
100
|
+
end
|
|
101
|
+
out
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# {"<node>/<app>" => status} of the applications that answered (one
|
|
105
|
+
# get per application, all at once).
|
|
106
|
+
def statuses(list = apps)
|
|
107
|
+
out = {}
|
|
108
|
+
gets(list.map { "asterism/#{_1}/@status" }).each do |key, replies|
|
|
109
|
+
st = replies.first && ::Asterism::Status.check(::Asterism::Codec.unpack(replies.first[1]))
|
|
110
|
+
parts = key.split("/")
|
|
111
|
+
out["#{parts[1]}/#{parts[2]}"] = st if st
|
|
112
|
+
end
|
|
113
|
+
out
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The tree: [{"node", "profile", "apps" => [{"app", "status",
|
|
117
|
+
# "objects" => [{"name", "methods"}]}]}]. meta: false leaves the
|
|
118
|
+
# methods out (no meta gets).
|
|
119
|
+
def tree(meta: true)
|
|
120
|
+
objs = objects
|
|
121
|
+
app_list = apps
|
|
122
|
+
threads = []
|
|
123
|
+
node_list = (app_list.map { _1.split("/")[0] } + objs.map { _1.split("/")[0] }).uniq
|
|
124
|
+
threads << Thread.new { profiles(node_list) }
|
|
125
|
+
threads << Thread.new { statuses(app_list) }
|
|
126
|
+
threads << Thread.new { meta ? metas(objs) : {} }
|
|
127
|
+
prof, stat, met = threads.map(&:value)
|
|
128
|
+
by_node = Hash.new { |h, k| h[k] = {} }
|
|
129
|
+
app_list.each { |a| n, ap = a.split("/"); by_node[n][ap] ||= [] }
|
|
130
|
+
objs.each do |o|
|
|
131
|
+
n, ap, name = o.split("/")
|
|
132
|
+
(by_node[n][ap] ||= []) << name
|
|
133
|
+
end
|
|
134
|
+
by_node.keys.sort.map do |n|
|
|
135
|
+
apps = by_node[n].keys.sort.map do |ap|
|
|
136
|
+
h = { "app" => ap }
|
|
137
|
+
h["status"] = stat["#{n}/#{ap}"] if stat.key?("#{n}/#{ap}")
|
|
138
|
+
h["objects"] = by_node[n][ap].sort.map do |name|
|
|
139
|
+
e = { "name" => name }
|
|
140
|
+
e["methods"] = met["#{n}/#{ap}/#{name}"] if met.key?("#{n}/#{ap}/#{name}")
|
|
141
|
+
d = object_doc("#{n}/#{ap}/#{name}")
|
|
142
|
+
e["doc"] = d if d
|
|
143
|
+
e
|
|
144
|
+
end
|
|
145
|
+
h
|
|
146
|
+
end
|
|
147
|
+
h = { "node" => n }
|
|
148
|
+
h["profile"] = prof[n] if prof[n]
|
|
149
|
+
h["apps"] = apps
|
|
150
|
+
h
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Sends a get on each key at once and waits for all of them (at most
|
|
155
|
+
# the time limit): {key => [[reply key, payload], ...]}.
|
|
156
|
+
def gets(keys, params: nil)
|
|
157
|
+
s = ::Asterism.session
|
|
158
|
+
ms = (@timeout * 1000).round
|
|
159
|
+
pending = keys.to_h { |k| [k, s.get(k, timeout_ms: ms, params: params)] }
|
|
160
|
+
out = keys.to_h { [_1, []] }
|
|
161
|
+
until pending.empty?
|
|
162
|
+
pending.delete_if do |k, g|
|
|
163
|
+
ended = g.done?
|
|
164
|
+
out[k].concat(g.each_reply.map { |r| [r[0], r[1]] })
|
|
165
|
+
ended
|
|
166
|
+
end
|
|
167
|
+
sleep STEP unless pending.empty?
|
|
168
|
+
end
|
|
169
|
+
out
|
|
170
|
+
rescue ::Asterism::Zenoh::Error => e
|
|
171
|
+
raise ::Asterism::Disconnected, e.message
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# A meta reply's methods: [{"name", "arity"} and, when the object
|
|
175
|
+
# tells them (docs/protocol.md 5), "args" (names) and "doc"]. Fields
|
|
176
|
+
# of another shape are left out, unknown ones ignored.
|
|
177
|
+
def meta_list(payload, path = nil)
|
|
178
|
+
m = ::Asterism::Codec.unpack(payload)
|
|
179
|
+
list = m.is_a?(Hash) ? m["methods"] : nil
|
|
180
|
+
return nil unless list.is_a?(Array)
|
|
181
|
+
@object_docs[path] = m["doc"] if path && m["doc"].is_a?(String)
|
|
182
|
+
list.filter_map do |e|
|
|
183
|
+
next unless e.is_a?(Hash) && e["name"]
|
|
184
|
+
h = { "name" => e["name"].to_s, "arity" => e["arity"].to_i }
|
|
185
|
+
args = e["args"]
|
|
186
|
+
h["args"] = args if args.is_a?(Array) && args.all? { _1.is_a?(String) } &&
|
|
187
|
+
(h["arity"].negative? || args.size == h["arity"])
|
|
188
|
+
h["doc"] = e["doc"] if e["doc"].is_a?(String)
|
|
189
|
+
h
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# The text an object tells about itself in its meta (read by metas),
|
|
194
|
+
# or nil.
|
|
195
|
+
def object_doc(path)
|
|
196
|
+
@object_docs[path]
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def now
|
|
200
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|