#!/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, printer_model=''):
"""
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:,})")
# Fallback: Use known static max capacities for specific printer models
# This handles cases where the max OID isn't scanned
model_lower = printer_model.lower()
known_max = None
if 'laserjet 4250' in model_lower:
known_max = 20000 # HP LaserJet 4250 (Q5942X cartridge)
elif 'laserjet 4200' in model_lower:
known_max = 12000 # HP LaserJet 4200 (Q1338A cartridge)
elif 'laserjet 4300' in model_lower:
known_max = 18000 # HP LaserJet 4300 (Q1339A cartridge)
elif 'laserjet 4350' in model_lower:
known_max = 20000 # HP LaserJet 4350 (Q5942X cartridge)
if known_max and toner_value > 100: # Only use if value is absolute pages
percentage = int((toner_value / known_max) * 100)
return (percentage, f"{percentage}% ({toner_value:,}/{known_max:,})")
# 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) - exact OID match only to avoid false matches
elif oid == '1.3.6.1.4.1.11.2.3.9.4.2.1.1.3.3.0':
# Model/Serial
if value and value not in ['0', '', 'N/A']:
data['maintenance_kit']['model'] = clean_value(value)
elif oid == '1.3.6.1.4.1.11.2.3.9.4.2.1.1.3.2.0':
# Printer Model
if value and value not in ['0', '', 'N/A']:
data['maintenance_kit']['printer_model'] = clean_value(value)
elif oid == '1.3.6.1.4.1.11.2.3.9.4.2.1.4.1.2.0':
# Maintenance kit remaining pages
if value and value not in ['0', '', 'N/A', '-1', '-2', '-3']:
try:
data['maintenance_kit']['remaining'] = int(value)
except:
pass
elif oid == '1.3.6.1.4.1.11.2.3.9.4.2.1.1.3.1.0':
# Part Number
if value and value not in ['0', '', 'N/A']:
data['maintenance_kit']['part_number'] = clean_value(value)
# Standard Printer MIB - Maintenance Kit (Index 2)
# Only for HP printers - Xerox uses index 2 for toner cartridges
elif oid == '1.3.6.1.2.1.43.11.1.1.6.1.2':
# Part Number (Standard MIB)
if value and value not in ['0', '', 'N/A']:
# Check if this looks like a maintenance kit (not toner)
value_lower = value.lower()
is_maintenance = 'maintenance kit' in value_lower or 'maintenance-kit' in value_lower
# Store the standard MIB part description to verify this is actually a maintenance kit
if is_maintenance:
data['maintenance_kit']['standard_part_desc'] = value
# Only set part_number if we don't already have one from HP proprietary MIB
if 'part_number' not in data['maintenance_kit']:
data['maintenance_kit']['part_number'] = clean_value(value)
elif oid == '1.3.6.1.2.1.43.11.1.1.9.1.2':
# Current level (Standard MIB)
# Only collect if we already identified this as a maintenance kit (not toner)
if value and value not in ['0', '', 'N/A', '-1', '-2', '-3']:
# Only store if we have a maintenance kit part_number already set from this index
# This prevents storing toner levels as maintenance kit levels
try:
# Store temporarily, will validate later
data['maintenance_kit']['standard_current_temp'] = int(value)
except:
pass
elif oid == '1.3.6.1.2.1.43.11.1.1.8.1.2':
# Max capacity (Standard MIB)
if value and value not in ['0', '', 'N/A', '-1', '-2', '-3']:
try:
# Store temporarily, will validate later
data['maintenance_kit']['standard_max_temp'] = int(value)
except:
pass
# 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'])
# Fix Xerox VersaLink color mapping issue
# Some Xerox VersaLink printers report cyan/yellow descriptions AND levels swapped at indices 2 and 4
# Affects: C7125, C405, C400, and potentially other VersaLink color models
versalink_swap_needed = False
if 'versalink' in model_lower and ('c7125' in model_lower or 'c405' in model_lower or 'c400' in model_lower):
if '2' in supplies and '4' in supplies:
desc2 = supplies['2'].get('description', '').lower()
desc4 = supplies['4'].get('description', '').lower()
# If index 2 says "yellow" and index 4 says "cyan", they need to be swapped
if 'yellow' in desc2 and 'cyan' in desc4:
versalink_swap_needed = True
supplies['2'], supplies['4'] = supplies['4'], supplies['2']
# Finalize Standard MIB maintenance kit data
# Only promote temp values if we verified index 2 is actually a maintenance kit
if 'standard_part_desc' in data['maintenance_kit']:
# We found "Maintenance Kit" at index 2, so the temp values are valid
if 'standard_current_temp' in data['maintenance_kit']:
data['maintenance_kit']['standard_current'] = data['maintenance_kit'].pop('standard_current_temp')
if 'standard_max_temp' in data['maintenance_kit']:
data['maintenance_kit']['standard_max'] = data['maintenance_kit'].pop('standard_max_temp')
# Clean up the verification field
data['maintenance_kit'].pop('standard_part_desc', None)
else:
# Index 2 is not a maintenance kit (probably toner) - discard temp values
data['maintenance_kit'].pop('standard_current_temp', None)
data['maintenance_kit'].pop('standard_max_temp', None)
# 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['model'])
data['toner_levels'][idx] = display
data['toner_percentages'][idx] = percentage
# Fix Xerox VersaLink toner level data swap (must happen AFTER percentage calculation)
# These printers report cyan/yellow LEVELS at swapped indices (not just descriptions)
if versalink_swap_needed:
# Swap the calculated toner levels and percentages
if '2' in data['toner_levels'] and '4' in data['toner_levels']:
data['toner_levels']['2'], data['toner_levels']['4'] = data['toner_levels']['4'], data['toner_levels']['2']
if '2' in data['toner_percentages'] and '4' in data['toner_percentages']:
data['toner_percentages']['2'], data['toner_percentages']['4'] = data['toner_percentages']['4'], data['toner_percentages']['2']
# 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')}
"""
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"""
"""
# 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 += """
"""
# Add maintenance kit info if available
if data.get('maintenance_kit'):
maint = data['maintenance_kit']
if maint: # Only display if there's actually data
html += """
Maintenance Kit
"""
# Standard MIB percentage (for HP 4250, 4350 and similar older models)
percentage = None
current = None
maximum = None
if 'standard_current' in maint and 'standard_max' in maint:
try:
current = maint['standard_current']
maximum = maint['standard_max']
if maximum > 0:
percentage = int((current / maximum) * 100)
except:
pass
if percentage is not None:
# Use progress bar for Standard MIB maintenance kit
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
html += f"""
Level
{percentage}%
"""
if 'part_number' in maint:
html += f"""
Cartridge: {maint['part_number']}
"""
html += """
"""
else:
# Fallback to old format for other maintenance kit info
html += """
"""
if 'part_number' in maint:
html += f"""
Part Number: {maint['part_number']}
"""
if 'printer_model' in maint:
html += f"""
Printer Model: {maint['printer_model']}
"""
# HP proprietary MIB remaining pages (for newer HP models like M404, M607)
if 'remaining' in maint:
# Format remaining pages
remaining = maint['remaining']
if isinstance(remaining, int):
html += f"""
Remaining: {remaining:,} pages
"""
else:
html += f"""
Remaining: {remaining}
"""
html += """
"""
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"""
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)
timestamp = datetime.now().strftime('%Y-%m-%d_%H-%M-%S')
html_file = f"output/printer_report_{timestamp}.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()