Compare commits

..

6 Commits

Author SHA1 Message Date
0643ae5ed8 add an online flag 2026-04-02 19:58:33 +02:00
596a810681 drop IPv6 prefix for IPv4 addrs 2026-04-02 19:20:13 +02:00
22d750e3ab add a setpoint indicator 2026-04-02 19:13:35 +02:00
80b66be4fc claude: use tsx instead of experimental-strip-types 2026-03-20 13:18:02 +01:00
d88322fe46 claude: move to typescript 2026-03-20 13:17:47 +01:00
009b6f1b08 minor: comments 2026-03-20 10:26:22 +01:00
16 changed files with 1259 additions and 488 deletions

View File

@@ -4,5 +4,17 @@
"bands": [
14
]
},
"rot7": {
"ip": "192.168.33.83",
"bands": [
7, 28
]
},
"rot21": {
"ip": "192.168.33.84",
"bands": [
21, 144
]
}
}

578
package-lock.json generated
View File

@@ -13,6 +13,453 @@
"@hono/node-ws": "^1.3.0",
"fast-xml-parser": "^5.3.5",
"hono": "^4.12.8"
},
"devDependencies": {
"@types/node": "^25.5.0",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@hono/node-server": {
@@ -43,6 +490,58 @@
"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/esbuild": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.4",
"@esbuild/android-arm": "0.27.4",
"@esbuild/android-arm64": "0.27.4",
"@esbuild/android-x64": "0.27.4",
"@esbuild/darwin-arm64": "0.27.4",
"@esbuild/darwin-x64": "0.27.4",
"@esbuild/freebsd-arm64": "0.27.4",
"@esbuild/freebsd-x64": "0.27.4",
"@esbuild/linux-arm": "0.27.4",
"@esbuild/linux-arm64": "0.27.4",
"@esbuild/linux-ia32": "0.27.4",
"@esbuild/linux-loong64": "0.27.4",
"@esbuild/linux-mips64el": "0.27.4",
"@esbuild/linux-ppc64": "0.27.4",
"@esbuild/linux-riscv64": "0.27.4",
"@esbuild/linux-s390x": "0.27.4",
"@esbuild/linux-x64": "0.27.4",
"@esbuild/netbsd-arm64": "0.27.4",
"@esbuild/netbsd-x64": "0.27.4",
"@esbuild/openbsd-arm64": "0.27.4",
"@esbuild/openbsd-x64": "0.27.4",
"@esbuild/openharmony-arm64": "0.27.4",
"@esbuild/sunos-x64": "0.27.4",
"@esbuild/win32-arm64": "0.27.4",
"@esbuild/win32-ia32": "0.27.4",
"@esbuild/win32-x64": "0.27.4"
}
},
"node_modules/fast-xml-parser": {
"version": "5.3.5",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.5.tgz",
@@ -61,6 +560,34 @@
"fxparser": "src/cli/cli.js"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/get-tsconfig": {
"version": "4.13.6",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/hono": {
"version": "4.12.8",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.8.tgz",
@@ -70,6 +597,16 @@
"node": ">=16.9.0"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/strnum": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
@@ -82,6 +619,47 @@
],
"license": "MIT"
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"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",

View File

@@ -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": "tsx src/index.ts",
"typecheck": "tsc --noEmit",
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
@@ -15,5 +16,10 @@
"@hono/node-ws": "^1.3.0",
"fast-xml-parser": "^5.3.5",
"hono": "^4.12.8"
},
"devDependencies": {
"@types/node": "^25.5.0",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
}

View File

