Compare commits
6 Commits
47286248ef
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
0643ae5ed8
|
|||
|
596a810681
|
|||
|
22d750e3ab
|
|||
|
80b66be4fc
|
|||
|
d88322fe46
|
|||
|
009b6f1b08
|
12
config.json
12
config.json
@@ -4,5 +4,17 @@
|
|||||||
"bands": [
|
"bands": [
|
||||||
14
|
14
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"rot7": {
|
||||||
|
"ip": "192.168.33.83",
|
||||||
|
"bands": [
|
||||||
|
7, 28
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"rot21": {
|
||||||
|
"ip": "192.168.33.84",
|
||||||
|
"bands": [
|
||||||
|
21, 144
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
578
package-lock.json
generated
578
package-lock.json
generated
@@ -13,6 +13,453 @@
|
|||||||
"@hono/node-ws": "^1.3.0",
|
"@hono/node-ws": "^1.3.0",
|
||||||
"fast-xml-parser": "^5.3.5",
|
"fast-xml-parser": "^5.3.5",
|
||||||
"hono": "^4.12.8"
|
"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": {
|
"node_modules/@hono/node-server": {
|
||||||
@@ -43,6 +490,58 @@
|
|||||||
"hono": "^4.6.0"
|
"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": {
|
"node_modules/fast-xml-parser": {
|
||||||
"version": "5.3.5",
|
"version": "5.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.5.tgz",
|
||||||
@@ -61,6 +560,34 @@
|
|||||||
"fxparser": "src/cli/cli.js"
|
"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": {
|
"node_modules/hono": {
|
||||||
"version": "4.12.8",
|
"version": "4.12.8",
|
||||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.8.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.8.tgz",
|
||||||
@@ -70,6 +597,16 @@
|
|||||||
"node": ">=16.9.0"
|
"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": {
|
"node_modules/strnum": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
|
||||||
@@ -82,6 +619,47 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/ws": {
|
||||||
"version": "8.19.0",
|
"version": "8.19.0",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -5,9 +5,10 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"author": "ericek111",
|
"author": "ericek111",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.js",
|
"main": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/index.js",
|
"start": "tsx src/index.ts",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -15,5 +16,10 @@
|
|||||||
"@hono/node-ws": "^1.3.0",
|
"@hono/node-ws": "^1.3.0",
|
||||||
"fast-xml-parser": "^5.3.5",
|
"fast-xml-parser": "^5.3.5",
|
||||||
"hono": "^4.12.8"
|
"hono": "^4.12.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.5.0",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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">
|
<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>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--box-size: 600px;
|
--box-size: 400px;
|
||||||
}
|
}
|
||||||
html, body {
|
html, body {
|
||||||
font-family: 'Roboto Condensed', sans-serif, Arial, Tahoma, Verdana;
|
font-family: 'Roboto Condensed', sans-serif, Arial, Tahoma, Verdana;
|
||||||
@@ -16,10 +16,17 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.container {
|
.rotators {
|
||||||
width: var(--box-size);
|
display: flex;
|
||||||
margin: 0 auto;
|
flex-wrap: wrap;
|
||||||
position: relative;
|
justify-content: center;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.rotator-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
canvas {
|
canvas {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -27,11 +34,22 @@
|
|||||||
}
|
}
|
||||||
.info-panel {
|
.info-panel {
|
||||||
width: var(--box-size);
|
width: var(--box-size);
|
||||||
margin-top: 10px;
|
margin-top: 8px;
|
||||||
text-align: center;
|
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 {
|
.status-bar {
|
||||||
font-size: 25px;
|
font-size: 20px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
.status-wrapper {
|
.status-wrapper {
|
||||||
@@ -45,34 +63,24 @@
|
|||||||
.text-bold { font-weight: bold; }
|
.text-bold { font-weight: bold; }
|
||||||
.text-red { color: red; }
|
.text-red { color: red; }
|
||||||
.text-muted { color: #666; font-size: 73%; }
|
.text-muted { color: #666; font-size: 73%; }
|
||||||
a:hover { color: #fff; }
|
.turned-by {
|
||||||
a { color: #ccc; text-decoration: underline; }
|
font-size: 14px;
|
||||||
|
color: #888;
|
||||||
|
margin: 4px 0 0;
|
||||||
|
}
|
||||||
|
.turned-by .source {
|
||||||
|
color: #e0e040;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<div class="container">
|
<div class="rotators" id="rotators"></div>
|
||||||
<canvas id="MainCanvas" width="600" height="600"></canvas>
|
|
||||||
|
|
||||||
<div class="info-panel">
|
|
||||||
<p class="status-bar">
|
|
||||||
<span class="status-wrapper">
|
|
||||||
<span id="AntName" class="text-white">Loading...</span> | POE
|
|
||||||
<span id="ADCValue" class="text-white text-bold">0</span> V |
|
|
||||||
raw <span id="AZValue" class="text-bold">0</span>° |
|
|
||||||
<a href="/set" onclick="window.open(this.href, 'setup', 'width=700,height=1350,menubar=no,location=no,status=no'); return false;">SETUP</a>
|
|
||||||
</span>
|
|
||||||
<br>
|
|
||||||
<span id="MacAddress" class="text-muted"></span>
|
|
||||||
<span id="OnlineStatus" class="text-muted"></span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
class RotatorState {
|
class RotatorState {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.boxSize = 600;
|
this.boxSize = 400;
|
||||||
this.center = this.boxSize / 2;
|
this.center = this.boxSize / 2;
|
||||||
this.azShift = 0;
|
this.azShift = 0;
|
||||||
this.azRange = 360;
|
this.azRange = 360;
|
||||||
@@ -84,43 +92,17 @@
|
|||||||
this.mac = "";
|
this.mac = "";
|
||||||
this.antName = "";
|
this.antName = "";
|
||||||
this.adc = 0;
|
this.adc = 0;
|
||||||
this.lastSeen = 0;
|
this.online = false;
|
||||||
}
|
this.bands = [];
|
||||||
}
|
|
||||||
|
|
||||||
class RotatorAPI {
|
|
||||||
static async fetchText(endpoint) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/${endpoint}`);
|
|
||||||
if (!res.ok) throw new Error('Network response was not ok');
|
|
||||||
return await res.text();
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`Failed to fetch ${endpoint}`, e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static async sendTarget(azTarget) {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-type': 'application/x-www-form-urlencoded' },
|
|
||||||
body: `ROT=${azTarget}`
|
|
||||||
});
|
|
||||||
if (res.ok) alert(await res.text());
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to send target rotation", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class RotatorRenderer {
|
class RotatorRenderer {
|
||||||
constructor(canvasId, state) {
|
constructor(canvas, state) {
|
||||||
this.canvas = document.getElementById(canvasId);
|
this.canvas = canvas;
|
||||||
this.ctx = this.canvas.getContext('2d');
|
this.ctx = canvas.getContext('2d');
|
||||||
this.state = state;
|
this.state = state;
|
||||||
|
|
||||||
// Create offscreen canvas for caching static background layers
|
|
||||||
this.bgCanvas = document.createElement('canvas');
|
this.bgCanvas = document.createElement('canvas');
|
||||||
this.bgCanvas.width = state.boxSize;
|
this.bgCanvas.width = state.boxSize;
|
||||||
this.bgCanvas.height = state.boxSize;
|
this.bgCanvas.height = state.boxSize;
|
||||||
@@ -154,7 +136,6 @@
|
|||||||
|
|
||||||
ctx.clearRect(0, 0, sz, sz);
|
ctx.clearRect(0, 0, sz, sz);
|
||||||
|
|
||||||
// 1. Draw Direction Lines (formerly StaticBot)
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
ctx.strokeStyle = '#606060';
|
ctx.strokeStyle = '#606060';
|
||||||
@@ -167,12 +148,10 @@
|
|||||||
}
|
}
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// 2. Draw Map Image
|
|
||||||
if (this.mapImage.complete && this.mapImage.naturalHeight !== 0) {
|
if (this.mapImage.complete && this.mapImage.naturalHeight !== 0) {
|
||||||
ctx.drawImage(this.mapImage, 0, 0, sz, sz);
|
ctx.drawImage(this.mapImage, 0, 0, sz, sz);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Draw Static Overlays (Compass, Dark Zones)
|
|
||||||
ctx.lineWidth = 8;
|
ctx.lineWidth = 8;
|
||||||
ctx.strokeStyle = 'orange';
|
ctx.strokeStyle = 'orange';
|
||||||
if (this.state.azRange > 360) {
|
if (this.state.azRange > 360) {
|
||||||
@@ -188,7 +167,7 @@
|
|||||||
ctx.arc(cx, cy, sz / 2 * 0.9, startArc, 2 * Math.PI);
|
ctx.arc(cx, cy, sz / 2 * 0.9, startArc, 2 * Math.PI);
|
||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 2;
|
||||||
ctx.strokeStyle = '#c0c0c0';
|
ctx.strokeStyle = '#c0c0c0';
|
||||||
ctx.font = "20px Arial";
|
ctx.font = "16px Arial";
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
ctx.fillStyle = "#808080";
|
ctx.fillStyle = "#808080";
|
||||||
@@ -219,7 +198,6 @@
|
|||||||
}
|
}
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// Dark zone logic
|
|
||||||
if (this.state.azRange < 360) {
|
if (this.state.azRange < 360) {
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
@@ -251,7 +229,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Instantly trigger a main render after caching
|
|
||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,22 +239,40 @@
|
|||||||
const cy = this.state.center;
|
const cy = this.state.center;
|
||||||
const { azShift, azRange, azimuth, status, antRadiationAngle, targetAngle } = this.state;
|
const { azShift, azRange, azimuth, status, antRadiationAngle, targetAngle } = this.state;
|
||||||
|
|
||||||
// 1. Clear and composite background
|
|
||||||
ctx.clearRect(0, 0, sz, sz);
|
ctx.clearRect(0, 0, sz, sz);
|
||||||
ctx.drawImage(this.bgCanvas, 0, 0);
|
ctx.drawImage(this.bgCanvas, 0, 0);
|
||||||
|
|
||||||
// 2. Draw Target Line
|
const isOffline = !this.state.online;
|
||||||
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) {
|
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);
|
const pt = this.getPoint(targetAngle, sz / 2 * 0.9);
|
||||||
ctx.moveTo(cx, cy);
|
ctx.moveTo(cx, cy);
|
||||||
ctx.lineTo(pt.x, pt.y);
|
ctx.lineTo(pt.x, pt.y);
|
||||||
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Draw Pointer
|
// Pointer
|
||||||
|
ctx.beginPath();
|
||||||
ctx.lineWidth = 5;
|
ctx.lineWidth = 5;
|
||||||
const isOutOfRange = azimuth < 0 || azimuth > azRange;
|
const isOutOfRange = azimuth < 0 || azimuth > azRange;
|
||||||
if (isOutOfRange) ctx.strokeStyle = '#c0c0c0';
|
if (isOutOfRange) ctx.strokeStyle = '#c0c0c0';
|
||||||
@@ -293,7 +288,6 @@
|
|||||||
ctx.lineTo(pEnd.x, pEnd.y);
|
ctx.lineTo(pEnd.x, pEnd.y);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// Arrow for CW/CCW
|
|
||||||
if (status !== 4) {
|
if (status !== 4) {
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.fillStyle = "red";
|
ctx.fillStyle = "red";
|
||||||
@@ -311,8 +305,7 @@
|
|||||||
ctx.fill();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Draw Values Text
|
ctx.font = "bold 70px Arial";
|
||||||
ctx.font = "bold 100px Arial";
|
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
|
|
||||||
@@ -322,12 +315,12 @@
|
|||||||
const textLoc = this.getPoint(displayAngle + 180, sz * 0.2);
|
const textLoc = this.getPoint(displayAngle + 180, sz * 0.2);
|
||||||
|
|
||||||
if (isOutOfRange) {
|
if (isOutOfRange) {
|
||||||
ctx.font = "bold 30px Arial";
|
ctx.font = "bold 22px Arial";
|
||||||
ctx.fillStyle = '#c0c0c0';
|
ctx.fillStyle = '#c0c0c0';
|
||||||
ctx.fillText(azimuth < 0 ? "CCW endstop zone" : "CW endstop zone", textLoc.x, textLoc.y);
|
ctx.fillText(azimuth < 0 ? "CCW endstop zone" : "CW endstop zone", textLoc.x, textLoc.y);
|
||||||
} else {
|
} else {
|
||||||
ctx.fillStyle = "black";
|
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";
|
if (status !== 4) ctx.fillStyle = "red";
|
||||||
else if (azimuth > 359) ctx.fillStyle = "orange";
|
else if (azimuth > 359) ctx.fillStyle = "orange";
|
||||||
@@ -336,7 +329,6 @@
|
|||||||
ctx.fillText(`${textAngle}\u00B0`, textLoc.x, textLoc.y);
|
ctx.fillText(`${textAngle}\u00B0`, textLoc.x, textLoc.y);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Radiation Beam Cone
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(cx, cy);
|
ctx.moveTo(cx, cy);
|
||||||
const ccwAngle = antRadiationAngle / 2;
|
const ccwAngle = antRadiationAngle / 2;
|
||||||
@@ -355,22 +347,77 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
class RotatorUI {
|
class RotatorUI {
|
||||||
constructor(state) {
|
constructor(label, container, state) {
|
||||||
this.state = 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 = {
|
this.els = {
|
||||||
title: document.getElementById('AntNameTitle'),
|
adc: infoPanel.querySelector('.adc'),
|
||||||
name: document.getElementById('AntName'),
|
az: infoPanel.querySelector('.az'),
|
||||||
mac: document.getElementById('MacAddress'),
|
online: infoPanel.querySelector('.online'),
|
||||||
adc: document.getElementById('ADCValue'),
|
turnedBy: infoPanel.querySelector('.turned-by .source'),
|
||||||
az: document.getElementById('AZValue'),
|
|
||||||
online: document.getElementById('OnlineStatus')
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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() {
|
updateStatic() {
|
||||||
this.els.title.innerText = this.state.antName || "IP rotator";
|
this.nameEl.textContent = this.state.antName || this.label;
|
||||||
this.els.name.innerText = this.state.antName;
|
this.bandsEl.textContent = this.state.bands.length
|
||||||
this.els.mac.innerText = this.state.mac;
|
? this.state.bands.map(b => b + " MHz").join(", ")
|
||||||
|
: "";
|
||||||
|
this.renderer.updateMap(this.state.mapUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateDynamic() {
|
updateDynamic() {
|
||||||
@@ -383,91 +430,97 @@
|
|||||||
|
|
||||||
this.els.az.innerText = this.state.azimuth;
|
this.els.az.innerText = this.state.azimuth;
|
||||||
|
|
||||||
const isOffline = (Date.now() - this.state.lastSeen) > 1500;
|
this.els.online.innerHTML = this.state.online
|
||||||
this.els.online.innerHTML = isOffline
|
? " | <span class=\"text-white\">•</span> Connected"
|
||||||
? " | <span class=\"text-red\">•</span> Offline"
|
: " | <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 {
|
class RotatorApp {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.state = new RotatorState();
|
this.rotators = {};
|
||||||
this.ui = new RotatorUI(this.state);
|
this.container = document.getElementById('rotators');
|
||||||
this.renderer = new RotatorRenderer('MainCanvas', this.state);
|
|
||||||
this.websocket = null;
|
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() {
|
async initialize() {
|
||||||
const initData = await this.startWs();
|
const initState = await this.startWs();
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(initData)) {
|
for (const [label, clientState] of Object.entries(initState)) {
|
||||||
this.state[key] = value;
|
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();
|
document.title = Object.values(this.rotators)
|
||||||
this.renderer.updateMap(this.state.mapUrl);
|
.map(r => r.state.antName || "Rotator")
|
||||||
|
.join(" / ");
|
||||||
// Start timers
|
|
||||||
// this.startLoops();
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async startWs() {
|
async startWs() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve) => {
|
||||||
this.websocket = new WebSocket(document.location.href.replace(/\/?$/, '/') + "ws");
|
this.websocket = new WebSocket(document.location.href.replace(/\/?$/, '/') + "ws");
|
||||||
|
let resolved = false;
|
||||||
|
|
||||||
this.websocket.addEventListener("message", (e) => {
|
this.websocket.addEventListener("message", (e) => {
|
||||||
if (!e || !e.data)
|
if (!e || !e.data) return;
|
||||||
return;
|
|
||||||
|
|
||||||
const parsed = JSON.parse(e.data);
|
const parsed = JSON.parse(e.data);
|
||||||
if (!parsed)
|
if (!parsed || !parsed.data) return;
|
||||||
return;
|
|
||||||
|
|
||||||
const data = parsed.data;
|
const data = parsed.data;
|
||||||
if (!data)
|
|
||||||
return;
|
|
||||||
|
|
||||||
const rotData = data['rot14'];
|
if (!resolved) {
|
||||||
if (rotData.initData) {
|
resolved = true;
|
||||||
resolve(rotData.initData); // super ugly
|
resolve(data);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rotData.dynamic) {
|
for (const [label, rotData] of Object.entries(data)) {
|
||||||
const numAz = rotData.dynamic.azimuth;
|
const rot = this.rotators[label];
|
||||||
const numStat = rotData.dynamic.status;
|
if (!rot) continue;
|
||||||
|
|
||||||
let needsRender = false;
|
if (rotData.dynamic) {
|
||||||
if (this.state.azimuth !== numAz || this.state.status !== numStat) {
|
const { azimuth, status, adc, online } = rotData.dynamic;
|
||||||
needsRender = true;
|
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;
|
if (rotData.turnedBy) {
|
||||||
this.state.azimuth = numAz;
|
rot.state.targetAngle = rotData.targetAz;
|
||||||
this.state.status = numStat;
|
rot.ui.renderer.render();
|
||||||
this.state.lastSeen = Date.now();
|
rot.ui.updateTurnedBy(rotData.turnedBy, rotData.targetAz);
|
||||||
|
}
|
||||||
if (needsRender) this.renderer.render();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`RECEIVED: `, data);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
@@ -477,11 +530,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bootstrap
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
const app = new RotatorApp();
|
const app = new RotatorApp();
|
||||||
app.initialize();
|
app.initialize();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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
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(), 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
125
src/RotClient.js
125
src/RotClient.js
@@ -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
141
src/RotClient.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
72
src/RotRouter.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
54
src/index.ts
Normal file
54
src/index.ts
Normal 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
46
src/types.ts
Normal 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
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