Compare commits

..

8 Commits

Author SHA1 Message Date
21f4f0976e Add README
Covers installation, all 78 tools organized by category, 15 prompts,
web UI setup, and cross-server integration with mcpositioner.
2026-02-03 09:10:37 -07:00
748bfb8216 Extract positioner into standalone mcpositioner MCP server
Remove PositionerMixin, positioner.py HTTP client, firmware/, and
positioner optional dependency. The measure_antenna_range prompt now
describes cross-server orchestration with mcpositioner for 3D pattern
measurement instead of calling measure_pattern_3d directly.

78 tools remain (was 84). Server instructions updated to reference
mcpositioner as companion server for antenna range measurements.
2026-02-02 21:58:09 -07:00
0b3e4bdf64 Add firmware .gitignore for PlatformIO build artifacts 2026-02-01 22:44:38 -07:00
a5ab129cc3 Firmware build fixes: HardwareSerial for TMC2209, ArduinoJson 7.4, ESP-IDF 5.x WDT
- TMC2209Stepper: use HardwareSerial& constructor (SoftwareSerial deleted on ESP32)
- ArduinoJson: replace deprecated containsKey() with doc["key"].is<T>()
- esp_task_wdt_init: use struct-based config API (ESP-IDF 5.x)
- ESPAsyncWebServer: fix library owner (mathieucarbou, not wifwaf)
2026-02-01 22:37:40 -07:00
c07284a7d6 ESP32 antenna positioner: dual-axis stepper control + automated 3D pattern measurement
PlatformIO firmware for ESP32 + 2x TMC2209 (UART, StallGuard sensorless homing)
driving NEMA 17 steppers. HTTP API with mDNS discovery (positioner.local).

Python side: async httpx client, PositionerMixin with 6 MCP tools including
measure_pattern_3d which orchestrates the full theta/phi sweep — serpentine
scan path, per-point S21 capture, progress reporting, WebSocket broadcast.

Web UI gains positioner REST endpoints (status, move, home).
New measure_antenna_range prompt for guided workflow.
2026-02-01 22:34:10 -07:00
430caf9e62 Measured pattern import: CSV, EMCAR, NEC2, Touchstone S1P parsers + web UI upload
Add 5 MCP tools (PatternImportMixin) and 1 prompt for importing external
antenna pattern data. Pure-Python parsers with IDW interpolation on the
sphere, single-cut-to-3D synthesis for EMCAR/2-col CSV, and Touchstone S1P
bridge to the analytical pattern engine. Web UI gets a "Load File" button
with multipart upload endpoint and WebSocket broadcast. 78 tools, 14 prompts.
2026-01-31 15:58:19 -07:00
646c92324d 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].
2026-01-31 15:27:19 -07:00
e0fe09f3b8 Mixin refactor: split NanoVNA into 6 tool modules, add LC/component analysis
Break monolithic nanovna.py (~1750 lines) into focused mixin classes
in tools/ subpackage. NanoVNA now composes MeasurementMixin, ConfigMixin,
DisplayMixin, DeviceMixin, DiagnosticsMixin, and AnalysisMixin — server.py
registration via getattr() works unchanged.

New analysis tools: analyze_component, analyze_lc_series, analyze_lc_shunt,
analyze_lc_match, analyze_s11_resonance. Supporting math in calculations.py
(reactance_to_component, LC resonator analysis, impedance matching).

New prompts: measure_component, measure_lc_series, measure_lc_shunt,
impedance_match, measure_tdr, analyze_crystal, analyze_filter_response.

70 tools, 12 prompts registered.
2026-01-30 19:59:23 -07:00
39 changed files with 13270 additions and 1497 deletions

108
README.md Normal file
View File

@ -0,0 +1,108 @@
# mcnanovna
MCP server for controlling NanoVNA-H vector network analyzers over USB serial.
Provides 78 tools for frequency sweeps, S-parameter measurements, calibration, LCD capture, RF analysis, 3D antenna radiation pattern visualization, and pattern import from external files.
## Installation
```bash
# From PyPI (when published)
uvx mcnanovna
# From source
uv run mcnanovna
# With optional web UI for 3D pattern visualization
uv sync --extra webui
MCNANOVNA_WEB_PORT=8080 uv run mcnanovna
```
## Quick Start
Just plug in your NanoVNA-H and start the server. The device auto-connects on first tool call.
```bash
# Add to Claude Code
claude mcp add mcnanovna -- uvx mcnanovna
```
Then ask Claude to scan your antenna, analyze a filter, or measure a crystal.
## Tools
### Measurement
`info`, `sweep`, `scan`, `data`, `frequencies`, `marker`, `cal`, `save`, `recall`, `pause`, `resume`
### Configuration
`power`, `bandwidth`, `edelay`, `s21offset`, `vbat`, `capture`, `measure`, `config`, `saveconfig`, `clearconfig`, `color`, `freq`, `tcxo`, `vbat_offset`, `threshold`
### Display
`trace`, `transform`, `smooth`, `touchcal`, `touchtest`, `refresh`, `touch`, `release`
### Device
`reset`, `version`, `detect`, `disconnect`, `raw_command`, `cw`, `sd_list`, `sd_read`, `sd_delete`, `time`
### Diagnostics
`i2c`, `si`, `lcd`, `threads`, `stat`, `sample`, `test`, `gain`, `dump`, `port`, `offset`, `dac`, `usart_cfg`, `usart`, `band`
### Analysis
`analyze`, `export_touchstone`, `export_csv`, `analyze_filter`, `analyze_xtal`, `analyze_tdr`, `analyze_component`, `analyze_lc_series`, `analyze_lc_shunt`, `analyze_lc_match`, `analyze_s11_resonance`
### Radiation Patterns
`radiation_pattern`, `radiation_pattern_from_data`, `radiation_pattern_multi`
### Pattern Import
`import_pattern_csv`, `import_pattern_emcar`, `import_pattern_nec2`, `import_pattern_s1p`, `list_pattern_formats`
## Prompts
| Prompt | Description |
|--------|-------------|
| `calibrate` | SOLT calibration walkthrough |
| `export_touchstone` | S-parameter export to .s1p/.s2p |
| `analyze_antenna` | SWR, impedance, bandwidth analysis |
| `measure_cable` | TDR and cable characterization |
| `analyze_crystal` | Crystal motional parameter extraction |
| `analyze_filter_response` | Filter type classification and cutoffs |
| `measure_tdr` | Time domain reflectometry |
| `compare_sweeps` | Before/after comparison |
| `measure_component` | Unknown L/C/R identification |
| `measure_lc_series` | Series LC resonator measurement |
| `measure_lc_shunt` | Shunt LC resonator measurement |
| `impedance_match` | L-network matching design |
| `visualize_radiation_pattern` | 3D pattern from S11 scan |
| `import_pattern` | Load patterns from CSV/EMCAR/NEC2/S1P |
| `measure_antenna_range` | Automated 3D pattern with positioner |
## Web UI
Enable the optional 3D radiation pattern viewer:
```bash
uv sync --extra webui
MCNANOVNA_WEB_PORT=8080 uv run mcnanovna
```
Open http://localhost:8080 for an interactive Three.js visualization of antenna patterns.
## Antenna Positioner Integration
For automated 3D pattern measurement, use together with [mcpositioner](../mcpositioner/):
```bash
claude mcp add mcpositioner -- uvx mcpositioner
claude mcp add mcnanovna -- uvx mcnanovna
```
The `measure_antenna_range` prompt guides through the cross-server workflow.
## Supported Hardware
- NanoVNA-H (original)
- NanoVNA-H4
- Other variants using the same USB serial protocol (VID 0x0483, PID 0x5740)
## License
MIT

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"
}
}

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

@ -0,0 +1,117 @@
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 async function uploadPattern(
file: File,
options?: { frequency_hz?: number; polarization?: string; antenna_type?: string; reference_dbi?: number },
): Promise<PatternData> {
const form = new FormData();
form.append('file', file);
if (options?.frequency_hz) form.append('frequency_hz', String(options.frequency_hz));
if (options?.polarization) form.append('polarization', options.polarization);
if (options?.antenna_type) form.append('antenna_type', options.antenna_type);
if (options?.reference_dbi != null) form.append('reference_dbi', String(options.reference_dbi));
const resp = await fetch(`${BASE_URL}/api/pattern/import`, {
method: 'POST',
body: form,
});
if (!resp.ok) {
const body = await resp.text();
throw new Error(`Import failed (${resp.status}): ${body}`);
}
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();
},
};
}

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

@ -0,0 +1,254 @@
import type { DisplayMode, AppState } from './types';
import { iconRadio, iconActivity, iconCrosshair, iconUpload, iconWifi, iconWifiOff } from './icons';
export interface ControlCallbacks {
onCompute: () => void;
onScan: () => void;
onFileImport: (file: File) => 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);
// Hidden file input for pattern import
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = '.csv,.dat,.out,.nec,.s1p';
fileInput.style.display = 'none';
fileInput.addEventListener('change', () => {
const file = fileInput.files?.[0];
if (file) {
callbacks.onFileImport(file);
fileInput.value = '';
}
});
container.appendChild(fileInput);
const btnImport = document.createElement('button');
btnImport.className = 'ctrl-btn ctrl-btn-outline';
btnImport.innerHTML = `${iconUpload} Load File`;
btnImport.addEventListener('click', () => fileInput.click());
container.appendChild(btnImport);
// 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;
btnImport.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>`;
}

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

@ -0,0 +1,25 @@
// 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>`;
/** Upload icon (Lucide) */
export const iconUpload = `<svg ${ATTRS}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>`;

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

