3D antenna radiation pattern visualization: analytical models + Three.js web UI

Add analytical radiation pattern models for 5 antenna types (dipole, monopole,
EFHW, loop, patch) driven by S11 impedance measurements. Pure Python math with
closed-form far-field equations — no numpy or simulation dependencies.

New MCP tools:
- radiation_pattern: scan S11 → find resonance → compute 3D pattern
- radiation_pattern_from_data: compute from known impedance (no hardware)
- radiation_pattern_multi: patterns across a frequency band for animation

Web UI (opt-in via MCNANOVNA_WEB_PORT env var):
- Three.js gain-mapped sphere with OrbitControls
- Surface/wireframe/plane cut display modes with teal→amber color ramp
- Smith chart overlay, dBi reference rings, E/H plane cross-sections
- Real-time WebSocket push on new pattern computation
- FastAPI backend shares process with MCP server, zero new core deps

Frontend: Vite + TypeScript + Three.js, built assets committed to webui/static/.
Optional dependencies: fastapi + uvicorn via pip install mcnanovna[webui].
This commit is contained in:
Ryan Malloy 2026-01-31 15:27:19 -07:00
parent e0fe09f3b8
commit 646c92324d
28 changed files with 8049 additions and 6 deletions

24
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

17
frontend/index.html Normal file
View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>mcnanovna -- Radiation Pattern</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
</head>
<body>
<div id="app">
<div id="controls"></div>
<div id="scene-container"></div>
<canvas id="smith-chart" width="200" height="200"></canvas>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1194
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
frontend/package.json Normal file
View File

@ -0,0 +1,19 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"devDependencies": {
"@types/three": "^0.182.0",
"typescript": "~5.9.3",
"vite": "^7.2.4"
},
"dependencies": {
"three": "^0.182.0"
}
}

95
frontend/src/api.ts Normal file
View File

@ -0,0 +1,95 @@
import type { PatternData, ComputeParams, ScanParams } from './types';
const BASE_URL = '';
export async function fetchPattern(params: ComputeParams): Promise<PatternData> {
const resp = await fetch(`${BASE_URL}/api/pattern/compute`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
});
if (!resp.ok) {
const body = await resp.text();
throw new Error(`Compute failed (${resp.status}): ${body}`);
}
return resp.json();
}
export async function scanPattern(params: ScanParams): Promise<PatternData> {
const resp = await fetch(`${BASE_URL}/api/pattern`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
});
if (!resp.ok) {
const body = await resp.text();
throw new Error(`Scan failed (${resp.status}): ${body}`);
}
return resp.json();
}
export async function getStatus(): Promise<{ connected: boolean; device?: string }> {
const resp = await fetch(`${BASE_URL}/api/status`);
if (!resp.ok) throw new Error(`Status check failed (${resp.status})`);
return resp.json();
}
export async function getBands(): Promise<Record<string, { start_hz: number; stop_hz: number }>> {
const resp = await fetch(`${BASE_URL}/api/bands`);
if (!resp.ok) throw new Error(`Bands fetch failed (${resp.status})`);
return resp.json();
}
export type PatternCallback = (data: PatternData) => void;
export type StatusCallback = (connected: boolean) => void;
export function connectWebSocket(
onPattern: PatternCallback,
onStatus: StatusCallback
): { close: () => void } {
let ws: WebSocket | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let closed = false;
function connect() {
if (closed) return;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${protocol}//${window.location.host}/ws/pattern`;
ws = new WebSocket(url);
ws.onopen = () => {
onStatus(true);
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as PatternData;
onPattern(data);
} catch {
// non-pattern message, ignore
}
};
ws.onclose = () => {
onStatus(false);
if (!closed) {
reconnectTimer = setTimeout(connect, 3000);
}
};
ws.onerror = () => {
ws?.close();
};
}
connect();
return {
close() {
closed = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
ws?.close();
},
};
}

232
frontend/src/controls.ts Normal file
View File

@ -0,0 +1,232 @@
import type { DisplayMode, AppState } from './types';
import { iconRadio, iconActivity, iconCrosshair, iconWifi, iconWifiOff } from './icons';
export interface ControlCallbacks {
onCompute: () => void;
onScan: () => void;
onDisplayModeChange: (mode: DisplayMode) => void;
onAntennaTypeChange: (type: string) => void;
onFrequencyChange: (mhz: number) => void;
onImpedanceRealChange: (r: number) => void;
onImpedanceImagChange: (x: number) => void;
}
const ANTENNA_TYPES = [
{ value: 'dipole', label: 'Dipole' },
{ value: 'monopole', label: 'Monopole' },
{ value: 'efhw', label: 'EFHW' },
{ value: 'loop', label: 'Loop' },
{ value: 'patch', label: 'Patch' },
];
const DISPLAY_MODES: { value: DisplayMode; label: string }[] = [
{ value: 'surface', label: 'Surface' },
{ value: 'wireframe', label: 'Wireframe' },
{ value: 'e-plane', label: 'E-Plane' },
{ value: 'h-plane', label: 'H-Plane' },
];
export function createControls(
container: HTMLElement,
callbacks: ControlCallbacks,
initialState: AppState
): {
updateStatus: (connected: boolean) => void;
updatePeakGain: (dbi: number | null) => void;
updateLoading: (loading: boolean) => void;
} {
container.innerHTML = '';
container.className = 'controls-panel';
// Header
const header = el('div', 'controls-header');
header.innerHTML = `
<div class="controls-logo">
${iconRadio}
<span>mcnanovna</span>
</div>
<div class="controls-subtitle">Radiation Pattern</div>
`;
container.appendChild(header);
// Status
const statusEl = el('div', 'controls-status');
statusEl.innerHTML = statusHtml(initialState.connected);
container.appendChild(statusEl);
// Separator
container.appendChild(el('hr', 'controls-sep'));
// Section: Antenna
container.appendChild(sectionTitle('Antenna Configuration', iconSettings()));
// Antenna type
const antennaGroup = fieldGroup('Type');
const antennaSelect = document.createElement('select');
antennaSelect.className = 'ctrl-select';
ANTENNA_TYPES.forEach((t) => {
const opt = document.createElement('option');
opt.value = t.value;
opt.textContent = t.label;
if (t.value === initialState.antennaType) opt.selected = true;
antennaSelect.appendChild(opt);
});
antennaSelect.addEventListener('change', () => callbacks.onAntennaTypeChange(antennaSelect.value));
antennaGroup.appendChild(antennaSelect);
container.appendChild(antennaGroup);
// Frequency
const freqGroup = fieldGroup('Frequency (MHz)');
const freqInput = document.createElement('input');
freqInput.type = 'number';
freqInput.className = 'ctrl-input';
freqInput.value = String(initialState.frequencyMhz);
freqInput.step = '0.1';
freqInput.min = '0.1';
freqInput.max = '3000';
freqInput.addEventListener('change', () => {
const v = parseFloat(freqInput.value);
if (!isNaN(v) && v > 0) callbacks.onFrequencyChange(v);
});
freqGroup.appendChild(freqInput);
container.appendChild(freqGroup);
// Impedance row
const impedRow = el('div', 'controls-row');
const rGroup = fieldGroup('R (\u03A9)');
const rInput = document.createElement('input');
rInput.type = 'number';
rInput.className = 'ctrl-input';
rInput.value = String(initialState.impedanceReal);
rInput.step = '1';
rInput.addEventListener('change', () => {
const v = parseFloat(rInput.value);
if (!isNaN(v)) callbacks.onImpedanceRealChange(v);
});
rGroup.appendChild(rInput);
impedRow.appendChild(rGroup);
const xGroup = fieldGroup('X (\u03A9)');
const xInput = document.createElement('input');
xInput.type = 'number';
xInput.className = 'ctrl-input';
xInput.value = String(initialState.impedanceImag);
xInput.step = '1';
xInput.addEventListener('change', () => {
const v = parseFloat(xInput.value);
if (!isNaN(v)) callbacks.onImpedanceImagChange(v);
});
xGroup.appendChild(xInput);
impedRow.appendChild(xGroup);
container.appendChild(impedRow);
// Buttons
container.appendChild(el('div', 'controls-spacer'));
const btnCompute = document.createElement('button');
btnCompute.className = 'ctrl-btn ctrl-btn-primary';
btnCompute.innerHTML = `${iconCrosshair} Compute`;
btnCompute.addEventListener('click', callbacks.onCompute);
container.appendChild(btnCompute);
const btnScan = document.createElement('button');
btnScan.className = 'ctrl-btn ctrl-btn-secondary';
btnScan.innerHTML = `${iconActivity} Scan & Visualize`;
btnScan.addEventListener('click', callbacks.onScan);
container.appendChild(btnScan);
// Separator
container.appendChild(el('hr', 'controls-sep'));
// Display mode
container.appendChild(sectionTitle('Display', iconDisplay()));
const modeGroup = el('div', 'controls-mode-group');
DISPLAY_MODES.forEach((m) => {
const btn = document.createElement('button');
btn.className = 'ctrl-mode-btn' + (m.value === initialState.displayMode ? ' active' : '');
btn.textContent = m.label;
btn.dataset.mode = m.value;
btn.addEventListener('click', () => {
modeGroup.querySelectorAll('.ctrl-mode-btn').forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
callbacks.onDisplayModeChange(m.value);
});
modeGroup.appendChild(btn);
});
container.appendChild(modeGroup);
// Peak gain readout
container.appendChild(el('hr', 'controls-sep'));
const peakGainEl = el('div', 'controls-readout');
peakGainEl.innerHTML = peakGainHtml(null);
container.appendChild(peakGainEl);
// Loading overlay
const loadingEl = el('div', 'controls-loading');
loadingEl.textContent = '';
loadingEl.style.display = 'none';
container.appendChild(loadingEl);
return {
updateStatus(connected: boolean) {
statusEl.innerHTML = statusHtml(connected);
},
updatePeakGain(dbi: number | null) {
peakGainEl.innerHTML = peakGainHtml(dbi);
},
updateLoading(loading: boolean) {
loadingEl.style.display = loading ? 'block' : 'none';
loadingEl.textContent = loading ? 'Computing...' : '';
btnCompute.disabled = loading;
btnScan.disabled = loading;
},
};
}
// -- Helpers --
function el(tag: string, className: string): HTMLElement {
const e = document.createElement(tag);
e.className = className;
return e;
}
function fieldGroup(label: string): HTMLElement {
const g = el('div', 'ctrl-field');
const lbl = document.createElement('label');
lbl.className = 'ctrl-label';
lbl.textContent = label;
g.appendChild(lbl);
return g;
}
function sectionTitle(text: string, icon: string): HTMLElement {
const s = el('div', 'controls-section-title');
s.innerHTML = `${icon} ${text}`;
return s;
}
function statusHtml(connected: boolean): string {
const icon = connected ? iconWifi : iconWifiOff;
const cls = connected ? 'status-on' : 'status-off';
const label = connected ? 'Connected' : 'Disconnected';
return `<span class="status-dot ${cls}"></span>${icon}<span>${label}</span>`;
}
function peakGainHtml(dbi: number | null): string {
if (dbi === null) {
return `<span class="readout-label">Peak Gain</span><span class="readout-value">--</span>`;
}
return `<span class="readout-label">Peak Gain</span><span class="readout-value">${dbi.toFixed(1)} dBi</span>`;
}
function iconSettings(): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>`;
}
function iconDisplay(): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>`;
}

