discovery.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678
  1. """
  2. Bambu Lab printer discovery service using SSDP and subnet scanning.
  3. Bambu Lab printers advertise themselves via SSDP (Simple Service Discovery Protocol)
  4. on the local network. This service listens for these advertisements and provides
  5. a list of discovered printers.
  6. For Docker environments where SSDP multicast doesn't work, subnet scanning is
  7. available as an alternative discovery method.
  8. """
  9. import asyncio
  10. import ipaddress
  11. import logging
  12. import os
  13. import re
  14. import socket
  15. import struct
  16. from dataclasses import dataclass
  17. from datetime import datetime
  18. from pathlib import Path
  19. logger = logging.getLogger(__name__)
  20. def is_running_in_docker() -> bool:
  21. """Detect if we're running inside a Docker container."""
  22. # Check for .dockerenv file
  23. if Path("/.dockerenv").exists():
  24. return True
  25. # Check cgroup for docker/containerd
  26. try:
  27. with open("/proc/1/cgroup") as f:
  28. content = f.read()
  29. if "docker" in content or "containerd" in content or "kubepods" in content:
  30. return True
  31. except (FileNotFoundError, PermissionError):
  32. pass
  33. # Check for container environment variable
  34. return bool(os.environ.get("CONTAINER") or os.environ.get("DOCKER_CONTAINER"))
  35. # SSDP multicast address - Bambu uses port 2021, not standard 1900
  36. SSDP_ADDR = "239.255.255.250"
  37. SSDP_PORT = 2021 # Bambu Lab uses non-standard port
  38. # Bambu Lab SSDP search target
  39. BAMBU_SEARCH_TARGET = "urn:bambulab-com:device:3dprinter:1"
  40. # SSDP M-SEARCH message
  41. SSDP_MSEARCH = (
  42. "M-SEARCH * HTTP/1.1\r\n"
  43. f"HOST: {SSDP_ADDR}:{SSDP_PORT}\r\n"
  44. 'MAN: "ssdp:discover"\r\n'
  45. "MX: 3\r\n"
  46. f"ST: {BAMBU_SEARCH_TARGET}\r\n"
  47. "\r\n"
  48. )
  49. @dataclass
  50. class DiscoveredPrinter:
  51. """Represents a discovered Bambu Lab printer."""
  52. serial: str
  53. name: str
  54. ip_address: str
  55. model: str | None = None
  56. discovered_at: str | None = None
  57. def to_dict(self) -> dict:
  58. return {
  59. "serial": self.serial,
  60. "name": self.name,
  61. "ip_address": self.ip_address,
  62. "model": self.model,
  63. "discovered_at": self.discovered_at,
  64. }
  65. class PrinterDiscoveryService:
  66. """Service for discovering Bambu Lab printers on the network."""
  67. def __init__(self):
  68. self._discovered: dict[str, DiscoveredPrinter] = {}
  69. self._running = False
  70. self._task: asyncio.Task | None = None
  71. @property
  72. def is_running(self) -> bool:
  73. return self._running
  74. @property
  75. def discovered_printers(self) -> list[DiscoveredPrinter]:
  76. return list(self._discovered.values())
  77. def clear(self):
  78. """Clear discovered printers."""
  79. self._discovered.clear()
  80. async def start(self, duration: float = 10.0):
  81. """Start discovery for a specified duration."""
  82. if self._running:
  83. return
  84. self._running = True
  85. self._discovered.clear()
  86. self._task = asyncio.create_task(self._discover(duration))
  87. async def stop(self):
  88. """Stop discovery."""
  89. self._running = False
  90. if self._task and not self._task.done():
  91. self._task.cancel()
  92. try:
  93. await self._task
  94. except asyncio.CancelledError:
  95. pass
  96. self._task = None
  97. async def _discover(self, duration: float):
  98. """Run discovery for the specified duration.
  99. Bambu printers broadcast NOTIFY messages periodically on port 2021.
  100. We need to bind to that port and listen for broadcasts.
  101. """
  102. sock = None
  103. try:
  104. # Create UDP socket for SSDP
  105. sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
  106. sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  107. # Try to set SO_REUSEPORT if available (Linux/macOS)
  108. try:
  109. sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
  110. except (AttributeError, OSError):
  111. pass
  112. # Set non-blocking mode
  113. sock.setblocking(False)
  114. # Bind to the SSDP port to receive NOTIFY broadcasts from printers
  115. sock.bind(("", SSDP_PORT))
  116. # Join multicast group to receive multicast messages
  117. mreq = struct.pack("4sl", socket.inet_aton(SSDP_ADDR), socket.INADDR_ANY)
  118. sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
  119. # Enable broadcast
  120. sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
  121. logger.info(f"Starting SSDP discovery on port {SSDP_PORT} for Bambu Lab printers...")
  122. # Send initial M-SEARCH request to trigger responses
  123. try:
  124. sock.sendto(SSDP_MSEARCH.encode(), (SSDP_ADDR, SSDP_PORT))
  125. except Exception as e:
  126. logger.debug(f"M-SEARCH send error: {e}")
  127. start_time = asyncio.get_event_loop().time()
  128. last_send = start_time
  129. while self._running and (asyncio.get_event_loop().time() - start_time) < duration:
  130. # Try to receive data
  131. try:
  132. data, addr = sock.recvfrom(4096)
  133. message = data.decode("utf-8", errors="ignore")
  134. logger.debug(f"Received from {addr[0]}: {message[:100]}...")
  135. self._handle_response(message, addr[0])
  136. except BlockingIOError:
  137. # No data available, that's fine
  138. pass
  139. except Exception as e:
  140. logger.debug(f"SSDP receive error: {e}")
  141. # Re-send M-SEARCH every 3 seconds
  142. now = asyncio.get_event_loop().time()
  143. if now - last_send >= 3.0:
  144. try:
  145. sock.sendto(SSDP_MSEARCH.encode(), (SSDP_ADDR, SSDP_PORT))
  146. last_send = now
  147. except Exception as e:
  148. logger.debug(f"SSDP send error: {e}")
  149. await asyncio.sleep(0.1)
  150. logger.info(f"Discovery complete. Found {len(self._discovered)} printers.")
  151. except OSError as e:
  152. if e.errno == 98: # Address already in use
  153. logger.warning(f"Port {SSDP_PORT} is in use, trying alternative discovery...")
  154. await self._discover_alternative(duration)
  155. else:
  156. logger.error(f"Discovery error: {e}")
  157. except Exception as e:
  158. logger.error(f"Discovery error: {e}")
  159. finally:
  160. self._running = False
  161. if sock:
  162. try:
  163. sock.close()
  164. except Exception:
  165. pass
  166. async def _discover_alternative(self, duration: float):
  167. """Alternative discovery using a random port (less reliable)."""
  168. sock = None
  169. try:
  170. sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
  171. sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  172. sock.setblocking(False)
  173. sock.bind(("", 0))
  174. # Join multicast group
  175. mreq = struct.pack("4sl", socket.inet_aton(SSDP_ADDR), socket.INADDR_ANY)
  176. sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
  177. sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
  178. logger.info("Using alternative discovery method...")
  179. start_time = asyncio.get_event_loop().time()
  180. last_send = start_time
  181. while self._running and (asyncio.get_event_loop().time() - start_time) < duration:
  182. try:
  183. data, addr = sock.recvfrom(4096)
  184. self._handle_response(data.decode("utf-8", errors="ignore"), addr[0])
  185. except BlockingIOError:
  186. pass
  187. except Exception as e:
  188. logger.debug(f"SSDP receive error: {e}")
  189. now = asyncio.get_event_loop().time()
  190. if now - last_send >= 2.0:
  191. try:
  192. sock.sendto(SSDP_MSEARCH.encode(), (SSDP_ADDR, SSDP_PORT))
  193. last_send = now
  194. except Exception:
  195. pass
  196. await asyncio.sleep(0.1)
  197. logger.info(f"Alternative discovery complete. Found {len(self._discovered)} printers.")
  198. except Exception as e:
  199. logger.error(f"Alternative discovery error: {e}")
  200. finally:
  201. if sock:
  202. try:
  203. sock.close()
  204. except Exception:
  205. pass
  206. def _handle_response(self, response: str, ip_address: str):
  207. """Parse SSDP response and extract printer info."""
  208. # Check if it's a Bambu Lab printer response
  209. if BAMBU_SEARCH_TARGET not in response and "bambulab" not in response.lower():
  210. logger.debug(f"Ignoring non-Bambu response from {ip_address}")
  211. return
  212. # Extract USN (Unique Service Name) which contains the serial
  213. # Bambu format is just "USN: SERIALNUMBER" (no uuid: prefix)
  214. usn_match = re.search(r"USN:\s*(?:uuid:)?([^\s\r\n]+)", response, re.IGNORECASE)
  215. if not usn_match:
  216. logger.debug(f"No USN found in response from {ip_address}")
  217. return
  218. serial = usn_match.group(1).strip()
  219. # Extract device name from LOCATION or DevName header
  220. name = serial # Default to serial if no name found
  221. name_match = re.search(r"DevName\.bambu\.com:\s*(.+?)(?:\r\n|\n|$)", response, re.IGNORECASE)
  222. if name_match:
  223. name = name_match.group(1).strip()
  224. # Try to extract model from DevModel header
  225. model = None
  226. model_match = re.search(r"DevModel\.bambu\.com:\s*(.+?)(?:\r\n|\n|$)", response, re.IGNORECASE)
  227. if model_match:
  228. model = model_match.group(1).strip()
  229. # Also try NT header for model
  230. if not model:
  231. nt_match = re.search(r"NT:\s*urn:bambulab-com:device:([^:]+)", response, re.IGNORECASE)
  232. if nt_match:
  233. model = nt_match.group(1).strip()
  234. # Skip if already discovered
  235. if serial in self._discovered:
  236. return
  237. printer = DiscoveredPrinter(
  238. serial=serial,
  239. name=name,
  240. ip_address=ip_address,
  241. model=model,
  242. discovered_at=datetime.now().isoformat(),
  243. )
  244. self._discovered[serial] = printer
  245. logger.info(f"Discovered printer: {name} ({serial}) at {ip_address}")
  246. class SubnetScanner:
  247. """Scanner for discovering Bambu printers by probing IP addresses."""
  248. # Bambu printer ports
  249. MQTT_PORT = 8883
  250. FTP_PORT = 990
  251. def __init__(self):
  252. self._discovered: dict[str, DiscoveredPrinter] = {}
  253. self._running = False
  254. self._scanned = 0
  255. self._total = 0
  256. @property
  257. def is_running(self) -> bool:
  258. return self._running
  259. @property
  260. def discovered_printers(self) -> list[DiscoveredPrinter]:
  261. return list(self._discovered.values())
  262. @property
  263. def progress(self) -> tuple[int, int]:
  264. """Return (scanned, total) counts."""
  265. return self._scanned, self._total
  266. async def scan_subnet(self, subnet: str, timeout: float = 1.0) -> list[DiscoveredPrinter]:
  267. """Scan a subnet for Bambu printers.
  268. Args:
  269. subnet: CIDR notation subnet (e.g., "192.168.1.0/24")
  270. timeout: Connection timeout per host in seconds
  271. Returns:
  272. List of discovered printers
  273. """
  274. if self._running:
  275. return []
  276. self._running = True
  277. self._discovered.clear()
  278. self._scanned = 0
  279. try:
  280. network = ipaddress.ip_network(subnet, strict=False)
  281. hosts = list(network.hosts())
  282. self._total = len(hosts)
  283. if self._total > 1024:
  284. logger.warning(f"Subnet {subnet} has {self._total} hosts, limiting to /22 (1024 hosts)")
  285. self._total = 1024
  286. hosts = hosts[:1024]
  287. logger.info(f"Starting subnet scan of {subnet} ({self._total} hosts)")
  288. # Scan in batches to avoid overwhelming the network
  289. batch_size = 50
  290. for i in range(0, len(hosts), batch_size):
  291. if not self._running:
  292. break
  293. batch = hosts[i : i + batch_size]
  294. tasks = [self._probe_host(str(ip), timeout) for ip in batch]
  295. await asyncio.gather(*tasks, return_exceptions=True)
  296. self._scanned = min(i + batch_size, len(hosts))
  297. logger.info(f"Subnet scan complete. Found {len(self._discovered)} printers.")
  298. return self.discovered_printers
  299. except ValueError as e:
  300. logger.error(f"Invalid subnet format: {e}")
  301. return []
  302. finally:
  303. self._running = False
  304. async def _probe_host(self, ip: str, timeout: float):
  305. """Probe a single host for Bambu printer ports."""
  306. # Check FTP port (990) - more reliable indicator
  307. ftp_open = await self._check_port(ip, self.FTP_PORT, timeout)
  308. if not ftp_open:
  309. return
  310. # Also check MQTT port (8883) for confirmation
  311. mqtt_open = await self._check_port(ip, self.MQTT_PORT, timeout)
  312. if not mqtt_open:
  313. return
  314. # Both ports open - likely a Bambu printer
  315. logger.info(f"Found potential Bambu printer at {ip}")
  316. # Try to get printer info via SSDP unicast
  317. serial, name, model = await self._get_printer_info_ssdp(ip, timeout)
  318. printer = DiscoveredPrinter(
  319. serial=serial or f"unknown-{ip.replace('.', '-')}",
  320. name=name or f"Printer at {ip}",
  321. ip_address=ip,
  322. model=model,
  323. discovered_at=datetime.now().isoformat(),
  324. )
  325. self._discovered[ip] = printer
  326. async def _get_printer_info_ssdp(self, ip: str, timeout: float) -> tuple[str | None, str | None, str | None]:
  327. """Try to get printer info via SSDP unicast query."""
  328. loop = asyncio.get_event_loop()
  329. def _query():
  330. try:
  331. sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
  332. sock.settimeout(timeout)
  333. sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  334. # Send M-SEARCH directly to the printer
  335. msearch = (
  336. "M-SEARCH * HTTP/1.1\r\n"
  337. f"HOST: {ip}:{SSDP_PORT}\r\n"
  338. 'MAN: "ssdp:discover"\r\n'
  339. "MX: 1\r\n"
  340. f"ST: {BAMBU_SEARCH_TARGET}\r\n"
  341. "\r\n"
  342. )
  343. sock.sendto(msearch.encode(), (ip, SSDP_PORT))
  344. # Wait for response
  345. data, _ = sock.recvfrom(4096)
  346. response = data.decode("utf-8", errors="ignore")
  347. sock.close()
  348. # Parse response
  349. serial = None
  350. name = None
  351. model = None
  352. usn_match = re.search(r"USN:\s*(?:uuid:)?([^\s\r\n]+)", response, re.IGNORECASE)
  353. if usn_match:
  354. serial = usn_match.group(1).strip()
  355. name_match = re.search(r"DevName\.bambu\.com:\s*(.+?)(?:\r\n|\n|$)", response, re.IGNORECASE)
  356. if name_match:
  357. name = name_match.group(1).strip()
  358. model_match = re.search(r"DevModel\.bambu\.com:\s*(.+?)(?:\r\n|\n|$)", response, re.IGNORECASE)
  359. if model_match:
  360. model = model_match.group(1).strip()
  361. logger.debug(f"SSDP info from {ip}: serial={serial}, name={name}, model={model}")
  362. return serial, name, model
  363. except Exception as e:
  364. logger.debug(f"SSDP query to {ip} failed: {e}")
  365. return None, None, None
  366. return await loop.run_in_executor(None, _query)
  367. async def _check_port(self, ip: str, port: int, timeout: float) -> bool:
  368. """Check if a port is open on the given IP."""
  369. try:
  370. _, writer = await asyncio.wait_for(asyncio.open_connection(ip, port), timeout=timeout)
  371. writer.close()
  372. await writer.wait_closed()
  373. logger.debug(f"Port {port} open on {ip}")
  374. return True
  375. except TimeoutError:
  376. return False
  377. except ConnectionRefusedError:
  378. return False
  379. except OSError as e:
  380. # Log first few errors to help debug network issues
  381. if self._scanned < 5:
  382. logger.debug(f"OSError checking {ip}:{port}: {e}")
  383. return False
  384. def stop(self):
  385. """Stop the current scan."""
  386. self._running = False
  387. class TasmotaScanner:
  388. """Scanner for discovering Tasmota devices by probing IP addresses."""
  389. HTTP_PORT = 80
  390. def __init__(self):
  391. self._discovered: dict[str, dict] = {}
  392. self._running = False
  393. self._scanned = 0
  394. self._total = 0
  395. @property
  396. def is_running(self) -> bool:
  397. return self._running
  398. @property
  399. def discovered_devices(self) -> list[dict]:
  400. return list(self._discovered.values())
  401. @property
  402. def progress(self) -> tuple[int, int]:
  403. """Return (scanned, total) counts."""
  404. return self._scanned, self._total
  405. async def scan_range(self, from_ip: str, to_ip: str, timeout: float = 1.0) -> list[dict]:
  406. """Scan an IP range for Tasmota devices.
  407. Args:
  408. from_ip: Starting IP address (e.g., "192.168.1.1")
  409. to_ip: Ending IP address (e.g., "192.168.1.254")
  410. timeout: Connection timeout per host in seconds
  411. Returns:
  412. List of discovered Tasmota devices
  413. """
  414. if self._running:
  415. return []
  416. self._running = True
  417. self._discovered.clear()
  418. self._scanned = 0
  419. try:
  420. start = ipaddress.ip_address(from_ip)
  421. end = ipaddress.ip_address(to_ip)
  422. # Generate list of IPs in range
  423. hosts = []
  424. current = start
  425. while current <= end:
  426. hosts.append(str(current))
  427. current = ipaddress.ip_address(int(current) + 1)
  428. self._total = len(hosts)
  429. if self._total > 1024:
  430. logger.warning(f"IP range has {self._total} hosts, limiting to 1024")
  431. self._total = 1024
  432. hosts = hosts[:1024]
  433. logger.info(f"Starting Tasmota scan from {from_ip} to {to_ip} ({self._total} hosts)")
  434. # Scan in batches to avoid overwhelming the network
  435. batch_size = 50
  436. for i in range(0, len(hosts), batch_size):
  437. if not self._running:
  438. logger.info("Tasmota scan stopped by user")
  439. break
  440. batch = hosts[i : i + batch_size]
  441. tasks = [self._probe_host(ip) for ip in batch]
  442. try:
  443. await asyncio.gather(*tasks, return_exceptions=True)
  444. except Exception as e:
  445. logger.warning(f"Batch {i//batch_size} error: {e}")
  446. self._scanned = min(i + batch_size, len(hosts))
  447. logger.info(f"Tasmota scan complete. Found {len(self._discovered)} devices.")
  448. return self.discovered_devices
  449. except ValueError as e:
  450. logger.error(f"Invalid IP address format: {e}")
  451. return []
  452. finally:
  453. self._running = False
  454. async def _probe_host(self, ip: str):
  455. """Probe a single host for Tasmota HTTP API."""
  456. try:
  457. # Hard timeout of 5 seconds max per host
  458. await asyncio.wait_for(self._do_probe(ip), timeout=5.0)
  459. except TimeoutError:
  460. pass
  461. except Exception:
  462. pass
  463. async def _do_probe(self, ip: str):
  464. """Actually probe the host."""
  465. import httpx
  466. try:
  467. # Reasonable timeouts for network scanning
  468. client_timeout = httpx.Timeout(3.0, connect=1.0)
  469. async with httpx.AsyncClient(timeout=client_timeout, follow_redirects=False) as client:
  470. # First try simple Power command - most reliable indicator of Tasmota
  471. power_url = f"http://{ip}/cm?cmnd=Power"
  472. try:
  473. power_response = await client.get(power_url)
  474. if power_response.status_code == 401:
  475. # Device requires auth - still a Tasmota device!
  476. logger.info(f"Discovered Tasmota at {ip} (requires auth - 401)")
  477. device = {
  478. "ip_address": ip,
  479. "name": f"Tasmota ({ip})",
  480. "module": None,
  481. "state": "UNKNOWN",
  482. "discovered_at": datetime.now().isoformat(),
  483. }
  484. self._discovered[ip] = device
  485. return
  486. if power_response.status_code != 200:
  487. return
  488. power_data = power_response.json()
  489. # Check for Tasmota auth warning (returns 200 with WARNING)
  490. if "WARNING" in power_data:
  491. logger.info(f"Discovered Tasmota at {ip} (requires auth)")
  492. device = {
  493. "ip_address": ip,
  494. "name": f"Tasmota ({ip})",
  495. "module": None,
  496. "state": "UNKNOWN",
  497. "discovered_at": datetime.now().isoformat(),
  498. }
  499. self._discovered[ip] = device
  500. return
  501. # Check if response looks like Tasmota (has POWER or POWER1 key)
  502. power_state = power_data.get("POWER") or power_data.get("POWER1")
  503. if power_state is None:
  504. return
  505. except Exception as e:
  506. logger.debug(f"Error probing {ip}: {e}")
  507. return
  508. # It's a Tasmota device! Now get more info
  509. device_name = f"Tasmota ({ip})"
  510. module = None
  511. # Try to get device name from Status 0
  512. try:
  513. status_url = f"http://{ip}/cm?cmnd=Status%200"
  514. status_response = await client.get(status_url)
  515. if status_response.status_code == 200:
  516. status_data = status_response.json()
  517. if "Status" in status_data:
  518. status = status_data["Status"]
  519. device_name = status.get("DeviceName") or device_name
  520. if not device_name or device_name == f"Tasmota ({ip})":
  521. # Try FriendlyName
  522. friendly = status.get("FriendlyName")
  523. if friendly and isinstance(friendly, list) and friendly[0]:
  524. device_name = friendly[0]
  525. module = status.get("Module")
  526. except Exception:
  527. pass
  528. device = {
  529. "ip_address": ip,
  530. "name": device_name,
  531. "module": module,
  532. "state": power_state,
  533. "discovered_at": datetime.now().isoformat(),
  534. }
  535. self._discovered[ip] = device
  536. logger.info(f"Discovered Tasmota device: {device_name} at {ip}")
  537. except httpx.TimeoutException:
  538. pass
  539. except httpx.ConnectError:
  540. pass
  541. except Exception:
  542. pass
  543. def stop(self):
  544. """Stop the current scan."""
  545. self._running = False
  546. # Global instances
  547. discovery_service = PrinterDiscoveryService()
  548. subnet_scanner = SubnetScanner()
  549. tasmota_scanner = TasmotaScanner()