Fix hang in SNMP device_tracker implementation (#112815)
Co-authored-by: J. Nick Koston <nick@koston.org>pull/115186/head
parent
95606135a6
commit
61a359e4d2
|
@ -1249,6 +1249,8 @@ build.json @home-assistant/supervisor
|
||||||
/homeassistant/components/sms/ @ocalvo
|
/homeassistant/components/sms/ @ocalvo
|
||||||
/homeassistant/components/snapcast/ @luar123
|
/homeassistant/components/snapcast/ @luar123
|
||||||
/tests/components/snapcast/ @luar123
|
/tests/components/snapcast/ @luar123
|
||||||
|
/homeassistant/components/snmp/ @nmaggioni
|
||||||
|
/tests/components/snmp/ @nmaggioni
|
||||||
/homeassistant/components/snooz/ @AustinBrunkhorst
|
/homeassistant/components/snooz/ @AustinBrunkhorst
|
||||||
/tests/components/snooz/ @AustinBrunkhorst
|
/tests/components/snooz/ @AustinBrunkhorst
|
||||||
/homeassistant/components/solaredge/ @frenck
|
/homeassistant/components/solaredge/ @frenck
|
||||||
|
|
|
@ -5,8 +5,19 @@ from __future__ import annotations
|
||||||
import binascii
|
import binascii
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from pysnmp.entity import config as cfg
|
from pysnmp.error import PySnmpError
|
||||||
from pysnmp.entity.rfc3413.oneliner import cmdgen
|
from pysnmp.hlapi.asyncio import (
|
||||||
|
CommunityData,
|
||||||
|
ContextData,
|
||||||
|
ObjectIdentity,
|
||||||
|
ObjectType,
|
||||||
|
SnmpEngine,
|
||||||
|
Udp6TransportTarget,
|
||||||
|
UdpTransportTarget,
|
||||||
|
UsmUserData,
|
||||||
|
bulkWalkCmd,
|
||||||
|
isEndOfMib,
|
||||||
|
)
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.components.device_tracker import (
|
from homeassistant.components.device_tracker import (
|
||||||
|
@ -24,7 +35,13 @@ from .const import (
|
||||||
CONF_BASEOID,
|
CONF_BASEOID,
|
||||||
CONF_COMMUNITY,
|
CONF_COMMUNITY,
|
||||||
CONF_PRIV_KEY,
|
CONF_PRIV_KEY,
|
||||||
|
DEFAULT_AUTH_PROTOCOL,
|
||||||
DEFAULT_COMMUNITY,
|
DEFAULT_COMMUNITY,
|
||||||
|
DEFAULT_PORT,
|
||||||
|
DEFAULT_PRIV_PROTOCOL,
|
||||||
|
DEFAULT_TIMEOUT,
|
||||||
|
DEFAULT_VERSION,
|
||||||
|
SNMP_VERSIONS,
|
||||||
)
|
)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
@ -40,9 +57,12 @@ PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_scanner(hass: HomeAssistant, config: ConfigType) -> SnmpScanner | None:
|
async def async_get_scanner(
|
||||||
|
hass: HomeAssistant, config: ConfigType
|
||||||
|
) -> SnmpScanner | None:
|
||||||
"""Validate the configuration and return an SNMP scanner."""
|
"""Validate the configuration and return an SNMP scanner."""
|
||||||
scanner = SnmpScanner(config[DOMAIN])
|
scanner = SnmpScanner(config[DOMAIN])
|
||||||
|
await scanner.async_init()
|
||||||
|
|
||||||
return scanner if scanner.success_init else None
|
return scanner if scanner.success_init else None
|
||||||
|
|
||||||
|
@ -51,39 +71,75 @@ class SnmpScanner(DeviceScanner):
|
||||||
"""Queries any SNMP capable Access Point for connected devices."""
|
"""Queries any SNMP capable Access Point for connected devices."""
|
||||||
|
|
||||||
def __init__(self, config):
|
def __init__(self, config):
|
||||||
"""Initialize the scanner."""
|
"""Initialize the scanner and test the target device."""
|
||||||
|
host = config[CONF_HOST]
|
||||||
|
community = config[CONF_COMMUNITY]
|
||||||
|
baseoid = config[CONF_BASEOID]
|
||||||
|
authkey = config.get(CONF_AUTH_KEY)
|
||||||
|
authproto = DEFAULT_AUTH_PROTOCOL
|
||||||
|
privkey = config.get(CONF_PRIV_KEY)
|
||||||
|
privproto = DEFAULT_PRIV_PROTOCOL
|
||||||
|
|
||||||
self.snmp = cmdgen.CommandGenerator()
|
try:
|
||||||
|
# Try IPv4 first.
|
||||||
|
target = UdpTransportTarget((host, DEFAULT_PORT), timeout=DEFAULT_TIMEOUT)
|
||||||
|
except PySnmpError:
|
||||||
|
# Then try IPv6.
|
||||||
|
try:
|
||||||
|
target = Udp6TransportTarget(
|
||||||
|
(host, DEFAULT_PORT), timeout=DEFAULT_TIMEOUT
|
||||||
|
)
|
||||||
|
except PySnmpError as err:
|
||||||
|
_LOGGER.error("Invalid SNMP host: %s", err)
|
||||||
|
return
|
||||||
|
|
||||||
self.host = cmdgen.UdpTransportTarget((config[CONF_HOST], 161))
|
if authkey is not None or privkey is not None:
|
||||||
if CONF_AUTH_KEY not in config or CONF_PRIV_KEY not in config:
|
if not authkey:
|
||||||
self.auth = cmdgen.CommunityData(config[CONF_COMMUNITY])
|
authproto = "none"
|
||||||
|
if not privkey:
|
||||||
|
privproto = "none"
|
||||||
|
|
||||||
|
request_args = [
|
||||||
|
SnmpEngine(),
|
||||||
|
UsmUserData(
|
||||||
|
community,
|
||||||
|
authKey=authkey or None,
|
||||||
|
privKey=privkey or None,
|
||||||
|
authProtocol=authproto,
|
||||||
|
privProtocol=privproto,
|
||||||
|
),
|
||||||
|
target,
|
||||||
|
ContextData(),
|
||||||
|
]
|
||||||
else:
|
else:
|
||||||
self.auth = cmdgen.UsmUserData(
|
request_args = [
|
||||||
config[CONF_COMMUNITY],
|
SnmpEngine(),
|
||||||
config[CONF_AUTH_KEY],
|
CommunityData(community, mpModel=SNMP_VERSIONS[DEFAULT_VERSION]),
|
||||||
config[CONF_PRIV_KEY],
|
target,
|
||||||
authProtocol=cfg.usmHMACSHAAuthProtocol,
|
ContextData(),
|
||||||
privProtocol=cfg.usmAesCfb128Protocol,
|
]
|
||||||
)
|
|
||||||
self.baseoid = cmdgen.MibVariable(config[CONF_BASEOID])
|
|
||||||
self.last_results = []
|
|
||||||
|
|
||||||
# Test the router is accessible
|
self.request_args = request_args
|
||||||
data = self.get_snmp_data()
|
self.baseoid = baseoid
|
||||||
|
self.last_results = []
|
||||||
|
self.success_init = False
|
||||||
|
|
||||||
|
async def async_init(self):
|
||||||
|
"""Make a one-off read to check if the target device is reachable and readable."""
|
||||||
|
data = await self.async_get_snmp_data()
|
||||||
self.success_init = data is not None
|
self.success_init = data is not None
|
||||||
|
|
||||||
def scan_devices(self):
|
async def async_scan_devices(self):
|
||||||
"""Scan for new devices and return a list with found device IDs."""
|
"""Scan for new devices and return a list with found device IDs."""
|
||||||
self._update_info()
|
await self._async_update_info()
|
||||||
return [client["mac"] for client in self.last_results if client.get("mac")]
|
return [client["mac"] for client in self.last_results if client.get("mac")]
|
||||||
|
|
||||||
def get_device_name(self, device):
|
async def async_get_device_name(self, device):
|
||||||
"""Return the name of the given device or None if we don't know."""
|
"""Return the name of the given device or None if we don't know."""
|
||||||
# We have no names
|
# We have no names
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _update_info(self):
|
async def _async_update_info(self):
|
||||||
"""Ensure the information from the device is up to date.
|
"""Ensure the information from the device is up to date.
|
||||||
|
|
||||||
Return boolean if scanning successful.
|
Return boolean if scanning successful.
|
||||||
|
@ -91,38 +147,42 @@ class SnmpScanner(DeviceScanner):
|
||||||
if not self.success_init:
|
if not self.success_init:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if not (data := self.get_snmp_data()):
|
if not (data := await self.async_get_snmp_data()):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
self.last_results = data
|
self.last_results = data
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def get_snmp_data(self):
|
async def async_get_snmp_data(self):
|
||||||
"""Fetch MAC addresses from access point via SNMP."""
|
"""Fetch MAC addresses from access point via SNMP."""
|
||||||
devices = []
|
devices = []
|
||||||
|
|
||||||
errindication, errstatus, errindex, restable = self.snmp.nextCmd(
|
walker = bulkWalkCmd(
|
||||||
self.auth, self.host, self.baseoid
|
*self.request_args,
|
||||||
|
0,
|
||||||
|
50,
|
||||||
|
ObjectType(ObjectIdentity(self.baseoid)),
|
||||||
|
lexicographicMode=False,
|
||||||
)
|
)
|
||||||
|
async for errindication, errstatus, errindex, res in walker:
|
||||||
|
if errindication:
|
||||||
|
_LOGGER.error("SNMPLIB error: %s", errindication)
|
||||||
|
return
|
||||||
|
if errstatus:
|
||||||
|
_LOGGER.error(
|
||||||
|
"SNMP error: %s at %s",
|
||||||
|
errstatus.prettyPrint(),
|
||||||
|
errindex and res[int(errindex) - 1][0] or "?",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
if errindication:
|
for _oid, value in res:
|
||||||
_LOGGER.error("SNMPLIB error: %s", errindication)
|
if not isEndOfMib(res):
|
||||||
return
|
try:
|
||||||
if errstatus:
|
mac = binascii.hexlify(value.asOctets()).decode("utf-8")
|
||||||
_LOGGER.error(
|
except AttributeError:
|
||||||
"SNMP error: %s at %s",
|
continue
|
||||||
errstatus.prettyPrint(),
|
_LOGGER.debug("Found MAC address: %s", mac)
|
||||||
errindex and restable[int(errindex) - 1][0] or "?",
|
mac = ":".join([mac[i : i + 2] for i in range(0, len(mac), 2)])
|
||||||
)
|
devices.append({"mac": mac})
|
||||||
return
|
|
||||||
|
|
||||||
for resrow in restable:
|
|
||||||
for _, val in resrow:
|
|
||||||
try:
|
|
||||||
mac = binascii.hexlify(val.asOctets()).decode("utf-8")
|
|
||||||
except AttributeError:
|
|
||||||
continue
|
|
||||||
_LOGGER.debug("Found MAC address: %s", mac)
|
|
||||||
mac = ":".join([mac[i : i + 2] for i in range(0, len(mac), 2)])
|
|
||||||
devices.append({"mac": mac})
|
|
||||||
return devices
|
return devices
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"domain": "snmp",
|
"domain": "snmp",
|
||||||
"name": "SNMP",
|
"name": "SNMP",
|
||||||
"codeowners": [],
|
"codeowners": ["@nmaggioni"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/snmp",
|
"documentation": "https://www.home-assistant.io/integrations/snmp",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["pyasn1", "pysmi", "pysnmp"],
|
"loggers": ["pyasn1", "pysmi", "pysnmp"],
|
||||||
|
|
Loading…
Reference in New Issue