22
frontend/src/icons.ts Normal file
View File

@ -0,0 +1,22 @@
// Inline Lucide icon SVGs — no package dependency needed.
// Each returns an SVG string (24x24 viewBox, stroke-based).
const ATTRS = 'xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"';
/** Radio / antenna icon */
export const iconRadio = `<svg ${ATTRS}><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>`;
/** Activity / waveform icon */
export const iconActivity = `<svg ${ATTRS}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>`;
/** Settings / gear icon */
export const iconSettings = `<svg ${ATTRS}><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`;
/** Wifi icon (status connected) */
export const iconWifi = `<svg ${ATTRS}><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg>`;
/** WifiOff icon (status disconnected) */
export const iconWifiOff = `<svg ${ATTRS}><line x1="1" y1="1" x2="23" y2="23"/><path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55"/><path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39"/><path d="M10.71 5.05A16 16 0 0 1 22.56 9"/><path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg>`;
/** Crosshair / target icon */
export const iconCrosshair = `<svg ${ATTRS}><circle cx="12" cy="12" r="10"/><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><line x1="12" y1="6" x2="12" y2="2"/><line x1="12" y1="22" x2="12" y2="18"/></svg>`;

151
frontend/src/main.ts Normal file
View File

@ -0,0 +1,151 @@
import { createScene, type SceneContext } from './scene';
import { updatePattern } from './pattern';
import { createControls } from './controls';
import { drawSmithChart } from './smith';
import { fetchPattern, scanPattern, connectWebSocket } from './api';
import type { AppState, DisplayMode, PatternData } from './types';
import './style.css';
const state: AppState = {
connected: false,
loading: false,
pattern: null,
displayMode: 'surface',
antennaType: 'dipole',
frequencyMhz: 146.0,
impedanceReal: 50,
impedanceImag: 0,
};
let sceneCtx: SceneContext;
let controlsUi: ReturnType<typeof createControls>;
function render() {
if (state.pattern && sceneCtx) {
updatePattern(sceneCtx, state.pattern, state.displayMode);
controlsUi.updatePeakGain(state.pattern.peak_gain_dbi);
// Update Smith chart
const smithCanvas = document.getElementById('smith-chart') as HTMLCanvasElement | null;
if (smithCanvas) {
drawSmithChart(smithCanvas, state.pattern.resonance);
}
}
}
function handlePatternData(data: PatternData) {
state.pattern = data;
state.loading = false;
controlsUi.updateLoading(false);
render();
}
async function handleCompute() {
state.loading = true;
controlsUi.updateLoading(true);
try {
const data = await fetchPattern({
antenna_type: state.antennaType,
frequency_hz: state.frequencyMhz * 1e6,
impedance_real: state.impedanceReal,
impedance_imag: state.impedanceImag,
});
handlePatternData(data);
} catch (err) {
console.error('Compute error:', err);
state.loading = false;
controlsUi.updateLoading(false);
}
}
async function handleScan() {
state.loading = true;
controlsUi.updateLoading(true);
const centerHz = state.frequencyMhz * 1e6;
const span = centerHz * 0.1; // 10% span
try {
const data = await scanPattern({
antenna_type: state.antennaType,
start_hz: centerHz - span / 2,
stop_hz: centerHz + span / 2,
points: 101,
});
handlePatternData(data);
} catch (err) {
console.error('Scan error:', err);
state.loading = false;
controlsUi.updateLoading(false);
}
}
function init() {
const sceneContainer = document.getElementById('scene-container');
const controlsContainer = document.getElementById('controls');
const smithCanvas = document.getElementById('smith-chart') as HTMLCanvasElement | null;
if (!sceneContainer || !controlsContainer) {
console.error('Missing DOM containers');
return;
}
// Three.js scene
sceneCtx = createScene(sceneContainer);
// Controls panel
controlsUi = createControls(controlsContainer, {
onCompute: handleCompute,
onScan: handleScan,
onDisplayModeChange(mode: DisplayMode) {
state.displayMode = mode;
render();
},
onAntennaTypeChange(type: string) {
state.antennaType = type;
},
onFrequencyChange(mhz: number) {
state.frequencyMhz = mhz;
},
onImpedanceRealChange(r: number) {
state.impedanceReal = r;
},
onImpedanceImagChange(x: number) {
state.impedanceImag = x;
},
}, state);
// Initial Smith chart
if (smithCanvas) {
drawSmithChart(smithCanvas);
}
// WebSocket for live updates
connectWebSocket(handlePatternData, (connected) => {
state.connected = connected;
controlsUi.updateStatus(connected);
});
// Show loading message initially
showLoadingMessage(sceneContainer);
}
function showLoadingMessage(container: HTMLElement) {
const msg = document.createElement('div');
msg.className = 'scene-loading';
msg.innerHTML = `
<div class="scene-loading-content">
<div class="scene-loading-spinner"></div>
<p>Press <strong>Compute</strong> to generate a pattern,<br/>or <strong>Scan & Visualize</strong> with a connected VNA.</p>
</div>
`;
container.appendChild(msg);
// Remove after first pattern
const check = setInterval(() => {
if (state.pattern) {
msg.remove();
clearInterval(check);
}
}, 200);
}
document.addEventListener('DOMContentLoaded', init);

231
frontend/src/pattern.ts Normal file
View File

