asterism 0.1.0 → 0.2.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.
@@ -0,0 +1,422 @@
1
+ # Asterism::Zenoh with blocks, threads and Enumerators (CRuby only). The
2
+ # polled API of asterism-zenoh (Session, each_pending, each_reply) is the
3
+ # same on the boards and stays as it is; this is a layer on top of it.
4
+ #
5
+ # Asterism::Zenoh.open("tcp/192.0.2.2:7447") do |s|
6
+ # s.subscribe("home/**") { |sample| puts "#{sample.key} = #{sample.payload}" }
7
+ # s.queryable("home/pc/status") { |q| q.reply("ok") }
8
+ # s.put("home/pc/hello", "hi")
9
+ # s.get("home/**/status").each { |reply| p reply }
10
+ # s.subscribe("sensor/temp").each.lazy.map { _1.payload.to_f }.first(3)
11
+ # s.run # until Ctrl-C (or s.start / s.stop)
12
+ # end
13
+ #
14
+ # Blocks given to subscribe, queryable and liveliness_watch run on the
15
+ # session's receiving thread (start) or inside run; Enumerators (each, get)
16
+ # wait on the thread that iterates them. See Asterism::Runner.
17
+ module Asterism
18
+ module Zenoh
19
+ # Pause between looks while an Enumerator waits for data (seconds).
20
+ ENUM_STEP = 0.002
21
+
22
+ # A received sample. payload and attachment are binary Strings (the
23
+ # attachment nil when there was none); text is the payload as UTF-8.
24
+ Sample = Data.define(:key, :payload, :attachment) do
25
+ def text
26
+ payload.dup.force_encoding(Encoding::UTF_8)
27
+ end
28
+
29
+ def to_s
30
+ "#{key}: #{payload}"
31
+ end
32
+ end
33
+
34
+ # A reply to a get: the key it was sent with, payload and attachment.
35
+ Reply = Data.define(:key, :payload, :attachment) do
36
+ def text
37
+ payload.dup.force_encoding(Encoding::UTF_8)
38
+ end
39
+
40
+ def to_s
41
+ "#{key}: #{payload}"
42
+ end
43
+ end
44
+
45
+ # A liveliness change: key of the token, and whether it appeared.
46
+ Liveliness = Data.define(:key, :alive) do
47
+ alias_method :alive?, :alive
48
+ end
49
+
50
+ # Added to the queries a Connection's queryable hands out: reply with
51
+ # only a payload answers on the queryable's own key (when it has no
52
+ # wildcard) instead of the query's, which may be a pattern; payloads
53
+ # that are not Strings are sent as to_s.
54
+ module QueryReply
55
+ def reply(*args, **kw)
56
+ args[-1] = args[-1].to_s unless args.empty? || args[-1].is_a?(String)
57
+ args.unshift(@asterism_key) if args.size == 1 && @asterism_key
58
+ super(*args, **kw)
59
+ end
60
+ end
61
+
62
+ # The queryable's key when it names one key (no wildcard).
63
+ def self.plain_key(key)
64
+ key.include?("*") || key.include?("$") ? nil : key
65
+ end
66
+
67
+ def self.prepare_query(q, key)
68
+ q.extend(QueryReply)
69
+ q.instance_variable_set(:@asterism_key, key)
70
+ q
71
+ end
72
+
73
+ # Opens a session (the arguments of Session.open) and wraps it in a
74
+ # Connection. With a block: yields it, closes it when the block ends
75
+ # (also on an exception) and returns the block's value.
76
+ def self.open(locator = nil, interval: Runner::DEFAULT_INTERVAL, **opts)
77
+ conn = Connection.new(Session.open(locator, **opts), interval: interval)
78
+ return conn unless block_given?
79
+ begin
80
+ yield conn
81
+ ensure
82
+ conn.close
83
+ end
84
+ end
85
+
86
+ # Seconds (Float) or nil -> milliseconds for the polled API.
87
+ def self.ms(seconds, default_ms)
88
+ return default_ms if seconds.nil?
89
+ (seconds * 1000).round
90
+ end
91
+
92
+ # Waits on the calling thread until a sample comes into sub (an
93
+ # Asterism::Zenoh::Subscriber or LivelinessWatch), yielding each.
94
+ # Ends when sub or the session closes, or after timeout seconds.
95
+ def self.drain(sub, session, timeout)
96
+ deadline = timeout && (Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout)
97
+ loop do
98
+ got = sub.each_pending
99
+ got.each { |e| yield e }
100
+ break if sub.closed? || session.closed?
101
+ break if deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
102
+ sleep(ENUM_STEP) if got.empty?
103
+ end
104
+ end
105
+
106
+ # A session with the block API. The polled calls of the session are
107
+ # still there (session, or the methods without a block).
108
+ class Connection
109
+ attr_reader :session, :runner
110
+
111
+ def initialize(session, interval: Runner::DEFAULT_INTERVAL)
112
+ @session = session
113
+ @runner = Runner.new(session, interval: interval, name: "zenoh")
114
+ # The first task: false (the runner stops) once the session closed.
115
+ @runner.add { @session.poll ? 0 : false }
116
+ end
117
+
118
+ def put(key, payload, attachment: nil)
119
+ @session.put(key, payload.is_a?(String) ? payload : payload.to_s, attachment: attachment)
120
+ nil
121
+ end
122
+
123
+ # With a block: every sample is given to it (as a Sample) on the
124
+ # receiving thread; returns the Subscription (close it to stop).
125
+ # Without: a Subscription to take from (each, each_pending).
126
+ def subscribe(key, depth: 16, &blk)
127
+ sub = Subscription.new(self, @session.subscribe(key, depth), key)
128
+ sub.handle(&blk) if blk
129
+ sub
130
+ end
131
+
132
+ # With a block: each query is given to it on the receiving thread and
133
+ # finished when it returns (reply in the block with q.reply). Without:
134
+ # a Queryable whose each yields the queries (finished after each).
135
+ def queryable(key, depth: 16, complete: false, &blk)
136
+ qa = Queryable.new(self, @session.queryable(key, depth, complete: complete), key)
137
+ qa.handle(&blk) if blk
138
+ qa
139
+ end
140
+
141
+ # The replies to a get: yields each Reply until every answer came or
142
+ # timeout (seconds) ran out, and returns their number. Without a
143
+ # block, an Enumerator (each iteration sends the get again).
144
+ def get(key, timeout: 2.0, params: nil, payload: nil, attachment: nil, target: :all,
145
+ consolidation: :none, &blk)
146
+ args = [key, timeout, params, payload, attachment, target, consolidation]
147
+ return enum_for(:get_each, *args) unless blk
148
+ get_each(*args, &blk)
149
+ end
150
+
151
+ # A liveliness token (close it to withdraw).
152
+ def liveliness(key)
153
+ @session.liveliness(key)
154
+ end
155
+
156
+ # With a block: yields key and alive (true when the token appeared) on
157
+ # the receiving thread for each change; the tokens alive now come
158
+ # first. Without: a Watch whose each yields Liveliness values.
159
+ def liveliness_watch(key, depth: 16, &blk)
160
+ w = Watch.new(self, @session.liveliness_watch(key, depth), key)
161
+ w.handle(&blk) if blk
162
+ w
163
+ end
164
+
165
+ # The keys of the liveliness tokens alive now (an Array; waits for
166
+ # the answers, at most timeout seconds).
167
+ def liveliness_get(key, timeout: 2.0)
168
+ g = @session.liveliness_get(key, Zenoh.ms(timeout, 2000))
169
+ keys = []
170
+ loop do
171
+ g.each_reply.each { |r| keys << r[0] }
172
+ break if g.done?
173
+ sleep(ENUM_STEP)
174
+ end
175
+ g.each_reply.each { |r| keys << r[0] }
176
+ keys
177
+ end
178
+
179
+ # Receives on a thread of its own (blocks run there). Returns self.
180
+ def start
181
+ @runner.start
182
+ self
183
+ end
184
+
185
+ # Stops the receiving thread (or ends run).
186
+ def stop
187
+ @runner.stop
188
+ self
189
+ end
190
+
191
+ # Receives on this thread until stop, the connection closing, or
192
+ # Ctrl-C. Returns nil.
193
+ def run
194
+ @runner.run
195
+ end
196
+
197
+ def running?
198
+ @runner.running?
199
+ end
200
+
201
+ # on_error { |error, where| }: what the blocks raise. Without a
202
+ # handler it is printed (warn); the receiving goes on either way.
203
+ def on_error(&blk)
204
+ @runner.on_error(&blk)
205
+ self
206
+ end
207
+
208
+ def poll
209
+ @session.poll
210
+ end
211
+
212
+ def closed?
213
+ @session.closed?
214
+ end
215
+
216
+ def zid
217
+ @session.zid
218
+ end
219
+
220
+ def peers
221
+ @session.peers
222
+ end
223
+
224
+ # Stops receiving and closes the session (with everything declared on
225
+ # it). Idempotent.
226
+ def close
227
+ @runner.close
228
+ @session.close
229
+ nil
230
+ end
231
+
232
+ def inspect
233
+ "#<Asterism::Zenoh::Connection #{closed? ? 'closed' : zid}#{running? ? ' running' : ''}>"
234
+ end
235
+
236
+ private
237
+
238
+ def get_each(key, timeout, params, payload, attachment, target, consolidation)
239
+ g = @session.get(key, Zenoh.ms(timeout, 2000), params, payload,
240
+ attachment: attachment, target: target, consolidation: consolidation)
241
+ n = 0
242
+ loop do
243
+ got = g.each_reply
244
+ got.each do |r|
245
+ n += 1
246
+ yield Reply.new(r[0], r[1], r[2])
247
+ end
248
+ break if g.done? && g.pending == 0
249
+ sleep(ENUM_STEP) if got.empty?
250
+ end
251
+ n
252
+ end
253
+
254
+ # A subscription of a Connection. Either its block gets the samples
255
+ # (on the receiving thread), or the application takes them: each (an
256
+ # Enumerator that waits for samples) or each_pending (what is there
257
+ # now, as the polled API).
258
+ class Subscription
259
+ include Enumerable
260
+
261
+ attr_reader :key, :subscriber
262
+
263
+ def initialize(conn, subscriber, key)
264
+ @conn = conn
265
+ @subscriber = subscriber
266
+ @key = key
267
+ @task = nil
268
+ end
269
+
270
+ def handle(&blk)
271
+ raise ArgumentError, "a block is needed" unless blk
272
+ where = "subscribe #{@key}"
273
+ r = @conn.runner
274
+ @task = r.add do
275
+ got = @subscriber.each_pending
276
+ got.each { |e| r.guard(where) { blk.call(Sample.new(e[0], e[1], e[2])) } }
277
+ got.size
278
+ end
279
+ self
280
+ end
281
+
282
+ # Yields each Sample as it comes, waiting for the next; ends when the
283
+ # subscription or the session closes, or after timeout seconds.
284
+ # Without a block, an Enumerator (each.lazy.map { ... }.first(3)).
285
+ def each(timeout: nil, &blk)
286
+ return enum_for(:each, timeout: timeout) unless blk
287
+ raise ::Asterism::Error, "this subscription has a block; its samples go there" if @task
288
+ Zenoh.drain(@subscriber, @conn.session, timeout) { |e| blk.call(Sample.new(e[0], e[1], e[2])) }
289
+ self
290
+ end
291
+
292
+ # The samples there now as Samples (an Array), or yields them.
293
+ def each_pending
294
+ got = @subscriber.each_pending.map { |e| Sample.new(e[0], e[1], e[2]) }
295
+ return got unless block_given?
296
+ got.each { |s| yield s }
297
+ got.size
298
+ end
299
+
300
+ def pending = @subscriber.pending
301
+ def received = @subscriber.received
302
+ def dropped = @subscriber.dropped
303
+ def closed? = @subscriber.closed?
304
+
305
+ def close
306
+ @conn.runner.remove(@task) if @task
307
+ @task = nil
308
+ @subscriber.close
309
+ nil
310
+ end
311
+
312
+ def inspect
313
+ "#<Asterism::Zenoh::Connection::Subscription #{@key}#{@task ? ' (block)' : ''}>"
314
+ end
315
+ end
316
+
317
+ # A queryable of a Connection: its block answers on the receiving
318
+ # thread, or each yields the queries (each finished after the block).
319
+ class Queryable
320
+ include Enumerable
321
+
322
+ attr_reader :key, :queryable
323
+
324
+ def initialize(conn, queryable, key)
325
+ @conn = conn
326
+ @queryable = queryable
327
+ @key = key
328
+ @task = nil
329
+ end
330
+
331
+ def handle(&blk)
332
+ raise ArgumentError, "a block is needed" unless blk
333
+ where = "queryable #{@key}"
334
+ own = Zenoh.plain_key(@key)
335
+ r = @conn.runner
336
+ @task = r.add do
337
+ qs = @queryable.each_pending
338
+ qs.each do |q|
339
+ r.guard(where) { blk.call(Zenoh.prepare_query(q, own)) }
340
+ ensure
341
+ q.finish
342
+ end
343
+ qs.size
344
+ end
345
+ self
346
+ end
347
+
348
+ # Yields each query (Asterism::Zenoh::Query: key, params, payload,
349
+ # attachment, reply; reply(payload) answers on the queryable's own
350
+ # key) as it comes and finishes it after the block.
351
+ # Ends when the queryable or the session closes, or after timeout.
352
+ def each(timeout: nil, &blk)
353
+ return enum_for(:each, timeout: timeout) unless blk
354
+ raise ::Asterism::Error, "this queryable has a block; its queries go there" if @task
355
+ own = Zenoh.plain_key(@key)
356
+ Zenoh.drain(@queryable, @conn.session, timeout) do |q|
357
+ blk.call(Zenoh.prepare_query(q, own))
358
+ ensure
359
+ q.finish
360
+ end
361
+ self
362
+ end
363
+
364
+ def pending = @queryable.pending
365
+ def received = @queryable.received
366
+ def dropped = @queryable.dropped
367
+ def closed? = @queryable.closed?
368
+
369
+ def close
370
+ @conn.runner.remove(@task) if @task
371
+ @task = nil
372
+ @queryable.close
373
+ nil
374
+ end
375
+ end
376
+
377
+ # A liveliness watch of a Connection.
378
+ class Watch
379
+ include Enumerable
380
+
381
+ attr_reader :key, :watch
382
+
383
+ def initialize(conn, watch, key)
384
+ @conn = conn
385
+ @watch = watch
386
+ @key = key
387
+ @task = nil
388
+ end
389
+
390
+ def handle(&blk)
391
+ raise ArgumentError, "a block is needed" unless blk
392
+ where = "liveliness_watch #{@key}"
393
+ r = @conn.runner
394
+ @task = r.add do
395
+ got = @watch.each_pending
396
+ got.each { |e| r.guard(where) { blk.call(e[0], e[1]) } }
397
+ got.size
398
+ end
399
+ self
400
+ end
401
+
402
+ # Yields each change as a Liveliness (key, alive), waiting for the
403
+ # next; the tokens alive now come first.
404
+ def each(timeout: nil, &blk)
405
+ return enum_for(:each, timeout: timeout) unless blk
406
+ raise ::Asterism::Error, "this watch has a block; its changes go there" if @task
407
+ Zenoh.drain(@watch, @conn.session, timeout) { |e| blk.call(Liveliness.new(e[0], e[1])) }
408
+ self
409
+ end
410
+
411
+ def closed? = @watch.closed?
412
+
413
+ def close
414
+ @conn.runner.remove(@task) if @task
415
+ @task = nil
416
+ @watch.close
417
+ nil
418
+ end
419
+ end
420
+ end
421
+ end
422
+ end
@@ -18,6 +18,12 @@ module Asterism
18
18
  end
