claude: move to typescript
This commit is contained in:
35
package-lock.json
generated
35
package-lock.json
generated
@@ -13,6 +13,10 @@
|
||||
"@hono/node-ws": "^1.3.0",
|
||||
"fast-xml-parser": "^5.3.5",
|
||||
"hono": "^4.12.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.5.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
@@ -43,6 +47,16 @@
|
||||
"hono": "^4.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
|
||||
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.3.5",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.5.tgz",
|
||||
@@ -82,6 +96,27 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
"license": "ISC",
|
||||
"author": "ericek111",
|
||||
"type": "module",
|
||||
"main": "src/index.js",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"start": "node --experimental-strip-types src/index.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -15,5 +16,9 @@
|
||||
"@hono/node-ws": "^1.3.0",
|
||||
"fast-xml-parser": "^5.3.5",
|
||||
"hono": "^4.12.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.5.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import dgram from 'node:dgram';
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
|
||||
export default class N1mmServer {
|
||||
|
||||
constructor() {
|
||||
this.xmlParser = new XMLParser();
|
||||
this.turnHandlers = []; // TurnMessageHandler[]
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
57
src/N1mmServer.ts
Normal file
57
src/N1mmServer.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import dgram from 'node:dgram';
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import type { TurnMessageHandler } from './types.js';
|
||||
|
||||
interface N1MMRotorXml {
|
||||
N1MMRotor?: {
|
||||
freqband?: string;
|
||||
goazi?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default class N1mmServer {
|
||||
private readonly xmlParser = new XMLParser();
|
||||
private readonly turnHandlers: TurnMessageHandler[] = [];
|
||||
|
||||
start(): void {
|
||||
const server = dgram.createSocket('udp4');
|
||||
|
||||
server.on('error', err => {
|
||||
console.error(`server error:\n${err.stack}`);
|
||||
server.close();
|
||||
});
|
||||
|
||||
server.on('listening', () => {
|
||||
const { address, port } = server.address();
|
||||
console.log(`server listening ${address}:${port}`);
|
||||
});
|
||||
|
||||
server.on('message', (msg, rinfo) => {
|
||||
console.log(`server got: ${msg} from ${rinfo.address}:${rinfo.port}`);
|
||||
this.processN1mmXml(msg.toString());
|
||||
});
|
||||
|
||||
server.bind(12040);
|
||||
}
|
||||
|
||||
onTurnMessage(handler: TurnMessageHandler): void {
|
||||
this.turnHandlers.push(handler);
|
||||
}
|
||||
|
||||
private processN1mmXml(xml: string): void {
|
||||
try {
|
||||
const parsed = this.xmlParser.parse(xml) as N1MMRotorXml;
|
||||
const band = parseInt(parsed?.N1MMRotor?.freqband?.split(',')[0] ?? '0', 10);
|
||||
const az = parseInt(parsed?.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
124
src/RotClient.js
124
src/RotClient.js
@@ -1,124 +0,0 @@
|
||||
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
|
||||
*/
|
||||
export default class RotClient {
|
||||
|
||||
constructor(label, clientConfig) {
|
||||
this.label = label;
|
||||
this.ip = clientConfig.ip;
|
||||
this.bands = clientConfig.bands;
|
||||
this.cachedData = null
|
||||
this.dynamicData = null;
|
||||
this.dynamicHandlers = [];
|
||||
|
||||
setInterval(async () => {
|
||||
await this._readDynamicData();
|
||||
console.log(`${this.label}: `, this.dynamicData);
|
||||
}, 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) {
|
||||
return this.bands.includes(band);
|
||||
}
|
||||
|
||||
turn(az) {
|
||||
fetch(`http://${this.ip}:88/`, {
|
||||
"body": "ROT=" + az,
|
||||
"method": "POST",
|
||||
}).catch(() => {});
|
||||
console.log(`Turning ${this.label} to ${az}°...`);
|
||||
}
|
||||
|
||||
async readKey(endpoint, numType) {
|
||||
try {
|
||||
const resp = await fetch(`http://${this.ip}:88/${endpoint}`);
|
||||
const text = await resp.text();
|
||||
// TODO: End my suffering.
|
||||
return numType ? (numType === 1 ? parseInt(text, 10) : parseFloat(text)) : text;
|
||||
} catch(ex) {
|
||||
console.error(`${this.label}: Failed to read ${endpoint}:`, ex);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
getDynamicData() {
|
||||
return this.dynamicData;
|
||||
}
|
||||
|
||||
getAzi() {
|
||||
return this.dynamicData.azimuth;
|
||||
}
|
||||
|
||||
getAdc() {
|
||||
return this.dynamicData.adc;
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return this.dynamicData.status;
|
||||
}
|
||||
|
||||
}
|
||||
130
src/RotClient.ts
Normal file
130
src/RotClient.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import type { ClientConfig, DynamicData, InitData } from './types.js';
|
||||
|
||||
// Numeric parsing strategy for rotator HTTP endpoints
|
||||
const NumType = {
|
||||
String: 0,
|
||||
Int: 1,
|
||||
Float: 2,
|
||||
} as const;
|
||||
type NumType = (typeof NumType)[keyof typeof NumType];
|
||||
|
||||
interface EndpointDef {
|
||||
key: string;
|
||||
url: string;
|
||||
numType: NumType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client for the simple_rotator_interface_v protocol.
|
||||
* @see https://remoteqth.com/w/doku.php?id=simple_rotator_interface_v
|
||||
*/
|
||||
export default class RotClient {
|
||||
readonly label: string;
|
||||
private readonly ip: string;
|
||||
private readonly bands: number[];
|
||||
private cachedData: InitData | null = null;
|
||||
private dynamicData: DynamicData | null = null;
|
||||
private readonly dynamicHandlers: Array<(data: DynamicData) => void> = [];
|
||||
|
||||
constructor(label: string, { ip, bands }: ClientConfig) {
|
||||
this.label = label;
|
||||
this.ip = ip;
|
||||
this.bands = bands;
|
||||
|
||||
setInterval(() => void this.fetchDynamicData(), 1000);
|
||||
}
|
||||
|
||||
async readInitData(): Promise<InitData> {
|
||||
if (this.cachedData) return this.cachedData;
|
||||
|
||||
const endpoints: EndpointDef[] = [
|
||||
{ key: 'azShift', url: 'readStart', numType: NumType.Int },
|
||||
{ key: 'azRange', url: 'readMax', numType: NumType.Int },
|
||||
{ key: 'antRadiationAngle', url: 'readAnt', numType: NumType.Int },
|
||||
{ key: 'antName', url: 'readAntName', numType: NumType.String },
|
||||
{ key: 'mapUrl', url: 'readMapUrl', numType: NumType.String },
|
||||
{ key: 'mac', url: 'readMAC', numType: NumType.String },
|
||||
{ key: 'elevation', url: 'readElevation', numType: NumType.Int },
|
||||
];
|
||||
|
||||
this.cachedData = await this.readEndpoints(endpoints) as unknown as InitData;
|
||||
console.log(`${this.label} Set the initial offset to ${this.cachedData.azShift}.`);
|
||||
return this.cachedData;
|
||||
}
|
||||
|
||||
private async readEndpoints(endpoints: EndpointDef[]): Promise<Record<string, string | number | null>> {
|
||||
const data: Record<string, string | number | null> = {};
|
||||
for (const { key, url, numType } of endpoints) {
|
||||
data[key] = await this.readKey(url, numType);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
private async fetchDynamicData(): Promise<void> {
|
||||
const endpoints: EndpointDef[] = [
|
||||
{ key: 'adc', url: 'readADC', numType: NumType.Float },
|
||||
{ key: 'azimuth', url: 'readAZ', numType: NumType.Int },
|
||||
{ key: 'status', url: 'readStat', numType: NumType.Int },
|
||||
];
|
||||
|
||||
const data = await this.readEndpoints(endpoints) as unknown as DynamicData;
|
||||
|
||||
if (this.dynamicData !== null) {
|
||||
const oldData = this.dynamicData;
|
||||
const hasChanged = (Object.keys(oldData) as Array<keyof DynamicData>).some(
|
||||
key => oldData[key] !== data[key]
|
||||
);
|
||||
if (hasChanged) {
|
||||
this.dynamicHandlers.forEach(handler => handler(data));
|
||||
}
|
||||
}
|
||||
|
||||
this.dynamicData = data;
|
||||
console.log(`${this.label}: `, this.dynamicData);
|
||||
}
|
||||
|
||||
onDynamicDataUpdate(handler: (data: DynamicData) => void): void {
|
||||
this.dynamicHandlers.push(handler);
|
||||
}
|
||||
|
||||
hasBand(band: number): boolean {
|
||||
return this.bands.includes(band);
|
||||
}
|
||||
|
||||
turn(az: number): void {
|
||||
fetch(`http://${this.ip}:88/`, {
|
||||
body: `ROT=${az}`,
|
||||
method: 'POST',
|
||||
}).catch(() => {});
|
||||
console.log(`Turning ${this.label} to ${az}°...`);
|
||||
}
|
||||
|
||||
private async readKey(endpoint: string, numType: NumType): Promise<string | number | null> {
|
||||
try {
|
||||
const resp = await fetch(`http://${this.ip}:88/${endpoint}`);
|
||||
const text = await resp.text();
|
||||
if (numType === NumType.String) return text;
|
||||
if (numType === NumType.Int) return parseInt(text, 10);
|
||||
return parseFloat(text);
|
||||
} catch (ex) {
|
||||
console.error(`${this.label}: Failed to read ${endpoint}:`, ex);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
getDynamicData(): DynamicData | null {
|
||||
return this.dynamicData;
|
||||
}
|
||||
|
||||
getAzimuth(): number | null {
|
||||
return this.dynamicData?.azimuth ?? null;
|
||||
}
|
||||
|
||||
getAdc(): number | null {
|
||||
return this.dynamicData?.adc ?? null;
|
||||
}
|
||||
|
||||
getStatus(): number | null {
|
||||
return this.dynamicData?.status ?? null;
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import RotClient from './RotClient.js';
|
||||
import fs from 'node:fs';
|
||||
|
||||
export default class RotRouter { // implements TurnMessageHandler
|
||||
constructor() {
|
||||
this.clients = {};
|
||||
this.loadConfig();
|
||||
this.routerDataHandlers = []; // array of callbacks accepting (RotClient, data)
|
||||
|
||||
}
|
||||
|
||||
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)) {
|
||||
const client = new RotClient(label, clientConfig);
|
||||
client.onDynamicDataUpdate(data => this.routerDataHandlers.forEach(handler => handler(client, data)));
|
||||
this.clients[label] = client;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
onRotatorData(handler) { // will call with (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;
|
||||
}
|
||||
|
||||
}
|
||||
47
src/RotRouter.ts
Normal file
47
src/RotRouter.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import RotClient from './RotClient.js';
|
||||
import type { Config, DynamicData, InitialState, TurnMessage, TurnMessageHandler } from './types.js';
|
||||
|
||||
export default class RotRouter implements TurnMessageHandler {
|
||||
private readonly clients: Record<string, RotClient> = {};
|
||||
private readonly routerDataHandlers: Array<(rotator: RotClient, data: DynamicData) => void> = [];
|
||||
|
||||
constructor() {
|
||||
this.loadConfig();
|
||||
}
|
||||
|
||||
private loadConfig(): void {
|
||||
const raw = readFileSync('config.json', 'utf-8');
|
||||
const parsed = JSON.parse(raw) as Config;
|
||||
for (const [label, clientConfig] of Object.entries(parsed)) {
|
||||
const client = new RotClient(label, clientConfig);
|
||||
client.onDynamicDataUpdate(data => this.routerDataHandlers.forEach(h => h(client, data)));
|
||||
this.clients[label] = client;
|
||||
}
|
||||
}
|
||||
|
||||
private findClientForBand(band: number): RotClient | null {
|
||||
return Object.values(this.clients).find(c => c.hasBand(band)) ?? null;
|
||||
}
|
||||
|
||||
handleTurnMessage(msg: TurnMessage): void {
|
||||
const client = this.findClientForBand(msg.band);
|
||||
if (!client) {
|
||||
console.error(`No RotClient found for the ${msg.band} band!`);
|
||||
return;
|
||||
}
|
||||
client.turn(msg.az);
|
||||
}
|
||||
|
||||
onRotatorData(handler: (rotator: RotClient, data: DynamicData) => void): void {
|
||||
this.routerDataHandlers.push(handler);
|
||||
}
|
||||
|
||||
async readInitialState(): Promise<InitialState> {
|
||||
const state: InitialState = {};
|
||||
for (const client of Object.values(this.clients)) {
|
||||
state[client.label] = { initData: await client.readInitData() };
|
||||
}
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { createNodeWebSocket } from '@hono/node-ws'
|
||||
import {serve} from "@hono/node-server";
|
||||
|
||||
export default class WebsocketManager {
|
||||
|
||||
constructor() {
|
||||
this.clients = [];
|
||||
this.lastPing = {};
|
||||
this.initData = null;
|
||||
|
||||
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);
|
||||
},
|
||||
onMessage(event, ws) {
|
||||
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);
|
||||
console.log(`Data from client: ${event.data}`);
|
||||
}
|
||||
},
|
||||
onClose: (event, ws) => {
|
||||
this.leaveClient(ws);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
handleMessage(ws, data) {
|
||||
|
||||
}
|
||||
|
||||
send(ws, data) {
|
||||
ws.send(JSON.stringify({data: data}));
|
||||
}
|
||||
|
||||
broadcast(data) {
|
||||
this.clients.forEach(client => this.send(client, data));
|
||||
}
|
||||
|
||||
joinClient(ws) {
|
||||
this.clients.push(ws);
|
||||
this.lastPing[ws] = Date.now();
|
||||
this.send(ws, this.initData)
|
||||
}
|
||||
|
||||
leaveClient(ws) {
|
||||
this.clients = this.clients.filter(s => s !== ws);
|
||||
delete this.lastPing[ws];
|
||||
}
|
||||
|
||||
setInitData(initData) {
|
||||
this.initData = initData;
|
||||
}
|
||||
|
||||
}
|
||||
80
src/WebsocketManager.ts
Normal file
80
src/WebsocketManager.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { WSContext } from 'hono/ws';
|
||||
|
||||
interface IncomingMessage {
|
||||
ping?: boolean;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export default class WebsocketManager {
|
||||
private readonly clients = new Set<WSContext>();
|
||||
// Map correctly tracks identity — plain object keys would stringify all WSContext to "[object Object]"
|
||||
private readonly lastPing = new Map<WSContext, number>();
|
||||
private initData: unknown = null;
|
||||
|
||||
constructor() {
|
||||
setInterval(() => {
|
||||
const threshold = Date.now() - 60_000;
|
||||
for (const [client, ping] of this.lastPing) {
|
||||
if (ping < threshold) this.leaveClient(client);
|
||||
}
|
||||
}, 10_000);
|
||||
}
|
||||
|
||||
getHandler() {
|
||||
return {
|
||||
onOpen: (_event: Event, ws: WSContext) => {
|
||||
this.joinClient(ws);
|
||||
},
|
||||
onMessage: (event: MessageEvent, ws: WSContext) => {
|
||||
if (!event.data) return;
|
||||
|
||||
let msg: IncomingMessage;
|
||||
try {
|
||||
msg = JSON.parse(event.data as string) as IncomingMessage;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.ping) {
|
||||
this.lastPing.set(ws, Date.now());
|
||||
}
|
||||
|
||||
if (msg.data !== undefined) {
|
||||
this.handleMessage(ws, msg.data);
|
||||
console.log(`Data from client: ${event.data}`);
|
||||
}
|
||||
},
|
||||
onClose: (_event: CloseEvent, ws: WSContext) => {
|
||||
this.leaveClient(ws);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
private handleMessage(_ws: WSContext, _data: unknown): void {
|
||||
// Reserved for future incoming-message handling
|
||||
}
|
||||
|
||||
private send(ws: WSContext, data: unknown): void {
|
||||
ws.send(JSON.stringify({ data }));
|
||||
}
|
||||
|
||||
broadcast(data: unknown): void {
|
||||
this.clients.forEach(client => this.send(client, data));
|
||||
}
|
||||
|
||||
private joinClient(ws: WSContext): void {
|
||||
this.clients.add(ws);
|
||||
this.lastPing.set(ws, Date.now());
|
||||
this.send(ws, this.initData);
|
||||
}
|
||||
|
||||
private leaveClient(ws: WSContext): void {
|
||||
this.clients.delete(ws);
|
||||
this.lastPing.delete(ws);
|
||||
}
|
||||
|
||||
setInitData(initData: unknown): void {
|
||||
this.initData = initData;
|
||||
}
|
||||
}
|
||||
42
src/index.js
42
src/index.js
@@ -1,42 +0,0 @@
|
||||
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 })
|
||||
websocketManager.setInitData(await router.readInitialState());
|
||||
|
||||
router.onRotatorData((rotator, data) => {
|
||||
const wsUpdate = {};
|
||||
wsUpdate[rotator.label] = { dynamic: data };
|
||||
websocketManager.broadcast(wsUpdate);
|
||||
});
|
||||
|
||||
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)
|
||||
34
src/index.ts
Normal file
34
src/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
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 RotRouter from './RotRouter.js';
|
||||
import N1mmServer from './N1mmServer.js';
|
||||
import WebsocketManager from './WebsocketManager.js';
|
||||
|
||||
const router = new RotRouter();
|
||||
|
||||
const n1mm = new N1mmServer();
|
||||
n1mm.onTurnMessage(router);
|
||||
// n1mm.start();
|
||||
|
||||
const app = new Hono();
|
||||
app.use('/*', serveStatic({ root: './public' }));
|
||||
|
||||
const websocketManager = new WebsocketManager();
|
||||
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
|
||||
|
||||
websocketManager.setInitData(await router.readInitialState());
|
||||
|
||||
router.onRotatorData((rotator, data) => {
|
||||
websocketManager.broadcast({ [rotator.label]: { dynamic: data } });
|
||||
});
|
||||
|
||||
app.get('/ws', upgradeWebSocket(() => websocketManager.getHandler()));
|
||||
|
||||
const server = serve(
|
||||
{ fetch: app.fetch, port: 8787 },
|
||||
info => console.log(`Listening on http://localhost:${info.port}`),
|
||||
);
|
||||
|
||||
injectWebSocket(server);
|
||||
37
src/types.ts
Normal file
37
src/types.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export interface TurnMessage {
|
||||
band: number;
|
||||
az: number;
|
||||
}
|
||||
|
||||
export interface TurnMessageHandler {
|
||||
handleTurnMessage(msg: TurnMessage): void;
|
||||
}
|
||||
|
||||
export interface ClientConfig {
|
||||
ip: string;
|
||||
bands: number[];
|
||||
}
|
||||
|
||||
export type Config = Record<string, ClientConfig>;
|
||||
|
||||
export interface DynamicData {
|
||||
adc: number | null;
|
||||
azimuth: number | null;
|
||||
status: number | null;
|
||||
}
|
||||
|
||||
export interface InitData {
|
||||
azShift: number | null;
|
||||
azRange: number | null;
|
||||
antRadiationAngle: number | null;
|
||||
antName: string | null;
|
||||
mapUrl: string | null;
|
||||
mac: string | null;
|
||||
elevation: number | null;
|
||||
}
|
||||
|
||||
export interface ClientState {
|
||||
initData: InitData;
|
||||
}
|
||||
|
||||
export type InitialState = Record<string, ClientState>;
|
||||
13
tsconfig.json
Normal file
13
tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user