@ -0,0 +1,231 @@
import * as THREE from 'three';
import type { SceneContext } from './scene';
import type { PatternData, DisplayMode, PlanePoint } from './types';
const TEAL_400 = new THREE.Color(0x2dd4bf);
const AMBER_400 = new THREE.Color(0xfbbf24);
// HSL interpolation from teal-400 to amber-400
function gainColor(t: number): THREE.Color {
const hslA = { h: 0, s: 0, l: 0 };
const hslB = { h: 0, s: 0, l: 0 };
TEAL_400.getHSL(hslA);
AMBER_400.getHSL(hslB);
const clamped = Math.max(0, Math.min(1, t));
// Interpolate in HSL — handle hue wrap
let dh = hslB.h - hslA.h;
if (dh > 0.5) dh -= 1;
if (dh < -0.5) dh += 1;
const h = hslA.h + dh * clamped;
const s = hslA.s + (hslB.s - hslA.s) * clamped;
const l = hslA.l + (hslB.l - hslA.l) * clamped;
return new THREE.Color().setHSL(((h % 1) + 1) % 1, s, l);
}
function normalizeGain(dbi: number, minDbi: number, maxDbi: number): number {
if (maxDbi === minDbi) return 0.55;
const t = (dbi - minDbi) / (maxDbi - minDbi);
return 0.1 + Math.max(0, Math.min(1, t)) * 0.9; // maps to [0.1, 1.0]
}
function deg2rad(deg: number): number {
return (deg * Math.PI) / 180;
}
/** Build 3D sphere mesh from theta/phi/gain data. */
function buildPatternGeometry(
data: PatternData,
baseRadius: number
): { geometry: THREE.BufferGeometry; minGain: number; maxGain: number } {
const { theta_deg, phi_deg, gain_dbi } = data;
const nTheta = theta_deg.length;
const nPhi = phi_deg.length;
// Flatten gain to find range
let minGain = Infinity;
let maxGain = -Infinity;
for (let ti = 0; ti < nTheta; ti++) {
for (let pi = 0; pi < nPhi; pi++) {
const g = gain_dbi[ti][pi];
if (g < minGain) minGain = g;
if (g > maxGain) maxGain = g;
}
}
// Build vertices and colors
const positions: number[] = [];
const colors: number[] = [];
const indices: number[] = [];
// Vertex grid: nTheta rows x nPhi columns
for (let ti = 0; ti < nTheta; ti++) {
const theta = deg2rad(theta_deg[ti]);
for (let pi = 0; pi < nPhi; pi++) {
const phi = deg2rad(phi_deg[pi]);
const g = gain_dbi[ti][pi];
const normG = normalizeGain(g, minGain, maxGain);
const r = baseRadius * normG;
// Spherical to Cartesian (physics convention: theta=polar, phi=azimuthal)
const x = r * Math.sin(theta) * Math.cos(phi);
const y = r * Math.cos(theta);
const z = r * Math.sin(theta) * Math.sin(phi);
positions.push(x, y, z);
const color = gainColor(normG);
colors.push(color.r, color.g, color.b);
}
}
// Triangle indices (quads split into 2 triangles)
for (let ti = 0; ti < nTheta - 1; ti++) {
for (let pi = 0; pi < nPhi - 1; pi++) {
const a = ti * nPhi + pi;
const b = ti * nPhi + (pi + 1);
const c = (ti + 1) * nPhi + pi;
const d = (ti + 1) * nPhi + (pi + 1);
indices.push(a, b, d);
indices.push(a, d, c);
}
// Wrap phi: connect last column to first
const a = ti * nPhi + (nPhi - 1);
const b = ti * nPhi;
const c = (ti + 1) * nPhi + (nPhi - 1);
const d = (ti + 1) * nPhi;
indices.push(a, b, d);
indices.push(a, d, c);
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
geometry.setIndex(indices);
geometry.computeVertexNormals();
return { geometry, minGain, maxGain };
}
/** Build a plane-cut line from PlanePoint[] data. */
function buildPlaneLine(
points: PlanePoint[],
isEPlane: boolean,
minGain: number,
maxGain: number,
baseRadius: number
): THREE.Line {
const linePoints: THREE.Vector3[] = [];
const lineColors: number[] = [];
points.forEach((pt) => {
const angleDeg = isEPlane ? (pt.theta_deg ?? 0) : (pt.phi_deg ?? 0);
const angle = deg2rad(angleDeg);
const normG = normalizeGain(pt.gain_dbi, minGain, maxGain);
const r = baseRadius * normG;
let x: number, y: number, z: number;
if (isEPlane) {
// E-plane: varies theta at phi=0, shown in XY plane
x = r * Math.sin(angle);
y = r * Math.cos(angle);
z = 0;
} else {
// H-plane: varies phi at theta=90, shown in XZ plane
x = r * Math.cos(angle);
y = 0;
z = r * Math.sin(angle);
}
linePoints.push(new THREE.Vector3(x, y, z));
const color = gainColor(normG);
lineColors.push(color.r, color.g, color.b);
});
// Close the loop
if (linePoints.length > 0) {
linePoints.push(linePoints[0].clone());
lineColors.push(lineColors[0], lineColors[1], lineColors[2]);
}
const geo = new THREE.BufferGeometry().setFromPoints(linePoints);
geo.setAttribute('color', new THREE.Float32BufferAttribute(lineColors, 3));
const mat = new THREE.LineBasicMaterial({
vertexColors: true,
linewidth: 2,
});
return new THREE.Line(geo, mat);
}
export function updatePattern(ctx: SceneContext, data: PatternData, mode: DisplayMode): void {
// Clear previous
while (ctx.patternGroup.children.length > 0) {
const child = ctx.patternGroup.children[0];
ctx.patternGroup.remove(child);
if (child instanceof THREE.Mesh || child instanceof THREE.Line) {
child.geometry.dispose();
if (Array.isArray(child.material)) {
child.material.forEach((m) => m.dispose());
} else {
child.material.dispose();
}
}
}
const baseRadius = 2.0;
const { geometry, minGain, maxGain } = buildPatternGeometry(data, baseRadius);
if (mode === 'surface') {
const material = new THREE.MeshPhongMaterial({
vertexColors: true,
side: THREE.DoubleSide,
shininess: 40,
transparent: true,
opacity: 0.85,
});
const mesh = new THREE.Mesh(geometry, material);
ctx.patternGroup.add(mesh);
} else if (mode === 'wireframe') {
const material = new THREE.MeshPhongMaterial({
vertexColors: true,
wireframe: true,
side: THREE.DoubleSide,
});
const mesh = new THREE.Mesh(geometry, material);
ctx.patternGroup.add(mesh);
} else if (mode === 'e-plane') {
if (data.e_plane && data.e_plane.length > 0) {
const line = buildPlaneLine(data.e_plane, true, minGain, maxGain, baseRadius);
ctx.patternGroup.add(line);
}
// Also show a dim surface for context
const material = new THREE.MeshPhongMaterial({
vertexColors: true,
side: THREE.DoubleSide,
transparent: true,
opacity: 0.15,
});
const mesh = new THREE.Mesh(geometry, material);
ctx.patternGroup.add(mesh);
} else if (mode === 'h-plane') {
if (data.h_plane && data.h_plane.length > 0) {
const line = buildPlaneLine(data.h_plane, false, minGain, maxGain, baseRadius);
ctx.patternGroup.add(line);
}
const material = new THREE.MeshPhongMaterial({
vertexColors: true,
side: THREE.DoubleSide,
transparent: true,
opacity: 0.15,
});
const mesh = new THREE.Mesh(geometry, material);
ctx.patternGroup.add(mesh);
}
}

176
frontend/src/scene.ts Normal file
View File

@ -0,0 +1,176 @@
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js';
const BG_COLOR = 0x0f172a;
const GRID_COLOR = 0x334155; // slate-700
const RING_COLOR = 0x475569; // slate-600
const LABEL_COLOR = '#94a3b8'; // slate-400
export interface SceneContext {
scene: THREE.Scene;
camera: THREE.PerspectiveCamera;
renderer: THREE.WebGLRenderer;
labelRenderer: CSS2DRenderer;
controls: OrbitControls;
patternGroup: THREE.Group;
}
export function createScene(container: HTMLElement): SceneContext {
const scene = new THREE.Scene();
scene.background = new THREE.Color(BG_COLOR);
// Camera
const aspect = container.clientWidth / container.clientHeight;
const camera = new THREE.PerspectiveCamera(55, aspect, 0.1, 100);
camera.position.set(3, 2.5, 3);
camera.lookAt(0, 0, 0);
// WebGL renderer
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(container.clientWidth, container.clientHeight);
container.appendChild(renderer.domElement);
// CSS2D renderer for labels
const labelRenderer = new CSS2DRenderer();
labelRenderer.setSize(container.clientWidth, container.clientHeight);
labelRenderer.domElement.style.position = 'absolute';
labelRenderer.domElement.style.top = '0';
labelRenderer.domElement.style.left = '0';
labelRenderer.domElement.style.pointerEvents = 'none';
container.appendChild(labelRenderer.domElement);
// Orbit controls
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.minDistance = 1.5;
controls.maxDistance = 12;
// Lighting
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
scene.add(ambientLight);
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
dirLight.position.set(5, 8, 5);
scene.add(dirLight);
const dirLight2 = new THREE.DirectionalLight(0xffffff, 0.3);
dirLight2.position.set(-3, -2, -4);
scene.add(dirLight2);
// Grid on XZ plane
const gridHelper = new THREE.GridHelper(6, 12, GRID_COLOR, GRID_COLOR);
(gridHelper.material as THREE.Material).opacity = 0.3;
(gridHelper.material as THREE.Material).transparent = true;
scene.add(gridHelper);
// Axis lines
addAxisLines(scene);
// Axis labels
addAxisLabel(scene, 'X', new THREE.Vector3(3.3, 0, 0));
addAxisLabel(scene, 'Y', new THREE.Vector3(0, 3.3, 0));
addAxisLabel(scene, 'Z', new THREE.Vector3(0, 0, 3.3));
// Reference dBi rings on XZ plane
addReferenceRings(scene);
// Group for pattern meshes
const patternGroup = new THREE.Group();
scene.add(patternGroup);
// Resize handler
const onResize = () => {
const w = container.clientWidth;
const h = container.clientHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
labelRenderer.setSize(w, h);
};
window.addEventListener('resize', onResize);
// Animation loop
const animate = () => {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
labelRenderer.render(scene, camera);
};
animate();
return { scene, camera, renderer, labelRenderer, controls, patternGroup };
}
function addAxisLines(scene: THREE.Scene) {
const len = 3.2;
const xGeo = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(0, 0, 0),
new THREE.Vector3(len, 0, 0),
]);
const xMat = new THREE.LineBasicMaterial({ color: 0xef4444, opacity: 0.6, transparent: true });
scene.add(new THREE.Line(xGeo, xMat));
const yGeo = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(0, 0, 0),
new THREE.Vector3(0, len, 0),
]);
const yMat = new THREE.LineBasicMaterial({ color: 0x22c55e, opacity: 0.6, transparent: true });
scene.add(new THREE.Line(yGeo, yMat));
const zGeo = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(0, 0, 0),
new THREE.Vector3(0, 0, len),
]);
const zMat = new THREE.LineBasicMaterial({ color: 0x3b82f6, opacity: 0.6, transparent: true });
scene.add(new THREE.Line(zGeo, zMat));
}
function addAxisLabel(scene: THREE.Scene, text: string, position: THREE.Vector3) {
const el = document.createElement('span');
el.textContent = text;
el.style.color = LABEL_COLOR;
el.style.fontFamily = "'Inter', sans-serif";
el.style.fontSize = '12px';
el.style.fontWeight = '600';
const label = new CSS2DObject(el);
label.position.copy(position);
scene.add(label);
}
function addReferenceRings(scene: THREE.Scene) {
const dbiLevels = [-3, 0, 3, 6];
const minDbi = -3;
const maxDbi = 6;
dbiLevels.forEach((dbi) => {
const normalized = (dbi - minDbi) / (maxDbi - minDbi);
const radius = 0.1 + normalized * 1.9; // map to [0.1, 2.0]
const curve = new THREE.EllipseCurve(0, 0, radius, radius, 0, 2 * Math.PI, false, 0);
const points = curve.getPoints(64);
const geo = new THREE.BufferGeometry().setFromPoints(
points.map((p) => new THREE.Vector3(p.x, 0, p.y))
);
const mat = new THREE.LineBasicMaterial({
color: RING_COLOR,
opacity: 0.35,
transparent: true,
});
const ring = new THREE.Line(geo, mat);
scene.add(ring);
// Label for the ring
const el = document.createElement('span');
el.textContent = `${dbi >= 0 ? '+' : ''}${dbi} dBi`;
el.style.color = '#64748b'; // slate-500
el.style.fontFamily = "'Inter', sans-serif";
el.style.fontSize = '10px';
const label = new CSS2DObject(el);
label.position.set(radius + 0.1, 0, 0);
scene.add(label);
});
}

154
frontend/src/smith.ts Normal file
View File

