front-end
This commit is contained in:
45
RotRouter.js
45
RotRouter.js
@@ -1,45 +0,0 @@
|
||||
import RotClient from './RotClient.js';
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import fs from 'node:fs';
|
||||
|
||||
export default class RotRouter {
|
||||
constructor() {
|
||||
this.clients = [];
|
||||
this.loadConfig();
|
||||
|
||||
this.xmlParser = new XMLParser();
|
||||
}
|
||||
|
||||
processN1mmXml(xml) {
|
||||
try {
|
||||
const msg = this.xmlParser.parse(xml);
|
||||
const band = parseInt(msg?.N1MMRotor?.freqband.split(',')[0] || 0, 10);
|
||||
const az = parseInt(msg?.N1MMRotor?.goazi.split(',')[0] || 0, 10);
|
||||
if (!band || !az) {
|
||||
console.error(`Missing band or azimuth`);
|
||||
return;
|
||||
}
|
||||
const client = this.findClientForBand(band);
|
||||
if (!client) {
|
||||
console.error(`No RotClient found for the ${band} band!`);
|
||||
return;
|
||||
}
|
||||
|
||||
client.turn(az);
|
||||
} catch (ex) {
|
||||
console.error(`Failed to parse: ${xml}`, ex);
|
||||
}
|
||||
}
|
||||
|
||||
loadConfig() {
|
||||
const raw = fs.readFileSync("config.json", 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
for (const [label, clientConfig] of Object.entries(parsed)) {
|
||||
this.clients[label] = new RotClient(label, clientConfig);
|
||||
}
|
||||
}
|
||||
|
||||
findClientForBand(band) {
|
||||
return Object.values(this.clients).find(c => c.hasBand(band)) || null;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"rot14": {
|
||||
"ip": "192.168.0.82",
|
||||
"ip": "192.168.33.82",
|
||||
"bands": [
|
||||
14
|
||||
]
|
||||
|
||||
26
index.js
26
index.js
@@ -1,26 +0,0 @@
|
||||
import dgram from 'node:dgram';
|
||||
import RotRouter from './RotRouter.js';
|
||||
|
||||
const server = dgram.createSocket('udp4');
|
||||
|
||||
server.on('error', (err) => {
|
||||
console.error(`server error:\n${err.stack}`);
|
||||
server.close();
|
||||
});
|
||||
server.on('listening', () => {
|
||||
const address = server.address();
|
||||
console.log(`server listening ${address.address}:${address.port}`);
|
||||
});
|
||||
|
||||
|
||||
const router = new RotRouter();
|
||||
|
||||
server.on('message', (msg, rinfo) => {
|
||||
console.log(`server got: ${msg} from ${rinfo.address}:${rinfo.port}`);
|
||||
router.processN1mmXml(msg);
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
server.bind(12040);
|
||||
63
package-lock.json
generated
63
package-lock.json
generated
@@ -9,7 +9,38 @@
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"fast-xml-parser": "^5.3.5"
|
||||
"@hono/node-server": "^1.19.11",
|
||||
"@hono/node-ws": "^1.3.0",
|
||||
"fast-xml-parser": "^5.3.5",
|
||||
"hono": "^4.12.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.11",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
|
||||
"integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"hono": "^4"
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-ws": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-ws/-/node-ws-1.3.0.tgz",
|
||||
"integrity": "sha512-ju25YbbvLuXdqBCmLZLqnNYu1nbHIQjoyUqA8ApZOeL1k4skuiTcw5SW77/5SUYo2Xi2NVBJoVlfQurnKEp03Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ws": "^8.17.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@hono/node-server": "^1.19.2",
|
||||
"hono": "^4.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
@@ -30,6 +61,15 @@
|
||||
"fxparser": "src/cli/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.8",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.8.tgz",
|
||||
"integrity": "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strnum": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
|
||||
@@ -41,6 +81,27 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,15 @@
|
||||
"license": "ISC",
|
||||
"author": "ericek111",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"start": "node src/index.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"dependencies": {
|
||||
"fast-xml-parser": "^5.3.5"
|
||||
"@hono/node-server": "^1.19.11",
|
||||
"@hono/node-ws": "^1.3.0",
|
||||
"fast-xml-parser": "^5.3.5",
|
||||
"hono": "^4.12.8"
|
||||
}
|
||||
}
|
||||
|
||||
504
public/index.html
Normal file
504
public/index.html
Normal file
@@ -0,0 +1,504 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title id="AntNameTitle">IP rotator</title>
|
||||
<link href="https://fonts.googleapis.com/css?family=Roboto+Condensed:300italic,400italic,700italic,400,700,300&subset=latin-ext" rel="stylesheet" type="text/css">
|
||||
<style>
|
||||
:root {
|
||||
--box-size: 600px;
|
||||
}
|
||||
html, body {
|
||||
font-family: 'Roboto Condensed', sans-serif, Arial, Tahoma, Verdana;
|
||||
background-color: #000;
|
||||
color: #ccc;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.container {
|
||||
width: var(--box-size);
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
}
|
||||
canvas {
|
||||
display: block;
|
||||
cursor: crosshair;
|
||||
}
|
||||
.info-panel {
|
||||
width: var(--box-size);
|
||||
margin-top: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.status-bar {
|
||||
font-size: 25px;
|
||||
margin: 0;
|
||||
}
|
||||
.status-wrapper {
|
||||
color: #000;
|
||||
background: #666;
|
||||
padding: 4px 6px;
|
||||
border-radius: 5px;
|
||||
display: inline-block;
|
||||
}
|
||||
.text-white { color: #fff; }
|
||||
.text-bold { font-weight: bold; }
|
||||
.text-red { color: red; }
|
||||
.text-muted { color: #666; font-size: 73%; }
|
||||
a:hover { color: #fff; }
|
||||
a { color: #ccc; text-decoration: underline; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="container">
|
||||
<canvas id="MainCanvas" width="600" height="600"></canvas>
|
||||
|
||||
<div class="info-panel">
|
||||
<p class="status-bar">
|
||||
<span class="status-wrapper">
|
||||
<span id="AntName" class="text-white">Loading...</span> | POE
|
||||
<span id="ADCValue" class="text-white text-bold">0</span> V |
|
||||
raw <span id="AZValue" class="text-bold">0</span>° |
|
||||
<a href="/set" onclick="window.open(this.href, 'setup', 'width=700,height=1350,menubar=no,location=no,status=no'); return false;">SETUP</a>
|
||||
</span>
|
||||
<br>
|
||||
<span id="MacAddress" class="text-muted"></span>
|
||||
<span id="OnlineStatus" class="text-muted"></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
class RotatorState {
|
||||
constructor() {
|
||||
this.boxSize = 600;
|
||||
this.center = this.boxSize / 2;
|
||||
this.azShift = 0;
|
||||
this.azRange = 360;
|
||||
this.azimuth = 0;
|
||||
this.antRadiationAngle = 0;
|
||||
this.status = 4;
|
||||
this.mapUrl = "";
|
||||
this.elevation = 0;
|
||||
this.mac = "";
|
||||
this.antName = "";
|
||||
this.adc = 0;
|
||||
this.lastSeen = 0;
|
||||
}
|
||||
}
|
||||
|
||||
class RotatorAPI {
|
||||
static async fetchText(endpoint) {
|
||||
try {
|
||||
const res = await fetch(`/${endpoint}`);
|
||||
if (!res.ok) throw new Error('Network response was not ok');
|
||||
return await res.text();
|
||||
} catch (e) {
|
||||
console.warn(`Failed to fetch ${endpoint}`, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static async sendTarget(azTarget) {
|
||||
try {
|
||||
const res = await fetch('/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-type': 'application/x-www-form-urlencoded' },
|
||||
body: `ROT=${azTarget}`
|
||||
});
|
||||
if (res.ok) alert(await res.text());
|
||||
} catch (e) {
|
||||
console.error("Failed to send target rotation", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RotatorRenderer {
|
||||
constructor(canvasId, state) {
|
||||
this.canvas = document.getElementById(canvasId);
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
this.state = state;
|
||||
|
||||
// Create offscreen canvas for caching static background layers
|
||||
this.bgCanvas = document.createElement('canvas');
|
||||
this.bgCanvas.width = state.boxSize;
|
||||
this.bgCanvas.height = state.boxSize;
|
||||
this.bgCtx = this.bgCanvas.getContext('2d');
|
||||
|
||||
this.mapImage = new Image();
|
||||
this.mapImage.onload = () => this.drawBackgroundCache();
|
||||
}
|
||||
|
||||
getPoint(angle, radius) {
|
||||
const rad = angle * Math.PI / 180;
|
||||
return {
|
||||
x: this.state.center + Math.sin(rad) * radius,
|
||||
y: this.state.center - Math.cos(rad) * radius
|
||||
};
|
||||
}
|
||||
|
||||
updateMap(url) {
|
||||
if (url && this.mapImage.src !== new URL(url, document.baseURI).href) {
|
||||
this.mapImage.src = url;
|
||||
} else {
|
||||
this.drawBackgroundCache();
|
||||
}
|
||||
}
|
||||
|
||||
drawBackgroundCache() {
|
||||
const ctx = this.bgCtx;
|
||||
const sz = this.state.boxSize;
|
||||
const cx = this.state.center;
|
||||
const cy = this.state.center;
|
||||
|
||||
ctx.clearRect(0, 0, sz, sz);
|
||||
|
||||
// 1. Draw Direction Lines (formerly StaticBot)
|
||||
ctx.beginPath();
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeStyle = '#606060';
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const startRadius = (i % 2 === 0) ? sz / 2 * 0.1 : sz / 2 * 0.3;
|
||||
const pStart = this.getPoint(i * 15, startRadius);
|
||||
const pEnd = this.getPoint(i * 15, sz / 2 * 0.9);
|
||||
ctx.moveTo(pStart.x, pStart.y);
|
||||
ctx.lineTo(pEnd.x, pEnd.y);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// 2. Draw Map Image
|
||||
if (this.mapImage.complete && this.mapImage.naturalHeight !== 0) {
|
||||
ctx.drawImage(this.mapImage, 0, 0, sz, sz);
|
||||
}
|
||||
|
||||
// 3. Draw Static Overlays (Compass, Dark Zones)
|
||||
ctx.lineWidth = 8;
|
||||
ctx.strokeStyle = 'orange';
|
||||
if (this.state.azRange > 360) {
|
||||
const startRad = (270 + this.state.azShift) * Math.PI / 180;
|
||||
const stopRad = (270 + this.state.azShift + this.state.azRange - 360) * Math.PI / 180;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, sz / 2 * 0.9 - 8, startRad, stopRad);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
const startArc = this.state.elevation === 0 ? 0 : Math.PI;
|
||||
ctx.arc(cx, cy, sz / 2 * 0.9, startArc, 2 * Math.PI);
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeStyle = '#c0c0c0';
|
||||
ctx.font = "20px Arial";
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillStyle = "#808080";
|
||||
|
||||
const maxTick = this.state.elevation === 0 ? 35 : 18;
|
||||
const offset = this.state.elevation === 0 ? 0 : 270;
|
||||
|
||||
for (let i = 0; i <= maxTick; i++) {
|
||||
const angle = i * 10 + offset;
|
||||
if (i % 9 !== 0) {
|
||||
const p1 = this.getPoint(angle, sz / 2 * 0.95);
|
||||
const p2 = this.getPoint(angle, sz / 2 * 0.9);
|
||||
ctx.moveTo(p1.x, p1.y);
|
||||
ctx.lineTo(p2.x, p2.y);
|
||||
}
|
||||
if (i % 3 === 0 && i % 9 !== 0) {
|
||||
const pt = this.getPoint(angle, sz / 2);
|
||||
ctx.fillText(i * 10, pt.x, pt.y);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.state.elevation === 0) {
|
||||
ctx.fillStyle = "white";
|
||||
ctx.fillText("N", cx, sz * 0.025);
|
||||
ctx.fillText("E", sz * 0.975, cy);
|
||||
ctx.fillText("S", cx, sz * 0.975);
|
||||
ctx.fillText("W", sz * 0.025, cy);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Dark zone logic
|
||||
if (this.state.azRange < 360) {
|
||||
ctx.beginPath();
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeStyle = 'white';
|
||||
const p1 = this.getPoint(this.state.azShift, sz / 2 * 0.9);
|
||||
const p2 = this.getPoint(this.state.azShift + this.state.azRange, sz / 2 * 0.9);
|
||||
ctx.moveTo(cx, cy); ctx.lineTo(p1.x, p1.y);
|
||||
ctx.moveTo(cx, cy); ctx.lineTo(p2.x, p2.y);
|
||||
ctx.stroke();
|
||||
|
||||
if (this.state.azRange + this.state.antRadiationAngle < 360) {
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = "rgba(0, 0, 0, 0.5)";
|
||||
const darkRange = 360 - this.state.azRange - this.state.antRadiationAngle;
|
||||
const steps = (darkRange - 20) / 10;
|
||||
const startAngle = this.state.azShift + this.state.azRange + this.state.antRadiationAngle / 2;
|
||||
|
||||
const startPt = this.getPoint(startAngle, sz / 2 * 0.9);
|
||||
ctx.moveTo(cx, cy);
|
||||
ctx.lineTo(startPt.x, startPt.y);
|
||||
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const pt = this.getPoint(startAngle + 10 * (i + 1), sz / 2 * 0.9);
|
||||
ctx.lineTo(pt.x, pt.y);
|
||||
}
|
||||
const endPt = this.getPoint(this.state.azShift - this.state.antRadiationAngle / 2, sz / 2 * 0.9);
|
||||
ctx.lineTo(endPt.x, endPt.y);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
// Instantly trigger a main render after caching
|
||||
this.render();
|
||||
}
|
||||
|
||||
render() {
|
||||
const ctx = this.ctx;
|
||||
const sz = this.state.boxSize;
|
||||
const cx = this.state.center;
|
||||
const cy = this.state.center;
|
||||
const { azShift, azRange, azimuth, status, antRadiationAngle, targetAngle } = this.state;
|
||||
|
||||
// 1. Clear and composite background
|
||||
ctx.clearRect(0, 0, sz, sz);
|
||||
ctx.drawImage(this.bgCanvas, 0, 0);
|
||||
|
||||
// 2. Draw Target Line
|
||||
ctx.beginPath();
|
||||
const mappedAzimuth = azimuth > 360 ? azimuth - 360 : azimuth;
|
||||
const mappedTarget = (targetAngle + azShift) > 360 ? (targetAngle + azShift - 360) : (targetAngle + azShift);
|
||||
|
||||
if (targetAngle != null && Math.abs(mappedTarget - mappedAzimuth) > antRadiationAngle / 2) {
|
||||
const pt = this.getPoint(targetAngle, sz / 2 * 0.9);
|
||||
ctx.moveTo(cx, cy);
|
||||
ctx.lineTo(pt.x, pt.y);
|
||||
}
|
||||
|
||||
// 3. Draw Pointer
|
||||
ctx.lineWidth = 5;
|
||||
const isOutOfRange = azimuth < 0 || azimuth > azRange;
|
||||
if (isOutOfRange) ctx.strokeStyle = '#c0c0c0';
|
||||
else if (status !== 4) ctx.strokeStyle = "red";
|
||||
else if (azimuth > 359) ctx.strokeStyle = "orange";
|
||||
else ctx.strokeStyle = '#00aa00';
|
||||
|
||||
const displayAngle = azimuth + azShift;
|
||||
const pStart = this.getPoint(displayAngle, sz / 2 * 0.9);
|
||||
const pEnd = this.getPoint(displayAngle, sz / 2 * 0.7);
|
||||
|
||||
ctx.moveTo(pStart.x, pStart.y);
|
||||
ctx.lineTo(pEnd.x, pEnd.y);
|
||||
ctx.stroke();
|
||||
|
||||
// Arrow for CW/CCW
|
||||
if (status !== 4) {
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = "red";
|
||||
const offset = status < 4 ? -6 : 6;
|
||||
|
||||
const a1 = this.getPoint(displayAngle + offset / 2, sz / 2 * 0.85);
|
||||
const a2 = this.getPoint(displayAngle + offset / 2, sz / 2 * 0.75);
|
||||
const a3 = this.getPoint(displayAngle + offset * 1.3, sz / 2 * 0.8);
|
||||
ctx.moveTo(a1.x, a1.y); ctx.lineTo(a2.x, a2.y); ctx.lineTo(a3.x, a3.y);
|
||||
|
||||
const b1 = this.getPoint(displayAngle + offset * 1.5, sz / 2 * 0.83);
|
||||
const b2 = this.getPoint(displayAngle + offset * 1.5, sz / 2 * 0.77);
|
||||
const b3 = this.getPoint(displayAngle + offset * 2, sz / 2 * 0.8);
|
||||
ctx.moveTo(b1.x, b1.y); ctx.lineTo(b2.x, b2.y); ctx.lineTo(b3.x, b3.y);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// 4. Draw Values Text
|
||||
ctx.font = "bold 100px Arial";
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
let textAngle = this.state.elevation === 0 ? azimuth + azShift : azimuth;
|
||||
if (textAngle > 359) textAngle -= 360;
|
||||
|
||||
const textLoc = this.getPoint(displayAngle + 180, sz * 0.2);
|
||||
|
||||
if (isOutOfRange) {
|
||||
ctx.font = "bold 30px Arial";
|
||||
ctx.fillStyle = '#c0c0c0';
|
||||
ctx.fillText(azimuth < 0 ? "CCW endstop zone" : "CW endstop zone", textLoc.x, textLoc.y);
|
||||
} else {
|
||||
ctx.fillStyle = "black";
|
||||
ctx.fillText(`${textAngle}\u00B0`, textLoc.x + 3, textLoc.y + 3); // shadow
|
||||
|
||||
if (status !== 4) ctx.fillStyle = "red";
|
||||
else if (azimuth > 359) ctx.fillStyle = "orange";
|
||||
else ctx.fillStyle = "green";
|
||||
|
||||
ctx.fillText(`${textAngle}\u00B0`, textLoc.x, textLoc.y);
|
||||
}
|
||||
|
||||
// 5. Radiation Beam Cone
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, cy);
|
||||
const ccwAngle = antRadiationAngle / 2;
|
||||
for (let i = 0; i <= 16; i++) {
|
||||
const pt = this.getPoint(displayAngle - ccwAngle + (antRadiationAngle / 16) * i, sz / 2 * 0.9);
|
||||
ctx.lineTo(pt.x, pt.y);
|
||||
}
|
||||
|
||||
if (isOutOfRange) ctx.fillStyle = "rgba(255, 255, 255, 0.10)";
|
||||
else if (status !== 4) ctx.fillStyle = "rgba(255, 0, 0, 0.25)";
|
||||
else if (azimuth > 359) ctx.fillStyle = "rgba(255, 165, 0, 0.25)";
|
||||
else ctx.fillStyle = "rgba(255, 255, 255, 0.25)";
|
||||
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
class RotatorUI {
|
||||
constructor(state) {
|
||||
this.state = state;
|
||||
this.els = {
|
||||
title: document.getElementById('AntNameTitle'),
|
||||
name: document.getElementById('AntName'),
|
||||
mac: document.getElementById('MacAddress'),
|
||||
adc: document.getElementById('ADCValue'),
|
||||
az: document.getElementById('AZValue'),
|
||||
online: document.getElementById('OnlineStatus')
|
||||
};
|
||||
}
|
||||
|
||||
updateStatic() {
|
||||
this.els.title.innerText = this.state.antName || "IP rotator";
|
||||
this.els.name.innerText = this.state.antName;
|
||||
this.els.mac.innerText = this.state.mac;
|
||||
}
|
||||
|
||||
updateDynamic() {
|
||||
const adcFormatted = Math.round(this.state.adc * 10) / 10;
|
||||
if (this.state.adc < 11.5) {
|
||||
this.els.adc.innerHTML = `<span class="text-red">${adcFormatted}</span>`;
|
||||
} else {
|
||||
this.els.adc.innerText = adcFormatted;
|
||||
}
|
||||
|
||||
this.els.az.innerText = this.state.azimuth;
|
||||
|
||||
const isOffline = (Date.now() - this.state.lastSeen) > 1500;
|
||||
this.els.online.innerHTML = isOffline
|
||||
? " | <span class=\"text-red\">•</span> Offline"
|
||||
: " | <span class=\"text-white\">•</span> Connected";
|
||||
}
|
||||
}
|
||||
|
||||
class RotatorApp {
|
||||
constructor() {
|
||||
this.state = new RotatorState();
|
||||
this.ui = new RotatorUI(this.state);
|
||||
this.renderer = new RotatorRenderer('MainCanvas', this.state);
|
||||
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
this.renderer.canvas.addEventListener("click", (evt) => {
|
||||
const rect = this.renderer.canvas.getBoundingClientRect();
|
||||
const x = evt.clientX - rect.left;
|
||||
const y = evt.clientY - rect.top;
|
||||
|
||||
let angle = Math.atan2(this.state.center - y, x - this.state.center) * 180 / Math.PI;
|
||||
angle = Math.round((90 - angle + 360) % 360); // Simplify calculation for canvas math
|
||||
|
||||
this.state.targetAngle = angle;
|
||||
RotatorAPI.sendTarget(angle);
|
||||
this.renderer.render(); // Instantly show user click target
|
||||
});
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
// Sequential fetch to protect embedded web servers from connection exhaustion
|
||||
const endpoints = [
|
||||
{ key: 'azShift', url: 'readStart', isNum: true },
|
||||
{ key: 'azRange', url: 'readMax', isNum: true },
|
||||
{ key: 'antRadiationAngle', url: 'readAnt', isNum: true },
|
||||
{ key: 'antName', url: 'readAntName', isNum: false },
|
||||
{ key: 'mapUrl', url: 'readMapUrl', isNum: false },
|
||||
{ key: 'mac', url: 'readMAC', isNum: false },
|
||||
{ key: 'elevation', url: 'readElevation', isNum: true }
|
||||
];
|
||||
|
||||
for (let conf of endpoints) {
|
||||
const val = await RotatorAPI.fetchText(conf.url);
|
||||
if (val !== null) {
|
||||
this.state[conf.key] = conf.isNum ? Number(val) : val;
|
||||
}
|
||||
}
|
||||
|
||||
this.ui.updateStatic();
|
||||
this.renderer.updateMap(this.state.mapUrl);
|
||||
|
||||
// Start timers
|
||||
// this.startLoops();
|
||||
this.startWs();
|
||||
}
|
||||
|
||||
startWs() {
|
||||
const websocket = new WebSocket(document.location.href.replace(/\/?$/, '/') + "ws");
|
||||
websocket.addEventListener("message", (e) => {
|
||||
if (!e || !e.data)
|
||||
return;
|
||||
const data = JSON.parse(e.data);
|
||||
if (!data)
|
||||
return;
|
||||
|
||||
console.log(`RECEIVED: `, data);
|
||||
});
|
||||
setInterval(() => {
|
||||
websocket.send(JSON.stringify({ ping: 1 }));
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
async pollData() {
|
||||
const adc = await RotatorAPI.fetchText('readADC');
|
||||
const az = await RotatorAPI.fetchText('readAZ');
|
||||
const stat = await RotatorAPI.fetchText('readStat');
|
||||
|
||||
if (adc !== null && az !== null && stat !== null) {
|
||||
const numAz = Number(az);
|
||||
const numStat = Number(stat);
|
||||
|
||||
let needsRender = false;
|
||||
if (this.state.azimuth !== numAz || this.state.status !== numStat) {
|
||||
needsRender = true;
|
||||
}
|
||||
|
||||
this.state.adc = Number(adc);
|
||||
this.state.azimuth = numAz;
|
||||
this.state.status = numStat;
|
||||
this.state.lastSeen = Date.now();
|
||||
|
||||
if (needsRender) this.renderer.render();
|
||||
}
|
||||
this.ui.updateDynamic();
|
||||
}
|
||||
|
||||
startLoops() {
|
||||
// Data polling
|
||||
setInterval(() => this.pollData(), 500);
|
||||
// Force UI status check (offline state handling)
|
||||
setInterval(() => this.ui.updateDynamic(), 2000);
|
||||
// Map refresh
|
||||
setInterval(() => this.renderer.updateMap(this.state.mapUrl), 600000);
|
||||
|
||||
// Trigger first poll instantly
|
||||
this.pollData();
|
||||
}
|
||||
}
|
||||
|
||||
// Bootstrap
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const app = new RotatorApp();
|
||||
app.initialize();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
50
src/N1mmServer.js
Normal file
50
src/N1mmServer.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import dgram from 'node:dgram';
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
|
||||
export default class N1mmServer {
|
||||
|
||||
constructor() {
|
||||
this.xmlParser = new XMLParser();
|
||||
this.turnHandlers = []; // TurnEventHandler
|
||||
}
|
||||
|
||||
start() {
|
||||
const server = dgram.createSocket('udp4');
|
||||
|
||||
server.on('error', (err) => {
|
||||
console.error(`server error:\n${err.stack}`);
|
||||
server.close();
|
||||
});
|
||||
server.on('listening', () => {
|
||||
const address = server.address();
|
||||
console.log(`server listening ${address.address}:${address.port}`);
|
||||
});
|
||||
server.on('message', this._onUdpMessage.bind(this));
|
||||
server.bind(12040);
|
||||
}
|
||||
|
||||
_onUdpMessage(msg, rinfo) {
|
||||
console.log(`server got: ${msg} from ${rinfo.address}:${rinfo.port}`);
|
||||
this.processN1mmXml(msg);
|
||||
}
|
||||
|
||||
onTurnMessage(handler) {
|
||||
this.turnHandlers.push(handler);
|
||||
}
|
||||
|
||||
processN1mmXml(xml) {
|
||||
try {
|
||||
const msg = this.xmlParser.parse(xml);
|
||||
const band = parseInt(msg?.N1MMRotor?.freqband.split(',')[0] || 0, 10);
|
||||
const az = parseInt(msg?.N1MMRotor?.goazi.split(',')[0] || 0, 10);
|
||||
if (!band || !az) {
|
||||
console.error(`Missing band or azimuth`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.turnHandlers.forEach(h => h.handleTurnMessage({ band, az }));
|
||||
} catch (ex) {
|
||||
console.error(`Failed to parse: ${xml}`, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
* Implementation of a simple client for simple_rotator_interface_v -- https://remoteqth.com/w/doku.php?id=simple_rotator_interface_v
|
||||
*/
|
||||
export default class RotClient {
|
||||
|
||||
constructor(label, clientConfig) {
|
||||
this.label = label;
|
||||
this.ip = clientConfig.ip;
|
||||
33
src/RotRouter.js
Normal file
33
src/RotRouter.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import RotClient from './RotClient.js';
|
||||
import fs from 'node:fs';
|
||||
|
||||
export default class RotRouter { // implements TurnEventHandler
|
||||
constructor() {
|
||||
this.clients = [];
|
||||
this.loadConfig();
|
||||
|
||||
}
|
||||
|
||||
loadConfig() {
|
||||
const raw = fs.readFileSync("config.json", 'utf-8'); // TODO: Is the path correct?
|
||||
const parsed = JSON.parse(raw);
|
||||
for (const [label, clientConfig] of Object.entries(parsed)) {
|
||||
this.clients[label] = new RotClient(label, clientConfig);
|
||||
}
|
||||
}
|
||||
|
||||
findClientForBand(band) {
|
||||
return Object.values(this.clients).find(c => c.hasBand(band)) || null;
|
||||
}
|
||||
|
||||
handleTurnMessage(msg) { // msg : { band: 14, az: 321 }
|
||||
const client = this.findClientForBand(msg.band);
|
||||
if (!client) {
|
||||
console.error(`No RotClient found for the ${msg.band} band!`);
|
||||
return;
|
||||
}
|
||||
|
||||
client.turn(msg.az);
|
||||
}
|
||||
|
||||
}
|
||||
63
src/WebsocketManager.js
Normal file
63
src/WebsocketManager.js
Normal file
@@ -0,0 +1,63 @@
|
||||
import { createNodeWebSocket } from '@hono/node-ws'
|
||||
import {serve} from "@hono/node-server";
|
||||
|
||||
export default class WebsocketManager {
|
||||
|
||||
constructor() {
|
||||
this.clients = [];
|
||||
this.lastPing = {};
|
||||
|
||||
setInterval(() => {
|
||||
const thr = Date.now() - 60000;
|
||||
this.clients.filter(client => this.lastPing[client] < thr).forEach((client) => this.leaveClient(client));
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
getHandler() {
|
||||
const _this = this;
|
||||
return {
|
||||
onOpen: (event, ws) => {
|
||||
this.joinClient(ws);
|
||||
ws.send(JSON.stringify("cau"));
|
||||
},
|
||||
onMessage(event, ws) {
|
||||
console.log(`Message from client: ${event.data}`)
|
||||
if (!event.data)
|
||||
return;
|
||||
|
||||
const data = JSON.parse(event.data);
|
||||
if (!data)
|
||||
return;
|
||||
|
||||
if (data.ping) {
|
||||
_this.lastPing[ws] = Date.now();
|
||||
}
|
||||
|
||||
if (data.data)
|
||||
_this.handleMessage(ws, data.data);
|
||||
},
|
||||
onClose: (event, ws) => {
|
||||
this.leaveClient(ws);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
handleMessage(ws, data) {
|
||||
|
||||
}
|
||||
|
||||
broadcast(message) {
|
||||
this.clients.forEach(client => client.send(JSON.stringify({data: message})));
|
||||
}
|
||||
|
||||
joinClient(ws) {
|
||||
this.clients.push(ws);
|
||||
this.lastPing[ws] = Date.now();
|
||||
}
|
||||
|
||||
leaveClient(ws) {
|
||||
this.clients = this.clients.filter(s => s !== ws);
|
||||
delete this.lastPing[ws];
|
||||
}
|
||||
|
||||
}
|
||||
34
src/index.js
Normal file
34
src/index.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import RotRouter from './RotRouter.js';
|
||||
import N1mmServer from "./N1mmServer.js";
|
||||
import {Hono} from 'hono';
|
||||
import { serve } from '@hono/node-server'
|
||||
import { serveStatic } from '@hono/node-server/serve-static'
|
||||
import { createNodeWebSocket } from "@hono/node-ws";
|
||||
import WebsocketManager from "./WebsocketManager.js";
|
||||
|
||||
const router = new RotRouter();
|
||||
|
||||
const n1mm = new N1mmServer();
|
||||
n1mm.onTurnMessage(router);
|
||||
// n1mm.start();
|
||||
|
||||
const app = new Hono();
|
||||
// app.get('/', (c) => c.text('Hono!'));
|
||||
app.use('/*', serveStatic({ root: './public' }))
|
||||
|
||||
|
||||
const websocketManager = new WebsocketManager();
|
||||
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app })
|
||||
app.get(
|
||||
'/ws',
|
||||
upgradeWebSocket((c) => websocketManager.getHandler())
|
||||
);
|
||||
|
||||
const server = serve({
|
||||
fetch: app.fetch,
|
||||
port: 8787,
|
||||
}, (info) => {
|
||||
console.log(`Listening on http://localhost:${info.port}`) // Listening on http://localhost:3000
|
||||
});
|
||||
|
||||
injectWebSocket(server)
|
||||
Reference in New Issue
Block a user