def test_script_can_stop_itself(self): robot_mock = create_robot_mock() cont = Event() mock = Mock() sm = ScriptManager(robot_mock) sm.add_script( ScriptDescriptor( 'test', str_to_func(''' while not ctx.stop_requested: mock() Control.terminate() mock() '''), 0)) sm.assign('mock', mock) sm['test'].on_stopped(cont.set) # first call, make sure the script runs sm['test'].start().wait() if not cont.wait(2): self.fail() sm.reset() self.assertEqual(1, mock.call_count)
def test_resetting_the_manager_stops_running_scripts(self): robot_mock = create_robot_mock() stopped_mock = Mock() sm = ScriptManager(robot_mock) sm.add_script( ScriptDescriptor( 'test', str_to_func(''' while not ctx.stop_requested: pass '''), 0)) sm.add_script( ScriptDescriptor( 'test2', str_to_func(''' while not ctx.stop_requested: pass '''), 0)) # first call, make sure the script runs sm['test'].on_stopped(stopped_mock) sm['test2'].on_stopped(stopped_mock) sm['test'].start() sm['test2'].start() sm.reset() self.assertEqual(2, stopped_mock.call_count)
def test_script_can_stop_other_scripts(self): robot_mock = create_robot_mock() mock1 = Mock() mock2 = Mock() second_running_evt = Event() sm = ScriptManager(robot_mock) sm.add_script( ScriptDescriptor( 'test1', str_to_func(''' mock() second_running.wait() while not ctx.stop_requested: Control.terminate_all() '''), 0)) sm.add_script( ScriptDescriptor( 'test2', str_to_func(''' second_running.set() mock() while not ctx.stop_requested: time.sleep(0.01) '''), 0)) sm['test1'].assign('mock', mock1) sm['test1'].assign('second_running', second_running_evt) sm['test2'].assign('second_running', second_running_evt) sm['test2'].assign('mock', mock2) try: # first call, make sure the script runs script1_stopped = Event() script2_stopped = Event() sm['test1'].on_stopped(script1_stopped.set) sm['test2'].on_stopped(script2_stopped.set) sm['test1'].start() sm['test2'].start() script1_stopped.wait(1) script2_stopped.wait(1) # scripts started? self.assertEqual(1, mock1.call_count) self.assertEqual(1, mock2.call_count) # scripts stopped? self.assertFalse(sm['test1'].is_running) self.assertFalse(sm['test2'].is_running) finally: sm.reset()
def test_crashing_script_calls_stopped_handler(self): robot_mock = create_robot_mock() cont = Event() sm = ScriptManager(robot_mock) sm.add_script( ScriptDescriptor('test', str_to_func('''raise Excepti'''), 0)) sm['test'].on_stopped(cont.set) # first call, make sure the script runs sm['test'].start().wait() if not cont.wait(2): self.fail("Script.on_stopped handler was not called") sm.reset()
def test_new_stop_callback_is_called_even_after_script_is_stopped(self): robot_mock = create_robot_mock() cont = Event() mock = Mock() sm = ScriptManager(robot_mock) sm.add_script('test', '') sm['test'].on_stopped(cont.set) # start and make sure script is stopped sm['test'].start() sm['test'].stop().wait() sm['test'].on_stopped(mock) sm.reset() self.assertEqual(1, mock.call_count)
class RobotBLEController: # FIXME: revvy_ble intentionally doesn't have a type hint at this moment because it breaks tests right now def __init__(self, robot: Robot, sw_version, revvy_ble): self._log = get_logger('RobotManager') self._log('init') self.needs_interrupting = True self._configuring = False self._robot = robot self._ble = revvy_ble self._sw_version = sw_version self._status_update_thread = periodic(self._update, 0.005, "RobotStatusUpdaterThread") self._background_fns = [] rc = RemoteController() rcs = RemoteControllerScheduler(rc) rcs.on_controller_detected(self._on_controller_detected) rcs.on_controller_lost(self._on_controller_lost) self._remote_controller = rc self._remote_controller_thread = create_remote_controller_thread(rcs) self._resources = { 'led_ring': Resource('RingLed'), 'drivetrain': Resource('DriveTrain'), 'sound': Resource('Sound'), **{ f'motor_{port.id}': Resource(f'Motor {port.id}') for port in self._robot.motors }, **{ f'sensor_{port.id}': Resource(f'Sensor {port.id}') for port in self._robot.sensors } } revvy_ble['live_message_service'].register_message_handler( rcs.data_ready) revvy_ble.on_connection_changed(self._on_connection_changed) self._scripts = ScriptManager(self) self._config = empty_robot_config self._status_code = RevvyStatusCode.OK self.exited = Event() self.start_remote_controller = self._remote_controller_thread.start def _update(self): # noinspection PyBroadException try: self._robot.update_status() self._ble['battery_service'].characteristic( 'main_battery').update_value(self._robot.battery.main) self._ble['battery_service'].characteristic( 'motor_battery').update_value(self._robot.battery.motor) fns, self._background_fns = self._background_fns, [] if fns: for i, fn in enumerate(fns): self._log(f'Running {i}/{len(fns)} background functions') fn() self._log('Background functions finished') except TransportException: self._log(traceback.format_exc()) self.exit(RevvyStatusCode.ERROR) except Exception: self._log(traceback.format_exc()) @property def resources(self): return self._resources @property def config(self): return self._config @property def status_code(self): return self._status_code @property def robot(self): return self._robot @property def remote_controller(self): return self._remote_controller def exit(self, status_code): self._log(f'exit requested with code {status_code}') self._status_code = status_code if self.needs_interrupting: os.kill(os.getpid(), signal.SIGINT) self.exited.set() def wait_for_exit(self): self.exited.wait() return self._status_code def request_update(self): def update(): self._log('Exiting to update') time.sleep(1) self.exit(RevvyStatusCode.UPDATE_REQUEST) self.run_in_background(update) def start(self): self._log('start') if self._robot.status.robot_status == RobotStatus.StartingUp: self._log('Waiting for MCU') try: self._ping_robot() except TimeoutError: pass # FIXME somehow handle a dead MCU self._ble['device_information_service'].characteristic( 'hw_version').update(str(self._robot.hw_version)) self._ble['device_information_service'].characteristic( 'fw_version').update(str(self._robot.fw_version)) self._ble['device_information_service'].characteristic( 'sw_version').update(str(self._sw_version)) # start reader thread self._status_update_thread.start() self._ble.start() self._robot.status.robot_status = RobotStatus.NotConfigured self.configure(None, partial(self._robot.play_tune, 'robot2')) def run_in_background(self, callback): if callable(callback): self._log('Registering new background function') self._background_fns.append(callback) else: raise ValueError('callback is not callable') def _on_connection_changed(self, is_connected): self._log('Phone connected' if is_connected else 'Phone disconnected') if not is_connected: self._robot.status.controller_status = RemoteControllerStatus.NotConnected self._robot.play_tune('disconnect') self.configure(None) else: self._robot.status.controller_status = RemoteControllerStatus.ConnectedNoControl self._robot.play_tune('bell') def _on_controller_detected(self): self._log('Remote controller detected') self._robot.status.controller_status = RemoteControllerStatus.Controlled def _on_controller_lost(self): self._log('Remote controller lost') if self._robot.status.controller_status != RemoteControllerStatus.NotConnected: self._robot.status.controller_status = RemoteControllerStatus.ConnectedNoControl self.configure(None) def configure(self, config, after=None): self._log('Request configuration') if self._robot.status.robot_status != RobotStatus.Stopped: self.run_in_background(partial(self._configure, config)) if callable(after): self.run_in_background(after) def _reset_configuration(self): self._scripts.reset() self._scripts.assign('Motor', MotorConstants) self._scripts.assign('RingLed', RingLed) self._remote_controller_thread.stop().wait() for res in self._resources.values(): res.reset() # ping robot, because robot may reset after stopping scripts self._ping_robot() self._robot.reset() def _apply_new_configuration(self, config): # apply new configuration self._log('Applying new configuration') live_service = self._ble['live_message_service'] # set up motors for motor in self._robot.motors: motor.configure(config.motors[motor.id]) motor.on_status_changed.add(lambda p: live_service.update_motor( p.id, p.power, p.speed, p.pos)) for motor_id in config.drivetrain['left']: self._robot.drivetrain.add_left_motor(self._robot.motors[motor_id]) for motor_id in config.drivetrain['right']: self._robot.drivetrain.add_right_motor( self._robot.motors[motor_id]) # set up sensors for sensor in self._robot.sensors: sensor.configure(config.sensors[sensor.id]) sensor.on_status_changed.add( lambda p: live_service.update_sensor(p.id, p.raw_value)) def start_analog_script(src, channels): src.start(channels=channels) # set up remote controller for analog in config.controller.analog: script_handle = self._scripts.add_script(analog['script']) self._remote_controller.on_analog_values( analog['channels'], partial(start_analog_script, script_handle)) for button, script in enumerate(config.controller.buttons): if script: script_handle = self._scripts.add_script(script) self._remote_controller.on_button_pressed( button, script_handle.start) # start background scripts for script in config.background_scripts: script_handle = self._scripts.add_script(script) script_handle.start() def _configure(self, config): is_default_config = not config and self._robot.status.robot_status != RobotStatus.Stopped if is_default_config: config = empty_robot_config self._config = config self._scripts.stop_all_scripts() self._reset_configuration() self._apply_new_configuration(config) if is_default_config: self._log('Default configuration applied') self._robot.status.robot_status = RobotStatus.NotConfigured else: self._robot.status.robot_status = RobotStatus.Configured def stop(self): self._robot.status.controller_status = RemoteControllerStatus.NotConnected self._robot.status.robot_status = RobotStatus.Stopped self._remote_controller_thread.exit() self._ble.stop() self._scripts.reset() self._status_update_thread.exit() self._robot.stop() def _ping_robot(self, timeout=0): stopwatch = Stopwatch() retry_ping = True while retry_ping: retry_ping = False try: self._robot.ping() except (BrokenPipeError, IOError, OSError): retry_ping = True time.sleep(0.1) if timeout != 0 and stopwatch.elapsed > timeout: raise TimeoutError
class RobotManager: # FIXME: revvy intentionally doesn't have a type hint at this moment because it breaks tests right now def __init__(self, interface: RevvyControl, revvy, sound_paths, sw_version, default_config=None): print("RobotManager: __init__()") self.needs_interrupting = True self._configuring = False self._robot = Robot(interface, sound_paths, sw_version) self._interface = interface self._ble = revvy self._default_configuration = default_config or RobotConfig() self._status_update_thread = periodic(self._update, 0.02, "RobotStatusUpdaterThread") self._background_fn_lock = Lock() self._background_fns = [] rc = RemoteController() rcs = RemoteControllerScheduler(rc) rcs.on_controller_detected(self._on_controller_detected) rcs.on_controller_lost(self._on_controller_lost) self._remote_controller = rc self._remote_controller_scheduler = rcs self._remote_controller_thread = create_remote_controller_thread(rcs) self._resources = { 'led_ring': Resource(), 'drivetrain': Resource(), 'sound': Resource(), **{'motor_{}'.format(port.id): Resource() for port in self._robot.motors}, **{'sensor_{}'.format(port.id): Resource() for port in self._robot.sensors} } revvy['live_message_service'].register_message_handler(self._remote_controller_scheduler.data_ready) revvy.on_connection_changed(self._on_connection_changed) self._scripts = ScriptManager(self) self._config = self._default_configuration self._status_code = RevvyStatusCode.OK self.exited = False def _update(self): # noinspection PyBroadException try: self._robot.update_status() self._ble['battery_service'].characteristic('main_battery').update_value(self._robot.battery.main) self._ble['battery_service'].characteristic('motor_battery').update_value(self._robot.battery.motor) with self._background_fn_lock: fns = list(self._background_fns) self._background_fns.clear() for fn in fns: print("Running background function") fn() except TransportException: self.exit(RevvyStatusCode.ERROR) except Exception: print(traceback.format_exc()) @property def resources(self): return self._resources @property def config(self): return self._config @property def sound(self): return self._robot.sound @property def status_code(self): return self._status_code @property def robot(self): return self._robot @property def remote_controller(self): return self._remote_controller def exit(self, status_code): self._status_code = status_code if self.needs_interrupting: os.kill(os.getpid(), signal.SIGINT) self.exited = True def request_update(self): def update(): print('Exiting to update') time.sleep(1) self.exit(RevvyStatusCode.UPDATE_REQUEST) self.run_in_background(update) def start(self): print("RobotManager: start()") if self._robot.status.robot_status == RobotStatus.StartingUp: print("Waiting for MCU") # TODO if we are getting stuck here (> ~3s), firmware is probably not valid self._ping_robot() self._ble['device_information_service'].characteristic('hw_version').update(str(self._robot.version.hw)) self._ble['device_information_service'].characteristic('fw_version').update(str(self._robot.version.fw)) self._ble['device_information_service'].characteristic('sw_version').update(self._robot.version.sw) # start reader thread self._status_update_thread.start() self._ble.start() self._robot.status.robot_status = RobotStatus.NotConfigured self.configure(None, lambda: self.sound.play_tune('robot2')) def run_in_background(self, callback): if callable(callback): with self._background_fn_lock: print("Registering new background function") self._background_fns.append(callback) def _on_connection_changed(self, is_connected): print('Phone connected' if is_connected else 'Phone disconnected') if not is_connected: self._robot.status.controller_status = RemoteControllerStatus.NotConnected self.configure(None) else: self._robot.status.controller_status = RemoteControllerStatus.ConnectedNoControl self._robot.sound.play_tune('bell') def _on_controller_detected(self): self._robot.status.controller_status = RemoteControllerStatus.Controlled def _on_controller_lost(self): if self._robot.status.controller_status != RemoteControllerStatus.NotConnected: self._robot.status.controller_status = RemoteControllerStatus.ConnectedNoControl self.configure(None) def configure(self, config, after=None): print('RobotManager: configure()') if self._robot.status.robot_status != RobotStatus.Stopped: if not self._configuring: self._configuring = True self.run_in_background(lambda: self._configure(config)) if callable(after): self.run_in_background(after) def _reset_configuration(self): reset_volume() self._scripts.reset() self._scripts.assign('Motor', MotorConstants) self._scripts.assign('RingLed', RingLed) self._remote_controller_thread.stop() for res in self._resources: self._resources[res].reset() # ping robot, because robot may reset after stopping scripts self._ping_robot() self._robot.reset() def _apply_new_configuration(self, config): # apply new configuration print("Applying new configuration") live_service = self._ble['live_message_service'] # set up motors for motor in self._robot.motors: motor.configure(config.motors[motor.id]) motor.on_status_changed(lambda p: live_service.update_motor(p.id, p.power, p.speed, p.position)) for motor_id in config.drivetrain['left']: self._robot.drivetrain.add_left_motor(self._robot.motors[motor_id]) for motor_id in config.drivetrain['right']: self._robot.drivetrain.add_right_motor(self._robot.motors[motor_id]) self._robot.drivetrain.configure() # set up sensors for sensor in self._robot.sensors: sensor.configure(config.sensors[sensor.id]) sensor.on_value_changed(lambda p: live_service.update_sensor(p.id, p.raw_value)) # set up scripts for name in config.scripts: self._scripts.add_script(name, config.scripts[name]['script'], config.scripts[name]['priority']) # set up remote controller for analog in config.controller.analog: self._remote_controller.on_analog_values( analog['channels'], lambda in_data, scr=analog['script']: self._scripts[scr].start({'input': in_data}) ) for button in range(len(config.controller.buttons)): script = config.controller.buttons[button] if script: self._remote_controller.on_button_pressed(button, self._scripts[script].start) # start background scripts for script in config.background_scripts: self._scripts[script].start() def _configure(self, config): is_default_config = config is None if not config and self._robot.status.robot_status != RobotStatus.Stopped: config = self._default_configuration self._config = config self._scripts.stop_all_scripts() self._reset_configuration() if config is not None: self._apply_new_configuration(config) if is_default_config: print('Default configuration applied') self._robot.status.robot_status = RobotStatus.NotConfigured else: self._robot.status.robot_status = RobotStatus.Configured else: self._robot.status.robot_status = RobotStatus.NotConfigured self._configuring = False def start_remote_controller(self): self._remote_controller_thread.start() def stop(self): self._robot.status.robot_status = RobotStatus.Stopped self._remote_controller_thread.exit() self._ble.stop() self._scripts.reset() self._status_update_thread.exit() def _ping_robot(self): retry_ping = True while retry_ping: retry_ping = False try: self._interface.ping() except (BrokenPipeError, IOError, OSError): retry_ping = True time.sleep(0.1)