tassello 0.4.0 → 0.6.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.
- package/CHANGELOG.md +38 -0
- package/README.md +92 -4
- package/dist/server/actions.d.ts +24 -0
- package/dist/server/actions.js +331 -0
- package/dist/server/files.d.ts +1 -1
- package/dist/server/files.js +4 -1
- package/dist/server/interfaces.d.ts +16 -0
- package/dist/server/interfaces.js +145 -0
- package/dist/server/message_code.d.ts +12 -0
- package/dist/server/message_code.js +165 -0
- package/dist/server/message_format.d.ts +24 -0
- package/dist/server/message_format.js +345 -0
- package/dist/server/overlay_encoding.d.ts +26 -0
- package/dist/server/overlay_encoding.js +40 -0
- package/dist/server/overlays.d.ts +21 -0
- package/dist/server/overlays.js +172 -0
- package/dist/server/param_format.d.ts +33 -0
- package/dist/server/param_format.js +155 -0
- package/dist/server/param_yaml.d.ts +14 -0
- package/dist/server/param_yaml.js +168 -0
- package/dist/server/parameters.d.ts +38 -0
- package/dist/server/parameters.js +299 -0
- package/dist/server/ros.d.ts +4 -2
- package/dist/server/ros.js +13 -5
- package/dist/server/ros_graph.d.ts +25 -0
- package/dist/server/ros_graph.js +148 -0
- package/dist/server/ros_graph_format.d.ts +51 -0
- package/dist/server/ros_graph_format.js +139 -0
- package/dist/server/server.js +26 -1
- package/dist/server/session.d.ts +14 -0
- package/dist/server/session.js +116 -1
- package/dist/server/tf.d.ts +6 -0
- package/dist/server/tf.js +19 -1
- package/dist/server/topic_monitor.d.ts +25 -0
- package/dist/server/topic_monitor.js +229 -0
- package/dist/server/topic_publishing.d.ts +20 -0
- package/dist/server/topic_publishing.js +153 -0
- package/dist/server/topic_stats.d.ts +13 -0
- package/dist/server/topic_stats.js +106 -0
- package/dist/server/topics.js +2 -1
- package/dist/server/transforms.d.ts +1 -0
- package/dist/server/transforms.js +5 -0
- package/dist/shared/monitor.d.ts +144 -0
- package/dist/shared/monitor.js +12 -0
- package/dist/shared/parameters.d.ts +29 -0
- package/dist/shared/parameters.js +16 -0
- package/dist/shared/protocol.d.ts +134 -2
- package/dist/shared/protocol.js +2 -0
- package/dist/types/client/api.d.ts +1 -1
- package/dist/types/client/connection.d.ts +44 -2
- package/dist/types/client/icons.d.ts +6 -0
- package/dist/types/client/sdk/index.d.ts +4 -2
- package/dist/types/client/widgets/types.d.ts +8 -0
- package/dist/types/shared/monitor.d.ts +144 -0
- package/dist/types/shared/parameters.d.ts +29 -0
- package/dist/types/shared/protocol.d.ts +134 -2
- package/dist/www/assets/Joystick.js +1 -1
- package/dist/www/assets/RobotModelView.js +2 -2
- package/dist/www/assets/TerminalView.js +2 -2
- package/dist/www/assets/dialogs.js +1 -1
- package/dist/www/assets/icons.js +1 -1
- package/dist/www/assets/index.css +1 -1
- package/dist/www/assets/index.js +47 -45
- package/dist/www/assets/shared/tassello-sdk.js +1 -1
- package/docs/plugins.md +26 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,42 @@ change the plugin API (`tassello/sdk`, `tassello/server`, `tassello/vite`), as n
|
|
|
5
5
|
|
|
6
6
|
## Unreleased
|
|
7
7
|
|
|
8
|
+
- Parameters widget: nodes' parameters, as rqt_reconfigure and `ros2 param` show them. A node's
|
|
9
|
+
parameters grouped by their prefixes, each edited in place (a switch, a slider within its range,
|
|
10
|
+
a field), with what the node refuses and why; changes made elsewhere followed live on
|
|
11
|
+
`/parameter_events`. Their descriptions, ranges and constraints (`describe`); new ones set and
|
|
12
|
+
undeclared ones deleted (`set`, `delete`); all of them dumped as YAML, downloaded, saved on the
|
|
13
|
+
robot or copied (`dump`), and loaded from a YAML file of the robot or of the page's device
|
|
14
|
+
(`load`), as `ros2 param` does, with Python's yaml. A widget can be for one node, or view only.
|
|
15
|
+
- The file browser picks parameter files (YAML) too.
|
|
16
|
+
- Plugins: `connection.subscribeParameters`, `setParameters`, `dumpParameters`, `loadParameters`,
|
|
17
|
+
and the types of `src/shared/parameters.ts`.
|
|
18
|
+
- Navigation widget: the laser scan, put in the map frame by TF as each arrives (in 3D: a lidar
|
|
19
|
+
mounted upside down lands where it looks), the quickest check of the localization; and the
|
|
20
|
+
robot drawn with Nav2's footprint (`published_footprint`), moving with the robot. A scan no
|
|
21
|
+
longer coming turns gray; what cannot be shown is said over the map.
|
|
22
|
+
- Plugins: `connection.subscribeOverlay`, a laser scan's points or a footprint's polygon in a
|
|
23
|
+
frame.
|
|
24
|
+
- ROS Monitor widget: the ROS graph, as the `ros2` command line shows it, on three sheets: its
|
|
25
|
+
topics, services and actions, filtered by name or type, the hidden ones on demand. A topic's
|
|
26
|
+
messages (`ros2 topic echo`: a field of them, lists cut, the latest 100 kept while paused), their
|
|
27
|
+
rate, bandwidth and delay over a window (`hz`, `bw`, `delay`: from the serialized messages,
|
|
28
|
+
exact), its publishers and subscriptions with their QoS and those that cannot talk (`info -v`),
|
|
29
|
+
a message to publish, field by field or as JSON, once or at a rate (`pub`: QoS matching the
|
|
30
|
+
subscriptions, `now` stamps), going on while other topics are shown. A service to call, once
|
|
31
|
+
or at a rate (`ros2 service call`), its nodes (`info`) and its events (`echo`, since Jazzy).
|
|
32
|
+
Every goal of an action, whoever sent it, with its latest feedback, to cancel one or all; a
|
|
33
|
+
goal to send and follow to its result (`ros2 action send_goal`). Their types' definitions
|
|
34
|
+
(`ros2 interface show`).
|
|
35
|
+
- rclnodejs's code of the ROS interfaces sourced since it was installed (a robot's own messages,
|
|
36
|
+
services and actions), or rebuilt with new ones, is made when the server starts: their topics,
|
|
37
|
+
services and actions work in every widget without `npx generate-ros-messages`. rclnodejs's own
|
|
38
|
+
fallback missed services' and actions' interfaces.
|
|
39
|
+
- Plugins: `connection.subscribeGraph`, `subscribeTopicInfo`, `subscribeTopicMonitor`,
|
|
40
|
+
`publishTopic`, `interfaceInfo`, `subscribeAction`, `sendGoal`, `cancelGoal`, and `callService`'s
|
|
41
|
+
`checked` option, which makes the request from the service's definition: 64 bit integers, which
|
|
42
|
+
rclnodejs takes as BigInt only, now work. The types of `src/shared/monitor.ts`, and the
|
|
43
|
+
`MinusIcon`, `TopicIcon`, `ServiceIcon`, `ActionIcon`, `MonitorIcon` and `SendIcon` icons.
|
|
8
44
|
- Behavior Tree Inspector widget: what a BT.CPP behavior tree is doing, from its
|
|
9
45
|
`nav2_msgs/BehaviorTreeLog` (Nav2's `/behavior_tree_log`), on the tree of its XML file: the
|
|
10
46
|
running branch, each node's status, last result, counts and ports, and the latest transitions,
|
|
@@ -81,6 +117,8 @@ change the plugin API (`tassello/sdk`, `tassello/server`, `tassello/vite`), as n
|
|
|
81
117
|
- Plugins: `connection.subscribeNetwork`, `networkAction`, the `Network*`, `WifiNetwork`,
|
|
82
118
|
`WifiSecurity` and `ConnectionKind` types, and the `NetworkIcon`, `WifiIcon`, `WifiOffIcon`,
|
|
83
119
|
`EthernetIcon`, `HotspotIcon` and `ShieldIcon` icons.
|
|
120
|
+
- Plugins: a `select` property's options can be known at run time, e.g. the robot's: `useOptions`,
|
|
121
|
+
a hook the properties panel calls. Its values are not checked against the options.
|
|
84
122
|
|
|
85
123
|
## 0.1.0
|
|
86
124
|
|
package/README.md
CHANGED
|
@@ -11,8 +11,9 @@ and plugins add more.
|
|
|
11
11
|
- **Every device**: a desktop canvas, and layouts of their own for tablets and phones.
|
|
12
12
|
- **Built in**: teleop pads, a Nav2 map with goals, the robot's URDF in 3D, an arm jogger, cameras,
|
|
13
13
|
images, command buttons, a systemd application manager, a behavior tree inspector and
|
|
14
|
-
editor, the ROS log of every node, the robot's diagnostics,
|
|
15
|
-
|
|
14
|
+
editor, the ROS log of every node, the robot's diagnostics, its topics, services and actions
|
|
15
|
+
as the `ros2` command line shows them, its nodes' parameters, a file manager, a terminal, and its networks: Wi-Fi
|
|
16
|
+
joined, connections switched, the robot as an access point.
|
|
16
17
|
- **Plugins**: widgets of your own, with server-side code if they need it, as npm or ROS packages.
|
|
17
18
|
- **One process**: a ROS 2 node in Node.js. Nothing to install on the robot but Node.js.
|
|
18
19
|
|
|
@@ -40,6 +41,14 @@ tassello
|
|
|
40
41
|
and open `http://<robot>:8080`. The first start creates the panels directory with two example
|
|
41
42
|
panels, Teleoperation and Navigation.
|
|
42
43
|
|
|
44
|
+
Source the workspaces whose messages the widgets use too, a robot's own (`/opt/<vendor>/…`, a
|
|
45
|
+
colcon workspace's `install/setup.bash`): rclnodejs needs JavaScript code of every interface it
|
|
46
|
+
uses, which it makes when installed for the packages then sourced. Tassello makes that of the
|
|
47
|
+
others when it starts, and again for a package rebuilt with new interfaces, in a fraction of a
|
|
48
|
+
second; rclnodejs keeps it in its `generated` folder, which the server's user must be able to
|
|
49
|
+
write (else, run `npx generate-ros-messages` in Tassello's folder, with them sourced, as a user who
|
|
50
|
+
can).
|
|
51
|
+
|
|
43
52
|
```text
|
|
44
53
|
tassello [options] [--ros-args ...]
|
|
45
54
|
|
|
@@ -110,7 +119,7 @@ after the others on the devices already arranged.
|
|
|
110
119
|
| Widget | What it does |
|
|
111
120
|
|---|---|
|
|
112
121
|
| **Teleop** | Publishes velocity commands (`Twist` or `TwistStamped`) while its pads are held: one pad (x, z), or two in omnidirectional mode (x and y, z). Then one zero, and nothing, so that the lower priorities of a twist_mux, e.g. navigation, take over again |
|
|
113
|
-
| **Navigation** | The map, optionally the global and local costmaps and the planned path, and the robot where TF puts it. Its tools set the pose estimate and send navigation goals, as RViz's do: press where the robot is or should go, drag towards where it faces. Pan, zoom, fit the map or follow the robot |
|
|
122
|
+
| **Navigation** | The map, optionally the global and local costmaps and the planned path, the laser scan, and the robot where TF puts it, drawn with Nav2's footprint. Its tools set the pose estimate and send navigation goals, as RViz's do: press where the robot is or should go, drag towards where it faces. Pan, zoom, fit the map or follow the robot |
|
|
114
123
|
| **Robot Model** | The robot in 3D, from its URDF and meshes, posed by the joint states. The links of moving joints turn orange, with an arrow at the joint in the direction of the motion |
|
|
115
124
|
| **Arm Teleop** | Jogs an arm driven by ros2_control `JointTrajectoryController`s: a slider per joint, or pads moving the end effector in a straight line (damped least squares on the URDF's kinematics, collision checked by MoveIt's move_group when it runs) |
|
|
116
125
|
| **Camera** | An image topic, raw (`sensor_msgs/Image`) or compressed (`sensor_msgs/CompressedImage`) |
|
|
@@ -121,6 +130,8 @@ after the others on the devices already arranged.
|
|
|
121
130
|
| **Behavior Tree Editor** | Edits a BT.CPP behavior tree's XML file of the robot as a graph: nodes from a palette (BT.CPP's, Nav2's, your libraries'), dragged where they go, moved with their children, their names and ports edited in a side panel. Subtrees, undo and redo, copy and paste, an XML view. Saves only what changed, never over changes made meanwhile |
|
|
122
131
|
| **Logs** | The ROS log (`/rosout`), as rqt_console and swri_console show it: every node's messages coloured by level, the nodes running and those that logged with their counts, filtered by node, by level and by text (terms or a regular expression, to show or to hide). Pause, follow the newest, clear, download, and a message's details: where it was logged |
|
|
123
132
|
| **Diagnostics** | The robot's diagnostics (`/diagnostics`, or the aggregator's `/diagnostics_agg`), as rqt_robot_monitor shows them: each status's level, the groups with the worst under them, those with problems unfolded. As a tree, as a list (the worst first) or as the latest changes of level, filtered by level and by text; a widget can be for some statuses only. A status's details: its message, hardware ID, values and latest changes. Pause, download, and forget the statuses no longer published |
|
|
133
|
+
| **ROS Monitor** | The ROS graph, as the `ros2` command line shows it, on three sheets: its topics, services and actions, filtered by name or type, the hidden ones on demand. A topic's messages, their rate, bandwidth and delay, its publishers and subscriptions with their QoS and those that cannot talk, a message to publish once or at a rate (`ros2 topic echo`, `hz`, `bw`, `delay`, `info -v`, `pub`); a service to call, once or at a rate (`ros2 service call`, `info`, `echo`); every goal of an action, whoever sent it, with its feedback, and a goal to send or cancel (`ros2 action send_goal`, `info`); their types' definitions (`ros2 interface show`) |
|
|
134
|
+
| **Parameters** | Nodes' parameters, as rqt_reconfigure and `ros2 param` show them: the nodes of the graph, and the parameters of the one chosen grouped by their prefixes, each edited in place (a switch, a slider within its range, a field), live as they change wherever they are set. Their descriptions, ranges and constraints (`ros2 param describe`); new ones set and undeclared ones deleted (`set`, `delete`); all of them dumped as YAML, downloaded or saved on the robot (`dump`), and loaded from a YAML file of the robot or of the page's device (`load`) |
|
|
124
135
|
| **File Manager** | The robot's files: folders unfolded in place, places (home, the ROS workspaces, bookmarks) and the disks' free space, an entry's details and permissions, what takes a folder's space. Make, rename, cut, copy, paste, drag onto folders, delete, download (folders as `.tar.gz`) and upload (drop files and folders onto it). Text files edited in tabs as gedit does: highlighting, search and replace, undo; images and PGM maps shown |
|
|
125
136
|
| **Terminal** | A shell on the robot, or a command (`htop`, `tmux attach`), in a full terminal: colours, full screen programs, the mouse. It outlives the page: reloaded, reconnected or shown again, it is as it was. On touch screens, the keys phones lack, as Termux has them: Esc, Tab, Ctrl, Alt, Home, End, the arrows, Page Up and Down, and while typing the terminal fills the screen above the on-screen keyboard |
|
|
126
137
|
| **Networking** | The robot's networks, through NetworkManager, as a phone's settings show them: the Wi-Fi networks in range, joined with their password; the connections saved, switched between, edited (their addresses by DHCP or static), forgotten; the robot as an access point, its network shared by a QR code; the interfaces, their state and addresses. Asks before a change that would cut the page off from the robot |
|
|
@@ -133,6 +144,16 @@ Notes on some of them:
|
|
|
133
144
|
bt_navigator follows) and pose estimates as `geometry_msgs/PoseWithCovarianceStamped`
|
|
134
145
|
(`/initialpose`, with RViz's covariance, which AMCL starts from). Layers in another frame, like
|
|
135
146
|
Nav2's local costmap, are placed in the map frame by TF.
|
|
147
|
+
|
|
148
|
+
The laser scan (`sensor_msgs/LaserScan`, `/scan`) is the quickest check of the localization:
|
|
149
|
+
its points fall on the map's walls when the robot is where the map frame says. Each scan is put
|
|
150
|
+
in the map frame by TF as it arrives, on the server, in 3D (a lidar mounted upside down or
|
|
151
|
+
tilted lands where it looks), its points without a return left out, at most 5 a second; a scan
|
|
152
|
+
no longer coming turns gray after 1.5 s. The robot is drawn with the footprint Nav2's costmaps
|
|
153
|
+
use (`geometry_msgs/PolygonStamped`, `/local_costmap/published_footprint`), put in the robot's
|
|
154
|
+
frame as it arrives and drawn where the robot is now, so that it moves with it; a circle
|
|
155
|
+
without it, or zoomed out too far to see it. What cannot be shown, e.g. a scan whose frame TF
|
|
156
|
+
does not connect to the map, is said above the robot's pose.
|
|
136
157
|
- **Robot Model** loads the meshes of the description from the robot's ROS packages
|
|
137
158
|
(`package://` paths), which the server serves compressed. three.js is only fetched by the pages
|
|
138
159
|
showing one.
|
|
@@ -234,6 +255,71 @@ Notes on some of them:
|
|
|
234
255
|
filter and view it opens with. The server follows the diagnostics for every page, and for 10
|
|
235
256
|
minutes after the last page closes, keeping the latest status of each name and their latest
|
|
236
257
|
1,000 changes of level.
|
|
258
|
+
- **ROS Monitor** lists the topics (`ros2 topic list -v`: their types, publishers and
|
|
259
|
+
subscribers), the services and the actions (with the nodes serving and using them, asked node
|
|
260
|
+
by node as `ros2 action info` does), read again every 2 s. The filter keeps those whose name or
|
|
261
|
+
type contain any of its terms, separated by `;` (`ros2 topic find`), or match a regular
|
|
262
|
+
expression; the eye shows the hidden ones: names with a part starting with `_` (an action's
|
|
263
|
+
topics and services), and every node's parameter services. A topic, service or action clicked
|
|
264
|
+
shows its details in tabs, beside the list or under it; the arrow keys go through the list. The
|
|
265
|
+
widget's properties set the sheet and filter it opens with, and a topic, service or action to
|
|
266
|
+
open (one widget for `/odom`'s messages).
|
|
267
|
+
|
|
268
|
+
A topic's **Echo** shows its messages as `ros2 topic echo` prints them, folding: the latest,
|
|
269
|
+
at most 10 a second, or only a *field* of them (`pose.position`, `ranges[0]`); lists and
|
|
270
|
+
strings longer than the widget's *length* are cut (`--truncate-length`), NaN and infinities
|
|
271
|
+
written `.nan`, `.inf`. Pausing keeps the latest 100 to go back through. Copy writes a message
|
|
272
|
+
as YAML; the pencil edits it in the Publish tab. **Rate** shows, over the latest messages (the
|
|
273
|
+
*window*, `--window`), their rate and the spread of the intervals between them (`ros2 topic
|
|
274
|
+
hz`), their bandwidth and sizes, serialized (`ros2 topic bw`), and for messages with a header,
|
|
275
|
+
the delay from its stamp to their arrival on the ROS clock (`ros2 topic delay`). The server
|
|
276
|
+
takes the messages serialized: their sizes are exact, the stamps read from their bytes, and
|
|
277
|
+
only those shown are deserialized. **Info** lists the publishers and subscriptions with their
|
|
278
|
+
QoS (`ros2 topic info -v`), the dashboard's own named so, and says which do not talk and why: a
|
|
279
|
+
best effort publisher and a reliable subscription, a volatile publisher and a transient local
|
|
280
|
+
(latched) one, deadlines and liveliness that do not match, other types. **Publish** fills in a
|
|
281
|
+
message field by field, made from its type's definition (lists of numbers on a line, nested
|
|
282
|
+
messages folding, constants suggested), or as JSON; a time may be *now*, made the time of each
|
|
283
|
+
message as `ros2 topic pub` does (`"now"` in JSON, `"auto"` for a header). It is published
|
|
284
|
+
once, or at a rate, a number of times or until stopped (`-r`, `-t`), with its QoS: by default
|
|
285
|
+
reliable, and transient local if one of the topic's subscriptions is, which every subscription
|
|
286
|
+
matches. A new publisher waits up to a second for the subscriptions to match it, as a first
|
|
287
|
+
message would be lost. Publishing goes on while other topics are shown, until stopped from the
|
|
288
|
+
status line or the widget goes. **Type** shows the type's definition as `ros2 interface show`
|
|
289
|
+
prints it.
|
|
290
|
+
|
|
291
|
+
A service's **Call** fills in its request as Publish does a message, and calls it once or at
|
|
292
|
+
a rate (`ros2 service call -r`), with a timeout, showing its response and how long it took.
|
|
293
|
+
**Echo** shows its requests and responses as they go (`ros2 service echo`), when its node
|
|
294
|
+
introspects them: since Jazzy, with `configure_introspection`. An action's **Goals** are those
|
|
295
|
+
its servers have, whoever sent them, from its status topic, with the latest feedback of each:
|
|
296
|
+
what Nav2 is doing, goals sent from RViz included. Those sent from the dashboard have their
|
|
297
|
+
result too. Any goal is canceled with its button, all of them with *Cancel all*. **Send** fills
|
|
298
|
+
in a goal and sends it (`ros2 action send_goal -f`), following it to its result. The server
|
|
299
|
+
follows an action for the pages, and for 10 minutes after the last one: a page opened later
|
|
300
|
+
finds the goals sent and their results. **Info** lists the nodes serving and using a service
|
|
301
|
+
or an action (`ros2 service info`, `ros2 action info`).
|
|
302
|
+
|
|
303
|
+
Messages, requests and goals are made from rclnodejs's code of their types, which Tassello
|
|
304
|
+
makes when it starts for the workspaces sourced (see Install).
|
|
305
|
+
- **Parameters** lists the nodes of the graph (but the hidden ones, as `ros2 node list` does), or
|
|
306
|
+
shows one node only, its *node* (a panel to tune `/controller_server`). A node's parameters are
|
|
307
|
+
read through its parameter services (`rcl_interfaces`), grouped by the prefixes of their names
|
|
308
|
+
(the groups of read-only parameters only, such as `qos_overrides`, folded), and filtered by name
|
|
309
|
+
or description. Each is edited with its type's editor: a switch for a boolean, a slider and a
|
|
310
|
+
field for a number with a range, a field for the others (lists' items separated by commas),
|
|
311
|
+
applied on Enter or when left; what the node refuses (out of its range, of another type, read
|
|
312
|
+
only) is said under it, with the node's reason, and the value goes back. Changes made elsewhere
|
|
313
|
+
(`ros2 param set`, the node itself) show as they come: the server follows `/parameter_events`.
|
|
314
|
+
A parameter's details give its description, type, range and constraints, copy its value or its
|
|
315
|
+
`ros2 param set` command, and delete it (`ros2 param delete`, which only undeclared parameters
|
|
316
|
+
allow). *Add* sets a parameter the node did not declare, which nodes allow only if they say so;
|
|
317
|
+
its type is read from its value as `ros2 param set` reads it (`[1, 2.5]`: doubles), or chosen.
|
|
318
|
+
*Dump* writes the YAML `ros2 param dump` writes, downloaded, saved on the robot or copied; *Load*
|
|
319
|
+
reads a parameter file of the robot or of the page's device as `ros2 param load` does (the
|
|
320
|
+
node's section, and the `/**` one) and sets its parameters one by one, saying which the node
|
|
321
|
+
refused and why. Parameter files are read and written by Python's yaml, as `ros2 param` does. A
|
|
322
|
+
widget can be *view only*.
|
|
237
323
|
- **File Manager** opens its *folder* (`~` by default; `/…`, `~/…`, `package://…`) and lists it
|
|
238
324
|
with its entries' sizes and times, sorted by name (naturally: `run_2` before `run_10`), size or
|
|
239
325
|
time, folders first; the chevrons unfold folders in place. Its places are home, the root, the
|
|
@@ -379,7 +465,9 @@ and ship example panels. See **[docs/plugins.md](docs/plugins.md)**.
|
|
|
379
465
|
|
|
380
466
|
## Security
|
|
381
467
|
|
|
382
|
-
Tassello has no login. **Anyone who can open the page can drive the robot,
|
|
468
|
+
Tassello has no login. **Anyone who can open the page can drive the robot, publish on any topic,
|
|
469
|
+
call any service and send or cancel any goal (the ROS Monitor), change any node's parameters
|
|
470
|
+
(Parameters), run any command as
|
|
383
471
|
the server's user (the Terminal, a shell), read, write, upload, move and delete any of the
|
|
384
472
|
files that user can (the File Manager), and change the robot's networks and read their passwords
|
|
385
473
|
(Networking)**: keep it on a network you trust, or behind a reverse proxy
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import rclnodejs from 'rclnodejs';
|
|
2
|
+
import type { ActionGoal, CancelResult } from '../shared/monitor.ts';
|
|
3
|
+
import type { StreamState } from './frame_stream.ts';
|
|
4
|
+
import type { Interfaces } from './interfaces.ts';
|
|
5
|
+
import { type Logger } from './util.ts';
|
|
6
|
+
export interface ActionWatcher {
|
|
7
|
+
onStatus: (state: StreamState, message: string) => void;
|
|
8
|
+
onGoals: (goals: ActionGoal[]) => void;
|
|
9
|
+
}
|
|
10
|
+
export declare function uuidText(uuid: ArrayLike<number>): string;
|
|
11
|
+
export declare function uuidBytes(text: string): number[];
|
|
12
|
+
export declare class Actions {
|
|
13
|
+
private readonly node;
|
|
14
|
+
private readonly interfaces;
|
|
15
|
+
private readonly logger;
|
|
16
|
+
private readonly followed;
|
|
17
|
+
constructor(node: rclnodejs.Node, interfaces: Interfaces, logger: Logger);
|
|
18
|
+
watch(action: string, type: string, watcher: ActionWatcher): () => void;
|
|
19
|
+
send(action: string, type: string, goal: unknown): Promise<string>;
|
|
20
|
+
cancel(action: string, type: string, goal: string | null): Promise<CancelResult>;
|
|
21
|
+
stopAll(): void;
|
|
22
|
+
private follow;
|
|
23
|
+
private release;
|
|
24
|
+
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// Actions for the monitor widgets: ros2 action send_goal, and what an
|
|
2
|
+
// action's servers are doing, whoever sent them their goals: every goal of
|
|
3
|
+
// the status topic (<action>/_action/status), with the latest feedback of
|
|
4
|
+
// each (<action>/_action/feedback). The goals the dashboard sends have their
|
|
5
|
+
// result too. Goals are canceled through the cancel service, one or all of
|
|
6
|
+
// them, whoever sent them.
|
|
7
|
+
//
|
|
8
|
+
// One follower per action, shared by its pages. It outlives them while the
|
|
9
|
+
// goals they sent run, and for LINGER_MS after: a page opened later finds
|
|
10
|
+
// them, their results included.
|
|
11
|
+
import { randomBytes } from 'node:crypto';
|
|
12
|
+
import rclnodejs from 'rclnodejs';
|
|
13
|
+
import { missingCodeHint } from './message_code.js';
|
|
14
|
+
import { echoJson, parseType } from './message_format.js';
|
|
15
|
+
import { absoluteTopic, errorMessage } from './util.js';
|
|
16
|
+
const STATUS_TYPE = 'action_msgs/msg/GoalStatusArray';
|
|
17
|
+
const CANCEL_TYPE = 'action_msgs/srv/CancelGoal';
|
|
18
|
+
// action_msgs/msg/GoalStatus's values, in order
|
|
19
|
+
const STATUSES = ['unknown', 'accepted', 'executing', 'canceling', 'succeeded', 'canceled', 'aborted'];
|
|
20
|
+
const DONE = new Set(['succeeded', 'canceled', 'aborted']);
|
|
21
|
+
// Kept, the latest accepted
|
|
22
|
+
const MAX_GOALS = 20;
|
|
23
|
+
// Of the goals and feedback sent, the results
|
|
24
|
+
const TRUNCATE = 256;
|
|
25
|
+
const RESULT_TRUNCATE = 10_000;
|
|
26
|
+
const UPDATE_PERIOD_MS = 200;
|
|
27
|
+
const LINGER_MS = 10 * 60_000;
|
|
28
|
+
const SERVER_TIMEOUT_MS = 5000;
|
|
29
|
+
const { HistoryPolicy, ReliabilityPolicy, DurabilityPolicy } = rclnodejs.QoS;
|
|
30
|
+
// 8-4-4-4-12 hex digits
|
|
31
|
+
export function uuidText(uuid) {
|
|
32
|
+
const hex = Array.from(uuid, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
33
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
34
|
+
}
|
|
35
|
+
export function uuidBytes(text) {
|
|
36
|
+
const hex = text.replace(/-/g, '');
|
|
37
|
+
if (!/^[0-9a-f]{32}$/i.test(hex)) {
|
|
38
|
+
throw new Error(`"${text}" is not a goal's UUID`);
|
|
39
|
+
}
|
|
40
|
+
return Array.from({ length: 16 }, (_, i) => parseInt(hex.slice(i * 2, i * 2 + 2), 16));
|
|
41
|
+
}
|
|
42
|
+
function milliseconds(stamp) {
|
|
43
|
+
return stamp.sec * 1000 + stamp.nanosec / 1e6;
|
|
44
|
+
}
|
|
45
|
+
class FollowedAction {
|
|
46
|
+
watchers = new Set();
|
|
47
|
+
// Of the dashboard's goals, running
|
|
48
|
+
running = 0;
|
|
49
|
+
lingerTimer = null;
|
|
50
|
+
node;
|
|
51
|
+
interfaces;
|
|
52
|
+
logger;
|
|
53
|
+
action;
|
|
54
|
+
type;
|
|
55
|
+
goals = new Map();
|
|
56
|
+
subscriptions = [];
|
|
57
|
+
client = null;
|
|
58
|
+
cancelClient = null;
|
|
59
|
+
updateTimer = null;
|
|
60
|
+
updatedAt = 0;
|
|
61
|
+
state = 'waiting';
|
|
62
|
+
message = '';
|
|
63
|
+
constructor(node, interfaces, logger, action, type) {
|
|
64
|
+
this.node = node;
|
|
65
|
+
this.interfaces = interfaces;
|
|
66
|
+
this.logger = logger;
|
|
67
|
+
this.action = action;
|
|
68
|
+
this.type = type;
|
|
69
|
+
try {
|
|
70
|
+
this.subscriptions.push(this.node.createSubscription(STATUS_TYPE, `${action}/_action/status`, {
|
|
71
|
+
// The servers' own: their latest statuses, latched
|
|
72
|
+
qos: new rclnodejs.QoS(HistoryPolicy.RMW_QOS_POLICY_HISTORY_KEEP_LAST, 1, ReliabilityPolicy.RMW_QOS_POLICY_RELIABILITY_RELIABLE, DurabilityPolicy.RMW_QOS_POLICY_DURABILITY_TRANSIENT_LOCAL),
|
|
73
|
+
}, (message) => this.onStatuses(message)), this.node.createSubscription(
|
|
74
|
+
// @ts-expect-error: the type is a runtime string, not one of the literal types
|
|
75
|
+
`${type}_FeedbackMessage`, `${action}/_action/feedback`, {}, (message) => this.onFeedback(message)));
|
|
76
|
+
this.setState('streaming', '');
|
|
77
|
+
}
|
|
78
|
+
catch (e) {
|
|
79
|
+
this.setState('error', `Cannot follow ${action}: ${missingCodeHint(errorMessage(e))}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
add(watcher) {
|
|
83
|
+
this.watchers.add(watcher);
|
|
84
|
+
watcher.onStatus(this.state, this.message);
|
|
85
|
+
watcher.onGoals(this.list());
|
|
86
|
+
}
|
|
87
|
+
// Resolves with the goal's UUID once accepted
|
|
88
|
+
async send(goal) {
|
|
89
|
+
const message = this.interfaces.message(`${this.type}_Goal`, goal);
|
|
90
|
+
this.client ??= new rclnodejs.ActionClient(this.node,
|
|
91
|
+
// @ts-expect-error: as above
|
|
92
|
+
this.type, this.action);
|
|
93
|
+
if (!(await this.client.waitForServer(SERVER_TIMEOUT_MS))) {
|
|
94
|
+
throw new Error(`No server of ${this.action}`);
|
|
95
|
+
}
|
|
96
|
+
const bytes = Array.from(randomBytes(16));
|
|
97
|
+
const id = uuidText(bytes);
|
|
98
|
+
const handle = await this.client.sendGoal(message, undefined, { uuid: bytes });
|
|
99
|
+
if (!handle.accepted) {
|
|
100
|
+
throw new Error(`${this.action} rejected the goal`);
|
|
101
|
+
}
|
|
102
|
+
const now = Date.now();
|
|
103
|
+
const sent = { goal: echoJson(message, TRUNCATE), at: now, result: null, error: null };
|
|
104
|
+
const known = this.goals.get(id);
|
|
105
|
+
this.goals.set(id, {
|
|
106
|
+
id,
|
|
107
|
+
status: known?.status ?? 'accepted',
|
|
108
|
+
acceptedAt: known?.acceptedAt ?? now,
|
|
109
|
+
feedback: known?.feedback ?? null,
|
|
110
|
+
feedbacks: known?.feedbacks ?? 0,
|
|
111
|
+
updatedAt: now,
|
|
112
|
+
sent,
|
|
113
|
+
});
|
|
114
|
+
this.running++;
|
|
115
|
+
this.changed();
|
|
116
|
+
handle
|
|
117
|
+
.getResult()
|
|
118
|
+
.then((result) => {
|
|
119
|
+
sent.result = echoJson(result, RESULT_TRUNCATE);
|
|
120
|
+
this.update(id, STATUSES[handle.status] ?? 'unknown');
|
|
121
|
+
}, (e) => {
|
|
122
|
+
sent.error = `No result: ${errorMessage(e)}`;
|
|
123
|
+
this.update(id, null);
|
|
124
|
+
})
|
|
125
|
+
.finally(() => {
|
|
126
|
+
this.running--;
|
|
127
|
+
this.onIdle?.();
|
|
128
|
+
});
|
|
129
|
+
return id;
|
|
130
|
+
}
|
|
131
|
+
// Cancels the goal, or all of them (null)
|
|
132
|
+
async cancel(goal) {
|
|
133
|
+
this.cancelClient ??= this.node.createClient(CANCEL_TYPE, `${this.action}/_action/cancel_goal`);
|
|
134
|
+
const client = this.cancelClient;
|
|
135
|
+
if (!(await client.waitForService(SERVER_TIMEOUT_MS))) {
|
|
136
|
+
throw new Error(`No server of ${this.action}`);
|
|
137
|
+
}
|
|
138
|
+
const response = await client.sendRequestAsync({
|
|
139
|
+
goal_info: {
|
|
140
|
+
goal_id: { uuid: goal === null ? new Array(16).fill(0) : uuidBytes(goal) },
|
|
141
|
+
stamp: { sec: 0, nanosec: 0 },
|
|
142
|
+
},
|
|
143
|
+
}, { timeout: SERVER_TIMEOUT_MS });
|
|
144
|
+
return {
|
|
145
|
+
code: response.return_code,
|
|
146
|
+
canceling: Array.from(response.goals_canceling, (info) => uuidText(info.goal_id.uuid)),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
// Called when its last page went and no goal of the dashboard's runs
|
|
150
|
+
onIdle = null;
|
|
151
|
+
destroy() {
|
|
152
|
+
if (this.updateTimer) {
|
|
153
|
+
clearTimeout(this.updateTimer);
|
|
154
|
+
}
|
|
155
|
+
if (this.lingerTimer) {
|
|
156
|
+
clearTimeout(this.lingerTimer);
|
|
157
|
+
}
|
|
158
|
+
for (const subscription of this.subscriptions) {
|
|
159
|
+
this.node.destroySubscription(subscription);
|
|
160
|
+
}
|
|
161
|
+
if (this.cancelClient) {
|
|
162
|
+
this.node.destroyClient(this.cancelClient);
|
|
163
|
+
}
|
|
164
|
+
this.client?.destroy();
|
|
165
|
+
}
|
|
166
|
+
onStatuses(message) {
|
|
167
|
+
const now = Date.now();
|
|
168
|
+
for (const { goal_info, status } of message.status_list) {
|
|
169
|
+
const id = uuidText(goal_info.goal_id.uuid);
|
|
170
|
+
const known = this.goals.get(id);
|
|
171
|
+
const value = STATUSES[status] ?? 'unknown';
|
|
172
|
+
if (!known) {
|
|
173
|
+
this.goals.set(id, {
|
|
174
|
+
id,
|
|
175
|
+
status: value,
|
|
176
|
+
acceptedAt: milliseconds(goal_info.stamp),
|
|
177
|
+
feedback: null,
|
|
178
|
+
feedbacks: 0,
|
|
179
|
+
updatedAt: now,
|
|
180
|
+
sent: null,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
else if (known.status !== value && !(DONE.has(known.status) && known.sent?.result)) {
|
|
184
|
+
known.status = value;
|
|
185
|
+
known.acceptedAt = milliseconds(goal_info.stamp);
|
|
186
|
+
known.updatedAt = now;
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
known.acceptedAt = milliseconds(goal_info.stamp);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
this.changed();
|
|
193
|
+
}
|
|
194
|
+
onFeedback(message) {
|
|
195
|
+
const id = uuidText(message.goal_id.uuid);
|
|
196
|
+
const now = Date.now();
|
|
197
|
+
let goal = this.goals.get(id);
|
|
198
|
+
if (!goal) {
|
|
199
|
+
// Before its status
|
|
200
|
+
goal = { id, status: 'executing', acceptedAt: now, feedback: null, feedbacks: 0, updatedAt: now, sent: null };
|
|
201
|
+
this.goals.set(id, goal);
|
|
202
|
+
}
|
|
203
|
+
goal.feedback = echoJson(message.feedback, TRUNCATE);
|
|
204
|
+
goal.feedbacks++;
|
|
205
|
+
goal.updatedAt = now;
|
|
206
|
+
this.changed();
|
|
207
|
+
}
|
|
208
|
+
update(id, status) {
|
|
209
|
+
const goal = this.goals.get(id);
|
|
210
|
+
if (goal) {
|
|
211
|
+
if (status) {
|
|
212
|
+
goal.status = status;
|
|
213
|
+
}
|
|
214
|
+
goal.updatedAt = Date.now();
|
|
215
|
+
this.changed();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// The latest accepted first
|
|
219
|
+
list() {
|
|
220
|
+
return [...this.goals.values()].sort((a, b) => b.acceptedAt - a.acceptedAt).slice(0, MAX_GOALS);
|
|
221
|
+
}
|
|
222
|
+
// To the pages, at most every UPDATE_PERIOD_MS
|
|
223
|
+
changed() {
|
|
224
|
+
// Those beyond the latest MAX_GOALS go, but those still running
|
|
225
|
+
if (this.goals.size > MAX_GOALS) {
|
|
226
|
+
const kept = new Set(this.list().map((g) => g.id));
|
|
227
|
+
for (const [id, goal] of this.goals) {
|
|
228
|
+
if (!kept.has(id) && DONE.has(goal.status)) {
|
|
229
|
+
this.goals.delete(id);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (this.updateTimer) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const send = () => {
|
|
237
|
+
this.updateTimer = null;
|
|
238
|
+
this.updatedAt = Date.now();
|
|
239
|
+
const goals = this.list();
|
|
240
|
+
for (const watcher of this.watchers) {
|
|
241
|
+
watcher.onGoals(goals);
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
const wait = this.updatedAt + UPDATE_PERIOD_MS - Date.now();
|
|
245
|
+
if (wait > 0) {
|
|
246
|
+
this.updateTimer = setTimeout(send, wait);
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
send();
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
setState(state, message) {
|
|
253
|
+
if (state === 'error') {
|
|
254
|
+
this.logger.warn(message);
|
|
255
|
+
}
|
|
256
|
+
this.state = state;
|
|
257
|
+
this.message = message;
|
|
258
|
+
for (const watcher of this.watchers) {
|
|
259
|
+
watcher.onStatus(state, message);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
export class Actions {
|
|
264
|
+
node;
|
|
265
|
+
interfaces;
|
|
266
|
+
logger;
|
|
267
|
+
followed = new Map();
|
|
268
|
+
constructor(node, interfaces, logger) {
|
|
269
|
+
this.node = node;
|
|
270
|
+
this.interfaces = interfaces;
|
|
271
|
+
this.logger = logger;
|
|
272
|
+
}
|
|
273
|
+
// The action's goals, now and whenever they change, until the returned
|
|
274
|
+
// function is called
|
|
275
|
+
watch(action, type, watcher) {
|
|
276
|
+
const followed = this.follow(action, type);
|
|
277
|
+
followed.add(watcher);
|
|
278
|
+
return () => {
|
|
279
|
+
followed.watchers.delete(watcher);
|
|
280
|
+
this.release(followed);
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
// ros2 action send_goal: resolves with the goal's UUID once accepted
|
|
284
|
+
send(action, type, goal) {
|
|
285
|
+
const followed = this.follow(action, type);
|
|
286
|
+
return followed.send(goal).finally(() => this.release(followed));
|
|
287
|
+
}
|
|
288
|
+
cancel(action, type, goal) {
|
|
289
|
+
const followed = this.follow(action, type);
|
|
290
|
+
return followed.cancel(goal).finally(() => this.release(followed));
|
|
291
|
+
}
|
|
292
|
+
stopAll() {
|
|
293
|
+
for (const followed of this.followed.values()) {
|
|
294
|
+
followed.destroy();
|
|
295
|
+
}
|
|
296
|
+
this.followed.clear();
|
|
297
|
+
}
|
|
298
|
+
follow(action, type) {
|
|
299
|
+
const name = absoluteTopic(action);
|
|
300
|
+
if (parseType(type).kind !== 'action') {
|
|
301
|
+
throw new Error(`${type} is not an action type (package/action/Name)`);
|
|
302
|
+
}
|
|
303
|
+
const key = `${name} ${type}`;
|
|
304
|
+
let followed = this.followed.get(key);
|
|
305
|
+
if (!followed) {
|
|
306
|
+
const created = new FollowedAction(this.node, this.interfaces, this.logger, name, type);
|
|
307
|
+
created.onIdle = () => this.release(created);
|
|
308
|
+
this.followed.set(key, created);
|
|
309
|
+
followed = created;
|
|
310
|
+
}
|
|
311
|
+
if (followed.lingerTimer) {
|
|
312
|
+
clearTimeout(followed.lingerTimer);
|
|
313
|
+
followed.lingerTimer = null;
|
|
314
|
+
}
|
|
315
|
+
return followed;
|
|
316
|
+
}
|
|
317
|
+
// Once nothing needs it: for LINGER_MS more
|
|
318
|
+
release(followed) {
|
|
319
|
+
if (followed.watchers.size > 0 || followed.running > 0 || followed.lingerTimer) {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
followed.lingerTimer = setTimeout(() => {
|
|
323
|
+
for (const [key, f] of this.followed) {
|
|
324
|
+
if (f === followed) {
|
|
325
|
+
this.followed.delete(key);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
followed.destroy();
|
|
329
|
+
}, LINGER_MS);
|
|
330
|
+
}
|
|
331
|
+
}
|
package/dist/server/files.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export declare const IMAGE_EXTENSIONS: Set<string>;
|
|
2
|
-
export type FileKind = 'image' | 'behavior-tree' | 'bag' | 'folder';
|
|
2
|
+
export type FileKind = 'image' | 'behavior-tree' | 'bag' | 'yaml' | 'folder';
|
|
3
3
|
export declare const MAX_UPLOAD_BYTES: number;
|
|
4
4
|
export declare function fileKind(value: string | null): FileKind;
|
|
5
5
|
export declare function resolveImagePath(input: string, home?: string): string;
|
package/dist/server/files.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// behavior tree inspectors: files of the robot, which a page picks by
|
|
3
3
|
// browsing its directories, and files uploaded from a page, kept by the
|
|
4
4
|
// server next to the panels. Only those: the dashboard does not hand out any
|
|
5
|
-
// file of the robot. A page also browses for rosbags,
|
|
5
|
+
// file of the robot. A page also browses for rosbags, parameter files (YAML),
|
|
6
|
+
// and folders.
|
|
6
7
|
import { createHash } from 'node:crypto';
|
|
7
8
|
import { promises as fs } from 'node:fs';
|
|
8
9
|
import os from 'node:os';
|
|
@@ -13,6 +14,8 @@ const EXTENSIONS = {
|
|
|
13
14
|
image: IMAGE_EXTENSIONS,
|
|
14
15
|
'behavior-tree': new Set(['.xml']),
|
|
15
16
|
bag: new Set(['.mcap', '.db3']),
|
|
17
|
+
// Parameter files
|
|
18
|
+
yaml: new Set(['.yaml', '.yml']),
|
|
16
19
|
folder: new Set(),
|
|
17
20
|
};
|
|
18
21
|
const FILE_KINDS = new Set(Object.keys(EXTENSIONS));
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import rclnodejs from 'rclnodejs';
|
|
2
|
+
import type { InterfaceInfo, MessageSchema } from '../shared/monitor.ts';
|
|
3
|
+
import { type RosTime } from './message_format.ts';
|
|
4
|
+
export declare class Interfaces {
|
|
5
|
+
private readonly node;
|
|
6
|
+
private readonly schemas;
|
|
7
|
+
private readonly infos;
|
|
8
|
+
constructor(node: rclnodejs.Node);
|
|
9
|
+
schemasOf(type: string, into?: Record<string, MessageSchema>): Record<string, MessageSchema>;
|
|
10
|
+
info(type: string): InterfaceInfo;
|
|
11
|
+
message(type: string, value: unknown): Record<string, unknown>;
|
|
12
|
+
hasHeader(type: string): boolean;
|
|
13
|
+
now(): RosTime;
|
|
14
|
+
private schema;
|
|
15
|
+
private definition;
|
|
16
|
+
}
|