@@ -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>&deg; |
<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;
@@ -84,43 +92,17 @@
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);
}
this.online = false;
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,40 @@
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);
const isOffline = !this.state.online;
if (targetAngle != null && Math.abs(mappedTarget - mappedAzimuth) > antRadiationAngle / 2) {
if (isOffline) {
// Grey overlay
ctx.beginPath();
ctx.arc(cx, cy, sz / 2 * 0.9, 0, 2 * Math.PI);
ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
ctx.fill();
// OFFLINE label
ctx.font = 'bold 50px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#666';
ctx.fillText('OFFLINE', cx, cy);
return;
}
// 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 +288,6 @@
ctx.lineTo(pEnd.x, pEnd.y);
ctx.stroke();
// Arrow for CW/CCW
if (status !== 4) {
ctx.beginPath();
ctx.fillStyle = "red";
@@ -311,8 +305,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 +315,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 +329,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 +347,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>&deg;
<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() {
@@ -383,91 +430,97 @@
this.els.az.innerText = this.state.azimuth;
const isOffline = (Date.now() - this.state.lastSeen) > 1500;
this.els.online.innerHTML = isOffline
? " | <span class=\"text-red\">&bull;</span> Offline"
: " | <span class=\"text-white\">&bull;</span> Connected";
this.els.online.innerHTML = this.state.online
? " | <span class=\"text-white\">&bull;</span> Connected"
: " | <span class=\"text-red\">&bull;</span> Offline";
}
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, online } = rotData.dynamic;
let needsRender = rot.state.azimuth !== azimuth
|| rot.state.status !== status
|| rot.state.online !== online;
rot.state.adc = adc;
rot.state.azimuth = azimuth;
rot.state.status = status;
rot.state.online = online;
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,11 +530,10 @@
}
}
// Bootstrap
document.addEventListener("DOMContentLoaded", () => {
const app = new RotatorApp();
app.initialize();
});
</script>
</body>
</html>
</html>

View File

@@ -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 = []; // 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);
}
}
}

57
src/N1mmServer.ts Normal file
View 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(), rinfo.address);
});
server.bind(12040);
}
onTurnMessage(handler: TurnMessageHandler): void {
this.turnHandlers.push(handler);
}
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);
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, sourceIp }));
} catch (ex) {
console.error(`Failed to parse: ${xml}`, ex);
}
}
}

View File

@@ -1,125 +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.startOffset = null;
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;
}
}

141
src/RotClient.ts Normal file
View File

@@ -0,0 +1,141 @@
import type { ClientConfig, DynamicData, InitData } from './types.js';
const enum NumType {
String,
Int,
Float,
}
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 raw = await this.readEndpoints(endpoints);
const data: DynamicData = {
adc: raw.adc as number | null,
azimuth: raw.azimuth as number | null,
status: raw.status as number | null,
online: raw.azimuth !== null,
};
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);
}
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}°...`);
}
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;
}
}

View File

@@ -1,50 +0,0 @@
import RotClient from './RotClient.js';
import fs from 'node:fs';
export default class RotRouter { // implements TurnEventHandler
constructor() {
this.clients = {};
this.loadConfig();
this.routerDataHandlers = [];
}
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) { // (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;
}
}

72
src/RotRouter.ts Normal file
View File

@@ -0,0 +1,72 @@
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();
}
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);
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 {
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(), bands: client.getBands() };
}
return state;
}
}

View File

@@ -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
View 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;
}
}

View File

@@ -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)

54
src/index.ts Normal file
View File

@@ -0,0 +1,54 @@
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';
import { getConnInfo } from '@hono/node-server/conninfo';
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 });
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 rawIp = c.req.header('x-forwarded-for') ?? getConnInfo(c).remote.address ?? 'unknown';
const sourceIp = rawIp.startsWith('::ffff:') ? rawIp.slice(7) : rawIp;
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(
{ fetch: app.fetch, port: 8787 },
info => console.log(`Listening on http://localhost:${info.port}`),
);
injectWebSocket(server);

46
src/types.ts Normal file
View File

@@ -0,0 +1,46 @@
export interface TurnMessage {
band: number;
az: number;
sourceIp: string;
}
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;
online: boolean;
}
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 Setpoint {
targetAz: number;
sourceIp: string;
}
export interface ClientState {
initData: InitData;
bands: number[];
setpoint?: Setpoint;
}
export type InitialState = Record<string, ClientState>;

13
tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src/**/*"]
}