# coding: utf8
# Copyright 2021 Lenovo
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""
The command module for redfish systems.  Provides https-only support
for redfish compliant endpoints
"""

import asyncio
import json
import os
import re
import socket
import struct
import sys


import aiohmi.constants as const
import aiohmi.exceptions as exc
import aiohmi.redfish.oem.lookup as oem
from aiohmi.util.parse import parse_time
import aiohmi.util.webclient as webclient


numregex = re.compile('([0-9]+)')


powerstates = {
    'on': 'On',
    'off': 'ForceOff',
    'softoff': 'GracefulShutdown',
    'shutdown': 'GracefulShutdown',
    'reset': 'ForceRestart',
    'boot': None,
}


boot_devices_read = {
    'BiosSetup': 'setup',
    'Cd': 'optical',
    'Floppy': 'floppy',
    'Hdd': 'hd',
    'None': 'default',
    'Pxe': 'network',
    'Usb': 'usb',
    'SDCard': 'sdcard',
    'UefiHttp': 'http',
}


_healthmap = {
    'Critical': const.Health.Critical,
    'Unknown': const.Health.Warning,
    'Warning': const.Health.Warning,
    'OK': const.Health.Ok,
    None: const.Health.Ok,
}

# A sensor from the modern Sensors collection describes itself by its redfish
# reading type, while a sensor from the older Thermal and Power documents, and
# every sensor over ipmi, is described by the ipmi sensor type.  Callers ask
# for a category by the latter, so translate, and a sensor means the same thing
# whichever document it was read from.
_readingtypes = {
    'Temperature': 'Temperature',
    'Rotational': 'Fan',
    'AirFlow': 'Cooling Device',
    'Voltage': 'Voltage',
    'Current': 'Current',
    'Power': 'Power',
    'EnergyJoules': 'Energy',
    'EnergykWh': 'Energy',
    'EnergyWh': 'Energy',
}


def _mask_to_cidr(mask):
    maskn = socket.inet_pton(socket.AF_INET, mask)
    maskn = struct.unpack('!I', maskn)[0]
    cidr = 32
    while maskn & 0b1 == 0 and cidr > 0:
        cidr -= 1
        maskn >>= 1
    return cidr



def _cidr_to_mask(cidr):
    return socket.inet_ntop(
        socket.AF_INET, struct.pack(
            '!I', (2**32 - 1) ^ (2**(32 - cidr) - 1)))


def naturalize_string(key):
    """Analyzes string in a human way to enable natural sort

    :param nodename: The node name to analyze
    :returns: A structure that can be consumed by 'sorted'
    """
    return [int(text) if text.isdigit() else text.lower()
            for text in re.split(numregex, key)]


def natural_sort(iterable):
    """Return a sort using natural sort if possible

    :param iterable:
    :return:
    """
    try:
        return sorted(iterable, key=naturalize_string)
    except TypeError:
        # The natural sort attempt failed, fallback to ascii sort
        return sorted(iterable)


class SensorReading(object):
    def __init__(self, healthinfo, sensor=None, value=None, units=None,
                 unavailable=False):
        self.states = []
        if sensor:
            self.name = sensor['name']
        else:
            self.name = healthinfo['Name']
            self.health = _healthmap.get(healthinfo.get(
                'Status', {}).get('Health', None), const.Health.Warning)
            self.states = [healthinfo.get('Status', {}).get('Health',
                                                            'Unknown')]
            if self.health == const.Health.Ok:
                self.states = []
        self.value = value
        self.state_ids = None
        self.imprecision = None
        self.units = units
        self.unavailable = unavailable
    
    def __repr__(self):
        return repr({
            'value': self.value,
            'state_ids': self.state_ids,
            'units': self.units,
            'imprecision': self.imprecision,
            'name': self.name,
            'unavailable': self.unavailable,
        })


class Command(object):

    @classmethod
    async def create(cls, bmc, userid, password, verifycallback, sysurl=None,
                 bmcurl=None, chassisurl=None, pool=None, port=443):
        self = cls()
        self.wc = webclient.WebConnection(
            bmc, port, verifycallback=verifycallback)
        self._hwnamemap = {}
        self._fwnamemap = {}
        self._urlcache = {}
        self._varbmcurl = bmcurl
        self._varbiosurl = None
        self._varbmcnicurl = None
        self._varsetbiosurl = None
        self._varchassisurl = chassisurl
        self._varresetbmcurl = None
        self._varupdateservice = None
        self._varfwinventory = None
        self._oem = None
        self._gpool = pool
        self._bmcv4ip = None
        self._bmcv6ip = None
        self.xauthtoken = None
        addrinfo = await asyncio.get_running_loop().getaddrinfo(
            bmc, 0, family=0, type=socket.SOCK_STREAM)
        for addrinf in addrinfo:
            if addrinf[0] == socket.AF_INET:
                self._bmcv4ip = socket.inet_pton(addrinf[0], addrinf[-1][0])
            elif addrinf[0] == socket.AF_INET6:
                theip = addrinf[-1][0]
                theip = theip.split('%')[0]
                self._bmcv6ip = socket.inet_pton(addrinf[0], theip)
        self.wc.set_header('Accept', 'application/json')
        self.wc.set_header('User-Agent', 'aiohmi')
        self.wc.set_header('Accept-Encoding', 'gzip')
        self.wc.set_header('OData-Version', '4.0')
        overview = await self.wc.grab_json_response('/redfish/v1/')
        self.username = userid
        self.password = password
        self.wc.set_basic_credentials(self.username, self.password)
        self.wc.set_header('Content-Type', 'application/json')
        if 'Systems' not in overview and 'Managers' not in overview:
            raise exc.PyghmiException('Redfish not ready')
        if 'SessionService' in overview:
            await self._get_session_token(self.wc)
        self._varsensormap = {}
        self.powerurl = None
        self.sysurl = None
        self._initsysurl = sysurl
        tmpoem = await oem.get_oem_handler({}, sysurl, self.wc, self._urlcache, self,
                                    rootinfo=overview)
        self._varbmcurl = await tmpoem.get_default_mgrurl()        
        if 'Systems' in overview:
            self.sysurl = await tmpoem.get_default_sysurl()
            sysinfo = await self.sysinfo()
            self.powerurl = sysinfo.get('Actions', {}).get(
                '#ComputerSystem.Reset', {}).get('target', None)               
        return self

    async def _get_session_token(self, wc):
        # specification actually indicates we can skip straight to this url
        username = self.username
        password = self.password
        if not isinstance(username, str):
            username = username.decode()
        if not isinstance(password, str):
            password = password.decode()
        _, status, headers = await wc.grab_response_with_status('/redfish/v1/SessionService/Sessions',
                          {'UserName': username, 'Password': password})
        if status > 299 or status < 200:
            return
        self.xauthtoken = headers.get('X-Auth-Token')
        if self.xauthtoken:
            if 'Authorization' in wc.stdheaders:
                del wc.stdheaders['Authorization']
            if 'Authorization' in self.wc.stdheaders:
                del self.wc.stdheaders['Authorization']
            wc.stdheaders['X-Auth-Token'] = self.xauthtoken
            self.wc.stdheaders['X-Auth-Token'] = self.xauthtoken

    async def _accountserviceurl(self):
        sroot = await self._do_web_request('/redfish/v1/')
        return sroot.get('AccountService', {}).get('@odata.id', None)

    async def _validroles(self):
        okroles = set()
        roleurl = (await self._do_web_request(await self._accountserviceurl())).get(
            'Roles', {}).get('@odata.id', None)
        if roleurl:
            roles = (await self._do_web_request(roleurl)).get('Members', [])
            for role in roles:
                role = role.get('@odata.id', '')
                if not role:
                    continue
                okroles.add(role.split('/')[-1])
        if not okroles:
            okroles.add('Administrator')
            okroles.add('Operator')
            okroles.add('ReadOnly')
        return okroles

    async def get_trusted_cas(self):
        oem = await self.oem()
        async for ca in oem.get_trusted_cas():
            yield ca
    
    async def get_bmc_csr(self, keytype=None, keylength=None, cn=None, city=None,
                    state=None, country=None, org=None, orgunit=None):
        oem = await self.oem()
        return await oem.get_bmc_csr(
            keytype=keytype, keylength=keylength, cn=cn)

    async def install_bmc_certificate(self, certdata):
        oem = await self.oem()
        return await oem.install_bmc_certificate(certdata)
    
    async def add_trusted_ca(self, pemdata):
        oem = await self.oem()
        return await oem.add_trusted_ca(pemdata)
    
    async def del_trusted_ca(self, certid):
        oem = await self.oem()
        return await oem.del_trusted_ca(certid)

    async def get_users(self):
        """get list of users and channel access information (helper)

        :param channel: number [1:7]

        :return:
            name: (str)
            uid: (int)
            channel: (int)
            access:
                callback (bool)
                link_auth (bool)
                ipmi_msg (bool)
                privilege_level: (str)[callback, user, operatorm administrator,
                                       proprietary, no_access]
        """
        srvurl = await self._accountserviceurl()
        names = {}
        if srvurl:
            srvinfo = await self._do_web_request(srvurl)
            srvurl = srvinfo.get('Accounts', {}).get('@odata.id', None)
            if srvurl:
                srvinfo = await self._do_web_request(srvurl)
                accounts = srvinfo.get('Members', [])
                oem = await self.oem()
                for account in accounts:
                    accinfo = await self._do_web_request(account['@odata.id'])
                    currname = accinfo.get('UserName', '')
                    currid = accinfo.get('Id', None)
                    if currname:
                        names[currid] = {
                            'name': currname,
                            'uid': currid,
                            'expiration': await oem.get_user_expiration(currid),
                            'access': {
                                'privilege_level': accinfo.get('RoleId',
                                                               'Unknown')
                            }
                        }
        return names

    async def _account_url_info_by_id(self, uid, cache=True):
        srvurl = await self._accountserviceurl()
        oem = await self.oem()
        if srvurl:
            srvinfo = await self._do_web_request(srvurl, cache=cache)
            srvurl = srvinfo.get('Accounts', {}).get('@odata.id', None)
            if srvurl:
                srvinfo = await self._do_web_request(srvurl, cache=cache)
                accounts = srvinfo.get('Members', [])
                for account in accounts:
                    accinfo = await self._do_web_request(account['@odata.id'],
                                                         cache=cache)
                    currid = accinfo.get('Id', None)
                    if str(currid) == str(uid):
                        accinfo['expiration'] = await oem.get_user_expiration(
                            uid)
                        return account['@odata.id'], accinfo

    async def get_user(self, uid):
        srvurl = await self._accountserviceurl()
        oem = await self.oem()
        if srvurl:
            srvinfo = await self._do_web_request(srvurl)
            srvurl = srvinfo.get('Accounts', {}).get('@odata.id', None)
            if srvurl:
                srvinfo = await self._do_web_request(srvurl)
                accounts = srvinfo.get('Members', [])
                for account in accounts:
                    accinfo = await self._do_web_request(account['@odata.id'])
                    currname = accinfo.get('UserName', '')
                    currid = accinfo.get('Id', None)
                    if str(currid) == str(uid):
                        oem = await self.oem()
                        return {'name': currname, 'uid': uid,
                                'expiration': await oem.get_user_expiration(
                                    uid),
                                'access': {
                                    'privilege_level': accinfo.get(
                                        'RoleId', 'Unknown')}}

    async def set_user_password(self, uid, mode='set_password', password=None):
        """Set user password and (modes)

        :param uid: id number of user.  see: get_names_uid()['name']

        :param mode:
            disable       = disable user connections
            enable        = enable user connections
            set_password  = set or ensure password

        :param password: Password
            (optional when mode is [disable or enable])

        :return:
            True on success
        """

        accinfo = await self._account_url_info_by_id(uid)
        if not accinfo:
            raise Exception("No such account found")
        etag = accinfo[1].get('@odata.etag', None)
        if mode == 'set_password':
            await self._do_web_request(accinfo[0], {'Password': password},
                                 method='PATCH', etag=etag)
        elif mode == 'disable':
            await self._do_web_request(accinfo[0], {'Enabled': False},
                                 method='PATCH', etag=etag)
        elif mode == 'enable':
            await self._do_web_request(accinfo[0], {'Enabled': True},
                                 method='PATCH', etag=etag)
        return True

    async def disable_user(self, uid, mode):
        """Disable User

        Just disable the User.
        This will not disable the password or revoke privileges.

        :param uid: user id
        :param mode:
            disable       = disable user connections
            enable        = enable user connections
        """
        await self.set_user_password(uid, mode)
        return True

    async def set_user_access(self, uid, privilege_level='ReadOnly'):
        if privilege_level.startswith('custom.'):
            privilege_level = privilege_level.replace('custom.', '')
        accinfo = await self._account_url_info_by_id(uid)
        if accinfo:
            method = 'PATCH'
        else:
            accinfo = ((await self._accountserviceurl()) + '/Accounts', {})
            method = 'POST'
        etag = accinfo[1].get('@odata.etag', None)
        for role in await self._validroles():
            if role.lower() == privilege_level.lower():
                privilege_level = role
                break
        await self._do_web_request(accinfo[0], {'RoleId': privilege_level},
                             method=method, etag=etag)

    async def create_user(self, uid, name, password, privilege_level='ReadOnly'):
        """create/ensure a user is created with provided settings

        :param privilege_level:
            User Privilege level.  Redfish role, commonly Administrator,
            Operator, and ReadOnly
        """
        accinfo = await self._account_url_info_by_id(uid)
        if not accinfo:
            raise Exception("Unable to find indicated uid")
        if privilege_level.startswith('custom.'):
            privilege_level = privilege_level.replace('custom.', '')
        for role in await self._validroles():
            if role.lower() == privilege_level.lower():
                privilege_level = role
                break
        etag = accinfo[1].get('@odata.etag', None)
        userinfo = {
            "UserName": name,
            "Password": password,
            "RoleId": privilege_level,
        }
        await self._do_web_request(accinfo[0], userinfo, method='PATCH', etag=etag)
        return True

    async def get_screenshot(self, outfile):
        oem = await self.oem()
        return await oem.get_screenshot(outfile)

    async def get_ikvm_methods(self):
        oem = await self.oem()
        return await oem.get_ikvm_methods()

    async def get_ikvm_launchdata(self):
        oem = await self.oem()
        return await oem.get_ikvm_launchdata()   

    async def user_delete(self, uid):
        oem = await self.oem()
        return await oem.user_delete(uid, self)

    async def set_user_name(self, uid, name):
        """Set user name

        :param uid: user id
        :param name: username
        """
        accinfo = await self._account_url_info_by_id(uid)
        if not accinfo:
            raise Exception("No such account found")
        etag = accinfo[1].get('@odata.etag', None)
        await self._do_web_request(accinfo[0], {'UserName': name}, method='PATCH',
                             etag=etag)
        return True

    async def _updateservice(self):
        if not self._varupdateservice:
            overview = await self._do_web_request('/redfish/v1/')
            us = overview.get('UpdateService', {}).get('@odata.id', None)
            if not us:
                raise exc.UnsupportedFunctionality(
                    'BMC does not implement extended firmware information')
            self._varupdateservice = us
        return self._varupdateservice

    async def get_fwinventory(self):
        if not self._varfwinventory:
            usi = await self._do_web_request(await self._updateservice())
            self._varfwinventory = usi.get('FirmwareInventory', {}).get(
                '@odata.id', None)
            if not self._varfwinventory:
                raise exc.UnsupportedFunctionality(
                    'BMC does not implement extended firmware information')
        return self._varfwinventory

    def _system_url(self):
        """The system this client acts on, or a refusal that says so.

        Power and cooling equipment publishes no Systems collection, which is
        legal. sysurl is then None, and handing that to a request raised
        TypeError from inside the url library.
        """
        if not self.sysurl:
            raise exc.UnsupportedFunctionality(
                'this service publishes no computer system to act on')
        return self.sysurl

    async def sysinfo(self):
        if not self.sysurl:
            return {}
        try:
            return await self._do_web_request(self.sysurl)
        except exc.RedfishError:
            self.sysurl = None
            return {}

    async def bmcinfo(self):
        bmcurl = await self.get_bmcurl()
        if not bmcurl:
            raise exc.PyghmiException('Unable to identify BMC')
        return await self._do_web_request(bmcurl)

    async def get_power(self):
        currinfo = await self._do_web_request(self._system_url(), cache=False)
        return {'powerstate': str(currinfo['PowerState'].lower())}

    async def reseat_bay(self, bay):
        """Request the reseat of a bay

        Request the enclosure manager to reseat the system in a particular
        bay.

        :param bay: The bay identifier to reseat
        :return:
        """
        oem = await self.oem()
        await oem.reseat_bay(bay)

    async def set_power(self, powerstate, wait=False):
        if powerstate == 'boot':
            oldpowerstate = (await self.get_power())['powerstate']
            powerstate = 'on' if oldpowerstate == 'off' else 'reset'
        elif powerstate in ('on', 'off'):
            oldpowerstate = (await self.get_power())['powerstate']
            if oldpowerstate == powerstate:
                return {'powerstate': powerstate}
        reqpowerstate = powerstate
        if powerstate not in powerstates:
            raise exc.InvalidParameterValue(
                "Unknown power state %s requested" % powerstate)
        powerstate = powerstates[powerstate]
        await self._do_web_request(
            self.powerurl, {'ResetType': powerstate})
        if wait and reqpowerstate in ('on', 'off', 'softoff', 'shutdown'):
            if reqpowerstate in ('softoff', 'shutdown'):
                reqpowerstate = 'off'
            timeout = os.times()[4] + 300
            while (await self.get_power())['powerstate'] != reqpowerstate and os.times()[4] < timeout:
                await asyncio.sleep(1)
            if (await self.get_power())['powerstate'] != reqpowerstate:
                raise exc.PyghmiException(
                    "System did not accomplish power state change")
            return {'powerstate': reqpowerstate}
        return {'pendingpowerstate': reqpowerstate}

    def _get_cache(self, url, cache=30):
        now = os.times()[4]
        cachent = self._urlcache.get(url, None)
        if cachent and cachent['vintage'] > now - cache:
            return cachent['contents']
        return None

    async def _do_bulk_requests(self, urls, cache=True):
        if self._gpool:
            urls = [(x, None, None, cache) for x in urls]
            for res in self._gpool.starmap(self._do_web_request_withurl, urls):
                yield res
        else:
            for url in urls:
                yield await self._do_web_request_withurl(url, cache=cache)

    async def _do_web_request_withurl(self, url, payload=None, method=None,
                                cache=True):
        return await self._do_web_request(url, payload, method, cache), url

    async def _do_web_request(self, url, payload=None, method=None, cache=True,
                        etag=None):
        res = None
        if cache is True:
            cache = 30
        if cache and payload is None and method is None:
            res = self._get_cache(url, cache)
        if res:
            return res
        wc = self.wc
        if etag:
            wc.stdheaders['If-Match'] = etag
        try:
            res = await wc.grab_json_response_with_status(url, payload,
                                                    method=method)
        finally:
            if 'If-Match' in wc.stdheaders:
                del wc.stdheaders['If-Match']
        if res[1] == 401 and self.xauthtoken:
            wc.set_basic_credentials(self.username, self.password)
            await self._get_session_token(wc)
            if etag:
                wc.stdheaders['If-Match'] = etag
            try:
                res = await wc.grab_json_response_with_status(url, payload,
                                                              method=method)
            finally:
                if 'If-Match' in wc.stdheaders:
                    del wc.stdheaders['If-Match']

        if res[1] < 200 or res[1] >= 300:
            try:
                info = json.loads(res[0])
                errmsg = [
                    x.get('Message', x['MessageId']) for x in info.get(
                        'error', {}).get('@Message.ExtendedInfo', {})]
                msgid = [
                    x['MessageId'] for x in info.get(
                        'error', {}).get('@Message.ExtendedInfo', {})]
                errmsg = ','.join(errmsg)
                msgid = ','.join(msgid)
                raise exc.RedfishError(errmsg, msgid=msgid)
            except (ValueError, KeyError):
                raise exc.PyghmiException(str(url) + ":" + str(res[0]))
        if payload is None and method is None:
            self._urlcache[url] = {'contents': res[0],
                                   'vintage': os.times()[4]}
        elif method != 'GET':
            # A write may have changed anything, including documents other than
            # the one written (an action url is not the resource it acts on), so
            # do not let a cached read from before the write be handed out as
            # the current state
            self._urlcache.clear()
        return res[0]

    async def get_bootdev(self):
        """Get current boot device override information.

        :raises: PyghmiException on error
        :returns: dict
        """
        result = await self._do_web_request(self._system_url())
        overridestate = result.get('Boot', {}).get(
            'BootSourceOverrideEnabled', None)
        if overridestate == 'Disabled':
            return {'bootdev': 'default', 'persistent': True}
        persistent = None
        if overridestate == 'Once':
            persistent = False
        elif overridestate == 'Continuous':
            persistent = True
        else:
            raise exc.PyghmiException('Unrecognized Boot state: %s'
                                      % repr(overridestate))
        uefimode = result.get('Boot', {}).get('BootSourceOverrideMode', None)
        if uefimode == 'UEFI':
            uefimode = True
        elif uefimode == 'Legacy':
            uefimode = False
        else:
            raise exc.PyghmiException('Unrecognized mode: %s' % uefimode)
        bootdev = result.get('Boot', {}).get('BootSourceOverrideTarget', None)
        if bootdev not in boot_devices_read:
            raise exc.PyghmiException('Unrecognized boot target: %s'
                                      % repr(bootdev))
        bootdev = boot_devices_read[bootdev]
        return {'bootdev': bootdev, 'persistent': persistent,
                'uefimode': uefimode}

    async def set_bootdev(self, bootdev, persist=False, uefiboot=None):
        """Set boot device to use on next reboot

        :param bootdev:
                        *network -- Request network boot
                        *hd -- Boot from hard drive
                        *safe -- Boot from hard drive, requesting 'safe mode'
                        *optical -- boot from CD/DVD/BD drive
                        *setup -- Boot into setup utility
                        *default -- remove any directed boot device request
        :param persist: If true, ask that system firmware use this device
                        beyond next boot.  Be aware many systems do not honor
                        this
        :param uefiboot: If true, request UEFI boot explicitly.  If False,
                         request BIOS style boot.
                         None (default) does not modify the boot mode.
        :raises: PyghmiException on an error.
        :returns: dict or True -- If callback is not provided, the response
        """
        oem = await self.oem()
        return await oem.set_bootdev(bootdev, persist, uefiboot, self)

    async def get_biosurl(self):
        if self._varbiosurl is None:
            sysinfo = await self.sysinfo()
            biosurl = sysinfo.get('Bios', {}).get('@odata.id', '')
            if biosurl:
                # A link may be advertised and not served.  To a caller that is
                # the same as not having one, so answer the same way rather
                # than passing the bmc's complaint on as an unexpected error.
                try:
                    await self._do_web_request(biosurl)
                except exc.PyghmiException:
                    # A link that is not served need not answer with a redfish
                    # error, and the exception cannot say what the status was,
                    # so ask.  Anything else is a fault, not an answer.
                    _, status = await self.wc.grab_json_response_with_status(
                        biosurl)
                    if status not in (404, 405, 501):
                        raise
                    biosurl = ''
            # '' is remembered as 'asked, and there is none'
            self._varbiosurl = biosurl
        if not self._varbiosurl:
            raise exc.UnsupportedFunctionality(
                'Bios management not detected on this platform')
        return self._varbiosurl

    async def get_setbiosurl(self):
        if self._varsetbiosurl is None:
            biosinfo = await self._do_web_request(await self.get_biosurl())
            self._varsetbiosurl = biosinfo.get(
                '@Redfish.Settings', {}).get('SettingsObject', {}).get(
                    '@odata.id', None)
        if self._varsetbiosurl is None:
            raise exc.UnsupportedFunctionality('Ability to set BIOS settings '
                                               'not detected on this platform')
        return self._varsetbiosurl

    async def _sensormap(self):
        if not self._varsensormap:
            sysinfo = await self.sysinfo()
            if sysinfo:
                for chassis in sysinfo.get('Links', {}).get('Chassis', []):
                    await self._mapchassissensors(chassis)
            else:  # no system, but check if this is a singular chassis
                rootinfo = await self._do_web_request('/redfish/v1/')
                chassiscol = rootinfo.get('Chassis', {}).get('@odata.id', '')
                if chassiscol:
                    chassislist = await self._do_web_request(chassiscol)
                    if len(chassislist.get('Members', [])) == 1:
                        await self._mapchassissensors(chassislist['Members'][0])
        return self._varsensormap

    async def _mapchassissensors(self, chassis):
        chassisurl = chassis['@odata.id']
        chassisinfo = await self._do_web_request(chassisurl)
        sensors = None
        oem = await self.oem()
        if oem.usegenericsensors:
            sensors = chassisinfo.get('Sensors', {}).get('@odata.id', '')
        if sensors:
            sensorinf = await self._do_web_request(sensors)
            for sensor in sensorinf.get('Members', []):
                sensedata = await self._do_web_request(sensor['@odata.id'])
                if 'Name' in sensedata:
                    sensetype = sensedata.get('ReadingType', 'Unknown')
                    sensetype = _readingtypes.get(sensetype, sensetype)
                    self._varsensormap[sensedata['Name']] = {
                        'name': sensedata['Name'], 'type': sensetype,
                        'url': sensor['@odata.id'], 'generic': True}
        else:
            powurl = chassisinfo.get('Power', {}).get('@odata.id', '')
            if powurl:
                powinf = await self._do_web_request(powurl)
                for voltage in powinf.get('Voltages', []):
                    if 'Name' in voltage:
                        self._varsensormap[voltage['Name']] = {
                            'name': voltage['Name'], 'url': powurl,
                            'type': 'Voltage'}
            thermurl = chassisinfo.get('Thermal', {}).get('@odata.id', '')
            if thermurl:
                therminf = await self._do_web_request(thermurl)
                for fan in therminf.get('Fans', []):
                    if 'Name' in fan:
                        self._varsensormap[fan['Name']] = {
                            'name': fan['Name'], 'type': 'Fan',
                            'url': thermurl}
                for temp in therminf.get('Temperatures', []):
                    if 'Name' in temp:
                        self._varsensormap[temp['Name']] = {
                            'name': temp['Name'], 'type': 'Temperature',
                            'url': thermurl}      
        for subchassis in chassisinfo.get('Links', {}).get('Contains', []):
            await self._mapchassissensors(subchassis)

    async def _get_thermals(self, chassis):
        chassisurl = chassis['@odata.id']
        chassisinfo = await self._do_web_request(chassisurl)
        thermurl = chassisinfo.get('Thermal', {}).get('@odata.id', '')
        if thermurl:
            therminf = await self._do_web_request(thermurl, cache=1)
            return therminf.get('Temperatures', [])

    async def get_bmcurl(self):
        if not self._varbmcurl:
            sysinfo = await self.sysinfo()
            self._varbmcurl = sysinfo.get('Links', {}).get(
                'ManagedBy', [{}])[0].get('@odata.id', None)
        return self._varbmcurl

    async def get_bmcnicurl(self):
        if not self._varbmcnicurl:
            self._varbmcnicurl = await self._get_bmc_nic_url()
        return self._varbmcnicurl

    async def list_network_interface_names(self):
        bmcurl = await self.get_bmcurl()
        bmcinfo = await self._do_web_request(bmcurl)
        nicurl = bmcinfo.get('EthernetInterfaces', {}).get('@odata.id', None)
        if not nicurl:
            return
        niclist = await self._do_web_request(nicurl)
        for nic in niclist.get('Members', []):
            curl = nic.get('@odata.id', None)
            if not curl:
                continue
            yield curl.rsplit('/', 1)[1]

    async def _get_bmc_nic_url(self, name=None):
        bmcinfo = await self._do_web_request(await self.get_bmcurl())
        nicurl = bmcinfo.get('EthernetInterfaces', {}).get('@odata.id', None)
        if not nicurl:
            # Also optional. The None went straight into a request and
            # raised TypeError out of the url library.
            raise exc.UnsupportedFunctionality(
                'BMC publishes no network interface collection of its own')
        niclist = await self._do_web_request(nicurl)
        candidates = []
        oem = await self.oem()
        for nic in niclist.get('Members', []):
            curl = nic.get('@odata.id', None)
            if not curl:
                continue
            if name is not None:
                if curl.endswith('/{0}'.format(name)):
                    return curl
                continue
            if oem.hostnic and curl.endswith('/{0}'.format(
                    oem.hostnic)):
                continue
            nicinfo = await self._do_web_request(curl)
            if nicinfo.get('Links', {}).get('HostInterface', None):
                # skip host interface
                continue
            if not nicinfo.get('InterfaceEnabled', True):
                # skip disabled interfaces
                continue
            for addrs in nicinfo.get('IPv4Addresses', []):
                v4addr = socket.inet_pton(
                    socket.AF_INET, addrs.get('Address', '0.0.0.0'))
                if self._bmcv4ip == v4addr:
                    return curl
            for addrs in nicinfo.get('IPv6Addresses', []):
                v6addr = socket.inet_pton(
                    socket.AF_INET6, addrs.get('Address', '::'))
                if self._bmcv6ip == v6addr:
                    return curl
            candidates.append(curl)
        if name is not None:
            return None
        if len(candidates) == 1:
            return candidates[0]
        # UnsupportedFunctionality, not the bare base class, which confluent
        # maps through its generic handler and shows as "Unexpected Error".
        if not candidates:
            raise exc.UnsupportedFunctionality(
                'BMC published no enabled network interface of its own')
        # Several NICs is ordinary. Reaching here means the address this
        # session came in on matched none of them, which a tunnel or a NAT
        # does, and picking one to reconfigure would be a guess. Every caller
        # takes a name.
        raise exc.UnsupportedFunctionality(
            'BMC has {0} enabled interfaces and none of them carries the '
            'address this session connected to, so which one is meant cannot '
            'be determined. Name one of: {1}'.format(
                len(candidates),
                ', '.join(url.rsplit('/', 1)[-1] for url in candidates)))

    async def _bmcresetinfo(self):
        if not self._varresetbmcurl:
            bmcinfo = await self._do_web_request(await self.get_bmcurl())
            resetinf = bmcinfo.get('Actions', {}).get('#Manager.Reset', {})
            url = resetinf.get('target', '')
            valid = resetinf.get('ResetType@Redfish.AllowableValues', [])
            if not valid:
                tmpurl = resetinf.get('@Redfish.ActionInfo', None)
                if tmpurl:
                    resetinf = await self._do_web_request(tmpurl)
                    valid = resetinf.get('Parameters', [{}])[0].get(
                        'AllowableValues')
            resettype = None
            if 'GracefulRestart' in valid:
                resettype = 'GracefulRestart'
            elif 'ForceRestart' in valid:
                resettype = 'ForceRestart'
            elif 'ColdReset' in valid:
                resettype = 'ColdReset'
            self._varresetbmcurl = url, resettype
        return self._varresetbmcurl

    async def reset_bmc(self):
        url, action = await self._bmcresetinfo()
        if not url:
            raise Exception('BMC does not provide reset action')
        if not action:
            raise Exception('BMC does not accept a recognized reset type')
        await self._do_web_request(url, {'ResetType': action})

    async def set_identify(self, on=True, blink=None):
        oem = await self.oem()
        if hasattr(oem, 'set_identify'):
            return await oem.set_identify(on, blink)    
        targurl = self.sysurl
        if not targurl:
            root = await self._do_web_request('/redfish/v1')
            systemsurl = root.get('Systems', {}).get('@odata.id', None)
            if systemsurl:
                targurl = await self._do_web_request(systemsurl)
                if len(targurl.get('Members', [])) == 1:
                    targurl = targurl['Members'][0]['@odata.id']
        if not targurl:
            raise Exception("Unable to identify system url")
        targinfo = await self._do_web_request(targurl)
        if ('IndicatorLED' not in targinfo
                and 'LocationIndicatorActive' not in targinfo):
            # The indicator belongs to the chassis and may be described only
            # there, which is where reading looks too.  A shared chassis is
            # somebody else's indicator, as in get_identify.
            # Only when the system describes none of its own: reading prefers
            # the chassis, but writing cannot follow it there, because a
            # platform is free to publish the chassis copy read only.  A
            # ThinkSystem SD665-N V3 answers PropertyValueFormatError to every
            # value on Chassis.IndicatorLED and takes all of them on the system.
            for chassis in targinfo.get('Links', {}).get('Chassis', []):
                chassisinfo = await self._do_web_request(chassis['@odata.id'])
                if len(chassisinfo.get('Links', {}).get(
                        'ComputerSystems', [])) > 1:
                    continue
                if ('IndicatorLED' in chassisinfo
                        or 'LocationIndicatorActive' in chassisinfo):
                    targurl = chassis['@odata.id']
                    targinfo = chassisinfo
                    break
        if ('IndicatorLED' not in targinfo
                and 'LocationIndicatorActive' not in targinfo):
            # Reading already knows when there is no indicator to speak of, so
            # do not go on to write a property the platform never offered and
            # let it answer with whatever it makes of that
            raise exc.UnsupportedFunctionality(
                'Indicator LED state is not reported by this platform')
        if ('IndicatorLED' not in targinfo
                and 'LocationIndicatorActive' in targinfo):
            # IndicatorLED is deprecated in favour of a boolean, which has no
            # way to ask for blinking, so blinking becomes simply lit
            await self._do_web_request(
                targurl, {'LocationIndicatorActive': bool(blink or on)},
                method='PATCH', etag='*')
            return
        await self._do_web_request(
            targurl,
            {'IndicatorLED': 'Blinking' if blink else 'Lit' if on else 'Off'},
            method='PATCH', etag='*')

    _idstatemap = {
        'Blinking': 'blink',
        'Lit': 'on',
        'Off': 'off',
    }

    def _indicator_state(self, info):
        """Read a location indicator, by either the old or the current property.

        Returns None when the resource does not describe one.
        """
        ledstate = info.get('IndicatorLED', None)
        if ledstate is not None:
            if ledstate not in self._idstatemap:
                raise exc.PyghmiException(
                    'Unrecognized indicator LED state "{0}"'.format(ledstate))
            return self._idstatemap[ledstate]
        # IndicatorLED is deprecated in favour of a boolean, which cannot
        # express blinking
        active = info.get('LocationIndicatorActive', None)
        if active is None:
            return None
        return 'on' if active else 'off'

    async def get_identify(self):
        sysinfo = await self.sysinfo()
        idstate = self._indicator_state(sysinfo)
        # The physical indicator belongs to the chassis, and some
        # implementations do not refresh the system copy of it when it
        # changes, so prefer a chassis that describes this system alone.
        for chassis in sysinfo.get('Links', {}).get('Chassis', []):
            chassisinfo = await self._do_web_request(chassis['@odata.id'])
            if len(chassisinfo.get('Links', {}).get('ComputerSystems', [])) > 1:
                continue
            chassisstate = self._indicator_state(chassisinfo)
            if chassisstate is not None:
                idstate = chassisstate
                break
        if idstate is None:
            raise exc.UnsupportedFunctionality(
                'Indicator LED state is not reported by this platform')
        return {'identifystate': idstate}

    async def get_health(self, verbose=True):
        oem = await self.oem()
        return await oem.get_health(self, verbose)

    async def get_extended_bmc_configuration(self, hideadvanced=True):
        oem = await self.oem()
        return await oem.get_extended_bmc_configuration(self, hideadvanced=hideadvanced)

    async def get_bmc_configuration(self):
        """Get miscellaneous BMC configuration

        In much the same way a bmc can present arbitrary key-value
        structure for BIOS/UEFI configuration, provide a mechanism
        for a BMC to provide arbitrary key-value for BMC specific
        settings.
        """

        # For now, this is a stub, no implementation for redfish currently
        oem = await self.oem()
        return await oem.get_bmc_configuration()

    async def set_bmc_configuration(self, changeset):
        """Get miscellaneous BMC configuration

        In much the same way a bmc can present arbitrary key-value
        structure for BIOS/UEFI configuration, provide a mechanism
        for a BMC to provide arbitrary key-value for BMC specific
        settings.
        """

        # For now, this is a stub, no implementation for redfish currently
        oem = await self.oem()
        return await oem.set_bmc_configuration(changeset)

    async def set_system_configuration(self, changeset):
        oem = await self.oem()
        return await oem.set_system_configuration(changeset, self)

    async def get_ntp_enabled(self):
        bmcinfo = await self._do_web_request(await self.get_bmcurl())
        netprotocols = bmcinfo.get('NetworkProtocol', {}).get('@odata.id', None)
        if netprotocols:
            netprotoinfo = await self._do_web_request(netprotocols)
            enabled = netprotoinfo.get('NTP', {}).get('ProtocolEnabled', False)
            return enabled
        return False

    async def set_ntp_enabled(self, enable):
        bmcinfo = await self._do_web_request(await self.get_bmcurl())
        netprotocols = bmcinfo.get('NetworkProtocol', {}).get('@odata.id', None)
        if netprotocols:
            request = {'NTP':{'ProtocolEnabled': enable}}
            await self._do_web_request(netprotocols, request, method='PATCH',
                                       etag='*')
            await self._do_web_request(netprotocols, cache=0)

    async def get_ntp_servers(self):
        bmcinfo = await self._do_web_request(await self.get_bmcurl())
        netprotocols = bmcinfo.get('NetworkProtocol', {}).get('@odata.id', None)
        if not netprotocols:
            return []
        netprotoinfo = await self._do_web_request(netprotocols)
        return netprotoinfo.get('NTP', {}).get('NTPServers', [])

    async def set_ntp_server(self, server, index=None):
        bmcinfo = await self._do_web_request(await self.get_bmcurl())
        netprotocols = bmcinfo.get('NetworkProtocol', {}).get('@odata.id', None)
        currntpservers = await self.get_ntp_servers()
        if index is None:
            if server in currntpservers:
                return
            currntpservers = [server] + currntpservers
        else:
            if (index + 1) > len(currntpservers):
                if not server:
                    return
                currntpservers.append(server)
            else:
                if not server:
                    del currntpservers[index]
                else:
                    currntpservers[index] = server
        request = {'NTP':{'NTPServers': currntpservers}}
        await self._do_web_request(netprotocols, request, method='PATCH',
                                   etag='*')
        await self._do_web_request(netprotocols, cache=0)

    async def clear_bmc_configuration(self):
        """Reset BMC to factory default

        Call appropriate function to clear BMC to factory default settings.
        In many cases, this may render remote network access impracticle or
        impossible."
        """
        bmcinfo = await self._do_web_request(await self.get_bmcurl())
        rc = bmcinfo.get('Actions', {}).get('#Manager.ResetToDefaults', {})
        actinf = rc.get('ResetType@Redfish.AllowableValues', [])
        if 'ResetAll' in actinf: 
            acturl = rc.get('target', None)
            if acturl:
                await self._do_web_request(acturl, {'ResetType': 'ResetAll'})
                return
        raise exc.UnsupportedFunctionality(
            'Clear BMC configuration not supported on this platform')

    async def get_system_configuration(self, hideadvanced=True):
        oem = await self.oem()
        return await oem.get_system_configuration(hideadvanced, self)

    async def clear_system_configuration(self):
        """Clear the BIOS/UEFI configuration

        """
        biosinfo = await self._do_web_request(await self.get_biosurl())
        rb = biosinfo.get('Actions', {}).get('#Bios.ResetBios', {})
        actinf = rb.get('@Redfish.ActionInfo', None)
        rb = rb.get('target', '')
        parms = {}
        if actinf:
            actinf = await self._do_web_request(
                '/redfish/v1/Systems/Self/Bios/ResetBiosActionInfo')
            for parm in actinf.get('Parameters', ()):
                if parm.get('Required', False):
                    if parm.get('Name', None) == 'ResetType' and parm.get(
                            'AllowableValues', [None])[0] == 'Reset':
                        parms['ResetType'] = 'Reset'
                    else:
                        raise Exception(
                            'Unrecognized required parameter {0}'.format(
                                parm.get('Name', 'Unknown')))
        if not rb:
            raise Exception('BIOS reset not detected on this system')
        if not parms:
            parms = {'Action': 'Bios.ResetBios'}
        await self._do_web_request(rb, parms)

    async def set_net6_configuration(self, static_addresses=None, static_gateway=None, name=None):
        patch = {}
        if static_addresses is not None:
            sa = []
            patch['IPv6StaticAddresses'] = sa
            for addr in static_addresses:
                if '/' in addr:
                    addr, plen = addr.split('/', 1)
                else:
                    plen = '64'
                sa.append({
                    'PrefixLength': int(plen),
                    'Address': addr
                })
        if static_gateway:
            patch['IPv6StaticDefaultGateways'] = [{
                'Address': static_gateway,
            }]
        if patch:
            nicurl = await self._get_bmc_nic_url(name)
            await self._do_web_request(nicurl, patch, 'PATCH', etag='*')

    async def set_net_configuration(self, ipv4_address=None, ipv4_configuration=None,
                              ipv4_gateway=None, vlan_id=None, name=None):
        patch = {}
        ipinfo = {}
        dodhcp = None
        netmask = None
        if (ipv4_address is None and ipv4_configuration is None
                and ipv4_gateway is None and vlan_id is None):
            return
        if ipv4_address:
            if '/' in ipv4_address:
                ipv4_address, cidr = ipv4_address.split('/')
                netmask = _cidr_to_mask(int(cidr))
            patch['IPv4StaticAddresses'] = [ipinfo]
            ipinfo['Address'] = ipv4_address
            ipv4_configuration = 'static'
            if netmask:
                ipinfo['SubnetMask'] = netmask
        if ipv4_gateway:
            patch['IPv4StaticAddresses'] = [ipinfo]
            ipinfo['Gateway'] = ipv4_gateway
            ipv4_configuration = 'static'
        if ipv4_configuration and ipv4_configuration.lower() == 'dhcp':
            dodhcp = True
            patch['DHCPv4'] = {'DHCPEnabled': True}
        elif (ipv4_configuration == 'static'
              or 'IPv4StaticAddresses' in patch):
            dodhcp = False
            patch['DHCPv4'] = {'DHCPEnabled': False}
        if vlan_id in ('off', 0, '0'):
            patch['VLAN'] = {'VLANEnable': False}
        elif vlan_id:
            patch['VLAN'] = {'VLANEnable': True, 'VLANId': int(vlan_id)}        
        if patch:
            nicurl = await self._get_bmc_nic_url(name)
            try:
                await self._do_web_request(nicurl, patch, 'PATCH', etag='*')
            except exc.RedfishError:
                patch = {'IPv4Addresses': [ipinfo]}
                if dodhcp:
                    ipinfo['AddressOrigin'] = 'DHCP'
                elif dodhcp is not None:
                    ipinfo['AddressOrigin'] = 'Static'
                await self._do_web_request(nicurl, patch, 'PATCH', etag='*')

    async def get_net6_configuration(self, name=None):
        nicurl = await self._get_bmc_nic_url(name)
        netcfg = await self._do_web_request(nicurl, cache=False)
        retdata = {}
        saddrs = netcfg.get('IPv6StaticAddresses', ())
        retdata['static_addrs'] = []
        for sa in saddrs:
            ca = '{}/{}'.format(sa['Address'], sa['PrefixLength'])
            retdata['static_addrs'].append(ca)
        gws = netcfg.get('IPv6StaticDefaultGateways', None)
        if gws:
            for gw in gws:
                retdata['static_gateway'] = gw['Address']
        tagged = netcfg.get('VLAN', {}).get('VLANEnabled', False)
        if tagged:
            retdata['vlan_id'] = netcfg.get('VLAN', {}).get('VLANId', None)
        else:
            retdata['vlan_id'] = 'off'
        return retdata

    async def get_net_configuration(self, name=None):
        nicurl = await self._get_bmc_nic_url(name)
        netcfg = await self._do_web_request(nicurl, cache=False)
        ipv4 = netcfg.get('IPv4Addresses', {})
        if not ipv4:
            raise exc.PyghmiException('Unable to locate network information')
        retval = {}
        if len(netcfg['IPv4Addresses']) != 1:
            netcfg['IPv4Addresses'] = [
                x for x in netcfg['IPv4Addresses']
                if x['Address'] != '0.0.0.0']
        if len(netcfg['IPv4Addresses']) != 1:
            raise exc.PyghmiException('Multiple IP addresses not supported')
        currip = netcfg['IPv4Addresses'][0]
        cidr = _mask_to_cidr(currip['SubnetMask'])
        retval['ipv4_address'] = '{0}/{1}'.format(currip['Address'], cidr)
        retval['mac_address'] = netcfg['MACAddress']
        hasgateway = _mask_to_cidr(currip['Gateway']) if currip.get('Gateway', None) else None
        retval['ipv4_gateway'] = currip['Gateway'] if hasgateway else None
        retval['ipv4_configuration'] = currip['AddressOrigin']
        tagged = netcfg.get('VLAN', {}).get('VLANEnable', False)
        if tagged:
            retval['vlan_id'] = netcfg.get('VLAN', {}).get('VLANId', None)
        else:
            retval['vlan_id'] = 'off'        
        return retval

    async def get_hostname(self):
        netcfg = await self._do_web_request(await self.get_bmcnicurl())
        return netcfg['HostName']

    async def set_hostname(self, hostname):
        await self._do_web_request(await self.get_bmcnicurl(),
                             {'HostName': hostname}, 'PATCH', etag='*')

    async def _netprotocolurl(self):
        bmcinfo = await self._do_web_request(await self.get_bmcurl())
        netprotocols = bmcinfo.get('NetworkProtocol', {}).get('@odata.id', None)
        if not netprotocols:
            raise exc.UnsupportedFunctionality(
                'This platform does not describe its manager network protocols')
        return netprotocols

    async def get_mci(self):
        """Get the management controller identifier

        Redfish has no separate identifier for a management controller the way
        ipmi does, and the platforms that offer both report the same string for
        each, so use the name the manager answers to.
        """
        netcfg = await self._do_web_request(await self._netprotocolurl())
        name = netcfg.get('HostName', None)
        if name is None:
            raise exc.UnsupportedFunctionality(
                'This platform does not report a manager identifier')
        return name

    async def set_mci(self, mci):
        await self._do_web_request(await self._netprotocolurl(),
                                   {'HostName': mci}, method='PATCH', etag='*')

    async def get_domain_name(self):
        netcfg = await self._do_web_request(await self._netprotocolurl())
        fqdn = netcfg.get('FQDN', None)
        if not fqdn:
            return ''
        # The fqdn is the manager name with the domain appended, and only the
        # domain part is wanted here
        hostname = netcfg.get('HostName', None)
        if hostname and fqdn.startswith(hostname + '.'):
            return fqdn[len(hostname) + 1:]
        return fqdn.partition('.')[2]

    async def set_domain_name(self, domain):
        netprotocols = await self._netprotocolurl()
        netcfg = await self._do_web_request(netprotocols)
        hostname = netcfg.get('HostName', '')
        fqdn = '{0}.{1}'.format(hostname, domain) if domain else hostname
        await self._do_web_request(netprotocols, {'FQDN': fqdn},
                                   method='PATCH', etag='*')

    async def get_remote_kvm_available(self):
        bmcinfo = await self._do_web_request(await self.get_bmcurl())
        gconsole = bmcinfo.get('GraphicalConsole', {})
        return bool(gconsole.get('ServiceEnabled', False))

    _ledstatusmap = {
        'on': 'On',
        'blink': 'Blink',
        'off': 'Off',
    }

    async def get_leds(self):
        """Get LED status information

        Standard redfish only describes the identify indicator, so that is all a
        platform without an oem specific view of its leds can report.
        """
        sysinfo = await self.sysinfo()
        state = None
        for chassis in sysinfo.get('Links', {}).get('Chassis', []):
            chassisinfo = await self._do_web_request(chassis['@odata.id'])
            if len(chassisinfo.get('Links', {}).get('ComputerSystems', [])) > 1:
                # A chassis shared with other systems has the enclosure's
                # indicator rather than this node's, as in get_identify
                continue
            state = self._indicator_state(chassisinfo)
            if state is not None:
                break
        if state is None:
            state = self._indicator_state(sysinfo)
        if state is not None:
            yield ('identify', {'status': self._ledstatusmap.get(state, state)})

    # A firmware inventory entry says what it belongs to with RelatedItem, so
    # map the collections that turn up there onto the categories confluent asks
    # for.  Only the collections that mean one thing: a Storage resource is the
    # subsystem rather than a drive, and a Processor is a cpu as readily as an
    # accelerator, so both are decided further down instead.
    _fwcategorybyurl = (
        ('/Drives/', 'disks'),
        ('/PCIeDevices/', 'adapters'),
        ('/NetworkAdapters/', 'adapters'),
        ('/NetworkInterfaces/', 'adapters'),
        ('/Storage/', 'adapters'),
        ('/PowerSubsystem/', 'misc'),
        ('/PowerSupplies/', 'misc'),
        ('/ThermalSubsystem/', 'misc'),
        ('/Fans/', 'misc'),
        ('/Memory/', 'misc'),
    )

    # The legacy chassis power and thermal documents, which are a whole path
    # segment rather than a collection, so they are matched as one.  A bare
    # substring would also catch a chassis that merely happens to be called
    # something like PowerShelf1.
    _fwcategorybysegment = (
        ('Power', 'misc'),
        ('Thermal', 'misc'),
    )

    # What a Processor has to say it is before its firmware counts as a card in
    # the machine rather than as part of the system
    _acceleratortypes = ('GPU', 'FPGA', 'Accelerator', 'DSP')

    async def _fwcategory(self, fwi):
        """Which category a firmware inventory entry belongs to.

        Returns None when the entry gives nothing to judge by.
        """
        related = [x.get('@odata.id', '') for x in fwi.get('RelatedItem', [])]
        for url in related:
            for frag, category in self._fwcategorybyurl:
                if frag in url:
                    return category
            lastsegment = url.rstrip('/').rpartition('/')[2]
            for segment, category in self._fwcategorybysegment:
                if lastsegment == segment:
                    return category
            if '/Processors/' in url:
                return await self._processorcategory(url)
        return 'core' if related else None

    async def _processorcategory(self, url):
        """A cpu is system firmware, an accelerator is a card in the machine.

        The url cannot tell them apart, so the processor is asked.  A processor
        that will not say counts as a cpu, which is the common case and the
        answer this gave before the distinction was drawn.
        """
        try:
            procinfo = await self._do_web_request(url)
        except exc.PyghmiException:
            return 'core'
        proctype = procinfo.get('ProcessorType', None)
        return 'adapters' if proctype in self._acceleratortypes else 'core'

    async def get_firmware(self, components=(), category=None):
        self._fwnamemap = {}
        oem = await self.oem()
        try:
            async for firminfo in oem.get_firmware_inventory(components, self, category):
                yield firminfo
        except exc.BypassGenericBehavior:
            return
        fwlist = await self._do_web_request(await self.get_fwinventory())
        fwurls = [x['@odata.id'] for x in fwlist.get('Members', [])]
        wantcategory = category if category not in (None, 'all') else None
        entries = []
        # Every entry has to be in hand before any can be named, since a name is
        # only ambiguous in relation to the others.  An entry that answered with
        # nothing has nothing to report and must not take the naming of the rest
        # with it.
        results = [res async for res in self._do_bulk_requests(fwurls)
                   if res[0]]
        labels = self._firmware_labels(results)
        for res in results:
            entries.append((await self._fwcategory(res[0]),
                            self._extract_fwinfo(res, labels)))
        for fwcategory, res in entries:
            if res[0] is None:
                continue
            # An entry that says nothing about what it belongs to is system
            # firmware, which is what firmware on a bmc is unless it points at
            # something else.  This holds for a whole inventory that names no
            # relations and for the odd entry among ones that do.
            if wantcategory and (fwcategory or 'core') != wantcategory:
                continue
            yield res

    def _firmware_labels(self, results):
        """Pick something to call each firmware entry.

        A platform is free to give every entry the same Name, which then tells
        them apart from nothing, so where that happens look for a description
        that does, and fall back to the id.
        """
        names = [x[0].get('Name', 'Unknown') for x in results]
        descs = [x[0].get('Description', '') for x in results]
        labels = {}
        for idx, (fwi, url) in enumerate(results):
            label = names[idx]
            if names.count(label) > 1:
                if descs[idx] and descs.count(descs[idx]) == 1:
                    label = descs[idx]
                else:
                    label = fwi.get('Id', label)
            labels[url] = label
        return labels

    def _extract_fwinfo(self, inf, labels=None):
        currinf = self._oem._extract_fwinfo(inf)
        fwi, url = inf
        fwname = fwi.get('Name', 'Unknown')
        if labels:
            fwname = labels.get(url, fwname)
        if fwname in self._fwnamemap:
            fwname = fwi.get('Id', fwname)
        if fwname in self._fwnamemap:
            # Block duplicates for by name retrieval
            self._fwnamemap[fwname] = None
        else:
            self._fwnamemap[fwname] = url
        currinf['name'] = fwname
        currinf['id'] = fwi.get('Id', None)
        currinf['version'] = fwi.get('Version', 'Unknown')
        currinf['date'] = parse_time(fwi.get('ReleaseDate', ''))
        currinf['software_id'] = fwi.get('SoftwareId', '')
        if not (currinf['version'] or currinf['date']):
            return None, None
        # TODO(Jarrod Johnson): OEM extended data with buildid
        currstate = fwi.get('Status', {}).get('State', 'Unknown')
        if currstate == 'StandbyOffline':
            currinf['state'] = 'pending'
        elif currstate == 'Enabled':
            currinf['state'] = 'active'
        elif currstate == 'StandbySpare':
            currinf['state'] = 'backup'
        return fwname, currinf

    async def get_inventory_descriptions(self, withids=False):
        oem = await self.oem()
        async for desc in oem.get_inventory_descriptions(withids):
            yield desc

    async def get_inventory_of_component(self, component):
        oem = await self.oem()
        return await oem.get_inventory_of_component(component)

    async def get_inventory(self, withids=False):
        oem = await self.oem()
        async for x in oem.get_inventory(withids):
            yield x

    async def get_location_information(self):
        locationinfo = {}
        sysinfo = await self.sysinfo()
        for chassis in sysinfo.get('Links', {}).get('Chassis', []):
            chassisurl = chassis['@odata.id']
            data = await self._do_web_request(chassisurl)
            locdata = data.get('Location', {})
            postaladdress = locdata.get('PostalAddress', {})
            placement = locdata.get('Placement', {})
            contactinfo = locdata.get('Contacts', [])
            currval = postaladdress.get('Room', '')
            if currval:
                locationinfo['room'] = currval
            currval = postaladdress.get('Location', '')
            if currval:
                locationinfo['location'] = currval
            currval = postaladdress.get('Building', '')
            if currval:
                locationinfo['building'] = currval
            currval = placement.get('Rack', '')
            if currval:
                locationinfo['rack'] = currval
            for contact in contactinfo:
                contact = contact.get('ContactName', '')
                if not contact:
                    continue
                if 'contactnames' not in locationinfo:
                    locationinfo['contactnames'] = [contact]
                else:
                    locationinfo['contactnames'].append(contact)
        return locationinfo

    async def set_location_information(self, room=None, contactnames=None,
                                 location=None, building=None, rack=None):
        locationinfo = {}
        postaladdress = {}
        placement = {}
        if contactnames is not None:
            locationinfo['Contacts'] = [
                {'ContactName': x} for x in contactnames]
        if room is not None:
            postaladdress['Room'] = room
        if location is not None:
            postaladdress['Location'] = location
        if building is not None:
            postaladdress['Building'] = building
        if rack is not None:
            placement['Rack'] = rack
        if postaladdress:
            locationinfo['PostalAddress'] = postaladdress
        if placement:
            locationinfo['Placement'] = placement
        if locationinfo:
            sysinfo = await self.sysinfo()
            for chassis in sysinfo.get('Links', {}).get('Chassis', []):
                chassisurl = chassis['@odata.id']
                await self._do_web_request(chassisurl, {'Location': locationinfo},
                                     method='PATCH', etag='*')

    async def oem(self):
        if not self._oem:
            if self.sysurl:
                await self._do_web_request(self.sysurl, cache=False)  # This is to trigger token validation and renewel
            elif self._varbmcurl:
                await self._do_web_request(self._varbmcurl, cache=False)  # This is to trigger token validation and renewel
            sysinfo = await self.sysinfo()
            self._oem = await oem.get_oem_handler(
                sysinfo, self._initsysurl, self.wc, self._urlcache, self)
            self._oem.set_credentials(self.username, self.password)
        return self._oem

    async def get_description(self):
        oem = await self.oem()
        return await oem.get_description(self)

    async def get_event_log(self, clear=False):
        oem = await self.oem()
        async for logent in oem.get_event_log(clear, self):
            yield logent
    
    async def _get_chassis_env(self, chassis):
        chassisurl = chassis['@odata.id']
        chassisinfo = await self._do_web_request(chassisurl)
        envurl = chassisinfo.get('EnvironmentMetrics', {}).get('@odata.id', '')
        if not envurl:
            return {}
        envmetric = await self._do_web_request(envurl, cache=1)
        retval = {
            'watts': envmetric.get('PowerWatts', {}).get('Reading', None),
            'inlet': envmetric.get('TemperatureCelsius', {}).get('Reading', None)
        }
        return retval

    async def get_average_processor_temperature(self):
        oem = await self.oem()
        return await oem.get_average_processor_temperature(self)

    async def get_system_power_watts(self):
        oem = await self.oem()
        return await oem.get_system_power_watts(self)

    async def get_inlet_temperature(self):
        inlets = []
        sysinfo = await self.sysinfo()
        for chassis in sysinfo.get('Links', {}).get('Chassis', []):
            envinfo = await self._get_chassis_env(chassis)
            currinlet = envinfo.get('inlet', None)
            if currinlet:
                inlets.append(currinlet)
        if inlets:
            val = sum(inlets) / len(inlets)
            unavail = False
        else:
            val = None
            unavail = True
        return SensorReading(
                        None, {'name': 'Inlet Temperature'}, value=val, units='°C',
                        unavailable=unavail)
    async def get_sensor_descriptions(self):
        for sensor in natural_sort(await self._sensormap()):
            yield (await self._sensormap())[sensor]

    async def get_sensor_reading(self, sensorname):
        sensormap = await self._sensormap()
        if sensorname not in sensormap:
            raise Exception('Sensor not found')
        sensor = sensormap[sensorname]
        reading = await self._do_web_request(sensor['url'], cache=1)
        return self._extract_reading(sensor, reading)

    async def get_sensor_data(self):
        for sensor in natural_sort(await self._sensormap()):
            yield await self.get_sensor_reading(sensor)

    def _extract_reading(self, sensor, reading):
        if sensor.get('generic', False):  # generic sensor
            val = reading.get('Reading', None)
            unavail = val is None
            units = reading.get('ReadingUnits', None)
            if units == 'Cel':
                units = '°C'
            if units == 'cft_i/min':
                units = 'CFM'
            return SensorReading(reading, None, value=val, units=units,
                          unavailable=unavail)
        if sensor['type'] == 'Fan':
            for fan in reading['Fans']:
                if fan['Name'] == sensor['name']:
                    val = fan.get('Reading', None)
                    unavail = val is None
                    units = fan.get('ReadingUnits', None)
                    return SensorReading(
                        None, sensor, value=val, units=units,
                        unavailable=unavail)
        elif sensor['type'] == 'Temperature':
            for temp in reading['Temperatures']:
                if temp['Name'] == sensor['name']:
                    val = temp.get('ReadingCelsius', None)
                    unavail = val is None
                    return SensorReading(
                        None, sensor, value=val, units='°C',
                        unavailable=unavail)
        elif sensor['type'] == 'Voltage':
            for volt in reading['Voltages']:
                if volt['Name'] == sensor['name']:
                    val = volt.get('ReadingVolts', None)
                    unavail = val is None
                    return SensorReading(
                        None, sensor, value=val, units='V',
                        unavailable=unavail)

    async def list_media(self):
        oem = await self.oem()
        async for res in oem.list_media(self):
            yield res

    async def get_storage_configuration(self):
        """"Get storage configuration data

        Retrieves the storage configuration from the target.  Data is given
        about disks, pools, and volumes.  When referencing something, use the
        relevant 'cfgpath' attribute to describe it.  It is not guaranteed that
        cfgpath will be consistent version to version, so a lookup is suggested
        in end user applications.

        :return: A aiohmi.storage.ConfigSpec object describing current config
        """
        oem = await self.oem()
        return await oem.get_storage_configuration()

    async def remove_storage_configuration(self, cfgspec):
        """Remove specified storage configuration from controller.

        :param cfgspec: A aiohmi.storage.ConfigSpec describing what to remove
        :return:
        """
        oem = await self.oem()
        return await oem.remove_storage_configuration(cfgspec)

    async def apply_storage_configuration(self, cfgspec=None):
        """Evaluate a configuration for validity

        This will check if configuration is currently available and, if given,
        whether the specified cfgspec can be applied.
        :param cfgspec: A aiohmi.storage.ConfigSpec describing desired oonfig
        :return:
        """
        oem = await self.oem()
        return await oem.apply_storage_configuration(cfgspec)

    def check_storage_configuration(self, cfgspec=None):
        """Evaluate a configuration for validity

        This will check if configuration is currently available and, if given,
        whether the specified cfgspec can be applied.
        :param cfgspec: A aiohmi.storage.ConfigSpec describing desired oonfig
        :return:
        """
        return self._oem.check_storage_configuration(cfgspec)

    # How an implementation says an action it advertised is not there, as
    # opposed to refusing what was asked of it
    _absentmsgids = ('ActionNotSupported', 'ResourceMissingAtURI',
                     'ResourceNotFound')

    def _action_absent(self, theexc):
        """Whether a failed action call means the action is not served."""
        msgid = getattr(theexc, 'msgid', None)
        if msgid is None:
            # Not a redfish error at all, which is not how one that serves the
            # action reports refusing
            return True
        return any(x in str(msgid) for x in self._absentmsgids)

    async def attach_remote_media(self, url, username=None, password=None):
        """Attach remote media by url

        Given a url, attach remote media (cd/usb image) to the target system.

        :param url:  URL to indicate where to find image (protocol support
                     varies by BMC)
        :param username: Username for endpoint to use when accessing the URL.
                         If applicable, 'domain' would be indicated by '@' or
                         '\' syntax.
        :param password: Password for endpoint to use when accessing the URL.
        """
        # At the moment, there isn't a viable way to
        # identify the correct resource ahead of time.
        # As such it's OEM specific until the standard
        # provides a better way.
        vmurls = []
        sysinfo = await self.sysinfo()
        vmcoll = sysinfo.get(
            'VirtualMedia', {}).get('@odata.id', None)
        if not vmcoll:
            bmcinfo = await self.bmcinfo()
            vmcoll = bmcinfo.get(
                'VirtualMedia', {}).get('@odata.id', None)
        if vmcoll:
            vmlist = await self._do_web_request(vmcoll)
            vmurls = [x['@odata.id'] for x in vmlist.get('Members', [])]
        suspendedxauth = False  # Don't trigger token expiry linked unmount
        if 'X-Auth-Token' in self.wc.stdheaders:
            suspendedxauth = True
            del self.wc.stdheaders['X-Auth-Token']
            self.wc.set_basic_credentials(self.username, self.password)
        oem = await self.oem()
        try:
            await oem.attach_remote_media(url, username, password, vmurls)
        except exc.BypassGenericBehavior:
            if suspendedxauth:
                self.wc.stdheaders['X-Auth-Token'] = self.xauthtoken
                if 'Authorization' in self.wc.stdheaders:
                    del self.wc.stdheaders['Authorization']            
            return
        attached = False
        for vmurl in vmurls:
            vminfo = await self._do_web_request(vmurl, cache=False)
            # ConnectedVia describes how the device is wired to the host rather
            # than whether anything is in it, and some implementations report a
            # permanent value there, so judge free by whether an image is loaded
            if vminfo.get('Image', None) or vminfo.get('Inserted', False):
                continue
            inserturl = vminfo.get(
                'Actions', {}).get(
                    '#VirtualMedia.InsertMedia', {}).get('target', None)
            if inserturl:
                try:
                    await self._do_web_request(inserturl, {'Image': url})
                    attached = True
                except exc.PyghmiException as pe:
                    if not self._action_absent(pe):
                        # The action is there and refused; that reason is the
                        # one worth passing on
                        raise
                    # Some implementations advertise the insert action without
                    # serving it, so fall back to setting the properties
                    inserturl = None
            if not inserturl:
                try:
                    await self._do_web_request(vmurl,
                                               {'Image': url, 'Inserted': True},
                                               'PATCH', etag='*')
                except exc.RedfishError as re:
                    if re.msgid.endswith(u'PropertyUnknown'):
                        await self._do_web_request(vmurl, {'Image': url}, 'PATCH',
                                                   etag='*')
                    else:
                        raise
                attached = True
            break
        if suspendedxauth:
                self.wc.stdheaders['X-Auth-Token'] = self.xauthtoken
                if 'Authorization' in self.wc.stdheaders:
                    del self.wc.stdheaders['Authorization']
        if not attached:
            raise exc.UnsupportedFunctionality(
                'No virtual media device on this platform was able to accept '
                'the image')
        async for res in oem.list_media(self, cache=False):
            pass

    async def detach_remote_media(self):
        oem = await self.oem()
        try:
            await oem.detach_remote_media()
        except exc.BypassGenericBehavior:
            return
        sysinfo = await self.sysinfo()
        vmcoll = sysinfo.get('VirtualMedia', {}).get('@odata.id', None)
        if not vmcoll:
            bmcinfo = await self._do_web_request(await self.get_bmcurl())
            vmcoll = bmcinfo.get('VirtualMedia', {}).get('@odata.id', None)
        if vmcoll:
            vmlist = await self._do_web_request(vmcoll)
            vmurls = [x['@odata.id'] for x in vmlist.get('Members', [])]
            async for vminfo in self._do_bulk_requests(vmurls):
                vminfo, currl = vminfo
                if vminfo['Image']:
                    ejurl = vminfo.get(
                        'Actions', {}).get(
                            '#VirtualMedia.EjectMedia', {}).get('target', None)
                    if ejurl:
                        await self._do_web_request(ejurl, {})
                    else:
                        try:
                            await self._do_web_request(currl,
                                                       {'Image': None,
                                                        'Inserted': False},
                                                       method='PATCH', etag='*')
                        except exc.RedfishError as re:
                            if re.msgid.endswith(u'PropertyUnknown'):
                                await self._do_web_request(currl, {'Image': None},
                                                     method='PATCH', etag='*')
                            else:
                                raise
        oem = await self.oem()
        async for res in oem.list_media(self, cache=False):
            pass

    async def upload_media(self, filename, progress=None, data=None):
        """Upload a file to be hosted on the target BMC

        This will upload the specified data to
        the BMC so that it will make it available to the system as an emulated
        USB device.

        :param filename: The filename to use, the basename of the parameter
                         will be given to the bmc.
        :param progress: Optional callback for progress updates
        """
        oem = await self.oem()
        return await oem.upload_media(filename, progress, data)

    async def get_update_status(self):
        oem = await self.oem()
        return await oem.get_update_status()   

    async def get_update_types(self):
        """The kinds of firmware image this platform has to be told about

        Only some implementations will not take an image without being told
        which kind it is, and those are the ones with an answer here.
        """
        oem = await self.oem()
        return await oem.get_update_types(self)

    async def update_firmware(self, file, data=None, progress=None, bank=None, otherfields=()):
        """Send file to BMC to perform firmware update

         :param filename:  The filename to upload to the target BMC
         :param data:  The payload of the firmware.  Default is to read from
                       specified filename.
         :param progress:  A callback that will be given a dict describing
                           update process.  Provide if
         :param bank: Indicate a target 'bank' of firmware if supported
        """
        if progress is None:
            def progress(x):
                return True
        oem = await self.oem()
        return await oem.update_firmware(file, data, progress, bank, otherfields)

    async def get_diagnostic_data(self, savefile, progress=None, autosuffix=False):
        if os.path.exists(savefile) and not os.path.isdir(savefile):
            raise exc.InvalidParameterValue(
                'Not allowed to overwrite existing file: {0}'.format(
                    savefile))
        oem = await self.oem()
        return await oem.get_diagnostic_data(savefile, progress, autosuffix)

    async def get_licenses(self):
        oem = await self.oem()
        async for lic in oem.get_licenses(self):
            yield lic

    async def delete_license(self, name):
        oem = await self.oem()
        return await oem.delete_license(name, self)

    async def save_licenses(self, directory):
        if os.path.exists(directory) and not os.path.isdir(directory):
            raise exc.InvalidParameterValue(
                'Not allowed to overwrite existing file: {0}'.format(
                    directory))
        oem = await self.oem()
        async for lic in oem.save_licenses(directory, self):
            yield lic

    async def apply_license(self, filename, progress=None, data=None):
        oem = await self.oem()
        return await oem.apply_license(filename, self, progress, data)

if __name__ == '__main__':
    async def main():
        cmd = await Command.create(
            sys.argv[1], os.environ['BMCUSER'], os.environ['BMCPASS'],
            verifycallback=lambda x: True)
        print(repr(await cmd.get_power()))

    asyncio.run(main())