@ -0,0 +1,168 @@
import { createScene, type SceneContext } from './scene';
import { updatePattern } from './pattern';
import { createControls } from './controls';
import { drawSmithChart } from './smith';
import { fetchPattern, scanPattern, uploadPattern, 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);
}
}
async function handleFileImport(file: File) {
state.loading = true;
controlsUi.updateLoading(true);
try {
const data = await uploadPattern(file, {
frequency_hz: state.frequencyMhz * 1e6,
antenna_type: state.antennaType,
});
handlePatternData(data);
} catch (err) {
console.error('Import 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,
onFileImport: handleFileImport,
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();
}

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

@ -0,0 +1,407 @@
/* === 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);
}
.ctrl-btn-outline {
background: transparent;
color: var(--slate-400);
border-color: var(--slate-700);
border-style: dashed;
font-size: 0.8rem;
}
.ctrl-btn-outline:hover:not(:disabled) {
border-color: var(--slate-500);
border-style: solid;
color: var(--slate-200);
}
/* 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;
}

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

@ -0,0 +1,56 @@
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;
raw_cut?: { angles_deg: number[]; gain_db: number[]; plane: string };
import_info?: { format: string; filename?: string; points: number };
}
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.02.01"
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"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,700 @@
"""Import measured antenna patterns from external files.
Parses CSV, EMCAR vna.dat, NEC2 radiation output, and Touchstone S1P formats
into the standardized {theta_deg, phi_deg, gain_dbi} dict that the 3D viewer
and MCP tools already consume.
All functions are pure Python (math + list comprehensions). No external dependencies.
Content is passed as strings, not file paths MCP tools receive content from the LLM,
and the web UI endpoint handles multipart upload separately.
Inspired by the EMCAR antenna range (https://emcar.sourceforge.net/) which uses
LinuxCNC + HP8754A VNA for IEEE-149 stop-and-measure pattern recording.
"""
from __future__ import annotations
import math
import re
from mcnanovna.radiation import C, _deg_range
# ── Shared utilities ──────────────────────────────────────────────
def _build_pattern_dict(
thetas: list[float],
phis: list[float],
gain_dbi: list[list[float]],
model: str,
frequency_hz: float | None,
antenna_type: str,
metadata: dict | None = None,
) -> dict:
"""Assemble a standardized pattern dict matching generate_3d_pattern() output."""
peak = -999.0
for row in gain_dbi:
for g in row:
if g > peak:
peak = g
# E-plane: phi=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: theta=90 (row closest to 90 deg, 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))]
result: dict = {
"antenna_type": antenna_type,
"frequency_hz": frequency_hz,
"theta_deg": thetas,
"phi_deg": phis,
"gain_dbi": gain_dbi,
"peak_gain_dbi": round(peak, 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": model,
}
if frequency_hz:
result["wavelength_m"] = round(C / frequency_hz, 4)
if metadata:
result["import_info"] = metadata
return result
# ── Interpolation ─────────────────────────────────────────────────
def _great_circle_distance(t1: float, p1: float, t2: float, p2: float) -> float:
"""Great-circle angular distance between two (theta, phi) points in degrees.
Uses the Vincenty formula for numerical stability at small/large distances.
"""
t1r, p1r = math.radians(t1), math.radians(p1)
t2r, p2r = math.radians(t2), math.radians(p2)
dp = p2r - p1r
sin_t1, cos_t1 = math.sin(t1r), math.cos(t1r)
sin_t2, cos_t2 = math.sin(t2r), math.cos(t2r)
sin_dp, cos_dp = math.sin(dp), math.cos(dp)
num = math.sqrt((cos_t2 * sin_dp) ** 2 + (cos_t1 * sin_t2 - sin_t1 * cos_t2 * cos_dp) ** 2)
den = sin_t1 * sin_t2 + cos_t1 * cos_t2 * cos_dp
return math.degrees(math.atan2(num, den))
def interpolate_to_grid(
measurements: list[tuple[float, float, float]],
theta_step: float = 2.0,
phi_step: float = 5.0,
) -> tuple[list[float], list[float], list[list[float]]]:
"""Interpolate sparse measurements to a regular theta/phi grid.
Uses inverse-distance weighting (IDW) with great-circle distance on the
sphere surface. K=6 nearest neighbors, power=2 (Shepard's method).
Args:
measurements: List of (theta_deg, phi_deg, gain_dbi) tuples
theta_step: Output grid polar angle step (degrees)
phi_step: Output grid azimuthal angle step (degrees)
Returns:
(thetas, phis, gain_grid) where gain_grid[i][j] is gain in dBi
"""
thetas = _deg_range(0.0, 180.0, theta_step)
phis = _deg_range(0.0, 355.0, phi_step)
k = min(6, len(measurements))
gain_grid: list[list[float]] = []
for theta in thetas:
row: list[float] = []
for phi in phis:
# Compute distances to all measurement points
dists = []
for mt, mp, mg in measurements:
d = _great_circle_distance(theta, phi, mt, mp)
dists.append((d, mg))
# Exact match shortcut
dists.sort(key=lambda x: x[0])
if dists[0][0] < 0.01:
row.append(dists[0][1])
continue
# IDW with K nearest neighbors
nearest = dists[:k]
w_sum = 0.0
g_sum = 0.0
for d, g in nearest:
w = 1.0 / (d * d + 1e-10)
w_sum += w
g_sum += w * g
row.append(g_sum / w_sum)
gain_grid.append(row)
return thetas, phis, gain_grid
def single_cut_to_3d(
angles_deg: list[float],
gain_db: list[float],
cut_plane: str = "azimuth",
theta_step: float = 2.0,
phi_step: float = 5.0,
) -> tuple[list[float], list[float], list[list[float]]]:
"""Synthesize a 3D pattern from a single-plane cut measurement.
For azimuth cuts (typical of EMCAR): the measured data becomes the H-plane
(theta=90 deg), and elevation is synthesized with a sin(theta) taper
physically approximate but produces a useful 3D shape.
For elevation cuts: the measured data becomes the E-plane (phi=0 deg),
and the pattern is rotated around the vertical axis.
Args:
angles_deg: Measurement angles in degrees
gain_db: Gain values in dB at each angle
cut_plane: "azimuth" (H-plane) or "elevation" (E-plane)
theta_step: Output grid polar angle step
phi_step: Output grid azimuthal angle step
"""
thetas = _deg_range(0.0, 180.0, theta_step)
phis = _deg_range(0.0, 355.0, phi_step)
# Build a lookup for the measured cut via linear interpolation
def _interp_cut(angle: float) -> float:
"""Linearly interpolate the measured cut at an arbitrary angle."""
# Normalize to [0, 360)
angle = angle % 360.0
n = len(angles_deg)
if n == 0:
return -40.0
if n == 1:
return gain_db[0]
# Find bracketing points
normed = [a % 360.0 for a in angles_deg]
for i in range(n - 1):
a0, a1 = normed[i], normed[i + 1]
if a0 <= angle <= a1 and a1 > a0:
t = (angle - a0) / (a1 - a0)
return gain_db[i] * (1 - t) + gain_db[i + 1] * t
# Wrap-around interpolation
return gain_db[-1]
gain_grid: list[list[float]] = []
if cut_plane == "azimuth":
# Measured data is H-plane (theta=90). Synthesize elevation via sin(theta) taper.
for theta in thetas:
sin_t = math.sin(math.radians(theta))
taper = max(sin_t, 0.01) # Avoid -inf at poles
taper_db = 20.0 * math.log10(taper)
row = [_interp_cut(phi) + taper_db for phi in phis]
gain_grid.append(row)
else:
# Elevation cut: measured data defines E-plane, rotate around phi
for theta in thetas:
g_at_theta = _interp_cut(theta)
row = [g_at_theta] * len(phis)
gain_grid.append(row)
return thetas, phis, gain_grid
# ── Format parsers ────────────────────────────────────────────────
def parse_csv_pattern(content: str, frequency_hz: float | None = None) -> dict:
"""Parse a CSV file with antenna pattern data.
Auto-detects 2-column (angle, gain) vs 3-column (theta, phi, gain) from the
header row. Flexible header names: theta/elevation, phi/azimuth,
gain/gain_dbi/amplitude/db/dbi.
For 2-column data, the angle column name determines the cut plane:
- "phi" or "azimuth" azimuth cut
- "theta" or "elevation" elevation cut
Args:
content: CSV file content as string
frequency_hz: Operating frequency in Hz (optional, for metadata)
"""
lines = [line.strip() for line in content.strip().splitlines() if line.strip()]
if not lines:
raise ValueError("Empty CSV content")
# Parse header
header = lines[0].lower().replace(" ", "_")
cols = re.split(r"[,;\t]+", header)
# Identify column indices
theta_idx = phi_idx = gain_idx = -1
angle_idx = -1 # For 2-column mode
for i, col in enumerate(cols):
col_clean = col.strip()
if col_clean in ("theta", "theta_deg", "elevation", "el"):
theta_idx = i
elif col_clean in ("phi", "phi_deg", "azimuth", "az"):
phi_idx = i
elif col_clean in ("gain", "gain_dbi", "dbi", "db", "amplitude", "gain_db", "magnitude"):
gain_idx = i
elif col_clean in ("angle", "angle_deg", "deg", "degrees"):
angle_idx = i
# Determine mode
three_col = theta_idx >= 0 and phi_idx >= 0 and gain_idx >= 0
two_col = not three_col and gain_idx >= 0 and (theta_idx >= 0 or phi_idx >= 0 or angle_idx >= 0)
if not three_col and not two_col:
# Try numeric-only (no header): assume 2-col azimuth or 3-col
first_data = re.split(r"[,;\t]+", lines[0])
try:
vals = [float(v) for v in first_data]
if len(vals) >= 3:
three_col = True
theta_idx, phi_idx, gain_idx = 0, 1, 2
lines = lines # No header to skip
elif len(vals) == 2:
two_col = True
angle_idx, gain_idx = 0, 1
phi_idx = -1
lines = lines
else:
raise ValueError(f"Cannot parse CSV: unrecognized columns: {cols}")
except ValueError:
raise ValueError(f"Cannot parse CSV: unrecognized header columns: {cols}")
else:
lines = lines[1:] # Skip header
# Parse data
if three_col:
measurements: list[tuple[float, float, float]] = []
for line in lines:
parts = re.split(r"[,;\t]+", line.strip())
if len(parts) < max(theta_idx, phi_idx, gain_idx) + 1:
continue
try:
t = float(parts[theta_idx])
p = float(parts[phi_idx])
g = float(parts[gain_idx])
measurements.append((t, p, g))
except (ValueError, IndexError):
continue
if not measurements:
raise ValueError("No valid data rows found in CSV")
thetas, phis, gain_grid = interpolate_to_grid(measurements)
return _build_pattern_dict(
thetas,
phis,
gain_grid,
model="imported_csv",
frequency_hz=frequency_hz,
antenna_type="imported",
metadata={"format": "csv", "points": len(measurements), "columns": 3},
)
else:
# 2-column mode
use_idx = theta_idx if theta_idx >= 0 else (phi_idx if phi_idx >= 0 else angle_idx)
cut_plane = "elevation" if theta_idx >= 0 else "azimuth"
angles: list[float] = []
gains: list[float] = []
for line in lines:
parts = re.split(r"[,;\t]+", line.strip())
if len(parts) < max(use_idx, gain_idx) + 1:
continue
try:
a = float(parts[use_idx])
g = float(parts[gain_idx])
angles.append(a)
gains.append(g)
except (ValueError, IndexError):
continue
if not angles:
raise ValueError("No valid data rows found in CSV")
thetas, phis, gain_grid = single_cut_to_3d(angles, gains, cut_plane=cut_plane)
result = _build_pattern_dict(
thetas,
phis,
gain_grid,
model="imported_csv_single_cut",
frequency_hz=frequency_hz,
antenna_type="imported",
metadata={"format": "csv", "points": len(angles), "columns": 2, "cut_plane": cut_plane},
)
result["raw_cut"] = {"angles_deg": angles, "gain_db": gains, "plane": cut_plane}
return result
def parse_emcar_vna_dat(
content: str,
frequency_hz: float | None = None,
reference_dbi: float | None = None,
) -> dict:
"""Parse EMCAR vna.dat format (angle + amplitude_dBV pairs).
EMCAR records raw ADC voltage from the HP8754A VNA. Lines starting with #
are comments. Data is whitespace-separated: angle amplitude.
The gnuplot transform from EMCAR's plotting scripts is applied:
- Angle rotation: (-angle + 90) to convert from positioner coords to antenna coords
- Amplitude: 20*log10(amplitude + 0.01) to convert voltage to dB scale
Without a reference antenna, this shows relative pattern shape only.
Pass reference_dbi to offset the entire pattern to absolute gain.
Args:
content: vna.dat file content as string
frequency_hz: Operating frequency in Hz (optional)
reference_dbi: Offset to apply for absolute gain calibration (optional)
"""
angles: list[float] = []
gains: list[float] = []
for line in content.strip().splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split()
if len(parts) < 2:
continue
try:
angle = float(parts[0])
amplitude = float(parts[1])
except ValueError:
continue
# EMCAR gnuplot transform
rotated_angle = (-angle + 90.0) % 360.0
# Voltage to dB: 20*log10(amplitude + 0.01) — the +0.01 prevents log(0)
gain_db = 20.0 * math.log10(abs(amplitude) + 0.01)
if reference_dbi is not None:
gain_db += reference_dbi
angles.append(rotated_angle)
gains.append(gain_db)
if not angles:
raise ValueError("No valid data rows found in EMCAR vna.dat content")
# Sort by angle for clean interpolation
paired = sorted(zip(angles, gains), key=lambda x: x[0])
angles = [a for a, _ in paired]
gains = [g for _, g in paired]
thetas, phis, gain_grid = single_cut_to_3d(angles, gains, cut_plane="azimuth")
result = _build_pattern_dict(
thetas,
phis,
gain_grid,
model="imported_emcar",
frequency_hz=frequency_hz,
antenna_type="imported",
metadata={
"format": "emcar",
"points": len(angles),
"reference_dbi": reference_dbi,
"note": "Relative pattern shape" if reference_dbi is None else "Calibrated with reference",
},
)
result["raw_cut"] = {"angles_deg": angles, "gain_db": gains, "plane": "azimuth"}
return result
def parse_nec2_radiation(content: str, polarization: str = "total") -> dict:
"""Parse NEC2 radiation pattern output.
Finds the "RADIATION PATTERNS" section in NEC2 output files and extracts
the theta/phi/gain grid. Supports TOTAL, VERT, and HOR polarization selection.
NEC2 output columns (after the header):
THETA PHI VERT(dB) HOR(dB) TOTAL(dB) AXIAL_RATIO TILT SENSE
Values of -999.99 are mapped to a -40 dBi floor.
Args:
content: NEC2 output file content as string
polarization: Which gain column to use: "total", "vert", or "hor"
"""
lines = content.splitlines()
frequency_hz: float | None = None
measurements: list[tuple[float, float, float]] = []
# Column index for the selected polarization
pol_map = {"vert": 0, "hor": 1, "total": 2}
if polarization.lower() not in pol_map:
raise ValueError(f"polarization must be 'total', 'vert', or 'hor', got '{polarization}'")
pol_col = pol_map[polarization.lower()]
in_pattern = False
header_lines_remaining = 0
for line in lines:
stripped = line.strip()
# Look for frequency in the header area
freq_match = re.search(r"FREQUENCY\s*[=:]\s*([\d.]+)\s*(MHZ|MHz|GHZ|GHz|HZ|Hz)", stripped, re.IGNORECASE)
if freq_match:
freq_val = float(freq_match.group(1))
unit = freq_match.group(2).upper()
if unit == "MHZ":
frequency_hz = freq_val * 1e6
elif unit == "GHZ":
frequency_hz = freq_val * 1e9
else:
frequency_hz = freq_val
# Detect start of radiation pattern section
if "RADIATION PATTERNS" in stripped.upper():
in_pattern = True
header_lines_remaining = 3 # Skip title + column headers + separator
continue
if in_pattern:
if header_lines_remaining > 0:
header_lines_remaining -= 1
continue
# End of section: blank line or new section header
if not stripped or stripped.startswith("*") or stripped.startswith("-" * 10):
if measurements:
break
continue
# Parse data line
parts = stripped.split()
if len(parts) < 5:
continue
try:
theta = float(parts[0])
phi = float(parts[1])
# Columns 2,3,4 are VERT(dB), HOR(dB), TOTAL(dB)
gain_val = float(parts[2 + pol_col])
# NEC2 uses -999.99 for undefined/zero
if gain_val < -900:
gain_val = -40.0
measurements.append((theta, phi, gain_val))
except (ValueError, IndexError):
continue
if not measurements:
raise ValueError("No radiation pattern data found in NEC2 output")
thetas, phis, gain_grid = interpolate_to_grid(measurements)
return _build_pattern_dict(
thetas,
phis,
gain_grid,
model="imported_nec2",
frequency_hz=frequency_hz,
antenna_type="imported",
metadata={
"format": "nec2",
"points": len(measurements),
"polarization": polarization.lower(),
},
)
def parse_touchstone_s1p(
content: str,
antenna_type: str = "auto",
) -> dict:
"""Parse Touchstone S1P file and generate an analytical pattern at resonance.
Reads S11 complex data, finds the resonant frequency (minimum |S11|),
computes impedance, estimates antenna type, and delegates to
radiation.generate_3d_pattern(). This bridges imported S1P data to the
Phase 1 analytical pattern engine.
Args:
content: Touchstone .s1p file content as string
antenna_type: Antenna model, or 'auto' to estimate from impedance
"""
from mcnanovna.radiation import estimate_antenna_type, generate_3d_pattern
lines = content.strip().splitlines()
freq_mult = 1.0
data_format = "ri" # Default: real/imaginary
z0 = 50.0
frequencies: list[float] = []
s11_complex: list[complex] = []
for line in lines:
line = line.strip()
if not line or line.startswith("!"):
continue
# Option line: # Hz S RI R 50
if line.startswith("#"):
parts = line[1:].strip().upper().split()
for i, p in enumerate(parts):
if p in ("HZ", "KHZ", "MHZ", "GHZ"):
freq_mult = {"HZ": 1.0, "KHZ": 1e3, "MHZ": 1e6, "GHZ": 1e9}[p]
elif p in ("RI", "MA", "DB"):
data_format = p.lower()
elif p == "R" and i + 1 < len(parts):
try:
z0 = float(parts[i + 1])
except ValueError:
pass
continue
# Data line
parts = line.split()
if len(parts) < 3:
continue
try:
freq = float(parts[0]) * freq_mult
v1, v2 = float(parts[1]), float(parts[2])
if data_format == "ri":
s11 = complex(v1, v2)
elif data_format == "ma":
s11 = v1 * complex(math.cos(math.radians(v2)), math.sin(math.radians(v2)))
elif data_format == "db":
mag = 10.0 ** (v1 / 20.0)
s11 = mag * complex(math.cos(math.radians(v2)), math.sin(math.radians(v2)))
else:
s11 = complex(v1, v2)
frequencies.append(freq)
s11_complex.append(s11)
except (ValueError, IndexError):
continue
if not frequencies:
raise ValueError("No valid S-parameter data found in Touchstone file")
# Find resonance: minimum |S11|
min_mag = 999.0
min_idx = 0
for i, s in enumerate(s11_complex):
mag = abs(s)
if mag < min_mag:
min_mag = mag
min_idx = i
res_freq = frequencies[min_idx]
s11_res = s11_complex[min_idx]
# Compute impedance at resonance: Z = Z0 * (1+S11) / (1-S11)
denom = 1.0 - s11_res
if abs(denom) < 1e-10:
z_real, z_imag = 9999.0, 0.0
else:
z = z0 * (1.0 + s11_res) / denom
z_real, z_imag = z.real, z.imag
# Estimate antenna type if needed
if antenna_type == "auto":
antenna_type = estimate_antenna_type(z_real, z_imag, res_freq)
pattern = generate_3d_pattern(
antenna_type=antenna_type,
frequency_hz=res_freq,
s11_analysis={
"resonance": {
"frequency_hz": res_freq,
"impedance_real": round(z_real, 2),
"impedance_imag": round(z_imag, 2),
"s11_magnitude": round(min_mag, 4),
}
},
)
pattern["model"] = "imported_s1p_analytical"
pattern["import_info"] = {
"format": "s1p",
"points": len(frequencies),
"frequency_range_hz": [frequencies[0], frequencies[-1]],
"resonant_frequency_hz": res_freq,
"impedance_at_resonance": {"real": round(z_real, 2), "imag": round(z_imag, 2)},
"z0": z0,
}
return pattern
# ── Format auto-detection ─────────────────────────────────────────
def detect_format(filename: str, content: str | None = None) -> str:
"""Detect pattern file format from filename extension and optional content inspection.
Returns one of: "csv", "emcar", "nec2", "s1p"
"""
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
if ext == "csv":
return "csv"
if ext == "dat":
return "emcar"
if ext in ("out", "nec"):
return "nec2"
if ext == "s1p":
return "s1p"
# Content-based detection fallback
if content:
if "RADIATION PATTERNS" in content.upper():
return "nec2"
if content.lstrip().startswith("#") and re.search(r"#\s*\w+\s+S\s+", content[:200]):
return "s1p"
if re.match(r"[\w_]+[,;\t]", content.lstrip().split("\n")[0]):
return "csv"
raise ValueError(f"Cannot detect format from filename '{filename}'. Use .csv, .dat, .out, .nec, or .s1p")
def parse_pattern(
content: str,
format: str,
frequency_hz: float | None = None,
**kwargs,
) -> dict:
"""Parse a pattern file with explicit format selection.
Convenience dispatcher that routes to the appropriate parser.
Args:
content: File content as string
format: One of "csv", "emcar", "nec2", "s1p"
frequency_hz: Operating frequency (used by CSV and EMCAR)
**kwargs: Additional format-specific arguments
"""
if format == "csv":
return parse_csv_pattern(content, frequency_hz=frequency_hz)
elif format == "emcar":
return parse_emcar_vna_dat(content, frequency_hz=frequency_hz, reference_dbi=kwargs.get("reference_dbi"))
elif format == "nec2":
return parse_nec2_radiation(content, polarization=kwargs.get("polarization", "total"))
elif format == "s1p":
return parse_touchstone_s1p(content, antenna_type=kwargs.get("antenna_type", "auto"))
else:
raise ValueError(f"Unknown format '{format}'. Supported: csv, emcar, nec2, s1p")

View File

@ -156,19 +156,19 @@ Let me start by setting the sweep range. Ready?""",
**Scan range**: {_format_freq(start_hz)} {_format_freq(stop_hz)}
**Points**: {points}
**Format**: {'S2P (S11 + S21 — requires DUT connected between ports)' if s2p else 'S1P (S11 reflection only)'}
**Format**: {"S2P (S11 + S21 — requires DUT connected between ports)" if s2p else "S1P (S11 reflection only)"}
**Touchstone format reference** (IEEE Std 1363):
```
! NanoVNA-H measurement
# Hz S RI R 50
! freq {'s11_re s11_im s21_re s21_im s12_re s12_im s22_re s22_im' if s2p else 's11_re s11_im'}
! freq {"s11_re s11_im s21_re s21_im s12_re s12_im s22_re s22_im" if s2p else "s11_re s11_im"}
```
I'll:
1. Run a `scan` from {start_hz} to {stop_hz} with {points} points{', capturing both S11 and S21' if s2p else ', S11 only'}
1. Run a `scan` from {start_hz} to {stop_hz} with {points} points{", capturing both S11 and S21" if s2p else ", S11 only"}
2. Format each data point as real/imaginary pairs
3. {'For S2P: S12 and S22 will be set to 0+j0 (NanoVNA is a 1-port/2-port device that measures S11 and S21 only)' if s2p else 'Each line: frequency S11_real S11_imag'}
3. {"For S2P: S12 and S22 will be set to 0+j0 (NanoVNA is a 1-port/2-port device that measures S11 and S21 only)" if s2p else "Each line: frequency S11_real S11_imag"}
4. Present the complete file content for you to save
Let me run the scan now.""",
@ -206,10 +206,7 @@ Let me run the scan now.""",
return [
Message(
role="user",
content=(
f"Analyze my antenna on the {band_label} "
f"({_format_freq(f_start)} {_format_freq(f_stop)})."
),
content=(f"Analyze my antenna on the {band_label} ({_format_freq(f_start)} {_format_freq(f_stop)})."),
),
Message(
role="assistant",
@ -258,8 +255,7 @@ Let me run the scan and analysis now.""",
Message(
role="user",
content=(
f"Analyze a cable/transmission line from "
f"{_format_freq(start_hz)} to {_format_freq(stop_hz)}."
f"Analyze a cable/transmission line from {_format_freq(start_hz)} to {_format_freq(stop_hz)}."
),
),
Message(
@ -290,6 +286,202 @@ Let me start with the open-ended measurement. Is the cable connected with the fa
),
]
@mcp.prompt
def analyze_crystal(
frequency_hz: int = 10_000_000,
span_hz: int = 100_000,
points: int = 201,
) -> list[Message]:
"""Guide through quartz crystal parameter extraction.
Measures a crystal's motional parameters (Rm, Lm, Cm, Cp) and Q factor
using S21 transmission through a series test fixture.
Args:
frequency_hz: Nominal crystal frequency in Hz (e.g. 10000000 for 10 MHz)
span_hz: Frequency span around nominal (e.g. 100000 for +/-50 kHz)
points: Number of measurement points (201 recommended for resolution)
"""
start = frequency_hz - span_hz // 2
stop = frequency_hz + span_hz // 2
return [
Message(
role="user",
content=(
f"Measure and extract parameters for a {_format_freq(frequency_hz)} "
f"crystal with {_format_freq(span_hz)} span."
),
),
Message(
role="assistant",
content=f"""I'll guide you through crystal parameter extraction using the series-jig method.
**Target crystal**: {_format_freq(frequency_hz)} (nominal)
**Scan range**: {_format_freq(start)} {_format_freq(stop)} ({points} points)
**Test fixture setup** (series measurement jig):
```
Port 1 Crystal Port 2
SMA SMA
```
The crystal is placed in series between the two ports. S21 transmission
peaks at the series resonance frequency (fs) where the crystal's motional
impedance is at minimum.
**What I'll extract:**
- **fs** Series resonance frequency (max S21 transmission)
- **fp** Parallel resonance frequency (min S21 transmission, anti-resonance)
- **Rm** Motional resistance (ESR at resonance)
- **Lm** Motional inductance
- **Cm** Motional capacitance
- **Cp** Holder/shunt capacitance (from fs-fp spacing)
- **Q** Quality factor (typically 10,000100,000 for quartz)
- **Insertion loss** at series resonance
**Tips for accurate measurements:**
- Calibrate first (the `calibrate` prompt helps)
- Use short SMA cables to minimize fixture parasitics
- A span of 2-5x the crystal bandwidth gives good resolution
- For narrow-bandwidth crystals (high Q), increase points to 201 or 401
**Procedure:**
1. Connect the crystal in the series test jig between Port 1 and Port 2
2. I'll run `analyze_xtal` with the scan parameters
3. I'll present the extracted motional equivalent circuit parameters
Ready? Let me start the measurement.""",
),
]
@mcp.prompt
def analyze_filter_response(
start_hz: int = 1_000_000,
stop_hz: int = 500_000_000,
points: int = 201,
) -> list[Message]:
"""Guide through filter characterization and classification.
Measures S21 transmission through a filter to determine type,
cutoff frequencies, bandwidth, Q factor, and roll-off rate.
Args:
start_hz: Start frequency in Hz
stop_hz: Stop frequency in Hz
points: Number of measurement points (201 recommended)
"""
return [
Message(
role="user",
content=(
f"Characterize a filter from {_format_freq(start_hz)} to "
f"{_format_freq(stop_hz)} with {points} points."
),
),
Message(
role="assistant",
content=f"""I'll measure and classify your filter using S21 transmission analysis.
**Scan range**: {_format_freq(start_hz)} {_format_freq(stop_hz)} ({points} points)
**Setup:**
Connect the filter between Port 1 (input) and Port 2 (output).
Ensure proper impedance matching (50\u03a9 typically).
**What I'll determine:**
- **Filter type**: lowpass, highpass, bandpass, or bandstop
- **Cutoff frequencies** at -3dB, -6dB, -10dB, and -20dB thresholds
- **Bandwidth** at -3dB and -6dB (for bandpass filters)
- **Center frequency** and **Q factor** (for bandpass filters)
- **Roll-off rate** in dB/decade and dB/octave (skirt steepness)
- **Peak insertion loss** (passband flatness)
**Interpreting results:**
- **Roll-off** indicates filter order: ~20 dB/decade per pole (1st order = 20, 2nd = 40, etc.)
- **Q factor** for bandpass: higher Q = narrower bandwidth relative to center frequency
- **Insertion loss** in passband: lower is better (<1 dB for good passive filters)
- Compare -3dB and -6dB bandwidths to assess skirt shape
**Scan tips:**
- Start well below the expected passband for complete characterization
- Extend stop frequency to capture the full stopband roll-off
- Use 201+ points for accurate cutoff interpolation
Let me run the `analyze_filter` tool now.""",
),
]
@mcp.prompt
def measure_tdr(
start_hz: int = 100_000,
stop_hz: int = 900_000_000,
points: int = 201,
velocity_factor: float = 0.66,
) -> list[Message]:
"""Guide through Time Domain Reflectometry cable analysis.
Scans S11 and transforms to the time/distance domain to reveal
impedance discontinuities, cable faults, and connection quality.
Args:
start_hz: Start frequency in Hz (lower = longer range)
stop_hz: Stop frequency in Hz (higher = better resolution)
points: Number of measurement points (201 recommended)
velocity_factor: Cable velocity factor (0.66 for RG-58, see table)
"""
return [
Message(
role="user",
content=(
f"Run TDR analysis from {_format_freq(start_hz)} to "
f"{_format_freq(stop_hz)} with VF={velocity_factor}."
),
),
Message(
role="assistant",
content=f"""I'll perform Time Domain Reflectometry to map impedance along your cable.
**Scan range**: {_format_freq(start_hz)} {_format_freq(stop_hz)} ({points} points)
**Velocity factor**: {velocity_factor}
**How TDR works:**
S11 reflection data across a wide frequency span is transformed to the
time domain via inverse FFT. Each time delay corresponds to a physical
distance along the cable. Impedance changes (connectors, damage, open/short
ends) appear as reflection peaks at their physical location.
**Setup:**
Connect one end of the cable to Port 1 (CH0). The far end can be:
- **Open** large positive reflection at the end
- **Shorted** large negative reflection at the end
- **Terminated (50\u03a9)** no reflection at the end (only see faults)
**Velocity factor reference** (VF determines distance accuracy):
| Cable Type | VF |
|---|---|
| RG-58, RG-8 (solid PE) | 0.66 |
| RG-213 (solid PE) | 0.66 |
| LMR-400 | 0.85 |
| Foam PE dielectric | 0.82 |
| Air dielectric / hardline | 0.950.97 |
| RG-174 | 0.66 |
| Belden 9913 | 0.84 |
**Resolution and range** (determined by frequency span):
- Resolution \u2248 c \u00d7 VF / (2 \u00d7 span) wider span = finer detail
- Max range \u2248 c \u00d7 VF \u00d7 (N-1) / (2 \u00d7 span) more points = longer range
- With {_format_freq(stop_hz - start_hz)} span and VF={velocity_factor}:
~{299_792_458 * velocity_factor / (2 * (stop_hz - start_hz)):.2f} m resolution
**Window options:**
- **minimum** (beta=0): sharpest peaks but more sidelobe ringing
- **normal** (beta=6): good balance of resolution and sidelobe suppression
- **maximum** (beta=13): smoothest response, wider peaks
Let me run `analyze_tdr` now.""",
),
]
@mcp.prompt
def compare_sweeps(
start_hz: int = 1_000_000,
@ -341,3 +533,600 @@ for a valid comparison. Don't change calibration between scans.
Set up the **"before"** condition and tell me when ready for scan #1.""",
),
]
@mcp.prompt
def measure_component(
start_hz: int = 1_000_000,
stop_hz: int = 500_000_000,
points: int = 101,
) -> list[Message]:
"""Guide through identifying an unknown component (inductor, capacitor, or resistor).
Scans S11 reflection to classify the component, report its value,
ESR, Q factor, and self-resonant frequency.
Args:
start_hz: Start frequency in Hz
stop_hz: Stop frequency in Hz
points: Number of measurement points
"""
return [
Message(
role="user",
content=(f"Identify an unknown component from {_format_freq(start_hz)} to {_format_freq(stop_hz)}."),
),
Message(
role="assistant",
content=f"""I'll identify your component using S11 reflection analysis.
**Scan range**: {_format_freq(start_hz)} \u2013 {_format_freq(stop_hz)} ({points} points)
**Setup:**
Connect the unknown component to Port 1 using a test fixture or SMA adapter.
Port 2 is not used for this measurement.
**How S11 component identification works:**
The VNA measures the complex reflection coefficient (\u0393) at Port 1.
From \u0393, I compute the impedance Z = R + jX at each frequency:
- **Inductor**: positive reactance (X > 0), increasing with frequency. X = 2\u03c0fL
- **Capacitor**: negative reactance (X < 0), magnitude decreasing with frequency. X = \u22121/(2\u03c0fC)
- **Resistor**: minimal reactance across the sweep, R dominates
- **LC circuit**: reactance crosses zero at the self-resonant frequency (SRF)
**Frequency range tips:**
- **Large inductors** (>\u00b5H): use lower start frequency (100 kHz \u2013 50 MHz)
- **Small inductors** (<100 nH): use higher range (50 MHz \u2013 900 MHz)
- **Large capacitors** (>100 pF): lower range works well
- **Small capacitors** (<10 pF): use higher frequencies
- Start wide, then narrow down around the region of interest
**What I'll report:**
- Component type (inductor / capacitor / resistor / LC circuit)
- Primary value (nH, pF, or \u03a9)
- ESR (equivalent series resistance)
- Q factor at the measurement frequency
- Self-resonant frequency if the component has one
Let me run `analyze_component` now.""",
),
]
@mcp.prompt
def measure_lc_series(
start_hz: int = 1_000_000,
stop_hz: int = 500_000_000,
points: int = 201,
measure_r: float = 50.0,
) -> list[Message]:
"""Guide through series LC resonator measurement.
Measures a component in series between Port 1 and Port 2 using S21
transmission. At resonance, the series LC has minimum impedance and
maximum S21 transmission.
Args:
start_hz: Start frequency in Hz
stop_hz: Stop frequency in Hz
points: Number of measurement points (201 recommended)
measure_r: Port termination resistance in ohms (default 50)
"""
return [
Message(
role="user",
content=(f"Measure a series LC resonator from {_format_freq(start_hz)} to {_format_freq(stop_hz)}."),
),
Message(
role="assistant",
content=f"""I'll characterize your series LC resonator using S21 transmission measurement.
**Scan range**: {_format_freq(start_hz)} \u2013 {_format_freq(stop_hz)} ({points} points)
**Port termination**: {measure_r} \u03a9
**Test fixture setup** (series topology):
```
Port 1 \u2500\u2500\u2500\u2500\u2500[ DUT ]\u2500\u2500\u2500\u2500\u2500 Port 2
in series
```
The device under test is placed **in the signal path** between Port 1 and
Port 2. At the resonant frequency, the series LC has minimum impedance,
allowing maximum signal through \u2014 a **peak** in S21.
**What I'll extract:**
- **Resonant frequency** (peak S21 transmission)
- **Motional resistance** Rm (series resistance at resonance)
- **Inductance** L (from phase bandwidth)
- **Capacitance** C (from phase bandwidth)
- **Q factor** = 2\u03c0fL/Rm
- **Bandwidth** (from \u00b145\u00b0 phase crossings)
- **Insertion loss** at resonance
**Series vs crystal measurement:**
This tool extracts the same parameters as crystal analysis but without
searching for parallel resonance or holder capacitance. Use `analyze_crystal`
for quartz crystals, and this tool for general LC resonators, ceramic
resonators, or SAW devices.
**Tips:**
- Use 201+ points for narrow-bandwidth resonators
- Center the scan range around the expected resonance
- A wider span helps if you're unsure of the exact frequency
Let me run `analyze_lc_series` now.""",
),
]
@mcp.prompt
def measure_lc_shunt(
start_hz: int = 1_000_000,
stop_hz: int = 500_000_000,
points: int = 201,
measure_r: float = 50.0,
) -> list[Message]:
"""Guide through shunt LC resonator measurement.
Measures a component connected as a shunt (to ground) using S21
transmission. At resonance, the shunt LC absorbs signal, producing
a transmission dip.
Args:
start_hz: Start frequency in Hz
stop_hz: Stop frequency in Hz
points: Number of measurement points (201 recommended)
measure_r: Port termination resistance in ohms (default 50)
"""
return [
Message(
role="user",
content=(f"Measure a shunt LC resonator from {_format_freq(start_hz)} to {_format_freq(stop_hz)}."),
),
Message(
role="assistant",
content=f"""I'll characterize your shunt LC resonator using S21 transmission measurement.
**Scan range**: {_format_freq(start_hz)} \u2013 {_format_freq(stop_hz)} ({points} points)
**Port termination**: {measure_r} \u03a9
**Test fixture setup** (shunt topology):
```
Port 1 \u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500 Port 2
\u2502 \u2502
[DUT] signal
\u2502 path
GND
```
The device under test is connected from the signal path **to ground**
(shunt configuration). At resonance, the parallel LC presents maximum
impedance to ground, creating an **absorption dip** in S21 transmission.
**How it differs from series measurement:**
| | Series | Shunt |
|---|---|---|
| S21 at resonance | **Peak** (max transmission) | **Dip** (min transmission) |
| DUT placement | In-line between ports | From signal path to ground |
| Resonance impedance | Minimum (short) | Maximum (open to ground) |
| Typical use | Crystal filters, series traps | Notch filters, EMI suppression |
**What I'll extract:**
- **Resonant frequency** (minimum S21 transmission)
- **Motional resistance** Rm (from attenuation depth)
- **Inductance** L and **Capacitance** C
- **Q factor** = f/bandwidth
- **Bandwidth** and **insertion loss**
**Applications:**
- Notch filter characterization
- EMI filter evaluation
- Parallel resonator tuning
- LC tank circuit measurement
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,
r: float = 25.0,
x: float = 15.0,
z0: float = 50.0,
) -> list[Message]:
"""Guide through impedance matching network design.
Computes L-network solutions to match a load impedance to a target
impedance (typically 50 ohm). Can use direct R+jX values or scan S11.
Args:
frequency_hz: Design frequency in Hz
r: Load resistance in ohms (example default)
x: Load reactance in ohms (example default)
z0: Target impedance in ohms (default 50)
"""
return [
Message(
role="user",
content=(
f"Design an impedance matching network for "
f"{r}+j{x} \u03a9 at {_format_freq(frequency_hz)}, "
f"matching to {z0} \u03a9."
),
),
Message(
role="assistant",
content=f"""I'll compute L-network impedance matching solutions for your load.
**Design parameters:**
- **Load impedance**: {r} + j{x} \u03a9
- **Target impedance**: {z0} \u03a9
- **Frequency**: {_format_freq(frequency_hz)}
**What is an L-network?**
The simplest broadband matching network uses two reactive components
(inductors and/or capacitors) arranged in an "L" shape. There are typically
2\u20134 valid solutions, each with different component values and bandwidth
characteristics.
**L-network topologies:**
```
Source shunt \u2500\u2500 Series \u2500\u2500 Load shunt
\u2502 \u2502
[Zp] \u2500\u2500\u2500[Zs]\u2500\u2500\u2500 [Zp]
\u2502 \u2502
GND GND
```
Each solution specifies which positions get an inductor, capacitor, or
nothing. The tool returns up to 4 solutions.
**Two modes available:**
1. **Direct mode** (no hardware needed): Provide R and X values directly.
Use this when you already know the impedance from a previous measurement
or from a datasheet.
2. **Scan mode**: Provide start/stop frequencies and the tool scans S11 to
measure the actual impedance at the target frequency. More accurate when
the load is available for measurement.
**Choosing between solutions:**
- Prefer solutions with **fewer components** (null entries)
- **Capacitor in shunt** + **inductor in series** is the most common topology
- Higher-Q solutions give narrower bandwidth (sharper match)
- Consider practical component values (avoid sub-nH or sub-pF)
Let me compute the matching solutions now using `analyze_lc_match`.""",
),
]
@mcp.prompt
def measure_antenna_range(
antenna_type: str = "dipole",
band: str = "2m",
start_hz: int | None = None,
stop_hz: int | None = None,
points: int = 51,
theta_step: float = 5.0,
phi_step: float = 10.0,
settle_ms: int = 200,
) -> list[Message]:
"""Guide through automated 3D antenna pattern measurement using positioner + VNA.
This is a cross-server workflow: mcpositioner (separate MCP server) controls
the ESP32 antenna positioner, while this server (mcnanovna) provides VNA
measurements. The LLM orchestrates both.
Args:
antenna_type: Antenna label for metadata (e.g., 'dipole', 'yagi', 'measured')
band: Ham band name (e.g., '2m', '70cm') or 'custom'
start_hz: Start frequency in Hz (overrides band)
stop_hz: Stop frequency in Hz (overrides band)
points: Number of frequency points per S21 measurement
theta_step: Polar angle step in degrees (smaller = higher resolution, longer scan)
phi_step: Azimuth step in degrees
settle_ms: Milliseconds to wait after each positioner move before measuring
"""
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)} \u2013 {_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"
n_theta = int(180 / theta_step) + 1
n_phi = int(360 / phi_step)
total = n_theta * n_phi
est_minutes = total * (settle_ms / 1000.0 + 1.0) / 60
return [
Message(
role="user",
content=(
f"Measure the 3D radiation pattern of my {antenna_type} antenna "
f"on the {band_label} using the positioner and VNA."
),
),
Message(
role="assistant",
content=f"""I'll guide you through an automated 3D radiation pattern measurement.
This workflow uses **two MCP servers working together**:
- **mcnanovna** \u2014 controls the NanoVNA for S21 measurements (this server)
- **mcpositioner** \u2014 controls the ESP32 antenna positioner (separate server)
You'll need the mcpositioner MCP server running. Add it with:
`claude mcp add mcpositioner -- uvx mcpositioner`
**Grid setup:**
- Band: {band_label} ({_format_freq(f_start)} \u2013 {_format_freq(f_stop)})
- Grid: {theta_step}\u00b0 theta x {phi_step}\u00b0 phi = {n_theta} x {n_phi} = **{total} points**
- VNA scan: {points} frequency points per position
- Settle time: {settle_ms} ms after each move
- Estimated time: ~{est_minutes:.0f} minutes
**Hardware setup:**
1. ESP32 positioner powered and on WiFi (positioner.local)
2. NanoVNA-H connected via USB
3. Transmit antenna on NanoVNA Port 1 (stationary, aimed at positioner)
4. Antenna under test mounted on positioner, connected to NanoVNA Port 2
---
**Step 1: Pre-flight checks**
Call these tools to verify both systems are ready:
- `positioner_status` (mcpositioner) \u2014 verify ESP32 is reachable
- `info` (mcnanovna) \u2014 verify VNA is connected
**Step 2: Home the positioner**
Call `positioner_home` (mcpositioner) with axis='both'.
Wait for it to complete, then verify with `positioner_status`.
**Step 3: Calibrate VNA**
If not already calibrated, run a SOLT calibration covering {_format_freq(f_start)} to {_format_freq(f_stop)}.
**Step 4: Measure the grid**
For each point in the theta/phi grid, execute this sequence:
```
for theta in [0, {theta_step}, {2 * theta_step}, ..., 180]:
for phi in [0, {phi_step}, {2 * phi_step}, ..., {360 - phi_step}]:
1. positioner_move(theta, phi, wait=True) [mcpositioner]
2. wait {settle_ms}ms for mechanical settling
3. scan({f_start}, {f_stop}, {points}, s21=True) [mcnanovna]
4. extract peak S21 magnitude in dB from scan result
5. record: {{theta_deg: theta, phi_deg: phi, s21_peak_db: peak_db}}
```
**Serpentine optimization:** On odd-numbered theta rows, reverse the phi direction
to minimize motor travel.
**Extracting peak S21 from scan result:**
The scan returns data points with s21 complex values (real + imag).
magnitude_db = 20 * log10(sqrt(real^2 + imag^2)).
Take the maximum dB value across all frequency points.
**Step 5: Build the pattern dict**
Assemble measurements into the standard format:
```
pattern = {{
"antenna_type": "{antenna_type}",
"frequency_hz": {(f_start + f_stop) / 2},
"theta_deg": [list of theta values],
"phi_deg": [list of phi values],
"gain_dbi": [list of normalized gain values],
"peak_gain_dbi": max(gain_values),
"num_points": {total},
}}
```
**Relative vs. absolute calibration:**
- **Relative** (default): gain_dbi[i] = s21_peak_db[i] - max(all s21_peak_db)
- **Absolute**: Measure known-gain reference antenna at bore-sight first.
**Resolution tradeoffs:**
| Step size | Grid points | Est. time | Use case |
|-----------|------------|-----------|----------|
| 10\u00b0 x 20\u00b0 | 19 x 18 = 342 | ~9 min | Quick survey |
| 5\u00b0 x 10\u00b0 | 37 x 36 = 1332 | ~33 min | Standard |
| 2\u00b0 x 5\u00b0 | 91 x 72 = 6552 | ~164 min | High-res |
**Web UI**: If mcnanovna's web UI is running (MCNANOVNA_WEB_PORT), the final
pattern dict can be sent to /api/pattern/compute for 3D rendering.
Ready to start? I'll begin with the pre-flight checks on both servers.""",
),
]
@mcp.prompt
def import_pattern(
format: str = "csv",
) -> list[Message]:
"""Guide through importing a measured or simulated antenna pattern.
Walks through the process of importing pattern data from external files
(CSV, EMCAR vna.dat, NEC2 output, or Touchstone S1P) for 3D visualization.
Args:
format: File format \u2014 'csv', 'emcar', 'nec2', or 's1p'
"""
format_details = {
"csv": {
"name": "CSV",
"ext": ".csv",
"tool": "import_pattern_csv",
"desc": (
"Comma/semicolon/tab-separated with flexible headers.\n"
"- **3-column**: theta, phi, gain_dbi \u2014 full 3D pattern\n"
"- **2-column**: angle, gain \u2014 single cut, synthesized to 3D"
),
"example": "theta,phi,gain_dbi\\n0,0,-40\\n90,0,2.15\\n90,90,2.15\\n180,0,-40",
},
"emcar": {
"name": "EMCAR vna.dat",
"ext": ".dat",
"tool": "import_pattern_emcar",
"desc": (
"EMCAR antenna range format \u2014 angle + amplitude pairs.\n"
"Single azimuth cut from a positioner-driven measurement.\n"
"The gnuplot transform is applied automatically:\n"
"(-angle+90) rotation and 20*log10(amplitude+0.01)."
),
"example": "# EMCAR measurement\\n0 0.5\\n45 0.8\\n90 1.0\\n135 0.8\\n180 0.5",
},
"nec2": {
"name": "NEC2 Radiation Output",
"ext": ".out / .nec",
"tool": "import_pattern_nec2",
"desc": (
"NEC2/NEC4 output containing a RADIATION PATTERNS section.\n"
"Full theta\u00d7phi grid with VERT, HOR, and TOTAL gain columns.\n"
"Frequency is auto-detected from the file header."
),
"example": "(Standard NEC2 output file \u2014 run your .nec model first)",
},
"s1p": {
"name": "Touchstone S1P",
"ext": ".s1p",
"tool": "import_pattern_s1p",
"desc": (
"Touchstone S-parameter file containing S11 data.\n"
"Finds resonance, computes impedance, then generates an\n"
"analytical pattern using the Phase 1 antenna models.\n"
"Supports RI, MA, and DB formats."
),
"example": "# Hz S RI R 50\\n! NanoVNA export\\n144000000 0.1 -0.05\\n145000000 0.02 -0.01",
},
}
fmt = format_details.get(format.lower(), format_details["csv"])
return [
Message(
role="user",
content=f"I want to import a {fmt['name']} ({fmt['ext']}) antenna pattern for 3D visualization.",
),
Message(
role="assistant",
content=f"""I'll help you import a {fmt["name"]} pattern file.
**Format**: {fmt["name"]} ({fmt["ext"]})
**Tool**: `{fmt["tool"]}`
**Data format:**
{fmt["desc"]}
**Example content:**
```
{fmt["example"]}
```
**Workflow:**
1. Read the file content (or paste it directly)
2. Call `{fmt["tool"]}` with the content string
3. The tool returns a standard pattern dict with {{theta_deg, phi_deg, gain_dbi}}
4. If the web UI is running, upload via the "Load File" button for instant 3D rendering
**All supported formats** (use `list_pattern_formats` for full details):
| Format | Extension | Data |
|--------|-----------|------|
| CSV | .csv | 2-col or 3-col angle/gain |
| EMCAR | .dat | Positioner angle + amplitude |
| NEC2 | .out, .nec | Full radiation pattern grid |
| S1P | .s1p | S11 \u2192 analytical pattern |
**Tips:**
- CSV and EMCAR accept an optional `frequency_hz` parameter for metadata
- NEC2 supports `polarization` selection: 'total', 'vert', or 'hor'
- S1P supports `antenna_type` override or 'auto' detection from impedance
- Single-cut data (2-col CSV, EMCAR) is synthesized to 3D with a sin(\u03b8) taper
Please share the file content and I'll import it.""",
),
]

View File

@ -314,11 +314,36 @@ class NanoVNAProtocol:
help_text = " ".join(help_lines).lower()
# The help output format is: "Commands: scan scan_bin data ..."
for cmd in [
"scan_bin", "data", "frequencies", "sweep", "power", "bandwidth",
"cal", "save", "recall", "trace", "marker", "edelay", "s21offset",
"capture", "vbat", "tcxo", "reset", "smooth", "transform",
"threshold", "info", "version", "color", "measure", "pause",
"resume", "config", "usart_cfg", "vbat_offset", "time",
"scan_bin",
"data",
"frequencies",
"sweep",
"power",
"bandwidth",
"cal",
"save",
"recall",
"trace",
"marker",
"edelay",
"s21offset",
"capture",
"vbat",
"tcxo",
"reset",
"smooth",
"transform",
"threshold",
"info",
"version",
"color",
"measure",
"pause",
"resume",
"config",
"usart_cfg",
"vbat_offset",
"time",
]:
if cmd in help_text:
info.capabilities.append(cmd)
@ -366,6 +391,7 @@ class NanoVNAProtocol:
# -- Data parsing helpers --
@dataclass
class ScanPoint:
frequency_hz: int | None = None

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

@ -12,28 +12,94 @@ from mcnanovna.nanovna import NanoVNA
from mcnanovna.prompts import register_prompts
# All public methods on NanoVNA that should become MCP tools.
# Grouped by category for maintainability.
# Each method lives in a mixin class under tools/ — grouped here by module.
_TOOL_METHODS = [
# Tier 1: Essential measurement & control
"info", "sweep", "scan", "data", "frequencies", "marker", "cal",
"save", "recall", "pause", "resume",
# Tier 2: Configuration
"power", "bandwidth", "edelay", "s21offset", "vbat", "capture",
# Tier 3: Advanced
"trace", "transform", "smooth", "threshold", "reset", "version",
"detect", "disconnect", "raw_command", "cw",
# Phase 1 additions: Essential commands
"measure", "config", "saveconfig", "clearconfig", "color", "freq",
"tcxo", "vbat_offset",
# Phase 1 additions: Touch / Remote Desktop
"touchcal", "touchtest", "refresh", "touch", "release",
# Phase 1 additions: SD Card storage
"sd_list", "sd_read", "sd_delete", "time",
# Phase 1 additions: Debug / Diagnostic
"i2c", "si", "lcd", "threads", "stat", "sample", "test",
"gain", "dump", "port", "offset", "dac", "usart_cfg", "usart", "band",
# Convenience: server-side analysis
# tools/measurement.py — MeasurementMixin
"info",
"sweep",
"scan",
"data",
"frequencies",
"marker",
"cal",
"save",
"recall",
"pause",
"resume",
# tools/config.py — ConfigMixin
"power",
"bandwidth",
"edelay",
"s21offset",
"vbat",
"capture",
"measure",
"config",
"saveconfig",
"clearconfig",
"color",
"freq",
"tcxo",
"vbat_offset",
"threshold",
# tools/display.py — DisplayMixin
"trace",
"transform",
"smooth",
"touchcal",
"touchtest",
"refresh",
"touch",
"release",
# tools/device.py — DeviceMixin
"reset",
"version",
"detect",
"disconnect",
"raw_command",
"cw",
"sd_list",
"sd_read",
"sd_delete",
"time",
# tools/diagnostics.py — DiagnosticsMixin
"i2c",
"si",
"lcd",
"threads",
"stat",
"sample",
"test",
"gain",
"dump",
"port",
"offset",
"dac",
"usart_cfg",
"usart",
"band",
# tools/analysis.py — AnalysisMixin
"export_touchstone",
"export_csv",
"analyze_filter",
"analyze_xtal",
"analyze_tdr",
"analyze_component",
"analyze_lc_series",
"analyze_lc_shunt",
"analyze_lc_match",
"analyze_s11_resonance",
"analyze",
# tools/radiation.py — RadiationMixin
"radiation_pattern",
"radiation_pattern_from_data",
"radiation_pattern_multi",
# tools/pattern_import.py — PatternImportMixin
"import_pattern_csv",
"import_pattern_emcar",
"import_pattern_nec2",
"import_pattern_s1p",
"list_pattern_formats",
]
@ -47,8 +113,31 @@ def create_server() -> FastMCP:
"on first tool call — just plug in and go.\n\n"
"Use the 'analyze' tool for comprehensive measurement reports with SWR, "
"impedance, bandwidth, and reactive component analysis built in.\n\n"
"Export tools: 'export_touchstone' (.s1p/.s2p) and 'export_csv' for data "
"interchange with other RF tools.\n\n"
"Specialized analysis: 'analyze_filter' (type classification, cutoffs, Q), "
"'analyze_xtal' (crystal motional parameters), 'analyze_tdr' (time-domain "
"reflectometry with impedance/distance profiling).\n\n"
"LC & impedance tools: 'analyze_component' (identify unknown L/C/R from S11), "
"'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"
"Pattern import tools: 'import_pattern_csv', 'import_pattern_emcar', "
"'import_pattern_nec2', 'import_pattern_s1p' — import measured/simulated patterns "
"from external files (CSV, EMCAR vna.dat, NEC2 output, Touchstone S1P). "
"Use 'list_pattern_formats' for format details and examples.\n\n"
"For automated antenna range measurement with an ESP32 positioner, use "
"mcpositioner (separate MCP server) to control the positioner hardware, then "
"coordinate both servers — see the measure_antenna_range prompt for guidance.\n\n"
"Prompts are available for guided workflows: calibrate, export_touchstone, "
"analyze_antenna, measure_cable, and compare_sweeps."
"analyze_antenna, measure_cable, compare_sweeps, analyze_crystal, "
"analyze_filter_response, measure_tdr, measure_component, measure_lc_series, "
"measure_lc_shunt, impedance_match, visualize_radiation_pattern, import_pattern, "
"and measure_antenna_range."
),
)
vna = NanoVNA()
@ -64,6 +153,8 @@ def create_server() -> FastMCP:
def main() -> None:
import os
try:
from importlib.metadata import version
@ -73,6 +164,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

@ -0,0 +1,37 @@
"""NanoVNA tool mixins — each groups related MCP tool methods.
The NanoVNA class composes all mixins, so server.py's getattr() registration
loop works unchanged. Each mixin accesses shared state (self._protocol,
self._ensure_connected, etc.) through the final composed class at runtime.
"""
from __future__ import annotations
from fastmcp import Context
from .analysis import AnalysisMixin
from .config import ConfigMixin
from .device import DeviceMixin
from .diagnostics import DiagnosticsMixin
from .display import DisplayMixin
from .measurement import MeasurementMixin
from .pattern_import import PatternImportMixin
from .radiation import RadiationMixin
async def _progress(ctx: Context | None, progress: float, total: float, message: str) -> None:
"""Report progress if Context is available."""
if ctx:
await ctx.report_progress(progress, total, message)
__all__ = [
"AnalysisMixin",
"ConfigMixin",
"DeviceMixin",
"DiagnosticsMixin",
"DisplayMixin",
"MeasurementMixin",
"PatternImportMixin",
"RadiationMixin",
]

View File

@ -0,0 +1,574 @@
"""AnalysisMixin — export, filter analysis, crystal analysis, TDR, component ID, LC matching."""
from __future__ import annotations
import asyncio
from fastmcp import Context
class AnalysisMixin:
"""Analysis and export tools: analyze, export_touchstone, export_csv, analyze_filter, analyze_xtal,
analyze_tdr, analyze_component, analyze_lc_series, analyze_lc_shunt, analyze_lc_match, analyze_s11_resonance.
"""
async def export_touchstone(
self,
start_hz: int,
stop_hz: int,
points: int = 101,
format: str = "s1p",
z0: float = 50.0,
ctx: Context | None = None,
) -> dict:
"""Export S-parameter data as Touchstone file content (.s1p or .s2p).
Runs a scan and formats results per IEEE Std 1363. The .s1p format
captures S11 reflection only; .s2p captures both S11 and S21
(S12/S22 are zeroed since the NanoVNA doesn't measure them).
Args:
start_hz: Start frequency in Hz
stop_hz: Stop frequency in Hz
points: Number of measurement points (default 101)
format: Output format 's1p' (S11 only) or 's2p' (S11 + S21)
z0: Reference impedance in ohms (default 50)
"""
from mcnanovna.calculations import format_touchstone_s1p, format_touchstone_s2p
from mcnanovna.tools import _progress
s2p = format.lower() == "s2p"
await _progress(ctx, 1, 4, "Connecting to NanoVNA...")
await asyncio.to_thread(self._ensure_connected)
await _progress(ctx, 2, 4, f"Scanning {points} points...")
scan_result = await self.scan(
start_hz,
stop_hz,
points,
s11=True,
s21=s2p,
)
if "error" in scan_result:
return scan_result
await _progress(ctx, 3, 4, "Formatting Touchstone data...")
freqs = [pt["frequency_hz"] for pt in scan_result["data"]]
s11_data = [pt["s11"] for pt in scan_result["data"]]
if s2p:
s21_data = [pt["s21"] for pt in scan_result["data"]]
content = format_touchstone_s2p(freqs, s11_data, s21_data, z0)
filename = f"nanovna_{start_hz}_{stop_hz}.s2p"
else:
content = format_touchstone_s1p(freqs, s11_data, z0)
filename = f"nanovna_{start_hz}_{stop_hz}.s1p"
await _progress(ctx, 4, 4, "Export complete")
return {
"content": content,
"filename": filename,
"format": "s2p" if s2p else "s1p",
"points": scan_result["points"],
"z0": z0,
}
async def export_csv(
self,
start_hz: int,
stop_hz: int,
points: int = 101,
s11: bool = True,
s21: bool = True,
ctx: Context | None = None,
) -> dict:
"""Export scan data as CSV with derived metrics.
Runs a scan and formats results as CSV including raw S-parameters
and derived values (SWR, return loss, impedance, insertion loss, phase).
Args:
start_hz: Start frequency in Hz
stop_hz: Stop frequency in Hz
points: Number of measurement points (default 101)
s11: Include S11 reflection data (default True)
s21: Include S21 transmission data (default True)
"""
from mcnanovna.calculations import format_csv
from mcnanovna.tools import _progress
await _progress(ctx, 1, 4, "Connecting to NanoVNA...")
await asyncio.to_thread(self._ensure_connected)
await _progress(ctx, 2, 4, f"Scanning {points} points...")
scan_result = await self.scan(start_hz, stop_hz, points, s11=s11, s21=s21)
if "error" in scan_result:
return scan_result
await _progress(ctx, 3, 4, "Formatting CSV data...")
content = format_csv(scan_result["data"])
await _progress(ctx, 4, 4, "Export complete")
return {
"content": content,
"filename": f"nanovna_{start_hz}_{stop_hz}.csv",
"points": scan_result["points"],
}
async def analyze_filter(
self,
start_hz: int,
stop_hz: int,
points: int = 101,
ctx: Context | None = None,
) -> dict:
"""Classify and characterize a filter from S21 transmission measurement.
Scans S21 through the DUT and determines filter type (lowpass, highpass,
bandpass), cutoff frequencies at -3/-6/-10/-20 dB, bandwidth, Q factor,
and roll-off rate. Connect the filter between Port 1 and Port 2.
Args:
start_hz: Start frequency in Hz
stop_hz: Stop frequency in Hz
points: Number of measurement points (default 101)
"""
from mcnanovna.calculations import analyze_filter_response
from mcnanovna.tools import _progress
await _progress(ctx, 1, 4, "Connecting to NanoVNA...")
await asyncio.to_thread(self._ensure_connected)
await _progress(ctx, 2, 4, f"Scanning S21 ({points} points)...")
scan_result = await self.scan(start_hz, stop_hz, points, s11=False, s21=True)
if "error" in scan_result:
return scan_result
await _progress(ctx, 3, 4, "Analyzing filter response...")
freqs = [pt["frequency_hz"] for pt in scan_result["data"]]
s21_data = [pt["s21"] for pt in scan_result["data"]]
result = analyze_filter_response(s21_data, freqs)
result["scan_info"] = {
"start_hz": start_hz,
"stop_hz": stop_hz,
"points": scan_result["points"],
}
await _progress(ctx, 4, 4, "Filter analysis complete")
return result
async def analyze_xtal(
self,
start_hz: int,
stop_hz: int,
points: int = 101,
z0: float = 50.0,
ctx: Context | None = None,
) -> dict:
"""Extract quartz crystal motional parameters from S21 measurement.
Scans S21 through a crystal in a series test fixture and determines
series/parallel resonance, motional resistance (Rm), inductance (Lm),
capacitance (Cm), holder capacitance (Cp), Q factor, and insertion loss.
Connect the crystal between Port 1 and Port 2.
Args:
start_hz: Start frequency in Hz
stop_hz: Stop frequency in Hz
points: Number of measurement points (default 101)
z0: Reference impedance in ohms (default 50)
"""
from mcnanovna.calculations import analyze_crystal
from mcnanovna.tools import _progress
await _progress(ctx, 1, 4, "Connecting to NanoVNA...")
await asyncio.to_thread(self._ensure_connected)
await _progress(ctx, 2, 4, f"Scanning S21 ({points} points)...")
scan_result = await self.scan(start_hz, stop_hz, points, s11=False, s21=True)
if "error" in scan_result:
return scan_result
await _progress(ctx, 3, 4, "Extracting crystal parameters...")
freqs = [pt["frequency_hz"] for pt in scan_result["data"]]
s21_data = [pt["s21"] for pt in scan_result["data"]]
result = analyze_crystal(s21_data, freqs, z0)
result["scan_info"] = {
"start_hz": start_hz,
"stop_hz": stop_hz,
"points": scan_result["points"],
}
await _progress(ctx, 4, 4, "Crystal analysis complete")
return result
async def analyze_tdr(
self,
start_hz: int,
stop_hz: int,
points: int = 101,
velocity_factor: float = 0.66,
window: str = "normal",
z0: float = 50.0,
ctx: Context | None = None,
) -> dict:
"""Time Domain Reflectometry analysis from S11 frequency sweep.
Scans S11 reflection data and transforms it to the time domain using
an inverse FFT with Kaiser windowing. Returns impedance and reflection
profiles along the cable/transmission line, plus detected discontinuities.
Args:
start_hz: Start frequency in Hz (use wide span for better resolution)
stop_hz: Stop frequency in Hz
points: Number of measurement points (default 101)
velocity_factor: Cable velocity factor (default 0.66 for RG-58)
window: Kaiser window 'minimum' (sharp), 'normal', or 'maximum' (smooth)
z0: Reference impedance in ohms (default 50)
"""
from mcnanovna.calculations import tdr_analysis
from mcnanovna.tools import _progress
await _progress(ctx, 1, 4, "Connecting to NanoVNA...")
await asyncio.to_thread(self._ensure_connected)
await _progress(ctx, 2, 4, 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, 4, "Computing TDR transform...")
freqs = [pt["frequency_hz"] for pt in scan_result["data"]]
s11_data = [pt["s11"] for pt in scan_result["data"]]
result = tdr_analysis(s11_data, freqs, velocity_factor, window, z0)
result["scan_info"] = {
"start_hz": start_hz,
"stop_hz": stop_hz,
"points": scan_result["points"],
}
await _progress(ctx, 4, 4, "TDR analysis complete")
return result
async def analyze_component(
self,
start_hz: int,
stop_hz: int,
points: int = 101,
z0: float = 50.0,
ctx: Context | None = None,
) -> dict:
"""Identify an unknown component from S11 reflection measurement.
Scans S11 and classifies the DUT as an inductor, capacitor, resistor,
or LC circuit. Reports the primary value (nH, pF, or ohm), ESR,
Q factor, and self-resonant frequency if present.
Connect the component to Port 1 (open end of the test fixture).
Args:
start_hz: Start frequency in Hz
stop_hz: Stop frequency in Hz
points: Number of measurement points (default 101)
z0: Reference impedance in ohms (default 50)
"""
from mcnanovna.calculations import classify_component
from mcnanovna.tools import _progress
await _progress(ctx, 1, 4, "Connecting to NanoVNA...")
await asyncio.to_thread(self._ensure_connected)
await _progress(ctx, 2, 4, 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, 4, "Classifying component...")
freqs = [pt["frequency_hz"] for pt in scan_result["data"]]
s11_data = [pt["s11"] for pt in scan_result["data"]]
result = classify_component(s11_data, freqs, z0)
result["scan_info"] = {
"start_hz": start_hz,
"stop_hz": stop_hz,
"points": scan_result["points"],
}
await _progress(ctx, 4, 4, "Component analysis complete")
return result
async def analyze_lc_series(
self,
start_hz: int,
stop_hz: int,
points: int = 101,
measure_r: float = 50.0,
ctx: Context | None = None,
) -> dict:
"""Measure a series LC resonator from S21 transmission data.
The component under test is placed in series between Port 1 and Port 2.
At resonance, the series LC circuit has minimum impedance, producing a
transmission peak in S21. Reports resonant frequency, motional resistance,
inductance, capacitance, Q factor, bandwidth, and insertion loss.
Args:
start_hz: Start frequency in Hz
stop_hz: Stop frequency in Hz
points: Number of measurement points (default 101)
measure_r: Port termination resistance in ohms (default 50)
"""
from mcnanovna.calculations import analyze_lc_series as calc_lc_series
from mcnanovna.tools import _progress
await _progress(ctx, 1, 4, "Connecting to NanoVNA...")
await asyncio.to_thread(self._ensure_connected)
await _progress(ctx, 2, 4, f"Scanning S21 ({points} points)...")
scan_result = await self.scan(start_hz, stop_hz, points, s11=False, s21=True)
if "error" in scan_result:
return scan_result
await _progress(ctx, 3, 4, "Analyzing series LC resonator...")
freqs = [pt["frequency_hz"] for pt in scan_result["data"]]
s21_data = [pt["s21"] for pt in scan_result["data"]]
result = calc_lc_series(s21_data, freqs, measure_r)
result["scan_info"] = {
"start_hz": start_hz,
"stop_hz": stop_hz,
"points": scan_result["points"],
}
await _progress(ctx, 4, 4, "Series LC analysis complete")
return result
async def analyze_lc_shunt(
self,
start_hz: int,
stop_hz: int,
points: int = 101,
measure_r: float = 50.0,
ctx: Context | None = None,
) -> dict:
"""Measure a shunt LC resonator from S21 transmission data.
The component under test is connected as a shunt (parallel to ground)
between Port 1 and Port 2. At resonance, the shunt LC circuit has
maximum impedance to ground, producing a transmission dip (absorption)
in S21. Reports resonant frequency, motional resistance, inductance,
capacitance, Q factor, bandwidth, and insertion loss.
Args:
start_hz: Start frequency in Hz
stop_hz: Stop frequency in Hz
points: Number of measurement points (default 101)
measure_r: Port termination resistance in ohms (default 50)
"""
from mcnanovna.calculations import analyze_lc_shunt as calc_lc_shunt
from mcnanovna.tools import _progress
await _progress(ctx, 1, 4, "Connecting to NanoVNA...")
await asyncio.to_thread(self._ensure_connected)
await _progress(ctx, 2, 4, f"Scanning S21 ({points} points)...")
scan_result = await self.scan(start_hz, stop_hz, points, s11=False, s21=True)
if "error" in scan_result:
return scan_result
await _progress(ctx, 3, 4, "Analyzing shunt LC resonator...")
freqs = [pt["frequency_hz"] for pt in scan_result["data"]]
s21_data = [pt["s21"] for pt in scan_result["data"]]
result = calc_lc_shunt(s21_data, freqs, measure_r)
result["scan_info"] = {
"start_hz": start_hz,
"stop_hz": stop_hz,
"points": scan_result["points"],
}
await _progress(ctx, 4, 4, "Shunt LC analysis complete")
return result
async def analyze_lc_match(
self,
frequency_hz: int,
r: float | None = None,
x: float | None = None,
z0: float = 50.0,
start_hz: int | None = None,
stop_hz: int | None = None,
points: int = 101,
ctx: Context | None = None,
) -> dict:
"""Compute L-network impedance matching solutions.
Accepts either a direct impedance (r + jx) or scans S11 to measure it.
**Direct mode**: Provide r and x (real and imaginary parts of impedance
in ohms) along with frequency_hz. No hardware needed.
**Scan mode**: Provide start_hz and stop_hz to scan S11. The impedance
at the frequency nearest to frequency_hz is extracted and used for
matching. Connect the load to Port 1.
Returns up to 4 L-network solutions, each specifying source shunt,
series, and load shunt components (inductors or capacitors with values).
Args:
frequency_hz: Design frequency in Hz for component value calculation
r: Load resistance in ohms (direct mode)
x: Load reactance in ohms (direct mode)
z0: Target impedance in ohms (default 50)
start_hz: Start frequency for S11 scan (scan mode)
stop_hz: Stop frequency for S11 scan (scan mode)
points: Number of measurement points for scan (default 101)
"""
from mcnanovna.calculations import lc_match
from mcnanovna.tools import _progress
scan_info = None
if r is not None and x is not None:
# Direct mode — pure math, no hardware needed
total_steps = 2
await _progress(ctx, 1, total_steps, f"Computing match for {r}+j{x} \u03a9 at {frequency_hz} Hz...")
elif start_hz is not None and stop_hz is not None:
# Scan mode — measure impedance from S11
total_steps = 4
await _progress(ctx, 1, total_steps, "Connecting to NanoVNA...")
await asyncio.to_thread(self._ensure_connected)
await _progress(ctx, 2, total_steps, 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
scan_info = {
"start_hz": start_hz,
"stop_hz": stop_hz,
"points": scan_result["points"],
}
# Find the data point nearest to the target frequency
data = scan_result["data"]
best_idx = 0
best_dist = abs(data[0]["frequency_hz"] - frequency_hz)
for i, pt in enumerate(data[1:], 1):
dist = abs(pt["frequency_hz"] - frequency_hz)
if dist < best_dist:
best_dist = dist
best_idx = i
# Convert S11 to impedance: Z = z0 * (1 + \u0393) / (1 - \u0393)
s11 = data[best_idx]["s11"]
gamma = complex(s11["real"], s11["imag"])
denom = 1.0 - gamma
if abs(denom) < 1e-12:
return {"error": "S11 \u2248 1.0 (open circuit), cannot compute impedance"}
z = z0 * (1.0 + gamma) / denom
r = z.real
x = z.imag
frequency_hz = data[best_idx]["frequency_hz"]
await _progress(ctx, 3, total_steps, f"Impedance at {frequency_hz} Hz: {r:.1f}+j{x:.1f} \u03a9")
else:
return {"error": "Provide either (r, x) for direct mode or (start_hz, stop_hz) for scan mode"}
result = lc_match(r, x, frequency_hz, z0)
if scan_info is not None:
result["scan_info"] = scan_info
await _progress(ctx, total_steps, total_steps, "Matching network computation complete")
return result
async def analyze_s11_resonance(
self,
start_hz: int,
stop_hz: int,
points: int = 201,
z0: float = 50.0,
ctx: Context | None = None,
) -> dict:
"""Find resonant frequencies from S11 reflection data.
Scans S11 and searches for up to 6 points where the reactance crosses
zero, indicating resonance. Each resonance is classified as series
(reactance goes from negative to positive) or parallel (positive to
negative). Useful for identifying resonant modes of antennas, filters,
or transmission line stubs.
Args:
start_hz: Start frequency in Hz
stop_hz: Stop frequency in Hz
points: Number of measurement points (default 201)
z0: Reference impedance in ohms (default 50)
"""
from mcnanovna.calculations import find_s11_resonances
from mcnanovna.tools import _progress
await _progress(ctx, 1, 4, "Connecting to NanoVNA...")
await asyncio.to_thread(self._ensure_connected)
await _progress(ctx, 2, 4, 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, 4, "Searching for resonances...")
freqs = [pt["frequency_hz"] for pt in scan_result["data"]]
s11_data = [pt["s11"] for pt in scan_result["data"]]
result = find_s11_resonances(s11_data, freqs, z0)
result["scan_info"] = {
"start_hz": start_hz,
"stop_hz": stop_hz,
"points": scan_result["points"],
}
await _progress(ctx, 4, 4, f"Found {result['count']} resonance(s)")
return result
async def analyze(
self,
start_hz: int,
stop_hz: int,
points: int = 101,
s11: bool = True,
s21: bool = False,
ctx: Context | None = None,
) -> dict:
"""Run a scan and return comprehensive S-parameter analysis.
Combines the scan tool with server-side calculations to produce
a full measurement report including SWR, impedance, bandwidth,
return loss, and reactive components without the LLM needing
to do the math.
Args:
start_hz: Start frequency in Hz
stop_hz: Stop frequency in Hz
points: Number of measurement points (default 101)
s11: Include S11 reflection analysis (default True)
s21: Include S21 transmission analysis (default False)
"""
from mcnanovna.calculations import analyze_scan
from mcnanovna.tools import _progress
await _progress(ctx, 1, 5, "Connecting to NanoVNA...")
await asyncio.to_thread(self._ensure_connected)
await _progress(ctx, 2, 5, f"Scanning {points} points from {start_hz} to {stop_hz} Hz...")
scan_result = await self.scan(start_hz, stop_hz, points, s11=s11, s21=s21)
if "error" in scan_result:
return scan_result
await _progress(ctx, 3, 5, f"Received {scan_result['points']} measurement points")
await _progress(ctx, 4, 5, "Calculating S-parameter metrics...")
analysis = analyze_scan(scan_result["data"])
analysis["scan_info"] = {
"start_hz": start_hz,
"stop_hz": stop_hz,
"points": scan_result["points"],
"binary": scan_result.get("binary", False),
}
await _progress(ctx, 5, 5, "Analysis complete")
return analysis

View File

@ -0,0 +1,417 @@
"""ConfigMixin — device configuration, power, bandwidth, capture, and settings tools."""
from __future__ import annotations
import asyncio
import base64
import re
from fastmcp import Context
# Si5351 drive level descriptions
POWER_DESCRIPTIONS = {
0: "2mA Si5351 drive",
1: "4mA Si5351 drive",
2: "6mA Si5351 drive",
3: "8mA Si5351 drive",
255: "auto",
}
class ConfigMixin:
"""Configuration tools: power, bandwidth, edelay, capture, measure, config, color, tcxo, etc."""
def power(self, level: int | None = None) -> dict:
"""Get or set RF output power level.
Args:
level: Power level (0=2mA, 1=4mA, 2=6mA, 3=8mA Si5351 drive, 255=auto)
"""
self._ensure_connected()
if level is not None:
self._protocol.send_text_command(f"power {level}")
lines = self._protocol.send_text_command("power")
# Response: "power: N"
for line in lines:
if "power" in line.lower():
parts = line.split(":")
if len(parts) >= 2:
try:
val = int(parts[1].strip())
return {
"power": val,
"description": POWER_DESCRIPTIONS.get(val, f"level {val}"),
}
except ValueError:
pass
return {"power": level if level is not None else -1, "description": "unknown", "raw": lines}
def bandwidth(self, bw_hz: int | None = None) -> dict:
"""Get or set the IF bandwidth (affects measurement speed vs noise floor).
Args:
bw_hz: Bandwidth in Hz, or bandwidth divider value. Lower = slower but more accurate.
"""
self._ensure_connected()
if not self._has_capability("bandwidth"):
return {"error": "bandwidth command not supported by this firmware"}
if bw_hz is not None:
self._protocol.send_text_command(f"bandwidth {bw_hz}")
lines = self._protocol.send_text_command("bandwidth")
# Response: "bandwidth N (Mhz)" or similar
if lines:
line = lines[0].strip()
# Try to parse "bandwidth <divider> (<freq>Hz)"
m = re.match(r"bandwidth\s+(\d+)\s*\((\d+)\s*Hz\)", line, re.IGNORECASE)
if m:
return {"bandwidth_divider": int(m.group(1)), "bandwidth_hz": int(m.group(2))}
# Fallback: just return raw
return {"raw": line}
return {"bandwidth_divider": 0, "bandwidth_hz": 0}
def edelay(self, seconds: float | None = None) -> dict:
"""Get or set electrical delay compensation in seconds.
Args:
seconds: Electrical delay in seconds (e.g. 1e-9 for 1 nanosecond)
"""
self._ensure_connected()
if seconds is not None:
self._protocol.send_text_command(f"edelay {seconds}")
lines = self._protocol.send_text_command("edelay")
if lines:
try:
return {"edelay_seconds": float(lines[0].strip())}
except ValueError:
return {"raw": lines}
return {"edelay_seconds": 0.0}
def s21offset(self, db: float | None = None) -> dict:
"""Get or set S21 offset correction in dB.
Args:
db: Offset value in dB
"""
self._ensure_connected()
if not self._has_capability("s21offset"):
return {"error": "s21offset command not supported by this firmware"}
if db is not None:
self._protocol.send_text_command(f"s21offset {db}")
lines = self._protocol.send_text_command("s21offset")
if lines:
try:
return {"s21_offset_db": float(lines[0].strip())}
except ValueError:
return {"raw": lines}
return {"s21_offset_db": 0.0}
def vbat(self) -> dict:
"""Read battery voltage in millivolts."""
self._ensure_connected()
lines = self._protocol.send_text_command("vbat")
# Response: "4151 mV"
if lines:
parts = lines[0].strip().split()
if parts:
try:
mv = int(parts[0])
return {"voltage_mv": mv, "voltage_v": round(mv / 1000.0, 3)}
except ValueError:
pass
return {"voltage_mv": 0, "voltage_v": 0.0, "raw": lines}
def _capture_raw_bytes(self) -> tuple[int, int, bytearray]:
"""Read raw RGB565 pixel data from the device. Blocking serial I/O."""
import time
di = self._protocol.device_info
width = di.lcd_width
height = di.lcd_height
expected_size = width * height * 2
self._protocol._drain()
self._protocol._send_command("capture")
ser = self._protocol._require_connection()
old_timeout = ser.timeout
ser.timeout = 10.0
try:
buf = b""
deadline = time.monotonic() + 10.0
# Read past echo line
while time.monotonic() < deadline:
chunk = ser.read(max(1, ser.in_waiting))
if chunk:
buf += chunk
if b"\r\n" in buf:
break
echo_end = buf.index(b"\r\n") + 2
pixel_buf = buf[echo_end:]
# Read pixel data
while len(pixel_buf) < expected_size and time.monotonic() < deadline:
remaining = expected_size - len(pixel_buf)
chunk = ser.read(min(remaining, 4096))
if chunk:
pixel_buf += chunk
# Byte-swap RGB565 (firmware sends native LE, display expects BE)
swapped = bytearray(expected_size)
for i in range(0, min(len(pixel_buf), expected_size), 2):
if i + 1 < len(pixel_buf):
swapped[i] = pixel_buf[i + 1]
swapped[i + 1] = pixel_buf[i]
# Drain trailing prompt
trailing = b""
while time.monotonic() < deadline:
chunk = ser.read(max(1, ser.in_waiting))
if chunk:
trailing += chunk
if b"ch> " in trailing or not chunk:
break
return width, height, swapped
finally:
ser.timeout = old_timeout
async def capture(self, raw: bool = False, ctx: Context | None = None):
"""Capture the current LCD screen as RGB565 pixel data (base64 encoded).
Returns width, height, and raw pixel data for rendering. The pixel format
is RGB565 (16-bit, 2 bytes per pixel). Total size = width * height * 2 bytes.
Args:
raw: If True, return raw RGB565 data as a dict with base64-encoded bytes.
If False (default), convert to PNG and return as an Image.
"""
from mcnanovna.tools import _progress
await _progress(ctx, 1, 3, "Connecting to NanoVNA...")
await asyncio.to_thread(self._ensure_connected)
await _progress(ctx, 2, 3, "Reading LCD pixel data...")
width, height, swapped = await asyncio.to_thread(self._capture_raw_bytes)
if raw:
await _progress(ctx, 3, 3, "Capture complete")
return {
"format": "rgb565",
"width": width,
"height": height,
"data_length": len(swapped),
"data_base64": base64.b64encode(bytes(swapped)).decode("ascii"),
}
await _progress(ctx, 3, 3, "Encoding PNG image...")
# Convert RGB565 to PNG and return as MCP Image
import io
import struct as _struct
from PIL import Image as PILImage
from fastmcp.utilities.types import Image
img = PILImage.new("RGB", (width, height))
pixels = img.load()
for y in range(height):
for x in range(width):
offset = (y * width + x) * 2
pixel = _struct.unpack(">H", swapped[offset : offset + 2])[0]
r = ((pixel >> 11) & 0x1F) << 3
g = ((pixel >> 5) & 0x3F) << 2
b = (pixel & 0x1F) << 3
pixels[x, y] = (r, g, b)
buf_png = io.BytesIO()
img.save(buf_png, format="PNG")
return Image(data=buf_png.getvalue(), format="png")
def measure(self, mode: str | None = None) -> dict:
"""Set on-device measurement display mode.
Controls what the NanoVNA computes and displays on-screen. Available
modes depend on firmware build flags.
Args:
mode: Measurement mode one of: none, lc, lcshunt, lcseries,
xtal, filter, cable, resonance. Omit to get usage help.
"""
self._ensure_connected()
if not self._has_capability("measure"):
return {"error": "measure command not supported by this firmware"}
if mode is not None:
lines = self._protocol.send_text_command(f"measure {mode}")
return {"mode": mode, "response": lines}
lines = self._protocol.send_text_command("measure")
return {"response": lines}
def config(self, option: str | None = None, value: int | None = None) -> dict:
"""Query or set device configuration options.
Options depend on firmware build (e.g., auto, avg, connection, mode,
grid, dot, bk, flip, separator, tif). Each takes a value of 0 or 1.
Args:
option: Configuration option name
value: Value to set (0 or 1)
"""
self._ensure_connected()
if not self._has_capability("config"):
return {"error": "config command not supported by this firmware"}
if option is not None and value is not None:
lines = self._protocol.send_text_command(f"config {option} {value}")
return {"option": option, "value": value, "response": lines}
lines = self._protocol.send_text_command("config")
return {"response": lines}
def saveconfig(self) -> dict:
"""Save current device configuration to flash memory.
Saves config_t (distinct from calibration save which uses slots).
This includes touch calibration, display settings, TCXO frequency, etc.
"""
self._ensure_connected()
lines = self._protocol.send_text_command("saveconfig")
return {"saved": True, "response": lines}
def clearconfig(self, key: str = "1234") -> dict:
"""Clear all stored configuration and calibration data from flash.
This is a destructive operation requires the protection key '1234'.
After clearing, you must recalibrate the device.
Args:
key: Protection key (must be '1234' to confirm)
"""
self._ensure_connected()
if key != "1234":
return {"error": "Protection key must be '1234' to confirm clearing all data"}
lines = self._protocol.send_text_command(f"clearconfig {key}")
return {"cleared": True, "response": lines}
def color(self, color_id: int | None = None, rgb24: int | None = None) -> dict:
"""Get or set display color palette entries.
With no args, lists all color slots and their current RGB values.
With id + rgb24, sets a specific color.
Args:
color_id: Palette slot index (0-31)
rgb24: 24-bit RGB color value (e.g., 0xFF0000 for red)
"""
self._ensure_connected()
if not self._has_capability("color"):
return {"error": "color command not supported by this firmware"}
if color_id is not None and rgb24 is not None:
self._protocol.send_text_command(f"color {color_id} {rgb24}")
return {"color_id": color_id, "rgb24": f"0x{rgb24:06x}", "set": True}
lines = self._protocol.send_text_command("color")
colors = []
for line in lines:
line = line.strip()
if ":" in line:
parts = line.split(":")
try:
idx = int(parts[0].strip())
val = parts[1].strip()
colors.append({"id": idx, "rgb24": val})
except (ValueError, IndexError):
pass
return {"colors": colors, "raw": lines}
def freq(self, frequency_hz: int) -> dict:
"""Set a single output frequency and pause the sweep.
Useful for CW measurements or signal generation at a specific frequency.
The sweep is paused automatically.
Args:
frequency_hz: Output frequency in Hz
"""
self._ensure_connected()
self._protocol.send_text_command(f"freq {frequency_hz}")
return {"frequency_hz": frequency_hz, "sweep": "paused"}
def tcxo(self, frequency_hz: int | None = None) -> dict:
"""Get or set the TCXO reference oscillator frequency.
The TCXO frequency affects all frequency accuracy. Default is typically
26000000 Hz. Adjust if you have a precision frequency reference.
Args:
frequency_hz: TCXO frequency in Hz (e.g., 26000000)
"""
self._ensure_connected()
if not self._has_capability("tcxo"):
return {"error": "tcxo command not supported by this firmware"}
if frequency_hz is not None:
self._protocol.send_text_command(f"tcxo {frequency_hz}")
return {"tcxo_hz": frequency_hz, "set": True}
# Query mode returns "current: <value>"
lines = self._protocol.send_text_command("tcxo")
for line in lines:
if "current:" in line.lower():
parts = line.split(":")
if len(parts) >= 2:
try:
return {"tcxo_hz": int(parts[1].strip())}
except ValueError:
pass
return {"response": lines}
def vbat_offset(self, offset: int | None = None) -> dict:
"""Get or set battery voltage measurement offset.
Calibrates the battery voltage reading. The offset is added to the
raw ADC value to compensate for hardware variations.
Args:
offset: Voltage offset value (raw ADC units)
"""
self._ensure_connected()
if not self._has_capability("vbat_offset"):
return {"error": "vbat_offset command not supported by this firmware"}
if offset is not None:
self._protocol.send_text_command(f"vbat_offset {offset}")
return {"offset": offset, "set": True}
lines = self._protocol.send_text_command("vbat_offset")
if lines:
try:
return {"offset": int(lines[0].strip())}
except ValueError:
pass
return {"response": lines}
def threshold(self, frequency_hz: int | None = None) -> dict:
"""Get or set the harmonic mode frequency threshold (~290 MHz default).
Above this frequency the Si5351 uses odd harmonics for output.
Args:
frequency_hz: Threshold frequency in Hz
"""
self._ensure_connected()
if frequency_hz is not None:
self._protocol.send_text_command(f"threshold {frequency_hz}")
lines = self._protocol.send_text_command("threshold")
# Parse "current: 290000000" from response
for line in lines:
if "current:" in line.lower():
parts = line.split(":")
if len(parts) >= 2:
try:
return {"threshold_hz": int(parts[1].strip())}
except ValueError:
pass
return {"response": lines}

View File

@ -0,0 +1,237 @@
"""DeviceMixin — device lifecycle, reset, version, SD card, CW mode, and raw commands."""
from __future__ import annotations
import base64
from mcnanovna.discovery import find_nanovna_ports
from mcnanovna.protocol import NanoVNAProtocolError
class DeviceMixin:
"""Device tools: reset, version, detect, disconnect, raw_command, cw, sd_list, sd_read, sd_delete, time."""
def reset(self, dfu: bool = False) -> dict:
"""Reset the NanoVNA device. With dfu=True, enters DFU bootloader for firmware update.
Args:
dfu: If True, enter DFU bootloader mode (device will disconnect)
"""
self._ensure_connected()
cmd = "reset dfu" if dfu else "reset"
try:
self._protocol.send_text_command(cmd, timeout=2.0)
except NanoVNAProtocolError:
pass # Device resets and disconnects
self._protocol.close()
note = "Device entering DFU mode — reconnect after firmware update" if dfu else "Device resetting"
return {"reset": True, "dfu": dfu, "note": note}
def version(self) -> dict:
"""Get firmware version string."""
self._ensure_connected()
lines = self._protocol.send_text_command("version")
return {"version": lines[0].strip() if lines else "unknown"}
def detect(self) -> dict:
"""Scan USB ports for connected NanoVNA devices."""
ports = find_nanovna_ports()
return {
"devices": [
{
"port": p.device,
"vid": f"0x{p.vid:04x}",
"pid": f"0x{p.pid:04x}",
"serial_number": p.serial_number,
"description": p.description,
}
for p in ports
],
"count": len(ports),
"currently_connected": self._port,
}
def disconnect(self) -> dict:
"""Close the serial connection to the NanoVNA."""
port = self._port
self._protocol.close()
self._port = None
return {"disconnected": True, "port": port}
def raw_command(self, command: str) -> dict:
"""Send an arbitrary shell command to the NanoVNA and return raw text response.
Escape hatch for firmware commands not wrapped as dedicated tools.
Args:
command: The shell command string to send (e.g. 'config', 'usart_cfg')
"""
self._ensure_connected()
lines = self._protocol.send_text_command(command, timeout=10.0)
return {"command": command, "response_lines": lines}
def cw(self, frequency_hz: int, power: int | None = None) -> dict:
"""Set continuous wave (CW) mode — output a single frequency.
Configures the NanoVNA to sweep a single point, effectively
becoming a CW signal generator at the specified frequency.
Args:
frequency_hz: Output frequency in Hz
power: Optional power level (0-3, or 255 for auto)
"""
self._ensure_connected()
if power is not None:
self._protocol.send_text_command(f"power {power}")
# CW mode is just a sweep with start == stop and 1 point
self._protocol.send_text_command(f"sweep {frequency_hz} {frequency_hz} 1")
self._protocol.send_text_command("resume")
return {"frequency_hz": frequency_hz, "power": power, "mode": "cw"}
def sd_list(self, pattern: str = "*.*") -> dict:
"""List files on the SD card.
Requires SD card hardware support. Returns filenames and sizes.
Args:
pattern: Glob pattern to filter files (default: '*.*')
"""
self._ensure_connected()
if not self._has_capability("sd_list"):
return {"error": "SD card commands not supported by this firmware/hardware"}
lines = self._protocol.send_text_command(f"sd_list {pattern}", timeout=10.0)
files = []
for line in lines:
line = line.strip()
if not line or "err:" in line.lower():
if "err:" in line.lower():
return {"error": line}
continue
parts = line.rsplit(" ", 1)
if len(parts) == 2:
try:
files.append({"name": parts[0], "size": int(parts[1])})
except ValueError:
files.append({"name": line, "size": 0})
else:
files.append({"name": line, "size": 0})
return {"files": files, "count": len(files)}
def sd_read(self, filename: str) -> dict:
"""Read a file from the SD card.
Returns the file content as base64-encoded data. The firmware sends
a 4-byte size header followed by raw file data.
Args:
filename: Name of the file to read
"""
self._ensure_connected()
if not self._has_capability("sd_read"):
return {"error": "SD card commands not supported by this firmware/hardware"}
import struct
import time
self._protocol._drain()
self._protocol._send_command(f"sd_read {filename}")
ser = self._protocol._require_connection()
old_timeout = ser.timeout
ser.timeout = 10.0
try:
buf = b""
deadline = time.monotonic() + 10.0
# Read past echo line
while time.monotonic() < deadline:
chunk = ser.read(max(1, ser.in_waiting))
if chunk:
buf += chunk
if b"\r\n" in buf:
break
# Check for error response
content = buf.decode("ascii", errors="replace")
if "err:" in content.lower():
return {"error": "File not found or no SD card"}
echo_end = buf.index(b"\r\n") + 2
data_buf = buf[echo_end:]
# Read 4-byte file size header
while len(data_buf) < 4 and time.monotonic() < deadline:
chunk = ser.read(4 - len(data_buf))
if chunk:
data_buf += chunk
if len(data_buf) < 4:
return {"error": "Failed to read file size header"}
file_size = struct.unpack_from("<I", data_buf, 0)[0]
data_buf = data_buf[4:]
# Read file data
while len(data_buf) < file_size and time.monotonic() < deadline:
remaining = file_size - len(data_buf)
chunk = ser.read(min(remaining, 4096))
if chunk:
data_buf += chunk
# Drain trailing prompt
trailing = b""
while time.monotonic() < deadline:
chunk = ser.read(max(1, ser.in_waiting))
if chunk:
trailing += chunk
if b"ch> " in trailing or not chunk:
break
return {
"filename": filename,
"size": file_size,
"data_base64": base64.b64encode(data_buf[:file_size]).decode("ascii"),
}
finally:
ser.timeout = old_timeout
def sd_delete(self, filename: str) -> dict:
"""Delete a file from the SD card.
Args:
filename: Name of the file to delete
"""
self._ensure_connected()
if not self._has_capability("sd_delete"):
return {"error": "SD card commands not supported by this firmware/hardware"}
lines = self._protocol.send_text_command(f"sd_delete {filename}", timeout=10.0)
success = any("ok" in line.lower() for line in lines)
return {"filename": filename, "deleted": success, "response": lines}
def time(
self,
field: str | None = None,
value: int | None = None,
) -> dict:
"""Get or set RTC (real-time clock) time.
With no args, returns current date/time. With field + value, sets
a specific time component.
Args:
field: Time field to set: y, m, d, h, min, sec, or ppm
value: Value for the field (0-99 for date/time, float for ppm)
"""
self._ensure_connected()
if not self._has_capability("time"):
return {"error": "RTC (time) command not supported by this firmware/hardware"}
if field is not None and value is not None:
lines = self._protocol.send_text_command(f"time {field} {value}")
return {"field": field, "value": value, "response": lines}
lines = self._protocol.send_text_command("time")
# Response format: "20YY/MM/DD HH:MM:SS\nusage: ..."
for line in lines:
line = line.strip()
if "/" in line and ":" in line and "usage" not in line.lower():
return {"datetime": line}
return {"response": lines}

View File

@ -0,0 +1,323 @@
"""DiagnosticsMixin — low-level hardware diagnostics, register access, and debug tools."""
from __future__ import annotations
import re
class DiagnosticsMixin:
"""Diagnostic tools: i2c, si, lcd, threads, stat, sample, test, gain, dump, port, offset, dac, usart_cfg, usart, band."""
def i2c(self, page: int, reg: int, data: int) -> dict:
"""Write to an I2C register on the TLV320AIC3204 audio codec.
Low-level diagnostic tool for direct codec register access.
Args:
page: I2C register page number
reg: Register address within the page
data: Byte value to write (0-255)
"""
self._ensure_connected()
if not self._has_capability("i2c"):
return {"error": "i2c command not supported by this firmware"}
lines = self._protocol.send_text_command(f"i2c {page} {reg} {data}")
return {"page": page, "reg": reg, "data": data, "response": lines}
def si(self, reg: int, value: int) -> dict:
"""Write to a Si5351 frequency synthesizer register.
Low-level diagnostic tool for direct Si5351 register access.
Args:
reg: Si5351 register address
value: Byte value to write (0-255)
"""
self._ensure_connected()
if not self._has_capability("si"):
return {"error": "si (Si5351 register) command not supported by this firmware"}
lines = self._protocol.send_text_command(f"si {reg} {value}")
return {"reg": reg, "value": value, "response": lines}
def lcd(self, register: int, data_bytes: list[int] | None = None) -> dict:
"""Send a register command to the LCD display controller.
Low-level diagnostic tool. First arg is the register/command byte,
remaining are data bytes.
Args:
register: LCD register/command byte
data_bytes: Additional data bytes (list of ints)
"""
self._ensure_connected()
if not self._has_capability("lcd"):
return {"error": "lcd command not supported by this firmware"}
parts = [str(register)]
if data_bytes:
parts.extend(str(d) for d in data_bytes)
lines = self._protocol.send_text_command(f"lcd {' '.join(parts)}")
# Response: "ret = 0x..."
for line in lines:
if "ret" in line.lower():
return {"register": register, "data_bytes": data_bytes or [], "result": line.strip()}
return {"register": register, "data_bytes": data_bytes or [], "response": lines}
def threads(self) -> dict:
"""List ChibiOS RTOS thread information.
Shows all running threads with their stack usage, priority, and state.
Useful for diagnosing firmware issues.
TODO: When hardware with ENABLE_THREADS_COMMAND is available, explore
representing ChibiOS threads as MCP Tasks (FastMCP tasks=True) so they
surface in Claude Code's /tasks UI with live state tracking.
"""
self._ensure_connected()
if not self._has_capability("threads"):
return {"error": "threads command not supported by this firmware"}
lines = self._protocol.send_text_command("threads")
threads = []
for line in lines:
line = line.strip()
if not line or "stklimit" in line.lower():
continue # Skip header
parts = line.split("|")
if len(parts) >= 8:
threads.append(
{
"stk_limit": parts[0].strip(),
"stack": parts[1].strip(),
"stk_free": parts[2].strip(),
"addr": parts[3].strip(),
"refs": parts[4].strip(),
"prio": parts[5].strip(),
"state": parts[6].strip(),
"name": parts[7].strip(),
}
)
return {"threads": threads, "count": len(threads)}
def stat(self) -> dict:
"""Get audio ADC statistics for both channels.
Returns average and RMS values for the reference and signal channels
on both ports. Useful for diagnosing signal level issues.
"""
self._ensure_connected()
if not self._has_capability("stat"):
return {"error": "stat command not supported by this firmware"}
lines = self._protocol.send_text_command("stat", timeout=10.0)
channels = []
current_ch: dict = {}
for line in lines:
line = line.strip()
if line.startswith("Ch:"):
if current_ch:
channels.append(current_ch)
current_ch = {"channel": line.split(":")[1].strip()}
elif "average:" in line.lower():
nums = re.findall(r"-?\d+", line)
if len(nums) >= 2:
current_ch["average_ref"] = int(nums[0])
current_ch["average_signal"] = int(nums[1])
elif "rms:" in line.lower():
nums = re.findall(r"-?\d+", line)
if len(nums) >= 2:
current_ch["rms_ref"] = int(nums[0])
current_ch["rms_signal"] = int(nums[1])
if current_ch:
channels.append(current_ch)
return {"channels": channels}
def sample(self, mode: str) -> dict:
"""Set the ADC sample capture function.
Controls what data the sample command captures from the audio ADC.
Args:
mode: Sample mode 'gamma' (complex reflection), 'ampl' (amplitude),
or 'ref' (reference amplitude)
"""
self._ensure_connected()
if not self._has_capability("sample"):
return {"error": "sample command not supported by this firmware"}
valid = {"gamma", "ampl", "ref"}
if mode not in valid:
return {"error": f"Invalid mode '{mode}'. Valid: {', '.join(sorted(valid))}"}
lines = self._protocol.send_text_command(f"sample {mode}")
return {"mode": mode, "response": lines}
def test(self) -> dict:
"""Run hardware self-test.
Executes built-in hardware diagnostics. The specific tests depend
on the firmware build configuration.
"""
self._ensure_connected()
if not self._has_capability("test"):
return {"error": "test command not supported by this firmware"}
lines = self._protocol.send_text_command("test", timeout=30.0)
return {"response": lines}
def gain(self, lgain: int, rgain: int | None = None) -> dict:
"""Set audio codec gain levels.
Controls the TLV320AIC3204 PGA (Programmable Gain Amplifier).
Args:
lgain: Left channel gain (0-95, in 0.5 dB steps)
rgain: Right channel gain (0-95). If omitted, uses lgain for both.
"""
self._ensure_connected()
if not self._has_capability("gain"):
return {"error": "gain command not supported by this firmware"}
if rgain is not None:
lines = self._protocol.send_text_command(f"gain {lgain} {rgain}")
return {"lgain": lgain, "rgain": rgain, "response": lines}
lines = self._protocol.send_text_command(f"gain {lgain}")
return {"lgain": lgain, "rgain": lgain, "response": lines}
def dump(self, channel: int = 0) -> dict:
"""Dump raw audio ADC samples.
Captures and returns raw sample data from the audio buffer.
Useful for signal analysis and debugging.
Args:
channel: Audio channel to dump (0 or 1)
"""
self._ensure_connected()
if not self._has_capability("dump"):
return {"error": "dump command not supported by this firmware"}
lines = self._protocol.send_text_command(f"dump {channel}", timeout=10.0)
samples = []
for line in lines:
for val in line.strip().split():
try:
samples.append(int(val))
except ValueError:
pass
return {"channel": channel, "samples": samples, "count": len(samples)}
def port(self, port_num: int) -> dict:
"""Select the active audio port (TX or RX).
Switches the TLV320AIC3204 codec between transmit and receive paths.
Args:
port_num: Port number (0=TX, 1=RX)
"""
self._ensure_connected()
if not self._has_capability("port"):
return {"error": "port command not supported by this firmware"}
lines = self._protocol.send_text_command(f"port {port_num}")
return {"port": port_num, "description": "TX" if port_num == 0 else "RX", "response": lines}
def offset(self, frequency_hz: int | None = None) -> dict:
"""Get or set the variable IF frequency offset.
Adjusts the intermediate frequency offset used in the measurement
pipeline. Only available on firmware builds with USE_VARIABLE_OFFSET.
Args:
frequency_hz: IF offset frequency in Hz
"""
self._ensure_connected()
if not self._has_capability("offset"):
return {"error": "offset command not supported by this firmware"}
if frequency_hz is not None:
lines = self._protocol.send_text_command(f"offset {frequency_hz}")
return {"offset_hz": frequency_hz, "set": True, "response": lines}
lines = self._protocol.send_text_command("offset")
for line in lines:
if "current:" in line.lower():
parts = line.split(":")
if len(parts) >= 2:
try:
return {"offset_hz": int(parts[1].strip())}
except ValueError:
pass
return {"response": lines}
def dac(self, value: int | None = None) -> dict:
"""Get or set the DAC output value.
Controls the on-chip DAC (used for bias voltage or other analog output).
Range is 0-4095 (12-bit).
Args:
value: DAC value (0-4095)
"""
self._ensure_connected()
if not self._has_capability("dac"):
return {"error": "dac command not supported by this firmware/hardware"}
if value is not None:
lines = self._protocol.send_text_command(f"dac {value}")
return {"dac_value": value, "set": True, "response": lines}
lines = self._protocol.send_text_command("dac")
for line in lines:
if "current:" in line.lower():
parts = line.split(":")
if len(parts) >= 2:
try:
return {"dac_value": int(parts[1].strip())}
except ValueError:
pass
return {"response": lines}
def usart_cfg(self, baudrate: int | None = None) -> dict:
"""Get or set the USART serial port configuration.
Controls the secondary serial port (USART) baud rate. The USART can
be used for external device communication or serial console.
Args:
baudrate: Baud rate (minimum 300). Omit to query current setting.
"""
self._ensure_connected()
if not self._has_capability("usart_cfg"):
return {"error": "usart_cfg command not supported by this firmware"}
if baudrate is not None:
lines = self._protocol.send_text_command(f"usart_cfg {baudrate}")
return {"baudrate": baudrate, "set": True, "response": lines}
lines = self._protocol.send_text_command("usart_cfg")
# Response: "Serial: <baud> baud"
for line in lines:
if "baud" in line.lower():
m = re.search(r"(\d+)\s*baud", line, re.IGNORECASE)
if m:
return {"baudrate": int(m.group(1))}
return {"response": lines}
def usart(self, data: str, timeout_ms: int = 200) -> dict:
"""Send data through the USART serial port and read the response.
Forwards data to an external device connected to the USART port
and returns any response received within the timeout period.
Args:
data: String data to send
timeout_ms: Response timeout in milliseconds (default 200)
"""
self._ensure_connected()
if not self._has_capability("usart"):
return {"error": "usart command not supported by this firmware"}
lines = self._protocol.send_text_command(f"usart {data} {timeout_ms}", timeout=5.0)
return {"sent": data, "timeout_ms": timeout_ms, "response": lines}
def band(self, index: int, param: str, value: int) -> dict:
"""Configure frequency band parameters for the Si5351 synthesizer.
Low-level control of per-band synthesizer settings. Parameters include
mode, frequency, divider, multiplier, and power settings.
Args:
index: Band index
param: Parameter name mode, freq, div, mul, omul, pow, opow, l, r, lr, adj
value: Parameter value
"""
self._ensure_connected()
if not self._has_capability("b"):
return {"error": "band (b) command not supported by this firmware"}
lines = self._protocol.send_text_command(f"b {index} {param} {value}")
return {"index": index, "param": param, "value": value, "response": lines}

View File

@ -0,0 +1,161 @@
"""DisplayMixin — trace, transform, smoothing, touch, and remote desktop tools."""
from __future__ import annotations
class DisplayMixin:
"""Display and touch tools: trace, transform, smooth, touchcal, touchtest, refresh, touch, release."""
def trace(
self,
number: int | None = None,
trace_type: str | None = None,
channel: int | None = None,
scale: float | None = None,
refpos: float | None = None,
) -> dict:
"""Query or configure display traces.
Trace types: logmag, phase, delay, smith, polar, linear, swr, real, imag,
r, x, z, zp, g, b, y, rp, xp, and many more.
Args:
number: Trace number (0-3)
trace_type: Display format (e.g. 'logmag', 'swr', 'smith')
channel: Data channel (0=S11, 1=S21)
scale: Y-axis scale value
refpos: Reference position on display
"""
self._ensure_connected()
if number is not None:
if trace_type is not None:
cmd = f"trace {number} {trace_type}"
if channel is not None:
cmd += f" {channel}"
self._protocol.send_text_command(cmd)
elif scale is not None:
self._protocol.send_text_command(f"trace {number} scale {scale}")
elif refpos is not None:
self._protocol.send_text_command(f"trace {number} refpos {refpos}")
lines = self._protocol.send_text_command("trace")
return {"traces": lines}
def transform(self, mode: str | None = None) -> dict:
"""Control time-domain transform mode.
Modes: 'on', 'off', 'impulse', 'step', 'bandpass', 'minimum', 'normal', 'maximum'.
Args:
mode: Transform mode to set
"""
self._ensure_connected()
if not self._has_capability("transform"):
return {"error": "transform command not supported by this firmware"}
if mode is not None:
lines = self._protocol.send_text_command(f"transform {mode}")
return {"transform": mode, "response": lines}
lines = self._protocol.send_text_command("transform")
return {"transform_status": lines}
def smooth(self, factor: int | None = None) -> dict:
"""Get or set trace smoothing factor.
Args:
factor: Smoothing factor (0=off, higher=more smoothing)
"""
self._ensure_connected()
if not self._has_capability("smooth"):
return {"error": "smooth command not supported by this firmware"}
if factor is not None:
self._protocol.send_text_command(f"smooth {factor}")
lines = self._protocol.send_text_command("smooth")
return {"response": lines}
def touchcal(self) -> dict:
"""Start touch screen calibration sequence.
Interactive: the device displays calibration targets. Touch the upper-left
corner, then the lower-right corner when prompted. Returns the calibration
parameters when complete.
Note: This is an interactive hardware procedure that requires physical
touch input on the device screen.
"""
self._ensure_connected()
lines = self._protocol.send_text_command("touchcal", timeout=30.0)
# Parse "touch cal params: a b c d"
for line in lines:
if "touch cal params:" in line.lower():
parts = line.split(":")
if len(parts) >= 2:
vals = parts[1].strip().split()
if len(vals) >= 4:
try:
return {
"calibrated": True,
"params": [int(v) for v in vals[:4]],
"response": lines,
}
except ValueError:
pass
return {"response": lines}
def touchtest(self) -> dict:
"""Start touch screen test mode.
Enters a mode where touch points are drawn on screen for verification.
Useful for checking touch calibration accuracy. Exit by sending another
command or resetting.
"""
self._ensure_connected()
lines = self._protocol.send_text_command("touchtest", timeout=10.0)
return {"mode": "touchtest", "response": lines}
def refresh(self, enable: str | None = None) -> dict:
"""Enable or disable remote desktop refresh mode.
When enabled, the device streams display updates over the serial
connection for remote viewing.
Args:
enable: 'on' to enable remote desktop, 'off' to disable
"""
self._ensure_connected()
if enable is not None:
if enable not in ("on", "off"):
return {"error": "enable must be 'on' or 'off'"}
lines = self._protocol.send_text_command(f"refresh {enable}")
return {"remote_desktop": enable, "response": lines}
return {"error": "usage: refresh on|off"}
def touch(self, x: int, y: int) -> dict:
"""Send a touch press event at screen coordinates.
Simulates a finger press on the NanoVNA touchscreen for remote control.
Follow with 'release' to complete the touch gesture.
Args:
x: X coordinate (0 to lcd_width-1)
y: Y coordinate (0 to lcd_height-1)
"""
self._ensure_connected()
lines = self._protocol.send_text_command(f"touch {x} {y}")
return {"action": "press", "x": x, "y": y, "response": lines}
def release(self, x: int = -1, y: int = -1) -> dict:
"""Send a touch release event at screen coordinates.
Completes a touch gesture started with 'touch'. If coordinates
are omitted (-1), releases at the last touch position.
Args:
x: X coordinate (-1 for last position)
y: Y coordinate (-1 for last position)
"""
self._ensure_connected()
if x >= 0 and y >= 0:
lines = self._protocol.send_text_command(f"release {x} {y}")
else:
lines = self._protocol.send_text_command("release")
return {"action": "release", "x": x, "y": y, "response": lines}

View File

@ -0,0 +1,288 @@
"""MeasurementMixin — essential sweep, scan, calibration, and data retrieval tools."""
from __future__ import annotations
import asyncio
from fastmcp import Context
from mcnanovna.protocol import (
SCAN_MASK_BINARY,
SCAN_MASK_NO_CALIBRATION,
SCAN_MASK_OUT_DATA0,
SCAN_MASK_OUT_DATA1,
SCAN_MASK_OUT_FREQ,
parse_float_pairs,
parse_frequencies,
parse_scan_binary,
parse_scan_text,
)
# Channel name mapping for the data command
CHANNEL_NAMES = {
0: "S11 (measured)",
1: "S21 (measured)",
2: "ETERM_ED (directivity)",
3: "ETERM_ES (source match)",
4: "ETERM_ER (reflection tracking)",
5: "ETERM_ET (transmission tracking)",
6: "ETERM_EX (isolation)",
}
class MeasurementMixin:
"""Tier 1 tools: info, sweep, scan, data, frequencies, marker, cal, save, recall, pause, resume."""
def info(self) -> dict:
"""Get NanoVNA device information: board, firmware version, capabilities, display size, and hardware parameters."""
self._ensure_connected()
di = self._protocol.device_info
return {
"board": di.board,
"version": di.version,
"max_points": di.max_points,
"if_hz": di.if_hz,
"adc_hz": di.adc_hz,
"lcd_width": di.lcd_width,
"lcd_height": di.lcd_height,
"architecture": di.architecture,
"platform": di.platform,
"build_time": di.build_time,
"capabilities": di.capabilities,
"port": self._port,
}
def sweep(
self,
start_hz: int | None = None,
stop_hz: int | None = None,
points: int | None = None,
) -> dict:
"""Get or set the sweep frequency range. With no args, returns current settings. With args, sets new sweep parameters.
Args:
start_hz: Start frequency in Hz (e.g. 50000 for 50 kHz)
stop_hz: Stop frequency in Hz (e.g. 900000000 for 900 MHz)
points: Number of sweep points (max depends on hardware: 101 or 401)
"""
self._ensure_connected()
if start_hz is not None:
parts = [str(start_hz)]
if stop_hz is not None:
parts.append(str(stop_hz))
if points is not None:
parts.append(str(points))
self._protocol.send_text_command(f"sweep {' '.join(parts)}")
lines = self._protocol.send_text_command("sweep")
# Response: "start_hz stop_hz points"
if lines:
parts = lines[0].strip().split()
if len(parts) >= 3:
return {
"start_hz": int(parts[0]),
"stop_hz": int(parts[1]),
"points": int(parts[2]),
}
return {"start_hz": 0, "stop_hz": 0, "points": 0}
async def scan(
self,
start_hz: int,
stop_hz: int,
points: int = 101,
s11: bool = True,
s21: bool = True,
apply_cal: bool = True,
ctx: Context | None = None,
) -> dict:
"""Perform a frequency sweep and return S-parameter measurement data.
This is the primary measurement tool. It configures the sweep range,
triggers acquisition, and returns calibrated S11/S21 complex data.
Args:
start_hz: Start frequency in Hz (min ~600, max 2000000000)
stop_hz: Stop frequency in Hz
points: Number of measurement points (1 to device max, typically 101 or 401)
s11: Include S11 reflection data
s21: Include S21 transmission data
apply_cal: Apply stored calibration correction (set False for raw data)
"""
from mcnanovna.tools import _progress
await _progress(ctx, 1, 4, "Connecting to NanoVNA...")
await asyncio.to_thread(self._ensure_connected)
mask = SCAN_MASK_OUT_FREQ
if s11:
mask |= SCAN_MASK_OUT_DATA0
if s21:
mask |= SCAN_MASK_OUT_DATA1
if not apply_cal:
mask |= SCAN_MASK_NO_CALIBRATION
use_binary = self._has_capability("scan_bin")
await _progress(ctx, 2, 4, "Sending scan command...")
if use_binary:
binary_mask = mask | SCAN_MASK_BINARY
await _progress(ctx, 3, 4, f"Waiting for sweep data ({points} points)...")
rx_mask, rx_points, raw = await asyncio.to_thread(
self._protocol.send_binary_scan, start_hz, stop_hz, points, binary_mask
)
scan_points = parse_scan_binary(rx_mask, rx_points, raw)
else:
await _progress(ctx, 3, 4, f"Waiting for sweep data ({points} points)...")
lines = await asyncio.to_thread(
self._protocol.send_text_command,
f"scan {start_hz} {stop_hz} {points} {mask}",
30.0,
)
scan_points = parse_scan_text(lines, mask)
await _progress(ctx, 4, 4, "Parsing measurement data...")
data = []
for pt in scan_points:
entry: dict = {}
if pt.frequency_hz is not None:
entry["frequency_hz"] = pt.frequency_hz
if pt.s11 is not None:
entry["s11"] = {"real": pt.s11[0], "imag": pt.s11[1]}
if pt.s21 is not None:
entry["s21"] = {"real": pt.s21[0], "imag": pt.s21[1]}
data.append(entry)
return {
"start_hz": start_hz,
"stop_hz": stop_hz,
"points": len(data),
"binary": use_binary,
"mask": mask,
"data": data,
}
def data(self, channel: int = 0) -> dict:
"""Read measurement or calibration data arrays from device memory.
Channels: 0=S11 measured, 1=S21 measured, 2=directivity, 3=source match,
4=reflection tracking, 5=transmission tracking, 6=isolation.
Args:
channel: Data array index (0-6)
"""
self._ensure_connected()
if channel < 0 or channel > 6:
return {"error": "Channel must be 0-6"}
lines = self._protocol.send_text_command(f"data {channel}")
pairs = parse_float_pairs(lines)
return {
"channel": channel,
"channel_name": CHANNEL_NAMES.get(channel, f"channel {channel}"),
"points": len(pairs),
"data": [{"real": r, "imag": i} for r, i in pairs],
}
def frequencies(self) -> dict:
"""Get the list of frequency points for the current sweep configuration."""
self._ensure_connected()
lines = self._protocol.send_text_command("frequencies")
freqs = parse_frequencies(lines)
return {"count": len(freqs), "frequencies_hz": freqs}
def marker(
self,
number: int | None = None,
action: str | None = None,
index: int | None = None,
) -> dict:
"""Query or control markers on the NanoVNA display.
With no args, lists all active markers. With number + action, controls a specific marker.
Args:
number: Marker number (1-8)
action: Action to perform: 'on', 'off', or omit to query
index: Set marker to this sweep point index
"""
self._ensure_connected()
if number is not None:
if action is not None:
self._protocol.send_text_command(f"marker {number} {action}")
elif index is not None:
self._protocol.send_text_command(f"marker {number} {index}")
lines = self._protocol.send_text_command("marker")
markers = []
for line in lines:
parts = line.strip().split()
if len(parts) >= 3:
try:
markers.append(
{
"id": int(parts[0]),
"index": int(parts[1]),
"frequency_hz": int(parts[2]),
}
)
except ValueError:
pass
return {"markers": markers}
async def cal(self, step: str | None = None, ctx: Context | None = None) -> dict:
"""Query calibration status or perform a calibration step.
Steps: 'load', 'open', 'short', 'thru', 'isoln', 'done', 'on', 'off', 'reset'.
With no args, returns current calibration status.
Args:
step: Calibration step to execute
"""
from mcnanovna.tools import _progress
await asyncio.to_thread(self._ensure_connected)
if step is not None:
valid = {"load", "open", "short", "thru", "isoln", "done", "on", "off", "reset"}
if step not in valid:
return {"error": f"Invalid step '{step}'. Valid: {', '.join(sorted(valid))}"}
await _progress(ctx, 1, 2, f"Sending calibration command: {step}...")
lines = await asyncio.to_thread(self._protocol.send_text_command, f"cal {step}", 10.0)
await _progress(ctx, 2, 2, f"Calibration step '{step}' complete")
return {"step": step, "response": lines}
lines = await asyncio.to_thread(self._protocol.send_text_command, "cal")
return {"status": lines}
def save(self, slot: int) -> dict:
"""Save current calibration and configuration to a flash memory slot.
Args:
slot: Save slot number (0-4 on NanoVNA-H, 0-6 on NanoVNA-H4)
"""
self._ensure_connected()
self._protocol.send_text_command(f"save {slot}")
return {"slot": slot, "saved": True}
def recall(self, slot: int) -> dict:
"""Recall calibration and configuration from a flash memory slot.
Args:
slot: Save slot number (0-4 on NanoVNA-H, 0-6 on NanoVNA-H4)
"""
self._ensure_connected()
self._protocol.send_text_command(f"recall {slot}", timeout=5.0)
return {"slot": slot, "recalled": True}
def pause(self) -> dict:
"""Pause the continuous sweep. Measurements freeze at current values."""
self._ensure_connected()
self._protocol.send_text_command("pause")
return {"sweep": "paused"}
def resume(self) -> dict:
"""Resume continuous sweep after pause."""
self._ensure_connected()
self._protocol.send_text_command("resume")
return {"sweep": "running"}

View File

@ -0,0 +1,213 @@
"""PatternImportMixin — MCP tools for importing measured antenna patterns."""
from __future__ import annotations
from fastmcp import Context
class PatternImportMixin:
"""Pattern import tools: import CSV, EMCAR, NEC2, S1P files and list formats."""
async def import_pattern_csv(
self,
content: str,
frequency_hz: float | None = None,
theta_step: float = 2.0,
phi_step: float = 5.0,
ctx: Context | None = None,
) -> dict:
"""Import an antenna pattern from CSV data.
Auto-detects 2-column (angle, gain) vs 3-column (theta, phi, gain) from
the header. Flexible header names: theta/elevation, phi/azimuth,
gain/gain_dbi/amplitude/db.
For 2-column data, the angle column name determines the cut plane
the single cut is synthesized into a full 3D pattern with a sin(theta) taper.
Returns the standardized {theta_deg, phi_deg, gain_dbi} pattern dict
for 3D visualization.
Args:
content: CSV file content as string
frequency_hz: Operating frequency in Hz (optional, for metadata)
theta_step: Output grid polar angle step in degrees (default 2)
phi_step: Output grid azimuthal angle step in degrees (default 5)
"""
from mcnanovna.pattern_import import parse_csv_pattern
from mcnanovna.tools import _progress
await _progress(ctx, 1, 2, "Parsing CSV pattern data...")
result = parse_csv_pattern(content, frequency_hz=frequency_hz)
await _progress(ctx, 2, 2, f"Imported CSV pattern: {result.get('import_info', {}).get('points', '?')} points")
return result
async def import_pattern_emcar(
self,
content: str,
frequency_hz: float | None = None,
reference_dbi: float | None = None,
theta_step: float = 2.0,
phi_step: float = 5.0,
ctx: Context | None = None,
) -> dict:
"""Import an antenna pattern from EMCAR vna.dat format.
EMCAR records angle + amplitude_dBV pairs from a positioner-driven
antenna range (typically HP8754A VNA). Lines starting with # are comments.
The gnuplot transform is applied: (-angle+90) rotation and
20*log10(amplitude+0.01) voltage-to-dB conversion. This produces a
single azimuth cut which is synthesized into a 3D pattern.
Without a reference antenna, the pattern shows relative shape only.
Pass reference_dbi to offset to absolute gain.
Args:
content: vna.dat file content as string
frequency_hz: Operating frequency in Hz (optional)
reference_dbi: Reference antenna gain offset in dBi (optional)
theta_step: Output grid polar angle step in degrees (default 2)
phi_step: Output grid azimuthal angle step in degrees (default 5)
"""
from mcnanovna.pattern_import import parse_emcar_vna_dat
from mcnanovna.tools import _progress
await _progress(ctx, 1, 2, "Parsing EMCAR vna.dat data...")
result = parse_emcar_vna_dat(content, frequency_hz=frequency_hz, reference_dbi=reference_dbi)
await _progress(ctx, 2, 2, f"Imported EMCAR pattern: {result.get('import_info', {}).get('points', '?')} points")
return result
async def import_pattern_nec2(
self,
content: str,
polarization: str = "total",
theta_step: float = 2.0,
phi_step: float = 5.0,
ctx: Context | None = None,
) -> dict:
"""Import an antenna pattern from NEC2 radiation output.
Parses the RADIATION PATTERNS section from NEC2/NEC4 output files.
Extracts frequency from the header and provides full theta x phi gain data.
Supports polarization selection: 'total' (default), 'vert', or 'hor'.
Values of -999.99 in NEC2 output are mapped to a -40 dBi floor.
Args:
content: NEC2 output file content as string
polarization: Gain column to use 'total', 'vert', or 'hor' (default 'total')
theta_step: Output grid polar angle step in degrees (default 2)
phi_step: Output grid azimuthal angle step in degrees (default 5)
"""
from mcnanovna.pattern_import import parse_nec2_radiation
from mcnanovna.tools import _progress
await _progress(ctx, 1, 2, "Parsing NEC2 radiation pattern...")
result = parse_nec2_radiation(content, polarization=polarization)
await _progress(ctx, 2, 2, f"Imported NEC2 pattern: {result.get('import_info', {}).get('points', '?')} points")
return result
async def import_pattern_s1p(
self,
content: str,
antenna_type: str = "auto",
theta_step: float = 2.0,
phi_step: float = 5.0,
ctx: Context | None = None,
) -> dict:
"""Import S-parameters from a Touchstone S1P file and generate a pattern.
Parses S11 complex data, finds the resonant frequency (minimum |S11|),
computes impedance, and generates an analytical 3D pattern using the
Phase 1 antenna models. This bridges external VNA measurements to
pattern visualization.
Supports RI (real/imaginary), MA (magnitude/angle), and DB (dB/angle)
Touchstone formats. Reference impedance and frequency units are read
from the option line.
Args:
content: Touchstone .s1p file content as string
antenna_type: Antenna model 'dipole', 'monopole', 'efhw', 'loop', 'patch', or 'auto'
theta_step: Output grid polar angle step in degrees (default 2)
phi_step: Output grid azimuthal angle step in degrees (default 5)
"""
from mcnanovna.pattern_import import parse_touchstone_s1p
from mcnanovna.tools import _progress
await _progress(ctx, 1, 2, "Parsing Touchstone S1P data...")
result = parse_touchstone_s1p(content, antenna_type=antenna_type)
await _progress(
ctx,
2,
2,
f"Generated pattern from S1P: {result.get('antenna_type')} at "
f"{result.get('import_info', {}).get('resonant_frequency_hz', '?')} Hz",
)
return result
async def list_pattern_formats(
self,
ctx: Context | None = None,
) -> dict:
"""List supported pattern import formats with descriptions and examples.
Returns details about each format including file extensions, expected
data structure, and example content for testing.
"""
return {
"formats": [
{
"name": "CSV",
"extensions": [".csv"],
"tool": "import_pattern_csv",
"description": (
"Comma/semicolon/tab-separated pattern data. "
"Auto-detects 2-column (angle, gain) or 3-column (theta, phi, gain) from headers."
),
"example_3col": "theta,phi,gain_dbi\n0,0,-40\n90,0,2.15\n90,90,2.15\n180,0,-40",
"example_2col": "azimuth,gain_dbi\n0,2.15\n90,1.5\n180,0.8\n270,1.5",
},
{
"name": "EMCAR vna.dat",
"extensions": [".dat"],
"tool": "import_pattern_emcar",
"description": (
"EMCAR antenna range format: angle amplitude_dBV pairs. "
"Single azimuth cut from positioner-driven measurement. "
"Gnuplot transform applied: (-angle+90) rotation, 20*log10(amplitude+0.01)."
),
"example": "# EMCAR measurement\n0 0.5\n45 0.8\n90 1.0\n135 0.8\n180 0.5\n225 0.3\n270 0.2\n315 0.3",
"reference": "https://emcar.sourceforge.net/",
},
{
"name": "NEC2 Radiation",
"extensions": [".out", ".nec"],
"tool": "import_pattern_nec2",
"description": (
"NEC2/NEC4 output file with RADIATION PATTERNS section. "
"Full theta x phi grid with VERT, HOR, and TOTAL gain columns. "
"Frequency auto-detected from header."
),
"polarization_options": ["total", "vert", "hor"],
},
{
"name": "Touchstone S1P",
"extensions": [".s1p"],
"tool": "import_pattern_s1p",
"description": (
"Touchstone S-parameter file (S11 only). "
"Finds resonance, computes impedance, generates analytical pattern. "
"Supports RI, MA, and DB data formats."
),
"note": "Generates an analytical pattern from S11 data — not a measured pattern.",
},
],
"common_workflow": (
"1. Read the file content\n"
"2. Call the appropriate import_pattern_* tool with the content string\n"
"3. The tool returns the standard {theta_deg, phi_deg, gain_dbi} pattern dict\n"
"4. If the web UI is running, the pattern is automatically available for 3D rendering"
),
}

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()

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

@ -0,0 +1,299 @@
"""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, File, Form, UploadFile, 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
@app.post("/api/pattern/import")
async def api_pattern_import(
file: UploadFile = File(...),
format: str | None = Form(None),
frequency_hz: float | None = Form(None),
polarization: str = Form("total"),
antenna_type: str = Form("auto"),
reference_dbi: float | None = Form(None),
):
"""Import a pattern file (CSV, EMCAR, NEC2, or S1P) via multipart upload."""
from mcnanovna.pattern_import import detect_format, parse_pattern
content = (await file.read()).decode("utf-8", errors="replace")
filename = file.filename or "unknown"
# Auto-detect format from extension if not specified
if not format:
format = detect_format(filename, content)
kwargs: dict = {}
if format == "nec2":
kwargs["polarization"] = polarization
elif format == "s1p":
kwargs["antenna_type"] = antenna_type
elif format == "emcar" and reference_dbi is not None:
kwargs["reference_dbi"] = reference_dbi
pattern = parse_pattern(content, format, frequency_hz=frequency_hz, **kwargs)
# Add filename to import_info
if "import_info" in pattern:
pattern["import_info"]["filename"] = filename
else:
pattern["import_info"] = {"format": format, "filename": filename}
await _broadcast_pattern(pattern)
return pattern
# ── Positioner endpoints ─────────────────────────────────────
@app.get("/api/positioner/status")
async def api_positioner_status():
"""Get antenna positioner status (position, moving, homed)."""
try:
from mcnanovna.positioner import Positioner
pos = Positioner()
return await pos.status()
except ImportError:
return {"error": "Positioner support requires: pip install mcnanovna[positioner]"}
except Exception as exc:
return {"error": str(exc)}
@app.post("/api/positioner/move")
async def api_positioner_move(request_data: dict):
"""Move positioner to absolute theta/phi position."""
try:
from mcnanovna.positioner import Positioner
pos = Positioner()
theta = request_data.get("theta_deg", 0)
phi = request_data.get("phi_deg", 0)
result = await pos.move(theta, phi)
if request_data.get("wait", True):
result = await pos.wait_until_stopped()
return result
except Exception as exc:
return {"error": str(exc)}
@app.post("/api/positioner/home")
async def api_positioner_home(request_data: dict | None = None):
"""Home one or both positioner axes."""
try:
from mcnanovna.positioner import Positioner
pos = Positioner()
axis = (request_data or {}).get("axis", "both")
return await pos.home(axis)
except Exception as exc:
return {"error": str(exc)}
# ── 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-DVS5g-QC.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C5SDH5i7.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.2.1"
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"