"""Chassis environment: legacy Thermal/Power, or ThermalSubsystem/PowerSubsystem/Sensors. Services from Redfish 2020.4 on link ``ThermalSubsystem``, ``PowerSubsystem`` and ``Sensors`` from the Chassis and deprecate ``Thermal`` and ``Power``; older ones (such as Supermicro X10) have only the latter. ``environment_sources`` picks per part, and both paths end in the same shape, the parts of NAPALM's ``get_environment``: - ``fans``: ``{name: {"status": bool}}`` - ``temperature``: ``{name: {"temperature": float, "is_alert": bool, "is_critical": bool}}`` - ``power``: ``{name: {"status": bool, "capacity": float, "output": float}}`` (-1.0 unknown) plus ``voltages`` (``{name: {"voltage", "is_alert", "is_critical"}}``) and ``consumed_watts`` for the whole chassis. Absent parts and sensors without a reading are left out. """ from __future__ import annotations from collections.abc import Iterable, Mapping from typing import Any from .common import link, number, present, status, text, unique_name _SENSOR_TYPES_BY_UNITS = { "Cel": "Temperature", "V": "Voltage", "W": "Power", "A": "Current", "RPM": "Rotational", "%": "Percent", "Hz": "Frequency", } def environment_sources(chassis: Mapping[str, Any]) -> dict[str, Any]: """Which resources to read for a Chassis, preferring the newer ones per part. Returns ``{"thermal": {"schema", "path"} | None, "power": ... | None, "sensors": path | None}``. """ def pick(new: str, old: str) -> dict[str, str] | None: for schema in (new, old): path = link(chassis, schema) if path is not None: return {"schema": schema, "path": path} return None return { "thermal": pick("ThermalSubsystem", "Thermal"), "power": pick("PowerSubsystem", "Power"), "sensors": link(chassis, "Sensors"), } def _ok(resource: Mapping[str, Any]) -> bool: """Working: enabled (or no state given) and healthy (or no health given).""" state, health = status(resource) return state in (None, "Enabled") and health in (None, "OK") def _health_flags(resource: Mapping[str, Any]) -> tuple[bool, bool]: _, health = status(resource) return health in ("Warning", "Critical"), health == "Critical" def _legacy_flags(item: Mapping[str, Any], reading: float) -> tuple[bool, bool]: """Alert/critical from Thermal/Power v1 threshold properties and health.""" alert, critical = _health_flags(item) def beyond(suffix: str) -> bool: upper = number(item.get(f"UpperThreshold{suffix}")) lower = number(item.get(f"LowerThreshold{suffix}")) return (upper is not None and reading >= upper) or (lower is not None and reading <= lower) if beyond("Critical") or beyond("Fatal"): return True, True if beyond("NonCritical"): alert = True return alert, critical def _sensor_flags(sensor: Mapping[str, Any], reading: float) -> tuple[bool, bool]: """Alert/critical from Sensor ``Thresholds`` and health.""" alert, critical = _health_flags(sensor) thresholds = sensor.get("Thresholds") if isinstance(sensor.get("Thresholds"), Mapping) else {} def beyond(level: str) -> bool: def limit(name: str) -> float | None: t = thresholds.get(name) return number(t.get("Reading")) if isinstance(t, Mapping) else None upper, lower = limit(f"Upper{level}"), limit(f"Lower{level}") return (upper is not None and reading >= upper) or (lower is not None and reading <= lower) if beyond("Critical") or beyond("Fatal"): return True, True if beyond("Caution"): alert = True return alert, critical def _name(item: Mapping[str, Any], *keys: str) -> str: for key in keys: name = text(item.get(key)) if name: return name return text(item.get("MemberId")) or text(item.get("Id")) or "unknown" def _members(resource: Mapping[str, Any], key: str) -> Iterable[tuple[int, Mapping[str, Any]]]: items = resource.get(key) for index, item in enumerate(items if isinstance(items, list) else []): if isinstance(item, Mapping): yield index, item # --- legacy ------------------------------------------------------------------------------- def parse_thermal(thermal: Mapping[str, Any]) -> dict[str, Any]: """``Chassis/{id}/Thermal``: ``fans`` and ``temperature``.""" temperature: dict[str, Any] = {} for index, item in _members(thermal, "Temperatures"): reading = number(item.get("ReadingCelsius")) if reading is None or not present(item): continue alert, critical = _legacy_flags(item, reading) name = unique_name(temperature, _name(item, "Name"), item.get("MemberId", index)) temperature[name] = {"temperature": reading, "is_alert": alert, "is_critical": critical} fans: dict[str, Any] = {} for index, item in _members(thermal, "Fans"): if not present(item): continue name = unique_name(fans, _name(item, "Name", "FanName"), item.get("MemberId", index)) fans[name] = {"status": _ok(item)} return {"fans": fans, "temperature": temperature} def parse_power(power: Mapping[str, Any]) -> dict[str, Any]: """``Chassis/{id}/Power``: ``power`` (supplies), ``voltages``, ``consumed_watts``.""" supplies: dict[str, Any] = {} for index, item in _members(power, "PowerSupplies"): psu = parse_power_supply(item) if not psu["present"]: continue name = unique_name(supplies, psu["name"], item.get("MemberId", index)) supplies[name] = { "status": psu["status"], "capacity": psu["capacity"], "output": psu["output"], } voltages: dict[str, Any] = {} for index, item in _members(power, "Voltages"): reading = number(item.get("ReadingVolts")) if reading is None or not present(item): continue alert, critical = _legacy_flags(item, reading) name = unique_name(voltages, _name(item, "Name"), item.get("MemberId", index)) voltages[name] = {"voltage": reading, "is_alert": alert, "is_critical": critical} consumed = None for _, item in _members(power, "PowerControl"): watts = number(item.get("PowerConsumedWatts")) if watts is not None: consumed = (consumed or 0.0) + watts return {"power": supplies, "voltages": voltages, "consumed_watts": consumed} # --- ThermalSubsystem, PowerSubsystem, Sensors -------------------------------------------- def parse_thermal_subsystem(subsystem: Mapping[str, Any]) -> dict[str, Any]: """``Chassis/{id}/ThermalSubsystem``: where the fans and metrics are.""" state, health = status(subsystem) return { "fans_path": link(subsystem, "Fans"), "thermal_metrics_path": link(subsystem, "ThermalMetrics"), "state": state, "health": health, } def parse_power_subsystem(subsystem: Mapping[str, Any]) -> dict[str, Any]: """``Chassis/{id}/PowerSubsystem``: where the supplies are, total capacity.""" state, health = status(subsystem) return { "power_supplies_path": link(subsystem, "PowerSupplies"), "capacity_watts": number(subsystem.get("CapacityWatts")), "state": state, "health": health, } def _excerpt(resource: Mapping[str, Any], key: str, field: str = "Reading") -> float | None: value = resource.get(key) return number(value.get(field)) if isinstance(value, Mapping) else None def parse_fan(fan: Mapping[str, Any]) -> dict[str, Any]: """One member of ``ThermalSubsystem/Fans``.""" state, health = status(fan) rpm = _excerpt(fan, "SpeedPercent", "SpeedRPM") return { "id": text(fan.get("Id")), "name": _name(fan, "Name"), "present": present(fan), "status": _ok(fan), "speed_rpm": int(rpm) if rpm is not None else None, "speed_percent": _excerpt(fan, "SpeedPercent"), "state": state, "health": health, } def parse_power_supply( supply: Mapping[str, Any], metrics: Mapping[str, Any] | None = None ) -> dict[str, Any]: """One member of ``PowerSubsystem/PowerSupplies``, with its ``Metrics`` if read. An item of the legacy ``Power.PowerSupplies`` array parses too (``MemberId`` as id, ``LastPowerOutputWatts`` as output). """ state, health = status(supply) capacity = number(supply.get("PowerCapacityWatts")) output = _excerpt(metrics, "OutputPowerWatts") if metrics else None if output is None: output = number(supply.get("LastPowerOutputWatts")) return { "id": text(supply.get("Id")) or text(supply.get("MemberId")), "name": _name(supply, "Name"), "present": present(supply), "status": _ok(supply), "capacity": capacity if capacity is not None else -1.0, "output": output if output is not None else -1.0, "manufacturer": text(supply.get("Manufacturer")), "model": text(supply.get("Model")), "serial_number": text(supply.get("SerialNumber")), "part_number": text(supply.get("PartNumber")), "firmware_version": text(supply.get("FirmwareVersion")), "state": state, "health": health, } def parse_sensor(sensor: Mapping[str, Any]) -> dict[str, Any]: """One member of ``Chassis/{id}/Sensors``. ``reading_type`` falls back to one derived from ``ReadingUnits`` (some sensors, such as temperatures in the DMTF mockups, carry only units). """ state, health = status(sensor) units = text(sensor.get("ReadingUnits")) reading = number(sensor.get("Reading")) alert, critical = _sensor_flags(sensor, reading) if reading is not None else (False, False) return { "id": text(sensor.get("Id")), "name": _name(sensor, "Name"), "reading_type": text(sensor.get("ReadingType")) or _SENSOR_TYPES_BY_UNITS.get(units or ""), "reading": reading, "units": units, "physical_context": text(sensor.get("PhysicalContext")), "electrical_context": text(sensor.get("ElectricalContext")), "state": state, "health": health, "is_alert": alert, "is_critical": critical, } def parse_thermal_metrics(metrics: Mapping[str, Any]) -> dict[str, Any]: """``ThermalSubsystem/ThermalMetrics``: temperatures without thresholds. A fallback for services with a ThermalSubsystem but no Sensors collection. """ temperature: dict[str, Any] = {} for index, item in _members(metrics, "TemperatureReadingsCelsius"): reading = number(item.get("Reading")) if reading is None: continue name = unique_name(temperature, _name(item, "DeviceName"), index) temperature[name] = {"temperature": reading, "is_alert": False, "is_critical": False} return {"temperature": temperature} def build_environment( *, fans: Iterable[Mapping[str, Any]] = (), power_supplies: Iterable[Mapping[str, Any]] = (), sensors: Iterable[Mapping[str, Any]] = (), ) -> dict[str, Any]: """Combine parsed fans, power supplies and sensors into the shape of the legacy parsers. ``consumed_watts`` comes from the chassis total power sensor (``ReadingType`` Power, ``ElectricalContext`` Total, ``PhysicalContext`` Chassis). """ env: dict[str, Any] = { "fans": {}, "temperature": {}, "power": {}, "voltages": {}, "consumed_watts": None, } for fan in fans: if fan["present"]: name = unique_name(env["fans"], fan["name"], fan["id"]) env["fans"][name] = {"status": fan["status"]} for psu in power_supplies: if psu["present"]: name = unique_name(env["power"], psu["name"], psu["id"]) env["power"][name] = { "status": psu["status"], "capacity": psu["capacity"], "output": psu["output"], } for sensor in sensors: reading = sensor["reading"] if reading is None: continue flags = {"is_alert": sensor["is_alert"], "is_critical": sensor["is_critical"]} if sensor["reading_type"] == "Temperature": name = unique_name(env["temperature"], sensor["name"], sensor["id"]) env["temperature"][name] = {"temperature": reading, **flags} elif sensor["reading_type"] == "Voltage": name = unique_name(env["voltages"], sensor["name"], sensor["id"]) env["voltages"][name] = {"voltage": reading, **flags} elif ( sensor["reading_type"] == "Power" and sensor["electrical_context"] == "Total" and sensor["physical_context"] == "Chassis" and env["consumed_watts"] is None ): env["consumed_watts"] = reading return env