fetching + broadcasting dynamic data from RotClient-s
This commit is contained in:
@@ -395,6 +395,7 @@
|
|||||||
this.state = new RotatorState();
|
this.state = new RotatorState();
|
||||||
this.ui = new RotatorUI(this.state);
|
this.ui = new RotatorUI(this.state);
|
||||||
this.renderer = new RotatorRenderer('MainCanvas', this.state);
|
this.renderer = new RotatorRenderer('MainCanvas', this.state);
|
||||||
|
this.websocket = null;
|
||||||
|
|
||||||
this.bindEvents();
|
this.bindEvents();
|
||||||
}
|
}
|
||||||
@@ -415,22 +416,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async initialize() {
|
async initialize() {
|
||||||
// Sequential fetch to protect embedded web servers from connection exhaustion
|
const initData = await this.startWs();
|
||||||
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) {
|
for (const [key, value] of Object.entries(initData)) {
|
||||||
const val = await RotatorAPI.fetchText(conf.url);
|
this.state[key] = value;
|
||||||
if (val !== null) {
|
|
||||||
this.state[conf.key] = conf.isNum ? Number(val) : val;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.ui.updateStatic();
|
this.ui.updateStatic();
|
||||||
@@ -438,59 +427,53 @@
|
|||||||
|
|
||||||
// Start timers
|
// Start timers
|
||||||
// this.startLoops();
|
// this.startLoops();
|
||||||
this.startWs();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
startWs() {
|
async startWs() {
|
||||||
const websocket = new WebSocket(document.location.href.replace(/\/?$/, '/') + "ws");
|
return new Promise((resolve, reject) => {
|
||||||
websocket.addEventListener("message", (e) => {
|
this.websocket = new WebSocket(document.location.href.replace(/\/?$/, '/') + "ws");
|
||||||
if (!e || !e.data)
|
this.websocket.addEventListener("message", (e) => {
|
||||||
return;
|
if (!e || !e.data)
|
||||||
const data = JSON.parse(e.data);
|
return;
|
||||||
if (!data)
|
|
||||||
return;
|
|
||||||
|
|
||||||
console.log(`RECEIVED: `, data);
|
const parsed = JSON.parse(e.data);
|
||||||
|
if (!parsed)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const data = parsed.data;
|
||||||
|
if (!data)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const rotData = data['rot14'];
|
||||||
|
if (rotData.initData) {
|
||||||
|
resolve(rotData.initData); // super ugly
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rotData.dynamic) {
|
||||||
|
const numAz = rotData.dynamic.azimuth;
|
||||||
|
const numStat = rotData.dynamic.status;
|
||||||
|
|
||||||
|
let needsRender = false;
|
||||||
|
if (this.state.azimuth !== numAz || this.state.status !== numStat) {
|
||||||
|
needsRender = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state.adc = rotData.dynamic.adc;
|
||||||
|
this.state.azimuth = numAz;
|
||||||
|
this.state.status = numStat;
|
||||||
|
this.state.lastSeen = Date.now();
|
||||||
|
|
||||||
|
if (needsRender) this.renderer.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`RECEIVED: `, data);
|
||||||
|
});
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
this.websocket.send(JSON.stringify({ ping: 1 }));
|
||||||
|
}, 2000);
|
||||||
});
|
});
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
117
src/RotClient.js
117
src/RotClient.js
@@ -1,3 +1,5 @@
|
|||||||
|
import {handle} from "@hono/node-server/vercel";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Implementation of a simple client for simple_rotator_interface_v -- https://remoteqth.com/w/doku.php?id=simple_rotator_interface_v
|
* Implementation of a simple client for simple_rotator_interface_v -- https://remoteqth.com/w/doku.php?id=simple_rotator_interface_v
|
||||||
*/
|
*/
|
||||||
@@ -8,12 +10,78 @@ export default class RotClient {
|
|||||||
this.ip = clientConfig.ip;
|
this.ip = clientConfig.ip;
|
||||||
this.bands = clientConfig.bands;
|
this.bands = clientConfig.bands;
|
||||||
this.startOffset = null;
|
this.startOffset = null;
|
||||||
|
this.cachedData = null
|
||||||
|
this.dynamicData = null;
|
||||||
|
this.dynamicHandlers = [];
|
||||||
|
|
||||||
setInterval(async () => {
|
setInterval(async () => {
|
||||||
console.log(`${this.label}: azi ${await this.readAzi()}, adc ${await this.readAdc()}, status ${await this.readStatus()}`);
|
await this._readDynamicData();
|
||||||
|
console.log(`${this.label}: `, this.dynamicData);
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async readInitData() {
|
||||||
|
if (this.cachedData)
|
||||||
|
return this.cachedData;
|
||||||
|
|
||||||
|
const endpoints = [
|
||||||
|
{ key: 'azShift', url: 'readStart', numType: 1 },
|
||||||
|
{ key: 'azRange', url: 'readMax', numType: 1 },
|
||||||
|
{ key: 'antRadiationAngle', url: 'readAnt', numType: 1 },
|
||||||
|
{ key: 'antName', url: 'readAntName', numType: 0 },
|
||||||
|
{ key: 'mapUrl', url: 'readMapUrl', numType: 0 },
|
||||||
|
{ key: 'mac', url: 'readMAC', numType: 0 },
|
||||||
|
{ key: 'elevation', url: 'readElevation', numType: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
this.cachedData = await this._readEndpoints(endpoints);
|
||||||
|
|
||||||
|
console.log(`${this.label} Set the initial offset to ${this.cachedData.azShift}.`);
|
||||||
|
|
||||||
|
return this.cachedData;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _readEndpoints(endpoints) {
|
||||||
|
const data = {};
|
||||||
|
for (const row of endpoints) {
|
||||||
|
data[row.key] = await this.readKey(row.url, row.numType);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _readDynamicData() {
|
||||||
|
const dynamicEndpoints = [
|
||||||
|
{ key: 'adc', url: 'readADC', numType: 2 },
|
||||||
|
{ key: 'azimuth', url: 'readAZ', numType: 1 },
|
||||||
|
{ key: 'status', url: 'readStat', numType: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const data = await this._readEndpoints(dynamicEndpoints);
|
||||||
|
|
||||||
|
if (this.dynamicData !== null) {
|
||||||
|
const oldData = this.dynamicData;
|
||||||
|
// const changed = {}; // send everything for now
|
||||||
|
let hasChanged = false;
|
||||||
|
for (const key in oldData) {
|
||||||
|
if (oldData[key] !== data[key]) {
|
||||||
|
// changed[key] = data[key];
|
||||||
|
hasChanged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasChanged)
|
||||||
|
this.dynamicHandlers.forEach((handler) => handler(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dynamicData = data;
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
onDynamicDataUpdate(handler) {
|
||||||
|
this.dynamicHandlers.push(handler);
|
||||||
|
}
|
||||||
|
|
||||||
hasBand(band) {
|
hasBand(band) {
|
||||||
return this.bands.includes(band);
|
return this.bands.includes(band);
|
||||||
}
|
}
|
||||||
@@ -26,49 +94,32 @@ export default class RotClient {
|
|||||||
console.log(`Turning ${this.label} to ${az}°...`);
|
console.log(`Turning ${this.label} to ${az}°...`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async readAzi() {
|
async readKey(endpoint, numType) {
|
||||||
try {
|
try {
|
||||||
if (this.startOffset === null) {
|
const resp = await fetch(`http://${this.ip}:88/${endpoint}`);
|
||||||
this.startOffset = await this.readOffset();
|
const text = await resp.text();
|
||||||
console.log(`${this.label} Set the initial offset to ${this.startOffset}.`);
|
// TODO: End my suffering.
|
||||||
}
|
return numType ? (numType === 1 ? parseInt(text, 10) : parseFloat(text)) : text;
|
||||||
const resp = await fetch(`http://${this.ip}:88/readAZ`);
|
|
||||||
return this.startOffset + parseInt(await resp.text(), 10);
|
|
||||||
} catch(ex) {
|
} catch(ex) {
|
||||||
console.error(`${this.label}: Failed to read azimuth:`, ex);
|
console.error(`${this.label}: Failed to read ${endpoint}:`, ex);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async readAdc() {
|
getDynamicData() {
|
||||||
try {
|
return this.dynamicData;
|
||||||
const resp = await fetch(`http://${this.ip}:88/readADC`);
|
|
||||||
return parseFloat(await resp.text());
|
|
||||||
} catch(ex) {
|
|
||||||
console.error(`${this.label}: Failed to read ADC:`, ex);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async readStatus() {
|
getAzi() {
|
||||||
try {
|
return this.dynamicData.azimuth;
|
||||||
const resp = await fetch(`http://${this.ip}:88/readStat`);
|
|
||||||
return parseInt(await resp.text(), 10);
|
|
||||||
} catch(ex) {
|
|
||||||
console.error(`${this.label}: Failed to read status:`, ex);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async readOffset() {
|
getAdc() {
|
||||||
try {
|
return this.dynamicData.adc;
|
||||||
const resp = await fetch(`http://${this.ip}:88/readStart`);
|
|
||||||
return parseInt(await resp.text(), 10);
|
|
||||||
} catch(ex) {
|
|
||||||
console.error(`${this.label}: Failed to read the initial offset:`, ex);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getStatus() {
|
||||||
|
return this.dynamicData.status;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -3,8 +3,9 @@ import fs from 'node:fs';
|
|||||||
|
|
||||||
export default class RotRouter { // implements TurnEventHandler
|
export default class RotRouter { // implements TurnEventHandler
|
||||||
constructor() {
|
constructor() {
|
||||||
this.clients = [];
|
this.clients = {};
|
||||||
this.loadConfig();
|
this.loadConfig();
|
||||||
|
this.routerDataHandlers = [];
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12,7 +13,9 @@ export default class RotRouter { // implements TurnEventHandler
|
|||||||
const raw = fs.readFileSync("config.json", 'utf-8'); // TODO: Is the path correct?
|
const raw = fs.readFileSync("config.json", 'utf-8'); // TODO: Is the path correct?
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
for (const [label, clientConfig] of Object.entries(parsed)) {
|
for (const [label, clientConfig] of Object.entries(parsed)) {
|
||||||
this.clients[label] = new RotClient(label, clientConfig);
|
const client = new RotClient(label, clientConfig);
|
||||||
|
client.onDynamicDataUpdate(data => this.routerDataHandlers.forEach(handler => handler(client, data)));
|
||||||
|
this.clients[label] = client;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,4 +33,18 @@ export default class RotRouter { // implements TurnEventHandler
|
|||||||
client.turn(msg.az);
|
client.turn(msg.az);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onRotatorData(handler) { // (RotClient, data)
|
||||||
|
this.routerDataHandlers.push(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
async readInitialState() {
|
||||||
|
const ret = {};
|
||||||
|
for (const client of Object.values(this.clients)) {
|
||||||
|
ret[client.label] = {
|
||||||
|
initData: await client.readInitData()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ export default class WebsocketManager {
|
|||||||
constructor() {
|
constructor() {
|
||||||
this.clients = [];
|
this.clients = [];
|
||||||
this.lastPing = {};
|
this.lastPing = {};
|
||||||
|
this.initData = null;
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const thr = Date.now() - 60000;
|
const thr = Date.now() - 60000;
|
||||||
@@ -18,10 +19,8 @@ export default class WebsocketManager {
|
|||||||
return {
|
return {
|
||||||
onOpen: (event, ws) => {
|
onOpen: (event, ws) => {
|
||||||
this.joinClient(ws);
|
this.joinClient(ws);
|
||||||
ws.send(JSON.stringify("cau"));
|
|
||||||
},
|
},
|
||||||
onMessage(event, ws) {
|
onMessage(event, ws) {
|
||||||
console.log(`Message from client: ${event.data}`)
|
|
||||||
if (!event.data)
|
if (!event.data)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -33,8 +32,10 @@ export default class WebsocketManager {
|
|||||||
_this.lastPing[ws] = Date.now();
|
_this.lastPing[ws] = Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.data)
|
if (data.data) {
|
||||||
_this.handleMessage(ws, data.data);
|
_this.handleMessage(ws, data.data);
|
||||||
|
console.log(`Data from client: ${event.data}`);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onClose: (event, ws) => {
|
onClose: (event, ws) => {
|
||||||
this.leaveClient(ws);
|
this.leaveClient(ws);
|
||||||
@@ -46,13 +47,18 @@ export default class WebsocketManager {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
broadcast(message) {
|
send(ws, data) {
|
||||||
this.clients.forEach(client => client.send(JSON.stringify({data: message})));
|
ws.send(JSON.stringify({data: data}));
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcast(data) {
|
||||||
|
this.clients.forEach(client => this.send(client, data));
|
||||||
}
|
}
|
||||||
|
|
||||||
joinClient(ws) {
|
joinClient(ws) {
|
||||||
this.clients.push(ws);
|
this.clients.push(ws);
|
||||||
this.lastPing[ws] = Date.now();
|
this.lastPing[ws] = Date.now();
|
||||||
|
this.send(ws, this.initData)
|
||||||
}
|
}
|
||||||
|
|
||||||
leaveClient(ws) {
|
leaveClient(ws) {
|
||||||
@@ -60,4 +66,8 @@ export default class WebsocketManager {
|
|||||||
delete this.lastPing[ws];
|
delete this.lastPing[ws];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setInitData(initData) {
|
||||||
|
this.initData = initData;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ app.use('/*', serveStatic({ root: './public' }))
|
|||||||
|
|
||||||
const websocketManager = new WebsocketManager();
|
const websocketManager = new WebsocketManager();
|
||||||
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app })
|
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app })
|
||||||
|
websocketManager.setInitData(await router.readInitialState());
|
||||||
|
|
||||||
|
router.onRotatorData((rotator, data) => {
|
||||||
|
const wsUpdate = {};
|
||||||
|
wsUpdate[rotator.label] = { dynamic: data };
|
||||||
|
websocketManager.broadcast(wsUpdate);
|
||||||
|
});
|
||||||
|
|
||||||
app.get(
|
app.get(
|
||||||
'/ws',
|
'/ws',
|
||||||
upgradeWebSocket((c) => websocketManager.getHandler())
|
upgradeWebSocket((c) => websocketManager.getHandler())
|
||||||
|
|||||||
Reference in New Issue
Block a user