"""Insteon Device Classes."""
# pylint: disable=too-many-lines
import asyncio
from collections import namedtuple
from concurrent.futures import CancelledError
import datetime
from enum import Enum
import logging

import async_timeout

from insteonplm.address import Address
from insteonplm.constants import (
    COMMAND_ID_REQUEST_0X10_0X00,
    COMMAND_PRODUCT_DATA_REQUEST_0X03_0X00,
    COMMAND_ASSIGN_TO_ALL_LINK_GROUP_0X01_NONE,
    COMMAND_DELETE_FROM_ALL_LINK_GROUP_0X02_NONE,
    COMMAND_FX_USERNAME_0X03_0X01,
    COMMAND_ENTER_LINKING_MODE_0X09_NONE,
    COMMAND_ENTER_UNLINKING_MODE_0X0A_NONE,
    COMMAND_GET_INSTEON_ENGINE_VERSION_0X0D_0X00,
    COMMAND_PING_0X0F_0X00,
    COMMAND_EXTENDED_READ_WRITE_ALDB_0X2F_0X00,
    MESSAGE_ACK,
    MESSAGE_TYPE_BROADCAST_MESSAGE,
    MESSAGE_TYPE_DIRECT_MESSAGE,
    MESSAGE_FLAG_DIRECT_MESSAGE_NAK_0XA0,
    MESSAGE_STANDARD_MESSAGE_RECEIVED_0X50,
    MESSAGE_EXTENDED_MESSAGE_RECEIVED_0X51,
)
from insteonplm.messagecallback import MessageCallback
from insteonplm.messages.allLinkComplete import AllLinkComplete
from insteonplm.messages.extendedReceive import ExtendedReceive
from insteonplm.messages.standardReceive import StandardReceive
from insteonplm.messages.extendedSend import ExtendedSend
from insteonplm.messages.messageFlags import MessageFlags
from insteonplm.messages.standardSend import StandardSend
from insteonplm.messages.userdata import Userdata
from insteonplm.states import State

_LOGGER = logging.getLogger(__name__)
DIRECT_ACK_WAIT_TIMEOUT = 3
ALDB_RECORD_TIMEOUT = 10
ALDB_RECORD_RETRIES = 20
ALDB_ALL_RECORD_TIMEOUT = 30
ALDB_ALL_RECORD_RETRIES = 5


LoadAction = namedtuple("LoadAction", "mem_addr rec_count retries")
DeviceInfo = namedtuple("DeviceInfo", "address cat subcat firmware")


# pylint: disable=unused-argument
# pylint: disable=cyclic-import
def create(plm, address, cat, subcat, firmware=None):
    """Create a device from device info data."""
    from insteonplm.devices.ipdb import IPDB

    ipdb = IPDB()
    product = ipdb[[cat, subcat]]
    deviceclass = product.deviceclass
    device = None
    if deviceclass is not None:
        device = deviceclass(
            plm,
            address,
            cat,
            subcat,
            product.product_key,
            product.description,
            product.model,
        )
    return device


def create_x10(plm, housecode, unitcode, feature):
    """Create an X10 device from a feature definition."""
    from insteonplm.devices.ipdb import IPDB

    ipdb = IPDB()
    product = ipdb.x10(feature)
    deviceclass = product.deviceclass
    device = None
    if deviceclass:
        device = deviceclass(plm, housecode, unitcode)
    return device


def _get_most_recent_message(recent_messages):
    if not recent_messages:
        return None
    most_recent = recent_messages[0]
    for recent in recent_messages[1:]:
        if recent["received"] > most_recent["received"]:
            most_recent = recent
    return most_recent["msg"]


