lib/roby/interface.rb in roby-0.8.0 vs lib/roby/interface.rb in roby-3.0.0

- old
+ new

@@ -1,474 +1,141 @@ -require 'utilrb/column_formatter' - +require 'roby' module Roby - # An augmented DRbObject which allow to properly interface with remotely - # running plan objects. - class RemoteObjectProxy < DRbObject - attr_accessor :remote_interface + # High-level command and control of a Roby controller + # + # The {Interface} module provides a high-level control interface to a + # running Roby controller. It is the basis for all remote Roby UIs such as + # the Syskit IDE or the Roby shell. The following documentation aims at + # giving a bird eye's view of the module's structure + # + # == Jobs + # + # The high-level construct used in the Roby interface is the job. Jobs are + # representation of the high-level goals that a user gave to the system. A + # task represents a job if: + # + # - it provides the {Interface::Job} service + # - it has a non-nil {Interface::Job#job_id} argument + # - itself or its planned task is a mission + # + # In case a job task is a planning task, the job itself will be represented + # by the job's planned task. Across the job-related APIs, one will see that + # jobs are therefore associated with two tasks: the task or placeholder + # task, and the job task itself. + # + # The interface APIs provide ways to track the progress of jobs. Each job + # transition is represented by a Interface::JOB_* constant (e.g. + # {Interface::JOB_READY}), and notifications are sent to remote endpoints + # about the current state and progress of jobs. + # + # == Synchronous Client/Server API + # + # A Roby application will in most cases create an {Interface::Interface} + # object, which is the endpoint for all interface-related matters. A + # client/server mechanism allows to access the app's interface. + # {Interface::Server} provides the server-side and {Interface::Client} the + # client-side. Both classes are independent of the communication channel + # used. The communication is based on marshalling and demarshalling of an + # array that represents a method name and arguments on the + # {Interface::Interface} class. The marshalling/demarshalling and the exact + # packet format is left to the channel class given to Client and Server at + # construction time (see below) + # + # The core of the method calls on {Interface::Client} are the calls + # available on {Interface::Interface}. Check the latter to figure out what + # you can do with the former. In addition, it supports starting actions (and + # jobs) using an action_name!(arguments) syntax. This is meant as syntactic + # sugar for use in interactive implementations, but one should use + # {Interface::Interface#start_job} when starting jobs programmatically. + # + # In addition to the remote method calls, the Client API provides + # notifications pushed by the interface: + # + # - {Interface::Client#pop_notification}: general log messages from + # {Application#notify}. By default, all log messages generated from {Robot} + # are forwarded this way + # - {Interface::Client#pop_job_progress}: job progress + # - {Interface::Client#pop_exception}: about exceptions + # + # == Asynchronous API + # + # To connect to the client/server API, one has to have a remote Roby app to + # connect to. Moreoover, the API is really designed as a request/response + # scheme, which is not a very nice format to build UIs from. + # + # For these, reasons, a higher level, event-based API has been built on top + # of the client/server functionality. The main entrypoint for this + # asynchronous API is {Interface::Async::Interface}. In addition to properly + # handling (re)connections, this API provides also a nicer interface to job + # tracking. + # + # Jobs are represented by {Async::JobMonitor} objects, which track the job + # state and provide operations on them such as killing, dropping and + # restarting them as well as registering hooks to track their progress. One + # usually gets these job monitor objects by listening for new jobs using + # {Async::Interface#on_job}. + # + # Note that in most cases, new job monitor objects are inactive (i.e. won't + # get notifications) until you explicitely call {Async::JobMonitor#start} on + # them. Whether this is the case or not is documented on each method that + # return or yield a job monitor object. + # + # == Asynchronous log stream API + # + # In addition to the notifications provided by {Interface::Client}, one can + # use the Roby logging to build a complete representation of a plan. The + # {Interface::Async::Log} class gives easy-to-use access to such a rebuilt + # plan, along with the ability to disconnect and reconnect to a remote + # Roby app. + # + # == Event Loop Integration + # + # {Interface::Interface} hooks itself in the app's main event loop, as does + # {Interface::TCPServer}. On the client side, processing is done in + # {Interface::Client#poll} which therefore needs to be called periodically + # within your app's main loop. In Qt, it usually means starting a timer + # + # timer = Qt::Timer.new(self) + # timer.connect(SIGNAL('timeout()')) do + # client.poll + # end + # + # == Communication Channel + # + # {Interface::DRobyChannel} provides a default implementation, using the + # DRoby marshalling/demarshalling for object-to-binary translation, + # WebSockets for framing and a subclass of IO as the underlying + # communication medium. The most common usage is to spawn a TCP server based + # on this channel with {Interface::TCPServer}, and connect to it from the + # client side with {Interface.connect_with_tcp_to}. A Roby application + # spawns such a server automatically by calling + # {Roby::Application#setup_shell_interface} if + # {Roby::Application#public_shell_interface?} is true. + # - def to_s - __method_missing__(:to_s) - end - def pretty_print(pp) - pp.text to_s - end + module Interface + DEFAULT_PORT = 20201 + DEFAULT_REST_PORT = 20202 - alias __method_missing__ method_missing - def method_missing(*args, &block) - if remote_interface - remote_interface.call(self, *args, &block) - else - super - end - end - end + extend Logger::Hierarchy - # RemoteInterface objects are used as local representation of remote - # interface objects. They offer a seamless interface to a remotely running - # Roby controller. - class RemoteInterface - # Create a RemoteInterface object for the remote object represented by - # +interface+, where +interface+ is a DRbObject for a remote Interface - # object. - def initialize(interface) - @interface = interface - reconnect + module Async + extend Logger::Hierarchy end - - def reconnect - remote_models = @interface.task_models - remote_models.map do |klass| - klass = klass.proxy(nil) - - if klass.respond_to?(:remote_name) - # This is a local proxy for a remote model. Add it in our - # namespace as well. - path = klass.remote_name.split '::' - klass_name = path.pop - mod = Object - while !path.empty? - name = path.shift - mod = begin - mod.const_get(name) - rescue NameError - mod.const_set(name, Module.new) - end - end - mod.const_set(klass_name, klass) - end - end - end - - # Returns a Query object which can be used to interactively query the - # running plan - def find_tasks(model = nil, args = nil) - q = Query.new(self) - if model - q.which_fullfills(model, args) - end - q - end - - # Defined for remotes queries to work - def query_result_set(query) # :nodoc: - @interface.remote_query_result_set(Distributed.format(query)).each do |t| - t.remote_interface = self - end - end - # Defined for remotes queries to work - def query_each(result_set) # :nodoc: - result_set.each do |t| - yield(t) - end - end - # Defined for remotes queries to work - def query_roots(result_set, relation) # :nodoc: - @interface.remote_query_roots(result_set, Distributed.format(relation)).each do |t| - t.remote_interface = self - end - end - - # Returns the DRbObject for the remote controller state object - def state - remote_constant('State') - end - - def instance_methods(include_super = false) # :nodoc: - Interface.instance_methods(false). - actions.map { |name| "#{name}!" } - end - - def actions_summary(with_advanced = false) - methods = @interface.actions - if !with_advanced - methods = methods.delete_if { |m| m.description.advanced? } - end - - if !methods.empty? - puts - desc = methods.map do |p| - doc = p.description.doc || ["(no description set)"] - Hash['Name' => "#{p.name}!", 'Description' => doc.join("\n")] - end - - ColumnFormatter.from_hashes(desc, STDOUT, - :header_delimiter => true, - :column_delimiter => "|", - :order => %w{Name Description}) - puts - end - - nil - end - - def actions(with_advanced = false) - @interface.actions.each do |m| - next if m.description.advanced? if !with_advanced - display_action_description(m) - puts - end - nil - end - - # Standard way to display a set of tasks - def task_set_to_s(task_set) # :nodoc: - if task_set.empty? - return "no tasks" - end - - task = task_set.map do |task| - state_name = %w{pending starting running finishing finished}.find do |state_name| - task.send("#{state_name}?") - end - - since = task.start_time - lifetime = task.lifetime - Hash['Task' => task.to_s, - 'State' => state_name, - 'Since' => (since.asctime if since), - 'Lifetime' => (Time.at(lifetime).to_hms if lifetime) - ] - end - - io = StringIO.new - ColumnFormatter.from_hashes(task, STDOUT, - :header_delimiter => true, - :column_delimiter => "|", - :order => %w{Task State Lifetime Since}) - end - - # Displays information about the plan's missions - def missions - missions = find_tasks.mission.to_a - task_set_to_s(missions) - nil - end - - # Displays information about the running tasks - def running_tasks - tasks = find_tasks.running.to_a - task_set_to_s(tasks) - nil - end - - # Displays details about the actions matching 'regex' - def describe(name, with_advanced = false) - name = Regexp.new(name) - m = @interface.actions.find_all { |p| name === p.name } - - if !with_advanced - filtered = m.find_all { |m| !m.description.advanced? } - m = filtered if !filtered.empty? - end - - if m.empty? - puts "no such method" - else - m.each do |desc| - puts - display_action_description(desc) - puts - end - end - nil - end - - # Displays a help message - def help - puts - puts "Available Actions" - puts "=================" - actions_summary - puts "" - - - puts <<-EOHELP -each action is started with action_name!(:arg1 => value1, :arg2 => value2, ...) -and returns the corresponding task object. A message is displayed in the shell -when the task finishes." - -Shell Commands -============== -Command | Help ---------------------------------------------------------------------------------------------- -actions_summary(advanced = false) | displays the list of actions with a short documentation | -actions(advanced = false) | displays details for each available actions | -describe(regex) | displays details about the actions matching 'regex' | -missions | displays the set of running missions with their status | -running_tasks | displays the set of running tasks with their status | - | | -help | this help message | - - EOHELP - end - - # Standard display of an action description. +m+ is a PlanningMethod - # object. - def display_action_description(m) # :nodoc: - args = m.description.arguments. - sort_by { |arg_desc| arg_desc.name } - - first = true - args_summary = args.map do |arg_desc| - name = arg_desc.name - is_required = arg_desc.required - format = if is_required then "%s" - else "[%s]" - end - text = format % ["#{", " if !first}:#{name} => #{name}"] - first = false - text - end - - args_table = args. - map do |arg_desc| - Hash['Argument' => arg_desc.name, - 'Description' => (arg_desc.doc || "(no description set)")] - end - - method_doc = m.description.doc || [""] - puts "#{m.name}! #{args_summary.join("")}\n#{method_doc.join("\n")}" - if m.description.arguments.empty? - puts "No arguments" - else - ColumnFormatter.from_hashes(args_table, STDOUT, - :left_padding => " ", - :header_delimiter => true, - :column_delimiter => "|", - :order => %w{Argument Description}) - end - end - - - def method_missing(m, *args) # :nodoc: - result = @interface.send(m, *args) - if result.kind_of?(RemoteObjectProxy) - result.remote_interface = @interface - end - result - - rescue Exception => e - raise e, e.message, Roby.filter_backtrace(e.backtrace) - end end - - # This class is used to interface with the Roby event loop and plan. It is the - # main front object when accessing a Roby core remotely - class Interface - # This module defines the hooks needed to plug Interface objects onto - # ExecutionEngine - module GatherExceptions - # The set of Interface objects that have been registered to us - attribute(:interfaces) { Array.new } - - # Register a new Interface object so that it gets feedback information - # from the running controller. - def register_interface(iface) - Roby.synchronize do - interfaces << iface - end - end - - # Pushes a exception message to all the already registered remote interfaces. - def push_exception_message(name, error, tasks) - Roby.synchronize do - msg = Roby.format_exception(error.exception).join("\n") - msg << "\nThe following tasks have been killed:\n" - tasks.each do |t| - msg << " " - if error.exception.involved_plan_object?(t) - msg << "#{t.class}:0x#{t.address.to_s(16)}\n" - else - PP.pp(t, msg) - end - end - - interfaces.each do |iface| - iface.pending_messages << msg - end - end - end - - # Pushes an exception information on all remote interfaces connected to us - def handled_exception(error, task) - super if defined? super - push_exception_message("exception", error, [task]) - end - - # Pushes an exception information on all remote interfaces connected to us - def fatal_exception(error, tasks) - super if defined? super - push_exception_message("fatal exception", error, tasks) - end - end - - # The engine this interface is tied to - attr_reader :engine - # The set of pending messages that are to be displayed on the remote interface - attr_reader :pending_messages - # Creates a local server for a remote interface, acting on +control+ - def initialize(engine) - @pending_messages = Queue.new - @engine = engine - - engine.extend GatherExceptions - engine.register_interface self - end - - # Clear the current plan: remove all running and permanent tasks. - def clear - engine.execute do - plan.missions.dup.each { |t| plan.discard(t) } - plan.permanent_tasks.dup.each { |t| plan.auto(t) } - plan.permanent_events.dup.each { |t| plan.auto(t) } - end - end - - # Make the Roby event loop quit - def stop; engine.quit; nil end - # The Roby plan - def plan; engine.plan end - - # Synchronously call +m+ on +tasks+ with the given arguments. This, - # along with the implementation of RemoteInterface#method_missing, - # ensures that no interactive operations are performed outside the - # control thread. - def call(task, m, *args) - engine.execute do - if m.to_s =~ /!$/ - event_name = $` - # Check if the called event is terminal. If it is the case, - # discard the task before calling it, and make sure the user - # will get a message - # - if task.event(event_name).terminal? - plan.unmark_mission(task) - task.on(:stop) { |ev| pending_messages << "task #{ev.task} stopped by user request" } - else - task.on(event_name) { |ev| pending_messages << "done emitting #{ev.generator}" } - end - end - - task.send(m, *args) - end - end - - def find_tasks(model = nil, args = nil) - plan.find_tasks(model, args) - end - - # For using Query on Interface objects - def remote_query_result_set(m_query) # :nodoc: - plan.query_result_set(m_query.to_query(plan)). - map { |t| RemoteObjectProxy.new(t) } - end - # For using Query on Interface objects - def remote_query_roots(result_set, m_relation) # :nodoc: - plan.query_roots(result_set, m_relation.proxy(nil)). - map { |t| RemoteObjectProxy.new(t) } - end - - # Returns a DRbObject on the given named constant. Use this to get a - # remote interface to a given object, not taking into account its - # 'marshallability' - def remote_constant(name) - DRbObject.new(name.to_s.constantize) - end - - # Reload the Roby framework code - # - # WARNING: does not work for now - def reload - Roby.app.reload - nil - end - - # Returns the set of task models as DRobyTaskModel objects. The standard - # Roby task models are excluded. - def task_models - task_models = [] - engine.execute do - ObjectSpace.each_object(Class) do |obj| - if obj <= Roby::Task && obj.name !~ /^Roby::/ - task_models << obj - end - end - end - task_models.map { |t| t.droby_dump(nil) } - end - - # Returns the set of PlanningMethod objects that describe the methods - # exported in the application's planners. - def actions - Roby.app.planners. - map do |p| - p.planning_methods - end.flatten.sort_by { |p| p.name } - end - - # Called every once in a while by RemoteInterface to read and clear the - # set of pending messages. - def poll_messages - result = [] - while !pending_messages.empty? - msg = pending_messages.pop - result << msg - end - result - end - - # Tries to find a planner method which matches +name+ with +args+. If it finds - # one, creates a task planned by a planning task and yields both - def method_missing(name, *args) - if name.to_s =~ /!$/ - name = $`.to_sym - else - super - end - - if args.size > 1 - raise ArgumentError, "wrong number of arguments (#{args.size} for 1) in #{name}!" - end - - options = args.first || {} - task, planner = Robot.prepare_action(name, options) - begin - engine.wait_until(planner.event(:success)) do - plan.add_mission(task) - yield(task, planner) if block_given? - end - rescue Roby::UnreachableEvent - raise RuntimeError, "cannot start #{name}: #{planner.terminal_event.context.first}" - end - - engine.execute do - result = planner.result - result.on(:failed) { |ev| pending_messages << "task #{ev.task} failed" } - result.on(:success) { |ev| pending_messages << "task #{ev.task} finished successfully" } - RemoteObjectProxy.new(result) - end - end - end end - +require 'websocket' +require 'utilrb/hash' +require 'roby/interface/job' +require 'roby/interface/exceptions' +require 'roby/interface/command_argument' +require 'roby/interface/command' +require 'roby/interface/command_library' +require 'roby/interface/interface' +require 'roby/interface/droby_channel' +require 'roby/interface/server' +require 'roby/interface/client' +require 'roby/interface/subcommand_client' +require 'roby/interface/tcp' +require 'roby/interface/shell_client' +require 'roby/interface/shell_subcommand'