19
19
  end
20
20
 
21
+ # A monotonic clock (deadlines and node.every must not jump with the
22
+ # wall clock).
23
+ def self.now_ms
24
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
25
+ end
26
+
21
27
  def self.error_reply(klass, message)
22
28
  ::MessagePack.pack(["error", klass.to_s, message.to_s], compatibility_mode: true)
23
29
  end
@@ -1,5 +1,5 @@
1
1
  module Asterism
2
2
  # The gem's version (the CRuby gem asterism; it depends on asterism-zenoh
3
3
  # of the same version).
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/asterism.rb CHANGED
@@ -16,3 +16,8 @@ require_relative "asterism/version"
16
16
  require_relative "../mrblib/#{f}"
17
17
  end
18
18
  require_relative "asterism/cruby"
19
+ # The Ruby-like API on top (blocks, a receiving thread, Enumerators,
20
+ # pattern matching): CRuby only, see README "The Ruby-like API".
21
+ %w[runner zenoh objects ros].each do |f|
22
+ require_relative "asterism/cruby/#{f}"
23
+ end
data/mrblib/asterism.rb CHANGED
@@ -166,7 +166,7 @@ module Asterism
166
166
  # and Error when the same <node>/<app> is already on the network.
167
167
  def self.connect(locator, node:, app:, mode: nil, listen: nil)