# pylint: disable=too-many-instance-attributes
# pylint: disable=too-many-public-methods
class Device:
    """INSTEON Device Class."""

    def __init__(
        self, plm, address, cat, subcat, product_key=0x00, description="", model=""
    ):
        """Init the Device class."""
        self._plm = plm
        self._address = Address(address)
        self._cat = cat
        self._subcat = subcat
        if self._subcat is None:
            self._subcat = 0x00
        self._product_key = product_key
        if self._product_key is None:
            self._product_key = 0x00
        self._description = description
        self._model = model

        self._last_communication_received = datetime.datetime(1, 1, 1, 1, 1, 1)
        self._product_data_in_aldb = False
        self._stateList = StateList()
        self._sent_msg_wait_for_directACK = {}
        self._message_callbacks = MessageCallback()
        self._aldb = ALDB(self._send_msg, self._plm.loop, self._address)

        self._recent_messages = asyncio.Queue(loop=self._plm.loop)
        self._send_msg_queue = asyncio.Queue(loop=self._plm.loop)
        self._directACK_received_queue = asyncio.Queue(loop=self._plm.loop)
        self._device_info_queue = asyncio.Queue(loop=self._plm.loop)
        self._send_msg_lock = asyncio.Lock(loop=self._plm.loop)
        self._setup_default_links_lock = asyncio.Lock(loop=self._plm.loop)
        self._device_info_retries = 0
        self._all_link_complete_callback = None

        self._register_messages()

    # Public properties
    @property
    def address(self):
        """Return the INSTEON device address."""
        return self._address

    @property
    def cat(self):
        """Return the INSTEON device category."""
        return self._cat

    @property
    def subcat(self):
        """Return the INSTEON device subcategory."""
        return self._subcat

    @property
    def product_key(self):
        """Return the INSTEON product key."""
        return self._product_key

    @property
    def description(self):
        """Return the INSTEON device description."""
        return self._description

    @property
    def model(self):
        """Return the INSTEON device model number."""
        return self._model

    @property
    def id(self):
        """Return the ID of the device."""
        return self._address.id

    @property
    def states(self):
        """Return the device states/groups."""
        return self._stateList

    @property
    def prod_data_in_aldb(self):
        """Return if the PLM use the ALDB data to setup the device.

        True if Product data (cat, subcat) is stored in the PLM ALDB.
        False if product data must be acquired via a Device ID message or from
        a Product Data Request command.

        The method of linking determines if product data in the ALDB,
        therefore False is the default. The common reason to store product data
        in the ALDB is for one way devices or battery opperated devices where
        the ability to send a command request is limited.
        """
        return self._product_data_in_aldb

    @property
    def aldb(self):
        """Return the device All-Link Database."""
        return self._aldb

    # Public Methods
    def async_refresh_state(self):
        """Request each state to provide status update."""
        for state in self._stateList:
            self._stateList[state].async_refresh_state()

    def id_request(self):
        """Request a device ID from a device."""
        import inspect

        curframe = inspect.currentframe()
        calframe = inspect.getouterframes(curframe, 2)
        _LOGGER.debug("caller name: %s", calframe[1][3])
        msg = StandardSend(self.address, COMMAND_ID_REQUEST_0X10_0X00)
        self._plm.send_msg(msg)

    def product_data_request(self):
        """Request product data from a device.

        Not supported by all devices.
        Required after 01-Feb-2007.
        """
        msg = StandardSend(self._address, COMMAND_PRODUCT_DATA_REQUEST_0X03_0X00)
        self._send_msg(msg)

    def assign_to_all_link_group(self, group=0x01):
        """Assign a device to an All-Link Group.

        The default is group 0x01.
        """
        msg = StandardSend(
            self._address, COMMAND_ASSIGN_TO_ALL_LINK_GROUP_0X01_NONE, cmd2=group
        )
        self._send_msg(msg)

    def delete_from_all_link_group(self, group):
        """Delete a device to an All-Link Group."""
        msg = StandardSend(
            self._address, COMMAND_DELETE_FROM_ALL_LINK_GROUP_0X02_NONE, cmd2=group
        )
        self._send_msg(msg)

    def fx_username(self):
        """Get FX Username.

        Only required for devices that support FX Commands.
        FX Addressee responds with an ED 0x0301 FX Username Response message
        """
        msg = StandardSend(self._address, COMMAND_FX_USERNAME_0X03_0X01)
        self._send_msg(msg)

    def device_text_string_request(self):
        """Get FX Username.

        Only required for devices that support FX Commands.
        FX Addressee responds with an ED 0x0301 FX Username Response message.
        """
        msg = StandardSend(self._address, COMMAND_FX_USERNAME_0X03_0X01)
        self._send_msg(msg)

    def enter_linking_mode(self, group=0x01):
        """Tell a device to enter All-Linking Mode.

        Same as holding down the Set button for 10 sec.
        Default group is 0x01.

        Not supported by i1 devices.
        """
        msg = ExtendedSend(
            self._address,
            COMMAND_ENTER_LINKING_MODE_0X09_NONE,
            cmd2=group,
            userdata=Userdata(),
        )
        msg.set_checksum()
        self._send_msg(msg)

    def enter_unlinking_mode(self, group):
        """Unlink a device from an All-Link group.

        Not supported by i1 devices.
        """
        msg = StandardSend(
            self._address, COMMAND_ENTER_UNLINKING_MODE_0X0A_NONE, cmd2=group
        )
        self._send_msg(msg)

    def get_engine_version(self):
        """Get the device engine version."""
        msg = StandardSend(self._address, COMMAND_GET_INSTEON_ENGINE_VERSION_0X0D_0X00)
        self._send_msg(msg)

    def ping(self):
        """Ping a device."""
        msg = StandardSend(self._address, COMMAND_PING_0X0F_0X00)
        self._send_msg(msg)

    def create_default_links(self):
        """Create the default links between the IM and the device."""
        self._plm.manage_aldb_record(
            0x40, 0xE2, 0x00, self.address, self.cat, self.subcat, self.product_key
        )
        self.manage_aldb_record(
            0x41,
            0xA2,
            0x00,
            self._plm.address,
            self._plm.cat,
            self._plm.subcat,
            self._plm.product_key,
        )
        for link in self._stateList:
            state = self._stateList[link]
            if state.is_responder:
                # IM is controller
                self._plm.manage_aldb_record(
                    0x40, 0xE2, link, self._address, 0x00, 0x00, 0x00
                )
                # Device is responder
                self.manage_aldb_record(
                    0x41,
                    0xA2,
                    link,
                    self._plm.address,
                    state.linkdata1,
                    state.linkdata2,
                    state.linkdata3,
                )
            if state.is_controller:
                # IM is responder
                self._plm.manage_aldb_record(
                    0x41, 0xA2, link, self._address, 0x00, 0x00, 0x00
                )
                # Device is controller
                self.manage_aldb_record(
                    0x40, 0xE2, link, self._plm.address, 0x00, 0x00, 0x00
                )
        self.read_aldb()

    def read_aldb(self, mem_addr=0x0000, num_recs=0):
        """Read the device All-Link Database."""
        if self._aldb.version == ALDBVersion.Null:
            _LOGGER.info(
                "Device %s does not contain an All-Link Database", self._address.human
            )
        else:
            _LOGGER.info("Reading All-Link Database for device %s", self._address.human)
            asyncio.ensure_future(
                self._aldb.load(mem_addr, num_recs), loop=self._plm.loop
            )
            self._aldb.add_loaded_callback(self._aldb_loaded_callback)

    # pylint: disable=too-many-locals
    # pylint: disable=too-many-return-statements
    # pylint: disable=too-many-branches
    def manage_aldb_record(
        self, control_code, control_flags, group, address, data1, data2, data3
    ):
        """Update an IM All-Link record.

        Control Code values:
        - 0x00 Find First Starting at the top of the ALDB, search for the
        first ALL-Link Record matching the <ALL-Link Group> and <ID> in bytes
        5 – 8. The search ignores byte 4, <ALL-Link Record Flags>. You will
        receive an ACK at the end of the returned message if such an ALL-Link
        Record exists, or else a NAK if it doesn’t.  If the record exists, the
        IM will return it in an ALL-Link Record Response (0x51) message.

        - 0x01 Find Next Search for the next ALL-Link Record following the one
        found using <Control Code> 0x00 above.  This allows you to find both
        Controller and Responder records for a given <ALL-Link Group> and
        <ID>. Be sure to use the same <ALL-Link Group> and <ID> (bytes 5 – 8)
        as you used for <Control Code> 0x00. You will receive an ACK at the
        end of the returned message if another matching ALL-Link Record
        exists, or else a NAK if it doesn’t.  If the record exists, the IM
        will return it in an ALL-Link Record Response (0x51) message.

        - 0x20 Modify First Found or Add Modify an existing or else add a new
        ALL-Link Record for either a Controller or Responder. Starting at the
        top of the ALDB, search for the first ALL-Link Record matching the
        <ALL-Link Group> and <ID> in bytes 5 – 8.  The search ignores byte 4,
        <ALL-Link Record Flags>. If such an ALL-Link Record exists, overwrite
        it with the data in bytes 4 – 11; otherwise, create a new ALL-Link
        Record using bytes 4 – 11. Note that the IM will copy
        <ALL-Link Record Flags> you supplied in byte 4 below directly into the
        <ALL-Link Record Flags> byte of the ALL-Link Record in an ALDB-L
        (linear) database.  Use caution, because you can damage an ALDB-L if
        you misuse this Command.  For instance, if you zero the
        <ALL-Link Record Flags> byte in the first ALL-Link Record, the IM’s
        ALDB-L database will then appear empty.

        - 0x40 Modify First Controller Found or Add Modify an existing or else
        add a new Controller (master) ALL-Link Record. Starting at the top of
        the ALDB, search for the first ALL-Link Controller Record matching the
        <ALL-Link Group> and <ID> in bytes 5 – 8.  An ALL-Link Controller
        Record has bit 6 of its <ALL-Link Record Flags> byte set to 1. If such
        a Controller ALL-Link Record exists, overwrite it with the data in
        bytes 5 – 11; otherwise, create a new ALL-Link Record using bytes
        5 – 11.  In either case, the IM will set bit 6 of the
        <ALL-Link Record Flags> byte in the ALL-Link Record to 1 to indicate
        that the record is for a Controller.

        - 0x41 Modify First Responder Found or Add Modify an existing or else
        add a new Responder (slave) ALLLink Record. Starting at the top of the
        ALDB, search for the first ALL-Link Responder Record matching the
        <ALL-Link Group> and <ID> in bytes 5 – 8.  An ALL-Link Responder
        Record has bit 6 of its <ALL-Link Record Flags> byte cleared to 0. If
        such a Responder ALL-Link Record exists, overwrite it with the data in
        bytes 5 – 11; otherwise, create a new ALL-Link Record using bytes
        5 – 11.  In either case, The IM will clear bit 6 of the
        <ALL-Link Record Flags> byte in the ALL-Link Record to 0 to indicate
        that the record is for a Responder.

        - 0x80 Delete First Found Delete an ALL-Link Record. Starting at the
        top of the ALDB, search for the first ALL-Link Record matching the
        <ALL-Link Group> and <ID> in bytes 5 – 8.  The search ignores byte 4,
        <ALL-Link Record Flags>. You will receive an ACK at the end of the
        returned message if such an ALL-Link Record existed and was deleted,
        or else a NAK no such record exists.
        """
        found_first = False
        for memaddr in self.aldb:
            found = False
            rec = self.aldb[memaddr]
            found = Address(address) == rec.address
            found = found & int(group) == rec.group
            if control_code == 0x00:
                if found:
                    return (True, rec)
                if rec.control_flags.is_high_water_mark:
                    return (False, None)
            if control_code == 0x01:
                if found & found_first:
                    return (True, rec)
                if rec.control_flags.is_high_water_mark:
                    return (False, None)
                if found:
                    found_first = True
            elif control_code == 0x20:
                if found or rec.control_flags.is_high_water_mark:
                    new_rec = self._write_new_aldb_rec(
                        rec.mem_addr, control_flags, group, address, data1, data2, data3
                    )
                    return (found, new_rec)
            elif control_code == 0x40:
                found = found & rec.control_flags.is_controller
                if found or rec.control_flags.is_high_water_mark:
                    new_rec = self._write_new_aldb_rec(
                        rec.mem_addr, control_flags, group, address, data1, data2, data3
                    )
                    return (found, new_rec)
            elif control_code == 0x41:
                found = found & rec.control_flags.is_responder
                if found or rec.control_flags.is_high_water_mark:
                    new_rec = self._write_new_aldb_rec(
                        rec.mem_addr, control_flags, group, address, data1, data2, data3
                    )
                    return (found, new_rec)
            elif control_code == 0x80:
                if found:
                    cf = rec.control_flags
                    new_control_flags = ControlFlags(
                        False, cf.is_controller, cf.is_high_water_mark, False, False
                    )
                    new_rec = self._write_new_aldb_rec(
                        rec.mem_addr,
                        new_control_flags,
                        rec.group,
                        rec.address,
                        rec.data1,
                        rec.data2,
                        rec.data3,
                    )
                    return (found, new_rec)
            return (False, rec)

    def _write_new_aldb_rec(
        self,
        mem_addr: int,
        mode: str,
        group: int,
        target,
        data1=0x00,
        data2=0x00,
        data3=0x00,
    ):
        new_rec = ALDBRecord(mem_addr, mode, group, target, data1, data2, data3)
        self.write_aldb(
            new_rec.mem_addr,
            new_rec.control_flags,
            new_rec.group,
            new_rec.address,
            new_rec.data1,
            new_rec.data2,
            new_rec.data3,
        )
        return new_rec

    def write_aldb(
        self,
        mem_addr: int,
        mode: str,
        group: int,
        target,
        data1=0x00,
        data2=0x00,
        data3=0x00,
    ):
        """Write to the device All-Link Database.

        Parameters:
            Required:
            mode:   r - device is a responder of target
                    c - device is a controller of target
            group:  Link group
            target: Address of the other device

            Optional:
            data1:  Device dependant
            data2:  Device dependant
            data3:  Device dependant

        """
        if isinstance(mode, str) and mode.lower() in ["c", "r"]:
            pass
        else:
            _LOGGER.error("Insteon link mode: %s", mode)
            raise ValueError("Mode must be 'c' or 'r'")
        if isinstance(group, int):
            pass
        else:
            raise ValueError("Group must be an integer")

        target_addr = Address(target)

        _LOGGER.debug("calling aldb write_record")
        self._aldb.write_record(mem_addr, mode, group, target_addr, data1, data2, data3)
        self._aldb.add_loaded_callback(self._aldb_loaded_callback)

    def del_aldb(self, mem_addr: int):
        """Delete an All-Link Database record."""
        self._aldb.del_record(mem_addr)
        self._aldb.add_loaded_callback(self._aldb_loaded_callback)

    def _handle_aldb_record_received(self, msg):
        self._aldb.record_received(msg)

    # pylint: disable=unused-argument
    def _handle_pre_nak(self, msg):
        _LOGGER.warning("Device %s returned a pre-NAK message", self._address.human)
        _LOGGER.warning("Checking status to confirm if message was processed")
        self.async_refresh_state()

    def _handle_get_device_info_ack(self, msg):
        _LOGGER.debug("Starting _handle_get_device_info_ack with message:")
        _LOGGER.debug(msg)
        asyncio.ensure_future(self._wait_device_info(msg), loop=self._plm.loop)

    def _handle_assign_to_all_link_group(self, msg):
        cat = 0xFF
        subcat = 0
        product_key = 0
        if msg.code == StandardReceive.code and msg.flags.isBroadcast:
            _LOGGER.debug("Received broadcast ALDB group assigment request.")
            cat = msg.targetLow
            subcat = msg.targetMed
            product_key = msg.targetHi
            self._add_device_from_prod_data(msg.address, cat, subcat, product_key)
        elif msg.code == AllLinkComplete.code:
            if msg.linkcode in [0, 1, 3]:
                _LOGGER.debug("Received ALDB complete response.")
                cat = msg.category
                subcat = msg.subcategory
                product_key = msg.firmware
                self._add_device_from_prod_data(msg.address, cat, subcat, product_key)
            else:
                _LOGGER.debug("Received ALDB delete response.")
            self._refresh_aldb_records(msg.linkcode, msg.address, msg.group)

    def _add_device_from_prod_data(self, address, cat, subcat, product_key):
        _LOGGER.debug(
            "Received Device ID with address: %s  " "cat: 0x%x  subcat: 0x%x",
            address,
            cat,
            subcat,
        )
        device = self._plm.devices.create_device_from_category(
            self._plm, address, cat, subcat, product_key
        )
        if device:
            if not self._plm.devices[device.id]:
                self._plm.devices[device.id] = device
                _LOGGER.info("Device with id %s added to device list.", device.id)
            _LOGGER.debug("Total Insteon devices found: %d", len(self._plm.devices))
            self._plm.aldb_device_handled(self._address)
            self._plm.devices.save_device_info()
        else:
            _LOGGER.warning(
                "Device %s not in the Insteon Product Database", Address(address).human
            )
            self._plm.device_not_active(self.address)

    def _refresh_aldb_records(self, linkcode, address, group):
        """Refresh the IM and device ALDB records."""
        if self.aldb.status in [ALDBStatus.LOADED, ALDBStatus.PARTIAL]:
            for mem_addr in self.aldb:
                rec = self.aldb[mem_addr]
                if linkcode in [0, 1, 3]:
                    if rec.control_flags.is_high_water_mark:
                        _LOGGER.debug("Removing HWM record %04x", mem_addr)
                        self.aldb.pop(mem_addr)
                    elif not rec.control_flags.is_in_use:
                        _LOGGER.debug("Removing not in use record %04x", mem_addr)
                        self.aldb.pop(mem_addr)
                else:
                    if rec.address == self.address and rec.group == group:
                        _LOGGER.debug(
                            "Removing record %04x with addr %s and " "group %d",
                            mem_addr,
                            rec.address,
                            rec.group,
                        )
                        self.aldb.pop(mem_addr)
            self.read_aldb()
            # self.aldb.add_loaded_callback(self._refresh_aldb)

    async def _wait_device_info(self, msg):
        _LOGGER.debug(
            "Starting _wait_device_info for device %s in %s",
            msg.address.human,
            self._address.human,
        )
        try:
            with async_timeout.timeout(6):
                device_info_msg = await self._device_info_queue.get()
                _LOGGER.debug(
                    "_wait_device_info got device_id message %s", device_info_msg
                )
        except asyncio.TimeoutError:
            _LOGGER.debug(
                "_wait_device_info timed out for message %s in %s",
                msg.address.human,
                self._address.human,
            )
            if self._device_info_retries < 5:
                self._device_info_retries += 1
                _LOGGER.debug(
                    "Device %s id_request retry %d",
                    self.address.human,
                    self._device_info_retries,
                )
                self.id_request()
            else:
                _LOGGER.warning(
                    "Device %s did not respond to an ID request", self.address.human
                )
                _LOGGER.warning("Use a device override to define the device " "type")
                self._plm.device_not_active(self.address)
            return
        self._device_info_retries = 0
        address = device_info_msg.address
        cat = device_info_msg.targetLow
        subcat = device_info_msg.targetMed
        product_key = device_info_msg.targetHi
        self._add_device_from_prod_data(address, cat, subcat, product_key)

    def _handle_device_info_response(self, msg):
        _LOGGER.debug(
            "Got device_id for %s in %s", msg.address.human, self._address.human
        )
        self._device_info_queue.put_nowait(msg)

    def _handle_all_link_complete(self, msg):
        # Not ready for prime time
        # if msg and msg.group == 0 and msg.linkcode == 0x01:
        #    # IM is controller of group 1
        #    # Set up other needed links
        #    self.aldb.add_loaded_callback(self.create_default_links)

        self._aldb.remove_unused()
        self.read_aldb()

    def _register_messages(self):
        _LOGGER.debug("Registering messages for %s", self._address.human)
        std_device_info_request_ack = StandardSend.template(
            address=self._address,
            commandtuple=COMMAND_ID_REQUEST_0X10_0X00,
            acknak=MESSAGE_ACK,
        )
        _LOGGER.debug("ID ACK: %s", std_device_info_request_ack)
        self._message_callbacks.add(
            std_device_info_request_ack, self._handle_get_device_info_ack
        )

        std_device_info_response = StandardReceive.template(
            address=self._address,
            commandtuple=COMMAND_ASSIGN_TO_ALL_LINK_GROUP_0X01_NONE,
            flags=MessageFlags.template(messageType=MESSAGE_TYPE_BROADCAST_MESSAGE),
        )
        self._message_callbacks.add(
            std_device_info_response, self._handle_device_info_response
        )

        std_assign_all_link = StandardReceive.template(
            address=self._address,
            commandtuple=COMMAND_ASSIGN_TO_ALL_LINK_GROUP_0X01_NONE,
            flags=MessageFlags.template(MESSAGE_TYPE_DIRECT_MESSAGE),
        )
        self._message_callbacks.add(
            std_assign_all_link, self._handle_assign_to_all_link_group
        )

        ext_msg_aldb_record = ExtendedReceive.template(
            address=self._address,
            commandtuple=COMMAND_EXTENDED_READ_WRITE_ALDB_0X2F_0X00,
            userdata=Userdata.template({"d2": 1}),
            flags=MessageFlags.template(
                messageType=MESSAGE_TYPE_DIRECT_MESSAGE, extended=1
            ),
        )
        self._message_callbacks.add(
            ext_msg_aldb_record, self._handle_aldb_record_received
        )

        std_msg_pre_nak = StandardReceive.template(
            address=self._address,
            flags=MessageFlags.template(
                messageType=MESSAGE_FLAG_DIRECT_MESSAGE_NAK_0XA0
            ),
            cmd2=0xFC,
        )
        self._message_callbacks.add(std_msg_pre_nak, self._handle_pre_nak)

        ext_msg_pre_nak = ExtendedReceive.template(
            address=self._address,
            flags=MessageFlags.template(
                messageType=MESSAGE_FLAG_DIRECT_MESSAGE_NAK_0XA0
            ),
            cmd2=0xFC,
        )
        self._message_callbacks.add(ext_msg_pre_nak, self._handle_pre_nak)

        template_all_link_complete = AllLinkComplete(
            None, None, self._address, None, None, None
        )
        self._message_callbacks.add(
            template_all_link_complete, self._handle_all_link_complete
        )

    # Send / Receive message processing
    def receive_message(self, msg):
        """Receive a messages sent to this device."""
        _LOGGER.debug("Starting Device.receive_message for %s", msg.address.human)
        if hasattr(msg, "isack") and msg.isack:
            _LOGGER.debug("Got Message ACK %s", id(msg))
            if self._sent_msg_wait_for_directACK.get("callback") is not None:
                _LOGGER.debug("Look for direct ACK")
                asyncio.ensure_future(self._wait_for_direct_ACK(), loop=self._plm.loop)
            else:
                _LOGGER.debug("MSG queue: %s", self._sent_msg_wait_for_directACK)
                _LOGGER.debug("Message ACK with no callback")

        if not self._is_duplicate(msg):
            if (
                hasattr(msg, "flags")
                and hasattr(msg.flags, "isDirectACK")
                and msg.flags.isDirectACK
            ):
                _LOGGER.debug(
                    "Got Direct ACK message. Already in queue: %d, Queueing %s:%s",
                    self._directACK_received_queue.qsize(),
                    id(msg),
                    msg,
                )
                if self._send_msg_lock.locked():
                    self._directACK_received_queue.put_nowait(msg)
                else:
                    _LOGGER.debug("But Direct ACK not expected")

            callbacks = self._message_callbacks.get_callbacks_from_message(msg)
            for callback in callbacks:
                _LOGGER.debug("Scheduling msg callback: %s", callback)
                self._plm.loop.call_soon(callback, msg)
        else:
            _LOGGER.debug("msg is duplicate: %s", id(msg))
        self._last_communication_received = datetime.datetime.now()
        _LOGGER.debug("Ending Device.receive_message")

    def _is_duplicate(self, msg):
        if msg.code not in [
            MESSAGE_STANDARD_MESSAGE_RECEIVED_0X50,
            MESSAGE_EXTENDED_MESSAGE_RECEIVED_0X51,
        ]:
            self._save_recent_message(msg)
            return False

        recent_messages = []
        while not self._recent_messages.empty():
            recent_message = self._recent_messages.get_nowait()
            if recent_message:
                msg_received = recent_message["received"]
                if msg_received >= (
                    datetime.datetime.now() - datetime.timedelta(0, 0, 500000)
                ):
                    recent_messages.append(recent_message)

        # Save remaining messages for next call
        for recent_message in recent_messages:
            self._recent_messages.put_nowait(recent_message)
        self._save_recent_message(msg)
        # Create a template to compare current message to (ignore hops)
        if msg.flags.isExtended:
            template = ExtendedReceive.template(
                address=msg.address,
                target=msg.target,
                commandtuple={"cmd1": msg.cmd1, "cmd2": msg.cmd2},
                flags=MessageFlags.template(
                    msg.flags.messageType, msg.flags.extended
                ),
                userdata=msg.userdata
            )
        else:
            template = StandardReceive.template(
                address=msg.address,
                target=msg.target,
                commandtuple={"cmd1": msg.cmd1, "cmd2": msg.cmd2},
                flags=MessageFlags.template(
                    msg.flags.messageType, msg.flags.extended
                ),
            )

        for recent_message in recent_messages:
            prev_msg = recent_message["msg"]
            if prev_msg.matches_pattern(template):
                _LOGGER.debug("Duplicate matches pattern")
                _LOGGER.debug("TEMP: %s", template)
                _LOGGER.debug("Prev: %s", prev_msg)
                return True

            # Check if this is a cleanup message from a broadcast
            if prev_msg.flags.isAllLinkBroadcast:
                prev_group = prev_msg.target.bytes[2]
            elif prev_msg.flags.isAllLinkCleanup:
                prev_group = prev_msg.cmd2

            if msg.flags.isAllLinkCleanup or msg.flags.isAllLinkBroadcast:
                cmd1 = msg.cmd1
                if msg.flags.isAllLinkBroadcast:
                    group = msg.target.bytes[2]
                else:
                    group = msg.cmd2
                if prev_msg.cmd1 == cmd1 and prev_group == group:
                    return True

        # Address an edge case where two directACKs arrive back to back
        # Keep the first one (see insteonplm issue # 215)
        recent = _get_most_recent_message(recent_messages)
        for recent_msg in recent_messages:
            _LOGGER.debug("RCT: %s", recent_msg['msg'])
        if recent and msg.flags.isDirectACK and recent.flags.isDirectACK:
            _LOGGER.debug("Duplicate direct ACK")
            _LOGGER.debug("TEMP: %s", msg)
            _LOGGER.debug("Prev: %s", prev_msg)
            return True

        return False

    def _save_recent_message(self, msg):
        recent_message = {"msg": msg, "received": datetime.datetime.now()}
        self._recent_messages.put_nowait(recent_message)

    def _send_msg(self, msg, callback=None, on_timeout=False):
        _LOGGER.debug(
            "Starting %s Device._send_msg: Queuing message", self.address.human
        )
        msg_info = {"msg": msg, "callback": callback, "on_timeout": on_timeout}
        self._send_msg_queue.put_nowait(msg_info)
        asyncio.ensure_future(self._process_send_queue(), loop=self._plm.loop)
        # _LOGGER.debug('Ending Device._send_msg')

    async def _process_send_queue(self):
        _LOGGER.debug("Starting %s Device._process_send_queue", self._address.human)
        await self._send_msg_lock
        if self._send_msg_lock.locked():
            _LOGGER.debug("Device %s msg_lock locked", self._address.human)
        msg_info = await self._send_msg_queue.get()
        msg = msg_info.get("msg")
        callback = msg_info.get("callback")
        self._plm.send_msg(msg)
        if callback:
            self._sent_msg_wait_for_directACK = msg_info
        else:
            if self._send_msg_lock.locked():
                self._send_msg_lock.release()
                _LOGGER.debug("Device %s msg_lock unlocked", self._address.human)
        _LOGGER.debug("Ending %s Device._process_send_queue", self._address.human)

    async def _wait_for_direct_ACK(self):
        _LOGGER.debug("Starting Device._wait_for_direct_ACK")
        msg = None
        while True:
            # wait for an item from the queue
            try:
                with async_timeout.timeout(DIRECT_ACK_WAIT_TIMEOUT):
                    msg = await self._directACK_received_queue.get()
                    _LOGGER.debug(
                        "Remaining queue %d, Direct ACK: %s:%s",
                        self._directACK_received_queue.qsize(),
                        id(msg),
                        msg,
                    )
                    break
            except asyncio.TimeoutError:
                _LOGGER.debug("No direct ACK messages received.")
                break
            except CancelledError:
                break

        # _LOGGER.debug('Holding lock for 10 seconds')
        # await asyncio.sleep(10, loop=self._plm.loop)
        _LOGGER.debug("Releasing lock after processing direct ACK")
        if self._send_msg_lock.locked():
            self._send_msg_lock.release()
            _LOGGER.debug("Device %s msg_lock unlocked", self._address.human)
        if msg or self._sent_msg_wait_for_directACK.get("on_timeout"):
            callback = self._sent_msg_wait_for_directACK.get("callback", None)
            if callback is not None:
                _LOGGER.debug("Scheduling msg directACK callback: %s", callback)
                callback(msg)
        self._sent_msg_wait_for_directACK = {}
        _LOGGER.debug("Ending Device._wait_for_direct_ACK")

    def _aldb_loaded_callback(self):
        self._plm.devices.save_device_info()


