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,305 @@
1
+ # Asterism::ROS with a receiving thread and Enumerators (CRuby only).
2
+ # Asterism::ROS::Node, node.poll, the subscribe / every blocks and pattern
3
+ # matching on messages are the shared ones (mrblib/ros.rb), as on the
4
+ # boards; this layer runs node.poll on a receiving thread, routes what the
5
+ # blocks raise to on_error, and adds the Enumerators.
6
+ #
7
+ # Asterism::ROS.connect("tcp/192.0.2.2:7447", domain: 0) do |ros|
8
+ # node = ros.node("ruby_talker")
9
+ # chatter = node.publisher("/chatter", "std_msgs/msg/String")
10
+ # chatter << { data: "hello from Ruby" }
11
+ # node.subscribe("/odom", "nav_msgs/msg/Odometry") { |odom| p odom.pose.pose.position.x }
12
+ # node.every(0.1) { chatter << { data: Time.now.to_s } }
13
+ # node.service("/add_ruby", "example_interfaces/srv/AddTwoInts") { |req| { sum: req.a + req.b } }
14
+ # p node.call("/add_two_ints", "example_interfaces/srv/AddTwoInts", a: 1, b: 2).sum
15
+ # node.topic("/scan", "sensor_msgs/msg/LaserScan").each.lazy.first(1)
16
+ # ros.spin
17
+ # end
18
+ #
19
+ # Locking: each connection has one lock (its Runner's). A tick (node.poll:
20
+ # services answered, subscription blocks, timers) holds it; so do the calls
21
+ # that change a node (making publishers, subscriptions, services, clients,
22
+ # timers) and publishing. A service call made on another thread while the connection
23
+ # spins waits for the ticks (Asterism::Runner#wait_until) instead of
24
+ # polling by itself.
25
+ module Asterism
26
+ module ROS
27
+ # Opens a Zenoh session and makes a Connection for nodes on it. domain:
28
+ # the ROS domain of the nodes made by ros.node. type_path: directories
29
+ # with generated types to add to Asterism::ROS::TYPE_PATH. The other
30
+ # options go to Asterism::Zenoh::Session.open. With a block: yields the
31
+ # Connection, closes it when the block ends and returns its value.
32
+ def self.connect(locator = nil, domain: 0, type_path: nil, interval: Runner::DEFAULT_INTERVAL, **opts)
33
+ Array(type_path).each { |d| TYPE_PATH << File.expand_path(d) unless TYPE_PATH.include?(File.expand_path(d)) }
34
+ zconn = ::Asterism::Zenoh.open(locator, interval: interval, **opts)
35
+ conn = Connection.new(zconn, domain: domain)
36
+ return conn unless block_given?
37
+ begin
38
+ yield conn
39
+ ensure
40
+ conn.close
41
+ end
42
+ end
43
+
44
+ # The ROS side of a Zenoh connection: makes nodes and spins them.
45
+ class Connection
46
+ attr_reader :zenoh, :domain
47
+
48
+ def initialize(zconn, domain: 0)
49
+ @zenoh = zconn
50
+ @domain = domain.to_i
51
+ @nodes = []
52
+ end
53
+
54
+ def session = @zenoh.session
55
+ def runner = @zenoh.runner
56
+
57
+ # An Asterism::ROS::Node on this connection, with the block API
58
+ # (subscribe, every, topic; services answered while it spins).
59
+ def node(name, namespace: "/", enclave: "/")
60
+ runner.lock.synchronize do
61
+ n = Node.new(session, name, namespace: namespace, domain: @domain, enclave: enclave)
62
+ n.extend(Spinning)
63
+ n.asterism_spin_on(runner)
64
+ @nodes << n
65
+ n
66
+ end
67
+ end
68
+
69
+ def nodes = @nodes.dup
70
+
71
+ # Spins on this thread (services, subscription blocks, timers) until
72
+ # stop, the connection closing, or Ctrl-C. Returns nil.
73
+ def spin
74
+ runner.run
75
+ end
76
+ alias run spin
77
+
78
+ # Spins on a thread of its own. Returns self.
79
+ def start
80
+ runner.start
81
+ self
82
+ end
83
+
84
+ def stop
85
+ runner.stop
86
+ self
87
+ end
88
+
89
+ def running? = runner.running?
90
+
91
+ # on_error { |error, where| }: what subscription, timer and service
92
+ # blocks raise (the service request then gets no answer).
93
+ def on_error(&blk)
94
+ runner.on_error(&blk)
95
+ self
96
+ end
97
+
98
+ # Stops spinning, withdraws the nodes and closes the session.
99
+ def close
100
+ runner.stop
101
+ @nodes.each { |n| n.close rescue ::Asterism::Zenoh::Error }
102
+ @nodes = []
103
+ @zenoh.close
104
+ nil
105
+ end
106
+
107
+ def inspect
108
+ "#<Asterism::ROS::Connection domain #{@domain}#{running? ? ' spinning' : ''}>"
109
+ end
110
+ end
111
+
112
+ # A service block raised; it was reported through on_error already.
113
+ class ServiceFailed < ::StandardError; end
114
+
115
+ # Added to the nodes a Connection makes (Node#extend). The methods of
116
+ # Node keep their meaning; these come on top.
117
+ module Spinning
118
+ def asterism_spin_on(runner)
119
+ @asterism_runner = runner
120
+ node = self
121
+ @asterism_task = runner.add { node.poll ? 0 : false }
122
+ end
123
+
124
+ # As Node#pump (node.poll and waiting service calls go through it). A
125
+ # service block that raised was reported (on_error) and its request
126
+ # left unanswered; polling goes on.
127
+ def pump(steps = 8)
128
+ super
129
+ rescue ::Asterism::ROS::ServiceFailed
130
+ !@session.closed?
131
+ end
132
+
133
+ # The subscribe and every blocks (called from node.poll): what they
134
+ # raise goes to on_error, and the others still run.
135
+ def handle(sub, msg, info)
136
+ @asterism_runner.guard("subscribe #{sub.topic_key}") { super }
137
+ end
138
+
139
+ def fire(timer, now)
140
+ @asterism_runner.guard("every #{timer.period}") { super }
141
+ end
142
+
143
+ def asterism_runner = @asterism_runner
144
+
145
+ # The calls that change the node take the connection's lock.
146
+ %i[subscription client close every cancel_timer].each do |m|
147
+ define_method(m) do |*args, **kw, &blk|
148
+ @asterism_runner.lock.synchronize { super(*args, **kw, &blk) }
149
+ end
150
+ end
151
+
152
+ # As Node#publisher; publishing takes the lock too (a timer on the
153
+ # receiving thread and the application may publish at once).
154
+ def publisher(topic, type, qos: DEFAULT_QOS)
155
+ r = @asterism_runner
156
+ r.lock.synchronize do
157
+ pub = super
158
+ pub.extend(LockedPublisher)
159
+ pub.instance_variable_set(:@asterism_lock, r.lock)
160
+ pub
161
+ end
162
+ end
163
+
164
+ # As Node#service: the block answers while the connection spins (or
165
+ # from node.poll). What it raises goes to on_error and that request
166
+ # gets no answer.
167
+ def service(name, type, qos: DEFAULT_QOS, depth: 8, &handler)
168
+ raise ArgumentError, "service needs a block" unless handler
169
+ r = @asterism_runner
170
+ where = "service #{name}"
171
+ wrapped = proc do |req|
172
+ handler.call(req)
173
+ rescue StandardError => e
174
+ r.report(e, where)
175
+ raise ::Asterism::ROS::ServiceFailed, e.message
176
+ end
177
+ r.lock.synchronize { super(name, type, qos: qos, depth: depth, &wrapped) }
178
+ end
179
+
180
+ # With a block: as on the boards (Node#subscribe), the block gets each
181
+ # message (and its Attachment, or nil) from node.poll, here while the
182
+ # connection spins; returns the Subscription. Without: a Subscription
183
+ # whose each waits for messages (an Enumerator).
184
+ def subscribe(topic, type, qos: DEFAULT_QOS, depth: 16, &blk)
185
+ r = @asterism_runner
186
+ r.lock.synchronize do
187
+ sub = blk ? super : subscription(topic, type, qos: qos, depth: depth)
188
+ sub.extend(Stream)
189
+ sub.asterism_stream_on(session)
190
+ sub
191
+ end
192
+ end
193
+
194
+ # A topic to iterate: each subscribes for the iteration and withdraws
195
+ # after it (node.topic("/scan", "sensor_msgs/msg/LaserScan").each.lazy.first(1)).
196
+ def topic(topic, type, qos: DEFAULT_QOS, depth: 16)
197
+ Topic.new(self, topic, type, qos, depth)
198
+ end
199
+
200
+ # Node#call with the time limit in seconds as well: timeout: 2.0.
201
+ def call(service, type, request = nil, timeout: nil, timeout_ms: nil, **fields)
202
+ ms = timeout_ms || ::Asterism::Zenoh.ms(timeout, Client::DEFAULT_TIMEOUT_MS)
203
+ c = @asterism_runner.lock.synchronize do
204
+ @clients ||= {}
205
+ cl = @clients[service]
206
+ if cl.nil? || cl.closed?
207
+ cl = client(service, type)
208
+ @clients[service] = cl
209
+ end
210
+ cl
211
+ end
212
+ c.call(request, timeout_ms: ms, **fields)
213
+ end
214
+ end
215
+
216
+ module LockedPublisher
217
+ def publish(msg)
218
+ @asterism_lock.synchronize { super }
219
+ end
220
+ end
221
+
222
+ # Added to the subscriptions node.subscribe makes.
223
+ module Stream
224
+ include Enumerable
225
+
226
+ def asterism_stream_on(session)
227
+ @asterism_session = session
228
+ end
229
+
230
+ # Yields each message (and its Attachment) as it comes, waiting for
231
+ # the next; ends when the subscription or the session closes, or after
232
+ # timeout seconds. Without a block, an Enumerator.
233
+ def each(timeout: nil, &blk)
234
+ return enum_for(:each, timeout: timeout) unless blk
235
+ raise ::Asterism::Error, "this subscription has a block; its messages go there" if handler
236
+ deadline = timeout && (Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout)
237
+ loop do
238
+ got = each_pending
239
+ got.each { |m| blk.call(m[0], m[1]) }
240
+ break if closed? || @asterism_session.closed?
241
+ break if deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
242
+ sleep(::Asterism::Zenoh::ENUM_STEP) if got.empty?
243
+ end
244
+ self
245
+ end
246
+
247
+ end
248
+
249
+ # node.topic: subscribes while it is iterated.
250
+ class Topic
251
+ include Enumerable
252
+
253
+ attr_reader :name, :type
254
+
255
+ def initialize(node, name, type, qos, depth)
256
+ @node = node
257
+ @name = name
258
+ @type = type
259
+ @qos = qos
260
+ @depth = depth
261
+ end
262
+
263
+ # Yields each message and its Attachment, from a subscription made
264
+ # for this iteration (withdrawn when it ends: break, first(n), the
265
+ # time running out). Without a block, an Enumerator.
266
+ def each(timeout: nil, &blk)
267
+ return enum_for(:each, timeout: timeout) unless blk
268
+ sub = @node.subscribe(@name, @type, qos: @qos, depth: @depth)
269
+ begin
270
+ sub.each(timeout: timeout, &blk)
271
+ ensure
272
+ sub.close
273
+ end
274
+ self
275
+ end
276
+
277
+ def inspect
278
+ "#<Asterism::ROS::Topic #{@name}>"
279
+ end
280
+ end
281
+
282
+ class Client
283
+ # Prepended: a call made on another thread while the connection
284
+ # spins waits for the ticks instead of polling the node by itself.
285
+ # (Calls from a board's update loop are on one thread; here the
286
+ # application may call from several.)
287
+ # (The sequence number is read once in the shared call_async, so
288
+ # several threads may call through one client without a lock.)
289
+ module Waiting
290
+ def wait_for(c)
291
+ loop do
292
+ r = ::Asterism::Runner.for(@session)
293
+ if r && r.running? && !r.current?
294
+ r.wait_until { c.done? }
295
+ return if c.done?
296
+ else
297
+ return r ? r.lock.synchronize { super } : super
298
+ end
299
+ end
300
+ end
301
+ end
302
+ prepend Waiting
303
+ end
304
+ end
305
+ end
@@ -0,0 +1,253 @@
1
+ # The receiving loop behind the block API of the CRuby layer (CRuby only;
2
+ # the boards poll from their update loop instead).
3
+ #
4
+ # A Runner belongs to one Zenoh session. It holds tasks: small procs that
5
+ # take what has arrived (each_pending of the shared API) and hand it to the
6
+ # application's blocks. Every round of tasks (a tick) runs with the runner's
7
+ # lock held, so the shared, single-threaded layers are never entered by two
8
+ # threads at once. zenoh-c's own threads never call Ruby: they only fill the
9
+ # channels the tasks empty.
10
+ #
11
+ # runner.start ticks on a thread of its own (until stop or close)
12
+ # runner.run ticks on the calling thread (until stop, close or Ctrl-C)
13
+ # runner.stop ends either; joins the thread unless called from it
14
+ #
15
+ # A thread that waits for an answer while the runner ticks (proxy calls,
16
+ # ROS service calls) does not poll by itself: it waits on the runner's
17
+ # condition variable, and checks after each tick whether its answer came
18
+ # (wait_until). The runner's own thread, and any thread while the runner
19
+ # does not tick, poll as the shared layers always do.
20
+ require "monitor"
21
+
22
+ module Asterism
23
+ class Runner
24
+ # Pause between ticks when nothing arrived (seconds).
25
+ DEFAULT_INTERVAL = 0.002
26
+ # Longest a waiting thread sleeps before it checks again on its own.
27
+ WAIT_SLICE = 0.05
28
+
29
+ @registry = {}.compare_by_identity
30
+ @registry_lock = Mutex.new
31
+
32
+ class << self
33
+ # The runner of a Zenoh session (nil when it has none).
34
+ def for(session)
35
+ @registry_lock.synchronize { @registry[session] }
36
+ end
37
+
38
+ def register(session, runner)
39
+ @registry_lock.synchronize { @registry[session] = runner }
40
+ end
41
+
42
+ def unregister(session, runner)
43
+ @registry_lock.synchronize { @registry.delete(session) if @registry[session].equal?(runner) }
44
+ end
45
+ end
46
+
47
+ attr_reader :lock, :session
48
+
49
+ # session: the Asterism::Zenoh::Session the tasks read from. lock: the
50
+ # Monitor guarding the layer the tasks call into (the object layer has a
51
+ # process-wide one). on_closed is called once, on the ticking thread,
52
+ # when the session is found closed.
53
+ def initialize(session, lock: Monitor.new, interval: DEFAULT_INTERVAL, name: "asterism")
54
+ @session = session
55
+ @lock = lock
56
+ @cond = @lock.new_cond
57
+ @interval = interval
58
+ @name = name
59
+ @tasks = []
60
+ @error_handler = nil
61
+ @thread = nil
62
+ @ticking = nil # the thread inside start / run
63
+ @stop = false
64
+ @closed = false
65
+ self.class.register(session, self)
66
+ end
67
+
68
+ # Adds a task: a proc called every tick with the lock held. It returns
69
+ # the number of things it handled (0 or nil when nothing came), and
70
+ # false once the session is found closed. Returns the task, for remove.
71
+ def add(task = nil, &blk)
72
+ t = task || blk
73
+ @lock.synchronize { @tasks << t }
74
+ t
75
+ end
76
+
77
+ def remove(task)
78
+ @lock.synchronize { @tasks.delete(task) }
79
+ nil
80
+ end
81
+
82
+ # on_error { |error, where| }: errors raised by the application's blocks
83
+ # (StandardError) go here instead of stopping the runner. Without a
84
+ # handler they are printed with warn. where: what the block was for
85
+ # (e.g. "subscribe demo/**").
86
+ def on_error(&blk)
87
+ @error_handler = blk
88
+ self
89
+ end
90
+
91
+ # Runs the application's block; reports what it raises instead of
92
+ # letting it out. Returns the block's value, or nil when it raised.
93
+ def guard(where)
94
+ yield
95
+ rescue StandardError => e
96
+ report(e, where)
97
+ nil
98
+ end
99
+
100
+ def report(error, where)
101
+ if @error_handler
102
+ begin
103
+ @error_handler.call(error, where)
104
+ rescue StandardError => e2
105
+ warn "asterism: on_error raised #{e2.class}: #{e2.message}"
106
+ end
107
+ else
108
+ warn "asterism: #{where}: #{error.class}: #{error.message}"
109
+ end
110
+ end
111
+
112
+ # One round of the tasks. Returns the number of things handled, or nil
113
+ # once the session is closed.
114
+ def tick
115
+ n = 0
116
+ closed = false
117
+ @lock.synchronize do
118
+ begin
119
+ @tasks.dup.each do |t|
120
+ r = begin
121
+ t.call
122
+ rescue ::Asterism::Zenoh::Error
123
+ false # the session went away under the task
124
+ rescue StandardError => e
125
+ report(e, "receiver")
126
+ 0
127
+ end
128
+ if r == false
129
+ closed = true
130
+ break
131
+ end
132
+ n += r if r.is_a?(Integer)
133
+ end
134
+ ensure
135
+ @cond.broadcast
136
+ end
137
+ end
138
+ if closed
139
+ @closed = true
140
+ @stop = true
141
+ return nil
142
+ end
143
+ n
144
+ end
145
+
146
+ # Ticks on a thread of its own. Returns self. Does nothing when already
147
+ # ticking.
148
+ def start
149
+ @lock.synchronize do
150
+ return self if @ticking
151
+ @stop = false
152
+ @thread = Thread.new { loop_ticks }
153
+ @thread.name = "#{@name} receiver" if @thread.respond_to?(:name=)
154
+ @ticking = @thread
155
+ end
156
+ self
157
+ end
158
+
159
+ # Ticks on the calling thread until stop, the session closing, or
160
+ # Ctrl-C (which returns nil instead of raising). Raises Error when the
161
+ # runner already ticks on another thread.
162
+ def run
163
+ @lock.synchronize do
164
+ raise ::Asterism::Error, "already running (stop it first)" if @ticking
165
+ @stop = false
166
+ @ticking = Thread.current
167
+ end
168
+ begin
169
+ loop_ticks
170
+ rescue Interrupt
171
+ nil
172
+ ensure
173
+ @lock.synchronize do
174
+ @ticking = nil if @ticking.equal?(Thread.current)
175
+ @cond.broadcast
176
+ end
177
+ end
178
+ nil
179
+ end
180
+
181
+ # Ends start / run after the current tick. From another thread it waits
182
+ # for the receiving thread to end (unless that thread is inside a tick
183
+ # this thread is waiting for). Idempotent.
184
+ def stop
185
+ @stop = true
186
+ th = @thread
187
+ if th && !th.equal?(Thread.current) && !@lock.mon_owned?
188
+ th.join
189
+ end
190
+ @lock.synchronize do
191
+ @thread = nil if @thread.equal?(th) && !(th && th.alive?)
192
+ @cond.broadcast
193
+ end
194
+ self
195
+ end
196
+
197
+ # Stops and forgets the session. The caller closes the session after.
198
+ def close
199
+ stop
200
+ self.class.unregister(@session, self)
201
+ nil
202
+ end
203
+
204
+ # True while start or run ticks.
205
+ def running?
206
+ t = @ticking
207
+ !t.nil? && t.alive? && !@stop
208
+ end
209
+
210
+ # True on the thread that ticks.
211
+ def current?
212
+ @ticking.equal?(Thread.current)
213
+ end
214
+
215
+ def closed?
216
+ @closed
217
+ end
218
+
219
+ # For a thread other than the ticking one: waits until the block is
220
+ # true, checking it after each tick with the lock held. Returns when the
221
+ # block is true, or when the runner stops (the caller then looks at its
222
+ # own state, e.g. a time limit or a closed session). The block also sees
223
+ # its own deadline: it is checked at least every WAIT_SLICE seconds.
224
+ def wait_until
225
+ @lock.synchronize do
226
+ until yield
227
+ break unless running?
228
+ @cond.wait(WAIT_SLICE)
229
+ end
230
+ end
231
+ end
232
+
233
+ def inspect
234
+ "#<Asterism::Runner #{@name} #{running? ? 'running' : 'stopped'}>"
235
+ end
236
+
237
+ private
238
+
239
+ def loop_ticks
240
+ until @stop
241
+ n = tick
242
+ break if n.nil?
243
+ sleep(@interval) if n == 0 && !@stop
244
+ end
245
+ ensure
246
+ @lock.synchronize do
247
+ @ticking = nil if @ticking.equal?(Thread.current)
248
+ @thread = nil if @thread.equal?(Thread.current)
249
+ @cond.broadcast
250
+ end
251
+ end
252
+ end
253
+ end