@ -0,0 +1,154 @@
import type { ResonanceData } from './types';
const GRID_COLOR = '#475569'; // slate-600
const POINT_COLOR = '#2dd4bf'; // teal-400
const BG_COLOR = '#1e293b'; // slate-800
const BORDER_COLOR = '#334155'; // slate-700
const TEXT_COLOR = '#94a3b8'; // slate-400
/**
* Draw the Smith chart grid on a 2D canvas.
* Uses normalized impedance: z = Z/Z0 where Z0=50.
* Smith chart maps z to reflection coefficient Gamma = (z-1)/(z+1).
*/
export function drawSmithChart(canvas: HTMLCanvasElement, resonance?: ResonanceData): void {
const ctx = canvas.getContext('2d');
if (!ctx) return;
const w = canvas.width;
const h = canvas.height;
const cx = w / 2;
const cy = h / 2;
const R = (Math.min(w, h) / 2) * 0.85; // chart radius in pixels
// Clear
ctx.fillStyle = BG_COLOR;
ctx.fillRect(0, 0, w, h);
// Border
ctx.strokeStyle = BORDER_COLOR;
ctx.lineWidth = 1;
ctx.strokeRect(0, 0, w, h);
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, R, 0, 2 * Math.PI);
ctx.clip();
// Constant R circles
ctx.strokeStyle = GRID_COLOR;
ctx.lineWidth = 0.5;
const rValues = [0, 0.2, 0.5, 1, 2, 5];
for (const r of rValues) {
// Circle center at ((r/(r+1))*R + cx, cy), radius R/(r+1)
const circR = R / (r + 1);
const circX = cx + (r / (r + 1)) * R;
ctx.beginPath();
ctx.arc(circX, cy, circR, 0, 2 * Math.PI);
ctx.stroke();
}
// Constant X arcs
const xValues = [0.2, 0.5, 1, 2, 5];
for (const x of xValues) {
// Positive X arc (inductive, above center)
drawXArc(ctx, cx, cy, R, x);
// Negative X arc (capacitive, below center)
drawXArc(ctx, cx, cy, R, -x);
}
// Horizontal center line
ctx.beginPath();
ctx.moveTo(cx - R, cy);
ctx.lineTo(cx + R, cy);
ctx.stroke();
ctx.restore();
// Outer circle
ctx.strokeStyle = GRID_COLOR;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(cx, cy, R, 0, 2 * Math.PI);
ctx.stroke();
// Title
ctx.fillStyle = TEXT_COLOR;
ctx.font = "10px 'Inter', sans-serif";
ctx.textAlign = 'center';
ctx.fillText('Smith Chart', cx, h - 4);
// Plot impedance point if we have resonance data
if (resonance) {
const z_real = resonance.impedance_real / 50;
const z_imag = resonance.impedance_imag / 50;
// Reflection coefficient: Gamma = (z-1)/(z+1) where z = z_real + j*z_imag
const denom_r = (z_real + 1) * (z_real + 1) + z_imag * z_imag;
const gamma_real = ((z_real * z_real + z_imag * z_imag - 1)) / denom_r;
const gamma_imag = (2 * z_imag) / denom_r;
const px = cx + gamma_real * R;
const py = cy - gamma_imag * R; // y-axis inverted in canvas
// Draw point
ctx.fillStyle = POINT_COLOR;
ctx.beginPath();
ctx.arc(px, py, 4, 0, 2 * Math.PI);
ctx.fill();
// Outline
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(px, py, 4, 0, 2 * Math.PI);
ctx.stroke();
// Label
ctx.fillStyle = POINT_COLOR;
ctx.font = "bold 9px 'Inter', sans-serif";
ctx.textAlign = 'left';
const label = `${resonance.impedance_real.toFixed(0)}${resonance.impedance_imag >= 0 ? '+' : ''}${resonance.impedance_imag.toFixed(0)}j`;
ctx.fillText(label, px + 7, py + 3);
}
}
function drawXArc(
ctx: CanvasRenderingContext2D,
cx: number,
cy: number,
R: number,
x: number
): void {
const arcR = R / Math.abs(x);
const centerX = cx + R;
const centerY = x > 0 ? cy - arcR : cy + arcR;
// We need to clip the arc to within the unit circle.
// Use many small segments and only draw those inside.
const steps = 100;
ctx.beginPath();
let drawing = false;
for (let i = 0; i <= steps; i++) {
const t = (i / steps) * Math.PI;
const angle = x > 0 ? -Math.PI / 2 + t : Math.PI / 2 - t;
const px = centerX + arcR * Math.cos(angle);
const py = centerY + arcR * Math.sin(angle);
// Check if inside unit circle
const dx = px - cx;
const dy = py - cy;
if (dx * dx + dy * dy <= R * R * 1.01) {
if (!drawing) {
ctx.moveTo(px, py);
drawing = true;
} else {
ctx.lineTo(px, py);
}
} else {
drawing = false;
}
}
ctx.stroke();
}

393
frontend/src/style.css Normal file
View File

@ -0,0 +1,393 @@
/* === Base === */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--slate-50: #f8fafc;
--slate-100: #f1f5f9;
--slate-200: #e2e8f0;
--slate-300: #cbd5e1;
--slate-400: #94a3b8;
--slate-500: #64748b;
--slate-600: #475569;
--slate-700: #334155;
--slate-800: #1e293b;
--slate-900: #0f172a;
--slate-950: #020617;
--teal-400: #2dd4bf;
--teal-500: #14b8a6;
--amber-400: #fbbf24;
--amber-500: #f59e0b;
--red-400: #f87171;
--green-400: #4ade80;
--panel-width: 280px;
--panel-bg: rgba(15, 23, 42, 0.88);
--panel-border: rgba(51, 65, 85, 0.5);
--radius: 8px;
--radius-sm: 5px;
}
html, body {
height: 100%;
width: 100%;
overflow: hidden;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
font-size: 13px;
line-height: 1.5;
color: var(--slate-300);
background: var(--slate-900);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* === Layout === */
#app {
position: relative;
width: 100%;
height: 100%;
display: flex;
}
#scene-container {
flex: 1;
position: relative;
overflow: hidden;
}
#smith-chart {
position: absolute;
bottom: 16px;
right: 16px;
width: 200px;
height: 200px;
border-radius: var(--radius);
z-index: 10;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
}
/* === Controls Panel === */
.controls-panel {
width: var(--panel-width);
min-width: var(--panel-width);
height: 100%;
background: var(--panel-bg);
backdrop-filter: blur(16px);
border-right: 1px solid var(--panel-border);
padding: 20px 16px;
display: flex;
flex-direction: column;
gap: 10px;
overflow-y: auto;
z-index: 20;
}
.controls-header {
margin-bottom: 4px;
}
.controls-logo {
display: flex;
align-items: center;
gap: 8px;
color: var(--slate-100);
font-weight: 600;
font-size: 16px;
}
.controls-logo svg {
color: var(--teal-400);
}
.controls-subtitle {
color: var(--slate-500);
font-size: 11px;
margin-top: 2px;
letter-spacing: 0.03em;
}
/* Status */
.controls-status {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--slate-400);
padding: 6px 10px;
background: rgba(30, 41, 59, 0.6);
border-radius: var(--radius-sm);
}
.controls-status svg {
width: 14px;
height: 14px;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot.status-on {
background: var(--green-400);
box-shadow: 0 0 6px rgba(74, 222, 128, 0.5);
}
.status-dot.status-off {
background: var(--slate-600);
}
/* Separators */
.controls-sep {
border: none;
border-top: 1px solid var(--panel-border);
margin: 4px 0;
}
/* Section titles */
.controls-section-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 600;
color: var(--slate-400);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-top: 2px;
}
.controls-section-title svg {
opacity: 0.7;
}
/* Fields */
.ctrl-field {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
min-width: 0;
}
.ctrl-label {
font-size: 11px;
color: var(--slate-500);
font-weight: 500;
}
.ctrl-input,
.ctrl-select {
width: 100%;
padding: 7px 10px;
font-family: inherit;
font-size: 13px;
color: var(--slate-200);
background: var(--slate-800);
border: 1px solid var(--slate-700);
border-radius: var(--radius-sm);
outline: none;
transition: border-color 0.15s;
}
.ctrl-input:focus,
.ctrl-select:focus {
border-color: var(--teal-400);
}
.ctrl-input::placeholder {
color: var(--slate-600);
}
.ctrl-select {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
padding-right: 28px;
}
/* Row layout */
.controls-row {
display: flex;
gap: 8px;
}
/* Spacer */
.controls-spacer {
height: 4px;
}
/* Buttons */
.ctrl-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
padding: 9px 14px;
font-family: inherit;
font-size: 13px;
font-weight: 500;
border: 1px solid transparent;
border-radius: var(--radius);
cursor: pointer;
transition: all 0.15s;
}
.ctrl-btn svg {
width: 15px;
height: 15px;
flex-shrink: 0;
}
.ctrl-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ctrl-btn-primary {
background: var(--teal-500);
color: var(--slate-950);
}
.ctrl-btn-primary:hover:not(:disabled) {
background: var(--teal-400);
}
.ctrl-btn-secondary {
background: transparent;
color: var(--slate-300);
border-color: var(--slate-600);
}
.ctrl-btn-secondary:hover:not(:disabled) {
border-color: var(--slate-400);
color: var(--slate-100);
}
/* Display mode toggle group */
.controls-mode-group {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
}
.ctrl-mode-btn {
padding: 6px 8px;
font-family: inherit;
font-size: 12px;
font-weight: 500;
color: var(--slate-400);
background: var(--slate-800);
border: 1px solid var(--slate-700);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.15s;
}
.ctrl-mode-btn:hover {
color: var(--slate-200);
border-color: var(--slate-500);
}
.ctrl-mode-btn.active {
color: var(--teal-400);
border-color: var(--teal-400);
background: rgba(45, 212, 191, 0.08);
}
/* Readout */
.controls-readout {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 10px 12px;
background: rgba(30, 41, 59, 0.6);
border-radius: var(--radius-sm);
}
.readout-label {
font-size: 11px;
color: var(--slate-500);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.readout-value {
font-size: 18px;
font-weight: 600;
color: var(--amber-400);
font-variant-numeric: tabular-nums;
}
/* Loading overlay for controls */
.controls-loading {
font-size: 12px;
color: var(--teal-400);
text-align: center;
padding: 6px;
animation: pulse 1.2s ease-in-out infinite;
}
/* Scene loading overlay */
.scene-loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 5;
pointer-events: none;
}
.scene-loading-content {
text-align: center;
color: var(--slate-500);
font-size: 14px;
}
.scene-loading-content strong {
color: var(--slate-300);
}
.scene-loading-spinner {
width: 32px;
height: 32px;
margin: 0 auto 16px;
border: 3px solid var(--slate-700);
border-top-color: var(--teal-400);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* Scrollbar */
.controls-panel::-webkit-scrollbar {
width: 4px;
}
.controls-panel::-webkit-scrollbar-track {
background: transparent;
}
.controls-panel::-webkit-scrollbar-thumb {
background: var(--slate-700);
border-radius: 2px;
}

54
frontend/src/types.ts Normal file
View File

@ -0,0 +1,54 @@
export interface PatternData {
antenna_type: string;
frequency_hz: number;
wavelength_m: number;
theta_deg: number[];
phi_deg: number[];
gain_dbi: number[][];
peak_gain_dbi: number;
e_plane: PlanePoint[];
h_plane: PlanePoint[];
model: string;
resonance?: ResonanceData;
}
export interface PlanePoint {
theta_deg?: number;
phi_deg?: number;
gain_dbi: number;
}
export interface ResonanceData {
frequency_hz: number;
swr: number;
return_loss_db: number;
impedance_real: number;
impedance_imag: number;
}
export interface ComputeParams {
antenna_type: string;
frequency_hz: number;
impedance_real: number;
impedance_imag: number;
}
export interface ScanParams {
antenna_type: string;
start_hz: number;
stop_hz: number;
points: number;
}
export type DisplayMode = 'surface' | 'wireframe' | 'e-plane' | 'h-plane';
export interface AppState {
connected: boolean;
loading: boolean;
pattern: PatternData | null;
displayMode: DisplayMode;
antennaType: string;
frequencyMhz: number;
impedanceReal: number;
impedanceImag: number;
}

26
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

17
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,17 @@
import { defineConfig } from 'vite';
export default defineConfig({
build: {
outDir: '../src/mcnanovna/webui/static',
emptyOutDir: true,
},
server: {
proxy: {
'/api': 'http://localhost:8080',
'/ws': {
target: 'ws://localhost:8080',
ws: true,
},
},
},
});

View File

@ -1,6 +1,6 @@
[project]
name = "mcnanovna"
version = "2026.01.30"
version = "2026.01.31"
description = "MCP server for NanoVNA-H vector network analyzers"
authors = [{name = "Ryan Malloy", email = "ryan@supported.systems"}]
requires-python = ">=3.11"
@ -10,6 +10,12 @@ dependencies = [
"Pillow>=11.0.0",
]
[project.optional-dependencies]
webui = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.34.0",
]
[project.scripts]
mcnanovna = "mcnanovna.server:main"