# pylint: disable=too-many-instance-attributes
class X10Device:
    """X10 device class."""

    def __init__(self, plm, housecode, unitcode):
        """Init the X10Device class."""
        self._address = Address.x10(housecode, unitcode)
        self._plm = plm
        self._description = "Generic X10 device"
        self._model = ""
        self._aldb = ALDB(None, None, self._address, version=ALDBVersion.Null)
        self._message_callbacks = MessageCallback()
        self._stateList = StateList()
        self._send_msg_lock = asyncio.Lock(loop=self._plm.loop)
        self._last_communication_received = datetime.datetime(1, 1, 1, 1, 1, 1)

    @property
    def address(self):
        """X10 device address."""
        return self._address

    @property
    def description(self):
        """Return the INSTEON device description."""
        return self._description

    @property
    def model(self):
        """Return the INSTEON device model number."""
        return self._model

    @property
    def id(self):
        """Return the ID of the device."""
        return self._address.id

    @property
    def states(self):
        """Return the device states/groups."""
        return self._stateList

    @property
    def aldb(self):
        """Return the device All-Link Database."""
        return self._aldb

    # Send / Receive message processing
    def receive_message(self, msg):
        """Receive a message sent to this device."""
        _LOGGER.debug("Starting X10Device.receive_message")
        if hasattr(msg, "isack") and msg.isack:
            _LOGGER.debug("Got Message ACK")
            if self._send_msg_lock.locked():
                self._send_msg_lock.release()
        callbacks = self._message_callbacks.get_callbacks_from_message(msg)
        _LOGGER.debug("Found %d callbacks for msg %s", len(callbacks), msg)
        for callback in callbacks:
            _LOGGER.debug("Scheduling msg callback: %s", callback)
            self._plm.loop.call_soon(callback, msg)
        self._last_communication_received = datetime.datetime.now()
        _LOGGER.debug("Ending x10Device.receive_message")

    async def close(self):
        """Close the writer for a clean shutdown."""
        # Nothing actually needed here.

    def _send_msg(self, msg, wait_ack=True):
        _LOGGER.debug("Starting X10Device._send_msg")
        asyncio.ensure_future(
            self._process_send_queue(msg, wait_ack), loop=self._plm.loop
        )
        _LOGGER.debug("Ending x10Device._send_msg")

    async def _process_send_queue(self, msg, wait_ack):
        _LOGGER.debug("Starting x10Device._process_send_queue")
        await self._send_msg_lock
        if self._send_msg_lock.locked():
            _LOGGER.debug("Lock is locked from yield from")

        self._plm.send_msg(msg, wait_timeout=2)
        if not wait_ack:
            _LOGGER.debug("No directACK wait")
            _LOGGER.debug("Releasing lock")
            self._send_msg_lock.release()
        _LOGGER.debug("Ending x10Device._process_send_queue")


