This commit is contained in:
feie9454 2026-01-28 14:40:48 +08:00
parent 73c8acc8f4
commit 2df97a60aa
10 changed files with 743 additions and 0 deletions

61
main.py
View File

@ -13,9 +13,70 @@ import asyncio
from typing import Optional, Callable, List, Dict, Any
from pydantic import BaseModel
from rigol_phase import measure_phase, DEV as RIGOL_DEV
from oled_qr import start_oled_startup_display
from wifi_control import get_current_ssid, wifi_connect, ensure_wifi_or_start_ap, hotspot_down
app = FastAPI()
@app.on_event("startup")
def _startup_oled() -> None:
# Non-fatal: if OLED is missing/not wired, the service still starts.
def _state_provider():
with state_lock:
# keep it small and serialization-friendly
return {
"dis": state.get("dis"),
"phase": state.get("phase"),
"freq": state.get("freq"),
"p2p": state.get("p2p"),
"speed": state.get("speed"),
"tasks": list(state.get("tasks", [])),
}
# Inject provider into oled module without creating import cycles.
setattr(start_oled_startup_display, "state_provider", _state_provider)
start_oled_startup_display()
@app.on_event("startup")
def _startup_wifi() -> None:
# If not connected to any WiFi, bring up an AP for provisioning.
# Non-fatal: any errors should not prevent API server from starting.
try:
ensure_wifi_or_start_ap()
except Exception as e:
print(f"[WiFi] startup failed: {e}")
class WifiConnectRequest(BaseModel):
ssid: str
password: str = ""
ifname: Optional[str] = None
wait: Optional[int] = None
@app.get("/wifi")
def wifi_get():
ssid = None
try:
ssid = get_current_ssid()
except Exception as e:
raise HTTPException(status_code=500, detail=f"get ssid failed: {e}")
return {"ssid": ssid}
@app.post("/wifi")
def wifi_post(req: WifiConnectRequest):
try:
# Stop AP if it was started, then connect as client.
hotspot_down()
wifi_connect(req.ssid, req.password, ifname=req.ifname, wait=req.wait)
ssid = get_current_ssid(ifname=req.ifname)
return {"status": "connected", "ssid": ssid or req.ssid}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
class BatchTaskItem(BaseModel):
cmd: str
args: Dict[str, Any] = {}

View File

Can't render this file because it is too large.

View File

Can't render this file because it is too large.

395
oled_qr.py Normal file
View File

