discovery.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790
  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, timezone
  18. from pathlib import Path
  19. logger = logging.getLogger(__name__)
  20. # Runtime names :func:`detect_container_runtime` can return. These reach the
  21. # user in the connection diagnostic, so they are the names people know their
  22. # own setup by.
  23. RUNTIME_DOCKER = "Docker"
  24. RUNTIME_PODMAN = "Podman"
  25. RUNTIME_KUBERNETES = "Kubernetes"
  26. RUNTIME_CONTAINERD = "containerd"
  27. RUNTIME_LXC = "LXC"
  28. # Sentinel for "in a container we cannot name". The others are proper nouns
  29. # that interpolate into any language; this one is localized by the frontend
  30. # (diagnostic.check.network_mode.genericRuntime), so keep the two in step.
  31. RUNTIME_OTHER = "container"
  32. # Runtimes that put Bambuddy in an OCI container whose network mode is a
  33. # choice the user made and can change. LXC is deliberately absent: a Proxmox
  34. # or LXD system container is bridged onto the LAN like a small VM, so there is
  35. # no "recreate it with host networking" advice to give.
  36. OCI_RUNTIMES = frozenset({RUNTIME_DOCKER, RUNTIME_PODMAN, RUNTIME_KUBERNETES, RUNTIME_CONTAINERD, RUNTIME_OTHER})
  37. # systemd writes the engine's own name here. It is the only signal that tells
  38. # Podman apart from Docker without guessing, which is why it is consulted
  39. # first (see updates.py, which has used it for the same reason for longer).
  40. _SYSTEMD_CONTAINER = Path("/run/systemd/container")
  41. _SYSTEMD_RUNTIME_NAMES = {
  42. "docker": RUNTIME_DOCKER,
  43. "podman": RUNTIME_PODMAN,
  44. "containerd": RUNTIME_CONTAINERD,
  45. "lxc": RUNTIME_LXC,
  46. "lxc-libvirt": RUNTIME_LXC,
  47. "oci": RUNTIME_OTHER,
  48. }
  49. def _read_text(path: Path) -> str:
  50. """Read a small /proc or /run marker file, empty string if unreadable."""
  51. try:
  52. return path.read_text()
  53. except (OSError, ValueError):
  54. # Unreadable, absent, or /proc entry that vanished mid-read.
  55. return ""
  56. def detect_container_runtime() -> str | None:
  57. """Name the container runtime Bambuddy is running under, or None.
  58. ``is_running_in_docker`` below answers a narrower question and is
  59. deliberately left alone — see the comment on it.
  60. Detection is ordered most-specific first, because the generic markers
  61. cannot tell two engines apart: Podman sets ``/run/.containerenv`` *and*
  62. writes ``libpod`` into the cgroup path, while Docker sets ``/.dockerenv``
  63. and writes ``docker``. A container started by neither still gets a name
  64. (``container``) rather than None, because "we are in something" is a
  65. useful answer even when the engine is not.
  66. """
  67. systemd_name = _read_text(_SYSTEMD_CONTAINER).strip().lower()
  68. if systemd_name:
  69. return _SYSTEMD_RUNTIME_NAMES.get(systemd_name, RUNTIME_OTHER)
  70. # Podman writes /run/.containerenv into every container it starts. Older
  71. # versions put it at the root, so both are checked.
  72. if Path("/run/.containerenv").exists() or Path("/.containerenv").exists():
  73. return RUNTIME_PODMAN
  74. if Path("/.dockerenv").exists():
  75. return RUNTIME_DOCKER
  76. cgroup = _read_text(Path("/proc/1/cgroup"))
  77. if "libpod" in cgroup:
  78. return RUNTIME_PODMAN
  79. if "kubepods" in cgroup:
  80. return RUNTIME_KUBERNETES
  81. if "docker" in cgroup:
  82. return RUNTIME_DOCKER
  83. if "containerd" in cgroup:
  84. return RUNTIME_CONTAINERD
  85. if "/lxc" in cgroup:
  86. return RUNTIME_LXC
  87. env_name = (os.environ.get("CONTAINER") or "").strip().lower()
  88. if env_name:
  89. return _SYSTEMD_RUNTIME_NAMES.get(env_name, RUNTIME_OTHER)
  90. if os.environ.get("DOCKER_CONTAINER"):
  91. return RUNTIME_DOCKER
  92. return None
  93. def is_running_in_docker() -> bool:
  94. """Detect if we're running inside a Docker container.
  95. Kept Docker-specific on purpose, and NOT rewritten on top of
  96. :func:`detect_container_runtime`. Three callers key real behaviour off
  97. this: ``/api/discovery/info`` feeds it to the Add-Printer flow, where
  98. ``isDocker`` switches discovery from SSDP to subnet scanning, and the
  99. backup-path probe and support bundle both read it. Answering True for a
  100. host-networked Podman container would take SSDP away from users for whom
  101. it works (#3092). Widening it is a separate decision from naming the
  102. runtime, so it is made separately.
  103. """
  104. # Check for .dockerenv file
  105. if Path("/.dockerenv").exists():
  106. return True
  107. # Check cgroup for docker/containerd
  108. try:
  109. with open("/proc/1/cgroup") as f:
  110. content = f.read()
  111. if "docker" in content or "containerd" in content or "kubepods" in content:
  112. return True
  113. except (FileNotFoundError, PermissionError):
  114. pass # /proc/1/cgroup may not exist or be readable; fall through to env check
  115. # Check for container environment variable
  116. return bool(os.environ.get("CONTAINER") or os.environ.get("DOCKER_CONTAINER"))
  117. # SSDP multicast address - Bambu uses port 2021, not standard 1900
  118. SSDP_ADDR = "239.255.255.250"
  119. SSDP_PORT = 2021 # Bambu Lab uses non-standard port
  120. # Bambu Lab SSDP search target
  121. BAMBU_SEARCH_TARGET = "urn:bambulab-com:device:3dprinter:1"
  122. # Virtual printer serial suffix to exclude from discovery (Bambuddy's own virtual printer)
  123. # All virtual printer serials end with this suffix, regardless of model
  124. VIRTUAL_PRINTER_SERIAL_SUFFIX = "391800001"
  125. # SSDP M-SEARCH message
  126. SSDP_MSEARCH = (
  127. "M-SEARCH * HTTP/1.1\r\n"
  128. f"HOST: {SSDP_ADDR}:{SSDP_PORT}\r\n"
  129. 'MAN: "ssdp:discover"\r\n'
  130. "MX: 3\r\n"
  131. f"ST: {BAMBU_SEARCH_TARGET}\r\n"
  132. "\r\n"
  133. )
  134. @dataclass
  135. class DiscoveredPrinter:
  136. """Represents a discovered Bambu Lab printer."""
  137. serial: str
  138. name: str
  139. ip_address: str
  140. model: str | None = None
  141. discovered_at: str | None = None
  142. def to_dict(self) -> dict:
  143. return {
  144. "serial": self.serial,
  145. "name": self.name,
  146. "ip_address": self.ip_address,
  147. "model": self.model,
  148. "discovered_at": self.discovered_at,
  149. }
  150. class PrinterDiscoveryService:
  151. """Service for discovering Bambu Lab printers on the network."""
  152. def __init__(self):
  153. self._discovered: dict[str, DiscoveredPrinter] = {}
  154. self._running = False
  155. self._task: asyncio.Task | None = None
  156. @property
  157. def is_running(self) -> bool:
  158. return self._running
  159. @property
  160. def discovered_printers(self) -> list[DiscoveredPrinter]:
  161. return list(self._discovered.values())
  162. def clear(self):
  163. """Clear discovered printers."""
  164. self._discovered.clear()
  165. async def start(self, duration: float = 10.0):
  166. """Start discovery for a specified duration."""
  167. if self._running:
  168. return
  169. self._running = True
  170. self._discovered.clear()
  171. self._task = asyncio.create_task(self._discover(duration))
  172. async def stop(self):
  173. """Stop discovery."""
  174. self._running = False
  175. if self._task and not self._task.done():
  176. self._task.cancel()
  177. try:
  178. await self._task
  179. except asyncio.CancelledError:
  180. pass # Expected when cancelling the discovery task
  181. self._task = None
  182. async def _discover(self, duration: float):
  183. """Run discovery for the specified duration.
  184. Bambu printers broadcast NOTIFY messages periodically on port 2021.
  185. We need to bind to that port and listen for broadcasts.
  186. """
  187. sock = None
  188. try:
  189. # Create UDP socket for SSDP
  190. sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
  191. sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  192. # Try to set SO_REUSEPORT if available (Linux/macOS)
  193. try:
  194. sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
  195. except (AttributeError, OSError):
  196. pass # SO_REUSEPORT not available on all platforms; non-critical
  197. # Set non-blocking mode
  198. sock.setblocking(False)
  199. # Bind to the SSDP port to receive NOTIFY broadcasts from printers
  200. sock.bind(("", SSDP_PORT))
  201. # Join multicast group to receive multicast messages
  202. mreq = struct.pack("4sl", socket.inet_aton(SSDP_ADDR), socket.INADDR_ANY)
  203. sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
  204. # Enable broadcast
  205. sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
  206. logger.info("Starting SSDP discovery on port %s for Bambu Lab printers...", SSDP_PORT)
  207. # Send initial M-SEARCH request to trigger responses
  208. try:
  209. sock.sendto(SSDP_MSEARCH.encode(), (SSDP_ADDR, SSDP_PORT))
  210. except OSError as e:
  211. logger.debug("M-SEARCH send error: %s", e)
  212. start_time = asyncio.get_event_loop().time()
  213. last_send = start_time
  214. while self._running and (asyncio.get_event_loop().time() - start_time) < duration:
  215. # Try to receive data
  216. try:
  217. data, addr = sock.recvfrom(4096)
  218. message = data.decode("utf-8", errors="ignore")
  219. logger.debug("Received from %s: %s...", addr[0], message[:100])
  220. self._handle_response(message, addr[0])
  221. except BlockingIOError:
  222. # No data available, that's fine
  223. pass
  224. except OSError as e:
  225. logger.debug("SSDP receive error: %s", e)
  226. # Re-send M-SEARCH every 3 seconds
  227. now = asyncio.get_event_loop().time()
  228. if now - last_send >= 3.0:
  229. try:
  230. sock.sendto(SSDP_MSEARCH.encode(), (SSDP_ADDR, SSDP_PORT))
  231. last_send = now
  232. except OSError as e:
  233. logger.debug("SSDP send error: %s", e)
  234. await asyncio.sleep(0.1)
  235. logger.info("Discovery complete. Found %s printers.", len(self._discovered))
  236. except OSError as e:
  237. if e.errno == 98: # Address already in use
  238. logger.warning("Port %s is in use, trying alternative discovery...", SSDP_PORT)
  239. await self._discover_alternative(duration)
  240. else:
  241. logger.error("Discovery error: %s", e)
  242. except Exception as e:
  243. logger.error("Discovery error: %s", e)
  244. finally:
  245. self._running = False
  246. if sock:
  247. try:
  248. sock.close()
  249. except OSError:
  250. pass # Best-effort socket cleanup
  251. async def _discover_alternative(self, duration: float):
  252. """Alternative discovery using a random port (less reliable)."""
  253. sock = None
  254. try:
  255. sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
  256. sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  257. sock.setblocking(False)
  258. sock.bind(("", 0))
  259. # Join multicast group
  260. mreq = struct.pack("4sl", socket.inet_aton(SSDP_ADDR), socket.INADDR_ANY)
  261. sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
  262. sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
  263. logger.info("Using alternative discovery method...")
  264. start_time = asyncio.get_event_loop().time()
  265. last_send = start_time
  266. while self._running and (asyncio.get_event_loop().time() - start_time) < duration:
  267. try:
  268. data, addr = sock.recvfrom(4096)
  269. self._handle_response(data.decode("utf-8", errors="ignore"), addr[0])
  270. except BlockingIOError:
  271. pass # No data available yet on non-blocking socket
  272. except OSError as e:
  273. logger.debug("SSDP receive error: %s", e)
  274. now = asyncio.get_event_loop().time()
  275. if now - last_send >= 2.0:
  276. try:
  277. sock.sendto(SSDP_MSEARCH.encode(), (SSDP_ADDR, SSDP_PORT))
  278. last_send = now
  279. except OSError:
  280. pass # Best-effort M-SEARCH resend; will retry next interval
  281. await asyncio.sleep(0.1)
  282. logger.info("Alternative discovery complete. Found %s printers.", len(self._discovered))
  283. except Exception as e:
  284. logger.error("Alternative discovery error: %s", e)
  285. finally:
  286. if sock:
  287. try:
  288. sock.close()
  289. except OSError:
  290. pass # Best-effort socket cleanup
  291. def _handle_response(self, response: str, ip_address: str):
  292. """Parse SSDP response and extract printer info."""
  293. # Check if it's a Bambu Lab printer response
  294. if BAMBU_SEARCH_TARGET not in response and "bambulab" not in response.lower():
  295. logger.debug("Ignoring non-Bambu response from %s", ip_address)
  296. return
  297. # Extract USN (Unique Service Name) which contains the serial
  298. # Bambu format is just "USN: SERIALNUMBER" (no uuid: prefix)
  299. usn_match = re.search(r"USN:\s*(?:uuid:)?([^\s\r\n]+)", response, re.IGNORECASE)
  300. if not usn_match:
  301. logger.debug("No USN found in response from %s", ip_address)
  302. return
  303. serial = usn_match.group(1).strip()
  304. # Skip Bambuddy's own virtual printer (any model variant)
  305. if serial.endswith(VIRTUAL_PRINTER_SERIAL_SUFFIX):
  306. logger.debug("Ignoring Bambuddy virtual printer at %s", ip_address)
  307. return
  308. # Extract device name from LOCATION or DevName header
  309. name = serial # Default to serial if no name found
  310. name_match = re.search(r"DevName\.bambu\.com:\s*(.+?)(?:\r\n|\n|$)", response, re.IGNORECASE)
  311. if name_match:
  312. name = name_match.group(1).strip()
  313. # Try to extract model from DevModel header
  314. model = None
  315. model_match = re.search(r"DevModel\.bambu\.com:\s*(.+?)(?:\r\n|\n|$)", response, re.IGNORECASE)
  316. if model_match:
  317. model = model_match.group(1).strip()
  318. # Also try NT header for model
  319. if not model:
  320. nt_match = re.search(r"NT:\s*urn:bambulab-com:device:([^:]+)", response, re.IGNORECASE)
  321. if nt_match:
  322. model = nt_match.group(1).strip()
  323. # Skip if already discovered
  324. if serial in self._discovered:
  325. return
  326. printer = DiscoveredPrinter(
  327. serial=serial,
  328. name=name,
  329. ip_address=ip_address,
  330. model=model,
  331. discovered_at=datetime.now(timezone.utc).isoformat(),
  332. )
  333. self._discovered[serial] = printer
  334. logger.info("Discovered printer: %s (%s) at %s", name, serial, ip_address)
  335. class SubnetScanner:
  336. """Scanner for discovering Bambu printers by probing IP addresses."""
  337. # Bambu printer ports
  338. MQTT_PORT = 8883
  339. FTP_PORT = 990
  340. def __init__(self):
  341. self._discovered: dict[str, DiscoveredPrinter] = {}
  342. self._running = False
  343. self._scanned = 0
  344. self._total = 0
  345. @property
  346. def is_running(self) -> bool:
  347. return self._running
  348. @property
  349. def discovered_printers(self) -> list[DiscoveredPrinter]:
  350. return list(self._discovered.values())
  351. @property
  352. def progress(self) -> tuple[int, int]:
  353. """Return (scanned, total) counts."""
  354. return self._scanned, self._total
  355. async def scan_subnet(self, subnet: str, timeout: float = 1.0) -> list[DiscoveredPrinter]:
  356. """Scan a subnet for Bambu printers.
  357. Args:
  358. subnet: CIDR notation subnet (e.g., "192.168.1.0/24")
  359. timeout: Connection timeout per host in seconds
  360. Returns:
  361. List of discovered printers
  362. """
  363. if self._running:
  364. return []
  365. self._running = True
  366. self._discovered.clear()
  367. self._scanned = 0
  368. try:
  369. network = ipaddress.ip_network(subnet, strict=False)
  370. hosts = list(network.hosts())
  371. self._total = len(hosts)
  372. if self._total > 1024:
  373. logger.warning("Subnet %s has %s hosts, limiting to /22 (1024 hosts)", subnet, self._total)
  374. self._total = 1024
  375. hosts = hosts[:1024]
  376. logger.info("Starting subnet scan of %s (%s hosts)", subnet, self._total)
  377. # Scan in batches to avoid overwhelming the network
  378. batch_size = 50
  379. for i in range(0, len(hosts), batch_size):
  380. if not self._running:
  381. break
  382. batch = hosts[i : i + batch_size]
  383. tasks = [self._probe_host(str(ip), timeout) for ip in batch]
  384. await asyncio.gather(*tasks, return_exceptions=True)
  385. self._scanned = min(i + batch_size, len(hosts))
  386. logger.info("Subnet scan complete. Found %s printers.", len(self._discovered))
  387. return self.discovered_printers
  388. except ValueError as e:
  389. logger.error("Invalid subnet format: %s", e)
  390. return []
  391. finally:
  392. self._running = False
  393. async def _probe_host(self, ip: str, timeout: float):
  394. """Probe a single host for Bambu printer ports."""
  395. # Check FTP port (990) - more reliable indicator
  396. ftp_open = await self._check_port(ip, self.FTP_PORT, timeout)
  397. if not ftp_open:
  398. return
  399. # Also check MQTT port (8883) for confirmation
  400. mqtt_open = await self._check_port(ip, self.MQTT_PORT, timeout)
  401. if not mqtt_open:
  402. return
  403. # Both ports open - likely a Bambu printer
  404. logger.info("Found potential Bambu printer at %s", ip)
  405. # Try to get printer info via SSDP unicast
  406. serial, name, model = await self._get_printer_info_ssdp(ip, timeout)
  407. # Skip Bambuddy's own virtual printer (any model variant)
  408. if serial and serial.endswith(VIRTUAL_PRINTER_SERIAL_SUFFIX):
  409. logger.debug("Ignoring Bambuddy virtual printer at %s", ip)
  410. return
  411. printer = DiscoveredPrinter(
  412. serial=serial or f"unknown-{ip.replace('.', '-')}",
  413. name=name or f"Printer at {ip}",
  414. ip_address=ip,
  415. model=model,
  416. discovered_at=datetime.now(timezone.utc).isoformat(),
  417. )
  418. self._discovered[ip] = printer
  419. async def _get_printer_info_ssdp(self, ip: str, timeout: float) -> tuple[str | None, str | None, str | None]:
  420. """Try to get printer info via SSDP unicast query."""
  421. loop = asyncio.get_event_loop()
  422. def _query():
  423. try:
  424. sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
  425. sock.settimeout(timeout)
  426. sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  427. # Send M-SEARCH directly to the printer
  428. msearch = (
  429. "M-SEARCH * HTTP/1.1\r\n"
  430. f"HOST: {ip}:{SSDP_PORT}\r\n"
  431. 'MAN: "ssdp:discover"\r\n'
  432. "MX: 1\r\n"
  433. f"ST: {BAMBU_SEARCH_TARGET}\r\n"
  434. "\r\n"
  435. )
  436. sock.sendto(msearch.encode(), (ip, SSDP_PORT))
  437. # Wait for response
  438. data, _ = sock.recvfrom(4096)
  439. response = data.decode("utf-8", errors="ignore")
  440. sock.close()
  441. # Parse response
  442. serial = None
  443. name = None
  444. model = None
  445. usn_match = re.search(r"USN:\s*(?:uuid:)?([^\s\r\n]+)", response, re.IGNORECASE)
  446. if usn_match:
  447. serial = usn_match.group(1).strip()
  448. name_match = re.search(r"DevName\.bambu\.com:\s*(.+?)(?:\r\n|\n|$)", response, re.IGNORECASE)
  449. if name_match:
  450. name = name_match.group(1).strip()
  451. model_match = re.search(r"DevModel\.bambu\.com:\s*(.+?)(?:\r\n|\n|$)", response, re.IGNORECASE)
  452. if model_match:
  453. model = model_match.group(1).strip()
  454. logger.debug("SSDP info from %s: serial=%s, name=%s, model=%s", ip, serial, name, model)
  455. return serial, name, model
  456. except OSError as e:
  457. logger.debug("SSDP query to %s failed: %s", ip, e)
  458. return None, None, None
  459. return await loop.run_in_executor(None, _query)
  460. async def _check_port(self, ip: str, port: int, timeout: float) -> bool:
  461. """Check if a port is open on the given IP."""
  462. try:
  463. _, writer = await asyncio.wait_for(asyncio.open_connection(ip, port), timeout=timeout)
  464. writer.close()
  465. await writer.wait_closed()
  466. logger.debug("Port %s open on %s", port, ip)
  467. return True
  468. except TimeoutError:
  469. return False
  470. except ConnectionRefusedError:
  471. return False
  472. except OSError as e:
  473. # Log first few errors to help debug network issues
  474. if self._scanned < 5:
  475. logger.debug("OSError checking %s:%s: %s", ip, port, e)
  476. return False
  477. def stop(self):
  478. """Stop the current scan."""
  479. self._running = False
  480. class TasmotaScanner:
  481. """Scanner for discovering Tasmota devices by probing IP addresses."""
  482. HTTP_PORT = 80
  483. def __init__(self):
  484. self._discovered: dict[str, dict] = {}
  485. self._running = False
  486. self._scanned = 0
  487. self._total = 0
  488. @property
  489. def is_running(self) -> bool:
  490. return self._running
  491. @property
  492. def discovered_devices(self) -> list[dict]:
  493. return list(self._discovered.values())
  494. @property
  495. def progress(self) -> tuple[int, int]:
  496. """Return (scanned, total) counts."""
  497. return self._scanned, self._total
  498. async def scan_range(self, from_ip: str, to_ip: str, timeout: float = 1.0) -> list[dict]:
  499. """Scan an IP range for Tasmota devices.
  500. Args:
  501. from_ip: Starting IP address (e.g., "192.168.1.1")
  502. to_ip: Ending IP address (e.g., "192.168.1.254")
  503. timeout: Connection timeout per host in seconds
  504. Returns:
  505. List of discovered Tasmota devices
  506. """
  507. if self._running:
  508. return []
  509. self._running = True
  510. self._discovered.clear()
  511. self._scanned = 0
  512. try:
  513. start = ipaddress.ip_address(from_ip)
  514. end = ipaddress.ip_address(to_ip)
  515. # Generate list of IPs in range
  516. hosts = []
  517. current = start
  518. while current <= end:
  519. hosts.append(str(current))
  520. current = ipaddress.ip_address(int(current) + 1)
  521. self._total = len(hosts)
  522. if self._total > 1024:
  523. logger.warning("IP range has %s hosts, limiting to 1024", self._total)
  524. self._total = 1024
  525. hosts = hosts[:1024]
  526. logger.info("Starting Tasmota scan from %s to %s (%s hosts)", from_ip, to_ip, self._total)
  527. # Scan in batches to avoid overwhelming the network
  528. batch_size = 50
  529. for i in range(0, len(hosts), batch_size):
  530. if not self._running:
  531. logger.info("Tasmota scan stopped by user")
  532. break
  533. batch = hosts[i : i + batch_size]
  534. tasks = [self._probe_host(ip) for ip in batch]
  535. try:
  536. await asyncio.gather(*tasks, return_exceptions=True)
  537. except Exception as e:
  538. logger.warning("Batch %s error: %s", i // batch_size, e)
  539. self._scanned = min(i + batch_size, len(hosts))
  540. logger.info("Tasmota scan complete. Found %s devices.", len(self._discovered))
  541. return self.discovered_devices
  542. except ValueError as e:
  543. logger.error("Invalid IP address format: %s", e)
  544. return []
  545. finally:
  546. self._running = False
  547. async def _probe_host(self, ip: str):
  548. """Probe a single host for Tasmota HTTP API."""
  549. try:
  550. # Hard timeout of 5 seconds max per host
  551. await asyncio.wait_for(self._do_probe(ip), timeout=5.0)
  552. except TimeoutError:
  553. pass # Host did not respond in time; skip
  554. except Exception:
  555. pass # Probe failed for this host; skip silently
  556. async def _do_probe(self, ip: str):
  557. """Actually probe the host."""
  558. import httpx
  559. try:
  560. # Reasonable timeouts for network scanning
  561. client_timeout = httpx.Timeout(3.0, connect=1.0)
  562. async with httpx.AsyncClient(timeout=client_timeout, follow_redirects=False) as client:
  563. # First try simple Power command - most reliable indicator of Tasmota
  564. power_url = f"http://{ip}/cm?cmnd=Power"
  565. try:
  566. power_response = await client.get(power_url)
  567. if power_response.status_code == 401:
  568. # Device requires auth - still a Tasmota device!
  569. logger.info("Discovered Tasmota at %s (requires auth - 401)", ip)
  570. device = {
  571. "ip_address": ip,
  572. "name": f"Tasmota ({ip})",
  573. "module": None,
  574. "state": "UNKNOWN",
  575. "discovered_at": datetime.now(timezone.utc).isoformat(),
  576. }
  577. self._discovered[ip] = device
  578. return
  579. if power_response.status_code != 200:
  580. return
  581. power_data = power_response.json()
  582. # Check for Tasmota auth warning (returns 200 with WARNING)
  583. if "WARNING" in power_data:
  584. logger.info("Discovered Tasmota at %s (requires auth)", ip)
  585. device = {
  586. "ip_address": ip,
  587. "name": f"Tasmota ({ip})",
  588. "module": None,
  589. "state": "UNKNOWN",
  590. "discovered_at": datetime.now(timezone.utc).isoformat(),
  591. }
  592. self._discovered[ip] = device
  593. return
  594. # Check if response looks like Tasmota (has POWER or POWER1 key)
  595. power_state = power_data.get("POWER") or power_data.get("POWER1")
  596. if power_state is None:
  597. return
  598. except Exception as e:
  599. logger.debug("Error probing %s: %s", ip, e)
  600. return
  601. # It's a Tasmota device! Now get more info
  602. device_name = f"Tasmota ({ip})"
  603. module = None
  604. # Try to get device name from Status 0
  605. try:
  606. status_url = f"http://{ip}/cm?cmnd=Status%200"
  607. status_response = await client.get(status_url)
  608. if status_response.status_code == 200:
  609. status_data = status_response.json()
  610. if "Status" in status_data:
  611. status = status_data["Status"]
  612. device_name = status.get("DeviceName") or device_name
  613. if not device_name or device_name == f"Tasmota ({ip})":
  614. # Try FriendlyName
  615. friendly = status.get("FriendlyName")
  616. if friendly and isinstance(friendly, list) and friendly[0]:
  617. device_name = friendly[0]
  618. module = status.get("Module")
  619. except Exception:
  620. pass # Status query is optional; proceed with defaults
  621. device = {
  622. "ip_address": ip,
  623. "name": device_name,
  624. "module": module,
  625. "state": power_state,
  626. "discovered_at": datetime.now(timezone.utc).isoformat(),
  627. }
  628. self._discovered[ip] = device
  629. logger.info("Discovered Tasmota device: %s at %s", device_name, ip)
  630. except httpx.TimeoutException:
  631. pass # Host unreachable or too slow; not a Tasmota device
  632. except httpx.ConnectError:
  633. pass # Connection refused; no HTTP server on this host
  634. except Exception:
  635. pass # Unexpected error probing host; skip silently
  636. def stop(self):
  637. """Stop the current scan."""
  638. self._running = False
  639. # Global instances
  640. discovery_service = PrinterDiscoveryService()
  641. subnet_scanner = SubnetScanner()
  642. tasmota_scanner = TasmotaScanner()