#!/usr/bin/env python3 """ Extract key printer information from SNMP scan CSVs Converts all toner levels to percentages (0-100%) """ import csv import os import sys import re from pathlib import Path from datetime import datetime def clean_value(value): """Clean up encoding issues in SNMP values""" if not value: return value # Remove common encoding prefixes if value.startswith('รฝรจ'): value = value[2:] # Remove null characters and other control chars value = ''.join(char for char in value if ord(char) >= 32 or char in '\n\r\t') return value.strip() def extract_toner_info(csv_file): """ Extract comprehensive toner and supply information from CSV Maps supply indices to their levels and capacities """ supplies = {} toner_levels_raw = {} # Raw values from 43.11.1.1.9 toner_max_capacity = {} # Max capacity from 43.11.1.1.8 supply_current = {} # Current from 43.10.2.1.9 supply_max = {} # Max from 43.10.2.1.4 hp_designjet_levels = {} # HP DesignJet ink levels from proprietary OID with open(csv_file, 'r', encoding='utf-8') as f: reader = csv.DictReader(f) for row in reader: oid = row['OID'] value = clean_value(row['Value']) # Extract supply descriptions (43.11.1.1.6) if '1.3.6.1.2.1.43.11.1.1.6' in oid: index = oid.split('.')[-1] if index not in supplies: supplies[index] = {} supplies[index]['description'] = value # Extract toner max capacity (43.11.1.1.8.1.X) elif '1.3.6.1.2.1.43.11.1.1.8.1' in oid: parts = oid.split('.') if len(parts) >= 2: index = parts[-1] try: toner_max_capacity[index] = int(value) except: pass # Extract supply max capacity (43.10.2.1.4) elif '1.3.6.1.2.1.43.10.2.1.4' in oid: parts = oid.split('.') if len(parts) >= 2: index = parts[-1] try: supply_max[index] = int(value) except: pass # Extract supply current level (43.10.2.1.9) elif '1.3.6.1.2.1.43.10.2.1.9' in oid: parts = oid.split('.') if len(parts) >= 2: index = parts[-1] try: supply_current[index] = int(value) except: pass # Direct toner level readings (43.11.1.1.9.1.X) elif '1.3.6.1.2.1.43.11.1.1.9.1' in oid: parts = oid.split('.') if len(parts) >= 2: index = parts[-1] try: toner_levels_raw[index] = int(value) except: pass # HP DesignJet ink levels (1.3.6.1.4.1.11.2.3.9.4.2.1.4.1.5.4.X.1.0) elif '1.3.6.1.4.1.11.2.3.9.4.2.1.4.1.5.4.' in oid: parts = oid.split('.') # OID format: 1.3.6.1.4.1.11.2.3.9.4.2.1.4.1.5.4.{cartridge_index}.1.0 # Example: 1.3.6.1.4.1.11.2.3.9.4.2.1.4.1.5.4.1.1.0 (Matte Black) if len(parts) >= 18: index = parts[17] # Cartridge index (1-6) try: hp_designjet_levels[index] = int(value) except: pass return supplies, toner_levels_raw, toner_max_capacity, supply_current, supply_max, hp_designjet_levels def map_hp730_color(part_number): """ Map HP 730 part numbers to actual ink colors Returns color name or None if not recognized """ part_number = part_number.upper().strip() # HP 730 130ml and 300ml cartridges hp730_map = { # 130ml cartridges 'P2V62A': 'Cyan', 'P2V63A': 'Magenta', 'P2V64A': 'Yellow', 'P2V65A': 'Matte Black', 'P2V66A': 'Photo Black', 'P2V67A': 'Gray', # 300ml cartridges (verified from actual T1700dr installation) 'P2V68A': 'Cyan', 'P2V69A': 'Magenta', 'P2V70A': 'Yellow', 'P2V71A': 'Matte Black', 'P2V72A': 'Gray', 'P2V73A': 'Photo Black', } # Extract part number from description like "HP 730 ( P2V72A )" for pn, color in hp730_map.items(): if pn in part_number: return color return None def calculate_toner_percentage(toner_idx, toner_value, toner_max_capacity, supply_current, supply_max): """ Calculate toner percentage from various sources Returns: (percentage, display_string) """ # Negative values mean not installed if toner_value < 0: return (None, "Not installed") # If value is 0-100, it's already a percentage if 0 <= toner_value <= 100: return (toner_value, f"{toner_value}%") # First try: Use toner max capacity (43.11.1.1.8.1.X) if available # This is the correct OID for HP printers that report absolute page counts if toner_idx in toner_max_capacity: max_val = toner_max_capacity[toner_idx] if max_val > 0: percentage = int((toner_value / max_val) * 100) return (percentage, f"{percentage}% ({toner_value:,}/{max_val:,})") # Try to find matching supply data to calculate percentage # Often toner index '1' = supply '1', '2' = supply '2', etc. if toner_idx in supply_max and toner_idx in supply_current: max_val = supply_max[toner_idx] current_val = supply_current[toner_idx] if max_val > 0: percentage = int((current_val / max_val) * 100) return (percentage, f"{percentage}% ({current_val:,}/{max_val:,})") # Alternative: Try supply index '1' for the first toner if '1' in supply_max and '1' in supply_current: max_val = supply_max['1'] current_val = supply_current['1'] if max_val > 0 and toner_value > 100: # Estimate percentage based on pages percentage = int((toner_value / max_val) * 100) if percentage > 100: # Raw value is absolute pages, not related to this max # Just show the raw percentage estimate # Assume typical cartridge is ~3000-20000 pages if toner_value < 100: return (toner_value, f"{toner_value}%") elif toner_value < 500: return (10, f"~10% ({toner_value:,} pages remaining)") elif toner_value < 2000: return (25, f"~25% ({toner_value:,} pages remaining)") elif toner_value < 5000: return (50, f"~50% ({toner_value:,} pages remaining)") elif toner_value < 10000: return (75, f"~75% ({toner_value:,} pages remaining)") else: return (90, f"~90% ({toner_value:,} pages remaining)") return (percentage, f"{percentage}%") # If we can't calculate, estimate based on page count ranges if toner_value > 100: if toner_value < 500: return (10, f"~10% ({toner_value:,} pages remaining)") elif toner_value < 2000: return (25, f"~25% ({toner_value:,} pages remaining)") elif toner_value < 5000: return (50, f"~50% ({toner_value:,} pages remaining)") elif toner_value < 10000: return (75, f"~75% ({toner_value:,} pages remaining)") else: return (90, f"~90% ({toner_value:,} pages remaining)") return (None, f"{toner_value}") def get_fqdn_from_ip(ip_address): """Generate FQDN from IP address""" # Convert IP like 10.80.92.56 to Printer-10-80-92-56.printer.geaerospace.net ip_with_dashes = ip_address.replace('.', '-') return f"Printer-{ip_with_dashes}.printer.geaerospace.net" def normalize_model_name(model_name): """Normalize model name by removing firmware/system version info""" # Remove common firmware/version patterns # Examples: # "Xerox VersaLink C7125 Color MFP; System 69.21.21, Controller..." -> "Xerox VersaLink C7125" # "HP LaserJet Pro M404n" stays the same # Split on semicolon (common separator for firmware info) model_clean = model_name.split(';')[0].strip() # Remove common version patterns in parentheses import re model_clean = re.sub(r'\s*\([^)]*\)', '', model_clean) # Remove trailing descriptors that are too specific # Keep: "Color MFP", "PostScript", etc. # But remove system versions, dates, etc. return model_clean.strip() def normalize_supply_description(description): """Normalize supply description by removing serial numbers and other unique identifiers""" import re # Remove serial numbers (e.g., ";SN804A5C260...", ";SNunknown") # Match ;SN followed by anything (letters, numbers, "unknown", etc.) desc_clean = re.sub(r';SN\w+.*$', '', description) # Remove trailing dots/ellipsis desc_clean = desc_clean.rstrip('.') # Clean up extra whitespace desc_clean = ' '.join(desc_clean.split()) return desc_clean.strip() def fix_unknown_part_numbers(description, model): """ Replace 'PN unknown' with known part numbers based on printer model """ # Normalize model name for matching normalized_model = normalize_model_name(model) # Check if this model has known part number mappings if normalized_model not in XEROX_PART_NUMBER_MAP: return description # Extract the supply type from description (e.g., "Fuser, PN unknown" -> "Fuser") supply_type = description.split(',')[0].strip() # Check if we have a mapping for this supply type part_map = XEROX_PART_NUMBER_MAP[normalized_model] if supply_type in part_map: # Replace "PN unknown" with the actual part number known_pn = part_map[supply_type] return description.replace('PN unknown', f'PN {known_pn}') return description # Known part numbers for Xerox models that report generic descriptions XEROX_PART_NUMBER_MAP = { 'Xerox VersaLink C7125 Color MFP': { 'Black Toner Cartridge': '006R01820', 'Cyan Toner Cartridge': '006R01821', 'Magenta Toner Cartridge': '006R01822', 'Yellow Toner Cartridge': '006R01823', }, 'Xerox VersaLink C405': { 'Black Toner Cartridge': '106R03536', 'Cyan Toner Cartridge': '106R03538', 'Magenta Toner Cartridge': '106R03539', 'Yellow Toner Cartridge': '106R03537', }, 'Xerox VersaLink B7125 MFP': { 'Toner Cartridge': '006R01817', }, 'Xerox VersaLink B405': { 'Toner Cartridge': '106R03580', # Standard capacity (5.9K pages) }, 'Xerox VersaLink B400': { 'Toner Cartridge': '106R03580', # Standard capacity (5.9K pages) }, 'Xerox EC8036 Color': { 'Fuser': '008R13063', # 110V 'Second Bias Transfer Roll': '008R13064', }, 'Xerox AltaLink C8135': { 'Fuser': '008R13102', # 110V 'Second Bias Transfer Roll': '008R08103', }, } # Printer supply compatibility groups based on research COMPATIBILITY_GROUPS = [ { 'priority': 'High', 'name': 'HP LaserJet Pro M404n + M406 Consolidation', 'models': ['HP LaserJet Pro M404n', 'HP LaserJet Pro M406'], 'shared_supplies': [ {'type': 'Toner', 'parts': ['CF258A (58A Standard)', 'CF258X (58X High Yield)']}, {'type': 'Fuser', 'parts': ['RM2-6461 / RM2-6342']}, ], 'benefit': 'Both models share identical toner and fuser - consolidate to single SKUs' }, { 'priority': 'High', 'name': 'HP DesignJet Plotter Ink Sharing', 'models': ['HP DesignJet T1700dr', 'HP DesignJet T1600', 'HP DesignJet T2600'], 'shared_supplies': [ {'type': 'Ink Cartridges', 'parts': [ 'HP 730 series (P2V62A-P2V72A)', 'All colors: Matte Black, Photo Black, Cyan, Magenta, Yellow, Gray' ]}, ], 'benefit': 'T1700dr shares all HP 730 inks with T1600/T2600 series' }, { 'priority': 'Medium', 'name': 'HP LaserJet 90A/X Family', 'models': ['HP LaserJet M4555', 'HP LaserJet M602', 'HP LaserJet M603'], 'shared_supplies': [ {'type': 'Toner', 'parts': ['CE390A (90A Standard)', 'CE390X (90X High Yield)']}, ], 'benefit': 'All use CE390A/X toner cartridges' }, { 'priority': 'Medium', 'name': 'HP LaserJet 42A/X Family', 'models': ['HP LaserJet 4250', 'HP LaserJet 4350'], 'shared_supplies': [ {'type': 'Toner', 'parts': ['Q5942A (42A Standard)', 'Q5942X (42X High Yield)']}, ], 'benefit': 'Compatible toner across 4250/4350 models' }, { 'priority': 'Medium', 'name': 'HP LaserJet 55X Family', 'models': ['HP LaserJet P3015', 'HP LaserJet P3010'], 'shared_supplies': [ {'type': 'Toner', 'parts': ['CE255X (55X High Yield)']}, ], 'benefit': 'Share CE255X high-yield toner cartridges' }, { 'priority': 'Medium', 'name': 'Xerox VersaLink C405/C400 Series', 'models': ['Xerox VersaLink C405', 'Xerox VersaLink C400'], 'shared_supplies': [ {'type': 'Toner', 'parts': [ '106R03536 (Black)', '106R03538 (Cyan)', '106R03539 (Magenta)', '106R03537 (Yellow)' ]}, ], 'benefit': 'C405 and C400 share identical 106R035xx toner series' }, { 'priority': 'Medium', 'name': 'Xerox VersaLink B405/B400 Series', 'models': ['Xerox VersaLink B405', 'Xerox VersaLink B400'], 'shared_supplies': [ {'type': 'Toner', 'parts': [ '106R03580 (Standard Yield)', '106R03581 (High Yield)', '106R03582 (Extra High Yield)' ]}, ], 'benefit': 'B405 and B400 share 106R035xx toner family' }, { 'priority': 'Critical', 'name': 'HP LaserJet Pro M254dw Toner Correction', 'models': ['HP LaserJet Pro M254dw'], 'shared_supplies': [ {'type': 'Toner', 'parts': [ 'CF500A (202A Black)', 'CF501A (202A Cyan)', 'CF502A (202A Magenta)', 'CF503A (202A Yellow)' ]}, ], 'benefit': 'IMPORTANT: Uses CF500A (202A) series, NOT W2020A (414A). Verify existing stock!' }, ] def replace_generic_xerox_pn(description, model_name): """Replace generic Xerox part numbers with known specific ones based on model""" # Check if we have a mapping for this model if model_name not in XEROX_PART_NUMBER_MAP: return description # Find which cartridge type this is for cartridge_type, part_number in XEROX_PART_NUMBER_MAP[model_name].items(): if cartridge_type in description: # Handle "PN Genuine Xerox(R) Toner" replacement if 'PN Genuine Xerox(R) Toner' in description: return description.replace('PN Genuine Xerox(R) Toner', f'PN {part_number}') # Handle missing part number (just "Toner Cartridge" with serial) elif ';SN' in description and 'PN' not in description: # Insert part number before serial number return description.replace(';SN', f', PN {part_number};SN') # Handle simple "Toner Cartridge" without serial elif description == cartridge_type: return f'{cartridge_type}, PN {part_number}' return description def filter_generic_supplies(supplies_list): """Remove generic Xerox entries when specific part numbers exist""" # Convert to list if it's a set supplies = list(supplies_list) # Group by color/type (Black, Cyan, Magenta, Yellow) # If we have both a specific PN and "Genuine Xerox(R) Toner" for the same color, remove the generic one filtered = [] for supply in supplies: # Skip generic Xerox entries if we have a real part number for this color if 'PN Genuine Xerox(R) Toner' in supply: # Extract the color (Black, Cyan, Magenta, Yellow) color = None for c in ['Black', 'Cyan', 'Magenta', 'Yellow']: if c in supply: color = c break # Check if we have a real part number for this color if color: has_real_pn = any( color in s and 'PN Genuine Xerox(R) Toner' not in s and 'PN 006R' in s for s in supplies ) if has_real_pn: continue # Skip this generic entry filtered.append(supply) return filtered def extract_key_data(csv_file): """Extract all key printer information from CSV""" data = { 'model': 'Unknown', 'hostname': 'Unknown', 'serial': 'Unknown', 'supplies': {}, 'toner_levels': {}, 'toner_percentages': {}, 'maintenance_kit': {}, 'network': {}, 'is_color': False } if not os.path.exists(csv_file): return data with open(csv_file, 'r', encoding='utf-8') as f: reader = csv.DictReader(f) for row in reader: oid = row['OID'] value = clean_value(row['Value']) # System Description if oid == '1.3.6.1.2.1.1.1.0': data['model'] = value # Device Model (more specific) elif oid == '1.3.6.1.2.1.25.3.2.1.3.1': if data['model'] == 'Unknown' or 'ETHERNET' in data['model']: data['model'] = value # Hostname elif oid == '1.3.6.1.2.1.1.5.0': data['hostname'] = value # Serial Number (standard OID) elif '1.3.6.1.2.1.43.5.1.1.17' in oid: data['serial'] = value # Serial Number (HP-specific OID for DesignJet and other HP printers) elif '1.3.6.1.4.1.11.2.3.9.4.2.1.1.3.3.0' in oid: # Only use if we don't already have a serial from standard OID if data['serial'] == 'Unknown': data['serial'] = value # Maintenance Kit Info (HP) elif '1.3.6.1.4.1.11.2.3.9.4.2' in oid: if value and value not in ['0', '', 'N/A']: if '.1.1.3.3' in oid: data['maintenance_kit']['model'] = value elif '.1.1.3.2' in oid: data['maintenance_kit']['printer_model'] = value elif '.1.4.1.2' in oid: data['maintenance_kit']['remaining'] = value elif '.1.1.3.1' in oid: data['maintenance_kit']['part_number'] = value # Network location elif oid == '1.3.6.1.2.1.1.6.0': data['network']['location'] = value # Extract toner information supplies, toner_levels_raw, toner_max_capacity, supply_current, supply_max, hp_designjet_levels = extract_toner_info(csv_file) data['supplies'] = supplies # Detect large format printers (DesignJet, Plotter series) early to determine which OIDs to use model_lower = data['model'].lower() if data['model'] else '' data['is_large_format'] = any(keyword in model_lower for keyword in ['designjet', 'plotter', 't1700', 't1600', 't2600']) # If HP DesignJet with proprietary ink level OIDs, use those instead of standard OIDs if hp_designjet_levels and data['is_large_format']: # HP DesignJet provides direct percentages via proprietary OID for idx, percentage in hp_designjet_levels.items(): data['toner_levels'][idx] = f"{percentage}%" data['toner_percentages'][idx] = percentage else: # Standard printer: Calculate percentages from standard OIDs for idx, raw_value in toner_levels_raw.items(): percentage, display = calculate_toner_percentage(idx, raw_value, toner_max_capacity, supply_current, supply_max) data['toner_levels'][idx] = display data['toner_percentages'][idx] = percentage # Determine if printer is color (check for cyan/magenta/yellow indices) # Indices: 1=Black/Matte Black, 2=Cyan, 3=Magenta, 4=Yellow, 5=Photo Black, 6=Gray # Check both standard and HP DesignJet OIDs all_indices = set(toner_levels_raw.keys()) | set(hp_designjet_levels.keys()) data['is_color'] = any(idx in all_indices for idx in ['2', '3', '4', '5', '6']) return data def print_printer_summary(printer_ip, csv_file): """Print formatted summary for a single printer""" data = extract_key_data(csv_file) print(f"\n{'='*70}") print(f"Printer: {printer_ip}") print(f"{'='*70}") print(f"Model: {data['model']}") print(f"Hostname: {data['hostname']}") print(f"Serial: {data['serial']}") print(f"Type: {'Color' if data['is_color'] else 'Monochrome'}") # Toner levels if data['toner_levels']: # Use different label for DesignJet (ink) vs standard printers (toner) supply_type = "Ink Levels" if data.get('is_large_format', False) else "Toner Levels" print(f"\n {supply_type}:") # For HP DesignJets with HP 730 cartridges, map by part number instead of index # Build index-to-color mapping dynamically idx_to_color = {} if data.get('is_large_format', False): # Try to map by HP 730 part numbers for idx in data['toner_levels'].keys(): if idx in data.get('supplies', {}): desc = data['supplies'][idx].get('description', '') color = map_hp730_color(desc) if color: idx_to_color[idx] = color # If we couldn't map by part number, fall back to generic index mapping if not idx_to_color: idx_to_color = { '1': 'Cartridge 1', '2': 'Cartridge 2', '3': 'Cartridge 3', '4': 'Cartridge 4', '5': 'Cartridge 5', '6': 'Cartridge 6' } else: # Standard 4-color printers idx_to_color = { '1': 'Black', '2': 'Cyan', '3': 'Magenta', '4': 'Yellow' } # Define display order for colors color_order = ['Matte Black', 'Photo Black', 'Cyan', 'Magenta', 'Yellow', 'Gray', 'Black'] # Sort indices by color order sorted_indices = sorted( [idx for idx in data['toner_levels'].keys() if idx in idx_to_color], key=lambda x: color_order.index(idx_to_color[x]) if idx_to_color[x] in color_order else 999 ) for idx in sorted_indices: color = idx_to_color.get(idx, f'Index {idx}') # Check if this index actually contains toner/cartridge, not maintenance kit if idx in data['supplies']: desc = data['supplies'][idx].get('description', '') desc_lower = desc.lower() # Skip if it's clearly not a toner cartridge if any(keyword in desc_lower for keyword in ['maintenance kit', 'fuser', 'transfer', 'drum cartridge kit']): continue display = data['toner_levels'][idx] print(f" {color}: {display}") # Show cartridge info if available if idx in data['supplies']: desc = data['supplies'][idx].get('description', '') if desc: if len(desc) > 60: desc = desc[:60] + "..." print(f" Cartridge: {desc}") # Other supplies (drums, fuser, waste) if data['supplies']: other_supplies = [] for idx, supply in data['supplies'].items(): # Skip main toners (1-4) or all 6 colors for DesignJet (1-6) if data.get('is_large_format', False): if idx in ['1', '2', '3', '4', '5', '6']: continue else: if idx in ['1', '2', '3', '4']: continue desc = supply.get('description', '') if desc: # Check if it's a maintenance-related item keywords = ['fuser', 'drum', 'waste', 'transfer', 'belt', 'roller', 'kit'] if any(keyword in desc.lower() for keyword in keywords): # Fix unknown part numbers with known ones based on model desc = fix_unknown_part_numbers(desc, data['model']) other_supplies.append((idx, desc)) if other_supplies: print(f"\n Other Supplies (Fuser, Drums, etc.):") for idx, desc in sorted(other_supplies, key=lambda x: x[1]): if len(desc) > 70: desc = desc[:70] + "..." print(f" {desc}") # Maintenance kit (HP specific) if data['maintenance_kit']: print(f"\n Maintenance Kit:") for key, value in data['maintenance_kit'].items(): if value and value not in ['N/A', '0', '']: label = key.replace('_', ' ').title() print(f" {label}: {value}") # Network info if data['network'] and data['network'].get('location'): print(f"\n Network:") print(f" Location: {data['network']['location']}") def print_models_with_supplies(models_data): """Print a breakdown of printer models with their replacement hardware""" print(f"\n{'='*70}") print("PRINTER MODELS AND REPLACEMENT HARDWARE") print(f"{'='*70}\n") # Dictionary to track which models use which supplies supply_to_models = {} for model_name in sorted(models_data.keys()): supplies = models_data[model_name] print(f"\nModel: {model_name}") print("-" * 70) # Categorize supplies toner_cartridges = [] drums = [] fusers = [] waste_toners = [] transfer_items = [] maintenance_kits = [] other_items = [] # Filter out generic Xerox entries when we have specific PNs filtered_supplies = filter_generic_supplies(set(supplies)) for supply_desc in sorted(filtered_supplies): supply_lower = supply_desc.lower() # Track which models use this supply if supply_desc not in supply_to_models: supply_to_models[supply_desc] = [] if model_name not in supply_to_models[supply_desc]: supply_to_models[supply_desc].append(model_name) if 'toner' in supply_lower and 'waste' not in supply_lower: toner_cartridges.append(supply_desc) elif 'drum' in supply_lower: drums.append(supply_desc) elif 'fuser' in supply_lower: fusers.append(supply_desc) elif 'waste' in supply_lower: waste_toners.append(supply_desc) elif 'transfer' in supply_lower or 'belt' in supply_lower: transfer_items.append(supply_desc) elif 'maintenance' in supply_lower or 'kit' in supply_lower: maintenance_kits.append(supply_desc) else: other_items.append(supply_desc) # Print categorized items if toner_cartridges: print(" Toner Cartridges:") for item in sorted(toner_cartridges): print(f" - {item}") if drums: print(" Drums:") for item in sorted(drums): print(f" - {item}") if fusers: print(" Fusers:") for item in sorted(fusers): print(f" - {item}") if waste_toners: print(" Waste Toner:") for item in sorted(waste_toners): print(f" - {item}") if transfer_items: print(" Transfer/Belt:") for item in sorted(transfer_items): print(f" - {item}") if maintenance_kits: print(" Maintenance Kits:") for item in sorted(maintenance_kits): print(f" - {item}") if other_items: print(" Other:") for item in sorted(other_items): print(f" - {item}") # Print consolidated summary print(f"\n{'='*70}") print("CONSOLIDATED SUPPLY SUMMARY") print(f"{'='*70}\n") print("Supplies grouped by part (showing which models use each):\n") # Categorize all supplies globally categories = { 'Toner Cartridges': [], 'Drums': [], 'Fusers': [], 'Waste Toner': [], 'Transfer/Belt': [], 'Maintenance Kits': [], 'Other': [] } for supply_desc, model_list in supply_to_models.items(): supply_lower = supply_desc.lower() if 'toner' in supply_lower and 'waste' not in supply_lower: categories['Toner Cartridges'].append((supply_desc, model_list)) elif 'drum' in supply_lower: categories['Drums'].append((supply_desc, model_list)) elif 'fuser' in supply_lower: categories['Fusers'].append((supply_desc, model_list)) elif 'waste' in supply_lower: categories['Waste Toner'].append((supply_desc, model_list)) elif 'transfer' in supply_lower or 'belt' in supply_lower: categories['Transfer/Belt'].append((supply_desc, model_list)) elif 'maintenance' in supply_lower or 'kit' in supply_lower: categories['Maintenance Kits'].append((supply_desc, model_list)) else: categories['Other'].append((supply_desc, model_list)) # Print each category for category_name, items in categories.items(): if items: print(f"{category_name}:") for supply_desc, model_list in sorted(items): # Show models using this supply if len(model_list) > 1: model_str = f" (used by {len(model_list)} models)" else: model_str = f" (used by {model_list[0]})" print(f" - {supply_desc}{model_str}") print() print(f"{'='*70}\n") def generate_html_report(models_data, all_printers_data): """Generate a professional HTML report""" # Track supplies that need replacement (below 10% or not installed) low_supplies = [] for printer_ip, data in all_printers_data.items(): # Generate FQDN for this printer fqdn = get_fqdn_from_ip(printer_ip) # Check toner levels - map by part number for DesignJets, use index for standard printers idx_to_color = {} if data.get('is_large_format', False): # Try to map by HP 730 part numbers for idx in data.get('toner_levels', {}).keys(): if idx in data.get('supplies', {}): desc = data['supplies'][idx].get('description', '') color = map_hp730_color(desc) if color: idx_to_color[idx] = color # Fallback if mapping failed if not idx_to_color: idx_to_color = {'1': 'Cartridge 1', '2': 'Cartridge 2', '3': 'Cartridge 3', '4': 'Cartridge 4', '5': 'Cartridge 5', '6': 'Cartridge 6'} else: idx_to_color = {'1': 'Black', '2': 'Cyan', '3': 'Magenta', '4': 'Yellow'} for idx, color_name in idx_to_color.items(): # Check if this toner exists in the data if idx in data.get('toner_levels', {}): display = data['toner_levels'][idx] percentage = data.get('toner_percentages', {}).get(idx) # Get the cartridge description cartridge_desc = "Unknown Cartridge" if idx in data.get('supplies', {}): desc = data['supplies'][idx].get('description', '') desc_lower = desc.lower() # Skip if it's not a toner cartridge if any(keyword in desc_lower for keyword in ['maintenance kit', 'fuser', 'transfer', 'drum cartridge kit']): continue # Normalize the cartridge description to remove serial numbers cartridge_desc = normalize_supply_description(desc) # Add to low supplies if percentage <= 10% or not installed if percentage is not None and percentage <= 10: low_supplies.append({ 'printer_ip': printer_ip, 'fqdn': fqdn, 'hostname': data.get('hostname', 'Unknown'), 'model': data.get('normalized_model', data.get('model', 'Unknown')), 'color': color_name, 'percentage': percentage, 'cartridge': cartridge_desc, 'status': 'LOW' }) elif 'Not installed' in display or 'not installed' in display.lower(): low_supplies.append({ 'printer_ip': printer_ip, 'fqdn': fqdn, 'hostname': data.get('hostname', 'Unknown'), 'model': data.get('normalized_model', data.get('model', 'Unknown')), 'color': color_name, 'percentage': 0, 'cartridge': cartridge_desc, 'status': 'MISSING' }) # Build supply to models mapping supply_to_models = {} for model_name, supplies in models_data.items(): for supply_desc in set(supplies): if supply_desc not in supply_to_models: supply_to_models[supply_desc] = [] if model_name not in supply_to_models[supply_desc]: supply_to_models[supply_desc].append(model_name) # Categorize supplies for consolidated summary categories = { 'Toner Cartridges': [], 'Drums': [], 'Fusers': [], 'Waste Toner': [], 'Transfer/Belt': [], 'Maintenance Kits': [], 'Other': [] } for supply_desc, model_list in supply_to_models.items(): supply_lower = supply_desc.lower() if 'toner' in supply_lower and 'waste' not in supply_lower: categories['Toner Cartridges'].append((supply_desc, model_list)) elif 'drum' in supply_lower: categories['Drums'].append((supply_desc, model_list)) elif 'fuser' in supply_lower: categories['Fusers'].append((supply_desc, model_list)) elif 'waste' in supply_lower: categories['Waste Toner'].append((supply_desc, model_list)) elif 'transfer' in supply_lower or 'belt' in supply_lower: categories['Transfer/Belt'].append((supply_desc, model_list)) elif 'maintenance' in supply_lower or 'kit' in supply_lower: categories['Maintenance Kits'].append((supply_desc, model_list)) else: categories['Other'].append((supply_desc, model_list)) # Read favicon and convert to base64 import base64 try: with open('output/favicon.ico', 'rb') as f: favicon_base64 = base64.b64encode(f.read()).decode('utf-8') favicon_data_uri = f"data:image/x-icon;base64,{favicon_base64}" except: favicon_data_uri = "" # Embedded SVG logos logo_svg = ''' ''' dark_logo_svg = ''' ''' html = f""" Printer Inventory Report - West Jefferson Facility