@ -0,0 +1,395 @@
import os
import socket
import subprocess
import threading
import time
from dataclasses import dataclass
from typing import Optional, Callable, Dict, Any
def _truthy(value: Optional[str]) -> bool:
if value is None:
return False
return value.strip().lower() in {"1", "true", "yes", "y", "on"}
def _get_lan_ip() -> Optional[str]:
"""Best-effort LAN IP detection.
Uses a UDP socket trick (no packets actually need to be received) to learn
which local interface would be used to reach the internet.
"""
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
if ip and ip != "0.0.0.0":
return ip
finally:
s.close()
except Exception:
pass
try:
hostname = socket.gethostname()
ip = socket.gethostbyname(hostname)
if ip and ip != "127.0.0.1":
return ip
except Exception:
pass
return None
def build_public_url() -> str:
"""Build a URL that should be reachable from LAN clients."""
explicit = os.getenv("PUBLIC_URL", "").strip()
if explicit:
return explicit
ip = _get_lan_ip() or os.getenv("HOST", "0.0.0.0")
# If host is 0.0.0.0, it's not a client-reachable address.
if ip in {"0.0.0.0", "127.0.0.1"}:
ip = _get_lan_ip() or "raspberrypi.local"
scheme = os.getenv("PUBLIC_SCHEME", "http").strip() or "http"
return f"{scheme}://{ip}"
from luma.core.interface.serial import i2c
from luma.oled.device import ssd1306
def _init_oled_device():
address = int(os.getenv("OLED_I2C_ADDRESS", "0x3C"), 0)
port = int(os.getenv("OLED_I2C_PORT", "1"))
rotate = int(os.getenv("OLED_ROTATE", "0"))
serial = i2c(port=port, address=address)
return ssd1306(serial, width=128, height=64, rotate=rotate)
def _make_qr_64(url: str):
import qrcode
from PIL import Image
# Left: 64x64 QR
qr = qrcode.QRCode(
version=None,
error_correction=qrcode.constants.ERROR_CORRECT_M,
box_size=2,
border=1,
)
qr.add_data(url)
qr.make(fit=True)
# Use explicit black/white to avoid near-black values like 1 that can
# get thresholded to all-black when converted to 1-bit.
qr_img = qr.make_image(fill_color="black", back_color="white").convert("1")
# Fit into 64x64 (keep square)
return qr_img.resize((64, 64), Image.NEAREST)
def _format_state_lines(state: Optional[Dict[str, Any]]) -> list[str]:
if not state:
return ["state: n/a"]
dis_steps = state.get("dis")
phase = state.get("phase")
freq = state.get("freq")
p2p = state.get("p2p")
def _fmt_float(value: Any, *, decimals: int) -> str:
try:
if value is None or isinstance(value, bool):
return "-"
f = float(value)
return f"{f:.{decimals}f}" if decimals > 0 else f"{f:.0f}"
except Exception:
return "-"
# Keep units explicit as requested; no labels; we'll right-align later.
try:
dis_mm = float(dis_steps or 0) / 1600.0
except Exception:
dis_mm = None
try:
freq_khz = (float(freq) / 1000.0) if freq is not None else None
except Exception:
freq_khz = None
lines: list[str] = []
lines.append(f"{_fmt_float(dis_mm, decimals=2)} mm")
lines.append(f"{_fmt_float(freq_khz, decimals=2)} kHz")
lines.append(f"{_fmt_float(phase, decimals=3)} rad")
lines.append(f"{_fmt_float(p2p, decimals=0)} ADC")
return lines
def _load_mono_font():
from PIL import ImageFont
# Prefer a real monospace TTF if available.
candidates = []
env_path = os.getenv("OLED_FONT_PATH", "").strip()
if env_path:
candidates.append(env_path)
candidates.extend(
[
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf",
"/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf",
]
)
size = int(os.getenv("OLED_FONT_SIZE", "10"))
for path in candidates:
try:
return ImageFont.truetype(path, size=size)
except Exception:
continue
# Fallback: PIL default (often bitmap monospace)
return ImageFont.load_default()
def _max_chars_for_width(font, max_px: int) -> int:
try:
# Estimate monospace char width from '0'
bbox = font.getbbox("0")
char_w = max(1, bbox[2] - bbox[0])
return max(1, int(max_px // char_w))
except Exception:
# Conservative fallback
return max(1, int(max_px // 6))
def _get_wifi_ssid() -> str:
"""Best-effort current WiFi SSID.
Tries common tools on Raspberry Pi OS. Never raises.
"""
env = os.getenv("WIFI_SSID", "").strip()
if env:
return env
def _run(cmd: list[str]) -> str:
try:
p = subprocess.run(cmd, capture_output=True, text=True, timeout=0.5)
if p.returncode == 0:
return (p.stdout or "").strip()
except Exception:
return ""
return ""
# Most minimal dependency
ssid = _run(["iwgetid", "-r"])
if ssid:
return ssid
# NetworkManager
out = _run(["nmcli", "-t", "-f", "active,ssid", "dev", "wifi"])
if out:
for line in out.splitlines():
# yes:<ssid>
if line.startswith("yes:"):
return line.split(":", 1)[1].strip()
# wpa_supplicant
out = _run(["wpa_cli", "-i", "wlan0", "status"])
if out:
for line in out.splitlines():
if line.startswith("ssid="):
return line.split("=", 1)[1].strip()
return ""
def _make_wifi_line(ssid: str, max_chars: int, scroll_offset: int) -> str:
"""Build the right-top WiFi line with optional scrolling.
The prefix is fixed; only the SSID part scrolls when it doesn't fit.
"""
prefix = "WiFi:"
if max_chars <= 0:
return ""
ssid = (ssid or "").strip() or "-"
# Keep prefix visible when possible.
# We render as: "WiFi:" + " " + <ssid-part>
join = " "
base = prefix + join
if len(base) >= max_chars:
# Not enough space even for full prefix; truncate prefix.
return (prefix[:max_chars]).ljust(max_chars)
avail = max_chars - len(base)
if len(ssid) <= avail:
return (base + ssid).ljust(max_chars)
pad = os.getenv("OLED_SSID_SCROLL_PAD", " ")
if not pad:
pad = " "
scroll_text = ssid + pad
if not scroll_text:
return (base + "-").ljust(max_chars)
offset = scroll_offset % len(scroll_text)
# Wrap-around slice of length `avail`
doubled = scroll_text + scroll_text
window = doubled[offset : offset + avail]
return (base + window).ljust(max_chars)
def _render_screen(
qr_img_64,
state: Optional[Dict[str, Any]],
*,
wifi_line: Optional[str] = None,
font=None,
max_chars: Optional[int] = None,
):
from PIL import Image, ImageDraw
canvas = Image.new("1", (128, 64), 0)
canvas.paste(qr_img_64, (0, 0))
draw = ImageDraw.Draw(canvas)
if font is None:
font = _load_mono_font()
x = 66
top_y = 0
if max_chars is None:
max_chars = _max_chars_for_width(font, 128 - x)
# Right-top: WiFi SSID
ssid_text = wifi_line if wifi_line is not None else ""
if len(ssid_text) > max_chars:
ssid_text = ssid_text[:max_chars]
draw.text((x, top_y), ssid_text, font=font, fill=255)
# Right-bottom: state lines
lines = _format_state_lines(state)
line_h = 12
y = 64 - (len(lines) * line_h)
# Don't overlap SSID line
if y < line_h:
y = line_h
for line in lines:
text = str(line)
if len(text) > max_chars:
text = text[-max_chars:]
else:
text = text.rjust(max_chars)
draw.text((x, y), text, font=font, fill=255)
y += line_h
return canvas
@dataclass
class OLEDStartupDisplay:
stop_event: threading.Event
thread: threading.Thread
_manager: Optional[OLEDStartupDisplay] = None
def start_oled_startup_display() -> None:
"""Show server URL QR code + live state on a 128x64 I2C OLED.
Safe by design: failures only log, never crash the API server.
"""
global _manager
if _manager is not None:
return
if not _truthy(os.getenv("OLED_ENABLED", "1")):
return
stop_event = threading.Event()
def _run():
# A few retries in case I2C isn't ready instantly.
last_error: Optional[Exception] = None
for _ in range(3):
if stop_event.is_set():
return
try:
device = _init_oled_device()
url = build_public_url()
qr_img_64 = _make_qr_64(url)
# Optional live updates using a state provider.
state_provider: Optional[Callable[[], Dict[str, Any]]] = getattr(
start_oled_startup_display, "state_provider", None
)
refresh_s = float(os.getenv("OLED_REFRESH_S", "0.25"))
if refresh_s <= 0:
refresh_s = 0.25
ssid_refresh_s = float(os.getenv("OLED_SSID_REFRESH_S", "2.0"))
if ssid_refresh_s <= 0:
ssid_refresh_s = 2.0
last_ssid_check = 0.0
current_ssid = ""
ssid_scroll_offset = 0
while not stop_event.is_set():
now = time.time()
if (now - last_ssid_check) >= ssid_refresh_s:
last_ssid_check = now
new_ssid = _get_wifi_ssid()
if new_ssid != current_ssid:
current_ssid = new_ssid
ssid_scroll_offset = 0
st = None
if state_provider is not None:
try:
st = state_provider()
except Exception:
st = None
# Cache font/width for consistent layout and less overhead.
if "oled_font" not in locals():
oled_font = _load_mono_font()
oled_max_chars = _max_chars_for_width(oled_font, 128 - 66)
wifi_line = _make_wifi_line(current_ssid, oled_max_chars, ssid_scroll_offset)
# Scroll only when SSID is actually too long to fit.
if current_ssid and len((current_ssid or "").strip()) > max(0, oled_max_chars - len("WiFi: ")):
ssid_scroll_offset += 1
image = _render_screen(qr_img_64, st, wifi_line=wifi_line, font=oled_font, max_chars=oled_max_chars)
device.display(image)
time.sleep(refresh_s)
return
except Exception as e:
last_error = e
time.sleep(0.5)
if last_error is not None:
print(f"[OLED] init/display failed: {last_error}")
t = threading.Thread(target=_run, daemon=True)
t.start()
_manager = OLEDStartupDisplay(stop_event=stop_event, thread=t)
def stop_oled_startup_display() -> None:
global _manager
if _manager is None:
return
_manager.stop_event.set()
_manager = None

View File

@ -7,13 +7,17 @@ requires-python = ">=3.13"
dependencies = [
"fastapi>=0.128.0",
"gpiozero>=2.0.1",
"luma.oled",
"matplotlib>=3.8.0",
"nmcli",
"numpy>=2.4.0",
"pillow>=10.0.0",
"pigpio>=1.78",
"pyserial>=3.5",
"pyusb>=1.3.1",
"pyvisa>=1.16.0",
"pyvisa-py>=0.8.1",
"qrcode[pil]>=7.4.2",
"uvicorn[standard]>=0.35.0",
]

92
uv.lock generated
View File

@ -32,6 +32,29 @@ wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
[[package]]
name = "cbor2"
version = "5.8.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d9/8e/8b4fdde28e42ffcd741a37f4ffa9fb59cd4fe01625b544dfcfd9ccb54f01/cbor2-5.8.0.tar.gz", hash = "sha256:b19c35fcae9688ac01ef75bad5db27300c2537eb4ee00ed07e05d8456a0d4931", size = 107825, upload-time = "2025-12-30T18:44:22.455Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a6/0d/5a3f20bafaefeb2c1903d961416f051c0950f0d09e7297a3aa6941596b29/cbor2-5.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6d8d104480845e2f28c6165b4c961bbe58d08cb5638f368375cfcae051c28015", size = 70332, upload-time = "2025-12-30T18:43:54.694Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/57/66/177a3f089e69db69c987453ab4934086408c3338551e4984734597be9f80/cbor2-5.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:43efee947e5ab67d406d6e0dc61b5dee9d2f5e89ae176f90677a3741a20ca2e7", size = 285985, upload-time = "2025-12-30T18:43:55.733Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/b7/8e/9e17b8e4ed80a2ce97e2dfa5915c169dbb31599409ddb830f514b57f96cc/cbor2-5.8.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be7ae582f50be539e09c134966d0fd63723fc4789b8dff1f6c2e3f24ae3eaf32", size = 285173, upload-time = "2025-12-30T18:43:57.321Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/cc/33/9f92e107d78f88ac22723ac15d0259d220ba98c1d855e51796317f4c4114/cbor2-5.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50f5c709561a71ea7970b4cd2bf9eda4eccacc0aac212577080fdfe64183e7f5", size = 278395, upload-time = "2025-12-30T18:43:58.497Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/2f/3f/46b80050a4a35ce5cf7903693864a9fdea7213567dc8faa6e25cb375c182/cbor2-5.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a6790ecc73aa93e76d2d9076fc42bf91a9e69f2295e5fa702e776dbe986465bd", size = 278330, upload-time = "2025-12-30T18:43:59.656Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/eb/d2/d41f8c04c783a4d204e364be2d38043d4f732a3bed6f4c732e321cf34c7b/cbor2-5.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:c114af8099fa65a19a514db87ce7a06e942d8fea2730afd49be39f8e16e7f5e0", size = 69841, upload-time = "2025-12-30T18:44:01.159Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/1b/8c/0397a82f6e67665009951453c83058e4c77ba54b9a9017ede56d6870306c/cbor2-5.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:ab3ba00494ad8669a459b12a558448d309c271fa4f89b116ad496ee35db38fea", size = 64982, upload-time = "2025-12-30T18:44:02.138Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/4b/0c/0654233d7543ac8a50f4785f172430ddc97538ba418eb305d6e529d1a120/cbor2-5.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ad72381477133046ce217617d839ea4e9454f8b77d9a6351b229e214102daeb7", size = 70710, upload-time = "2025-12-30T18:44:03.209Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/84/62/4671d24e557d7f5a74a01b422c538925140c0495e57decde7e566f91d029/cbor2-5.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6da25190fad3434ce99876b11d4ca6b8828df6ca232cf7344cd14ae1166fb718", size = 285005, upload-time = "2025-12-30T18:44:05.109Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/87/85/0c67d763a08e848c9a80d7e4723ba497cce676f41bc7ca1828ae90a0a872/cbor2-5.8.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c13919e3a24c5a6d286551fa288848a4cedc3e507c58a722ccd134e461217d99", size = 282435, upload-time = "2025-12-30T18:44:06.465Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/b2/01/0650972b4dbfbebcfbe37cbba7fc3cd9019a8da6397ab3446e07175e342b/cbor2-5.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f8c40d32e5972047a777f9bf730870828f3cf1c43b3eb96fd0429c57a1d3b9e6", size = 277493, upload-time = "2025-12-30T18:44:07.609Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/b3/6c/7704a4f32adc7f10f3b41ec067f500a4458f7606397af5e4cf2d368fd288/cbor2-5.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7627894bc0b3d5d0807f31e3107e11b996205470c4429dc2bb4ef8bfe7f64e1e", size = 276085, upload-time = "2025-12-30T18:44:09.021Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/88/6d/e43452347630efe8133f5304127539100d937c138c0996d27ec63963ec2c/cbor2-5.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:b51c5e59becae746ca4de2bbaa8a2f5c64a68fec05cea62941b1a84a8335f7d1", size = 71657, upload-time = "2025-12-30T18:44:10.162Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/8b/66/9a780ef34ab10a0437666232e885378cdd5f60197b1b5e61a62499e5a10a/cbor2-5.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:53b630f4db4b9f477ad84077283dd17ecf9894738aa17ef4938c369958e02a71", size = 67171, upload-time = "2025-12-30T18:44:11.619Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d6/4f/101071f880b4da05771128c0b89f41e334cff044dee05fb013c8f4be661c/cbor2-5.8.0-py3-none-any.whl", hash = "sha256:3727d80f539567b03a7aa11890e57798c67092c38df9e6c23abb059e0f65069c", size = 24374, upload-time = "2025-12-30T18:44:21.476Z" },
]
[[package]]
name = "click"
version = "8.3.1"
@ -288,6 +311,32 @@ wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/80/be/3578e8afd18c88cdf9cb4cffde75a96d2be38c5a903f1ed0ceec061bd09e/kiwisolver-1.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:4a48a2ce79d65d363597ef7b567ce3d14d68783d2b2263d98db3d9477805ba32", size = 70260, upload-time = "2025-08-10T21:27:36.606Z" },
]
[[package]]
name = "luma-core"
version = "2.5.3"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "cbor2" },
{ name = "pillow" },
{ name = "smbus2" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e6/a3/0abb456daf2279483579bed6cf2a7305f93f56ab89f0f238f206fffce303/luma_core-2.5.3.tar.gz", hash = "sha256:ecfb1c12fc32f8ee6cff0f613804b2609387c17547f739d002649f2e6d56ec2f", size = 105745, upload-time = "2025-12-16T21:56:28.065Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/59/de/eb014859db3b59eaa35b157451121fbd8cffb96da8f4f52b4fa223fe0bc7/luma_core-2.5.3-py3-none-any.whl", hash = "sha256:ad466acb7bc805ad87cf1ed591d1d0588c3fa9900cba338d4eebf02a4226b95c", size = 72744, upload-time = "2025-12-16T21:56:26.277Z" },
]
[[package]]
name = "luma-oled"
version = "3.14.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "luma-core" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/95/a7/c5a51d4980a3a4db3b63570731eeac61f2e1142d5462f7e2866ed9d87070/luma_oled-3.14.0.tar.gz", hash = "sha256:36218565eda0614c8cf44ef42cb9a5904ddf808e4516e99ddae111fc93c5a206", size = 16429131, upload-time = "2024-11-02T23:20:47.97Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/08/a4/8bbfef1670dc46105e7c93508b99356e8214f9e75d0ddcf24159b654c61d/luma.oled-3.14.0-py2.py3-none-any.whl", hash = "sha256:c1a2063242e1732889be9e3508440de728bf5a5dbef8005b64248b8fd1c145fb", size = 32791, upload-time = "2024-11-02T23:20:45.874Z" },
]
[[package]]
name = "matplotlib"
version = "3.10.8"
@ -342,13 +391,17 @@ source = { virtual = "." }
dependencies = [
{ name = "fastapi" },
{ name = "gpiozero" },
{ name = "luma-oled" },
{ name = "matplotlib" },
{ name = "nmcli" },
{ name = "numpy" },
{ name = "pigpio" },
{ name = "pillow" },
{ name = "pyserial" },
{ name = "pyusb" },
{ name = "pyvisa" },
{ name = "pyvisa-py" },
{ name = "qrcode", extra = ["pil"] },
{ name = "uvicorn", extra = ["standard"] },
]
@ -356,16 +409,29 @@ dependencies = [
requires-dist = [
{ name = "fastapi", specifier = ">=0.128.0" },
{ name = "gpiozero", specifier = ">=2.0.1" },
{ name = "luma-oled" },
{ name = "matplotlib", specifier = ">=3.8.0" },
{ name = "nmcli" },
{ name = "numpy", specifier = ">=2.4.0" },
{ name = "pigpio", specifier = ">=1.78" },
{ name = "pillow", specifier = ">=10.0.0" },
{ name = "pyserial", specifier = ">=3.5" },
{ name = "pyusb", specifier = ">=1.3.1" },
{ name = "pyvisa", specifier = ">=1.16.0" },
{ name = "pyvisa-py", specifier = ">=0.8.1" },
{ name = "qrcode", extras = ["pil"], specifier = ">=7.4.2" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.35.0" },
]
[[package]]
name = "nmcli"
version = "1.7.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1f/fd/01e2cc2d84cf52653e59a2ce4fe0ebb79b388523be753befe843064394c5/nmcli-1.7.0.tar.gz", hash = "sha256:4fb17b6c33d276a264a27b7109fa1d70987570536fa8852b51830f9f7732f982", size = 21209, upload-time = "2026-01-03T02:15:20.137Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/8a/b7/648f9f6989c6c2860ebb403c762137578ca412fe9932b85ab33dc21517fa/nmcli-1.7.0-py3-none-any.whl", hash = "sha256:27144f03600d5e53c09cfa500cd7160dea61f83c3669ccd736401793b65ce980", size = 19905, upload-time = "2026-01-03T02:15:18.799Z" },
]
[[package]]
name = "numpy"
version = "2.4.1"
@ -669,6 +735,23 @@ wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "qrcode"
version = "8.2"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" },
]
[package.optional-dependencies]
pil = [
{ name = "pillow" },
]
[[package]]
name = "setuptools"
version = "80.9.0"
@ -687,6 +770,15 @@ wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "smbus2"
version = "0.6.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4e/36/afafd43770caae69f04e21402552a8f94a072def46a002fab9357f4852ce/smbus2-0.6.0.tar.gz", hash = "sha256:9b5ff1e998e114730f9dfe0c4babbef06c92468cfb61eaa684e30f225661b95b", size = 17403, upload-time = "2025-12-20T09:02:52.017Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a5/cf/2e1d6805da6f9c9b3a4358076ff2e072d828ba7fed124edc1b729e210c55/smbus2-0.6.0-py2.py3-none-any.whl", hash = "sha256:03d83d2a9a4afc5ddca0698ccabf101cb3de52bc5aefd7b76778ffb27ff654e0", size = 11849, upload-time = "2025-12-20T09:02:51.219Z" },
]
[[package]]
name = "starlette"
version = "0.50.0"

