#!/usr/lib64/linuxfabrik-monitoring-plugins/venv/bin/python
# -*- coding: utf-8; py-indent-offset: 4 -*-
#
# Author:  Linuxfabrik GmbH, Zurich, Switzerland
# Contact: info (at) linuxfabrik (dot) ch
#          https://www.linuxfabrik.ch/
# License: The Unlicense, see LICENSE file.

# https://github.com/Linuxfabrik/monitoring-plugins/blob/main/CONTRIBUTING.md

"""See the check's README for more details."""

import argparse
import collections
import json
import sys

import lib.args
import lib.base
import lib.lftest
import lib.redfish
import lib.txt
from lib.globals import STATE_CRIT, STATE_OK, STATE_UNKNOWN, STATE_WARN

__author__ = 'Linuxfabrik GmbH, Zurich/Switzerland'
__version__ = '2026102501'

DESCRIPTION = """Checks hardware sensor readings (temperature, voltage, fan speed, power) from the
Redfish Chassis collection via the Redfish API. Reads the modern Sensors collection where
available and falls back to the legacy Thermal and Power endpoints otherwise, which also
supply the fans and power supplies the Sensors collection does not list. Alerts when any
sensor reports a non-ok state."""

API_BASE = '/redfish/v1'
DEFAULT_CACHE_EXPIRE = (
    5  # minutes; also caches API responses, kept below the session timeout
)
DEFAULT_INSECURE = True
DEFAULT_NO_PROXY = False
DEFAULT_RETRIES = 3  # extra attempts on a failed Redfish request
DEFAULT_TIMEOUT = 8
DEFAULT_VERBOSE = False


def parse_args():
    """Parse command line arguments using argparse."""
    parser = argparse.ArgumentParser(
        description=DESCRIPTION,
        epilog=lib.args.epilog(__file__),
        formatter_class=lib.args.HelpFormatter,
    )

    parser.add_argument(
        '-V',
        '--version',
        action='version',
        version=f'%(prog)s: v{__version__} by {__author__}',
    )

    parser.add_argument(
        '--always-ok',
        help=lib.args.help('--always-ok'),
        dest='ALWAYS_OK',
        action='store_true',
        default=False,
    )

    parser.add_argument(
        '--brief',
        help='Hide items that are OK and show only those in WARN/CRIT state. '
        'Alerting is unaffected: all items still drive the overall check state.',
        dest='BRIEF',
        action='store_true',
        default=False,
    )

    parser.add_argument(
        '--cache-expire',
        help=lib.args.help('--cache-expire') + ' Default: %(default)s',
        dest='CACHE_EXPIRE',
        type=int,
        default=DEFAULT_CACHE_EXPIRE,
    )

    parser.add_argument(
        '--ignore',
        help='Ignore items whose name matches this Python regular expression. '
        'Case-sensitive by default; use `(?i)` for case-insensitive matching. '
        'Can be specified multiple times.',
        dest='IGNORE',
        action='append',
        default=None,
    )

    parser.add_argument(
        '--insecure',
        help=lib.args.help('--insecure'),
        dest='INSECURE',
        action='store_true',
        default=DEFAULT_INSECURE,
    )

    parser.add_argument(
        '--inventory',
        help='Output the parsed components as JSON on stdout and exit OK, instead of '
        'running a health check. Use this to collect a hardware inventory: the JSON is a '
        'single object keyed by component type, so the output of several Redfish checks can '
        'be merged into one inventory document with `jq --slurp`. Ignores --brief, --match '
        'and --ignore.',
        dest='INVENTORY',
        action='store_true',
        default=False,
    )

    parser.add_argument(
        '--match',
        help='Only check items whose name matches this Python regular expression. '
        'Case-sensitive by default; use `(?i)` for case-insensitive matching. '
        'Can be specified multiple times. ' + lib.args.MATCH_IGNORE_PRECEDENCE,
        dest='MATCH',
        action='append',
        default=None,
    )

    parser.add_argument(
        '--no-insecure',
        help=lib.args.help('--no-insecure'),
        dest='INSECURE',
        action='store_false',
        default=DEFAULT_INSECURE,
    )

    parser.add_argument(
        '--no-perfdata',
        help=lib.args.help('--no-perfdata'),
        dest='NO_PERFDATA',
        action='store_true',
        default=False,
    )

    parser.add_argument(
        '--no-proxy',
        help=lib.args.help('--no-proxy'),
        dest='NO_PROXY',
        action='store_true',
        default=DEFAULT_NO_PROXY,
    )

    parser.add_argument(
        '--password',
        help='Redfish API password.',
        dest='PASSWORD',
    )

    parser.add_argument(
        '--proxy',
        help=lib.args.help('--proxy'),
        dest='PROXY',
        default=None,
    )

    parser.add_argument(
        '--retries',
        help='Number of extra attempts if a request to the Redfish API fails, before the '
        'check gives up. Helps against an occasionally slow or flaky management controller. '
        'Default: %(default)s',
        dest='RETRIES',
        type=int,
        default=DEFAULT_RETRIES,
    )

    parser.add_argument(
        '--test',
        help=lib.args.help('--test'),
        dest='TEST',
        type=lib.args.csv,
    )

    parser.add_argument(
        '--timeout',
        help=lib.args.help('--timeout') + ' Default: %(default)s (seconds)',
        dest='TIMEOUT',
        type=int,
        default=DEFAULT_TIMEOUT,
    )

    parser.add_argument(
        '--url',
        help='Redfish API URL.',
        dest='URL',
        required=True,
    )

    parser.add_argument(
        '--username',
        help='Redfish API username.',
        dest='USERNAME',
    )

    parser.add_argument(
        '--verbose',
        help=lib.args.help('--verbose') + ' ' + lib.redfish.VERBOSE_HELP,
        dest='VERBOSE',
        action='store_true',
        default=DEFAULT_VERBOSE,
    )

    args, _ = parser.parse_known_args()
    return args