Printer Inventory Report

West Jefferson Facility
Generated on
{datetime.now().strftime('%B %d, %Y at %I:%M %p')}
""" if low_supplies: html += f"""

Supplies Requiring Immediate Replacement ({len(low_supplies)} items)

""" for item in sorted(low_supplies, key=lambda x: (0 if x['status'] == 'MISSING' else 1, x['percentage'])): if item['status'] == 'MISSING': status_text = f"{item['color']} Toner: NOT INSTALLED" alert_label = "MISSING CARTRIDGE - INSTALL NOW" else: status_text = f"{item['color']} Toner: {item['percentage']}% remaining" alert_label = "CRITICAL - ORDER NOW" html += f"""
{item['fqdn']}
{status_text}
Part: {item['cartridge'][:80]}
{alert_label}
""" html += """
""" else: html += """

Supply Status

All printer supplies are above 10% - No immediate replacements needed
""" html += """

Individual Printer Status

""" # Add each printer for printer_ip, data in sorted(all_printers_data.items()): printer_type = 'Color' if data.get('is_color') else 'Monochrome' badge_class = 'badge-color' if data.get('is_color') else 'badge-mono' fqdn = get_fqdn_from_ip(printer_ip) html += f"""

{fqdn}

IP Address: {printer_ip}
Model: {data.get('normalized_model', data.get('model', 'Unknown'))} {printer_type}
Serial: {data.get('serial', 'Unknown')}
""" # Add toner levels if data.get('toner_levels'): # Use "Ink Levels" for large format printers supply_label = "Ink Levels" if data.get('is_large_format', False) else "Toner Levels" html += f"""