class StateList:
    """Internal class used to hold a list of device states."""

    def __init__(self):
        """Init the StateList Class."""
        self._stateList = {}

    def __len__(self):
        """Get the number of states in the StateList."""
        return len(self._stateList)

    def __iter__(self):
        """Iterate through each state in the StateList."""
        for state in self._stateList:
            yield state

    def __getitem__(self, group):
        """Get a state from the StateList."""
        return self._stateList.get(group, None)

    def __setitem__(self, group, state):
        """Add or update a state in the StateList."""
        if not isinstance(state, State):
            raise ValueError

        self._stateList[group] = state

    def __repr__(self):
        """Juman representation of a state in the StateList."""
        attrs = vars(self)
        return ", ".join("%s: %r" % item for item in attrs.items())

    def add(self, plm, device, stateType, stateName, group, defaultValue=None):
        """Add a state to the StateList."""
        self._stateList[group] = stateType(
            plm, device, stateName, group, defaultValue=defaultValue
        )


class ALDBRecord:
    """Represents an ALDB record."""

    def __init__(self, memory, control_flags, group, address, data1, data2, data3):
        """Initialze the ALDBRecord class."""
        self._memoryLocation = memory
        self._address = Address(address)
        self._group = group
        self._data1 = data1
        self._data2 = data2
        self._data3 = data3
        self._control_flags = ControlFlags.create_from_byte(control_flags)

    def __str__(self):
        """Return the string representation of an ALDB record."""
        props = self._record_properties()
        msgstr = "{"
        first = True
        for prop in props:
            if not first:
                msgstr = "{}, ".format(msgstr)
            for key, val in prop.items():
                if isinstance(val, Address):
                    msgstr = "{}'{}': {}".format(msgstr, key, val.human)
                elif key == "memory":
                    msgstr = "{}'{}': 0x{:04x}".format(msgstr, key, val)
                elif isinstance(val, int):
                    msgstr = "{}'{}': 0x{:02x}".format(msgstr, key, val)
                else:
                    msgstr = "{}'{}': {}".format(msgstr, key, val)
            first = False
        msgstr = "{}{}".format(msgstr, "}")
        return msgstr

    @property
    def mem_addr(self):
        """Return the memory address of the database record."""
        return self._memoryLocation

    @property
    def memhi(self):
        """Return the memory address MSB."""
        return self._memoryLocation >> 8

    @property
    def memlo(self):
        """Return the memory address LSB."""
        return self._memoryLocation & 0xFF

    @property
    def address(self):
        """Return the address of the device the record points to."""
        return self._address

    @property
    def group(self):
        """Return the group the record responds to."""
        return self._group

    @property
    def control_flags(self):
        """Return the record control flags."""
        return self._control_flags

    @property
    def data1(self):
        """Return the data1 field of the ALDB record."""
        return self._data1

    @property
    def data2(self):
        """Return the data2 field of the ALDB record."""
        return self._data2

    @property
    def data3(self):
        """Return the data3 field of the ALDB record."""
        return self._data3

    @staticmethod
    def create_from_userdata(userdata):
        """Create ALDB Record from the userdata dictionary."""
        memhi = userdata.get("d3")
        memlo = userdata.get("d4")
        memory = memhi << 8 | memlo
        control_flags = userdata.get("d6")
        group = userdata.get("d7")
        addrhi = userdata.get("d8")
        addrmed = userdata.get("d9")
        addrlo = userdata.get("d10")
        addr = Address(bytearray([addrhi, addrmed, addrlo]))
        data1 = userdata.get("d11")
        data2 = userdata.get("d12")
        data3 = userdata.get("d13")
        return ALDBRecord(memory, control_flags, group, addr, data1, data2, data3)

    def to_userdata(self):
        """Return a Userdata dictionary."""
        userdata = Userdata(
            {
                "d3": self.memhi,
                "d4": self.memlo,
                "d6": self.control_flags,
                "d7": self.group,
                "d8": self.address.bytes[2],
                "d9": self.address.bytes[1],
                "d10": self.address.bytes[0],
                "d11": self.data1,
                "d12": self.data2,
                "d13": self.data3,
            }
        )
        return userdata

    def _record_properties(self):
        if self._control_flags.is_controller:
            mode = "C"
        else:
            mode = "R"
        rec = [
            {"memory": self._memoryLocation},
            {"inuse": self._control_flags.is_in_use},
            {"mode": mode},
            {"highwater": self._control_flags.is_high_water_mark},
            {"group": self.group},
            {"address": self.address},
            {"data1": self.data1},
            {"data2": self.data2},
            {"data3": self.data3},
        ]
        return rec