168
168
  raise Error, "already connected (Asterism.close first)" if connected?
169
- close
169
+ release
170
170
  node = check_name("node", node)
171
171
  app = check_name("app", app)
172
172
  begin
@@ -195,6 +195,7 @@ module Asterism
195
195
  @proxies = {}
196
196
  @depth = 0
197
197
  @lost = nil
198
+ @known = [] # the other nodes told to on_join so far
198
199
  self
199
200
  end
200
201
 
@@ -206,8 +207,9 @@ module Asterism
206
207
  found = false
207
208
  while true
208
209
  s.poll
210
+ ended = g.done? # before taking the replies (see Future#collect)
209
211
  found = true if g.each_reply.size > 0
210
- break if found || g.done? || now_ms - t0 > 1500
212
+ break if found || ended || now_ms - t0 > 1500
211
213
  pause(WAIT_STEP_MS)
212
214
  end
213
215
  found
@@ -235,8 +237,17 @@ module Asterism
235
237
  @lost
236
238
  end
237
239
 
238
- # Closes the connection (and with it every exposed object). Idempotent.
240
+ # Closes the connection (and with it every exposed object), and forgets
241
+ # the on_join / on_leave blocks. Idempotent.
239
242
  def self.close
243
+ release
244
+ @known = nil
245
+ @on_join = nil
246
+ @on_leave = nil
247
+ nil
248
+ end
249
+
250
+ def self.release
240
251
  s = @session