{supply_label}

""" # Map by part number for DesignJets, use index for standard printers idx_to_color = {} if data.get('is_large_format', False): # Try to map by HP 730 part numbers for idx in data.get('toner_levels', {}).keys(): if idx in data.get('supplies', {}): desc = data['supplies'][idx].get('description', '') color = map_hp730_color(desc) if color: idx_to_color[idx] = color # Fallback if mapping failed if not idx_to_color: idx_to_color = {'1': 'Cartridge 1', '2': 'Cartridge 2', '3': 'Cartridge 3', '4': 'Cartridge 4', '5': 'Cartridge 5', '6': 'Cartridge 6'} else: idx_to_color = {'1': 'Black', '2': 'Cyan', '3': 'Magenta', '4': 'Yellow'} # Define display order for colors color_order = ['Matte Black', 'Photo Black', 'Cyan', 'Magenta', 'Yellow', 'Gray', 'Black'] # Sort indices by color order sorted_indices = sorted( [idx for idx in data['toner_levels'].keys() if idx in idx_to_color], key=lambda x: color_order.index(idx_to_color[x]) if idx_to_color[x] in color_order else 999 ) for idx in sorted_indices: color = idx_to_color.get(idx, f'Index {idx}') if idx in data['toner_levels']: # Check if this index actually contains toner/cartridge, not maintenance kit if idx in data.get('supplies', {}): desc = data['supplies'][idx].get('description', '') desc_lower = desc.lower() # Skip if it's clearly not a toner cartridge if any(keyword in desc_lower for keyword in ['maintenance kit', 'fuser', 'transfer', 'drum cartridge kit']): continue display = data['toner_levels'][idx] percentage = data.get('toner_percentages', {}).get(idx) # Determine progress bar class if percentage is not None: if percentage < 20: progress_class = 'progress-low' elif percentage < 50: progress_class = 'progress-medium' else: progress_class = 'progress-high' width = max(percentage, 5) # Minimum width for visibility else: progress_class = '' width = 0 html += f"""
{color}
""" if percentage is not None: html += f"""
{percentage}%
""" else: html += f""" {display} """ html += """
""" # Add cartridge info if idx in data.get('supplies', {}): desc = data['supplies'][idx].get('description', '') if desc: # Replace generic Xerox PNs with known specific ones (before normalizing) model_name = data.get('normalized_model', data.get('model', '')) enriched_desc = replace_generic_xerox_pn(desc, model_name) # Normalize supply description normalized_desc = normalize_supply_description(enriched_desc) html += f"""
Part: {normalized_desc[:80]}
""" html += """
""" html += """
""" # Add other supplies if data.get('supplies'): other_supplies = [] # Exclude main ink/toner cartridges (1-6 for DesignJets, 1-4 for standard printers) excluded_indices = ['1', '2', '3', '4', '5', '6'] if data.get('is_large_format', False) else ['1', '2', '3', '4'] for idx, supply in data['supplies'].items(): if idx not in excluded_indices: desc = supply.get('description', '') if desc: keywords = ['fuser', 'drum', 'waste', 'transfer', 'belt', 'roller', 'kit'] if any(keyword in desc.lower() for keyword in keywords): # Fix unknown part numbers with known ones based on model desc = fix_unknown_part_numbers(desc, data['model']) # Normalize supply description to remove serial numbers normalized_desc = normalize_supply_description(desc) other_supplies.append(normalized_desc) if other_supplies: html += """

