asterism 0.4.1 → 0.5.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: fd2f9fe700f5ca0b88f98fc6598217c87be3257e31a655d33ba710f9260b76ea
4
- data.tar.gz: '0943fd816e28b2a60c5d812095d5c87d9ac0f1a8d1a0aaf7428989f34526aeb5'
3
+ metadata.gz: b55c825f8859d86ba09baad0585c0f416a168023b8032c3f871d63bebef0538c
4
+ data.tar.gz: 4d6fd26b326e62348e4fba8c6881c48b5b7145dd0b0c701eb7ba1dcc1bf5b261
5
5
  SHA512:
6
- metadata.gz: 86426bc1893654601c5a7b9234b09d82b10c6e8260553160f2484b2665ec0eba6bf4293fa1994f88b814997cbad2c69af2d4b2a492f034303ee20c61145f3f43
7
- data.tar.gz: 387f4b1de6234f98b3c4b71ce0aee0d6f4d70ec32250350786cff73ec4cf1d500a64277b90f389f2544465409279aba601ac67fd0b3470919f0902727a21334e
6
+ metadata.gz: 0acc8e1c4e4f028124d247a54e35ebda3451449dcfc46da503d345c857502200b1b68b4f15a4d59fbb7a603d9331f6996aa9072b1426e039fea87b43efbddd5e
7
+ data.tar.gz: 03b912800f24b881a7e01eb6479c2d8c3c2c32a3346538ea60ae5f65c431f3035e097f5fc90fdcaec7734efbf95496544c4b0dd174b12e6be20cabfec2db3635
data/CHANGELOG.md CHANGED
@@ -6,6 +6,41 @@ 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.5.0
10
+
11
+ Node profiles; every 0.4.x call works as before. Needs asterism-zenoh /
12
+ picoruby-asterism-zenoh 0.4.0 (unchanged).
13
+
14
+ Added
15
+
16
+ - Node profiles (README "Profiles"): `Asterism.profile(kind:, name:, host:,
17
+ icon:)` sets this node's profile (the fields given; the rest stay) and
18
+ returns it, `Asterism.profile` returns it, `Asterism.profile_of(node,
19
+ icon: true, timeout: 2.0)` asks another node's (nil when it does not
20
+ answer). The CRuby API: `net.profile`, `net.profile_of`. Answered on
21
+ `asterism/<node>/@profile` (a queryable of every connected application,
22
+ MessagePack Hash: `kind`, `host`, `name`, `icon`, `icon_hash`; the
23
+ parameter `icon=0` leaves the icon out), and put on the same key without
24
+ the icon at connect and on each change.
25
+ - Defaults: `kind` is `cruby` on CRuby and `picoruby` on mruby / PicoRuby;
26
+ the icon is the file at `Asterism.default_icon_path` when it is there:
27
+ the gem's `data/icon.png` on CRuby (new; a ruby, 32x32), and
28
+ `/usr/share/asterism/icon.png` on the boards.
29
+ - `Asterism::Profile`: `png_info` (the icon checks: PNG signature, chunks
30
+ from IHDR to IEND, 8 KB, 256x256; and `icon_hash`, the Adler-32 of the
31
+ chunks' CRCs), `check` (cleans a received profile: an icon that does not
32
+ pass is dropped), the limits as constants.
33
+ - `rake test:mruby` and `host_test/`: the shared layer on mruby (built with
34
+ picoruby-asterism-zenoh, MRB_UTF8_STRING as on the boards), profiles
35
+ between two mruby processes. CI runs it.
36
+
37
+ Changed
38
+
39
+ - The "Ruby profile" (0.4.0) is now called the Ruby feature table, so that
40
+ it is not taken for a node's profile: `docs/ruby_profile.md` is
41
+ `docs/ruby_features.md`. The probes' directory `profile/` and its code
42
+ keep their names.
43
+
9
44
  ## 0.4.1
10
45
 
11
46
  Additions and a fix; every 0.4.0 call works as before. Needs
data/README.md CHANGED
@@ -10,10 +10,12 @@ repository holds the pure Ruby layers, written once for every Ruby:
10
10
  | `mrblib/` | The layers: remote objects (`Asterism`), `Asterism::CDR`, `Asterism::ROS`. The source of truth for both forms below |
11
11
  | `mrbgem.rake` | The mrbgem `picoruby-asterism` (mruby / PicoRuby): compiles `mrblib/` |
12
12
  | `asterism.gemspec`, `lib/` | The CRuby gem `asterism`: `lib/asterism.rb` loads `mrblib/` as it is, `lib/asterism/cruby.rb` adds what CRuby needs, `lib/asterism/cruby/` the CRuby API (CRuby only) |
13
+ | `data/icon.png` | CRuby's default profile icon (32x32 PNG; [Profiles](#profiles)) |
13
14
  | `data/msgs/` | ROS 2 message types generated from ROS 2 Jazzy's definitions (Apache-2.0; tf2_msgs BSD-3-Clause; see NOTICE) |
14
15
  | `tools/` | The type generator `asterism_msggen.rb`, the Jazzy definitions it reads, `ros2_types.rb` (refreshes them from a ROS 2 image) |
