discovery.py 24 KB

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