diff --git a/man/ipmitool.py.1 b/man/ipmitool.py.1 index 76aa53d..5e1fe7b 100644 --- a/man/ipmitool.py.1 +++ b/man/ipmitool.py.1 @@ -1,4 +1,4 @@ -.TH IPMITOOL.PY "1" "2026\-10\-08" "python\-ipmi" "python\-ipmi manual" +.TH IPMITOOL.PY "1" "2026\-10\-09" "python\-ipmi" "python\-ipmi manual" .SH NAME ipmitool.py \- pure python IPMI tool .SH SYNOPSIS @@ -319,10 +319,19 @@ List all SEL entries Clear SEL .SH COMMAND \fI\,'ipmitool.py sel list'\/\fR -usage: ipmitool.py sel list [\-h] +usage: ipmitool.py sel list [\-h] [\-d] [\-s] List all SEL entries +.SH OPTIONS \fI\,'ipmitool.py sel list'\/\fR +.TP +\fB\-d\fR, \fB\-\-details\fR +print all fields of the entries + +.TP +\fB\-s\fR, \fB\-\-sdr\fR +read the SDRs to print the sensor names and the converted values of threshold events + .SH COMMAND \fI\,'ipmitool.py sel clear'\/\fR usage: ipmitool.py sel clear [\-h] diff --git a/pyipmi/ipmitool.py b/pyipmi/ipmitool.py index fa8777a..85357e2 100755 --- a/pyipmi/ipmitool.py +++ b/pyipmi/ipmitool.py @@ -127,9 +127,120 @@ def cmd_bmc_reset(ipmi: pyipmi.Ipmi, args: argparse.Namespace) -> None: ipmi.warm_reset() +def sel_sensor_records(ipmi: pyipmi.Ipmi) -> dict[tuple[int, int, int], + pyipmi.sdr.SdrCommon]: + """Return the sensor records by owner ID, owner LUN and sensor number. + + The records are read from the SDR repository, or from the device SDR + repository of a device without SDR repository. + """ + device_id = ipmi.get_device_id() + if device_id.supports_function('sdr_repository'): + records = ipmi.sdr_repository_entries() + elif device_id.supports_function('sensor'): + records = ipmi.device_sdr_entries() + else: + return {} + return {(s.owner_id, s.owner_lun, s.number): s for s in records + if hasattr(s, 'number') and hasattr(s, 'owner_id')} + + +def sel_entry_sensor(entry: pyipmi.sel.SelEntry, + records: dict[tuple[int, int, int], + pyipmi.sdr.SdrCommon] + ) -> pyipmi.sdr.SdrCommon | None: + """Return the sensor record of the sensor that generated an event.""" + owner_id = entry.generator_id & 0xff + owner_lun = (entry.generator_id >> 8) & 0x3 + return records.get((owner_id, owner_lun, entry.sensor_number)) + + +def sel_entry_values(entry: pyipmi.sel.SelEntry, + record: pyipmi.sdr.SdrCommon | None) -> str | None: + """Return the trigger reading and threshold of a threshold event. + + The raw values are converted with the full sensor record, if given. + """ + (reading, threshold) = entry.threshold_event_values() + if reading is None and threshold is None: + return None + + def convert(raw: int) -> str: + if isinstance(record, pyipmi.sdr.SdrFullSensorRecord): + return format_analog_value(record.convert_sensor_raw_to_value(raw)) + return f'0x{raw:02x}' + + values = [] + if reading is not None: + values.append(f'Reading {convert(reading)}') + if threshold is not None: + values.append(f'Threshold {convert(threshold)}') + return ', '.join(values) + + +def sel_entry_sensor_name(entry: pyipmi.sel.SelEntry, + record: pyipmi.sdr.SdrCommon | None) -> str: + sensor_type = entry.sensor_type_to_string(entry.sensor_type) + name = getattr(record, 'device_id_string', None) + if name: + return f'{sensor_type} {name}' + return f'{sensor_type} #0x{entry.sensor_number:02x}' + + +def print_sel_entry(entry: pyipmi.sel.SelEntry, + record: pyipmi.sdr.SdrCommon | None) -> None: + columns = [f'0x{entry.record_id:04x}', f'{entry.timestamp_to_string():<19}'] + if entry.type == pyipmi.sel.SelEntry.TYPE_SYSTEM_EVENT: + columns.append(sel_entry_sensor_name(entry, record)) + columns.append(entry.event_to_string()) + columns.append(entry.direction_to_string()) + values = sel_entry_values(entry, record) + if values is not None: + columns.append(values) + else: + # the bytes after the timestamp (or the record type) are OEM data + start = 7 if entry.is_timestamped() else 3 + columns.append(pyipmi.sel.SelEntry.type_to_string(entry.type) or '') + columns.append(' '.join(f'{b:02x}' for b in entry.data[start:])) + print(' | '.join(columns)) + + +def print_sel_entry_details(entry: pyipmi.sel.SelEntry, + record: pyipmi.sdr.SdrCommon | None) -> None: + raw = ' '.join(f'{b:02x}' for b in entry.data) + print(f'SEL Record ID: 0x{entry.record_id:04x}') + print(f'Record Type: {pyipmi.sel.SelEntry.type_to_string(entry.type)}') + print(f'Timestamp: {entry.timestamp_to_string()}') + if entry.type == pyipmi.sel.SelEntry.TYPE_SYSTEM_EVENT: + print(f'Generator ID: 0x{entry.generator_id:04x} ' + f'({entry.generator_to_string()})') + print(f'EvM Revision: 0x{entry.evm_rev:02x}') + print(f'Sensor Type: 0x{entry.sensor_type:02x} ' + f'({entry.sensor_type_to_string(entry.sensor_type)})') + print(f'Sensor Number: 0x{entry.sensor_number:02x}') + name = getattr(record, 'device_id_string', None) + if name: + print(f'Sensor Name: {name}') + print(f'Event Type: 0x{entry.event_type:02x}') + print(f'Event Direction: {entry.direction_to_string()}') + print(f'Event Data: ' + f"{' '.join(f'{b:02x}' for b in entry.event_data)}") + print(f'Description: {entry.event_to_string()}') + values = sel_entry_values(entry, record) + if values is not None: + print(f'Values: {values}') + print(f'Raw Data: {raw}') + print() + + def cmd_sel_list(ipmi: pyipmi.Ipmi, args: argparse.Namespace) -> None: + records = sel_sensor_records(ipmi) if args.sdr else {} + print_entry = print_sel_entry_details if args.details else print_sel_entry for entry in ipmi.sel_entries(): - print(entry) + record = None + if entry.type == pyipmi.sel.SelEntry.TYPE_SYSTEM_EVENT: + record = sel_entry_sensor(entry, records) + print_entry(entry, record) def cmd_sel_clear(ipmi: pyipmi.Ipmi, args: argparse.Namespace) -> None: @@ -1212,7 +1323,12 @@ def build_parser() -> argparse.ArgumentParser: group.command('showall', cmd_sdr_show_all, 'Show detail for all SDRs') group = commands.group('sel', 'Print System Event Log (SEL)') - group.command('list', cmd_sel_list, 'List all SEL entries') + p = group.command('list', cmd_sel_list, 'List all SEL entries') + p.add_argument('-d', '--details', action='store_true', + help='print all fields of the entries') + p.add_argument('-s', '--sdr', action='store_true', + help='read the SDRs to print the sensor names and the ' + 'converted values of threshold events') group.command('clear', cmd_sel_clear, 'Clear SEL') group = commands.group('sensor', 'Sensor commands') diff --git a/pyipmi/sel.py b/pyipmi/sel.py index 0d49c41..8038354 100644 --- a/pyipmi/sel.py +++ b/pyipmi/sel.py @@ -36,18 +36,374 @@ from array import array from collections.abc import Generator +from datetime import datetime, timezone from .errors import CompletionCodeError, DecodingError from .utils import check_completion_code, ByteBuffer, ByteSequence from .msgs import create_request_by_name, Message from .msgs import constants from .event import EVENT_ASSERTION, EVENT_DEASSERTION +from .sensor import (EVENT_READING_TYPE_CODE_THRESHOLD, + EVENT_READING_TYPE_SENSOR_SPECIFIC) from .helper import clear_repository_helper from .state import State from .mixin import IpmiMixin +# Names of the sensor types (IPMI v2.0 table 42-3, PICMG 3.0 table 3-3) +SENSOR_TYPE_NAMES = { + 0x01: 'Temperature', + 0x02: 'Voltage', + 0x03: 'Current', + 0x04: 'Fan', + 0x05: 'Physical Security', + 0x06: 'Platform Security', + 0x07: 'Processor', + 0x08: 'Power Supply', + 0x09: 'Power Unit', + 0x0a: 'Cooling Device', + 0x0b: 'Other Units-based Sensor', + 0x0c: 'Memory', + 0x0d: 'Drive Slot / Bay', + 0x0e: 'POST Memory Resize', + 0x0f: 'System Firmware Progress', + 0x10: 'Event Logging Disabled', + 0x11: 'Watchdog 1', + 0x12: 'System Event', + 0x13: 'Critical Interrupt', + 0x14: 'Button / Switch', + 0x15: 'Module / Board', + 0x16: 'Microcontroller / Coprocessor', + 0x17: 'Add-in Card', + 0x18: 'Chassis', + 0x19: 'Chip Set', + 0x1a: 'Other FRU', + 0x1b: 'Cable / Interconnect', + 0x1c: 'Terminator', + 0x1d: 'System Boot / Restart Initiated', + 0x1e: 'Boot Error', + 0x1f: 'Base OS Boot / Installation Status', + 0x20: 'OS Stop / Shutdown', + 0x21: 'Slot / Connector', + 0x22: 'System ACPI Power State', + 0x23: 'Watchdog 2', + 0x24: 'Platform Alert', + 0x25: 'Entity Presence', + 0x26: 'Monitor ASIC / IC', + 0x27: 'LAN', + 0x28: 'Management Subsystem Health', + 0x29: 'Battery', + 0x2a: 'Session Audit', + 0x2b: 'Version Change', + 0x2c: 'FRU State', + 0xf0: 'FRU Hot Swap', + 0xf1: 'IPMB Physical Link', + 0xf2: 'Module Hot Swap', + 0xf3: 'Power Channel Notification', + 0xf4: 'Telco Alarm Input', +} + +# The event offsets of the generic event/reading types (IPMI v2.0 table +# 42-2), by event/reading type +GENERIC_EVENT_STRINGS = { + 0x01: ('Lower Non-critical going low', + 'Lower Non-critical going high', + 'Lower Critical going low', + 'Lower Critical going high', + 'Lower Non-recoverable going low', + 'Lower Non-recoverable going high', + 'Upper Non-critical going low', + 'Upper Non-critical going high', + 'Upper Critical going low', + 'Upper Critical going high', + 'Upper Non-recoverable going low', + 'Upper Non-recoverable going high'), + 0x02: ('Transition to Idle', + 'Transition to Active', + 'Transition to Busy'), + 0x03: ('State Deasserted', + 'State Asserted'), + 0x04: ('Predictive Failure deasserted', + 'Predictive Failure asserted'), + 0x05: ('Limit Not Exceeded', + 'Limit Exceeded'), + 0x06: ('Performance Met', + 'Performance Lags'), + 0x07: ('Transition to OK', + 'Transition to Non-Critical from OK', + 'Transition to Critical from less severe', + 'Transition to Non-recoverable from less severe', + 'Transition to Non-Critical from more severe', + 'Transition to Critical from Non-recoverable', + 'Transition to Non-recoverable', + 'Monitor', + 'Informational'), + 0x08: ('Device Absent', + 'Device Present'), + 0x09: ('Device Disabled', + 'Device Enabled'), + 0x0a: ('Transition to Running', + 'Transition to In Test', + 'Transition to Power Off', + 'Transition to On Line', + 'Transition to Off Line', + 'Transition to Off Duty', + 'Transition to Degraded', + 'Transition to Power Save', + 'Install Error'), + 0x0b: ('Fully Redundant', + 'Redundancy Lost', + 'Redundancy Degraded', + 'Non-redundant: Sufficient Resources from Redundant', + 'Non-redundant: Sufficient Resources from Insufficient Resources', + 'Non-redundant: Insufficient Resources', + 'Redundancy Degraded from Fully Redundant', + 'Redundancy Degraded from Non-redundant'), + 0x0c: ('D0 Power State', + 'D1 Power State', + 'D2 Power State', + 'D3 Power State'), +} + +_FRU_STATES = ('M0 - FRU Not Installed', + 'M1 - FRU Inactive', + 'M2 - FRU Activation Request', + 'M3 - FRU Activation In Progress', + 'M4 - FRU Active', + 'M5 - FRU Deactivation Request', + 'M6 - FRU Deactivation In Progress', + 'M7 - FRU Communication Lost') + +# The event offsets of the sensor-specific event/reading type 0x6f (IPMI +# v2.0 table 42-3, PICMG 3.0 table 3-3), by sensor type. None marks a +# reserved offset. +SENSOR_SPECIFIC_EVENT_STRINGS = { + 0x05: ('General Chassis Intrusion', + 'Drive Bay Intrusion', + 'I/O Card Area Intrusion', + 'Processor Area Intrusion', + 'LAN Leash Lost', + 'Unauthorized Dock', + 'Fan Area Intrusion'), + 0x06: ('Secure Mode Violation Attempt', + 'Pre-boot Password Violation - User Password', + 'Pre-boot Password Violation - Setup Password', + 'Pre-boot Password Violation - Network Boot Password', + 'Other Pre-boot Password Violation', + 'Out-of-band Access Password Violation'), + 0x07: ('IERR', + 'Thermal Trip', + 'FRB1/BIST Failure', + 'FRB2/Hang in POST Failure', + 'FRB3/Processor Startup/Initialization Failure', + 'Configuration Error', + 'SM BIOS Uncorrectable CPU-complex Error', + 'Processor Presence Detected', + 'Processor Disabled', + 'Terminator Presence Detected', + 'Processor Automatically Throttled', + 'Machine Check Exception (Uncorrectable)', + 'Correctable Machine Check Error'), + 0x08: ('Presence Detected', + 'Power Supply Failure Detected', + 'Predictive Failure', + 'Power Supply Input Lost (AC/DC)', + 'Power Supply Input Lost or Out-of-range', + 'Power Supply Input Out-of-range, but Present', + 'Configuration Error', + 'Power Supply Inactive'), + 0x09: ('Power Off / Power Down', + 'Power Cycle', + '240VA Power Down', + 'Interlock Power Down', + 'AC Lost / Power Input Lost', + 'Soft Power Control Failure', + 'Power Unit Failure Detected', + 'Predictive Failure'), + 0x0c: ('Correctable ECC / Other Correctable Memory Error', + 'Uncorrectable ECC / Other Uncorrectable Memory Error', + 'Parity', + 'Memory Scrub Error', + 'Memory Device Disabled', + 'Correctable ECC / Other Correctable Memory Error Logging Limit ' + 'Reached', + 'Presence Detected', + 'Configuration Error', + 'Spare', + 'Memory Automatically Throttled', + 'Critical Overtemperature'), + 0x0d: ('Drive Presence', + 'Drive Fault', + 'Predictive Failure', + 'Hot Spare', + 'Consistency Check / Parity Check in Progress', + 'In Critical Array', + 'In Failed Array', + 'Rebuild/Remap in Progress', + 'Rebuild/Remap Aborted'), + 0x0f: ('System Firmware Error (POST Error)', + 'System Firmware Hang', + 'System Firmware Progress'), + 0x10: ('Correctable Memory Error Logging Disabled', + 'Event Type Logging Disabled', + 'Log Area Reset/Cleared', + 'All Event Logging Disabled', + 'SEL Full', + 'SEL Almost Full', + 'Correctable Machine Check Error Logging Disabled'), + 0x11: ('BIOS Watchdog Reset', + 'OS Watchdog Reset', + 'OS Watchdog Shut Down', + 'OS Watchdog Power Down', + 'OS Watchdog Power Cycle', + 'OS Watchdog NMI / Diagnostic Interrupt', + 'OS Watchdog Expired, Status Only', + 'OS Watchdog Pre-timeout Interrupt, non-NMI'), + 0x12: ('System Reconfigured', + 'OEM System Boot Event', + 'Undetermined System Hardware Failure', + 'Entry Added to Auxiliary Log', + 'PEF Action', + 'Timestamp Clock Synch'), + 0x13: ('Front Panel NMI / Diagnostic Interrupt', + 'Bus Timeout', + 'I/O Channel Check NMI', + 'Software NMI', + 'PCI PERR', + 'PCI SERR', + 'EISA Fail Safe Timeout', + 'Bus Correctable Error', + 'Bus Uncorrectable Error', + 'Fatal NMI', + 'Bus Fatal Error', + 'Bus Degraded'), + 0x14: ('Power Button Pressed', + 'Sleep Button Pressed', + 'Reset Button Pressed', + 'FRU Latch Open', + 'FRU Service Request Button'), + 0x19: ('Soft Power Control Failure', + 'Thermal Trip'), + 0x1b: ('Cable/Interconnect is Connected', + 'Configuration Error - Incorrect Cable Connected / Incorrect ' + 'Interconnection'), + 0x1d: ('Initiated by Power Up', + 'Initiated by Hard Reset', + 'Initiated by Warm Reset', + 'User Requested PXE Boot', + 'Automatic Boot to Diagnostic', + 'OS / Run-time Software Initiated Hard Reset', + 'OS / Run-time Software Initiated Warm Reset', + 'System Restart'), + 0x1e: ('No Bootable Media', + 'Non-bootable Diskette Left in Drive', + 'PXE Server Not Found', + 'Invalid Boot Sector', + 'Timeout Waiting for User Selection of Boot Source'), + 0x1f: ('A: Boot Completed', + 'C: Boot Completed', + 'PXE Boot Completed', + 'Diagnostic Boot Completed', + 'CD-ROM Boot Completed', + 'ROM Boot Completed', + 'Boot Completed - Boot Device Not Specified', + 'Base OS/Hypervisor Installation Started', + 'Base OS/Hypervisor Installation Completed', + 'Base OS/Hypervisor Installation Aborted', + 'Base OS/Hypervisor Installation Failed'), + 0x20: ('Critical Stop During OS Load / Initialization', + 'Run-time Critical Stop', + 'OS Graceful Stop', + 'OS Graceful Shutdown', + 'Soft Shutdown Initiated by PEF', + 'Agent Not Responding'), + 0x21: ('Fault Status Asserted', + 'Identify Status Asserted', + 'Slot / Connector Device Installed/Attached', + 'Slot / Connector Ready for Device Installation', + 'Slot / Connector Ready for Device Removal', + 'Slot Power is Off', + 'Slot / Connector Device Removal Request', + 'Interlock Asserted', + 'Slot is Disabled', + 'Slot Holds Spare Device'), + 0x22: ('S0/G0 Working', + 'S1 Sleeping with System H/W & Processor Context Maintained', + 'S2 Sleeping, Processor Context Lost', + 'S3 Sleeping, Processor & H/W Context Lost, Memory Retained', + 'S4 Non-volatile Sleep / Suspend-to-disk', + 'S5/G2 Soft-off', + 'S4/S5 Soft-off, Particular S4/S5 State Cannot Be Determined', + 'G3/Mechanical Off', + 'Sleeping in an S1, S2, or S3 State', + 'G1 Sleeping', + 'S5 Entered by Override', + 'Legacy ON State', + 'Legacy OFF State', + None, + 'Unknown'), + 0x23: ('Timer Expired, Status Only', + 'Hard Reset', + 'Power Down', + 'Power Cycle', + None, + None, + None, + None, + 'Timer Interrupt'), + 0x24: ('Platform Generated Page', + 'Platform Generated LAN Alert', + 'Platform Event Trap Generated', + 'Platform Generated SNMP Trap'), + 0x25: ('Entity Present', + 'Entity Absent', + 'Entity Disabled'), + 0x27: ('LAN Heartbeat Lost', + 'LAN Heartbeat'), + 0x28: ('Sensor Access Degraded or Unavailable', + 'Controller Access Degraded or Unavailable', + 'Management Controller Off-line', + 'Management Controller Unavailable', + 'Sensor Failure', + 'FRU Failure'), + 0x29: ('Battery Low (Predictive Failure)', + 'Battery Failed', + 'Battery Presence Detected'), + 0x2a: ('Session Activated', + 'Session Deactivated', + 'Invalid Username or Password', + 'Invalid Password Disable'), + 0x2b: ('Hardware Change Detected', + 'Firmware or Software Change Detected', + 'Hardware Incompatibility Detected', + 'Firmware or Software Incompatibility Detected', + 'Invalid or Unsupported Hardware Version', + 'Invalid or Unsupported Firmware or Software Version', + 'Hardware Change Detected Was Successful', + 'Software or Firmware Change Detected Was Successful'), + 0x2c: ('FRU Not Installed', + 'FRU Inactive', + 'FRU Activation Requested', + 'FRU Activation In Progress', + 'FRU Active', + 'FRU Deactivation Requested', + 'FRU Deactivation In Progress', + 'FRU Communication Lost'), + 0xf0: _FRU_STATES, + 0xf2: ('Module Handle Closed', + 'Module Handle Opened', + 'Quiesced', + 'Backend Power Failure', + 'Backend Power Shut Down'), +} + +# The timestamp of an event before the SEL time was set is the time since +# the initialization of the BMC (IPMI v2.0 section 37.1) +TIMESTAMP_UNSPECIFIED = 0xffffffff +TIMESTAMP_PRE_INIT_MAX = 0x20000000 + + class Sel(IpmiMixin): """SEL commands, available on :class:`pyipmi.Ipmi`.""" @@ -287,6 +643,8 @@ class SelEntry(State): ``EVENT_DEASSERTION`` of :mod:`pyipmi.event`. event_type (int): The event/reading type. event_data (list[int]): The event data bytes 1 - 3. + event_offset (int): The event offset of a generic or sensor-specific + event, bits 3:0 of the event data byte 1. """ TYPE_SYSTEM_EVENT = 0x02 @@ -345,6 +703,113 @@ def type_to_string(entry_type: int) -> str | None: string = f'OEM non-timestamped (0x{entry_type:02x})' return string + @staticmethod + def sensor_type_to_string(sensor_type: int) -> str: + """Return the name of a sensor type. + + Args: + sensor_type: The sensor type. + + Returns: + The name of the sensor type, the type as hex number for an + unknown or OEM type. + """ + name = SENSOR_TYPE_NAMES.get(sensor_type) + if name is None: + if sensor_type >= 0xc0: + return f'OEM (0x{sensor_type:02x})' + return f'Unknown (0x{sensor_type:02x})' + return name + + def is_timestamped(self) -> bool: + """Return True if the entry has a timestamp.""" + return self.type not in self.TYPE_OEM_NON_TIMESTAMPED_RANGE + + def timestamp_to_string(self) -> str: + """Return the timestamp as date and time (UTC). + + Returns: + The date and time as 'YYYY-MM-DD HH:MM:SS', 'Pre-Init s' for + a time since the initialization of the BMC, or 'Unspecified'. + """ + if (not self.is_timestamped() + or self.timestamp == TIMESTAMP_UNSPECIFIED): + return 'Unspecified' + if self.timestamp <= TIMESTAMP_PRE_INIT_MAX: + return f'Pre-Init {self.timestamp:d}s' + time = datetime.fromtimestamp(self.timestamp, timezone.utc) + return time.strftime('%Y-%m-%d %H:%M:%S') + + def generator_to_string(self) -> str: + """Return the generator ID as IPMB address or software ID. + + Returns: + E.g. 'IPMB 0x20 LUN 0 channel 0' or 'Software 0x01'. + """ + address = self.generator_id & 0xff + lun = (self.generator_id >> 8) & 0x3 + channel = (self.generator_id >> 12) & 0xf + if address & 0x1: + string = f'Software 0x{address >> 1:02x}' + else: + string = f'IPMB 0x{address:02x} LUN {lun:d}' + if channel: + string += f' channel {channel:d}' + return string + + def event_to_string(self) -> str: + """Return the description of the event. + + The event offset is looked up in the generic or sensor-specific + event offsets of the IPMI specification. + + Returns: + The description of the event, or the event/reading type and + offset as hex numbers if they are unknown. + """ + strings: tuple[str | None, ...] | None = None + if self.event_type == EVENT_READING_TYPE_SENSOR_SPECIFIC: + strings = SENSOR_SPECIFIC_EVENT_STRINGS.get(self.sensor_type) + else: + strings = GENERIC_EVENT_STRINGS.get(self.event_type) + string = None + if strings is not None and self.event_offset < len(strings): + string = strings[self.event_offset] + if string is None: + if 0x70 <= self.event_type <= 0x7f: + string = (f'OEM Event Type 0x{self.event_type:02x} ' + f'Offset 0x{self.event_offset:02x}') + else: + string = (f'Event Type 0x{self.event_type:02x} ' + f'Offset 0x{self.event_offset:02x}') + return string + + def direction_to_string(self) -> str: + """Return 'Asserted' or 'Deasserted'.""" + if self.event_direction == EVENT_DEASSERTION: + return 'Deasserted' + return 'Asserted' + + def threshold_event_values(self) -> tuple[int | None, int | None]: + """Return the raw values of a threshold event. + + The event data bytes 2 and 3 can hold the trigger reading and the + trigger threshold, as indicated in the event data byte 1. + + Returns: + A tuple of the raw trigger reading and the raw threshold value, + None if a value is not given or the event is no threshold event. + """ + if self.event_type != EVENT_READING_TYPE_CODE_THRESHOLD: + return (None, None) + reading = None + threshold = None + if (self.event_data[0] >> 6) & 0x3 == 0x1: + reading = self.event_data[1] + if (self.event_data[0] >> 4) & 0x3 == 0x1: + threshold = self.event_data[2] + return (reading, threshold) + def _from_data(self, data: ByteSequence) -> None: if len(data) != 16: raise DecodingError(f'Invalid SEL record length ({len(data):d})') @@ -372,3 +837,4 @@ def _from_data(self, data: ByteSequence) -> None: self.event_direction = EVENT_ASSERTION self.event_type = event_desc & 0x7f self.event_data = [buffer.pop_unsigned_int(1) for _ in range(3)] + self.event_offset = self.event_data[0] & 0x0f diff --git a/tests/test_ipmitool.py b/tests/test_ipmitool.py index ba22451..812c78a 100644 --- a/tests/test_ipmitool.py +++ b/tests/test_ipmitool.py @@ -13,6 +13,7 @@ from pyipmi.msgs import create_response_by_name, decode_message from pyipmi.ipmitool import build_parser, log_level, parse_interface_options from pyipmi.sdr import SdrCommon, SdrCompactSensorRecord, SdrFullSensorRecord +from pyipmi.sel import SelEntry from .ipmi_helper import create_ipmi @@ -822,3 +823,97 @@ def test_print_sdr_list_entry_not_available(self, capsys): ipmitool.print_sdr_list_entry(3, None, 'Other', None, None) assert capsys.readouterr().out == ( '0x0003 | na | Other | None | na\n') + + +class TestSelList: + + ENTRIES = [ + # threshold event of the temperature sensor 4 of the BMC + bytes([0x01, 0x00, 0x02, 0x00, 0x10, 0x20, 0x68, 0x20, 0x00, + 0x04, 0x01, 0x04, 0x01, 0x59, 0x55, 0x50]), + # sensor-specific deassertion of the FRU hot swap sensor 0 + bytes([0x02, 0x00, 0x02, 0x10, 0x00, 0x00, 0x00, 0x82, 0x00, + 0x04, 0xf0, 0x00, 0xef, 0x02, 0x01, 0xff]), + # OEM non-timestamped record + bytes([0x03, 0x00, 0xe0] + list(range(1, 14))), + ] + + def ipmi(self): + ipmi = MagicMock() + ipmi.sel_entries.return_value = [SelEntry(e) for e in self.ENTRIES] + ipmi.get_device_id.return_value.supports_function.return_value = True + record = SdrFullSensorRecord() + record.id = 1 + record.owner_id = 0x20 + record.owner_lun = 0 + record.number = 4 + record.device_id_string = 'CPU Temp' + record.analog_data_format = 0 + record.m = 1 + record.b = 0 + record.k1 = 0 + record.k2 = 0 + record.linearization = 0 + ipmi.sdr_repository_entries.return_value = [record] + return ipmi + + def test_options(self): + parser = build_parser() + args = parser.parse_args(['sel', 'list']) + assert not args.details and not args.sdr + args = parser.parse_args(['sel', 'list', '-d', '--sdr']) + assert args.details and args.sdr + # the global option for the log level is independent + assert not args.verbose + args = parser.parse_args(['-v', 'sel', 'list']) + assert args.verbose and not args.details + + def test_list(self, capsys): + ipmi = self.ipmi() + ipmitool.cmd_sel_list(ipmi, argparse.Namespace(details=False, + sdr=False)) + ipmi.sdr_repository_entries.assert_not_called() + assert capsys.readouterr().out.splitlines() == [ + '0x0001 | 2025-05-11 02:48:32 | Temperature #0x04 ' + '| Upper Critical going high | Asserted ' + '| Reading 0x55, Threshold 0x50', + '0x0002 | Pre-Init 16s | FRU Hot Swap #0x00 ' + '| M2 - FRU Activation Request | Deasserted', + '0x0003 | Unspecified | OEM non-timestamped (0xe0) ' + '| 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d', + ] + + def test_list_sdr(self, capsys): + ipmitool.cmd_sel_list(self.ipmi(), argparse.Namespace(details=False, + sdr=True)) + lines = capsys.readouterr().out.splitlines() + assert lines[0] == ('0x0001 | 2025-05-11 02:48:32 ' + '| Temperature CPU Temp ' + '| Upper Critical going high | Asserted ' + '| Reading 85.000, Threshold 80.000') + # the sensor of another owner has no record + assert 'FRU Hot Swap #0x00' in lines[1] + + def test_list_details(self, capsys): + ipmitool.cmd_sel_list(self.ipmi(), argparse.Namespace(details=True, + sdr=True)) + out = capsys.readouterr().out + assert ('SEL Record ID: 0x0001\n' + 'Record Type: System Event\n' + 'Timestamp: 2025-05-11 02:48:32\n' + 'Generator ID: 0x0020 (IPMB 0x20 LUN 0)\n' + 'EvM Revision: 0x04\n' + 'Sensor Type: 0x01 (Temperature)\n' + 'Sensor Number: 0x04\n' + 'Sensor Name: CPU Temp\n' + 'Event Type: 0x01\n' + 'Event Direction: Asserted\n' + 'Event Data: 59 55 50\n' + 'Description: Upper Critical going high\n' + 'Values: Reading 85.000, Threshold 80.000\n' + 'Raw Data: 01 00 02 00 10 20 68 20 00 04 01 04 01 59 ' + '55 50\n') in out + assert ('SEL Record ID: 0x0003\n' + 'Record Type: OEM non-timestamped (0xe0)\n' + 'Timestamp: Unspecified\n' + 'Raw Data: 03 00 e0 01 02') in out diff --git a/tests/test_sel.py b/tests/test_sel.py index 8719cc1..34d007e 100644 --- a/tests/test_sel.py +++ b/tests/test_sel.py @@ -258,3 +258,89 @@ def test_clear_sel(monkeypatch): ('ClearSelReq', b'\x34\x12CLR\xaa'), ('ClearSelReq', b'\x34\x12CLR\x00'), ('ClearSelReq', b'\x34\x12CLR\x00')] + + +def sel_entry(record_type=0x02, timestamp=0x12345678, generator_id=0x0020, + sensor_type=0x01, sensor_number=4, event_desc=0x01, + event_data=(0x52, 0x00, 0x00)): + data = (b'\x01\x00' + bytes([record_type]) + + timestamp.to_bytes(4, 'little') + + generator_id.to_bytes(2, 'little') + + bytes([0x04, sensor_type, sensor_number, event_desc]) + + bytes(event_data)) + return SelEntry(ByteBuffer(data)) + + +@pytest.mark.parametrize('sensor_type, string', [ + (0x01, 'Temperature'), + (0x2c, 'FRU State'), + (0xf0, 'FRU Hot Swap'), + (0xc5, 'OEM (0xc5)'), + (0x00, 'Unknown (0x00)'), +]) +def test_sel_entry_sensor_type_to_string(sensor_type, string): + assert SelEntry.sensor_type_to_string(sensor_type) == string + + +@pytest.mark.parametrize('record_type, timestamp, string', [ + (0x02, 0x68201000, '2025-05-11 02:48:32'), + (0x02, 0x10, 'Pre-Init 16s'), + (0x02, 0x20000000, 'Pre-Init 536870912s'), + (0x02, 0xffffffff, 'Unspecified'), + (0xc0, 0x68201000, '2025-05-11 02:48:32'), + (0xe0, 0x68201000, 'Unspecified'), +]) +def test_sel_entry_timestamp_to_string(record_type, timestamp, string): + entry = sel_entry(record_type=record_type, timestamp=timestamp) + assert entry.timestamp_to_string() == string + + +@pytest.mark.parametrize('generator_id, string', [ + (0x0020, 'IPMB 0x20 LUN 0'), + (0x0282, 'IPMB 0x82 LUN 2'), + (0x0041, 'Software 0x20'), + (0x7020, 'IPMB 0x20 LUN 0 channel 7'), +]) +def test_sel_entry_generator_to_string(generator_id, string): + entry = sel_entry(generator_id=generator_id) + assert entry.generator_to_string() == string + + +@pytest.mark.parametrize('sensor_type, event_desc, event_data, string', [ + # generic threshold event + (0x01, 0x01, (0x59, 0, 0), 'Upper Critical going high'), + # generic discrete event + (0x25, 0x08, (0x01, 0, 0), 'Device Present'), + # sensor-specific events + (0x08, 0x6f, (0x01, 0, 0), 'Power Supply Failure Detected'), + (0xf0, 0x6f, (0x04, 0, 0), 'M4 - FRU Active'), + # the direction is not part of the event type + (0x2c, 0xef, (0x04, 0, 0), 'FRU Active'), + # unknown offsets and types + (0x01, 0x01, (0x0c, 0, 0), 'Event Type 0x01 Offset 0x0c'), + (0x23, 0x6f, (0x04, 0, 0), 'Event Type 0x6f Offset 0x04'), + (0xc0, 0x6f, (0x01, 0, 0), 'Event Type 0x6f Offset 0x01'), + (0xc0, 0x70, (0x01, 0, 0), 'OEM Event Type 0x70 Offset 0x01'), +]) +def test_sel_entry_event_to_string(sensor_type, event_desc, event_data, + string): + entry = sel_entry(sensor_type=sensor_type, event_desc=event_desc, + event_data=event_data) + assert entry.event_to_string() == string + + +def test_sel_entry_direction_to_string(): + assert sel_entry(event_desc=0x01).direction_to_string() == 'Asserted' + assert sel_entry(event_desc=0x81).direction_to_string() == 'Deasserted' + + +@pytest.mark.parametrize('event_desc, event_data, values', [ + (0x01, (0x59, 0x55, 0x50), (0x55, 0x50)), + (0x01, (0x49, 0x55, 0x50), (0x55, None)), + (0x01, (0x19, 0x55, 0x50), (None, 0x50)), + (0x01, (0x09, 0x55, 0x50), (None, None)), + (0x6f, (0x59, 0x55, 0x50), (None, None)), +]) +def test_sel_entry_threshold_event_values(event_desc, event_data, values): + entry = sel_entry(event_desc=event_desc, event_data=event_data) + assert entry.threshold_event_values() == values