class ControlFlags:
    """Represents a ControlFlag byte of an ALDB record."""

    def __init__(self, in_use, controller, used_before, bit5=0, bit4=0):
        """Init the ControlFlags Class."""
        self._in_use = bool(in_use)
        self._controller = bool(controller)
        self._used_before = bool(used_before)
        self._bit5 = bool(bit5)
        self._bit4 = bool(bit4)

    @property
    def is_in_use(self):
        """Return True if the record is in use."""
        return self._in_use

    @property
    def is_available(self):
        """Return True if the record is available for use."""
        return not self._in_use

    @property
    def is_controller(self):
        """Return True if the device is a controller."""
        return self._controller

    @property
    def is_responder(self):
        """Return True if the device is a responder."""
        return not self._controller

    @property
    def is_high_water_mark(self):
        """Return True if this is the last record."""
        return not self._used_before

    @property
    def is_used_before(self):
        """Return True if this is not the last record."""
        return self._used_before

    @property
    def byte(self):
        """Return a byte representation of ControlFlags."""
        flags = (
            int(self._in_use) << 7
            | int(self._controller) << 6
            | int(self._bit5) << 5
            | int(self._bit4) << 4
            | int(self._used_before) << 1
        )
        return flags

    @staticmethod
    def create_from_byte(control_flags):
        """Create a ControlFlags class from a control flags byte."""
        in_use = bool(control_flags & 1 << 7)
        controller = bool(control_flags & 1 << 6)
        bit5 = bool(control_flags & 1 << 5)
        bit4 = bool(control_flags & 1 << 4)
        used_before = bool(control_flags & 1 << 1)
        flags = ControlFlags(in_use, controller, used_before, bit5=bit5, bit4=bit4)
        return flags