241
252
  @session = nil
242
253
  @objects = {}
@@ -247,7 +258,6 @@ module Asterism
247
258
  @node_token = nil
248
259
  @queryable = nil
249
260
  @watch = nil
250
- nil
251
261
  end
252
262
 
253
263
  def self.session!
@@ -255,10 +265,11 @@ module Asterism
255
265
  @session
256
266
  end
257
267
 
268
+ # The connection went away: closed, but the next Asterism.poll still tells
269
+ # on_leave about every node known until then.
258
270
  def self.lost(reason)
259
271
  @lost = reason
260
- close
261
- @lost = reason
272
+ release
262
273
  end
263
274
 
264
275
  # ------------------------------------------------------------ exposing
@@ -300,10 +311,71 @@ module Asterism
300
311
  # ------------------------------------------------------------ polling
301
312
 
302
313
  # Call from the application's update loop. Answers the calls that came
303
- # in and follows who is alive. false once the connection is closed.
314
+ # in, follows who is alive and calls the on_join / on_leave blocks. false
315
+ # once the connection is closed.
304
316
  def self.poll
305
- return false unless @session
306
- pump
317
+ ok = @session ? pump : false
318
+ tell_nodes
319
+ ok
320
+ end
321
+
322
+ # ------------------------------------------------------------ nodes coming and going
323
+
324
+ # on_join { |node| }: another node appeared (its node token or one of its
325
+ # objects). The nodes there already join on the first polls after
326
+ # connecting. on_leave { |node| }: a node is gone (no token and no object
327
+ # left); when the connection is lost, every node known until then leaves.
328
+ #
329
+ # The blocks run from Asterism.poll only, never from the polling inside a
330
+ # waiting call. What a block raises comes out of Asterism.poll (on CRuby
331
+ # with a receiving thread: to on_error). Asterism.close forgets them.
332
+ def self.on_join(&blk)
333
+ raise ArgumentError, "on_join needs a block" unless blk
334
+ (@on_join ||= []) << blk
335
+ self
336
+ end
337
+
338
+ def self.on_leave(&blk)
339
+ raise ArgumentError, "on_leave needs a block" unless blk
340
+ (@on_leave ||= []) << blk
341
+ self
342
+ end
343
+
344
+ # Compares the nodes alive now with those told before and calls the
345
+ # blocks for the difference.
346
+ def self.tell_nodes
347
+ known = @known
348
+ return if known.nil?
349
+ now = @session ? nodes : []
350
+ now.shift if @session # this node
351
+ joined = []
352
+ left = []
353
+ now.each { |n| joined << n unless known.include?(n) }
354
+ known.each { |n| left << n unless now.include?(n) }
355
+ # After a loss, everything has been told: nothing more to compare.
356
+ @known = @session ? now : nil
357
+ tell_each("on_join", @on_join, joined)
358
+ tell_each("on_leave", @on_leave, left)
359
+ end
360
+
361
+ # While loops, not each with a block: the blocks are the application's,
362
+ # and nothing else stands between them and the update loop on the stack.
363
+ def self.tell_each(where, blocks, names)
364
+ return if blocks.nil? || names.empty?
365
+ i = 0
366
+ while i < names.size
367
+ j = 0
368
+ while j < blocks.size
369
+ tell(where, blocks[j], names[i])
370
+ j += 1
371
+ end
372
+ i += 1
373
+ end
374
+ end
375
+
376
+ # One block for one node (CRuby routes what it raises to on_error).
377
+ def self.tell(_where, blk, node)
378
+ blk.call(node)
307
379
  end
308
380
 
309
381
  def self.pump
data/mrblib/future.rb CHANGED
@@ -70,11 +70,16 @@ module Asterism
70
70
  def collect
71
71
  return if @finished
72
72
  g = @get
73
+ # done? before taking the replies: with a binding whose replies come
74
+ # in on another thread (zenoh-c), a reply and the end of the get may
75
+ # both arrive between the two; read the other way round, the get
76
+ # would look finished with its reply still queued.
77
+ ended = g.done?
73
78
  # The Array form, not a block: a block called from C costs another
74
79
  # interpreter entry on the C stack, and this runs inside nested waits.
75
80
  replies = g.each_reply
76
81
  @reply = replies[0][1] if @reply.nil? && replies.size > 0
77
- if !@reply.nil? || g.done? || ::Asterism.now_ms >= @deadline || !::Asterism.connected?
82
+ if !@reply.nil? || ended || ::Asterism.now_ms >= @deadline || !::Asterism.connected?
78
83
  @finished = true
79
84
  @took = ::Asterism.now_ms - @started
80
85
  end