15
- | `test/` | `test/msgs` (types, CDR; CRuby only), `test_asterism.rb` (objects and ROS between CRuby sessions), `test_api_*.rb` (the CRuby API) |
16
- | `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), `zenoh.rb` (plain Zenoh) |
16
+ | `test/` | `test/msgs` (types, CDR; CRuby only), `test_asterism.rb` (objects and ROS between CRuby sessions), `test_profile.rb` (node profiles), `test_api_*.rb` (the CRuby API) |
17
+ | `host_test/` | `rake test:mruby`: the shared layer on mruby (profiles between two mruby processes) |
18
+ | `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) |
17
19
 
18
20
  The Zenoh binding underneath is a separate gem with the same Ruby API on
19
21
  each Ruby:
@@ -35,7 +37,9 @@ Both gems are on rubygems.org (`gem install asterism` installs
35
37
  asterism-zenoh too). What changed in each version is in
36
38
  [CHANGELOG.md](CHANGELOG.md); 0.4.0 is the first step toward 1.0
37
39
  ([docs/api_review.md](docs/api_review.md)) and only adds and deprecates
38
- (see [Deprecations and 1.0](#deprecations-and-10)).
40
+ (see [Deprecations and 1.0](#deprecations-and-10)); 0.5.0 adds node
41
+ profiles ([Profiles](#profiles): what a node runs on, where, a name and an
42
+ icon).
39
43
 
40
44
  ## Which API?
41
45
 
@@ -83,7 +87,7 @@ build but not yet tested. To install from the repositories instead:
83
87
 
84
88
  ```
85
89
  (cd ../asterism-zenoh && rake gem) && rake gem
86
- gem install --local ../asterism-zenoh/pkg/asterism-zenoh-0.4.0.gem pkg/asterism-0.4.0.gem
90
+ gem install --local ../asterism-zenoh/pkg/asterism-zenoh-0.4.0.gem pkg/asterism-0.5.0.gem
87
91
  ```
88
92
 
89
93
  From the working trees, without installing:
@@ -111,8 +115,17 @@ rake test:msgs # generator, type hashes, CDR, bundled types (CRuby only;
111
115
  rake test:objects # objects and Asterism::ROS between CRuby sessions over a local peer link
112
116
  rake test:api # the CRuby API (blocks, receiving threads, Enumerators)
113
117
  ASTERISM_TEST_ROUTER=tcp/127.0.0.1:7447 rake test:objects test:api # the same through a zenohd router
118
+ rake test:mruby # the shared layer on mruby (builds mruby; not part of `rake`)
114
119
  ```
115
120
 
121
+ `test:mruby` builds mruby with picoruby-asterism-zenoh and this gem
122
+ (MRB_UTF8_STRING, as on Family mruby's boards) and runs `host_test/`: two
123
+ mruby processes over a local peer link. It takes the mruby and zenoh-pico
124
+ pinned by a picoruby-asterism-zenoh checkout next to this one (its
125
+ `rake mruby:fetch zenoh:fetch`), or `PICORUBY_ASTERISM_ZENOH_DIR` /
126
+ `MRUBY_DIR`. A small MessagePack for the test is in
127
+ `host_test/msgpack.rb` (the boards bring their own).
128
+
116
129
  `test:objects` and `test:api` need asterism-zenoh: built next to this repository
117
130
  (`../asterism-zenoh`, its `rake compile`), or `ASTERISM_ZENOH_DIR`, or the
118
131
  installed gem. The tests run with deprecated calls raising
@@ -131,6 +144,17 @@ ruby examples/node_polled.rb --router tcp/192.0.2.2:7447 # the same, polled
131
144
  # /cruby/add_two_ints served, AddTwoInts calls
132
145
  ruby examples/ros2_talker.rb --router tcp/192.0.2.2:7447 [--service NAME]
133
146
 
147
+ # ROS 2: drive a robot (made for family-mruby's MuJoCo rover,
148
+ # docker-compose.mujoco.yml): 1 m forward and 90 degrees left by /odom,
149
+ # compared with /ground_truth/odom; or the keyboard
150
+ ruby examples/ros2_rover.rb --router tcp/192.0.2.2:7447 [--keys]
151
+
152
+ # MuJoCo from CRuby (Fiddle), the robot as objects mujoco/rover/{drive,state,world};
153
+ # the MuJoCo release is fetched into examples/mujoco/vendor (examples/mujoco/README.md)
154
+ ruby examples/mujoco/fetch.rb
155
+ ruby examples/mujoco/rover.rb --router tcp/192.0.2.2:7447
156
+ ruby examples/mujoco/drive.rb --router tcp/192.0.2.2:7447 [--keys]
157
+
134
158
  # plain Zenoh: run two with different --name
135
159
  ruby examples/zenoh.rb --router tcp/192.0.2.2:7447 --name pc1
136
160
  ```
@@ -227,6 +251,7 @@ end
227
251
  | `net.each(pattern = "**")` | the proxies alive now; an Enumerator without a block. With a block it returns their number (1.0: `self`, as Ruby's `each` does; `net.count` is the number) |
228
252
  | `net.on_join { \|node\| }` / `net.on_leave { \|node\| }` | `Asterism.on_join` / `on_leave` of the portable API (the same blocks, told from `Asterism.poll`), here on the receiving thread; what they raise goes to `on_error`. `net.off_join(proc)` / `off_leave(proc)` remove one |
229
253
  | `net.start` / `stop` / `run` / `running?` / `on_error` | as for Zenoh. A method of an exposed object that raises is answered to its caller as a `RemoteError`, as before; `on_error` gets what `on_join` / `on_leave` raise |
254
+ | `net.profile(**fields)` / `net.profile_of(node, icon: true, timeout: 2.0)` | `Asterism.profile` / `profile_of` ([Profiles](#profiles)); `profile_of` waits for the receiving thread's ticks while it runs |
230
255
  | `net.nodes`, `node_id`, `app`, `connected?`, `lost_reason`, `poll`, `close` | |
231
256
 
232
257
  ### Over TLS (a router on the internet)
@@ -400,6 +425,60 @@ Asterism.nodes # => ["linux", "fmruby-bbbbbb"]
400
425
  | `Asterism.nodes` | `[String]` | Node IDs alive now, this one first. |
401
426
  | `Asterism.connected?` / `node_id` / `app` / `exposed` / `lost_reason` | | |
402
427
  | `Asterism.close` | nil | Withdraws every exposed object. Idempotent. |
428
+ | `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). |
429
+ | `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). |
430
+
431
+ ## Profiles
432
+
433
+ A node tells what it is (0.5.0): what Ruby runs it, where, a display name
434
+ and an icon. A console draws it with that; anyone can ask.
435
+
436
+ ```ruby
437
+ Asterism.profile(name: "Rover", icon: File.binread("rover.png")) # this node's (any time)
438
+ Asterism.profile
439
+ # => {"kind" => "cruby", "name" => "Rover", "icon" => "\x89PNG...", "icon_hash" => "2a8c0626"}
440
+ Asterism.profile_of("fmruby-aaaaaa")
441
+ # => {"kind" => "family_mruby", "host" => "atoms3r", "icon" => "\x89PNG...", "icon_hash" => "..."}
442
+ Asterism.profile_of("fmruby-aaaaaa", icon: false) # the same without the icon bytes
443
+ Asterism.profile_of("an-older-node") # => nil
444
+ ```
445
+
446
+ | Field | Value | Default |
447
+ |---|---|---|
448
+ | `kind` | what Ruby runs the node: `family_mruby`, `picoruby`, `cruby`, `console` (a lower-case word: `a-z`, `0-9`, `_`, up to 32 bytes; other words are allowed) | `cruby` on CRuby, `picoruby` on mruby / PicoRuby; Family mruby's firmware sets `family_mruby` |
449
+ | `host` | where it runs, e.g. `atoms3r`, `tab5`, `p4nano`, `browser`, `sim` (up to 32 bytes) | none; Family mruby's firmware sets it from the board |
450
+ | `name` | a display name (up to 64 bytes) | none |
451
+ | `icon` | the bytes of a PNG: at most 8 KB and 256x256 (32x32 is the size meant). PNG only: an SVG can carry scripts | the file at `Asterism.default_icon_path` when it is there: on CRuby the gem's `data/icon.png` (a ruby), on the boards `/usr/share/asterism/icon.png` |
452
+ | `icon_hash` | set by Asterism, never given: the icon's summary (8 hex digits: the Adler-32 of the PNG's chunk CRCs, so a board computes it from a few bytes) | |
453
+
454
+ - **Setting.** `Asterism.profile(**fields)` changes the fields given and keeps
455
+ the others; `nil` leaves `name` / `host` out, goes back to the default
456
+ `kind`, and means no icon for `icon:`. It raises `ArgumentError` for a
457
+ field it does not accept (an unknown name, a `kind` that is not a word, an
458
+ icon that is not a PNG, larger than 8 KB or 256x256). It may be called
459
+ before connecting and at any time after; the profile belongs to the
460
+ process (the VM on a board) and survives `close`. A firmware can set its
461
+ defaults with the same call before the application runs (Family mruby
462
+ sets `kind`, `host` and, through `/usr/share/asterism/icon.png`, the icon).
463
+ - **Reading.** `Asterism.profile_of(node)` asks `asterism/<node>/@profile`
464
+ and waits like a call (`timeout:` / `timeout_ms:`); this node's own comes
465
+ without the network. The answer is cleaned (`Asterism::Profile.check`):
466
+ only the fields above, each checked; an icon that is not an acceptable
467
+ PNG is dropped with its hash, an answer without a valid `kind` is `nil`.
468
+ `icon: false` asks without the icon (the parameter `icon=0`): a reader
469
+ that already has the icon of that `icon_hash` asks this way.
470
+ - **Following.** At each connect and at each change while connected, the
471
+ node also puts its profile without the icon on `asterism/<node>/@profile`;
472
+ subscribe to `asterism/*/@profile` to follow the changes (and fetch the
473
+ icon again only when `icon_hash` changed). Older nodes neither answer nor
474
+ put.
475
+ - **Several applications of one node** (a board running two): each answers
476
+ for the node, and `profile_of` takes the first answer. Give them the same
477
+ profile (Family mruby's firmware defaults are the same in each), or set it
478
+ in one and read the notices, where the last change wins.
479
+ - `Asterism::Profile.png_info(bytes)` gives `[width, height, icon_hash]` of
480
+ an acceptable PNG (nil otherwise), for a tool that wants to check an icon
481
+ first.
403
482
 
404
483
  ## Errors
405
484
 
@@ -441,6 +520,7 @@ is closed (1.0: `Disconnected`, as the object layer).
441
520
  | Receiving interval of the CRuby API | 2 ms | `interval:` |
442
521
  | Calls waiting inside each other | 4 | `MAX_NESTING` |
443
522
  | Encoding depth of a value | 16 | `Codec::MAX_DEPTH` |
523
+ | A profile icon | 8 KB, 256x256, PNG only | `Asterism::Profile::ICON_MAX`, `ICON_SIDE_MAX` |
444
524
  | `get` consolidation | `:none` (every reply; zenoh's own default is `:auto`, but services and the object layer want every reply) | `consolidation:` |
445
525
 
446
526
  ### Queues
@@ -530,8 +610,10 @@ caller gets `RemoteError` (`Asterism::EncodeError`). Nesting is limited to
530
610
  | `asterism/<node>/<app>/<object>/meta` | get / queryable | reply: `{"methods" => [[name, arity], ...]}` (arity `-1` when not declared). The object may be `*` in the query: one reply per object |
531
611
  | `asterism/<node>` | liveliness | the node is connected |
532
612
  | `asterism/<node>/<app>/<object>` | liveliness | the object is exposed |
613
+ | `asterism/<node>/@profile` | get / queryable, and put | query (parameter `icon=0` for no icon): reply the node's profile, a Hash (`kind`, `host`, `name`, `icon`, `icon_hash`; [Profiles](#profiles)). The node also puts the profile without the icon at connect and on each change. `@profile` is a verbatim chunk, so `asterism/**` does not match it: the object layer's wildcards never see it. An application's own profile would go at `asterism/<node>/<app>/@profile` (not in 0.5.0) |
533
614
 
534
- One queryable per application (`asterism/<node>/<app>/**`). A call whose
615
+ One queryable per application (`asterism/<node>/<app>/**`), and one for the
616
+ profile (`asterism/<node>/@profile`). A call whose
535
617
  node or app is a wildcard reaches every application that exposes that
536
618
  object, each answering with its own key; `Asterism` itself only sends calls
537
619
  to one object.
@@ -601,7 +683,7 @@ nested type (`geometry_msgs/msg/Twist`) at start-up took 0.6 KB more.
601
683
  different on Family mruby's PicoRuby: `^x` of an outer local inside a
602
684
  block, `^(expr)`, `Const[...]` and `**rest` next to another key. The full
603
685
  list of what the boards' Ruby can and cannot do is in
604
- [docs/ruby_profile.md](docs/ruby_profile.md).
686
+ [docs/ruby_features.md](docs/ruby_features.md) (the Ruby feature table).
605
687
 
606
688
  ## ROS 2 (rmw_zenoh): `Asterism::ROS` and `Asterism::CDR`
607
689
 
data/data/icon.png ADDED
Binary file
@@ -26,7 +26,8 @@ module Asterism
26
26
  # The portable API, entered under LOCK. Prepended to the Asterism module's
27
27
  # singleton class, so the shared methods themselves are not changed.
28
28
  module Locked
29
- %i[expose unexpose poll nodes [] call_async meta connected? exposed on_join on_leave off_join off_leave].each do |m|
29
+ %i[expose unexpose poll nodes [] call_async meta connected? exposed on_join on_leave off_join off_leave
30
+ profile profile_async].each do |m|
30
31
  define_method(m) do |*args, **kw, &blk|
31
32
  ::Asterism::LOCK.synchronize { super(*args, **kw, &blk) }
32
33
  end
@@ -147,6 +148,17 @@ module Asterism
147
148
  ::Asterism.unexpose(name)
148
149
  end
149
150
 
151
+ # Asterism.profile: this node's profile (with keywords, sets fields).
152
+ def profile(**fields)
153
+ ::Asterism.profile(**fields)
154
+ end
155
+
156
+ # Asterism.profile_of: another node's profile, or nil. Waits like a
157
+ # call (for the receiving thread's ticks while it runs).
158
+ def profile_of(node, icon: true, timeout: nil, timeout_ms: nil)
159
+ ::Asterism.profile_of(node, icon: icon, timeout: timeout, timeout_ms: timeout_ms)
160
+ end
161
+
150
162
  # A proxy for <node>/<app>/<object>; timeout: in seconds (or
151
163
  # timeout_ms:).
152
164
  def [](path, timeout: nil, timeout_ms: nil)
@@ -24,6 +24,44 @@ module Asterism
24
24
  Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
25
25
  end
26
26
 
27
+ # The profile's defaults on CRuby (mrblib/profile.rb): kind "cruby" and
28
+ # the icon bundled with the gem (replace it with Asterism.profile(icon:)).
29
+ ICON_FILE = File.expand_path("../../data/icon.png", __dir__)
30
+
31
+ def self.default_profile_kind
32
+ "cruby"
33
+ end
34
+
35
+ def self.default_icon_path
36
+ ICON_FILE
37
+ end
38
+
39
+ def self.read_icon_file(path)
40
+ File.binread(path)
41
+ rescue SystemCallError
42
+ nil
43
+ end
44
+
45
+ module Profile
46
+ # Icons come out of MessagePack as UTF-8 Strings: hand them out as the
47
+ # bytes they are.
48
+ class << self
49
+ alias_method :check_portable, :check
50
+
51
+ def check(h)
52
+ out = check_portable(h)
53
+ out["icon"] = out["icon"].b if out&.key?("icon")
54
+ out
55
+ end
56
+
57
+ alias_method :icon_portable!, :icon!
58
+
59
+ def icon!(v)
60
+ icon_portable!(v).b.freeze
61
+ end
62
+ end
63
+ end
64
+
27
65
  def self.error_reply(klass, message)
28
66
  ::MessagePack.pack(["error", klass.to_s, message.to_s], compatibility_mode: true)
29
67
  end
@@ -1,5 +1,6 @@
1
1
  module Asterism
2
- # The gem's version (the CRuby gem asterism; it depends on asterism-zenoh
3
- # of the same version).
4
- VERSION = "0.4.1"
2
+ # The gem's version (the CRuby gem asterism). The Zenoh binding it needs
3
+ # is in asterism.gemspec (asterism-zenoh ~> 0.4.0 for 0.5.0: the bindings
4
+ # did not change; see CHANGELOG.md).
5
+ VERSION = "0.5.0"
5
6
  end
data/lib/asterism.rb CHANGED
@@ -12,7 +12,7 @@ require "asterism/zenoh"
12
12
  require_relative "asterism/version"
13
13
 
14
14
  # The same order as an mrbgem's mrblib (alphabetical).
15
- %w[asterism cdr future proxy ros].each do |f|
15
+ %w[asterism cdr future profile proxy ros].each do |f|
16
16
  require_relative "../mrblib/#{f}"
17
17
  end
18
18
  require_relative "asterism/cruby"
data/mrblib/asterism.rb CHANGED
@@ -14,6 +14,7 @@
14
14
  # asterism/<node>/<app>/<object>/meta get: reply {"methods" => [[name, arity], ...]}
15
15
  # asterism/<node> liveliness: the node is up
16
16
  # asterism/<node>/<app>/<object> liveliness: the object is exposed
17
+ # asterism/<node>/@profile get: reply the node's profile (profile.rb)
17
18
  #
18
19
  # Everything is polled: nothing runs behind the application's back. A call
19
20
  # that waits for its answer keeps polling meanwhile and answers the calls
@@ -221,6 +222,7 @@ module Asterism
221
222
  @node_token = s.liveliness("#{ROOT}/#{node}")
222
223
  @queryable = s.queryable("#{ROOT}/#{node}/#{app}/**", depth: 32)
223
224
  @watch = s.liveliness_watch("#{ROOT}/**", depth: watch_depth)
225
+ @profile_queryable = s.queryable("#{ROOT}/#{node}/#{Profile::KEY}", depth: 4)
224
226
  rescue Asterism::Zenoh::Error => e
225
227
  s.close
226
228
  raise Disconnected, e.message
@@ -235,6 +237,7 @@ module Asterism
235
237
  @depth = 0
236
238
  @lost = nil
237
239
  @known = [] # the other nodes told to on_join so far
240
+ tell_profile # for those who follow the profiles (the reply is the queryable's)
238
241
  self
239
242
  end
240
243
 
@@ -300,6 +303,7 @@ module Asterism
300
303
  @node_token = nil
301
304
  @queryable = nil
302
305
  @watch = nil
306
+ @profile_queryable = nil
303
307
  end
304
308
 
305
309
  # @api private
@@ -447,6 +451,7 @@ module Asterism
447
451
  return false
448
452
  end
449
453
  answer_calls
454
+ answer_profile
450
455
  follow_liveliness
451
456
  !@session.nil?
452
457
  rescue Asterism::Zenoh::Error => e
data/mrblib/profile.rb ADDED
@@ -0,0 +1,362 @@
1
+ # A node's profile: what it runs on, where, a display name and an icon,
2
+ # told by the node itself (0.5.0).
3
+ #
4
+ # Asterism.profile(name: "Rover", icon: png) # this node's; returns it
5
+ # Asterism.profile # => {"kind" => "picoruby", "name" => "Rover", ...}
6
+ # Asterism.profile_of("fmruby-aaaaaa") # another node's, or nil
7
+ #
8
+ # Key: asterism/<node>/@profile (a queryable of every application connected
9
+ # as that node). The reply is a MessagePack Hash:
10
+ # "kind" what Ruby runs the node: "family_mruby", "picoruby",
11
+ # "cruby", "console" (others are allowed)
12
+ # "host" where it runs, optional ("atoms3r", "tab5", "sim", ...)
13
+ # "name" a display name, optional
14
+ # "icon" PNG bytes, optional (8 KB and 256x256 at most)
15
+ # "icon_hash" the icon's summary (Profile.png_info): a reader that has
16
+ # that icon already asks again with the parameter icon=0 and
17
+ # gets the rest without it
18
+ # Each connect, and each change while connected, also puts the profile
19
+ # without its icon on the same key, for those who follow it (a console).
20
+ #
21
+ # Room left for later: an application's own profile would be
22
+ # asterism/<node>/<app>/@profile, with the same Hash.
23
+ module Asterism
24
+ module Profile
25
+ # The kinds this version knows. Others pass: a kind is any lower-case
26
+ # word (a-z, 0-9, _; up to KIND_MAX bytes) starting with a letter.
27
+ KINDS = ["family_mruby", "picoruby", "cruby", "console"]
28
+ KEY = "@profile"
29
+ ICON_MAX = 8192 # bytes
30
+ ICON_SIDE_MAX = 256 # pixels, each side
31
+ KIND_MAX = 32
32
+ HOST_MAX = 32
33
+ NAME_MAX = 64
34
+ PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10]
35
+
36
+ # [width, height, icon_hash] of a PNG that a profile may carry, or nil.
37
+ # Checks the signature, the size (ICON_MAX bytes), that the chunks tile
38
+ # the bytes from IHDR to IEND, and the image's sides (ICON_SIDE_MAX).
39
+ #
40
+ # icon_hash is the Adler-32 (8 hex digits) of the chunks' CRC-32 fields,
41
+ # in order: each CRC already sums up its chunk, so the summary costs a
42
+ # few bytes of arithmetic instead of a pass over the whole file (small
43
+ # integers only, the same on every Ruby). On CRuby it equals
44
+ # Zlib.adler32 of those fields joined.
45
+ def self.png_info(b)
46
+ return nil unless b.is_a?(String)
47
+ size = b.bytesize
48
+ return nil if size < 8 + 25 + 12 || size > ICON_MAX
49
+ i = 0
50
+ while i < 8
51
+ return nil unless b.getbyte(i) == PNG_SIGNATURE[i]
52
+ i += 1
53
+ end
54
+ pos = 8
55
+ first = true
56
+ w = 0
57
+ h = 0
58
+ a = 1
59
+ s = 0
60
+ while pos + 12 <= size
61
+ # The length is 4 bytes; anything past 16 bits is larger than ICON_MAX.
62
+ return nil unless b.getbyte(pos) == 0 && b.getbyte(pos + 1) == 0
63
+ len = b.getbyte(pos + 2) * 256 + b.getbyte(pos + 3)
64
+ nxt = pos + 12 + len
65
+ return nil if nxt > size
66
+ if first
67
+ return nil unless type?(b, pos, 73, 72, 68, 82) && len == 13 # IHDR
68
+ return nil unless b.getbyte(pos + 8) == 0 && b.getbyte(pos + 9) == 0
69
+ return nil unless b.getbyte(pos + 12) == 0 && b.getbyte(pos + 13) == 0
70
+ w = b.getbyte(pos + 10) * 256 + b.getbyte(pos + 11)
71
+ h = b.getbyte(pos + 14) * 256 + b.getbyte(pos + 15)
72
+ return nil if w < 1 || h < 1 || w > ICON_SIDE_MAX || h > ICON_SIDE_MAX
73
+ first = false
74
+ end
75
+ k = nxt - 4
76
+ while k < nxt
77
+ a = (a + b.getbyte(k)) % 65521
78
+ s = (s + a) % 65521
79
+ k += 1
80
+ end
81
+ if type?(b, pos, 73, 69, 78, 68) # IEND
82
+ return nil unless nxt == size
83
+ return [w, h, hex4(s) + hex4(a)]
84
+ end
85
+ pos = nxt
86
+ end
87
+ nil
88
+ end
89
+
90
+ # @api private
91
+ def self.type?(b, pos, c0, c1, c2, c3)
92
+ b.getbyte(pos + 4) == c0 && b.getbyte(pos + 5) == c1 && b.getbyte(pos + 6) == c2 && b.getbyte(pos + 7) == c3
93
+ end
94
+
95
+ # @api private
96
+ def self.hex4(n)
97
+ t = n.to_s(16)
98
+ t = "0" + t while t.length < 4
99
+ t
100
+ end
101
+
102
+ # A received profile, cleaned: the known fields that pass their checks
103
+ # (a field that does not is left out; an icon that is not a valid PNG
104
+ # goes with its hash). nil when it is not a profile (no valid kind).
105
+ def self.check(h)
106
+ return nil unless h.is_a?(Hash)
107
+ kind = h["kind"]
108
+ return nil unless kind.is_a?(String) && kind?(kind)
109
+ out = { "kind" => kind }
110
+ host = h["host"]
111
+ out["host"] = host if host.is_a?(String) && !host.empty? && host.bytesize <= HOST_MAX
112
+ name = h["name"]
113
+ out["name"] = name if name.is_a?(String) && !name.empty? && name.bytesize <= NAME_MAX
114
+ icon = h["icon"]
115
+ if icon.is_a?(String)
116
+ info = png_info(icon)
117
+ if info
118
+ out["icon"] = icon
119
+ out["icon_hash"] = info[2]
120
+ end
121
+ elsif hash_text?(h["icon_hash"])
122
+ out["icon_hash"] = h["icon_hash"]
123
+ end
124
+ out
125
+ end
126
+
127
+ # @api private
128
+ def self.kind?(s)
129
+ n = s.bytesize
130
+ return false if n < 1 || n > KIND_MAX
131
+ i = 0
132
+ while i < n
133
+ c = s.getbyte(i)
134
+ lower = c >= 97 && c <= 122
135
+ return false unless lower || (i > 0 && ((c >= 48 && c <= 57) || c == 95))
136
+ i += 1
137
+ end
138
+ true
139
+ end
140
+
141
+ # @api private
142
+ def self.hash_text?(s)
143
+ return false unless s.is_a?(String) && s.bytesize == 8
144
+ i = 0
145
+ while i < 8
146
+ c = s.getbyte(i)
147
+ return false unless (c >= 48 && c <= 57) || (c >= 97 && c <= 102)
148
+ i += 1
149
+ end
150
+ true
151
+ end
152
+
153
+ # The checks of Asterism.profile (ArgumentError, with the reason).
154
+ # @api private
155
+ def self.kind!(v)
156
+ s = v.is_a?(Symbol) ? v.to_s : v
157
+ unless s.is_a?(String) && kind?(s)
158
+ raise ArgumentError, "profile kind: #{v.inspect} is not a lower-case word (a-z, 0-9, _; " \
159
+ "#{KIND_MAX} bytes at most), e.g. :cruby"
160
+ end
161
+ s
162
+ end
163
+
164
+ # @api private
165
+ def self.text!(what, v, max)
166
+ s = v.is_a?(Symbol) ? v.to_s : v
167
+ raise ArgumentError, "profile #{what}: expected a String, got #{v.class}" unless s.is_a?(String)
168
+ raise ArgumentError, "profile #{what}: empty (pass nil to leave it out)" if s.empty?
169
+ raise ArgumentError, "profile #{what}: #{s.bytesize} bytes (#{max} at most)" if s.bytesize > max
170
+ s
171
+ end
172
+
173
+ # @api private
174
+ def self.icon!(v)
175
+ raise ArgumentError, "profile icon: expected the bytes of a PNG (a String), got #{v.class}" unless v.is_a?(String)
176
+ if v.bytesize > ICON_MAX
177
+ raise ArgumentError, "profile icon: #{v.bytesize} bytes (#{ICON_MAX} at most)"
178
+ end
179
+ unless png_info(v)
180
+ raise ArgumentError, "profile icon: not a PNG, or not one this accepts (up to " \
181
+ "#{ICON_SIDE_MAX}x#{ICON_SIDE_MAX}, chunks from IHDR to IEND)"
182
+ end
183
+ v
184
+ end
185
+
186
+ # Whether a query's parameters ask for the icon (icon=0 says no).
187
+ # @api private
188
+ def self.wants_icon?(params)
189
+ return true if params.nil? || params.empty?
190
+ params.split(";").each do |pair|
191
+ kv = pair.split("=")
192
+ next unless kv[0] == "icon"
193
+ v = kv[1].to_s
194
+ return false if v == "0" || v == "no" || v == "false"
195
+ end
196
+ true
197
+ end
198
+ end
199
+
200
+ # This node's profile. With keywords, sets those fields and returns the
201
+ # profile; without, returns it. The fields: kind: (a Symbol or String;
202
+ # nil goes back to the default), name:, host: (Strings; nil leaves them
203
+ # out), icon: (the bytes of a PNG; nil for no icon). The fields not given
204
+ # stay as they are. Raises ArgumentError for a field it does not accept
205
+ # (see Asterism::Profile).
206
+ #
207
+ # The defaults: kind "picoruby" on mruby / PicoRuby, "cruby" on CRuby; no
208
+ # host, no name; the icon is the file at Asterism.default_icon_path when
209
+ # there is one (the boards: /usr/share/asterism/icon.png; CRuby: the gem's
210
+ # data/icon.png). A board's firmware may set its own defaults with this
211
+ # same call before the application runs (Family mruby does).
212
+ #
213
+ # Kept across connect and close (it belongs to the process, or the VM on
214
+ # a board). While connected, a change is also put on the profile's key.
215
+ def self.profile(**fields)
216
+ return profile_hash(true) if fields.empty?
217
+ changes = {}
218
+ fields.each do |k, v|
219
+ key = k.to_s
220
+ if key == "kind"
221
+ changes[key] = v.nil? ? nil : Profile.kind!(v)
222
+ elsif key == "name"
223
+ changes[key] = v.nil? ? nil : Profile.text!("name", v, Profile::NAME_MAX)
224
+ elsif key == "host"
225
+ changes[key] = v.nil? ? nil : Profile.text!("host", v, Profile::HOST_MAX)
226
+ elsif key == "icon"
227
+ changes[key] = v.nil? ? nil : Profile.icon!(v)
228
+ else
229
+ raise ArgumentError, "unknown profile field #{key} (kind, name, host, icon)"
230
+ end
231
+ end
232
+ set = (@profile_set ||= {})
233
+ changes.each do |k, v|
234
+ if v.nil? && k != "icon"
235
+ set.delete(k)
236
+ else
237
+ set[k] = v # icon: nil stays, so the default file is not used
238
+ end
239
+ end
240
+ @profile_icon = nil if changes.key?("icon")
241
+ tell_profile
242
+ profile_hash(true)
243
+ end
244
+
245
+ # Another node's profile (Asterism::Profile.check has cleaned it), or nil
246
+ # when it does not answer in time (an older version of Asterism, a ROS 2
247
+ # node, a node that is gone). icon: false asks for it without the icon
248
+ # bytes (icon_hash still says which icon it is). timeout: in seconds (or
249
+ # timeout_ms:), 2.0 by default. Waits like a call (polling, answering
250
+ # calls meanwhile); raises Disconnected when not connected. This node's
251
+ # own profile comes without the network.
252
+ def self.profile_of(node, icon: true, timeout: nil, timeout_ms: nil)
253
+ f = profile_async(node, icon, timeout, timeout_ms)
254
+ begin
255
+ raw = f.raw_value
256
+ rescue Disconnected
257
+ raise
258
+ rescue Error
259
+ return nil # no answer in time, or not MessagePack
260
+ end
261
+ Profile.check(raw)
262
+ end
263
+
264
+ # @api private
265
+ def self.profile_async(node, icon, timeout, timeout_ms)
266
+ node = check_name("node", node)
267
+ ms = time_ms("Asterism.profile_of", timeout, timeout_ms, nil, DEFAULT_TIMEOUT_MS)
268
+ path = "#{node}/#{Profile::KEY}"
269
+ return Future.answered(path, "profile", Codec.pack(profile_hash(icon))) if @session && node == @node
270
+ begin
271
+ g = session!.get("#{ROOT}/#{path}", timeout_ms: ms, params: icon ? nil : "icon=0")
272
+ rescue Asterism::Zenoh::Error => e
273
+ lost(e.message)
274
+ raise Disconnected, e.message
275
+ end
276
+ Future.new(path, "profile", g, now_ms + ms, ms)
277
+ end
278
+
279
+ # The kind when the application did not set one.
280
+ # @api private
281
+ def self.default_profile_kind
282
+ "picoruby"
283
+ end
284
+
285
+ # Where the default icon is read from (when the application gave none);
286
+ # nil for none. CRuby: the gem's data/icon.png.
287
+ def self.default_icon_path
288
+ "/usr/share/asterism/icon.png"
289
+ end
290
+
291
+ # The bytes of a file, nil when it cannot be read.
292
+ # @api private
293
+ def self.read_icon_file(path)
294
+ return nil unless Object.const_defined?(:File) && File.exist?(path)
295
+ File.open(path, "r") { |f| f.read }
296
+ rescue
297
+ nil
298
+ end
299
+
300
+ # The profile as sent: String keys, the icon only when with_icon.
301
+ # @api private
302
+ def self.profile_hash(with_icon)
303
+ set = @profile_set || {}
304
+ h = { "kind" => set["kind"] || default_profile_kind }
305
+ h["host"] = set["host"] if set["host"]
306
+ h["name"] = set["name"] if set["name"]
307
+ icon = profile_icon
308
+ if icon
309
+ h["icon"] = icon[0] if with_icon
310
+ h["icon_hash"] = icon[1]
311
+ end
312
+ h
313
+ end
314
+
315
+ # [bytes, icon_hash] of this node's icon, or nil. The default file is
316
+ # read once, when the profile is first needed.
317
+ # @api private
318
+ def self.profile_icon
319
+ if @profile_icon.nil?
320
+ set = @profile_set || {}
321
+ bytes = set.key?("icon") ? set["icon"] : nil
322
+ unless set.key?("icon")
323
+ path = default_icon_path
324
+ bytes = path ? read_icon_file(path) : nil
325
+ end
326
+ info = bytes ? Profile.png_info(bytes) : nil
327
+ @profile_icon = info ? [bytes, info[2]] : false
328
+ end
329
+ @profile_icon ? @profile_icon : nil
330
+ end
331
+
332
+ # Puts the profile without its icon on its key (while connected).
333
+ # @api private
334
+ def self.tell_profile
335
+ s = @session
336
+ return unless s
337
+ s.put("#{ROOT}/#{@node}/#{Profile::KEY}", Codec.pack(profile_hash(false)))
338
+ rescue Asterism::Zenoh::Error
339
+ nil # the next poll notices a lost connection
340
+ end
341
+
342
+ # @api private
343
+ def self.answer_profile
344
+ qa = @profile_queryable
345
+ return unless qa
346
+ queries = qa.each_pending
347
+ i = 0
348
+ while i < queries.size
349
+ q = queries[i]
350
+ i += 1
351
+ begin
352
+ q.reply("#{ROOT}/#{@node}/#{Profile::KEY}", Codec.pack(profile_hash(Profile.wants_icon?(q.params))))
353
+ ensure
354
+ begin
355
+ q.finish
356
+ rescue Asterism::Zenoh::Error
357
+ # the session went away meanwhile
358
+ end
359
+ end
360
+ end
361
+ end
362
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: asterism
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Katsuhiko Kageyama
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-09 00:00:00.000000000 Z
11
+ date: 2026-10-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: asterism-zenoh
@@ -50,6 +50,7 @@ files:
50
50
  - LICENSE
51
51
  - NOTICE
52
52
  - README.md
53
+ - data/icon.png
53
54
  - data/msgs/LICENSE-Apache-2.0.txt
54
55
  - data/msgs/LICENSE-BSD-3-Clause-tf2_msgs.txt
55
56
  - data/msgs/NOTICE
@@ -147,6 +148,7 @@ files:
147
148
  - mrblib/asterism.rb
148
149
  - mrblib/cdr.rb
149
150
  - mrblib/future.rb
151
+ - mrblib/profile.rb
150
152
  - mrblib/proxy.rb
151
153
  - mrblib/ros.rb
152
154
  - tools/asterism_msggen.rb