View File

@ -16,6 +16,7 @@ from mcnanovna.tools import (
DiagnosticsMixin,
DisplayMixin,
MeasurementMixin,
RadiationMixin,
)
@ -26,6 +27,7 @@ class NanoVNA(
DeviceMixin,
DiagnosticsMixin,
AnalysisMixin,
RadiationMixin,
):
"""MCP tool class for NanoVNA-H vector network analyzers.

View File

@ -723,6 +723,88 @@ Let me run `analyze_lc_shunt` now.""",
),
]
@mcp.prompt
def visualize_radiation_pattern(
antenna_type: str = "dipole",
band: str = "2m",
start_hz: int | None = None,
stop_hz: int | None = None,
points: int = 101,
) -> list[Message]:
"""Guide through 3D antenna radiation pattern visualization.
Scans S11 to determine resonant frequency and impedance, then computes
an analytical 3D radiation pattern for the specified antenna type.
If the web UI is running (MCNANOVNA_WEB_PORT), the pattern is pushed
to the 3D viewer in real time.
Args:
antenna_type: Antenna model 'dipole', 'monopole', 'efhw', 'loop', 'patch', or 'auto'
band: Ham band name (e.g., '2m', '70cm', '20m') or 'custom'
start_hz: Start frequency in Hz (overrides band)
stop_hz: Stop frequency in Hz (overrides band)
points: Number of measurement points
"""
if start_hz is not None and stop_hz is not None:
f_start, f_stop = start_hz, stop_hz
band_label = f"Custom ({_format_freq(f_start)} {_format_freq(f_stop)})"
elif band in HAM_BANDS:
f_start, f_stop = HAM_BANDS[band]
band_label = f"{band.upper()} band"
else:
f_start, f_stop = HAM_BANDS["2m"]
band_label = "2M band"
type_label = antenna_type if antenna_type != "auto" else "auto-detected"
return [
Message(
role="user",
content=(
f"Visualize the radiation pattern of my {type_label} antenna "
f"on the {band_label} ({_format_freq(f_start)} {_format_freq(f_stop)})."
),
),
Message(
role="assistant",
content=f"""I'll generate a 3D radiation pattern for your antenna.
**Antenna type**: {type_label}
**Band**: {band_label} ({_format_freq(f_start)} {_format_freq(f_stop)})
**Points**: {points}
**Supported antenna models** (Phase 1 analytical patterns):
| Type | Pattern shape | Feed-point Z at resonance |
|------|--------------|--------------------------|
| Dipole | Figure-8 (donut) | ~73 Ω |
| Monopole | Half-donut over ground | ~36 Ω |
| EFHW | Same as dipole | ~2500-5000 Ω |
| Small loop | Figure-8 (rotated 90°) | Low R, inductive |
| Patch | Broadside hemisphere | ~50-300 Ω |
**How it works:**
1. Scan S11 across the band to find resonant frequency and impedance
2. {"Estimate antenna type from impedance" if antenna_type == "auto" else f"Use the '{antenna_type}' analytical model"}
3. Compute 3D gain pattern on a θ×φ grid (~6500 points)
4. Return gain in dBi at each (θ, φ) ready for 3D rendering
**Output includes:**
- Full 3D gain grid (θ: 0-180°, φ: 0-355°)
- Peak gain in dBi
- E-plane cut (φ=0°) and H-plane cut (θ=90°)
- S11 analysis context (resonance, impedance, SWR)
**Web UI** (if running):
Set `MCNANOVNA_WEB_PORT=8080` to launch the 3D viewer at http://localhost:8080.
The pattern renders as an interactive gain-mapped sphere with OrbitControls.
**No hardware?** Use `radiation_pattern_from_data` to compute a pattern from
known impedance values no VNA connection required.
Let me scan and generate the pattern now.""",
),
]
@mcp.prompt
def impedance_match(
frequency_hz: int = 145_000_000,

423
src/mcnanovna/radiation.py Normal file
View File

@ -0,0 +1,423 @@
"""Antenna radiation pattern models driven by S11 measurements.
Phase 1: Analytical models using closed-form equations for common antenna types.
S11 gives us impedance + resonant frequency; combined with user-specified antenna
type, we compute idealized 3D radiation patterns.
All functions are pure Python (math + list comprehensions). No external dependencies.
Grid size is typically 91x72 = 6552 points trivial without numpy.
Phase 2 hooks: generate_3d_pattern() returns standardized {theta, phi, gain_dbi}
dicts. Future measured-pattern tools will produce the same format.
"""
from __future__ import annotations
import math
# Speed of light in m/s
C = 299_792_458
def _deg_range(start: float, stop: float, step: float) -> list[float]:
"""Generate a list of angles in degrees from start to stop (inclusive)."""
result = []
val = start
while val <= stop + step * 0.01:
result.append(val)
val += step
return result
def _directivity_to_dbi(directivity: float) -> float:
"""Convert linear directivity to dBi."""
if directivity <= 0:
return -40.0
return 10.0 * math.log10(directivity)
# ── Antenna pattern functions ─────────────────────────────────────
#
# Each returns linear gain (directivity) for given angles.
# Convention: theta = polar angle from z-axis (0° = zenith),
# phi = azimuthal angle in x-y plane (0° = x-axis).
# Patterns are normalized so peak directivity matches the theoretical value.
def dipole_gain(theta_deg: float, frequency_hz: float, length_m: float | None = None) -> float:
"""Half-wave dipole radiation pattern.
Uses the exact far-field expression for a center-fed thin dipole:
E(θ) = cos(kL/2 · cos θ) - cos(kL/2) / sin θ
For a half-wave dipole (L = λ/2), this simplifies to:
E(θ) = cos(π/2 · cos θ) / sin θ
Peak directivity: 2.15 dBi (1.64 linear).
Args:
theta_deg: Polar angle from antenna axis (degrees, 0=along wire)
frequency_hz: Operating frequency in Hz
length_m: Physical length in meters (default: half-wavelength)
"""
wavelength = C / frequency_hz
if length_m is None:
length_m = wavelength / 2.0
theta = math.radians(theta_deg)
sin_theta = math.sin(theta)
if abs(sin_theta) < 1e-10:
return 0.0
k = 2.0 * math.pi / wavelength
half_kl = k * length_m / 2.0
cos_theta = math.cos(theta)
numerator = math.cos(half_kl * cos_theta) - math.cos(half_kl)
e_field = numerator / sin_theta
# Normalize to peak directivity of 1.64 (2.15 dBi) for half-wave
# The raw max of cos(π/2·cosθ)/sinθ is 1.0 at θ=90°
directivity = 1.64 * e_field * e_field
return directivity
def monopole_gain(theta_deg: float, frequency_hz: float, length_m: float | None = None) -> float:
"""Quarter-wave monopole over perfect ground plane.
Image theory: a monopole over ground has the same pattern as the upper
hemisphere of a dipole, but with doubled directivity (energy only radiates
into the upper hemisphere).
Peak directivity: 5.15 dBi (3.28 linear) = dipole + 3 dB ground gain.
Impedance at resonance: ~36 Ω (half of dipole's 73 Ω).
Args:
theta_deg: Elevation angle (0=horizon, 90=zenith)
frequency_hz: Operating frequency in Hz
length_m: Physical length in meters (default: quarter-wavelength)
"""
wavelength = C / frequency_hz
if length_m is None:
length_m = wavelength / 4.0
# Monopole: no radiation below ground plane
if theta_deg > 90.0:
return 0.0
# Map elevation to dipole theta (monopole elevation 0°=horizon → dipole 90°)
dipole_theta = 90.0 - theta_deg
# Use dipole pattern with doubled length (image theory)
gain = dipole_gain(dipole_theta, frequency_hz, length_m * 2.0)
# Double directivity for hemisphere-only radiation
return gain * 2.0
def loop_gain(theta_deg: float, frequency_hz: float, circumference_m: float | None = None) -> float:
"""Small magnetic loop antenna pattern.
A small loop (circumference << λ) acts as a magnetic dipole with
a sin(θ) pattern (null along the loop axis, maximum in the plane
of the loop).
Peak directivity: 1.76 dBi (1.5 linear) same as a short dipole.
Args:
theta_deg: Polar angle from loop axis (0=through loop, 90=in loop plane)
frequency_hz: Operating frequency in Hz
circumference_m: Loop circumference in meters (default: λ/10)
"""
wavelength = C / frequency_hz
if circumference_m is None:
circumference_m = wavelength / 10.0
theta = math.radians(theta_deg)
sin_theta = math.sin(theta)
# Small loop: D(θ) = 1.5 * sin²(θ)
return 1.5 * sin_theta * sin_theta
def patch_gain(
theta_deg: float,
phi_deg: float,
frequency_hz: float,
width_m: float | None = None,
length_m: float | None = None,
er: float = 4.4,
) -> float:
"""Rectangular microstrip patch antenna (cavity model).
The patch radiates broadside (maximum at θ=0). The E-plane (φ=0) and
H-plane (φ=90°) patterns differ due to the rectangular aperture.
E-plane: cos(θ) envelope × sinc(kW/2 · sin θ)
H-plane: cos(kL_eff/2 · sin θ) × cos(θ)
Peak directivity: ~6-8 dBi depending on substrate.
Args:
theta_deg: Polar angle from broadside (0=directly above patch)
phi_deg: Azimuthal angle (0=E-plane, 90=H-plane)
frequency_hz: Operating frequency in Hz
width_m: Patch width in meters (default: estimated from frequency + εr)
length_m: Patch length in meters (default: λ_eff/2)
er: Substrate relative permittivity (default 4.4 for FR-4)
"""
wavelength = C / frequency_hz
lambda_eff = wavelength / math.sqrt(er)
if length_m is None:
length_m = lambda_eff / 2.0
if width_m is None:
width_m = wavelength / 2.0 # Typical width ~ free-space λ/2
theta = math.radians(theta_deg)
phi = math.radians(phi_deg)
cos_theta = math.cos(theta)
sin_theta = math.sin(theta)
cos_phi = math.cos(phi)
sin_phi = math.sin(phi)
# No radiation behind ground plane
if theta_deg > 90.0:
return 0.0
k0 = 2.0 * math.pi / wavelength
# E-plane factor (along patch length)
kw_arg = k0 * width_m * sin_theta * cos_phi / 2.0
if abs(kw_arg) < 1e-10:
sinc_e = 1.0
else:
sinc_e = math.sin(kw_arg) / kw_arg
# H-plane factor (along patch width)
kl_arg = k0 * length_m * sin_theta * sin_phi / 2.0
cos_h = math.cos(kl_arg)
# Combine: directivity pattern
pattern = cos_theta * sinc_e * cos_h
gain = pattern * pattern
# Approximate peak directivity for a patch: D ≈ 4πWL/λ² * radiation efficiency
# Simplified to ~6.6 (8.2 dBi) for typical patch
peak_d = min(4.0 * math.pi * width_m * length_m / (wavelength * wavelength), 8.0)
peak_d = max(peak_d, 4.0) # Floor at ~6 dBi
return peak_d * gain
def efhw_gain(theta_deg: float, frequency_hz: float) -> float:
"""End-Fed Half-Wave (EFHW) antenna pattern.
An EFHW has fundamentally the same radiation pattern as a center-fed
half-wave dipole the current distribution is the same sinusoidal
shape. The feed-point impedance is very high (~2500-5000 Ω) because
it's fed at the voltage maximum, but the far-field pattern is identical.
Peak directivity: 2.15 dBi (1.64 linear).
Args:
theta_deg: Polar angle from wire axis (0=along wire)
frequency_hz: Operating frequency in Hz
"""
return dipole_gain(theta_deg, frequency_hz, length_m=None)
# ── Antenna type estimation ───────────────────────────────────────
def estimate_antenna_type(
impedance_real: float,
impedance_imag: float,
resonant_freq_hz: float,
bandwidth_hz: float = 0,
) -> str:
"""Estimate antenna type from measured impedance at resonance.
Uses feed-point impedance as the primary discriminator:
- ~73 Ω half-wave dipole
- ~36 Ω quarter-wave monopole (or λ/4 vertical)
- ~2500-5000 Ω EFHW
- Low R with high inductive X small loop
- ~50-300 Ω broadside patch
This is a heuristic many antennas don't fit neatly into categories.
Args:
impedance_real: Resistance at resonance (Ω)
impedance_imag: Reactance at resonance (Ω)
resonant_freq_hz: Resonant frequency in Hz
bandwidth_hz: 2:1 SWR bandwidth in Hz (0 if unknown)
"""
r = impedance_real
x = abs(impedance_imag)
if r > 1500:
return "efhw"
if r < 5 and x > 10:
return "loop"
if 25 <= r <= 42 and x < 20:
return "monopole"
if 45 <= r <= 100 and x < 30:
return "dipole"
if 100 < r <= 500 and x < 50:
return "patch"
# Fallback: use resonant impedance vs canonical values
if abs(r - 73) < abs(r - 36):
return "dipole"
return "monopole"
# ── 3D pattern generation ─────────────────────────────────────────
def generate_3d_pattern(
antenna_type: str,
frequency_hz: float,
s11_analysis: dict | None = None,
theta_step: float = 2.0,
phi_step: float = 5.0,
length_m: float | None = None,
er: float = 4.4,
) -> dict:
"""Generate a full 3D radiation pattern grid.
Returns a dict with theta/phi axes and a 2D gain grid suitable for
rendering as a gain-mapped sphere in Three.js.
This is the standardized output format shared between Phase 1 (analytical)
and Phase 2 (measured) patterns.
Args:
antenna_type: One of 'dipole', 'monopole', 'efhw', 'loop', 'patch'
frequency_hz: Operating frequency in Hz
s11_analysis: Optional S11 analysis dict (from analyze_scan) for context
theta_step: Polar angle resolution in degrees (default 2°)
phi_step: Azimuthal angle resolution in degrees (default 5°)
length_m: Override antenna element length in meters
er: Substrate εr for patch antennas (default 4.4)
"""
thetas = _deg_range(0.0, 180.0, theta_step)
phis = _deg_range(0.0, 355.0, phi_step)
# Build gain grid: gain[i_theta][i_phi] in dBi
gain_grid: list[list[float]] = []
peak_linear = 0.0
for theta in thetas:
row: list[float] = []
for phi in phis:
if antenna_type == "dipole":
g = dipole_gain(theta, frequency_hz, length_m)
elif antenna_type == "monopole":
g = monopole_gain(theta, frequency_hz, length_m)
elif antenna_type == "efhw":
g = efhw_gain(theta, frequency_hz)
elif antenna_type == "loop":
g = loop_gain(theta, frequency_hz)
elif antenna_type == "patch":
g = patch_gain(theta, phi, frequency_hz, length_m=length_m, er=er)
else:
g = dipole_gain(theta, frequency_hz, length_m)
if g > peak_linear:
peak_linear = g
row.append(g)
gain_grid.append(row)
# Convert to dBi
gain_dbi: list[list[float]] = []
for row in gain_grid:
gain_dbi.append([_directivity_to_dbi(g) for g in row])
peak_dbi = _directivity_to_dbi(peak_linear)
# Compute E-plane and H-plane cuts
# E-plane: φ=0° (first column of each theta row)
e_plane = [{"theta_deg": thetas[i], "gain_dbi": gain_dbi[i][0]} for i in range(len(thetas))]
# H-plane: θ=90° (row at theta=90°, all phi values)
theta_90_idx = min(range(len(thetas)), key=lambda i: abs(thetas[i] - 90.0))
h_plane = [{"phi_deg": phis[j], "gain_dbi": gain_dbi[theta_90_idx][j]} for j in range(len(phis))]
wavelength = C / frequency_hz
result: dict = {
"antenna_type": antenna_type,
"frequency_hz": frequency_hz,
"wavelength_m": round(wavelength, 4),
"theta_deg": thetas,
"phi_deg": phis,
"gain_dbi": gain_dbi,
"peak_gain_dbi": round(peak_dbi, 2),
"e_plane": e_plane,
"h_plane": h_plane,
"grid_size": {"theta_points": len(thetas), "phi_points": len(phis), "total_points": len(thetas) * len(phis)},
"model": "analytical",
}
if s11_analysis:
result["s11_context"] = s11_analysis
return result
# ── Multi-frequency pattern generation ────────────────────────────
def generate_multi_frequency_patterns(
antenna_type: str,
frequencies_hz: list[float],
s11_analysis: dict | None = None,
theta_step: float = 4.0,
phi_step: float = 10.0,
length_m: float | None = None,
er: float = 4.4,
) -> dict:
"""Generate patterns at multiple frequencies for animation.
Uses a coarser grid (4°×10° = 46×36 = 1656 points per frequency)
to keep total payload reasonable for WebSocket streaming.
Args:
antenna_type: Antenna type string
frequencies_hz: List of frequencies to compute patterns at
s11_analysis: Optional analysis context
theta_step: Coarser theta step for multi-freq (default 4°)
phi_step: Coarser phi step for multi-freq (default 10°)
length_m: Override antenna element length
er: Substrate εr for patch
"""
patterns = []
for freq in frequencies_hz:
p = generate_3d_pattern(
antenna_type,
freq,
s11_analysis=None,
theta_step=theta_step,
phi_step=phi_step,
length_m=length_m,
er=er,
)
# Slim down for multi-freq: drop plane cuts, keep grid
patterns.append(
{
"frequency_hz": freq,
"gain_dbi": p["gain_dbi"],
"peak_gain_dbi": p["peak_gain_dbi"],
}
)
return {
"antenna_type": antenna_type,
"count": len(patterns),
"theta_deg": _deg_range(0.0, 180.0, theta_step),
"phi_deg": _deg_range(0.0, 355.0, phi_step),
"patterns": patterns,
"model": "analytical",
}

View File

@ -90,6 +90,10 @@ _TOOL_METHODS = [
"analyze_lc_match",
"analyze_s11_resonance",
"analyze",
# tools/radiation.py — RadiationMixin
"radiation_pattern",
"radiation_pattern_from_data",
"radiation_pattern_multi",
]
@ -112,10 +116,14 @@ def create_server() -> FastMCP:
"'analyze_lc_series' and 'analyze_lc_shunt' (resonator parameters from S21), "
"'analyze_lc_match' (L-network matching solver, accepts direct R+jX or scans S11), "
"'analyze_s11_resonance' (find up to 6 resonant frequencies).\n\n"
"Radiation pattern tools: 'radiation_pattern' (scan S11 → 3D pattern), "
"'radiation_pattern_from_data' (compute pattern from known impedance, no hardware), "
"'radiation_pattern_multi' (patterns at N frequencies for animation). "
"Supported antenna types: dipole, monopole, efhw, loop, patch, or 'auto' to estimate.\n\n"
"Prompts are available for guided workflows: calibrate, export_touchstone, "
"analyze_antenna, measure_cable, compare_sweeps, analyze_crystal, "
"analyze_filter_response, measure_tdr, measure_component, measure_lc_series, "
"measure_lc_shunt, and impedance_match."
"measure_lc_shunt, impedance_match, and visualize_radiation_pattern."
),
)
vna = NanoVNA()
@ -131,6 +139,8 @@ def create_server() -> FastMCP:
def main() -> None:
import os
try:
from importlib.metadata import version
@ -140,6 +150,19 @@ def main() -> None:
print(f"mcnanovna v{package_version} — NanoVNA-H MCP server")
web_port = os.environ.get("MCNANOVNA_WEB_PORT")
if web_port:
try:
port = int(web_port)
from mcnanovna.webui import start_web_server
start_web_server(port)
print(f"Web UI available at http://localhost:{port}")
except ImportError:
print("Web UI requires optional dependencies: pip install mcnanovna[webui]")
except ValueError:
print(f"Invalid MCNANOVNA_WEB_PORT: {web_port}")
server = create_server()
server.run()