Other Supplies

""" for supply in sorted(set(other_supplies)): html += f"""
{supply[:90]}
""" html += """
""" html += """
""" html += """

Replacement Parts by Printer Model

""" # Add each model for model_name in sorted(models_data.keys()): supplies = models_data[model_name] # Categorize supplies categorized = { 'Toner Cartridges': [], 'Drums': [], 'Fusers': [], 'Waste Toner': [], 'Transfer/Belt': [], 'Maintenance Kits': [], 'Other': [] } # Filter out generic Xerox entries when we have specific PNs filtered_supplies = filter_generic_supplies(set(supplies)) for supply_desc in sorted(filtered_supplies): supply_lower = supply_desc.lower() if 'toner' in supply_lower and 'waste' not in supply_lower: categorized['Toner Cartridges'].append(supply_desc) elif 'drum' in supply_lower: categorized['Drums'].append(supply_desc) elif 'fuser' in supply_lower: categorized['Fusers'].append(supply_desc) elif 'waste' in supply_lower: categorized['Waste Toner'].append(supply_desc) elif 'transfer' in supply_lower or 'belt' in supply_lower: categorized['Transfer/Belt'].append(supply_desc) elif 'maintenance' in supply_lower or 'kit' in supply_lower: categorized['Maintenance Kits'].append(supply_desc) else: categorized['Other'].append(supply_desc) html += f"""

