├── Protocols.md ├── frontend ├── src │ ├── index.css │ ├── main.tsx │ ├── App.css │ ├── types.ts │ ├── components │ │ ├── StationRow.tsx │ │ ├── SlicePanel.tsx │ │ └── Settings.tsx │ ├── assets │ │ └── react.svg │ └── App.tsx ├── postcss.config.js ├── tsconfig.json ├── vite.config.ts ├── tailwind.config.js ├── .gitignore ├── index.html ├── eslint.config.js ├── tsconfig.node.json ├── tsconfig.app.json ├── package.json └── public │ └── vite.svg ├── src ├── state │ ├── index.ts │ └── types.ts ├── logbook │ └── index.ts ├── dashboard │ ├── index.ts │ └── DashboardManager.ts ├── utils │ ├── logger.ts │ └── CqTargeting.ts ├── wsjtx │ ├── types.ts │ ├── UdpListener.ts │ ├── UdpRebroadcaster.ts │ ├── ProcessManager.ts │ ├── QsoStateMachine.ts │ ├── WindowManager.ts │ └── UdpSender.ts ├── flex │ ├── FlexClient.ts │ ├── FlexDiscovery.ts │ └── Vita49Client.ts ├── index.ts ├── SettingsManager.ts └── web │ └── server.ts ├── tsconfig.json ├── .gitignore ├── templates └── wsjtx-template - old.ini ├── config.json ├── .github └── workflows │ └── security.yml ├── LICENSE ├── package.json ├── test.ps1 ├── MCP-AI-usage-guide.md ├── test-log4om-adif.js ├── Log-Control.md ├── install.cmd ├── Rig Control.md └── README.md /Protocols.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SensorsIot/FT8-MCP/main/Protocols.md -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | body { 4 | background-color: #111827; 5 | color: white; 6 | } 7 | -------------------------------------------------------------------------------- /frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | '@tailwindcss/postcss': {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vite.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /src/state/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * State module exports 3 | */ 4 | 5 | export * from './types'; 6 | export { StateManager, StateManagerConfig } from './StateManager'; 7 | export { ChannelUdpManager } from './ChannelUdpManager'; 8 | -------------------------------------------------------------------------------- /src/logbook/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Logbook module - exports LogbookManager and related types 3 | */ 4 | 5 | export { 6 | LogbookManager, 7 | LogbookManagerConfig, 8 | QsoRecord, 9 | WorkedEntry, 10 | frequencyToBand, 11 | } from './LogbookManager'; 12 | -------------------------------------------------------------------------------- /frontend/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: [ 4 | "./index.html", 5 | "./src/**/*.{js,ts,jsx,tsx}", 6 | ], 7 | theme: { 8 | extend: {}, 9 | }, 10 | plugins: [], 11 | } 12 | -------------------------------------------------------------------------------- /frontend/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import './index.css' 4 | import App from './App.tsx' 5 | 6 | createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /src/dashboard/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Dashboard module - exports DashboardManager and related types 3 | */ 4 | 5 | export { 6 | DashboardManager, 7 | DashboardManagerConfig, 8 | TrackedStation, 9 | SliceState, 10 | StationStatus, 11 | WsjtxDecode, 12 | WsjtxStatus, 13 | } from './DashboardManager'; 14 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | frontend 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "Node16", 5 | "moduleResolution": "Node16", 6 | "rootDir": "./src", 7 | "outDir": "./dist", 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "strict": true, 11 | "skipLibCheck": true, 12 | "declaration": true, 13 | "incremental": true, 14 | "tsBuildInfoFile": "./dist/.tsbuildinfo" 15 | }, 16 | "include": ["src/**/*"], 17 | "exclude": ["node_modules", "dist", "frontend"] 18 | } 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Credentials and Secrets 2 | .env 3 | .env.local 4 | .env.*.local 5 | *.pem 6 | *.key 7 | *.cert 8 | *.crt 9 | id_rsa 10 | id_rsa.pub 11 | secrets.json 12 | credentials.json 13 | 14 | # Dependencies 15 | node_modules/ 16 | 17 | # Build output 18 | dist/ 19 | 20 | # AI and Tooling Artifacts 21 | .gemini/ 22 | .antigravity/ 23 | .claude/ 24 | CLAUDE.md 25 | GEMINI.md 26 | AGENTS.md 27 | .codex/ 28 | .copilot/ 29 | .cursor/ 30 | .vscode/ 31 | .idea/ 32 | *.log 33 | tmp/ 34 | temp/ 35 | nul 36 | 37 | # Test/debug files 38 | slices.json 39 | slices-response.json 40 | 41 | # Windows artifacts 42 | C:UsersHB9BLsuperclaude.bat 43 | -------------------------------------------------------------------------------- /templates/wsjtx-template - old.ini: -------------------------------------------------------------------------------- 1 | [General] 2 | 3 | [MainWindow] 4 | SWLView=false 5 | ShowMenus=true 6 | 7 | [Common] 8 | Mode=FT8 9 | NDepth=3 10 | RxFreq=1500 11 | TxFreq=1500 12 | AutoSeq=true 13 | 14 | [WideGraph] 15 | PlotZero=0 16 | PlotGain=0 17 | PlotWidth=1707 18 | BinsPerPixel=1 19 | SmoothYellow=1 20 | Percent2D=30 21 | WaterfallAvg=5 22 | StartFreq=0 23 | Flatten=true 24 | 25 | [Configuration] 26 | MyCall=HB9BLA 27 | MyGrid=JN37VL 28 | HideControls=true 29 | RxBandwidth=2500 30 | PTTMethod=@Variant(\0\0\0\x7f\0\0\0\x1eTransceiverFactory::PTTMethod\0\0\0\0\xfPTT_method_VOX\0) 31 | UDPServer=127.0.0.1 32 | UDPServerPort=2237 33 | AcceptUDPRequests=false 34 | -------------------------------------------------------------------------------- /frontend/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | import { defineConfig, globalIgnores } from 'eslint/config' 7 | 8 | export default defineConfig([ 9 | globalIgnores(['dist']), 10 | { 11 | files: ['**/*.{ts,tsx}'], 12 | extends: [ 13 | js.configs.recommended, 14 | tseslint.configs.recommended, 15 | reactHooks.configs.flat.recommended, 16 | reactRefresh.configs.vite, 17 | ], 18 | languageOptions: { 19 | ecmaVersion: 2020, 20 | globals: globals.browser, 21 | }, 22 | }, 23 | ]) 24 | -------------------------------------------------------------------------------- /frontend/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2023", 5 | "lib": [ 6 | "ES2023" 7 | ], 8 | "module": "ESNext", 9 | "types": [ 10 | "node" 11 | ], 12 | "skipLibCheck": true, 13 | /* Bundler mode */ 14 | "moduleResolution": "bundler", 15 | "allowImportingTsExtensions": true, 16 | "verbatimModuleSyntax": true, 17 | "moduleDetection": "force", 18 | "noEmit": true, 19 | /* Linting */ 20 | "strict": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "noFallthroughCasesInSwitch": true, 24 | "noUncheckedSideEffectImports": true 25 | }, 26 | "include": [ 27 | "vite.config.ts" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /frontend/src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | text-align: center; 6 | } 7 | 8 | .logo { 9 | height: 6em; 10 | padding: 1.5em; 11 | will-change: filter; 12 | transition: filter 300ms; 13 | } 14 | .logo:hover { 15 | filter: drop-shadow(0 0 2em #646cffaa); 16 | } 17 | .logo.react:hover { 18 | filter: drop-shadow(0 0 2em #61dafbaa); 19 | } 20 | 21 | @keyframes logo-spin { 22 | from { 23 | transform: rotate(0deg); 24 | } 25 | to { 26 | transform: rotate(360deg); 27 | } 28 | } 29 | 30 | @media (prefers-reduced-motion: no-preference) { 31 | a:nth-of-type(2) .logo { 32 | animation: logo-spin infinite 20s linear; 33 | } 34 | } 35 | 36 | .card { 37 | padding: 2em; 38 | } 39 | 40 | .read-the-docs { 41 | color: #888; 42 | } 43 | -------------------------------------------------------------------------------- /frontend/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2022", 5 | "useDefineForClassFields": true, 6 | "lib": [ 7 | "ES2022", 8 | "DOM", 9 | "DOM.Iterable" 10 | ], 11 | "module": "ESNext", 12 | "types": [ 13 | "vite/client" 14 | ], 15 | "skipLibCheck": true, 16 | /* Bundler mode */ 17 | "moduleResolution": "bundler", 18 | "allowImportingTsExtensions": true, 19 | "verbatimModuleSyntax": true, 20 | "moduleDetection": "force", 21 | "noEmit": true, 22 | "jsx": "react-jsx", 23 | /* Linting */ 24 | "strict": true, 25 | "noUnusedLocals": true, 26 | "noUnusedParameters": true, 27 | "noFallthroughCasesInSwitch": true, 28 | "noUncheckedSideEffectImports": true 29 | }, 30 | "include": [ 31 | "src" 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "react": "^19.2.0", 14 | "react-dom": "^19.2.0" 15 | }, 16 | "devDependencies": { 17 | "@eslint/js": "^9.17.0", 18 | "@tailwindcss/postcss": "^4.0.0", 19 | "@types/node": "^24.10.1", 20 | "@types/react": "^18.3.18", 21 | "@types/react-dom": "^18.3.5", 22 | "@vitejs/plugin-react": "^4.3.4", 23 | "autoprefixer": "^10.4.20", 24 | "eslint": "^9.17.0", 25 | "eslint-plugin-react-hooks": "^5.0.0", 26 | "eslint-plugin-react-refresh": "^0.4.16", 27 | "globals": "^15.14.0", 28 | "postcss": "^8.4.49", 29 | "tailwindcss": "^4.0.0", 30 | "typescript": "~5.6.2", 31 | "typescript-eslint": "^8.18.2", 32 | "vite": "^6.0.5" 33 | } 34 | } -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "mode": "FLEX", 3 | "wsjtx": { 4 | "path": "C:\\WSJT\\wsjtx\\bin\\wsjtx.exe" 5 | }, 6 | "flex": { 7 | "host": "10.99.6.135", 8 | "catBasePort": 60000, 9 | "defaultBands": [ 10 | 28074000, 11 | 21074000, 12 | 14074000, 13 | 7074000 14 | ] 15 | }, 16 | "standard": { 17 | "rigName": "IC-7300" 18 | }, 19 | "station": { 20 | "callsign": "HB9BLA", 21 | "grid": "JN37VL", 22 | "continent": "EU", 23 | "dxcc": "HB", 24 | "prefixes": ["HB9", "HB3", "HB0"] 25 | }, 26 | "mcp": { 27 | "name": "wsjt-x-mcp", 28 | "version": "1.0.0" 29 | }, 30 | "web": { 31 | "port": 3001 32 | }, 33 | "dashboard": { 34 | "stationLifetimeSeconds": 120, 35 | "snrWeakThreshold": -15, 36 | "snrStrongThreshold": 0, 37 | "adifLogPath": "" 38 | }, 39 | "logbook": { 40 | "enableHrdServer": true, 41 | "hrdPort": 7800, 42 | "udpRebroadcast": { 43 | "enabled": true, 44 | "port": 2236, 45 | "instanceId": "WSJT-X-MCP", 46 | "host": "127.0.0.1" 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /.github/workflows/security.yml: -------------------------------------------------------------------------------- 1 | name: Security Checks 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | check-forbidden-files: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | 15 | - name: Check for forbidden files 16 | run: | 17 | forbidden_patterns=( 18 | ".env" 19 | ".env.local" 20 | "*.pem" 21 | "*.key" 22 | "id_rsa" 23 | "secrets.json" 24 | ".gemini/" 25 | ".antigravity/" 26 | ".claude/" 27 | ) 28 | 29 | found_forbidden=0 30 | for pattern in "${forbidden_patterns[@]}"; do 31 | if find . -name "$pattern" -print -quit | grep -q .; then 32 | echo "::error::Forbidden file or directory found matching pattern: $pattern" 33 | find . -name "$pattern" 34 | found_forbidden=1 35 | fi 36 | done 37 | 38 | if [ $found_forbidden -eq 1 ]; then 39 | exit 1 40 | fi 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 SensorsIot 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /frontend/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wsjt-x-mcp", 3 | "version": "1.0.0", 4 | "description": "![License](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)\r ![Windows](https://img.shields.io/badge/Windows-0078D6?style=for-the-badge&logo=windows&logoColor=white)", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "node --max-old-space-size=8192 node_modules/typescript/bin/tsc", 8 | "start": "node dist/index.js", 9 | "dev": "node --max-old-space-size=4096 node_modules/ts-node/dist/bin.js src/index.ts", 10 | "inspector": "npx @modelcontextprotocol/inspector node node_modules/ts-node/dist/bin.js src/index.ts", 11 | "test": "echo \"Error: no test specified\" && exit 1" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/SensorsIot/wsjt-x-MCP.git" 16 | }, 17 | "keywords": [], 18 | "author": "", 19 | "license": "ISC", 20 | "type": "commonjs", 21 | "bugs": { 22 | "url": "https://github.com/SensorsIot/wsjt-x-MCP/issues" 23 | }, 24 | "homepage": "https://github.com/SensorsIot/wsjt-x-MCP#readme", 25 | "dependencies": { 26 | "@modelcontextprotocol/sdk": "^1.0.4", 27 | "@types/express": "^5.0.5", 28 | "@types/node": "^24.10.1", 29 | "@types/ws": "^8.18.1", 30 | "express": "^5.1.0", 31 | "serialport": "^13.0.0", 32 | "ts-node": "^10.9.2", 33 | "typescript": "^5.9.3", 34 | "ws": "^8.18.3", 35 | "zod": "^3.25.76" 36 | }, 37 | "devDependencies": { 38 | "@modelcontextprotocol/inspector": "^0.17.2", 39 | "@types/serialport": "^8.0.5" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as path from 'path'; 3 | 4 | // Simple file logger for debugging 5 | class FileLogger { 6 | private logFilePath: string; 7 | private logStream: fs.WriteStream | null = null; 8 | 9 | constructor() { 10 | // Log to the project root directory 11 | this.logFilePath = path.join(process.cwd(), 'mcp-server.log'); 12 | this.initLogFile(); 13 | } 14 | 15 | private initLogFile() { 16 | try { 17 | // Create or append to log file 18 | this.logStream = fs.createWriteStream(this.logFilePath, { flags: 'a' }); 19 | this.log('=== MCP Server Started ==='); 20 | } catch (error) { 21 | console.error('Failed to create log file:', error); 22 | } 23 | } 24 | 25 | log(...args: any[]) { 26 | const timestamp = new Date().toISOString(); 27 | const message = args.map(arg => 28 | typeof arg === 'object' ? JSON.stringify(arg, null, 2) : String(arg) 29 | ).join(' '); 30 | 31 | const logLine = `[${timestamp}] ${message}\n`; 32 | 33 | // Write to file 34 | if (this.logStream) { 35 | this.logStream.write(logLine); 36 | } 37 | 38 | // Also write to stderr (so it doesn't break MCP stdio) 39 | console.error(message); 40 | } 41 | 42 | error(...args: any[]) { 43 | this.log('ERROR:', ...args); 44 | } 45 | 46 | warn(...args: any[]) { 47 | this.log('WARN:', ...args); 48 | } 49 | 50 | close() { 51 | if (this.logStream) { 52 | this.log('=== MCP Server Stopped ==='); 53 | this.logStream.end(); 54 | } 55 | } 56 | } 57 | 58 | // Singleton instance 59 | export const logger = new FileLogger(); 60 | -------------------------------------------------------------------------------- /frontend/src/types.ts: -------------------------------------------------------------------------------- 1 | // Station status for dashboard coloring (hierarchical priority) 2 | export type StationStatus = 'worked' | 'normal' | 'weak' | 'strong' | 'priority' | 'new_dxcc'; 3 | 4 | // Tracked station with all relevant data for dashboard display 5 | export interface TrackedStation { 6 | callsign: string; 7 | grid: string; 8 | snr: number; 9 | frequency: number; // Audio frequency offset (deltaFrequency) 10 | mode: string; 11 | lastSeen: number; // Timestamp of last decode 12 | firstSeen: number; // Timestamp of first decode in this session 13 | decodeCount: number; // Number of decodes received 14 | status: StationStatus; // Computed status for coloring 15 | message: string; // Last decoded message 16 | } 17 | 18 | // Slice/Instance state for dashboard 19 | export interface SliceState { 20 | id: string; // Instance/slice ID 21 | name: string; // Display name 22 | band: string; // Band (e.g., "20m", "40m") 23 | mode: string; // Operating mode (FT8, FT4, etc.) 24 | dialFrequency: number; // Dial frequency in Hz 25 | stations: TrackedStation[]; 26 | isTransmitting: boolean; 27 | txEnabled: boolean; 28 | } 29 | 30 | // Dashboard configuration from server 31 | export interface DashboardConfig { 32 | stationLifetimeSeconds: number; 33 | colors: Record; 34 | } 35 | 36 | // WebSocket message types 37 | export interface StationsUpdateMessage { 38 | type: 'STATIONS_UPDATE'; 39 | slices: SliceState[]; 40 | config: DashboardConfig; 41 | } 42 | 43 | export interface WelcomeMessage { 44 | type: 'WELCOME'; 45 | message: string; 46 | } 47 | 48 | export interface InstancesUpdateMessage { 49 | type: 'INSTANCES_UPDATE'; 50 | instances: Array<{ 51 | name: string; 52 | status: string; 53 | freq: string; 54 | }>; 55 | } 56 | 57 | export type WebSocketMessage = StationsUpdateMessage | WelcomeMessage | InstancesUpdateMessage; 58 | -------------------------------------------------------------------------------- /test.ps1: -------------------------------------------------------------------------------- 1 | # Quick Test Script for WSJT-X MCP Server 2 | # Target System: 10.99.6.171 3 | 4 | Write-Host "=== WSJT-X MCP Server - Quick Test ===" -ForegroundColor Cyan 5 | Write-Host "" 6 | 7 | # Check Node.js 8 | Write-Host "Checking Node.js..." -ForegroundColor Yellow 9 | $nodeVersion = node --version 10 | if ($nodeVersion) { 11 | Write-Host "✓ Node.js $nodeVersion installed" -ForegroundColor Green 12 | } else { 13 | Write-Host "✗ Node.js not found. Please install Node.js 18+" -ForegroundColor Red 14 | exit 1 15 | } 16 | 17 | # Check WSJT-X 18 | Write-Host "Checking WSJT-X..." -ForegroundColor Yellow 19 | $wsjtxPath = "C:\WSJT\wsjtx\bin\wsjtx.exe" 20 | if (Test-Path $wsjtxPath) { 21 | Write-Host "✓ WSJT-X found at $wsjtxPath" -ForegroundColor Green 22 | } else { 23 | Write-Host "⚠ WSJT-X not found at default path" -ForegroundColor Yellow 24 | Write-Host " Please update path in src/wsjtx/ProcessManager.ts" -ForegroundColor Yellow 25 | } 26 | 27 | # Check dependencies 28 | Write-Host "Checking dependencies..." -ForegroundColor Yellow 29 | if (Test-Path "node_modules") { 30 | Write-Host "✓ Dependencies installed" -ForegroundColor Green 31 | } else { 32 | Write-Host "Installing dependencies..." -ForegroundColor Yellow 33 | npm install 34 | if ($LASTEXITCODE -eq 0) { 35 | Write-Host "✓ Dependencies installed successfully" -ForegroundColor Green 36 | } else { 37 | Write-Host "✗ Failed to install dependencies" -ForegroundColor Red 38 | exit 1 39 | } 40 | } 41 | 42 | # Build frontend 43 | Write-Host "Building frontend..." -ForegroundColor Yellow 44 | cd frontend 45 | if (-not (Test-Path "dist")) { 46 | npm run build 47 | if ($LASTEXITCODE -eq 0) { 48 | Write-Host "✓ Frontend built successfully" -ForegroundColor Green 49 | } else { 50 | Write-Host "✗ Failed to build frontend" -ForegroundColor Red 51 | cd .. 52 | exit 1 53 | } 54 | } else { 55 | Write-Host "✓ Frontend already built" -ForegroundColor Green 56 | } 57 | cd .. 58 | 59 | # Display configuration 60 | Write-Host "" 61 | Write-Host "=== Configuration ===" -ForegroundColor Cyan 62 | $mode = if ($env:WSJTX_MODE) { $env:WSJTX_MODE } else { "STANDARD" } 63 | Write-Host "Operation Mode: $mode" -ForegroundColor White 64 | 65 | if ($mode -eq "FLEX") { 66 | $flexHost = if ($env:FLEX_HOST) { $env:FLEX_HOST } else { "255.255.255.255" } 67 | Write-Host "FlexRadio Host: $flexHost" -ForegroundColor White 68 | } 69 | 70 | # Display URLs 71 | Write-Host "" 72 | Write-Host "=== Access URLs ===" -ForegroundColor Cyan 73 | Write-Host "Web Dashboard: http://10.99.6.171:3000" -ForegroundColor White 74 | Write-Host "Local: http://localhost:3000" -ForegroundColor White 75 | 76 | # Start server 77 | Write-Host "" 78 | Write-Host "=== Starting Server ===" -ForegroundColor Cyan 79 | Write-Host "Press Ctrl+C to stop" -ForegroundColor Yellow 80 | Write-Host "" 81 | 82 | npm start 83 | -------------------------------------------------------------------------------- /MCP-AI-usage-guide.md: -------------------------------------------------------------------------------- 1 | # MCP AI Usage Guide (Compact Version) 2 | 3 | ## 1. Available MCP Interface 4 | ### Resources 5 | - **`wsjt-x://decodes`** — returns a `DecodesSnapshot`. 6 | 7 | ### Events 8 | - **`resources/updated`** (for `wsjt-x://decodes`) 9 | - Sent at the end of each decode cycle. 10 | - Includes the **full DecodesSnapshot**. 11 | 12 | ### Tools 13 | - **`call_cq({ band?, freq_hz?, mode? })`** 14 | - **`answer_decoded_station({ decode_id, force_mode? })`** 15 | - **`rig_get_state()`** *(optional)* 16 | 17 | No slice IDs, channel numbers, or WSJT-X instance details are exposed. 18 | 19 | --- 20 | 21 | ## 2. Canonical Types 22 | 23 | ### DecodeRecord 24 | ``` 25 | id string 26 | timestamp string (ISO) 27 | band string 28 | mode "FT8" | "FT4" 29 | dial_hz number 30 | audio_offset_hz number 31 | rf_hz number 32 | snr_db number 33 | dt_sec number 34 | call string 35 | grid string | null 36 | is_cq boolean 37 | is_my_call boolean 38 | is_directed_cq_to_me boolean 39 | cq_target_token string | null 40 | raw_text string 41 | 42 | is_new? boolean 43 | low_confidence? boolean 44 | off_air? boolean 45 | ``` 46 | 47 | ### DecodesSnapshot 48 | ``` 49 | snapshot_id: string 50 | generated_at: string 51 | decodes: DecodeRecord[] 52 | ``` 53 | 54 | --- 55 | 56 | ## 3. How the AI Client Should Operate 57 | 58 | ### Event-driven flow 59 | 1. Wait for `resources/updated` events. 60 | 2. Read `params.snapshot`. 61 | 3. Use `snapshot.decodes` directly. 62 | 63 | --- 64 | 65 | ## 4. Selecting a Station to Answer 66 | 67 | ### Filter candidates 68 | ```ts 69 | const candidates = snapshot.decodes.filter(d => 70 | d.is_cq && 71 | d.is_directed_cq_to_me && 72 | !already_worked(d.call, d.band, d.mode) 73 | ); 74 | ``` 75 | 76 | ### Rank and choose 77 | ```ts 78 | const best = candidates.sort((a, b) => b.snr_db - a.snr_db)[0]; 79 | ``` 80 | 81 | ### Answer 82 | ```ts 83 | answer_decoded_station({ decode_id: best.id }); 84 | ``` 85 | 86 | --- 87 | 88 | ## 5. When No Suitable Target Exists 89 | 90 | Start CQ: 91 | ```ts 92 | call_cq({ band: "20m", mode: "FT8" }); 93 | ``` 94 | 95 | --- 96 | 97 | ## 6. Rules the AI Must Follow 98 | 99 | ### MUST: 100 | - Only respond if: 101 | - `is_cq === true` 102 | - `is_directed_cq_to_me === true` 103 | - Use **decode_id** only. 104 | - Treat snapshot from the event as authoritative. 105 | 106 | ### MUST NOT: 107 | - Parse CQ text manually. 108 | - Use or infer slice/channel/instance information. 109 | - Attempt low-level WSJT-X control. 110 | 111 | --- 112 | 113 | ## 7. Recovery Logic 114 | If needed: 115 | ```ts 116 | const snapshot = getResource("wsjt-x://decodes"); 117 | ``` 118 | 119 | --- 120 | 121 | ## 8. Full Summary Algorithm 122 | 123 | ```ts 124 | on resources/updated: 125 | snapshot = params.snapshot 126 | decodes = snapshot.decodes 127 | 128 | targets = decodes 129 | .filter(d => 130 | d.is_cq && 131 | d.is_directed_cq_to_me && 132 | !already_worked(d.call, d.band, d.mode) 133 | ) 134 | .sort((a, b) => b.snr_db - a.snr_db) 135 | 136 | if (targets.length > 0): 137 | answer_decoded_station({ decode_id: targets[0].id }) 138 | else: 139 | call_cq({ band: "20m", mode: "FT8" }) 140 | ``` 141 | 142 | --- 143 | 144 | This guide is designed for embedding directly into AI instructions or prompt templates. 145 | -------------------------------------------------------------------------------- /src/wsjtx/types.ts: -------------------------------------------------------------------------------- 1 | // WSJT-X UDP Message Types (QQT encoding) 2 | // Reference: https://sourceforge.net/p/wsjt/wsjtx/ci/master/tree/Network/NetworkMessage.hpp 3 | export enum WsjtxMessageType { 4 | HEARTBEAT = 0, // Out/In - heartbeat with version info 5 | STATUS = 1, // Out - status update (frequency, mode, etc.) 6 | DECODE = 2, // Out - decoded message 7 | CLEAR = 3, // Out/In - clear decode windows 8 | REPLY = 4, // In - reply to a CQ/QRZ 9 | QSO_LOGGED = 5, // Out - QSO logged 10 | CLOSE = 6, // Out/In - application closing 11 | REPLAY = 7, // In - request decode replay 12 | HALT_TX = 8, // In - halt transmission 13 | FREE_TEXT = 9, // In - set free text message 14 | WSPR_DECODE = 10, // Out - WSPR decode 15 | LOCATION = 11, // In - set grid location 16 | LOGGED_ADIF = 12, // Out - ADIF log entry / In - Rig Control Command 17 | RIG_CONTROL = 12, // In - Rig control command (set frequency, mode, PTT) 18 | HIGHLIGHT_CALLSIGN = 13, // In - highlight a callsign 19 | SWITCH_CONFIGURATION = 14, // In - switch to named configuration 20 | CONFIGURE = 15, // In - configure mode, frequency, etc. 21 | } 22 | 23 | export interface WsjtxDecode { 24 | id: string; 25 | newDecode: boolean; 26 | time: number; 27 | snr: number; 28 | deltaTime: number; 29 | deltaFrequency: number; 30 | mode: string; 31 | message: string; 32 | lowConfidence: boolean; 33 | offAir: boolean; 34 | } 35 | 36 | export interface WsjtxStatus { 37 | id: string; 38 | dialFrequency: number; 39 | mode: string; 40 | dxCall: string; 41 | report: string; 42 | txMode: string; 43 | txEnabled: boolean; 44 | transmitting: boolean; 45 | decoding: boolean; 46 | rxDF: number; 47 | txDF: number; 48 | deCall: string; 49 | deGrid: string; 50 | dxGrid: string; 51 | txWatchdog: boolean; 52 | subMode: string; 53 | fastMode: boolean; 54 | specialOpMode: number; 55 | frequencyTolerance: number; 56 | trPeriod: number; 57 | configurationName: string; 58 | } 59 | 60 | // Station status for dashboard coloring (hierarchical priority) 61 | export type StationStatus = 'worked' | 'normal' | 'weak' | 'strong' | 'priority' | 'new_dxcc'; 62 | 63 | // Tracked station with all relevant data for dashboard display 64 | export interface TrackedStation { 65 | callsign: string; 66 | grid: string; 67 | snr: number; 68 | frequency: number; // Audio frequency offset (deltaFrequency) 69 | mode: string; 70 | lastSeen: number; // Timestamp of last decode 71 | firstSeen: number; // Timestamp of first decode in this session 72 | decodeCount: number; // Number of decodes received 73 | status: StationStatus; // Computed status for coloring 74 | message: string; // Last decoded message 75 | } 76 | 77 | // Slice/Instance state for dashboard 78 | export interface SliceState { 79 | id: string; // Instance/slice ID 80 | name: string; // Display name 81 | band: string; // Band (e.g., "20m", "40m") 82 | mode: string; // Operating mode (FT8, FT4, etc.) 83 | dialFrequency: number; // Dial frequency in Hz 84 | stations: TrackedStation[]; 85 | isTransmitting: boolean; 86 | txEnabled: boolean; 87 | } 88 | 89 | // WebSocket message types for frontend 90 | export interface StationsUpdateMessage { 91 | type: 'STATIONS_UPDATE'; 92 | slices: SliceState[]; 93 | config: { 94 | stationLifetimeSeconds: number; 95 | colors: Record; 96 | }; 97 | } 98 | -------------------------------------------------------------------------------- /src/flex/FlexClient.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import { Config } from '../SettingsManager'; 3 | import { Vita49Client, FlexSlice } from './Vita49Client'; 4 | 5 | export class FlexClient extends EventEmitter { 6 | private config: Config['flex']; 7 | private vita49Client: Vita49Client; 8 | 9 | constructor(config: Config['flex']) { 10 | super(); 11 | this.config = config; 12 | // VITA 49 API always uses port 4992 13 | this.vita49Client = new Vita49Client(config.host, 4992); 14 | this.setupListeners(); 15 | } 16 | 17 | private setupListeners() { 18 | this.vita49Client.on('connected', () => { 19 | console.log('FlexRadio connected'); 20 | this.emit('connected'); 21 | }); 22 | 23 | this.vita49Client.on('slice-added', (slice: FlexSlice) => { 24 | console.log(`FlexClient: Slice added - ${slice.id}`); 25 | this.emit('slice-added', slice); 26 | }); 27 | 28 | this.vita49Client.on('slice-removed', (slice: FlexSlice) => { 29 | console.log(`FlexClient: Slice removed - ${slice.id}`); 30 | this.emit('slice-removed', slice); 31 | }); 32 | 33 | this.vita49Client.on('slice-updated', (slice: FlexSlice) => { 34 | this.emit('slice-updated', slice); 35 | }); 36 | 37 | this.vita49Client.on('error', (error) => { 38 | console.error('FlexClient error:', error); 39 | this.emit('error', error); 40 | }); 41 | } 42 | 43 | public async connect(): Promise { 44 | console.log(`Connecting to FlexRadio at ${this.config.host}:4992...`); 45 | await this.vita49Client.connect(); 46 | } 47 | 48 | public async disconnect(): Promise { 49 | console.log('Disconnecting from FlexRadio...'); 50 | this.vita49Client.disconnect(); 51 | } 52 | 53 | public getSlices(): FlexSlice[] { 54 | return this.vita49Client.getSlices(); 55 | } 56 | 57 | public isConnected(): boolean { 58 | return this.vita49Client.isConnected(); 59 | } 60 | 61 | /** 62 | * Tune a slice to a specific frequency 63 | */ 64 | public tuneSlice(sliceIndex: number, frequencyHz: number): void { 65 | this.vita49Client.tuneSlice(sliceIndex, frequencyHz); 66 | } 67 | 68 | /** 69 | * Set mode for a slice 70 | */ 71 | public setSliceMode(sliceIndex: number, mode: string): void { 72 | this.vita49Client.setSliceMode(sliceIndex, mode); 73 | } 74 | 75 | /** 76 | * Set PTT for a slice 77 | */ 78 | public setSliceTx(sliceIndex: number, tx: boolean): void { 79 | this.vita49Client.setSliceTx(sliceIndex, tx); 80 | } 81 | 82 | /** 83 | * Set DAX channel for a slice 84 | */ 85 | public setSliceDax(sliceIndex: number, daxChannel: number): void { 86 | this.vita49Client.setSliceDax(sliceIndex, daxChannel); 87 | } 88 | 89 | /** 90 | * Create a DAX RX audio stream for a channel 91 | * This enables audio streaming from the radio to the DAX virtual audio device 92 | */ 93 | public createDaxRxAudioStream(daxChannel: number): void { 94 | this.vita49Client.createDaxRxAudioStream(daxChannel); 95 | } 96 | 97 | /** 98 | * Create a DAX TX audio stream 99 | * This enables audio streaming from the DAX virtual audio device to the radio 100 | */ 101 | public createDaxTxAudioStream(): void { 102 | this.vita49Client.createDaxTxAudioStream(); 103 | } 104 | 105 | /** 106 | * Request DAX stream list for debugging 107 | */ 108 | public listDaxStreams(): void { 109 | this.vita49Client.listDaxStreams(); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /frontend/src/components/StationRow.tsx: -------------------------------------------------------------------------------- 1 | import type { TrackedStation, StationStatus, DashboardConfig } from '../types'; 2 | 3 | interface StationRowProps { 4 | station: TrackedStation; 5 | config: DashboardConfig; 6 | } 7 | 8 | // Status labels for display 9 | const STATUS_LABELS: Record = { 10 | worked: 'WRKD', 11 | normal: '', 12 | weak: 'WEAK', 13 | strong: 'STRG', 14 | priority: 'PRIO', 15 | new_dxcc: 'NEW!', 16 | }; 17 | 18 | // Format frequency for display (Hz to kHz offset) 19 | function formatFrequency(freq: number): string { 20 | return freq.toString().padStart(4, ' '); 21 | } 22 | 23 | // Format time since last decode 24 | function formatAge(lastSeen: number): string { 25 | const seconds = Math.floor((Date.now() - lastSeen) / 1000); 26 | if (seconds < 60) return `${seconds}s`; 27 | const minutes = Math.floor(seconds / 60); 28 | return `${minutes}m`; 29 | } 30 | 31 | // Format SNR with sign 32 | function formatSnr(snr: number): string { 33 | const sign = snr >= 0 ? '+' : ''; 34 | return `${sign}${snr}`.padStart(3, ' '); 35 | } 36 | 37 | export function StationRow({ station, config }: StationRowProps) { 38 | const statusColor = config.colors[station.status] || config.colors.normal; 39 | const statusLabel = STATUS_LABELS[station.status]; 40 | const isWorked = station.status === 'worked'; 41 | 42 | return ( 43 |
52 | {/* Callsign */} 53 | 58 | {station.callsign} 59 | 60 | 61 | {/* SNR */} 62 | = 0 ? 'text-green-400' : station.snr <= -15 ? 'text-yellow-400' : 'text-gray-300' 65 | }`} 66 | title="Signal-to-Noise Ratio" 67 | > 68 | {formatSnr(station.snr)} 69 | 70 | 71 | {/* Frequency offset */} 72 | 73 | {formatFrequency(station.frequency)} 74 | 75 | 76 | {/* Grid square */} 77 | 78 | {station.grid || '----'} 79 | 80 | 81 | {/* Status badge */} 82 | {statusLabel && ( 83 | 90 | {statusLabel} 91 | 92 | )} 93 | 94 | {/* Age indicator */} 95 | 96 | {formatAge(station.lastSeen)} 97 | 98 | 99 | {/* Decode count */} 100 | {station.decodeCount > 1 && ( 101 | 102 | x{station.decodeCount} 103 | 104 | )} 105 |
106 | ); 107 | } 108 | -------------------------------------------------------------------------------- /frontend/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { loadConfig } from './SettingsManager'; 2 | import { FlexClient } from './flex/FlexClient'; 3 | import { discoverFlexRadio } from './flex/FlexDiscovery'; 4 | import { WsjtxManager } from './wsjtx/WsjtxManager'; 5 | import { WsjtxMcpServer } from './mcp/McpServer'; 6 | import { WebServer } from './web/server'; 7 | import { setDefaultWsjtxPath, killOrphanedJt9Processes } from './wsjtx/ProcessManager'; 8 | import { logger } from './utils/logger'; 9 | 10 | // Redirect console.log to stderr to avoid breaking stdio MCP transport 11 | // When using MCP Inspector, only JSON-RPC messages should go to stdout 12 | const originalLog = console.log; 13 | console.log = (...args: any[]) => { 14 | logger.log(...args); 15 | }; 16 | 17 | async function main() { 18 | console.log('Starting WSJT-X MCP Server...'); 19 | 20 | try { 21 | // Clean up any orphaned jt9 processes from previous sessions 22 | await killOrphanedJt9Processes(); 23 | 24 | const config = loadConfig(); 25 | console.log(`Operation Mode: ${config.mode}`); 26 | 27 | // Set WSJT-X path from config 28 | if (config.wsjtx?.path) { 29 | setDefaultWsjtxPath(config.wsjtx.path); 30 | console.log(`WSJT-X Path: ${config.wsjtx.path}`); 31 | } 32 | 33 | const wsjtxManager = new WsjtxManager(config); 34 | const webServer = new WebServer(config, wsjtxManager); 35 | 36 | let flexClient: FlexClient | null = null; 37 | 38 | // If in Flex Mode, auto-discover radio and connect BEFORE starting MCP 39 | if (config.mode === 'FLEX') { 40 | // Auto-discover FlexRadio on the network 41 | console.log('Discovering FlexRadio on the network...'); 42 | const discoveredRadio = await discoverFlexRadio(5000); 43 | 44 | let flexHost = config.flex.host; 45 | if (discoveredRadio) { 46 | flexHost = discoveredRadio.ip; 47 | console.log(`Auto-discovered FlexRadio: ${discoveredRadio.model || 'Unknown'} at ${flexHost}`); 48 | } else { 49 | console.log(`No FlexRadio discovered, using configured host: ${flexHost}`); 50 | } 51 | 52 | // Create FlexClient with discovered or configured host 53 | const flexConfig = { ...config.flex, host: flexHost }; 54 | flexClient = new FlexClient(flexConfig); 55 | 56 | wsjtxManager.setFlexClient(flexClient); 57 | 58 | // Handle FlexClient errors gracefully - don't crash the server 59 | flexClient.on('error', (error: Error) => { 60 | console.error('FlexRadio connection error (will retry):', error.message); 61 | }); 62 | 63 | // Try to connect, but don't fail if radio isn't available 64 | try { 65 | await flexClient.connect(); 66 | } catch (error) { 67 | console.warn('Could not connect to FlexRadio - will retry when available'); 68 | } 69 | } 70 | 71 | // Create MCP server with optional FlexClient (for rig control tools) 72 | const mcpServer = new WsjtxMcpServer(wsjtxManager, config, flexClient || undefined); 73 | 74 | // Start WSJT-X Manager 75 | await wsjtxManager.start(); 76 | 77 | // Start MCP Server 78 | await mcpServer.start(); 79 | 80 | // Start Web Dashboard 81 | webServer.start(); 82 | 83 | // Handle shutdown 84 | process.on('SIGINT', async () => { 85 | console.log('\nShutting down...'); 86 | if (flexClient) { 87 | await flexClient.disconnect(); 88 | } 89 | await wsjtxManager.stop(); 90 | // Clean up any remaining jt9 processes 91 | await killOrphanedJt9Processes(); 92 | logger.close(); 93 | process.exit(0); 94 | }); 95 | 96 | } catch (error) { 97 | console.error('Failed to start server:', error instanceof Error ? error.message : String(error)); 98 | if (error instanceof Error && error.stack) { 99 | console.error(error.stack); 100 | } 101 | process.exit(1); 102 | } 103 | } 104 | 105 | main(); 106 | -------------------------------------------------------------------------------- /frontend/src/components/SlicePanel.tsx: -------------------------------------------------------------------------------- 1 | import type { SliceState, DashboardConfig } from '../types'; 2 | import { StationRow } from './StationRow'; 3 | 4 | interface SlicePanelProps { 5 | slice: SliceState; 6 | config: DashboardConfig; 7 | } 8 | 9 | // Format frequency for display (Hz to MHz) 10 | function formatDialFrequency(freqHz: number): string { 11 | if (freqHz === 0) return '?.??? MHz'; 12 | const mhz = freqHz / 1_000_000; 13 | return `${mhz.toFixed(3)} MHz`; 14 | } 15 | 16 | export function SlicePanel({ slice, config }: SlicePanelProps) { 17 | const stationCount = slice.stations.length; 18 | const workedCount = slice.stations.filter(s => s.status === 'worked').length; 19 | const newCount = stationCount - workedCount; 20 | 21 | return ( 22 |
23 | {/* Header */} 24 |
25 |
26 |
27 | {/* Transmit indicator */} 28 |
44 | 45 | {/* Band and mode */} 46 |
47 |

48 | {slice.band} {slice.mode} 49 |

50 |

51 | {formatDialFrequency(slice.dialFrequency)} 52 |

53 |
54 |
55 | 56 | {/* Station counts */} 57 |
58 | 59 | {newCount} new 60 | 61 | 62 | {workedCount} wrkd 63 | 64 |
65 |
66 | 67 | {/* Instance name */} 68 |

69 | Instance: {slice.name} 70 |

71 |
72 | 73 | {/* Station list */} 74 |
75 | {stationCount === 0 ? ( 76 |
77 |

No stations decoded yet

78 |

Waiting for decodes...

79 |
80 | ) : ( 81 |
82 | {slice.stations.map((station) => ( 83 | 88 | ))} 89 |
90 | )} 91 |
92 | 93 | {/* Footer with stats */} 94 | {stationCount > 0 && ( 95 |
96 |
97 | 98 | Total: {stationCount} station{stationCount !== 1 ? 's' : ''} 99 | 100 | 101 | Lifetime: {config.stationLifetimeSeconds}s 102 | 103 |
104 |
105 | )} 106 |
107 | ); 108 | } 109 | -------------------------------------------------------------------------------- /src/flex/FlexDiscovery.ts: -------------------------------------------------------------------------------- 1 | import dgram from 'dgram'; 2 | import { EventEmitter } from 'events'; 3 | 4 | export interface DiscoveredRadio { 5 | ip: string; 6 | port: number; 7 | nickname?: string; 8 | model?: string; 9 | serial?: string; 10 | callsign?: string; 11 | } 12 | 13 | export class FlexDiscovery extends EventEmitter { 14 | private socket: dgram.Socket | null = null; 15 | private discoveredRadios: Map = new Map(); 16 | 17 | /** 18 | * Discover FlexRadio devices on the network. 19 | * FlexRadio broadcasts discovery packets on UDP port 4992. 20 | * @param timeout Timeout in milliseconds (default: 3000) 21 | * @returns Promise with first discovered radio, or null if none found 22 | */ 23 | public async discoverRadio(timeout: number = 3000): Promise { 24 | return new Promise((resolve) => { 25 | this.socket = dgram.createSocket({ type: 'udp4', reuseAddr: true }); 26 | 27 | const timeoutId = setTimeout(() => { 28 | this.close(); 29 | // Return first discovered radio or null 30 | const radios = Array.from(this.discoveredRadios.values()); 31 | resolve(radios.length > 0 ? radios[0] : null); 32 | }, timeout); 33 | 34 | this.socket.on('message', (msg, rinfo) => { 35 | const radio = this.parseDiscoveryPacket(msg, rinfo); 36 | if (radio) { 37 | this.discoveredRadios.set(radio.ip, radio); 38 | console.log(`Discovered FlexRadio at ${radio.ip}: ${radio.nickname || radio.model || 'Unknown'}`); 39 | 40 | // Resolve immediately on first discovery 41 | clearTimeout(timeoutId); 42 | this.close(); 43 | resolve(radio); 44 | } 45 | }); 46 | 47 | this.socket.on('error', (err) => { 48 | console.error('Discovery error:', err); 49 | clearTimeout(timeoutId); 50 | this.close(); 51 | resolve(null); 52 | }); 53 | 54 | this.socket.bind(4992, () => { 55 | console.log('Listening for FlexRadio discovery broadcasts...'); 56 | }); 57 | }); 58 | } 59 | 60 | private parseDiscoveryPacket(msg: Buffer, rinfo: dgram.RemoteInfo): DiscoveredRadio | null { 61 | try { 62 | const data = msg.toString(); 63 | 64 | // FlexRadio discovery packets contain key=value pairs 65 | // Example: discovery_protocol_version=3.0.0.1 model=FLEX-6600 serial=... ip=... port=4992 66 | if (!data.includes('discovery_protocol_version')) { 67 | return null; 68 | } 69 | 70 | const radio: DiscoveredRadio = { 71 | ip: rinfo.address, 72 | port: 4992 73 | }; 74 | 75 | // Parse key=value pairs 76 | const pairs = data.split(' '); 77 | for (const pair of pairs) { 78 | const [key, value] = pair.split('='); 79 | if (!key || !value) continue; 80 | 81 | switch (key) { 82 | case 'ip': 83 | radio.ip = value; 84 | break; 85 | case 'port': 86 | radio.port = parseInt(value) || 4992; 87 | break; 88 | case 'nickname': 89 | radio.nickname = value; 90 | break; 91 | case 'model': 92 | radio.model = value; 93 | break; 94 | case 'serial': 95 | radio.serial = value; 96 | break; 97 | case 'callsign': 98 | radio.callsign = value; 99 | break; 100 | } 101 | } 102 | 103 | return radio; 104 | } catch (error) { 105 | return null; 106 | } 107 | } 108 | 109 | public close(): void { 110 | if (this.socket) { 111 | try { 112 | this.socket.close(); 113 | } catch (e) { 114 | // Ignore 115 | } 116 | this.socket = null; 117 | } 118 | } 119 | 120 | public getDiscoveredRadios(): DiscoveredRadio[] { 121 | return Array.from(this.discoveredRadios.values()); 122 | } 123 | } 124 | 125 | /** 126 | * Convenience function to discover a FlexRadio 127 | */ 128 | export async function discoverFlexRadio(timeout: number = 3000): Promise { 129 | const discovery = new FlexDiscovery(); 130 | const radio = await discovery.discoverRadio(timeout); 131 | return radio; 132 | } 133 | -------------------------------------------------------------------------------- /src/utils/CqTargeting.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * CQ Targeting Logic (v7 FSD §14) 3 | * 4 | * Server-side CQ targeting evaluation. Clients MUST NOT reimplement this logic. 5 | */ 6 | 7 | import { StationProfile } from '../state/types'; 8 | 9 | /** 10 | * Region keywords recognized in CQ messages 11 | */ 12 | const REGION_KEYWORDS = new Set([ 13 | 'DX', 'NA', 'SA', 'EU', 'AS', 'AF', 'OC', 'JA', 14 | // Extended keywords (optional) 15 | 'ASIA', 'EUROPE', 'AFRICA' 16 | ]); 17 | 18 | /** 19 | * Extract CQ target token from raw WSJT-X message text (v7 FSD §14) 20 | * 21 | * Examples: 22 | * "CQ HB9XYZ JN36" → null 23 | * "CQ DX HB9XYZ JN36" → "DX" 24 | * "CQ NA W1ABC FN31" → "NA" 25 | * "CQ EU DL1ABC JO62" → "EU" 26 | * "CQ JA JA1XYZ PM95" → "JA" 27 | * 28 | * @param raw_text Raw decoded message text 29 | * @returns CQ target token (uppercase) or null 30 | */ 31 | export function extractCqTargetToken(raw_text: string): string | null { 32 | // Normalize: trim and uppercase 33 | const text = raw_text.trim().toUpperCase(); 34 | 35 | // Must start with "CQ " 36 | if (!text.startsWith('CQ ')) { 37 | return null; 38 | } 39 | 40 | // Split on whitespace 41 | const tokens = text.split(/\s+/); 42 | if (tokens.length < 2) { 43 | return null; 44 | } 45 | 46 | // Token after "CQ" 47 | const t1 = tokens[1]; 48 | 49 | // If it's a recognized region keyword, return it 50 | if (REGION_KEYWORDS.has(t1)) { 51 | return t1; 52 | } 53 | 54 | // Otherwise, treat tokens[1] as callsign → no explicit CQ target 55 | return null; 56 | } 57 | 58 | /** 59 | * Determine if this station is allowed to answer a given CQ (v7 FSD §14) 60 | * 61 | * This encapsulates all rules for converting CQ target token + station location 62 | * into the is_directed_cq_to_me boolean. 63 | * 64 | * @param stationProfile Station configuration (continent, dxcc, prefixes) 65 | * @param cq_target_token CQ target token from extractCqTargetToken() 66 | * @returns true if station is allowed to answer this CQ 67 | */ 68 | export function isDirectedCqToMe( 69 | stationProfile: StationProfile, 70 | cq_target_token: string | null 71 | ): boolean { 72 | // No explicit target token → general CQ → always allowed 73 | if (cq_target_token === null) { 74 | return true; 75 | } 76 | 77 | const continent = stationProfile.my_continent.toUpperCase(); 78 | const dxcc = stationProfile.my_dxcc.toUpperCase(); 79 | 80 | switch (cq_target_token) { 81 | case 'DX': 82 | // "CQ DX" means "stations that are DX to me" 83 | // For most operators, this is acceptable to treat as "everyone eligible" 84 | // A stricter implementation would require caller's DXCC and check if different 85 | // For now: minimal safe default = allow all 86 | return true; 87 | 88 | case 'NA': 89 | return continent === 'NA'; 90 | 91 | case 'SA': 92 | return continent === 'SA'; 93 | 94 | case 'EU': 95 | case 'EUROPE': 96 | return continent === 'EU'; 97 | 98 | case 'AS': 99 | case 'ASIA': 100 | return continent === 'AS'; 101 | 102 | case 'AF': 103 | case 'AFRICA': 104 | return continent === 'AF'; 105 | 106 | case 'OC': 107 | return continent === 'OC'; 108 | 109 | case 'JA': 110 | // JA-specific: require DXCC/prefix to be JA, JR, 7J, etc. 111 | return dxcc.startsWith('JA') || 112 | dxcc.startsWith('JR') || 113 | dxcc.startsWith('7J'); 114 | 115 | default: 116 | // Unknown or unsupported CQ target token 117 | // Conservative approach: do NOT answer 118 | return false; 119 | } 120 | } 121 | 122 | /** 123 | * Enrich a raw decode with CQ targeting fields (v7 FSD §14) 124 | * 125 | * This is the integration point for CQ targeting logic. 126 | * Call this function when constructing InternalDecodeRecord. 127 | * 128 | * @param raw_text Raw WSJT-X message text 129 | * @param is_cq Whether this is a CQ-type message 130 | * @param stationProfile Station configuration 131 | * @returns Object with cq_target_token and is_directed_cq_to_me 132 | */ 133 | export function enrichWithCqTargeting( 134 | raw_text: string, 135 | is_cq: boolean, 136 | stationProfile: StationProfile 137 | ): { cq_target_token: string | null; is_directed_cq_to_me: boolean } { 138 | // If not a CQ message, both fields are null/false 139 | if (!is_cq) { 140 | return { 141 | cq_target_token: null, 142 | is_directed_cq_to_me: false 143 | }; 144 | } 145 | 146 | // Extract CQ target token 147 | const cq_target_token = extractCqTargetToken(raw_text); 148 | 149 | // Determine if we're allowed to answer 150 | const is_directed_cq_to_me = isDirectedCqToMe(stationProfile, cq_target_token); 151 | 152 | return { cq_target_token, is_directed_cq_to_me }; 153 | } 154 | -------------------------------------------------------------------------------- /test-log4om-adif.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Test script to send ADIF format QSO to Log4OM 3 | * This matches the new UdpRebroadcaster implementation 4 | */ 5 | 6 | const dgram = require('dgram'); 7 | 8 | // Configuration (from config.json) 9 | const CONFIG = { 10 | port: 2236, // Log4OM GT_LOG port for ADIF messages 11 | host: '127.0.0.1', 12 | }; 13 | 14 | /** 15 | * Format an ADIF field: VALUE 16 | */ 17 | function adifField(fieldName, value) { 18 | const length = Buffer.byteLength(value, 'utf8'); 19 | return `<${fieldName}:${length}>${value}`; 20 | } 21 | 22 | /** 23 | * Format date as YYYYMMDD for ADIF 24 | */ 25 | function formatAdifDate(date) { 26 | const year = date.getUTCFullYear(); 27 | const month = String(date.getUTCMonth() + 1).padStart(2, '0'); 28 | const day = String(date.getUTCDate()).padStart(2, '0'); 29 | return `${year}${month}${day}`; 30 | } 31 | 32 | /** 33 | * Format time as HHMMSS for ADIF 34 | */ 35 | function formatAdifTime(date) { 36 | const hours = String(date.getUTCHours()).padStart(2, '0'); 37 | const minutes = String(date.getUTCMinutes()).padStart(2, '0'); 38 | const seconds = String(date.getUTCSeconds()).padStart(2, '0'); 39 | return `${hours}${minutes}${seconds}`; 40 | } 41 | 42 | /** 43 | * Encode a QSO record to ADIF format 44 | */ 45 | function encodeAdifMessage(qso) { 46 | const fields = []; 47 | 48 | // Required fields 49 | if (qso.call) { 50 | fields.push(adifField('CALL', qso.call)); 51 | } 52 | 53 | if (qso.mode) { 54 | fields.push(adifField('MODE', qso.mode)); 55 | } 56 | 57 | if (qso.band) { 58 | fields.push(adifField('BAND', qso.band)); 59 | } 60 | 61 | // Frequency in MHz 62 | if (qso.freq_hz) { 63 | const freqMhz = (qso.freq_hz / 1000000).toFixed(6); 64 | fields.push(adifField('FREQ', freqMhz)); 65 | } 66 | 67 | // Timestamps 68 | if (qso.timestamp_start) { 69 | const startDate = new Date(qso.timestamp_start); 70 | fields.push(adifField('QSO_DATE', formatAdifDate(startDate))); 71 | fields.push(adifField('TIME_ON', formatAdifTime(startDate))); 72 | } 73 | 74 | if (qso.timestamp_end) { 75 | const endDate = new Date(qso.timestamp_end); 76 | fields.push(adifField('TIME_OFF', formatAdifTime(endDate))); 77 | } 78 | 79 | // Optional fields 80 | if (qso.grid) { 81 | fields.push(adifField('GRIDSQUARE', qso.grid)); 82 | } 83 | 84 | if (qso.rst_sent) { 85 | fields.push(adifField('RST_SENT', qso.rst_sent)); 86 | } 87 | 88 | if (qso.rst_recv) { 89 | fields.push(adifField('RST_RCVD', qso.rst_recv)); 90 | } 91 | 92 | if (qso.tx_power_w) { 93 | fields.push(adifField('TX_PWR', qso.tx_power_w.toString())); 94 | } 95 | 96 | if (qso.notes) { 97 | fields.push(adifField('COMMENT', qso.notes)); 98 | } 99 | 100 | // End of record 101 | fields.push(''); 102 | 103 | return fields.join(' '); 104 | } 105 | 106 | // Create a fake QSO record for testing 107 | const now = new Date(); 108 | const fakeQso = { 109 | call: 'DL5XYZ', 110 | grid: 'JO62', 111 | freq_hz: 14074000, // 20m FT8 112 | band: '20m', 113 | mode: 'FT8', 114 | rst_sent: '-10', 115 | rst_recv: '-05', 116 | tx_power_w: 100, 117 | notes: 'TEST ADIF QSO from MCP', 118 | timestamp_start: new Date(now.getTime() - 90000).toISOString(), // 90 seconds ago 119 | timestamp_end: now.toISOString(), 120 | }; 121 | 122 | console.log('=== Log4OM ADIF Test ==='); 123 | console.log('Sending fake QSO:'); 124 | console.log(JSON.stringify(fakeQso, null, 2)); 125 | console.log(''); 126 | 127 | console.log(`Target: ${CONFIG.host}:${CONFIG.port}`); 128 | console.log(''); 129 | 130 | // Create UDP socket 131 | const socket = dgram.createSocket('udp4'); 132 | 133 | // Encode the message 134 | const adifMessage = encodeAdifMessage(fakeQso); 135 | console.log('ADIF message:'); 136 | console.log(adifMessage); 137 | console.log(''); 138 | 139 | const buffer = Buffer.from(adifMessage, 'utf8'); 140 | console.log(`Message size: ${buffer.length} bytes`); 141 | console.log(''); 142 | 143 | // Send the message 144 | socket.send(buffer, CONFIG.port, CONFIG.host, (err) => { 145 | if (err) { 146 | console.error('❌ Error sending message:', err); 147 | } else { 148 | console.log('✅ ADIF message sent successfully!'); 149 | console.log(''); 150 | console.log('Check Log4OM for a new QSO entry:'); 151 | console.log(` Call: ${fakeQso.call}`); 152 | console.log(` Grid: ${fakeQso.grid}`); 153 | console.log(` Band: ${fakeQso.band}`); 154 | console.log(` Mode: ${fakeQso.mode}`); 155 | console.log(` RST Sent: ${fakeQso.rst_sent}`); 156 | console.log(` RST Recv: ${fakeQso.rst_recv}`); 157 | console.log(''); 158 | console.log('IMPORTANT: Make sure Log4OM is configured with:'); 159 | console.log(` UDP Port: ${CONFIG.port}`); 160 | console.log(' Message Type: ADIF_MESSAGE (not JT_MESSAGE)'); 161 | } 162 | socket.close(); 163 | }); 164 | -------------------------------------------------------------------------------- /src/wsjtx/UdpListener.ts: -------------------------------------------------------------------------------- 1 | import dgram from 'dgram'; 2 | import { EventEmitter } from 'events'; 3 | import { WsjtxMessageType, WsjtxDecode, WsjtxStatus } from './types'; 4 | 5 | export class WsjtxUdpListener extends EventEmitter { 6 | private socket: dgram.Socket; 7 | private port: number; 8 | 9 | constructor(port: number = 2237) { 10 | super(); 11 | this.port = port; 12 | this.socket = dgram.createSocket('udp4'); 13 | } 14 | 15 | public start(): void { 16 | this.socket.on('message', (msg, rinfo) => { 17 | try { 18 | this.parseMessage(msg); 19 | } catch (error) { 20 | console.error('Error parsing WSJT-X message:', error); 21 | } 22 | }); 23 | 24 | this.socket.on('error', (err) => { 25 | console.error('UDP socket error:', err); 26 | this.emit('error', err); 27 | }); 28 | 29 | this.socket.bind(this.port, () => { 30 | console.log(`WSJT-X UDP listener started on port ${this.port}`); 31 | }); 32 | } 33 | 34 | public stop(): void { 35 | this.socket.close(); 36 | } 37 | 38 | private parseMessage(buffer: Buffer): void { 39 | // QQT (Qt QDataStream) parsing 40 | let offset = 0; 41 | 42 | // Magic number (4 bytes) 43 | const magic = buffer.readUInt32BE(offset); 44 | offset += 4; 45 | if (magic !== 0xadbccbda) { 46 | console.warn('Invalid magic number'); 47 | return; 48 | } 49 | 50 | // Schema version (4 bytes) 51 | const schema = buffer.readUInt32BE(offset); 52 | offset += 4; 53 | 54 | // Message type (4 bytes) 55 | const messageType = buffer.readUInt32BE(offset); 56 | offset += 4; 57 | 58 | // ID (QString - length-prefixed UTF-16) 59 | const { value: id, newOffset } = this.readQString(buffer, offset); 60 | offset = newOffset; 61 | 62 | switch (messageType) { 63 | case WsjtxMessageType.HEARTBEAT: 64 | this.emit('heartbeat', { id }); 65 | break; 66 | 67 | case WsjtxMessageType.STATUS: 68 | const status = this.parseStatus(buffer, offset, id); 69 | this.emit('status', status); 70 | break; 71 | 72 | case WsjtxMessageType.DECODE: 73 | const decode = this.parseDecode(buffer, offset, id); 74 | this.emit('decode', decode); 75 | break; 76 | 77 | case WsjtxMessageType.CLOSE: 78 | this.emit('close', { id }); 79 | break; 80 | 81 | default: 82 | // Ignore other message types for now 83 | break; 84 | } 85 | } 86 | 87 | private readQString(buffer: Buffer, offset: number): { value: string; newOffset: number } { 88 | const length = buffer.readUInt32BE(offset); 89 | offset += 4; 90 | 91 | if (length === 0xffffffff || length === 0) { 92 | return { value: '', newOffset: offset }; 93 | } 94 | 95 | // WSJT-X implementation uses Latin-1/ASCII encoding for QString, not UTF-16BE 96 | // despite what Qt documentation says about QDataStream 97 | const value = buffer.toString('latin1', offset, offset + length); 98 | return { value, newOffset: offset + length }; 99 | } 100 | 101 | private parseStatus(buffer: Buffer, offset: number, id: string): WsjtxStatus { 102 | // Simplified status parsing - full implementation would parse all fields 103 | const dialFrequency = Number(buffer.readBigUInt64BE(offset)); 104 | offset += 8; 105 | 106 | const { value: mode, newOffset: offset2 } = this.readQString(buffer, offset); 107 | const { value: dxCall, newOffset: offset3 } = this.readQString(buffer, offset2); 108 | 109 | return { 110 | id, 111 | dialFrequency, 112 | mode, 113 | dxCall, 114 | // Simplified - would parse remaining fields 115 | report: '', 116 | txMode: mode, 117 | txEnabled: false, 118 | transmitting: false, 119 | decoding: false, 120 | rxDF: 0, 121 | txDF: 0, 122 | deCall: '', 123 | deGrid: '', 124 | dxGrid: '', 125 | txWatchdog: false, 126 | subMode: '', 127 | fastMode: false, 128 | specialOpMode: 0, 129 | frequencyTolerance: 0, 130 | trPeriod: 0, 131 | configurationName: '', 132 | }; 133 | } 134 | 135 | private parseDecode(buffer: Buffer, offset: number, id: string): WsjtxDecode { 136 | // Simplified decode parsing 137 | const newDecode = buffer.readUInt8(offset) !== 0; 138 | offset += 1; 139 | 140 | const time = buffer.readUInt32BE(offset); 141 | offset += 4; 142 | 143 | const snr = buffer.readInt32BE(offset); 144 | offset += 4; 145 | 146 | const deltaTime = buffer.readDoubleBE(offset); 147 | offset += 8; 148 | 149 | const deltaFrequency = buffer.readUInt32BE(offset); 150 | offset += 4; 151 | 152 | const { value: mode, newOffset: offset2 } = this.readQString(buffer, offset); 153 | const { value: message, newOffset: offset3 } = this.readQString(buffer, offset2); 154 | 155 | return { 156 | id, 157 | newDecode, 158 | time, 159 | snr, 160 | deltaTime, 161 | deltaFrequency, 162 | mode, 163 | message, 164 | lowConfidence: false, 165 | offAir: false, 166 | }; 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/wsjtx/UdpRebroadcaster.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * UdpRebroadcaster - Consolidates QSO records from multiple WSJT-X instances 3 | * and rebroadcasts them in ADIF format for external loggers (Log4OM, etc.) 4 | * 5 | * Architecture: 6 | * - Listens to QSO Logged events from LogbookManager 7 | * - Converts QsoRecord to ADIF text format 8 | * - Sends to rebroadcast port (default: 2241) 9 | */ 10 | 11 | import dgram from 'dgram'; 12 | import { EventEmitter } from 'events'; 13 | import { QsoRecord } from '../state/types'; 14 | 15 | export interface UdpRebroadcasterConfig { 16 | enabled: boolean; // Enable rebroadcasting 17 | port: number; // Rebroadcast port (default: 2241) 18 | instanceId: string; // Unified instance ID (default: "WSJT-X-MCP") 19 | host?: string; // Target host (default: 127.0.0.1) 20 | } 21 | 22 | export class UdpRebroadcaster extends EventEmitter { 23 | private config: UdpRebroadcasterConfig; 24 | private socket: dgram.Socket | null = null; 25 | 26 | constructor(config: UdpRebroadcasterConfig) { 27 | super(); 28 | this.config = { 29 | ...config, 30 | host: config.host || '127.0.0.1', 31 | }; 32 | } 33 | 34 | public start(): void { 35 | if (!this.config.enabled) { 36 | console.log('[UdpRebroadcaster] Disabled in configuration'); 37 | return; 38 | } 39 | 40 | this.socket = dgram.createSocket('udp4'); 41 | console.log(`[UdpRebroadcaster] Started - rebroadcasting ADIF to ${this.config.host}:${this.config.port}`); 42 | } 43 | 44 | public stop(): void { 45 | if (this.socket) { 46 | this.socket.close(); 47 | this.socket = null; 48 | console.log('[UdpRebroadcaster] Stopped'); 49 | } 50 | } 51 | 52 | /** 53 | * Rebroadcast a QSO Logged message in ADIF format 54 | * Called when any channel logs a QSO 55 | */ 56 | public rebroadcastQsoLogged(qso: QsoRecord): void { 57 | if (!this.socket || !this.config.enabled) { 58 | return; 59 | } 60 | 61 | try { 62 | const adifMessage = this.encodeAdifMessage(qso); 63 | const buffer = Buffer.from(adifMessage, 'utf8'); 64 | 65 | this.socket.send(buffer, this.config.port, this.config.host, (err) => { 66 | if (err) { 67 | console.error('[UdpRebroadcaster] Error sending ADIF QSO:', err); 68 | } else { 69 | console.log(`[UdpRebroadcaster] Sent ADIF QSO: ${qso.call} on ${qso.band} ${qso.mode}`); 70 | } 71 | }); 72 | } catch (error) { 73 | console.error('[UdpRebroadcaster] Error encoding ADIF message:', error); 74 | } 75 | } 76 | 77 | /** 78 | * Encode a QSO record to ADIF format 79 | * Format: VALUE ... 80 | * 81 | * Example: 82 | * DL1ABC JO50 20m FT8 83 | * 20251202 152000 152130 84 | * -08 -12 50 14.074000 85 | */ 86 | private encodeAdifMessage(qso: QsoRecord): string { 87 | const fields: string[] = []; 88 | 89 | // Required fields 90 | if (qso.call) { 91 | fields.push(this.adifField('CALL', qso.call)); 92 | } 93 | 94 | if (qso.mode) { 95 | fields.push(this.adifField('MODE', qso.mode)); 96 | } 97 | 98 | if (qso.band) { 99 | fields.push(this.adifField('BAND', qso.band)); 100 | } 101 | 102 | // Frequency in MHz 103 | if (qso.freq_hz) { 104 | const freqMhz = (qso.freq_hz / 1000000).toFixed(6); 105 | fields.push(this.adifField('FREQ', freqMhz)); 106 | } 107 | 108 | // Timestamps 109 | if (qso.timestamp_start) { 110 | const startDate = new Date(qso.timestamp_start); 111 | fields.push(this.adifField('QSO_DATE', this.formatAdifDate(startDate))); 112 | fields.push(this.adifField('TIME_ON', this.formatAdifTime(startDate))); 113 | } 114 | 115 | if (qso.timestamp_end) { 116 | const endDate = new Date(qso.timestamp_end); 117 | fields.push(this.adifField('TIME_OFF', this.formatAdifTime(endDate))); 118 | } 119 | 120 | // Optional fields 121 | if (qso.grid) { 122 | fields.push(this.adifField('GRIDSQUARE', qso.grid)); 123 | } 124 | 125 | if (qso.rst_sent) { 126 | fields.push(this.adifField('RST_SENT', qso.rst_sent)); 127 | } 128 | 129 | if (qso.rst_recv) { 130 | fields.push(this.adifField('RST_RCVD', qso.rst_recv)); 131 | } 132 | 133 | if (qso.tx_power_w) { 134 | fields.push(this.adifField('TX_PWR', qso.tx_power_w.toString())); 135 | } 136 | 137 | if (qso.notes) { 138 | fields.push(this.adifField('COMMENT', qso.notes)); 139 | } 140 | 141 | // End of record 142 | fields.push(''); 143 | 144 | return fields.join(' '); 145 | } 146 | 147 | /** 148 | * Format an ADIF field: VALUE 149 | */ 150 | private adifField(fieldName: string, value: string): string { 151 | const length = Buffer.byteLength(value, 'utf8'); 152 | return `<${fieldName}:${length}>${value}`; 153 | } 154 | 155 | /** 156 | * Format date as YYYYMMDD for ADIF 157 | */ 158 | private formatAdifDate(date: Date): string { 159 | const year = date.getUTCFullYear(); 160 | const month = String(date.getUTCMonth() + 1).padStart(2, '0'); 161 | const day = String(date.getUTCDate()).padStart(2, '0'); 162 | return `${year}${month}${day}`; 163 | } 164 | 165 | /** 166 | * Format time as HHMMSS for ADIF 167 | */ 168 | private formatAdifTime(date: Date): string { 169 | const hours = String(date.getUTCHours()).padStart(2, '0'); 170 | const minutes = String(date.getUTCMinutes()).padStart(2, '0'); 171 | const seconds = String(date.getUTCSeconds()).padStart(2, '0'); 172 | return `${hours}${minutes}${seconds}`; 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /Log-Control.md: -------------------------------------------------------------------------------- 1 | # Log-Control.md 2 | How Logging Programs Work with SliceMaster (Log4OM, N1MM, etc.) 3 | 4 | This document describes how a **logging program** (e.g. Log4OM, N1MM+, DXLab Log, HRD Logbook) integrates with **SliceMaster 6000** in a multi-slice FlexRadio + WSJT-X environment. 5 | 6 | Focus: 7 | - The logger talks to **SliceMaster**, not directly to WSJT-X, for **rig control and band/mode info**. 8 | - WSJT-X may still send **UDP / ADIF** to the logger, but that is optional and separate from rig control. 9 | 10 | --- 11 | 12 | ## 1. Scope & Assumptions 13 | 14 | - FlexRadio running **SmartSDR + DAX**. 15 | - SliceMaster 6000 is connected to the Flex via **SmartSDR API**. 16 | - Multiple WSJT-X instances are running, each tied to one slice via HRD TCP and DAX. 17 | - A logging program (e.g. Log4OM) is running on the same or a reachable host. 18 | 19 | **Assumption for this document:** 20 | > The **logging program uses SliceMaster for rig control and frequency/mode information**. 21 | > WSJT-X does **not** talk directly to the logger for rig control (no CAT from WSJT-X to logger). 22 | 23 | WSJT-X may still send completed QSOs via UDP/ADIF, but all **radio state** (freq, mode, band) is taken from SliceMaster. 24 | 25 | --- 26 | 27 | ## 2. Actors & Links 28 | 29 | ### 2.1 Actors 30 | 31 | - **FlexRadio** – Multi-slice SDR controlled via SmartSDR API + DAX. 32 | - **SliceMaster** – Middle layer that understands Flex slices and emulates HRD TCP rig(s). 33 | - **WSJT-X instances** – One per slice, each controlled via HRD TCP from SliceMaster. 34 | - **Logger** – e.g. Log4OM, N1MM+, DXLab, etc. 35 | 36 | ### 2.2 Logical Connections 37 | 38 | ``` 39 | WSJT-X #A/B/C/D 40 | ▲ 41 | │ HRD TCP (per slice) 42 | ▼ 43 | SliceMaster (HRD Servers for WSJT-X) 44 | 45 | Logger (Log4OM / N1MM / etc.) 46 | ▲ 47 | │ HRD TCP (main rig) 48 | ▼ 49 | SliceMaster (HRD Server for logger) 50 | 51 | SliceMaster ↔ FlexRadio (SmartSDR API + DAX) 52 | ``` 53 | 54 | Logger **never talks to WSJT-X for rig control**. 55 | It only talks to **SliceMaster**. 56 | 57 | --- 58 | 59 | ## 3. Rig Control Flow: Logger ↔ SliceMaster ↔ Flex 60 | 61 | ### 3.1 Logger Configuration 62 | 63 | In the logger (example Log4OM): 64 | 65 | - **Rig type**: `Ham Radio Deluxe` 66 | - **Server**: `SliceMaster_IP:HRD_MAIN_PORT` 67 | 68 | The logger believes it is connected to an HRD server. 69 | SliceMaster emulates this HRD server and maps its commands to Flex. 70 | 71 | ### 3.2 Typical commands from logger to SliceMaster 72 | 73 | - `get frequency` 74 | - `get mode` 75 | - `set frequency ` (e.g., click on a spot) 76 | - `set mode ` 77 | 78 | SliceMaster: 79 | 80 | 1. Receives HRD command from logger 81 | 2. Translates it to **Flex API** calls (change slice frequency/mode, or select TX slice) 82 | 3. Sends back frequency/mode via HRD responses 83 | 84 | The logger always sees the **current radio frequency/mode**, as maintained by SliceMaster. 85 | 86 | --- 87 | 88 | ## 4. WSJT-X, SliceMaster, and Logger — Data Flows 89 | 90 | ### 4.1 WSJT-X rig control 91 | 92 | - WSJT-X instances connect to **per-slice HRD ports** on SliceMaster. 93 | - Each instance controls one Flex slice (tuning, PTT, etc.). 94 | - SliceMaster maps WSJT-X commands to Flex API. 95 | 96 | Logger does not participate in this path. 97 | 98 | ### 4.2 Logger rig control 99 | 100 | - Logger connects to a **separate HRD port** on SliceMaster (the “main rig” port). 101 | - When the active slice changes, SliceMaster updates what the “main rig” represents for the logger: 102 | - Current TX slice 103 | - Current active slice for SSB/CW/digital operation 104 | - Logger always sees the correct **band/mode/frequency** for logging, regardless of which WSJT-X instance is active. 105 | 106 | ### 4.3 QSO data / logging 107 | 108 | There are two possible arrangements: 109 | 110 | #### Option A — Logger listens to WSJT-X UDP/ADIF (recommended) 111 | - WSJT-X sends: 112 | - ADIF records to a file 113 | - UDP QSO/broadcasts on `localhost:UDP_PORT_X` 114 | - Logger imports new QSOs from WSJT-X: 115 | - via UDP (most modern loggers support the WSJT-X broadcast protocol) 116 | - or by polling the WSJT-X ADIF log file 117 | 118 | **BUT:** frequency/mode used in the log entry is taken from **SliceMaster HRD** (i.e., from the actual Flex slice state), not from WSJT-X’s internal rig state. 119 | 120 | #### Option B — Logger only uses manual entries 121 | - Operator presses “Log QSO” in the logger manually. 122 | - Logger queries SliceMaster for current frequency/mode, and saves the QSO. 123 | 124 | In both cases, **WSJT-X does not provide rig control to the logger**. 125 | 126 | --- 127 | 128 | ## 5. Spot and Bandmap Integration 129 | 130 | SliceMaster can aggregate spots from: 131 | 132 | - WSJT-X decodes (per instance) 133 | - Skimmer, DX cluster, RBN, etc. 134 | 135 | Then it serves a **Telnet cluster-like source** that loggers can connect to: 136 | 137 | - Logger’s DX cluster configuration points to SliceMaster Telnet server. 138 | - All WSJT-X-generated FT8 spots show up in the logger’s bandmap. 139 | 140 | Rig control is still via **HRD**, but **spot data** comes through SliceMaster’s cluster server. 141 | 142 | --- 143 | 144 | ## 6. Summary of Responsibilities 145 | 146 | ### SliceMaster 147 | - Emulates **HRD TCP** for *both* WSJT-X and the logger. 148 | - Talks to Flex via **SmartSDR API** and manages slices. 149 | - Decides which slice is the “main rig” for the logger. 150 | - Optionally aggregates and forwards spots to the logger. 151 | 152 | ### WSJT-X 153 | - Controls only its **assigned slice** via HRD TCP. 154 | - Produces QSO data (UDP/ADIF) that can be consumed by logger. 155 | - Does **not** provide rig control to logger directly in this architecture. 156 | 157 | ### Logger (Log4OM, N1MM, etc.) 158 | - Connects to SliceMaster as if it were HRD. 159 | - Reads frequency/mode for logging. 160 | - May receive spots from SliceMaster’s cluster server. 161 | - May ingest QSOs from WSJT-X via UDP/ADIF, but rig control stays independent. 162 | 163 | --- 164 | 165 | ## 7. Key Design Principle for Future MCP 166 | 167 | To reproduce this behavior in a custom MCP: 168 | 169 | 1. Implement at least **two HRD TCP servers**: 170 | - One per slice (for WSJT-X instances) 171 | - One “main rig” port (for loggers) 172 | 173 | 2. Ensure the logger’s HRD connection always reflects: 174 | - Current TX slice 175 | - Correct band/mode/frequency 176 | 177 | 3. Let WSJT-X send QSOs via UDP/ADIF **directly to the logger**, but keep all **rig control** between logger ↔ MCP ↔ radio. 178 | 179 | This keeps the design clean, avoids COM conflicts, and mirrors SliceMaster’s proven behavior. 180 | 181 | --- 182 | 183 | # End of Log-Control.md 184 | -------------------------------------------------------------------------------- /install.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | setlocal enabledelayedexpansion 3 | 4 | REM Claude Code Windows CMD Bootstrap Script 5 | REM Installs Claude Code for environments where PowerShell is not available 6 | 7 | REM Parse command line argument 8 | set "TARGET=%~1" 9 | if "!TARGET!"=="" set "TARGET=stable" 10 | 11 | REM Validate target parameter 12 | if /i "!TARGET!"=="stable" goto :target_valid 13 | if /i "!TARGET!"=="latest" goto :target_valid 14 | echo !TARGET! | findstr /r "^[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*" >nul 15 | if !ERRORLEVEL! equ 0 goto :target_valid 16 | 17 | echo Usage: %0 [stable^|latest^|VERSION] >&2 18 | echo Example: %0 1.0.58 >&2 19 | exit /b 1 20 | 21 | :target_valid 22 | 23 | REM Check for 64-bit Windows 24 | if /i "%PROCESSOR_ARCHITECTURE%"=="AMD64" goto :arch_valid 25 | if /i "%PROCESSOR_ARCHITECTURE%"=="ARM64" goto :arch_valid 26 | if /i "%PROCESSOR_ARCHITEW6432%"=="AMD64" goto :arch_valid 27 | if /i "%PROCESSOR_ARCHITEW6432%"=="ARM64" goto :arch_valid 28 | 29 | echo Claude Code does not support 32-bit Windows. Please use a 64-bit version of Windows. >&2 30 | exit /b 1 31 | 32 | :arch_valid 33 | 34 | REM Set constants 35 | set "GCS_BUCKET=https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases" 36 | set "DOWNLOAD_DIR=%USERPROFILE%\.claude\downloads" 37 | set "PLATFORM=win32-x64" 38 | 39 | REM Create download directory 40 | if not exist "!DOWNLOAD_DIR!" mkdir "!DOWNLOAD_DIR!" 41 | 42 | REM Check for curl availability 43 | curl --version >nul 2>&1 44 | if !ERRORLEVEL! neq 0 ( 45 | echo curl is required but not available. Please install curl or use PowerShell installer. >&2 46 | exit /b 1 47 | ) 48 | 49 | REM Always download stable version (which has the most up-to-date installer) 50 | call :download_file "!GCS_BUCKET!/stable" "!DOWNLOAD_DIR!\stable" 51 | if !ERRORLEVEL! neq 0 ( 52 | echo Failed to get stable version >&2 53 | exit /b 1 54 | ) 55 | 56 | REM Read version from file 57 | set /p VERSION=<"!DOWNLOAD_DIR!\stable" 58 | del "!DOWNLOAD_DIR!\stable" 59 | 60 | REM Download manifest 61 | call :download_file "!GCS_BUCKET!/!VERSION!/manifest.json" "!DOWNLOAD_DIR!\manifest.json" 62 | if !ERRORLEVEL! neq 0 ( 63 | echo Failed to get manifest >&2 64 | exit /b 1 65 | ) 66 | 67 | REM Extract checksum from manifest 68 | call :parse_manifest "!DOWNLOAD_DIR!\manifest.json" "!PLATFORM!" 69 | if !ERRORLEVEL! neq 0 ( 70 | echo Platform !PLATFORM! not found in manifest >&2 71 | del "!DOWNLOAD_DIR!\manifest.json" 2>nul 72 | exit /b 1 73 | ) 74 | del "!DOWNLOAD_DIR!\manifest.json" 75 | 76 | REM Download binary 77 | set "BINARY_PATH=!DOWNLOAD_DIR!\claude-!VERSION!-!PLATFORM!.exe" 78 | call :download_file "!GCS_BUCKET!/!VERSION!/!PLATFORM!/claude.exe" "!BINARY_PATH!" 79 | if !ERRORLEVEL! neq 0 ( 80 | echo Failed to download binary >&2 81 | if exist "!BINARY_PATH!" del "!BINARY_PATH!" 82 | exit /b 1 83 | ) 84 | 85 | REM Verify checksum 86 | call :verify_checksum "!BINARY_PATH!" "!EXPECTED_CHECKSUM!" 87 | if !ERRORLEVEL! neq 0 ( 88 | echo Checksum verification failed >&2 89 | del "!BINARY_PATH!" 90 | exit /b 1 91 | ) 92 | 93 | REM Run claude install to set up launcher and shell integration 94 | echo Setting up Claude Code... 95 | if "!TARGET!"=="stable" ( 96 | "!BINARY_PATH!" install 97 | ) else ( 98 | "!BINARY_PATH!" install "!TARGET!" 99 | ) 100 | set "INSTALL_RESULT=!ERRORLEVEL!" 101 | 102 | REM Clean up downloaded file 103 | REM Wait a moment for any file handles to be released 104 | timeout /t 1 /nobreak >nul 2>&1 105 | del /f "!BINARY_PATH!" >nul 2>&1 106 | if exist "!BINARY_PATH!" ( 107 | echo Warning: Could not remove temporary file: !BINARY_PATH! 108 | ) 109 | 110 | if !INSTALL_RESULT! neq 0 ( 111 | echo Installation failed >&2 112 | exit /b 1 113 | ) 114 | 115 | echo. 116 | echo Installation complete^^! 117 | echo. 118 | exit /b 0 119 | 120 | REM ============================================================================ 121 | REM SUBROUTINES 122 | REM ============================================================================ 123 | 124 | :download_file 125 | REM Downloads a file using curl 126 | REM Args: %1=URL, %2=OutputPath 127 | set "URL=%~1" 128 | set "OUTPUT=%~2" 129 | 130 | curl -fsSL "!URL!" -o "!OUTPUT!" 131 | exit /b !ERRORLEVEL! 132 | 133 | :parse_manifest 134 | REM Parse JSON manifest to extract checksum for platform 135 | REM Args: %1=ManifestPath, %2=Platform 136 | set "MANIFEST_PATH=%~1" 137 | set "PLATFORM_NAME=%~2" 138 | set "EXPECTED_CHECKSUM=" 139 | 140 | REM Use findstr to find platform section, then look for checksum 141 | set "FOUND_PLATFORM=" 142 | set "IN_PLATFORM_SECTION=" 143 | 144 | REM Read the manifest line by line 145 | for /f "usebackq tokens=*" %%i in ("!MANIFEST_PATH!") do ( 146 | set "LINE=%%i" 147 | 148 | REM Check if this line contains our platform 149 | echo !LINE! | findstr /c:"\"%PLATFORM_NAME%\":" >nul 150 | if !ERRORLEVEL! equ 0 ( 151 | set "IN_PLATFORM_SECTION=1" 152 | ) 153 | 154 | REM If we're in the platform section, look for checksum 155 | if defined IN_PLATFORM_SECTION ( 156 | echo !LINE! | findstr /c:"\"checksum\":" >nul 157 | if !ERRORLEVEL! equ 0 ( 158 | REM Extract checksum value 159 | for /f "tokens=2 delims=:" %%j in ("!LINE!") do ( 160 | set "CHECKSUM_PART=%%j" 161 | REM Remove quotes, whitespace, and comma 162 | set "CHECKSUM_PART=!CHECKSUM_PART: =!" 163 | set "CHECKSUM_PART=!CHECKSUM_PART:"=!" 164 | set "CHECKSUM_PART=!CHECKSUM_PART:,=!" 165 | 166 | REM Check if it looks like a SHA256 (64 hex chars) 167 | if not "!CHECKSUM_PART!"=="" ( 168 | call :check_length "!CHECKSUM_PART!" 64 169 | if !ERRORLEVEL! equ 0 ( 170 | set "EXPECTED_CHECKSUM=!CHECKSUM_PART!" 171 | exit /b 0 172 | ) 173 | ) 174 | ) 175 | ) 176 | 177 | REM Check if we've left the platform section (closing brace) 178 | echo !LINE! | findstr /c:"}" >nul 179 | if !ERRORLEVEL! equ 0 set "IN_PLATFORM_SECTION=" 180 | ) 181 | ) 182 | 183 | if "!EXPECTED_CHECKSUM!"=="" exit /b 1 184 | exit /b 0 185 | 186 | :check_length 187 | REM Check if string length equals expected length 188 | REM Args: %1=String, %2=ExpectedLength 189 | set "STR=%~1" 190 | set "EXPECTED_LEN=%~2" 191 | set "LEN=0" 192 | :count_loop 193 | if "!STR:~%LEN%,1!"=="" goto :count_done 194 | set /a LEN+=1 195 | goto :count_loop 196 | :count_done 197 | if %LEN%==%EXPECTED_LEN% exit /b 0 198 | exit /b 1 199 | 200 | :verify_checksum 201 | REM Verify file checksum using certutil 202 | REM Args: %1=FilePath, %2=ExpectedChecksum 203 | set "FILE_PATH=%~1" 204 | set "EXPECTED=%~2" 205 | 206 | for /f "skip=1 tokens=*" %%i in ('certutil -hashfile "!FILE_PATH!" SHA256') do ( 207 | set "ACTUAL=%%i" 208 | set "ACTUAL=!ACTUAL: =!" 209 | if "!ACTUAL!"=="CertUtil:Thecommandcompletedsuccessfully." goto :verify_done 210 | if "!ACTUAL!" neq "" ( 211 | if /i "!ACTUAL!"=="!EXPECTED!" ( 212 | exit /b 0 213 | ) else ( 214 | exit /b 1 215 | ) 216 | ) 217 | ) 218 | 219 | :verify_done 220 | exit /b 1 221 | -------------------------------------------------------------------------------- /Rig Control.md: -------------------------------------------------------------------------------- 1 | # Rig Control.md 2 | Full Rig-Control Reference for MCP / SliceMaster Workflow (Flex + WSJT‑X + HRD) 3 | 4 | This document describes the **complete rig-control architecture**, **flows**, and **interactions** between: 5 | 6 | - WSJT‑X 7 | - SliceMaster‑like MCP 8 | - HRD TCP rig interface 9 | - Flex SmartSDR API 10 | - Multi-slice environments 11 | 12 | It is designed as a reference for building a new MCP with behavior equivalent or superior to SliceMaster 6000. 13 | 14 | --- 15 | 16 | # 1. Architecture Overview 17 | 18 | ``` 19 | WSJT-X / JTDX / N1MM / Loggers 20 | ▲ 21 | │ HRD TCP (per slice) 22 | ▼ 23 | MCP Rig-Control Core (HRD Server) 24 | │ 25 | ┌──────┴────────────┬──────────┐ 26 | │ Flex API Backend │ Hamlib │ 27 | │ (Slices A–F) │ Backend │ 28 | └───────────▲────────┘ │ 29 | │ Flex TCP │ 30 | ▼ ▼ 31 | FlexRadio SDR Standard Radios 32 | SmartSDR API (e.g. IC-7300) 33 | ``` 34 | 35 | --- 36 | 37 | # 2. Rig-Control Concepts 38 | 39 | ## 2.1 Channels vs Slices 40 | To support both Flex and traditional radios, the MCP abstracts radio receivers as **channels**. 41 | 42 | - **Flex** → channel = slice (A–F) 43 | - **IC‑7300** → channel = VFO A 44 | - **Dual-VFO radios** → channel A/B 45 | - **WSJT‑X instance** maps to exactly **one** channel 46 | 47 | This keeps the MCP interface consistent regardless of hardware. 48 | 49 | --- 50 | 51 | # 3. HRD TCP Protocol Role 52 | 53 | WSJT‑X does **not** talk directly to SmartSDR CAT in this mode. 54 | Instead, WSJT‑X uses: 55 | 56 | ``` 57 | Rig: Ham Radio Deluxe 58 | Network Server: MCP_IP:PORT 59 | ``` 60 | 61 | The MCP implements HRD TCP and translates commands such as: 62 | 63 | - `set frequency ` 64 | - `get frequency` 65 | - `set mode ` 66 | - `set ptt on/off` 67 | 68 | into **Flex API** or **Hamlib** operations. 69 | 70 | ### Why HRD? 71 | - Stable, simple TCP protocol 72 | - Universal support in WSJT‑X, JTDX, N1MM, Log4OM, etc. 73 | - SliceMaster uses the same architecture 74 | - Allows full multi-slice control 75 | 76 | --- 77 | 78 | # 4. WSJT‑X Multi‑Instance Handling 79 | 80 | Each WSJT-X instance is launched with its own rig-name: 81 | 82 | ``` 83 | wsjtx.exe --rig-name=Slice-A 84 | ``` 85 | 86 | WSJT‑X then stores settings here: 87 | 88 | ``` 89 | C:\Users\\AppData\Local\WSJT-X - Slice-A\WSJT-X - Slice-A.ini 90 | ``` 91 | 92 | SliceMaster/MCP clones the default WSJT-X configuration into four (or more) rig-named folders and patches: 93 | 94 | - Rig = Ham Radio Deluxe 95 | - Network Server = HRD port per slice 96 | - Audio input = corresponding DAX RX 97 | - Audio output = DAX TX 98 | - UDPServerPort = unique per slice 99 | 100 | --- 101 | 102 | # 5. Complete Rig-Control Flows 103 | 104 | ## 5.1 Flow A — **User changes band in WSJT‑X** 105 | 106 | ``` 107 | User changes frequency in WSJT-X 108 | │ 109 | ▼ 110 | WSJT-X sends HRD command: 111 | "set frequency " 112 | │ 113 | ▼ 114 | MCP HRD server receives command (slice-specific) 115 | │ 116 | ▼ 117 | MCP Flex Backend → SmartSDR API: 118 | slice.set_frequency(Hz) 119 | │ 120 | ▼ 121 | SmartSDR updates slice state 122 | │ 123 | ▼ 124 | SmartSDR notifies MCP via Flex API events 125 | │ 126 | ▼ 127 | WSJT-X polling: 128 | "get frequency" 129 | MCP replies with new freq 130 | │ 131 | ▼ 132 | WSJT-X updates UI + band 133 | ``` 134 | 135 | ### Result: 136 | Band in WSJT‑X = Band of the Flex slice. 137 | 138 | --- 139 | 140 | ## 5.2 Flow B — **User tunes the Flex radio first** 141 | 142 | ``` 143 | User tunes slice in SmartSDR 144 | │ 145 | ▼ 146 | SmartSDR API event → MCP receives "slice freq changed" 147 | │ 148 | ▼ 149 | MCP updates HRD state 150 | │ 151 | ▼ 152 | WSJT-X periodically polls HRD: 153 | "get frequency" 154 | │ 155 | ▼ 156 | WSJT-X updates UI and dial to match Flex 157 | ``` 158 | 159 | This is how SliceMaster ensures **two-way synchronization**. 160 | 161 | --- 162 | 163 | ## 5.3 Flow C — **PTT Control Flow** 164 | 165 | ### When WSJT‑X transmits: 166 | 167 | ``` 168 | WSJT-X → HRD: "set ptt on" 169 | MCP → Flex API: slice.tx = true 170 | Flex radio transmits 171 | SmartSDR API event → MCP updates state 172 | WSJT-X queries → sees TX active 173 | ``` 174 | 175 | ### When transmission ends: 176 | 177 | ``` 178 | WSJT-X → HRD: "set ptt off" 179 | MCP → Flex API: slice.tx = false 180 | SmartSDR event → MCP updates 181 | WSJT-X polls → sees RX state 182 | ``` 183 | 184 | ### Optional MCP features: 185 | - TX guard (block TX on wrong band/antenna) 186 | - TX follow (auto-select right slice before PTT) 187 | 188 | --- 189 | 190 | ## 5.4 Flow D — **Mode Changes** 191 | 192 | WSJT‑X sends: 193 | 194 | ``` 195 | set mode DIGU 196 | ``` 197 | 198 | MCP translates: 199 | 200 | ``` 201 | FlexAPI: slice.set_mode("DIGU") 202 | ``` 203 | 204 | SmartSDR updates. 205 | 206 | WSJT-X polls for confirmation and updates its GUI. 207 | 208 | --- 209 | 210 | ## 5.5 Flow E — **Split Operation** 211 | 212 | WSJT‑X may request split using HRD semantics. 213 | 214 | MCP handles: 215 | 216 | - Setting TX slice 217 | - Adjusting RX/TX frequencies 218 | - SmartSDR split logic 219 | 220 | Flex handles split very cleanly so HRD → Flex translation is trivial. 221 | 222 | --- 223 | 224 | # 6. MCP Tool API (LLM Facing) 225 | 226 | These tools hide internal backend complexity. 227 | 228 | ### `rig_get_state()` 229 | Returns all channels, freq, mode, TX, DAX. 230 | 231 | ### `rig_tune_channel(index, freq_hz)` 232 | Moves channel to new freq; updates HRD clients and Flex/Hamlib. 233 | 234 | ### `rig_set_mode(index, mode)` 235 | DIGU, USB, LSB, CW etc. 236 | 237 | ### `rig_set_tx_channel(index)` 238 | Choose TX slice. 239 | 240 | ### `rig_emergency_stop()` 241 | Force PTT off, inhibit TX. 242 | 243 | ### `wsjtx_get_decodes(instance)` 244 | Returns recent decodes via UDP. 245 | 246 | This abstracts Flex or IC‑7300 into one interface. 247 | 248 | --- 249 | 250 | # 7. Mapping Backends 251 | 252 | | Backend | Used For | WSJT‑X Rig Setting | Notes | 253 | |--------|----------|---------------------|-------| 254 | | **flex_hrd** | Flex multi-slice | Ham Radio Deluxe | Best all-in-one mode | 255 | | **flex_slice** | WSJT-X Flex rig | Flex Slice A-F | No HRD needed | 256 | | **flex_udp** | Full MCP-driven | None | Advanced AI mode | 257 | | **hamlib_cat** | IC-7300, TS-590 | IC-7300 / Hamlib | Standard CAT radios | 258 | | **ts2000_cat** | Legacy | TS-2000 | Optional fallback | 259 | 260 | --- 261 | 262 | # 8. Summary 263 | 264 | - HRD TCP is the ideal universal rig protocol for WSJT-X + Flex + loggers. 265 | - MCP sits between WSJT-X and FlexRadio. 266 | - Every slice gets its own WSJT-X instance + config folder + HRD port. 267 | - MCP translates HRD commands → Flex API. 268 | - Both directions stay synchronized. 269 | - Architecture works for Flex and standard radios (via Hamlib). 270 | 271 | This document defines the full rig-control logic required to build a SliceMaster-like MCP with reliable multi-slice digital-mode support. 272 | 273 | --- 274 | 275 | # End of Rig Control.md 276 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { Settings } from './components/Settings'; 3 | import { SlicePanel } from './components/SlicePanel'; 4 | import type { SliceState, DashboardConfig, WebSocketMessage } from './types'; 5 | 6 | type Page = 'dashboard' | 'settings'; 7 | 8 | // Default dashboard config (used until server sends config) 9 | const DEFAULT_CONFIG: DashboardConfig = { 10 | stationLifetimeSeconds: 120, 11 | colors: { 12 | worked: '#6b7280', 13 | normal: '#3b82f6', 14 | weak: '#eab308', 15 | strong: '#22c55e', 16 | priority: '#f97316', 17 | new_dxcc: '#ec4899', 18 | }, 19 | }; 20 | 21 | // Determine API base URL - use current host for production, localhost for dev 22 | const getApiBase = () => { 23 | if (window.location.port === '5173' || window.location.port === '5174') { 24 | // Development mode - Vite dev server 25 | return `http://${window.location.hostname}:3001`; 26 | } 27 | // Production mode - served from the same server 28 | return ''; 29 | }; 30 | 31 | const API_BASE = getApiBase(); 32 | const WS_URL = `ws://${window.location.hostname}:3001`; 33 | 34 | function App() { 35 | const [slices, setSlices] = useState([]); 36 | const [dashboardConfig, setDashboardConfig] = useState(DEFAULT_CONFIG); 37 | const [connected, setConnected] = useState(false); 38 | const [page, setPage] = useState('dashboard'); 39 | 40 | useEffect(() => { 41 | const ws = new WebSocket(WS_URL); 42 | 43 | ws.onopen = () => { 44 | setConnected(true); 45 | console.log('Connected to MCP Server'); 46 | }; 47 | 48 | ws.onmessage = (event) => { 49 | const data: WebSocketMessage = JSON.parse(event.data); 50 | console.log('Received:', data.type); 51 | 52 | if (data.type === 'STATIONS_UPDATE') { 53 | setSlices(data.slices); 54 | setDashboardConfig(data.config); 55 | } 56 | }; 57 | 58 | ws.onclose = () => { 59 | setConnected(false); 60 | console.log('Disconnected from MCP Server'); 61 | }; 62 | 63 | return () => ws.close(); 64 | }, []); 65 | 66 | return ( 67 |
68 |
69 |
70 |
71 |
72 |

