diff --git a/ironic/drivers/modules/inspector/hooks/local_link_connection.py b/ironic/drivers/modules/inspector/hooks/local_link_connection.py
new file mode 100644
index 0000000000..ba7996de1c
--- /dev/null
+++ b/ironic/drivers/modules/inspector/hooks/local_link_connection.py
@@ -0,0 +1,123 @@
+# 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.
+
+import binascii
+
+from construct import core
+import netaddr
+from oslo_log import log as logging
+
+from ironic.common import exception
+from ironic.drivers.modules.inspector.hooks import base
+from ironic.drivers.modules.inspector import lldp_tlvs as tlv
+import ironic.objects.port as ironic_port
+
+
+LOG = logging.getLogger(__name__)
+PORT_ID_ITEM_NAME = "port_id"
+SWITCH_ID_ITEM_NAME = "switch_id"
+
+
+class LocalLinkConnectionHook(base.InspectionHook):
+    """Hook to process mandatory LLDP packet fields"""
+
+    dependencies = ['validate-interfaces']
+
+    def _get_local_link_patch(self, lldp_data, port, node_uuid):
+        local_link_connection = {}
+
+        for tlv_type, tlv_value in lldp_data:
+            try:
+                data = bytearray(binascii.unhexlify(tlv_value))
+            except binascii.Error:
+                LOG.warning('TLV value for TLV type %d is not in correct '
+                            'format. Ensure that the TLV value is in '
+                            'hexidecimal format when sent to ironic. Node: %s',
+                            tlv_type, node_uuid)
+                return
+
+            item = value = None
+            if tlv_type == tlv.LLDP_TLV_PORT_ID:
+                try:
+                    port_id = tlv.PortId.parse(data)
+                except (core.MappingError, netaddr.AddrFormatError) as e:
+                    LOG.warning('TLV parse error for Port ID for node %s: %s',
+                                node_uuid, e)
+                    return
+
+                item = PORT_ID_ITEM_NAME
+                value = port_id.value.value if port_id.value else None
+            elif tlv_type == tlv.LLDP_TLV_CHASSIS_ID:
+                try:
+                    chassis_id = tlv.ChassisId.parse(data)
+                except (core.MappingError, netaddr.AddrFormatError) as e:
+                    LOG.warning('TLV parse error for Chassis ID for node %s: '
+                                '%s', node_uuid, e)
+                    return
+
+                # Only accept mac address for chassis ID
+                if 'mac_address' in chassis_id.subtype:
+                    item = SWITCH_ID_ITEM_NAME
+                    value = chassis_id.value.value
+
+            if item is None or value is None:
+                continue
+            if item in port.local_link_connection:
+                continue
+            local_link_connection[item] = value
+
+        try:
+            LOG.debug('Updating port %s for node %s', port.address, node_uuid)
+            for item in local_link_connection:
+                port.set_local_link_connection(item,
+                                               local_link_connection[item])
+            port.save()
+        except exception.IronicException as e:
+            LOG.warning('Failed to update port %(uuid)s for node %(node)s. '
+                        'Error: %(error)s', {'uuid': port.id,
+                                             'node': node_uuid,
+                                             'error': e})
+
+    def __call__(self, task, inventory, plugin_data):
+        """Process LLDP data and patch Ironic port local link connection.
+
+        Process the non-vendor-specific LLDP packet fields for each NIC found
+        for a baremetal node, port ID and chassis ID. These fields, if found
+        and if valid, will be saved into the local link connection information
+        (port id and switch id) fields on the Ironic port that represents that
+        NIC.
+        """
+        lldp_raw = plugin_data.get('lldp_raw') or {}
+
+        for iface in inventory['interfaces']:
+            # The all_interfaces field in plugin_data is provided by the
+            # validate-interfaces hook, so it is a dependency for this hook (?)
+            if iface['name'] not in plugin_data.get('all_interfaces'):
+                continue
+
+            mac_address = iface['mac_address']
+            port = ironic_port.Port.get_by_address(task.context, mac_address)
+            if not port:
+                LOG.debug('Skipping LLDP processing for interface %s of node '
+                          '%s: matching port not found in Ironic.',
+                          mac_address, task.node.uuid)
+                continue
+
+            lldp_data = lldp_raw.get(iface['name']) or iface.get('lldp')
+            if lldp_data is None:
+                LOG.warning('No LLDP data found for interface %s of node %s',
+                            mac_address, task.node.uuid)
+                continue
+
+            # Parse raw lldp data
+            self._get_local_link_patch(lldp_data, port, task.node.uuid)
diff --git a/ironic/drivers/modules/inspector/hooks/parse_lldp.py b/ironic/drivers/modules/inspector/hooks/parse_lldp.py
new file mode 100644
index 0000000000..2f2f77e82e
--- /dev/null
+++ b/ironic/drivers/modules/inspector/hooks/parse_lldp.py
@@ -0,0 +1,87 @@
+# 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.
+
+"""LLDP Processing Hook for basic TLVs"""
+
+import binascii
+
+from oslo_log import log as logging
+
+from ironic.drivers.modules.inspector.hooks import base
+from ironic.drivers.modules.inspector import lldp_parsers
+
+
+LOG = logging.getLogger(__name__)
+
+
+class ParseLLDPHook(base.InspectionHook):
+    """Process LLDP packet fields and store them in plugin_data['parsed_lldp']
+
+    Convert binary LLDP information into a readable form. Loop through raw
+    LLDP TLVs and parse those from the basic management, 802.1, and 802.3 TLV
+    sets. Store parsed data in the plugin_data as a new parsed_lldp dictionary
+    with interface names as keys.
+    """
+
+    def _parse_lldp_tlvs(self, tlvs, node_uuid):
+        """Parse LLDP TLVs into a dictionary of name/value pairs
+
+        :param tlvs: List of raw TLVs
+        :param node_uuid: UUID of the node being inspected
+        :returns: Dictionary of name/value pairs. The LLDP user-friendly
+                  names, e.g. "switch_port_id" are the keys.
+        """
+        # Generate name/value pairs for each TLV supported by this plugin.
+        parser = lldp_parsers.LLDPBasicMgmtParser(node_uuid)
+
+        for tlv_type, tlv_value in tlvs:
+            try:
+                data = bytearray(binascii.a2b_hex(tlv_value))
+            except TypeError as e:
+                LOG.warning(
+                    'TLV value for TLV type %(tlv_type)d is not in correct '
+                    'format, value must be in hexadecimal: %(msg)s. Node: '
+                    '%(node)s', {'tlv_type': tlv_type, 'msg': e,
+                                 'node': node_uuid})
+                continue
+
+            if parser.parse_tlv(tlv_type, data):
+                LOG.debug("Handled TLV type %d. Node: %s", tlv_type, node_uuid)
+            else:
+                LOG.debug("LLDP TLV type %d not handled. Node: %s", tlv_type,
+                          node_uuid)
+        return parser.nv_dict
+
+    def __call__(self, task, inventory, plugin_data):
+        """Process LLDP data and update plugin_data with processed data"""
+
+        lldp_raw = plugin_data.get('lldp_raw') or {}
+
+        for interface in inventory['interfaces']:
+            if_name = interface['name']
+            tlvs = lldp_raw.get(if_name) or interface.get('lldp')
+            if tlvs is None:
+                LOG.warning("No LLDP Data found for interface %s of node %s",
+                            if_name, task.node.uuid)
+                continue
+
+            LOG.debug("Processing LLDP Data for interface %s of node %s",
+                      if_name, task.node.uuid)
+
+            # Store LLDP data per interface in plugin_data[parsed_lldp]
+            nv = self._parse_lldp_tlvs(tlvs, task.node.uuid)
+            if nv:
+                if plugin_data.get('parsed_lldp'):
+                    plugin_data['parsed_lldp'].update({if_name: nv})
+                else:
+                    plugin_data['parsed_lldp'] = {if_name: nv}
diff --git a/ironic/drivers/modules/inspector/lldp_parsers.py b/ironic/drivers/modules/inspector/lldp_parsers.py
new file mode 100644
index 0000000000..e4fcfeb032
--- /dev/null
+++ b/ironic/drivers/modules/inspector/lldp_parsers.py
@@ -0,0 +1,364 @@
+# 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.
+
+""" Names and mapping functions used to map LLDP TLVs to name/value pairs """
+
+import binascii
+
+from construct import core
+import netaddr
+from oslo_log import log as logging
+
+from ironic.common.i18n import _
+from ironic.drivers.modules.inspector import lldp_tlvs as tlv
+
+LOG = logging.getLogger(__name__)
+
+
+# Names used in name/value pair from parsed TLVs
+LLDP_CHASSIS_ID_NM = 'switch_chassis_id'
+LLDP_PORT_ID_NM = 'switch_port_id'
+LLDP_PORT_DESC_NM = 'switch_port_description'
+LLDP_SYS_NAME_NM = 'switch_system_name'
+LLDP_SYS_DESC_NM = 'switch_system_description'
+LLDP_SWITCH_CAP_NM = 'switch_capabilities'
+LLDP_CAP_SUPPORT_NM = 'switch_capabilities_support'
+LLDP_CAP_ENABLED_NM = 'switch_capabilities_enabled'
+LLDP_MGMT_ADDRESSES_NM = 'switch_mgmt_addresses'
+LLDP_PORT_VLANID_NM = 'switch_port_untagged_vlan_id'
+LLDP_PORT_PROT_NM = 'switch_port_protocol'
+LLDP_PORT_PROT_VLAN_ENABLED_NM = 'switch_port_protocol_vlan_enabled'
+LLDP_PORT_PROT_VLAN_SUPPORT_NM = 'switch_port_protocol_vlan_support'
+LLDP_PORT_PROT_VLANIDS_NM = 'switch_port_protocol_vlan_ids'
+LLDP_PORT_VLANS_NM = 'switch_port_vlans'
+LLDP_PROTOCOL_IDENTITIES_NM = 'switch_protocol_identities'
+LLDP_PORT_MGMT_VLANID_NM = 'switch_port_management_vlan_id'
+LLDP_PORT_LINK_AGG_NM = 'switch_port_link_aggregation'
+LLDP_PORT_LINK_AGG_ENABLED_NM = 'switch_port_link_aggregation_enabled'
+LLDP_PORT_LINK_AGG_SUPPORT_NM = 'switch_port_link_aggregation_support'
+LLDP_PORT_LINK_AGG_ID_NM = 'switch_port_link_aggregation_id'
+LLDP_PORT_MAC_PHY_NM = 'switch_port_mac_phy_config'
+LLDP_PORT_LINK_AUTONEG_ENABLED_NM = 'switch_port_autonegotiation_enabled'
+LLDP_PORT_LINK_AUTONEG_SUPPORT_NM = 'switch_port_autonegotiation_support'
+LLDP_PORT_CAPABILITIES_NM = 'switch_port_physical_capabilities'
+LLDP_PORT_MAU_TYPE_NM = 'switch_port_mau_type'
+LLDP_MTU_NM = 'switch_port_mtu'
+
+
+class LLDPParser(object):
+    """Base class to handle parsing of LLDP TLVs
+
+    Each class that inherits from this base class must provide a parser map.
+    Parser maps are used to associate a LLDP TLV with a function handler and
+    arguments necessary to parse the TLV and generate one or more name/value
+    pairs. Each LLDP TLV maps to a tuple with the following fields:
+
+    function - Handler function to generate name/value pairs
+
+    construct - Name of construct definition for TLV
+
+    name - User-friendly name of TLV. For TLVs that generate only one
+    name/value pair, this is the name used
+
+    len_check - Boolean indicating if length check should be done on construct
+
+    It is valid to have a function handler of None, this is for TLVs that
+    are not mapped to a name/value pair (e.g.LLDP_TLV_TTL).
+    """
+
+    def __init__(self, node_uuid, nv=None):
+        """Create LLDPParser
+
+        :param node_uuid - UUID of node being inspected
+        :param nv - dictionary of name/value pairs to use
+        """
+        self.nv_dict = nv or {}
+        self.node_uuid = node_uuid
+        self.parser_map = {}
+
+    def set_value(self, name, value):
+        """Set name value pair in dictionary
+
+        The value for a name should not be changed if it exists.
+        """
+        self.nv_dict.setdefault(name, value)
+
+    def append_value(self, name, value):
+        """Add value to a list mapped to name"""
+        self.nv_dict.setdefault(name, []).append(value)
+
+    def add_single_value(self, struct, name, data):
+        """Add a single name/value pair to the nv dictionary"""
+        self.set_value(name, struct.value)
+
+    def add_nested_value(self, struct, name, data):
+        """Add a single nested name/value pair to the dictionary"""
+        self.set_value(name, struct.value.value)
+
+    def parse_tlv(self, tlv_type, data):
+        """Parse TLVs from mapping table
+
+        This functions takes the TLV type and the raw data for this TLV and
+        gets a tuple from the parser_map. The construct field in the tuple
+        contains the construct lib definition of the TLV which can be parsed
+        to access individual fields. Once the TLV is parsed, the handler
+        function for each TLV will store the individual fields as name/value
+        pairs in nv_dict.
+
+        If the handler function does not exist, then no name/value pairs will
+        be added to nv_dict, but since the TLV was handled, True will be
+        returned.
+
+        :param: tlv_type - type identifier for TLV
+        :param: data - raw TLV value
+        :returns: True if TLV in parser_map and data is valid, otherwise False.
+        """
+
+        s = self.parser_map.get(tlv_type)
+        if not s:
+            return False
+
+        func = s[0]  # handler
+
+        if not func:
+            return True  # TLV is handled
+
+        try:
+            tlv_parser = s[1]
+            name = s[2]
+            check_len = s[3]
+        except KeyError as e:
+            LOG.warning("Key error in TLV table: %s. Node: %s", e,
+                        self.node_uuid)
+            return False
+
+        # Some constructs require a length validation to ensure that the
+        # proper number of bytes have been provided, for example when a
+        # BitStruct is used.
+        if check_len and (tlv_parser.sizeof() != len(data)):
+            LOG.warning("Invalid data for %(name)s expected len %(expect)d, "
+                        "got %(actual)d. Node: %(node)s",
+                        {'name': name, 'expect': tlv_parser.sizeof(),
+                         'actual': len(data), 'node': self.node_uuid})
+            return False
+
+        # Use the construct parser to parse the TLV so that its individual
+        # fields can be accessed
+        try:
+            struct = tlv_parser.parse(data)
+        except (core.ConstructError, netaddr.AddrFormatError) as e:
+            LOG.warning("TLV parse error: %s. Node: %s", e, self.node_uuid)
+            return False
+
+        # Call functions with parsed structure
+        try:
+            func(struct, name, data)
+        except ValueError as e:
+            LOG.warning("TLV value error: %s. Node: %s", e, self.node_uuid)
+            return False
+
+        return True
+
+    def add_dot1_link_aggregation(self, struct, name, data):
+        """Add name/value pairs for TLV Dot1_LinkAggregationId
+
+        This is in the base class since it can be used by both dot1 and dot3.
+        """
+
+        self.set_value(LLDP_PORT_LINK_AGG_ENABLED_NM,
+                       struct.status.enabled)
+        self.set_value(LLDP_PORT_LINK_AGG_SUPPORT_NM,
+                       struct.status.supported)
+        self.set_value(LLDP_PORT_LINK_AGG_ID_NM, struct.portid)
+
+
+class LLDPBasicMgmtParser(LLDPParser):
+    """Class to handle parsing of 802.1AB Basic Management set
+
+    This class will also handle 802.1Q and 802.3 OUI TLVs.
+    """
+    def __init__(self, nv=None):
+        super(LLDPBasicMgmtParser, self).__init__(nv)
+
+        self.parser_map = {
+            tlv.LLDP_TLV_CHASSIS_ID:
+                (self.add_nested_value, tlv.ChassisId, LLDP_CHASSIS_ID_NM,
+                 False),
+            tlv.LLDP_TLV_PORT_ID:
+                (self.add_nested_value, tlv.PortId, LLDP_PORT_ID_NM, False),
+            tlv.LLDP_TLV_TTL: (None, None, None, False),
+            tlv.LLDP_TLV_PORT_DESCRIPTION:
+                (self.add_single_value, tlv.PortDesc, LLDP_PORT_DESC_NM,
+                 False),
+            tlv.LLDP_TLV_SYS_NAME:
+                (self.add_single_value, tlv.SysName, LLDP_SYS_NAME_NM, False),
+            tlv.LLDP_TLV_SYS_DESCRIPTION:
+                (self.add_single_value, tlv.SysDesc, LLDP_SYS_DESC_NM, False),
+            tlv.LLDP_TLV_SYS_CAPABILITIES:
+                (self.add_capabilities, tlv.SysCapabilities,
+                 LLDP_SWITCH_CAP_NM, True),
+            tlv.LLDP_TLV_MGMT_ADDRESS:
+                (self.add_mgmt_address, tlv.MgmtAddress,
+                 LLDP_MGMT_ADDRESSES_NM, False),
+            tlv.LLDP_TLV_ORG_SPECIFIC:
+                (self.handle_org_specific_tlv, tlv.OrgSpecific, None, False),
+            tlv.LLDP_TLV_END_LLDPPDU: (None, None, None, False)
+        }
+
+    def add_mgmt_address(self, struct, name, data):
+        """Handle LLDP_TLV_MGMT_ADDRESS
+
+        There can be multiple Mgmt Address TLVs, store in list.
+        """
+        if struct.address:
+            self.append_value(name, struct.address)
+
+    def _get_capabilities_list(self, caps):
+        """Get capabilities from bit map"""
+        cap_map = [
+            (caps.repeater, 'Repeater'),
+            (caps.bridge, 'Bridge'),
+            (caps.wlan, 'WLAN'),
+            (caps.router, 'Router'),
+            (caps.telephone, 'Telephone'),
+            (caps.docsis, 'DOCSIS cable device'),
+            (caps.station, 'Station only'),
+            (caps.cvlan, 'C-Vlan'),
+            (caps.svlan, 'S-Vlan'),
+            (caps.tpmr, 'TPMR')]
+
+        return [cap for (bit, cap) in cap_map if bit]
+
+    def add_capabilities(self, struct, name, data):
+        """Handle LLDP_TLV_SYS_CAPABILITIES"""
+        self.set_value(LLDP_CAP_SUPPORT_NM,
+                       self._get_capabilities_list(struct.system))
+        self.set_value(LLDP_CAP_ENABLED_NM,
+                       self._get_capabilities_list(struct.enabled))
+
+    def handle_org_specific_tlv(self, struct, name, data):
+        """Handle Organizationally Unique ID TLVs
+
+        This class supports 802.1Q and 802.3 OUI TLVs.
+
+        See http://www.ieee802.org/1/pages/802.1Q-2014.html, Annex D
+        and http://standards.ieee.org/about/get/802/802.3.html
+        """
+        oui = binascii.hexlify(struct.oui).decode()
+        subtype = struct.subtype
+        oui_data = data[4:]
+
+        if oui == tlv.LLDP_802dot1_OUI:
+            parser = LLDPdot1Parser(self.node_uuid, self.nv_dict)
+            if parser.parse_tlv(subtype, oui_data):
+                LOG.debug("Handled 802.1 subtype %d", subtype)
+            else:
+                LOG.debug("Subtype %d not found for 802.1", subtype)
+        elif oui == tlv.LLDP_802dot3_OUI:
+            parser = LLDPdot3Parser(self.node_uuid, self.nv_dict)
+            if parser.parse_tlv(subtype, oui_data):
+                LOG.debug("Handled 802.3 subtype %d", subtype)
+            else:
+                LOG.debug("Subtype %d not found for 802.3", subtype)
+        else:
+            LOG.warning("Organizationally Unique ID %s not recognized for "
+                        "node %s", oui, self.node_uuid)
+
+
+class LLDPdot1Parser(LLDPParser):
+    """Class to handle parsing of 802.1Q TLVs"""
+    def __init__(self, node_uuid, nv=None):
+        super(LLDPdot1Parser, self).__init__(node_uuid, nv)
+
+        self.parser_map = {
+            tlv.dot1_PORT_VLANID:
+                (self.add_single_value, tlv.Dot1_UntaggedVlanId,
+                 LLDP_PORT_VLANID_NM, False),
+            tlv.dot1_PORT_PROTOCOL_VLANID:
+                (self.add_dot1_port_protocol_vlan, tlv.Dot1_PortProtocolVlan,
+                 LLDP_PORT_PROT_NM, True),
+            tlv.dot1_VLAN_NAME:
+                (self.add_dot1_vlans, tlv.Dot1_VlanName, None, False),
+            tlv.dot1_PROTOCOL_IDENTITY:
+                (self.add_dot1_protocol_identities, tlv.Dot1_ProtocolIdentity,
+                 LLDP_PROTOCOL_IDENTITIES_NM, False),
+            tlv.dot1_MANAGEMENT_VID:
+                (self.add_single_value, tlv.Dot1_MgmtVlanId,
+                 LLDP_PORT_MGMT_VLANID_NM, False),
+            tlv.dot1_LINK_AGGREGATION:
+                (self.add_dot1_link_aggregation, tlv.Dot1_LinkAggregationId,
+                 LLDP_PORT_LINK_AGG_NM, True)
+        }
+
+    def add_dot1_port_protocol_vlan(self, struct, name, data):
+        """Handle dot1_PORT_PROTOCOL_VLANID"""
+        self.set_value(LLDP_PORT_PROT_VLAN_ENABLED_NM, struct.flags.enabled)
+        self.set_value(LLDP_PORT_PROT_VLAN_SUPPORT_NM, struct.flags.supported)
+
+        # There can be multiple port/protocol vlans TLVs, store in list
+        self.append_value(LLDP_PORT_PROT_VLANIDS_NM, struct.vlanid)
+
+    def add_dot1_vlans(self, struct, name, data):
+        """Handle dot1_VLAN_NAME
+
+        There can be multiple VLAN TLVs, add dictionary entry with id/vlan
+        to list.
+        """
+        vlan_dict = {}
+        vlan_dict['name'] = struct.vlan_name
+        vlan_dict['id'] = struct.vlanid
+        self.append_value(LLDP_PORT_VLANS_NM, vlan_dict)
+
+    def add_dot1_protocol_identities(self, struct, name, data):
+        """Handle dot1_PROTOCOL_IDENTITY
+
+        There can be multiple protocol ids TLVs, store in list
+        """
+        self.append_value(LLDP_PROTOCOL_IDENTITIES_NM,
+                          binascii.b2a_hex(struct.protocol).decode())
+
+
+class LLDPdot3Parser(LLDPParser):
+    """Class to handle parsing of 802.3 TLVs"""
+    def __init__(self, node_uuid, nv=None):
+        super(LLDPdot3Parser, self).__init__(node_uuid, nv)
+
+        # Note that 802.3 link Aggregation has been deprecated and moved to
+        # 802.1 spec, but it is in the same format. Use the same function as
+        # dot1 handler.
+        self.parser_map = {
+            tlv.dot3_MACPHY_CONFIG_STATUS:
+                (self.add_dot3_macphy_config, tlv.Dot3_MACPhy_Config_Status,
+                 LLDP_PORT_MAC_PHY_NM, True),
+            tlv.dot3_LINK_AGGREGATION:
+                (self.add_dot1_link_aggregation, tlv.Dot1_LinkAggregationId,
+                 LLDP_PORT_LINK_AGG_NM, True),
+            tlv.dot3_MTU:
+                (self.add_single_value, tlv.Dot3_MTU, LLDP_MTU_NM, False)
+        }
+
+    def add_dot3_macphy_config(self, struct, name, data):
+        """Handle dot3_MACPHY_CONFIG_STATUS"""
+
+        try:
+            mau_type = tlv.OPER_MAU_TYPES[struct.mau_type]
+        except KeyError:
+            raise ValueError(_('Invalid index for mau type'))
+
+        self.set_value(LLDP_PORT_LINK_AUTONEG_ENABLED_NM,
+                       struct.autoneg.enabled)
+        self.set_value(LLDP_PORT_LINK_AUTONEG_SUPPORT_NM,
+                       struct.autoneg.supported)
+        self.set_value(LLDP_PORT_CAPABILITIES_NM,
+                       tlv.get_autoneg_cap(struct.pmd_autoneg))
+        self.set_value(LLDP_PORT_MAU_TYPE_NM, mau_type)
diff --git a/ironic/drivers/modules/inspector/lldp_tlvs.py b/ironic/drivers/modules/inspector/lldp_tlvs.py
new file mode 100644
index 0000000000..ba0374cc6f
--- /dev/null
+++ b/ironic/drivers/modules/inspector/lldp_tlvs.py
@@ -0,0 +1,365 @@
+# 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.
+
+""" Link Layer Discovery Protocol TLVs """
+
+# See http://construct.readthedocs.io/en/latest/index.html
+
+import functools
+
+import construct
+from construct import core
+import netaddr
+from oslo_log import log as logging
+
+LOG = logging.getLogger(__name__)
+
+# Constants defined according to 802.1AB-2016 LLDP spec
+# https://standards.ieee.org/findstds/standard/802.1AB-2016.html
+
+# TLV types
+LLDP_TLV_END_LLDPPDU = 0
+LLDP_TLV_CHASSIS_ID = 1
+LLDP_TLV_PORT_ID = 2
+LLDP_TLV_TTL = 3
+LLDP_TLV_PORT_DESCRIPTION = 4
+LLDP_TLV_SYS_NAME = 5
+LLDP_TLV_SYS_DESCRIPTION = 6
+LLDP_TLV_SYS_CAPABILITIES = 7
+LLDP_TLV_MGMT_ADDRESS = 8
+LLDP_TLV_ORG_SPECIFIC = 127
+
+# 802.1Q defines from http://www.ieee802.org/1/pages/802.1Q-2014.html, Annex D
+LLDP_802dot1_OUI = "0080c2"
+# subtypes
+dot1_PORT_VLANID = 1
+dot1_PORT_PROTOCOL_VLANID = 2
+dot1_VLAN_NAME = 3
+dot1_PROTOCOL_IDENTITY = 4
+dot1_MANAGEMENT_VID = 6
+dot1_LINK_AGGREGATION = 7
+
+# 802.3 defines from http://standards.ieee.org/about/get/802/802.3.html,
+# section 79
+LLDP_802dot3_OUI = "00120f"
+# Subtypes
+dot3_MACPHY_CONFIG_STATUS = 1
+dot3_LINK_AGGREGATION = 3  # Deprecated, but still in use
+dot3_MTU = 4
+
+
+def bytes_to_int(obj):
+    """Convert bytes to an integer
+
+    :param: obj - array of bytes
+    """
+    return functools.reduce(lambda x, y: x << 8 | y, obj)
+
+
+def mapping_for_enum(mapping):
+    """Return tuple used for keys as a dict
+
+    :param: mapping - dict with tuple as keys
+    """
+    return dict(mapping.keys())
+
+
+def mapping_for_switch(mapping):
+    """Return dict from values
+
+     :param: mapping - dict with tuple as keys
+     """
+    return {key[0]: value for key, value in mapping.items()}
+
+
+IPv4Address = core.ExprAdapter(
+    core.Byte[4],
+    encoder=lambda obj, ctx: netaddr.IPAddress(obj).words,
+    decoder=lambda obj, ctx: str(netaddr.IPAddress(bytes_to_int(obj)))
+)
+
+IPv6Address = core.ExprAdapter(
+    core.Byte[16],
+    encoder=lambda obj, ctx: netaddr.IPAddress(obj).words,
+    decoder=lambda obj, ctx: str(netaddr.IPAddress(bytes_to_int(obj)))
+)
+
+MACAddress = core.ExprAdapter(
+    core.Byte[6],
+    encoder=lambda obj, ctx: netaddr.EUI(obj).words,
+    decoder=lambda obj, ctx: str(netaddr.EUI(bytes_to_int(obj),
+                                 dialect=netaddr.mac_unix_expanded))
+)
+
+IANA_ADDRESS_FAMILY_ID_MAPPING = {
+    ('ipv4', 1): IPv4Address,
+    ('ipv6', 2): IPv6Address,
+    ('mac', 6): MACAddress,
+}
+
+IANAAddress = core.Struct(
+    'family' / core.Enum(core.Int8ub, **mapping_for_enum(
+        IANA_ADDRESS_FAMILY_ID_MAPPING)),
+    'value' / core.Switch(construct.this.family, mapping_for_switch(
+        IANA_ADDRESS_FAMILY_ID_MAPPING)))
+
+# Note that 'GreedyString()' is used in cases where string len is not defined
+CHASSIS_ID_MAPPING = {
+    ('entPhysAlias_c', 1): core.Struct('value' / core.GreedyString("utf8")),
+    ('ifAlias', 2): core.Struct('value' / core.GreedyString("utf8")),
+    ('entPhysAlias_p', 3): core.Struct('value' / core.GreedyString("utf8")),
+    ('mac_address', 4): core.Struct('value' / MACAddress),
+    ('IANA_address', 5): IANAAddress,
+    ('ifName', 6): core.Struct('value' / core.GreedyString("utf8")),
+    ('local', 7): core.Struct('value' / core.GreedyString("utf8"))
+}
+
+#
+# Basic Management Set TLV field definitions
+#
+
+# Chassis ID value is based on the subtype
+ChassisId = core.Struct(
+    'subtype' / core.Enum(core.Byte, **mapping_for_enum(
+        CHASSIS_ID_MAPPING)),
+    'value' / core.Switch(construct.this.subtype,
+                          mapping_for_switch(CHASSIS_ID_MAPPING))
+)
+
+PORT_ID_MAPPING = {
+    ('ifAlias', 1): core.Struct('value' / core.GreedyString("utf8")),
+    ('entPhysicalAlias', 2): core.Struct('value' / core.GreedyString("utf8")),
+    ('mac_address', 3): core.Struct('value' / MACAddress),
+    ('IANA_address', 4): IANAAddress,
+    ('ifName', 5): core.Struct('value' / core.GreedyString("utf8")),
+    ('local', 7): core.Struct('value' / core.GreedyString("utf8"))
+}
+
+# Port ID value is based on the subtype
+PortId = core.Struct(
+    'subtype' / core.Enum(core.Byte, **mapping_for_enum(
+        PORT_ID_MAPPING)),
+    'value' / core.Switch(construct.this.subtype,
+                          mapping_for_switch(PORT_ID_MAPPING))
+)
+
+PortDesc = core.Struct('value' / core.GreedyString("utf8"))
+
+SysName = core.Struct('value' / core.GreedyString("utf8"))
+
+SysDesc = core.Struct('value' / core.GreedyString("utf8"))
+
+MgmtAddress = core.Struct(
+    'len' / core.Int8ub,
+    'family' / core.Enum(core.Int8ub, **mapping_for_enum(
+        IANA_ADDRESS_FAMILY_ID_MAPPING)),
+    'address' / core.Switch(construct.this.family, mapping_for_switch(
+        IANA_ADDRESS_FAMILY_ID_MAPPING))
+)
+
+Capabilities = core.BitStruct(
+    core.Padding(5),
+    'tpmr' / core.Bit,
+    'svlan' / core.Bit,
+    'cvlan' / core.Bit,
+    'station' / core.Bit,
+    'docsis' / core.Bit,
+    'telephone' / core.Bit,
+    'router' / core.Bit,
+    'wlan' / core.Bit,
+    'bridge' / core.Bit,
+    'repeater' / core.Bit,
+    core.Padding(1)
+)
+
+SysCapabilities = core.Struct(
+    'system' / Capabilities,
+    'enabled' / Capabilities
+)
+
+OrgSpecific = core.Struct(
+    'oui' / core.Bytes(3),
+    'subtype' / core.Int8ub
+)
+
+#
+# 802.1Q TLV field definitions
+# See http://www.ieee802.org/1/pages/802.1Q-2014.html, Annex D
+#
+
+Dot1_UntaggedVlanId = core.Struct('value' / core.Int16ub)
+
+Dot1_PortProtocolVlan = core.Struct(
+    'flags' / core.BitStruct(
+        core.Padding(5),
+        'enabled' / core.Flag,
+        'supported' / core.Flag,
+        core.Padding(1),
+    ),
+    'vlanid' / core.Int16ub
+)
+
+Dot1_VlanName = core.Struct(
+    'vlanid' / core.Int16ub,
+    'name_len' / core.Rebuild(core.Int8ub,
+                              construct.len_(construct.this.value)),
+    'vlan_name' / core.PaddedString(construct.this.name_len, "utf8")
+)
+
+Dot1_ProtocolIdentity = core.Struct(
+    'len' / core.Rebuild(core.Int8ub, construct.len_(construct.this.value)),
+    'protocol' / core.Bytes(construct.this.len)
+)
+
+Dot1_MgmtVlanId = core.Struct('value' / core.Int16ub)
+
+Dot1_LinkAggregationId = core.Struct(
+    'status' / core.BitStruct(
+        core.Padding(6),
+        'enabled' / core.Flag,
+        'supported' / core.Flag
+    ),
+    'portid' / core.Int32ub
+)
+
+#
+# 802.3 TLV field definitions
+# See http://standards.ieee.org/about/get/802/802.3.html,
+# section 79
+#
+
+
+def get_autoneg_cap(pmd):
+    """Get autonegotiated capability strings
+
+    This returns a list of capability strings from the Physical Media
+    Dependent (PMD) capability bits.
+
+    :param  pmd: PMD bits
+    :return: Sorted ist containing capability strings
+    """
+    caps_set = set()
+
+    pmd_map = [
+        (pmd._10base_t_hdx, '10BASE-T hdx'),
+        (pmd._10base_t_hdx, '10BASE-T fdx'),
+        (pmd._10base_t4, '10BASE-T4'),
+        (pmd._100base_tx_hdx, '100BASE-TX hdx'),
+        (pmd._100base_tx_fdx, '100BASE-TX fdx'),
+        (pmd._100base_t2_hdx, '100BASE-T2 hdx'),
+        (pmd._100base_t2_fdx, '100BASE-T2 fdx'),
+        (pmd.pause_fdx, 'PAUSE fdx'),
+        (pmd.asym_pause, 'Asym PAUSE fdx'),
+        (pmd.sym_pause, 'Sym PAUSE fdx'),
+        (pmd.asym_sym_pause, 'Asym and Sym PAUSE fdx'),
+        (pmd._1000base_x_hdx, '1000BASE-X hdx'),
+        (pmd._1000base_x_fdx, '1000BASE-X fdx'),
+        (pmd._1000base_t_hdx, '1000BASE-T hdx'),
+        (pmd._1000base_t_fdx, '1000BASE-T fdx')]
+
+    for bit, cap in pmd_map:
+        if bit:
+            caps_set.add(cap)
+
+    return sorted(caps_set)
+
+
+Dot3_MACPhy_Config_Status = core.Struct(
+    'autoneg' / core.BitStruct(
+        core.Padding(6),
+        'enabled' / core.Flag,
+        'supported' / core.Flag,
+    ),
+    # See IANAifMauAutoNegCapBits
+    # RFC 4836, Definitions of Managed Objects for IEEE 802.3
+    'pmd_autoneg' / core.BitStruct(
+        core.Padding(1),
+        '_10base_t_hdx' / core.Bit,
+        '_10base_t_fdx' / core.Bit,
+        '_10base_t4' / core.Bit,
+        '_100base_tx_hdx' / core.Bit,
+        '_100base_tx_fdx' / core.Bit,
+        '_100base_t2_hdx' / core.Bit,
+        '_100base_t2_fdx' / core.Bit,
+        'pause_fdx' / core.Bit,
+        'asym_pause' / core.Bit,
+        'sym_pause' / core.Bit,
+        'asym_sym_pause' / core.Bit,
+        '_1000base_x_hdx' / core.Bit,
+        '_1000base_x_fdx' / core.Bit,
+        '_1000base_t_hdx' / core.Bit,
+        '_1000base_t_fdx' / core.Bit
+    ),
+    'mau_type' / core.Int16ub
+)
+
+# See ifMauTypeList in
+# RFC 4836, Definitions of Managed Objects for IEEE 802.3
+OPER_MAU_TYPES = {
+    0: "Unknown",
+    1: "AUI",
+    2: "10BASE-5",
+    3: "FOIRL",
+    4: "10BASE-2",
+    5: "10BASE-T duplex mode unknown",
+    6: "10BASE-FP",
+    7: "10BASE-FB",
+    8: "10BASE-FL duplex mode unknown",
+    9: "10BROAD36",
+    10: "10BASE-T half duplex",
+    11: "10BASE-T full duplex",
+    12: "10BASE-FL half duplex",
+    13: "10BASE-FL full duplex",
+    14: "100 BASE-T4",
+    15: "100BASE-TX half duplex",
+    16: "100BASE-TX full duplex",
+    17: "100BASE-FX half duplex",
+    18: "100BASE-FX full duplex",
+    19: "100BASE-T2 half duplex",
+    20: "100BASE-T2 full duplex",
+    21: "1000BASE-X half duplex",
+    22: "1000BASE-X full duplex",
+    23: "1000BASE-LX half duplex",
+    24: "1000BASE-LX full duplex",
+    25: "1000BASE-SX half duplex",
+    26: "1000BASE-SX full duplex",
+    27: "1000BASE-CX half duplex",
+    28: "1000BASE-CX full duplex",
+    29: "1000BASE-T half duplex",
+    30: "1000BASE-T full duplex",
+    31: "10GBASE-X",
+    32: "10GBASE-LX4",
+    33: "10GBASE-R",
+    34: "10GBASE-ER",
+    35: "10GBASE-LR",
+    36: "10GBASE-SR",
+    37: "10GBASE-W",
+    38: "10GBASE-EW",
+    39: "10GBASE-LW",
+    40: "10GBASE-SW",
+    41: "10GBASE-CX4",
+    42: "2BASE-TL",
+    43: "10PASS-TS",
+    44: "100BASE-BX10D",
+    45: "100BASE-BX10U",
+    46: "100BASE-LX10",
+    47: "1000BASE-BX10D",
+    48: "1000BASE-BX10U",
+    49: "1000BASE-LX10",
+    50: "1000BASE-PX10D",
+    51: "1000BASE-PX10U",
+    52: "1000BASE-PX20D",
+    53: "1000BASE-PX20U",
+}
+
+Dot3_MTU = core.Struct('value' / core.Int16ub)
diff --git a/ironic/objects/port.py b/ironic/objects/port.py
index 8f6f7ddf0a..d961a6e3eb 100644
--- a/ironic/objects/port.py
+++ b/ironic/objects/port.py
@@ -491,6 +491,18 @@ class Port(base.IronicObject, object_base.VersionedObjectDictCompat):
         """
         return cls.supports_version((1, 9))
 
+    def set_local_link_connection(self, key, value):
+        """Set a `local_link_connection` value.
+
+        Setting a `local_link_connection` dict value via this method ensures
+        that this field will be flagged for saving.
+
+        :param key: Key of item to set
+        :param value: Value of item to set
+        """
+        self.local_link_connection[key] = value
+        self._changed_fields.add('local_link_connection')
+
 
 @base.IronicObjectRegistry.register
 class PortCRUDNotification(notification.NotificationBase):
diff --git a/ironic/tests/unit/drivers/modules/inspector/hooks/test_local_link_connection.py b/ironic/tests/unit/drivers/modules/inspector/hooks/test_local_link_connection.py
new file mode 100644
index 0000000000..d5b224b62e
--- /dev/null
+++ b/ironic/tests/unit/drivers/modules/inspector/hooks/test_local_link_connection.py
@@ -0,0 +1,178 @@
+# 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.
+
+import copy
+from unittest import mock
+
+from oslo_utils import uuidutils
+
+from ironic.conductor import task_manager
+from ironic.conf import CONF
+from ironic.drivers.modules.inspector.hooks import local_link_connection as \
+    hook
+from ironic.objects import port
+from ironic.tests.unit.db import base as db_base
+from ironic.tests.unit.objects import utils as obj_utils
+
+
+_INVENTORY = {
+    'interfaces': [{
+        'name': 'em1',
+        'mac_address': '11:11:11:11:11:11',
+        'ipv4_address': '1.1.1.1',
+        'lldp': [(0, ''),
+                 (1, '04885a92ec5459'),
+                 (2, '0545746865726e6574312f3138'),
+                 (3, '0078')]
+    }]
+}
+
+
+class LocalLinkConnectionTestCase(db_base.DbTestCase):
+    def setUp(self):
+        super().setUp()
+        CONF.set_override('enabled_inspect_interfaces',
+                          ['agent', 'no-inspect'])
+        self.node = obj_utils.create_test_node(self.context,
+                                               inspect_interface='agent')
+        self.inventory = copy.deepcopy(_INVENTORY)
+        self.plugin_data = {'all_interfaces': {'em1': {}}}
+        self.port = obj_utils.create_test_port(
+            self.context, uuid=uuidutils.generate_uuid(), node_id=self.node.id,
+            address='11:11:11:11:11:11', local_link_connection={})
+
+    @mock.patch.object(port.Port, 'save', autospec=True)
+    @mock.patch.object(port.Port, 'get_by_address', autospec=True)
+    def test_valid_data(self, mock_get_port, mock_port_save):
+        mock_get_port.return_value = self.port
+        with task_manager.acquire(self.context, self.node.id) as task:
+            hook.LocalLinkConnectionHook().__call__(task, self.inventory,
+                                                    self.plugin_data)
+            self.assertTrue(mock_port_save.called)
+            self.assertEqual({'switch_id': '88:5a:92:ec:54:59',
+                              'port_id': 'Ethernet1/18'},
+                             self.port.local_link_connection)
+
+    @mock.patch.object(port.Port, 'save', autospec=True)
+    @mock.patch.object(port.Port, 'get_by_address', autospec=True)
+    def test_lldp_none(self, mock_get_port, mock_port_save):
+        self.inventory['interfaces'][0]['lldp'] = None
+        mock_get_port.return_value = self.port
+        with task_manager.acquire(self.context, self.node.id) as task:
+            hook.LocalLinkConnectionHook().__call__(task, self.inventory,
+                                                    self.plugin_data)
+            self.assertFalse(mock_port_save.called)
+            self.assertEqual(self.port.local_link_connection, {})
+
+    @mock.patch.object(port.Port, 'save', autospec=True)
+    def test_interface_not_in_all_interfaces(self, mock_port_save):
+        self.plugin_data['all_interfaces'] = {}
+        with task_manager.acquire(self.context, self.node.id) as task:
+            hook.LocalLinkConnectionHook().__call__(task, self.inventory,
+                                                    self.plugin_data)
+            self.assertFalse(mock_port_save.called)
+            self.assertEqual(self.port.local_link_connection, {})
+
+    @mock.patch.object(hook.LOG, 'debug', autospec=True)
+    @mock.patch.object(port.Port, 'get_by_address', autospec=True)
+    @mock.patch.object(port.Port, 'save', autospec=True)
+    def test_no_port_in_ironic(self, mock_port_save, mock_get_port, mock_log):
+        mock_get_port.return_value = None
+        with task_manager.acquire(self.context, self.node.id) as task:
+            hook.LocalLinkConnectionHook().__call__(task, self.inventory,
+                                                    self.plugin_data)
+            self.assertFalse(mock_port_save.called)
+            self.assertEqual(self.port.local_link_connection, {})
+            mock_log.assert_called_once_with(
+                'Skipping LLDP processing for interface %s of node %s: '
+                'matching port not found in Ironic.',
+                self.inventory['interfaces'][0]['mac_address'],
+                task.node.uuid)
+
+    @mock.patch.object(port.Port, 'save', autospec=True)
+    @mock.patch.object(port.Port, 'get_by_address', autospec=True)
+    def test_port_local_link_connection_already_exists(self,
+                                                       mock_get_port,
+                                                       mock_port_save):
+        self.port['local_link_connection'] = {'switch_id': '11:11:11:11:11:11',
+                                              'port_id': 'Ether'}
+        mock_get_port.return_value = self.port
+
+        with task_manager.acquire(self.context, self.node.id) as task:
+            hook.LocalLinkConnectionHook().__call__(task, self.inventory,
+                                                    self.plugin_data)
+            self.assertTrue(mock_port_save.called)
+            self.assertEqual(self.port.local_link_connection,
+                             {'switch_id': '11:11:11:11:11:11',
+                              'port_id': 'Ether'})
+
+    @mock.patch.object(port.Port, 'save', autospec=True)
+    @mock.patch.object(hook.LOG, 'warning', autospec=True)
+    @mock.patch.object(port.Port, 'get_by_address', autospec=True)
+    def test_invalid_tlv_value_hex_format(self, mock_get_port, mock_log,
+                                          mock_port_save):
+        self.inventory['interfaces'][0]['lldp'] = [(2, 'weee')]
+        mock_get_port.return_value = self.port
+        with task_manager.acquire(self.context, self.node.id) as task:
+            hook.LocalLinkConnectionHook().__call__(task, self.inventory,
+                                                    self.plugin_data)
+            mock_log.assert_called_once_with(
+                'TLV value for TLV type %d is not in correct format. Ensure '
+                'that the TLV value is in hexidecimal format when sent to '
+                'ironic. Node: %s', 2, task.node.uuid)
+            self.assertFalse(mock_port_save.called)
+            self.assertEqual(self.port.local_link_connection, {})
+
+    @mock.patch.object(port.Port, 'save', autospec=True)
+    @mock.patch.object(port.Port, 'get_by_address', autospec=True)
+    def test_invalid_port_id_subtype(self, mock_get_port, mock_port_save):
+        # First byte of TLV value is processed to calculate the subtype for
+        # the port ID, Subtype 6 ('06...') isn't a subtype supported by this
+        # hook, so we expect it to skip this TLV.
+        self.inventory['interfaces'][0]['lldp'][2] = (
+            2, '0645746865726e6574312f3138')
+        mock_get_port.return_value = self.port
+        with task_manager.acquire(self.context, self.node.id) as task:
+            hook.LocalLinkConnectionHook().__call__(task, self.inventory,
+                                                    self.plugin_data)
+            self.assertTrue(mock_port_save.called)
+            self.assertEqual(self.port.local_link_connection,
+                             {'switch_id': '88:5a:92:ec:54:59'})
+
+    @mock.patch.object(port.Port, 'save', autospec=True)
+    @mock.patch.object(port.Port, 'get_by_address', autospec=True)
+    def test_port_id_subtype_mac(self, mock_get_port, mock_port_save):
+        self.inventory['interfaces'][0]['lldp'][2] = (
+            2, '03885a92ec5458')
+        mock_get_port.return_value = self.port
+        with task_manager.acquire(self.context, self.node.id) as task:
+            hook.LocalLinkConnectionHook().__call__(task, self.inventory,
+                                                    self.plugin_data)
+            self.assertTrue(mock_port_save.called)
+            self.assertEqual(self.port.local_link_connection,
+                             {'port_id': '88:5a:92:ec:54:58',
+                              'switch_id': '88:5a:92:ec:54:59'})
+
+    @mock.patch.object(port.Port, 'save', autospec=True)
+    @mock.patch.object(port.Port, 'get_by_address', autospec=True)
+    def test_invalid_chassis_id_subtype(self, mock_get_port, mock_port_save):
+        # First byte of TLV value is processed to calculate the subtype for
+        # the chassis ID, Subtype 5 ('05...') isn't a subtype supported by
+        # this hook, so we expect it to skip this TLV.
+        self.inventory['interfaces'][0]['lldp'][1] = (1, '05885a92ec5459')
+        mock_get_port.return_value = self.port
+        with task_manager.acquire(self.context, self.node.id) as task:
+            hook.LocalLinkConnectionHook().__call__(task, self.inventory,
+                                                    self.plugin_data)
+            self.assertTrue(mock_port_save.called)
+            self.assertEqual({'port_id': 'Ethernet1/18'},
+                             self.port.local_link_connection)
diff --git a/ironic/tests/unit/drivers/modules/inspector/hooks/test_parse_lldp.py b/ironic/tests/unit/drivers/modules/inspector/hooks/test_parse_lldp.py
new file mode 100644
index 0000000000..8513af2109
--- /dev/null
+++ b/ironic/tests/unit/drivers/modules/inspector/hooks/test_parse_lldp.py
@@ -0,0 +1,422 @@
+# 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.
+
+from unittest import mock
+
+from ironic.conductor import task_manager
+from ironic.conf import CONF
+from ironic.drivers.modules.inspector.hooks import parse_lldp as hook
+from ironic.drivers.modules.inspector import lldp_parsers as nv
+from ironic.tests.unit.db import base as db_base
+from ironic.tests.unit.objects import utils as obj_utils
+
+
+class ParseLLDPTestCase(db_base.DbTestCase):
+    def setUp(self):
+        super().setUp()
+        CONF.set_override('enabled_inspect_interfaces',
+                          ['agent', 'no-inspect'])
+        self.node = obj_utils.create_test_node(self.context,
+                                               inspect_interface='agent')
+        self.inventory = {
+            'interfaces': [{
+                'name': 'em1',
+            }],
+            'cpu': 1,
+            'disks': 1,
+            'memory': 1
+        }
+        self.ip = '1.2.1.2'
+        self.mac = '11:22:33:44:55:66'
+        self.plugin_data = {'all_interfaces':
+                            {'em1': {'mac': self.mac,
+                                     'ip': self.ip}}}
+        self.expected = {'em1': {'ip': self.ip, 'mac': self.mac}}
+
+    def test_all_valid_data(self):
+        self.plugin_data['lldp_raw'] = {
+            'em1': [
+                [1, "04112233aabbcc"],  # ChassisId
+                [2, "07373334"],        # PortId
+                [3, "003c"],            # TTL
+                [4, "686f737430322e6c61622e656e6720706f7274203320"
+                 "28426f6e6429"],  # PortDesc
+                [5, "737730312d646973742d31622d623132"],  # SysName
+                [6, "4e6574776f726b732c20496e632e20353530302c2076657273696f"
+                 "6e203132204275696c6420646174653a20323031342d30332d31332030"
+                 "383a33383a33302055544320"],  # SysDesc
+                [7, "00140014"],  # SysCapabilities
+                [8, "0501c000020f020000000000"],  # MgmtAddress
+                [8, "110220010db885a3000000008a2e03707334020000000000"],
+                [8, "0706aa11bb22cc3302000003e900"],  # MgmtAddress
+                [127, "00120f01036c110010"],  # dot3 MacPhyConfigStatus
+                [127, "00120f030300000002"],  # dot3 LinkAggregation
+                [127, "00120f0405ea"],  # dot3 MTU
+                [127, "0080c2010066"],  # dot1 PortVlan
+                [127, "0080c20206000a"],  # dot1 PortProtocolVlanId
+                [127, "0080c202060014"],  # dot1 PortProtocolVlanId
+                [127, "0080c204080026424203000000"],   # dot1 ProtocolIdentity
+                [127, "0080c203006507766c616e313031"],  # dot1 VlanName
+                [127, "0080c203006607766c616e313032"],  # dot1 VlanName
+                [127, "0080c203006807766c616e313034"],  # dot1 VlanName
+                [127, "0080c2060058"],  # dot1 MgmtVID
+                [0, ""],
+            ]
+        }
+        self.inventory['interfaces'] = [{
+            'name': 'em1',
+            'lldp': [[0, ""]]
+        }]
+
+        expected = {
+            nv.LLDP_CAP_ENABLED_NM: ['Bridge', 'Router'],
+            nv.LLDP_CAP_SUPPORT_NM: ['Bridge', 'Router'],
+            nv.LLDP_CHASSIS_ID_NM: "11:22:33:aa:bb:cc",
+            nv.LLDP_MGMT_ADDRESSES_NM: ['192.0.2.15',
+                                        '2001:db8:85a3::8a2e:370:7334',
+                                        'aa:11:bb:22:cc:33'],
+            nv.LLDP_PORT_LINK_AUTONEG_ENABLED_NM: True,
+            nv.LLDP_PORT_DESC_NM: 'host02.lab.eng port 3 (Bond)',
+            nv.LLDP_PORT_ID_NM: '734',
+            nv.LLDP_PORT_LINK_AGG_ENABLED_NM: True,
+            nv.LLDP_PORT_LINK_AGG_ID_NM: 2,
+            nv.LLDP_PORT_LINK_AGG_SUPPORT_NM: True,
+            nv.LLDP_PORT_MGMT_VLANID_NM: 88,
+            nv.LLDP_PORT_MAU_TYPE_NM: '100BASE-TX full duplex',
+            nv.LLDP_MTU_NM: 1514,
+            nv.LLDP_PORT_CAPABILITIES_NM: ['1000BASE-T fdx',
+                                           '100BASE-TX fdx',
+                                           '100BASE-TX hdx',
+                                           '10BASE-T fdx',
+                                           '10BASE-T hdx',
+                                           'Asym and Sym PAUSE fdx'],
+            nv.LLDP_PORT_PROT_VLAN_ENABLED_NM: True,
+            nv.LLDP_PORT_PROT_VLANIDS_NM: [10, 20],
+            nv.LLDP_PORT_PROT_VLAN_SUPPORT_NM: True,
+            nv.LLDP_PORT_VLANID_NM: 102,
+            nv.LLDP_PORT_VLANS_NM: [{'id': 101, 'name': 'vlan101'},
+                                    {'id': 102, 'name': 'vlan102'},
+                                    {'id': 104, "name": 'vlan104'}],
+            nv.LLDP_PROTOCOL_IDENTITIES_NM: ['0026424203000000'],
+            nv.LLDP_SYS_DESC_NM: 'Networks, Inc. 5500, version 12'
+            ' Build date: 2014-03-13 08:38:30 UTC ',
+            nv.LLDP_SYS_NAME_NM: 'sw01-dist-1b-b12'
+        }
+        with task_manager.acquire(self.context, self.node.id) as task:
+            hook.ParseLLDPHook().__call__(task, self.inventory,
+                                          self.plugin_data)
+            actual = self.plugin_data.get('parsed_lldp').get('em1')
+
+            for name, value in expected.items():
+                if name is nv.LLDP_PORT_VLANS_NM:
+                    for d1, d2 in zip(expected[name], actual[name]):
+                        for key, value in d1.items():
+                            self.assertEqual(d2[key], value)
+                else:
+                    self.assertEqual(actual[name], expected[name])
+
+    def test_old_format(self):
+        self.inventory['interfaces'] = [{
+            'name': 'em1',
+            'lldp': [
+                [1, "04112233aabbcc"],  # ChassisId
+                [2, "07373334"],        # PortId
+                [3, "003c"],            # TTL
+                [4, "686f737430322e6c61622e656e6720706f7274203320"
+                 "28426f6e6429"],  # PortDesc
+                [5, "737730312d646973742d31622d623132"],  # SysName
+                [6, "4e6574776f726b732c20496e632e20353530302c2076657273696f"
+                 "6e203132204275696c6420646174653a20323031342d30332d31332030"
+                 "383a33383a33302055544320"],  # SysDesc
+                [7, "00140014"],  # SysCapabilities
+                [8, "0501c000020f020000000000"],  # MgmtAddress
+                [8, "110220010db885a3000000008a2e03707334020000000000"],
+                [8, "0706aa11bb22cc3302000003e900"],  # MgmtAddress
+                [127, "00120f01036c110010"],  # dot3 MacPhyConfigStatus
+                [127, "00120f030300000002"],  # dot3 LinkAggregation
+                [127, "00120f0405ea"],  # dot3 MTU
+                [127, "0080c2010066"],  # dot1 PortVlan
+                [127, "0080c20206000a"],  # dot1 PortProtocolVlanId
+                [127, "0080c202060014"],  # dot1 PortProtocolVlanId
+                [127, "0080c204080026424203000000"],   # dot1 ProtocolIdentity
+                [127, "0080c203006507766c616e313031"],  # dot1 VlanName
+                [127, "0080c203006607766c616e313032"],  # dot1 VlanName
+                [127, "0080c203006807766c616e313034"],  # dot1 VlanName
+                [127, "0080c2060058"],  # dot1 MgmtVID
+                [0, ""]]
+        }]
+
+        expected = {
+            nv.LLDP_CAP_ENABLED_NM: ['Bridge', 'Router'],
+            nv.LLDP_CAP_SUPPORT_NM: ['Bridge', 'Router'],
+            nv.LLDP_CHASSIS_ID_NM: "11:22:33:aa:bb:cc",
+            nv.LLDP_MGMT_ADDRESSES_NM: ['192.0.2.15',
+                                        '2001:db8:85a3::8a2e:370:7334',
+                                        'aa:11:bb:22:cc:33'],
+            nv.LLDP_PORT_LINK_AUTONEG_ENABLED_NM: True,
+            nv.LLDP_PORT_DESC_NM: 'host02.lab.eng port 3 (Bond)',
+            nv.LLDP_PORT_ID_NM: '734',
+            nv.LLDP_PORT_LINK_AGG_ENABLED_NM: True,
+            nv.LLDP_PORT_LINK_AGG_ID_NM: 2,
+            nv.LLDP_PORT_LINK_AGG_SUPPORT_NM: True,
+            nv.LLDP_PORT_MGMT_VLANID_NM: 88,
+            nv.LLDP_PORT_MAU_TYPE_NM: '100BASE-TX full duplex',
+            nv.LLDP_MTU_NM: 1514,
+            nv.LLDP_PORT_CAPABILITIES_NM: ['1000BASE-T fdx',
+                                           '100BASE-TX fdx',
+                                           '100BASE-TX hdx',
+                                           '10BASE-T fdx',
+                                           '10BASE-T hdx',
+                                           'Asym and Sym PAUSE fdx'],
+            nv.LLDP_PORT_PROT_VLAN_ENABLED_NM: True,
+            nv.LLDP_PORT_PROT_VLANIDS_NM: [10, 20],
+            nv.LLDP_PORT_PROT_VLAN_SUPPORT_NM: True,
+            nv.LLDP_PORT_VLANID_NM: 102,
+            nv.LLDP_PORT_VLANS_NM: [{'id': 101, 'name': 'vlan101'},
+                                    {'id': 102, 'name': 'vlan102'},
+                                    {'id': 104, "name": 'vlan104'}],
+            nv.LLDP_PROTOCOL_IDENTITIES_NM: ['0026424203000000'],
+            nv.LLDP_SYS_DESC_NM: 'Networks, Inc. 5500, version 12 '
+            'Build date: 2014-03-13 08:38:30 UTC ',
+            nv.LLDP_SYS_NAME_NM: 'sw01-dist-1b-b12'
+        }
+
+        with task_manager.acquire(self.context, self.node.id) as task:
+            hook.ParseLLDPHook().__call__(task, self.inventory,
+                                          self.plugin_data)
+            actual = self.plugin_data['parsed_lldp']['em1']
+            for name, value in expected.items():
+                if name is nv.LLDP_PORT_VLANS_NM:
+                    for d1, d2 in zip(expected[name], actual[name]):
+                        for key, value in d1.items():
+                            self.assertEqual(d2[key], value)
+                else:
+                    self.assertEqual(actual[name], expected[name])
+
+    def test_multiple_interfaces(self):
+        self.inventory = {
+            # An artificial mix of old and new LLDP fields.
+            'interfaces': [
+                {
+                    'name': 'em1'
+                },
+                {
+                    'name': 'em2',
+                    'lldp': [
+                        [1, "04112233aabbdd"],
+                        [2, "07373838"],
+                        [3, "003c"]
+                    ]
+                },
+                {
+                    'name': 'em3',
+                    'lldp': [[3, "003c"]]
+                }
+            ],
+            'cpu': 1,
+            'disks': 1,
+            'memory': 1
+        }
+        self.plugin_data = {
+            'all_interfaces': {
+                'em1': {'mac': self.mac, 'ip': self.ip},
+                'em2': {'mac': self.mac, 'ip': self.ip},
+                'em3': {'mac': self.mac, 'ip': self.ip}
+            },
+            'lldp_raw': {
+                'em1': [
+                    [1, "04112233aabbcc"],
+                    [2, "07373334"],
+                    [3, "003c"]
+                ],
+                'em3': [
+                    [1, "04112233aabbee"],
+                    [2, "07373939"],
+                    [3, "003c"]
+                ],
+            }
+        }
+        expected = {"em1": {nv.LLDP_CHASSIS_ID_NM: "11:22:33:aa:bb:cc",
+                            nv.LLDP_PORT_ID_NM: "734"},
+                    "em2": {nv.LLDP_CHASSIS_ID_NM: "11:22:33:aa:bb:dd",
+                            nv.LLDP_PORT_ID_NM: "788"},
+                    "em3": {nv.LLDP_CHASSIS_ID_NM: "11:22:33:aa:bb:ee",
+                            nv.LLDP_PORT_ID_NM: "799"}}
+        with task_manager.acquire(self.context, self.node.id) as task:
+            hook.ParseLLDPHook().__call__(task, self.inventory,
+                                          self.plugin_data)
+            self.assertEqual(expected, self.plugin_data['parsed_lldp'])
+
+    def test_chassis_ids(self):
+        # Test IPv4 address
+        self.inventory['interfaces'] = [
+            {
+                'name': 'em1',
+                'lldp': [[1, '0501c000020f']]
+            },
+            {
+                'name': 'em2',
+                'lldp': [[1, '0773773031']]
+            }
+        ]
+        self.expected = {
+            'em1': {nv.LLDP_CHASSIS_ID_NM: '192.0.2.15'},
+            'em2': {nv.LLDP_CHASSIS_ID_NM: "sw01"}
+        }
+        with task_manager.acquire(self.context, self.node.id) as task:
+            hook.ParseLLDPHook().__call__(task, self.inventory,
+                                          self.plugin_data)
+            self.assertEqual(self.expected, self.plugin_data['parsed_lldp'])
+
+    def test_duplicate_tlvs(self):
+        self.inventory['interfaces'] = [{
+            'name': 'em1',
+            'lldp': [
+                [1, "04112233aabbcc"],  # ChassisId
+                [1, "04332211ddeeff"],  # ChassisId
+                [1, "04556677aabbcc"],  # ChassisId
+                [2, "07373334"],  # PortId
+                [2, "07373435"],  # PortId
+                [2, "07373536"]   # PortId
+            ]}]
+        # Only the first unique TLV is processed
+        self.expected = {'em1': {
+            nv.LLDP_CHASSIS_ID_NM: "11:22:33:aa:bb:cc",
+            nv.LLDP_PORT_ID_NM: "734"
+        }}
+        with task_manager.acquire(self.context, self.node.id) as task:
+            hook.ParseLLDPHook().__call__(task, self.inventory,
+                                          self.plugin_data)
+            self.assertEqual(self.expected, self.plugin_data['parsed_lldp'])
+
+    def test_unhandled_tlvs(self):
+        self.inventory['interfaces'] = [{
+            'name': 'em1',
+            'lldp': [
+                [10, "04112233aabbcc"],
+                [12, "07373334"],
+                [128, "00120f080300010000"]]}]
+        # Nothing should be written to lldp_processed
+        with task_manager.acquire(self.context, self.node.id) as task:
+            hook.ParseLLDPHook().__call__(task, self.inventory,
+                                          self.plugin_data)
+            self.assertNotIn('parsed_lldp', self.plugin_data)
+
+    def test_unhandled_oui(self):
+        self.inventory['interfaces'] = [{
+            'name': 'em1',
+            'lldp': [
+                [127, "00906901425030323134323530393236"],
+                [127, "23ac0074657374"],
+                [127, "00120e010300010000"]]}]
+        # Nothing should be written to lldp_processed
+        with task_manager.acquire(self.context, self.node.id) as task:
+            hook.ParseLLDPHook().__call__(task, self.inventory,
+                                          self.plugin_data)
+            self.assertNotIn('parsed_lldp', self.plugin_data)
+
+    @mock.patch.object(nv.LOG, 'warning', autospec=True)
+    def test_null_strings(self, mock_log):
+        self.inventory['interfaces'] = [{
+            'name': 'em1',
+            'lldp': [
+                [1, "04"],
+                [4, ""],  # PortDesc
+                [5, ""],  # SysName
+                [6, ""],  # SysDesc
+                [127, "0080c203006507"]  # dot1 VlanName
+            ]}]
+        self.expected = {'em1': {
+            nv.LLDP_PORT_DESC_NM: '',
+            nv.LLDP_SYS_DESC_NM: '',
+            nv.LLDP_SYS_NAME_NM: ''
+        }}
+        with task_manager.acquire(self.context, self.node.id) as task:
+            hook.ParseLLDPHook().__call__(task, self.inventory,
+                                          self.plugin_data)
+            self.assertEqual(self.expected, self.plugin_data['parsed_lldp'])
+            self.assertEqual(2, mock_log.call_count)
+
+    @mock.patch.object(nv.LOG, 'warning', autospec=True)
+    def test_truncated_int(self, mock_log):
+        self.inventory['interfaces'] = [{
+            'name': 'em1',
+            'lldp': [
+                [127, "00120f04"],  # dot3 MTU
+                [127, "0080c201"],  # dot1 PortVlan
+                [127, "0080c206"],  # dot1 MgmtVID
+            ]
+        }]
+        # Nothing should be written to lldp_processed
+        with task_manager.acquire(self.context, self.node.id) as task:
+            hook.ParseLLDPHook().__call__(task, self.inventory,
+                                          self.plugin_data)
+            self.assertNotIn('parsed_lldp', self.plugin_data)
+            self.assertEqual(3, mock_log.call_count)
+
+    @mock.patch.object(nv.LOG, 'warning', autospec=True)
+    def test_invalid_ip(self, mock_log):
+        self.inventory['interfaces'] = [{
+            'name': 'em1',
+            'lldp': [
+                [8, "0501"],  # truncated
+                [8, "0507c000020f020000000000"]
+            ]  # invalid id
+        }]
+        with task_manager.acquire(self.context, self.node.id) as task:
+            hook.ParseLLDPHook().__call__(task, self.inventory,
+                                          self.plugin_data)
+            self.assertNotIn('parsed_lldp', self.plugin_data)
+            self.assertEqual(1, mock_log.call_count)
+
+    @mock.patch.object(nv.LOG, 'warning', autospec=True)
+    def test_truncated_mac(self, mock_log):
+        self.inventory['interfaces'] = [{
+            'name': 'em1',
+            'lldp': [[8, "0506"]]
+        }]
+        with task_manager.acquire(self.context, self.node.id) as task:
+            hook.ParseLLDPHook().__call__(task, self.inventory,
+                                          self.plugin_data)
+            self.assertNotIn('parsed_lldp', self.plugin_data)
+            self.assertEqual(1, mock_log.call_count)
+
+    @mock.patch.object(nv.LOG, 'warning', autospec=True)
+    def test_bad_value_macphy(self, mock_log):
+        self.inventory['interfaces'] = [{
+            'name': 'em1',
+            'lldp': [
+                [127, "00120f01036c11FFFF"],  # invalid mau type
+                [127, "00120f01036c11"],      # truncated
+                [127, "00120f01036c"]         # truncated
+            ]
+        }]
+        with task_manager.acquire(self.context, self.node.id) as task:
+            hook.ParseLLDPHook().__call__(task, self.inventory,
+                                          self.plugin_data)
+            self.assertNotIn('parsed_lldp', self.plugin_data)
+            self.assertEqual(3, mock_log.call_count)
+
+    @mock.patch.object(nv.LOG, 'warning', autospec=True)
+    def test_bad_value_linkagg(self, mock_log):
+        self.inventory['interfaces'] = [{
+            'name': 'em1',
+            'lldp': [
+                [127, "00120f0303"],  # dot3 LinkAggregation
+                [127, "00120f03"]     # truncated
+            ]
+        }]
+        with task_manager.acquire(self.context, self.node.id) as task:
+            hook.ParseLLDPHook().__call__(task, self.inventory,
+                                          self.plugin_data)
+            self.assertNotIn('parsed_lldp', self.plugin_data)
+            self.assertEqual(2, mock_log.call_count)
diff --git a/requirements.txt b/requirements.txt
index e57c720491..aa0e212b30 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -48,3 +48,5 @@ futurist>=1.2.0 # Apache-2.0
 tooz>=2.7.0 # Apache-2.0
 openstacksdk>=0.48.0 # Apache-2.0
 sushy>=4.3.0
+construct>=2.9.39 # MIT
+netaddr>=0.9.0 # BSD
diff --git a/setup.cfg b/setup.cfg
index 78f3de67d1..4e0b239e67 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -211,6 +211,8 @@ ironic.inspection.hooks =
     physical-network = ironic.drivers.modules.inspector.hooks.physical_network:PhysicalNetworkHook
     raid-device = ironic.drivers.modules.inspector.hooks.raid_device:RaidDeviceHook
     root-device = ironic.drivers.modules.inspector.hooks.root_device:RootDeviceHook
+    local-link-connection = ironic.drivers.modules.inspector.hooks.local_link_connection:LocalLinkConnectionHook
+    parse-lldp = ironic.drivers.modules.inspector.hooks.parse_lldp:ParseLLDPHook
 
 [egg_info]
 tag_build =