{model_name}

""" for category, items in categorized.items(): if items: html += f"""

{category}

    """ for item in sorted(items): html += f"""
  • {item}
  • """ html += """
""" html += """
""" html += """

Supply Inventory - Consolidated View

""" for category_name, items in categories.items(): if items: html += f"""

{category_name}

""" for supply_desc, model_list in sorted(items): model_count = len(model_list) if model_count > 1: count_text = f"{model_count} models" else: count_text = model_list[0][:40] html += f"""
{supply_desc}
{count_text}
""" html += """
""" html += """

Supply Compatibility & Consolidation Opportunities

Based on manufacturer specifications and cross-reference research, these printer models share compatible supplies. Consolidating inventory around these groups can reduce SKU count and simplify purchasing.

""" # Check which models from compatibility groups are actually in our inventory inventory_models = set(models_data.keys()) # Group compatibility items by priority priority_order = ['Critical', 'High', 'Medium'] for priority in priority_order: groups_in_priority = [g for g in COMPATIBILITY_GROUPS if g['priority'] == priority] for group in groups_in_priority: # Check which models from this group are in our inventory models_in_inventory = [m for m in group['models'] if m in inventory_models] priority_class = group['priority'].lower() html += f"""

{group['name']}

{group['priority']} Value
Compatible Models:
    """ for model in group['models']: in_inventory = ' โœ“ In Inventory' if model in inventory_models else '' html += f"""
  • {model}{in_inventory}
  • """ html += """