73 | 📡 WSJT-X Mission Control 74 |

75 |
76 |
77 | 78 | {connected ? 'Connected' : 'Disconnected'} 79 | 80 |
81 |
82 | 104 |
105 |
106 | 107 | {page === 'dashboard' && ( 108 |
109 | {/* Summary stats */} 110 | {slices.length > 0 && ( 111 |
112 | 113 | {slices.length} slice{slices.length !== 1 ? 's' : ''} active 114 | 115 | 116 | 117 | {slices.reduce((acc, s) => acc + s.stations.filter(st => st.status !== 'worked').length, 0)} 118 | new stations 119 | 120 | 121 | 122 | {slices.reduce((acc, s) => acc + s.stations.length, 0)} 123 | total 124 | 125 |
126 | )} 127 | 128 | {/* Slice panels grid */} 129 |
130 | {slices.length === 0 ? ( 131 |
132 |

No active slices. Waiting for WSJT-X connections...

133 | 139 |
140 | ) : ( 141 | slices.map((slice) => ( 142 | 147 | )) 148 | )} 149 |
150 | 151 | {/* Legend */} 152 | {slices.length > 0 && ( 153 |
154 |
155 | 156 | Strong 157 |
158 |
159 | 160 | Normal 161 |
162 |
163 | 164 | Weak 165 |
166 |
167 | 168 | Worked 169 |
170 |
171 | 172 | Priority 173 |
174 |
175 | 176 | New DXCC 177 |
178 |
179 | )} 180 |
181 | )} 182 | 183 | {page === 'settings' && ( 184 | setPage('dashboard')} apiBase={API_BASE} /> 185 | )} 186 |
187 |
188 | ); 189 | } 190 | 191 | export default App; 192 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WSJT-X MCP Server 2 | 3 | ![License](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge) 4 | ![Windows](https://img.shields.io/badge/Windows-0078D6?style=for-the-badge&logo=windows&logoColor=white) 5 | 6 | ![TypeScript](https://img.shields.io/badge/TypeScript-007ACC?style=for-the-badge&logo=typescript&logoColor=white) 7 | ![Node.js](https://img.shields.io/badge/Node.js-43853D?style=for-the-badge&logo=node.js&logoColor=white) 8 | ![Status](https://img.shields.io/badge/Status-In_Development-orange?style=for-the-badge) 9 | 10 | **Control your Amateur Radio station with AI.** 11 | 12 | The **WSJT-X MCP Server** bridges the gap between modern AI agents (like Claude, ChatGPT, or Gemini) and the popular **WSJT-X** software. It enables your AI assistant to monitor radio traffic, analyze signals, and autonomously conduct QSOs on modes like FT8 and FT4. 13 | 14 | --- 15 | 16 | ## Features 17 | 18 | - **AI-Driven Control**: Exposes WSJT-X functionality via the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/). 19 | - **Multi-Instance Support**: Control multiple radios/bands simultaneously from a single AI session. 20 | - **Autonomous QSOs**: "Fire-and-forget" QSO automation—tell the AI to "work that station," and the server handles the complete exchange sequence. 21 | - **Windows Native**: Designed primarily for Windows, with optional support for Raspberry Pi. 22 | - **Dual Operation Modes**: 23 | - **FlexRadio Mode**: Auto-launch WSJT-X instances for each slice with built-in CAT server (no external SmartCAT needed). 24 | - **Standard Mode**: Direct control for standard rigs (default: **IC-7300**). 25 | - **Web Dashboard**: Real-time monitoring and manual control interface on port 3000. 26 | - **Live Monitoring**: Stream decoded messages and signal reports directly to the AI context. 27 | 28 | ## Architecture 29 | 30 | The MCP Server runs locally on the same machine as your WSJT-X instances (PC or Raspberry Pi). It acts as a middleware, translating MCP requests from the AI Agent into UDP commands for WSJT-X. 31 | 32 | ### Standard Mode 33 | ``` 34 | AI Agent <--MCP/stdio--> MCP Server <--UDP 2237--> WSJT-X <--CAT--> Radio (IC-7300) 35 | ``` 36 | 37 | ### FlexRadio Mode 38 | ``` 39 | AI Agent <--MCP/stdio--> MCP Server 40 | | 41 | +---------------+---------------+ 42 | | | | 43 | FlexClient FlexRadio HrdCatServer 44 | (VITA 49) Manager (HRD Protocol) 45 | | | | 46 | +-------+-------+-------+-------+ 47 | | | 48 | SmartSDR WSJT-X 49 | | Instances 50 | FlexRadio (per slice) 51 | ``` 52 | 53 | **FlexRadio Features:** 54 | - Auto-discovery of FlexRadio on the network 55 | - Auto-launch WSJT-X instance for each slice 56 | - Built-in HRD CAT server (ports 7809-7812) 57 | - Bidirectional control: WSJT-X frequency/mode/PTT changes sync to FlexRadio 58 | - Auto-configuration of WSJT-X INI files (audio, rig, wide graph) 59 | 60 | ## Capabilities 61 | 62 | | Category | Functionality | 63 | |----------|---------------| 64 | | **Management** | Start/Stop instances, List active radios | 65 | | **Monitoring** | Live decodes, Frequency/Mode status, Signal reports | 66 | | **Control** | Set Frequency, Change Mode, PTT control | 67 | | **Automation** | Call CQ, Reply to Station, **Execute Full QSO** | 68 | | **Visualization** | **Web Dashboard** (port 3000), Action Logs | 69 | 70 | ## Installation 71 | 72 | ### Prerequisites 73 | - **Node.js** (v18+) 74 | - **WSJT-X** installed (default: `C:\WSJT\wsjtx\bin\wsjtx.exe`) 75 | - For FlexRadio mode: **SmartSDR** running 76 | 77 | ### Quick Start 78 | ```bash 79 | # Clone the repository 80 | git clone https://github.com/SensorsIot/wsjt-x-MCP.git 81 | cd wsjt-x-MCP 82 | 83 | # Install dependencies 84 | npm install 85 | 86 | # Build TypeScript 87 | npx tsc 88 | 89 | # Run the server 90 | npm start 91 | ``` 92 | 93 | ### Web Dashboard 94 | ```bash 95 | cd frontend 96 | npm install 97 | npm run dev # Development with hot reload 98 | # or 99 | npm run build # Production build 100 | ``` 101 | 102 | Access the dashboard at: http://localhost:3000 103 | 104 | ## Configuration 105 | 106 | Configuration is stored in `config.json`: 107 | 108 | ### Standard Mode (default) 109 | ```json 110 | { 111 | "mode": "STANDARD", 112 | "wsjtx": { 113 | "path": "C:\\WSJT\\wsjtx\\bin\\wsjtx.exe" 114 | }, 115 | "station": { 116 | "callsign": "YOUR_CALL", 117 | "grid": "YOUR_GRID" 118 | }, 119 | "standard": { 120 | "rigName": "IC-7300" 121 | } 122 | } 123 | ``` 124 | 125 | ### FlexRadio Mode 126 | ```json 127 | { 128 | "mode": "FLEX", 129 | "wsjtx": { 130 | "path": "C:\\WSJT\\wsjtx\\bin\\wsjtx.exe" 131 | }, 132 | "station": { 133 | "callsign": "YOUR_CALL", 134 | "grid": "YOUR_GRID" 135 | }, 136 | "flex": { 137 | "host": "auto", 138 | "catBasePort": 7831 139 | } 140 | } 141 | ``` 142 | 143 | ### Environment Variables 144 | - `WSJTX_MODE`: Set to `FLEX` for FlexRadio mode (default: `STANDARD`) 145 | - `FLEX_HOST`: FlexRadio IP address (default: auto-discovery) 146 | - `RIG_NAME`: Rig name for Standard mode (default: `IC-7300`) 147 | 148 | ## MCP Tools 149 | 150 | The server exposes these tools to AI agents: 151 | 152 | | Tool | Description | 153 | |------|-------------| 154 | | `start_instance` | Launch a new WSJT-X instance | 155 | | `stop_instance` | Stop a running instance | 156 | | `execute_qso` | Autonomously complete a QSO with a target station | 157 | 158 | ## Project Structure 159 | 160 | ``` 161 | wsjt-x-MCP/ 162 | ├── src/ 163 | │ ├── index.ts # Entry point 164 | │ ├── SettingsManager.ts # Configuration management 165 | │ │ 166 | │ ├── wsjtx/ # WSJT-X management 167 | │ │ ├── WsjtxManager.ts # Main orchestrator 168 | │ │ ├── ProcessManager.ts # WSJT-X process lifecycle 169 | │ │ ├── FlexRadioManager.ts # FlexRadio slice management 170 | │ │ ├── UdpListener.ts # UDP message receiver 171 | │ │ ├── UdpSender.ts # UDP message sender 172 | │ │ ├── QsoStateMachine.ts # Autonomous QSO handler 173 | │ │ ├── WindowManager.ts # WSJT-X window positioning 174 | │ │ └── WsjtxConfig.ts # INI file auto-configuration 175 | │ │ 176 | │ ├── state/ # MCP state management 177 | │ │ ├── StateManager.ts # Aggregate MCP state 178 | │ │ ├── ChannelUdpManager.ts # Per-channel UDP 179 | │ │ └── types.ts 180 | │ │ 181 | │ ├── logbook/ # Logbook operations 182 | │ │ └── LogbookManager.ts # ADIF, WorkedIndex, HRD server 183 | │ │ 184 | │ ├── dashboard/ # Web dashboard state 185 | │ │ └── DashboardManager.ts # Station tracking 186 | │ │ 187 | │ ├── cat/ # CAT control 188 | │ │ └── HrdCatServer.ts # HRD protocol server 189 | │ │ 190 | │ ├── flex/ # FlexRadio backend 191 | │ │ ├── FlexClient.ts # FlexRadio connection 192 | │ │ ├── Vita49Client.ts # VITA 49 protocol 193 | │ │ └── FlexDiscovery.ts # Auto-discovery 194 | │ │ 195 | │ ├── mcp/ # MCP protocol 196 | │ │ └── McpServer.ts # MCP stdio transport 197 | │ │ 198 | │ └── web/ # Web interface 199 | │ └── server.ts # Express + WebSocket 200 | │ 201 | ├── frontend/ # React web dashboard 202 | ├── dist/ # Compiled JavaScript 203 | └── config.json # Runtime configuration 204 | ``` 205 | 206 | ## Documentation 207 | 208 | - **[WSJT-X-MCP-FSD.md](WSJT-X-MCP-FSD.md)** - Full Functional Specification Document 209 | - **[CLAUDE.md](CLAUDE.md)** - Development guide for Claude Code 210 | 211 | ## Contributing 212 | 213 | Contributions are welcome! Please feel free to submit a Pull Request. 214 | 215 | ## License 216 | 217 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 218 | -------------------------------------------------------------------------------- /src/wsjtx/ProcessManager.ts: -------------------------------------------------------------------------------- 1 | import { spawn, ChildProcess, exec } from 'child_process'; 2 | import { EventEmitter } from 'events'; 3 | import path from 'path'; 4 | 5 | /** 6 | * Kill all orphaned jt9.exe processes 7 | * jt9 is the WSJT-X decoder subprocess that can get orphaned when WSJT-X doesn't shut down cleanly 8 | */ 9 | export async function killOrphanedJt9Processes(): Promise { 10 | return new Promise((resolve) => { 11 | if (process.platform === 'win32') { 12 | // Windows: Use tasklist to find jt9 processes and taskkill to kill them 13 | exec('tasklist /FI "IMAGENAME eq jt9.exe" /FO CSV /NH', (err, stdout) => { 14 | if (err || !stdout.trim() || stdout.includes('No tasks')) { 15 | resolve(0); 16 | return; 17 | } 18 | 19 | // Count jt9 processes 20 | const lines = stdout.trim().split('\n').filter(line => line.includes('jt9.exe')); 21 | const count = lines.length; 22 | 23 | if (count > 0) { 24 | console.log(`[ProcessManager] Found ${count} orphaned jt9 process(es), killing...`); 25 | exec('taskkill /F /IM jt9.exe', (killErr) => { 26 | if (killErr) { 27 | console.warn(`[ProcessManager] Warning: Could not kill jt9 processes: ${killErr.message}`); 28 | } else { 29 | console.log(`[ProcessManager] Killed ${count} orphaned jt9 process(es)`); 30 | } 31 | resolve(count); 32 | }); 33 | } else { 34 | resolve(0); 35 | } 36 | }); 37 | } else { 38 | // Linux/Mac: Use pkill 39 | exec('pgrep -c jt9', (err, stdout) => { 40 | const count = parseInt(stdout.trim()) || 0; 41 | if (count > 0) { 42 | console.log(`[ProcessManager] Found ${count} orphaned jt9 process(es), killing...`); 43 | exec('pkill -9 jt9', (killErr) => { 44 | if (killErr) { 45 | console.warn(`[ProcessManager] Warning: Could not kill jt9 processes: ${killErr.message}`); 46 | } else { 47 | console.log(`[ProcessManager] Killed ${count} orphaned jt9 process(es)`); 48 | } 49 | resolve(count); 50 | }); 51 | } else { 52 | resolve(0); 53 | } 54 | }); 55 | } 56 | }); 57 | } 58 | 59 | export interface WsjtxInstanceConfig { 60 | name: string; 61 | band?: string; 62 | rigName?: string; 63 | udpPort?: number; 64 | wsjtxPath?: string; 65 | // FlexRadio-specific settings 66 | sliceIndex?: number; // Slice index (A=0, B=1, etc.) 67 | daxChannel?: number; // DAX audio channel (1-8) 68 | smartCatPort?: number; // SmartCAT TCP port for CAT control 69 | smartCatHost?: string; // SmartCAT host (usually localhost) 70 | } 71 | 72 | // Default path - can be overridden via config 73 | let defaultWsjtxPath = 'C:\\WSJT\\wsjtx\\bin\\wsjtx.exe'; 74 | 75 | export function setDefaultWsjtxPath(path: string) { 76 | defaultWsjtxPath = path; 77 | } 78 | 79 | export function getDefaultWsjtxPath(): string { 80 | return defaultWsjtxPath; 81 | } 82 | 83 | export class WsjtxProcess extends EventEmitter { 84 | private process: ChildProcess | null = null; 85 | private config: WsjtxInstanceConfig; 86 | public readonly name: string; 87 | public readonly udpPort: number; 88 | 89 | constructor(config: WsjtxInstanceConfig) { 90 | super(); 91 | this.config = config; 92 | this.name = config.name; 93 | this.udpPort = config.udpPort || 2237; 94 | } 95 | 96 | public start(): void { 97 | // Use configured path or default 98 | const wsjtxPath = this.config.wsjtxPath || defaultWsjtxPath; 99 | 100 | const args: string[] = []; 101 | 102 | // Use --rig-name to identify this instance and load its saved configuration 103 | if (this.config.rigName) { 104 | args.push('--rig-name', this.config.rigName); 105 | } else { 106 | args.push('--rig-name', this.name); 107 | } 108 | 109 | console.log(`\nStarting WSJT-X instance: ${this.name}`); 110 | console.log(` Command: ${wsjtxPath} ${args.join(' ')}`); 111 | 112 | // Log FlexRadio-specific configuration (auto-configured via INI) 113 | if (this.config.smartCatPort !== undefined) { 114 | console.log(` === FlexRadio Configuration ===`); 115 | console.log(` DAX Channel: ${this.config.daxChannel || 'Not specified'}`); 116 | console.log(` SmartCAT: ${this.config.smartCatHost || '127.0.0.1'}:${this.config.smartCatPort}`); 117 | console.log(` Rig Type: Ham Radio Deluxe`); 118 | console.log(` Audio In: DAX Audio RX ${this.config.daxChannel || 1} (FlexRadio Systems DAX Audio)`); 119 | console.log(` Audio Out: DAX Audio TX (FlexRadio Systems DAX TX)`); 120 | console.log(` =====================================================`); 121 | } 122 | 123 | this.process = spawn(wsjtxPath, args, { 124 | detached: false, 125 | stdio: 'ignore', 126 | }); 127 | 128 | this.process.on('error', (error) => { 129 | console.error(`WSJT-X process error (${this.name}):`, error); 130 | this.emit('error', error); 131 | }); 132 | 133 | this.process.on('exit', (code, signal) => { 134 | console.log(`WSJT-X instance ${this.name} exited with code ${code}, signal ${signal}`); 135 | this.emit('exit', { code, signal }); 136 | this.process = null; 137 | }); 138 | 139 | this.emit('started'); 140 | } 141 | 142 | public stop(): void { 143 | if (this.process) { 144 | console.log(`Stopping WSJT-X instance: ${this.name}`); 145 | this.process.kill('SIGTERM'); 146 | 147 | // Force kill after 5 seconds if still running 148 | setTimeout(() => { 149 | if (this.process && !this.process.killed) { 150 | console.log(`Force killing WSJT-X instance: ${this.name}`); 151 | this.process.kill('SIGKILL'); 152 | } 153 | }, 5000); 154 | } 155 | } 156 | 157 | public isRunning(): boolean { 158 | return this.process !== null && !this.process.killed; 159 | } 160 | } 161 | 162 | export class ProcessManager extends EventEmitter { 163 | private instances: Map = new Map(); 164 | private nextPort: number = 2237; 165 | 166 | public startInstance(config: WsjtxInstanceConfig): WsjtxProcess { 167 | if (this.instances.has(config.name)) { 168 | throw new Error(`Instance ${config.name} already exists`); 169 | } 170 | 171 | // Assign UDP port if not specified 172 | if (!config.udpPort) { 173 | config.udpPort = this.nextPort++; 174 | } 175 | 176 | const instance = new WsjtxProcess(config); 177 | 178 | instance.on('started', () => { 179 | console.log(`Instance ${config.name} started successfully`); 180 | this.emit('instance-started', instance); 181 | }); 182 | 183 | instance.on('exit', () => { 184 | console.log(`Instance ${config.name} has exited`); 185 | this.instances.delete(config.name); 186 | this.emit('instance-stopped', instance); 187 | }); 188 | 189 | instance.on('error', (error) => { 190 | console.error(`Instance ${config.name} error:`, error); 191 | this.emit('instance-error', { instance, error }); 192 | }); 193 | 194 | this.instances.set(config.name, instance); 195 | instance.start(); 196 | 197 | return instance; 198 | } 199 | 200 | public stopInstance(name: string): boolean { 201 | const instance = this.instances.get(name); 202 | if (!instance) { 203 | return false; 204 | } 205 | 206 | instance.stop(); 207 | return true; 208 | } 209 | 210 | public getInstance(name: string): WsjtxProcess | undefined { 211 | return this.instances.get(name); 212 | } 213 | 214 | public getAllInstances(): WsjtxProcess[] { 215 | return Array.from(this.instances.values()); 216 | } 217 | 218 | public stopAll(): void { 219 | console.log('Stopping all WSJT-X instances...'); 220 | for (const instance of this.instances.values()) { 221 | instance.stop(); 222 | } 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /src/SettingsManager.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | 5 | export const OperationModeSchema = z.enum(['FLEX', 'STANDARD']); 6 | export type OperationMode = z.infer; 7 | 8 | // Station status priority (higher = more important, used for hierarchical coloring) 9 | export const StationStatusSchema = z.enum([ 10 | 'worked', // Already in log (lowest priority - gray) 11 | 'normal', // Normal station (default) 12 | 'weak', // Weak signal (below threshold) 13 | 'strong', // Strong signal (above threshold) 14 | 'priority', // Contest priority (placeholder for future) 15 | 'new_dxcc', // New DXCC (placeholder for future) 16 | ]); 17 | export type StationStatus = z.infer; 18 | 19 | export const ConfigSchema = z.object({ 20 | // Common parameters 21 | mode: OperationModeSchema.default('STANDARD'), 22 | wsjtx: z.object({ 23 | path: z.string().default('C:\\WSJT\\wsjtx\\bin\\wsjtx.exe'), 24 | }), 25 | station: z.object({ 26 | callsign: z.string().default(''), 27 | grid: z.string().default(''), 28 | continent: z.string().default('NA'), // "EU", "NA", "SA", "AF", "AS", "OC", "AN" 29 | dxcc: z.string().default(''), // e.g. "HB9", "W", "K" 30 | prefixes: z.array(z.string()).default([]), // All known prefixes for this station 31 | }), 32 | // Standard mode parameters 33 | standard: z.object({ 34 | rigName: z.string().default('IC-7300'), 35 | }), 36 | // FlexRadio mode parameters 37 | flex: z.object({ 38 | host: z.string().default('127.0.0.1'), 39 | catBasePort: z.number().default(60000), // SmartCAT TCP port (increments per slice) 40 | // Default FT8 dial frequencies for each slice (in Hz) 41 | // Slice A=index 0, B=index 1, etc. 42 | defaultBands: z.array(z.number()).optional(), // e.g., [28074000, 21074000, 14074000, 7074000] 43 | }), 44 | // Dashboard station tracking settings 45 | dashboard: z.object({ 46 | stationLifetimeSeconds: z.number().default(120), // How long to show stations after last decode 47 | snrWeakThreshold: z.number().default(-15), // SNR below this = weak 48 | snrStrongThreshold: z.number().default(0), // SNR above this = strong 49 | adifLogPath: z.string().default(''), // Path to combined ADIF log file 50 | colors: z.object({ 51 | worked: z.string().default('#6b7280'), // gray-500 52 | normal: z.string().default('#3b82f6'), // blue-500 53 | weak: z.string().default('#eab308'), // yellow-500 54 | strong: z.string().default('#22c55e'), // green-500 55 | priority: z.string().default('#f97316'), // orange-500 56 | new_dxcc: z.string().default('#ec4899'), // pink-500 57 | }).optional(), 58 | }).optional(), 59 | // Logbook settings 60 | logbook: z.object({ 61 | path: z.string().optional(), // Path to ADIF logbook (default: %APPDATA%/wsjt-x-mcp/mcp_logbook.adi) 62 | enableHrdServer: z.boolean().default(false), // Enable HRD server for external loggers (Log4OM, N1MM) 63 | hrdPort: z.number().default(7800), // HRD server port for external loggers 64 | udpRebroadcast: z.object({ // UDP rebroadcast for external loggers (Log4OM) 65 | enabled: z.boolean().default(false), // Enable UDP rebroadcast 66 | port: z.number().default(2241), // Rebroadcast port (Log4OM listens here) 67 | instanceId: z.string().default('WSJT-X-MCP'), // Unified instance ID 68 | host: z.string().default('127.0.0.1'), // Target host 69 | }).optional(), 70 | }).optional(), 71 | // Internal parameters (not user-configurable) 72 | mcp: z.object({ 73 | name: z.string().default('wsjt-x-mcp'), 74 | version: z.string().default('1.0.0'), 75 | }), 76 | web: z.object({ 77 | port: z.number().default(3000), 78 | }) 79 | }); 80 | 81 | export type Config = z.infer; 82 | 83 | const CONFIG_FILE = path.join(process.cwd(), 'config.json'); 84 | 85 | export function loadConfig(): Config { 86 | let fileConfig = {}; 87 | 88 | // Try to load from config file 89 | if (fs.existsSync(CONFIG_FILE)) { 90 | try { 91 | const fileContent = fs.readFileSync(CONFIG_FILE, 'utf-8'); 92 | fileConfig = JSON.parse(fileContent); 93 | console.log('Loaded config from config.json'); 94 | } catch (error) { 95 | console.error('Error loading config.json:', error); 96 | } 97 | } 98 | 99 | // Merge with env vars (env vars take precedence) 100 | const mode = process.env.WSJTX_MODE?.toUpperCase() === 'FLEX' ? 'FLEX' : 101 | (fileConfig as any)?.mode || 'STANDARD'; 102 | 103 | return ConfigSchema.parse({ 104 | ...fileConfig, 105 | mode, 106 | flex: { 107 | ...((fileConfig as any)?.flex || {}), 108 | host: process.env.FLEX_HOST || (fileConfig as any)?.flex?.host, 109 | }, 110 | standard: { 111 | ...((fileConfig as any)?.standard || {}), 112 | rigName: process.env.RIG_NAME || (fileConfig as any)?.standard?.rigName, 113 | rigPort: process.env.RIG_PORT || (fileConfig as any)?.standard?.rigPort, 114 | }, 115 | wsjtx: (fileConfig as any)?.wsjtx || {}, 116 | station: (fileConfig as any)?.station || {}, 117 | dashboard: (fileConfig as any)?.dashboard || {}, 118 | logbook: (fileConfig as any)?.logbook || {}, 119 | mcp: (fileConfig as any)?.mcp || {}, 120 | web: (fileConfig as any)?.web || {} 121 | }); 122 | } 123 | 124 | // Config change categories 125 | export type ConfigChangeLevel = 'live' | 'wsjtx_restart' | 'app_restart'; 126 | 127 | export interface ConfigChangeResult { 128 | config: Config; 129 | changeLevel: ConfigChangeLevel; 130 | changedFields: string[]; 131 | } 132 | 133 | // Determine what level of restart is needed for a config change 134 | function getChangeLevel(oldConfig: any, newConfig: any, path: string = ''): { level: ConfigChangeLevel; fields: string[] } { 135 | // Fields that require full app restart 136 | const appRestartFields = ['mode', 'web.port', 'flex.host']; 137 | 138 | // Fields that require WSJT-X instance restart (INI file changes) 139 | const wsjtxRestartFields = ['wsjtx.path', 'flex.catBasePort', 'flex.defaultBands', 'standard.rigName']; 140 | 141 | // All other fields can be applied live 142 | 143 | let maxLevel: ConfigChangeLevel = 'live'; 144 | const changedFields: string[] = []; 145 | 146 | function compare(oldVal: any, newVal: any, currentPath: string) { 147 | if (typeof newVal === 'object' && newVal !== null && !Array.isArray(newVal)) { 148 | for (const key of Object.keys(newVal)) { 149 | compare(oldVal?.[key], newVal[key], currentPath ? `${currentPath}.${key}` : key); 150 | } 151 | } else { 152 | // Check if value actually changed 153 | const oldStr = JSON.stringify(oldVal); 154 | const newStr = JSON.stringify(newVal); 155 | if (oldStr !== newStr) { 156 | changedFields.push(currentPath); 157 | 158 | if (appRestartFields.includes(currentPath)) { 159 | maxLevel = 'app_restart'; 160 | } else if (wsjtxRestartFields.includes(currentPath) && maxLevel !== 'app_restart') { 161 | maxLevel = 'wsjtx_restart'; 162 | } 163 | } 164 | } 165 | } 166 | 167 | compare(oldConfig, newConfig, ''); 168 | return { level: maxLevel, fields: changedFields }; 169 | } 170 | 171 | export function saveConfig(config: Partial): ConfigChangeResult { 172 | let existingConfig = {}; 173 | 174 | if (fs.existsSync(CONFIG_FILE)) { 175 | try { 176 | existingConfig = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8')); 177 | } catch (error) { 178 | // Ignore 179 | } 180 | } 181 | 182 | const mergedConfig = { ...existingConfig, ...config }; 183 | 184 | // Determine what changed 185 | const { level, fields } = getChangeLevel(existingConfig, mergedConfig, ''); 186 | 187 | fs.writeFileSync(CONFIG_FILE, JSON.stringify(mergedConfig, null, 2)); 188 | console.log('Config saved to config.json'); 189 | if (fields.length > 0) { 190 | console.log(` Changed fields: ${fields.join(', ')}`); 191 | console.log(` Change level: ${level}`); 192 | } 193 | 194 | return { 195 | config: ConfigSchema.parse(mergedConfig), 196 | changeLevel: level, 197 | changedFields: fields 198 | }; 199 | } 200 | 201 | export function getConfigFilePath(): string { 202 | return CONFIG_FILE; 203 | } 204 | -------------------------------------------------------------------------------- /src/wsjtx/QsoStateMachine.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import { WsjtxDecode } from './types'; 3 | import { UdpSender } from './UdpSender'; 4 | 5 | export enum QsoState { 6 | IDLE = 'IDLE', 7 | CALLING_CQ = 'CALLING_CQ', 8 | WAITING_REPLY = 'WAITING_REPLY', 9 | SENDING_REPORT = 'SENDING_REPORT', 10 | WAITING_REPORT = 'WAITING_REPORT', 11 | SENDING_RR73 = 'SENDING_RR73', 12 | WAITING_73 = 'WAITING_73', 13 | COMPLETE = 'COMPLETE', 14 | FAILED = 'FAILED', 15 | } 16 | 17 | export interface QsoConfig { 18 | instanceId: string; 19 | targetCallsign: string; 20 | myCallsign: string; 21 | myGrid: string; 22 | udpPort?: number; 23 | timeout?: number; // milliseconds 24 | maxRetries?: number; 25 | initialDecode?: WsjtxDecode; // The decode to respond to (simulates double-click) 26 | } 27 | 28 | export class QsoStateMachine extends EventEmitter { 29 | private state: QsoState = QsoState.IDLE; 30 | private config: QsoConfig; 31 | private udpSender: UdpSender; 32 | private timeout: number; 33 | private maxRetries: number; 34 | private retryCount: number = 0; 35 | private timeoutHandle?: NodeJS.Timeout; 36 | private receivedReport?: string; 37 | 38 | constructor(config: QsoConfig) { 39 | super(); 40 | this.config = config; 41 | this.timeout = config.timeout || 15000; // 15 seconds (FT8 cycle) 42 | this.maxRetries = config.maxRetries || 3; 43 | this.udpSender = new UdpSender(config.udpPort || 2237); 44 | } 45 | 46 | public start(): void { 47 | if (this.state !== QsoState.IDLE) { 48 | throw new Error('QSO already in progress'); 49 | } 50 | 51 | console.log(`Starting QSO with ${this.config.targetCallsign}`); 52 | this.setState(QsoState.CALLING_CQ); 53 | this.callCQ(); 54 | } 55 | 56 | public handleDecode(decode: WsjtxDecode): void { 57 | // Only process messages for our instance 58 | if (decode.id !== this.config.instanceId) { 59 | return; 60 | } 61 | 62 | const message = decode.message.trim(); 63 | console.log(`[QSO] State: ${this.state}, Message: ${message}`); 64 | 65 | switch (this.state) { 66 | case QsoState.WAITING_REPLY: 67 | if (this.isCallingMe(message)) { 68 | this.clearTimeout(); 69 | this.setState(QsoState.SENDING_REPORT); 70 | this.sendReport(decode); 71 | } 72 | break; 73 | 74 | case QsoState.WAITING_REPORT: 75 | if (this.isReportForMe(message)) { 76 | this.clearTimeout(); 77 | this.receivedReport = this.extractReport(message); 78 | this.setState(QsoState.SENDING_RR73); 79 | this.sendRR73(decode); 80 | } 81 | break; 82 | 83 | case QsoState.WAITING_73: 84 | if (this.is73ForMe(message)) { 85 | this.clearTimeout(); 86 | this.setState(QsoState.COMPLETE); 87 | this.complete(); 88 | } 89 | break; 90 | } 91 | } 92 | 93 | private callCQ(): void { 94 | // If we have an initialDecode, use Reply (simulates double-click on their CQ) 95 | // This is the proper way to initiate a QSO in WSJT-X 96 | if (this.config.initialDecode) { 97 | const message = `${this.config.targetCallsign} ${this.config.myCallsign} ${this.config.myGrid}`; 98 | console.log(`[QSO] Sending Reply (double-click): ${message}`); 99 | this.sendMessage(message, this.config.initialDecode); 100 | } else { 101 | // Fallback: send as free text if no decode available 102 | const message = `${this.config.targetCallsign} ${this.config.myCallsign} ${this.config.myGrid}`; 103 | console.log(`[QSO] Sending FreeText: ${message}`); 104 | this.sendMessage(message); 105 | } 106 | this.setState(QsoState.WAITING_REPLY); 107 | this.startTimeout(); 108 | } 109 | 110 | private sendReport(decode: WsjtxDecode): void { 111 | const report = this.formatReport(decode.snr); 112 | const message = `${this.config.targetCallsign} ${this.config.myCallsign} ${report}`; 113 | this.sendMessage(message, decode); 114 | this.setState(QsoState.WAITING_REPORT); 115 | this.startTimeout(); 116 | } 117 | 118 | private sendRR73(decode: WsjtxDecode): void { 119 | const message = `${this.config.targetCallsign} ${this.config.myCallsign} RR73`; 120 | this.sendMessage(message, decode); 121 | this.setState(QsoState.WAITING_73); 122 | this.startTimeout(); 123 | } 124 | 125 | private sendMessage(text: string, decode?: WsjtxDecode): void { 126 | console.log(`[QSO] Sending: ${text}`); 127 | 128 | if (decode) { 129 | // Reply to specific decode 130 | this.udpSender.sendReply( 131 | this.config.instanceId, 132 | decode.time, 133 | decode.snr, 134 | decode.deltaTime, 135 | decode.deltaFrequency, 136 | decode.mode, 137 | text 138 | ); 139 | } else { 140 | // Send free text 141 | this.udpSender.sendFreeText(this.config.instanceId, text, true); 142 | } 143 | } 144 | 145 | private isCallingMe(message: string): boolean { 146 | // Match: "MYCALL THEIRCALL GRID" or "MYCALL THEIRCALL" 147 | const pattern = new RegExp(`${this.config.myCallsign}\\s+${this.config.targetCallsign}`, 'i'); 148 | return pattern.test(message); 149 | } 150 | 151 | private isReportForMe(message: string): boolean { 152 | // Match: "MYCALL THEIRCALL +/-XX" or "MYCALL THEIRCALL RXX" 153 | const pattern = new RegExp(`${this.config.myCallsign}\\s+${this.config.targetCallsign}\\s+[R+-]\\d+`, 'i'); 154 | return pattern.test(message); 155 | } 156 | 157 | private is73ForMe(message: string): boolean { 158 | // Match: "MYCALL THEIRCALL 73" or "THEIRCALL MYCALL 73" 159 | return message.includes('73') && 160 | (message.includes(this.config.myCallsign) || message.includes(this.config.targetCallsign)); 161 | } 162 | 163 | private extractReport(message: string): string { 164 | const match = message.match(/([R+-]\d+)/); 165 | return match ? match[1] : ''; 166 | } 167 | 168 | private formatReport(snr: number): string { 169 | return snr >= 0 ? `+${snr.toString().padStart(2, '0')}` : snr.toString().padStart(3, '0'); 170 | } 171 | 172 | private setState(newState: QsoState): void { 173 | const oldState = this.state; 174 | this.state = newState; 175 | console.log(`[QSO] State transition: ${oldState} -> ${newState}`); 176 | this.emit('state-change', { oldState, newState }); 177 | } 178 | 179 | private startTimeout(): void { 180 | this.clearTimeout(); 181 | this.timeoutHandle = setTimeout(() => { 182 | this.handleTimeout(); 183 | }, this.timeout); 184 | } 185 | 186 | private clearTimeout(): void { 187 | if (this.timeoutHandle) { 188 | clearTimeout(this.timeoutHandle); 189 | this.timeoutHandle = undefined; 190 | } 191 | } 192 | 193 | private handleTimeout(): void { 194 | console.log(`[QSO] Timeout in state: ${this.state}`); 195 | this.retryCount++; 196 | 197 | if (this.retryCount >= this.maxRetries) { 198 | console.log(`[QSO] Max retries reached, failing QSO`); 199 | this.setState(QsoState.FAILED); 200 | this.fail('Max retries exceeded'); 201 | } else { 202 | console.log(`[QSO] Retry ${this.retryCount}/${this.maxRetries}`); 203 | // Retry current state 204 | switch (this.state) { 205 | case QsoState.WAITING_REPLY: 206 | this.callCQ(); 207 | break; 208 | case QsoState.WAITING_REPORT: 209 | case QsoState.WAITING_73: 210 | // Wait for next cycle 211 | this.startTimeout(); 212 | break; 213 | } 214 | } 215 | } 216 | 217 | private complete(): void { 218 | this.clearTimeout(); 219 | console.log(`[QSO] QSO complete with ${this.config.targetCallsign}`); 220 | this.emit('complete', { 221 | targetCallsign: this.config.targetCallsign, 222 | report: this.receivedReport, 223 | }); 224 | this.cleanup(); 225 | } 226 | 227 | private fail(reason: string): void { 228 | this.clearTimeout(); 229 | console.log(`[QSO] QSO failed: ${reason}`); 230 | this.emit('failed', { reason }); 231 | this.cleanup(); 232 | } 233 | 234 | private cleanup(): void { 235 | this.udpSender.close(); 236 | } 237 | 238 | public abort(): void { 239 | console.log(`[QSO] Aborting QSO`); 240 | this.setState(QsoState.FAILED); 241 | this.fail('Aborted by user'); 242 | } 243 | 244 | public getState(): QsoState { 245 | return this.state; 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /src/web/server.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { WebSocketServer, WebSocket } from 'ws'; 3 | import http from 'http'; 4 | import path from 'path'; 5 | import { Config, saveConfig, loadConfig, ConfigChangeLevel } from '../SettingsManager'; 6 | import { WsjtxManager } from '../wsjtx/WsjtxManager'; 7 | import { SliceState, StationsUpdateMessage } from '../wsjtx/types'; 8 | import fs from 'fs'; 9 | 10 | export class WebServer { 11 | private app: express.Application; 12 | private server: http.Server; 13 | private wss: WebSocketServer; 14 | private config: Config; 15 | private wsjtxManager: WsjtxManager; 16 | 17 | constructor(config: Config, wsjtxManager: WsjtxManager) { 18 | this.config = config; 19 | this.wsjtxManager = wsjtxManager; 20 | this.app = express(); 21 | this.server = http.createServer(this.app); 22 | this.wss = new WebSocketServer({ server: this.server }); 23 | 24 | this.setupMiddleware(); 25 | this.setupRoutes(); 26 | this.setupWebSockets(); 27 | } 28 | 29 | private setupMiddleware() { 30 | // Enable CORS for development 31 | this.app.use((req, res, next) => { 32 | res.header('Access-Control-Allow-Origin', '*'); 33 | res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); 34 | res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept'); 35 | if (req.method === 'OPTIONS') { 36 | return res.sendStatus(200); 37 | } 38 | next(); 39 | }); 40 | 41 | this.app.use(express.json()); 42 | // Serve static files from the React frontend app 43 | const frontendPath = path.join(__dirname, '../../frontend/dist'); 44 | this.app.use(express.static(frontendPath)); 45 | } 46 | 47 | private setupRoutes() { 48 | // API: Get status 49 | this.app.get('/api/status', (req, res) => { 50 | res.json({ status: 'ok', mode: this.config.mode }); 51 | }); 52 | 53 | // API: Get current config 54 | this.app.get('/api/config', (req, res) => { 55 | res.json(this.config); 56 | }); 57 | 58 | // API: Update config 59 | this.app.post('/api/config', async (req, res) => { 60 | try { 61 | const result = saveConfig(req.body); 62 | this.config = result.config; 63 | 64 | // Generate appropriate message based on change level 65 | let message: string; 66 | let action: 'none' | 'wsjtx_restart' | 'app_restart' = 'none'; 67 | 68 | if (result.changedFields.length === 0) { 69 | message = 'No changes detected.'; 70 | } else if (result.changeLevel === 'live') { 71 | message = 'Config applied immediately.'; 72 | // Broadcast config update to all WebSocket clients 73 | this.broadcastConfigUpdate(); 74 | } else if (result.changeLevel === 'wsjtx_restart') { 75 | message = 'Config saved. WSJT-X instances will be restarted to apply changes.'; 76 | action = 'wsjtx_restart'; 77 | // Restart WSJT-X instances 78 | await this.wsjtxManager.restartAllInstances(); 79 | } else { 80 | message = 'Config saved. Please restart the server to apply changes (mode, port, or host changed).'; 81 | action = 'app_restart'; 82 | } 83 | 84 | res.json({ 85 | success: true, 86 | config: result.config, 87 | message, 88 | changeLevel: result.changeLevel, 89 | changedFields: result.changedFields, 90 | action 91 | }); 92 | } catch (error) { 93 | res.status(400).json({ success: false, error: String(error) }); 94 | } 95 | }); 96 | 97 | // API: Validate WSJT-X path 98 | this.app.post('/api/validate-path', (req, res) => { 99 | const { path: wsjtxPath } = req.body; 100 | const exists = fs.existsSync(wsjtxPath); 101 | res.json({ valid: exists, path: wsjtxPath }); 102 | }); 103 | 104 | // API: Get instances 105 | this.app.get('/api/instances', (req, res) => { 106 | const instances = this.wsjtxManager.getInstances(); 107 | res.json(instances); 108 | }); 109 | 110 | // API: Start instance 111 | this.app.post('/api/instances/start', (req, res) => { 112 | try { 113 | const { name, band, rigName } = req.body; 114 | this.wsjtxManager.startInstance({ name: name || 'default', band, rigName }); 115 | res.json({ success: true, message: `Started instance: ${name || 'default'}` }); 116 | } catch (error) { 117 | res.status(400).json({ success: false, error: String(error) }); 118 | } 119 | }); 120 | 121 | // API: Stop instance 122 | this.app.post('/api/instances/stop', (req, res) => { 123 | const { name } = req.body; 124 | const success = this.wsjtxManager.stopInstance(name); 125 | res.json({ success, message: success ? `Stopped instance: ${name}` : `Instance not found: ${name}` }); 126 | }); 127 | 128 | // API: Get slice states (decoded stations) 129 | this.app.get('/api/slices', (req, res) => { 130 | const slices = this.wsjtxManager.getSliceStates(); 131 | res.json(slices); 132 | }); 133 | 134 | // API: Execute QSO 135 | this.app.post('/api/qso/execute', (req, res) => { 136 | try { 137 | const { instanceId, targetCallsign, myCallsign, myGrid } = req.body; 138 | this.wsjtxManager.executeQso(instanceId, targetCallsign, myCallsign, myGrid); 139 | res.json({ 140 | success: true, 141 | message: `Started QSO with ${targetCallsign} on ${instanceId}`, 142 | instanceId, 143 | targetCallsign 144 | }); 145 | } catch (error) { 146 | res.status(400).json({ success: false, error: String(error) }); 147 | } 148 | }); 149 | 150 | // Handle React routing, return all requests to React app 151 | this.app.get('/{*splat}', (req, res) => { 152 | const frontendPath = path.join(__dirname, '../../frontend/dist'); 153 | res.sendFile(path.join(frontendPath, 'index.html')); 154 | }); 155 | } 156 | 157 | private setupWebSockets() { 158 | this.wss.on('connection', (ws: WebSocket) => { 159 | console.log('Web Client connected'); 160 | 161 | // Send initial state 162 | ws.send(JSON.stringify({ type: 'WELCOME', message: 'Connected to WSJT-X MCP Server' })); 163 | 164 | // Send current slice states 165 | this.sendStationsUpdate(ws); 166 | 167 | ws.on('message', (message: string) => { 168 | console.log('Received:', message); 169 | // WebSocket messages from dashboard (future expansion) 170 | }); 171 | }); 172 | 173 | // Listen for station updates from WsjtxManager 174 | this.wsjtxManager.on('stations-update', (slices: SliceState[]) => { 175 | this.broadcastStationsUpdate(slices); 176 | }); 177 | } 178 | 179 | private getDefaultDashboardConfig() { 180 | return { 181 | stationLifetimeSeconds: this.config.dashboard?.stationLifetimeSeconds ?? 120, 182 | colors: this.config.dashboard?.colors ?? { 183 | worked: '#6b7280', 184 | normal: '#3b82f6', 185 | weak: '#eab308', 186 | strong: '#22c55e', 187 | priority: '#f97316', 188 | new_dxcc: '#ec4899', 189 | }, 190 | }; 191 | } 192 | 193 | private sendStationsUpdate(ws: WebSocket): void { 194 | const message: StationsUpdateMessage = { 195 | type: 'STATIONS_UPDATE', 196 | slices: this.wsjtxManager.getSliceStates(), 197 | config: this.getDefaultDashboardConfig(), 198 | }; 199 | ws.send(JSON.stringify(message)); 200 | } 201 | 202 | private broadcastStationsUpdate(slices: SliceState[]): void { 203 | const message: StationsUpdateMessage = { 204 | type: 'STATIONS_UPDATE', 205 | slices, 206 | config: this.getDefaultDashboardConfig(), 207 | }; 208 | const json = JSON.stringify(message); 209 | 210 | this.wss.clients.forEach((client) => { 211 | if (client.readyState === WebSocket.OPEN) { 212 | client.send(json); 213 | } 214 | }); 215 | } 216 | 217 | private broadcastConfigUpdate(): void { 218 | const message = { 219 | type: 'CONFIG_UPDATE', 220 | config: this.getDefaultDashboardConfig(), 221 | }; 222 | const json = JSON.stringify(message); 223 | 224 | this.wss.clients.forEach((client) => { 225 | if (client.readyState === WebSocket.OPEN) { 226 | client.send(json); 227 | } 228 | }); 229 | } 230 | 231 | public start() { 232 | const port = this.config.web.port; 233 | this.server.listen(port, () => { 234 | console.log(`Web Dashboard running at http://localhost:${port}`); 235 | }); 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /src/flex/Vita49Client.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import net from 'net'; 3 | 4 | export interface FlexSlice { 5 | id: string; 6 | frequency: number; 7 | mode: string; 8 | active: boolean; 9 | daxChannel?: number; 10 | rxAnt?: string; 11 | } 12 | 13 | export class Vita49Client extends EventEmitter { 14 | private socket: net.Socket | null = null; 15 | private host: string; 16 | private port: number; 17 | private connected: boolean = false; 18 | private slices: Map = new Map(); 19 | private commandSeq: number = 1; 20 | 21 | constructor(host: string = '255.255.255.255', port: number = 4992) { 22 | super(); 23 | this.host = host; 24 | this.port = port; 25 | } 26 | 27 | public async connect(): Promise { 28 | return new Promise((resolve, reject) => { 29 | this.socket = new net.Socket(); 30 | 31 | this.socket.on('connect', () => { 32 | console.log(`Connected to FlexRadio at ${this.host}:${this.port}`); 33 | this.connected = true; 34 | // Subscribe to slice status updates 35 | this.sendCommand('sub slice all'); 36 | // Subscribe to DAX audio updates 37 | this.sendCommand('sub dax all'); 38 | // Request current slice list 39 | this.sendCommand('slice list'); 40 | // Request DAX audio client list 41 | this.sendCommand('dax audio list'); 42 | this.emit('connected'); 43 | resolve(); 44 | }); 45 | 46 | this.socket.on('data', (data) => { 47 | this.handleData(data.toString()); 48 | }); 49 | 50 | this.socket.on('error', (err) => { 51 | console.error('FlexRadio connection error:', err); 52 | this.emit('error', err); 53 | reject(err); 54 | }); 55 | 56 | this.socket.on('close', () => { 57 | console.log('FlexRadio connection closed'); 58 | this.connected = false; 59 | this.emit('disconnected'); 60 | }); 61 | 62 | this.socket.connect(this.port, this.host); 63 | }); 64 | } 65 | 66 | private handleData(data: string): void { 67 | const lines = data.split('\n'); 68 | 69 | for (const line of lines) { 70 | if (!line.trim()) continue; 71 | 72 | // Log all FlexRadio responses for debugging DAX issues 73 | if (line.includes('dax') || line.includes('audio') || line.includes('stream')) { 74 | console.log(`[FlexRadio DAX] ${line}`); 75 | } 76 | 77 | // Parse FlexRadio responses 78 | // Format: S| or status messages 79 | // Also handle R (response) messages 80 | if (line.startsWith('S')) { 81 | const parts = line.substring(1).split('|'); 82 | if (parts.length >= 2) { 83 | this.handleMessage(parts[1].trim()); 84 | } 85 | } else if (line.startsWith('R')) { 86 | // Response to a command - log for debugging 87 | console.log(`[FlexRadio Response] ${line}`); 88 | } 89 | } 90 | } 91 | 92 | private handleMessage(message: string): void { 93 | const parts = message.split(' '); 94 | const command = parts[0]; 95 | 96 | switch (command) { 97 | case 'slice': 98 | this.handleSliceMessage(parts); 99 | break; 100 | default: 101 | // Ignore other messages for now 102 | break; 103 | } 104 | } 105 | 106 | private handleSliceMessage(parts: string[]): void { 107 | // Format: slice = ... 108 | if (parts.length < 2) return; 109 | 110 | const sliceIndex = parts[1]; 111 | const sliceId = `slice_${sliceIndex}`; 112 | 113 | let slice = this.slices.get(sliceId); 114 | const isNew = !slice; 115 | if (!slice) { 116 | slice = { 117 | id: sliceId, 118 | frequency: 0, 119 | mode: '', 120 | active: false, 121 | }; 122 | this.slices.set(sliceId, slice); 123 | } 124 | 125 | const wasActive = slice.active; 126 | let inUseChanged = false; 127 | 128 | // First pass: parse ALL key=value pairs to get complete slice state 129 | for (let i = 2; i < parts.length; i++) { 130 | const [key, value] = parts[i].split('='); 131 | 132 | switch (key) { 133 | case 'RF_frequency': 134 | slice.frequency = parseFloat(value) * 1e6; // Convert MHz to Hz 135 | break; 136 | case 'mode': 137 | slice.mode = value; 138 | break; 139 | case 'in_use': 140 | // in_use indicates slice exists/allocated - this is what we care about 141 | // Note: 'active' field means currently selected/focused slice, which we ignore 142 | slice.active = value === '1'; 143 | if (slice.active !== wasActive) { 144 | inUseChanged = true; 145 | } 146 | break; 147 | case 'dax': 148 | slice.daxChannel = parseInt(value); 149 | break; 150 | case 'rxant': 151 | slice.rxAnt = value; 152 | break; 153 | } 154 | } 155 | 156 | // Second pass: emit events AFTER all fields are parsed 157 | if (inUseChanged) { 158 | if (!wasActive && slice.active) { 159 | console.log(`Slice ${sliceId} activated: ${slice.frequency} Hz, ${slice.mode}`); 160 | this.emit('slice-added', slice); 161 | } else if (wasActive && !slice.active) { 162 | console.log(`Slice ${sliceId} deactivated`); 163 | this.emit('slice-removed', slice); 164 | } 165 | } 166 | 167 | // Emit update event 168 | this.emit('slice-updated', slice); 169 | } 170 | 171 | private sendCommand(command: string): void { 172 | if (!this.socket || !this.connected) { 173 | console.warn('Cannot send command: not connected'); 174 | return; 175 | } 176 | 177 | // FlexRadio API format: C| 178 | const seq = this.commandSeq++; 179 | const fullCommand = `C${seq}|${command}\n`; 180 | console.log(`Sending command: ${fullCommand.trim()}`); 181 | this.socket.write(fullCommand); 182 | } 183 | 184 | public getSlices(): FlexSlice[] { 185 | return Array.from(this.slices.values()).filter(s => s.active); 186 | } 187 | 188 | /** 189 | * Tune a slice to a specific frequency 190 | * @param sliceIndex Slice index (0, 1, 2, ...) 191 | * @param frequencyHz Frequency in Hz 192 | */ 193 | public tuneSlice(sliceIndex: number, frequencyHz: number): void { 194 | // FlexRadio API: slice tune 195 | const freqMhz = (frequencyHz / 1e6).toFixed(6); 196 | this.sendCommand(`slice tune ${sliceIndex} ${freqMhz}`); 197 | } 198 | 199 | /** 200 | * Set the mode for a slice 201 | * @param sliceIndex Slice index (0, 1, 2, ...) 202 | * @param mode Mode string (USB, LSB, DIGU, DIGL, CW, AM, FM, etc.) 203 | */ 204 | public setSliceMode(sliceIndex: number, mode: string): void { 205 | // FlexRadio API: slice set mode= 206 | this.sendCommand(`slice set ${sliceIndex} mode=${mode}`); 207 | } 208 | 209 | /** 210 | * Set PTT (transmit) state for a slice 211 | * @param sliceIndex Slice index (0, 1, 2, ...) 212 | * @param tx True for transmit, false for receive 213 | */ 214 | public setSliceTx(sliceIndex: number, tx: boolean): void { 215 | // FlexRadio API: xmit <0|1> 216 | // Note: FlexRadio has a single transmitter, so this affects the active TX slice 217 | this.sendCommand(`xmit ${tx ? '1' : '0'}`); 218 | } 219 | 220 | /** 221 | * Set the DAX channel for a slice 222 | * @param sliceIndex Slice index (0, 1, 2, ...) 223 | * @param daxChannel DAX channel (1-8), 0 to disable 224 | */ 225 | public setSliceDax(sliceIndex: number, daxChannel: number): void { 226 | // FlexRadio API: slice set dax= 227 | console.log(`Setting DAX channel ${daxChannel} for slice ${sliceIndex}`); 228 | this.sendCommand(`slice set ${sliceIndex} dax=${daxChannel}`); 229 | } 230 | 231 | /** 232 | * Create a DAX RX audio stream for a channel 233 | * This creates the audio stream that will be sent to the DAX audio device 234 | * @param daxChannel DAX channel (1-8) 235 | */ 236 | public createDaxRxAudioStream(daxChannel: number): void { 237 | // FlexRadio API: stream create type=dax_rx dax_channel= 238 | console.log(`Creating DAX RX audio stream for channel ${daxChannel}`); 239 | this.sendCommand(`stream create type=dax_rx dax_channel=${daxChannel}`); 240 | } 241 | 242 | /** 243 | * Create a DAX TX audio stream for transmitting 244 | * @param daxChannel DAX channel (typically 1 for TX) 245 | */ 246 | public createDaxTxAudioStream(daxChannel: number = 1): void { 247 | // FlexRadio API: stream create type=dax_tx 248 | console.log(`Creating DAX TX audio stream`); 249 | this.sendCommand(`stream create type=dax_tx`); 250 | } 251 | 252 | /** 253 | * Request the current DAX audio stream list 254 | */ 255 | public listDaxStreams(): void { 256 | this.sendCommand('stream list'); 257 | } 258 | 259 | public disconnect(): void { 260 | if (this.socket) { 261 | this.socket.destroy(); 262 | this.socket = null; 263 | } 264 | this.connected = false; 265 | } 266 | 267 | public isConnected(): boolean { 268 | return this.connected; 269 | } 270 | } 271 | -------------------------------------------------------------------------------- /frontend/src/components/Settings.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | interface Config { 4 | mode: 'FLEX' | 'STANDARD'; 5 | wsjtx: { 6 | path: string; 7 | }; 8 | station: { 9 | callsign: string; 10 | grid: string; 11 | }; 12 | standard: { 13 | rigName: string; 14 | }; 15 | flex: { 16 | host: string; 17 | catBasePort: number; 18 | }; 19 | } 20 | 21 | interface SettingsProps { 22 | onBack: () => void; 23 | apiBase: string; 24 | } 25 | 26 | export function Settings({ onBack, apiBase }: SettingsProps) { 27 | const [config, setConfig] = useState(null); 28 | const [loading, setLoading] = useState(true); 29 | const [saving, setSaving] = useState(false); 30 | const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); 31 | const [pathValid, setPathValid] = useState(null); 32 | 33 | useEffect(() => { 34 | fetchConfig(); 35 | }, []); 36 | 37 | const fetchConfig = async () => { 38 | try { 39 | const res = await fetch(`${apiBase}/api/config`); 40 | const data = await res.json(); 41 | setConfig(data); 42 | validatePath(data.wsjtx.path); 43 | } catch (error) { 44 | setMessage({ type: 'error', text: 'Failed to load config' }); 45 | } finally { 46 | setLoading(false); 47 | } 48 | }; 49 | 50 | const validatePath = async (path: string) => { 51 | try { 52 | const res = await fetch(`${apiBase}/api/validate-path`, { 53 | method: 'POST', 54 | headers: { 'Content-Type': 'application/json' }, 55 | body: JSON.stringify({ path }), 56 | }); 57 | const data = await res.json(); 58 | setPathValid(data.valid); 59 | } catch { 60 | setPathValid(null); 61 | } 62 | }; 63 | 64 | const handleSave = async () => { 65 | if (!config) return; 66 | setSaving(true); 67 | setMessage(null); 68 | 69 | try { 70 | const res = await fetch(`${apiBase}/api/config`, { 71 | method: 'POST', 72 | headers: { 'Content-Type': 'application/json' }, 73 | body: JSON.stringify(config), 74 | }); 75 | const data = await res.json(); 76 | if (data.success) { 77 | setMessage({ type: 'success', text: data.message }); 78 | } else { 79 | setMessage({ type: 'error', text: data.error }); 80 | } 81 | } catch (error) { 82 | setMessage({ type: 'error', text: 'Failed to save config' }); 83 | } finally { 84 | setSaving(false); 85 | } 86 | }; 87 | 88 | const updateWsjtx = (field: keyof Config['wsjtx'], value: string) => { 89 | if (!config) return; 90 | setConfig({ 91 | ...config, 92 | wsjtx: { ...config.wsjtx, [field]: value }, 93 | }); 94 | if (field === 'path') { 95 | validatePath(value); 96 | } 97 | }; 98 | 99 | const updateStation = (field: keyof Config['station'], value: string) => { 100 | if (!config) return; 101 | setConfig({ 102 | ...config, 103 | station: { ...config.station, [field]: value }, 104 | }); 105 | }; 106 | 107 | const updateStandard = (field: keyof Config['standard'], value: string) => { 108 | if (!config) return; 109 | setConfig({ 110 | ...config, 111 | standard: { ...config.standard, [field]: value }, 112 | }); 113 | }; 114 | 115 | 116 | if (loading) { 117 | return ( 118 |
119 |
Loading configuration...
120 |
121 | ); 122 | } 123 | 124 | if (!config) { 125 | return ( 126 |
127 |
Failed to load configuration
128 |
129 | ); 130 | } 131 | 132 | return ( 133 |
134 | {/* Header */} 135 |
136 |

Settings

137 | 143 |
144 | 145 | {/* Message */} 146 | {message && ( 147 |
154 | {message.text} 155 |
156 | )} 157 | 158 | {/* Operation Mode */} 159 |
160 |

Operation Mode

161 |
162 | 173 | 184 |
185 |

186 | {config.mode === 'STANDARD' 187 | ? 'Manual instance management with direct rig connection' 188 | : 'Automatic instance management based on SmartSDR slices'} 189 |

190 |
191 | 192 | {/* WSJT-X Path (Common) */} 193 |
194 |

WSJT-X Executable

195 |
196 | 197 |
198 | updateWsjtx('path', e.target.value)} 202 | className="flex-1 bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white focus:border-blue-500 focus:outline-none font-mono text-sm" 203 | placeholder="C:\WSJT\wsjtx\bin\wsjtx.exe" 204 | /> 205 |
206 | {pathValid === true && Valid} 207 | {pathValid === false && Not found} 208 | {pathValid === null && Checking...} 209 |
210 |
211 |
212 |
213 | 214 | {/* Station Info (Common) */} 215 |
216 |