191
wifi_control.py Normal file
View File

@ -0,0 +1,191 @@
import os
from typing import Any, Optional
def _truthy(value: Optional[str]) -> bool:
if value is None:
return False
return value.strip().lower() in {"1", "true", "yes", "y", "on"}
def _nmcli():
import nmcli # type: ignore
# Ensure stable English-ish outputs internally.
try:
nmcli.set_lang("C.UTF-8")
except Exception:
pass
# The python wrapper uses sudo by default. If we run as root, disable sudo.
try:
if hasattr(os, "geteuid") and os.geteuid() == 0:
nmcli.disable_use_sudo()
except Exception:
pass
if _truthy(os.getenv("NMCLI_DISABLE_SUDO")):
try:
nmcli.disable_use_sudo()
except Exception:
pass
return nmcli
def get_wifi_ifname() -> str:
return os.getenv("WIFI_IFNAME", "wlan0").strip() or "wlan0"
def get_current_ssid(*, ifname: Optional[str] = None) -> Optional[str]:
"""Return current connected WiFi SSID if any, else None."""
nmcli = _nmcli()
ifname = ifname or get_wifi_ifname()
# Preferred: find "in_use" AP from scan list (no rescan by default).
try:
aps = nmcli.device.wifi(ifname=ifname, rescan=False)
for ap in aps or []:
in_use = getattr(ap, "in_use", None)
ssid = getattr(ap, "ssid", None)
if in_use in (True, "*", "yes") and ssid:
return str(ssid)
except Exception:
pass
# Fallback: device status provides active connection name (often equals SSID).
try:
devices = nmcli.device.status()
for d in devices or []:
dtype = (getattr(d, "type", "") or "").lower()
if "wifi" not in dtype and "wireless" not in dtype:
continue
if (getattr(d, "device", None) or getattr(d, "ifname", None) or "") != ifname:
continue
state = (getattr(d, "state", "") or "").lower()
conn = getattr(d, "connection", None) or getattr(d, "con", None)
if "connected" in state and conn and conn not in {"--", "-"}:
return str(conn)
except Exception:
pass
return None
def wifi_connect(
ssid: str,
password: str = "",
*,
ifname: Optional[str] = None,
wait: Optional[int] = None,
) -> None:
"""Connect to the given SSID using NetworkManager (nmcli wrapper)."""
nmcli = _nmcli()
ifname = ifname or get_wifi_ifname()
ssid = (ssid or "").strip()
if not ssid:
raise ValueError("ssid is required")
if wait is None:
wait = int(os.getenv("WIFI_CONNECT_WAIT", "30"))
# Ensure Wi-Fi radio is on.
try:
nmcli.radio.wifi_on()
except Exception:
pass
nmcli.device.wifi_connect(ssid, password or "", ifname=ifname, wait=wait)
def _hotspot_config() -> dict[str, Any]:
return {
"ifname": get_wifi_ifname(),
"con_name": os.getenv("AP_CON_NAME", "motor-scan-ap").strip() or "motor-scan-ap",
"ssid": os.getenv("AP_SSID", "motor-scan").strip() or "motor-scan",
"password": os.getenv("AP_PASSWORD", "motor-scan-1234").strip() or "motor-scan-1234",
"band": os.getenv("AP_BAND", "bg").strip() or None,
"channel": int(os.getenv("AP_CHANNEL", "6")),
}
def hotspot_up() -> dict[str, Any]:
"""Start an AP (hotspot) if not connected to WiFi."""
nmcli = _nmcli()
cfg = _hotspot_config()
# NetworkManager requires WPA2 password >= 8 chars if provided.
if cfg["password"] and len(cfg["password"]) < 8:
raise ValueError("AP_PASSWORD must be at least 8 characters")
try:
nmcli.radio.wifi_on()
except Exception:
pass
hs = nmcli.device.wifi_hotspot(
ifname=cfg["ifname"],
con_name=cfg["con_name"],
ssid=cfg["ssid"],
band=cfg["band"],
channel=cfg["channel"],
password=cfg["password"],
)
return {
"mode": "ap",
"ifname": cfg["ifname"],
"con_name": cfg["con_name"],
"ssid": cfg["ssid"],
"channel": cfg["channel"],
"band": cfg["band"],
"hotspot": getattr(hs, "__dict__", None) or str(hs),
}
def hotspot_down() -> None:
nmcli = _nmcli()
cfg = _hotspot_config()
try:
nmcli.connection.down(cfg["con_name"], wait=10)
except Exception:
pass
if _truthy(os.getenv("AP_DELETE_ON_CONNECT", "0")):
try:
nmcli.connection.delete(cfg["con_name"], wait=10)
except Exception:
pass
def get_wifi_mode() -> str:
"""Return 'client', 'ap' or 'none' (best-effort)."""
ssid = get_current_ssid()
if ssid:
return "client"
nmcli = _nmcli()
cfg = _hotspot_config()
try:
active = nmcli.connection.show_all(active=True)
for c in active or []:
name = getattr(c, "name", None) or getattr(c, "connection", None)
if name == cfg["con_name"]:
return "ap"
except Exception:
pass
return "none"
def ensure_wifi_or_start_ap() -> dict[str, Any]:
"""If no WiFi connection, start AP; return the resulting mode and details."""
if not _truthy(os.getenv("WIFI_MANAGE_ENABLED", "1")):
return {"mode": "disabled"}
ssid = get_current_ssid()
if ssid:
return {"mode": "client", "ssid": ssid}
return hotspot_up()