def start_timelapse( self, settings, octoprint_printer_profile, ffmpeg_path, g90_influences_extruder): # we must supply the settings first! Else reset won't work properly. self._reset() # in case the settings have been destroyed and recreated self.Settings = settings # time tracking - how much time did we add to the print? self.SnapshotCount = 0 self.SecondsAddedByOctolapse = 0 self.HasSentInitialStatus = False self.RequiresLocationDetectionAfterHome = False self.OctoprintPrinterProfile = octoprint_printer_profile self.FfMpegPath = ffmpeg_path self.PrintStartTime = time.time() self.Snapshot = Snapshot(self.Settings.current_snapshot()) self.Gcode = SnapshotGcodeGenerator( self.Settings, octoprint_printer_profile) self.Printer = Printer(self.Settings.current_printer()) self.Rendering = Rendering(self.Settings.current_rendering()) self.CaptureSnapshot = CaptureSnapshot( self.Settings, self.DataFolder, print_start_time=self.PrintStartTime) self.Position = Position( self.Settings, octoprint_printer_profile, g90_influences_extruder) self.State = TimelapseState.WaitingForTrigger self.IsTestMode = self.Settings.current_debug_profile().is_test_mode self.Triggers = Triggers(self.Settings) self.Triggers.create() # take a snapshot of the current settings for use in the Octolapse Tab self.CurrentProfiles = self.Settings.get_profiles_dict() # send an initial state message self._on_timelapse_start()
def __init__(self, octolapseSettings, dataFolder, timelapseFolder, onSnapshotStart=None, onSnapshotEnd=None, onRenderStart=None, onRenderComplete=None, onRenderFail=None, onRenderSynchronizeFail=None, onRenderSynchronizeComplete=None, onRenderEnd=None, onTimelapseStopping=None, onTimelapseStopped=None, onStateChanged=None, onTimelapseStart=None, onSnapshotPositionError=None, onPositionError=None): # config variables - These don't change even after a reset self.Settings = octolapseSettings self.DataFolder = dataFolder self.DefaultTimelapseDirectory = timelapseFolder self.OnRenderStartCallback = onRenderStart self.OnRenderCompleteCallback = onRenderComplete self.OnRenderFailCallback = onRenderFail self.OnRenderingSynchronizeFailCallback = onRenderSynchronizeFail self.OnRenderingSynchronizeCompleteCallback = onRenderSynchronizeComplete self.OnRenderEndCallback = onRenderEnd self.OnSnapshotStartCallback = onSnapshotStart self.OnSnapshotCompleteCallback = onSnapshotEnd self.TimelapseStoppingCallback = onTimelapseStopping self.TimelapseStoppedCallback = onTimelapseStopped self.OnStateChangedCallback = onStateChanged self.OnTimelapseStartCallback = onTimelapseStart self.OnSnapshotPositionErrorCallback = onSnapshotPositionError self.OnPositionErrorCallback = onPositionError self.Responses = Responses( ) # Used to decode responses from the 3d printer self.Commands = Commands() # used to parse and generate gcode self.Triggers = Triggers(octolapseSettings) # Settings that may be different after StartTimelapse is called self.FfMpegPath = None self.Snapshot = None self.Gcode = None self.Printer = None self.CaptureSnapshot = None self.Position = None self.HasSentInitialStatus = False # State Tracking that should only be reset when starting a timelapse self.IsRendering = False self.HasBeenCancelled = False self.HasBeenStopped = False # State tracking variables self._reset()
class Timelapse(object): def __init__( self, settings, octoprint_printer, data_folder, timelapse_folder, on_print_started=None, on_print_start_failed=None, on_snapshot_start=None, on_snapshot_end=None, on_render_start=None, on_render_end=None, on_timelapse_stopping=None, on_timelapse_stopped=None, on_state_changed=None, on_timelapse_start=None, on_snapshot_position_error=None, on_position_error=None): # config variables - These don't change even after a reset self.DataFolder = data_folder self.Settings = settings # type: OctolapseSettings self.OctoprintPrinter = octoprint_printer self.DefaultTimelapseDirectory = timelapse_folder self.OnPrintStartCallback = on_print_started self.OnPrintStartFailedCallback = on_print_start_failed self.OnRenderStartCallback = on_render_start self.OnRenderEndCallback = on_render_end self.OnSnapshotStartCallback = on_snapshot_start self.OnSnapshotCompleteCallback = on_snapshot_end self.TimelapseStoppingCallback = on_timelapse_stopping self.TimelapseStoppedCallback = on_timelapse_stopped self.OnStateChangedCallback = on_state_changed self.OnTimelapseStartCallback = on_timelapse_start self.OnSnapshotPositionErrorCallback = on_snapshot_position_error self.OnPositionErrorCallback = on_position_error self.Responses = Responses() # Used to decode responses from the 3d printer self.Commands = Commands() # used to parse and generate gcode self.Triggers = None self.RenderingJobs = set() self.PrintEndStatus = "Unknown" # Settings that may be different after StartTimelapse is called self.OctoprintPrinterProfile = None self.PrintStartTime = None self.FfMpegPath = None self.Snapshot = None self.Gcode = None self.Printer = None self.CaptureSnapshot = None self.Position = None self.HasSentInitialStatus = False self.Rendering = None self.State = TimelapseState.Idle self.IsTestMode = False # State Tracking that should only be reset when starting a timelapse self.SnapshotCount = 0 self.HasBeenStopped = False self.TimelapseStopRequested = False self.SavedCommand = None self.SecondsAddedByOctolapse = 0 # State tracking variables self.RequiresLocationDetectionAfterHome = False # fetch position private variables self._position_payload = None self._position_timeout = 600.0 self._position_signal = threading.Event() self._position_signal.set() # get snapshot async private variables self._snapshot_success = False # It shouldn't take more than 5 seconds to take a snapshot! self._snapshot_timeout = 5.0 self._snapshot_signal = threading.Event() self._snapshot_signal.set() self.CurrentProfiles = {} self.CurrentFileLine = 0 self._reset() def start_timelapse( self, settings, octoprint_printer_profile, ffmpeg_path, g90_influences_extruder): # we must supply the settings first! Else reset won't work properly. self._reset() # in case the settings have been destroyed and recreated self.Settings = settings # time tracking - how much time did we add to the print? self.SnapshotCount = 0 self.SecondsAddedByOctolapse = 0 self.HasSentInitialStatus = False self.RequiresLocationDetectionAfterHome = False self.OctoprintPrinterProfile = octoprint_printer_profile self.FfMpegPath = ffmpeg_path self.PrintStartTime = time.time() self.Snapshot = Snapshot(self.Settings.current_snapshot()) self.Gcode = SnapshotGcodeGenerator( self.Settings, octoprint_printer_profile) self.Printer = Printer(self.Settings.current_printer()) self.Rendering = Rendering(self.Settings.current_rendering()) self.CaptureSnapshot = CaptureSnapshot( self.Settings, self.DataFolder, print_start_time=self.PrintStartTime) self.Position = Position( self.Settings, octoprint_printer_profile, g90_influences_extruder) self.State = TimelapseState.WaitingForTrigger self.IsTestMode = self.Settings.current_debug_profile().is_test_mode self.Triggers = Triggers(self.Settings) self.Triggers.create() # take a snapshot of the current settings for use in the Octolapse Tab self.CurrentProfiles = self.Settings.get_profiles_dict() # send an initial state message self._on_timelapse_start() def on_position_received(self, payload): if self.State != TimelapseState.Idle: self._position_payload = payload self._position_signal.set() def get_position_async(self, start_gcode=None, timeout=None): if timeout is None: timeout = self._position_timeout self.Settings.current_debug_profile().log_print_state_change("Octolapse is requesting a position.") # Warning, we can only request one position at a time! if not self._position_signal.is_set(): self.Settings.current_debug_profile().log_warning( "Warning: A position request has already been made, clearing existing signal." ) self._position_signal.clear() # build the staret commands commands_to_send = ["M400", "M114"] # send any code that is to be run before the position request if start_gcode is not None and len(start_gcode) > 0: commands_to_send = start_gcode + commands_to_send self.OctoprintPrinter.commands(commands_to_send) event_is_set = self._position_signal.wait(timeout) if not event_is_set: # we ran into a timeout while waiting for a fresh position # set the position signal self._snapshot_signal.set() self.Settings.current_debug_profile().log_warning( "Warning: A timeout occurred while requesting the current position!." ) return None return self._position_payload def _on_snapshot_success(self, *args, **kwargs): # Increment the number of snapshots received self.SnapshotCount += 1 self._snapshot_success = True self._snapshot_signal.set() def _on_snapshot_fail(self, *args, **kwargs): reason = args[0] message = "Failed to download the snapshot. Reason: {0}".format( reason) self.Settings.current_debug_profile().log_snapshot_download(message) self._snapshot_success = False self.SnapshotError = message self._snapshot_signal.set() def _take_snapshot_async(self): snapshot_async_payload = { "success": False, "error": "" } if not self._snapshot_signal.is_set(): self.Settings.current_debug_profile().log_warning( "Warning: A snapshot request has already been made, clearing existing signal." ) # only clear signal and send a new M114 if we haven't already done that from another thread self._snapshot_signal.clear() # start the snapshot self.Settings.current_debug_profile().log_snapshot_download("Taking a snapshot.") try: self.CaptureSnapshot.snap( utility.get_currently_printing_filename(self.OctoprintPrinter), self.SnapshotCount, on_success=self._on_snapshot_success, on_fail=self._on_snapshot_fail ) event_is_set = self._snapshot_signal.wait(self._snapshot_timeout) if not event_is_set: # we ran into a timeout while waiting for a fresh position snapshot_async_payload["success"] = False snapshot_async_payload["error"] = \ "Snapshot timed out in {0} seconds.".format(self._snapshot_timeout) self._snapshot_signal.set() else: snapshot_async_payload["success"] = True except Exception as e: self.Settings.current_debug_profile().log_exception(e) snapshot_async_payload["success"] = False snapshot_async_payload["error"] = "An unexpected error was encountered while taking a snapshot" return snapshot_async_payload def _take_timelapse_snapshot(self, trigger, triggering_command): timelapse_snapshot_payload = { "snapshot_position": None, "return_position": None, "snapshot_gcode": None, "snapshot_payload": None, "current_snapshot_time": 0, "total_snapshot_time": 0, "success": False, "error": "" } try: # create the GCode for the timelapse and store it snapshot_gcode = self.Gcode.create_snapshot_gcode( self.Position, trigger, triggering_command ) # save the gcode fo the payload timelapse_snapshot_payload["snapshot_gcode"] = snapshot_gcode assert (isinstance(snapshot_gcode, SnapshotGcode)) self.Settings.current_debug_profile().log_snapshot_gcode( "Sending snapshot start gcode and snapshot commands.") # park the printhead in the snapshot position and wait for the movement to complete snapshot_position = self.get_position_async( start_gcode=snapshot_gcode.StartGcode + snapshot_gcode.SnapshotCommands ) timelapse_snapshot_payload["snapshot_position"] = snapshot_position # record the snapshot start time snapshot_start_time = time.time() # take the snapshot snapshot_async_payload = self._take_snapshot_async() timelapse_snapshot_payload["snapshot_payload"] = snapshot_async_payload # calculate the snapshot time snapshot_time = time.time() - snapshot_start_time # record the return movement start time return_start_time = time.time() self.Settings.current_debug_profile().log_snapshot_gcode("Sending snapshot return gcode.") # return the printhead to the start position return_position = self.get_position_async( start_gcode=snapshot_gcode.ReturnCommands ) # Note that sending the EndGccode via the end_gcode parameter allows us to execute additional # gcode and still know when the ReturnCommands were completed. Hopefully this will reduce delays. timelapse_snapshot_payload["return_position"] = return_position self.Settings.current_debug_profile().log_snapshot_gcode("Sending snapshot end gcode.") # send the end gcode end_position = self.get_position_async( start_gcode=snapshot_gcode.EndGcode ) # calculate the return movement time return_time = time.time() - return_start_time # Note that sending the EndGccode via the end_gcode parameter allows us to execute additional # gcode and still know when the ReturnCommands were completed. Hopefully this will reduce delays. timelapse_snapshot_payload["end_position"] = end_position # calculate the total snapshot time # Note that we use 2 * return_time as opposed to snapshot_travel_time + return_time. # This is so we can avoid sending an M400 until after we've lifted and hopped current_snapshot_time = snapshot_time + 2 * return_time self.SecondsAddedByOctolapse += current_snapshot_time timelapse_snapshot_payload["current_snapshot_time"] = current_snapshot_time timelapse_snapshot_payload["total_snapshot_time"] = self.SecondsAddedByOctolapse # we've completed the procedure, set success timelapse_snapshot_payload["success"] = True except Exception as e: self.Settings.current_debug_profile().log_exception(e) timelapse_snapshot_payload["error"] = "An unexpected error was encountered while running the timelapse " \ "snapshot procedure. " return timelapse_snapshot_payload def alter_gcode_for_trigger(self, command): # We don't want to send the snapshot command to the printer, or any of # the SupporessedSavedCommands (gcode.py) if command is None or command == (None,) or command in self.Commands.SuppressedSavedCommands: # this will suppress the command since it won't be added to our snapshot commands list return None # adjust the triggering command for test mode command = self.Commands.get_test_mode_command_string(command) if command == "": return None # send the triggering command return command # public functions def to_state_dict(self): try: position_dict = None position_state_dict = None extruder_dict = None trigger_state = None if self.Settings is not None: if self.Settings.show_position_changes and self.Position is not None: position_dict = self.Position.to_position_dict() if self.Settings.show_position_state_changes and self.Position is not None: position_state_dict = self.Position.to_state_dict() if self.Settings.show_extruder_state_changes and self.Position is not None: extruder_dict = self.Position.Extruder.to_dict() if self.Settings.show_trigger_state_changes and self.Triggers is not None: trigger_state = { "Name": self.Triggers.Name, "Triggers": self.Triggers.state_to_list() } state_dict = { "Extruder": extruder_dict, "Position": position_dict, "PositionState": position_state_dict, "TriggerState": trigger_state } return state_dict except Exception as e: self.Settings.CurrentDebugProfile().log_exception(e) # if we're here, we've reached and logged an error. return { "Extruder": None, "Position": None, "PositionState": None, "TriggerState": None } def stop_snapshots(self, message=None, error=False): self.State = TimelapseState.WaitingToRender if self.TimelapseStoppedCallback is not None: timelapse_stopped_callback_thread = threading.Thread( target=self.TimelapseStoppedCallback, args=[message, error] ) timelapse_stopped_callback_thread.daemon = True timelapse_stopped_callback_thread.start() return True def on_print_failed(self): if self.State != TimelapseState.Idle: self.end_timelapse("FAILED") def on_print_disconnecting(self): if self.State != TimelapseState.Idle: self.end_timelapse("DISCONNECTING") def on_print_disconnected(self): if self.State != TimelapseState.Idle: self.end_timelapse("DISCONNECTED") def on_print_canceled(self): if self.State != TimelapseState.Idle: self.end_timelapse("CANCELED") def on_print_completed(self): if self.State != TimelapseState.Idle: self.end_timelapse("COMPLETED") def end_timelapse(self, print_status): self.PrintEndStatus = print_status try: if self.State in [ TimelapseState.WaitingForTrigger, TimelapseState.WaitingToRender, TimelapseState.WaitingToEndTimelapse ]: if not self._render_timelapse(self.PrintEndStatus): if self.OnRenderEndCallback is not None: payload = RenderingCallbackArgs( "Could not start timelapse job.", -1, "unknown", "unknown", "unknown", "unknown", "unknown", "unknown", False, 0, 0, "RENDER") render_end_callback_thread = threading.Thread( target=self.OnRenderEndCallback, args=[payload] ) render_end_callback_thread.daemon = True render_end_callback_thread.start() self._reset() elif self.State != TimelapseState.Idle: self.State = TimelapseState.WaitingToEndTimelapse except Exception as e: self.Settings.current_debug_profile().log_exception(e) def on_print_paused(self): try: if self.State == TimelapseState.Idle: return elif self.State < TimelapseState.WaitingToRender: self.Settings.current_debug_profile().log_print_state_change("Print Paused.") self.Triggers.pause() except Exception as e: self.Settings.current_debug_profile().log_exception(e) def on_print_resumed(self): try: if self.State == TimelapseState.Idle: return elif self.State < TimelapseState.WaitingToRender: self.Triggers.resume() except Exception as e: self.Settings.current_debug_profile().log_exception(e) def is_timelapse_active(self): if ( self.Settings is None or self.State in [TimelapseState.Idle, TimelapseState.Initializing, TimelapseState.WaitingToRender] or self.Triggers is None or self.Triggers.count() < 1 ): return False return True def get_is_rendering(self): return len(self.RenderingJobs) > 0 def on_print_start(self, tags): self.OnPrintStartCallback(tags) def on_print_start_failed(self, message): self.OnPrintStartFailedCallback(message) def on_gcode_queuing(self, cmd, cmd_type, gcode, tags): # detect print start if ( self.Settings.is_octolapse_enabled and self.State == TimelapseState.Idle and {'trigger:comm.start_print', 'fileline:1'} <= tags and self.OctoprintPrinter.is_printing() ): if self.OctoprintPrinter.set_job_on_hold(True): try: self.State = TimelapseState.Initializing self.Settings.current_debug_profile().log_print_state_change( "Print Start Detected. Command: {0}, Tags:{1}".format(cmd, tags) ) # call the synchronous callback on_print_start self.on_print_start(tags) if self.State == TimelapseState.WaitingForTrigger: # set the current line to 0 so that the plugin checks for line 1 below after startup. self.CurrentFileLine = 0 else: # if we're not in the waiting for trigger state, there is a problem self.on_print_start_failed( "Unable to start timelapse, failed to initialize. Print start failed." ) finally: self.OctoprintPrinter.set_job_on_hold(False) else: self.on_print_start_failed( "Unabled to start timelapse, failed to acquire a job lock. Print start failed." ) # if the timelapse is not active, exit without changing any gcode if not self.is_timelapse_active(): return # check the current line number if {'source:file'} in tags: # this line is from the file, advance! self.CurrentFileLine += 1 if "fileline:{0}".format(self.CurrentFileLine) not in tags: actual_file_line = "unknown" for tag in tags: if len(tag) > 9 and tag.startswith("fileline:"): actual_file_line = tag[9:] message = "File line number {0} was expected, but {1} was received!".format( self.CurrentFileLine + 1, actual_file_line ) self.Settings.current_debug_profile().log_error(message) self.stop_snapshots(message, True) try: self.Settings.current_debug_profile().log_gcode_queuing( "Queuing Command: Command Type:{0}, gcode:{1}, cmd: {2}, tags: {3}".format(cmd_type, gcode, cmd, tags)) # update the position tracker so that we know where all of the axis are. # We will need this later when generating snapshot gcode so that we can return to the previous # position cmd = cmd.upper().strip() # create our state change dictionaries position_change_dict = None position_state_change_dict = None extruder_change_dict = None trigger_change_list = None self.Position.update(cmd) # capture any changes, if necessary, to the position, position state and extruder state # Note: We'll get the trigger state later if (self.Settings.show_position_changes and (self.Position.has_position_changed() or not self.HasSentInitialStatus)): position_change_dict = self.Position.to_position_dict() if (self.Settings.show_position_state_changes and (self.Position.has_state_changed() or not self.HasSentInitialStatus)): position_state_change_dict = self.Position.to_state_dict() if (self.Settings.show_extruder_state_changes and (self.Position.Extruder.has_changed() or not self.HasSentInitialStatus)): extruder_change_dict = self.Position.Extruder.to_dict() # get the position state in case it has changed # if there has been a position or extruder state change, inform any listener is_snapshot_gcode_command = self._is_snapshot_command(cmd) # make sure we're not using inches is_metric = self.Position.is_metric() if is_metric is None and self.Position.has_position_error(): self.stop_snapshots( "The printer profile requires an explicit G21 command before any position altering/setting " "commands, including any home commands. Stopping timelapse, but continuing the print. ", error=True ) elif not is_metric and self.Position.has_position_error(): if self.Printer.units_default == "inches": self.stop_snapshots( "The printer profile uses 'inches' as the default unit of measurement. In order to use " "Octolapse, a G21 command must come before any position altering/setting commands, including" " any home commands. Stopping timelapse, but continuing the print. ", error=True ) else: self.stop_snapshots( "The gcode file contains a G20 command (set units to inches), which Octolapse does not " "support. Stopping timelapse, but continuing the print.", error=True ) elif self.Position.has_position_error(0): self._on_position_error() # check to see if we've just completed a home command elif (self.State == TimelapseState.WaitingForTrigger and (self.Position.requires_location_detection(1)) and self.OctoprintPrinter.is_printing()): self.State = TimelapseState.AcquiringLocation def acquire_position_async(post_position_command): try: self.Settings.current_debug_profile().log_print_state_change( "A position altering command has been detected. Fetching and updating position. " "Position Command: {0}".format(post_position_command)) # Undo the last position update, we will be resending the command self.Position.undo_update() current_position = self.get_position_async() if current_position is None: self.PrintEndStatus = "POSITION_TIMEOUT" self.State = TimelapseState.WaitingToEndTimelapse self.Settings.current_debug_profile().log_print_state_change( "Unable to acquire a position.") else: # update position self.Position.update_position( x=current_position["x"], y=current_position["y"], z=current_position["z"], e=current_position["e"], force=True, calculate_changes=True) # adjust the triggering command if post_position_command is not None and post_position_command != (None,): post_position_command = self.Commands.get_test_mode_command_string( post_position_command ) if post_position_command != "": self.Settings.current_debug_profile().log_print_state_change( "Sending saved command - {0}.".format(post_position_command)) # send the triggering command self.OctoprintPrinter.commands(post_position_command) # set the state if self.State == TimelapseState.AcquiringLocation: self.State = TimelapseState.WaitingForTrigger self.Settings.current_debug_profile().log_print_state_change("Position Acquired") finally: try: if self.State == TimelapseState.WaitingToEndTimelapse: self.stop_snapshots( "A timeout occurred when attempting to acquire the printer's current position." "The current timeout is {0} seconds. Stopping timelapse.".format( self._position_timeout ), True ) finally: self.OctoprintPrinter.set_job_on_hold(False) if self.OctoprintPrinter.set_job_on_hold(True): thread = threading.Thread(target=acquire_position_async, args=[cmd]) thread.daemon = True thread.start() return None, elif (self.State == TimelapseState.WaitingForTrigger and self.OctoprintPrinter.is_printing() and not self.Position.has_position_error(0)): self.Triggers.update(self.Position, cmd) # If our triggers have changed, update our dict if self.Settings.show_trigger_state_changes and self.Triggers.has_changed(): trigger_change_list = self.Triggers.changes_to_list() _first_triggering = self.get_first_triggering() if _first_triggering: # set the state self.State = TimelapseState.TakingSnapshot def take_snapshot_async(triggering_command, trigger): timelapse_snapshot_payload = None try: self.Settings.current_debug_profile().log_snapshot_download( "About to take a snapshot. Triggering Command: {0}".format( triggering_command)) if self.OnSnapshotStartCallback is not None: snapshot_callback_thread = threading.Thread(target=self.OnSnapshotStartCallback) snapshot_callback_thread.daemon = True snapshot_callback_thread.start() # Undo the last position update, we're not going to be using it! self.Position.undo_update() # take the snapshot timelapse_snapshot_payload = self._take_timelapse_snapshot(trigger, triggering_command) self.Settings.current_debug_profile().log_snapshot_download("The snapshot has completed") finally: # set the state if self.State == TimelapseState.TakingSnapshot: self.State = TimelapseState.WaitingForTrigger timelapse_ended = False try: if self.State == TimelapseState.WaitingToEndTimelapse: timelapse_ended = False self.stop_snapshots( "A timeout occurred when attempting to take a snapshot." "The current timeout is {0} seconds. Stopping timelapse.".format( self._snapshot_timeout), True ) finally: self.OctoprintPrinter.set_job_on_hold(False) if not timelapse_ended: # notify that we're finished, but only if we haven't just stopped the timelapse. self._on_trigger_snapshot_complete(timelapse_snapshot_payload) if self.OctoprintPrinter.set_job_on_hold(True): thread = threading.Thread(target=take_snapshot_async, args=[cmd, _first_triggering]) thread.daemon = True thread.start() return None, elif self.State == TimelapseState.TakingSnapshot: # Don't do anything further to any commands unless we are # taking a timelapse , or if octolapse paused the print. # suppress any commands we don't, under any cirumstances, # to execute while we're taking a snapshot if cmd in self.Commands.SuppressedSnapshotGcodeCommands: cmd = None, # suppress the command if is_snapshot_gcode_command: # in all cases do not return the snapshot command to the printer. # It is NOT a real gcode and could cause errors. cmd = None, # notify any callbacks self._on_state_changed( position_change_dict, position_state_change_dict, extruder_change_dict, trigger_change_list) self.HasSentInitialStatus = True if cmd != (None,): cmd = self._get_command_for_octoprint(cmd) except: e = sys.exc_info()[0] self.Settings.current_debug_profile().log_exception(e) raise return cmd def get_first_triggering(self): try: # make sure we're in a state that could want to check for triggers if not self.State == TimelapseState.WaitingForTrigger: return False # see if the PREVIOUS command triggered (that means current gcode gets sent if the trigger[0] # is triggering first_trigger = self.Triggers.get_first_triggering(0, Triggers.TRIGGER_TYPE_IN_PATH) if first_trigger: self.Settings.current_debug_profile().log_triggering("An in-path snapshot is triggering") return first_trigger first_trigger = self.Triggers.get_first_triggering(1, Triggers.TRIGGER_TYPE_DEFAULT) if first_trigger: # We're triggering self.Settings.current_debug_profile().log_triggering("A snapshot is triggering") return first_trigger except Exception as e: self.Settings.current_debug_profile().log_exception(e) # no need to re-raise here, the trigger just won't happen return False def on_gcode_sent(self, cmd, cmd_type, gcode, tags): self.Settings.current_debug_profile().log_gcode_sent( "Sent to printer: Command Type:{0}, gcode:{1}, cmd: {2}, tags: {3}".format(cmd_type, gcode, cmd, tags)) def on_gcode_received(self, comm, line, *args, **kwargs): self.Settings.current_debug_profile().log_gcode_received( "Received from printer: line:{0}".format(line) ) return line # internal functions #################### def _get_command_for_octoprint(self, cmd): if cmd is None or cmd == (None,): return cmd if self.IsTestMode and self.State >= TimelapseState.WaitingForTrigger: return self.Commands.alter_for_test_mode(cmd) # if we were given a list, return it. if isinstance(cmd, list): return cmd # if we were given a command return None (don't change the command at all) return None def _on_state_changed( self, position_change_dict, position_state_change_dict, extruder_change_dict, trigger_change_list): """Notifies any callbacks about any changes contained in the dictionaries. If you send a dict here the client will get a message, so check the settings to see if they are subscribed to notifications before populating the dictinaries!""" trigger_changes_dict = None try: # Notify any callbacks if (self.OnStateChangedCallback is not None and (position_change_dict is not None or position_state_change_dict is not None or extruder_change_dict is not None or trigger_change_list is not None)): if trigger_change_list is not None and len(trigger_change_list) > 0: trigger_changes_dict = { "Name": self.Triggers.Name, "Triggers": trigger_change_list } change_dict = { "Extruder": extruder_change_dict, "Position": position_change_dict, "PositionState": position_state_change_dict, "TriggerState": trigger_changes_dict } if ( change_dict["Extruder"] is not None or change_dict["Position"] is not None or change_dict["PositionState"] is not None or change_dict["TriggerState"] is not None ): state_changed_callback_thread = threading.Thread( target=self.OnStateChangedCallback, args=[change_dict] ) state_changed_callback_thread.daemon = True state_changed_callback_thread.start() except Exception as e: # no need to re-raise, callbacks won't be notified, however. self.Settings.current_debug_profile().log_exception(e) def _is_snapshot_command(self, command): command_name = get_gcode_from_string(command) snapshot_command_name = get_gcode_from_string(self.Printer.snapshot_command) return command_name == snapshot_command_name def _is_trigger_waiting(self): # make sure we're in a state that could want to check for triggers if not self.State == TimelapseState.WaitingForTrigger: return None # Loop through all of the active currentTriggers waiting_trigger = self.Triggers.get_first_waiting() if waiting_trigger is not None: return True return False def _on_position_error(self): message = self.Position.position_error(0) self.Settings.current_debug_profile().log_error(message) if self.OnPositionErrorCallback is not None: position_error_callback_thread = threading.Thread( target=self.OnPositionErrorCallback, args=[message] ) position_error_callback_thread.daemon = True position_error_callback_thread.start() def _on_trigger_snapshot_complete(self, snapshot_payload): if self.OnSnapshotCompleteCallback is not None: payload = { "success": snapshot_payload["success"], "error": snapshot_payload["error"], "snapshot_count": self.SnapshotCount, "total_snapshot_time": snapshot_payload["total_snapshot_time"], "current_snapshot_time": snapshot_payload["total_snapshot_time"] } if self.OnSnapshotCompleteCallback is not None: snapshot_complete_callback_thread = threading.Thread( target=self.OnSnapshotCompleteCallback, args=[payload] ) snapshot_complete_callback_thread.daemon = True snapshot_complete_callback_thread.start() def _render_timelapse(self, print_end_state): # make sure we have a non null TimelapseSettings object. We may have terminated the timelapse for some reason if self.Rendering is not None and self.Rendering.enabled: self.Settings.current_debug_profile().log_render_start("Started Rendering Timelapse") # we are rendering, set the state before starting the rendering job. timelapse_render_job = Render( self.Settings, self.Snapshot, self.Rendering, self.DataFolder, self.DefaultTimelapseDirectory, self.FfMpegPath, 1, time_added=self.SecondsAddedByOctolapse, on_render_start=self._on_render_start, on_render_fail=self._on_render_fail, on_render_success=self._on_render_success, on_render_complete=self.on_render_complete, on_after_sync_fail=self._on_synchronize_rendering_fail, on_after_sync_success=self._on_synchronize_rendering_complete, on_complete=self._on_render_end ) job_id = "TimelapseRenderJob_{0}".format(str(uuid.uuid4())) self.RenderingJobs.add(job_id) try: timelapse_render_job.process(job_id, utility.get_currently_printing_filename( self.OctoprintPrinter), self.PrintStartTime, time.time(), print_end_state) return True except Exception as e: self.Settings.current_debug_profile().log_exception(e) self.RenderingJobs.remove(job_id) return False def _on_render_start(self, *args, **kwargs): job_id = args[0] self.Settings.current_debug_profile().log_render_start( "Started rendering/synchronizing the timelapse. JobId: {0}".format(job_id)) payload = args[1] # notify the caller if self.OnRenderStartCallback is not None: render_start_complete_callback_thread = threading.Thread( target=self.OnRenderStartCallback, args=[payload] ) render_start_complete_callback_thread.daemon = True render_start_complete_callback_thread.start() def _on_render_fail(self, *args, **kwargs): job_id = args[0] self.Settings.current_debug_profile().log_render_fail( "The timelapse rendering failed. JobId: {0}".format(job_id)) def _on_render_success(self, *args, **kwargs): job_id = args[0] self.Settings.current_debug_profile().log_render_complete( "Rendering completed successfully. JobId: {0}".format(job_id)) # payload = args[1] def on_render_complete(self, *args, **kwargs): job_id = args[0] # payload = args[1] self.Settings.current_debug_profile().log_render_complete( "Completed rendering the timelapse. JobId: {0}".format(job_id)) def _on_synchronize_rendering_fail(self, *args, **kwargs): job_id = args[0] payload = args[1] self.Settings.current_debug_profile().log_render_sync( "Synchronization with the default timelapse plugin failed." " JobId: {0}, Reason: {1}".format(job_id, payload.Reason) ) def _on_synchronize_rendering_complete(self, *args, **kwargs): job_id = args[0] # payload = args[1] self.Settings.current_debug_profile().log_render_sync( "Synchronization with the default timelapse plugin was successful. JobId: {0}".format(job_id)) def _on_render_end(self, *args, **kwargs): job_id = args[0] payload = args[1] self.Settings.current_debug_profile().log_render_complete("Completed rendering. JobId: {0}".format(job_id)) assert (isinstance(payload, RenderingCallbackArgs)) # Remove job from list. If it is not there, raise an exception. self.RenderingJobs.remove(job_id) if payload.ErrorType is not None: if self.Snapshot.cleanup_after_render_fail: self.CaptureSnapshot.clean_snapshots(utility.get_snapshot_temp_directory(self.DataFolder)) else: if self.Snapshot.cleanup_after_render_complete: self.CaptureSnapshot.clean_snapshots(utility.get_snapshot_temp_directory(self.DataFolder)) if self.OnRenderEndCallback is not None: render_end_complete_callback_thread = threading.Thread( target=self.OnRenderEndCallback, args=[payload] ) render_end_complete_callback_thread.daemon = True render_end_complete_callback_thread.start() def _on_timelapse_start(self): if self.OnTimelapseStartCallback is None: return self.OnTimelapseStartCallback() def _reset(self): self.State = TimelapseState.Idle self.CurrentFileLine = 0 self.HasSentInitialStatus = False if self.Triggers is not None: self.Triggers.reset() self.CommandIndex = -1 self.PrintStartTime = None self.SnapshotGcodes = None self.SavedCommand = None self.PositionRequestAttempts = 0 self.IsTestMode = False self.ReturnPositionReceivedTime = None # A list of callbacks who want to be informed when a timelapse ends self.TimelapseStopRequested = False self._snapshot_success = False self.SnapshotError = "" self.HasBeenStopped = False self.CurrentProfiles = { "printer": "", "stabilization": "", "snapshot": "", "rendering": "", "camera": "", "debug_profile": "" } # fetch position private variables self._position_payload = None self._position_signal.set() # get snapshot async private variables self._snapshot_signal.set() def _reset_snapshot(self): self.State = TimelapseState.WaitingForTrigger self.CommandIndex = -1 self.SnapshotGcodes = None self.SavedCommand = None self.PositionRequestAttempts = 0 self._snapshot_success = False self.SnapshotError = ""
class Timelapse(object): def __init__(self, octolapseSettings, dataFolder, timelapseFolder, onSnapshotStart=None, onSnapshotEnd=None, onRenderStart=None, onRenderComplete=None, onRenderFail=None, onRenderSynchronizeFail=None, onRenderSynchronizeComplete=None, onRenderEnd=None, onTimelapseStopping=None, onTimelapseStopped=None, onStateChanged=None, onTimelapseStart=None, onSnapshotPositionError=None, onPositionError=None): # config variables - These don't change even after a reset self.Settings = octolapseSettings self.DataFolder = dataFolder self.DefaultTimelapseDirectory = timelapseFolder self.OnRenderStartCallback = onRenderStart self.OnRenderCompleteCallback = onRenderComplete self.OnRenderFailCallback = onRenderFail self.OnRenderingSynchronizeFailCallback = onRenderSynchronizeFail self.OnRenderingSynchronizeCompleteCallback = onRenderSynchronizeComplete self.OnRenderEndCallback = onRenderEnd self.OnSnapshotStartCallback = onSnapshotStart self.OnSnapshotCompleteCallback = onSnapshotEnd self.TimelapseStoppingCallback = onTimelapseStopping self.TimelapseStoppedCallback = onTimelapseStopped self.OnStateChangedCallback = onStateChanged self.OnTimelapseStartCallback = onTimelapseStart self.OnSnapshotPositionErrorCallback = onSnapshotPositionError self.OnPositionErrorCallback = onPositionError self.Responses = Responses( ) # Used to decode responses from the 3d printer self.Commands = Commands() # used to parse and generate gcode self.Triggers = Triggers(octolapseSettings) # Settings that may be different after StartTimelapse is called self.FfMpegPath = None self.Snapshot = None self.Gcode = None self.Printer = None self.CaptureSnapshot = None self.Position = None self.HasSentInitialStatus = False # State Tracking that should only be reset when starting a timelapse self.IsRendering = False self.HasBeenCancelled = False self.HasBeenStopped = False # State tracking variables self._reset() # public functions def StartTimelapse(self, octoprintPrinter, octoprintPrinterProfile, ffmpegPath, g90InfluencesExtruder): self._reset() self.HasSentInitialStatus = False self.OctoprintPrinter = octoprintPrinter self.OctoprintPrinterProfile = octoprintPrinterProfile self.FfMpegPath = ffmpegPath self.PrintStartTime = time.time() self.Snapshot = Snapshot(self.Settings.CurrentSnapshot()) self.Gcode = SnapshotGcodeGenerator(self.Settings, octoprintPrinterProfile) self.Printer = Printer(self.Settings.CurrentPrinter()) self.Rendering = Rendering(self.Settings.CurrentRendering()) self.CaptureSnapshot = CaptureSnapshot( self.Settings, self.DataFolder, printStartTime=self.PrintStartTime) self.Position = Position(self.Settings, octoprintPrinterProfile, g90InfluencesExtruder) self.State = TimelapseState.WaitingForTrigger self.IsTestMode = self.Settings.CurrentDebugProfile().is_test_mode self.Triggers.Create() # send an initial state message self._onTimelapseStart() def GetStateDict(self): try: positionDict = None positionStateDict = None extruderDict = None triggerState = None if (self.Settings.show_position_changes and self.Position is not None): positionDict = self.Position.ToPositionDict() if (self.Settings.show_position_state_changes and self.Position is not None): positionStateDict = self.Position.ToStateDict() if (self.Settings.show_extruder_state_changes and self.Position is not None): extruderDict = self.Position.Extruder.ToDict() if (self.Settings.show_trigger_state_changes): triggerState = { "Name": self.Triggers.Name, "Triggers": self.Triggers.StateToList() } stateDict = { "Extruder": extruderDict, "Position": positionDict, "PositionState": positionStateDict, "TriggerState": triggerState } return stateDict except Exception as e: self.Settings.CurrentDebugProfile().LogException(e) # if we're here, we've reached and logged an error. return { "Extruder": None, "Position": None, "PositionState": None, "TriggerState": None } def StopSnapshots(self): """Stops octolapse from taking any further snapshots. Any existing snapshots will render after the print is ends.""" # we don't need to end the timelapse if it hasn't started if (self.State == TimelapseState.WaitingForTrigger or self.TimelapseStopRequested): self.State = TimelapseState.WaitingToRender self.TimelapseStopRequested = False if (self.TimelapseStoppedCallback is not None): self.TimelapseStoppedCallback() return True # if we are here, we're delaying the request until after the snapshot self.TimelapseStopRequested = True if (self.TimelapseStoppingCallback is not None): self.TimelapseStoppingCallback() def EndTimelapse(self, cancelled=False, force=False): try: if (not self.State == TimelapseState.Idle): if (not force): if (self.State > TimelapseState.WaitingForTrigger and self.State < TimelapseState.WaitingToRender): if (cancelled): self.HasBeenCancelled = True else: self.HasBeenStopped = True return self._renderTimelapse() self._reset() except Exception as e: self.Settings.CurrentDebugProfile().LogException(e) def PrintPaused(self): try: if (self.State == TimelapseState.Idle): return elif (self.State < TimelapseState.WaitingToRender): self.Settings.CurrentDebugProfile().LogPrintStateChange( "Print Paused.") self.Triggers.Pause() except Exception as e: self.Settings.CurrentDebugProfile().LogException(e) def PrintResumed(self): try: if (self.State == TimelapseState.Idle): return elif (self.State < TimelapseState.WaitingToRender): self.Triggers.Resume() except Exception as e: self.Settings.CurrentDebugProfile().LogException(e) def IsTimelapseActive(self): try: if (self.State == TimelapseState.Idle or self.State == TimelapseState.WaitingToRender or self.Triggers.Count() < 1): return False return True except Exception as e: self.Settings.CurrentDebugProfile().LogException(e) return False def GcodeQueuing(self, comm_instance, phase, cmd, cmd_type, gcode, *args, **kwargs): try: self.Settings.CurrentDebugProfile().LogQueuingGcode( "Queuing Command: Command Type:{0}, gcode:{1}, cmd: {2}". format(cmd_type, gcode, cmd)) # update the position tracker so that we know where all of the axis are. # We will need this later when generating snapshot gcode so that we can return to the previous # position cmd = cmd.upper().strip() # create our state change dictionaries positionChangeDict = None positionStateChangeDict = None extruderChangeDict = None triggerChangeList = None self.Position.Update(cmd) if (self.Position.HasPositionError(0)): self._onPositionError() # capture any changes, if neccessary, to the position, position state and extruder state # Note: We'll get the trigger state later if (self.Settings.show_position_changes and (self.Position.HasPositionChanged() or not self.HasSentInitialStatus)): positionChangeDict = self.Position.ToPositionDict() if (self.Settings.show_position_state_changes and (self.Position.HasStateChanged() or not self.HasSentInitialStatus)): positionStateChangeDict = self.Position.ToStateDict() if (self.Settings.show_extruder_state_changes and (self.Position.Extruder.HasChanged() or not self.HasSentInitialStatus)): extruderChangeDict = self.Position.Extruder.ToDict() # get the position state in case it has changed # if there has been a position or extruder state change, inform any listener isSnapshotGcodeCommand = self._isSnapshotCommand(cmd) # check to see if we've just completed a home command if (self.State == TimelapseState.WaitingForTrigger and self.Position.HasReceivedHomeCommand(1) and self.OctoprintPrinter.is_printing()): if (self.Printer.auto_detect_origin): self.State = TimelapseState.AcquiringHomeLocation if (self.IsTestMode): cmd = self.Commands.GetTestModeCommandString(cmd) self.SavedCommand = cmd cmd = None, self._pausePrint() elif (self.State == TimelapseState.WaitingForTrigger and self.OctoprintPrinter.is_printing() and not self.Position.HasPositionError(0)): self.Triggers.Update(self.Position, cmd) # If our triggers have changed, update our dict if (self.Settings.show_trigger_state_changes and self.Triggers.HasChanged()): triggerChangeList = self.Triggers.ChangesToList() if (self.GcodeQueuing_IsTriggering(cmd, isSnapshotGcodeCommand)): # Undo the last position update, we're not going to be using it! self.Position.UndoUpdate() # Store the current position (our previous position), since this will be our snapshot position self.Position.SavePosition() # we don't want to execute the current command. We have saved it for later. # but we don't want to send the snapshot command to the printer, or any of the SupporessedSavedCommands (gcode.py) if (isSnapshotGcodeCommand or cmd in self.Commands.SuppressedSavedCommands): self.SavedCommand = None # this will suppress the command since it won't be added to our snapshot commands list else: if (self.IsTestMode): cmd = self.Commands.GetTestModeCommandString(cmd) self.SavedCommand = cmd # this will cause the command to be added to the end of our snapshot commands # pause the printer to start the snapshot self.State = TimelapseState.RequestingReturnPosition # Pausing the print here will immediately trigger an M400 and a location request self._pausePrint() # send M400 and position request # send a notification to the client that the snapshot is starting if (self.OnSnapshotStartCallback is not None): self.OnSnapshotStartCallback() # suppress the command cmd = None, elif ((self.State > TimelapseState.WaitingForTrigger and self.State < TimelapseState.SendingReturnGcode) or (self.State in [ TimelapseState.AcquiringHomeLocation, TimelapseState.SendingSavedHomeLocationCommand ])): # Don't do anything further to any commands unless we are taking a timelapse , or if octolapse paused the print. # suppress any commands we don't, under any cirumstances, to execute while we're taking a snapshot if (cmd in self.Commands.SuppressedSnapshotGcodeCommands): cmd = None, # suppress the command if (isSnapshotGcodeCommand): # in all cases do not return the snapshot command to the printer. It is NOT a real gcode and could cause errors. cmd = None, # notify any callbacks self._onStateChanged(positionChangeDict, positionStateChangeDict, extruderChangeDict, triggerChangeList) self.HasSentInitialStatus = True if (cmd != None, ): return self._returnGcodeCommandToOctoprint(cmd) # if we are here we need to suppress the command except Exception as e: self.Settings.CurrentDebugProfile().LogException(e) raise return cmd def GcodeQueuing_IsTriggering(self, cmd, isSnapshotGcodeCommand): try: # make sure we're in a state that could want to check for triggers if (not self.State == TimelapseState.WaitingForTrigger): return None currentTrigger = self.Triggers.GetFirstTriggering(0) if (currentTrigger is not None): #We're triggering self.Settings.CurrentDebugProfile().LogTriggering( "A snapshot is triggering") # notify any callbacks return True elif (self._isTriggerWaiting(cmd)): self.Settings.CurrentDebugProfile().LogTriggerWaitState( "Trigger is Waiting On Extruder.") except Exception as e: self.Settings.CurrentDebugProfile().LogException(e) # no need to re-raise here, the trigger just won't happen return False def GcodeSent(self, comm_instance, phase, cmd, cmd_type, gcode, *args, **kwargs): self.Settings.CurrentDebugProfile().LogSentGcode( "Sent to printer: Command Type:{0}, gcode:{1}, cmd: {2}".format( cmd_type, gcode, cmd)) def PositionReceived(self, payload): # if we cancelled the print, we don't want to do anything. if (self.HasBeenCancelled): self.EndTimelapse(force=True) return x = payload["x"] y = payload["y"] z = payload["z"] e = payload["e"] if (self.State == TimelapseState.AcquiringHomeLocation): self.Settings.CurrentDebugProfile().LogPrintStateChange( "Snapshot home position received by Octolapse.") self._positionReceived_Home(x, y, z, e) elif (self.State == TimelapseState.SendingSavedHomeLocationCommand): self.Settings.CurrentDebugProfile().LogPrintStateChange( "Snapshot home saved command position received by Octolapse.") self._positionReceived_HomeLocationSavedCommand(x, y, z, e) elif (self.State == TimelapseState.RequestingReturnPosition): self.Settings.CurrentDebugProfile().LogPrintStateChange( "Snapshot return position received by Octolapse.") self._positionReceived_Return(x, y, z, e) elif (self.State == TimelapseState.SendingSnapshotGcode): self.Settings.CurrentDebugProfile().LogPrintStateChange( "Snapshot position received by Octolapse.") self._positionReceived_Snapshot(x, y, z, e) elif (self.State == TimelapseState.SendingReturnGcode): self._positionReceived_ResumePrint(x, y, z, e) else: self.Settings.CurrentDebugProfile().LogPrintStateChange( "Position received by Octolapse while paused, but was declined." ) return False, "Declined - Incorrect State" # internal functions #################### def _returnGcodeCommandToOctoprint(self, cmd): if (cmd is None or cmd == (None, )): return cmd if (self.IsTestMode and self.State >= TimelapseState.WaitingForTrigger): return self.Commands.AlterCommandForTestMode(cmd) # if we were given a list, return it. if (isinstance(cmd, list)): return cmd # if we were given a command return None (don't change the command at all) return None def _onStateChanged(self, positionChangeDict, positionStateChangeDict, extruderChangeDict, triggerChangeList): """Notifies any callbacks about any changes contained in the dictionaries. If you send a dict here the client will get a message, so check the settings to see if they are subscribed to notifications before populating the dictinaries!""" triggerChangesDict = None try: # Notify any callbacks if (self.OnStateChangedCallback is not None and (positionChangeDict is not None or positionStateChangeDict is not None or extruderChangeDict is not None or triggerChangeList is not None)): if (triggerChangeList is not None and len(triggerChangeList) > 0): triggerChangesDict = { "Name": self.Triggers.Name, "Triggers": triggerChangeList } changeDict = { "Extruder": extruderChangeDict, "Position": positionChangeDict, "PositionState": positionStateChangeDict, "TriggerState": triggerChangesDict } if (changeDict["Extruder"] is not None or changeDict["Position"] is not None or changeDict["PositionState"] is not None or changeDict["TriggerState"] is not None): self.OnStateChangedCallback(changeDict) except Exception as e: # no need to re-raise, callbacks won't be notified, however. self.Settings.CurrentDebugProfile().LogException(e) def _isSnapshotCommand(self, command): commandName = GetGcodeFromString(command) snapshotCommandName = GetGcodeFromString(self.Printer.snapshot_command) return commandName == snapshotCommandName def _isTriggerWaiting(self, cmd): # make sure we're in a state that could want to check for triggers if (not self.State == TimelapseState.WaitingForTrigger): return None isWaiting = False # Loop through all of the active currentTriggers waitingTrigger = self.Triggers.GetFirstWaiting() if (waitingTrigger is not None): return True return False def _positionReceived_Home(self, x, y, z, e): try: self.Position.UpdatePosition(x=x, y=y, z=z, e=e, force=True, calculateChanges=True) except Exception as e: self.Settings.CurrentDebugProfile().LogException(e) # we need to abandon the snapshot completely, reset and resume self.State = TimelapseState.SendingSavedHomeLocationCommand self.OctoprintPrinter.commands(self.SavedCommand) self.SavedCommand = "" self.OctoprintPrinter.commands("M400") self.OctoprintPrinter.commands("M114") def _positionReceived_HomeLocationSavedCommand(self, x, y, z, e): # just sent so we can resume after the commands were sent. # Todo: do this in the gcode sent function instead of sending an m400/m114 combo self.State = TimelapseState.WaitingForTrigger self._resumePrint() def _positionReceived_Return(self, x, y, z, e): try: self.ReturnPositionReceivedTime = time.time() #todo: Do we need to re-request the position like we do for the return? Maybe... printerTolerance = self.Printer.printer_position_confirmation_tolerance # If we are requesting a return position we have NOT yet executed the command that triggered the snapshot. # Because of this we need to compare the position we received to the previous position, not the current one. if (not self.Position.IsAtSavedPosition(x, y, z)): self.Settings.CurrentDebugProfile().LogWarning( "The snapshot return position recieved from the printer does not match the position expected by Octolapse. received (x:{0},y:{1},z:{2}), Expected (x:{3},y:{4},z:{5})" .format(x, y, z, self.Position.X(), self.Position.Y(), self.Position.Z())) self.Position.UpdatePosition(x=x, y=y, z=z, force=True) else: # return position information received self.Settings.CurrentDebugProfile().LogSnapshotPositionReturn( "Snapshot return position received - x:{0},y:{1},z:{2},e:{3}" .format(x, y, z, e)) # make sure the SnapshotCommandIndex = 0 # Todo: ensure this is unnecessary self.CommandIndex = 0 # create the GCode for the timelapse and store it isRelative = self.Position.IsRelative() isExtruderRelative = self.Position.IsExtruderRelative() self.SnapshotGcodes = self.Gcode.CreateSnapshotGcode( x, y, z, self.Position.F(), self.Position.IsRelative(), self.Position.IsExtruderRelative(), self.Position.Extruder, self.Position.DistanceToZLift(), savedCommand=self.SavedCommand) # make sure we acutally received gcode if (self.SnapshotGcodes is None): self._resetSnapshot() self._resumePrint() self._onSnapshotPositionError() return False, "Error - No Snapshot Gcode" elif (self.Gcode.HasSnapshotPositionErrors): # there is a position error, but gcode was generated. Just report it to the user. self._onSnapshotPositionError() self.State = TimelapseState.SendingSnapshotGcode snapshotCommands = self.SnapshotGcodes.SnapshotCommands() # send our commands to the printer # these commands will go through queuing, no reason to track position self.OctoprintPrinter.commands(snapshotCommands) except Exception as e: self.Settings.CurrentDebugProfile().LogException(e) # we need to abandon the snapshot completely, reset and resume self._resetSnapshot() self._resumePrint() def _positionReceived_Snapshot(self, x, y, z, e): try: # snapshot position information received self.Settings.CurrentDebugProfile().LogSnapshotPositionReturn( "Snapshot position received, checking position: Received: x:{0},y:{1},z:{2},e:{3}, Expected: x:{4},y:{5},z:{6}" .format(x, y, z, e, self.Position.X(), self.Position.Y(), self.Position.Z())) printerTolerance = self.Printer.printer_position_confirmation_tolerance # see if the CURRENT position is the same as the position we received from the printer # AND that it is equal to the snapshot position if (not self.Position.IsAtCurrentPosition(x, y, None)): self.Settings.CurrentDebugProfile().LogWarning( "The snapshot position is incorrect. Received: x:{0},y:{1},z:{2},e:{3}, Expected: x:{4},y:{5},z:{6}" .format(x, y, z, e, self.Position.X(), self.Position.Y(), self.Position.Z())) elif (not self.Position.IsAtCurrentPosition(self.SnapshotGcodes.X, self.SnapshotGcodes.Y, None, applyOffset=False) ): # our snapshot gcode will NOT be offset self.Settings.CurrentDebugProfile().LogError( "The snapshot gcode position is incorrect. x:{0},y:{1},z:{2},e:{3}, Expected: x:{4},y:{5},z:{6}" .format(x, y, z, e, self.SnapshotGcodes.X, self.SnapshotGcodes.Y, self.Position.Z())) self.Settings.CurrentDebugProfile().LogSnapshotPositionReturn( "The snapshot position is correct, taking snapshot.") self.State = TimelapseState.TakingSnapshot self._takeSnapshot() except Exception as e: self.Settings.CurrentDebugProfile().LogException(e) # our best bet of fixing things up here is just to return to the previous position. self._sendReturnCommands() def _onPositionError(self): message = self.Position.PositionError(0) self.Settings.CurrentDebugProfile().LogError(message) if (self.OnPositionErrorCallback is not None): self.OnPositionErrorCallback(message) def _onSnapshotPositionError(self): if (self.Printer.abort_out_of_bounds): message = "No snapshot gcode was created for this snapshot. Aborting this snapshot. Details: {0}".format( self.Gcode.SnapshotPositionErrors) else: message = "The snapshot position has been updated due to an out-of-bounds error. Details: {0}".format( self.Gcode.SnapshotPositionErrors) self.Settings.CurrentDebugProfile().LogError(message) if (self.OnSnapshotPositionErrorCallback is not None): self.OnSnapshotPositionErrorCallback(message) def _positionReceived_ResumePrint(self, x, y, z, e): try: if (not self.Position.IsAtCurrentPosition(x, y, None)): self.Settings.CurrentDebugProfile().LogError( "Save Command Position is incorrect. Received: x:{0},y:{1},z:{2},e:{3}, Expected: x:{4},y:{5},z:{6}" .format(x, y, z, e, self.Position.X(), self.Position.Y(), self.Position.Z())) else: self.Settings.CurrentDebugProfile( ).LogSnapshotPositionResumePrint( "Save Command Position is correct. Received: x:{0},y:{1},z:{2},e:{3}, Expected: x:{4},y:{5},z:{6}" .format(x, y, z, e, self.Position.X(), self.Position.Y(), self.Position.Z())) self.SecondsAddedByOctolapse += time.time( ) - self.ReturnPositionReceivedTime # before resetting the snapshot, see if it was a success snapshotSuccess = self.SnapshotSuccess snapshotError = self.SnapshotError # end the snapshot self._resetSnapshot() # If we've requested that the timelapse stop, stop it now if (self.TimelapseStopRequested): self.StopSnapshots() except Exception as e: self.Settings.CurrentDebugProfile().LogException(e) # do not re-raise, we are better off trying to resume the print here. self._resumePrint() self._onTriggerSnapshotComplete(snapshotSuccess, snapshotError) def _onTriggerSnapshotComplete(self, snapshotSuccess, snapshotError=""): if (self.OnSnapshotCompleteCallback is not None): payload = { "success": snapshotSuccess, "error": snapshotError, "snapshot_count": self.SnapshotCount, "seconds_added_by_octolapse": self.SecondsAddedByOctolapse } self.OnSnapshotCompleteCallback(payload) def _pausePrint(self): self.OctoprintPrinter.pause_print() def _resumePrint(self): self.OctoprintPrinter.resume_print() if (self.HasBeenStopped or self.HasBeenCancelled): self.EndTimelapse(force=True) def _takeSnapshot(self): self.Settings.CurrentDebugProfile().LogSnapshotDownload( "Taking Snapshot.") try: self.CaptureSnapshot.Snap(utility.CurrentlyPrintingFileName( self.OctoprintPrinter), self.SnapshotCount, onComplete=self._onSnapshotComplete, onSuccess=self._onSnapshotSuccess, onFail=self._onSnapshotFail) except Exception as e: self.Settings.CurrentDebugProfile().LogException(e) # try to recover by sending the return command self._sendReturnCommands() def _onSnapshotSuccess(self, *args, **kwargs): # Increment the number of snapshots received self.SnapshotCount += 1 self.SnapshotSuccess = True def _onSnapshotFail(self, *args, **kwargs): reason = args[0] message = "Failed to download the snapshot. Reason:{0}".format(reason) self.Settings.CurrentDebugProfile().LogSnapshotDownload(message) self.SnapshotSuccess = False self.SnapshotError = message def _onSnapshotComplete(self, *args, **kwargs): self.Settings.CurrentDebugProfile().LogSnapshotDownload( "Snapshot Completed.") self._sendReturnCommands() def _sendReturnCommands(self): try: # if the print has been cancelled, quit now. if (self.HasBeenCancelled): self.EndTimelapse(force=True) return # Expand the current command to include the return commands if (self.SnapshotGcodes is None): self.Settings.CurrentDebugProfile().LogError( "The snapshot gcode generator has no value.") self.EndTimelapse(force=True) return returnCommands = self.SnapshotGcodes.ReturnCommands() if (returnCommands is None): self.Settings.CurrentDebugProfile().LogError( "No return commands were generated!") ## How do we handle this? we probably need to cancel the print or something.... # Todo: What to do if no return commands are generated? We should never let this happen. Make sure this is true. self.EndTimelapse(force=True) return # set the state so that the final received position will trigger a resume. self.State = TimelapseState.SendingReturnGcode # these commands will go through queuing, no need to update the position self.OctoprintPrinter.commands(returnCommands) except Exception as e: self.Settings.CurrentDebugProfile().LogException(e) # need to re-raise, can't fix this here, but at least it will be logged # properly raise def _renderTimelapse(self): # make sure we have a non null TimelapseSettings object. We may have terminated the timelapse for some reason if (self.Rendering.enabled): self.Settings.CurrentDebugProfile().LogRenderStart( "Started Rendering Timelapse") # we are rendering, set the state before starting the rendering job. self.IsRendering = True timelapseRenderJob = Render( self.Settings, self.Snapshot, self.Rendering, self.DataFolder, self.DefaultTimelapseDirectory, self.FfMpegPath, 1, timeAdded=self.SecondsAddedByOctolapse, onRenderStart=self._onRenderStart, onRenderFail=self._onRenderFail, onRenderSuccess=self._onRenderSuccess, onRenderComplete=self._onRenderComplete, onAfterSyncFail=self._onSynchronizeRenderingFail, onAfterSycnSuccess=self._onSynchronizeRenderingComplete, onComplete=self._onRenderEnd) timelapseRenderJob.Process( utility.CurrentlyPrintingFileName(self.OctoprintPrinter), self.PrintStartTime, time.time()) return True return False def _onRenderStart(self, *args, **kwargs): self.Settings.CurrentDebugProfile().LogRenderStart( "Started rendering/synchronizing the timelapse.") finalFilename = args[0] baseFileName = args[1] willSync = args[2] snapshotCount = args[3] snapshotTimeSeconds = args[4] payload = dict(FinalFileName=finalFilename, WillSync=willSync, SnapshotCount=snapshotCount, SnapshotTimeSeconds=snapshotTimeSeconds) # notify the caller if (self.OnRenderStartCallback is not None): self.OnRenderStartCallback(payload) def _onRenderFail(self, *args, **kwargs): self.IsRendering = False self.Settings.CurrentDebugProfile().LogRenderFail( "The timelapse rendering failed.") #Notify Octoprint finalFilename = args[0] baseFileName = args[1] returnCode = args[2] reason = args[3] payload = dict(gcode="unknown", movie=finalFilename, movie_basename=baseFileName, returncode=returnCode, reason=reason) if (self.OnRenderFailCallback is not None): self.OnRenderFailCallback(payload) def _onRenderSuccess(self, *args, **kwargs): finalFilename = args[0] baseFileName = args[1] #TODO: Notify the user that the rendering is completed if we are not synchronizing with octoprint self.Settings.CurrentDebugProfile().LogRenderComplete( "Rendering completed successfully.") def _onRenderComplete(self, *args, **kwargs): self.IsRendering = False finalFileName = args[0] synchronize = args[1] self.Settings.CurrentDebugProfile().LogRenderComplete( "Completed rendering the timelapse.") if (self.OnRenderCompleteCallback is not None): self.OnRenderCompleteCallback() def _onSynchronizeRenderingFail(self, *args, **kwargs): finalFilename = args[0] baseFileName = args[1] # Notify the user of success and refresh the default timelapse control payload = dict( gcode="unknown", movie=finalFilename, movie_basename=baseFileName, reason= "Error copying the rendering to the Octoprint timelapse folder. If logging is enabled you can search for 'Synchronization Error' to find the error. Your timelapse is likely within the octolapse data folder." ) if (self.OnRenderingSynchronizeFailCallback is not None): self.OnRenderingSynchronizeFailCallback(payload) def _onSynchronizeRenderingComplete(self, *args, **kwargs): finalFilename = args[0] baseFileName = args[1] # Notify the user of success and refresh the default timelapse control payload = dict( gcode="unknown", movie=finalFilename, movie_basename=baseFileName, movie_prefix= "from Octolapse has been synchronized and is now available within the default timelapse plugin tab. Octolapse ", returncode=0, reason="See the octolapse log for details.") if (self.OnRenderingSynchronizeCompleteCallback is not None): self.OnRenderingSynchronizeCompleteCallback(payload) def _onRenderEnd(self, *args, **kwargs): self.IsRendering = False finalFileName = args[0] baseFileName = args[1] synchronize = args[2] success = args[3] self.Settings.CurrentDebugProfile().LogRenderComplete( "Completed rendering.") moviePrefix = "from Octolapse" if (not synchronize): moviePrefix = "from Octolapse. Your timelapse was NOT synchronized (see advanced rendering settings for details), but can be found in octolapse's data directory. A file browser will be added in a future release (hopefully)" payload = dict(movie=finalFileName, movie_basename=baseFileName, movie_prefix=moviePrefix, success=success) if (self.OnRenderEndCallback is not None): self.OnRenderEndCallback(payload) def _onTimelapseStart(self): if (self.OnTimelapseStartCallback is None): return self.OnTimelapseStartCallback() def _reset(self): self.State = TimelapseState.Idle self.HasSentInitialStatus = False self.Triggers.Reset() self.CommandIndex = -1 self.SnapshotCount = 0 self.PrintStartTime = None self.SnapshotGcodes = None self.SavedCommand = None self.PositionRequestAttempts = 0 self.IsTestMode = False # time tracking - how much time did we add to the print? self.SecondsAddedByOctolapse = 0 self.ReturnPositionReceivedTime = None # A list of callbacks who want to be informed when a timelapse ends self.TimelapseStopRequested = False self.SnapshotSuccess = False self.SnapshotError = "" self.HasBeenCancelled = False self.HasBeenStopped = False def _resetSnapshot(self): self.State = TimelapseState.WaitingForTrigger self.CommandIndex = -1 self.SnapshotGcodes = None self.SavedCommand = None self.PositionRequestAttempts = 0 self.SnapshotSuccess = False self.SnapshotError = ""
class Timelapse(object): def __init__( self, settings, octoprint_printer, data_folder, timelapse_folder, on_print_started=None, on_print_start_failed=None, on_snapshot_start=None, on_snapshot_end=None, on_render_start=None, on_render_end=None, on_timelapse_stopping=None, on_timelapse_stopped=None, on_state_changed=None, on_timelapse_start=None, on_timelapse_end=None, on_snapshot_position_error=None, on_position_error=None, on_plugin_message_sent=None): # config variables - These don't change even after a reset self.DataFolder = data_folder self.Settings = settings # type: OctolapseSettings self.OctoprintPrinter = octoprint_printer self.DefaultTimelapseDirectory = timelapse_folder self.OnPrintStartCallback = on_print_started self.OnPrintStartFailedCallback = on_print_start_failed self.OnRenderStartCallback = on_render_start self.OnRenderEndCallback = on_render_end self.OnSnapshotStartCallback = on_snapshot_start self.OnSnapshotCompleteCallback = on_snapshot_end self.TimelapseStoppingCallback = on_timelapse_stopping self.TimelapseStoppedCallback = on_timelapse_stopped self.OnStateChangedCallback = on_state_changed self.OnTimelapseStartCallback = on_timelapse_start self.OnTimelapseEndCallback = on_timelapse_end self.OnSnapshotPositionErrorCallback = on_snapshot_position_error self.OnPositionErrorCallback = on_position_error self.OnPluginMessageSentCallback = on_plugin_message_sent self.Commands = Commands() # used to parse and generate gcode self.Triggers = None self.PrintEndStatus = "Unknown" self.LastStateChangeMessageTime = None self.StateChangeMessageThread = None # Settings that may be different after StartTimelapse is called self.OctoprintPrinterProfile = None self.PrintStartTime = None self.FfMpegPath = None self.Snapshot = None self.Gcode = None self.Printer = None self.CaptureSnapshot = None self.Position = None self.Rendering = None self.State = TimelapseState.Idle self.IsTestMode = False # State Tracking that should only be reset when starting a timelapse self.SnapshotCount = 0 self.HasBeenStopped = False self.TimelapseStopRequested = False self.SavedCommand = None self.SecondsAddedByOctolapse = 0 # State tracking variables self.RequiresLocationDetectionAfterHome = False # fetch position private variables self._position_payload = None self._position_timeout_long = 600.0 self._position_timeout_short = 10.0 self._position_signal = threading.Event() self._position_signal.set() # get snapshot async private variables self._snapshot_success = False # It shouldn't take more than 5 seconds to take a snapshot! self._snapshot_timeout = 5.0 self._snapshot_signal = threading.Event() self._snapshot_signal.set() self._most_recent_snapshot_payload = None self.CurrentProfiles = {} self.CurrentFileLine = 0 # snapshot thread queue self._snapshot_task_queue = Queue(maxsize=1) self._rendering_task_queue = Queue(maxsize=1) self._reset() def start_timelapse( self, settings, octoprint_printer_profile, ffmpeg_path, g90_influences_extruder): # we must supply the settings first! Else reset won't work properly. self._reset() # in case the settings have been destroyed and recreated self.Settings = settings # time tracking - how much time did we add to the print? self.SnapshotCount = 0 self.SecondsAddedByOctolapse = 0 self.RequiresLocationDetectionAfterHome = False self.OctoprintPrinterProfile = octoprint_printer_profile self.FfMpegPath = ffmpeg_path self.PrintStartTime = time.time() self.Snapshot = Snapshot(self.Settings.current_snapshot()) self.Gcode = SnapshotGcodeGenerator( self.Settings, octoprint_printer_profile) self.Printer = Printer(self.Settings.current_printer()) self.Rendering = Rendering(self.Settings.current_rendering()) self.CaptureSnapshot = CaptureSnapshot( self.Settings, self.DataFolder, print_start_time=self.PrintStartTime) self.Position = Position( self.Settings, octoprint_printer_profile, g90_influences_extruder) self.State = TimelapseState.WaitingForTrigger self.IsTestMode = self.Settings.current_debug_profile().is_test_mode self.Triggers = Triggers(self.Settings) self.Triggers.create() # take a snapshot of the current settings for use in the Octolapse Tab self.CurrentProfiles = self.Settings.get_profiles_dict() # send an initial state message self._on_timelapse_start() def on_position_received(self, payload): if self.State != TimelapseState.Idle: self._position_payload = payload self._position_signal.set() def send_snapshot_gcode_array(self, gcode_array): self.OctoprintPrinter.commands(gcode_array, tags={"snapshot_gcode"}) def get_position_async(self, start_gcode=None, timeout=None): if timeout is None: timeout = self._position_timeout_long self.Settings.current_debug_profile().log_print_state_change("Octolapse is requesting a position.") # Warning, we can only request one position at a time! if self._position_signal.is_set(): self._position_signal.clear() # build the staret commands commands_to_send = ["M400", "M114"] # send any code that is to be run before the position request if start_gcode is not None and len(start_gcode) > 0: commands_to_send = start_gcode + commands_to_send self.send_snapshot_gcode_array(commands_to_send) event_is_set = self._position_signal.wait(timeout) if not event_is_set: # we ran into a timeout while waiting for a fresh position # set the position signal self._snapshot_signal.set() self.Settings.current_debug_profile().log_warning( "Warning: A timeout occurred while requesting the current position!." ) return None return self._position_payload def _on_snapshot_success(self, *args, **kwargs): # Increment the number of snapshots received self.SnapshotCount += 1 self._snapshot_success = True self._snapshot_signal.set() def _on_snapshot_fail(self, *args, **kwargs): reason = args[0] message = "Failed to download the snapshot. Reason: {0}".format( reason) self.Settings.current_debug_profile().log_snapshot_download(message) self._snapshot_success = False self.SnapshotError = message self._snapshot_signal.set() def _on_snapshot_complete(self, *args, **kwargs): self.Settings.current_debug_profile().log_snapshot_download("Snapshot download complete.") def _take_snapshot_async(self): snapshot_async_payload = { "success": False, "error": "Waiting on thread to signal, aborting" } if self._snapshot_signal.is_set(): # only clear signal and send a new M114 if we haven't already done that from another thread self._snapshot_signal.clear() # start the snapshot self.Settings.current_debug_profile().log_snapshot_download("Taking a snapshot.") snapshot_guid = str(uuid.uuid4()) snapshot_job = self.CaptureSnapshot.create_snapshot_job( utility.get_currently_printing_filename(self.OctoprintPrinter), self.SnapshotCount, snapshot_guid, self._snapshot_task_queue, on_success=self._on_snapshot_success, on_fail=self._on_snapshot_fail, on_complete=self._on_snapshot_complete ) self._snapshot_task_queue.put(snapshot_guid) snapshot_thread = threading.Thread(target=snapshot_job) snapshot_thread.daemon = True snapshot_thread.start() event_is_set = self._snapshot_signal.wait(self._snapshot_timeout) if not event_is_set: # we ran into a timeout while waiting for a fresh position snapshot_async_payload["success"] = False snapshot_async_payload["error"] = \ "Snapshot timed out in {0} seconds.".format(self._snapshot_timeout) self._snapshot_signal.set() else: snapshot_async_payload["success"] = True return snapshot_async_payload def _take_timelapse_snapshot( self, trigger, command_string, cmd, parameters, triggering_command_position, triggering_extruder_position ): timelapse_snapshot_payload = { "snapshot_position": None, "return_position": None, "snapshot_gcode": None, "snapshot_payload": None, "current_snapshot_time": 0, "total_snapshot_time": 0, "success": False, "error": "" } try: show_real_snapshot_time = self.Settings.show_real_snapshot_time # create the GCode for the timelapse and store it snapshot_gcode = self.Gcode.create_snapshot_gcode( self.Position, trigger, command_string, cmd, parameters, triggering_command_position, triggering_extruder_position ) # save the gcode fo the payload timelapse_snapshot_payload["snapshot_gcode"] = snapshot_gcode if snapshot_gcode is None: self.Settings.current_debug_profile().log_warning( "No snapshot gcode was generated." ) return timelapse_snapshot_payload assert (isinstance(snapshot_gcode, SnapshotGcode)) if not show_real_snapshot_time: gcodes_to_send = snapshot_gcode.StartGcode + snapshot_gcode.SnapshotCommands if len(gcodes_to_send) > 0: self.Settings.current_debug_profile().log_snapshot_gcode( "Sending snapshot start gcode and snapshot commands.") snapshot_position = self.get_position_async( start_gcode=snapshot_gcode.StartGcode + snapshot_gcode.SnapshotCommands ) else: self.Settings.current_debug_profile().log_snapshot_gcode( "Sending snapshot start gcode.") # send start commands and zhop/retract if they exist if len(snapshot_gcode.StartGcode) > 0: start_position = self.get_position_async(start_gcode=snapshot_gcode.StartGcode) # Todo: Handle start_position = None # park the printhead in the snapshot position and wait for the movement to complete snapshot_start_time = time.time() if len(snapshot_gcode.SnapshotCommands) > 0: self.Settings.current_debug_profile().log_snapshot_gcode("Sending snapshot commands.") snapshot_position = self.get_position_async( start_gcode=snapshot_gcode.SnapshotCommands, timeout=self._position_timeout_short ) # record the snapshot position timelapse_snapshot_payload["snapshot_position"] = snapshot_position # by now we should be ready to take a snapshot snapshot_async_payload = self._take_snapshot_async() timelapse_snapshot_payload["snapshot_payload"] = snapshot_async_payload if not show_real_snapshot_time: # return the printhead to the start position gcode_to_send = snapshot_gcode.ReturnCommands + snapshot_gcode.EndGcode if len (gcode_to_send) > 0: self.Settings.current_debug_profile().log_snapshot_gcode("Sending snapshot return and end gcode.") self.send_snapshot_gcode_array(gcode_to_send) else: if len(snapshot_gcode.ReturnCommands) > 0: self.Settings.current_debug_profile().log_snapshot_gcode("Sending return gcode.") return_position = self.get_position_async( start_gcode=snapshot_gcode.ReturnCommands, timeout=self._position_timeout_short ) timelapse_snapshot_payload["return_position"] = return_position # calculate the total snapshot time snapshot_end_time = time.time() snapshot_time = snapshot_end_time - snapshot_start_time self.SecondsAddedByOctolapse += snapshot_time timelapse_snapshot_payload["current_snapshot_time"] = snapshot_time timelapse_snapshot_payload["total_snapshot_time"] = self.SecondsAddedByOctolapse if len(snapshot_gcode.EndGcode) > 0: self.Settings.current_debug_profile().log_snapshot_gcode("Sending end gcode.") self.send_snapshot_gcode_array(snapshot_gcode.EndGcode) # we've completed the procedure, set success timelapse_snapshot_payload["success"] = True except Exception as e: self.Settings.current_debug_profile().log_exception(e) timelapse_snapshot_payload["error"] = "An unexpected error was encountered while running the timelapse " \ "snapshot procedure. " return timelapse_snapshot_payload # public functions def to_state_dict(self): try: position_dict = None position_state_dict = None extruder_dict = None trigger_state = None if self.Settings is not None: if self.Settings.show_position_changes and self.Position is not None: position_dict = self.Position.to_position_dict() if self.Settings.show_position_state_changes and self.Position is not None: position_state_dict = self.Position.to_state_dict() if self.Settings.show_extruder_state_changes and self.Position is not None: extruder_dict = self.Position.Extruder.to_dict() if self.Settings.show_trigger_state_changes and self.Triggers is not None: trigger_state = { "Name": self.Triggers.Name, "Triggers": self.Triggers.state_to_list() } state_dict = { "Extruder": extruder_dict, "Position": position_dict, "PositionState": position_state_dict, "TriggerState": trigger_state } return state_dict except Exception as e: self.Settings.CurrentDebugProfile().log_exception(e) # if we're here, we've reached and logged an error. return { "Extruder": None, "Position": None, "PositionState": None, "TriggerState": None } def stop_snapshots(self, message=None, error=False): self.State = TimelapseState.WaitingToRender if self.TimelapseStoppedCallback is not None: timelapse_stopped_callback_thread = threading.Thread( target=self.TimelapseStoppedCallback, args=[message, error] ) timelapse_stopped_callback_thread.daemon = True timelapse_stopped_callback_thread.start() return True def on_print_failed(self): if self.State != TimelapseState.Idle: self.end_timelapse("FAILED") def on_print_disconnecting(self): if self.State != TimelapseState.Idle: self.end_timelapse("DISCONNECTING") def on_print_disconnected(self): if self.State != TimelapseState.Idle: self.end_timelapse("DISCONNECTED") def on_print_canceled(self): if self.State != TimelapseState.Idle: self.end_timelapse("CANCELED") def on_print_completed(self): if self.State != TimelapseState.Idle: self.end_timelapse("COMPLETED") def end_timelapse(self, print_status): self.PrintEndStatus = print_status try: if self.PrintStartTime is None: self._reset() elif self.PrintStartTime is not None and self.State in [ TimelapseState.WaitingForTrigger, TimelapseState.WaitingToRender, TimelapseState.WaitingToEndTimelapse ]: if not self._render_timelapse(self.PrintEndStatus): if self.OnRenderEndCallback is not None: payload = RenderingCallbackArgs( "Could not start timelapse job.", -1, "unknown", "unknown", "unknown", "unknown", "unknown", "unknown", False, 0, 0, True, "timelapse_start", "The render_start function returned false" ) render_end_callback_thread = threading.Thread( target=self.OnRenderEndCallback, args=[payload] ) render_end_callback_thread.daemon = True render_end_callback_thread.start() self._reset() if self.State != TimelapseState.Idle: self.State = TimelapseState.WaitingToEndTimelapse except Exception as e: self.Settings.current_debug_profile().log_exception(e) if self.OnTimelapseEndCallback is not None: self.OnTimelapseEndCallback() def on_print_paused(self): try: if self.State == TimelapseState.Idle: return elif self.State < TimelapseState.WaitingToRender: self.Settings.current_debug_profile().log_print_state_change("Print Paused.") self.Triggers.pause() except Exception as e: self.Settings.current_debug_profile().log_exception(e) def on_print_resumed(self): try: if self.State == TimelapseState.Idle: return elif self.State < TimelapseState.WaitingToRender: self.Triggers.resume() except Exception as e: self.Settings.current_debug_profile().log_exception(e) def is_timelapse_active(self): if ( self.Settings is None or self.State in [TimelapseState.Idle, TimelapseState.Initializing, TimelapseState.WaitingToRender] or self.OctoprintPrinter.get_state_id() == "CANCELLING" or self.Triggers is None or self.Triggers.count() < 1 ): return False return True def get_is_rendering(self): return self._rendering_task_queue.qsize() > 0 def on_print_start(self, tags): self.OnPrintStartCallback(tags) def on_print_start_failed(self, message): self.OnPrintStartFailedCallback(message) def on_gcode_queuing(self, command_string, cmd_type, gcode, tags): self.detect_timelapse_start(command_string, tags) # if the timelapse is not active, exit without changing any gcode if not self.is_timelapse_active(): return self.check_current_line_number(tags) # update the position tracker so that we know where all of the axis are. # We will need this later when generating snapshot gcode so that we can return to the previous # position is_snapshot_gcode_command = self._is_snapshot_command(command_string) try: self.Settings.current_debug_profile().log_gcode_queuing( "Queuing Command: Command Type:{0}, gcode:{1}, cmd: {2}, tags: {3}".format( cmd_type, gcode, command_string, tags ) ) try: cmd, parameters = Commands.parse(command_string) except ValueError as e: message = "An error was thrown by the gcode parser, stopping timelapse. Details: {0}".format(str(e)) self.Settings.current_debug_profile().log_warning( message ) self.stop_snapshots(message, True) return None # get the position state in case it has changed # if there has been a position or extruder state change, inform any listener if cmd is not None and not is_snapshot_gcode_command: # create our state change dictionaries self.Position.update(command_string, cmd, parameters) # if this code is snapshot gcode, simply return it to the printer. if {'plugin:octolapse', 'snapshot_gcode'}.issubset(tags): return None if not self.check_for_non_metric_errors(): if self.Position.has_position_error(0): # There are position errors, report them! self._on_position_error() elif (self.State == TimelapseState.WaitingForTrigger and (self.Position.requires_location_detection(1)) and self.OctoprintPrinter.is_printing()): if 'source:script' in tags: # warn user self._send_plugin_message_async( "warning", "Octolapse could not acquire a position while sending" " OctoPrint scripts (settings=>GCODE Scripts)." " A fix is in the works, but a modification to OctoPrint itself" " is required. For now please move your start gcode into the" " actual gcode file.") else: self.State = TimelapseState.AcquiringLocation if self.OctoprintPrinter.set_job_on_hold(True): thread = threading.Thread(target=self.acquire_position, args=[command_string, cmd, parameters]) thread.daemon = True thread.start() return None, elif (self.State == TimelapseState.WaitingForTrigger and self.OctoprintPrinter.is_printing() and not self.Position.has_position_error(0)): # update the triggers with the current position self.Triggers.update(self.Position, command_string) # see if at least one trigger is triggering _first_triggering = self.get_first_triggering() if _first_triggering: if 'source:script' in tags: # warn user self._send_plugin_message_async( "warning", "Octolapse could not take a snapshot while sending" " OctoLapse scripts (settings=>GCODE Scripts)." " A fix is in the works, but a modification to OctoPrint itself" " is required. For now please move your start gcode into the" " actual gcode file." ) else: # We are triggering, take a snapshot self.State = TimelapseState.TakingSnapshot # pause any timer triggers that are enabled self.Triggers.pause() # get the job lock if self.OctoprintPrinter.set_job_on_hold(True): # take the snapshot on a new thread thread = threading.Thread( target=self.acquire_snapshot, args=[command_string, cmd, parameters, _first_triggering] ) thread.daemon = True thread.start() # suppress the current command, we'll send it later return None, elif self.State == TimelapseState.TakingSnapshot: # Don't do anything further to any commands unless we are # taking a timelapse , or if octolapse paused the print. # suppress any commands we don't, under any cirumstances, # to execute while we're taking a snapshot if cmd in self.Commands.SuppressedSnapshotGcodeCommands: command_string = None, # suppress the command if is_snapshot_gcode_command: # in all cases do not return the snapshot command to the printer. # It is NOT a real gcode and could cause errors. command_string = None, except Exception as e: self.Settings.current_debug_profile().log_exception(e) raise # notify any callbacks self._send_state_changed_message() # do any post processing for test mode if command_string != (None,): command_string = self._get_command_for_octoprint(command_string, cmd,parameters) return command_string def detect_timelapse_start(self, cmd, tags): # detect print start if ( self.Settings.is_octolapse_enabled and self.State == TimelapseState.Idle and {'trigger:comm.start_print', 'trigger:comm.reset_line_numbers'} <= tags and # ({'trigger:comm.start_print', 'fileline:1'} <= tags or {'script:beforePrintStarted', 'trigger:comm.send_gcode_script'} <= tags) and self.OctoprintPrinter.is_printing() ): if self.OctoprintPrinter.set_job_on_hold(True): try: self.State = TimelapseState.Initializing self.Settings.current_debug_profile().log_print_state_change( "Print Start Detected. Command: {0}, Tags:{1}".format(cmd, tags) ) # call the synchronous callback on_print_start self.on_print_start(tags) if self.State == TimelapseState.WaitingForTrigger: # set the current line to 0 so that the plugin checks for line 1 below after startup. self.CurrentFileLine = 0 finally: self.OctoprintPrinter.set_job_on_hold(False) else: self.on_print_start_failed( "Unable to start timelapse, failed to acquire a job lock. Print start failed." ) def check_current_line_number(self, tags): # check the current line number if {'source:file'} in tags: # this line is from the file, advance! self.CurrentFileLine += 1 if "fileline:{0}".format(self.CurrentFileLine) not in tags: actual_file_line = "unknown" for tag in tags: if len(tag) > 9 and tag.startswith("fileline:"): actual_file_line = tag[9:] message = "File line number {0} was expected, but {1} was received!".format( self.CurrentFileLine + 1, actual_file_line ) self.Settings.current_debug_profile().log_error(message) self.stop_snapshots(message, True) def check_for_non_metric_errors(self): # make sure we're not using inches is_metric = self.Position.is_metric() has_error = False error_message = "" if is_metric is None and self.Position.has_position_error(): has_error = True error_message = "The printer profile requires an explicit G21 command before any position " \ "altering/setting commands, including any home commands. Stopping timelapse, " \ "but continuing the print. " elif not is_metric and self.Position.has_position_error(): has_error = True if self.Printer.units_default == "inches": error_message = "The printer profile uses 'inches' as the default unit of measurement. In order to" \ " use Octolapse, a G21 command must come before any position altering/setting commands, including" \ " any home commands. Stopping timelapse, but continuing the print. " else: error_message = "The gcode file contains a G20 command (set units to inches), which Octolapse " \ "does not support. Stopping timelapse, but continuing the print." if has_error: self.stop_snapshots(error_message,has_error) return has_error def get_first_triggering(self): try: # make sure we're in a state that could want to check for triggers if not self.State == TimelapseState.WaitingForTrigger: return False # see if the PREVIOUS command triggered (that means current gcode gets sent if the trigger[0] # is triggering first_trigger = self.Triggers.get_first_triggering(0, Triggers.TRIGGER_TYPE_IN_PATH) if first_trigger: self.Settings.current_debug_profile().log_triggering("An in-path snapshot is triggering") return first_trigger first_trigger = self.Triggers.get_first_triggering(1, Triggers.TRIGGER_TYPE_DEFAULT) if first_trigger: # We're triggering self.Settings.current_debug_profile().log_triggering("A snapshot is triggering") return first_trigger except Exception as e: self.Settings.current_debug_profile().log_exception(e) # no need to re-raise here, the trigger just won't happen return False def acquire_position(self, command_string, cmd, parameters): try: self.Settings.current_debug_profile().log_print_state_change( "A position altering command has been detected. Fetching and updating position. " "Position Command: {0}".format(cmd)) # Undo the last position update, we will be resending the command self.Position.undo_update() current_position = self.get_position_async() if current_position is None: self.PrintEndStatus = "POSITION_TIMEOUT" self.State = TimelapseState.WaitingToEndTimelapse self.Settings.current_debug_profile().log_print_state_change( "Unable to acquire a position.") else: # update position self.Position.update_position( x=current_position["x"], y=current_position["y"], z=current_position["z"], e=current_position["e"], force=True, calculate_changes=True) # adjust the triggering command if self.IsTestMode: gcode = self.Commands.alter_for_test_mode(command_string, cmd, parameters, return_string=True) else: gcode = command_string if gcode != "": self.Settings.current_debug_profile().log_print_state_change( "Sending triggering command for position acquisition - {0}.".format(gcode)) # send the triggering command self.send_snapshot_gcode_array([gcode]) # set the state if self.State == TimelapseState.AcquiringLocation: self.State = TimelapseState.WaitingForTrigger self.Settings.current_debug_profile().log_print_state_change("Position Acquired") finally: self.OctoprintPrinter.set_job_on_hold(False) def acquire_snapshot(self, command_string, cmd, parameters, trigger): try: self.Settings.current_debug_profile().log_snapshot_download( "About to take a snapshot. Triggering Command: {0}".format(cmd)) if self.OnSnapshotStartCallback is not None: snapshot_callback_thread = threading.Thread(target=self.OnSnapshotStartCallback) snapshot_callback_thread.daemon = True snapshot_callback_thread.start() # Capture and undo the last position update, we're not going to be using it! triggering_command_position, triggering_extruder_position = self.Position.undo_update() # take the snapshot # Todo: We probably don't need the payload here. self._most_recent_snapshot_payload = self._take_timelapse_snapshot( trigger, command_string, cmd, parameters, triggering_command_position, triggering_extruder_position ) self.Settings.current_debug_profile().log_snapshot_download("The snapshot has completed") finally: # set the state if self.State == TimelapseState.TakingSnapshot: self.State = TimelapseState.WaitingForTrigger self.Triggers.resume() self.OctoprintPrinter.set_job_on_hold(False) # notify that we're finished, but only if we haven't just stopped the timelapse. if self._most_recent_snapshot_payload is not None: # send a copy of the dict in case it gets changed by threads. self._on_trigger_snapshot_complete(self._most_recent_snapshot_payload.copy()) def on_gcode_sent(self, cmd, cmd_type, gcode, tags): self.Settings.current_debug_profile().log_gcode_sent( "Sent to printer: Command Type:{0}, gcode:{1}, cmd: {2}, tags: {3}".format(cmd_type, gcode, cmd, tags)) def on_gcode_received(self, comm, line, *args, **kwargs): self.Settings.current_debug_profile().log_gcode_received( "Received from printer: line:{0}".format(line) ) return line # internal functions #################### def _get_command_for_octoprint(self, command_string, cmd, parameters): if command_string is None or command_string == (None,): return command_string if self.IsTestMode and self.State >= TimelapseState.WaitingForTrigger: return self.Commands.alter_for_test_mode(command_string, cmd, parameters) # if we were given a list, return it. if isinstance(command_string, list): return command_string # if we were given a command return None (don't change the command at all) return None def _send_state_changed_message(self): """Notifies any callbacks about any changes contained in the dictionaries. If you send a dict here the client will get a message, so check the settings to see if they are subscribed to notifications before populating the dictinaries!""" delay_seconds = 0 # if another thread is trying to send the message, stop it if self.StateChangeMessageThread is not None and self.StateChangeMessageThread.isAlive(): self.StateChangeMessageThread.cancel() if self.LastStateChangeMessageTime is not None: # do not send more than 1 per second time_since_last_update = time.time() - self.LastStateChangeMessageTime if time_since_last_update < 1: delay_seconds = 1-time_since_last_update if delay_seconds < 0: delay_seconds = 0 try: # Notify any callbacks if self.OnStateChangedCallback is not None: def send_change_message(): trigger_change_list = None position_change_dict = None position_state_change_dict = None extruder_change_dict = None trigger_changes_dict = None # Get the changes if self.Settings.show_trigger_state_changes: trigger_change_list = self.Triggers.state_to_list() if self.Settings.show_position_changes: position_change_dict = self.Position.to_position_dict() if self.Settings.show_position_state_changes: position_state_change_dict = self.Position.to_state_dict() if self.Settings.show_extruder_state_changes: extruder_change_dict = self.Position.Extruder.to_dict() # if there are any state changes, send them if ( position_change_dict is not None or position_state_change_dict is not None or extruder_change_dict is not None or trigger_change_list is not None ): if trigger_change_list is not None and len(trigger_change_list) > 0: trigger_changes_dict = { "Name": self.Triggers.Name, "Triggers": trigger_change_list } change_dict = { "Extruder": extruder_change_dict, "Position": position_change_dict, "PositionState": position_state_change_dict, "TriggerState": trigger_changes_dict } if ( change_dict["Extruder"] is not None or change_dict["Position"] is not None or change_dict["PositionState"] is not None or change_dict["TriggerState"] is not None ): self.OnStateChangedCallback(change_dict) self.LastStateChangeMessageTime = time.time() # Send a delayed message self.StateChangeMessageThread = threading.Timer( delay_seconds, send_change_message ) self.StateChangeMessageThread.daemon = True self.StateChangeMessageThread.start() except Exception as e: # no need to re-raise, callbacks won't be notified, however. self.Settings.current_debug_profile().log_exception(e) def _send_plugin_message(self, message_type, message): self.OnPluginMessageSentCallback(message_type, message) def _send_plugin_message_async(self, message_type, message): warning_thread = threading.Thread(target=self._send_plugin_message, args=[message_type, message]) warning_thread.daemon = True warning_thread.start() def _is_snapshot_command(self, command_string): return command_string == self.Printer.snapshot_command def _is_trigger_waiting(self): # make sure we're in a state that could want to check for triggers if not self.State == TimelapseState.WaitingForTrigger: return None # Loop through all of the active currentTriggers waiting_trigger = self.Triggers.get_first_waiting() if waiting_trigger is not None: return True return False def _on_position_error(self): message = self.Position.position_error(0) self.Settings.current_debug_profile().log_error(message) if self.OnPositionErrorCallback is not None: position_error_callback_thread = threading.Thread( target=self.OnPositionErrorCallback, args=[message] ) position_error_callback_thread.daemon = True position_error_callback_thread.start() def _on_trigger_snapshot_complete(self, snapshot_payload): if self.OnSnapshotCompleteCallback is not None: payload = { "success": snapshot_payload["success"], "error": snapshot_payload["error"], "snapshot_count": self.SnapshotCount, "total_snapshot_time": snapshot_payload["total_snapshot_time"], "current_snapshot_time": snapshot_payload["total_snapshot_time"] } if self.OnSnapshotCompleteCallback is not None: snapshot_complete_callback_thread = threading.Thread( target=self.OnSnapshotCompleteCallback, args=[payload] ) snapshot_complete_callback_thread.daemon = True snapshot_complete_callback_thread.start() def _render_timelapse(self, print_end_state): def _render_timelapse_async(render_job_id, timelapse_render_job): try: snapshot_thread = threading.Thread(target=timelapse_render_job, args=[]) snapshot_thread.daemon = True num_snapshot_tasks = self._snapshot_task_queue.qsize() if num_snapshot_tasks > 0: self.Settings.current_debug_profile().log_render_start("Started Rendering Timelapse.") else: self.Settings.current_debug_profile().log_render_start( "Waiting for {0} snapshot threads to complete".format( self._snapshot_task_queue.qsize())) self._snapshot_task_queue.join() self.Settings.current_debug_profile().log_render_start( "All snapshot tasks have completed, rendering timelapse" ) # we are rendering, set the state before starting the rendering job. self._rendering_task_queue.put(render_job_id) snapshot_thread.start() except Exception as e: self.Settings.current_debug_profile().log_exception(e) self._rendering_task_queue.get() self._rendering_task_queue.task_done() # make sure we have a non null TimelapseSettings object. We may have terminated the timelapse for some reason if self.Rendering is not None and self.Rendering.enabled: job_id = "TimelapseRenderJob_{0}".format(str(uuid.uuid4())) job = Render.create_render_job( self.Settings, self.Snapshot, self.Rendering, self.DataFolder, self.DefaultTimelapseDirectory, self.FfMpegPath, 1, self._rendering_task_queue, job_id, utility.get_currently_printing_filename(self.OctoprintPrinter), self.PrintStartTime, time.time(), print_end_state, self.SecondsAddedByOctolapse, self._on_render_start, self._on_render_end ) rendering_thread = threading.Thread(target=_render_timelapse_async, args=[job_id, job]) rendering_thread.daemon = True rendering_thread.start() return True return False def _on_render_start(self, *args, **kwargs): job_id = args[0] self.Settings.current_debug_profile().log_render_start( "Started rendering/synchronizing the timelapse. JobId: {0}".format(job_id)) payload = args[1] # notify the caller if self.OnRenderStartCallback is not None: render_start_complete_callback_thread = threading.Thread( target=self.OnRenderStartCallback, args=[payload] ) render_start_complete_callback_thread.daemon = True render_start_complete_callback_thread.start() def _on_render_end(self, *args, **kwargs): job_id = args[0] payload = args[1] self.Settings.current_debug_profile().log_render_complete("Completed rendering. JobId: {0}".format(job_id)) assert (isinstance(payload, RenderingCallbackArgs)) if not payload.HasError and self.Snapshot.cleanup_after_render_fail: self.CaptureSnapshot.clean_snapshots(utility.get_snapshot_temp_directory(self.DataFolder)) elif self.Snapshot.cleanup_after_render_complete: self.CaptureSnapshot.clean_snapshots(utility.get_snapshot_temp_directory(self.DataFolder)) if self.OnRenderEndCallback is not None: render_end_complete_callback_thread = threading.Thread( target=self.OnRenderEndCallback, args=[payload] ) render_end_complete_callback_thread.daemon = True render_end_complete_callback_thread.start() def _on_timelapse_start(self): if self.OnTimelapseStartCallback is None: return self.OnTimelapseStartCallback() def _reset(self): self.State = TimelapseState.Idle self.CurrentFileLine = 0 if self.Triggers is not None: self.Triggers.reset() self.CommandIndex = -1 self.LastStateChangeMessageTime = None self.PrintStartTime = None self.SnapshotGcodes = None self.SavedCommand = None self.PositionRequestAttempts = 0 self.IsTestMode = False self.ReturnPositionReceivedTime = None # A list of callbacks who want to be informed when a timelapse ends self.TimelapseStopRequested = False self._snapshot_success = False self.SnapshotError = "" self.HasBeenStopped = False self.CurrentProfiles = { "printer": "", "stabilization": "", "snapshot": "", "rendering": "", "camera": "", "debug_profile": "" } # fetch position private variables self._position_payload = None self._position_signal.set() # get snapshot async private variables self._snapshot_signal.set() def _reset_snapshot(self): self.State = TimelapseState.WaitingForTrigger self.CommandIndex = -1 self.SnapshotGcodes = None self.SavedCommand = None self.PositionRequestAttempts = 0 self._snapshot_success = False self.SnapshotError = ""