diff --git a/api/services/status_poller.py b/api/services/status_poller.py index 8230067..ffda477 100644 --- a/api/services/status_poller.py +++ b/api/services/status_poller.py @@ -27,125 +27,16 @@ from utils.retry_utils import retry_async from utils.logger import log_state_transition, log_api_poll_event -# State mapping for printer states -state_map = { - 'IDLE': 'Ready', 'PRINTING': 'Printing', 'PAUSED': 'Paused', 'ERROR': 'Error', - 'FINISHED': 'Finished', 'READY': 'Ready', 'STOPPED': 'Stopped', 'ATTENTION': 'Attention', - 'EJECTING': 'Ejecting', 'PREPARE': 'Preparing', 'OFFLINE': 'Offline', - 'COOLING': 'Cooling' -} - - -def get_minutes_since_finished(printer): - """Calculate minutes elapsed since printer entered FINISHED state""" - logging.debug(f"Checking finish time for {printer.get('name')}: " - f"state={printer.get('state')}, finish_time={printer.get('finish_time')}") - - if printer.get('state') != 'FINISHED' or not printer.get('finish_time'): - return None - - current_time = time.time() - finish_time = printer.get('finish_time', current_time) - elapsed_seconds = current_time - finish_time - - minutes = int(elapsed_seconds / 60) - - logging.debug(f"Timer for {printer.get('name')}: {minutes} minutes") - return minutes - - -def prepare_printer_data_for_broadcast(printers): - """Prepare printer data with calculated fields for broadcasting""" - printers_copy = copy.deepcopy(printers) - - for printer in printers_copy: - # Map backend field names to frontend expected names - if 'file' in printer: - printer['current_file'] = printer.get('file') - - if 'state' in printer: - printer['status'] = printer.get('state') - - # Extract temperature values - temps = printer.get('temps', {}) - nozzle_temp = temps.get('nozzle', 0) if temps else 0 - bed_temp = temps.get('bed', 0) if temps else 0 - - if 'nozzle_temp' in printer and printer['nozzle_temp']: - nozzle_temp = printer['nozzle_temp'] - if 'bed_temp' in printer and printer['bed_temp']: - bed_temp = printer['bed_temp'] - - # Check Bambu MQTT state directly for real-time temps and error info - if printer.get('type') == 'bambu': - printer_name = printer.get('name') - if printer_name: - with bambu_states_lock: - if printer_name in BAMBU_PRINTER_STATES: - bambu_state = BAMBU_PRINTER_STATES[printer_name] - if bambu_state.get('nozzle_temp') is not None: - nozzle_temp = bambu_state.get('nozzle_temp', 0) - if bambu_state.get('bed_temp') is not None: - bed_temp = bambu_state.get('bed_temp', 0) - - if bambu_state.get('state') == 'ERROR' or printer.get('state') == 'ERROR': - error_msg = bambu_state.get('error') - hms_alerts = bambu_state.get('hms_alerts', []) - - if error_msg: - printer['error_message'] = error_msg - elif hms_alerts: - printer['error_message'] = '; '.join(hms_alerts) - else: - printer['error_message'] = 'Unknown error' - - printer['nozzle_temp'] = nozzle_temp - printer['bed_temp'] = bed_temp - - # Calculate minutes since finished - minutes_since_finished = get_minutes_since_finished(printer) - printer['minutes_since_finished'] = minutes_since_finished - - # Add print stage info - state = printer.get('state', 'Unknown') - print_stage = 'idle' - stage_detail = '' - - if state == 'PRINTING': - print_stage = 'printing' - stage_detail = f"{printer.get('progress', 0)}% complete" - elif state == 'FINISHED': - print_stage = 'finished' - if minutes_since_finished is not None: - stage_detail = f'Finished {minutes_since_finished}m ago' - else: - stage_detail = 'Print complete' - elif state == 'EJECTING': - print_stage = 'ejecting' - stage_detail = 'Ejecting print' - elif state == 'COOLING': - print_stage = 'cooling' - cooldown_target = printer.get('cooldown_target_temp', 0) - stage_detail = f'Cooling bed to {cooldown_target}°C' - elif state == 'READY': - print_stage = 'ready' - stage_detail = 'Ready for next job' - elif state == 'PAUSED': - print_stage = 'paused' - stage_detail = 'Print paused' - elif state == 'ERROR': - print_stage = 'error' - stage_detail = printer.get('error_message', 'Printer error') - - printer['print_stage'] = print_stage - printer['stage_detail'] = stage_detail - - # Add timestamps for timeline tracking - printer['print_started_at'] = printer.get('print_started_at') - printer['finish_time'] = printer.get('finish_time') - printer['ejection_start_time'] = printer.get('ejection_start_time') - - return printers_copy +# Re-export pure/near-pure helpers so existing imports keep working +from utils.status_poller_helpers import ( # noqa: F401 + state_map, + get_minutes_since_finished, + prepare_printer_data_for_broadcast, + _build_minimal_printer, + _offline_update, + _ready_update, + _api_temps, +) def update_bambu_printer_states(): @@ -284,6 +175,183 @@ async def _fetch(): return printer, None +def _apply_printer_updates(printer_updates): + """Apply collected updates to PRINTERS and run manually_set failsafe. + + Must be called while holding ``WriteLock(printers_rwlock)``. + """ + for update in printer_updates: + if 0 <= update['index'] < len(PRINTERS): + current_manually_set = PRINTERS[update['index']].get('manually_set', False) + new_manually_set = update['updates'].get('manually_set', current_manually_set) + if current_manually_set and not new_manually_set and update['updates'].get('state') != 'PRINTING': + logging.warning(f"WARNING: Printer {PRINTERS[update['index']]['name']} manually_set changing from True to False!") + if current_manually_set and PRINTERS[update['index']]['state'] == 'READY': + logging.warning(f"Preventing manual flag from being cleared for READY printer {PRINTERS[update['index']]['name']}") + update['updates']['manually_set'] = True + + if (PRINTERS[update['index']].get('state') == 'READY' and + update['updates'].get('state') == 'FINISHED' and + (PRINTERS[update['index']].get('file') is None or PRINTERS[update['index']].get('ejection_processed', False))): + logging.debug(f"Preserving READY state for {PRINTERS[update['index']]['name']} despite API FINISHED state") + update['updates']['state'] = 'READY' + update['updates']['status'] = 'Ready' + update['updates']['manually_set'] = True + + old_state = PRINTERS[update['index']].get('state') + new_state = update['updates'].get('state') + if new_state and old_state != new_state: + logging.info(f"Printer {PRINTERS[update['index']]['name']} state: {old_state} -> {new_state}") + + for key, value in update['updates'].items(): + PRINTERS[update['index']][key] = value + + # Failsafe for manually_set printers + for i, printer in enumerate(PRINTERS): + if printer.get('manually_set', False) and printer.get('state') not in ['READY', 'PRINTING', 'EJECTING']: + logging.warning(f"Failsafe: Fixing printer {printer['name']} - has manually_set=True but state={printer['state']}. Setting back to READY") + printer['state'] = 'READY' + printer['status'] = 'Ready' + printer['manually_set'] = True + printer['count_incremented_for_current_job'] = False + + +def _monitor_ejection_completion(printer, printer_index, printer_updates, start_bg_dist): + """Check if an EJECTING printer has completed and transition to READY. + + Must be called while holding ``WriteLock(printers_rwlock)``. + *start_bg_dist* is a callable that triggers background order distribution. + """ + printer_name = printer['name'] + printer_type = printer.get('type', 'prusa') + current_time = time.time() + ejection_start = printer.get('ejection_start_time', 0) + elapsed_minutes = (current_time - ejection_start) / 60.0 if ejection_start else 0 + + # Find the API state from the latest update for this printer + api_state = None + current_api_file = None + for update in printer_updates: + if update['index'] == printer_index: + api_state = update['updates'].get('state') + current_api_file = update['updates'].get('file', '') + break + + logging.debug(f"Ejection check for {printer_name} (type: {printer_type}): api_state={api_state}, api_file='{current_api_file}', stored_file='{printer.get('file', '')}', elapsed={elapsed_minutes:.1f}min") + + ejection_complete = False + completion_reason = "" + + ejection_state = get_printer_ejection_state(printer_name) + if ejection_state['state'] == 'completed': + ejection_complete = True + completion_reason = "State manager shows completed" + elif api_state in ['IDLE', 'READY', 'OPERATIONAL']: + ejection_complete = True + completion_reason = f"API state = {api_state}" + elif printer_type != 'bambu': + stored_file = printer.get('file', '') + if stored_file and 'ejection_' in stored_file: + if not current_api_file or current_api_file != stored_file: + ejection_complete = True + completion_reason = f"Ejection file '{stored_file}' no longer active" + elif api_state == 'FINISHED': + ejection_complete = True + completion_reason = "Prusa API shows FINISHED after ejection" + elif printer_type == 'bambu': + try: + with bambu_states_lock: + if printer_name in BAMBU_PRINTER_STATES: + bambu_state = BAMBU_PRINTER_STATES[printer_name] + if bambu_state.get('ejection_complete', False): + ejection_complete = True + completion_reason = "Bambu ejection_complete flag" + elif bambu_state.get('state', '') in ['IDLE', 'READY']: + ejection_complete = True + completion_reason = f"Bambu state = {bambu_state.get('state', '')}" + except Exception as e: + logging.error(f"Error checking Bambu ejection state for {printer_name}: {e}") + + if ejection_complete: + logging.warning(f"EJECTION COMPLETE: {printer_name} transitioning from EJECTING to READY ({completion_reason})") + + printer.update(_ready_update( + order_id=None, ejection_processed=False, + manual_timeout=time.time() + 300, + ejection_start_time=None, finish_time=None, + last_ejection_time=time.time(), + count_incremented_for_current_job=False, + )) + + release_ejection_lock(printer_name) + clear_printer_ejection_state(printer_name) + start_bg_dist() + else: + if elapsed_minutes > 5: + logging.info(f"Ejection still in progress for {printer_name}: {elapsed_minutes:.1f} minutes elapsed") + + +def _monitor_cooling_state(printer): + """Check if a COOLING printer has reached target temp and act. + + Must be called while holding ``WriteLock(printers_rwlock)``. + """ + printer_name = printer['name'] + cooldown_target = printer.get('cooldown_target_temp', 0) + cooldown_order_id = printer.get('cooldown_order_id') + + current_bed_temp = 0 + try: + with bambu_states_lock: + if printer_name in BAMBU_PRINTER_STATES: + current_bed_temp = BAMBU_PRINTER_STATES[printer_name].get('bed_temp', 0) + except Exception as e: + logging.warning(f"Could not get bed temp for {printer_name}: {e}") + + printer['status'] = f'Cooling ({current_bed_temp}°C → {cooldown_target}°C)' + + if current_bed_temp <= cooldown_target: + logging.info(f"COOLING->EJECTING: {printer_name} (bed temp {current_bed_temp}°C <= target {cooldown_target}°C)") + + with SafeLock(orders_lock): + order = next((o for o in ORDERS if o['id'] == cooldown_order_id), None) + + if order and order.get('ejection_enabled', False): + gcode_content = order.get('end_gcode', '').strip() + if not gcode_content: + gcode_content = "G28 X Y\nM84" + + printer.update({ + "state": 'EJECTING', "status": 'Ejecting', + "ejection_start_time": time.time(), + "ejection_processed": True, "ejection_in_progress": True, + "manually_set": False, + "cooldown_target_temp": None, "cooldown_order_id": None, + }) + + success = send_bambu_ejection_gcode(printer, gcode_content) + if not success: + logging.error(f"Bambu ejection failed for {printer_name} after cooling") + printer.update({ + "state": 'READY', "status": 'Ready', + "ejection_processed": False, "ejection_in_progress": False, + "manually_set": True, + }) + else: + logging.warning(f"COOLING->READY: {printer_name} (order not found or ejection not enabled)") + printer.update({ + "state": 'READY', "status": 'Ready', + "progress": 0, "time_remaining": 0, + "manually_set": True, + "cooldown_target_temp": None, "cooldown_order_id": None, + }) + else: + finish_time = printer.get('finish_time', time.time()) + cooling_minutes = (time.time() - finish_time) / 60.0 + if int(cooling_minutes) % 2 == 0 and cooling_minutes > 0: + logging.debug(f"COOLING: {printer_name} at {current_bed_temp}°C, target {cooldown_target}°C ({cooling_minutes:.1f}min elapsed)") + + async def get_printer_status_async(socketio, app, batch_index=None, batch_size=None): """Main status polling function - fetches status from all printers and updates state""" # Import here to avoid circular imports @@ -300,72 +368,19 @@ async def get_printer_status_async(socketio, app, batch_index=None, batch_size=N if batch_size is None: batch_size = Config.STATUS_BATCH_SIZE - printers_to_process = [] - printer_indices = [] - with ReadLock(printers_rwlock): all_printers = [p.copy() for p in PRINTERS if not p.get('service_mode', False)] + # Select the slice of printers for this batch if batch_index is not None: start_idx = batch_index * batch_size - end_idx = min(start_idx + batch_size, len(all_printers)) - - for i in range(start_idx, end_idx): - if i < len(all_printers): - printer_copy = all_printers[i] - - minimal_printer = { - 'name': printer_copy['name'], - 'ip': printer_copy['ip'], - 'state': printer_copy.get('state', 'Unknown'), - 'manually_set': printer_copy.get('manually_set', False), - 'file': printer_copy.get('file', ''), - 'order_id': printer_copy.get('order_id'), - 'ejection_processed': printer_copy.get('ejection_processed', False), - 'ejection_in_progress': printer_copy.get('ejection_in_progress', False), - 'manual_timeout': printer_copy.get('manual_timeout', 0), - 'type': printer_copy.get('type', 'prusa'), - 'last_ejection_time': printer_copy.get('last_ejection_time', 0), - 'finish_time': printer_copy.get('finish_time'), - 'count_incremented_for_current_job': printer_copy.get('count_incremented_for_current_job', False) - } - - if printer_copy.get('type') != 'bambu': - minimal_printer['api_key'] = printer_copy.get('api_key') - else: - minimal_printer['device_id'] = printer_copy.get('device_id') - minimal_printer['serial_number'] = printer_copy.get('serial_number') - minimal_printer['access_code'] = printer_copy.get('access_code') - - printers_to_process.append(minimal_printer) - printer_indices.append(i) + batch_printers = all_printers[start_idx:start_idx + batch_size] else: - for i, printer in enumerate(all_printers): - minimal_printer = { - 'name': printer['name'], - 'ip': printer['ip'], - 'state': printer.get('state', 'Unknown'), - 'manually_set': printer.get('manually_set', False), - 'file': printer.get('file', ''), - 'order_id': printer.get('order_id'), - 'ejection_processed': printer.get('ejection_processed', False), - 'ejection_in_progress': printer.get('ejection_in_progress', False), - 'manual_timeout': printer.get('manual_timeout', 0), - 'type': printer.get('type', 'prusa'), - 'last_ejection_time': printer.get('last_ejection_time', 0), - 'finish_time': printer.get('finish_time'), - 'count_incremented_for_current_job': printer.get('count_incremented_for_current_job', False) - } - - if printer.get('type') != 'bambu': - minimal_printer['api_key'] = printer.get('api_key') - else: - minimal_printer['device_id'] = printer.get('device_id') - minimal_printer['serial_number'] = printer.get('serial_number') - minimal_printer['access_code'] = printer.get('access_code') + start_idx = 0 + batch_printers = all_printers - printers_to_process.append(minimal_printer) - printer_indices.append(i) + printers_to_process = [_build_minimal_printer(p) for p in batch_printers] + printer_indices = list(range(start_idx, start_idx + len(printers_to_process))) if not printers_to_process: logging.debug(f"No printers to process in batch {batch_index}") @@ -392,19 +407,7 @@ async def get_printer_status_async(socketio, app, batch_index=None, batch_size=N logging.error(f"Error fetching status for {printers_to_process[idx]['name']}: {str(result)}") printer_updates.append({ 'index': printer_indices[idx], - 'updates': { - "state": "OFFLINE", - "status": "Offline", - "temps": {"nozzle": 0, "bed": 0}, - "progress": 0, - "time_remaining": 0, - "file": "None", - "job_id": None, - "manually_set": False, - "ejection_in_progress": False, - "finish_time": None, - "count_incremented_for_current_job": False - } + 'updates': _offline_update(), }) continue @@ -450,56 +453,23 @@ async def get_printer_status_async(socketio, app, batch_index=None, batch_size=N updates['ejection_in_progress'] = True else: manual_timeout = printer.get('manual_timeout', 0) - current_time = time.time() - if manual_timeout > 0 and current_time < manual_timeout: + if manual_timeout > 0 and time.time() < manual_timeout: logging.debug(f"Manual state timeout active for {printer['name']}, preserving READY state") - updates = { - "state": "READY", - "status": "Ready", - "temps": {"bed": data['printer'].get('temp_bed', 0), "nozzle": data['printer'].get('temp_nozzle', 0)}, - "z_height": data['printer'].get('axis_z', 0), - "progress": 0, - "time_remaining": 0, - "file": None, - "job_id": None, - "manually_set": True, - "ejection_processed": ejection_processed, - "ejection_in_progress": False, - "count_incremented_for_current_job": printer.get('count_incremented_for_current_job', False) - } else: logging.debug(f"Preserving manually set state for {printer['name']} despite API state {api_state}") - updates = { - "state": "READY", - "status": "Ready", - "temps": {"bed": data['printer'].get('temp_bed', 0), "nozzle": data['printer'].get('temp_nozzle', 0)}, - "z_height": data['printer'].get('axis_z', 0), - "progress": 0, - "time_remaining": 0, - "file": None, - "job_id": None, - "manually_set": True, - "ejection_processed": ejection_processed, - "ejection_in_progress": False, - "count_incremented_for_current_job": printer.get('count_incremented_for_current_job', False) - } + updates = _ready_update( + **_api_temps(data), + ejection_processed=ejection_processed, + count_incremented_for_current_job=printer.get('count_incremented_for_current_job', False), + ) elif ejection_processed and current_state == 'READY': logging.debug(f"Preserving READY state for {printer['name']} due to prior ejection, ignoring API state {api_state}") - updates = { - "state": 'READY', - "status": 'Ready', - "temps": {"bed": data['printer'].get('temp_bed', 0), "nozzle": data['printer'].get('temp_nozzle', 0)}, - "z_height": data['printer'].get('axis_z', 0), - "progress": 0, - "time_remaining": 0, - "file": None, - "job_id": None, - "order_id": None, - "ejection_processed": True, - "ejection_in_progress": False, - "manually_set": True, - "count_incremented_for_current_job": printer.get('count_incremented_for_current_job', False) - } + updates = _ready_update( + **_api_temps(data), + order_id=None, + ejection_processed=True, + count_incremented_for_current_job=printer.get('count_incremented_for_current_job', False), + ) elif ejection_in_progress and current_state == 'EJECTING' and api_state in ['IDLE', 'READY', 'OPERATIONAL', 'FINISHED']: logging.debug(f"Maintaining EJECTING state for {printer['name']} as ejection is in progress internally, ignoring API state {api_state}") updates = { @@ -629,40 +599,20 @@ async def get_printer_status_async(socketio, app, batch_index=None, batch_size=N 'MANUAL_RESET_DETECTED', {'api_state': api_state, 'reason': 'Manual reset detected, auto-transitioning to READY'} ) - updates.update({ - "state": 'READY', - "status": 'Ready', - "progress": 0, - "time_remaining": 0, - "file": None, - "job_id": None, - "order_id": None, - "manually_set": True, - "ejection_processed": False, - "ejection_in_progress": False, - "ejection_start_time": None, - "finish_time": None, - "count_incremented_for_current_job": False - }) + updates.update(_ready_update( + order_id=None, ejection_processed=False, + ejection_start_time=None, finish_time=None, + count_incremented_for_current_job=False, + )) threading.Timer(2.0, lambda: start_background_distribution(socketio, app)).start() elif stored_state == 'EJECTING': logging.warning(f"IMPORTANT: Printer {printer['name']} completed ejection (API={api_state}), transitioning from EJECTING to READY") - updates.update({ - "state": 'READY', - "status": 'Ready', - "progress": 0, - "time_remaining": 0, - "file": None, - "job_id": None, - "order_id": None, - "manually_set": True, - "ejection_processed": False, - "ejection_in_progress": False, - "ejection_start_time": None, - "last_ejection_time": time.time(), - "finish_time": None, - "count_incremented_for_current_job": False - }) + updates.update(_ready_update( + order_id=None, ejection_processed=False, + ejection_start_time=None, finish_time=None, + last_ejection_time=time.time(), + count_incremented_for_current_job=False, + )) release_ejection_lock(printer['name']) clear_printer_ejection_state(printer['name']) else: @@ -710,19 +660,7 @@ async def get_printer_status_async(socketio, app, batch_index=None, batch_size=N else: printer_updates.append({ 'index': printer_indices[idx], - 'updates': { - "state": "OFFLINE", - "status": "Offline", - "temps": {"nozzle": 0, "bed": 0}, - "progress": 0, - "time_remaining": 0, - "file": "None", - "job_id": None, - "manually_set": False, - "ejection_in_progress": False, - "finish_time": None, - "count_incremented_for_current_job": False - } + 'updates': _offline_update(), }) if ejection_tasks: @@ -739,188 +677,18 @@ async def get_printer_status_async(socketio, app, batch_index=None, batch_size=N # Apply updates and handle state transitions with WriteLock(printers_rwlock): - for update in printer_updates: - if 0 <= update['index'] < len(PRINTERS): - current_manually_set = PRINTERS[update['index']].get('manually_set', False) - new_manually_set = update['updates'].get('manually_set', current_manually_set) - if current_manually_set and not new_manually_set and update['updates'].get('state') != 'PRINTING': - logging.warning(f"WARNING: Printer {PRINTERS[update['index']]['name']} manually_set changing from True to False!") - if current_manually_set and PRINTERS[update['index']]['state'] == 'READY': - logging.warning(f"Preventing manual flag from being cleared for READY printer {PRINTERS[update['index']]['name']}") - update['updates']['manually_set'] = True - - if (PRINTERS[update['index']].get('state') == 'READY' and - update['updates'].get('state') == 'FINISHED' and - (PRINTERS[update['index']].get('file') is None or PRINTERS[update['index']].get('ejection_processed', False))): - logging.debug(f"Preserving READY state for {PRINTERS[update['index']]['name']} despite API FINISHED state") - update['updates']['state'] = 'READY' - update['updates']['status'] = 'Ready' - update['updates']['manually_set'] = True + _apply_printer_updates(printer_updates) - old_state = PRINTERS[update['index']].get('state') - new_state = update['updates'].get('state') - if new_state and old_state != new_state: - logging.info(f"Printer {PRINTERS[update['index']]['name']} state: {old_state} -> {new_state}") + def start_bg_dist(): + threading.Timer( + 2.0, lambda: start_background_distribution(socketio, app) + ).start() - for key, value in update['updates'].items(): - PRINTERS[update['index']][key] = value - - # Failsafe for manually_set printers - for i, printer in enumerate(PRINTERS): - if printer.get('manually_set', False) and printer.get('state') not in ['READY', 'PRINTING', 'EJECTING']: - logging.warning(f"Failsafe: Fixing printer {printer['name']} - has manually_set=True but state={printer['state']}. Setting back to READY") - printer['state'] = 'READY' - printer['status'] = 'Ready' - printer['manually_set'] = True - printer['count_incremented_for_current_job'] = False - - # Enhanced ejection completion monitoring for i, printer in enumerate(PRINTERS): if printer.get('state') == 'EJECTING': - printer_name = printer['name'] - printer_type = printer.get('type', 'prusa') - current_time = time.time() - ejection_start = printer.get('ejection_start_time', 0) - elapsed_minutes = (current_time - ejection_start) / 60.0 if ejection_start else 0 - - api_state = None - current_api_file = None - - for update in printer_updates: - if update['index'] == i: - api_state = update['updates'].get('state') - current_api_file = update['updates'].get('file', '') - break - - logging.debug(f"Ejection check for {printer_name} (type: {printer_type}): api_state={api_state}, api_file='{current_api_file}', stored_file='{printer.get('file', '')}', elapsed={elapsed_minutes:.1f}min") - - ejection_complete = False - completion_reason = "" - - ejection_state = get_printer_ejection_state(printer_name) - if ejection_state['state'] == 'completed': - ejection_complete = True - completion_reason = "State manager shows completed" - elif api_state in ['IDLE', 'READY', 'OPERATIONAL']: - ejection_complete = True - completion_reason = f"API state = {api_state}" - elif printer_type != 'bambu': - stored_file = printer.get('file', '') - if stored_file and 'ejection_' in stored_file: - if not current_api_file or current_api_file != stored_file: - ejection_complete = True - completion_reason = f"Ejection file '{stored_file}' no longer active" - elif api_state == 'FINISHED': - ejection_complete = True - completion_reason = "Prusa API shows FINISHED after ejection" - elif printer_type == 'bambu': - try: - with bambu_states_lock: - if printer_name in BAMBU_PRINTER_STATES: - bambu_state = BAMBU_PRINTER_STATES[printer_name] - if bambu_state.get('ejection_complete', False): - ejection_complete = True - completion_reason = "Bambu ejection_complete flag" - elif bambu_state.get('state', '') in ['IDLE', 'READY']: - ejection_complete = True - completion_reason = f"Bambu state = {bambu_state.get('state', '')}" - except Exception as e: - logging.error(f"Error checking Bambu ejection state for {printer_name}: {e}") - - if ejection_complete: - logging.warning(f"EJECTION COMPLETE: {printer_name} transitioning from EJECTING to READY ({completion_reason})") - - printer.update({ - "state": 'READY', - "status": 'Ready', - "progress": 0, - "time_remaining": 0, - "file": None, - "job_id": None, - "order_id": None, - "manually_set": True, - "manual_timeout": time.time() + 300, - "ejection_processed": False, - "ejection_in_progress": False, - "ejection_start_time": None, - "finish_time": None, - "last_ejection_time": time.time(), - "count_incremented_for_current_job": False - }) - - release_ejection_lock(printer_name) - clear_printer_ejection_state(printer_name) - - threading.Timer(2.0, lambda: start_background_distribution(socketio, app)).start() - else: - if elapsed_minutes > 5: - logging.info(f"Ejection still in progress for {printer_name}: {elapsed_minutes:.1f} minutes elapsed") - - # COOLING STATE MONITORING - for i, printer in enumerate(PRINTERS): - if printer.get('state') == 'COOLING': - printer_name = printer['name'] - cooldown_target = printer.get('cooldown_target_temp', 0) - cooldown_order_id = printer.get('cooldown_order_id') - - current_bed_temp = 0 - try: - with bambu_states_lock: - if printer_name in BAMBU_PRINTER_STATES: - current_bed_temp = BAMBU_PRINTER_STATES[printer_name].get('bed_temp', 0) - except Exception as e: - logging.warning(f"Could not get bed temp for {printer_name}: {e}") - - printer['status'] = f'Cooling ({current_bed_temp}°C → {cooldown_target}°C)' - - if current_bed_temp <= cooldown_target: - logging.info(f"COOLING->EJECTING: {printer_name} (bed temp {current_bed_temp}°C <= target {cooldown_target}°C)") - - with SafeLock(orders_lock): - order = next((o for o in ORDERS if o['id'] == cooldown_order_id), None) - - if order and order.get('ejection_enabled', False): - gcode_content = order.get('end_gcode', '').strip() - if not gcode_content: - gcode_content = "G28 X Y\nM84" - - printer.update({ - "state": 'EJECTING', - "status": 'Ejecting', - "ejection_start_time": time.time(), - "ejection_processed": True, - "ejection_in_progress": True, - "manually_set": False, - "cooldown_target_temp": None, - "cooldown_order_id": None - }) - - success = send_bambu_ejection_gcode(printer, gcode_content) - if not success: - logging.error(f"Bambu ejection failed for {printer_name} after cooling") - printer.update({ - "state": 'READY', - "status": 'Ready', - "ejection_processed": False, - "ejection_in_progress": False, - "manually_set": True - }) - else: - logging.warning(f"COOLING->READY: {printer_name} (order not found or ejection not enabled)") - printer.update({ - "state": 'READY', - "status": 'Ready', - "progress": 0, - "time_remaining": 0, - "manually_set": True, - "cooldown_target_temp": None, - "cooldown_order_id": None - }) - else: - finish_time = printer.get('finish_time', time.time()) - cooling_minutes = (time.time() - finish_time) / 60.0 - if int(cooling_minutes) % 2 == 0 and cooling_minutes > 0: - logging.debug(f"COOLING: {printer_name} at {current_bed_temp}°C, target {cooldown_target}°C ({cooling_minutes:.1f}min elapsed)") + _monitor_ejection_completion(printer, i, printer_updates, start_bg_dist) + elif printer.get('state') == 'COOLING': + _monitor_cooling_state(printer) save_data(PRINTERS_FILE, PRINTERS) diff --git a/api/tests/test_services/test_status_poller.py b/api/tests/test_services/test_status_poller.py new file mode 100644 index 0000000..55c7c3c --- /dev/null +++ b/api/tests/test_services/test_status_poller.py @@ -0,0 +1,666 @@ +""" +Characterization tests for status_poller.py + +These tests capture the current behavior of the status polling system +before refactoring. They serve as a safety net: if all tests pass after +refactoring, the external behavior is preserved. +""" + +import time +import copy +import pytest +from unittest.mock import patch, MagicMock, AsyncMock + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def make_printer(**overrides): + """Create a printer dict with sensible defaults for testing.""" + base = { + 'name': 'Printer1', + 'ip': '192.168.1.100', + 'type': 'prusa', + 'group': 'Default', + 'state': 'READY', + 'status': 'Ready', + 'temps': {'nozzle': 25, 'bed': 22}, + 'progress': 0, + 'time_remaining': 0, + 'z_height': 0, + 'file': None, + 'api_key': 'test_key', + 'manually_set': False, + 'order_id': None, + 'ejection_processed': False, + 'ejection_in_progress': False, + 'manual_timeout': 0, + 'last_ejection_time': 0, + 'finish_time': None, + 'service_mode': False, + 'count_incremented_for_current_job': False, + 'print_started_at': None, + 'ejection_start_time': None, + } + base.update(overrides) + return base + + +def make_api_response(state='IDLE', temp_bed=25, temp_nozzle=30, axis_z=0): + """Create a Prusa-style API /status response.""" + return { + 'printer': { + 'state': state, + 'temp_bed': temp_bed, + 'temp_nozzle': temp_nozzle, + 'axis_z': axis_z, + } + } + + +def make_job_response(progress=0, time_remaining=0, file_name='test.gcode', job_id=123): + """Create a Prusa-style API /job response.""" + return { + 'progress': progress, + 'time_remaining': time_remaining, + 'file': {'display_name': file_name}, + 'id': job_id, + } + + +# =========================================================================== +# get_minutes_since_finished (pure function) +# =========================================================================== + +class TestGetMinutesSinceFinished: + """Verify the elapsed-time calculator for FINISHED printers.""" + + def test_finished_five_minutes_ago(self): + from services.status_poller import get_minutes_since_finished + printer = {'state': 'FINISHED', 'finish_time': time.time() - 300, 'name': 'P1'} + assert get_minutes_since_finished(printer) == 5 + + def test_finished_one_hour_ago(self): + from services.status_poller import get_minutes_since_finished + printer = {'state': 'FINISHED', 'finish_time': time.time() - 3600, 'name': 'P1'} + assert get_minutes_since_finished(printer) == 60 + + def test_returns_none_when_not_finished(self): + from services.status_poller import get_minutes_since_finished + printer = {'state': 'PRINTING', 'finish_time': time.time() - 300, 'name': 'P1'} + assert get_minutes_since_finished(printer) is None + + def test_returns_none_when_finish_time_is_none(self): + from services.status_poller import get_minutes_since_finished + assert get_minutes_since_finished({'state': 'FINISHED', 'finish_time': None, 'name': 'P1'}) is None + + def test_returns_none_when_finish_time_missing(self): + from services.status_poller import get_minutes_since_finished + assert get_minutes_since_finished({'state': 'FINISHED', 'name': 'P1'}) is None + + def test_returns_zero_for_just_finished(self): + from services.status_poller import get_minutes_since_finished + printer = {'state': 'FINISHED', 'finish_time': time.time() - 15, 'name': 'P1'} + assert get_minutes_since_finished(printer) == 0 + + +# =========================================================================== +# prepare_printer_data_for_broadcast (most widely-used export) +# =========================================================================== + +class TestPreparePrinterDataForBroadcast: + """Lock in the broadcast data mapping and per-state enrichment logic.""" + + # -- field mappings -- + + @patch('utils.status_poller_helpers.BAMBU_PRINTER_STATES', {}) + def test_maps_file_to_current_file(self): + from services.status_poller import prepare_printer_data_for_broadcast + result = prepare_printer_data_for_broadcast( + [make_printer(file='test.gcode', state='PRINTING')] + ) + assert result[0]['current_file'] == 'test.gcode' + + @patch('utils.status_poller_helpers.BAMBU_PRINTER_STATES', {}) + def test_maps_state_to_status(self): + from services.status_poller import prepare_printer_data_for_broadcast + result = prepare_printer_data_for_broadcast([make_printer(state='PRINTING')]) + # status is overwritten to match state (raw value) + assert result[0]['status'] == 'PRINTING' + + # -- print_stage / stage_detail for every state -- + + @patch('utils.status_poller_helpers.BAMBU_PRINTER_STATES', {}) + def test_stage_ready(self): + from services.status_poller import prepare_printer_data_for_broadcast + r = prepare_printer_data_for_broadcast([make_printer(state='READY')])[0] + assert r['print_stage'] == 'ready' + assert r['stage_detail'] == 'Ready for next job' + + @patch('utils.status_poller_helpers.BAMBU_PRINTER_STATES', {}) + def test_stage_printing(self): + from services.status_poller import prepare_printer_data_for_broadcast + r = prepare_printer_data_for_broadcast( + [make_printer(state='PRINTING', progress=45)] + )[0] + assert r['print_stage'] == 'printing' + assert '45%' in r['stage_detail'] + + @patch('utils.status_poller_helpers.BAMBU_PRINTER_STATES', {}) + def test_stage_finished_with_time(self): + from services.status_poller import prepare_printer_data_for_broadcast + r = prepare_printer_data_for_broadcast( + [make_printer(state='FINISHED', finish_time=time.time() - 600)] + )[0] + assert r['print_stage'] == 'finished' + assert r['minutes_since_finished'] == 10 + assert '10m ago' in r['stage_detail'] + + @patch('utils.status_poller_helpers.BAMBU_PRINTER_STATES', {}) + def test_stage_finished_without_time(self): + from services.status_poller import prepare_printer_data_for_broadcast + r = prepare_printer_data_for_broadcast( + [make_printer(state='FINISHED', finish_time=None)] + )[0] + assert r['print_stage'] == 'finished' + assert r['stage_detail'] == 'Print complete' + + @patch('utils.status_poller_helpers.BAMBU_PRINTER_STATES', {}) + def test_stage_ejecting(self): + from services.status_poller import prepare_printer_data_for_broadcast + r = prepare_printer_data_for_broadcast([make_printer(state='EJECTING')])[0] + assert r['print_stage'] == 'ejecting' + assert r['stage_detail'] == 'Ejecting print' + + @patch('utils.status_poller_helpers.BAMBU_PRINTER_STATES', {}) + def test_stage_cooling(self): + from services.status_poller import prepare_printer_data_for_broadcast + r = prepare_printer_data_for_broadcast( + [make_printer(state='COOLING', cooldown_target_temp=40)] + )[0] + assert r['print_stage'] == 'cooling' + assert '40' in r['stage_detail'] + + @patch('utils.status_poller_helpers.BAMBU_PRINTER_STATES', {}) + def test_stage_paused(self): + from services.status_poller import prepare_printer_data_for_broadcast + r = prepare_printer_data_for_broadcast([make_printer(state='PAUSED')])[0] + assert r['print_stage'] == 'paused' + assert r['stage_detail'] == 'Print paused' + + @patch('utils.status_poller_helpers.BAMBU_PRINTER_STATES', {}) + def test_stage_error_with_message(self): + from services.status_poller import prepare_printer_data_for_broadcast + r = prepare_printer_data_for_broadcast( + [make_printer(state='ERROR', error_message='Thermal runaway')] + )[0] + assert r['print_stage'] == 'error' + assert r['stage_detail'] == 'Thermal runaway' + + @patch('utils.status_poller_helpers.BAMBU_PRINTER_STATES', {}) + def test_stage_error_default_message(self): + from services.status_poller import prepare_printer_data_for_broadcast + r = prepare_printer_data_for_broadcast([make_printer(state='ERROR')])[0] + assert r['print_stage'] == 'error' + assert r['stage_detail'] == 'Printer error' + + @patch('utils.status_poller_helpers.BAMBU_PRINTER_STATES', {}) + def test_stage_idle_fallback(self): + from services.status_poller import prepare_printer_data_for_broadcast + r = prepare_printer_data_for_broadcast([make_printer(state='IDLE')])[0] + assert r['print_stage'] == 'idle' + + # -- temperature handling -- + + @patch('utils.status_poller_helpers.BAMBU_PRINTER_STATES', {}) + def test_temps_from_temps_dict(self): + from services.status_poller import prepare_printer_data_for_broadcast + r = prepare_printer_data_for_broadcast( + [make_printer(temps={'nozzle': 210, 'bed': 60})] + )[0] + assert r['nozzle_temp'] == 210 + assert r['bed_temp'] == 60 + + @patch('utils.status_poller_helpers.BAMBU_PRINTER_STATES', {}) + def test_direct_temp_fields_override_dict(self): + from services.status_poller import prepare_printer_data_for_broadcast + r = prepare_printer_data_for_broadcast( + [make_printer(temps={'nozzle': 100, 'bed': 50}, nozzle_temp=215, bed_temp=65)] + )[0] + assert r['nozzle_temp'] == 215 + assert r['bed_temp'] == 65 + + def test_bambu_mqtt_temps_override(self): + from services.status_poller import prepare_printer_data_for_broadcast + with patch('utils.status_poller_helpers.BAMBU_PRINTER_STATES', { + 'BambuP1': {'nozzle_temp': 220, 'bed_temp': 70, 'state': 'PRINTING'} + }): + r = prepare_printer_data_for_broadcast( + [make_printer(name='BambuP1', type='bambu', state='PRINTING')] + )[0] + assert r['nozzle_temp'] == 220 + assert r['bed_temp'] == 70 + + # -- Bambu error messages -- + + def test_bambu_error_from_error_field(self): + from services.status_poller import prepare_printer_data_for_broadcast + with patch('utils.status_poller_helpers.BAMBU_PRINTER_STATES', { + 'B1': {'state': 'ERROR', 'nozzle_temp': 0, 'bed_temp': 0, + 'error': 'Motor stall detected', 'hms_alerts': []} + }): + r = prepare_printer_data_for_broadcast( + [make_printer(name='B1', type='bambu', state='ERROR')] + )[0] + assert r['error_message'] == 'Motor stall detected' + + def test_bambu_error_from_hms_alerts(self): + from services.status_poller import prepare_printer_data_for_broadcast + with patch('utils.status_poller_helpers.BAMBU_PRINTER_STATES', { + 'B1': {'state': 'ERROR', 'nozzle_temp': 0, 'bed_temp': 0, + 'error': None, 'hms_alerts': ['Nozzle clog', 'AMS jam']} + }): + r = prepare_printer_data_for_broadcast( + [make_printer(name='B1', type='bambu', state='ERROR')] + )[0] + assert 'Nozzle clog' in r['error_message'] + assert 'AMS jam' in r['error_message'] + + def test_bambu_error_unknown_fallback(self): + from services.status_poller import prepare_printer_data_for_broadcast + with patch('utils.status_poller_helpers.BAMBU_PRINTER_STATES', { + 'B1': {'state': 'ERROR', 'nozzle_temp': 0, 'bed_temp': 0, + 'error': None, 'hms_alerts': []} + }): + r = prepare_printer_data_for_broadcast( + [make_printer(name='B1', type='bambu', state='ERROR')] + )[0] + assert r['error_message'] == 'Unknown error' + + # -- invariants -- + + @patch('utils.status_poller_helpers.BAMBU_PRINTER_STATES', {}) + def test_does_not_mutate_input(self): + from services.status_poller import prepare_printer_data_for_broadcast + printers = [make_printer(state='PRINTING', progress=50)] + original = copy.deepcopy(printers) + prepare_printer_data_for_broadcast(printers) + assert printers == original + + @patch('utils.status_poller_helpers.BAMBU_PRINTER_STATES', {}) + def test_timestamps_preserved(self): + from services.status_poller import prepare_printer_data_for_broadcast + now = time.time() + r = prepare_printer_data_for_broadcast([make_printer( + state='FINISHED', print_started_at=now - 3600, + finish_time=now - 60, ejection_start_time=now - 30, + )])[0] + assert r['print_started_at'] == now - 3600 + assert r['finish_time'] == now - 60 + assert r['ejection_start_time'] == now - 30 + + @patch('utils.status_poller_helpers.BAMBU_PRINTER_STATES', {}) + def test_multiple_printers(self): + from services.status_poller import prepare_printer_data_for_broadcast + result = prepare_printer_data_for_broadcast([ + make_printer(name='P1', state='READY'), + make_printer(name='P2', state='PRINTING', progress=50), + make_printer(name='P3', state='FINISHED', finish_time=time.time() - 120), + ]) + assert len(result) == 3 + assert result[0]['print_stage'] == 'ready' + assert result[1]['print_stage'] == 'printing' + assert result[2]['print_stage'] == 'finished' + + +# =========================================================================== +# update_bambu_printer_states (MQTT -> PRINTERS sync) +# =========================================================================== + +class TestUpdateBambuPrinterStates: + """Verify Bambu MQTT state sync logic.""" + + def _run(self, printers, bambu_states): + """Run update_bambu_printer_states with mocked globals. Returns save_data mock.""" + with patch('services.status_poller.PRINTERS', printers), \ + patch('services.status_poller.BAMBU_PRINTER_STATES', bambu_states), \ + patch('services.status_poller.save_data') as mock_save, \ + patch('services.status_poller.PRINTERS_FILE', '/tmp/test.json'): + from services.status_poller import update_bambu_printer_states + update_bambu_printer_states() + return mock_save + + def test_propagates_state_and_data(self): + printers = [make_printer(name='B1', type='bambu', state='READY')] + bambu = {'B1': {'state': 'PRINTING', 'nozzle_temp': 220, 'bed_temp': 60, + 'progress': 50, 'time_remaining': 1800, 'current_file': 'test.3mf'}} + mock_save = self._run(printers, bambu) + assert printers[0]['state'] == 'PRINTING' + assert printers[0]['nozzle_temp'] == 220 + assert printers[0]['bed_temp'] == 60 + assert printers[0]['progress'] == 50 + assert printers[0]['file'] == 'test.3mf' + mock_save.assert_called_once() + + def test_skips_non_bambu(self): + printers = [make_printer(name='P1', type='prusa', state='READY')] + self._run(printers, {'P1': {'state': 'PRINTING'}}) + assert printers[0]['state'] == 'READY' + + def test_skips_cooling(self): + printers = [make_printer(name='B1', type='bambu', state='COOLING')] + self._run(printers, {'B1': {'state': 'IDLE', 'nozzle_temp': 30, 'bed_temp': 25}}) + assert printers[0]['state'] == 'COOLING' + + def test_preserves_manual_ready_on_idle(self): + printers = [make_printer(name='B1', type='bambu', state='READY', manually_set=True)] + self._run(printers, {'B1': {'state': 'IDLE', 'nozzle_temp': 30, 'bed_temp': 25}}) + assert printers[0]['state'] == 'READY' + assert printers[0]['nozzle_temp'] == 30 # temps still updated + + def test_manual_ready_allows_printing(self): + printers = [make_printer(name='B1', type='bambu', state='READY', manually_set=True)] + self._run(printers, {'B1': {'state': 'PRINTING', 'nozzle_temp': 220, 'bed_temp': 60, + 'progress': 10, 'time_remaining': 3600}}) + assert printers[0]['state'] == 'PRINTING' + assert printers[0]['manually_set'] is False + + def test_blocks_finished_to_ready(self): + printers = [make_printer(name='B1', type='bambu', state='FINISHED')] + mock_save = self._run(printers, {'B1': {'state': 'READY', 'nozzle_temp': 30, 'bed_temp': 25}}) + assert printers[0]['state'] == 'FINISHED' + mock_save.assert_not_called() + + def test_sets_finish_time_on_transition(self): + printers = [make_printer(name='B1', type='bambu', state='PRINTING')] + before = time.time() + self._run(printers, {'B1': {'state': 'FINISHED', 'nozzle_temp': 200, 'bed_temp': 55}}) + after = time.time() + assert printers[0]['state'] == 'FINISHED' + assert before <= printers[0]['finish_time'] <= after + + def test_noop_when_no_bambu_states(self): + printers = [make_printer(name='B1', type='bambu', state='READY')] + mock_save = self._run(printers, {}) + assert printers[0]['state'] == 'READY' + mock_save.assert_not_called() + + def test_clears_manual_on_ejecting(self): + printers = [make_printer(name='B1', type='bambu', state='READY', manually_set=True)] + self._run(printers, {'B1': {'state': 'EJECTING', 'nozzle_temp': 200, 'bed_temp': 55}}) + assert printers[0]['state'] == 'EJECTING' + assert printers[0]['manually_set'] is False + + def test_file_fallback_key(self): + """Falls back to 'file' key when 'current_file' absent.""" + printers = [make_printer(name='B1', type='bambu', state='READY')] + self._run(printers, {'B1': {'state': 'PRINTING', 'file': 'fallback.3mf'}}) + assert printers[0]['file'] == 'fallback.3mf' + + +# =========================================================================== +# get_printer_status_async (state-machine integration tests) +# =========================================================================== + +class TestStateTransitions: + """Test the state machine inside get_printer_status_async. + + Each test sets up a known PRINTERS state, provides a controlled API + response via a mocked ``fetch_status``, runs the poller, and asserts + the resulting printer state. + """ + + # -- helpers -- + + @staticmethod + def _make_session_mock(job_response=None): + """Build aiohttp.ClientSession mock that returns *job_response* on GET. + + Uses MagicMock for the session and context-manager wrapper because + aiohttp's ``session.get(url)`` returns a context-manager object (not a + coroutine). Only ``__aenter__`` / ``__aexit__`` need to be async. + """ + mock_job_resp = MagicMock() + if job_response: + mock_job_resp.status = 200 + mock_job_resp.json = AsyncMock(return_value=job_response) + else: + mock_job_resp.status = 404 + mock_job_resp.json = AsyncMock(return_value={}) + + mock_session = MagicMock() + mock_get_cm = MagicMock() + mock_get_cm.__aenter__ = AsyncMock(return_value=mock_job_resp) + mock_get_cm.__aexit__ = AsyncMock(return_value=False) + mock_session.get.return_value = mock_get_cm + + mock_session_cm = MagicMock() + mock_session_cm.__aenter__ = AsyncMock(return_value=mock_session) + mock_session_cm.__aexit__ = AsyncMock(return_value=False) + return mock_session_cm + + async def _run_poll(self, printers, api_responses, job_response=None, + bambu_states=None, orders=None): + """Execute one poll cycle and return (printers, mock_socketio). + + *api_responses*: ``{printer_name: api_data_or_None}`` + *bambu_states*: Optional dict to use as ``BAMBU_PRINTER_STATES``. + *orders*: Optional list to use as ``ORDERS``. + """ + from services.status_poller import get_printer_status_async + + mock_socketio = MagicMock() + mock_app = MagicMock() + + async def _fetch(session, printer): + return printer, api_responses.get(printer['name']) + + _bs = bambu_states if bambu_states is not None else {} + with patch('services.status_poller.PRINTERS', printers), \ + patch('services.status_poller.ORDERS', orders or []), \ + patch('services.status_poller.BAMBU_PRINTER_STATES', _bs), \ + patch('utils.status_poller_helpers.BAMBU_PRINTER_STATES', _bs), \ + patch('services.status_poller.save_data'), \ + patch('services.status_poller.load_data', + return_value={'total_filament_used_g': 0}), \ + patch('services.status_poller.PRINTERS_FILE', '/tmp/t.json'), \ + patch('services.status_poller.TOTAL_FILAMENT_FILE', '/tmp/tf.json'), \ + patch('services.status_poller.clear_stuck_ejection_locks'), \ + patch('services.status_poller.update_bambu_printer_states'), \ + patch('services.status_poller.fetch_status', new=_fetch), \ + patch('services.status_poller.decrypt_api_key', return_value='k'), \ + patch('services.status_poller.handle_finished_state_ejection'), \ + patch('services.status_poller.release_ejection_lock'), \ + patch('services.status_poller.clear_printer_ejection_state'), \ + patch('services.status_poller.get_printer_ejection_state', + return_value={'state': 'none'}), \ + patch('services.status_poller.log_api_poll_event'), \ + patch('services.status_poller.log_state_transition'), \ + patch('aiohttp.ClientSession', + return_value=self._make_session_mock(job_response)), \ + patch('threading.Timer', return_value=MagicMock()): + await get_printer_status_async( + mock_socketio, mock_app, batch_index=0, batch_size=10 + ) + + return printers, mock_socketio + + # -- OFFLINE -- + + @pytest.mark.asyncio + async def test_offline_when_api_returns_none(self): + printers = [make_printer()] + result, _ = await self._run_poll(printers, {'Printer1': None}) + assert result[0]['state'] == 'OFFLINE' + + @pytest.mark.asyncio + async def test_offline_on_fetch_exception(self): + """fetch_status raising maps to OFFLINE.""" + from services.status_poller import get_printer_status_async + + printers = [make_printer()] + mock_sio = MagicMock() + + async def _boom(session, printer): + raise ConnectionError("refused") + + with patch('services.status_poller.PRINTERS', printers), \ + patch('services.status_poller.ORDERS', []), \ + patch('services.status_poller.BAMBU_PRINTER_STATES', {}), \ + patch('utils.status_poller_helpers.BAMBU_PRINTER_STATES', {}), \ + patch('services.status_poller.save_data'), \ + patch('services.status_poller.load_data', + return_value={'total_filament_used_g': 0}), \ + patch('services.status_poller.PRINTERS_FILE', '/tmp/t.json'), \ + patch('services.status_poller.TOTAL_FILAMENT_FILE', '/tmp/tf.json'), \ + patch('services.status_poller.clear_stuck_ejection_locks'), \ + patch('services.status_poller.update_bambu_printer_states'), \ + patch('services.status_poller.fetch_status', new=_boom), \ + patch('services.status_poller.decrypt_api_key', return_value='k'), \ + patch('services.status_poller.handle_finished_state_ejection'), \ + patch('services.status_poller.release_ejection_lock'), \ + patch('services.status_poller.clear_printer_ejection_state'), \ + patch('services.status_poller.get_printer_ejection_state', + return_value={'state': 'none'}), \ + patch('services.status_poller.log_api_poll_event'), \ + patch('services.status_poller.log_state_transition'), \ + patch('aiohttp.ClientSession', + return_value=self._make_session_mock()), \ + patch('threading.Timer', return_value=MagicMock()): + await get_printer_status_async(mock_sio, MagicMock(), batch_index=0, batch_size=10) + + assert printers[0]['state'] == 'OFFLINE' + + # -- manually_set preservation -- + + @pytest.mark.asyncio + async def test_manual_ready_stays_ready_on_idle(self): + printers = [make_printer(state='READY', manually_set=True)] + result, _ = await self._run_poll(printers, { + 'Printer1': make_api_response(state='IDLE') + }) + assert result[0]['state'] == 'READY' + assert result[0]['manually_set'] is True + + @pytest.mark.asyncio + async def test_manual_ready_transitions_to_printing(self): + printers = [make_printer(state='READY', manually_set=True)] + job = make_job_response(progress=10, file_name='part.gcode') + result, _ = await self._run_poll( + printers, + {'Printer1': make_api_response(state='PRINTING')}, + job_response=job, + ) + assert result[0]['state'] == 'PRINTING' + + # -- ejection-processed preservation -- + + @pytest.mark.asyncio + async def test_ejection_processed_ready_stays_ready(self): + printers = [make_printer(state='READY', ejection_processed=True)] + result, _ = await self._run_poll(printers, { + 'Printer1': make_api_response(state='IDLE') + }) + assert result[0]['state'] == 'READY' + + # -- EJECTING preservation -- + + @pytest.mark.asyncio + async def test_ejecting_stays_while_in_progress(self): + printers = [make_printer(state='EJECTING', ejection_in_progress=True)] + result, _ = await self._run_poll(printers, { + 'Printer1': make_api_response(state='IDLE') + }) + assert result[0]['state'] == 'EJECTING' + + @pytest.mark.asyncio + async def test_ejecting_stays_with_ejection_file_printing(self): + printers = [make_printer(state='EJECTING', file='ejection_test.gcode')] + result, _ = await self._run_poll(printers, { + 'Printer1': make_api_response(state='PRINTING') + }) + assert result[0]['state'] == 'EJECTING' + + # -- COOLING skip -- + + @pytest.mark.asyncio + async def test_cooling_preserves_state(self): + """COOLING printer stays COOLING when bed temp is still above target.""" + printers = [make_printer( + name='Printer1', type='bambu', state='COOLING', + cooldown_target_temp=40, cooldown_order_id=1, + finish_time=time.time() - 60, + )] + # Bed temp (50) still above target (40) -> stays COOLING + bambu = {'Printer1': {'bed_temp': 50, 'state': 'IDLE'}} + result, _ = await self._run_poll( + printers, + {'Printer1': make_api_response(state='IDLE', temp_bed=50)}, + bambu_states=bambu, + ) + assert result[0]['state'] == 'COOLING' + + # -- stored FINISHED + API IDLE -> READY -- + + @pytest.mark.asyncio + async def test_stored_finished_api_idle_goes_ready(self): + printers = [make_printer(state='FINISHED', finish_time=time.time() - 300)] + result, _ = await self._run_poll(printers, { + 'Printer1': make_api_response(state='IDLE') + }) + assert result[0]['state'] == 'READY' + assert result[0]['manually_set'] is True + + # -- stored EJECTING + API IDLE -> READY -- + + @pytest.mark.asyncio + async def test_stored_ejecting_api_idle_goes_ready(self): + printers = [make_printer(state='EJECTING', ejection_in_progress=False)] + result, _ = await self._run_poll(printers, { + 'Printer1': make_api_response(state='IDLE') + }) + assert result[0]['state'] == 'READY' + assert result[0]['manually_set'] is True + + # -- socket emission -- + + @pytest.mark.asyncio + async def test_emits_status_update(self): + printers = [make_printer()] + _, mock_sio = await self._run_poll(printers, { + 'Printer1': make_api_response(state='IDLE') + }) + mock_sio.emit.assert_called_once() + event, payload = mock_sio.emit.call_args[0] + assert event == 'status_update' + assert 'printers' in payload + assert 'total_filament' in payload + assert 'orders' in payload + + # -- normal PRINTING updates -- + + @pytest.mark.asyncio + async def test_printing_updates_progress(self): + printers = [make_printer(state='READY')] + job = make_job_response(progress=42, time_remaining=900, file_name='widget.gcode') + result, _ = await self._run_poll( + printers, + {'Printer1': make_api_response(state='PRINTING')}, + job_response=job, + ) + assert result[0]['state'] == 'PRINTING' + assert result[0]['progress'] == 42 + assert result[0]['file'] == 'widget.gcode' + + # -- service_mode printers are skipped -- + + @pytest.mark.asyncio + async def test_service_mode_printer_skipped(self): + printers = [make_printer(state='READY', service_mode=True)] + # No API response needed because it should never be fetched + result, mock_sio = await self._run_poll(printers, {}) + # Printer state should be unchanged - the poller skips service_mode printers + # (they are excluded from all_printers at line 307) + # Socket still emits but with no printer changes + assert result[0]['state'] == 'READY' diff --git a/api/utils/status_poller_helpers.py b/api/utils/status_poller_helpers.py new file mode 100644 index 0000000..7cd1b5d --- /dev/null +++ b/api/utils/status_poller_helpers.py @@ -0,0 +1,195 @@ +""" +Pure / near-pure helpers used by the status poller. + +These functions have no side-effects on global state (or at most read +from a single global). They are extracted here to keep status_poller.py +focused on orchestration and mutable-state management. +""" +import time +import copy + +from services.state import logging +from services.bambu_handler import BAMBU_PRINTER_STATES, bambu_states_lock + +# State mapping for printer states +state_map = { + 'IDLE': 'Ready', 'PRINTING': 'Printing', 'PAUSED': 'Paused', 'ERROR': 'Error', + 'FINISHED': 'Finished', 'READY': 'Ready', 'STOPPED': 'Stopped', 'ATTENTION': 'Attention', + 'EJECTING': 'Ejecting', 'PREPARE': 'Preparing', 'OFFLINE': 'Offline', + 'COOLING': 'Cooling' +} + + +def get_minutes_since_finished(printer): + """Calculate minutes elapsed since printer entered FINISHED state""" + logging.debug(f"Checking finish time for {printer.get('name')}: " + f"state={printer.get('state')}, finish_time={printer.get('finish_time')}") + + if printer.get('state') != 'FINISHED' or not printer.get('finish_time'): + return None + + current_time = time.time() + finish_time = printer.get('finish_time', current_time) + elapsed_seconds = current_time - finish_time + + minutes = int(elapsed_seconds / 60) + + logging.debug(f"Timer for {printer.get('name')}: {minutes} minutes") + return minutes + + +def prepare_printer_data_for_broadcast(printers): + """Prepare printer data with calculated fields for broadcasting""" + printers_copy = copy.deepcopy(printers) + + for printer in printers_copy: + # Map backend field names to frontend expected names + if 'file' in printer: + printer['current_file'] = printer.get('file') + + if 'state' in printer: + printer['status'] = printer.get('state') + + # Extract temperature values + temps = printer.get('temps', {}) + nozzle_temp = temps.get('nozzle', 0) if temps else 0 + bed_temp = temps.get('bed', 0) if temps else 0 + + if 'nozzle_temp' in printer and printer['nozzle_temp']: + nozzle_temp = printer['nozzle_temp'] + if 'bed_temp' in printer and printer['bed_temp']: + bed_temp = printer['bed_temp'] + + # Check Bambu MQTT state directly for real-time temps and error info + if printer.get('type') == 'bambu': + printer_name = printer.get('name') + if printer_name: + with bambu_states_lock: + if printer_name in BAMBU_PRINTER_STATES: + bambu_state = BAMBU_PRINTER_STATES[printer_name] + if bambu_state.get('nozzle_temp') is not None: + nozzle_temp = bambu_state.get('nozzle_temp', 0) + if bambu_state.get('bed_temp') is not None: + bed_temp = bambu_state.get('bed_temp', 0) + + if bambu_state.get('state') == 'ERROR' or printer.get('state') == 'ERROR': + error_msg = bambu_state.get('error') + hms_alerts = bambu_state.get('hms_alerts', []) + + if error_msg: + printer['error_message'] = error_msg + elif hms_alerts: + printer['error_message'] = '; '.join(hms_alerts) + else: + printer['error_message'] = 'Unknown error' + + printer['nozzle_temp'] = nozzle_temp + printer['bed_temp'] = bed_temp + + # Calculate minutes since finished + minutes_since_finished = get_minutes_since_finished(printer) + printer['minutes_since_finished'] = minutes_since_finished + + # Add print stage info + state = printer.get('state', 'Unknown') + print_stage = 'idle' + stage_detail = '' + + if state == 'PRINTING': + print_stage = 'printing' + stage_detail = f"{printer.get('progress', 0)}% complete" + elif state == 'FINISHED': + print_stage = 'finished' + if minutes_since_finished is not None: + stage_detail = f'Finished {minutes_since_finished}m ago' + else: + stage_detail = 'Print complete' + elif state == 'EJECTING': + print_stage = 'ejecting' + stage_detail = 'Ejecting print' + elif state == 'COOLING': + print_stage = 'cooling' + cooldown_target = printer.get('cooldown_target_temp', 0) + stage_detail = f'Cooling bed to {cooldown_target}°C' + elif state == 'READY': + print_stage = 'ready' + stage_detail = 'Ready for next job' + elif state == 'PAUSED': + print_stage = 'paused' + stage_detail = 'Print paused' + elif state == 'ERROR': + print_stage = 'error' + stage_detail = printer.get('error_message', 'Printer error') + + printer['print_stage'] = print_stage + printer['stage_detail'] = stage_detail + + # Add timestamps for timeline tracking + printer['print_started_at'] = printer.get('print_started_at') + printer['finish_time'] = printer.get('finish_time') + printer['ejection_start_time'] = printer.get('ejection_start_time') + + return printers_copy + + +def _build_minimal_printer(printer): + """Build a lightweight copy of a printer dict for status polling.""" + mp = { + 'name': printer['name'], + 'ip': printer['ip'], + 'state': printer.get('state', 'Unknown'), + 'manually_set': printer.get('manually_set', False), + 'file': printer.get('file', ''), + 'order_id': printer.get('order_id'), + 'ejection_processed': printer.get('ejection_processed', False), + 'ejection_in_progress': printer.get('ejection_in_progress', False), + 'manual_timeout': printer.get('manual_timeout', 0), + 'type': printer.get('type', 'prusa'), + 'last_ejection_time': printer.get('last_ejection_time', 0), + 'finish_time': printer.get('finish_time'), + 'count_incremented_for_current_job': printer.get('count_incremented_for_current_job', False), + } + if printer.get('type') != 'bambu': + mp['api_key'] = printer.get('api_key') + else: + mp['device_id'] = printer.get('device_id') + mp['serial_number'] = printer.get('serial_number') + mp['access_code'] = printer.get('access_code') + return mp + + +def _offline_update(): + """Standard update dict for an unreachable / offline printer.""" + return { + "state": "OFFLINE", "status": "Offline", + "temps": {"nozzle": 0, "bed": 0}, + "progress": 0, "time_remaining": 0, "file": "None", "job_id": None, + "manually_set": False, "ejection_in_progress": False, + "finish_time": None, "count_incremented_for_current_job": False, + } + + +def _ready_update(**overrides): + """Base update dict for transitioning a printer to READY state. + + Contains only the universally-common fields. Callers add extras + (temps, order_id, ejection_processed, finish_time, etc.) via *overrides*. + """ + base = { + "state": "READY", "status": "Ready", + "progress": 0, "time_remaining": 0, + "file": None, "job_id": None, + "manually_set": True, + "ejection_in_progress": False, + } + base.update(overrides) + return base + + +def _api_temps(data): + """Extract temperature and z-height values from an API response.""" + return { + "temps": {"bed": data['printer'].get('temp_bed', 0), + "nozzle": data['printer'].get('temp_nozzle', 0)}, + "z_height": data['printer'].get('axis_z', 0), + }