def get_legacy_items(resource, key):
    """Return the objects a legacy Thermal or Power resource lists under `key`.

    Firmware that does not conform to the schema sends `null` where an array belongs,
    or entries that are no objects. Both give nothing to evaluate instead of ending
    the check.
    """
    items = resource.get(key)
    if not isinstance(items, list):
        return []
    return [item for item in items if isinstance(item, dict)]


def main():
    """The main function. This is where the magic happens."""

    # parse the command line
    try:
        args = parse_args()
    except SystemExit:
        sys.exit(STATE_UNKNOWN)

    # --verbose: record every Redfish response for the output, where a bug report can pick
    # them up, and trace every request to a file. A file as well, because a check slow enough
    # to need diagnosing is usually one the monitoring server terminates for exceeding its
    # timeout, and a terminated check prints nothing at all.
    if args.VERBOSE:
        success, trace_path = lib.redfish.start_trace()
        if not success:
            lib.base.cu(trace_path)
        lib.redfish.record_responses()

    # set default values for append parameters that were not specified
    if args.IGNORE is None:
        args.IGNORE = []
    if args.MATCH is None:
        args.MATCH = []

    # compile the item filter regexes once
    ignore_patterns = [
        lib.base.coe(p) for p in lib.txt.compile_regex(args.IGNORE, '--ignore')
    ]
    match_patterns = [
        lib.base.coe(p) for p in lib.txt.compile_regex(args.MATCH, '--match')
    ]

    # fetch data
    if args.TEST is not None:
        # do not call the API, replay the responses a --verbose run recorded instead. The
        # code below then walks them exactly as it would walk the controller.
        stdout, _, _ = lib.lftest.test(args.TEST)
        lib.base.coe(lib.redfish.replay(stdout))
    if not args.URL.startswith('http'):
        lib.base.cu('--url parameter has to start with "http://" or https://".')
    header = {'Accept': 'application/json'}
    # cache_expire (seconds) enables the lib fetch layer's shared per-URL cache,
    # so sibling Redfish checks on this host share one session and each fetch.
    cache_expire = args.CACHE_EXPIRE * 60
    header.update(lib.redfish.get_auth_header(args, cache_expire=cache_expire))
    expand = lib.redfish.get_expand_suffix(
        args.URL,
        header=header,
        insecure=args.INSECURE,
        no_proxy=args.NO_PROXY,
        proxy=args.PROXY,
        timeout=args.TIMEOUT,
        retries=args.RETRIES,
        cache_expire=cache_expire,
    )
    # Entry point: the Chassis collection, read in one request via the Redfish
    # $expand query and cached by the lib fetch layer so the sibling Redfish
    # checks on this host reuse it within the cache window.
    chassis_url = f'{args.URL}{API_BASE}/Chassis'
    result = lib.base.coe(
        lib.redfish.fetch_collection(
            chassis_url,
            expand=expand,
            header=header,
            insecure=args.INSECURE,
            no_proxy=args.NO_PROXY,
            proxy=args.PROXY,
            timeout=args.TIMEOUT,
            retries=args.RETRIES,
            cache_expire=cache_expire,
        )
    )
    # "Members": [
    #     {
    #         "@odata.id": "/redfish/v1/Chassis/1U"
    #     }
    # ],
    if len(result.get('Members', [])) == 0:
        lib.base.cu('Nothing to check, no Redfish members found.')

    # init some vars
    msg = ''
    state = STATE_OK
    perfdata = ''
    # (chassis id, item, reading key) per metric; the labels are built once all
    # chassis are known, see "build the message"
    perfdata_items = []
    member_count = 0
    # only allocated and populated in --inventory mode, so a normal health
    # check holds nothing extra in memory
    inventory = [] if args.INVENTORY else None

    # analyze data: follow each "Member" link, aggregate chassis,
    # sensor and thermal health into `state`.
    # fetch_members fills in any chassis the controller left as bare references
    # (i.e. when it did not honour $expand on the collection above), so a chassis
    # the collection already carries costs no request of its own
    chassis_members = lib.base.coe(
        lib.redfish.fetch_members(
            result.get('Members', []),
            args.URL,
            header=header,
            insecure=args.INSECURE,
            no_proxy=args.NO_PROXY,
            proxy=args.PROXY,
            timeout=args.TIMEOUT,
            retries=args.RETRIES,
            cache_expire=cache_expire,
        )
    )
    # a collection may list a chassis twice (DMTF DSP2043 public-iiot does), which
    # would count its sensors twice
    seen_chassis = set()
    for chassis_data in chassis_members:
        chassis_path = chassis_data.get('@odata.id', '')
        if chassis_path and chassis_path in seen_chassis:
            continue
        seen_chassis.add(chassis_path)
        chassis = lib.redfish.get_chassis(chassis_data)
        if chassis['Status_State'] in lib.redfish.UNMONITORED_STATES:
            continue
        member_count += 1
        chassis_state = lib.redfish.get_state(chassis)
        state = lib.base.get_worst(state, chassis_state)

        # build the message
        msg += 'Member:'
        msg += f' {chassis["Manufacturer"]}' if chassis['Manufacturer'] else ''
        msg += f' {chassis["Model"]}' if chassis['Model'] else ''
        msg += ', '
        msg += f'Power: {chassis["PowerState"]}, ' if chassis['PowerState'] else ''
        msg += f'LED: {chassis["IndicatorLED"]}, ' if chassis['IndicatorLED'] else ''
        msg += f'SKU: {chassis["SKU"]}, ' if chassis['SKU'] else ''
        msg += f'SerNo: {chassis["SerialNumber"]}, ' if chassis['SerialNumber'] else ''
        msg += f'PartNumber: {chassis["PartNumber"]}, ' if chassis['PartNumber'] else ''
        msg = msg[:-2] + lib.base.state2str(chassis_state, prefix=' ')

        # get the modern Sensors collection for the member, if advertised.
        # fetch_collection asks the controller to inline the members via the
        # Redfish $expand query, so a large sensor list costs one request
        # instead of one per sensor; fetch_members fills in any members the
        # controller left as bare references.
        table_data = []
        # names of all enabled sensors in the Sensors collection, before any
        # display filter, and whether one of them is a fan, so the legacy
        # endpoints below add nothing twice
        sensor_names = set()
        sensors_have_fans = False
        if chassis['Sensors_@odata.id']:
            sensors = lib.base.coe(
                lib.redfish.fetch_collection(
                    lib.base.coe(
                        lib.redfish.build_url(args.URL, chassis['Sensors_@odata.id'])
                    ),
                    expand=expand,
                    header=header,
                    insecure=args.INSECURE,
                    no_proxy=args.NO_PROXY,
                    proxy=args.PROXY,
                    timeout=args.TIMEOUT,
                    retries=args.RETRIES,
                    cache_expire=cache_expire,
                )
            )
            sensor_members = lib.base.coe(
                lib.redfish.fetch_members(
                    sensors.get('Members', []),
                    args.URL,
                    header=header,
                    insecure=args.INSECURE,
                    no_proxy=args.NO_PROXY,
                    proxy=args.PROXY,
                    timeout=args.TIMEOUT,
                    retries=args.RETRIES,
                    cache_expire=cache_expire,
                )
            )
            sensor_members = [
                lib.redfish.get_chassis_sensors(sensor_data)
                for sensor_data in sensor_members
                if isinstance(sensor_data, dict)
            ]
            # Dell iDRAC names the sensors of every power supply alike
            # ("InputPowerSensor"), only the Id tells them apart, so a name
            # that occurs more than once is replaced by the Id to keep the
            # table rows and the perfdata labels apart
            name_count = collections.Counter(
                sensor_data['Name'] for sensor_data in sensor_members
            )
            for sensor_data in sensor_members:
                if sensor_data['Status_State'] in lib.redfish.UNMONITORED_STATES:
                    continue
                if name_count[sensor_data['Name']] > 1 and sensor_data['Id']:
                    sensor_data['Name'] = sensor_data['Id']
                sensor_names.add(sensor_data['Name'])
                # the same fan often carries a different name in the legacy
                # Thermal resource (DMTF mockup: "Chassis Fan #1" in the Sensors
                # collection, "BaseBoard System Fan" in Thermal), so fans count
                # as a category
                if (
                    sensor_data['PhysicalContext'] == 'Fan'
                    or sensor_data['ReadingUnits'] == 'RPM'
                    or (
                        'fan' in str(sensor_data['Name']).lower()
                        and sensor_data['ReadingUnits'] != 'Cel'
                    )
                ):
                    sensors_have_fans = True
                # collect for --inventory before any display filter
                if args.INVENTORY:
                    inventory.append(dict(sensor_data, chassi_ids=chassis['Id']))
                # --match/--ignore: filter by item name
                item_name = sensor_data['Name'] or sensor_data.get('Id', '')
                if ignore_patterns and any(
                    p.search(item_name) for p in ignore_patterns
                ):
                    continue
                if match_patterns and not any(
                    p.search(item_name) for p in match_patterns
                ):
                    continue
                perfdata_items.append((chassis['Id'], sensor_data, 'Reading'))
                # is the sensor reading within its thresholds?
                sensor_data_value_state = lib.redfish.get_sensor_state(
                    sensor_data, 'Reading'
                )
                state = lib.base.get_worst(state, sensor_data_value_state)
                sensor_data['Value'] = lib.base.state2str(
                    sensor_data_value_state, empty_ok=False
                )
                # is the sensor_data state healthy at all?
                sensor_data_state = lib.redfish.get_state(sensor_data)
                state = lib.base.get_worst(state, sensor_data_state)
                sensor_data['State'] = lib.base.state2str(
                    sensor_data_state, empty_ok=False
                )
                table_data.append(sensor_data)

        # get the legacy Thermal and Power resources, but only if the chassis
        # advertises them. HPE iLO 6 answers both paths on a storage backplane,
        # which advertises neither, with the data of the server chassis, and every
        # sensor would then be reported twice. Fetched defensively: a failing
        # legacy endpoint must not abort the check.
        thermal = {}
        if chassis['Thermal_@odata.id']:
            success, thermal = lib.redfish.fetch_resource(
                lib.base.coe(
                    lib.redfish.build_url(args.URL, chassis['Thermal_@odata.id'])
                ),
                header=header,
                insecure=args.INSECURE,
                no_proxy=args.NO_PROXY,
                proxy=args.PROXY,
                timeout=args.TIMEOUT,
                retries=args.RETRIES,
                cache_expire=cache_expire,
            )
            if not success or not isinstance(thermal, dict):
                thermal = {}
        power = {}
        if chassis['Power_@odata.id']:
            success, power = lib.redfish.fetch_resource(
                lib.base.coe(
                    lib.redfish.build_url(args.URL, chassis['Power_@odata.id'])
                ),
                header=header,
                insecure=args.INSECURE,
                no_proxy=args.NO_PROXY,
                proxy=args.PROXY,
                timeout=args.TIMEOUT,
                retries=args.RETRIES,
                cache_expire=cache_expire,
            )
            if not success or not isinstance(power, dict):
                power = {}

        # Older BMCs (e.g. iLO 4) only expose the legacy Thermal (Temperatures,
        # Fans) and Power (Voltages, power supplies) endpoints, so without a
        # Sensors collection everything comes from there. With one, the legacy
        # endpoints add the fans if it lists none (iLO 6 lists temperatures
        # only), and the power supplies it does not name, whose health the
        # Sensors collection does not carry.
        power_supplies = get_legacy_items(power, 'PowerSupplies')
        # power control reports the chassis-wide power consumption. Cisco
        # returns a single object where the schema expects a list, so
        # normalize it before iterating.
        power_control = power.get('PowerControl', [])
        if isinstance(power_control, dict):
            power_control = [power_control]
        if not isinstance(power_control, list):
            power_control = []
        power_control = [pc for pc in power_control if isinstance(pc, dict)]
        # (parsed item, key holding the reading, display unit)
        legacy = [
            (lib.redfish.get_chassis_thermal_fans(f), 'Reading', '')
            for f in get_legacy_items(thermal, 'Fans')
        ]
        if sensor_names:
            if sensors_have_fans:
                legacy = []
            power_control = []
        else:
            legacy = [
                (
                    lib.redfish.get_chassis_thermal_temperatures(t),
                    'ReadingCelsius',
                    'Cel',
                )
                for t in get_legacy_items(thermal, 'Temperatures')
            ] + legacy
            legacy += [
                (lib.redfish.get_chassis_power_voltages(v), 'ReadingVolts', 'V')
                for v in get_legacy_items(power, 'Voltages')
            ]
        for item, reading_key, units in legacy:
            if item['Status_State'] in lib.redfish.UNMONITORED_STATES:
                continue
            # collect for --inventory before any display filter
            if args.INVENTORY:
                inventory.append(dict(item, chassi_ids=chassis['Id']))
            # --match/--ignore: filter by item name
            item_name = item['Name'] or item.get('Id', '')
            if ignore_patterns and any(p.search(item_name) for p in ignore_patterns):
                continue
            if match_patterns and not any(p.search(item_name) for p in match_patterns):
                continue
            # adapt the legacy Upper/LowerThreshold* field names to the
            # modern sensor schema so get_sensor_state()/get_perfdata()
            # can consume them unchanged
            item['Reading'] = item.get(reading_key, '')
            if units:
                item['ReadingUnits'] = units
            item['Thresholds_UpperCritical'] = item.get('UpperThresholdCritical', '')
            item['Thresholds_UpperCaution'] = item.get('UpperThresholdNonCritical', '')
            item['Thresholds_LowerCritical'] = item.get('LowerThresholdCritical', '')
            item['Thresholds_LowerCaution'] = item.get('LowerThresholdNonCritical', '')
            item['Thresholds_UpperFatal'] = item.get('UpperThresholdFatal', '')
            item['Thresholds_LowerFatal'] = item.get('LowerThresholdFatal', '')
            perfdata_items.append((chassis['Id'], item, 'Reading'))
            value_state = lib.redfish.get_sensor_state(item, 'Reading')
            state = lib.base.get_worst(state, value_state)
            item['Value'] = lib.base.state2str(value_state, empty_ok=False)
            item_state = lib.redfish.get_state(item)
            state = lib.base.get_worst(state, item_state)
            item['State'] = lib.base.state2str(item_state, empty_ok=False)
            table_data.append(item)
        # power supplies are evaluated on health only (no reading thresholds)
        for psu_data in power_supplies:
            if not isinstance(psu_data, dict):
                continue
            psu = lib.redfish.get_chassis_power_powersupplies(psu_data)
            if psu['Status_State'] in lib.redfish.UNMONITORED_STATES:
                continue
            # identical units share a model number, so the member id keeps
            # their names, and with them their perfdata labels, apart
            psu['Name'] = ' '.join(
                str(part)
                for part in (
                    'Power Supply',
                    psu_data.get('MemberId', ''),
                    psu.get('Model', ''),
                )
                if part not in ('', None)
            )
            if psu['Name'] in sensor_names:
                continue
            # collect for --inventory before any display filter
            if args.INVENTORY:
                inventory.append(dict(psu, chassi_ids=chassis['Id']))
            # --match/--ignore: filter by item name
            item_name = psu['Name'] or psu.get('Id', '')
            if ignore_patterns and any(p.search(item_name) for p in ignore_patterns):
                continue
            if match_patterns and not any(p.search(item_name) for p in match_patterns):
                continue
            psu['PhysicalContext'] = 'PowerSupply'
            psu['Reading'] = psu.get('LastPowerOutputWatts', '')
            psu['ReadingUnits'] = 'W'
            perfdata_items.append((chassis['Id'], psu, 'Reading'))
            psu_state = lib.redfish.get_state(psu)
            state = lib.base.get_worst(state, psu_state)
            psu['Value'] = lib.base.state2str(psu_state, empty_ok=False)
            psu['State'] = lib.base.state2str(psu_state, empty_ok=False)
            table_data.append(psu)
        for pc in power_control:
            pc = lib.redfish.get_chassis_power_powercontrol(pc)
            if pc['Status_State'] in lib.redfish.UNMONITORED_STATES:
                continue
            pc['Name'] = pc.get('Name') or 'Power Control'
            # collect for --inventory before any display filter
            if args.INVENTORY:
                inventory.append(dict(pc, chassi_ids=chassis['Id']))
            # --match/--ignore: filter by item name
            item_name = pc['Name'] or pc.get('MemberId', '')
            if ignore_patterns and any(p.search(item_name) for p in ignore_patterns):
                continue
            if match_patterns and not any(p.search(item_name) for p in match_patterns):
                continue
            pc['PhysicalContext'] = 'PowerControl'
            pc['Reading'] = pc.get('PowerConsumedWatts', '')
            pc['ReadingUnits'] = 'W'
            perfdata_items.append((chassis['Id'], pc, 'Reading'))
            pc_state = lib.redfish.get_state(pc)
            state = lib.base.get_worst(state, pc_state)
            pc['Value'] = lib.base.state2str(pc_state, empty_ok=False)
            pc['State'] = lib.base.state2str(pc_state, empty_ok=False)
            table_data.append(pc)

        if args.BRIEF:
            table_data = [
                r
                for r in table_data
                if not (r.get('State') == '[OK]' and r.get('Value') == '[OK]')
            ]
        if table_data:
            keys = [
                'Name',
                'PhysicalContext',
                'Reading',
                'ReadingUnits',
                'Value',
                'State',
            ]
            headers = ['Sensor', 'Location', 'Reading', 'Unit', 'Value', 'State']
            msg += '\n\n' + lib.base.get_table(table_data, keys, header=headers)

        # redundancy
        table_data = []
        for redundancy in get_legacy_items(thermal, 'Redundancy'):
            redundancy = lib.redfish.get_chassis_thermal_redundancy(redundancy)
            if redundancy['Status_State'] in lib.redfish.UNMONITORED_STATES:
                continue
            perfdata_items.append((chassis['Id'], redundancy, 'Reading'))
            # is the redundancy state healthy at all?
            redundancy_state = lib.redfish.get_state(redundancy)
            state = lib.base.get_worst(state, redundancy_state)
            redundancy['State'] = lib.base.state2str(redundancy_state, empty_ok=False)
            table_data.append(redundancy)
        if args.BRIEF:
            table_data = [r for r in table_data if r.get('State') != '[OK]']
        if table_data:
            keys = ['Name', 'Mode', 'State']
            headers = ['Redundancy', 'Mode', 'State']
            msg += '\n\n' + lib.base.get_table(table_data, keys, header=headers)

        msg += '\n\n'

    # --inventory: emit the collected components as JSON and exit before
    # building the human-readable message and perfdata
    if args.INVENTORY:
        print(
            json.dumps(
                {'sensor': inventory}, ensure_ascii=False, indent=4, sort_keys=True
            )
        )
        sys.exit(STATE_OK)

    # build the message
    members = lib.txt.pluralize('member', member_count)
    if state == STATE_CRIT:
        msg = (
            f'Checked sensors on {member_count} {members}.'
            f' There are critical errors.\n\n'
        ) + msg
    elif state == STATE_WARN:
        msg = (
            f'Checked sensors on {member_count} {members}. There are warnings.\n\n'
        ) + msg
    else:
        msg = (
            f'Everything is ok. Checked sensors on {member_count} {members}.\n\n'
        ) + msg

    # the perfdata label is "<PhysicalContext>_<Name>" (see
    # lib.redfish.get_perfdata()). Blades, switches or trays of one enclosure
    # carry identical sensor names, so where a label occurs on more than one
    # chassis, the chassis id goes in front of it. Labels that are unique keep
    # their form, so a single server's graphs are not renamed.
    def perfdata_label(item):
        return f'{item.get("PhysicalContext", "")}_{item.get("Name", "")}'.replace(
            ' ', '_'
        )

    label_chassis = collections.defaultdict(set)
    for chassis_id, item, _ in perfdata_items:
        label_chassis[perfdata_label(item)].add(chassis_id)
    for chassis_id, item, key in perfdata_items:
        if len(label_chassis[perfdata_label(item)]) > 1:
            item = dict(
                item,
                PhysicalContext=' '.join(
                    part
                    for part in (str(chassis_id), item.get('PhysicalContext', ''))
                    if part
                ),
            )
        perfdata += lib.redfish.get_perfdata(item, key)

    if args.VERBOSE:
        msg += '\n\n' + lib.redfish.format_responses()

    # over and out
    lib.base.oao(
        msg, state, perfdata, always_ok=args.ALWAYS_OK, no_perfdata=args.NO_PERFDATA
    )


if __name__ == '__main__':
    try:
        main()
    except Exception:
        lib.base.cu()