Shared Supplies: """ for supply in group['shared_supplies']: html += f"""
{supply['type']}
    """ for part in supply['parts']: html += f"""
  • {part}
  • """ html += """
""" html += f"""
Consolidation Benefit: {group['benefit']}
""" # Show which printers in our inventory match this group if models_in_inventory: html += f"""
Printers in Your Inventory ({len(models_in_inventory)} model{'s' if len(models_in_inventory) > 1 else ''}):
    """ for model in models_in_inventory: # Count how many printers of this model we have printer_count = sum(1 for p_data in all_printers_data.values() if p_data.get('normalized_model') == model) html += f"""
  • {model} ({printer_count} printer{'s' if printer_count > 1 else ''})
  • """ html += """
""" html += """
""" html += """
""" return html def main(): """Main function to extract summaries from all printer CSVs""" output_dir = "output" if not os.path.exists(output_dir): print(f"Error: Output directory '{output_dir}' not found") print("Please run snmp_scanner.py first") sys.exit(1) csv_files = list(Path(output_dir).glob("printer-*.csv")) if not csv_files: print(f"No CSV files found in {output_dir}/") sys.exit(1) print(f"Found {len(csv_files)} printer scan files") print("\n" + "="*70) print("PRINTER SNMP DATA SUMMARY") print("="*70) # Dictionary to collect models and their supplies models_data = {} all_printers_data = {} for csv_file in sorted(csv_files): # Extract IP from filename filename = csv_file.stem ip_match = re.search(r'printer-(\d+)-(\d+)-(\d+)-(\d+)', filename) if ip_match: printer_ip = '.'.join(ip_match.groups()) print_printer_summary(printer_ip, csv_file) # Extract data for model summary data = extract_key_data(csv_file) model = data['model'] normalized_model = normalize_model_name(model) # Store printer data with both original and normalized model data['normalized_model'] = normalized_model all_printers_data[printer_ip] = data if normalized_model not in models_data: models_data[normalized_model] = [] # Add all supplies for this model for idx, supply in data['supplies'].items(): desc = supply.get('description', '') if desc: # Fix unknown part numbers with known ones based on model desc = fix_unknown_part_numbers(desc, data['model']) # Replace generic Xerox PNs with known specific ones (before normalizing) enriched_desc = replace_generic_xerox_pn(desc, normalized_model) # Normalize supply description to remove serial numbers normalized_desc = normalize_supply_description(enriched_desc) models_data[normalized_model].append(normalized_desc) # Add maintenance kit info if available if data['maintenance_kit']: if 'model' in data['maintenance_kit']: models_data[normalized_model].append(f"Maintenance Kit: {data['maintenance_kit']['model']}") print(f"\n{'='*70}") print("Summary complete!") print(f"{'='*70}\n") # Print models with their supplies print_models_with_supplies(models_data) # Generate HTML report html_content = generate_html_report(models_data, all_printers_data) html_file = "output/printer_report.html" with open(html_file, 'w', encoding='utf-8') as f: f.write(html_content) print(f"\n{'='*70}") print(f"HTML report generated: {html_file}") print(f"Self-contained report with embedded logos and assets") print(f"{'='*70}\n") if __name__ == "__main__": main()