Files
maziggy 3a5f802cdc fix(diagnostics): read the subnet the host is actually on (issue #3092)
The Network subnet check told the reporter that 192.168.98.170 and
192.168.96.9 were on different networks and to go configure routing
between them. They are four hundred addresses apart inside one
192.168.96.0/22 LAN.

An IPv4 address does not carry its prefix, and the check supplied /24
for both sides. That is the most common LAN and not the only one, and
the guess is wrong in both directions: it splits a /22 and it merges a
/25. Read the prefix off the interface that owns the address instead.

find_local_ipv4_network() enumerates every interface, including the ones
EXCLUDED_INTERFACE_PREFIXES hides. That list keeps docker0 and friends
out of the Virtual Printer's bind dropdown; here the caller is asking
about an address the kernel has already picked as a route source, and
answering "unknown" because it sits on a bridge would be a worse answer
than the truth. When nothing claims the address the check skips, which
is what it always did with no host IP at all -- it must not assert a
split it cannot see.

The same check chose which of Bambuddy's own addresses to compare by
probing a route toward 10.255.255.255, which on a multi-homed host is
not the interface the printer is on. It asks for the route toward the
printer now. On a two-NIC dev box that alone was warning about a printer
sitting on the second card's own subnet.

The probe takes IPv4 literals only. connect() on a name would resolve
it on the event loop, and _same_subnet rejects names anyway, so nothing
is lost. Resolving the prefix shells out to `ip -j addr show`, so it
moves off the loop too.

-----

fix(diagnostics): name the container engine instead of asking about Docker (issue #3092)

"Not running in Docker - not applicable", said to a Bambuddy inside a
Podman container. It reads as "you are on bare metal", and it sent the
reporter looking for his problem somewhere else.

Podman runs Bambuddy in exactly the two shapes Docker does, and the
shape is the thing that breaks printer discovery and the Virtual
Printer. detect_container_runtime() names the engine -- Docker, Podman,
Kubernetes, containerd, LXC, or a container it cannot place -- and the
check became Container network mode.

is_running_in_docker() is deliberately left alone rather than rewritten
on top of it. Three callers key real behaviour off that flag, and one of
them switches the Add Printer flow from SSDP to subnet scanning. SSDP
works for a host-networked Podman container, so answering True there
would take a working feature away to fix a sentence. Widening it is a
separate decision from naming the engine, so it is made separately.

Mode detection keeps the original signal first, which also makes the
Docker path incapable of regressing: a Docker host always has a docker0,
so a container that sees one shares its namespace, and the new rules can
only turn a warning into a pass. That signal says nothing about Podman,
which creates no such interface on a host running no bridge containers --
which is how host networking came to be reported as bridge. The general
form of the same idea answers for Podman: an interface whose iflink
equals its ifindex was created in this namespace, and a NAT-networked
container only ever receives one end of a veth pair. tun/tap is skipped,
because a container may run its own WireGuard and that tun is native to
a namespace it is not evidence of. The interface also has to be the one
the kernel just named -- sysfs is namespace-tagged but a bind-mounted
host /sys is not, and reading a colliding name's numbers would be
reading another namespace's answer.

What is still unreadable now says so and suggests host networking if
discovery is failing, rather than guessing bridge and telling a healthy
install to recreate itself. An LXC or LXD system container is named and
told the question does not apply: it is on the LAN like a small virtual
machine, so there is no network mode to recommend -- and its subnet
check still runs.

An engine we cannot name is a sentinel the frontend localizes, not a
word interpolated into thirteen other languages.

The support bundle carries the engine name beside the Docker flag, so
the next report of this shape is answerable from the bundle.
2026-09-19 12:19:19 +02:00

791 lines
29 KiB
Python

"""
Bambu Lab printer discovery service using SSDP and subnet scanning.
Bambu Lab printers advertise themselves via SSDP (Simple Service Discovery Protocol)
on the local network. This service listens for these advertisements and provides
a list of discovered printers.
For Docker environments where SSDP multicast doesn't work, subnet scanning is
available as an alternative discovery method.
"""
import asyncio
import ipaddress
import logging
import os
import re
import socket
import struct
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
logger = logging.getLogger(__name__)
# Runtime names :func:`detect_container_runtime` can return. These reach the
# user in the connection diagnostic, so they are the names people know their
# own setup by.
RUNTIME_DOCKER = "Docker"
RUNTIME_PODMAN = "Podman"
RUNTIME_KUBERNETES = "Kubernetes"
RUNTIME_CONTAINERD = "containerd"
RUNTIME_LXC = "LXC"
# Sentinel for "in a container we cannot name". The others are proper nouns
# that interpolate into any language; this one is localized by the frontend
# (diagnostic.check.network_mode.genericRuntime), so keep the two in step.
RUNTIME_OTHER = "container"
# Runtimes that put Bambuddy in an OCI container whose network mode is a
# choice the user made and can change. LXC is deliberately absent: a Proxmox
# or LXD system container is bridged onto the LAN like a small VM, so there is
# no "recreate it with host networking" advice to give.
OCI_RUNTIMES = frozenset({RUNTIME_DOCKER, RUNTIME_PODMAN, RUNTIME_KUBERNETES, RUNTIME_CONTAINERD, RUNTIME_OTHER})
# systemd writes the engine's own name here. It is the only signal that tells
# Podman apart from Docker without guessing, which is why it is consulted
# first (see updates.py, which has used it for the same reason for longer).
_SYSTEMD_CONTAINER = Path("/run/systemd/container")
_SYSTEMD_RUNTIME_NAMES = {
"docker": RUNTIME_DOCKER,
"podman": RUNTIME_PODMAN,
"containerd": RUNTIME_CONTAINERD,
"lxc": RUNTIME_LXC,
"lxc-libvirt": RUNTIME_LXC,
"oci": RUNTIME_OTHER,
}
def _read_text(path: Path) -> str:
"""Read a small /proc or /run marker file, empty string if unreadable."""
try:
return path.read_text()
except (OSError, ValueError):
# Unreadable, absent, or /proc entry that vanished mid-read.
return ""
def detect_container_runtime() -> str | None:
"""Name the container runtime Bambuddy is running under, or None.
``is_running_in_docker`` below answers a narrower question and is
deliberately left alone — see the comment on it.
Detection is ordered most-specific first, because the generic markers
cannot tell two engines apart: Podman sets ``/run/.containerenv`` *and*
writes ``libpod`` into the cgroup path, while Docker sets ``/.dockerenv``
and writes ``docker``. A container started by neither still gets a name
(``container``) rather than None, because "we are in something" is a
useful answer even when the engine is not.
"""
systemd_name = _read_text(_SYSTEMD_CONTAINER).strip().lower()
if systemd_name:
return _SYSTEMD_RUNTIME_NAMES.get(systemd_name, RUNTIME_OTHER)
# Podman writes /run/.containerenv into every container it starts. Older
# versions put it at the root, so both are checked.
if Path("/run/.containerenv").exists() or Path("/.containerenv").exists():
return RUNTIME_PODMAN
if Path("/.dockerenv").exists():
return RUNTIME_DOCKER
cgroup = _read_text(Path("/proc/1/cgroup"))
if "libpod" in cgroup:
return RUNTIME_PODMAN
if "kubepods" in cgroup:
return RUNTIME_KUBERNETES
if "docker" in cgroup:
return RUNTIME_DOCKER
if "containerd" in cgroup:
return RUNTIME_CONTAINERD
if "/lxc" in cgroup:
return RUNTIME_LXC
env_name = (os.environ.get("CONTAINER") or "").strip().lower()
if env_name:
return _SYSTEMD_RUNTIME_NAMES.get(env_name, RUNTIME_OTHER)
if os.environ.get("DOCKER_CONTAINER"):
return RUNTIME_DOCKER
return None
def is_running_in_docker() -> bool:
"""Detect if we're running inside a Docker container.
Kept Docker-specific on purpose, and NOT rewritten on top of
:func:`detect_container_runtime`. Three callers key real behaviour off
this: ``/api/discovery/info`` feeds it to the Add-Printer flow, where
``isDocker`` switches discovery from SSDP to subnet scanning, and the
backup-path probe and support bundle both read it. Answering True for a
host-networked Podman container would take SSDP away from users for whom
it works (#3092). Widening it is a separate decision from naming the
runtime, so it is made separately.
"""
# Check for .dockerenv file
if Path("/.dockerenv").exists():
return True
# Check cgroup for docker/containerd
try:
with open("/proc/1/cgroup") as f:
content = f.read()
if "docker" in content or "containerd" in content or "kubepods" in content:
return True
except (FileNotFoundError, PermissionError):
pass # /proc/1/cgroup may not exist or be readable; fall through to env check
# Check for container environment variable
return bool(os.environ.get("CONTAINER") or os.environ.get("DOCKER_CONTAINER"))
# SSDP multicast address - Bambu uses port 2021, not standard 1900
SSDP_ADDR = "239.255.255.250"
SSDP_PORT = 2021 # Bambu Lab uses non-standard port
# Bambu Lab SSDP search target
BAMBU_SEARCH_TARGET = "urn:bambulab-com:device:3dprinter:1"
# Virtual printer serial suffix to exclude from discovery (Bambuddy's own virtual printer)
# All virtual printer serials end with this suffix, regardless of model
VIRTUAL_PRINTER_SERIAL_SUFFIX = "391800001"
# SSDP M-SEARCH message
SSDP_MSEARCH = (
"M-SEARCH * HTTP/1.1\r\n"
f"HOST: {SSDP_ADDR}:{SSDP_PORT}\r\n"
'MAN: "ssdp:discover"\r\n'
"MX: 3\r\n"
f"ST: {BAMBU_SEARCH_TARGET}\r\n"
"\r\n"
)
@dataclass
class DiscoveredPrinter:
"""Represents a discovered Bambu Lab printer."""
serial: str
name: str
ip_address: str
model: str | None = None
discovered_at: str | None = None
def to_dict(self) -> dict:
return {
"serial": self.serial,
"name": self.name,
"ip_address": self.ip_address,
"model": self.model,
"discovered_at": self.discovered_at,
}
class PrinterDiscoveryService:
"""Service for discovering Bambu Lab printers on the network."""
def __init__(self):
self._discovered: dict[str, DiscoveredPrinter] = {}
self._running = False
self._task: asyncio.Task | None = None
@property
def is_running(self) -> bool:
return self._running
@property
def discovered_printers(self) -> list[DiscoveredPrinter]:
return list(self._discovered.values())
def clear(self):
"""Clear discovered printers."""
self._discovered.clear()
async def start(self, duration: float = 10.0):
"""Start discovery for a specified duration."""
if self._running:
return
self._running = True
self._discovered.clear()
self._task = asyncio.create_task(self._discover(duration))
async def stop(self):
"""Stop discovery."""
self._running = False
if self._task and not self._task.done():
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass # Expected when cancelling the discovery task
self._task = None
async def _discover(self, duration: float):
"""Run discovery for the specified duration.
Bambu printers broadcast NOTIFY messages periodically on port 2021.
We need to bind to that port and listen for broadcasts.
"""
sock = None
try:
# Create UDP socket for SSDP
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# Try to set SO_REUSEPORT if available (Linux/macOS)
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
except (AttributeError, OSError):
pass # SO_REUSEPORT not available on all platforms; non-critical
# Set non-blocking mode
sock.setblocking(False)
# Bind to the SSDP port to receive NOTIFY broadcasts from printers
sock.bind(("", SSDP_PORT))
# Join multicast group to receive multicast messages
mreq = struct.pack("4sl", socket.inet_aton(SSDP_ADDR), socket.INADDR_ANY)
sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
# Enable broadcast
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
logger.info("Starting SSDP discovery on port %s for Bambu Lab printers...", SSDP_PORT)
# Send initial M-SEARCH request to trigger responses
try:
sock.sendto(SSDP_MSEARCH.encode(), (SSDP_ADDR, SSDP_PORT))
except OSError as e:
logger.debug("M-SEARCH send error: %s", e)
start_time = asyncio.get_event_loop().time()
last_send = start_time
while self._running and (asyncio.get_event_loop().time() - start_time) < duration:
# Try to receive data
try:
data, addr = sock.recvfrom(4096)
message = data.decode("utf-8", errors="ignore")
logger.debug("Received from %s: %s...", addr[0], message[:100])
self._handle_response(message, addr[0])
except BlockingIOError:
# No data available, that's fine
pass
except OSError as e:
logger.debug("SSDP receive error: %s", e)
# Re-send M-SEARCH every 3 seconds
now = asyncio.get_event_loop().time()
if now - last_send >= 3.0:
try:
sock.sendto(SSDP_MSEARCH.encode(), (SSDP_ADDR, SSDP_PORT))
last_send = now
except OSError as e:
logger.debug("SSDP send error: %s", e)
await asyncio.sleep(0.1)
logger.info("Discovery complete. Found %s printers.", len(self._discovered))
except OSError as e:
if e.errno == 98: # Address already in use
logger.warning("Port %s is in use, trying alternative discovery...", SSDP_PORT)
await self._discover_alternative(duration)
else:
logger.error("Discovery error: %s", e)
except Exception as e:
logger.error("Discovery error: %s", e)
finally:
self._running = False
if sock:
try:
sock.close()
except OSError:
pass # Best-effort socket cleanup
async def _discover_alternative(self, duration: float):
"""Alternative discovery using a random port (less reliable)."""
sock = None
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.setblocking(False)
sock.bind(("", 0))
# Join multicast group
mreq = struct.pack("4sl", socket.inet_aton(SSDP_ADDR), socket.INADDR_ANY)
sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
logger.info("Using alternative discovery method...")
start_time = asyncio.get_event_loop().time()
last_send = start_time
while self._running and (asyncio.get_event_loop().time() - start_time) < duration:
try:
data, addr = sock.recvfrom(4096)
self._handle_response(data.decode("utf-8", errors="ignore"), addr[0])
except BlockingIOError:
pass # No data available yet on non-blocking socket
except OSError as e:
logger.debug("SSDP receive error: %s", e)
now = asyncio.get_event_loop().time()
if now - last_send >= 2.0:
try:
sock.sendto(SSDP_MSEARCH.encode(), (SSDP_ADDR, SSDP_PORT))
last_send = now
except OSError:
pass # Best-effort M-SEARCH resend; will retry next interval
await asyncio.sleep(0.1)
logger.info("Alternative discovery complete. Found %s printers.", len(self._discovered))
except Exception as e:
logger.error("Alternative discovery error: %s", e)
finally:
if sock:
try:
sock.close()
except OSError:
pass # Best-effort socket cleanup
def _handle_response(self, response: str, ip_address: str):
"""Parse SSDP response and extract printer info."""
# Check if it's a Bambu Lab printer response
if BAMBU_SEARCH_TARGET not in response and "bambulab" not in response.lower():
logger.debug("Ignoring non-Bambu response from %s", ip_address)
return
# Extract USN (Unique Service Name) which contains the serial
# Bambu format is just "USN: SERIALNUMBER" (no uuid: prefix)
usn_match = re.search(r"USN:\s*(?:uuid:)?([^\s\r\n]+)", response, re.IGNORECASE)
if not usn_match:
logger.debug("No USN found in response from %s", ip_address)
return
serial = usn_match.group(1).strip()
# Skip Bambuddy's own virtual printer (any model variant)
if serial.endswith(VIRTUAL_PRINTER_SERIAL_SUFFIX):
logger.debug("Ignoring Bambuddy virtual printer at %s", ip_address)
return
# Extract device name from LOCATION or DevName header
name = serial # Default to serial if no name found
name_match = re.search(r"DevName\.bambu\.com:\s*(.+?)(?:\r\n|\n|$)", response, re.IGNORECASE)
if name_match:
name = name_match.group(1).strip()
# Try to extract model from DevModel header
model = None
model_match = re.search(r"DevModel\.bambu\.com:\s*(.+?)(?:\r\n|\n|$)", response, re.IGNORECASE)
if model_match:
model = model_match.group(1).strip()
# Also try NT header for model
if not model:
nt_match = re.search(r"NT:\s*urn:bambulab-com:device:([^:]+)", response, re.IGNORECASE)
if nt_match:
model = nt_match.group(1).strip()
# Skip if already discovered
if serial in self._discovered:
return
printer = DiscoveredPrinter(
serial=serial,
name=name,
ip_address=ip_address,
model=model,
discovered_at=datetime.now(timezone.utc).isoformat(),
)
self._discovered[serial] = printer
logger.info("Discovered printer: %s (%s) at %s", name, serial, ip_address)
class SubnetScanner:
"""Scanner for discovering Bambu printers by probing IP addresses."""
# Bambu printer ports
MQTT_PORT = 8883
FTP_PORT = 990
def __init__(self):
self._discovered: dict[str, DiscoveredPrinter] = {}
self._running = False
self._scanned = 0
self._total = 0
@property
def is_running(self) -> bool:
return self._running
@property
def discovered_printers(self) -> list[DiscoveredPrinter]:
return list(self._discovered.values())
@property
def progress(self) -> tuple[int, int]:
"""Return (scanned, total) counts."""
return self._scanned, self._total
async def scan_subnet(self, subnet: str, timeout: float = 1.0) -> list[DiscoveredPrinter]:
"""Scan a subnet for Bambu printers.
Args:
subnet: CIDR notation subnet (e.g., "192.168.1.0/24")
timeout: Connection timeout per host in seconds
Returns:
List of discovered printers
"""
if self._running:
return []
self._running = True
self._discovered.clear()
self._scanned = 0
try:
network = ipaddress.ip_network(subnet, strict=False)
hosts = list(network.hosts())
self._total = len(hosts)
if self._total > 1024:
logger.warning("Subnet %s has %s hosts, limiting to /22 (1024 hosts)", subnet, self._total)
self._total = 1024
hosts = hosts[:1024]
logger.info("Starting subnet scan of %s (%s hosts)", subnet, self._total)
# Scan in batches to avoid overwhelming the network
batch_size = 50
for i in range(0, len(hosts), batch_size):
if not self._running:
break
batch = hosts[i : i + batch_size]
tasks = [self._probe_host(str(ip), timeout) for ip in batch]
await asyncio.gather(*tasks, return_exceptions=True)
self._scanned = min(i + batch_size, len(hosts))
logger.info("Subnet scan complete. Found %s printers.", len(self._discovered))
return self.discovered_printers
except ValueError as e:
logger.error("Invalid subnet format: %s", e)
return []
finally:
self._running = False
async def _probe_host(self, ip: str, timeout: float):
"""Probe a single host for Bambu printer ports."""
# Check FTP port (990) - more reliable indicator
ftp_open = await self._check_port(ip, self.FTP_PORT, timeout)
if not ftp_open:
return
# Also check MQTT port (8883) for confirmation
mqtt_open = await self._check_port(ip, self.MQTT_PORT, timeout)
if not mqtt_open:
return
# Both ports open - likely a Bambu printer
logger.info("Found potential Bambu printer at %s", ip)
# Try to get printer info via SSDP unicast
serial, name, model = await self._get_printer_info_ssdp(ip, timeout)
# Skip Bambuddy's own virtual printer (any model variant)
if serial and serial.endswith(VIRTUAL_PRINTER_SERIAL_SUFFIX):
logger.debug("Ignoring Bambuddy virtual printer at %s", ip)
return
printer = DiscoveredPrinter(
serial=serial or f"unknown-{ip.replace('.', '-')}",
name=name or f"Printer at {ip}",
ip_address=ip,
model=model,
discovered_at=datetime.now(timezone.utc).isoformat(),
)
self._discovered[ip] = printer
async def _get_printer_info_ssdp(self, ip: str, timeout: float) -> tuple[str | None, str | None, str | None]:
"""Try to get printer info via SSDP unicast query."""
loop = asyncio.get_event_loop()
def _query():
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
sock.settimeout(timeout)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# Send M-SEARCH directly to the printer
msearch = (
"M-SEARCH * HTTP/1.1\r\n"
f"HOST: {ip}:{SSDP_PORT}\r\n"
'MAN: "ssdp:discover"\r\n'
"MX: 1\r\n"
f"ST: {BAMBU_SEARCH_TARGET}\r\n"
"\r\n"
)
sock.sendto(msearch.encode(), (ip, SSDP_PORT))
# Wait for response
data, _ = sock.recvfrom(4096)
response = data.decode("utf-8", errors="ignore")
sock.close()
# Parse response
serial = None
name = None
model = None
usn_match = re.search(r"USN:\s*(?:uuid:)?([^\s\r\n]+)", response, re.IGNORECASE)
if usn_match:
serial = usn_match.group(1).strip()
name_match = re.search(r"DevName\.bambu\.com:\s*(.+?)(?:\r\n|\n|$)", response, re.IGNORECASE)
if name_match:
name = name_match.group(1).strip()
model_match = re.search(r"DevModel\.bambu\.com:\s*(.+?)(?:\r\n|\n|$)", response, re.IGNORECASE)
if model_match:
model = model_match.group(1).strip()
logger.debug("SSDP info from %s: serial=%s, name=%s, model=%s", ip, serial, name, model)
return serial, name, model
except OSError as e:
logger.debug("SSDP query to %s failed: %s", ip, e)
return None, None, None
return await loop.run_in_executor(None, _query)
async def _check_port(self, ip: str, port: int, timeout: float) -> bool:
"""Check if a port is open on the given IP."""
try:
_, writer = await asyncio.wait_for(asyncio.open_connection(ip, port), timeout=timeout)
writer.close()
await writer.wait_closed()
logger.debug("Port %s open on %s", port, ip)
return True
except TimeoutError:
return False
except ConnectionRefusedError:
return False
except OSError as e:
# Log first few errors to help debug network issues
if self._scanned < 5:
logger.debug("OSError checking %s:%s: %s", ip, port, e)
return False
def stop(self):
"""Stop the current scan."""
self._running = False
class TasmotaScanner:
"""Scanner for discovering Tasmota devices by probing IP addresses."""
HTTP_PORT = 80
def __init__(self):
self._discovered: dict[str, dict] = {}
self._running = False
self._scanned = 0
self._total = 0
@property
def is_running(self) -> bool:
return self._running
@property
def discovered_devices(self) -> list[dict]:
return list(self._discovered.values())
@property
def progress(self) -> tuple[int, int]:
"""Return (scanned, total) counts."""
return self._scanned, self._total
async def scan_range(self, from_ip: str, to_ip: str, timeout: float = 1.0) -> list[dict]:
"""Scan an IP range for Tasmota devices.
Args:
from_ip: Starting IP address (e.g., "192.168.1.1")
to_ip: Ending IP address (e.g., "192.168.1.254")
timeout: Connection timeout per host in seconds
Returns:
List of discovered Tasmota devices
"""
if self._running:
return []
self._running = True
self._discovered.clear()
self._scanned = 0
try:
start = ipaddress.ip_address(from_ip)
end = ipaddress.ip_address(to_ip)
# Generate list of IPs in range
hosts = []
current = start
while current <= end:
hosts.append(str(current))
current = ipaddress.ip_address(int(current) + 1)
self._total = len(hosts)
if self._total > 1024:
logger.warning("IP range has %s hosts, limiting to 1024", self._total)
self._total = 1024
hosts = hosts[:1024]
logger.info("Starting Tasmota scan from %s to %s (%s hosts)", from_ip, to_ip, self._total)
# Scan in batches to avoid overwhelming the network
batch_size = 50
for i in range(0, len(hosts), batch_size):
if not self._running:
logger.info("Tasmota scan stopped by user")
break
batch = hosts[i : i + batch_size]
tasks = [self._probe_host(ip) for ip in batch]
try:
await asyncio.gather(*tasks, return_exceptions=True)
except Exception as e:
logger.warning("Batch %s error: %s", i // batch_size, e)
self._scanned = min(i + batch_size, len(hosts))
logger.info("Tasmota scan complete. Found %s devices.", len(self._discovered))
return self.discovered_devices
except ValueError as e:
logger.error("Invalid IP address format: %s", e)
return []
finally:
self._running = False
async def _probe_host(self, ip: str):
"""Probe a single host for Tasmota HTTP API."""
try:
# Hard timeout of 5 seconds max per host
await asyncio.wait_for(self._do_probe(ip), timeout=5.0)
except TimeoutError:
pass # Host did not respond in time; skip
except Exception:
pass # Probe failed for this host; skip silently
async def _do_probe(self, ip: str):
"""Actually probe the host."""
import httpx
try:
# Reasonable timeouts for network scanning
client_timeout = httpx.Timeout(3.0, connect=1.0)
async with httpx.AsyncClient(timeout=client_timeout, follow_redirects=False) as client:
# First try simple Power command - most reliable indicator of Tasmota
power_url = f"http://{ip}/cm?cmnd=Power"
try:
power_response = await client.get(power_url)
if power_response.status_code == 401:
# Device requires auth - still a Tasmota device!
logger.info("Discovered Tasmota at %s (requires auth - 401)", ip)
device = {
"ip_address": ip,
"name": f"Tasmota ({ip})",
"module": None,
"state": "UNKNOWN",
"discovered_at": datetime.now(timezone.utc).isoformat(),
}
self._discovered[ip] = device
return
if power_response.status_code != 200:
return
power_data = power_response.json()
# Check for Tasmota auth warning (returns 200 with WARNING)
if "WARNING" in power_data:
logger.info("Discovered Tasmota at %s (requires auth)", ip)
device = {
"ip_address": ip,
"name": f"Tasmota ({ip})",
"module": None,
"state": "UNKNOWN",
"discovered_at": datetime.now(timezone.utc).isoformat(),
}
self._discovered[ip] = device
return
# Check if response looks like Tasmota (has POWER or POWER1 key)
power_state = power_data.get("POWER") or power_data.get("POWER1")
if power_state is None:
return
except Exception as e:
logger.debug("Error probing %s: %s", ip, e)
return
# It's a Tasmota device! Now get more info
device_name = f"Tasmota ({ip})"
module = None
# Try to get device name from Status 0
try:
status_url = f"http://{ip}/cm?cmnd=Status%200"
status_response = await client.get(status_url)
if status_response.status_code == 200:
status_data = status_response.json()
if "Status" in status_data:
status = status_data["Status"]
device_name = status.get("DeviceName") or device_name
if not device_name or device_name == f"Tasmota ({ip})":
# Try FriendlyName
friendly = status.get("FriendlyName")
if friendly and isinstance(friendly, list) and friendly[0]:
device_name = friendly[0]
module = status.get("Module")
except Exception:
pass # Status query is optional; proceed with defaults
device = {
"ip_address": ip,
"name": device_name,
"module": module,
"state": power_state,
"discovered_at": datetime.now(timezone.utc).isoformat(),
}
self._discovered[ip] = device
logger.info("Discovered Tasmota device: %s at %s", device_name, ip)
except httpx.TimeoutException:
pass # Host unreachable or too slow; not a Tasmota device
except httpx.ConnectError:
pass # Connection refused; no HTTP server on this host
except Exception:
pass # Unexpected error probing host; skip silently
def stop(self):
"""Stop the current scan."""
self._running = False
# Global instances
discovery_service = PrinterDiscoveryService()
subnet_scanner = SubnetScanner()
tasmota_scanner = TasmotaScanner()