class ALDBStatus(Enum):
    """All-Link Database load status."""

    EMPTY = 0
    LOADING = 1
    LOADED = 2
    FAILED = 3
    PARTIAL = 4


class ALDBVersion(Enum):
    """All-Link Database version."""

    Null = 0
    v1 = 1
    v2 = 2
    v2cs = 20


# pylint: disable=too-many-instance-attributes
class ALDB:
    """Represents a device All-Link database."""

    def __init__(
        self, send_method, loop, address, version=ALDBVersion.v2, mem_addr=0x0000
    ):
        """Instantiate the ALL-Link Database object."""
        self._records = {}
        self._status = ALDBStatus.EMPTY
        self._prior_status = self._status
        self._version = version

        self._send_method = send_method
        self._loop = loop
        self._address = address
        self._mem_addr = mem_addr

        self._rec_mgr_lock = asyncio.Lock(loop=self._loop)
        self._load_action = LoadAction(0, 0, 0)
        self._cb_aldb_loaded = []

    def __len__(self):
        """Return the number of devices in the ALDB."""
        return len(self._records)

    def __iter__(self):
        """Iterate through each ALDB device record."""
        keys = list(self._records.keys())
        keys.sort(reverse=True)
        for key in keys:
            yield key

    def __getitem__(self, mem_addr):
        """Fetch a device from the ALDB."""
        return self._records.get(mem_addr)

    def __setitem__(self, mem_addr, record):
        """Add or Update a device in the ALDB."""
        if not isinstance(record, ALDBRecord):
            raise ValueError

        self._records[mem_addr] = record

    def __repr__(self):
        """Human representation of a device from the ALDB."""
        attrs = vars(self)
        return ", ".join("%s: %r" % item for item in attrs.items())

    @property
    def status(self):
        """Return the ALDB load status."""
        return self._status

    @status.setter
    def status(self, val: ALDBStatus):
        """Set the ALDB status value."""
        self._status = val

    @property
    def version(self):
        """Return the ALDB version."""
        return self._version

    def pop(self, key):
        """Pop and remove an item from the ALDB."""
        return self._records.pop(key)

    def add_loaded_callback(self, callback):
        """Add a callback to be run when the ALDB load is complete."""
        if callback not in self._cb_aldb_loaded:
            self._cb_aldb_loaded.append(callback)

    async def load(self, mem_addr=0x0000, rec_count=0, retry=0):
        """Read the device database and load."""
        if self._version == ALDBVersion.Null:
            self._status = ALDBStatus.LOADED
            _LOGGER.debug("Device has no ALDB")
        else:
            self._status = ALDBStatus.LOADING
            _LOGGER.debug("Tring to lock from load")
            await self._rec_mgr_lock
            _LOGGER.debug("load yielded lock")

            mem_hi = mem_addr >> 8
            mem_lo = mem_addr & 0xFF
            log_output = "ALDB read"
            max_retries = 0
            if rec_count:
                max_retries = ALDB_RECORD_RETRIES
                if mem_addr == 0x0000:
                    log_output = "{:s} first record".format(log_output)
                else:
                    log_output = "{:s} record {:04x}".format(log_output, mem_addr)
            else:
                max_retries = ALDB_ALL_RECORD_RETRIES
                log_output = "{:s} all records".format(log_output)

            if retry:
                log_output = "{:s} retry {:d} of {:d}".format(
                    log_output, retry, max_retries
                )
            _LOGGER.info(log_output)
            userdata = Userdata(
                {"d1": 0, "d2": 0, "d3": mem_hi, "d4": mem_lo, "d5": rec_count}
            )
            msg = ExtendedSend(
                self._address,
                COMMAND_EXTENDED_READ_WRITE_ALDB_0X2F_0X00,
                userdata=userdata,
            )
            msg.set_checksum()
            self._send_method(msg, self._handle_read_aldb_ack, True)

            if not self._load_action:
                self._set_load_action(mem_addr, rec_count, -1, False)

    def get(self, mem_addr):
        """Return an All-Link record at a memory address."""
        return self._records.get(mem_addr)

    def clear(self):
        """Remove all records."""
        self._records.clear()

    def remove_unused(self):
        """Remove All-Link records marked as not in use."""
        unused = []
        for mem_addr in self._records:
            rec = self._records[mem_addr]
            if rec.control_flags.is_available:
                unused.append(mem_addr)
        for mem_addr in unused:
            self._records.pop(mem_addr)

    # pylint: disable=too-many-locals
    def write_record(
        self,
        mem_addr: int,
        mode: str,
        group: int,
        target,
        data1=0x00,
        data2=0x00,
        data3=0x00,
    ):
        """Write an All-Link database record."""
        if not (self._have_first_record() and self._have_last_record()):
            _LOGGER.error(
                "Must load the Insteon All-Link Database before " "writing to it"
            )
        else:
            self._prior_status = self._status
            self._status = ALDBStatus.LOADING
            mem_hi = mem_addr >> 8
            mem_lo = mem_addr & 0xFF
            controller = mode == "c"
            control_flag = ControlFlags(True, controller, True, False, False)
            addr = Address(target)
            addr_lo = addr.bytes[0]
            addr_mid = addr.bytes[1]
            addr_hi = addr.bytes[2]
            userdata = Userdata(
                {
                    "d1": 0,
                    "d2": 0x02,
                    "d3": mem_hi,
                    "d4": mem_lo,
                    "d5": 0x08,
                    "d6": control_flag.byte,
                    "d7": group,
                    "d8": addr_lo,
                    "d9": addr_mid,
                    "d10": addr_hi,
                    "d11": data1,
                    "d12": data2,
                    "d13": data3,
                }
            )
            msg = ExtendedSend(
                self._address,
                COMMAND_EXTENDED_READ_WRITE_ALDB_0X2F_0X00,
                userdata=userdata,
            )
            msg.set_checksum()
            _LOGGER.debug("writing message %s", msg)
            self._send_method(msg, self._handle_write_aldb_ack, True)
            self._load_action = LoadAction(mem_addr, 1, 0)

    # pylint: disable=too-many-locals
    def del_record(self, mem_addr: int):
        """Write an All-Link database record."""
        record = self._records.get(mem_addr)
        if not record:
            _LOGGER.error(
                "Must load the Insteon All-Link Database record " "before deleting it"
            )
        else:
            self._prior_status = self._status
            self._status = ALDBStatus.LOADING
            mem_hi = mem_addr >> 8
            mem_lo = mem_addr & 0xFF
            controller = record.control_flags.is_controller
            control_flag = ControlFlags(False, controller, True, False, False)
            addr = record.address
            addr_lo = addr.bytes[0]
            addr_mid = addr.bytes[1]
            addr_hi = addr.bytes[2]
            group = record.group
            data1 = record.data1
            data2 = record.data2
            data3 = record.data3
            userdata = Userdata(
                {
                    "d1": 0,
                    "d2": 0x02,
                    "d3": mem_hi,
                    "d4": mem_lo,
                    "d5": 0x08,
                    "d6": control_flag.byte,
                    "d7": group,
                    "d8": addr_lo,
                    "d9": addr_mid,
                    "d10": addr_hi,
                    "d11": data1,
                    "d12": data2,
                    "d13": data3,
                }
            )
            msg = ExtendedSend(
                self._address,
                COMMAND_EXTENDED_READ_WRITE_ALDB_0X2F_0X00,
                userdata=userdata,
            )
            msg.set_checksum()
            _LOGGER.debug("writing message %s", msg)
            self._send_method(msg, self._handle_write_aldb_ack, True)
            self._load_action = LoadAction(mem_addr, 1, 0)

    def find_matching_link(self, mode, group, addr):
        """Find a matching link in the current device.

        Mode: r | c is the mode of the link in the linked device
              This method will search for a corresponding link in the
              reverse direction.
        group: All-Link group number
        addr:  Inteon address of the linked device
        """
        found_rec = None
        mode_test = None
        if mode.lower() in ["c", "r"]:
            link_group = int(group)
            link_addr = Address(addr)
            for mem_addr in self:
                rec = self[mem_addr]
                if mode.lower() == "r":
                    mode_test = rec.control_flags.is_controller
                else:
                    mode_test = rec.control_flags.is_responder
                if mode_test and rec.group == link_group and rec.address == link_addr:
                    found_rec = rec
        return found_rec

    def record_received(self, msg):
        """Handle ALDB record received from device."""
        release_lock = False
        userdata = msg.userdata
        rec = ALDBRecord.create_from_userdata(userdata)
        self._records[rec.mem_addr] = rec

        _LOGGER.debug("ALDB Record: %s", rec)

        rec_count = self._load_action.rec_count
        if rec_count == 1 or self._have_all_records():
            release_lock = True

        if self._is_first_record(rec):
            self._mem_addr = rec.mem_addr

        if release_lock and self._rec_mgr_lock.locked():
            _LOGGER.debug("Releasing lock because record received")
            self._rec_mgr_lock.release()

    def load_saved_records(self, status, records):
        """Load ALDB records from a set of saved records."""
        if isinstance(status, ALDBStatus):
            self._status = status
        else:
            self._status = ALDBStatus(status)
        for mem_addr in records:
            rec = records[mem_addr]
            control_flags = int(rec.get("control_flags", 0))
            group = int(rec.get("group", 0))
            rec_addr = rec.get("address", "000000")
            data1 = int(rec.get("data1", 0))
            data2 = int(rec.get("data2", 0))
            data3 = int(rec.get("data3", 0))
            self[int(mem_addr)] = ALDBRecord(
                int(mem_addr), control_flags, group, rec_addr, data1, data2, data3
            )
        if self._status == ALDBStatus.LOADED:
            keys = list(self._records.keys())
            keys.sort(reverse=True)
            first_key = keys[0]
            self._mem_addr = first_key

    # pylint: disable=unused-argument
    def _handle_read_aldb_ack(self, msg):
        _LOGGER.debug("Read ALDB directACK received or timeout reached")
        asyncio.ensure_future(self._read_timeout_manager(), loop=self._loop)

    def _handle_write_aldb_ack(self, msg):
        if msg:
            _LOGGER.info(
                "Device %s confirmed All-Link record was written", self._address.human
            )
            try:
                self._records.pop(self._load_action.mem_addr)
            except KeyError:
                pass
            asyncio.ensure_future(
                self.load(self._load_action.mem_addr, 1, 0), loop=self._loop
            )
        else:
            _LOGGER.info(
                "Device %s did not confirm All-Link record was " "written",
                self._address.human,
            )
            self._status = self._prior_status

    async def _read_timeout_manager(self):
        _LOGGER.debug("_read_timeout_manager started.")
        read_complete = False
        rec_count = self._load_action.rec_count
        timeout = ALDB_RECORD_TIMEOUT if rec_count else ALDB_ALL_RECORD_TIMEOUT

        try:
            with async_timeout.timeout(timeout + self._load_action.retries):
                _LOGGER.debug("Tring to get lock in _read_timeout_manager.")
                await self._rec_mgr_lock
                _LOGGER.debug("_read_timeout_manager lock yielded.")
                read_complete = True
        except asyncio.TimeoutError:
            if (
                self._load_action.rec_count
                and self._load_action.mem_addr == 0x0000
                and self._have_first_record()
            ):
                read_complete = False
            elif self.get(self._load_action.mem_addr):
                read_complete = True
            else:
                _LOGGER.debug("ALDB record response timeout.")
                read_complete = False

        self._set_load_action(
            self._load_action.mem_addr,
            self._load_action.rec_count,
            self._load_action.retries,
            read_complete,
        )

        mem_addr = self._load_action.mem_addr
        rec_count = self._load_action.rec_count
        retries = self._load_action.retries

        if self._rec_mgr_lock.locked():
            self._rec_mgr_lock.release()

        if mem_addr is not None:
            asyncio.ensure_future(
                self.load(mem_addr, rec_count, retries), loop=self._loop
            )
        elif read_complete or self._have_all_records():
            _LOGGER.info("All-Link Database load complete for device %s", self._address)
            self._load_finished(ALDBStatus.LOADED)
        else:
            if self._records:
                _LOGGER.warning(
                    "Insteon All-Link Database partially loaded " "for device %s",
                    self._address,
                )
                self._load_finished(ALDBStatus.PARTIAL)
            else:
                _LOGGER.warning(
                    "Insteon All-Link Database  failed to load " "for device %s",
                    self._address,
                )
                self._load_finished(ALDBStatus.FAILED)

    def _load_finished(self, status):
        self._status = status
        while self._cb_aldb_loaded:
            _LOGGER.debug("Calling aldb loaded callback")
            callback = self._cb_aldb_loaded.pop()
            if callback:
                callback()
        self._load_action = LoadAction(0, 0, 0)

    def _set_load_action(self, mem_addr, rec_count, retries, read_complete=False):
        """Calculate the next record to read.

        If the last record was successful and one record was being read then
        look for the next record until we get to the high water mark.

        If the last read was successful and all records were being read then
        look for the first record.

        if the last read was unsuccessful and one record was being read then
        repeat the last read until max retries

        If the last read was unsuccessful and all records were being read then
        repeat the last read until max retries or look for the first record.
        """
        if self._have_all_records():
            mem_addr = None
            rec_count = 0
            retries = 0
        elif read_complete:
            retries = 0
            if rec_count:
                mem_addr = self._next_address(mem_addr)
            else:
                mem_addr = self._next_address(mem_addr)
                rec_count = 1
                retries = 0
        elif rec_count and retries < ALDB_RECORD_RETRIES:
            retries = retries + 1
        elif not rec_count and retries < ALDB_ALL_RECORD_RETRIES:
            retries = retries + 1
        elif not rec_count and retries >= ALDB_ALL_RECORD_RETRIES:
            mem_addr = self._next_address(mem_addr)
            rec_count = 1
            retries = 0
        else:
            mem_addr = None
            rec_count = 0
            retries = 0

        self._load_action = LoadAction(mem_addr, rec_count, retries)
        if mem_addr is not None:
            _LOGGER.debug(
                "Load action: addr: %04x rec_count: %d retries: %d",
                self._load_action.mem_addr,
                self._load_action.rec_count,
                self._load_action.retries,
            )

    def _next_address(self, mem_addr):
        if self._have_first_record() and mem_addr == 0x0000:
            mem_addr = self._mem_addr
        while self.get(mem_addr):
            if self.get(mem_addr).control_flags.is_high_water_mark:
                mem_addr = None
            else:
                mem_addr = mem_addr - 8
        return mem_addr

    def _have_first_record(self):
        have_first_record = False
        if self._records:
            keys = list(self._records.keys())
            keys.sort(reverse=True)
            first_key = int(keys[0])
            if first_key == self._mem_addr:
                have_first_record = True
            elif first_key == 0x0FFF:
                self._mem_addr = first_key
                have_first_record = True
        return have_first_record

    def _have_last_record(self):
        have_last_record = False
        if self._records:
            keys = list(self._records.keys())
            keys.sort(reverse=False)
            last_key = keys[0]
            if self[last_key].control_flags.is_high_water_mark:
                have_last_record = True
        return have_last_record

    def _is_first_record(self, rec):
        is_first_record = False
        mem_addr = self._load_action.mem_addr
        rec_count = self._load_action.rec_count
        if rec_count and mem_addr == 0x0000:
            is_first_record = True
        elif self._mem_addr == rec.mem_addr:
            is_first_record = True
        elif self._mem_addr == 0x0000 and rec.mem_addr == 0x0FFF:
            is_first_record = True
        return is_first_record

    def _have_all_records(self):
        have_all_recs = False
        if self._have_first_record() and self._have_last_record():
            prev_mem_addr = None
            for mem_addr in self:
                if not prev_mem_addr:
                    prev_mem_addr = mem_addr
                elif mem_addr == (prev_mem_addr - 8):
                    if self[mem_addr].control_flags.is_high_water_mark:
                        have_all_recs = True
                    else:
                        prev_mem_addr = mem_addr
                else:
                    have_all_recs = False
                    break
        return have_all_recs