Station Information

217 |

Used for autonomous QSO execution

218 |
219 |
220 | 221 | updateStation('callsign', e.target.value.toUpperCase())} 225 | className="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white uppercase focus:border-blue-500 focus:outline-none" 226 | placeholder="W1ABC" 227 | /> 228 |
229 |
230 | 231 | updateStation('grid', e.target.value.toUpperCase())} 235 | className="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white uppercase focus:border-blue-500 focus:outline-none" 236 | placeholder="FN31" 237 | maxLength={6} 238 | /> 239 |
240 |
241 |
242 | 243 | {/* Standard Mode Settings */} 244 | {config.mode === 'STANDARD' && ( 245 |
246 |

Standard Mode Settings

247 |
248 | 249 | updateStandard('rigName', e.target.value)} 253 | className="w-full max-w-xs bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white focus:border-blue-500 focus:outline-none" 254 | placeholder="IC-7300" 255 | /> 256 |

257 | Used as WSJT-X instance identifier (--rig-name parameter) 258 |

259 |
260 |
261 | )} 262 | 263 | {/* Save Button */} 264 |
265 | 271 | 278 |
279 | 280 | {/* Help Text */} 281 |
282 | Changes require a server restart to take effect. 283 |
284 |
285 | ); 286 | } 287 | -------------------------------------------------------------------------------- /src/state/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * State Types for MCP v3 3 | * 4 | * Per FSD v3 §3, §7, §8, §11: 5 | * - ChannelState: per-channel state object 6 | * - McpState: aggregate state for all channels 7 | * - DecodeRecord: parsed decode with channel context 8 | * - QsoRecord: logged QSO with full metadata 9 | * - WorkedEntry: logbook index entry for duplicate detection 10 | */ 11 | 12 | // Channel status enum per FSD §3.1 13 | export type ChannelStatus = 'idle' | 'decoding' | 'calling' | 'in_qso' | 'error' | 'offline'; 14 | 15 | // Digital mode types 16 | export type DigitalMode = 'FT8' | 'FT4' | 'JT65' | 'JT9' | 'WSPR'; 17 | export type RadioMode = 'DIGU' | 'USB' | 'LSB' | 'CW' | 'FM' | 'AM'; 18 | 19 | /** 20 | * Per-channel state object (FSD §3.1) 21 | */ 22 | export interface ChannelState { 23 | // Identity 24 | id: string; // "A", "B", "C", "D" 25 | index: number; // 0-3 26 | instanceName: string; // "Slice-A", etc. 27 | 28 | // Radio state (from FlexRadio or virtual) 29 | freq_hz: number; // Current dial frequency in Hz 30 | mode: string; // Radio mode: DIGU, USB, etc. 31 | band: string; // Derived: "20m", "40m", etc. 32 | 33 | // TX designation 34 | is_tx: boolean; // True if this is the TX slice 35 | 36 | // Audio routing 37 | dax_rx: number | null; // DAX RX channel number (1-4) 38 | dax_tx: number | null; // DAX TX channel (usually shared) 39 | 40 | // Network ports 41 | wsjtx_udp_port: number; // UDP port for this instance (2237-2240) 42 | hrd_port: number; // HRD CAT port for this instance (7809-7812) 43 | 44 | // WSJT-X state (from UDP Status messages) 45 | wsjtx_mode: string | null; // FT8, FT4, etc. 46 | wsjtx_tx_enabled: boolean; // TX enabled in WSJT-X 47 | wsjtx_transmitting: boolean; // Currently transmitting 48 | wsjtx_decoding: boolean; // Currently decoding 49 | wsjtx_rx_df: number; // RX audio frequency offset 50 | wsjtx_tx_df: number; // TX audio frequency offset 51 | 52 | // Status 53 | status: ChannelStatus; // Current channel status 54 | connected: boolean; // WSJT-X instance connected (heartbeats received) 55 | last_heartbeat: number | null; // Timestamp of last heartbeat 56 | last_decode_time: string | null; // ISO8601 of last decode 57 | 58 | // Activity counters 59 | decode_count: number; // Total decodes this session 60 | qso_count: number; // Total QSOs this session 61 | } 62 | 63 | /** 64 | * WSJT-X instance state (FSD §6) 65 | */ 66 | export interface WsjtxInstanceState { 67 | name: string; // Instance name (rig name) 68 | channel_index: number; // Associated channel (0-3) 69 | pid: number | null; // Process ID if running 70 | running: boolean; // Process is running 71 | restart_count: number; // Number of restarts 72 | last_start: number | null; // Timestamp of last start 73 | error: string | null; // Last error message if any 74 | } 75 | 76 | /** 77 | * Logbook index for duplicate detection (FSD §7.4) 78 | */ 79 | export interface WorkedEntry { 80 | call: string; 81 | band: string; // "20m", "40m", etc. 82 | mode: string; // "FT8", "FT4" 83 | last_qso_time: string; // ISO8601 84 | } 85 | 86 | /** 87 | * Logbook index aggregate 88 | */ 89 | export interface LogbookIndex { 90 | entries: Map; // Key: "CALL:BAND:MODE" 91 | total_qsos: number; 92 | last_updated: string | null; // ISO8601 93 | } 94 | 95 | /** 96 | * Aggregate MCP state (FSD §3.1) 97 | */ 98 | export interface McpState { 99 | channels: ChannelState[]; 100 | tx_channel_index: number | null; // Which channel is TX (0-3 or null) 101 | flex_connected: boolean; 102 | wsjtx_instances: WsjtxInstanceState[]; 103 | logbook: LogbookIndex; 104 | config: McpConfig; 105 | } 106 | 107 | /** 108 | * MCP configuration subset exposed in state 109 | */ 110 | export interface McpConfig { 111 | callsign: string; 112 | grid: string; 113 | decode_history_minutes: number; 114 | station_lifetime_seconds: number; 115 | } 116 | 117 | /** 118 | * Station profile for CQ targeting (v7 FSD §7) 119 | */ 120 | export interface StationProfile { 121 | my_call: string; 122 | my_continent: string; // "EU", "NA", "SA", "AF", "AS", "OC", "AN" 123 | my_dxcc: string; // e.g. "HB9" 124 | my_prefixes: string[]; // All known prefixes for this station 125 | // Optional: CQ zone, ITU zone, custom regions 126 | } 127 | 128 | /** 129 | * Internal decode record with channel routing info (v7 FSD §13.2) 130 | * This is used internally by MCP and includes channel/slice details. 131 | */ 132 | export interface InternalDecodeRecord { 133 | // Internal routing fields (not exposed to MCP clients) 134 | channel_index: number; // 0-3 135 | slice_id: string; // "A".."D" 136 | 137 | // Core decode data 138 | timestamp: string; // ISO8601 UTC 139 | band: string; // e.g. "20m", "40m" 140 | mode: string; // FT8, FT4, etc. 141 | 142 | // Frequency info 143 | dial_hz: number; // WSJT-X dial frequency 144 | audio_offset_hz: number; // Delta frequency from decode 145 | rf_hz: number; // dial_hz + audio_offset_hz 146 | 147 | // Signal info 148 | snr_db: number; 149 | dt_sec: number; // Delta time 150 | 151 | // Parsed message 152 | call: string; // Non-null (filtered before creating) 153 | grid: string | null; 154 | is_cq: boolean; 155 | is_my_call: boolean; 156 | raw_text: string; 157 | 158 | // Enriched CQ targeting fields (computed by CQ targeting logic) 159 | is_directed_cq_to_me: boolean; // Server-side decision 160 | cq_target_token: string | null; // "DX", "NA", "EU", "JA", etc. 161 | 162 | // Optional WSJT-X flags 163 | is_new?: boolean; // WSJT-X "new" flag 164 | low_confidence?: boolean; // WSJT-X lowConfidence flag 165 | off_air?: boolean; // WSJT-X offAir flag 166 | } 167 | 168 | /** 169 | * MCP-facing decode record (v7 FSD §2.1) 170 | * This is the canonical type exposed via wsjt-x://decodes resource and events. 171 | * Derived from InternalDecodeRecord by dropping channel_index/slice_id and adding id. 172 | */ 173 | export interface DecodeRecord { 174 | id: string; // Unique within snapshot 175 | 176 | timestamp: string; // ISO8601 UTC 177 | 178 | band: string; // e.g. "20m", "40m" 179 | mode: string; // FT8, FT4 180 | 181 | dial_hz: number; // WSJT-X dial frequency 182 | audio_offset_hz: number; // Audio offset (DF) in Hz 183 | rf_hz: number; // RF frequency (dial_hz + audio_offset_hz) 184 | 185 | snr_db: number; // SNR in dB 186 | dt_sec: number; // Timing offset in seconds 187 | 188 | call: string; // Decoded primary callsign 189 | grid: string | null; // Maidenhead locator or null 190 | 191 | is_cq: boolean; // True if CQ-type message 192 | is_my_call: boolean; // True if addressed to our callsign 193 | 194 | /** 195 | * True if THIS station is allowed to answer this CQ according to 196 | * CQ pattern (CQ DX, CQ NA, etc.) and operator's location. 197 | * Server is authoritative; client MUST NOT reimplement this logic. 198 | */ 199 | is_directed_cq_to_me: boolean; 200 | 201 | /** 202 | * Raw CQ target token extracted from message (informational only). 203 | * Examples: "DX", "NA", "EU", "JA" or null for plain CQ. 204 | */ 205 | cq_target_token: string | null; 206 | 207 | raw_text: string; // Raw WSJT-X decoded message text 208 | 209 | // Optional WSJT-X flags 210 | is_new?: boolean; 211 | low_confidence?: boolean; 212 | off_air?: boolean; 213 | } 214 | 215 | /** 216 | * Snapshot of all current decodes (v7 FSD §2.2) 217 | * This is the canonical representation used in both: 218 | * - wsjt-x://decodes resource 219 | * - resources/updated event payload 220 | */ 221 | export interface DecodesSnapshot { 222 | snapshot_id: string; // Unique ID for this snapshot (e.g. UUID) 223 | generated_at: string; // ISO8601 UTC when snapshot was built 224 | decodes: DecodeRecord[]; // Full decode list exposed to client 225 | } 226 | 227 | /** 228 | * QSO record for logging (FSD §8.1) 229 | */ 230 | export interface QsoRecord { 231 | timestamp_start: string; // ISO8601 232 | timestamp_end: string; // ISO8601 233 | call: string; 234 | grid: string | null; 235 | band: string; 236 | freq_hz: number; 237 | mode: string; // FT8, FT4 238 | rst_sent: string | null; 239 | rst_recv: string | null; 240 | tx_power_w: number | null; 241 | slice_id: string; 242 | channel_index: number; 243 | wsjtx_instance: string; 244 | notes: string | null; 245 | 246 | // Exchange info (for contests, etc.) 247 | exchange_sent: string | null; 248 | exchange_recv: string | null; 249 | } 250 | 251 | /** 252 | * Helper: Convert frequency to band name 253 | */ 254 | export function frequencyToBand(freqHz: number): string { 255 | const freqMhz = freqHz / 1_000_000; 256 | 257 | if (freqMhz >= 1.8 && freqMhz < 2.0) return '160m'; 258 | if (freqMhz >= 3.5 && freqMhz < 4.0) return '80m'; 259 | if (freqMhz >= 5.3 && freqMhz < 5.5) return '60m'; 260 | if (freqMhz >= 7.0 && freqMhz < 7.3) return '40m'; 261 | if (freqMhz >= 10.1 && freqMhz < 10.15) return '30m'; 262 | if (freqMhz >= 14.0 && freqMhz < 14.35) return '20m'; 263 | if (freqMhz >= 18.068 && freqMhz < 18.168) return '17m'; 264 | if (freqMhz >= 21.0 && freqMhz < 21.45) return '15m'; 265 | if (freqMhz >= 24.89 && freqMhz < 24.99) return '12m'; 266 | if (freqMhz >= 28.0 && freqMhz < 29.7) return '10m'; 267 | if (freqMhz >= 50.0 && freqMhz < 54.0) return '6m'; 268 | if (freqMhz >= 144.0 && freqMhz < 148.0) return '2m'; 269 | if (freqMhz >= 420.0 && freqMhz < 450.0) return '70cm'; 270 | 271 | return 'unknown'; 272 | } 273 | 274 | /** 275 | * Helper: Get slice letter from index 276 | */ 277 | export function indexToSliceLetter(index: number): string { 278 | return String.fromCharCode(65 + index); // 0 -> "A", 1 -> "B", etc. 279 | } 280 | 281 | /** 282 | * Helper: Get index from slice letter 283 | */ 284 | export function sliceLetterToIndex(letter: string): number { 285 | return letter.toUpperCase().charCodeAt(0) - 65; // "A" -> 0, "B" -> 1, etc. 286 | } 287 | 288 | /** 289 | * Helper: Create WorkedIndex key 290 | */ 291 | export function workedKey(call: string, band: string, mode: string): string { 292 | return `${call.toUpperCase()}:${band}:${mode.toUpperCase()}`; 293 | } 294 | 295 | /** 296 | * Helper: Create default channel state 297 | */ 298 | export function createDefaultChannelState(index: number): ChannelState { 299 | const id = indexToSliceLetter(index); 300 | return { 301 | id, 302 | index, 303 | instanceName: `Slice-${id}`, 304 | freq_hz: 0, 305 | mode: 'DIGU', 306 | band: 'unknown', 307 | is_tx: index === 0, // Default: first channel is TX 308 | dax_rx: index + 1, 309 | dax_tx: 1, 310 | wsjtx_udp_port: 2237 + index, 311 | hrd_port: 7809 + index, 312 | wsjtx_mode: null, 313 | wsjtx_tx_enabled: false, 314 | wsjtx_transmitting: false, 315 | wsjtx_decoding: false, 316 | wsjtx_rx_df: 0, 317 | wsjtx_tx_df: 0, 318 | status: 'offline', 319 | connected: false, 320 | last_heartbeat: null, 321 | last_decode_time: null, 322 | decode_count: 0, 323 | qso_count: 0, 324 | }; 325 | } 326 | 327 | /** 328 | * Helper: Create default MCP state 329 | */ 330 | export function createDefaultMcpState(config: McpConfig): McpState { 331 | return { 332 | channels: [0, 1, 2, 3].map(createDefaultChannelState), 333 | tx_channel_index: 0, 334 | flex_connected: false, 335 | wsjtx_instances: [], 336 | logbook: { 337 | entries: new Map(), 338 | total_qsos: 0, 339 | last_updated: null, 340 | }, 341 | config, 342 | }; 343 | } 344 | -------------------------------------------------------------------------------- /src/wsjtx/WindowManager.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'child_process'; 2 | import { promisify } from 'util'; 3 | 4 | const execAsync = promisify(exec); 5 | 6 | // WSJT-X uses ~1.46 Hz per bin for FT8 7 | const HZ_PER_BIN = 1.4648; 8 | 9 | // Target frequency range for Wide Graph (0-2500 Hz) 10 | const TARGET_FREQ_RANGE = 2500; 11 | 12 | /** 13 | * Detect primary screen dimensions (Windows, no PowerShell) 14 | * Uses WMIC; falls back to 2560x1440 if detection fails 15 | */ 16 | export async function detectScreenDimensions( 17 | fallback: ScreenDimensions = { width: 1920, height: 1080 } 18 | ): Promise { 19 | try { 20 | const { stdout } = await execAsync( 21 | 'wmic path Win32_VideoController get CurrentHorizontalResolution,CurrentVerticalResolution /value', 22 | { windowsHide: true } 23 | ); 24 | const lines = stdout 25 | .split(/\r?\n/) 26 | .map(l => l.trim()) 27 | .filter(Boolean); 28 | 29 | let width: number | null = null; 30 | let height: number | null = null; 31 | 32 | for (const line of lines) { 33 | if (line.startsWith('CurrentHorizontalResolution=')) { 34 | width = Number(line.split('=')[1]); 35 | } else if (line.startsWith('CurrentVerticalResolution=')) { 36 | height = Number(line.split('=')[1]); 37 | } 38 | } 39 | 40 | if (width && height && Number.isFinite(width) && Number.isFinite(height)) { 41 | return { width, height }; 42 | } 43 | console.warn( 44 | `detectScreenDimensions: unexpected output "${stdout.trim()}", using fallback ${fallback.width}x${fallback.height}` 45 | ); 46 | } catch (error) { 47 | console.warn(`detectScreenDimensions: failed to query screen size, using fallback ${fallback.width}x${fallback.height}`, error); 48 | } 49 | return fallback; 50 | } 51 | 52 | export interface WindowLayout { 53 | mainWindow: { x: number; y: number; width: number; height: number }; 54 | wideGraph: { x: number; y: number; width: number; height: number }; 55 | binsPerPixel: number; // Calculated BinsPerPixel setting for WSJT-X 56 | } 57 | 58 | export interface WindowConfig { 59 | sliceIndex: number; 60 | screenWidth?: number; // Full screen width (default 2560) 61 | screenHeight?: number; // Full screen height (default 1440) 62 | taskbarHeight?: number; // Height reserved for taskbar (default 48) 63 | } 64 | 65 | export interface ScreenDimensions { 66 | width: number; 67 | height: number; 68 | } 69 | 70 | /** 71 | * Calculate window layout for a slice using quadrant-based positioning 72 | * Reserves space for the Windows taskbar at the bottom 73 | * 74 | * Screen divided into 4 quadrants: 75 | * Slice 0: Top-Left | Slice 1: Top-Right 76 | * Slice 2: Bottom-Left | Slice 3: Bottom-Right 77 | * 78 | * Within each quadrant: WideGraph (waterfall) on TOP, Main window BELOW 79 | */ 80 | export function calculateLayout(config: WindowConfig): WindowLayout { 81 | const { 82 | sliceIndex, 83 | screenWidth = 2560, 84 | screenHeight = 1440, 85 | taskbarHeight = 48, // Windows 10/11 taskbar is typically 40-48 pixels 86 | } = config; 87 | 88 | // Calculate usable screen height (excluding taskbar) 89 | const usableHeight = screenHeight - taskbarHeight; 90 | 91 | // Calculate quadrant dimensions (exact half of screen width, half of usable height) 92 | const quadrantWidth = Math.floor(screenWidth / 2); 93 | const quadrantHeight = Math.floor(usableHeight / 2); 94 | 95 | // Map slice index to quadrant position 96 | // 0 = top-left, 1 = top-right, 2 = bottom-left, 3 = bottom-right 97 | const col = sliceIndex % 2; // 0 = left, 1 = right 98 | const row = Math.floor(sliceIndex / 2); // 0 = top, 1 = bottom 99 | 100 | // Calculate quadrant origin (no padding - starts at edge) 101 | const quadrantX = col * quadrantWidth; 102 | const quadrantY = row * quadrantHeight; 103 | 104 | // Window dimensions within quadrant (full width) 105 | const windowWidth = quadrantWidth; 106 | // Split quadrant height: ~35% for waterfall, ~65% for main window (no gap) 107 | const wideGraphHeight = Math.floor(quadrantHeight * 0.35); 108 | const mainWindowHeight = quadrantHeight - wideGraphHeight; 109 | 110 | // Calculate BinsPerPixel to show TARGET_FREQ_RANGE (2500 Hz) in the window width 111 | // Hz per pixel needed = targetFreq / width 112 | // BinsPerPixel = hzPerPixel / HZ_PER_BIN 113 | // Fixed to 3 per user request 114 | const binsPerPixel = 3; 115 | 116 | return { 117 | // WideGraph (waterfall) on TOP of quadrant 118 | wideGraph: { 119 | x: quadrantX, 120 | y: quadrantY, 121 | width: windowWidth, 122 | height: wideGraphHeight, 123 | }, 124 | // Main window BELOW the waterfall (directly adjacent, no gap) 125 | mainWindow: { 126 | x: quadrantX, 127 | y: quadrantY + wideGraphHeight, 128 | width: windowWidth, 129 | height: mainWindowHeight, 130 | }, 131 | binsPerPixel, 132 | }; 133 | } 134 | 135 | /** 136 | * Move a window that matches BOTH patterns (for identifying specific Wide Graph windows) 137 | */ 138 | async function moveWindowByTwoPatterns( 139 | pattern1: string, 140 | pattern2: string, 141 | x: number, 142 | y: number, 143 | width: number, 144 | height: number 145 | ): Promise { 146 | const script = ` 147 | $code = @' 148 | using System; 149 | using System.Runtime.InteropServices; 150 | using System.Text; 151 | 152 | public class Win32Window2 { 153 | [DllImport("user32.dll", SetLastError = true)] 154 | public static extern bool MoveWindow(IntPtr hWnd, int X, int Y, int nWidth, int nHeight, bool bRepaint); 155 | 156 | public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); 157 | 158 | [DllImport("user32.dll")] 159 | public static extern bool EnumWindows(EnumWindowsProc enumProc, IntPtr lParam); 160 | 161 | [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)] 162 | public static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount); 163 | 164 | [DllImport("user32.dll")] 165 | public static extern bool IsWindowVisible(IntPtr hWnd); 166 | } 167 | '@ 168 | 169 | Add-Type -TypeDefinition $code -Language CSharp -ErrorAction SilentlyContinue 170 | 171 | $pattern1 = "${pattern1}" 172 | $pattern2 = "${pattern2}" 173 | $found = $false 174 | 175 | $callback = { 176 | param([IntPtr]$hWnd, [IntPtr]$lParam) 177 | 178 | if ([Win32Window2]::IsWindowVisible($hWnd)) { 179 | $sb = New-Object System.Text.StringBuilder 256 180 | [Win32Window2]::GetWindowText($hWnd, $sb, 256) | Out-Null 181 | $title = $sb.ToString() 182 | 183 | # Match windows containing BOTH patterns 184 | if (($title -like "*$pattern1*") -and ($title -like "*$pattern2*")) { 185 | [Win32Window2]::MoveWindow($hWnd, ${x}, ${y}, ${width}, ${height}, $true) | Out-Null 186 | $script:found = $true 187 | } 188 | } 189 | return $true 190 | } 191 | 192 | [Win32Window2]::EnumWindows($callback, [IntPtr]::Zero) | Out-Null 193 | 194 | if ($script:found) { "Moved: $pattern1 + $pattern2" } else { "Not found: $pattern1 + $pattern2" } 195 | `; 196 | 197 | try { 198 | const encodedCommand = Buffer.from(script, 'utf16le').toString('base64'); 199 | const { stdout } = await execAsync(`powershell -NoProfile -EncodedCommand ${encodedCommand}`, { 200 | windowsHide: true, 201 | }); 202 | console.log(` Window move result: ${stdout.trim()}`); 203 | return stdout.includes('Moved:'); 204 | } catch (error) { 205 | console.error(` Failed to move window "${pattern1} + ${pattern2}":`, error); 206 | return false; 207 | } 208 | } 209 | 210 | /** 211 | * Move a window by its title using PowerShell -EncodedCommand 212 | * This avoids escaping issues with here-strings 213 | */ 214 | async function moveWindow( 215 | windowTitle: string, 216 | x: number, 217 | y: number, 218 | width: number, 219 | height: number 220 | ): Promise { 221 | // PowerShell script using inline C# without here-string 222 | const script = ` 223 | $code = @' 224 | using System; 225 | using System.Runtime.InteropServices; 226 | using System.Text; 227 | 228 | public class Win32Window { 229 | [DllImport("user32.dll", SetLastError = true)] 230 | public static extern bool MoveWindow(IntPtr hWnd, int X, int Y, int nWidth, int nHeight, bool bRepaint); 231 | 232 | public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); 233 | 234 | [DllImport("user32.dll")] 235 | public static extern bool EnumWindows(EnumWindowsProc enumProc, IntPtr lParam); 236 | 237 | [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)] 238 | public static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount); 239 | 240 | [DllImport("user32.dll")] 241 | public static extern bool IsWindowVisible(IntPtr hWnd); 242 | } 243 | '@ 244 | 245 | Add-Type -TypeDefinition $code -Language CSharp -ErrorAction SilentlyContinue 246 | 247 | $targetTitle = "${windowTitle}" 248 | $found = $false 249 | 250 | $callback = { 251 | param([IntPtr]$hWnd, [IntPtr]$lParam) 252 | 253 | if ([Win32Window]::IsWindowVisible($hWnd)) { 254 | $sb = New-Object System.Text.StringBuilder 256 255 | [Win32Window]::GetWindowText($hWnd, $sb, 256) | Out-Null 256 | $title = $sb.ToString() 257 | 258 | if ($title -like "*$targetTitle*") { 259 | [Win32Window]::MoveWindow($hWnd, ${x}, ${y}, ${width}, ${height}, $true) | Out-Null 260 | $script:found = $true 261 | } 262 | } 263 | return $true 264 | } 265 | 266 | [Win32Window]::EnumWindows($callback, [IntPtr]::Zero) | Out-Null 267 | 268 | if ($script:found) { "Moved: $targetTitle" } else { "Not found: $targetTitle" } 269 | `; 270 | 271 | try { 272 | // Encode the script as Base64 for -EncodedCommand 273 | const encodedCommand = Buffer.from(script, 'utf16le').toString('base64'); 274 | const { stdout } = await execAsync(`powershell -NoProfile -EncodedCommand ${encodedCommand}`, { 275 | windowsHide: true, 276 | }); 277 | console.log(` Window move result: ${stdout.trim()}`); 278 | return stdout.includes('Moved:'); 279 | } catch (error) { 280 | console.error(` Failed to move window "${windowTitle}":`, error); 281 | return false; 282 | } 283 | } 284 | 285 | /** 286 | * Position WSJT-X windows for a specific instance 287 | * @param rigName The rig name used for the instance 288 | * @param sliceIndex The slice index (0=A, 1=B, etc.) 289 | * @param retries Number of retries (windows may not be ready immediately) 290 | */ 291 | export async function positionWsjtxWindows( 292 | rigName: string, 293 | sliceIndex: number, 294 | retries: number = 5 295 | ): Promise { 296 | const layout = calculateLayout({ sliceIndex }); 297 | 298 | console.log(`\nPositioning WSJT-X windows for ${rigName} (Slice ${sliceIndex}):`); 299 | console.log(` Main window: ${layout.mainWindow.x},${layout.mainWindow.y} (${layout.mainWindow.width}x${layout.mainWindow.height})`); 300 | console.log(` Wide Graph: ${layout.wideGraph.x},${layout.wideGraph.y} (${layout.wideGraph.width}x${layout.wideGraph.height})`); 301 | 302 | // Wait a bit for windows to be created 303 | await new Promise(resolve => setTimeout(resolve, 2000)); 304 | 305 | for (let attempt = 1; attempt <= retries; attempt++) { 306 | console.log(` Attempt ${attempt}/${retries}...`); 307 | 308 | // WSJT-X main window title format: "WSJT-X v2.x.x by K1JT et al. - rigName" 309 | // or just "WSJT-X" with rigName in title 310 | const mainMoved = await moveWindow( 311 | rigName, 312 | layout.mainWindow.x, 313 | layout.mainWindow.y, 314 | layout.mainWindow.width, 315 | layout.mainWindow.height 316 | ); 317 | 318 | // Wide Graph window title includes rigName: "Wide Graph - rigName" 319 | const wideGraphMoved = await moveWindowByTwoPatterns( 320 | 'Wide Graph', 321 | rigName, 322 | layout.wideGraph.x, 323 | layout.wideGraph.y, 324 | layout.wideGraph.width, 325 | layout.wideGraph.height 326 | ); 327 | 328 | if (mainMoved && wideGraphMoved) { 329 | console.log(` Windows positioned successfully!`); 330 | return; 331 | } 332 | 333 | // Wait before retry 334 | if (attempt < retries) { 335 | await new Promise(resolve => setTimeout(resolve, 1500)); 336 | } 337 | } 338 | 339 | console.log(` Warning: Could not position all windows after ${retries} attempts`); 340 | } 341 | 342 | /** 343 | * Position all WSJT-X instances based on a slice mapping 344 | */ 345 | export async function positionAllWindows( 346 | sliceMapping: Map 347 | ): Promise { 348 | for (const [sliceId, info] of sliceMapping) { 349 | await positionWsjtxWindows(info.rigName, info.sliceIndex); 350 | } 351 | } 352 | -------------------------------------------------------------------------------- /src/dashboard/DashboardManager.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * DashboardManager - Station tracking and dashboard state for web UI 3 | * 4 | * Per FSD v3: 5 | * - Tracks decoded stations per slice/channel 6 | * - Computes station status (worked, strong, weak, etc.) 7 | * - Provides real-time updates to web dashboard 8 | * - Handles station lifetime and cleanup 9 | * 10 | * This replaces the StationTracker from src/wsjtx/ for cleaner separation. 11 | */ 12 | 13 | import { EventEmitter } from 'events'; 14 | import { Config } from '../SettingsManager'; 15 | import { LogbookManager } from '../logbook'; 16 | 17 | // === Types === 18 | 19 | export type StationStatus = 'worked' | 'normal' | 'weak' | 'strong' | 'priority' | 'new_dxcc'; 20 | 21 | export interface TrackedStation { 22 | callsign: string; 23 | grid: string; 24 | snr: number; 25 | frequency: number; // Audio offset 26 | mode: string; 27 | lastSeen: number; // Timestamp 28 | firstSeen: number; // Timestamp 29 | decodeCount: number; 30 | status: StationStatus; 31 | message: string; // Last decoded message 32 | } 33 | 34 | export interface SliceState { 35 | id: string; 36 | name: string; 37 | band: string; 38 | mode: string; 39 | dialFrequency: number; 40 | stations: TrackedStation[]; 41 | isTransmitting: boolean; 42 | txEnabled: boolean; 43 | } 44 | 45 | export interface WsjtxDecode { 46 | id: string; 47 | newDecode: boolean; 48 | time: number; 49 | snr: number; 50 | deltaTime: number; 51 | deltaFrequency: number; 52 | mode: string; 53 | message: string; 54 | lowConfidence: boolean; 55 | offAir: boolean; 56 | } 57 | 58 | export interface WsjtxStatus { 59 | id: string; 60 | dialFrequency: number; 61 | mode: string; 62 | dxCall: string; 63 | report: string; 64 | txMode: string; 65 | txEnabled: boolean; 66 | transmitting: boolean; 67 | decoding: boolean; 68 | rxDF: number; 69 | txDF: number; 70 | } 71 | 72 | export interface DashboardManagerConfig { 73 | stationLifetimeSeconds?: number; // How long to show stations after last decode 74 | snrWeakThreshold?: number; // SNR below this = weak 75 | snrStrongThreshold?: number; // SNR above this = strong 76 | colors?: { 77 | worked?: string; 78 | normal?: string; 79 | weak?: string; 80 | strong?: string; 81 | priority?: string; 82 | new_dxcc?: string; 83 | }; 84 | } 85 | 86 | // === Helpers === 87 | 88 | function extractCallsign(message: string): string | null { 89 | const parts = message.trim().split(/\s+/); 90 | 91 | // Skip CQ messages - extract the calling station 92 | if (parts[0] === 'CQ') { 93 | if (parts.length >= 3) { 94 | const potentialCall = parts[1].length <= 3 ? parts[2] : parts[1]; 95 | if (isValidCallsign(potentialCall)) { 96 | return potentialCall; 97 | } 98 | } 99 | return null; 100 | } 101 | 102 | // For other messages, first part is usually a callsign 103 | if (parts.length >= 1 && isValidCallsign(parts[0])) { 104 | return parts[0]; 105 | } 106 | 107 | // Try second part 108 | if (parts.length >= 2 && isValidCallsign(parts[1])) { 109 | return parts[1]; 110 | } 111 | 112 | return null; 113 | } 114 | 115 | function extractGrid(message: string): string { 116 | const parts = message.trim().split(/\s+/); 117 | const gridPattern = /^[A-R]{2}[0-9]{2}([a-x]{2})?$/i; 118 | 119 | for (const part of parts) { 120 | if (gridPattern.test(part)) { 121 | return part.toUpperCase(); 122 | } 123 | } 124 | 125 | return ''; 126 | } 127 | 128 | function isValidCallsign(str: string): boolean { 129 | if (!str || str.length < 3 || str.length > 10) return false; 130 | const callPattern = /^[A-Z0-9]{1,3}[0-9][A-Z]{1,4}(\/[A-Z0-9]+)?$/i; 131 | return callPattern.test(str); 132 | } 133 | 134 | function frequencyToBand(freqHz: number): string { 135 | const freqMhz = freqHz / 1_000_000; 136 | 137 | if (freqMhz >= 1.8 && freqMhz < 2.0) return '160m'; 138 | if (freqMhz >= 3.5 && freqMhz < 4.0) return '80m'; 139 | if (freqMhz >= 5.3 && freqMhz < 5.5) return '60m'; 140 | if (freqMhz >= 7.0 && freqMhz < 7.3) return '40m'; 141 | if (freqMhz >= 10.1 && freqMhz < 10.15) return '30m'; 142 | if (freqMhz >= 14.0 && freqMhz < 14.35) return '20m'; 143 | if (freqMhz >= 18.068 && freqMhz < 18.168) return '17m'; 144 | if (freqMhz >= 21.0 && freqMhz < 21.45) return '15m'; 145 | if (freqMhz >= 24.89 && freqMhz < 24.99) return '12m'; 146 | if (freqMhz >= 28.0 && freqMhz < 29.7) return '10m'; 147 | if (freqMhz >= 50.0 && freqMhz < 54.0) return '6m'; 148 | if (freqMhz >= 144.0 && freqMhz < 148.0) return '2m'; 149 | if (freqMhz >= 420.0 && freqMhz < 450.0) return '70cm'; 150 | 151 | return `${freqMhz.toFixed(3)} MHz`; 152 | } 153 | 154 | // === Internal Types === 155 | 156 | interface SliceData { 157 | id: string; 158 | name: string; 159 | mode: string; 160 | dialFrequency: number; 161 | isTransmitting: boolean; 162 | txEnabled: boolean; 163 | stations: Map; 164 | } 165 | 166 | // === DashboardManager Class === 167 | 168 | export class DashboardManager extends EventEmitter { 169 | private config: DashboardManagerConfig; 170 | private slices: Map = new Map(); 171 | private logbookManager: LogbookManager | null = null; 172 | private cleanupInterval: NodeJS.Timeout | null = null; 173 | 174 | constructor(config?: DashboardManagerConfig) { 175 | super(); 176 | this.config = { 177 | stationLifetimeSeconds: 120, 178 | snrWeakThreshold: -15, 179 | snrStrongThreshold: 0, 180 | ...config, 181 | }; 182 | 183 | // Start periodic cleanup of expired stations 184 | this.startCleanup(); 185 | } 186 | 187 | /** 188 | * Set logbook manager for duplicate checking 189 | */ 190 | public setLogbookManager(logbookManager: LogbookManager): void { 191 | this.logbookManager = logbookManager; 192 | } 193 | 194 | /** 195 | * Update configuration 196 | */ 197 | public updateConfig(config: DashboardManagerConfig): void { 198 | this.config = { ...this.config, ...config }; 199 | } 200 | 201 | // === Decode/Status Handling === 202 | 203 | public handleDecode(decode: WsjtxDecode): void { 204 | const callsign = extractCallsign(decode.message); 205 | if (!callsign) return; 206 | 207 | // Get or create slice data 208 | let slice = this.slices.get(decode.id); 209 | if (!slice) { 210 | slice = { 211 | id: decode.id, 212 | name: decode.id, 213 | mode: decode.mode, 214 | dialFrequency: 0, 215 | isTransmitting: false, 216 | txEnabled: false, 217 | stations: new Map(), 218 | }; 219 | this.slices.set(decode.id, slice); 220 | } 221 | 222 | // Update mode from decode 223 | slice.mode = decode.mode; 224 | 225 | const now = Date.now(); 226 | const existing = slice.stations.get(callsign); 227 | 228 | // Compute station status 229 | const status = this.computeStatus(callsign, decode.snr, slice.dialFrequency, slice.mode); 230 | 231 | if (existing) { 232 | // Update existing station 233 | existing.snr = decode.snr; 234 | existing.frequency = decode.deltaFrequency; 235 | existing.lastSeen = now; 236 | existing.decodeCount++; 237 | existing.status = status; 238 | existing.message = decode.message; 239 | 240 | const grid = extractGrid(decode.message); 241 | if (grid) { 242 | existing.grid = grid; 243 | } 244 | } else { 245 | // Add new station 246 | const station: TrackedStation = { 247 | callsign, 248 | grid: extractGrid(decode.message), 249 | snr: decode.snr, 250 | frequency: decode.deltaFrequency, 251 | mode: decode.mode, 252 | lastSeen: now, 253 | firstSeen: now, 254 | decodeCount: 1, 255 | status, 256 | message: decode.message, 257 | }; 258 | slice.stations.set(callsign, station); 259 | } 260 | 261 | this.emitUpdate(); 262 | } 263 | 264 | public handleStatus(status: WsjtxStatus): void { 265 | let slice = this.slices.get(status.id); 266 | if (!slice) { 267 | slice = { 268 | id: status.id, 269 | name: status.id, 270 | mode: status.mode, 271 | dialFrequency: status.dialFrequency, 272 | isTransmitting: status.transmitting, 273 | txEnabled: status.txEnabled, 274 | stations: new Map(), 275 | }; 276 | this.slices.set(status.id, slice); 277 | } else { 278 | slice.mode = status.mode; 279 | slice.dialFrequency = status.dialFrequency; 280 | slice.isTransmitting = status.transmitting; 281 | slice.txEnabled = status.txEnabled; 282 | } 283 | 284 | // Re-compute status for all stations when frequency changes 285 | for (const station of slice.stations.values()) { 286 | station.status = this.computeStatus( 287 | station.callsign, 288 | station.snr, 289 | slice.dialFrequency, 290 | slice.mode 291 | ); 292 | } 293 | 294 | this.emitUpdate(); 295 | } 296 | 297 | // === Status Computation === 298 | 299 | private computeStatus(callsign: string, snr: number, dialFrequency: number, mode: string): StationStatus { 300 | // 1. Check if already worked 301 | const band = frequencyToBand(dialFrequency); 302 | if (this.logbookManager?.isWorked(callsign, band, mode)) { 303 | return 'worked'; 304 | } 305 | 306 | // 2. Contest priority (placeholder) 307 | // TODO: Implement contest rules engine 308 | 309 | // 3. New DXCC (placeholder) 310 | // TODO: Implement DXCC lookup 311 | 312 | // 4. Signal strength 313 | if (snr >= (this.config.snrStrongThreshold ?? 0)) { 314 | return 'strong'; 315 | } 316 | if (snr <= (this.config.snrWeakThreshold ?? -15)) { 317 | return 'weak'; 318 | } 319 | 320 | // 5. Default 321 | return 'normal'; 322 | } 323 | 324 | // === State Access === 325 | 326 | public getSliceStates(): SliceState[] { 327 | const states: SliceState[] = []; 328 | 329 | for (const slice of this.slices.values()) { 330 | const stations = Array.from(slice.stations.values()) 331 | .sort((a, b) => b.lastSeen - a.lastSeen); 332 | 333 | states.push({ 334 | id: slice.id, 335 | name: slice.name, 336 | band: frequencyToBand(slice.dialFrequency), 337 | mode: slice.mode, 338 | dialFrequency: slice.dialFrequency, 339 | stations, 340 | isTransmitting: slice.isTransmitting, 341 | txEnabled: slice.txEnabled, 342 | }); 343 | } 344 | 345 | return states; 346 | } 347 | 348 | public getStationCount(): number { 349 | let count = 0; 350 | for (const slice of this.slices.values()) { 351 | count += slice.stations.size; 352 | } 353 | return count; 354 | } 355 | 356 | public getColors(): Record { 357 | return { 358 | worked: this.config.colors?.worked ?? '#6b7280', 359 | normal: this.config.colors?.normal ?? '#3b82f6', 360 | weak: this.config.colors?.weak ?? '#eab308', 361 | strong: this.config.colors?.strong ?? '#22c55e', 362 | priority: this.config.colors?.priority ?? '#f97316', 363 | new_dxcc: this.config.colors?.new_dxcc ?? '#ec4899', 364 | }; 365 | } 366 | 367 | // === Cleanup === 368 | 369 | private startCleanup(): void { 370 | this.cleanupInterval = setInterval(() => { 371 | this.cleanupExpiredStations(); 372 | }, 10000); 373 | } 374 | 375 | private cleanupExpiredStations(): void { 376 | const now = Date.now(); 377 | const lifetimeMs = (this.config.stationLifetimeSeconds ?? 120) * 1000; 378 | let changed = false; 379 | 380 | for (const slice of this.slices.values()) { 381 | for (const [callsign, station] of slice.stations) { 382 | if (now - station.lastSeen > lifetimeMs) { 383 | slice.stations.delete(callsign); 384 | changed = true; 385 | } 386 | } 387 | } 388 | 389 | if (changed) { 390 | this.emitUpdate(); 391 | } 392 | } 393 | 394 | private emitUpdate(): void { 395 | this.emit('update', this.getSliceStates()); 396 | } 397 | 398 | // === Lifecycle === 399 | 400 | public stop(): void { 401 | if (this.cleanupInterval) { 402 | clearInterval(this.cleanupInterval); 403 | this.cleanupInterval = null; 404 | } 405 | console.log('[Dashboard] Stopped'); 406 | } 407 | } 408 | -------------------------------------------------------------------------------- /src/wsjtx/UdpSender.ts: -------------------------------------------------------------------------------- 1 | import dgram from 'dgram'; 2 | import { WsjtxMessageType } from './types'; 3 | 4 | // Maximum quint32 value - signals "no change" for numeric fields in Configure message 5 | const NO_CHANGE = 0xFFFFFFFF; 6 | 7 | export class UdpSender { 8 | private socket: dgram.Socket; 9 | private targetPort: number; 10 | private targetHost: string; 11 | 12 | constructor(port: number = 2237, host: string = 'localhost') { 13 | this.targetPort = port; 14 | this.targetHost = host; 15 | this.socket = dgram.createSocket('udp4'); 16 | } 17 | 18 | /** 19 | * Write a QString (Qt UTF-16BE string) to a buffer 20 | * Empty string = 0xFFFFFFFF length (null QString) 21 | */ 22 | private writeQString(buffer: Buffer, offset: number, value: string): number { 23 | if (!value || value.length === 0) { 24 | buffer.writeUInt32BE(0xffffffff, offset); 25 | return offset + 4; 26 | } 27 | 28 | // Qt QString uses UTF-16BE, Node uses UTF-16LE, so we need to swap 29 | const utf16Buffer = Buffer.from(value, 'utf16le'); 30 | // Swap bytes to convert to UTF-16BE 31 | for (let i = 0; i < utf16Buffer.length; i += 2) { 32 | const temp = utf16Buffer[i]; 33 | utf16Buffer[i] = utf16Buffer[i + 1]; 34 | utf16Buffer[i + 1] = temp; 35 | } 36 | buffer.writeUInt32BE(utf16Buffer.length, offset); 37 | offset += 4; 38 | utf16Buffer.copy(buffer, offset); 39 | return offset + utf16Buffer.length; 40 | } 41 | 42 | private createHeader(messageType: number, id: string): Buffer { 43 | const buffers: Buffer[] = []; 44 | 45 | // Magic number 46 | const magic = Buffer.alloc(4); 47 | magic.writeUInt32BE(0xadbccbda, 0); 48 | buffers.push(magic); 49 | 50 | // Schema version 51 | const schema = Buffer.alloc(4); 52 | schema.writeUInt32BE(2, 0); 53 | buffers.push(schema); 54 | 55 | // Message type 56 | const type = Buffer.alloc(4); 57 | type.writeUInt32BE(messageType, 0); 58 | buffers.push(type); 59 | 60 | // ID (QString) 61 | const idBuffer = Buffer.alloc(4 + Buffer.from(id, 'utf16le').length); 62 | this.writeQString(idBuffer, 0, id); 63 | buffers.push(idBuffer); 64 | 65 | return Buffer.concat(buffers); 66 | } 67 | 68 | public sendReply(id: string, time: number, snr: number, deltaTime: number, deltaFrequency: number, mode: string, message: string): void { 69 | const header = this.createHeader(4, id); // Reply = 4 70 | 71 | const body = Buffer.alloc(1000); // Allocate enough space 72 | let offset = 0; 73 | 74 | // Time (quint32) 75 | body.writeUInt32BE(time, offset); 76 | offset += 4; 77 | 78 | // SNR (qint32) 79 | body.writeInt32BE(snr, offset); 80 | offset += 4; 81 | 82 | // Delta time (double) 83 | body.writeDoubleBE(deltaTime, offset); 84 | offset += 8; 85 | 86 | // Delta frequency (quint32) 87 | body.writeUInt32BE(deltaFrequency, offset); 88 | offset += 4; 89 | 90 | // Mode (QString) 91 | offset = this.writeQString(body, offset, mode); 92 | 93 | // Message (QString) 94 | offset = this.writeQString(body, offset, message); 95 | 96 | // Low confidence (bool) 97 | body.writeUInt8(0, offset); 98 | offset += 1; 99 | 100 | // Modifiers (quint8) - 0x02 = Shift modifier (enables TX) 101 | body.writeUInt8(0x02, offset); 102 | offset += 1; 103 | 104 | const packet = Buffer.concat([header, body.slice(0, offset)]); 105 | this.send(packet); 106 | } 107 | 108 | public sendHaltTx(id: string, autoTxOnly: boolean = true): void { 109 | const header = this.createHeader(8, id); // HaltTx = 8 110 | 111 | const body = Buffer.alloc(1); 112 | body.writeUInt8(autoTxOnly ? 1 : 0, 0); 113 | 114 | const packet = Buffer.concat([header, body]); 115 | this.send(packet); 116 | } 117 | 118 | public sendFreeText(id: string, text: string, send: boolean = false): void { 119 | const header = this.createHeader(WsjtxMessageType.FREE_TEXT, id); 120 | 121 | const body = Buffer.alloc(1000); 122 | let offset = 0; 123 | 124 | offset = this.writeQString(body, offset, text); 125 | body.writeUInt8(send ? 1 : 0, offset); 126 | offset += 1; 127 | 128 | const packet = Buffer.concat([header, body.slice(0, offset)]); 129 | this.send(packet); 130 | console.log(`[UDP] Sent FreeText to ${id}: "${text}" (send=${send}) on port ${this.targetPort}`); 131 | } 132 | 133 | /** 134 | * Configure WSJT-X mode and settings 135 | * Empty strings or NO_CHANGE values mean "don't change" 136 | */ 137 | public sendConfigure( 138 | id: string, 139 | options: { 140 | mode?: string; // e.g., "FT8", "FT4" 141 | frequencyTolerance?: number; 142 | submode?: string; 143 | fastMode?: boolean; 144 | trPeriod?: number; // T/R period in seconds 145 | rxDF?: number; // RX audio frequency offset 146 | dxCall?: string; 147 | dxGrid?: string; 148 | generateMessages?: boolean; 149 | } 150 | ): void { 151 | const header = this.createHeader(WsjtxMessageType.CONFIGURE, id); 152 | 153 | const body = Buffer.alloc(2000); 154 | let offset = 0; 155 | 156 | // Mode (QString) - empty = no change 157 | offset = this.writeQString(body, offset, options.mode || ''); 158 | 159 | // Frequency Tolerance (quint32) - max = no change 160 | body.writeUInt32BE(options.frequencyTolerance ?? NO_CHANGE, offset); 161 | offset += 4; 162 | 163 | // Submode (QString) 164 | offset = this.writeQString(body, offset, options.submode || ''); 165 | 166 | // Fast Mode (bool) 167 | body.writeUInt8(options.fastMode ? 1 : 0, offset); 168 | offset += 1; 169 | 170 | // T/R Period (quint32) 171 | body.writeUInt32BE(options.trPeriod ?? NO_CHANGE, offset); 172 | offset += 4; 173 | 174 | // Rx DF (quint32) - max = no change 175 | body.writeUInt32BE(options.rxDF ?? NO_CHANGE, offset); 176 | offset += 4; 177 | 178 | // DX Call (QString) 179 | offset = this.writeQString(body, offset, options.dxCall || ''); 180 | 181 | // DX Grid (QString) 182 | offset = this.writeQString(body, offset, options.dxGrid || ''); 183 | 184 | // Generate Messages (bool) 185 | body.writeUInt8(options.generateMessages ? 1 : 0, offset); 186 | offset += 1; 187 | 188 | const packet = Buffer.concat([header, body.slice(0, offset)]); 189 | this.send(packet); 190 | 191 | const optionsSummary = Object.entries(options) 192 | .filter(([_, v]) => v !== undefined && v !== '') 193 | .map(([k, v]) => `${k}=${v}`) 194 | .join(', '); 195 | console.log(`[UDP] Sent Configure to ${id}: ${optionsSummary} on port ${this.targetPort}`); 196 | } 197 | 198 | /** 199 | * Switch to a named configuration profile in WSJT-X 200 | */ 201 | public sendSwitchConfiguration(id: string, configurationName: string): void { 202 | const header = this.createHeader(WsjtxMessageType.SWITCH_CONFIGURATION, id); 203 | 204 | const body = Buffer.alloc(500); 205 | const offset = this.writeQString(body, 0, configurationName); 206 | 207 | const packet = Buffer.concat([header, body.slice(0, offset)]); 208 | this.send(packet); 209 | } 210 | 211 | /** 212 | * Clear decode windows 213 | * window: 0 = Band Activity, 1 = Rx Frequency, 2 = Both 214 | */ 215 | public sendClear(id: string, window: 0 | 1 | 2 = 2): void { 216 | const header = this.createHeader(WsjtxMessageType.CLEAR, id); 217 | 218 | const body = Buffer.alloc(1); 219 | body.writeUInt8(window, 0); 220 | 221 | const packet = Buffer.concat([header, body]); 222 | this.send(packet); 223 | } 224 | 225 | /** 226 | * Set the station's Maidenhead grid location 227 | */ 228 | public sendLocation(id: string, grid: string): void { 229 | const header = this.createHeader(WsjtxMessageType.LOCATION, id); 230 | 231 | const body = Buffer.alloc(100); 232 | const offset = this.writeQString(body, 0, grid); 233 | 234 | const packet = Buffer.concat([header, body.slice(0, offset)]); 235 | this.send(packet); 236 | } 237 | 238 | /** 239 | * Set dial frequency in WSJT-X (Rig Control Command) 240 | * This will tune WSJT-X to the specified frequency, which will then 241 | * command the radio via CAT. Band changes automatically if frequency 242 | * is on a different band. 243 | * 244 | * Note: The Rig Control message format is simpler than other messages - 245 | * it does NOT include the instance ID in the header. 246 | * 247 | * Format: magic(4) + schema(4) + type(4) + frequency(8) + mode(QString) 248 | * 249 | * @param id - Instance ID (rig name) - used only for logging, not sent 250 | * @param frequencyHz - Dial frequency in Hz (e.g., 14074000 for 20m FT8) 251 | * @param mode - Optional mode to set (e.g., "USB", "DIGU") 252 | */ 253 | public sendSetFrequency(id: string, frequencyHz: number, mode?: string): void { 254 | // Rig Control message does NOT include instance ID - simpler format 255 | const buffers: Buffer[] = []; 256 | 257 | // Magic number 258 | const magic = Buffer.alloc(4); 259 | magic.writeUInt32BE(0xadbccbda, 0); 260 | buffers.push(magic); 261 | 262 | // Schema version 263 | const schema = Buffer.alloc(4); 264 | schema.writeUInt32BE(2, 0); 265 | buffers.push(schema); 266 | 267 | // Message type (12 = Rig Control) 268 | const type = Buffer.alloc(4); 269 | type.writeUInt32BE(WsjtxMessageType.RIG_CONTROL, 0); 270 | buffers.push(type); 271 | 272 | // Frequency (qint64 - 8 bytes, signed 64-bit integer) 273 | const freq = Buffer.alloc(8); 274 | freq.writeBigInt64BE(BigInt(frequencyHz), 0); 275 | buffers.push(freq); 276 | 277 | // Mode (QString) - empty = don't change mode 278 | const modeStr = mode || ''; 279 | const modeBuffer = Buffer.alloc(500); 280 | const modeLen = this.writeQString(modeBuffer, 0, modeStr); 281 | buffers.push(modeBuffer.slice(0, modeLen)); 282 | 283 | const packet = Buffer.concat(buffers); 284 | this.send(packet); 285 | 286 | console.log(`[UDP] Sent SetFrequency: ${frequencyHz} Hz${mode ? `, mode=${mode}` : ''} to port ${this.targetPort}`); 287 | } 288 | 289 | /** 290 | * Send Close message to gracefully shut down WSJT-X instance 291 | * Message Type 6 - requests WSJT-X to close gracefully 292 | */ 293 | public sendClose(id: string): void { 294 | const header = this.createHeader(WsjtxMessageType.CLOSE, id); 295 | this.send(header); 296 | console.log(`[UDP] Sent Close message to ${id} on port ${this.targetPort}`); 297 | } 298 | 299 | /** 300 | * Highlight a callsign in the WSJT-X band activity window 301 | */ 302 | public sendHighlightCallsign( 303 | id: string, 304 | callsign: string, 305 | backgroundColor: { r: number; g: number; b: number; a?: number }, 306 | foregroundColor: { r: number; g: number; b: number; a?: number }, 307 | highlightLast: boolean = true 308 | ): void { 309 | const header = this.createHeader(WsjtxMessageType.HIGHLIGHT_CALLSIGN, id); 310 | 311 | const body = Buffer.alloc(500); 312 | let offset = 0; 313 | 314 | // Callsign 315 | offset = this.writeQString(body, offset, callsign); 316 | 317 | // Background color (QColor - ARGB format) 318 | // Qt QColor in QDataStream: 1 byte spec (1=RGB), then 4 x quint16 for RGBA 319 | body.writeUInt8(1, offset); // color spec = RGB 320 | offset += 1; 321 | body.writeUInt16BE(backgroundColor.a ?? 255, offset); 322 | offset += 2; 323 | body.writeUInt16BE(backgroundColor.r, offset); 324 | offset += 2; 325 | body.writeUInt16BE(backgroundColor.g, offset); 326 | offset += 2; 327 | body.writeUInt16BE(backgroundColor.b, offset); 328 | offset += 2; 329 | body.writeUInt16BE(0, offset); // padding 330 | offset += 2; 331 | 332 | // Foreground color 333 | body.writeUInt8(1, offset); 334 | offset += 1; 335 | body.writeUInt16BE(foregroundColor.a ?? 255, offset); 336 | offset += 2; 337 | body.writeUInt16BE(foregroundColor.r, offset); 338 | offset += 2; 339 | body.writeUInt16BE(foregroundColor.g, offset); 340 | offset += 2; 341 | body.writeUInt16BE(foregroundColor.b, offset); 342 | offset += 2; 343 | body.writeUInt16BE(0, offset); 344 | offset += 2; 345 | 346 | // Highlight last (bool) 347 | body.writeUInt8(highlightLast ? 1 : 0, offset); 348 | offset += 1; 349 | 350 | const packet = Buffer.concat([header, body.slice(0, offset)]); 351 | this.send(packet); 352 | } 353 | 354 | private send(packet: Buffer): void { 355 | this.socket.send(packet, this.targetPort, this.targetHost, (err) => { 356 | if (err) { 357 | console.error('UDP send error:', err); 358 | } 359 | }); 360 | } 361 | 362 | public setTarget(port: number, host: string = 'localhost'): void { 363 | this.targetPort = port; 364 | this.targetHost = host; 365 | } 366 | 367 | public close(): void { 368 | this.socket.close(); 369 | } 370 | } 371 | --------------------------------------------------------------------------------