View File

@ -15,6 +15,7 @@ from .device import DeviceMixin
from .diagnostics import DiagnosticsMixin
from .display import DisplayMixin
from .measurement import MeasurementMixin
from .radiation import RadiationMixin
async def _progress(ctx: Context | None, progress: float, total: float, message: str) -> None:
@ -30,4 +31,5 @@ __all__ = [
"DiagnosticsMixin",
"DisplayMixin",
"MeasurementMixin",
"RadiationMixin",
]

View File

@ -0,0 +1,204 @@
"""RadiationMixin — 3D antenna radiation pattern tools."""
from __future__ import annotations
import asyncio
from fastmcp import Context
class RadiationMixin:
"""Radiation pattern tools: radiation_pattern, radiation_pattern_from_data, radiation_pattern_multi."""
async def radiation_pattern(
self,
antenna_type: str = "dipole",
start_hz: int | None = None,
stop_hz: int | None = None,
points: int = 101,
theta_step: float = 2.0,
phi_step: float = 5.0,
er: float = 4.4,
ctx: Context | None = None,
) -> dict:
"""Scan S11, find resonance, and compute a 3D radiation pattern.
Performs an S11 scan across the specified range, identifies the resonant
frequency and impedance, optionally estimates the antenna type, then
generates an analytical 3D radiation pattern grid.
Returns {theta_deg, phi_deg, gain_dbi} suitable for 3D visualization.
Args:
antenna_type: Antenna model 'dipole', 'monopole', 'efhw', 'loop', 'patch', or 'auto' to estimate from impedance
start_hz: Start frequency in Hz (default: 2m band 144 MHz)
stop_hz: Stop frequency in Hz (default: 2m band 148 MHz)
points: Number of S11 scan points (default 101)
theta_step: Polar angle resolution in degrees (default 2°)
phi_step: Azimuthal angle resolution in degrees (default 5°)
er: Substrate εr for patch antennas (default 4.4 for FR-4)
"""
from mcnanovna.calculations import analyze_scan, find_resonance
from mcnanovna.radiation import estimate_antenna_type, generate_3d_pattern
from mcnanovna.tools import _progress
if start_hz is None:
start_hz = 144_000_000
if stop_hz is None:
stop_hz = 148_000_000
await _progress(ctx, 1, 5, "Connecting to NanoVNA...")
await asyncio.to_thread(self._ensure_connected)
await _progress(ctx, 2, 5, f"Scanning S11 ({points} points)...")
scan_result = await self.scan(start_hz, stop_hz, points, s11=True, s21=False)
if "error" in scan_result:
return scan_result
await _progress(ctx, 3, 5, "Analyzing S11 data...")
freqs = [pt["frequency_hz"] for pt in scan_result["data"]]
s11_data = [pt["s11"] for pt in scan_result["data"]]
s11_complex = [complex(s["real"], s["imag"]) for s in s11_data]
analysis = analyze_scan(scan_result["data"])
resonance = find_resonance(s11_complex, freqs)
# Auto-detect antenna type from impedance
if antenna_type == "auto":
res_freq = resonance.get("frequency_hz", freqs[len(freqs) // 2])
z_real = resonance.get("impedance_real", 50.0)
z_imag = resonance.get("impedance_imag", 0.0)
bw = analysis.get("s11_analysis", {}).get("bandwidth_2_1", {}).get("bandwidth_hz", 0)
antenna_type = estimate_antenna_type(z_real, z_imag, res_freq, bw)
res_freq = resonance.get("frequency_hz", (start_hz + stop_hz) // 2)
await _progress(ctx, 4, 5, f"Computing {antenna_type} radiation pattern at {res_freq} Hz...")
pattern = generate_3d_pattern(
antenna_type=antenna_type,
frequency_hz=res_freq,
s11_analysis=analysis.get("s11_analysis"),
theta_step=theta_step,
phi_step=phi_step,
er=er,
)
pattern["scan_info"] = {
"start_hz": start_hz,
"stop_hz": stop_hz,
"points": scan_result["points"],
}
pattern["resonance"] = resonance
await _progress(ctx, 5, 5, "Radiation pattern complete")
return pattern
async def radiation_pattern_from_data(
self,
antenna_type: str = "dipole",
frequency_hz: float = 145_000_000,
impedance_real: float = 73.0,
impedance_imag: float = 0.0,
theta_step: float = 2.0,
phi_step: float = 5.0,
length_m: float | None = None,
er: float = 4.4,
ctx: Context | None = None,
) -> dict:
"""Compute a 3D radiation pattern from provided impedance (no hardware).
Generates an analytical radiation pattern using the specified antenna
type and frequency. No VNA connection required useful for what-if
analysis or when impedance is already known.
Args:
antenna_type: Antenna model 'dipole', 'monopole', 'efhw', 'loop', 'patch', or 'auto'
frequency_hz: Operating frequency in Hz (default 145 MHz)
impedance_real: Known resistance at resonance in ohms (default 73)
impedance_imag: Known reactance at resonance in ohms (default 0)
theta_step: Polar angle resolution in degrees (default 2°)
phi_step: Azimuthal angle resolution in degrees (default 5°)
length_m: Override antenna element length in meters (default: calculated from frequency)
er: Substrate εr for patch antennas (default 4.4)
"""
from mcnanovna.radiation import estimate_antenna_type, generate_3d_pattern
from mcnanovna.tools import _progress
if antenna_type == "auto":
antenna_type = estimate_antenna_type(impedance_real, impedance_imag, frequency_hz)
await _progress(ctx, 1, 2, f"Computing {antenna_type} pattern at {frequency_hz} Hz...")
s11_context = {
"resonance": {
"frequency_hz": frequency_hz,
"impedance_real": impedance_real,
"impedance_imag": impedance_imag,
}
}
pattern = generate_3d_pattern(
antenna_type=antenna_type,
frequency_hz=frequency_hz,
s11_analysis=s11_context,
theta_step=theta_step,
phi_step=phi_step,
length_m=length_m,
er=er,
)
await _progress(ctx, 2, 2, "Pattern generation complete")
return pattern
async def radiation_pattern_multi(
self,
antenna_type: str = "dipole",
start_hz: int = 144_000_000,
stop_hz: int = 148_000_000,
num_frequencies: int = 5,
theta_step: float = 4.0,
phi_step: float = 10.0,
length_m: float | None = None,
er: float = 4.4,
ctx: Context | None = None,
) -> dict:
"""Compute radiation patterns at multiple frequencies across a band.
Generates a series of 3D patterns at evenly-spaced frequencies for
animation or comparison. Uses a coarser grid to keep payload size
manageable for WebSocket streaming.
Args:
antenna_type: Antenna model 'dipole', 'monopole', 'efhw', 'loop', 'patch'
start_hz: Band start frequency in Hz
stop_hz: Band stop frequency in Hz
num_frequencies: Number of frequency steps (default 5)
theta_step: Polar angle resolution in degrees (default 4°, coarser for multi)
phi_step: Azimuthal angle resolution in degrees (default 10°, coarser for multi)
length_m: Override antenna element length in meters
er: Substrate εr for patch antennas (default 4.4)
"""
from mcnanovna.radiation import generate_multi_frequency_patterns
from mcnanovna.tools import _progress
if num_frequencies < 2:
num_frequencies = 2
if num_frequencies > 20:
num_frequencies = 20
step = (stop_hz - start_hz) / (num_frequencies - 1)
frequencies = [start_hz + int(i * step) for i in range(num_frequencies)]
await _progress(ctx, 1, 2, f"Computing {num_frequencies} patterns from {start_hz} to {stop_hz} Hz...")
result = generate_multi_frequency_patterns(
antenna_type=antenna_type,
frequencies_hz=frequencies,
theta_step=theta_step,
phi_step=phi_step,
length_m=length_m,
er=er,
)
await _progress(ctx, 2, 2, f"Generated {num_frequencies} radiation patterns")
return result

View File

@ -0,0 +1,32 @@
"""Optional web UI for 3D radiation pattern visualization.
Requires optional dependencies: pip install mcnanovna[webui]
Activated by setting MCNANOVNA_WEB_PORT=8080 environment variable.
"""
from __future__ import annotations
import threading
def start_web_server(port: int = 8080) -> None:
"""Start the FastAPI web server in a background thread.
The web server shares the same process as the MCP server but listens
on a separate TCP port. It serves the built frontend assets and
provides REST + WebSocket endpoints for pattern computation.
Args:
port: TCP port to listen on (default 8080)
"""
import uvicorn
from mcnanovna.webui.api import create_app
app = create_app()
def _run() -> None:
uvicorn.run(app, host="0.0.0.0", port=port, log_level="warning")
thread = threading.Thread(target=_run, daemon=True, name="mcnanovna-webui")
thread.start()

218
src/mcnanovna/webui/api.py Normal file
View File

@ -0,0 +1,218 @@
"""FastAPI REST + WebSocket endpoints for the radiation pattern web UI.
Serves the built frontend assets and provides endpoints for pattern
computation, VNA scanning, and real-time WebSocket updates.
"""
from __future__ import annotations
import asyncio
import json
from pathlib import Path
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
STATIC_DIR = Path(__file__).parent / "static"
# Shared lock prevents concurrent VNA scans from web + MCP
_scan_lock = asyncio.Lock()
# Connected WebSocket clients for pattern broadcast
_ws_clients: set[WebSocket] = set()
class ComputeRequest(BaseModel):
antenna_type: str = "dipole"
frequency_hz: float = 145_000_000
impedance_real: float = 73.0
impedance_imag: float = 0.0
theta_step: float = 2.0
phi_step: float = 5.0
length_m: float | None = None
er: float = 4.4
class ScanRequest(BaseModel):
antenna_type: str = "auto"
start_hz: int = 144_000_000
stop_hz: int = 148_000_000
points: int = 101
theta_step: float = 2.0
phi_step: float = 5.0
er: float = 4.4
def create_app() -> FastAPI:
app = FastAPI(title="mcnanovna Web UI", docs_url=None, redoc_url=None)
# ── API routes ────────────────────────────────────────────────
@app.get("/api/status")
async def api_status():
"""VNA connection status."""
return {"status": "ok", "web_ui": True, "clients": len(_ws_clients)}
@app.get("/api/bands")
async def api_bands():
"""Ham band presets for the frequency selector."""
from mcnanovna.prompts import HAM_BANDS
return {name: {"start_hz": start, "stop_hz": stop} for name, (start, stop) in HAM_BANDS.items()}
@app.post("/api/pattern/compute")
async def api_pattern_compute(req: ComputeRequest):
"""Compute a radiation pattern from provided impedance (no VNA needed)."""
from mcnanovna.radiation import estimate_antenna_type, generate_3d_pattern
antenna_type = req.antenna_type
if antenna_type == "auto":
antenna_type = estimate_antenna_type(req.impedance_real, req.impedance_imag, req.frequency_hz)
pattern = generate_3d_pattern(
antenna_type=antenna_type,
frequency_hz=req.frequency_hz,
s11_analysis={
"resonance": {
"frequency_hz": req.frequency_hz,
"impedance_real": req.impedance_real,
"impedance_imag": req.impedance_imag,
}
},
theta_step=req.theta_step,
phi_step=req.phi_step,
length_m=req.length_m,
er=req.er,
)
# Broadcast to WebSocket clients
await _broadcast_pattern(pattern)
return pattern
@app.post("/api/pattern")
async def api_pattern_scan(req: ScanRequest):
"""Scan S11 on the VNA and compute a radiation pattern."""
from mcnanovna.calculations import analyze_scan, find_resonance
from mcnanovna.radiation import estimate_antenna_type, generate_3d_pattern
async with _scan_lock:
# Lazy import — NanoVNA singleton not available until server starts
vna = _get_vna()
if vna is None:
return {"error": "VNA not available — MCP server must be running"}
scan_result = await vna.scan(req.start_hz, req.stop_hz, req.points, s11=True, s21=False)
if "error" in scan_result:
return scan_result
freqs = [pt["frequency_hz"] for pt in scan_result["data"]]
s11_data = [pt["s11"] for pt in scan_result["data"]]
s11_complex = [complex(s["real"], s["imag"]) for s in s11_data]
analysis = analyze_scan(scan_result["data"])
resonance = find_resonance(s11_complex, freqs)
antenna_type = req.antenna_type
if antenna_type == "auto":
z_real = resonance.get("impedance_real", 50.0)
z_imag = resonance.get("impedance_imag", 0.0)
bw = analysis.get("s11_analysis", {}).get("bandwidth_2_1", {}).get("bandwidth_hz", 0)
antenna_type = estimate_antenna_type(z_real, z_imag, resonance.get("frequency_hz", 0), bw)
res_freq = resonance.get("frequency_hz", (req.start_hz + req.stop_hz) // 2)
pattern = generate_3d_pattern(
antenna_type=antenna_type,
frequency_hz=res_freq,
s11_analysis=analysis.get("s11_analysis"),
theta_step=req.theta_step,
phi_step=req.phi_step,
er=req.er,
)
pattern["resonance"] = resonance
pattern["scan_info"] = {
"start_hz": req.start_hz,
"stop_hz": req.stop_hz,
"points": scan_result["points"],
}
await _broadcast_pattern(pattern)
return pattern
# ── WebSocket ─────────────────────────────────────────────────
@app.websocket("/ws/pattern")
async def ws_pattern(websocket: WebSocket):
await websocket.accept()
_ws_clients.add(websocket)
try:
while True:
# Keep connection alive; client can also send requests
data = await websocket.receive_text()
try:
msg = json.loads(data)
if msg.get("type") == "compute":
req = ComputeRequest(**msg.get("params", {}))
result = await api_pattern_compute(req)
await websocket.send_json({"type": "pattern", "data": result})
except (json.JSONDecodeError, TypeError, ValueError):
await websocket.send_json({"type": "error", "message": "Invalid request"})
except WebSocketDisconnect:
pass
finally:
_ws_clients.discard(websocket)
# ── Static files (built frontend) ─────────────────────────────
@app.get("/")
async def serve_index():
index_path = STATIC_DIR / "index.html"
if index_path.exists():
return FileResponse(index_path)
return {"error": "Frontend not built. Run: cd frontend && npm run build"}
if STATIC_DIR.exists():
app.mount("/assets", StaticFiles(directory=str(STATIC_DIR / "assets")), name="assets")
return app
# ── Helpers ───────────────────────────────────────────────────────
def _get_vna():
"""Try to get the shared NanoVNA instance from the MCP server.
Returns None if the MCP server hasn't started yet or if the module
structure doesn't support it. The web UI can still compute patterns
without hardware using the /api/pattern/compute endpoint.
"""
try:
# The NanoVNA is instantiated in create_server() — we create a
# separate lightweight instance for the web UI. It shares the same
# USB device (auto-discovery handles this).
from mcnanovna.nanovna import NanoVNA
if not hasattr(_get_vna, "_instance"):
_get_vna._instance = NanoVNA()
return _get_vna._instance
except Exception:
return None
async def _broadcast_pattern(pattern: dict) -> None:
"""Push a pattern update to all connected WebSocket clients."""
if not _ws_clients:
return
message = json.dumps({"type": "pattern", "data": pattern})
disconnected = set()
for ws in _ws_clients:
try:
await ws.send_text(message)
except Exception:
disconnected.add(ws)
_ws_clients.difference_update(disconnected)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>mcnanovna -- Radiation Pattern</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-khVaWAJ2.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DeJaSUhK.css">
</head>
<body>
<div id="app">
<div id="controls"></div>
<div id="scene-container"></div>
<canvas id="smith-chart" width="200" height="200"></canvas>
</div>
</body>
</html>

213
uv.lock generated
View File

@ -2,6 +2,15 @@ version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
@ -416,6 +425,21 @@ lua = [
{ name = "lupa" },
]
[[package]]
name = "fastapi"
version = "0.128.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/08/8c8508db6c7b9aae8f7175046af41baad690771c9bcde676419965e338c7/fastapi-0.128.0.tar.gz", hash = "sha256:1cc179e1cef10a6be60ffe429f79b829dce99d8de32d7acb7e6c8dfdf7f2645a", size = 365682, upload-time = "2025-12-27T15:21:13.714Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" },
]
[[package]]
name = "fastmcp"
version = "2.14.4"
@ -467,6 +491,42 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httptools"
version = "0.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" },
{ url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375, upload-time = "2025-10-10T03:54:31.941Z" },
{ url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621, upload-time = "2025-10-10T03:54:33.176Z" },
{ url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954, upload-time = "2025-10-10T03:54:34.226Z" },
{ url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175, upload-time = "2025-10-10T03:54:35.942Z" },
{ url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310, upload-time = "2025-10-10T03:54:37.1Z" },
{ url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875, upload-time = "2025-10-10T03:54:38.421Z" },
{ url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" },
{ url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" },
{ url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" },
{ url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" },
{ url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" },
{ url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" },
{ url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" },
{ url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" },
{ url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" },
{ url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" },
{ url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" },
{ url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" },
{ url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" },
{ url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" },
{ url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" },
{ url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" },
{ url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" },
{ url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" },
{ url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" },
{ url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" },
{ url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
@ -703,7 +763,7 @@ wheels = [
[[package]]
name = "mcnanovna"
version = "2026.1.30"
version = "2026.1.31"
source = { editable = "." }
dependencies = [
{ name = "fastmcp" },
@ -711,12 +771,21 @@ dependencies = [
{ name = "pyserial" },
]
[package.optional-dependencies]
webui = [
{ name = "fastapi" },
{ name = "uvicorn", extra = ["standard"] },
]
[package.metadata]
requires-dist = [
{ name = "fastapi", marker = "extra == 'webui'", specifier = ">=0.115.0" },
{ name = "fastmcp", specifier = ">=2.14.0" },
{ name = "pillow", specifier = ">=11.0.0" },
{ name = "pyserial", specifier = ">=3.5" },
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'webui'", specifier = ">=0.34.0" },
]
provides-extras = ["webui"]
[[package]]
name = "mcp"
@ -1550,15 +1619,15 @@ wheels = [
[[package]]
name = "starlette"
version = "0.52.1"
version = "0.50.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
{ url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" },
]
[[package]]
@ -1619,6 +1688,142 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" },
]
[package.optional-dependencies]
standard = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "httptools" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
{ name = "watchfiles" },
{ name = "websockets" },
]
[[package]]
name = "uvloop"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" },
{ url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" },
{ url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" },
{ url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" },
{ url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" },
{ url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" },
{ url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" },
{ url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" },
{ url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" },
{ url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" },
{ url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" },
{ url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
]
[[package]]
name = "watchfiles"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" },
{ url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" },
{ url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" },
{ url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" },
{ url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" },
{ url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" },
{ url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" },
{ url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" },
{ url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" },
{ url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" },
{ url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" },
{ url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" },
{ url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" },
{ url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" },
{ url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" },
{ url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" },
{ url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" },
{ url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" },
{ url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" },
{ url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" },
{ url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" },
{ url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" },
{ url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" },
{ url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" },
{ url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" },
{ url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" },
{ url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" },
{ url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" },
{ url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" },
{ url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" },
{ url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" },
{ url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" },
{ url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" },
{ url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" },
{ url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" },
{ url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" },
{ url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" },
{ url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" },
{ url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" },
{ url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" },
{ url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" },
{ url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" },
{ url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" },
{ url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" },
{ url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" },
{ url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" },
{ url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" },
{ url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" },
{ url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" },
{ url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" },
{ url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" },
{ url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" },
{ url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" },
{ url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" },
{ url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" },
{ url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" },
{ url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" },
{ url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" },
{ url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" },
{ url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" },
{ url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" },
{ url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" },
{ url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" },
{ url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" },
{ url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" },
{ url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" },
{ url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" },
{ url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" },
{ url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" },
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
{ url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" },
{ url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" },
{ url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" },
{ url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" },
]
[[package]]
name = "websockets"
version = "16.0"