UNPKG

@foxglove/ros1

Version:

Standalone TypeScript implementation of the ROS 1 (Robot Operating System) protocol with a pluggable transport layer

844 lines (721 loc) 28.1 kB
import { MessageDefinition } from "@foxglove/message-definition"; import { parse as parseMessageDefinition, md5 as rosMsgMd5sum, stringify as rosMsgDefinitionText, } from "@foxglove/rosmsg"; import { MessageWriter } from "@foxglove/rosmsg-serialization"; import { HttpServer, XmlRpcFault, XmlRpcValue } from "@foxglove/xmlrpc"; import { EventEmitter } from "eventemitter3"; import { Client } from "./Client"; import { LoggerService } from "./LoggerService"; import { Publication } from "./Publication"; import { RosFollower } from "./RosFollower"; import { RosFollowerClient } from "./RosFollowerClient"; import { RosMasterClient } from "./RosMasterClient"; import { RosParamClient } from "./RosParamClient"; import { Subscription } from "./Subscription"; import { TcpConnection } from "./TcpConnection"; import { TcpPublisher } from "./TcpPublisher"; import { TcpSocketCreate, TcpServer, TcpAddress, NetworkInterface } from "./TcpTypes"; import { RosXmlRpcResponse } from "./XmlRpcTypes"; import { retryForever } from "./backoff"; import { difference } from "./difference"; import { isEmptyPlainObject } from "./objectTests"; export type RosGraph = { publishers: Map<string, Set<string>>; // Maps topic names to arrays of nodes publishing each topic subscribers: Map<string, Set<string>>; // Maps topic names to arrays of nodes subscribing to each topic services: Map<string, Set<string>>; // Maps service names to arrays of nodes providing each service }; export type SubscribeOpts = { topic: string; dataType: string; md5sum?: string; tcpNoDelay?: boolean; }; export type PublishOpts = { topic: string; dataType: string; latching?: boolean; messageDefinition?: MessageDefinition[]; messageDefinitionText?: string; md5sum?: string; }; export type ParamUpdateArgs = { key: string; value: XmlRpcValue; prevValue: XmlRpcValue; callerId: string; }; export type PublisherUpdateArgs = { topic: string; publishers: string[]; prevPublishers: string[]; callerId: string; }; const OK = 1; export interface RosNodeEvents { paramUpdate: (args: ParamUpdateArgs) => void; publisherUpdate: (args: PublisherUpdateArgs) => void; error: (err: Error) => void; } export class RosNode extends EventEmitter<RosNodeEvents> { readonly name: string; readonly hostname: string; readonly pid: number; rosMasterClient: RosMasterClient; rosParamClient: RosParamClient; rosFollower: RosFollower; subscriptions = new Map<string, Subscription>(); publications = new Map<string, Publication>(); parameters = new Map<string, XmlRpcValue>(); private _running = true; private _tcpSocketCreate: TcpSocketCreate; private _connectionIdCounter = 0; private _tcpPublisher?: TcpPublisher; private _localApiUrl?: string; private _log?: LoggerService; constructor(options: { name: string; hostname: string; pid: number; rosMasterUri: string; httpServer: HttpServer; tcpSocketCreate: TcpSocketCreate; tcpServer?: TcpServer; log?: LoggerService; }) { super(); this.name = options.name; this.hostname = options.hostname; this.pid = options.pid; this.rosMasterClient = new RosMasterClient(options.rosMasterUri); this.rosParamClient = new RosParamClient(options.rosMasterUri); this.rosFollower = new RosFollower(this, options.httpServer); this._tcpSocketCreate = options.tcpSocketCreate; if (options.tcpServer != undefined) { this._tcpPublisher = new TcpPublisher({ server: options.tcpServer, nodeName: this.name, getConnectionId: this._newConnectionId, getPublication: this._getPublication, log: options.log, }); this._tcpPublisher.on("connection", this._handleTcpClientConnection); this._tcpPublisher.on("error", this._handleTcpPublisherError); } this._log = options.log; this.rosFollower.on("paramUpdate", this._handleParamUpdate); this.rosFollower.on("publisherUpdate", this._handlePublisherUpdate); } async start(port?: number): Promise<void> { return await this.rosFollower .start(this.hostname, port) .then(() => this._log?.debug?.(`rosfollower listening at ${this.rosFollower.url()}`)); } shutdown(_msg?: string): void { this._log?.debug?.("shutting down"); this._running = false; this._tcpPublisher?.close(); this.rosFollower.close(); if (this.parameters.size > 0) { this.unsubscribeAllParams().catch((unk) => { const err = unk instanceof Error ? unk : new Error(unk as string); this._log?.warn?.(err.message, "shutdown"); this.emit("error", err); }); } for (const subTopic of Array.from(this.subscriptions.keys())) { this.unsubscribe(subTopic); } for (const pubTopic of Array.from(this.publications.keys())) { this.unadvertise(pubTopic); } this.subscriptions.clear(); this.publications.clear(); this.parameters.clear(); } subscribe(options: SubscribeOpts): Subscription { const { topic, dataType } = options; const md5sum = options.md5sum ?? "*"; const tcpNoDelay = options.tcpNoDelay ?? false; // Check if we are already subscribed let subscription = this.subscriptions.get(topic); if (subscription != undefined) { this._log?.debug?.(`reusing existing subscribtion to ${topic} (${dataType})`); return subscription; } subscription = new Subscription({ name: topic, md5sum, dataType, tcpNoDelay }); this.subscriptions.set(topic, subscription); this._log?.debug?.(`subscribing to ${topic} (${dataType})`); // Asynchronously register this subscription with rosmaster and connect to // each publisher this._registerSubscriberAndConnect(subscription).catch((err) => { // This should never be called, this._registerSubscriberAndConnect() is not expected to throw this._log?.warn?.( `subscribe registration and connection unexpectedly failed: ${err}`, "subscribe", ); }); return subscription; } async advertise(options: PublishOpts): Promise<Publication> { const { topic, dataType } = options; const addr = await this.tcpServerAddress(); if (addr == undefined) { throw new Error(`Cannot publish ${topic} without a listening tcpServer`); } // Check if we are already publishing let publication = this.publications.get(topic); if (publication != undefined) { this._log?.debug?.(`reusing existing publication for ${topic} (${dataType})`); return publication; } const messageDefinition = options.messageDefinition ?? parseMessageDefinition(options.messageDefinitionText ?? ""); const canonicalMsgDefText = rosMsgDefinitionText(messageDefinition); const messageDefinitionText = options.messageDefinitionText ?? canonicalMsgDefText; const md5sum = options.md5sum ?? rosMsgMd5sum(messageDefinition); const messageWriter = new MessageWriter(messageDefinition); publication = new Publication( topic, md5sum, dataType, options.latching ?? false, messageDefinition, messageDefinitionText, messageWriter, ); this.publications.set(topic, publication); this._log?.debug?.(`publishing ${topic} (${dataType})`); // Register with with rosmaster as a publisher for the requested topic. If // this request fails, an exception is thrown const subscribers = await this._registerPublisher(publication); this._log?.info?.( `registered as a publisher for ${topic}, ${subscribers.length} current subscriber(s)`, ); return publication; } async publish(topic: string, message: unknown): Promise<void> { if (this._tcpPublisher == undefined) { throw new Error(`Cannot publish without a tcpServer`); } const publication = this.publications.get(topic); if (publication == undefined) { throw new Error(`Cannot publish to unadvertised topic "${topic}"`); } return await this._tcpPublisher.publish(publication, message); } isSubscribedTo(topic: string): boolean { return this._running && this.subscriptions.has(topic); } isAdvertising(topic: string): boolean { return this._running && this.publications.has(topic); } unsubscribe(topic: string): boolean { const subscription = this.subscriptions.get(topic); if (subscription == null) { return false; } this._unregisterSubscriber(topic).catch((err) => { // This should never happen this._log?.warn?.(`unregisterSubscriber failed for ${topic}: ${err}`); }); subscription.close(); this.subscriptions.delete(topic); return true; } unadvertise(topic: string): boolean { const publication = this.publications.get(topic); if (publication == null) { return false; } this._unregisterPublisher(topic).catch((err) => { // This should never happen this._log?.warn?.(`_unregisterPublisher failed for ${topic}: ${err}`); }); publication.close(); this.publications.delete(topic); return true; } async getParamNames(): Promise<string[]> { const [status, msg, names] = await this.rosParamClient.getParamNames(this.name); if (status !== OK) { throw new Error(`getParamNames returned failure (status=${status}): ${msg}`); } if (!Array.isArray(names)) { throw new Error(`getParamNames returned unrecognized data (${msg})`); } return names as string[]; } async setParameter(key: string, value: XmlRpcValue): Promise<void> { const [status, msg] = await this.rosParamClient.setParam(this.name, key, value); if (status !== OK) { throw new Error(`setParam returned failure (status=${status}): ${msg}`); } // Also do a local update because ROS param server won't notify us if // we initiated the parameter update. this._handleParamUpdate(key, value, this.name); } async subscribeParam(key: string): Promise<XmlRpcValue> { const callerApi = this._callerApi(); const [status, msg, value] = await this.rosParamClient.subscribeParam( this.name, callerApi, key, ); if (status !== OK) { throw new Error(`subscribeParam returned failure (status=${status}): ${msg}`); } // rosparam server returns an empty object ({}) if the parameter has not been set yet const adjustedValue = isEmptyPlainObject(value) ? undefined : value; this.parameters.set(key, adjustedValue); this._log?.debug?.(`subscribed ${callerApi} to param "${key}" (${String(adjustedValue)})`); return adjustedValue; } async unsubscribeParam(key: string): Promise<boolean> { const callerApi = this._callerApi(); const [status, msg, value] = await this.rosParamClient.unsubscribeParam( this.name, callerApi, key, ); if (status !== OK) { throw new Error(`unsubscribeParam returned failure (status=${status}): ${msg}`); } this.parameters.delete(key); const didUnsubscribe = (value as number) === 1; this._log?.debug?.( `unsubscribed ${callerApi} from param "${key}" (didUnsubscribe=${didUnsubscribe})`, ); return didUnsubscribe; } async subscribeAllParams(): Promise<Readonly<Map<string, XmlRpcValue>>> { let keys = await this.getParamNames(); const curKeys = Array.from(this.parameters.keys()); const callerApi = this._callerApi(); // Remove any local parameters the rosparam server didn't return const removedKeys = difference(curKeys, keys); if (removedKeys.length > 0) { this._log?.debug?.(`removing missing parameters ${JSON.stringify(removedKeys)}`); for (const key of removedKeys) { this.parameters.delete(key); } } // Check if there are any parameters we don't already have locally keys = difference(keys, curKeys); if (keys.length === 0) { return this.parameters; } const res = await this.rosParamClient.subscribeParams(this.name, callerApi, keys); if (res.length !== keys.length) { throw new Error( `subscribeAllParams returned ${res.length} entries, expected ${ keys.length }: ${JSON.stringify(res)}`, ); } // Update the local map of all subscribed parameters for (let i = 0; i < keys.length; i++) { const key = keys[i] as string; const entry = res[i]; if (entry instanceof XmlRpcFault) { this._log?.warn?.(`subscribeAllParams faulted on "${key}" (${entry})`); this.emit("error", new Error(`subscribeAllParams faulted on "${key}" (${entry})`)); continue; } const [status, msg, value] = entry as RosXmlRpcResponse; if (status !== OK) { this._log?.warn?.(`subscribeAllParams not ok for "${key}" (status=${status}): ${msg}`); this.emit( "error", new Error(`subscribeAllParams not ok for "${key}" (status=${status}): ${msg}`), ); continue; } // rosparam server returns an empty object ({}) if the parameter has not been set yet const adjustedValue = isEmptyPlainObject(value) ? undefined : value; this.parameters.set(key, adjustedValue); } this._log?.debug?.(`subscribed ${callerApi} to parameters (${keys})`); return this.parameters; } async unsubscribeAllParams(): Promise<void> { const keys = Array.from(this.parameters.keys()); const callerApi = this._callerApi(); const res = await this.rosParamClient.unsubscribeParams(this.name, callerApi, keys); if (res.length !== keys.length) { throw new Error(`unsubscribeAllParams returned unrecognized data: ${JSON.stringify(res)}`); } for (let i = 0; i < keys.length; i++) { const key = keys[i]!; const [status, msg] = res[i] as RosXmlRpcResponse; if (status !== OK) { this._log?.warn?.(`unsubscribeAllParams failed for "${key}" (status=${status}): ${msg}`); this.emit( "error", new Error(`unsubscribeAllParams failed for "${key}" (status=${status}): ${msg}`), ); continue; } } this._log?.debug?.(`unsubscribed ${callerApi} from all parameters (${String(keys)})`); this.parameters.clear(); } async getPublishedTopics(subgraph?: string): Promise<[topic: string, dataType: string][]> { const [status, msg, topicsAndTypes] = await this.rosMasterClient.getPublishedTopics( this.name, subgraph, ); if (status !== OK) { throw new Error(`getPublishedTopics returned failure (status=${status}): ${msg}`); } return topicsAndTypes as [string, string][]; } async getSystemState(): Promise<RosGraph> { const [status, msg, systemState] = await this.rosMasterClient.getSystemState(this.name); if (status !== OK) { throw new Error(`getPublishedTopics returned failure (status=${status}): ${msg}`); } if (!Array.isArray(systemState) || systemState.length !== 3) { throw new Error(`getPublishedTopics returned unrecognized data (${msg})`); } // Each of these has the form [ [topic, [node1...nodeN]] ... ] type SystemStateEntry = [topic: string, nodes: string[]]; type SystemStateResponse = [SystemStateEntry[], SystemStateEntry[], SystemStateEntry[]]; const [pubs, subs, srvs] = systemState as SystemStateResponse; const createMap = (entries: SystemStateEntry[]) => new Map<string, Set<string>>(entries.map(([topic, nodes]) => [topic, new Set(nodes)])); return { publishers: createMap(pubs), subscribers: createMap(subs), services: createMap(srvs), }; } async tcpServerAddress(): Promise<TcpAddress | undefined> { return await this._tcpPublisher?.address(); } receivedBytes(): number { let bytes = 0; for (const sub of this.subscriptions.values()) { bytes += sub.receivedBytes(); } return bytes; } static async RequestTopic( name: string, topic: string, apiClient: RosFollowerClient, ): Promise<{ address: string; port: number }> { let res: RosXmlRpcResponse; try { res = await apiClient.requestTopic(name, topic, [["TCPROS"]]); } catch (err) { throw new Error(`requestTopic("${topic}") from ${apiClient.url()} failed. err=${err}`); } const [status, msg, protocol] = res; if (status !== OK) { throw new Error( `requestTopic("${topic}") from ${apiClient.url()} failed. status=${status}, msg=${msg}`, ); } if (!Array.isArray(protocol) || protocol.length < 3 || protocol[0] !== "TCPROS") { throw new Error(`TCP not supported by ${apiClient.url()} for topic "${topic}"`); } return { port: protocol[2] as number, address: protocol[1] as string }; } private _newConnectionId = (): number => { return this._connectionIdCounter++; }; private _getPublication = (topic: string): Publication | undefined => { return this.publications.get(topic); }; private _callerApi(): string { if (this._localApiUrl != undefined) { return this._localApiUrl; } this._localApiUrl = this.rosFollower.url(); if (this._localApiUrl == undefined) { throw new Error("Local XMLRPC server was not started"); } return this._localApiUrl; } private _handleParamUpdate = (key: string, value: XmlRpcValue, callerId: string) => { const prevValue = this.parameters.get(key); this.parameters.set(key, value); this.emit("paramUpdate", { key, value, prevValue, callerId }); }; private _handlePublisherUpdate = (topic: string, publishers: string[], callerId: string) => { const sub = this.subscriptions.get(topic); if (sub == undefined) { return; } const prevPublishers = Array.from(sub.publishers().values()).map((v) => v.publisherXmlRpcUrl()); const removed = difference(prevPublishers, publishers); const added = difference(publishers, prevPublishers); // Remove all publishers that have disappeared for (const removePub of removed) { for (const [connectionId, pub] of sub.publishers().entries()) { if (pub.publisherXmlRpcUrl() === removePub) { this._log?.info?.(`publisher ${removePub} for ${sub.name} went offline, disconnecting`); sub.removePublisher(connectionId); break; } } } // Add any new publishers that have appeared for (const addPub of added) { this._log?.info?.(`publisher ${addPub} for ${sub.name} came online, connecting`); this._subscribeToPublisher(addPub, sub).catch((err) => { // This should never be called, this._subscribeToPublisher() is not expected to throw this._log?.warn?.(`subscribe to publisher unexpectedly failed: ${err}`); }); } this.emit("publisherUpdate", { topic, publishers, prevPublishers, callerId }); }; private _handleTcpClientConnection = ( topic: string, connectionId: number, destinationCallerId: string, client: Client, ) => { const publication = this.publications.get(topic); if (publication == undefined) { this._log?.warn?.(`${client.toString()} connected to non-published topic ${topic}`); this.emit( "error", new Error(`${client.toString()} connected to non-published topic ${topic}`), ); return client.close(); } this._log?.info?.( `adding subscriber ${client.toString()} (${destinationCallerId}) to topic ${topic}, connectionId ${connectionId}`, ); publication.addSubscriber(connectionId, destinationCallerId, client); }; private _handleTcpPublisherError = (err: Error) => { this.emit("error", err); }; private async _registerSubscriber(subscription: Subscription): Promise<string[]> { if (!this._running) { return []; } const callerApi = this._callerApi(); // Register with rosmaster as a subscriber to this topic const [status, msg, publishers] = await this.rosMasterClient.registerSubscriber( this.name, subscription.name, subscription.dataType, callerApi, ); if (status !== OK) { throw new Error(`registerSubscriber() failed. status=${status}, msg="${msg}"`); } if (!Array.isArray(publishers)) { throw new Error( `registerSubscriber() did not receive a list of publishers. value=${publishers}`, ); } this._log?.debug?.(`registered subscriber to ${subscription.name} (${subscription.dataType})`); return publishers as string[]; } private async _registerPublisher(publication: Publication): Promise<string[]> { if (!this._running) { return []; } const callerApi = this._callerApi(); const [status, msg, subscribers] = await this.rosMasterClient.registerPublisher( this.name, publication.name, publication.dataType, callerApi, ); if (status !== OK) { throw new Error(`registerPublisher() failed. status=${status}, msg="${msg}"`); } this._log?.debug?.(`registered publisher for ${publication.name} (${publication.dataType})`); if (!Array.isArray(subscribers)) { throw new Error( `registerPublisher() did not receive a list of subscribers. value=${String(subscribers)}`, ); } return subscribers as string[]; } private async _unregisterSubscriber(topic: string): Promise<void> { try { const callerApi = this._callerApi(); // Unregister with rosmaster as a subscriber to this topic const [status, msg] = await this.rosMasterClient.unregisterSubscriber( this.name, topic, callerApi, ); if (status !== OK) { throw new Error(`unregisterSubscriber() failed. status=${status}, msg="${msg}"`); } this._log?.debug?.(`unregistered subscriber to ${topic}`); } catch (unk) { // Warn and carry on, the rosmaster graph will be out of sync but there's // not much we can do (it may already be offline) const err = unk instanceof Error ? unk : new Error(unk as string); this._log?.warn?.(err.message, "unregisterSubscriber"); this.emit("error", err); } } private async _unregisterPublisher(topic: string): Promise<void> { try { const callerApi = this._callerApi(); // Unregister with rosmaster as a publisher of this topic const [status, msg] = await this.rosMasterClient.unregisterPublisher( this.name, topic, callerApi, ); if (status !== OK) { throw new Error(`unregisterPublisher() failed. status=${status}, msg="${msg}"`); } this._log?.debug?.(`unregistered publisher for ${topic}`); } catch (unk) { // Warn and carry on, the rosmaster graph will be out of sync but there's // not much we can do (it may already be offline) const err = unk instanceof Error ? unk : new Error(unk as string); this._log?.warn?.(err.message, "unregisterPublisher"); this.emit("error", err); } } private async _registerSubscriberAndConnect(subscription: Subscription): Promise<void> { // Register with rosmaster as a subscriber to the requested topic. Continue // retrying until the XML-RPC call to roscore succeeds, or we are no longer // subscribed const publishers = await retryForever(async () => { if (!this.isSubscribedTo(subscription.name)) { return []; } return await this._registerSubscriber(subscription); }); // Register with each publisher. Any failures communicating with individual node XML-RPC servers // or TCP sockets will be caught and retried await Promise.allSettled( publishers.map(async (pubUrl) => await this._subscribeToPublisher(pubUrl, subscription)), ); } async _subscribeToPublisher(pubUrl: string, subscription: Subscription): Promise<void> { const topic = subscription.name; const dataType = subscription.dataType; const md5sum = subscription.md5sum; const tcpNoDelay = subscription.tcpNoDelay; if (!this.isSubscribedTo(topic)) { return; } let connection: TcpConnection; let address: string; let port: number; try { // Create an XMLRPC client to talk to this publisher const rosFollowerClient = new RosFollowerClient(pubUrl); // Call requestTopic on this publisher to register ourselves as a subscriber const socketInfo = await RosNode.RequestTopic(this.name, topic, rosFollowerClient); ({ address, port } = socketInfo); const uri = TcpConnection.Uri(address, port); this._log?.debug?.( `registered with ${pubUrl} as a subscriber to ${topic}, connecting to ${uri}`, ); if (!this.isSubscribedTo(topic)) { return; } // Create a TCP socket connecting to this publisher const socket = await this._tcpSocketCreate({ host: address, port }); connection = new TcpConnection( socket, address, port, new Map<string, string>([ ["topic", topic], ["md5sum", md5sum ?? "*"], ["callerid", this.name], ["type", dataType], ["tcp_nodelay", tcpNoDelay ? "1" : "0"], ]), this._log, ); if (!this.isSubscribedTo(topic)) { socket.close().catch((err) => { this._log?.warn?.( `closing connection to ${address}:${port} for topic "${topic}" failed: ${err}`, ); }); return; } // Hold a reference to this publisher const connectionId = this._newConnectionId(); subscription.addPublisher(connectionId, rosFollowerClient, connection); } catch (err) { // Consider tracking failed RosFollower connections (node XML-RPC servers) and entering a // retry loop this._log?.warn?.( `subscribing to ${topic} at ${pubUrl} failed (${err}), this connection will be dropped`, ); this.emit( "error", new Error( `Subscribing to ${topic} at ${pubUrl} failed (${err}), this connection will be dropped`, ), ); return; } // Asynchronously initiate the socket connection. This will enter a retry loop on failure connection.connect().catch((err) => { // This should never happen, connect() is assumed not to throw this._log?.warn?.( `connecting to ${address}:${port} for topic "${topic}" unexpectedly failed: ${err}`, ); }); } static GetRosHostname( getEnvVar: (envVar: string) => string | undefined, getHostname: () => string | undefined, getNetworkInterfaces: () => NetworkInterface[], ): string { // Prefer ROS_HOSTNAME, then ROS_IP env vars let hostname = getEnvVar("ROS_HOSTNAME") ?? getEnvVar("ROS_IP"); if (hostname != undefined && hostname.length > 0) { return hostname; } // Try to get the operating system hostname hostname = getHostname(); if (hostname != undefined && hostname.length > 0) { return hostname; } // Fall back to iterating network interfaces looking for an IP address let bestAddr: NetworkInterface | undefined; const ifaces = getNetworkInterfaces(); for (const iface of ifaces) { if ( (iface.family !== "IPv4" && iface.family !== "IPv6") || iface.internal || iface.address.length === 0 ) { continue; } if (bestAddr == undefined) { // Use the first non-internal interface we find bestAddr = iface; } else if (RosNode.IsPrivateIP(bestAddr.address) && !RosNode.IsPrivateIP(iface.address)) { // Prefer public IPs over private bestAddr = iface; } else if (bestAddr.family !== "IPv6" && iface.family === "IPv6") { // Prefer IPv6 bestAddr = iface; } } if (bestAddr != undefined) { return bestAddr.address; } // Last resort, return IPv4 loopback return "127.0.0.1"; } static IsPrivateIP(ip: string): boolean { // Logic based on isPrivateIP() in ros_comm network.cpp return ip.startsWith("192.168") || ip.startsWith("10.") || ip.startsWith("169.254"); } }