feat: subnet printer discovery (Feature 3)

- New GET /api/system/printers/scan SSE endpoint: parallel async TCP
  scan of a /24 subnet, streams found/progress/done events
- New GET /api/system/printers/scan-hints: returns auto-detected local
  subnets from socket.getaddrinfo, excludes loopback/docker ranges
- FindPrintersModal in Settings > Print: shows subnet+port inputs,
  live progress bar, found printers list; selecting a result pre-fills
  the new printer form (user still names it before saving)
- "Εύρεση εκτυπωτών" button added next to "+ Νέος εκτυπωτής"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 11:14:37 +03:00
parent f3d03bf85f
commit b5b647422a
2 changed files with 290 additions and 6 deletions

View File

@@ -1,5 +1,10 @@
import asyncio
import ipaddress
import json
import socket
import time
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from typing import List
@@ -164,6 +169,103 @@ def delete_printer(printer_id: int, db: Session = Depends(get_db), user: User =
return {"ok": True}
def _detect_local_subnets() -> list[str]:
"""Return plausible /24 subnets based on local interface IPs, excluding loopback/docker."""
subnets = []
try:
hostname = socket.gethostname()
for info in socket.getaddrinfo(hostname, None):
ip_str = info[4][0]
try:
addr = ipaddress.IPv4Address(ip_str)
if addr.is_loopback or addr.is_link_local:
continue
# Exclude docker bridge ranges (172.16-31.x.x)
if addr.packed[0] == 172 and 16 <= addr.packed[1] <= 31:
continue
net = str(ipaddress.IPv4Network(f"{ip_str}/24", strict=False))
if net not in subnets:
subnets.append(net)
except (ValueError, OSError):
continue
except OSError:
pass
return subnets
async def _check_port(ip: str, port: int, timeout: float) -> bool:
try:
_, writer = await asyncio.wait_for(
asyncio.open_connection(ip, port), timeout=timeout
)
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
return True
except Exception:
return False
async def _scan_subnet_sse(subnet: str, port: int, token: str):
"""Async generator: yields SSE lines as printers are found."""
try:
network = ipaddress.IPv4Network(subnet, strict=False)
except ValueError as e:
yield f"data: {json.dumps({'error': str(e)})}\n\n"
return
hosts = list(network.hosts())
semaphore = asyncio.Semaphore(50) # max 50 concurrent connects
async def check(ip_str: str):
async with semaphore:
return ip_str, await _check_port(ip_str, port, timeout=0.4)
tasks = [asyncio.create_task(check(str(h))) for h in hosts]
yield f"data: {json.dumps({'type': 'start', 'total': len(tasks), 'subnet': subnet, 'port': port})}\n\n"
done_count = 0
for coro in asyncio.as_completed(tasks):
ip_str, reachable = await coro
done_count += 1
if reachable:
yield f"data: {json.dumps({'type': 'found', 'ip': ip_str, 'port': port})}\n\n"
if done_count % 20 == 0 or done_count == len(tasks):
yield f"data: {json.dumps({'type': 'progress', 'done': done_count, 'total': len(tasks)})}\n\n"
yield f"data: {json.dumps({'type': 'done'})}\n\n"
@router.get("/printers/scan")
async def scan_printers(
subnet: str = Query(default=""),
port: int = Query(default=9100),
user: User = Depends(require_manager),
):
"""SSE endpoint: scan a subnet for devices responding on the given port."""
# Fall back to auto-detected subnet if none provided
if not subnet:
detected = _detect_local_subnets()
subnet = detected[0] if detected else "192.168.1.0/24"
return StreamingResponse(
_scan_subnet_sse(subnet, port, ""),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
@router.get("/printers/scan-hints")
def scan_hints(user: User = Depends(require_manager)):
"""Return auto-detected local subnets to suggest in the scan UI."""
return {"subnets": _detect_local_subnets()}
@router.get("/stats")
def system_stats(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
return {