From e34cfa386d1b6ff0e00313466bfe876c92c6ca3f Mon Sep 17 00:00:00 2001 From: Heiko Thiery Date: Fri, 9 Oct 2026 14:23:22 +0200 Subject: [PATCH 1/2] sel: decode the SEL entries to human readable strings A SEL entry only had its raw fields, so the sensor type, the event offset, the timestamp and the generator ID had to be looked up in the IPMI specification. Add the names of the sensor types and the descriptions of the generic and sensor-specific event offsets (IPMI v2.0 tables 42-2 and 42-3, PICMG 3.0 hot swap states), and SelEntry methods to convert the sensor type, event, direction, timestamp and generator ID to strings. A timestamp of an event before the SEL time was set is shown as time since the BMC initialization (IPMI v2.0 section 37.1). threshold_event_values() returns the trigger reading and threshold of a threshold event. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Heiko Thiery --- pyipmi/sel.py | 466 ++++++++++++++++++++++++++++++++++++++++++++++ tests/test_sel.py | 86 +++++++++ 2 files changed, 552 insertions(+) diff --git a/pyipmi/sel.py b/pyipmi/sel.py index 0d49c410..8038354e 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_sel.py b/tests/test_sel.py index 8719cc10..34d007ec 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 From 274b29ebe1f6c6fd6ef6a1a097a94941c5be12d9 Mon Sep 17 00:00:00 2001 From: Heiko Thiery Date: Fri, 9 Oct 2026 14:23:22 +0200 Subject: [PATCH 2/2] ipmitool: print the SEL entries human readable in 'sel list' 'sel list' printed the raw fields of the entries. Print one line per entry with the date and time, the sensor type, the event description and the direction, and the trigger reading and threshold of threshold events. The data of OEM records is printed as hex bytes. With -s/--sdr the SDRs are read to print the sensor names and to convert the threshold event values. With -d/--details all fields of an entry are printed. The option is not named -v, the global -v already sets the log level. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Heiko Thiery --- man/ipmitool.py.1 | 13 ++++- pyipmi/ipmitool.py | 120 ++++++++++++++++++++++++++++++++++++++++- tests/test_ipmitool.py | 95 ++++++++++++++++++++++++++++++++ 3 files changed, 224 insertions(+), 4 deletions(-) diff --git a/man/ipmitool.py.1 b/man/ipmitool.py.1 index 76aa53db..5e1fe7bd 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 fa8777a2..85357e26 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/tests/test_ipmitool.py b/tests/test_ipmitool.py index ba224517..812c78ad 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