add a setpoint indicator
This commit is contained in:
12
config.json
12
config.json
@@ -4,5 +4,17 @@
|
||||
"bands": [
|
||||
14
|
||||
]
|
||||
},
|
||||
"rot7": {
|
||||
"ip": "192.168.33.83",
|
||||
"bands": [
|
||||
7, 28
|
||||
]
|
||||
},
|
||||
"rot21": {
|
||||
"ip": "192.168.33.84",
|
||||
"bands": [
|
||||
21, 144
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,11 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title id="AntNameTitle">IP rotator</title>
|
||||
<title>RotRouter</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;
|
||||
--box-size: 400px;
|
||||
}
|
||||
html, body {
|
||||
font-family: 'Roboto Condensed', sans-serif, Arial, Tahoma, Verdana;
|
||||
@@ -16,10 +16,17 @@
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.container {
|
||||
width: var(--box-size);
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
.rotators {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
}
|
||||
.rotator-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
canvas {
|
||||
display: block;
|
||||
@@ -27,11 +34,22 @@
|
||||
}
|
||||
.info-panel {
|
||||
width: var(--box-size);
|
||||
margin-top: 10px;
|
||||
margin-top: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
.rotator-label {
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
margin: 0 0 2px;
|
||||
}
|
||||
.rotator-bands {
|
||||
font-size: 15px;
|
||||
color: #aaa;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
.status-bar {
|
||||
font-size: 25px;
|
||||
font-size: 20px;
|
||||
margin: 0;
|
||||
}
|
||||
.status-wrapper {
|
||||
@@ -45,34 +63,24 @@
|
||||
.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; }
|
||||
.turned-by {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
.turned-by .source {
|
||||
color: #e0e040;
|
||||
}
|
||||
</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>
|
||||
<div class="rotators" id="rotators"></div>
|
||||
|
||||
<script>
|
||||
class RotatorState {
|
||||
constructor() {
|
||||
this.boxSize = 600;
|
||||
this.boxSize = 400;
|
||||
this.center = this.boxSize / 2;
|
||||
this.azShift = 0;
|
||||
this.azRange = 360;
|
||||
@@ -85,42 +93,16 @@
|
||||
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);
|
||||
}
|
||||
this.bands = [];
|
||||
}
|
||||
}
|
||||
|
||||
class RotatorRenderer {
|
||||
constructor(canvasId, state) {
|
||||
this.canvas = document.getElementById(canvasId);
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
constructor(canvas, state) {
|
||||
this.canvas = canvas;
|
||||
this.ctx = 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;
|
||||
@@ -154,7 +136,6 @@
|
||||
|
||||
ctx.clearRect(0, 0, sz, sz);
|
||||
|
||||
// 1. Draw Direction Lines (formerly StaticBot)
|
||||
ctx.beginPath();
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeStyle = '#606060';
|
||||
@@ -167,12 +148,10 @@
|
||||
}
|
||||
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) {
|
||||
@@ -188,7 +167,7 @@
|
||||
ctx.arc(cx, cy, sz / 2 * 0.9, startArc, 2 * Math.PI);
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeStyle = '#c0c0c0';
|
||||
ctx.font = "20px Arial";
|
||||
ctx.font = "16px Arial";
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillStyle = "#808080";
|
||||
@@ -219,7 +198,6 @@
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Dark zone logic
|
||||
if (this.state.azRange < 360) {
|
||||
ctx.beginPath();
|
||||
ctx.lineWidth = 1;
|
||||
@@ -251,7 +229,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Instantly trigger a main render after caching
|
||||
this.render();
|
||||
}
|
||||
|
||||
@@ -262,22 +239,22 @@
|
||||
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) {
|
||||
// Setpoint line (only while turning)
|
||||
if (targetAngle != null && status !== 4) {
|
||||
ctx.beginPath();
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeStyle = 'red';
|
||||
const pt = this.getPoint(targetAngle, sz / 2 * 0.9);
|
||||
ctx.moveTo(cx, cy);
|
||||
ctx.lineTo(pt.x, pt.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// 3. Draw Pointer
|
||||
// Pointer
|
||||
ctx.beginPath();
|
||||
ctx.lineWidth = 5;
|
||||
const isOutOfRange = azimuth < 0 || azimuth > azRange;
|
||||
if (isOutOfRange) ctx.strokeStyle = '#c0c0c0';
|
||||
@@ -293,7 +270,6 @@
|
||||
ctx.lineTo(pEnd.x, pEnd.y);
|
||||
ctx.stroke();
|
||||
|
||||
// Arrow for CW/CCW
|
||||
if (status !== 4) {
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = "red";
|
||||
@@ -311,8 +287,7 @@
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// 4. Draw Values Text
|
||||
ctx.font = "bold 100px Arial";
|
||||
ctx.font = "bold 70px Arial";
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
@@ -322,12 +297,12 @@
|
||||
const textLoc = this.getPoint(displayAngle + 180, sz * 0.2);
|
||||
|
||||
if (isOutOfRange) {
|
||||
ctx.font = "bold 30px Arial";
|
||||
ctx.font = "bold 22px 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
|
||||
ctx.fillText(`${textAngle}\u00B0`, textLoc.x + 3, textLoc.y + 3);
|
||||
|
||||
if (status !== 4) ctx.fillStyle = "red";
|
||||
else if (azimuth > 359) ctx.fillStyle = "orange";
|
||||
@@ -336,7 +311,6 @@
|
||||
ctx.fillText(`${textAngle}\u00B0`, textLoc.x, textLoc.y);
|
||||
}
|
||||
|
||||
// 5. Radiation Beam Cone
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, cy);
|
||||
const ccwAngle = antRadiationAngle / 2;
|
||||
@@ -355,22 +329,77 @@
|
||||
}
|
||||
|
||||
class RotatorUI {
|
||||
constructor(state) {
|
||||
constructor(label, container, state) {
|
||||
this.state = state;
|
||||
this.label = label;
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'rotator-card';
|
||||
|
||||
this.nameEl = document.createElement('p');
|
||||
this.nameEl.className = 'rotator-label';
|
||||
this.nameEl.textContent = label;
|
||||
card.appendChild(this.nameEl);
|
||||
|
||||
this.bandsEl = document.createElement('p');
|
||||
this.bandsEl.className = 'rotator-bands';
|
||||
card.appendChild(this.bandsEl);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = state.boxSize;
|
||||
canvas.height = state.boxSize;
|
||||
card.appendChild(canvas);
|
||||
|
||||
const infoPanel = document.createElement('div');
|
||||
infoPanel.className = 'info-panel';
|
||||
infoPanel.innerHTML = `
|
||||
<p class="status-bar">
|
||||
<span class="status-wrapper">
|
||||
POE <span class="adc text-white text-bold">0</span> V |
|
||||
raw <span class="az text-bold">0</span>°
|
||||
<span class="online text-muted"></span>
|
||||
</span>
|
||||
</p>
|
||||
<p class="turned-by">Last turned by: <span class="source">-</span></p>
|
||||
`;
|
||||
card.appendChild(infoPanel);
|
||||
|
||||
container.appendChild(card);
|
||||
|
||||
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')
|
||||
adc: infoPanel.querySelector('.adc'),
|
||||
az: infoPanel.querySelector('.az'),
|
||||
online: infoPanel.querySelector('.online'),
|
||||
turnedBy: infoPanel.querySelector('.turned-by .source'),
|
||||
};
|
||||
|
||||
this.renderer = new RotatorRenderer(canvas, state);
|
||||
|
||||
canvas.addEventListener("click", (evt) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = evt.clientX - rect.left;
|
||||
const y = evt.clientY - rect.top;
|
||||
|
||||
let angle = Math.atan2(state.center - y, x - state.center) * 180 / Math.PI;
|
||||
angle = Math.round((90 - angle + 360) % 360);
|
||||
|
||||
state.targetAngle = angle;
|
||||
this.renderer.render();
|
||||
|
||||
fetch(`/turn/${encodeURIComponent(label)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-type': 'application/x-www-form-urlencoded' },
|
||||
body: `ROT=${angle}`
|
||||
}).catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
updateStatic() {
|
||||
this.els.title.innerText = this.state.antName || "IP rotator";
|
||||
this.els.name.innerText = this.state.antName;
|
||||
this.els.mac.innerText = this.state.mac;
|
||||
this.nameEl.textContent = this.state.antName || this.label;
|
||||
this.bandsEl.textContent = this.state.bands.length
|
||||
? this.state.bands.map(b => b + " MHz").join(", ")
|
||||
: "";
|
||||
this.renderer.updateMap(this.state.mapUrl);
|
||||
}
|
||||
|
||||
updateDynamic() {
|
||||
@@ -388,86 +417,90 @@
|
||||
? " | <span class=\"text-red\">•</span> Offline"
|
||||
: " | <span class=\"text-white\">•</span> Connected";
|
||||
}
|
||||
|
||||
updateTurnedBy(sourceIp, targetAz) {
|
||||
this.els.turnedBy.textContent = `${sourceIp} \u2192 ${targetAz}\u00B0`;
|
||||
}
|
||||
}
|
||||
|
||||
class RotatorApp {
|
||||
constructor() {
|
||||
this.state = new RotatorState();
|
||||
this.ui = new RotatorUI(this.state);
|
||||
this.renderer = new RotatorRenderer('MainCanvas', this.state);
|
||||
this.rotators = {};
|
||||
this.container = document.getElementById('rotators');
|
||||
this.websocket = null;
|
||||
|
||||
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() {
|
||||
const initData = await this.startWs();
|
||||
const initState = await this.startWs();
|
||||
|
||||
for (const [key, value] of Object.entries(initData)) {
|
||||
this.state[key] = value;
|
||||
for (const [label, clientState] of Object.entries(initState)) {
|
||||
const state = new RotatorState();
|
||||
|
||||
if (clientState.initData) {
|
||||
for (const [key, value] of Object.entries(clientState.initData)) {
|
||||
state[key] = value;
|
||||
}
|
||||
}
|
||||
state.bands = clientState.bands || [];
|
||||
|
||||
const ui = new RotatorUI(label, this.container, state);
|
||||
ui.updateStatic();
|
||||
|
||||
if (clientState.setpoint) {
|
||||
state.targetAngle = clientState.setpoint.targetAz;
|
||||
ui.updateTurnedBy(clientState.setpoint.sourceIp, clientState.setpoint.targetAz);
|
||||
}
|
||||
|
||||
this.rotators[label] = { state, ui };
|
||||
}
|
||||
|
||||
this.ui.updateStatic();
|
||||
this.renderer.updateMap(this.state.mapUrl);
|
||||
|
||||
// Start timers
|
||||
// this.startLoops();
|
||||
|
||||
document.title = Object.values(this.rotators)
|
||||
.map(r => r.state.antName || "Rotator")
|
||||
.join(" / ");
|
||||
}
|
||||
|
||||
async startWs() {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise((resolve) => {
|
||||
this.websocket = new WebSocket(document.location.href.replace(/\/?$/, '/') + "ws");
|
||||
let resolved = false;
|
||||
|
||||
this.websocket.addEventListener("message", (e) => {
|
||||
if (!e || !e.data)
|
||||
return;
|
||||
if (!e || !e.data) return;
|
||||
|
||||
const parsed = JSON.parse(e.data);
|
||||
if (!parsed)
|
||||
return;
|
||||
if (!parsed || !parsed.data) return;
|
||||
|
||||
const data = parsed.data;
|
||||
if (!data)
|
||||
return;
|
||||
|
||||
const rotData = data['rot14'];
|
||||
if (rotData.initData) {
|
||||
resolve(rotData.initData); // super ugly
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
resolve(data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (rotData.dynamic) {
|
||||
const numAz = rotData.dynamic.azimuth;
|
||||
const numStat = rotData.dynamic.status;
|
||||
for (const [label, rotData] of Object.entries(data)) {
|
||||
const rot = this.rotators[label];
|
||||
if (!rot) continue;
|
||||
|
||||
let needsRender = false;
|
||||
if (this.state.azimuth !== numAz || this.state.status !== numStat) {
|
||||
needsRender = true;
|
||||
if (rotData.dynamic) {
|
||||
const { azimuth, status, adc } = rotData.dynamic;
|
||||
let needsRender = rot.state.azimuth !== azimuth || rot.state.status !== status;
|
||||
|
||||
rot.state.adc = adc;
|
||||
rot.state.azimuth = azimuth;
|
||||
rot.state.status = status;
|
||||
rot.state.lastSeen = Date.now();
|
||||
|
||||
if (needsRender) rot.ui.renderer.render();
|
||||
rot.ui.updateDynamic();
|
||||
}
|
||||
|
||||
this.state.adc = rotData.dynamic.adc;
|
||||
this.state.azimuth = numAz;
|
||||
this.state.status = numStat;
|
||||
this.state.lastSeen = Date.now();
|
||||
|
||||
if (needsRender) this.renderer.render();
|
||||
if (rotData.turnedBy) {
|
||||
rot.state.targetAngle = rotData.targetAz;
|
||||
rot.ui.renderer.render();
|
||||
rot.ui.updateTurnedBy(rotData.turnedBy, rotData.targetAz);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`RECEIVED: `, data);
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
@@ -477,7 +510,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Bootstrap
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const app = new RotatorApp();
|
||||
app.initialize();
|
||||
|
||||
@@ -28,7 +28,7 @@ export default class N1mmServer {
|
||||
|
||||
server.on('message', (msg, rinfo) => {
|
||||
console.log(`server got: ${msg} from ${rinfo.address}:${rinfo.port}`);
|
||||
this.processN1mmXml(msg.toString());
|
||||
this.processN1mmXml(msg.toString(), rinfo.address);
|
||||
});
|
||||
|
||||
server.bind(12040);
|
||||
@@ -38,7 +38,7 @@ export default class N1mmServer {
|
||||
this.turnHandlers.push(handler);
|
||||
}
|
||||
|
||||
private processN1mmXml(xml: string): void {
|
||||
private processN1mmXml(xml: string, sourceIp: string): void {
|
||||
try {
|
||||
const parsed = this.xmlParser.parse(xml) as N1MMRotorXml;
|
||||
const band = parseInt(parsed?.N1MMRotor?.freqband?.split(',')[0] ?? '0', 10);
|
||||
@@ -49,7 +49,7 @@ export default class N1mmServer {
|
||||
return;
|
||||
}
|
||||
|
||||
this.turnHandlers.forEach(h => h.handleTurnMessage({ band, az }));
|
||||
this.turnHandlers.forEach(h => h.handleTurnMessage({ band, az, sourceIp }));
|
||||
} catch (ex) {
|
||||
console.error(`Failed to parse: ${xml}`, ex);
|
||||
}
|
||||
|
||||
@@ -89,10 +89,17 @@ export default class RotClient {
|
||||
return this.bands.includes(band);
|
||||
}
|
||||
|
||||
getBands(): number[] {
|
||||
return this.bands;
|
||||
}
|
||||
|
||||
turn(az: number): void {
|
||||
fetch(`http://${this.ip}:88/`, {
|
||||
body: `ROT=${az}`,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
|
||||
},
|
||||
}).catch(() => {});
|
||||
console.log(`Turning ${this.label} to ${az}°...`);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,16 @@ import { readFileSync } from 'node:fs';
|
||||
import RotClient from './RotClient.js';
|
||||
import type { Config, DynamicData, InitialState, TurnMessage, TurnMessageHandler } from './types.js';
|
||||
|
||||
export interface TurnEvent {
|
||||
label: string;
|
||||
az: number;
|
||||
sourceIp: string;
|
||||
}
|
||||
|
||||
export default class RotRouter implements TurnMessageHandler {
|
||||
private readonly clients: Record<string, RotClient> = {};
|
||||
private readonly routerDataHandlers: Array<(rotator: RotClient, data: DynamicData) => void> = [];
|
||||
private readonly turnEventHandlers: Array<(event: TurnEvent) => void> = [];
|
||||
|
||||
constructor() {
|
||||
this.loadConfig();
|
||||
@@ -31,6 +38,24 @@ export default class RotRouter implements TurnMessageHandler {
|
||||
return;
|
||||
}
|
||||
client.turn(msg.az);
|
||||
this.fireTurnEvent(client.label, msg.az, msg.sourceIp);
|
||||
}
|
||||
|
||||
turn(label: string, az: number, sourceIp: string): boolean {
|
||||
const client = this.clients[label];
|
||||
if (!client) return false;
|
||||
client.turn(az);
|
||||
this.fireTurnEvent(label, az, sourceIp);
|
||||
return true;
|
||||
}
|
||||
|
||||
private fireTurnEvent(label: string, az: number, sourceIp: string): void {
|
||||
const event: TurnEvent = { label, az, sourceIp };
|
||||
this.turnEventHandlers.forEach(h => h(event));
|
||||
}
|
||||
|
||||
onTurnEvent(handler: (event: TurnEvent) => void): void {
|
||||
this.turnEventHandlers.push(handler);
|
||||
}
|
||||
|
||||
onRotatorData(handler: (rotator: RotClient, data: DynamicData) => void): void {
|
||||
@@ -40,7 +65,7 @@ export default class RotRouter implements TurnMessageHandler {
|
||||
async readInitialState(): Promise<InitialState> {
|
||||
const state: InitialState = {};
|
||||
for (const client of Object.values(this.clients)) {
|
||||
state[client.label] = { initData: await client.readInitData() };
|
||||
state[client.label] = { initData: await client.readInitData(), bands: client.getBands() };
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
20
src/index.ts
20
src/index.ts
@@ -18,12 +18,30 @@ app.use('/*', serveStatic({ root: './public' }));
|
||||
const websocketManager = new WebsocketManager();
|
||||
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
|
||||
|
||||
websocketManager.setInitData(await router.readInitialState());
|
||||
const initialState = await router.readInitialState();
|
||||
websocketManager.setInitData(initialState);
|
||||
|
||||
router.onRotatorData((rotator, data) => {
|
||||
websocketManager.broadcast({ [rotator.label]: { dynamic: data } });
|
||||
});
|
||||
|
||||
router.onTurnEvent((event) => {
|
||||
const setpoint = { targetAz: event.az, sourceIp: event.sourceIp };
|
||||
const client = initialState[event.label];
|
||||
if (client) client.setpoint = setpoint;
|
||||
websocketManager.broadcast({ [event.label]: { turnedBy: event.sourceIp, targetAz: event.az } });
|
||||
});
|
||||
|
||||
app.post('/turn/:label', async (c) => {
|
||||
const label = c.req.param('label');
|
||||
const body = await c.req.parseBody();
|
||||
const az = Number(body['ROT']);
|
||||
if (isNaN(az)) return c.text('Invalid azimuth', 400);
|
||||
const sourceIp = c.req.header('x-forwarded-for') ?? c.req.header('x-real-ip') ?? 'unknown';
|
||||
const ok = router.turn(label, az, sourceIp);
|
||||
return ok ? c.text(`Turning ${label} to ${az}`) : c.text('Unknown rotator', 404);
|
||||
});
|
||||
|
||||
app.get('/ws', upgradeWebSocket(() => websocketManager.getHandler()));
|
||||
|
||||
const server = serve(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface TurnMessage {
|
||||
band: number;
|
||||
az: number;
|
||||
sourceIp: string;
|
||||
}
|
||||
|
||||
export interface TurnMessageHandler {
|
||||
@@ -30,8 +31,15 @@ export interface InitData {
|
||||
elevation: number | null;
|
||||
}
|
||||
|
||||
export interface Setpoint {
|
||||
targetAz: number;
|
||||
sourceIp: string;
|
||||
}
|
||||
|
||||
export interface ClientState {
|
||||
initData: InitData;
|
||||
bands: number[];
|
||||
setpoint?: Setpoint;
|
||||
}
|
||||
|
||||
export type InitialState = Record<string, ClientState>;
|
||||
|
||||
Reference in New Issue
Block a user