Aggiunto collegamento al server
This commit is contained in:
27
docker-compose.yml
Normal file
27
docker-compose.yml
Normal file
@@ -0,0 +1,27 @@
|
||||
services:
|
||||
signalk:
|
||||
image: signalk/signalk-server:latest
|
||||
container_name: signalk
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3001:3000"
|
||||
volumes:
|
||||
- /Users/sese/Local/dev/MEB/meb-plugin:/home/node/.signalk/node_modules/meb:ro
|
||||
- /Users/sese/Local/dev/MEB/local-plugin-data:/home/node/.signalk/meb-data
|
||||
dns:
|
||||
- 8.8.8.8
|
||||
- 1.1.1.1
|
||||
networks:
|
||||
- meb-proxy-net
|
||||
- meb-internal
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 2G
|
||||
cpus: '1.5'
|
||||
|
||||
networks:
|
||||
meb-proxy-net:
|
||||
external: true
|
||||
meb-internal:
|
||||
external: true
|
||||
565
package-lock.json
generated
565
package-lock.json
generated
@@ -15,8 +15,20 @@
|
||||
"i": "^0.3.7",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mongoose": "^9.1.2",
|
||||
"mqtt": "^5.14.1",
|
||||
"msgpack-lite": "^0.1.26",
|
||||
"node-telegram-bot-api": "^0.66.0",
|
||||
"path": "^0.12.7"
|
||||
"path": "^0.12.7",
|
||||
"ws": "^8.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
|
||||
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@cypress/request": {
|
||||
@@ -90,6 +102,24 @@
|
||||
"sparse-bitfield": "^3.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz",
|
||||
"integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/readable-stream": {
|
||||
"version": "4.0.23",
|
||||
"resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.23.tgz",
|
||||
"integrity": "sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/webidl-conversions": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
|
||||
@@ -105,6 +135,27 @@
|
||||
"@types/webidl-conversions": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/abort-controller": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"event-target-shim": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.5"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
@@ -250,6 +301,26 @@
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bcrypt-pbkdf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
|
||||
@@ -275,6 +346,18 @@
|
||||
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/broker-factory": {
|
||||
"version": "3.1.13",
|
||||
"resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.13.tgz",
|
||||
"integrity": "sha512-H2VALe31mEtO/SRcNp4cUU5BAm1biwhc/JaF77AigUuni/1YT0FLCJfbUxwIEs9y6Kssjk2fmXgf+Y9ALvmKlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.6",
|
||||
"fast-unique-numbers": "^9.0.26",
|
||||
"tslib": "^2.8.1",
|
||||
"worker-factory": "^7.0.48"
|
||||
}
|
||||
},
|
||||
"node_modules/bson": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bson/-/bson-7.0.0.tgz",
|
||||
@@ -284,12 +367,42 @@
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/call-bind": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
|
||||
@@ -355,6 +468,41 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/commist": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/commist/-/commist-3.2.0.tgz",
|
||||
"integrity": "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"engines": [
|
||||
"node >= 6.0"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.0.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-stream/node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
@@ -672,12 +820,36 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/event-lite": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/event-lite/-/event-lite-0.1.3.tgz",
|
||||
"integrity": "sha512-8qz9nOz5VeD2z96elrEKD2U433+L3DWdUdDkOINLGOJvx1GsMBbMn0aCeu28y8/e85A6mCigBiFlYMnTBEGlSw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/event-target-shim": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz",
|
||||
"integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/events": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8.x"
|
||||
}
|
||||
},
|
||||
"node_modules/extend": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
@@ -707,6 +879,19 @@
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/fast-unique-numbers": {
|
||||
"version": "9.0.26",
|
||||
"resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-9.0.26.tgz",
|
||||
"integrity": "sha512-3Mtq8p1zQinjGyWfKeuBunbuFoixG72AUkk4VvzbX4ykCW9Q4FzRaNyIlfQhUjnKw2ARVP+/CKnoyr6wfHftig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.6",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/file-type": {
|
||||
"version": "3.9.0",
|
||||
"resolved": "https://registry.npmjs.org/file-type/-/file-type-3.9.0.tgz",
|
||||
@@ -1023,6 +1208,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/help-me": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
|
||||
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/http-signature": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.4.0.tgz",
|
||||
@@ -1045,12 +1236,38 @@
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
|
||||
"integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/int64-buffer": {
|
||||
"version": "0.1.10",
|
||||
"resolved": "https://registry.npmjs.org/int64-buffer/-/int64-buffer-0.1.10.tgz",
|
||||
"integrity": "sha512-v7cSY1J8ydZ0GyjUHqF+1bshJ6cnEVLo9EnjB8p+4HDRPZc9N5jjmvUV7NvEsqQOKyH0pmIBFWXVQbiS0+OBbA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/internal-slot": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
||||
@@ -1065,6 +1282,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-array-buffer": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||
@@ -1405,6 +1631,16 @@
|
||||
"integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-sdsl": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz",
|
||||
"integrity": "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/js-sdsl"
|
||||
}
|
||||
},
|
||||
"node_modules/jsbn": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz",
|
||||
@@ -1545,6 +1781,12 @@
|
||||
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -1593,6 +1835,15 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/mongodb": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.0.0.tgz",
|
||||
@@ -1682,6 +1933,151 @@
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mqtt": {
|
||||
"version": "5.14.1",
|
||||
"resolved": "https://registry.npmjs.org/mqtt/-/mqtt-5.14.1.tgz",
|
||||
"integrity": "sha512-NxkPxE70Uq3Ph7goefQa7ggSsVzHrayCD0OyxlJgITN/EbzlZN+JEPmaAZdxP1LsIT5FamDyILoQTF72W7Nnbw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/readable-stream": "^4.0.21",
|
||||
"@types/ws": "^8.18.1",
|
||||
"commist": "^3.2.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"debug": "^4.4.1",
|
||||
"help-me": "^5.0.0",
|
||||
"lru-cache": "^10.4.3",
|
||||
"minimist": "^1.2.8",
|
||||
"mqtt-packet": "^9.0.2",
|
||||
"number-allocator": "^1.0.14",
|
||||
"readable-stream": "^4.7.0",
|
||||
"rfdc": "^1.4.1",
|
||||
"socks": "^2.8.6",
|
||||
"split2": "^4.2.0",
|
||||
"worker-timers": "^8.0.23",
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
"bin": {
|
||||
"mqtt": "build/bin/mqtt.js",
|
||||
"mqtt_pub": "build/bin/pub.js",
|
||||
"mqtt_sub": "build/bin/sub.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mqtt-packet": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-9.0.2.tgz",
|
||||
"integrity": "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bl": "^6.0.8",
|
||||
"debug": "^4.3.4",
|
||||
"process-nextick-args": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/mqtt-packet/node_modules/bl": {
|
||||
"version": "6.1.6",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz",
|
||||
"integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/readable-stream": "^4.0.0",
|
||||
"buffer": "^6.0.3",
|
||||
"inherits": "^2.0.4",
|
||||
"readable-stream": "^4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mqtt-packet/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/mqtt-packet/node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/mqtt-packet/node_modules/readable-stream": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
|
||||
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"abort-controller": "^3.0.0",
|
||||
"buffer": "^6.0.3",
|
||||
"events": "^3.3.0",
|
||||
"process": "^0.11.10",
|
||||
"string_decoder": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mqtt-packet/node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mqtt/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/mqtt/node_modules/readable-stream": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
|
||||
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"abort-controller": "^3.0.0",
|
||||
"buffer": "^6.0.3",
|
||||
"events": "^3.3.0",
|
||||
"process": "^0.11.10",
|
||||
"string_decoder": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mqtt/node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mquery": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mquery/-/mquery-6.0.0.tgz",
|
||||
@@ -1697,6 +2093,21 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/msgpack-lite": {
|
||||
"version": "0.1.26",
|
||||
"resolved": "https://registry.npmjs.org/msgpack-lite/-/msgpack-lite-0.1.26.tgz",
|
||||
"integrity": "sha512-SZ2IxeqZ1oRFGo0xFGbvBJWMp3yLIY9rlIJyxy8CGrwZn1f0ZK4r6jV/AM1r0FZMDUkWkglOk/eeKIL9g77Nxw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"event-lite": "^0.1.1",
|
||||
"ieee754": "^1.1.8",
|
||||
"int64-buffer": "^0.1.9",
|
||||
"isarray": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"msgpack": "bin/msgpack"
|
||||
}
|
||||
},
|
||||
"node_modules/node-telegram-bot-api": {
|
||||
"version": "0.66.0",
|
||||
"resolved": "https://registry.npmjs.org/node-telegram-bot-api/-/node-telegram-bot-api-0.66.0.tgz",
|
||||
@@ -1717,6 +2128,33 @@
|
||||
"node": ">=0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/number-allocator": {
|
||||
"version": "1.0.14",
|
||||
"resolved": "https://registry.npmjs.org/number-allocator/-/number-allocator-1.0.14.tgz",
|
||||
"integrity": "sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.1",
|
||||
"js-sdsl": "4.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/number-allocator/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/oauth-sign": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz",
|
||||
@@ -2091,6 +2529,12 @@
|
||||
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rfdc": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
|
||||
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-array-concat": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
|
||||
@@ -2317,6 +2761,30 @@
|
||||
"integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/smart-buffer": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
|
||||
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6.0.0",
|
||||
"npm": ">= 3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socks": {
|
||||
"version": "2.8.7",
|
||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
|
||||
"integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "^10.0.1",
|
||||
"smart-buffer": "^4.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.0.0",
|
||||
"npm": ">= 3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sparse-bitfield": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
|
||||
@@ -2326,6 +2794,15 @@
|
||||
"memory-pager": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/sshpk": {
|
||||
"version": "1.18.0",
|
||||
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz",
|
||||
@@ -2486,6 +2963,12 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||
@@ -2578,6 +3061,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/typedarray": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unbox-primitive": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
|
||||
@@ -2596,6 +3085,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
|
||||
@@ -2782,11 +3277,79 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/worker-factory": {
|
||||
"version": "7.0.48",
|
||||
"resolved": "https://registry.npmjs.org/worker-factory/-/worker-factory-7.0.48.tgz",
|
||||
"integrity": "sha512-CGmBy3tJvpBPjUvb0t4PrpKubUsfkI1Ohg0/GGFU2RvA9j/tiVYwKU8O7yu7gH06YtzbeJLzdUR29lmZKn5pag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.6",
|
||||
"fast-unique-numbers": "^9.0.26",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/worker-timers": {
|
||||
"version": "8.0.29",
|
||||
"resolved": "https://registry.npmjs.org/worker-timers/-/worker-timers-8.0.29.tgz",
|
||||
"integrity": "sha512-9jk0MWHhWAZ2xlJPXr45oe5UF/opdpfZrY0HtyPizWuJ+ce1M3IYk/4IIdGct3kn9Ncfs+tkZt3w1tU6KW2Fsg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.6",
|
||||
"tslib": "^2.8.1",
|
||||
"worker-timers-broker": "^8.0.15",
|
||||
"worker-timers-worker": "^9.0.13"
|
||||
}
|
||||
},
|
||||
"node_modules/worker-timers-broker": {
|
||||
"version": "8.0.15",
|
||||
"resolved": "https://registry.npmjs.org/worker-timers-broker/-/worker-timers-broker-8.0.15.tgz",
|
||||
"integrity": "sha512-Te+EiVUMzG5TtHdmaBZvBrZSFNauym6ImDaCAnzQUxvjnw+oGjMT2idmAOgDy30vOZMLejd0bcsc90Axu6XPWA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.6",
|
||||
"broker-factory": "^3.1.13",
|
||||
"fast-unique-numbers": "^9.0.26",
|
||||
"tslib": "^2.8.1",
|
||||
"worker-timers-worker": "^9.0.13"
|
||||
}
|
||||
},
|
||||
"node_modules/worker-timers-worker": {
|
||||
"version": "9.0.13",
|
||||
"resolved": "https://registry.npmjs.org/worker-timers-worker/-/worker-timers-worker-9.0.13.tgz",
|
||||
"integrity": "sha512-qjn18szGb1kjcmh2traAdki1eiIS5ikFo+L90nfMOvSRpuDw1hAcR1nzkP2+Hkdqz5thIRnfuWx7QSpsEUsA6Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.6",
|
||||
"tslib": "^2.8.1",
|
||||
"worker-factory": "^7.0.48"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,12 +25,8 @@
|
||||
"dependencies": {
|
||||
"axios": "^1.12.2",
|
||||
"dotenv": "^17.2.3",
|
||||
"form-data": "^4.0.5",
|
||||
"fs": "0.0.1-security",
|
||||
"i": "^0.3.7",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mongoose": "^9.1.2",
|
||||
"msgpack-lite": "^0.1.26",
|
||||
"node-telegram-bot-api": "^0.66.0",
|
||||
"path": "^0.12.7"
|
||||
"ws": "^8.19.0"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
plugin/.DS_Store
vendored
BIN
plugin/.DS_Store
vendored
Binary file not shown.
@@ -1,32 +0,0 @@
|
||||
const apiToken = "08a9a9828f8186c661d0293741fd01971bc2d2f4"
|
||||
|
||||
function aisStream() {
|
||||
|
||||
const socket = new WebSocket('wss://stream.aisstream.io/v0/stream');
|
||||
socket.onopen = function (_) {
|
||||
let subscriptionMessage = {
|
||||
Apikey: apiToken,
|
||||
BoundingBox: [[15.0, 37.5], [16.5, 38.8]]
|
||||
}
|
||||
socket.send(JSON.stringify(subscriptionMessage));
|
||||
|
||||
console.log("✅ WebSocket Connected");
|
||||
};
|
||||
|
||||
socket.onmessage = function (event) {
|
||||
event.data.text().then(text => {
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
console.log(json);
|
||||
|
||||
} catch (e) {
|
||||
console.error("Invalid JSON:", text);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
socket.onerror = (error) => console.error('WebSocket Error:', error);
|
||||
socket.onclose = () => console.log('WebSocket Connection Closed');
|
||||
}
|
||||
|
||||
module.exports = { aisStream };
|
||||
@@ -83,15 +83,28 @@ function formatWithUnit(value, unitKey, category = 'forecast') {
|
||||
return `${value}${unit}`;
|
||||
}
|
||||
|
||||
async function getForecast(location) {
|
||||
async function getForecast(location, options = { mode: 'both' }) {
|
||||
|
||||
const mode = options.mode || 'both';
|
||||
const params = [];
|
||||
|
||||
const currentParams = FORECAST_PARAMS.current.join(",");
|
||||
const hourlyParams = FORECAST_PARAMS.hourly.join(",");
|
||||
|
||||
if (mode === 'both' || mode === 'current') {
|
||||
params.push('current=' + currentParams);
|
||||
}
|
||||
|
||||
if (mode === 'both' || mode === 'hourly') {
|
||||
params.push('hourly=' + hourlyParams);
|
||||
}
|
||||
|
||||
if (!location?.latitude || !location?.longitude) {
|
||||
console.warn('[OpenMeteo] Coordinate non valide per forecast');
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentParams = FORECAST_PARAMS.current.join(",");
|
||||
const hourlyParams = FORECAST_PARAMS.hourly.join(",");
|
||||
const api = `https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&hourly=${hourlyParams}¤t=${currentParams}`;
|
||||
const api = `https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&${params.join('&')}`;
|
||||
|
||||
try {
|
||||
const response = await axios.get(api, {
|
||||
@@ -102,11 +115,6 @@ async function getForecast(location) {
|
||||
|
||||
const { data } = response;
|
||||
|
||||
if (!data?.current) {
|
||||
console.warn('[OpenMeteo Forecast] Risposta senza dati current');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Aggiorna unità globali da API response
|
||||
if (data.current_units) {
|
||||
globalUnits.forecast = {
|
||||
@@ -122,23 +130,36 @@ async function getForecast(location) {
|
||||
}
|
||||
|
||||
return {
|
||||
temperature: data.current.temperature_2m ?? null,
|
||||
humidity: data.current.relative_humidity_2m ?? null,
|
||||
pressure: data.current.pressure_msl ?? null,
|
||||
windSpeed: data.current.wind_speed_10m ?? null,
|
||||
windDirection: data.current.wind_direction_10m ?? null,
|
||||
windGusts: data.current.wind_gusts_10m ?? null,
|
||||
rain: data.current.rain ?? null,
|
||||
precipitation: data.current.precipitation ?? null,
|
||||
// Unità di misura
|
||||
timestamp: Date.now(),
|
||||
temperature: data.current?.temperature_2m ?? null,
|
||||
humidity: data.current?.relative_humidity_2m ?? null,
|
||||
pressure: data.current?.pressure_msl ?? null,
|
||||
|
||||
// Refactored to match sensorsReferences.json hierarchy
|
||||
wind: {
|
||||
speed: data.current?.wind_speed_10m ?? null,
|
||||
direction: data.current?.wind_direction_10m ?? null,
|
||||
gusts: data.current?.wind_gusts_10m ?? null,
|
||||
},
|
||||
|
||||
rain: data.current?.rain ?? null,
|
||||
precipitation: data.current?.precipitation ?? null, // Keeping simple properties flat
|
||||
|
||||
// Unita di misura
|
||||
units: globalUnits.forecast,
|
||||
// Dati orari per grafici
|
||||
hourly: {
|
||||
// Parametri orari
|
||||
hourly: data.hourly ? {
|
||||
time: data.hourly?.time,
|
||||
temperature: data.hourly?.temperature_2m,
|
||||
pressure: data.hourly?.pressure_msl,
|
||||
precipitationProbability: data.hourly?.precipitation_probability,
|
||||
precipitation: data.hourly?.precipitation,
|
||||
rain: data.hourly?.rain,
|
||||
cloudCover: data.hourly?.cloud_cover,
|
||||
windDirection: data.hourly?.wind_direction_10m,
|
||||
humidity: data.hourly?.relative_humidity_2m,
|
||||
windSpeed: data.hourly?.wind_speed_10m
|
||||
},
|
||||
} : null,
|
||||
hourlyUnits: data.hourly_units || null
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -147,15 +168,28 @@ async function getForecast(location) {
|
||||
}
|
||||
}
|
||||
|
||||
async function getSeaConditions(location) {
|
||||
async function getSeaConditions(location, options = { mode: 'both' }) {
|
||||
|
||||
const mode = options.mode || 'both';
|
||||
const params = [];
|
||||
|
||||
const currentParams = MARINE_PARAMS.current.join(",");
|
||||
const hourlyParams = MARINE_PARAMS.hourly.join(",");
|
||||
|
||||
if (mode === 'both' || mode === 'current') {
|
||||
params.push('current=' + currentParams);
|
||||
}
|
||||
|
||||
if (mode === 'both' || mode === 'hourly') {
|
||||
params.push('hourly=' + hourlyParams);
|
||||
}
|
||||
|
||||
if (!location?.latitude || !location?.longitude) {
|
||||
console.warn('[OpenMeteo] Coordinate non valide per onde');
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentParams = MARINE_PARAMS.current.join(",");
|
||||
const hourlyParams = MARINE_PARAMS.hourly.join(",");
|
||||
const api = `https://marine-api.open-meteo.com/v1/marine?latitude=${location.latitude}&longitude=${location.longitude}&hourly=${hourlyParams}¤t=${currentParams}&models=ecmwf_wam`;
|
||||
const api = `https://marine-api.open-meteo.com/v1/marine?latitude=${location.latitude}&longitude=${location.longitude}&${params.join('&')}&models=ecmwf_wam`;
|
||||
|
||||
try {
|
||||
const response = await axios.get(api, {
|
||||
@@ -166,11 +200,6 @@ async function getSeaConditions(location) {
|
||||
|
||||
const { data } = response;
|
||||
|
||||
if (!data?.current) {
|
||||
console.warn('[OpenMeteo Marine] Risposta senza dati current');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Aggiorna unità globali da API response
|
||||
if (data.current_units) {
|
||||
globalUnits.waves = {
|
||||
@@ -184,23 +213,28 @@ async function getSeaConditions(location) {
|
||||
}
|
||||
|
||||
return {
|
||||
waveHeight: data.current.wave_height ?? null,
|
||||
wavePeriod: data.current.wave_period ?? null,
|
||||
waveDirection: data.current.wave_direction ?? null,
|
||||
wavePeakPeriod: data.current.wave_peak_period ?? null,
|
||||
currentDirection: data.current.ocean_current_direction ?? null,
|
||||
currentVelocity: data.current.ocean_current_velocity ?? null,
|
||||
// Refactored to match sensorsReferences.json hierarchy
|
||||
waves: {
|
||||
height: data.current?.wave_height ?? null,
|
||||
period: data.current?.wave_period ?? null,
|
||||
direction: data.current?.wave_direction ?? null,
|
||||
peakPeriod: data.current?.wave_peak_period ?? null
|
||||
},
|
||||
|
||||
// Keeping these flat essentially
|
||||
currentDirection: data.current?.ocean_current_direction ?? null,
|
||||
currentVelocity: data.current?.ocean_current_velocity ?? null,
|
||||
// Unità di misura
|
||||
units: globalUnits.waves,
|
||||
// Dati orari per grafici
|
||||
hourly: {
|
||||
hourly: data.hourly ? {
|
||||
time: data.hourly?.time,
|
||||
waveHeight: data.hourly?.wave_height,
|
||||
wavePeriod: data.hourly?.wave_period,
|
||||
waveDirection: data.hourly?.wave_direction,
|
||||
currentDirection: data.hourly?.ocean_current_direction,
|
||||
currentVelocity: data.hourly?.ocean_current_velocity
|
||||
},
|
||||
} : null,
|
||||
hourlyUnits: data.hourly_units || null
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,947 +0,0 @@
|
||||
/**
|
||||
* telegram.core.js - Bot Telegram ottimizzato per MEB SignalK
|
||||
* Gestione utenti, comandi e live updates
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { paths } = require("../config.js");
|
||||
const {
|
||||
encrypt,
|
||||
decrypt,
|
||||
generateToken,
|
||||
generateReadableToken,
|
||||
encryptLog,
|
||||
decryptLog,
|
||||
loadSecureFile,
|
||||
saveSecureFile
|
||||
} = require("../tools/crypt");
|
||||
|
||||
const TelegramBot = require('node-telegram-bot-api');
|
||||
|
||||
function getSK(path) {
|
||||
if (!app) return null;
|
||||
const v = app.getSelfPath(path);
|
||||
return v && v.value !== undefined && v.value !== null ? v.value : null;
|
||||
}
|
||||
|
||||
// ==================== INIZIALIZZAZIONE BOT ====================
|
||||
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
|
||||
let bot = null;
|
||||
|
||||
function initBot() {
|
||||
if (!BOT_TOKEN) {
|
||||
console.warn("[Telegram] BOT_TOKEN non impostato: bot disabilitato.");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Riusa istanza esistente se disponibile
|
||||
if (global.__meb_telegram_bot) {
|
||||
bot = global.__meb_telegram_bot;
|
||||
console.log("[Telegram] Riutilizzo istanza bot esistente");
|
||||
} else {
|
||||
bot = new TelegramBot(BOT_TOKEN, { polling: true });
|
||||
global.__meb_telegram_bot = bot;
|
||||
console.log("[Telegram] Nuova istanza bot creata");
|
||||
}
|
||||
|
||||
// Registra handlers solo una volta
|
||||
if (!global.__meb_telegram_handlers) {
|
||||
global.__meb_telegram_handlers = true;
|
||||
registerHandlers();
|
||||
console.log("[Telegram] Handlers registrati");
|
||||
}
|
||||
|
||||
return bot;
|
||||
}
|
||||
|
||||
// Inizializza all'import
|
||||
bot = initBot();
|
||||
|
||||
// ==================== CONFIGURAZIONE ====================
|
||||
const CONFIG = {
|
||||
filesPerPage: 8,
|
||||
liveUpdateInterval: 3000,
|
||||
fileExpirationTime: 10
|
||||
};
|
||||
|
||||
const telegram_users_file = paths.telegramUsers;
|
||||
const logs_references_file = paths.logsReferences;
|
||||
const authorized_admins_file = paths.authorizedAdmins;
|
||||
|
||||
let app = null;
|
||||
|
||||
// Maps per gestione timer e stati
|
||||
const liveParamIntervals = new Map();
|
||||
const keyExpirationTimers = new Map();
|
||||
|
||||
// ==================== GESTIONE FILE SENSIBILI ====================
|
||||
|
||||
function loadAuthorizedAdmins() {
|
||||
try {
|
||||
if (!fs.existsSync(authorized_admins_file)) {
|
||||
return new Set();
|
||||
}
|
||||
const content = fs.readFileSync(authorized_admins_file, 'utf8');
|
||||
const admins = content
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line && !line.startsWith('#'));
|
||||
return new Set(admins);
|
||||
} catch (error) {
|
||||
console.error('[Telegram] Errore caricamento admin:', error.message);
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function saveAuthorizedAdmins(admins) {
|
||||
try {
|
||||
const adminArray = Array.from(admins);
|
||||
const content = '# Authorized Admin ChatIDs (one per line)\n' + adminArray.join('\n');
|
||||
fs.writeFileSync(authorized_admins_file, content, 'utf8');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[Telegram] Errore salvataggio admin:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isAdmin(chatID) {
|
||||
const admins = loadAuthorizedAdmins();
|
||||
return admins.has(String(chatID));
|
||||
}
|
||||
|
||||
function loadUsers() {
|
||||
return loadSecureFile(telegram_users_file, []);
|
||||
}
|
||||
|
||||
function saveUsers(users) {
|
||||
saveSecureFile(telegram_users_file, users);
|
||||
}
|
||||
|
||||
function loadLogsReferences() {
|
||||
return loadSecureFile(logs_references_file, { references: [] });
|
||||
}
|
||||
|
||||
function saveLogsReferences(data) {
|
||||
saveSecureFile(logs_references_file, data);
|
||||
}
|
||||
|
||||
function isAuthenticated(chatID) {
|
||||
const user = getUserByChatID(chatID);
|
||||
return user && user.hasLoggedYet;
|
||||
}
|
||||
|
||||
function createNewUser(permissions = ["basic"]) {
|
||||
const users = loadUsers();
|
||||
const newUser = {
|
||||
token: generateReadableToken(24),
|
||||
chatID: null,
|
||||
isAuthorized: permissions,
|
||||
hasLoggedYet: false
|
||||
};
|
||||
users.push(newUser);
|
||||
saveUsers(users);
|
||||
return newUser;
|
||||
}
|
||||
|
||||
function login(token, chatID) {
|
||||
const users = loadUsers();
|
||||
const userIDX = users.findIndex(u => u.token === token);
|
||||
|
||||
if (userIDX === -1) {
|
||||
throw new Error("Token non valido");
|
||||
}
|
||||
|
||||
const user = users[userIDX];
|
||||
|
||||
if (user.hasLoggedYet && user.chatID && user.chatID !== String(chatID)) {
|
||||
throw new Error("Questo token è già associato ad un altro account");
|
||||
}
|
||||
|
||||
if (!user.hasLoggedYet) {
|
||||
const newToken = generateReadableToken(32);
|
||||
user.token = newToken;
|
||||
user.hasLoggedYet = true;
|
||||
user.chatID = String(chatID);
|
||||
users[userIDX] = user;
|
||||
saveUsers(users);
|
||||
return { ...user, isFirstLogin: true, newToken };
|
||||
}
|
||||
|
||||
user.chatID = String(chatID);
|
||||
users[userIDX] = user;
|
||||
saveUsers(users);
|
||||
return { ...user, isFirstLogin: false };
|
||||
}
|
||||
|
||||
function logout(chatID) {
|
||||
const users = loadUsers();
|
||||
const userIDX = users.findIndex(u => u.chatID === String(chatID));
|
||||
|
||||
if (userIDX === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
saveUsers(users);
|
||||
return users[userIDX];
|
||||
}
|
||||
|
||||
function getUserWith(token) {
|
||||
const users = loadUsers();
|
||||
return users.find(u => u.token === token);
|
||||
}
|
||||
|
||||
function getUserByChatID(chatID) {
|
||||
const users = loadUsers();
|
||||
return users.find(u => u.chatID === String(chatID));
|
||||
}
|
||||
|
||||
async function linkBot(appInstance) {
|
||||
app = appInstance;
|
||||
if (!bot) {
|
||||
console.warn("[MEB TELEGRAM] linkBot chiamato senza TOKEN: ritorno null.");
|
||||
return null;
|
||||
}
|
||||
return bot;
|
||||
}
|
||||
|
||||
function fetchFiles(chatId, page = 0) {
|
||||
const logDirectory = path.join(__dirname, "..", "datasetModels/saved_datas");
|
||||
|
||||
try {
|
||||
const logsData = loadLogsReferences();
|
||||
const registeredFiles = new Set((logsData.references || []).map(r => r.name));
|
||||
|
||||
const items = fs.readdirSync(logDirectory);
|
||||
|
||||
const files = items.filter(item => {
|
||||
const fullPath = path.join(logDirectory, item);
|
||||
return fs.statSync(fullPath).isFile() && registeredFiles.has(item);
|
||||
});
|
||||
|
||||
if (files.length === 0) {
|
||||
bot.sendMessage(chatId, "📂 Non ci sono log salvati.");
|
||||
return;
|
||||
}
|
||||
|
||||
const sortedFiles = files
|
||||
.map(file => ({
|
||||
name: file,
|
||||
time: fs.statSync(path.join(logDirectory, file)).mtime.getTime()
|
||||
}))
|
||||
.sort((a, b) => b.time - a.time)
|
||||
.map(file => file.name);
|
||||
|
||||
const totalPages = Math.ceil(sortedFiles.length / CONFIG.filesPerPage);
|
||||
let currentPage = page;
|
||||
if (currentPage < 0) currentPage = 0;
|
||||
if (currentPage > totalPages - 1) currentPage = totalPages - 1;
|
||||
|
||||
const startIdx = currentPage * CONFIG.filesPerPage;
|
||||
const endIdx = startIdx + CONFIG.filesPerPage;
|
||||
const pageFiles = sortedFiles.slice(startIdx, endIdx);
|
||||
|
||||
const fileButtons = pageFiles.map(file => [
|
||||
{ text: `📄 ${file}`, callback_data: `request_file_${file}` }
|
||||
]);
|
||||
|
||||
const navigationButtons = [];
|
||||
|
||||
if (totalPages > 1) {
|
||||
const navRow = [];
|
||||
if (currentPage > 0) {
|
||||
navRow.push({ text: "←", callback_data: `page_${currentPage - 1}` });
|
||||
}
|
||||
navRow.push({ text: `📖 ${currentPage + 1}/${totalPages}`, callback_data: `page_info` });
|
||||
if (currentPage < totalPages - 1) {
|
||||
navRow.push({ text: "→", callback_data: `page_${currentPage + 1}` });
|
||||
}
|
||||
navigationButtons.push(navRow);
|
||||
}
|
||||
|
||||
navigationButtons.push([{ text: "Annulla", callback_data: "dismiss" }]);
|
||||
|
||||
bot.sendMessage(chatId,
|
||||
`📥 *Logs di Bordo*\n` +
|
||||
`Ogni file corrisponde ad una *sessione*. Seleziona un file per scaricarlo.\n` +
|
||||
`⚠️ Avrai solo *10 secondi* per salvare file e chiave.`,
|
||||
{
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: { inline_keyboard: [...fileButtons, ...navigationButtons] }
|
||||
}
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
bot.sendMessage(chatId, `Errore lettura directory: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentPosition() {
|
||||
if (!app) return null;
|
||||
const position = app.getSelfPath('navigation.position');
|
||||
if (!position) return null;
|
||||
return {
|
||||
latitude: position.value.latitude,
|
||||
longitude: position.value.longitude,
|
||||
};
|
||||
}
|
||||
|
||||
async function send(message) {
|
||||
if (!bot) {
|
||||
console.warn('[Telegram] send() chiamato ma bot non inizializzato');
|
||||
return;
|
||||
}
|
||||
const users = loadUsers();
|
||||
const loggedUsers = users.filter(u => u.hasLoggedYet && u.chatID);
|
||||
|
||||
console.log(`[Telegram] send() - Utenti totali: ${users.length}, Utenti loggati: ${loggedUsers.length}`);
|
||||
|
||||
if (loggedUsers.length === 0) {
|
||||
console.warn('[Telegram] Nessun utente loggato a cui inviare il messaggio');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const user of loggedUsers) {
|
||||
try {
|
||||
await bot.sendMessage(user.chatID, message);
|
||||
console.log(`[Telegram] Messaggio inviato a ${user.chatID}`);
|
||||
} catch (error) {
|
||||
console.error(`[Telegram] Send error to ${user.chatID}:`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== RENDER FUNCTIONS ====================
|
||||
|
||||
function renderPositionText() {
|
||||
if (!app) return "❌ App non disponibile";
|
||||
|
||||
const pos = app.getSelfPath('navigation.position')?.value;
|
||||
const sog = getSK('navigation.speedOverGround');
|
||||
const cog = getSK('navigation.courseOverGroundTrue');
|
||||
const heading = getSK('navigation.headingTrue');
|
||||
|
||||
const lat = pos?.latitude?.toFixed(5) ?? "N/A";
|
||||
const lon = pos?.longitude?.toFixed(5) ?? "N/A";
|
||||
const speed = sog != null ? (sog * 1.94384).toFixed(1) : "N/A"; // m/s to knots
|
||||
const course = cog != null ? (cog * 180 / Math.PI).toFixed(0) : "N/A"; // rad to deg
|
||||
const headingDeg = heading != null ? (heading * 180 / Math.PI).toFixed(0) : "N/A";
|
||||
|
||||
return `📍 *Posizione & Velocità*\n\n` +
|
||||
`Latitudine: \`${lat}\`\n` +
|
||||
`Longitudine: \`${lon}\`\n` +
|
||||
`SOG: ${speed} kn\n` +
|
||||
`COG: ${course}°\n` +
|
||||
`Heading: ${headingDeg}°`;
|
||||
}
|
||||
|
||||
function renderWindText() {
|
||||
const speed = getSK('meb.wind.speed');
|
||||
const direction = getSK('meb.wind.direction');
|
||||
|
||||
return `🌬️ *Vento*\n\n` +
|
||||
`Velocità: ${speed} km/h\n` +
|
||||
`Direzione: ${direction}°\n`;
|
||||
}
|
||||
|
||||
function renderWavesText() {
|
||||
|
||||
const height = getSK('meb.waves.height');
|
||||
const period = getSK('meb.waves.period');
|
||||
const dir = getSK('meb.waves.direction');
|
||||
|
||||
return `🌊 *Onde*\n\n` +
|
||||
`Altezza: ${height} m\n` +
|
||||
`Periodo: ${period} s\n` +
|
||||
`Direzione: ${dir}°`;
|
||||
}
|
||||
|
||||
function renderForecastsText() {
|
||||
const temp = getSK('meb.temperature');
|
||||
const humidity = getSK('meb.humidity');
|
||||
const pressure = getSK('meb.pressure');
|
||||
const rain = getSK('meb.precipitation');
|
||||
return `⛅️ *Previsioni Meteo*\n\n` +
|
||||
`Temperatura: ${temp} °C\n` +
|
||||
`Umidità: ${humidity} %\n` +
|
||||
`Pressione: ${pressure} hPa\n`;
|
||||
}
|
||||
|
||||
function renderBatteriesText() {
|
||||
|
||||
const voltage = getSK('electrical.batteries.traction.voltage');
|
||||
const current = getSK('electrical.batteries.traction.current');
|
||||
const soc = getSK('electrical.batteries.traction.stateOfCharge');
|
||||
const power = getSK('electrical.batteries.traction.power');
|
||||
|
||||
return `🔋 *Batterie*\n\n` +
|
||||
`Tensione: ${voltage?.toFixed(1) ?? "N/A"} V\n` +
|
||||
`Corrente: ${current?.toFixed(1) ?? "N/A"} A\n` +
|
||||
`SOC: ${soc != null ? (soc * 100).toFixed(0) : "N/A"} %\n` +
|
||||
`Potenza: ${power?.toFixed(0) ?? "N/A"} W`;
|
||||
}
|
||||
|
||||
function renderDashboardText() {
|
||||
const posText = renderPositionText()
|
||||
const windText = renderWindText()
|
||||
const wavesText = renderWavesText()
|
||||
const forecastText = renderForecastsText()
|
||||
const battText = renderBatteriesText()
|
||||
|
||||
return `📊 *Dashboard Completa*\n` +
|
||||
`\n${posText}\n\n` +
|
||||
`\n${forecastText}\n\n` +
|
||||
`\n${windText}\n\n` +
|
||||
`\n${wavesText}\n\n` +
|
||||
`\n${battText}`;
|
||||
}
|
||||
|
||||
// ==================== REGISTRAZIONE HANDLERS ====================
|
||||
|
||||
function registerHandlers() {
|
||||
if (!bot) return;
|
||||
|
||||
// Handler: /start
|
||||
bot.onText(/\/start/, (msg) => {
|
||||
const chatId = msg.chat.id;
|
||||
|
||||
if (isAuthenticated(chatId)) {
|
||||
const menu = {
|
||||
keyboard: [
|
||||
[{ text: "📊 Dashboard" }],
|
||||
[{ text: "Parametri di Bordo" }],
|
||||
[{ text: "File di Logs" }],
|
||||
[{ text: "Genera un nuovo log" }],
|
||||
[{ text: "Stato dei Log" }]
|
||||
],
|
||||
resize_keyboard: true,
|
||||
one_time_keyboard: false
|
||||
};
|
||||
|
||||
bot.sendMessage(chatId,
|
||||
"Benvenuto nel Data Console.\n" +
|
||||
"• Visualizza i dati del computer di bordo\n" +
|
||||
"• Ricevi aggiornamenti su parametri a scelta\n" +
|
||||
"• Scarica i file di log della barca",
|
||||
{ parse_mode: 'Markdown', reply_markup: menu }
|
||||
);
|
||||
} else {
|
||||
bot.sendMessage(chatId,
|
||||
"Benvenuto nel MEB Data Console!\n" +
|
||||
"Per accedere ai dati è necessario un token di accesso.",
|
||||
{ parse_mode: 'Markdown' }
|
||||
);
|
||||
|
||||
bot.sendMessage(chatId, "👤 Login", {
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[{ text: "❓ Come ottengo un token", callback_data: "token_login_question" }],
|
||||
[{ text: "🔑 Ho un token", callback_data: "token_ready" }]
|
||||
]
|
||||
},
|
||||
parse_mode: 'Markdown'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Menu testuale
|
||||
bot.onText(/📊 Dashboard/, (msg) => {
|
||||
const chatId = msg.chat.id;
|
||||
if (!isAuthenticated(chatId)) {
|
||||
bot.sendMessage(chatId, "Effettua prima il login con /login <token>");
|
||||
return;
|
||||
}
|
||||
|
||||
const dashboardMsg = renderDashboardText();
|
||||
bot.sendMessage(chatId, dashboardMsg, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[{ text: "🔄 Aggiorna", callback_data: "refresh_dashboard" }],
|
||||
[{ text: "📡 Live (3s)", callback_data: "live_dashboard" }]
|
||||
]
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
bot.onText(/File di Logs/, (msg) => {
|
||||
const chatId = msg.chat.id;
|
||||
if (!isAuthenticated(chatId)) {
|
||||
bot.sendMessage(chatId, "Effettua prima il login con /login <token>");
|
||||
return;
|
||||
}
|
||||
fetchFiles(chatId, 0);
|
||||
});
|
||||
|
||||
bot.onText(/Parametri di Bordo/, (msg) => {
|
||||
const chatId = msg.chat.id;
|
||||
|
||||
if (!isAuthenticated(chatId)) {
|
||||
bot.sendMessage(chatId, "Effettua il login con /login <token>");
|
||||
return;
|
||||
}
|
||||
|
||||
bot.sendMessage(chatId, "*Parametri di Bordo*\nQui potrai visualizzare i parametri attuali del computer di bordo. Scegli il parametro che vuoi visualizzare dal menu qui sotto.", {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[{ text: "📊 Dashboard", callback_data: "get_dashboard" }],
|
||||
[{ text: "⛅️ Meteo", callback_data: "get_forecasts" }],
|
||||
[{ text: "📍 Posizione", callback_data: "get_position" }],
|
||||
[{ text: "🌬️ Vento", callback_data: "get_wind" }],
|
||||
[{ text: "🌊 Onde", callback_data: "get_waves" }],
|
||||
[{ text: "🔋 Batterie", callback_data: "get_batteries" }],
|
||||
[{ text: "Annulla", callback_data: "dismiss" }]
|
||||
]
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Login
|
||||
bot.onText(/\/login\s+(.+)/, (msg, match) => {
|
||||
const chatID = msg.chat.id;
|
||||
const token = (match && match[1] || "").trim();
|
||||
|
||||
if (!token) {
|
||||
bot.sendMessage(chatID, "Inserisci il token: /login <token>");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = login(token, chatID);
|
||||
if (!result) {
|
||||
bot.sendMessage(chatID, "Token non valido.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.isFirstLogin) {
|
||||
bot.sendMessage(chatID,
|
||||
`*Primo accesso completato!*\n\n` +
|
||||
`Il tuo nuovo token permanente:\n\`${result.newToken}\`\n\n` +
|
||||
`Salvalo! Non potrà essere usato da altri account.`,
|
||||
{ parse_mode: 'Markdown' }
|
||||
);
|
||||
} else {
|
||||
bot.sendMessage(chatID, "✅ Login effettuato!");
|
||||
}
|
||||
|
||||
const menu = {
|
||||
keyboard: [
|
||||
[{ text: "📊 Dashboard" }],
|
||||
[{ text: "Parametri di Bordo" }],
|
||||
[{ text: "File di Logs" }],
|
||||
[{ text: "Genera un nuovo log" }],
|
||||
[{ text: "Stato dei Log" }]
|
||||
],
|
||||
resize_keyboard: true
|
||||
};
|
||||
|
||||
bot.sendMessage(chatID, "Menu principale:", { reply_markup: menu });
|
||||
} catch (error) {
|
||||
bot.sendMessage(chatID, `❌ ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
bot.onText(/\/logout/, (msg) => {
|
||||
const chatID = msg.chat.id;
|
||||
const user = logout(chatID);
|
||||
if (!user) {
|
||||
bot.sendMessage(chatID, "Non sei loggato.");
|
||||
return;
|
||||
}
|
||||
bot.sendMessage(chatID, "Logout effettuato. Usa /login <token> per rientrare.");
|
||||
});
|
||||
|
||||
// Admin commands
|
||||
bot.onText(/\/newuser(?:\s+(.*))?/, (msg, match) => {
|
||||
const chatID = msg.chat.id;
|
||||
|
||||
if (!isAdmin(chatID)) {
|
||||
bot.sendMessage(chatID, "⛔ Non autorizzato.");
|
||||
return;
|
||||
}
|
||||
|
||||
const permissionsArg = (match && match[1] || "").trim();
|
||||
const permissions = permissionsArg
|
||||
? permissionsArg.split(',').map(p => p.trim()).filter(p => p)
|
||||
: ["basic"];
|
||||
|
||||
try {
|
||||
const newUser = createNewUser(permissions);
|
||||
bot.sendMessage(chatID,
|
||||
`✅ *Nuovo utente creato*\n\nToken: \`${newUser.token}\``,
|
||||
{ parse_mode: 'Markdown' }
|
||||
);
|
||||
} catch (error) {
|
||||
bot.sendMessage(chatID, `❌ ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
bot.onText(/\/addadmin\s+(\d+)/, (msg, match) => {
|
||||
const chatID = msg.chat.id;
|
||||
const newAdminID = match && match[1];
|
||||
|
||||
if (!isAdmin(chatID)) {
|
||||
bot.sendMessage(chatID, "⛔ Non autorizzato.");
|
||||
return;
|
||||
}
|
||||
|
||||
const admins = loadAuthorizedAdmins();
|
||||
if (admins.has(newAdminID)) {
|
||||
bot.sendMessage(chatID, "Già admin.");
|
||||
return;
|
||||
}
|
||||
|
||||
admins.add(newAdminID);
|
||||
saveAuthorizedAdmins(admins);
|
||||
bot.sendMessage(chatID, `✅ Admin \`${newAdminID}\` aggiunto.`, { parse_mode: 'Markdown' });
|
||||
});
|
||||
|
||||
bot.onText(/\/removeadmin\s+(\d+)/, (msg, match) => {
|
||||
const chatID = msg.chat.id;
|
||||
const adminToRemove = match && match[1];
|
||||
|
||||
if (!isAdmin(chatID)) {
|
||||
bot.sendMessage(chatID, "⛔ Non autorizzato.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (adminToRemove === String(chatID)) {
|
||||
bot.sendMessage(chatID, "Non puoi rimuovere te stesso.");
|
||||
return;
|
||||
}
|
||||
|
||||
const admins = loadAuthorizedAdmins();
|
||||
if (!admins.has(adminToRemove)) {
|
||||
bot.sendMessage(chatID, "Non è admin.");
|
||||
return;
|
||||
}
|
||||
|
||||
admins.delete(adminToRemove);
|
||||
saveAuthorizedAdmins(admins);
|
||||
bot.sendMessage(chatID, `✅ Admin \`${adminToRemove}\` rimosso.`, { parse_mode: 'Markdown' });
|
||||
});
|
||||
|
||||
bot.onText(/\/listusers/, (msg) => {
|
||||
const chatID = msg.chat.id;
|
||||
|
||||
if (!isAdmin(chatID)) {
|
||||
bot.sendMessage(chatID, "⛔ Non autorizzato.");
|
||||
return;
|
||||
}
|
||||
|
||||
const users = loadUsers();
|
||||
if (users.length === 0) {
|
||||
bot.sendMessage(chatID, "Nessun utente.");
|
||||
return;
|
||||
}
|
||||
|
||||
let message = `👥 *Utenti:* ${users.length}\n\n`;
|
||||
users.forEach((user, idx) => {
|
||||
const status = user.hasLoggedYet ? '✅' : '⏳';
|
||||
message += `${idx + 1}. ${status} \`${user.chatID || 'N/A'}\`\n`;
|
||||
});
|
||||
|
||||
bot.sendMessage(chatID, message, { parse_mode: 'Markdown' });
|
||||
});
|
||||
|
||||
bot.onText(/\/mychatid/, (msg) => {
|
||||
bot.sendMessage(msg.chat.id, `ChatID: \`${msg.chat.id}\``, { parse_mode: 'Markdown' });
|
||||
});
|
||||
|
||||
// Interval control
|
||||
bot.onText(/\/changei\s+(log|api)\s+(\d+)/, (msg, match) => {
|
||||
const chatID = msg.chat.id;
|
||||
|
||||
if (!isAdmin(chatID)) {
|
||||
bot.sendMessage(chatID, "⛔ Non autorizzato.");
|
||||
return;
|
||||
}
|
||||
|
||||
const type = match[1];
|
||||
const seconds = parseInt(match[2], 10);
|
||||
|
||||
if (isNaN(seconds) || seconds < 1) {
|
||||
bot.sendMessage(chatID, "❌ Secondi non validi (min 1).");
|
||||
return;
|
||||
}
|
||||
|
||||
const newIntervalMs = seconds * 1000;
|
||||
|
||||
// Debug: verifica stato app
|
||||
if (!app) {
|
||||
bot.sendMessage(chatID, "❌ App non inizializzata. Riprova tra qualche secondo.");
|
||||
console.error('[Telegram] app è null in change_interval');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!app.intervalControl) {
|
||||
bot.sendMessage(chatID, "❌ Sistema intervalControl non disponibile. Il plugin potrebbe non essere ancora avviato.");
|
||||
console.error('[Telegram] app.intervalControl non esiste');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = app.intervalControl.updateInterval(type, newIntervalMs);
|
||||
|
||||
if (result) {
|
||||
const typeLabel = type === 'log' ? 'Log recording' : 'OpenMeteo API';
|
||||
bot.sendMessage(chatID,
|
||||
`✅ *${typeLabel}* aggiornato a *${seconds}s*`,
|
||||
{ parse_mode: 'Markdown' }
|
||||
);
|
||||
} else {
|
||||
bot.sendMessage(chatID, "❌ Tipo non valido. Usa: `log` o `api`", { parse_mode: 'Markdown' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Telegram] Errore change_interval:', error);
|
||||
bot.sendMessage(chatID, `❌ Errore: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
bot.onText(/\/intervals/, (msg) => {
|
||||
const chatID = msg.chat.id;
|
||||
|
||||
if (!isAdmin(chatID)) {
|
||||
bot.sendMessage(chatID, "⛔ Non autorizzato.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!app) {
|
||||
bot.sendMessage(chatID, "❌ App non inizializzata.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!app.intervalControl) {
|
||||
bot.sendMessage(chatID, "❌ Sistema intervalControl non disponibile.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const intervals = app.intervalControl.getIntervals();
|
||||
|
||||
bot.sendMessage(chatID,
|
||||
`⏱️ *Intervalli Attuali*\n\n` +
|
||||
`📝 Log: *${intervals.log_interval / 1000}s*\n` +
|
||||
`🌤️ API: *${intervals.openmeteo_interval / 1000}s*\n\n` +
|
||||
`Per modificare:\n` +
|
||||
`\`/changei log <sec>\`\n` +
|
||||
`\`/changei api <sec>\``,
|
||||
{ parse_mode: 'Markdown' }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[Telegram] Errore intervals:', error);
|
||||
bot.sendMessage(chatID, `❌ Errore: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Callback query handler
|
||||
bot.on('callback_query', async (query) => {
|
||||
const chatId = query.message.chat.id;
|
||||
const messageId = query.message.message_id;
|
||||
const data = query.data;
|
||||
|
||||
await bot.answerCallbackQuery(query.id);
|
||||
|
||||
if (!isAuthenticated(chatId) && !['token_login_question', 'token_ready'].includes(data)) {
|
||||
bot.sendMessage(chatId, "Effettua prima il login.");
|
||||
return;
|
||||
}
|
||||
|
||||
switch (data) {
|
||||
case 'dismiss':
|
||||
bot.deleteMessage(chatId, messageId).catch(() => {});
|
||||
break;
|
||||
|
||||
case 'token_login_question':
|
||||
bot.sendMessage(chatId,
|
||||
"Per ottenere un token, contatta un amministratore del sistema."
|
||||
);
|
||||
break;
|
||||
|
||||
case 'token_ready':
|
||||
bot.sendMessage(chatId, "Usa: /login <token>");
|
||||
break;
|
||||
|
||||
case 'get_dashboard':
|
||||
case 'refresh_dashboard':
|
||||
bot.editMessageText(renderDashboardText(), {
|
||||
chat_id: chatId,
|
||||
message_id: messageId,
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[{ text: "🔄 Aggiorna", callback_data: "refresh_dashboard" }],
|
||||
[{ text: "📡 Live (3s)", callback_data: "live_dashboard" }],
|
||||
[{ text: "⏹️ Chiudi", callback_data: "dismiss" }]
|
||||
]
|
||||
}
|
||||
}).catch(() => {});
|
||||
break;
|
||||
|
||||
case 'live_dashboard':
|
||||
// Ferma eventuali live precedenti
|
||||
if (liveParamIntervals.has(chatId)) {
|
||||
clearInterval(liveParamIntervals.get(chatId));
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
bot.editMessageText(renderDashboardText(), {
|
||||
chat_id: chatId,
|
||||
message_id: messageId,
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[{ text: "⏹️ Stop Live", callback_data: "stop_live" }]
|
||||
]
|
||||
}
|
||||
}).catch(() => {
|
||||
clearInterval(interval);
|
||||
liveParamIntervals.delete(chatId);
|
||||
});
|
||||
}, CONFIG.liveUpdateInterval);
|
||||
|
||||
liveParamIntervals.set(chatId, interval);
|
||||
break;
|
||||
|
||||
case 'stop_live':
|
||||
if (liveParamIntervals.has(chatId)) {
|
||||
clearInterval(liveParamIntervals.get(chatId));
|
||||
liveParamIntervals.delete(chatId);
|
||||
}
|
||||
bot.editMessageText(renderDashboardText(), {
|
||||
chat_id: chatId,
|
||||
message_id: messageId,
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[{ text: "🔄 Aggiorna", callback_data: "refresh_dashboard" }],
|
||||
[{ text: "📡 Live (3s)", callback_data: "live_dashboard" }],
|
||||
[{ text: "⏹️ Chiudi", callback_data: "dismiss" }]
|
||||
]
|
||||
}
|
||||
}).catch(() => {});
|
||||
break;
|
||||
|
||||
case 'get_position':
|
||||
bot.editMessageText(renderPositionText(), {
|
||||
chat_id: chatId,
|
||||
message_id: messageId,
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: { inline_keyboard: [[{ text: "← Indietro", callback_data: "back_to_params" }]] }
|
||||
}).catch(() => {});
|
||||
break;
|
||||
|
||||
case 'get_wind':
|
||||
bot.editMessageText(renderWindText(), {
|
||||
chat_id: chatId,
|
||||
message_id: messageId,
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: { inline_keyboard: [[{ text: "← Indietro", callback_data: "back_to_params" }]] }
|
||||
}).catch(() => {});
|
||||
break;
|
||||
|
||||
case 'get_waves':
|
||||
bot.editMessageText(renderWavesText(), {
|
||||
chat_id: chatId,
|
||||
message_id: messageId,
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: { inline_keyboard: [[{ text: "← Indietro", callback_data: "back_to_params" }]] }
|
||||
}).catch(() => {});
|
||||
break;
|
||||
|
||||
case 'get_forecasts':
|
||||
bot.editMessageText(renderForecastsText(), {
|
||||
chat_id: chatId,
|
||||
message_id: messageId,
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: { inline_keyboard: [[{ text: "← Indietro", callback_data: "back_to_params" }]] }
|
||||
}).catch(() => {});
|
||||
break;
|
||||
|
||||
case 'get_batteries':
|
||||
bot.editMessageText(renderBatteriesText(), {
|
||||
chat_id: chatId,
|
||||
message_id: messageId,
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: { inline_keyboard: [[{ text: "← Indietro", callback_data: "back_to_params" }]] }
|
||||
}).catch(() => {});
|
||||
break;
|
||||
|
||||
case 'back_to_params':
|
||||
bot.editMessageText("*Parametri di Bordo*\nQui potrai visualizzare i parametri attuali del computer di bordo. Scegli il parametro che vuoi visualizzare dal menu qui sotto.", {
|
||||
chat_id: chatId,
|
||||
message_id: messageId,
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[{ text: "📊 Dashboard Completa", callback_data: "get_dashboard" }],
|
||||
[{ text: "⛅️ Meteo", callback_data: "get_forecasts" }],
|
||||
[{ text: "📍 Posizione", callback_data: "get_position" }],
|
||||
[{ text: "🌬️ Vento", callback_data: "get_wind" }],
|
||||
[{ text: "🌊 Onde", callback_data: "get_waves" }],
|
||||
[{ text: "🔋 Batterie", callback_data: "get_batteries" }],
|
||||
[{ text: "Annulla", callback_data: "dismiss" }]
|
||||
]
|
||||
}
|
||||
}).catch(() => {});
|
||||
break;
|
||||
|
||||
default:
|
||||
// Gestione paginazione file
|
||||
if (data.startsWith('page_')) {
|
||||
const page = parseInt(data.replace('page_', ''), 10);
|
||||
if (!isNaN(page)) {
|
||||
bot.deleteMessage(chatId, messageId).catch(() => {});
|
||||
fetchFiles(chatId, page);
|
||||
}
|
||||
}
|
||||
// Gestione richiesta file
|
||||
else if (data.startsWith('request_file_')) {
|
||||
const fileName = data.replace('request_file_', '');
|
||||
const filePath = path.join(__dirname, "..", "datasetModels/saved_datas", fileName);
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
const logsData = loadLogsReferences();
|
||||
const fileRef = (logsData.references || []).find(r => r.name === fileName);
|
||||
const key = fileRef?.key || "Chiave non trovata";
|
||||
|
||||
try {
|
||||
const fileMsg = await bot.sendDocument(chatId, filePath, {
|
||||
caption: `🔑 Chiave: \`${key}\`\n⚠️ Questo messaggio verrà eliminato tra 10 secondi.`,
|
||||
parse_mode: 'Markdown'
|
||||
});
|
||||
|
||||
// Elimina dopo 10 secondi
|
||||
setTimeout(() => {
|
||||
bot.deleteMessage(chatId, fileMsg.message_id).catch(() => {});
|
||||
}, CONFIG.fileExpirationTime * 1000);
|
||||
|
||||
} catch (error) {
|
||||
bot.sendMessage(chatId, `❌ Errore invio file: ${error.message}`);
|
||||
}
|
||||
} else {
|
||||
bot.sendMessage(chatId, "❌ File non trovato.");
|
||||
}
|
||||
|
||||
bot.deleteMessage(chatId, messageId).catch(() => {});
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
} // Fine registerHandlers
|
||||
|
||||
module.exports = {
|
||||
linkBot,
|
||||
send,
|
||||
loadUsers,
|
||||
saveUsers,
|
||||
getUserByChatID,
|
||||
isAuthenticated,
|
||||
isAdmin
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
[
|
||||
{
|
||||
"token": "eccef678c73b825fd2af7a3ce76603aeef68c6280862f1c2",
|
||||
"hasLogged": true,
|
||||
"chatId": 5868470977,
|
||||
"chatID": 5868470977
|
||||
},
|
||||
{
|
||||
"token": "5A6MjMd6amSGgZbk6PZ9T9sdJKjWwbHM",
|
||||
"chatID": "5868470977",
|
||||
"isAuthorized": [
|
||||
"basic"
|
||||
],
|
||||
"hasLoggedYet": true
|
||||
},
|
||||
{
|
||||
"token": "af9aBSY9taEedmZXFhy3Fhns3VHtXSxT",
|
||||
"chatID": "838642766",
|
||||
"isAuthorized": [
|
||||
"basic"
|
||||
],
|
||||
"hasLoggedYet": true
|
||||
}
|
||||
]
|
||||
136
plugin/config.js
136
plugin/config.js
@@ -2,45 +2,137 @@ const dotenv = require("dotenv");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
|
||||
// Carica il file .env dalla root del plugin
|
||||
dotenv.config({ path: path.resolve(__dirname, "..", ".env"), quiet: true });
|
||||
|
||||
// Base path per tutti i file generati dal server
|
||||
const SIGNALK_FILES = process.env.SIGNALK_FILES || path.resolve(__dirname, "..", "data");
|
||||
const SIGNALK_FILES = process.env.SIGNALK_FILES || path.resolve(__dirname);
|
||||
|
||||
// Crea le directory necessarie se non esistono
|
||||
function ensureDir(dirPath) {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
console.log(`[Config] Creata directory: ${dirPath}`);
|
||||
function checkFolder(dirPath) {
|
||||
try {
|
||||
fs.accessSync(dirPath, fs.constants.R_OK | fs.constants.W_OK);
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
fs.mkdirSync(dirPath, {
|
||||
recursive: true,
|
||||
mode: 0o777
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Permission denied for ${dirPath}`);
|
||||
}
|
||||
}
|
||||
return dirPath;
|
||||
}
|
||||
|
||||
// Paths per i vari tipi di file
|
||||
const paths = {
|
||||
// Base
|
||||
base: SIGNALK_FILES,
|
||||
|
||||
// Logs: hourly_archive.json, logs_references.json, saved_datas/
|
||||
logs: ensureDir(path.join(SIGNALK_FILES, "logs")),
|
||||
|
||||
logs: checkFolder(path.join(SIGNALK_FILES, "logs")),
|
||||
hourlyArchive: path.join(SIGNALK_FILES, "logs", "hourly_archive.json"),
|
||||
logsReferences: path.join(SIGNALK_FILES, "logs", "logs_references.json"),
|
||||
savedDatas: ensureDir(path.join(SIGNALK_FILES, "logs", "saved_datas")),
|
||||
|
||||
// Private: authorized_admins.txt, telegram_users.json
|
||||
private: ensureDir(path.join(SIGNALK_FILES, "private")),
|
||||
savedDatas: checkFolder(path.join(SIGNALK_FILES, "logs", "saved_datas")),
|
||||
|
||||
private: checkFolder(path.join(SIGNALK_FILES, "private")),
|
||||
authorizedAdmins: path.join(SIGNALK_FILES, "private", "authorized_admins.txt"),
|
||||
telegramUsers: path.join(SIGNALK_FILES, "private", "telegram_users.json"),
|
||||
|
||||
// Sensors: sensors.references.json
|
||||
sensors: ensureDir(path.join(SIGNALK_FILES, "sensors")),
|
||||
sensorsReferences: path.join(SIGNALK_FILES, "sensors", "sensors.references.json")
|
||||
|
||||
sensorsReferences: path.join(__dirname, "sensors", "sensors.references.json")
|
||||
};
|
||||
|
||||
const config = {
|
||||
telegramBotToken: process.env.TELEGRAM_BOT_TOKEN,
|
||||
mapboxKey: process.env.MAPBOX_KEY,
|
||||
cloudUrl: process.env.CLOUD_URL || "https://realtime.mebcloud.it",
|
||||
cloudApiKey: process.env.CLOUD_API_KEY,
|
||||
realtimeUrl: process.env.REALTIME_URL || 'http://realtime:3002',
|
||||
paths
|
||||
};
|
||||
|
||||
module.exports = { config, paths };
|
||||
/**
|
||||
* Carica la configurazione sensori dal server (con fallback locale).
|
||||
* Se viene fornito un ticket, usa l'endpoint autenticato /sensors/data/references.
|
||||
* Altrimenti usa l'endpoint pubblico /sensors/references (legacy).
|
||||
*
|
||||
* @param {string|null} ticket - Ticket di autenticazione (opzionale)
|
||||
* @returns {Promise<Object|null>} { items, version, isActive } o null
|
||||
*/
|
||||
async function loadSensorReferencesFromServer(ticket = null) {
|
||||
// Tentativo 1: Endpoint autenticato (con ticket)
|
||||
if (ticket) {
|
||||
const authUrl = config.realtimeUrl + '/sensors/data/references';
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
const res = await fetch(authUrl, {
|
||||
signal: controller.signal,
|
||||
headers: { 'Authorization': `Bearer ${ticket}` }
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
console.log(`[MEB] Sensor references caricati (autenticato, v: ${data.version})`);
|
||||
return data;
|
||||
}
|
||||
console.warn(`[MEB] Endpoint autenticato HTTP ${res.status}, provo fallback pubblico`);
|
||||
} catch (err) {
|
||||
console.warn(`[MEB] Endpoint autenticato fallito: ${err.message}, provo fallback pubblico`);
|
||||
}
|
||||
}
|
||||
|
||||
// Tentativo 2: Endpoint pubblico (legacy)
|
||||
const publicUrl = config.realtimeUrl + '/sensors/references';
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
const res = await fetch(publicUrl, { signal: controller.signal });
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
console.log(`[MEB] Sensor references caricati dal server (versione: ${data.version})`);
|
||||
return data;
|
||||
}
|
||||
console.warn(`[MEB] Server sensor refs HTTP ${res.status}, uso fallback locale`);
|
||||
} catch (err) {
|
||||
console.warn(`[MEB] Impossibile caricare sensor refs dal server: ${err.message}`);
|
||||
}
|
||||
|
||||
// Tentativo 3: File locale
|
||||
try {
|
||||
const raw = fs.readFileSync(paths.sensorsReferences, 'utf-8');
|
||||
const data = JSON.parse(raw);
|
||||
console.log(`[MEB] Sensor references caricati da file locale (versione: ${data.version})`);
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error(`[MEB] Nessuna sorgente sensor refs disponibile: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Controlla se la versione dei sensor references sul server e' cambiata.
|
||||
* Ritorna la nuova versione se diversa, null altrimenti.
|
||||
*/
|
||||
async function checkSensorReferencesVersion(currentVersion) {
|
||||
const url = config.realtimeUrl + '/sensors/references/version';
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||
|
||||
const res = await fetch(url, { signal: controller.signal });
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.version && data.version !== currentVersion) {
|
||||
return data.version;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silenzioso: il polling della versione non e' critico
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = { config, paths, loadSensorReferencesFromServer, checkSensorReferencesVersion };
|
||||
@@ -1,105 +0,0 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// Coda di scrittura per gestire backpressure
|
||||
let writeQueue = [];
|
||||
let isDraining = false;
|
||||
|
||||
/**
|
||||
* Inizializza il dataset e lo prepara per essere salvato.
|
||||
*
|
||||
* @param {String[]} headers Un array di stringhe che rappresentano i tipi di dati.
|
||||
* @param {WriteStream} streamer Lo stream di scrittura del file.
|
||||
* @returns {boolean} True se l'inizializzazione ha successo
|
||||
*/
|
||||
function datasetInit(headers, streamer) {
|
||||
if (!streamer || streamer.destroyed) {
|
||||
console.error('[DatasetCore] Stream non valido per inizializzazione');
|
||||
return false;
|
||||
}
|
||||
if (!Array.isArray(headers) || headers.length === 0) {
|
||||
console.error('[DatasetCore] Headers non validi');
|
||||
return false;
|
||||
}
|
||||
|
||||
writeQueue = [];
|
||||
isDraining = false;
|
||||
|
||||
streamer.write(headers.join(',') + '\n');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggiunge una riga di dati al dataset con gestione backpressure.
|
||||
*
|
||||
* @param {Object} data I dati da scrivere
|
||||
* @param {String[]} headers Gli header delle colonne
|
||||
* @param {WriteStream} streamer Lo stream di scrittura
|
||||
* @returns {boolean} True se la scrittura è andata a buon fine
|
||||
*/
|
||||
function appendData(data, headers, streamer) {
|
||||
if (!streamer || streamer.destroyed) {
|
||||
console.error('[DatasetCore] Stream non disponibile o distrutto');
|
||||
return false;
|
||||
}
|
||||
if (!data || typeof data !== 'object') {
|
||||
console.warn('[DatasetCore] Dati non validi, skip scrittura');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Escape valori che contengono virgole o newline per CSV valido
|
||||
const escapeCSV = (val) => {
|
||||
if (val === undefined || val === null) return '';
|
||||
const str = String(val);
|
||||
if (str.includes(',') || str.includes('\n') || str.includes('"')) {
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return str;
|
||||
};
|
||||
|
||||
const row = headers.map(header => escapeCSV(data[header])).join(',');
|
||||
|
||||
// Gestione backpressure con coda
|
||||
const canWrite = streamer.write(row + '\n');
|
||||
|
||||
if (!canWrite) {
|
||||
if (!isDraining) {
|
||||
isDraining = true;
|
||||
console.warn('[DatasetCore] Buffer saturo, attendo drain...');
|
||||
streamer.once('drain', () => {
|
||||
isDraining = false;
|
||||
// Processa coda pendente
|
||||
while (writeQueue.length > 0 && !streamer.destroyed) {
|
||||
const pendingRow = writeQueue.shift();
|
||||
if (!streamer.write(pendingRow + '\n')) {
|
||||
writeQueue.unshift(pendingRow);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// Aggiungi alla coda solo se non troppo piena (max 1000 entries)
|
||||
if (writeQueue.length < 1000) {
|
||||
writeQueue.push(row);
|
||||
} else {
|
||||
console.error('[DatasetCore] Coda piena, scarto dati');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ottiene la dimensione della coda di scrittura pendente
|
||||
* @returns {number} Numero di righe in attesa
|
||||
*/
|
||||
function getPendingWrites() {
|
||||
return writeQueue.length;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
datasetInit,
|
||||
appendData,
|
||||
getPendingWrites
|
||||
};
|
||||
@@ -1,274 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* Searches for a directory. If not found, creates it.
|
||||
* @param {string} dirPath - The absolute or relative path to the directory.
|
||||
* @returns {string} - The absolute path to the directory.
|
||||
*/
|
||||
function getDirectory(dirPath) {
|
||||
const absolutePath = path.resolve(dirPath);
|
||||
if (!fs.existsSync(absolutePath)) {
|
||||
fs.mkdirSync(absolutePath, { recursive: true });
|
||||
}
|
||||
return absolutePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches for a file. If not found, creates it with initialData.
|
||||
* @param {string} filePath - The absolute or relative path to the file.
|
||||
* @param {object} [initialData={}] - The initial data to write if the file is created.
|
||||
* @returns {object} - The content of the file as an object.
|
||||
*/
|
||||
function write(filePath, initialData = {}) {
|
||||
const absolutePath = path.resolve(filePath);
|
||||
const dir = path.dirname(absolutePath);
|
||||
|
||||
getDirectory(dir);
|
||||
|
||||
if (!fs.existsSync(absolutePath)) {
|
||||
fs.writeFileSync(absolutePath, JSON.stringify(initialData, null, 2), 'utf-8');
|
||||
return initialData;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(absolutePath, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
} catch (error) {
|
||||
console.error(`Error reading or parsing JSON file at ${absolutePath}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrive dati in un file JSON.
|
||||
* @param {string} filePath - Il path assoluto o relativo al file JSON.
|
||||
* @param {object} data - Gli elementi da aggiungere nel file JSON.
|
||||
*/
|
||||
function update(filePath, data) {
|
||||
const absolutePath = path.resolve(filePath);
|
||||
const dir = path.dirname(absolutePath);
|
||||
|
||||
getDirectory(dir);
|
||||
|
||||
fs.writeFileSync(absolutePath, JSON.stringify(data, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggiunge un elemento all'array del file specificato
|
||||
* Se il file non esiste, lo crea con un array contenente l'elemento.
|
||||
* Se il file esiste ma non è un array, genera un errore.
|
||||
* @param {string} filePath - Il path del file JSON.
|
||||
* @param {any} element - L'elemento da aggiungere all'array.
|
||||
* @returns {array} - L'array aggiornato.
|
||||
*/
|
||||
function appendTo(filePath, element) {
|
||||
const absolutePath = path.resolve(filePath);
|
||||
let data = [];
|
||||
|
||||
if (fs.existsSync(absolutePath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(absolutePath, 'utf-8');
|
||||
data = JSON.parse(content);
|
||||
} catch (error) {
|
||||
console.error(`Error reading or parsing JSON file at ${absolutePath}:`, error);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
// Ensure directory exists if we are creating the file
|
||||
const dir = path.dirname(absolutePath);
|
||||
getDirectory(dir);
|
||||
}
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error(`File at ${absolutePath} exists but is not a JSON array.`);
|
||||
}
|
||||
|
||||
data.push(element);
|
||||
fs.writeFileSync(absolutePath, JSON.stringify(data, null, 2), 'utf-8');
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggiunge un elemento a un array specifico all'interno di un oggetto JSON
|
||||
* Es: JSON = {date: "now", elements: [], security: false}
|
||||
* appendToElement(filePath, 'elements', {title: "", description: ""})
|
||||
*
|
||||
* @param {string} filePath - Il path del file JSON.
|
||||
* @param {string} arrayKey - La chiave dell'array nell'oggetto JSON (es: 'elements').
|
||||
* @param {any} element - L'elemento da aggiungere all'array specificato.
|
||||
* @returns {boolean} - Se l'operazione è andata a buon fine, restituisce true.
|
||||
*/
|
||||
function appendToElement(filePath, arrayKey, element) {
|
||||
const absolutePath = path.resolve(filePath);
|
||||
let data = {};
|
||||
|
||||
if (fs.existsSync(absolutePath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(absolutePath, 'utf-8');
|
||||
data = JSON.parse(content);
|
||||
} catch (error) {
|
||||
console.error(`Error reading or parsing JSON file at ${absolutePath}:`, error);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
const dir = path.dirname(absolutePath);
|
||||
getDirectory(dir);
|
||||
data = {};
|
||||
}
|
||||
|
||||
if (!data.hasOwnProperty(arrayKey)) {
|
||||
data[arrayKey] = [];
|
||||
}
|
||||
if (!Array.isArray(data[arrayKey])) {
|
||||
throw new Error(`Property '${arrayKey}' in file at ${absolutePath} exists but is not an array.`);
|
||||
}
|
||||
|
||||
data[arrayKey].push(element);
|
||||
|
||||
fs.writeFileSync(absolutePath, JSON.stringify(data, null, 2), 'utf-8');
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Rimuove un elemento da un array specifico all'interno di un oggetto JSON
|
||||
* cercando per proprietà "name"
|
||||
* Es: JSON = {date: "now", elements: [{name: "item1"}, {name: "item2"}], security: false}
|
||||
* removeFromElement(filePath, 'elements', 'item1')
|
||||
*
|
||||
* @param {string} filePath - Il path del file JSON.
|
||||
* @param {string} arrayKey - La chiave dell'array nell'oggetto JSON (es: 'elements').
|
||||
* @param {string} nameToRemove - Il valore della proprietà "name" dell'elemento da rimuovere.
|
||||
* @returns {object} - Oggetto con {success: boolean, removed: object|null, remaining: number}
|
||||
*/
|
||||
function removeFromElement(filePath, arrayKey, nameToRemove) {
|
||||
const absolutePath = path.resolve(filePath);
|
||||
let data = {};
|
||||
|
||||
if (fs.existsSync(absolutePath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(absolutePath, 'utf-8');
|
||||
data = JSON.parse(content);
|
||||
} catch (error) {
|
||||
console.error(`Error reading or parsing JSON file at ${absolutePath}:`, error);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
throw new Error(`File at ${absolutePath} does not exist.`);
|
||||
}
|
||||
|
||||
if (!data.hasOwnProperty(arrayKey)) {
|
||||
throw new Error(`Property '${arrayKey}' does not exist in file at ${absolutePath}.`);
|
||||
}
|
||||
if (!Array.isArray(data[arrayKey])) {
|
||||
throw new Error(`Property '${arrayKey}' in file at ${absolutePath} is not an array.`);
|
||||
}
|
||||
|
||||
const initialLength = data[arrayKey].length;
|
||||
const indexToRemove = data[arrayKey].findIndex(item => item.name === nameToRemove);
|
||||
|
||||
if (indexToRemove === -1) {
|
||||
return {
|
||||
success: false,
|
||||
removed: null,
|
||||
remaining: initialLength,
|
||||
message: `Element with name '${nameToRemove}' not found in array '${arrayKey}'.`
|
||||
};
|
||||
}
|
||||
|
||||
const removedElement = data[arrayKey].splice(indexToRemove, 1)[0];
|
||||
fs.writeFileSync(absolutePath, JSON.stringify(data, null, 2), 'utf-8');
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
function findInElement(filePath, arrayKey, name) {
|
||||
const absolutePath = path.resolve(filePath);
|
||||
let data = {};
|
||||
|
||||
if (fs.existsSync(absolutePath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(absolutePath, 'utf-8');
|
||||
data = JSON.parse(content);
|
||||
} catch (error) {
|
||||
console.error(`Error reading or parsing JSON file at ${absolutePath}:`, error);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
throw new Error(`File at ${absolutePath} does not exist.`);
|
||||
}
|
||||
|
||||
if (!data.hasOwnProperty(arrayKey)) {
|
||||
throw new Error(`Property '${arrayKey}' does not exist in file at ${absolutePath}.`);
|
||||
}
|
||||
if (!Array.isArray(data[arrayKey])) {
|
||||
throw new Error(`Property '${arrayKey}' in file at ${absolutePath} is not an array.`);
|
||||
}
|
||||
|
||||
const index = data[arrayKey].findIndex(item => item.name === name);
|
||||
|
||||
return data[arrayKey][index]
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Aggiorna un elemento in un array specifico all'interno di un oggetto JSON
|
||||
* cercando per proprietà "name" e sostituendolo con un nuovo elemento
|
||||
* Es: JSON = {date: "now", elements: [{name: "item1", value: 10}, {name: "item2", value: 20}]}
|
||||
* updateInElement(filePath, 'elements', 'item1', {name: "item1", value: 99})
|
||||
*
|
||||
* @param {string} filePath - Il path del file JSON.
|
||||
* @param {string} arrayKey - La chiave dell'array nell'oggetto JSON (es: 'elements').
|
||||
* @param {string} nameToUpdate - Il valore della proprietà "name" dell'elemento da aggiornare.
|
||||
* @param {any} newElement - Il nuovo elemento che sostituirà quello trovato.
|
||||
* @returns {boolean} - True se l'operazione ha successo, false se l'elemento non è stato trovato.
|
||||
*/
|
||||
function updateInElement(filePath, arrayKey, nameToUpdate, newElement) {
|
||||
const absolutePath = path.resolve(filePath);
|
||||
let data = {};
|
||||
|
||||
if (fs.existsSync(absolutePath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(absolutePath, 'utf-8');
|
||||
data = JSON.parse(content);
|
||||
} catch (error) {
|
||||
console.error(`Error reading or parsing JSON file at ${absolutePath}:`, error);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
throw new Error(`File at ${absolutePath} does not exist.`);
|
||||
}
|
||||
|
||||
if (!data.hasOwnProperty(arrayKey)) {
|
||||
throw new Error(`Property '${arrayKey}' does not exist in file at ${absolutePath}.`);
|
||||
}
|
||||
if (!Array.isArray(data[arrayKey])) {
|
||||
throw new Error(`Property '${arrayKey}' in file at ${absolutePath} is not an array.`);
|
||||
}
|
||||
|
||||
const index = data[arrayKey].findIndex(item => item.name === nameToUpdate);
|
||||
|
||||
if (index === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
data[arrayKey][index] = newElement;
|
||||
fs.writeFileSync(absolutePath, JSON.stringify(data, null, 2), 'utf-8');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
module.exports = {
|
||||
getDirectory,
|
||||
write,
|
||||
update,
|
||||
appendToElement,
|
||||
findInElement,
|
||||
removeFromElement,
|
||||
updateInElement
|
||||
};
|
||||
@@ -1,351 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { paths } = require('../config.js');
|
||||
|
||||
const ARCHIVE_FILE = paths.hourlyArchive;
|
||||
|
||||
// Cache dati OpenMeteo condivisi (evita chiamate duplicate)
|
||||
let sharedWeatherData = {
|
||||
forecast: null,
|
||||
waves: null,
|
||||
units: null, // Unità di misura globali
|
||||
lastUpdate: null,
|
||||
updateInterval: 2 * 60 * 1000 // 2 minuti
|
||||
};
|
||||
|
||||
// Archivio dati orari
|
||||
let hourlyArchive = {
|
||||
temperature: [],
|
||||
windSpeed: [],
|
||||
windDirection: [],
|
||||
waveHeight: [],
|
||||
wavePeriod: [],
|
||||
waveDirection: [],
|
||||
humidity: [],
|
||||
pressure: []
|
||||
};
|
||||
|
||||
/**
|
||||
* Carica l'archivio da file
|
||||
*/
|
||||
function loadArchive() {
|
||||
try {
|
||||
if (fs.existsSync(ARCHIVE_FILE)) {
|
||||
const data = fs.readFileSync(ARCHIVE_FILE, 'utf8');
|
||||
const parsed = JSON.parse(data);
|
||||
// Valida struttura archivio
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
hourlyArchive = {
|
||||
temperature: Array.isArray(parsed.temperature) ? parsed.temperature : [],
|
||||
windSpeed: Array.isArray(parsed.windSpeed) ? parsed.windSpeed : [],
|
||||
windDirection: Array.isArray(parsed.windDirection) ? parsed.windDirection : [],
|
||||
waveHeight: Array.isArray(parsed.waveHeight) ? parsed.waveHeight : [],
|
||||
wavePeriod: Array.isArray(parsed.wavePeriod) ? parsed.wavePeriod : [],
|
||||
waveDirection: Array.isArray(parsed.waveDirection) ? parsed.waveDirection : [],
|
||||
humidity: Array.isArray(parsed.humidity) ? parsed.humidity : [],
|
||||
pressure: Array.isArray(parsed.pressure) ? parsed.pressure : []
|
||||
};
|
||||
console.log('[GraphsCore] Archivio caricato');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[GraphsCore] Errore caricamento archivio:', error.message);
|
||||
// Resetta archivio se corrotto
|
||||
hourlyArchive = {
|
||||
temperature: [], windSpeed: [], windDirection: [],
|
||||
waveHeight: [], wavePeriod: [], waveDirection: [],
|
||||
humidity: [], pressure: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Salva l'archivio su file
|
||||
*/
|
||||
function saveArchive() {
|
||||
try {
|
||||
fs.writeFileSync(ARCHIVE_FILE, JSON.stringify(hourlyArchive, null, 2));
|
||||
} catch (error) {
|
||||
console.error('[GraphsCore] Errore salvataggio archivio:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggiorna i dati meteo condivisi
|
||||
* @param {object} forecastData - Dati forecast da OpenMeteo
|
||||
* @param {object} wavesData - Dati onde da OpenMeteo
|
||||
*/
|
||||
function updateSharedWeatherData(forecastData, wavesData) {
|
||||
if (forecastData) {
|
||||
sharedWeatherData.forecast = forecastData;
|
||||
}
|
||||
if (wavesData) {
|
||||
sharedWeatherData.waves = wavesData;
|
||||
}
|
||||
|
||||
// Aggiorna unità se disponibili
|
||||
if (forecastData?.units || wavesData?.units) {
|
||||
sharedWeatherData.units = {
|
||||
forecast: forecastData?.units || sharedWeatherData.units?.forecast || {},
|
||||
waves: wavesData?.units || sharedWeatherData.units?.waves || {}
|
||||
};
|
||||
}
|
||||
|
||||
sharedWeatherData.lastUpdate = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ottiene i dati meteo condivisi
|
||||
* @returns {object} Dati meteo attuali
|
||||
*/
|
||||
function getSharedWeatherData() {
|
||||
return {
|
||||
forecast: sharedWeatherData.forecast,
|
||||
waves: sharedWeatherData.waves,
|
||||
units: sharedWeatherData.units,
|
||||
lastUpdate: sharedWeatherData.lastUpdate,
|
||||
isValid: sharedWeatherData.lastUpdate &&
|
||||
(Date.now() - sharedWeatherData.lastUpdate) < sharedWeatherData.updateInterval * 2
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ottiene le unità di misura globali
|
||||
*/
|
||||
function getUnits() {
|
||||
return sharedWeatherData.units || {
|
||||
forecast: {
|
||||
temperature: '°C',
|
||||
humidity: '%',
|
||||
pressure: 'hPa',
|
||||
windSpeed: 'km/h',
|
||||
windDirection: '°'
|
||||
},
|
||||
waves: {
|
||||
waveHeight: 'm',
|
||||
wavePeriod: 's',
|
||||
waveDirection: '°'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatta un valore con la sua unità
|
||||
*/
|
||||
function formatValue(value, unitKey, category = 'forecast') {
|
||||
if (value === null || value === undefined) return 'n/d';
|
||||
const units = getUnits();
|
||||
const unit = units[category]?.[unitKey] || '';
|
||||
return `${value}${unit}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica se i dati condivisi sono ancora validi
|
||||
*/
|
||||
function isWeatherDataValid() {
|
||||
if (!sharedWeatherData.lastUpdate) return false;
|
||||
return (Date.now() - sharedWeatherData.lastUpdate) < sharedWeatherData.updateInterval;
|
||||
}
|
||||
|
||||
/**
|
||||
* Archivia un punto dati orario
|
||||
*/
|
||||
function archiveHourlyData(data) {
|
||||
if (!data || typeof data !== 'object') {
|
||||
console.warn('[GraphsCore] archiveHourlyData: dati non validi');
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const maxPoints = 168; // 7 giorni di dati orari
|
||||
|
||||
const addPoint = (arr, value) => {
|
||||
if (value === null || value === undefined || Number.isNaN(value)) return;
|
||||
arr.push({ timestamp, value });
|
||||
if (arr.length > maxPoints) arr.shift();
|
||||
};
|
||||
|
||||
addPoint(hourlyArchive.temperature, data.temperature);
|
||||
addPoint(hourlyArchive.windSpeed, data.windSpeed);
|
||||
addPoint(hourlyArchive.windDirection, data.windDirection);
|
||||
addPoint(hourlyArchive.waveHeight, data.waveHeight);
|
||||
addPoint(hourlyArchive.wavePeriod, data.wavePeriod);
|
||||
addPoint(hourlyArchive.waveDirection, data.waveDirection);
|
||||
addPoint(hourlyArchive.humidity, data.humidity);
|
||||
addPoint(hourlyArchive.pressure, data.pressure);
|
||||
|
||||
saveArchive();
|
||||
console.log('[GraphsCore] Dati orari archiviati');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ottiene i dati per un grafico specifico
|
||||
* @param {string} parameter - temperatura, vento, onde, etc.
|
||||
* @param {number} hours - ultimi N ore (default 24)
|
||||
*/
|
||||
function getGraphData(parameter, hours = 24) {
|
||||
const paramMap = {
|
||||
'temperature': hourlyArchive.temperature,
|
||||
'windSpeed': hourlyArchive.windSpeed,
|
||||
'windDirection': hourlyArchive.windDirection,
|
||||
'waveHeight': hourlyArchive.waveHeight,
|
||||
'wavePeriod': hourlyArchive.wavePeriod,
|
||||
'waveDirection': hourlyArchive.waveDirection,
|
||||
'humidity': hourlyArchive.humidity,
|
||||
'pressure': hourlyArchive.pressure
|
||||
};
|
||||
|
||||
const data = paramMap[parameter] || [];
|
||||
const cutoff = Date.now() - (hours * 60 * 60 * 1000);
|
||||
|
||||
return data.filter(point => new Date(point.timestamp).getTime() > cutoff);
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera dati formattati per Chart.js
|
||||
*/
|
||||
function formatForChart(parameter, hours = 24) {
|
||||
const data = getGraphData(parameter, hours);
|
||||
|
||||
return {
|
||||
labels: data.map(p => {
|
||||
const d = new Date(p.timestamp);
|
||||
return `${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
}),
|
||||
datasets: [{
|
||||
label: getParameterLabel(parameter),
|
||||
data: data.map(p => p.value),
|
||||
borderColor: getParameterColor(parameter),
|
||||
backgroundColor: getParameterColor(parameter, 0.2),
|
||||
tension: 0.3,
|
||||
fill: true
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Label leggibili per i parametri
|
||||
*/
|
||||
function getParameterLabel(param) {
|
||||
const labels = {
|
||||
'temperature': 'Temperatura (°C)',
|
||||
'windSpeed': 'Velocità Vento (km/h)',
|
||||
'windDirection': 'Direzione Vento (°)',
|
||||
'waveHeight': 'Altezza Onde (m)',
|
||||
'wavePeriod': 'Periodo Onde (s)',
|
||||
'waveDirection': 'Direzione Onde (°)',
|
||||
'humidity': 'Umidità (%)',
|
||||
'pressure': 'Pressione (hPa)'
|
||||
};
|
||||
return labels[param] || param;
|
||||
}
|
||||
|
||||
/**
|
||||
* Colori per i grafici
|
||||
*/
|
||||
function getParameterColor(param, alpha = 1) {
|
||||
const colors = {
|
||||
'temperature': `rgba(255, 99, 132, ${alpha})`,
|
||||
'windSpeed': `rgba(54, 162, 235, ${alpha})`,
|
||||
'windDirection': `rgba(75, 192, 192, ${alpha})`,
|
||||
'waveHeight': `rgba(153, 102, 255, ${alpha})`,
|
||||
'wavePeriod': `rgba(255, 159, 64, ${alpha})`,
|
||||
'waveDirection': `rgba(255, 205, 86, ${alpha})`,
|
||||
'humidity': `rgba(201, 203, 207, ${alpha})`,
|
||||
'pressure': `rgba(100, 149, 237, ${alpha})`
|
||||
};
|
||||
return colors[param] || `rgba(128, 128, 128, ${alpha})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ottiene tutti i dati disponibili per dashboard
|
||||
*/
|
||||
function getAllGraphsData(hours = 24) {
|
||||
return {
|
||||
temperature: formatForChart('temperature', hours),
|
||||
windSpeed: formatForChart('windSpeed', hours),
|
||||
waveHeight: formatForChart('waveHeight', hours),
|
||||
humidity: formatForChart('humidity', hours)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Statistiche sull'archivio
|
||||
*/
|
||||
function getArchiveStats() {
|
||||
return {
|
||||
temperature: hourlyArchive.temperature.length,
|
||||
windSpeed: hourlyArchive.windSpeed.length,
|
||||
waveHeight: hourlyArchive.waveHeight.length,
|
||||
oldestData: getOldestTimestamp(),
|
||||
newestData: getNewestTimestamp()
|
||||
};
|
||||
}
|
||||
|
||||
function getOldestTimestamp() {
|
||||
const all = [
|
||||
...hourlyArchive.temperature,
|
||||
...hourlyArchive.windSpeed,
|
||||
...hourlyArchive.waveHeight
|
||||
];
|
||||
if (all.length === 0) return null;
|
||||
return all.reduce((oldest, p) =>
|
||||
new Date(p.timestamp) < new Date(oldest.timestamp) ? p : oldest
|
||||
).timestamp;
|
||||
}
|
||||
|
||||
function getNewestTimestamp() {
|
||||
const all = [
|
||||
...hourlyArchive.temperature,
|
||||
...hourlyArchive.windSpeed,
|
||||
...hourlyArchive.waveHeight
|
||||
];
|
||||
if (all.length === 0) return null;
|
||||
return all.reduce((newest, p) =>
|
||||
new Date(p.timestamp) > new Date(newest.timestamp) ? p : newest
|
||||
).timestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulisce l'archivio
|
||||
*/
|
||||
function clearArchive() {
|
||||
hourlyArchive = {
|
||||
temperature: [],
|
||||
windSpeed: [],
|
||||
windDirection: [],
|
||||
waveHeight: [],
|
||||
wavePeriod: [],
|
||||
waveDirection: [],
|
||||
humidity: [],
|
||||
pressure: []
|
||||
};
|
||||
saveArchive();
|
||||
console.log('[GraphsCore] Archivio pulito');
|
||||
}
|
||||
|
||||
// Carica archivio all'avvio
|
||||
loadArchive();
|
||||
|
||||
module.exports = {
|
||||
// Gestione dati condivisi
|
||||
updateSharedWeatherData,
|
||||
getSharedWeatherData,
|
||||
isWeatherDataValid,
|
||||
|
||||
// Unità di misura
|
||||
getUnits,
|
||||
formatValue,
|
||||
|
||||
// Archivio orario
|
||||
archiveHourlyData,
|
||||
getGraphData,
|
||||
formatForChart,
|
||||
getAllGraphsData,
|
||||
getArchiveStats,
|
||||
clearArchive,
|
||||
|
||||
// Utility
|
||||
getParameterLabel,
|
||||
getParameterColor
|
||||
};
|
||||
723
plugin/index.cjs
723
plugin/index.cjs
@@ -1,550 +1,193 @@
|
||||
const { config, paths } = require("./config.js");
|
||||
const { setupRoutes, getOpenApiSpec } = require("./tools/routes.js");
|
||||
const { aisStream } = require("./api_models/aisstream.js")
|
||||
const mapHandler = require("./tools/map.handler.js");
|
||||
const { linkBot, send } = require("./bot/telegram.core.js");
|
||||
const dataset = require("./datasetModels/datasetCore.js");
|
||||
const dataUtils = require("./datasetModels/datasetUtils.js");
|
||||
const graphsCore = require("./datasetModels/graphsCore.js");
|
||||
const { generateToken, encryptLog, loadSecureFile, saveSecureFile } = require("./tools/crypt.js");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { config } = require("./config.js");
|
||||
const registerRoutes = require("./routes");
|
||||
const { linkBotToApp } = require("./telegram/telegram.core.js");
|
||||
const { getForecast, getSeaConditions } = require("./api_models/openmeteo.js");
|
||||
|
||||
const { publish } = require("./tools/publisher.js");
|
||||
const realtime = require("./realtime/core.js");
|
||||
const dataHub = require("./tools/dataHub.js");
|
||||
|
||||
// CONFIG modificabile runtime (non più frozen per permettere modifiche admin)
|
||||
const CONFIG = {
|
||||
log_interval: 2000, // Dataset entry ogni 2 secondi
|
||||
openmeteo_interval: 300000, // OpenMeteo ogni 5 minuti
|
||||
hourly_archive_interval: 3600000, // Archivio orario per grafici
|
||||
number_value_fallback: 999999999999,
|
||||
value_fallback: "Funzionalità da Sviluppare"
|
||||
forecast_current_frequency: 300000, // 5 min default in ms
|
||||
forecast_hourly_frequency: 3600000, // 1 hour default
|
||||
};
|
||||
|
||||
// Funzione per aggiornare gli intervalli runtime
|
||||
function updateInterval(type, newIntervalMs) {
|
||||
if (type === 'api' || type === 'openmeteo') {
|
||||
CONFIG.openmeteo_interval = newIntervalMs;
|
||||
return { type: 'openmeteo_interval', value: newIntervalMs };
|
||||
} else if (type === 'log') {
|
||||
CONFIG.log_interval = newIntervalMs;
|
||||
return { type: 'log_interval', value: newIntervalMs };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Getter per CONFIG (usato da altri moduli)
|
||||
function getConfig() {
|
||||
return { ...CONFIG };
|
||||
}
|
||||
|
||||
const CSV_HEADERS = Object.freeze([
|
||||
'timestamp',
|
||||
'wavesHeight',
|
||||
'wavesPeriod',
|
||||
'wavesDirection',
|
||||
'windSpeed',
|
||||
'windDirection',
|
||||
'temperature',
|
||||
// 'currentSpeed',
|
||||
// 'currentDirection',
|
||||
'speedOverGround',
|
||||
'courseOverGround',
|
||||
'headingTrue',
|
||||
'latitude',
|
||||
'longitude',
|
||||
'1Voltage',
|
||||
'1Current',
|
||||
'1StateOfCharge',
|
||||
'1Temperature',
|
||||
'0Voltage',
|
||||
'0Current',
|
||||
'0CellsStateOfCharge',
|
||||
'0AverageCellTemperature',
|
||||
'0Power',
|
||||
'propultionShaftSpeed',
|
||||
'systemUptime'
|
||||
]);
|
||||
|
||||
const state = {
|
||||
logTimer: null,
|
||||
logStreamer: null,
|
||||
logsCount: 0,
|
||||
isRecordingLogs: false,
|
||||
currentLogFile: null,
|
||||
currentLogKey: null,
|
||||
openMeteoTimer: null,
|
||||
hourlyArchiveTimer: null,
|
||||
unsubPos: null,
|
||||
app: null,
|
||||
startTime: null
|
||||
};
|
||||
|
||||
const logsDirectory = dataUtils.getDirectory(paths.savedDatas);
|
||||
const logsReferencesFile = paths.logsReferences;
|
||||
const lastCallRef = { current: null };
|
||||
|
||||
|
||||
const getSKValue = (path, fallback = CONFIG.value_fallback) => {
|
||||
if (!state.app) {
|
||||
console.warn(`[getSKValue] App not initialized, returning fallback for path: ${path}`);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try {
|
||||
const value = state.app.getSelfPath(path)?.value;
|
||||
return (value !== undefined && value !== null) ? value : fallback;
|
||||
} catch (error) {
|
||||
console.error(`[getSKValue] Error reading path ${path}:`, error.message);
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const closeStream = (stream) => {
|
||||
return new Promise((resolve) => {
|
||||
if (!stream || stream.destroyed) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
stream.end(() => {
|
||||
resolve();
|
||||
});
|
||||
|
||||
setTimeout(resolve, 1000);
|
||||
});
|
||||
startTime: null,
|
||||
};
|
||||
|
||||
const clearIntervalSafe = (timerId) => {
|
||||
if (timerId) {
|
||||
clearInterval(timerId);
|
||||
}
|
||||
if (timerId) clearInterval(timerId);
|
||||
return null;
|
||||
};
|
||||
|
||||
const collectSensorData = (settings = {}) => {
|
||||
// Prendi la posizione dalla navigazione se disponibile
|
||||
const position = state.app?.getSelfPath('navigation.position')?.value;
|
||||
const lat = position?.latitude ?? settings.latitude ?? CONFIG.number_value_fallback;
|
||||
const lon = position?.longitude ?? settings.longitude ?? CONFIG.number_value_fallback;
|
||||
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
wavesHeight: getSKValue("meb.waves.height"),
|
||||
wavesPeriod: getSKValue("meb.waves.period"),
|
||||
wavesDirection: getSKValue("meb.waves.direction"),
|
||||
windSpeed: getSKValue("meb.wind.speed"),
|
||||
windDirection: getSKValue("meb.wind.direction"),
|
||||
temperature: getSKValue("meb.temperature"),
|
||||
// currentSpeed: getSKValue("meb.currents.speed"),
|
||||
// currentDirection: getSKValue("meb.currents.direction"),
|
||||
speedOverGround: getSKValue("navigation.speedOverGround"),
|
||||
courseOverGround: getSKValue("navigation.courseOverGroundTrue"),
|
||||
headingTrue: getSKValue("navigation.headingTrue"),
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
'1Voltage': getSKValue("electrical.batteries.service.Voltage"),
|
||||
'1Current': getSKValue("electrical.batteries.service.current"),
|
||||
'1StateOfCharge': getSKValue("electrical.batteries.service.stateOfCharge"),
|
||||
'1Temperature': getSKValue("electrical.batteries.service.temperature"),
|
||||
'0Voltage': getSKValue("electrical.batteries.traction.Voltage"),
|
||||
'0Current': getSKValue("electrical.batteries.traction.current"),
|
||||
'0CellsStateOfCharge': getSKValue("electrical.batteries.traction.stateOfCharge"),
|
||||
'0AverageCellTemperature': getSKValue("electrical.batteries.traction.temperature"),
|
||||
'0Power': getSKValue("electrical.batteries.traction.power"),
|
||||
propultionShaftSpeed: getSKValue("propulsion.0.revolutions"),
|
||||
systemUptime: process.uptime() ?? CONFIG.number_value_fallback
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
function createNewFiles() {
|
||||
try {
|
||||
const now = new Date();
|
||||
const dateStr = now.toLocaleString('it-IT', {
|
||||
timeZone: 'Europe/Rome',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
}).replace(/:/g, '-');
|
||||
const logFileName = `log_${dateStr}.csv`;
|
||||
const logFile = path.join(logsDirectory, logFileName);
|
||||
|
||||
// Close existing stream gracefully
|
||||
if (state.logStreamer && !state.logStreamer.destroyed) {
|
||||
state.logStreamer.end();
|
||||
}
|
||||
|
||||
state.logStreamer = fs.createWriteStream(logFile, { flags: 'a' });
|
||||
|
||||
state.logStreamer.on('error', (err) => {
|
||||
console.error('[log_file] Errore nello stream:', err);
|
||||
});
|
||||
|
||||
dataset.datasetInit(CSV_HEADERS, state.logStreamer);
|
||||
state.logsCount = 0;
|
||||
|
||||
state.currentLogFile = logFileName;
|
||||
state.currentLogKey = generateToken();
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[log_file] Errore nella creazione di un nuovo file:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== RECORDING CONTROL ====================
|
||||
|
||||
/**
|
||||
* Stops the data recording process
|
||||
* @returns {boolean} True if stopped successfully, false if already stopped
|
||||
*/
|
||||
function stopRecording() {
|
||||
if (!state.isRecordingLogs) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
state.logTimer = clearIntervalSafe(state.logTimer);
|
||||
|
||||
if (state.logStreamer && !state.logStreamer.destroyed) {
|
||||
state.logStreamer.end();
|
||||
}
|
||||
|
||||
state.isRecordingLogs = false;
|
||||
|
||||
// Usa la chiave generata all'inizio della sessione
|
||||
if (state.currentLogFile && state.currentLogKey) {
|
||||
const logFilePath = path.join(logsDirectory, state.currentLogFile);
|
||||
|
||||
// Carica, aggiorna e salva references criptate
|
||||
const logsData = loadSecureFile(logsReferencesFile, { references: [] });
|
||||
logsData.references.push({
|
||||
name: state.currentLogFile,
|
||||
token: state.currentLogKey
|
||||
});
|
||||
saveSecureFile(logsReferencesFile, logsData);
|
||||
|
||||
// Cripta il file log con la stessa chiave
|
||||
// encryptLog(logFilePath, state.currentLogKey);
|
||||
|
||||
console.log(`[stopRecording] Log ${state.currentLogFile} criptato e salvato.`);
|
||||
}
|
||||
|
||||
state.logsCount = 0;
|
||||
state.currentLogFile = null;
|
||||
state.currentLogKey = null;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[log_stop] Errore durante l\'arresto della registrazione:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the data recording process
|
||||
* @param {object} settings - Plugin settings
|
||||
* @returns {boolean} True if started successfully, false if already running
|
||||
*/
|
||||
function startRecording(settings = {}) {
|
||||
if (state.isRecordingLogs) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
state.isRecordingLogs = true;
|
||||
state.startTime = Date.now();
|
||||
|
||||
if (!createNewFiles()) {
|
||||
state.isRecordingLogs = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
state.logTimer = setInterval(() => {
|
||||
try {
|
||||
if (!state.logStreamer || state.logStreamer.destroyed) {
|
||||
console.error('[log_dataset_error] Stream non disponibile');
|
||||
return;
|
||||
}
|
||||
const data = collectSensorData(settings);
|
||||
const success = dataset.appendData(data, CSV_HEADERS, state.logStreamer);
|
||||
if (success) {
|
||||
state.logsCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[log_dataset_error] Errore durante la raccolta dei dati:', error);
|
||||
}
|
||||
}, CONFIG.log_interval);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[log_dataset_error] Errore nell\'avvio della registrazione', error);
|
||||
state.isRecordingLogs = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restarts the recording process
|
||||
* @param {object} settings - Plugin settings
|
||||
* @returns {boolean} Success status
|
||||
*/
|
||||
function restartRecording(settings = {}) {
|
||||
stopRecording();
|
||||
startRecording(settings);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets current recording status with detailed metrics
|
||||
* @returns {object} Status object
|
||||
*/
|
||||
function getRecordingStatus() {
|
||||
return {
|
||||
isRecording: state.isRecordingLogs,
|
||||
recordCount: state.logsCount,
|
||||
recordingInterval: CONFIG.log_interval,
|
||||
uptime: state.startTime ? Date.now() - state.startTime : 0,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = function (app) {
|
||||
state.app = app;
|
||||
let lastHourlyUpdate = 0;
|
||||
|
||||
const fetchAndPublishWeather = async (forceHourly = false) => {
|
||||
try {
|
||||
const pos = app.getSelfPath('navigation.position')?.value;
|
||||
if (!pos?.latitude || !pos?.longitude) {
|
||||
console.debug('[MEB] Posizione non disponibile per meteo');
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
// Richiedi 'hourly' se forzato, o se e' passata piu' di 1 ora
|
||||
const shouldFetchHourly = forceHourly || (now - lastHourlyUpdate > CONFIG.forecast_hourly_frequency);
|
||||
const mode = shouldFetchHourly ? 'both' : 'current';
|
||||
|
||||
if (shouldFetchHourly) console.log('[MEB] Scaricamento previsioni complete (hourly + current)...');
|
||||
else console.debug('[MEB] Aggiornamento meteo (current)...');
|
||||
|
||||
const [forecast, sea] = await Promise.all([
|
||||
getForecast(pos, { mode }),
|
||||
getSeaConditions(pos, { mode })
|
||||
]);
|
||||
|
||||
|
||||
|
||||
if (forecast) publish(app, forecast, {});
|
||||
if (sea) publish(app, sea, {});
|
||||
|
||||
if (shouldFetchHourly) {
|
||||
lastHourlyUpdate = now;
|
||||
}
|
||||
|
||||
if (forecast || sea) {
|
||||
// Aggiorna cache centralizzata per Telegram on-demand
|
||||
dataHub.updateWeatherData(forecast, sea);
|
||||
|
||||
// Invia al server SOLO quando è hourly (contiene previsioni 7gg)
|
||||
// I dati current-only non vengono inviati — sono già disponibili localmente
|
||||
if (shouldFetchHourly) {
|
||||
realtime.sendWeatherPayload({ forecast, sea });
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[MEB] Errore ciclo meteo:', error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const plugin = {
|
||||
id: "meb",
|
||||
name: "MEB Plugin",
|
||||
|
||||
start: async (settings) => {
|
||||
|
||||
const randomVal = (min, max) => parseFloat((Math.random() * (max - min) + min).toFixed(2));
|
||||
|
||||
// Dati di test — i path SignalK DEVONO corrispondere alle sensor-references rules
|
||||
// Le regole definiscono main_path e subPath, quindi i dati devono seguire esattamente quei path
|
||||
publish(app, {
|
||||
// engine (main_path: propulsion.0, subPath: revolutions)
|
||||
"propulsion.0.revolutions": randomVal(1000, 5000),
|
||||
// navigation (main_path: navigation)
|
||||
"navigation.courseOverGroundTrue": randomVal(0, 360),
|
||||
"navigation.speedOverGround": randomVal(0, 30),
|
||||
"navigation.headingTrue": randomVal(0, 360),
|
||||
// position (lat/lon sotto navigation.position come oggetto)
|
||||
"navigation.position.latitude": randomVal(40, 45),
|
||||
"navigation.position.longitude": randomVal(9, 14),
|
||||
// service battery (main_path: electrical.batteries.service) — NB: spelling "electrical"
|
||||
"electrical.batteries.service.current": randomVal(-50, 50),
|
||||
"electrical.batteries.service.Voltage": randomVal(0, 500),
|
||||
"electrical.batteries.service.stateOfCharge": randomVal(0.1, 1),
|
||||
// traction battery (main_path: electrical.batteries.traction)
|
||||
"electrical.batteries.traction.current": randomVal(-100, 100),
|
||||
"electrical.batteries.traction.power": randomVal(0, 5000),
|
||||
"electrical.batteries.traction.stateOfCharge": randomVal(0.1, 1),
|
||||
"electrical.batteries.traction.temperature": randomVal(20, 45),
|
||||
"electrical.batteries.traction.Voltage": randomVal(48, 58),
|
||||
// temperatura (main_path: meb.temperature, single field)
|
||||
"meb.temperature": randomVal(15, 35),
|
||||
// waves (main_path: meb.waves)
|
||||
"meb.waves.direction": randomVal(0, 360),
|
||||
"meb.waves.height": randomVal(0, 4),
|
||||
"meb.waves.period": randomVal(1, 12),
|
||||
// wind (main_path: meb.wind)
|
||||
"meb.wind.direction": randomVal(0, 360),
|
||||
"meb.wind.speed": randomVal(0, 40),
|
||||
// system uptime (main_path: system.uptime, single field)
|
||||
"system.uptime": Math.floor(process.uptime())
|
||||
})
|
||||
|
||||
try {
|
||||
// ==================== WEB SOCKET AISSTREAM ====================
|
||||
try {
|
||||
aisStream();
|
||||
} catch (error) {
|
||||
console.error('[ERROR] Errore in AISStream:', error);
|
||||
// Aggiorna CONFIG dai settings di SignalK
|
||||
if (settings && settings.forecast_current_frequency) {
|
||||
CONFIG.forecast_current_frequency = settings.forecast_current_frequency * 1000;
|
||||
}
|
||||
if (settings && settings.forecast_hourly_frequency) {
|
||||
CONFIG.forecast_hourly_frequency = settings.forecast_hourly_frequency * 1000;
|
||||
}
|
||||
|
||||
// ==================== WEATHER UPDATES (OpenMeteo condiviso ogni 2 min) ====================
|
||||
|
||||
let location = {
|
||||
latitude: app.getSelfPath('navigation.position')?.value?.latitude,
|
||||
longitude: app.getSelfPath('navigation.position')?.value?.longitude,
|
||||
};
|
||||
state.startTime = Date.now();
|
||||
|
||||
const updateWeatherData = async () => {
|
||||
const currentPos = app.getSelfPath('navigation.position')?.value;
|
||||
if (currentPos?.latitude && currentPos?.longitude) {
|
||||
location = { latitude: currentPos.latitude, longitude: currentPos.longitude };
|
||||
} else if (!location.latitude || !location.longitude) {
|
||||
location = {
|
||||
latitude: Number(settings?.latitude),
|
||||
longitude: Number(settings?.longitude),
|
||||
};
|
||||
}
|
||||
// Inizializza realtime (async: carica sensor refs dal server)
|
||||
await realtime.init(app, settings.sensor_code);
|
||||
|
||||
if (!location.latitude || !location.longitude) {
|
||||
console.warn("[OpenMeteo] Posizione non disponibile");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const [forecastData, wavesData] = await Promise.all([
|
||||
getForecast(location),
|
||||
getSeaConditions(location)
|
||||
]);
|
||||
|
||||
// Log per debug
|
||||
if (forecastData) {
|
||||
console.log("[OpenMeteo] Forecast ricevuto:", {
|
||||
temp: forecastData.temperature,
|
||||
wind: forecastData.windSpeed,
|
||||
humidity: forecastData.humidity
|
||||
});
|
||||
}
|
||||
|
||||
if (wavesData) {
|
||||
console.log("[OpenMeteo] Marine ricevuto:", {
|
||||
waveHeight: wavesData.waveHeight,
|
||||
wavePeriod: wavesData.wavePeriod
|
||||
});
|
||||
}
|
||||
|
||||
// Aggiorna dati condivisi per grafici
|
||||
graphsCore.updateSharedWeatherData(forecastData, wavesData);
|
||||
|
||||
|
||||
// Pubblica su SignalK solo se abbiamo dati validi
|
||||
const weatherPayload = {
|
||||
temperature: forecastData?.temperature ?? null,
|
||||
humidity: forecastData?.humidity ?? null,
|
||||
pressure: forecastData?.pressure ?? null,
|
||||
wind: {
|
||||
speed: forecastData?.windSpeed ?? null,
|
||||
direction: forecastData?.windDirection ?? null,
|
||||
gusts: forecastData?.windGusts ?? null
|
||||
},
|
||||
waves: {
|
||||
height: wavesData?.waveHeight ?? null,
|
||||
period: wavesData?.wavePeriod ?? null,
|
||||
direction: wavesData?.waveDirection ?? null
|
||||
},
|
||||
rain: forecastData?.rain ?? null,
|
||||
precipitation: forecastData?.precipitation ?? null
|
||||
};
|
||||
|
||||
publish(app, weatherPayload, settings);
|
||||
console.log("[OpenMeteo] Dati pubblicati su SignalK");
|
||||
|
||||
} catch (error) {
|
||||
console.error("[OpenMeteo] Errore aggiornamento:", error.message);
|
||||
}
|
||||
};
|
||||
|
||||
// Funzione per archiviare dati orari per grafici
|
||||
const archiveHourlyData = () => {
|
||||
const sharedData = graphsCore.getSharedWeatherData();
|
||||
if (sharedData.forecast || sharedData.waves) {
|
||||
graphsCore.archiveHourlyData({
|
||||
temperature: sharedData.forecast?.temperature,
|
||||
humidity: sharedData.forecast?.humidity,
|
||||
pressure: sharedData.forecast?.pressure,
|
||||
windSpeed: sharedData.forecast?.windSpeed,
|
||||
windDirection: sharedData.forecast?.windDirection,
|
||||
waveHeight: sharedData.waves?.waveHeight,
|
||||
wavePeriod: sharedData.waves?.wavePeriod,
|
||||
waveDirection: sharedData.waves?.waveDirection,
|
||||
// currentSpeed: sharedData.waves?.currentVelocity,
|
||||
// currentDirection: sharedData.waves?.currentDirection
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Avvia aggiornamento meteo immediato + timer 2 minuti
|
||||
updateWeatherData();
|
||||
state.openMeteoTimer = setInterval(updateWeatherData, CONFIG.openmeteo_interval);
|
||||
|
||||
// Archivia dati ogni ora per i grafici
|
||||
state.hourlyArchiveTimer = setInterval(archiveHourlyData, CONFIG.hourly_archive_interval);
|
||||
|
||||
|
||||
// ==================== MAPPA INTERATTIVA ====================
|
||||
try {
|
||||
mapHandler(app, settings);
|
||||
} catch (error) {
|
||||
console.error('[ERROR] Errore nell\'avvio della mappa:', error);
|
||||
}
|
||||
|
||||
// ==================== LOG DATI ====================
|
||||
try {
|
||||
startRecording(settings);
|
||||
} catch (error) {
|
||||
console.error('[ERROR] Errore nell\'avvio dei log:', error);
|
||||
}
|
||||
|
||||
|
||||
app.datasetControl = {
|
||||
start: () => startRecording(settings),
|
||||
stop: stopRecording,
|
||||
restart: () => restartRecording(settings),
|
||||
getStatus: getRecordingStatus
|
||||
};
|
||||
|
||||
// Esponi funzioni per modifica intervalli
|
||||
app.intervalControl = {
|
||||
updateInterval: (type, newIntervalMs) => {
|
||||
const result = updateInterval(type, newIntervalMs);
|
||||
if (!result) return null;
|
||||
|
||||
// Riavvia il timer appropriato
|
||||
if (result.type === 'openmeteo_interval') {
|
||||
state.openMeteoTimer = clearIntervalSafe(state.openMeteoTimer);
|
||||
updateWeatherData(); // Aggiorna subito
|
||||
state.openMeteoTimer = setInterval(updateWeatherData, newIntervalMs);
|
||||
console.log(`[IntervalControl] OpenMeteo interval aggiornato a ${newIntervalMs}ms`);
|
||||
} else if (result.type === 'log_interval') {
|
||||
// Riavvia recording con nuovo intervallo
|
||||
const wasRecording = state.isRecordingLogs;
|
||||
if (wasRecording) {
|
||||
state.logTimer = clearIntervalSafe(state.logTimer);
|
||||
state.logTimer = setInterval(() => {
|
||||
try {
|
||||
if (!state.logStreamer || state.logStreamer.destroyed) {
|
||||
console.error('[log_dataset_error] Stream non disponibile');
|
||||
return;
|
||||
}
|
||||
const data = collectSensorData(settings);
|
||||
const success = dataset.appendData(data, CSV_HEADERS, state.logStreamer);
|
||||
if (success) {
|
||||
state.logsCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[log_dataset_error] Errore durante la raccolta dei dati:', error);
|
||||
}
|
||||
}, newIntervalMs);
|
||||
}
|
||||
console.log(`[IntervalControl] Log interval aggiornato a ${newIntervalMs}ms`);
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
getIntervals: () => ({
|
||||
log_interval: CONFIG.log_interval,
|
||||
openmeteo_interval: CONFIG.openmeteo_interval,
|
||||
hourly_archive_interval: CONFIG.hourly_archive_interval
|
||||
})
|
||||
};
|
||||
|
||||
// ==================== BOT TELEGRAM (dopo intervalControl) ====================
|
||||
// Telegram Bot
|
||||
if (config.telegramBotToken) {
|
||||
try {
|
||||
await linkBot(app);
|
||||
let deviceName = process.env.HOST_NAME || 'Dispositivo Sconosciuto';
|
||||
await send(`Il bot è di nuovo disponibile. (Avviato da ${deviceName})`);
|
||||
console.log('[MEB TELEGRAM] Bot avviato con app.intervalControl disponibile');
|
||||
await linkBotToApp(app);
|
||||
console.log('[MEB] Telegram bot started');
|
||||
} catch (error) {
|
||||
console.error('[ERROR] Errore nell\'avvio del bot telegram', error);
|
||||
console.error('[MEB] Error starting Telegram bot:', error);
|
||||
}
|
||||
} else {
|
||||
console.warn('[MEB TELEGRAM] Bot disabilitato: TELEGRAM_BOT_TOKEN non configurato.');
|
||||
console.warn('[MEB] Telegram bot disabled: TELEGRAM_BOT_TOKEN not set');
|
||||
}
|
||||
|
||||
// ===== Shutdown Hooks =====
|
||||
// Map & API routes
|
||||
try {
|
||||
registerRoutes(app, settings);
|
||||
console.log('[MEB] Routes registered');
|
||||
} catch (error) {
|
||||
console.error('[MEB] Error registering routes:', error);
|
||||
}
|
||||
|
||||
// Avvio ciclo meteo: Prima esecuzione immediata (con hourly)
|
||||
fetchAndPublishWeather(true);
|
||||
|
||||
// Timer ricorrente
|
||||
state.openMeteoTimer = setInterval(() => {
|
||||
fetchAndPublishWeather(false);
|
||||
}, CONFIG.forecast_current_frequency);
|
||||
console.log(`[MEB] Meteo polling avviato ogni ${CONFIG.forecast_current_frequency / 1000}s`);
|
||||
|
||||
|
||||
// Shutdown hooks (register once)
|
||||
const shutdown = async (reason = 'signal') => {
|
||||
try {
|
||||
console.log(`[shutdown] Received ${reason}. Stopping plugin...`);
|
||||
console.log(`[MEB] Received ${reason}. Stopping plugin...`);
|
||||
await plugin.stop();
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error('[shutdown] Error during stop:', err);
|
||||
console.error('[MEB] Error during shutdown:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Evita di registrare multipli handler
|
||||
if (!process.__meb_shutdown_hooks_installed) {
|
||||
process.__meb_shutdown_hooks_installed = true;
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('uncaughtException', (err) => {
|
||||
console.error('[uncaughtException]', err);
|
||||
console.error('[MEB] uncaughtException:', err);
|
||||
shutdown('uncaughtException');
|
||||
});
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
console.error('[unhandledRejection]', reason);
|
||||
console.error('[MEB] unhandledRejection:', reason);
|
||||
shutdown('unhandledRejection');
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Errore] Errore durante l\'avvio del plugin:', error);
|
||||
console.error('[MEB] Error during plugin startup:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
@@ -552,46 +195,82 @@ module.exports = function (app) {
|
||||
stop: async () => {
|
||||
try {
|
||||
state.openMeteoTimer = clearIntervalSafe(state.openMeteoTimer);
|
||||
state.hourlyArchiveTimer = clearIntervalSafe(state.hourlyArchiveTimer);
|
||||
|
||||
if (typeof state.unsubPos === "function") {
|
||||
try {
|
||||
state.unsubPos();
|
||||
state.unsubPos = null;
|
||||
} catch (error) {
|
||||
console.error('[ERROR] Errore durante la cancellazione dell\'iscrizione alla posizione:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// stopRecording gestisce già criptazione e salvataggio reference
|
||||
if (app.datasetControl) {
|
||||
try {
|
||||
app.datasetControl.stop();
|
||||
} catch (error) {
|
||||
console.error('[ERROR] Errore durante l\'arresto del controllo del dataset:', error);
|
||||
}
|
||||
}
|
||||
|
||||
await closeStream(state.logStreamer);
|
||||
console.log('[stop] Plugin arrestato correttamente.');
|
||||
|
||||
realtime.stop();
|
||||
console.log('[MEB] Plugin stopped');
|
||||
} catch (error) {
|
||||
console.error('[ERROR] Errore durante l\'arresto del plugin:', error);
|
||||
console.error('[MEB] Error during plugin stop:', error);
|
||||
}
|
||||
},
|
||||
|
||||
schema: () => ({
|
||||
type: "object",
|
||||
required: [],
|
||||
properties: {},
|
||||
}),
|
||||
schema: () => ({}),
|
||||
|
||||
registerWithRouter: (router) => {
|
||||
setupRoutes(router, lastCallRef, app);
|
||||
// Aggiorna la configurazione (da Telegram o API)
|
||||
setConfig: (key, value) => {
|
||||
if (key === 'forecast_current_frequency') {
|
||||
const ms = value * 1000;
|
||||
CONFIG.forecast_current_frequency = ms;
|
||||
|
||||
// Riavvia il timer con la nuova frequenza
|
||||
state.openMeteoTimer = clearIntervalSafe(state.openMeteoTimer);
|
||||
state.openMeteoTimer = setInterval(() => {
|
||||
fetchAndPublishWeather(false);
|
||||
}, ms);
|
||||
|
||||
console.log(`[MEB] Intervallo current aggiornato a ${value} s`);
|
||||
return true;
|
||||
}
|
||||
if (key === 'forecast_hourly_frequency') {
|
||||
CONFIG.forecast_hourly_frequency = value * 1000;
|
||||
console.log(`[MEB] Intervallo Hourly aggiornato a ${value} s`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
getOpenApi: getOpenApiSpec,
|
||||
// Gestione Polling Meteo (Start/Stop/Force)
|
||||
startPolling: () => {
|
||||
if (state.openMeteoTimer) {
|
||||
console.log('[MEB] Polling già attivo.');
|
||||
return false;
|
||||
}
|
||||
|
||||
fetchAndPublishWeather(false);
|
||||
|
||||
state.openMeteoTimer = setInterval(() => {
|
||||
fetchAndPublishWeather(false);
|
||||
}, CONFIG.forecast_current_frequency);
|
||||
|
||||
console.log(`[MEB] Meteo AVVIATO (freq: ${CONFIG.forecast_current_frequency / 1000}s)`);
|
||||
return true;
|
||||
},
|
||||
|
||||
stopPolling: () => {
|
||||
if (!state.openMeteoTimer) {
|
||||
console.log('[MEB] Polling già fermo.');
|
||||
return false;
|
||||
}
|
||||
state.openMeteoTimer = clearIntervalSafe(state.openMeteoTimer);
|
||||
console.log('[MEB] Meteo polling FERMATO.');
|
||||
return true;
|
||||
},
|
||||
|
||||
isPollingActive: () => !!state.openMeteoTimer,
|
||||
|
||||
forceUpdate: async () => {
|
||||
console.log('[MEB] Aggiornamento Meteo Forzato da Utente.');
|
||||
await fetchAndPublishWeather(false);
|
||||
return true;
|
||||
},
|
||||
|
||||
getOpenApi: () => ({
|
||||
openapi: "3.0.0",
|
||||
info: { title: "MEB Plugin API", version: "2.0.0" },
|
||||
servers: [{ url: "/plugins/meb" }],
|
||||
paths: {}
|
||||
}),
|
||||
};
|
||||
|
||||
app.mebPlugin = plugin;
|
||||
|
||||
return plugin;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
border-radius: 25px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
#error-popup {
|
||||
|
||||
@@ -1,785 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MEB - Decryption Tool</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
min-height: 100vh;
|
||||
color: #e4e4e4;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
header {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 2.5rem;
|
||||
color: #00d4ff;
|
||||
text-shadow: 0 0 20px rgba(0, 212, 255, 0.5);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
header p {
|
||||
color: #888;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 16px;
|
||||
padding: 30px;
|
||||
margin-bottom: 20px;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
color: #00d4ff;
|
||||
margin-bottom: 20px;
|
||||
font-size: 1.3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.card h2 .icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
/* Drop Zone */
|
||||
.drop-zone {
|
||||
border: 2px dashed rgba(0, 212, 255, 0.4);
|
||||
border-radius: 12px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
background: rgba(0, 212, 255, 0.05);
|
||||
}
|
||||
|
||||
.drop-zone:hover,
|
||||
.drop-zone.dragover {
|
||||
border-color: #00d4ff;
|
||||
background: rgba(0, 212, 255, 0.15);
|
||||
transform: scale(1.01);
|
||||
}
|
||||
|
||||
.drop-zone .icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 15px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.drop-zone p {
|
||||
color: #aaa;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.drop-zone .hint {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* File Input Hidden */
|
||||
#fileInput {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Key Input */
|
||||
.key-section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
flex: 1;
|
||||
padding: 14px 18px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.input-group input:focus {
|
||||
outline: none;
|
||||
border-color: #00d4ff;
|
||||
box-shadow: 0 0 15px rgba(0, 212, 255, 0.3);
|
||||
}
|
||||
|
||||
.input-group input::placeholder {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 14px 28px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #00d4ff, #0099cc);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0, 212, 255, 0.4);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
background: #444;
|
||||
color: #888;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: linear-gradient(135deg, #00ff88, #00cc6a);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
box-shadow: 0 8px 25px rgba(0, 255, 136, 0.4);
|
||||
}
|
||||
|
||||
/* File Info */
|
||||
.file-info {
|
||||
display: none;
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #00d4ff;
|
||||
}
|
||||
|
||||
.file-info.visible {
|
||||
display: block;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
.file-info .file-name {
|
||||
font-weight: 600;
|
||||
color: #00d4ff;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.file-info .file-size {
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
/* Status Messages */
|
||||
.status {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
display: none;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
.status.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.status.success {
|
||||
background: rgba(0, 255, 136, 0.15);
|
||||
border: 1px solid rgba(0, 255, 136, 0.3);
|
||||
color: #00ff88;
|
||||
}
|
||||
|
||||
.status.error {
|
||||
background: rgba(255, 68, 68, 0.15);
|
||||
border: 1px solid rgba(255, 68, 68, 0.3);
|
||||
color: #ff4444;
|
||||
}
|
||||
|
||||
.status.info {
|
||||
background: rgba(0, 212, 255, 0.15);
|
||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||
color: #00d4ff;
|
||||
}
|
||||
|
||||
/* Preview Section */
|
||||
.preview-section {
|
||||
display: none;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.preview-section.visible {
|
||||
display: block;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
max-height: 400px;
|
||||
overflow: auto;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.preview-content::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.preview-content::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.preview-content::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 212, 255, 0.4);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Action Buttons */
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Algorithm Info */
|
||||
.algo-info {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background: rgba(255, 193, 7, 0.1);
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #ffc107;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.algo-info code {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 600px) {
|
||||
header h1 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Toggle visibility button */
|
||||
.toggle-visibility {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
padding: 10px;
|
||||
font-size: 1.2rem;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.toggle-visibility:hover {
|
||||
color: #00d4ff;
|
||||
}
|
||||
|
||||
/* Loading spinner */
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: #fff;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>🔐 MEB Decryption Tool</h1>
|
||||
<p>Decripta i file CSV criptati con AES-256-GCM</p>
|
||||
</header>
|
||||
|
||||
<!-- Upload Section -->
|
||||
<div class="card">
|
||||
<h2><span class="icon">📁</span> Importa File Criptato</h2>
|
||||
|
||||
<div class="drop-zone" id="dropZone">
|
||||
<span class="icon">⬆️</span>
|
||||
<p>Trascina qui il file criptato o clicca per selezionarlo</p>
|
||||
<span class="hint">Formati supportati: .csv (criptati)</span>
|
||||
</div>
|
||||
<input type="file" id="fileInput" accept=".csv,.bin,.enc">
|
||||
|
||||
<div class="file-info" id="fileInfo">
|
||||
<div class="file-name" id="fileName"></div>
|
||||
<div class="file-size" id="fileSize"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Key Section -->
|
||||
<div class="card">
|
||||
<h2><span class="icon">🔑</span> Chiave di Decriptazione</h2>
|
||||
|
||||
<div class="key-section">
|
||||
<div class="input-group">
|
||||
<input type="password" id="decryptKey" placeholder="Inserisci la chiave di decriptazione (token)">
|
||||
<button class="toggle-visibility" id="toggleKey" title="Mostra/Nascondi chiave">👁️</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="algo-info">
|
||||
<strong>ℹ️ Formato chiave supportato:</strong><br>
|
||||
• Token esadecimale (48 caratteri): <code>217af80a15d54289...</code><br>
|
||||
• Qualsiasi stringa (verrà hashata con SHA-256)<br>
|
||||
• Algoritmo: <code>AES-256-GCM</code> con IV (12 byte) + Auth Tag (16 byte)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Decrypt Button -->
|
||||
<div class="card">
|
||||
<button class="btn btn-primary" id="decryptBtn" disabled>
|
||||
<span id="decryptBtnText">🔓 Decripta File</span>
|
||||
</button>
|
||||
|
||||
<div class="status" id="status"></div>
|
||||
|
||||
<!-- Preview Section -->
|
||||
<div class="preview-section" id="previewSection">
|
||||
<div class="preview-header">
|
||||
<h3>📄 Anteprima Contenuto</h3>
|
||||
<span id="previewLines"></span>
|
||||
</div>
|
||||
<div class="preview-content" id="previewContent"></div>
|
||||
|
||||
<div class="action-buttons">
|
||||
<button class="btn btn-success" id="downloadBtn">
|
||||
⬇️ Scarica File Decriptato
|
||||
</button>
|
||||
<button class="btn btn-secondary" id="copyBtn">
|
||||
📋 Copia negli Appunti
|
||||
</button>
|
||||
<button class="btn btn-secondary" id="resetBtn">
|
||||
🔄 Nuovo File
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ==================== CRYPTO FUNCTIONS (Same as crypt.js) ====================
|
||||
|
||||
/**
|
||||
* Normalizza qualsiasi chiave a 32 byte per AES-256
|
||||
* Replica la logica di normalizeKey() in crypt.js
|
||||
*/
|
||||
async function normalizeKey(customKey) {
|
||||
if (!customKey) {
|
||||
throw new Error("Chiave non fornita");
|
||||
}
|
||||
|
||||
// Se è hex di 64 caratteri, converti direttamente
|
||||
if (/^[0-9a-fA-F]{64}$/.test(customKey)) {
|
||||
return hexToArrayBuffer(customKey);
|
||||
}
|
||||
|
||||
// Se è hex di 48 caratteri (token standard), hash con SHA-256
|
||||
// Altrimenti hash SHA-256 per ottenere 32 byte
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(customKey);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
return hashBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converte stringa hex in ArrayBuffer
|
||||
*/
|
||||
function hexToArrayBuffer(hex) {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decripta dati con AES-256-GCM
|
||||
* Struttura file: [IV(12 byte) + TAG(16 byte) + CIPHERTEXT]
|
||||
*/
|
||||
async function decryptAES256GCM(encryptedBuffer, keyBuffer) {
|
||||
const encryptedArray = new Uint8Array(encryptedBuffer);
|
||||
|
||||
if (encryptedArray.length < 28) {
|
||||
throw new Error("File troppo corto o corrotto");
|
||||
}
|
||||
|
||||
// Estrai IV (primi 12 byte)
|
||||
const iv = encryptedArray.slice(0, 12);
|
||||
|
||||
// Estrai Auth Tag (byte 12-28)
|
||||
const tag = encryptedArray.slice(12, 28);
|
||||
|
||||
// Estrai ciphertext (resto del file)
|
||||
const ciphertext = encryptedArray.slice(28);
|
||||
|
||||
// In WebCrypto, il tag è concatenato al ciphertext per la decrittazione
|
||||
const ciphertextWithTag = new Uint8Array(ciphertext.length + tag.length);
|
||||
ciphertextWithTag.set(ciphertext);
|
||||
ciphertextWithTag.set(tag, ciphertext.length);
|
||||
|
||||
// Importa la chiave
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyBuffer,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['decrypt']
|
||||
);
|
||||
|
||||
// Decripta
|
||||
const decryptedBuffer = await crypto.subtle.decrypt(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
iv: iv,
|
||||
tagLength: 128 // 16 byte = 128 bit
|
||||
},
|
||||
cryptoKey,
|
||||
ciphertextWithTag
|
||||
);
|
||||
|
||||
return decryptedBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Controlla se il file è già in chiaro (CSV/testo)
|
||||
*/
|
||||
function isPlainText(buffer) {
|
||||
const arr = new Uint8Array(buffer);
|
||||
if (arr.length < 10) return false;
|
||||
|
||||
// soglia: se più del 10% dei byte nei primi 256 sono non stampabili, consideriamo binario
|
||||
const maxCheck = Math.min(256, arr.length);
|
||||
let nonPrintable = 0;
|
||||
|
||||
for (let i = 0; i < maxCheck; i++) {
|
||||
const b = arr[i];
|
||||
|
||||
// caratteri ammessi: tab (9), newline (10), carriage return (13),
|
||||
// spazio (32) fino a ~ carattere 126 (tilde)
|
||||
const isAllowedWhitespace = b === 9 || b === 10 || b === 13;
|
||||
const isPrintable = b >= 32 && b <= 126;
|
||||
|
||||
if (!(isAllowedWhitespace || isPrintable)) {
|
||||
nonPrintable++;
|
||||
}
|
||||
}
|
||||
|
||||
const ratio = nonPrintable / maxCheck;
|
||||
return ratio < 0.1; // se meno del 10% sono strani, lo consideriamo testo
|
||||
}
|
||||
|
||||
// ==================== DOM ELEMENTS ====================
|
||||
const dropZone = document.getElementById('dropZone');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const fileInfo = document.getElementById('fileInfo');
|
||||
const fileName = document.getElementById('fileName');
|
||||
const fileSize = document.getElementById('fileSize');
|
||||
const decryptKey = document.getElementById('decryptKey');
|
||||
const toggleKey = document.getElementById('toggleKey');
|
||||
const decryptBtn = document.getElementById('decryptBtn');
|
||||
const decryptBtnText = document.getElementById('decryptBtnText');
|
||||
const status = document.getElementById('status');
|
||||
const previewSection = document.getElementById('previewSection');
|
||||
const previewContent = document.getElementById('previewContent');
|
||||
const previewLines = document.getElementById('previewLines');
|
||||
const downloadBtn = document.getElementById('downloadBtn');
|
||||
const copyBtn = document.getElementById('copyBtn');
|
||||
const resetBtn = document.getElementById('resetBtn');
|
||||
|
||||
// ==================== STATE ====================
|
||||
let selectedFile = null;
|
||||
let decryptedContent = null;
|
||||
|
||||
// ==================== EVENT LISTENERS ====================
|
||||
|
||||
// Drop zone click
|
||||
dropZone.addEventListener('click', () => fileInput.click());
|
||||
|
||||
// Drag and drop
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('dragover');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragleave', () => {
|
||||
dropZone.classList.remove('dragover');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('dragover');
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
handleFile(files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
// File input change
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
if (e.target.files.length > 0) {
|
||||
handleFile(e.target.files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle key visibility
|
||||
toggleKey.addEventListener('click', () => {
|
||||
const type = decryptKey.type === 'password' ? 'text' : 'password';
|
||||
decryptKey.type = type;
|
||||
toggleKey.textContent = type === 'password' ? '👁️' : '🙈';
|
||||
});
|
||||
|
||||
// Key input
|
||||
decryptKey.addEventListener('input', updateDecryptButton);
|
||||
|
||||
// Decrypt button
|
||||
decryptBtn.addEventListener('click', decryptFile);
|
||||
|
||||
// Download button
|
||||
downloadBtn.addEventListener('click', downloadDecrypted);
|
||||
|
||||
// Copy button
|
||||
copyBtn.addEventListener('click', copyToClipboard);
|
||||
|
||||
// Reset button
|
||||
resetBtn.addEventListener('click', resetAll);
|
||||
|
||||
// ==================== FUNCTIONS ====================
|
||||
|
||||
function handleFile(file) {
|
||||
selectedFile = file;
|
||||
fileName.textContent = `📄 ${file.name}`;
|
||||
fileSize.textContent = `Dimensione: ${formatSize(file.size)}`;
|
||||
fileInfo.classList.add('visible');
|
||||
updateDecryptButton();
|
||||
hideStatus();
|
||||
previewSection.classList.remove('visible');
|
||||
decryptedContent = null;
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
|
||||
}
|
||||
|
||||
function updateDecryptButton() {
|
||||
decryptBtn.disabled = !(selectedFile && decryptKey.value.trim());
|
||||
}
|
||||
|
||||
function showStatus(message, type) {
|
||||
status.textContent = message;
|
||||
status.className = `status visible ${type}`;
|
||||
}
|
||||
|
||||
function hideStatus() {
|
||||
status.classList.remove('visible');
|
||||
}
|
||||
|
||||
async function decryptFile() {
|
||||
if (!selectedFile || !decryptKey.value.trim()) return;
|
||||
|
||||
// Show loading
|
||||
decryptBtnText.innerHTML = '<span class="spinner"></span> Decrittazione...';
|
||||
decryptBtn.disabled = true;
|
||||
|
||||
try {
|
||||
// Read file
|
||||
const fileBuffer = await selectedFile.arrayBuffer();
|
||||
|
||||
// Check if already plain text
|
||||
if (isPlainText(fileBuffer)) {
|
||||
decryptedContent = new TextDecoder().decode(fileBuffer);
|
||||
showStatus('⚠️ Il file è già in chiaro (non criptato)', 'info');
|
||||
} else {
|
||||
// Normalize key
|
||||
const keyBuffer = await normalizeKey(decryptKey.value.trim());
|
||||
|
||||
// Decrypt
|
||||
const decryptedBuffer = await decryptAES256GCM(fileBuffer, keyBuffer);
|
||||
decryptedContent = new TextDecoder().decode(decryptedBuffer);
|
||||
|
||||
showStatus('✅ File decriptato con successo!', 'success');
|
||||
}
|
||||
|
||||
// Show preview
|
||||
showPreview(decryptedContent);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Decryption error:', error);
|
||||
|
||||
let errorMsg = '❌ Errore nella decrittazione: ';
|
||||
if (error.message.includes('tag') || error.message.includes('decrypt')) {
|
||||
errorMsg += 'Chiave non valida o file corrotto';
|
||||
} else {
|
||||
errorMsg += error.message;
|
||||
}
|
||||
|
||||
showStatus(errorMsg, 'error');
|
||||
previewSection.classList.remove('visible');
|
||||
} finally {
|
||||
decryptBtnText.innerHTML = '🔓 Decripta File';
|
||||
updateDecryptButton();
|
||||
}
|
||||
}
|
||||
|
||||
function showPreview(content) {
|
||||
const lines = content.split('\n');
|
||||
const previewText = lines.slice(0, 50).join('\n');
|
||||
|
||||
previewContent.textContent = previewText;
|
||||
previewLines.textContent = `${lines.length} righe totali`;
|
||||
|
||||
if (lines.length > 50) {
|
||||
previewContent.textContent += '\n\n... [Anteprima troncata a 50 righe] ...';
|
||||
}
|
||||
|
||||
previewSection.classList.add('visible');
|
||||
}
|
||||
|
||||
function downloadDecrypted() {
|
||||
if (!decryptedContent || !selectedFile) return;
|
||||
|
||||
const blob = new Blob([decryptedContent], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = selectedFile.name.replace('.csv', '_decrypted.csv');
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
showStatus('✅ File scaricato!', 'success');
|
||||
}
|
||||
|
||||
async function copyToClipboard() {
|
||||
if (!decryptedContent) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(decryptedContent);
|
||||
showStatus('✅ Contenuto copiato negli appunti!', 'success');
|
||||
} catch (error) {
|
||||
showStatus('❌ Errore nella copia: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
selectedFile = null;
|
||||
decryptedContent = null;
|
||||
fileInput.value = '';
|
||||
decryptKey.value = '';
|
||||
fileInfo.classList.remove('visible');
|
||||
previewSection.classList.remove('visible');
|
||||
hideStatus();
|
||||
updateDecryptButton();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,386 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Previsioni - 7 giorni</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
min-height: 100vh;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 20px 30px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header .status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #4ade80;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.controls {
|
||||
padding: 20px 30px;
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
padding: 10px 20px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.control-btn.active {
|
||||
background: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.dashboard {
|
||||
padding: 20px 30px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
|
||||
gap: 25px;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 16px;
|
||||
padding: 25px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.chart-card h3 {
|
||||
margin-bottom: 20px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chart-card .icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
position: relative;
|
||||
height: 250px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
padding: 20px 30px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.stat-card .label {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.stat-card .value {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.stat-card .unit {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 200px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dashboard {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
padding: 15px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>📊 MEB Grafici Meteo</h1>
|
||||
<div class="status">
|
||||
<div class="status-dot"></div>
|
||||
<span id="last-update">Aggiornamento...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button class="control-btn active" data-hours="24">24 Ore</button>
|
||||
<button class="control-btn" data-hours="48">48 Ore</button>
|
||||
<button class="control-btn" data-hours="168">7 Giorni</button>
|
||||
<button class="control-btn" onclick="refreshData()">🔄 Aggiorna</button>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="label">🌡️ Temperatura Attuale</div>
|
||||
<div class="value" id="current-temp">--</div>
|
||||
<div class="unit" id="unit-temp">°C</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">🌬️ Vento</div>
|
||||
<div class="value" id="current-wind">--</div>
|
||||
<div class="unit" id="unit-wind">km/h</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">🌊 Altezza Onde</div>
|
||||
<div class="value" id="current-waves">--</div>
|
||||
<div class="unit" id="unit-waves">m</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">💧 Umidità</div>
|
||||
<div class="value" id="current-humidity">--</div>
|
||||
<div class="unit" id="unit-humidity">%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard">
|
||||
<div class="chart-card">
|
||||
<h3><span class="icon">🌡️</span> Temperatura</h3>
|
||||
<div class="chart-container">
|
||||
<canvas id="temperatureChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-card">
|
||||
<h3><span class="icon">🌬️</span> Velocità Vento</h3>
|
||||
<div class="chart-container">
|
||||
<canvas id="windChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-card">
|
||||
<h3><span class="icon">🌊</span> Altezza Onde</h3>
|
||||
<div class="chart-container">
|
||||
<canvas id="waveChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-card">
|
||||
<h3><span class="icon">💧</span> Umidità</h3>
|
||||
<div class="chart-container">
|
||||
<canvas id="humidityChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Configurazione globale Chart.js
|
||||
Chart.defaults.color = 'rgba(255, 255, 255, 0.7)';
|
||||
Chart.defaults.borderColor = 'rgba(255, 255, 255, 0.1)';
|
||||
|
||||
let charts = {};
|
||||
let selectedHours = 24;
|
||||
|
||||
// Opzioni comuni per i grafici
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false }
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: 'rgba(255, 255, 255, 0.05)' },
|
||||
ticks: { maxTicksLimit: 8 }
|
||||
},
|
||||
y: {
|
||||
grid: { color: 'rgba(255, 255, 255, 0.05)' },
|
||||
beginAtZero: false
|
||||
}
|
||||
},
|
||||
elements: {
|
||||
point: { radius: 2, hoverRadius: 5 },
|
||||
line: { borderWidth: 2 }
|
||||
}
|
||||
};
|
||||
|
||||
// Inizializza grafici
|
||||
function initCharts() {
|
||||
const tempCtx = document.getElementById('temperatureChart').getContext('2d');
|
||||
charts.temperature = new Chart(tempCtx, {
|
||||
type: 'line',
|
||||
data: { labels: [], datasets: [] },
|
||||
options: chartOptions
|
||||
});
|
||||
|
||||
const windCtx = document.getElementById('windChart').getContext('2d');
|
||||
charts.wind = new Chart(windCtx, {
|
||||
type: 'line',
|
||||
data: { labels: [], datasets: [] },
|
||||
options: chartOptions
|
||||
});
|
||||
|
||||
const waveCtx = document.getElementById('waveChart').getContext('2d');
|
||||
charts.wave = new Chart(waveCtx, {
|
||||
type: 'line',
|
||||
data: { labels: [], datasets: [] },
|
||||
options: chartOptions
|
||||
});
|
||||
|
||||
const humidityCtx = document.getElementById('humidityChart').getContext('2d');
|
||||
charts.humidity = new Chart(humidityCtx, {
|
||||
type: 'line',
|
||||
data: { labels: [], datasets: [] },
|
||||
options: chartOptions
|
||||
});
|
||||
}
|
||||
|
||||
// Aggiorna dati dai grafici
|
||||
async function refreshData() {
|
||||
try {
|
||||
const response = await fetch(`/plugins/meb/api/graphs?hours=${selectedHours}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.temperature) {
|
||||
charts.temperature.data = data.temperature;
|
||||
charts.temperature.update('none');
|
||||
}
|
||||
|
||||
if (data.windSpeed) {
|
||||
charts.wind.data = data.windSpeed;
|
||||
charts.wind.update('none');
|
||||
}
|
||||
|
||||
if (data.waveHeight) {
|
||||
charts.wave.data = data.waveHeight;
|
||||
charts.wave.update('none');
|
||||
}
|
||||
|
||||
if (data.humidity) {
|
||||
charts.humidity.data = data.humidity;
|
||||
charts.humidity.update('none');
|
||||
}
|
||||
|
||||
// Aggiorna valori attuali
|
||||
if (data.current) {
|
||||
document.getElementById('current-temp').textContent =
|
||||
data.current.temperature?.toFixed(1) ?? '--';
|
||||
document.getElementById('current-wind').textContent =
|
||||
data.current.windSpeed?.toFixed(1) ?? '--';
|
||||
document.getElementById('current-waves').textContent =
|
||||
data.current.waveHeight?.toFixed(2) ?? '--';
|
||||
document.getElementById('current-humidity').textContent =
|
||||
data.current.humidity?.toFixed(0) ?? '--';
|
||||
}
|
||||
|
||||
// Aggiorna unità dinamiche
|
||||
if (data.units) {
|
||||
const { forecast, waves } = data.units;
|
||||
if (forecast) {
|
||||
document.getElementById('unit-temp').textContent = forecast.temperature || '°C';
|
||||
document.getElementById('unit-wind').textContent = forecast.windSpeed || 'km/h';
|
||||
document.getElementById('unit-humidity').textContent = forecast.humidity || '%';
|
||||
}
|
||||
if (waves) {
|
||||
document.getElementById('unit-waves').textContent = waves.waveHeight || 'm';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('last-update').textContent =
|
||||
`Ultimo aggiornamento: ${new Date().toLocaleTimeString('it-IT')}`;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Errore caricamento dati:', error);
|
||||
document.getElementById('last-update').textContent = 'Errore connessione';
|
||||
}
|
||||
}
|
||||
|
||||
// Gestione pulsanti periodo
|
||||
document.querySelectorAll('.control-btn[data-hours]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.control-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
selectedHours = parseInt(btn.dataset.hours);
|
||||
refreshData();
|
||||
});
|
||||
});
|
||||
|
||||
// Inizializzazione
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initCharts();
|
||||
refreshData();
|
||||
// Aggiorna ogni 2 minuti
|
||||
setInterval(refreshData, 2 * 60 * 1000);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Mappa Meteo SignalK</title>
|
||||
<title>Mappa SignalK</title>
|
||||
|
||||
<script src="https://api.mapbox.com/mapbox-gl-js/v3.2.0/mapbox-gl.js"></script>
|
||||
<link href="https://api.mapbox.com/mapbox-gl-js/v3.2.0/mapbox-gl.css" rel="stylesheet">
|
||||
@@ -34,15 +34,17 @@
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
color: white;
|
||||
background-color: rgba(60, 60, 60, 0.85);
|
||||
color: rgb(0, 0, 0);
|
||||
background-color: rgba(233, 233, 233, 0.412);
|
||||
border: 1px solid rgba(233, 233, 233, 0.412);
|
||||
padding: 12px 18px;
|
||||
border-radius: 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 20px;
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.25;
|
||||
min-width: 270px;
|
||||
z-index: 999;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -210,6 +210,7 @@
|
||||
border-radius: 5px;
|
||||
outline: none;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: linear-gradient(to right, #007aff 0%, #007aff 0%, #e0e0e0 0%, #e0e0e0 100%);
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
450
plugin/realtime/core.js
Normal file
450
plugin/realtime/core.js
Normal file
@@ -0,0 +1,450 @@
|
||||
const WebSocket = require('ws');
|
||||
const msgpack = require('msgpack-lite');
|
||||
const { loadSensorReferencesFromServer, checkSensorReferencesVersion } = require('../config');
|
||||
const dataHub = require('../tools/dataHub');
|
||||
|
||||
// Stato connessione
|
||||
let ws = null;
|
||||
let sendTimer = null;
|
||||
let isConnected = false;
|
||||
let reconnectTimer = null;
|
||||
let pingInterval = null;
|
||||
let configCheckTimer = null;
|
||||
let app = null;
|
||||
|
||||
// Sensor references (caricati dal server)
|
||||
let sensorRules = null;
|
||||
|
||||
// Buffer locale anti-perdita dati
|
||||
const localBuffer = [];
|
||||
const MAX_BUFFER_SIZE = 3600; // ~1h di dati a 1/sec
|
||||
|
||||
// Statistiche (solo in memoria, niente file I/O)
|
||||
let stats = {
|
||||
sensorID: '',
|
||||
sent: 0,
|
||||
firstSent: null,
|
||||
sentEveryMLS: 1000,
|
||||
reconnections: 0,
|
||||
status: 'disconnected',
|
||||
buffered: 0,
|
||||
lastConfigVersion: null
|
||||
};
|
||||
|
||||
// Reconnection con exponential backoff
|
||||
const BASE_RECONNECT_DELAY = 2000;
|
||||
const MAX_RECONNECT_DELAY = 60000;
|
||||
|
||||
/**
|
||||
* Inizializza il modulo realtime.
|
||||
* 1. Autentica il sensore per ottenere un ticket
|
||||
* 2. Usa il ticket per caricare i sensor references (endpoint autenticato)
|
||||
* 3. Usa lo stesso ticket per connettere il WebSocket
|
||||
*/
|
||||
async function init(signalKApp, sensorCode) {
|
||||
app = signalKApp;
|
||||
stats.sensorID = sensorCode || process.env.SENSOR_CODE || 'N/D';
|
||||
stats.sentEveryMLS = parseInt(process.env.SEND_INTERVAL || '500');
|
||||
console.log(`[MEB] Send interval: ${stats.sentEveryMLS}ms (SEND_INTERVAL=${process.env.SEND_INTERVAL || 'default 500'})`);
|
||||
|
||||
// Autenticazione unica: ottieni ticket
|
||||
const authResult = await authenticate();
|
||||
|
||||
if (authResult) {
|
||||
// Carica sensor references con ticket (read-only, non consuma il ticket)
|
||||
sensorRules = await loadSensorReferencesFromServer(authResult.ticket);
|
||||
if (sensorRules) stats.lastConfigVersion = sensorRules.version;
|
||||
|
||||
// Connetti WebSocket con lo stesso ticket (viene consumato qui)
|
||||
connectWebSocket(authResult.wsUrl, authResult.ticket);
|
||||
} else {
|
||||
// Fallback: carica references senza auth, programma riconnessione
|
||||
console.warn('[MEB] Auth fallita, carico references senza autenticazione');
|
||||
sensorRules = await loadSensorReferencesFromServer();
|
||||
if (sensorRules) stats.lastConfigVersion = sensorRules.version;
|
||||
scheduleReconnect();
|
||||
}
|
||||
|
||||
// Avvia polling versione config ogni 5 minuti
|
||||
configCheckTimer = setInterval(checkConfigUpdate, 5 * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Controlla se la config sensori sul server e' cambiata e la ricarica.
|
||||
*/
|
||||
async function checkConfigUpdate() {
|
||||
const newVersion = await checkSensorReferencesVersion(stats.lastConfigVersion);
|
||||
if (newVersion) {
|
||||
console.log(`[MEB] Sensor config aggiornata: ${stats.lastConfigVersion} → ${newVersion}`);
|
||||
sensorRules = await loadSensorReferencesFromServer();
|
||||
if (sensorRules) {
|
||||
stats.lastConfigVersion = sensorRules.version;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────── AUTENTICAZIONE ────────────────────
|
||||
|
||||
async function authenticate() {
|
||||
try {
|
||||
const REALTIME_URL = process.env.REALTIME_URL || 'http://localhost:3002';
|
||||
const url = REALTIME_URL + '/connect/request';
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sensor_code: stats.sensorID })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error(`[MEB] Realtime Auth failed: ${res.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (!data.success || !data.ticket) return null;
|
||||
|
||||
return {
|
||||
ticket: data.ticket,
|
||||
wsUrl: data.ws_url || REALTIME_URL.replace('http', 'ws') + '/ws'
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('[MEB] Error in Realtime auth:', err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────── WEBSOCKET ────────────────────
|
||||
|
||||
function connectWebSocket(wsUrl, ticket) {
|
||||
const fullUrl = `${wsUrl}?ticket=${ticket}`;
|
||||
ws = new WebSocket(fullUrl);
|
||||
|
||||
ws.on('open', () => {
|
||||
console.log('[MEB] Realtime WebSocket connected');
|
||||
isConnected = true;
|
||||
stats.status = 'connected';
|
||||
stats.reconnections = 0; // Reset su connessione riuscita
|
||||
|
||||
startSending();
|
||||
startPingInterval();
|
||||
|
||||
// Flush buffer locale dopo reconnessione
|
||||
flushBuffer();
|
||||
});
|
||||
|
||||
ws.on('close', (code) => {
|
||||
console.log(`[MEB] Realtime WebSocket closed (code: ${code})`);
|
||||
isConnected = false;
|
||||
stats.status = 'disconnected';
|
||||
stopSending();
|
||||
stopPingInterval();
|
||||
scheduleReconnect();
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
console.error('[MEB] Realtime WebSocket error:', err.message);
|
||||
isConnected = false;
|
||||
stats.status = 'error';
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
// Gestisce messaggi dal server (comandi, conferme, ecc.)
|
||||
try {
|
||||
const decoded = msgpack.decode(data);
|
||||
if (decoded?.type === 'connected') {
|
||||
console.log(`[MEB] Server confirmed connection: sensorId=${decoded.sensorId}`);
|
||||
}
|
||||
} catch {
|
||||
// Ignora messaggi non decodificabili
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ──────────────────── INVIO DATI (1/sec) ────────────────────
|
||||
|
||||
function startSending() {
|
||||
stopSending();
|
||||
sendData(); // Prima chiamata immediata
|
||||
sendTimer = setInterval(sendData, stats.sentEveryMLS);
|
||||
}
|
||||
|
||||
function stopSending() {
|
||||
if (sendTimer) {
|
||||
clearInterval(sendTimer);
|
||||
sendTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legge un valore dal data model di SignalK.
|
||||
*/
|
||||
function getSignalKData(skPath) {
|
||||
const val = app.getSelfPath(skPath);
|
||||
return val && val.value !== undefined && val.value !== null ? val.value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raccoglie TUTTI i dati sensore definiti nella config.
|
||||
* Produce chiavi flat: "temperature", "wind_direction", "position_latitude", ecc.
|
||||
* Stessa logica di logRecorder.collectSensorData().
|
||||
*/
|
||||
function collectAllSensorData() {
|
||||
const data = {};
|
||||
|
||||
if (!sensorRules || !sensorRules.items) {
|
||||
// Fallback hardcoded se non ci sono regole
|
||||
return {
|
||||
service_battery_voltage: getSignalKData('electrical.batteries.service.Voltage') || 0,
|
||||
service_battery_stateOfCharge: (getSignalKData('electrical.batteries.service.stateOfCharge') || 0) * 100,
|
||||
traction_battery_power: getSignalKData('electrical.batteries.traction.power') || 0,
|
||||
temperature: (getSignalKData('meb.temperature') || 273.15) - 273.15,
|
||||
position_latitude: app.getSelfPath('navigation.position')?.value?.latitude || 0,
|
||||
position_longitude: app.getSelfPath('navigation.position')?.value?.longitude || 0
|
||||
};
|
||||
}
|
||||
|
||||
for (const item of sensorRules.items) {
|
||||
const mainPath = item.main_path;
|
||||
|
||||
if (!item.elements || item.elements === null) {
|
||||
// Campo singolo: usa il nome della collection
|
||||
data[item.collection] = getSignalKData(mainPath);
|
||||
} else {
|
||||
for (const element of item.elements) {
|
||||
// Separa subelements dalle proprietà campo
|
||||
const { subelements, ...fields } = element;
|
||||
const [fieldName, subPath] = Object.entries(fields)[0];
|
||||
const keyName = item.collection
|
||||
? `${item.collection}_${fieldName}`
|
||||
: fieldName;
|
||||
|
||||
if (fieldName === 'latitude' || fieldName === 'longitude') {
|
||||
const baseValue = app.getSelfPath(`${mainPath}.position`)?.value;
|
||||
data[keyName] = (baseValue && typeof baseValue === 'object')
|
||||
? baseValue[fieldName] ?? null
|
||||
: null;
|
||||
} else {
|
||||
data[keyName] = getSignalKData(`${mainPath}.${subPath}`);
|
||||
}
|
||||
|
||||
// Gestisci subelementi (es. direction.average)
|
||||
if (subelements && Array.isArray(subelements)) {
|
||||
for (const sub of subelements) {
|
||||
const [subFieldName, subSubPath] = Object.entries(sub)[0];
|
||||
const subKey = `${keyName}_${subFieldName}`;
|
||||
data[subKey] = getSignalKData(`${mainPath}.${subPath}.${subSubPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invia dati sensore al server via WebSocket (msgpack).
|
||||
* Se il WS e' disconnesso, buffer localmente.
|
||||
*/
|
||||
function sendData() {
|
||||
const data = collectAllSensorData();
|
||||
|
||||
// Aggiorna la cache centralizzata per Telegram e altri consumer
|
||||
dataHub.updateSensorData(data);
|
||||
|
||||
const message = {
|
||||
type: 'sensor',
|
||||
ts: Date.now(),
|
||||
data
|
||||
};
|
||||
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
bufferLocally(message);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
ws.send(msgpack.encode(message));
|
||||
stats.sent++;
|
||||
if (!stats.firstSent) stats.firstSent = new Date().toISOString();
|
||||
} catch (err) {
|
||||
console.error('[MEB] Error sending realtime data:', err.message);
|
||||
bufferLocally(message);
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────── WEATHER (REST API) ────────────────────
|
||||
|
||||
/**
|
||||
* Invia dati meteo al server via REST API dedicata (POST /weather).
|
||||
* Non usa piu' il WebSocket per i dati meteo — endpoint REST separato.
|
||||
*/
|
||||
async function sendWeatherPayload(payload) {
|
||||
try {
|
||||
const REALTIME_URL = process.env.REALTIME_URL || 'http://localhost:3002';
|
||||
const url = `${REALTIME_URL}/weather`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sensor_code: stats.sensorID,
|
||||
data: payload
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const result = await res.json();
|
||||
console.log(`[MEB] Weather payload inviato via REST — sensor: ${result.sensor}`);
|
||||
} else {
|
||||
console.error(`[MEB] Weather REST failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[MEB] Error sending weather via REST:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────── BUFFER ANTI-PERDITA ────────────────────
|
||||
|
||||
function bufferLocally(message) {
|
||||
localBuffer.push(message);
|
||||
if (localBuffer.length > MAX_BUFFER_SIZE) {
|
||||
localBuffer.shift(); // Rimuovi il piu' vecchio
|
||||
}
|
||||
stats.buffered = localBuffer.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush del buffer locale verso il server dopo reconnessione.
|
||||
* Invia gradualmente per non sovraccaricare il WS.
|
||||
*/
|
||||
function flushBuffer() {
|
||||
if (localBuffer.length === 0) return;
|
||||
|
||||
console.log(`[MEB] Flushing ${localBuffer.length} buffered messages...`);
|
||||
|
||||
const flushBatch = () => {
|
||||
if (localBuffer.length === 0 || !ws || ws.readyState !== WebSocket.OPEN) {
|
||||
stats.buffered = localBuffer.length;
|
||||
return;
|
||||
}
|
||||
|
||||
// Invia 10 messaggi alla volta per non bloccare
|
||||
const batch = Math.min(localBuffer.length, 10);
|
||||
for (let i = 0; i < batch; i++) {
|
||||
const msg = localBuffer.shift();
|
||||
try {
|
||||
ws.send(msgpack.encode(msg));
|
||||
stats.sent++;
|
||||
} catch {
|
||||
localBuffer.unshift(msg);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
stats.buffered = localBuffer.length;
|
||||
|
||||
if (localBuffer.length > 0) {
|
||||
setTimeout(flushBatch, 100); // Pausa tra batch
|
||||
} else {
|
||||
console.log('[MEB] Buffer flush completato');
|
||||
}
|
||||
};
|
||||
|
||||
// Attendi 1s dopo la connessione prima di iniziare il flush
|
||||
setTimeout(flushBatch, 1000);
|
||||
}
|
||||
|
||||
// ──────────────────── RECONNESSIONE ────────────────────
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (reconnectTimer) return;
|
||||
stats.reconnections++;
|
||||
|
||||
// Exponential backoff con jitter
|
||||
const delay = Math.min(
|
||||
BASE_RECONNECT_DELAY * Math.pow(1.5, Math.min(stats.reconnections, 15)),
|
||||
MAX_RECONNECT_DELAY
|
||||
);
|
||||
const jitter = delay * 0.2 * Math.random();
|
||||
const finalDelay = Math.round(delay + jitter);
|
||||
|
||||
console.log(`[MEB] Reconnecting in ${Math.round(finalDelay / 1000)}s (tentativo ${stats.reconnections})`);
|
||||
|
||||
reconnectTimer = setTimeout(async () => {
|
||||
reconnectTimer = null;
|
||||
start();
|
||||
}, finalDelay);
|
||||
}
|
||||
|
||||
// ──────────────────── PING/PONG ────────────────────
|
||||
|
||||
function startPingInterval() {
|
||||
stopPingInterval();
|
||||
pingInterval = setInterval(() => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.ping();
|
||||
}
|
||||
}, 25000); // 25s, server ha heartbeat a 30s
|
||||
}
|
||||
|
||||
function stopPingInterval() {
|
||||
if (pingInterval) {
|
||||
clearInterval(pingInterval);
|
||||
pingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────── START / STOP ────────────────────
|
||||
|
||||
async function start() {
|
||||
const result = await authenticate();
|
||||
if (!result) {
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
connectWebSocket(result.wsUrl, result.ticket);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ferma tutto: WebSocket, timer, ping, config check.
|
||||
*/
|
||||
function stop() {
|
||||
stopSending();
|
||||
stopPingInterval();
|
||||
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
if (configCheckTimer) {
|
||||
clearInterval(configCheckTimer);
|
||||
configCheckTimer = null;
|
||||
}
|
||||
if (ws) {
|
||||
ws.close(1000, 'Plugin stopping');
|
||||
ws = null;
|
||||
}
|
||||
|
||||
isConnected = false;
|
||||
stats.status = 'stopped';
|
||||
console.log('[MEB] Realtime module stopped');
|
||||
}
|
||||
|
||||
function getStats() {
|
||||
return { ...stats, isConnected, bufferSize: localBuffer.length };
|
||||
}
|
||||
|
||||
function getSensorRules() {
|
||||
return sensorRules;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init,
|
||||
stop,
|
||||
sendWeatherPayload,
|
||||
collectAllSensorData,
|
||||
getSensorRules,
|
||||
getStats
|
||||
};
|
||||
56
plugin/routes/dataset.js
Normal file
56
plugin/routes/dataset.js
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Registers dataset recording control routes.
|
||||
* @param {Object} router - Route wrapper with get/post methods
|
||||
* @param {Object} app - SignalK app instance
|
||||
*/
|
||||
function registerDatasetRoutes(router, app) {
|
||||
router.post("/dataset/start", (req, res) => {
|
||||
try {
|
||||
if (!app.datasetControl) {
|
||||
return res.status(503).json({ error: "Dataset control not available" });
|
||||
}
|
||||
const result = app.datasetControl.start();
|
||||
res.json({ success: result, message: result ? "Recording started" : "Already recording" });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/dataset/stop", (req, res) => {
|
||||
try {
|
||||
if (!app.datasetControl) {
|
||||
return res.status(503).json({ error: "Dataset control not available" });
|
||||
}
|
||||
const result = app.datasetControl.stop();
|
||||
res.json({ success: result, message: result ? "Recording stopped" : "No active recording" });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/dataset/restart", (req, res) => {
|
||||
try {
|
||||
if (!app.datasetControl) {
|
||||
return res.status(503).json({ error: "Dataset control not available" });
|
||||
}
|
||||
const result = app.datasetControl.restart();
|
||||
res.json({ success: result, message: "Recording restarted" });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/dataset/status", (req, res) => {
|
||||
try {
|
||||
if (!app.datasetControl) {
|
||||
return res.status(503).json({ error: "Dataset control not available" });
|
||||
}
|
||||
const status = app.datasetControl.getStatus();
|
||||
res.json(status);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = registerDatasetRoutes;
|
||||
63
plugin/routes/forecasts.js
Normal file
63
plugin/routes/forecasts.js
Normal file
@@ -0,0 +1,63 @@
|
||||
const { getForecast, getSeaConditions } = require("../api_models/openmeteo.js");
|
||||
|
||||
/**
|
||||
* Registers forecast-related routes.
|
||||
* @param {Object} router - Route wrapper with get/post methods
|
||||
* @param {Object} app - SignalK app instance
|
||||
*/
|
||||
function registerForecastRoutes(router, app) {
|
||||
// Get current forecast data directly from OpenMeteo
|
||||
router.get("/forecasts/data", async (req, res) => {
|
||||
try {
|
||||
const position = app.getSelfPath('navigation.position')?.value;
|
||||
|
||||
if (!position?.latitude || !position?.longitude) {
|
||||
return res.status(503).json({ error: "Position not available" });
|
||||
}
|
||||
|
||||
const mode = req.query.mode || 'both';
|
||||
const [forecastData, wavesData] = await Promise.all([
|
||||
getForecast(position, { mode }),
|
||||
getSeaConditions(position, { mode })
|
||||
]);
|
||||
|
||||
res.status(200).json({
|
||||
forecast: forecastData,
|
||||
sea: wavesData
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[MEB] Error in /meb/forecasts/data:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Force update: fetch fresh hourly data from OpenMeteo
|
||||
router.post("/forecasts/update", async (req, res) => {
|
||||
try {
|
||||
const position = app.getSelfPath('navigation.position')?.value;
|
||||
|
||||
if (!position?.latitude || !position?.longitude) {
|
||||
return res.status(503).json({ error: "Position not available" });
|
||||
}
|
||||
|
||||
const [forecastData, wavesData] = await Promise.all([
|
||||
getForecast(position, { mode: 'both' }),
|
||||
getSeaConditions(position, { mode: 'both' })
|
||||
]);
|
||||
|
||||
if (!forecastData?.hourly || !wavesData?.hourly) {
|
||||
return res.status(500).json({ error: "Hourly data not available from API" });
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
forecast: forecastData,
|
||||
sea: wavesData
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[MEB] Error in /meb/forecasts/update:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = registerForecastRoutes;
|
||||
30
plugin/routes/helm.js
Normal file
30
plugin/routes/helm.js
Normal file
@@ -0,0 +1,30 @@
|
||||
const path = require("path");
|
||||
|
||||
const websPath = path.join(__dirname, "..", "public");
|
||||
|
||||
/**
|
||||
* Registers helm/steering support routes.
|
||||
* @param {Object} router - Route wrapper with get/post methods
|
||||
*/
|
||||
function registerHelmRoutes(router) {
|
||||
router.get("/helm", (req, res) => {
|
||||
try {
|
||||
const side = req.query.side || "destra";
|
||||
const helmPath = path.join(websPath, "steering_support", `helm_steering_${side}.html`);
|
||||
res.status(200).sendFile(helmPath);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/helm/support", (req, res) => {
|
||||
try {
|
||||
const indexPath = path.join(websPath, "steering_support", "steering_support.html");
|
||||
res.status(200).sendFile(indexPath);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = registerHelmRoutes;
|
||||
34
plugin/routes/index.js
Normal file
34
plugin/routes/index.js
Normal file
@@ -0,0 +1,34 @@
|
||||
const registerMapRoutes = require("./map");
|
||||
const registerHelmRoutes = require("./helm");
|
||||
const registerDatasetRoutes = require("./dataset");
|
||||
const registerForecastRoutes = require("./forecasts");
|
||||
const registerTelegramRoutes = require("./telegram");
|
||||
|
||||
/**
|
||||
* Registers all plugin routes under the /meb prefix.
|
||||
* @param {Object} app - SignalK app instance
|
||||
* @param {Object} settings - Plugin settings
|
||||
*/
|
||||
module.exports = function (app, settings) {
|
||||
const router = {
|
||||
get: (subPath, handler) => {
|
||||
const fullPath = '/meb' + (subPath.startsWith('/') ? subPath : `/${subPath}`);
|
||||
app.get(fullPath, handler);
|
||||
},
|
||||
post: (subPath, handler) => {
|
||||
const fullPath = '/meb' + (subPath.startsWith('/') ? subPath : `/${subPath}`);
|
||||
app.post(fullPath, handler);
|
||||
}
|
||||
};
|
||||
|
||||
// Health check
|
||||
router.get("/ping", (req, res) => {
|
||||
res.status(200).send("Ping is active!");
|
||||
});
|
||||
|
||||
registerMapRoutes(router, app, settings);
|
||||
registerHelmRoutes(router);
|
||||
registerDatasetRoutes(router, app);
|
||||
registerForecastRoutes(router, app);
|
||||
registerTelegramRoutes(router);
|
||||
};
|
||||
43
plugin/routes/map.js
Normal file
43
plugin/routes/map.js
Normal file
@@ -0,0 +1,43 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const websPath = path.join(__dirname, "..", "public");
|
||||
|
||||
/**
|
||||
* Registers map-related routes.
|
||||
* @param {Object} router - Route wrapper with get/post methods
|
||||
* @param {Object} app - SignalK app instance
|
||||
* @param {Object} settings - Plugin settings
|
||||
*/
|
||||
function registerMapRoutes(router, app, settings) {
|
||||
// Serve interactive map with Mapbox token injected
|
||||
router.get('/map', (req, res) => {
|
||||
const filePath = path.join(websPath, "map.html");
|
||||
fs.readFile(filePath, "utf8", (err, html) => {
|
||||
if (err) {
|
||||
res.status(500).send("Error loading map");
|
||||
return;
|
||||
}
|
||||
const token = settings?.mapboxKey ?? "";
|
||||
const finalHtml = html.replace("{{MAPBOX_KEY}}", token);
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.send(finalHtml);
|
||||
});
|
||||
});
|
||||
|
||||
// Stream boat position and serve latest via API
|
||||
let lastPosition = null;
|
||||
|
||||
app.streambundle.getSelfStream("navigation.position").onValue(pos => {
|
||||
lastPosition = pos;
|
||||
});
|
||||
|
||||
router.get('/map/boat', (req, res) => {
|
||||
if (!lastPosition) {
|
||||
return res.json({ error: "No position data available" });
|
||||
}
|
||||
res.json(lastPosition);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = registerMapRoutes;
|
||||
13
plugin/routes/telegram.js
Normal file
13
plugin/routes/telegram.js
Normal file
@@ -0,0 +1,13 @@
|
||||
const { reloadBot } = require("../telegram/telegram.core.js");
|
||||
|
||||
module.exports = function (router) {
|
||||
router.post("/telegram/reload", (req, res) => {
|
||||
try {
|
||||
reloadBot();
|
||||
res.status(200).json({ status: "success", message: "Bot ricaricato." });
|
||||
} catch (error) {
|
||||
console.error("[MEB] Errore nel ricaricamento del bot da API:", error);
|
||||
res.status(500).json({ status: "error", message: "Errore durante il reload del bot." });
|
||||
}
|
||||
});
|
||||
};
|
||||
0
plugin/sensors/sensors.references.json
Normal file
0
plugin/sensors/sensors.references.json
Normal file
152
plugin/telegram/callbacks/dashboard.js
Normal file
152
plugin/telegram/callbacks/dashboard.js
Normal file
@@ -0,0 +1,152 @@
|
||||
// Mappa globale per salvare gli interval id anche dopo un "hot-reload"
|
||||
if (!global.__meb_live_dashboards) {
|
||||
global.__meb_live_dashboards = new Map();
|
||||
}
|
||||
|
||||
module.exports = [
|
||||
{
|
||||
id: 'dashboard-refresh',
|
||||
execute: async ({ bot, chatId, msg }) => {
|
||||
const dash = require('../commands/dashboard.js');
|
||||
const newText = dash.formatSensorData();
|
||||
|
||||
try {
|
||||
await bot.editMessageText(newText, {
|
||||
chat_id: chatId,
|
||||
message_id: msg.message_id,
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[
|
||||
{ text: "⚡️ Avvia Live Tracker (2s)", callback_data: 'dashboard-live-start' }
|
||||
],
|
||||
[
|
||||
{ text: "🔄 Ricarica Dati", callback_data: 'dashboard-refresh' }
|
||||
]
|
||||
]
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
if (!e.message.includes('message is not modified')) {
|
||||
console.error("Errore nel refresh dashboard:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'dashboard-live-start',
|
||||
execute: async ({ bot, chatId, msg }) => {
|
||||
const dash = require('../commands/dashboard.js');
|
||||
|
||||
const messageId = msg.message_id;
|
||||
const liveKey = `${chatId}_${messageId}`;
|
||||
|
||||
// Se è già attivo un live per questo messaggio, non fare nulla
|
||||
if (global.__meb_live_dashboards.has(liveKey)) return;
|
||||
|
||||
// Avvisa che sta partendo
|
||||
const startMarkup = {
|
||||
inline_keyboard: [
|
||||
[
|
||||
{ text: "🛑 Ferma Live Tracker", callback_data: 'dashboard-live-stop' }
|
||||
]
|
||||
]
|
||||
};
|
||||
|
||||
await bot.editMessageReplyMarkup(startMarkup, { chat_id: chatId, message_id: messageId });
|
||||
|
||||
// Inizializza l'interval a 2 secondi. Autodistruzione dopo 30s
|
||||
let count = 15; // 15 tick da 2 secondi = 30 secondi
|
||||
const intervalTimer = setInterval(async () => {
|
||||
count--;
|
||||
const baseText = dash.formatSensorData();
|
||||
|
||||
// Se il tempo scade, disattiva il live e ripristina i tasti normali
|
||||
if (count <= 0) {
|
||||
if (global.__meb_live_dashboards.has(liveKey)) {
|
||||
clearInterval(global.__meb_live_dashboards.get(liveKey));
|
||||
global.__meb_live_dashboards.delete(liveKey);
|
||||
}
|
||||
try {
|
||||
await bot.editMessageText(baseText + `\n🛑 _Live tracker terminato automaticamente (30s) per risparmiare risorse._`, {
|
||||
chat_id: chatId,
|
||||
message_id: messageId,
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[{ text: "⚡️ Avvia Live Tracker (2s)", callback_data: 'dashboard-live-start' }],
|
||||
[{ text: "🔄 Ricarica Dati", callback_data: 'dashboard-refresh' }]
|
||||
]
|
||||
}
|
||||
});
|
||||
} catch (e) { }
|
||||
return;
|
||||
}
|
||||
|
||||
// Altrimenti prosegui con l'aggiornamento e la stringa del countdown
|
||||
const newText = baseText + `\n⏳ _Live attivo: arresto automatico tra *${count * 2}s*_`;
|
||||
|
||||
try {
|
||||
await bot.editMessageText(newText, {
|
||||
chat_id: chatId,
|
||||
message_id: messageId,
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: startMarkup
|
||||
});
|
||||
} catch (e) {
|
||||
// API limits o the message was not modified
|
||||
if (e.response && e.response.statusCode === 400 && e.message.includes("message is not modified")) {
|
||||
// ignore
|
||||
} else if (e.response && e.response.statusCode === 429) {
|
||||
// Troppe richieste Telegram
|
||||
console.warn("[Telegram Dashboard] Rate Limit raggionto. Riprovo più tardi...");
|
||||
} else if (e.response && e.response.statusCode === 400 && e.message.includes("message to edit not found")) {
|
||||
// Il messaggio è stato cancellato dall'utente
|
||||
clearInterval(intervalTimer);
|
||||
global.__meb_live_dashboards.delete(liveKey);
|
||||
} else {
|
||||
console.error("[Telegram Dashboard] Errore update live:", e);
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
global.__meb_live_dashboards.set(liveKey, intervalTimer);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'dashboard-live-stop',
|
||||
execute: async ({ bot, chatId, msg }) => {
|
||||
const dash = require('../commands/dashboard.js');
|
||||
|
||||
const messageId = msg.message_id;
|
||||
const liveKey = `${chatId}_${messageId}`;
|
||||
|
||||
// Pulisci l'interval se esiste
|
||||
if (global.__meb_live_dashboards.has(liveKey)) {
|
||||
clearInterval(global.__meb_live_dashboards.get(liveKey));
|
||||
global.__meb_live_dashboards.delete(liveKey);
|
||||
}
|
||||
|
||||
// Ripristina la formattazione iniziale
|
||||
const newText = dash.formatSensorData();
|
||||
|
||||
try {
|
||||
await bot.editMessageText(newText, {
|
||||
chat_id: chatId,
|
||||
message_id: messageId,
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[
|
||||
{ text: "⚡️ Avvia Live Tracker (2s)", callback_data: 'dashboard-live-start' }
|
||||
],
|
||||
[
|
||||
{ text: "🔄 Ricarica Dati", callback_data: 'dashboard-refresh' }
|
||||
]
|
||||
]
|
||||
}
|
||||
});
|
||||
} catch (e) { }
|
||||
}
|
||||
}
|
||||
];
|
||||
26
plugin/telegram/callbacks/data.js
Normal file
26
plugin/telegram/callbacks/data.js
Normal file
@@ -0,0 +1,26 @@
|
||||
module.exports = [
|
||||
{
|
||||
id: 'data-refresh',
|
||||
execute: async ({ bot, chatId, msg }) => {
|
||||
const dataCmd = require('../commands/data.js');
|
||||
const newText = dataCmd.formatSensorData();
|
||||
|
||||
try {
|
||||
await bot.editMessageText(newText, {
|
||||
chat_id: chatId,
|
||||
message_id: msg.message_id,
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[{ text: 'Aggiorna', callback_data: 'data-refresh' }]
|
||||
]
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
if (!e.message.includes('message is not modified')) {
|
||||
console.error('[Telegram Data] Errore refresh:', e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
141
plugin/telegram/callbacks/live.js
Normal file
141
plugin/telegram/callbacks/live.js
Normal file
@@ -0,0 +1,141 @@
|
||||
// Mappa globale per salvare gli interval id anche dopo un "hot-reload"
|
||||
if (!global.__meb_live_trackers) {
|
||||
global.__meb_live_trackers = new Map();
|
||||
}
|
||||
|
||||
module.exports = [
|
||||
{
|
||||
id: 'live-refresh',
|
||||
execute: async ({ bot, chatId, msg }) => {
|
||||
const liveCmd = require('../commands/live.js');
|
||||
const newText = liveCmd.formatLiveData();
|
||||
|
||||
try {
|
||||
await bot.editMessageText(newText, {
|
||||
chat_id: chatId,
|
||||
message_id: msg.message_id,
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[{ text: 'Avvia Live (2s)', callback_data: 'live-start' }],
|
||||
[{ text: 'Aggiorna', callback_data: 'live-refresh' }]
|
||||
]
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
if (!e.message.includes('message is not modified')) {
|
||||
console.error('[Telegram Live] Errore refresh:', e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'live-start',
|
||||
execute: async ({ bot, chatId, msg }) => {
|
||||
const liveCmd = require('../commands/live.js');
|
||||
|
||||
const messageId = msg.message_id;
|
||||
const liveKey = `${chatId}_${messageId}`;
|
||||
|
||||
// Se gia' attivo per questo messaggio, ignora
|
||||
if (global.__meb_live_trackers.has(liveKey)) return;
|
||||
|
||||
const stopMarkup = {
|
||||
inline_keyboard: [
|
||||
[{ text: 'Ferma Live', callback_data: 'live-stop' }]
|
||||
]
|
||||
};
|
||||
|
||||
await bot.editMessageReplyMarkup(stopMarkup, {
|
||||
chat_id: chatId,
|
||||
message_id: messageId
|
||||
});
|
||||
|
||||
// 30 tick da 2 secondi = 60 secondi, poi auto-stop
|
||||
let count = 30;
|
||||
const intervalTimer = setInterval(async () => {
|
||||
count--;
|
||||
const baseText = liveCmd.formatLiveData();
|
||||
|
||||
// Auto-stop quando il tempo scade
|
||||
if (count <= 0) {
|
||||
if (global.__meb_live_trackers.has(liveKey)) {
|
||||
clearInterval(global.__meb_live_trackers.get(liveKey));
|
||||
global.__meb_live_trackers.delete(liveKey);
|
||||
}
|
||||
try {
|
||||
await bot.editMessageText(
|
||||
baseText + `\n_Live terminato automaticamente (60s)._`,
|
||||
{
|
||||
chat_id: chatId,
|
||||
message_id: messageId,
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[{ text: 'Avvia Live (2s)', callback_data: 'live-start' }],
|
||||
[{ text: 'Aggiorna', callback_data: 'live-refresh' }]
|
||||
]
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (e) { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
|
||||
// Aggiornamento live con countdown
|
||||
const newText = baseText + `\n_Live attivo: arresto tra *${count * 2}s*_`;
|
||||
|
||||
try {
|
||||
await bot.editMessageText(newText, {
|
||||
chat_id: chatId,
|
||||
message_id: messageId,
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: stopMarkup
|
||||
});
|
||||
} catch (e) {
|
||||
if (e.response && e.response.statusCode === 429) {
|
||||
console.warn('[Telegram Live] Rate limit raggiunto');
|
||||
} else if (e.message && e.message.includes('message to edit not found')) {
|
||||
// Messaggio cancellato dall'utente
|
||||
clearInterval(intervalTimer);
|
||||
global.__meb_live_trackers.delete(liveKey);
|
||||
}
|
||||
// Ignora "message is not modified"
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
global.__meb_live_trackers.set(liveKey, intervalTimer);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'live-stop',
|
||||
execute: async ({ bot, chatId, msg }) => {
|
||||
const liveCmd = require('../commands/live.js');
|
||||
|
||||
const messageId = msg.message_id;
|
||||
const liveKey = `${chatId}_${messageId}`;
|
||||
|
||||
// Pulisci l'interval se esiste
|
||||
if (global.__meb_live_trackers.has(liveKey)) {
|
||||
clearInterval(global.__meb_live_trackers.get(liveKey));
|
||||
global.__meb_live_trackers.delete(liveKey);
|
||||
}
|
||||
|
||||
const newText = liveCmd.formatLiveData();
|
||||
|
||||
try {
|
||||
await bot.editMessageText(newText + '\n_Live fermato._', {
|
||||
chat_id: chatId,
|
||||
message_id: messageId,
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[{ text: 'Avvia Live (2s)', callback_data: 'live-start' }],
|
||||
[{ text: 'Aggiorna', callback_data: 'live-refresh' }]
|
||||
]
|
||||
}
|
||||
});
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
];
|
||||
51
plugin/telegram/callbacks/logs.js
Normal file
51
plugin/telegram/callbacks/logs.js
Normal file
@@ -0,0 +1,51 @@
|
||||
const realtime = require('../../realtime/core.js');
|
||||
const { config } = require('../../config.js');
|
||||
|
||||
module.exports = [
|
||||
{
|
||||
id: 'logs-refresh',
|
||||
execute: async ({ bot, chatId, msg }) => {
|
||||
const stats = realtime.getStats();
|
||||
const consoleUrl = config.cloudUrl || 'https://console.mebboat.it';
|
||||
|
||||
let statusIcon = '🔴';
|
||||
if (stats.status === 'connected') statusIcon = '🟢';
|
||||
else if (stats.status === 'error') statusIcon = '🟡';
|
||||
|
||||
const now = new Date().toLocaleTimeString('it-IT');
|
||||
let text = `📊 *Registrazione Dati Realtime*\n\n`;
|
||||
text += `Stato: ${statusIcon} *${stats.status}*\n`;
|
||||
text += `Sensore: \`${stats.sensorID}\`\n`;
|
||||
text += `Messaggi inviati: *${stats.sent}*\n`;
|
||||
text += `Frequenza: ogni *${stats.sentEveryMLS / 1000}s*\n`;
|
||||
|
||||
if (stats.buffered > 0) {
|
||||
text += `⚠️ Messaggi in buffer: *${stats.buffered}*\n`;
|
||||
}
|
||||
|
||||
if (stats.reconnections > 0) {
|
||||
text += `Riconnessioni: ${stats.reconnections}\n`;
|
||||
}
|
||||
|
||||
text += `\n_(Aggiornato: ${now})_`;
|
||||
|
||||
try {
|
||||
await bot.editMessageText(text, {
|
||||
chat_id: chatId,
|
||||
message_id: msg.message_id,
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[{ text: '📈 Apri Console Log', url: `${consoleUrl}/logs` }],
|
||||
[{ text: '🔄 Aggiorna Stato', callback_data: 'logs-refresh' }]
|
||||
]
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
if (!e.message.includes('message is not modified')) {
|
||||
console.error("[Telegram] Errore refresh logs:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
80
plugin/telegram/callbacks/settings.js
Normal file
80
plugin/telegram/callbacks/settings.js
Normal file
@@ -0,0 +1,80 @@
|
||||
module.exports = [
|
||||
{
|
||||
id: 'set-meteo',
|
||||
execute: async ({ bot, chatId, app }) => {
|
||||
const config = app.mebConfig;
|
||||
const currentFreqMin = config.forecast_current_frequency / 60000;
|
||||
const hourlyFreqMin = config.forecast_hourly_frequency / 60000;
|
||||
|
||||
const msg = `*Configura Aggiornamenti Meteo*\n\n` +
|
||||
`Aggiorno il meteo (attuale) ogni *${currentFreqMin} minuti*\n` +
|
||||
`Registro le previsioni future (prossimi 7 giorni) ogni *${hourlyFreqMin} minuti*`;
|
||||
|
||||
await bot.sendMessage(chatId, msg, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[
|
||||
{ text: "1 sec", callback_data: 'set-meteo-curr-1' },
|
||||
{ text: "10 sec", callback_data: 'set-meteo-curr-10' },
|
||||
],
|
||||
[
|
||||
{ text: "1 min", callback_data: 'set-meteo-curr-60' },
|
||||
{ text: "10 min", callback_data: 'set-meteo-curr-600' }
|
||||
],
|
||||
[
|
||||
{ text: "30m", callback_data: 'set-meteo-hour-1800' }
|
||||
],
|
||||
[
|
||||
{ text: "⬅️ Indietro", callback_data: 'session-refresh' }
|
||||
]
|
||||
]
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
match: (data) => data.startsWith('set-meteo-curr-'),
|
||||
execute: async ({ bot, chatId, app, data, msg }) => {
|
||||
const val = parseInt(data.replace('set-meteo-curr-', ''), 10);
|
||||
if (app.mebPlugin && app.mebPlugin.setConfig) {
|
||||
app.mebPlugin.setConfig('forecast_current_frequency', val);
|
||||
await bot.editMessageText(`✅ Frequenza Aggiornamenti meteo aggiornata a *${val / 60} minuti*.\n_Ritorno al menu..._`, {
|
||||
chat_id: chatId,
|
||||
message_id: msg.message_id,
|
||||
parse_mode: 'Markdown'
|
||||
});
|
||||
setTimeout(() => {
|
||||
const sessionCmd = require('../commands/status.js');
|
||||
bot.editMessageText("*Servizi*\n\n", {
|
||||
chat_id: chatId, message_id: msg.message_id, parse_mode: 'Markdown', reply_markup: sessionCmd.createSessionMenu(app).reply_markup
|
||||
}).catch(() => { });
|
||||
}, 3000);
|
||||
} else {
|
||||
await bot.sendMessage(chatId, "Errore: il plugin non è accessibile per salvare la configurazione.");
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
match: (data) => data.startsWith('set-meteo-hour-'),
|
||||
execute: async ({ bot, chatId, app, data, msg }) => {
|
||||
const val = parseInt(data.replace('set-meteo-hour-', ''), 10);
|
||||
if (app.mebPlugin && app.mebPlugin.setConfig) {
|
||||
app.mebPlugin.setConfig('forecast_hourly_frequency', val);
|
||||
await bot.editMessageText(`✅ Frequenza previsioni future aggiornata a *${val / 60} minuti*.\n_Ritorno al menu..._`, {
|
||||
chat_id: chatId,
|
||||
message_id: msg.message_id,
|
||||
parse_mode: 'Markdown'
|
||||
});
|
||||
setTimeout(() => {
|
||||
const sessionCmd = require('../commands/status.js');
|
||||
bot.editMessageText("*Servizi*\n\n", {
|
||||
chat_id: chatId, message_id: msg.message_id, parse_mode: 'Markdown', reply_markup: sessionCmd.createSessionMenu(app).reply_markup
|
||||
}).catch(() => { });
|
||||
}, 3000);
|
||||
} else {
|
||||
await bot.sendMessage(chatId, "Errore: il plugin non è accessibile per salvare la configurazione.");
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
67
plugin/telegram/callbacks/status.js
Normal file
67
plugin/telegram/callbacks/status.js
Normal file
@@ -0,0 +1,67 @@
|
||||
const realtime = require('../../realtime/core.js');
|
||||
|
||||
module.exports = [
|
||||
{
|
||||
id: 'session-weather-toggle',
|
||||
execute: async ({ bot, chatId, app, msg }) => {
|
||||
if (!app.mebPlugin) {
|
||||
return bot.answerCallbackQuery(msg.id, { text: "Errore: Plugin Meteo non caricato" });
|
||||
}
|
||||
|
||||
let isActive = app.mebPlugin.isPollingActive();
|
||||
|
||||
if (isActive) {
|
||||
app.mebPlugin.stopPolling();
|
||||
} else {
|
||||
app.mebPlugin.startPolling();
|
||||
}
|
||||
|
||||
const sessionCmd = require('../commands/status.js');
|
||||
const newMarkup = sessionCmd.createSessionMenu(app);
|
||||
|
||||
await bot.editMessageReplyMarkup(newMarkup.reply_markup, {
|
||||
chat_id: chatId,
|
||||
message_id: msg.message_id
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'session-realtime-info',
|
||||
execute: async ({ bot, chatId, msg }) => {
|
||||
const stats = realtime.getStats();
|
||||
|
||||
let text = `📡 *Stato Realtime*\n\n`;
|
||||
text += `Stato: *${stats.status}*\n`;
|
||||
text += `Sensore: \`${stats.sensorID}\`\n`;
|
||||
text += `Messaggi inviati: *${stats.sent}*\n`;
|
||||
text += `Buffer: ${stats.buffered} msg\n`;
|
||||
text += `Riconnessioni: ${stats.reconnections}\n`;
|
||||
text += `\n_I dati vengono inviati automaticamente ogni ${stats.sentEveryMLS / 1000}s_`;
|
||||
|
||||
await bot.answerCallbackQuery(msg.id, { text: `Realtime: ${stats.status} | ${stats.sent} msg inviati` });
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'session-refresh',
|
||||
execute: async ({ bot, chatId, app, msg }) => {
|
||||
const sessionCmd = require('../commands/status.js');
|
||||
const newMarkup = sessionCmd.createSessionMenu(app);
|
||||
|
||||
const now = new Date().toLocaleTimeString('it-IT');
|
||||
const newText = `*Servizi*\n\n_(Ultimo aggiornamento: ${now})_`;
|
||||
|
||||
try {
|
||||
await bot.editMessageText(newText, {
|
||||
chat_id: chatId,
|
||||
message_id: msg.message_id,
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: newMarkup.reply_markup
|
||||
});
|
||||
} catch (e) {
|
||||
if (!e.message.includes('message is not modified')) {
|
||||
console.error("Errore nel refresh session:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
26
plugin/telegram/callbacks/weather.js
Normal file
26
plugin/telegram/callbacks/weather.js
Normal file
@@ -0,0 +1,26 @@
|
||||
module.exports = [
|
||||
{
|
||||
id: 'weather-refresh',
|
||||
execute: async ({ bot, chatId, msg }) => {
|
||||
const weather = require('../commands/weather.js');
|
||||
const newText = weather.formatWeatherData();
|
||||
|
||||
try {
|
||||
await bot.editMessageText(newText, {
|
||||
chat_id: chatId,
|
||||
message_id: msg.message_id,
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[{ text: 'Aggiorna', callback_data: 'weather-refresh' }]
|
||||
]
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
if (!e.message.includes('message is not modified')) {
|
||||
console.error('[Telegram Weather] Errore refresh:', e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
57
plugin/telegram/commands/dashboard.js
Normal file
57
plugin/telegram/commands/dashboard.js
Normal file
@@ -0,0 +1,57 @@
|
||||
const dataHub = require('../../tools/dataHub');
|
||||
|
||||
function formatSensorData() {
|
||||
const sensorSnapshot = dataHub.getSensorData();
|
||||
const data = { timestamp: new Date().toISOString(), ...(sensorSnapshot || {}) };
|
||||
let output = `📊 *Dashboard Sensori*\n`;
|
||||
output += `_Ultimo aggiornamento: ${new Date().toLocaleTimeString('it-IT')}_\n\n`;
|
||||
|
||||
let isDataEmpty = true;
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (key === 'timestamp') continue;
|
||||
isDataEmpty = false;
|
||||
|
||||
let formattedKey = key.replace(/_/g, ' ');
|
||||
// Prima lettera maiuscola
|
||||
formattedKey = formattedKey.charAt(0).toUpperCase() + formattedKey.slice(1);
|
||||
|
||||
const formattedValue = (value !== null && value !== undefined)
|
||||
? (typeof value === 'number' ? value.toFixed(2) : value)
|
||||
: 'N/A';
|
||||
|
||||
output += `🔹 *${formattedKey}:* ${formattedValue}\n`;
|
||||
}
|
||||
|
||||
if (isDataEmpty) {
|
||||
output += `_Nessun dato configurato o letto. Controlla sensors.references.json_\n`;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
command: 'dashboard',
|
||||
description: 'Mostra i sensori live (dal file references)',
|
||||
pattern: /\/dashboard/,
|
||||
execute: async (bot, msg) => {
|
||||
const chatId = msg.chat.id;
|
||||
|
||||
const text = formatSensorData();
|
||||
|
||||
await bot.sendMessage(chatId, text, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[
|
||||
{ text: "⚡️ Avvia Live Tracker (2s)", callback_data: 'dashboard-live-start' }
|
||||
],
|
||||
[
|
||||
{ text: "🔄 Ricarica Dati", callback_data: 'dashboard-refresh' }
|
||||
]
|
||||
]
|
||||
}
|
||||
});
|
||||
},
|
||||
formatSensorData // Esportato per riuso nel refresh e nel live
|
||||
};
|
||||
58
plugin/telegram/commands/data.js
Normal file
58
plugin/telegram/commands/data.js
Normal file
@@ -0,0 +1,58 @@
|
||||
const dataHub = require('../../tools/dataHub');
|
||||
|
||||
/**
|
||||
* Formatta i dati sensore in un messaggio Telegram leggibile.
|
||||
* @returns {string} Testo formattato Markdown
|
||||
*/
|
||||
function formatSensorData() {
|
||||
const sensors = dataHub.getSensorData();
|
||||
|
||||
if (!sensors) {
|
||||
return 'Nessun dato sensore disponibile.\nI sensori potrebbero non essere ancora attivi.';
|
||||
}
|
||||
|
||||
let text = '*Dati Sensori*\n';
|
||||
text += `_${new Date().toLocaleTimeString('it-IT')}_\n\n`;
|
||||
|
||||
let hasData = false;
|
||||
|
||||
for (const [key, value] of Object.entries(sensors)) {
|
||||
if (key.startsWith('_')) continue; // Skip campi interni
|
||||
|
||||
hasData = true;
|
||||
let label = key.replace(/_/g, ' ');
|
||||
label = label.charAt(0).toUpperCase() + label.slice(1);
|
||||
|
||||
const formatted = (value !== null && value !== undefined)
|
||||
? (typeof value === 'number' ? value.toFixed(2) : String(value))
|
||||
: 'N/A';
|
||||
|
||||
text += `*${label}:* ${formatted}\n`;
|
||||
}
|
||||
|
||||
if (!hasData) {
|
||||
text += '_Nessun dato configurato. Controlla sensors.references.json_\n';
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
command: 'data',
|
||||
description: 'Mostra i dati sensori attuali',
|
||||
pattern: /\/data/,
|
||||
execute: async (bot, msg) => {
|
||||
const chatId = msg.chat.id;
|
||||
const text = formatSensorData();
|
||||
|
||||
await bot.sendMessage(chatId, text, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[{ text: 'Aggiorna', callback_data: 'data-refresh' }]
|
||||
]
|
||||
}
|
||||
});
|
||||
},
|
||||
formatSensorData
|
||||
};
|
||||
84
plugin/telegram/commands/live.js
Normal file
84
plugin/telegram/commands/live.js
Normal file
@@ -0,0 +1,84 @@
|
||||
const dataHub = require('../../tools/dataHub');
|
||||
|
||||
/**
|
||||
* Formatta tutti i dati (sensori + meteo) per il live tracker.
|
||||
* @returns {string} Testo formattato Markdown
|
||||
*/
|
||||
function formatLiveData() {
|
||||
const sensors = dataHub.getSensorData();
|
||||
const { forecast, sea } = dataHub.getWeatherData();
|
||||
|
||||
let text = '*LIVE - Dati Completi*\n';
|
||||
text += `_${new Date().toLocaleTimeString('it-IT')}_\n\n`;
|
||||
|
||||
// Sezione sensori
|
||||
if (sensors) {
|
||||
text += '*Sensori:*\n';
|
||||
for (const [key, value] of Object.entries(sensors)) {
|
||||
if (key.startsWith('_')) continue;
|
||||
|
||||
let label = key.replace(/_/g, ' ');
|
||||
label = label.charAt(0).toUpperCase() + label.slice(1);
|
||||
|
||||
const val = (value !== null && value !== undefined)
|
||||
? (typeof value === 'number' ? value.toFixed(2) : String(value))
|
||||
: 'N/A';
|
||||
|
||||
text += ` ${label}: ${val}\n`;
|
||||
}
|
||||
} else {
|
||||
text += '_Nessun dato sensore disponibile_\n';
|
||||
}
|
||||
|
||||
// Sezione meteo (compatta)
|
||||
if (forecast) {
|
||||
text += '\n*Meteo:*\n';
|
||||
const parts = [];
|
||||
if (forecast.temperature !== null && forecast.temperature !== undefined) {
|
||||
parts.push(`Temp: ${forecast.temperature}C`);
|
||||
}
|
||||
if (forecast.humidity !== null && forecast.humidity !== undefined) {
|
||||
parts.push(`Um: ${forecast.humidity}%`);
|
||||
}
|
||||
if (forecast.wind?.speed !== null && forecast.wind?.speed !== undefined) {
|
||||
parts.push(`Vento: ${forecast.wind.speed}km/h`);
|
||||
}
|
||||
text += ` ${parts.join(' | ')}\n`;
|
||||
}
|
||||
|
||||
if (sea?.waves) {
|
||||
const seaParts = [];
|
||||
if (sea.waves.height !== null && sea.waves.height !== undefined) {
|
||||
seaParts.push(`Onde: ${sea.waves.height}m`);
|
||||
}
|
||||
if (sea.waves.period !== null && sea.waves.period !== undefined) {
|
||||
seaParts.push(`Per: ${sea.waves.period}s`);
|
||||
}
|
||||
if (seaParts.length > 0) {
|
||||
text += ` ${seaParts.join(' | ')}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
command: 'live',
|
||||
description: 'Dati live (meteo + sensori) con aggiornamento automatico',
|
||||
pattern: /\/live/,
|
||||
execute: async (bot, msg) => {
|
||||
const chatId = msg.chat.id;
|
||||
const text = formatLiveData();
|
||||
|
||||
await bot.sendMessage(chatId, text, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[{ text: 'Avvia Live (2s)', callback_data: 'live-start' }],
|
||||
[{ text: 'Aggiorna', callback_data: 'live-refresh' }]
|
||||
]
|
||||
}
|
||||
});
|
||||
},
|
||||
formatLiveData
|
||||
};
|
||||
53
plugin/telegram/commands/logs.js
Normal file
53
plugin/telegram/commands/logs.js
Normal file
@@ -0,0 +1,53 @@
|
||||
const realtime = require('../../realtime/core.js');
|
||||
const { config } = require('../../config.js');
|
||||
|
||||
module.exports = {
|
||||
command: 'logs',
|
||||
description: 'Mostra lo stato della registrazione dati in tempo reale',
|
||||
pattern: /\/logs/,
|
||||
execute: async (bot, msg, { app }) => {
|
||||
const chatId = msg.chat.id;
|
||||
try {
|
||||
const stats = realtime.getStats();
|
||||
const consoleUrl = config.cloudUrl || 'https://console.mebboat.it';
|
||||
|
||||
let statusIcon = '🔴';
|
||||
if (stats.status === 'connected') statusIcon = '🟢';
|
||||
else if (stats.status === 'error') statusIcon = '🟡';
|
||||
|
||||
let text = `📊 *Registrazione Dati Realtime*\n\n`;
|
||||
text += `Stato: ${statusIcon} *${stats.status}*\n`;
|
||||
text += `Sensore: \`${stats.sensorID}\`\n`;
|
||||
text += `Messaggi inviati: *${stats.sent}*\n`;
|
||||
text += `Frequenza: ogni *${stats.sentEveryMLS / 1000}s*\n`;
|
||||
|
||||
if (stats.buffered > 0) {
|
||||
text += `⚠️ Messaggi in buffer: *${stats.buffered}*\n`;
|
||||
}
|
||||
|
||||
if (stats.reconnections > 0) {
|
||||
text += `Riconnessioni: ${stats.reconnections}\n`;
|
||||
}
|
||||
|
||||
if (stats.firstSent) {
|
||||
text += `\nPrimo invio: ${stats.firstSent}\n`;
|
||||
}
|
||||
|
||||
text += `\n_I dati vengono inviati automaticamente al server ogni secondo._`;
|
||||
text += `\n_Consulta i log storici sulla console:_`;
|
||||
|
||||
await bot.sendMessage(chatId, text, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[{ text: '📈 Apri Console Log', url: `${consoleUrl}/logs` }],
|
||||
[{ text: '🔄 Aggiorna Stato', callback_data: 'logs-refresh' }]
|
||||
]
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Telegram] Errore comando /logs:", error);
|
||||
bot.sendMessage(chatId, `❌ Errore: ${error.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
24
plugin/telegram/commands/realtime.js
Normal file
24
plugin/telegram/commands/realtime.js
Normal file
@@ -0,0 +1,24 @@
|
||||
const realtime = require('../../realtime/core.js');
|
||||
|
||||
module.exports = {
|
||||
command: 'realtime',
|
||||
description: 'Dettagli della connessione realtime',
|
||||
pattern: /\/realtime/,
|
||||
execute: async (bot, msg) => {
|
||||
const stats = realtime.getStats();
|
||||
const statusEmoji = stats.status === 'connected' ? '🟢' : '🔴';
|
||||
|
||||
let message = `*Connessione Realtime* ${statusEmoji}\n\n`;
|
||||
message += `*ID Sensore:* ${stats.sensorID}\n`;
|
||||
message += `*Stato:* ${stats.status}\n`;
|
||||
message += `*Messaggi inviati:* ${stats.sent}\n`;
|
||||
message += `*Riconnessioni:* ${stats.reconnections}\n`;
|
||||
message += `*Frequenza:* ${stats.sentEveryMLS}ms\n`;
|
||||
|
||||
if (stats.firstSent) {
|
||||
message += `*Primo invio:* ${new Date(stats.firstSent).toLocaleString()}\n`;
|
||||
}
|
||||
|
||||
await bot.sendMessage(msg.chat.id, message, { parse_mode: 'Markdown' });
|
||||
}
|
||||
};
|
||||
20
plugin/telegram/commands/settings.js
Normal file
20
plugin/telegram/commands/settings.js
Normal file
@@ -0,0 +1,20 @@
|
||||
module.exports = {
|
||||
command: 'settings',
|
||||
description: 'Mostra le impostazioni del Computer di Bordo',
|
||||
pattern: /\/settings/,
|
||||
execute: async (bot, msg) => {
|
||||
const chatId = msg.chat.id;
|
||||
|
||||
await bot.sendMessage(chatId, "*Configurazione Computer di Bordo*\nScegli quali parametri modificare:", {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[
|
||||
{ text: "Meteo", callback_data: 'set-meteo' },
|
||||
{ text: "Batterie", callback_data: 'set-batteries' }
|
||||
]
|
||||
]
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
40
plugin/telegram/commands/status.js
Normal file
40
plugin/telegram/commands/status.js
Normal file
@@ -0,0 +1,40 @@
|
||||
const realtime = require('../../realtime/core.js');
|
||||
|
||||
function createSessionMenu(app) {
|
||||
const weatherActive = app.mebPlugin && app.mebPlugin.isPollingActive ? app.mebPlugin.isPollingActive() : false;
|
||||
const realtimeStats = realtime.getStats();
|
||||
const realtimeConnected = realtimeStats.isConnected;
|
||||
|
||||
return {
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[
|
||||
{ text: weatherActive ? "Meteo: 🟢 ON (Premi per fermare)" : "Meteo: 🔴 OFF (Premi per avviare)", callback_data: 'session-weather-toggle' }
|
||||
],
|
||||
[
|
||||
{ text: realtimeConnected ? "Realtime: 🟢 Connesso" : "Realtime: 🔴 Disconnesso", callback_data: 'session-realtime-info' }
|
||||
],
|
||||
[
|
||||
{ text: "🔄", callback_data: 'session-refresh' },
|
||||
{ text: "⚙️ ⛅️ (meteo)", callback_data: 'set-meteo' }
|
||||
]
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
command: 'session',
|
||||
description: 'Verifica le attività di Meteo e Realtime',
|
||||
pattern: /\/session/,
|
||||
execute: async (bot, msg, { app }) => {
|
||||
const chatId = msg.chat.id;
|
||||
const msgText = `*Servizi*\n\n`;
|
||||
|
||||
await bot.sendMessage(chatId, msgText, {
|
||||
parse_mode: 'Markdown',
|
||||
...createSessionMenu(app)
|
||||
});
|
||||
},
|
||||
createSessionMenu
|
||||
};
|
||||
47
plugin/telegram/commands/structure.js
Normal file
47
plugin/telegram/commands/structure.js
Normal file
@@ -0,0 +1,47 @@
|
||||
const realtime = require('../../realtime/core.js');
|
||||
|
||||
module.exports = {
|
||||
command: 'structure',
|
||||
description: 'Mostra la struttura dati del plugin',
|
||||
pattern: /\/structure/,
|
||||
execute: async (bot, msg) => {
|
||||
const chatId = msg.chat.id;
|
||||
const rules = realtime.getSensorRules();
|
||||
|
||||
if (!rules) {
|
||||
return bot.sendMessage(chatId, 'Nessuna configurazione sensori caricata.');
|
||||
}
|
||||
|
||||
let text = `*Struttura Dati Plugin*\n`;
|
||||
text += `Versione: \`${rules.version}\`\n`;
|
||||
text += `Attivo: ${rules.isActive ? 'Si' : 'No'}\n`;
|
||||
text += `Collezioni: ${rules.items?.length || 0}\n\n`;
|
||||
|
||||
if (rules.items) {
|
||||
for (const item of rules.items) {
|
||||
text += `*${item.collection}*\n`;
|
||||
text += ` Path: \`${item.main_path}\`\n`;
|
||||
|
||||
if (item.elements && Array.isArray(item.elements)) {
|
||||
for (const element of item.elements) {
|
||||
const { subelements, ...fields } = element;
|
||||
const [name, subPath] = Object.entries(fields)[0];
|
||||
text += ` - ${name} -> \`${item.main_path}.${subPath}\`\n`;
|
||||
|
||||
if (subelements && Array.isArray(subelements)) {
|
||||
for (const sub of subelements) {
|
||||
const [sName, sPath] = Object.entries(sub)[0];
|
||||
text += ` - ${sName} -> \`${item.main_path}.${subPath}.${sPath}\`\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
text += ` (valore singolo)\n`;
|
||||
}
|
||||
text += `\n`;
|
||||
}
|
||||
}
|
||||
|
||||
await bot.sendMessage(chatId, text, { parse_mode: 'Markdown' });
|
||||
}
|
||||
};
|
||||
84
plugin/telegram/commands/weather.js
Normal file
84
plugin/telegram/commands/weather.js
Normal file
@@ -0,0 +1,84 @@
|
||||
const dataHub = require('../../tools/dataHub');
|
||||
|
||||
/**
|
||||
* Formatta i dati meteo in un messaggio Telegram leggibile.
|
||||
* @returns {string} Testo formattato Markdown
|
||||
*/
|
||||
function formatWeatherData() {
|
||||
const { forecast, sea } = dataHub.getWeatherData();
|
||||
|
||||
if (!forecast && !sea) {
|
||||
return 'Nessun dato meteo disponibile.\nIl polling potrebbe non essere ancora partito.';
|
||||
}
|
||||
|
||||
let text = '*Meteo Attuale*\n';
|
||||
text += `_${new Date().toLocaleTimeString('it-IT')}_\n\n`;
|
||||
|
||||
if (forecast) {
|
||||
if (forecast.temperature !== null && forecast.temperature !== undefined) {
|
||||
text += `Temperatura: *${forecast.temperature}*C\n`;
|
||||
}
|
||||
if (forecast.humidity !== null && forecast.humidity !== undefined) {
|
||||
text += `Umidita: *${forecast.humidity}*%\n`;
|
||||
}
|
||||
if (forecast.pressure !== null && forecast.pressure !== undefined) {
|
||||
text += `Pressione: *${forecast.pressure}* hPa\n`;
|
||||
}
|
||||
if (forecast.rain !== null && forecast.rain !== undefined) {
|
||||
text += `Pioggia: *${forecast.rain}* mm\n`;
|
||||
}
|
||||
|
||||
if (forecast.wind) {
|
||||
text += `\nVento:\n`;
|
||||
if (forecast.wind.speed !== null && forecast.wind.speed !== undefined) {
|
||||
text += ` Velocita: *${forecast.wind.speed}* km/h\n`;
|
||||
}
|
||||
if (forecast.wind.direction !== null && forecast.wind.direction !== undefined) {
|
||||
text += ` Direzione: *${forecast.wind.direction}*\n`;
|
||||
}
|
||||
if (forecast.wind.gusts !== null && forecast.wind.gusts !== undefined) {
|
||||
text += ` Raffiche: *${forecast.wind.gusts}* km/h\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sea) {
|
||||
text += `\nMare:\n`;
|
||||
if (sea.waves) {
|
||||
if (sea.waves.height !== null && sea.waves.height !== undefined) {
|
||||
text += ` Altezza onde: *${sea.waves.height}* m\n`;
|
||||
}
|
||||
if (sea.waves.period !== null && sea.waves.period !== undefined) {
|
||||
text += ` Periodo: *${sea.waves.period}* s\n`;
|
||||
}
|
||||
if (sea.waves.direction !== null && sea.waves.direction !== undefined) {
|
||||
text += ` Direzione: *${sea.waves.direction}*\n`;
|
||||
}
|
||||
}
|
||||
if (sea.temperature !== null && sea.temperature !== undefined) {
|
||||
text += ` Temp. acqua: *${sea.temperature}*C\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
command: 'weather',
|
||||
description: 'Mostra i dati meteo attuali',
|
||||
pattern: /\/weather/,
|
||||
execute: async (bot, msg) => {
|
||||
const chatId = msg.chat.id;
|
||||
const text = formatWeatherData();
|
||||
|
||||
await bot.sendMessage(chatId, text, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[{ text: 'Aggiorna', callback_data: 'weather-refresh' }]
|
||||
]
|
||||
}
|
||||
});
|
||||
},
|
||||
formatWeatherData
|
||||
};
|
||||
269
plugin/telegram/telegram.core.js
Normal file
269
plugin/telegram/telegram.core.js
Normal file
@@ -0,0 +1,269 @@
|
||||
const TelegramBot = require('node-telegram-bot-api');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
|
||||
let bot = null;
|
||||
let app = null;
|
||||
let pollingRetryCount = 0;
|
||||
const MAX_POLLING_RETRIES = 10;
|
||||
const POLLING_BASE_DELAY_MS = 5000;
|
||||
|
||||
// Registry per i comandi, callback e query inline in formato { pattern: Regex, execute: Function }
|
||||
let commandsRegistry = [];
|
||||
let callbackHandlers = [];
|
||||
let inlineQueriesRegistry = [];
|
||||
let isMessageListenerRegistered = false;
|
||||
|
||||
// Inizializzazione del bot.
|
||||
function initBot() {
|
||||
if (!BOT_TOKEN) {
|
||||
console.warn("[Telegram] BOT_TOKEN not set: bot disabled");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (global.__meb_telegram_bot) {
|
||||
bot = global.__meb_telegram_bot;
|
||||
console.log("[Telegram] Già avviato. Riavvio del bot.");
|
||||
} else {
|
||||
bot = new TelegramBot(BOT_TOKEN, { polling: true });
|
||||
|
||||
// Gestione errori di polling: intercetta EFATAL (DNS/Rete) e riavvia con backoff esponenziale
|
||||
bot.on('polling_error', (error) => {
|
||||
const isNetworkError = error.code === 'EFATAL' || (error.message && (error.message.includes('EAI_AGAIN') || error.message.includes('ECONNREFUSED') || error.message.includes('ETIMEDOUT')));
|
||||
if (isNetworkError) {
|
||||
if (pollingRetryCount >= MAX_POLLING_RETRIES) {
|
||||
console.error(`[Telegram] Polling fallito dopo ${MAX_POLLING_RETRIES} tentativi. Bot disattivato. Riavviare il plugin per riprovare.`);
|
||||
return;
|
||||
}
|
||||
pollingRetryCount++;
|
||||
const delay = Math.min(POLLING_BASE_DELAY_MS * Math.pow(2, pollingRetryCount - 1), 300000); // max 5 min
|
||||
console.warn(`[Telegram] Errore Polling Critico (${error.code}), tentativo ${pollingRetryCount}/${MAX_POLLING_RETRIES}. Riavvio tra ${delay / 1000}s...`);
|
||||
setTimeout(() => {
|
||||
bot.startPolling({ restart: true })
|
||||
.then(() => { pollingRetryCount = 0; })
|
||||
.catch(err => console.error("[Telegram] Errore riavvio polling:", err.message));
|
||||
}, delay);
|
||||
} else {
|
||||
console.error(`[Telegram] Polling error: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
global.__meb_telegram_bot = bot;
|
||||
console.log("[Telegram] Avvio del bot.");
|
||||
}
|
||||
|
||||
// Caricamento dei comandi e dei callback.
|
||||
if (!global.__meb_telegram_handlers) {
|
||||
global.__meb_telegram_handlers = true;
|
||||
loadCommands();
|
||||
loadCallbacks();
|
||||
loadInlineQueries();
|
||||
setupMessageListener(); // Registra il listener generale dei messaggi
|
||||
}
|
||||
|
||||
return bot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registra il listener centrale per tutti i messaggi.
|
||||
*/
|
||||
function setupMessageListener() {
|
||||
if (!bot || isMessageListenerRegistered) return;
|
||||
|
||||
bot.on('message', async (msg) => {
|
||||
if (!msg.text) return;
|
||||
// Cicla i comandi registrati e vedi se il testo corrisponde a un pattern
|
||||
for (const cmd of commandsRegistry) {
|
||||
if (cmd.pattern && cmd.pattern.test(msg.text)) {
|
||||
try {
|
||||
await cmd.execute(bot, msg, { app, getSK });
|
||||
} catch (error) {
|
||||
console.error(`[Telegram] Error executing command ${msg.text}:`, error);
|
||||
bot.sendMessage(msg.chat.id, "⚠️ Errore interno durante l'esecuzione del comando.");
|
||||
}
|
||||
return; // Trovato ed eseguito
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
bot.on('callback_query', async (query) => {
|
||||
const chatId = query.message.chat.id;
|
||||
const data = query.data;
|
||||
|
||||
await bot.answerCallbackQuery(query.id);
|
||||
|
||||
const context = { bot, app, getSK, chatId, data, msg: query.message };
|
||||
|
||||
// Find matching handler
|
||||
const handler = callbackHandlers.find(h => {
|
||||
if (h.id) return h.id === data;
|
||||
if (h.match) return h.match(data);
|
||||
return false;
|
||||
});
|
||||
|
||||
if (handler) {
|
||||
try {
|
||||
await handler.execute(context);
|
||||
} catch (err) {
|
||||
const msgErr = err.message || (err.response && err.response.body && err.response.body.description) || String(err);
|
||||
if (msgErr.includes("message is not modified") || msgErr.includes("message to edit not found")) {
|
||||
// Silently ignore unmodified edit or deleted message
|
||||
} else {
|
||||
console.error(`[Telegram] Error executing callback ${data}:`, err);
|
||||
await bot.sendMessage(chatId, `Errore nella chimata dell'api, ${msgErr}.`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn(`[Telegram] Unknown callback action: ${data}`);
|
||||
await bot.sendMessage(chatId, `Azione sconosciuta: ${data}`);
|
||||
}
|
||||
});
|
||||
|
||||
bot.on('inline_query', async (query) => {
|
||||
const text = query.query;
|
||||
|
||||
// Cerca una query inline corrispondente
|
||||
for (const handler of inlineQueriesRegistry) {
|
||||
if (handler.pattern && handler.pattern.test(text)) {
|
||||
try {
|
||||
await handler.execute(bot, query, { app, getSK });
|
||||
} catch (err) {
|
||||
console.error(`[Telegram] Error executing inline query ${text}:`, err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
isMessageListenerRegistered = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ottiene il valore di una chiave dal DataBrowser di SignalK.
|
||||
* @param {*} skPath Nome della chiave (path completo, come ad esempio "navigation.position.latitude").
|
||||
* @returns Valore della chiave.
|
||||
*/
|
||||
function getSK(skPath) {
|
||||
if (!app) return null;
|
||||
const v = app.getSelfPath(skPath);
|
||||
return v && v.value !== undefined && v.value !== null ? v.value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Carica o ricarica i comandi del bot. Pulisce la cache di module_require per implementare l'hot reload.
|
||||
* @returns {void}
|
||||
*/
|
||||
function loadCommands() {
|
||||
if (!bot) return;
|
||||
const commandsDir = path.join(__dirname, 'commands');
|
||||
|
||||
if (fs.existsSync(commandsDir)) {
|
||||
commandsRegistry = []; // Svuota i vecchi comandi
|
||||
const menuCommands = []; // Per il menu di Telegram
|
||||
|
||||
// Legge solo i file .js dalla cartella /commands.
|
||||
const commandFiles = fs.readdirSync(commandsDir).filter(file => file.endsWith('.js'));
|
||||
// Per ogni file, importa il comando
|
||||
for (const file of commandFiles) {
|
||||
const fullPath = path.resolve(commandsDir, file);
|
||||
//Importa i comandi da module.exports all'interno del file
|
||||
const command = require(fullPath);
|
||||
|
||||
//Registra il comando nel registry interno.
|
||||
if (command.pattern && command.execute) {
|
||||
commandsRegistry.push(command);
|
||||
|
||||
// Se ha una descrizione e un nome comando, lo aggiungiamo al menu
|
||||
if (command.command && command.description) {
|
||||
menuCommands.push({
|
||||
command: command.command.toLowerCase(),
|
||||
description: command.description
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Invia la lista dei comandi a Telegram per il menu a sinistra
|
||||
if (menuCommands.length > 0) {
|
||||
bot.setMyCommands(menuCommands).catch(err => {
|
||||
console.error("[Telegram] Errore nel setMyCommands:", err);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Carica o ricarica i callback del bot.
|
||||
* @returns {void}
|
||||
*/
|
||||
function loadCallbacks() {
|
||||
if (!bot) return;
|
||||
const callbacksDir = path.join(__dirname, 'callbacks');
|
||||
callbackHandlers = [];
|
||||
|
||||
if (fs.existsSync(callbacksDir)) {
|
||||
// Legge solo i file .js dalla cartella /callbacks.
|
||||
const callbackFiles = fs.readdirSync(callbacksDir).filter(file => file.endsWith('.js'));
|
||||
// Per ogni file, importa i callback e li aggiunge all'array callbackHandlers.
|
||||
for (const file of callbackFiles) {
|
||||
const fullPath = path.resolve(callbacksDir, file);
|
||||
//Importa i callback da module.exports all'interno del file
|
||||
const handlers = require(fullPath);
|
||||
if (Array.isArray(handlers)) {
|
||||
callbackHandlers.push(...handlers);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Carica o ricarica le query inline del bot.
|
||||
* @returns {void}
|
||||
*/
|
||||
function loadInlineQueries() {
|
||||
if (!bot) return;
|
||||
const inlineDir = path.join(__dirname, 'inline');
|
||||
inlineQueriesRegistry = [];
|
||||
|
||||
if (fs.existsSync(inlineDir)) {
|
||||
const inlineFiles = fs.readdirSync(inlineDir).filter(file => file.endsWith('.js'));
|
||||
for (const file of inlineFiles) {
|
||||
const fullPath = path.resolve(inlineDir, file);
|
||||
const handler = require(fullPath);
|
||||
if (handler.pattern && handler.execute) {
|
||||
inlineQueriesRegistry.push(handler);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Collega il bot all'app.
|
||||
* @param {*} mebApp L'app di SignalK.
|
||||
* @returns {TelegramBot} Il bot.
|
||||
*/
|
||||
function linkBotToApp(mebApp) {
|
||||
app = mebApp;
|
||||
bot = initBot();
|
||||
return bot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invia un messaggio ad un utente tramite il bot.
|
||||
* @param {*} chatId L'ID della chat.
|
||||
* @param {*} text Il testo del messaggio.
|
||||
* @param {*} options Le opzioni del messaggio.
|
||||
* @returns {Promise<TelegramBot>} Il bot.
|
||||
*/
|
||||
function send(chatId, text, options = {}) {
|
||||
if (!bot) return Promise.reject("Bot not initialized");
|
||||
return bot.sendMessage(chatId, text, options);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
linkBotToApp,
|
||||
send
|
||||
};
|
||||
@@ -1,258 +0,0 @@
|
||||
/**
|
||||
* Modulo di crittografia centralizzato per MEB Plugin
|
||||
* Supporta AES-256-GCM per file sensibili e log CSV
|
||||
*
|
||||
* BEST PRACTICES SICUREZZA ENTERPRISE:
|
||||
* 1. La MASTER_KEY dovrebbe essere in variabile d'ambiente (process.env.MEB_MASTER_KEY)
|
||||
* 2. In produzione usare AWS KMS, HashiCorp Vault, o Azure Key Vault
|
||||
* 3. Rotazione periodica delle chiavi (ogni 90 giorni)
|
||||
* 4. Separazione chiavi: una per users, una per logs_references, una per log files
|
||||
* 5. Audit log di ogni accesso ai file sensibili
|
||||
*
|
||||
* GENERAZIONE CHIAVE SICURA:
|
||||
* Esegui nel terminale: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
* Poi imposta: export MEB_MASTER_KEY="la_chiave_generata"
|
||||
*/
|
||||
|
||||
const crypto = require("crypto");
|
||||
const fs = require('fs');
|
||||
|
||||
|
||||
const MASTER_KEY_HEX = process.env.CRYPTOKEY || null;
|
||||
const TOKEN_CHARSET = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
|
||||
const specialCharset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*_+-=';
|
||||
|
||||
|
||||
/**
|
||||
* Ottiene la chiave master (32 byte per AES-256)
|
||||
* @returns {Buffer} Chiave di 32 byte
|
||||
*/
|
||||
function getMasterKey() {
|
||||
if (!MASTER_KEY_HEX) {
|
||||
throw new Error("MASTER_KEY non definita. Imposta MEB_MASTER_KEY nelle variabili d'ambiente.");
|
||||
}
|
||||
const key = Buffer.from(MASTER_KEY_HEX, 'hex');
|
||||
if (key.length !== 32) {
|
||||
throw new Error("MASTER_KEY deve essere di 32 byte (64 caratteri hex).");
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizza qualsiasi chiave custom a 32 byte Buffer per AES-256.
|
||||
* Accetta chiavi di qualsiasi lunghezza/formato.
|
||||
* @param {string|Buffer|null} customKey - Chiave custom o null per usare master key
|
||||
* @returns {Buffer} Chiave di 32 byte
|
||||
*/
|
||||
function normalizeKey(customKey) {
|
||||
if (!customKey) return getMasterKey();
|
||||
|
||||
if (typeof customKey === 'string') {
|
||||
// Se è hex di 64 caratteri, convertilo direttamente
|
||||
if (/^[0-9a-fA-F]{64}$/.test(customKey)) {
|
||||
return Buffer.from(customKey, 'hex');
|
||||
}
|
||||
// Altrimenti hash SHA-256 per ottenere 32 byte
|
||||
return crypto.createHash('sha256').update(customKey, 'utf8').digest();
|
||||
}
|
||||
|
||||
if (Buffer.isBuffer(customKey)) {
|
||||
if (customKey.length === 32) return customKey;
|
||||
return crypto.createHash('sha256').update(customKey).digest();
|
||||
}
|
||||
|
||||
throw new Error("customKey deve essere una stringa o un Buffer");
|
||||
}
|
||||
|
||||
// ==================== GENERAZIONE TOKEN ====================
|
||||
|
||||
/**
|
||||
* Genera un token esadecimale casuale UNICO ogni volta
|
||||
* @param {number} bytes - Numero di byte (default 24 = 48 caratteri hex)
|
||||
* @returns {string} Token esadecimale unico
|
||||
*/
|
||||
function generateToken(bytes = 24) {
|
||||
return crypto.randomBytes(bytes).toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera un token leggibile (senza caratteri ambigui come 0/O, 1/l/I)
|
||||
* Più facile da comunicare verbalmente
|
||||
* @param {number} length - Lunghezza del token (default 32)
|
||||
* @returns {string} Token alfanumerico leggibile
|
||||
*/
|
||||
function generateReadableToken(length = 32) {
|
||||
const bytes = crypto.randomBytes(length);
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += TOKEN_CHARSET[bytes[i] % TOKEN_CHARSET.length];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera un token con caratteri speciali (più sicuro per chiavi sensibili)
|
||||
* @param {number} length - Lunghezza del token (default 64)
|
||||
* @returns {string} Token con caratteri speciali
|
||||
*/
|
||||
function generateSecureToken(length = 64) {
|
||||
|
||||
const bytes = crypto.randomBytes(length);
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += specialCharset[bytes[i] % specialCharset.length];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== CRITTOGRAFIA OGGETTI JSON (per file sensibili) ====================
|
||||
|
||||
/**
|
||||
* Cripta un oggetto JSON in Buffer binario (AES-256-GCM)
|
||||
* Usato per telegram_users.json e logs_references.json
|
||||
* @param {object} obj - Oggetto da criptare
|
||||
* @param {string|Buffer|null} customKey - Chiave custom (opzionale)
|
||||
* @returns {Buffer} Dati criptati [IV(12) + TAG(16) + CIPHERTEXT]
|
||||
*/
|
||||
// DISABILITATO: salviamo in chiaro
|
||||
function encrypt(obj, customKey = null) {
|
||||
const plaintext = Buffer.from(JSON.stringify(obj), 'utf8');
|
||||
return plaintext; // ritorna direttamente il contenuto in chiaro
|
||||
}
|
||||
|
||||
/**
|
||||
* Decripta un Buffer in oggetto JSON
|
||||
* @param {Buffer} buffer - Dati criptati
|
||||
* @param {string|Buffer|null} customKey - Chiave custom (opzionale)
|
||||
* @returns {object} Oggetto decriptato (array vuoto se fallisce)
|
||||
*/
|
||||
// DISABILITATO: leggiamo direttamente in chiaro
|
||||
function decrypt(buffer, customKey = null) {
|
||||
try {
|
||||
if (!buffer) return [];
|
||||
const content = buffer.toString('utf8');
|
||||
return JSON.parse(content);
|
||||
} catch (error) {
|
||||
console.error('[decrypt] Errore:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== CRITTOGRAFIA FILE LOG CSV ====================
|
||||
|
||||
/**
|
||||
* Cripta un file CSV/testo sul disco
|
||||
* @param {string} filePath - Percorso del file
|
||||
* @param {string|Buffer|null} customKey - Chiave custom (qualsiasi lunghezza)
|
||||
* @returns {boolean} True se successo
|
||||
*/
|
||||
// DISABILITATO: i file log rimangono sempre in chiaro
|
||||
function encryptLog(filePath, customKey = null) {
|
||||
try {
|
||||
// Non fare nulla, lascia il file in chiaro
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[encryptLog] Errore:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decripta un file CSV/testo e lo riscrive sul disco
|
||||
* @param {string} filePath - Percorso del file criptato
|
||||
* @param {string|Buffer|null} customKey - Chiave custom
|
||||
* @returns {string|null} Contenuto decriptato o null se errore
|
||||
*/
|
||||
// DISABILITATO: i file sono già in chiaro
|
||||
function decryptLog(filePath, customKey = null) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
return content; // ritorna contenuto in chiaro senza modifiche
|
||||
} catch (error) {
|
||||
console.error('[decryptLog] Errore:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decripta un file log e restituisce il contenuto SENZA modificare il file
|
||||
* @param {string} filePath - Percorso del file criptato
|
||||
* @param {string|Buffer|null} customKey - Chiave custom
|
||||
* @returns {string|null} Contenuto decriptato o null se errore
|
||||
*/
|
||||
// DISABILITATO: i file sono già in chiaro
|
||||
function decryptLogToMemory(filePath, customKey = null) {
|
||||
try {
|
||||
return fs.readFileSync(filePath, 'utf8');
|
||||
} catch (error) {
|
||||
console.error('[decryptLogToMemory] Errore:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== GESTIONE FILE SENSIBILI (telegram_users, logs_references) ====================
|
||||
|
||||
/**
|
||||
* Carica e decripta un file JSON sensibile
|
||||
* Gestisce automaticamente file in chiaro (migrazione) e criptati
|
||||
* @param {string} filePath - Percorso del file
|
||||
* @param {object} defaultValue - Valore di default se file non esiste
|
||||
* @returns {object} Dati decriptati
|
||||
*/
|
||||
function loadSecureFile(filePath, defaultValue = {}) {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return defaultValue;
|
||||
}
|
||||
const content = fs.readFileSync(filePath, 'utf8').trim();
|
||||
try {
|
||||
return JSON.parse(content);
|
||||
} catch (e) {
|
||||
console.error(`[loadSecureFile] JSON non valido in ${filePath}:`, e.message);
|
||||
return defaultValue;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[loadSecureFile] Errore caricamento ${filePath}:`, error.message);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cripta e salva un file JSON sensibile
|
||||
* @param {string} filePath - Percorso del file
|
||||
* @param {object} data - Dati da salvare
|
||||
* @returns {boolean} True se successo
|
||||
*/
|
||||
function saveSecureFile(filePath, data) {
|
||||
try {
|
||||
const content = JSON.stringify(data, null, 2);
|
||||
fs.writeFileSync(filePath, content, 'utf8');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`[saveSecureFile] Errore salvataggio ${filePath}:`, error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
// Generazione token
|
||||
generateToken,
|
||||
generateReadableToken,
|
||||
generateSecureToken,
|
||||
|
||||
// Crittografia oggetti JSON
|
||||
encrypt,
|
||||
decrypt,
|
||||
|
||||
// Crittografia file log
|
||||
encryptLog,
|
||||
decryptLog,
|
||||
decryptLogToMemory,
|
||||
|
||||
// Gestione file sensibili
|
||||
loadSecureFile,
|
||||
saveSecureFile,
|
||||
|
||||
// Utility
|
||||
normalizeKey
|
||||
};
|
||||
78
plugin/tools/dataHub.js
Normal file
78
plugin/tools/dataHub.js
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* dataHub.js - Cache centralizzata dei dati del plugin.
|
||||
*
|
||||
* Tutti i moduli (realtime, telegram, ecc.) leggono da qui.
|
||||
* I dati vengono scritti da:
|
||||
* - realtime/core.js → updateSensorData() (ogni 500ms)
|
||||
* - index.cjs → updateWeatherData() (ogni 5min)
|
||||
*
|
||||
* Nessuna duplicazione: i dati vengono raccolti UNA volta e condivisi.
|
||||
*/
|
||||
|
||||
let latestSensorData = null;
|
||||
let latestWeatherData = { forecast: null, sea: null };
|
||||
let lastSensorUpdate = 0;
|
||||
let lastWeatherUpdate = 0;
|
||||
|
||||
/**
|
||||
* Aggiorna lo snapshot dei dati sensore.
|
||||
* Chiamato da sendData() in core.js ogni 500ms.
|
||||
* @param {Object} data - Dati sensore flat (es. { wind_direction: 180, temperature: 22.5 })
|
||||
*/
|
||||
function updateSensorData(data) {
|
||||
latestSensorData = data ? { ...data } : null;
|
||||
lastSensorUpdate = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggiorna lo snapshot dei dati meteo.
|
||||
* Chiamato da fetchAndPublishWeather() in index.cjs.
|
||||
* @param {Object|null} forecast - Dati previsioni (temperatura, vento, ecc.)
|
||||
* @param {Object|null} sea - Dati condizioni marine (onde, ecc.)
|
||||
*/
|
||||
function updateWeatherData(forecast, sea) {
|
||||
latestWeatherData = {
|
||||
forecast: forecast || null,
|
||||
sea: sea || null
|
||||
};
|
||||
lastWeatherUpdate = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Legge l'ultimo snapshot dei dati sensore.
|
||||
* @returns {Object|null} Dati sensore o null se non ancora disponibili
|
||||
*/
|
||||
function getSensorData() {
|
||||
return latestSensorData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legge l'ultimo snapshot dei dati meteo.
|
||||
* @returns {{ forecast: Object|null, sea: Object|null }}
|
||||
*/
|
||||
function getWeatherData() {
|
||||
return latestWeatherData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legge tutti i dati disponibili (sensori + meteo) con timestamps.
|
||||
* @returns {{ sensors: Object|null, weather: Object, timestamps: Object }}
|
||||
*/
|
||||
function getAllData() {
|
||||
return {
|
||||
sensors: latestSensorData,
|
||||
weather: latestWeatherData,
|
||||
timestamps: {
|
||||
sensorUpdate: lastSensorUpdate,
|
||||
weatherUpdate: lastWeatherUpdate
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
updateSensorData,
|
||||
updateWeatherData,
|
||||
getSensorData,
|
||||
getWeatherData,
|
||||
getAllData
|
||||
};
|
||||
50
plugin/tools/healthcheck.js
Normal file
50
plugin/tools/healthcheck.js
Normal file
@@ -0,0 +1,50 @@
|
||||
const SERVICES = {
|
||||
api: 'https://api.mebboat.it/health',
|
||||
storage: 'https://storage.mebboat.it/health',
|
||||
auth: 'https://auth.mebboat.it/health',
|
||||
realtime: 'https://realtime.mebboat.it/health',
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks the health of a single service.
|
||||
* @param {string} url - Health endpoint URL
|
||||
* @returns {Promise<{ok: boolean, status: string}>}
|
||||
*/
|
||||
async function checkService(url) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: { Accept: "application/json" }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return { ok: false, status: `HTTP ${response.status}` };
|
||||
}
|
||||
|
||||
const data = await response.json().catch(() => null);
|
||||
const isOk = data?.status === "ok";
|
||||
return { ok: isOk, status: isOk ? 'online' : 'offline' };
|
||||
} catch (err) {
|
||||
return { ok: false, status: `error: ${err.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks all MEB cloud services in parallel.
|
||||
* @returns {Promise<Object>} Map of service name -> health result
|
||||
*/
|
||||
async function checkAllServices() {
|
||||
const entries = Object.entries(SERVICES);
|
||||
const results = await Promise.all(
|
||||
entries.map(async ([name, url]) => {
|
||||
const result = await checkService(url);
|
||||
return [name, result];
|
||||
})
|
||||
);
|
||||
return Object.fromEntries(results);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SERVICES,
|
||||
checkService,
|
||||
checkAllServices
|
||||
};
|
||||
@@ -1,219 +0,0 @@
|
||||
/**
|
||||
* logRecorder.js - Gestione registrazione dati separata
|
||||
* Centralizza tutte le funzioni di logging del dataset
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const { datasetInit, appendData } = require('../datasetModels/datasetCore');
|
||||
|
||||
let app = null;
|
||||
let recordingInterval = null;
|
||||
let isRecording = false;
|
||||
|
||||
// Stato condiviso della registrazione
|
||||
const recordingState = {
|
||||
active: false,
|
||||
startTime: null,
|
||||
entryCount: 0,
|
||||
currentFile: null,
|
||||
stream: null
|
||||
};
|
||||
|
||||
/**
|
||||
* Inizializza il recorder con l'istanza di SignalK app
|
||||
*/
|
||||
function init(signalkApp) {
|
||||
app = signalkApp;
|
||||
console.log('[LogRecorder] Inizializzato');
|
||||
}
|
||||
|
||||
/**
|
||||
* Raccoglie i dati dai sensori SignalK
|
||||
*/
|
||||
function collectSensorData() {
|
||||
const getSK = (p) => {
|
||||
const v = app.getSelfPath(p);
|
||||
return v && v.value !== undefined && v.value !== null ? v.value : null;
|
||||
};
|
||||
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
|
||||
// Posizione
|
||||
latitude: getSK('navigation.position')?.latitude ?? null,
|
||||
longitude: getSK('navigation.position')?.longitude ?? null,
|
||||
speed: getSK('navigation.speedOverGround'),
|
||||
heading: getSK('navigation.headingTrue'),
|
||||
|
||||
// Batteria Trazione
|
||||
traction_voltage: getSK('electrical.batteries.traction.Voltage'),
|
||||
traction_current: getSK('electrical.batteries.traction.current'),
|
||||
traction_soc: getSK('electrical.batteries.traction.stateOfCharge'),
|
||||
traction_temperature: getSK('electrical.batteries.traction.temperature'),
|
||||
traction_power: getSK('electrical.batteries.traction.power'),
|
||||
|
||||
// Batteria Servizio
|
||||
service_voltage: getSK('electrical.batteries.service.Voltage'),
|
||||
service_current: getSK('electrical.batteries.service.current'),
|
||||
service_soc: getSK('electrical.batteries.service.stateOfCharge'),
|
||||
service_temperature: getSK('electrical.batteries.service.temperature'),
|
||||
|
||||
// Meteo (da OpenMeteo condiviso)
|
||||
temperature: getSK('meb.temperature'),
|
||||
windSpeed: getSK('meb.appleWindSpeed'),
|
||||
windDirection: getSK('meb.appleWindDirection'),
|
||||
|
||||
// Onde
|
||||
waveHeight: getSK('meb.waves.waveHeight'),
|
||||
wavePeriod: getSK('meb.waves.wavePeriod'),
|
||||
waveDirection: getSK('meb.waves.waveDirection')
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea un nuovo file di log
|
||||
*/
|
||||
function createNewLogFile() {
|
||||
const headers = [
|
||||
'timestamp',
|
||||
'latitude', 'longitude', 'speed', 'heading',
|
||||
'traction_voltage', 'traction_current', 'traction_soc', 'traction_temperature', 'traction_power',
|
||||
'service_voltage', 'service_current', 'service_soc', 'service_temperature',
|
||||
'temperature', 'windSpeed', 'windDirection',
|
||||
'waveHeight', 'wavePeriod', 'waveDirection'
|
||||
];
|
||||
|
||||
const result = datasetInit(headers);
|
||||
if (result) {
|
||||
recordingState.currentFile = result.fileName;
|
||||
recordingState.stream = result.stream;
|
||||
console.log(`[LogRecorder] Nuovo file: ${result.fileName}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrive una riga di dati nel log
|
||||
*/
|
||||
function writeLogEntry(data) {
|
||||
if (!recordingState.stream) return false;
|
||||
|
||||
const values = [
|
||||
data.timestamp,
|
||||
data.latitude, data.longitude, data.speed, data.heading,
|
||||
data.traction_voltage, data.traction_current, data.traction_soc, data.traction_temperature, data.traction_power,
|
||||
data.service_voltage, data.service_current, data.service_soc, data.service_temperature,
|
||||
data.temperature, data.windSpeed, data.windDirection,
|
||||
data.waveHeight, data.wavePeriod, data.waveDirection
|
||||
];
|
||||
|
||||
appendData(values);
|
||||
recordingState.entryCount++;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Avvia la registrazione
|
||||
* @param {number} intervalMs - Intervallo in millisecondi (default 2000)
|
||||
*/
|
||||
function startRecording(intervalMs = 2000) {
|
||||
if (isRecording) {
|
||||
console.log('[LogRecorder] Registrazione già attiva');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!app) {
|
||||
console.error('[LogRecorder] App non inizializzata');
|
||||
return false;
|
||||
}
|
||||
|
||||
const fileResult = createNewLogFile();
|
||||
if (!fileResult) {
|
||||
console.error('[LogRecorder] Impossibile creare file di log');
|
||||
return false;
|
||||
}
|
||||
|
||||
recordingState.active = true;
|
||||
recordingState.startTime = Date.now();
|
||||
recordingState.entryCount = 0;
|
||||
isRecording = true;
|
||||
|
||||
recordingInterval = setInterval(() => {
|
||||
const data = collectSensorData();
|
||||
writeLogEntry(data);
|
||||
}, intervalMs);
|
||||
|
||||
console.log(`[LogRecorder] Registrazione avviata (ogni ${intervalMs}ms)`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ferma la registrazione
|
||||
*/
|
||||
function stopRecording() {
|
||||
if (!isRecording) {
|
||||
console.log('[LogRecorder] Nessuna registrazione attiva');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (recordingInterval) {
|
||||
clearInterval(recordingInterval);
|
||||
recordingInterval = null;
|
||||
}
|
||||
|
||||
if (recordingState.stream) {
|
||||
recordingState.stream.end();
|
||||
}
|
||||
|
||||
const duration = Date.now() - recordingState.startTime;
|
||||
console.log(`[LogRecorder] Registrazione fermata. Durata: ${Math.round(duration / 1000)}s, Entries: ${recordingState.entryCount}`);
|
||||
|
||||
recordingState.active = false;
|
||||
recordingState.stream = null;
|
||||
isRecording = false;
|
||||
|
||||
return {
|
||||
duration,
|
||||
entries: recordingState.entryCount,
|
||||
file: recordingState.currentFile
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Riavvia la registrazione (nuovo file)
|
||||
*/
|
||||
function restartRecording(intervalMs = 2000) {
|
||||
stopRecording();
|
||||
return startRecording(intervalMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ottiene lo stato corrente della registrazione
|
||||
*/
|
||||
function getStatus() {
|
||||
return {
|
||||
isRecording,
|
||||
active: recordingState.active,
|
||||
startTime: recordingState.startTime,
|
||||
entryCount: recordingState.entryCount,
|
||||
currentFile: recordingState.currentFile,
|
||||
runningTime: isRecording ? Date.now() - recordingState.startTime : 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica se la registrazione è attiva
|
||||
*/
|
||||
function isActive() {
|
||||
return isRecording;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
restartRecording,
|
||||
getStatus,
|
||||
isActive,
|
||||
collectSensorData
|
||||
};
|
||||
@@ -1,35 +0,0 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
module.exports = function(app, settings) {
|
||||
// Serve mappa
|
||||
app.get('/meb/map', (req, res) => {
|
||||
const filePath = path.join(__dirname, "public", "map.html");
|
||||
fs.readFile(filePath, "utf8", (err, html) => {
|
||||
if (err) {
|
||||
res.status(500).send("Errore nel caricamento della mappa");
|
||||
return;
|
||||
}
|
||||
const token = settings?.mapboxKey ?? "";
|
||||
const finalHtml = html.replace("{{MAPBOX_KEY}}", token);
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.send(finalHtml);
|
||||
});
|
||||
});
|
||||
|
||||
// WebSocket forward: posizione in tempo reale
|
||||
let lastPosition = null;
|
||||
|
||||
app.streambundle.getSelfStream("navigation.position").onValue(pos => {
|
||||
lastPosition = pos;
|
||||
});
|
||||
|
||||
// Endpoint JSON per marker barca (se vuoi usarlo invece del WS SignalK)
|
||||
app.get('/meb/map/boat', (req, res) => {
|
||||
if (!lastPosition) {
|
||||
res.json({ error: "No position data available" });
|
||||
return;
|
||||
}
|
||||
res.json(lastPosition);
|
||||
});
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
* @param {string} prefix - Prefisso per i path SignalK
|
||||
* @returns {Array} Array di valori SignalK
|
||||
*/
|
||||
function generateValues(data, prefix = "meb") {
|
||||
function generateValues(data, prefix = "") {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return [];
|
||||
}
|
||||
@@ -22,7 +22,7 @@ function generateValues(data, prefix = "meb") {
|
||||
const val = obj[key];
|
||||
if (val === undefined || val === null) continue;
|
||||
|
||||
const newPath = [...pathParts, key];
|
||||
const newPath = pathParts.length > 0 ? [...pathParts, key] : [key];
|
||||
|
||||
if (typeof val === "object" && !Array.isArray(val)) {
|
||||
traverse(val, newPath);
|
||||
@@ -37,7 +37,8 @@ function generateValues(data, prefix = "meb") {
|
||||
}
|
||||
}
|
||||
|
||||
traverse(data, [prefix]);
|
||||
const initialPath = prefix ? [prefix] : [];
|
||||
traverse(data, initialPath);
|
||||
return values;
|
||||
}
|
||||
|
||||
@@ -56,12 +57,9 @@ function publishWeatherData(app, weatherData, settings) {
|
||||
const values = generateValues(weatherData);
|
||||
|
||||
if (values.length === 0) {
|
||||
console.debug('[Publisher] Nessun valore da pubblicare');
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug(`📤 Pubblicazione ${values.length} valori SignalK`);
|
||||
|
||||
try {
|
||||
app.handleMessage("meb", {
|
||||
updates: [{ values }],
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
function setupRoutes(router, lastCallRef, app) {
|
||||
router.get("/ping", async (req, res) => {
|
||||
try {
|
||||
const text = lastCallRef.current || "pong";
|
||||
res.status(200).sendFile(__dirname + "/steering_support/helm_steering_destra.html");
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/helm_steering_destro", (req, res) => {
|
||||
try {
|
||||
res.status(200).sendFile(__dirname + "/steering_support/helm_steering_destro.html");
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/tools", (req, res) => {
|
||||
try {
|
||||
const path = require("path");
|
||||
const filePath = path.join(__dirname, "..", "public", "decrypt_tool.html");
|
||||
res.status(200).sendFile(filePath);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// LOGS DATASETS
|
||||
router.post("/dataset/start", (req, res) => {
|
||||
try {
|
||||
if (!app.datasetControl) {
|
||||
return res.status(503).json({ error: "Dataset control non disponibile" });
|
||||
}
|
||||
const result = app.datasetControl.start();
|
||||
res.json({ success: result, message: result ? "Registrazione avviata" : "Registrazione già in corso" });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/dataset/stop", (req, res) => {
|
||||
try {
|
||||
if (!app.datasetControl) {
|
||||
return res.status(503).json({ error: "Dataset control non disponibile" });
|
||||
}
|
||||
const result = app.datasetControl.stop();
|
||||
res.json({ success: result, message: result ? "Registrazione fermata" : "Nessuna registrazione in corso" });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/dataset/restart", (req, res) => {
|
||||
try {
|
||||
if (!app.datasetControl) {
|
||||
return res.status(503).json({ error: "Dataset control non disponibile" });
|
||||
}
|
||||
const result = app.datasetControl.restart();
|
||||
res.json({ success: result, message: "Registrazione riavviata" });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/dataset/status", (req, res) => {
|
||||
try {
|
||||
if (!app.datasetControl) {
|
||||
return res.status(503).json({ error: "Dataset control non disponibile" });
|
||||
}
|
||||
const status = app.datasetControl.getStatus();
|
||||
res.json(status);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/dataset/files", (req, res) => {
|
||||
try {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const logsDirectory = path.join(__dirname, '..', 'datasetModels', 'saved_datas');
|
||||
|
||||
if (!fs.existsSync(logsDirectory)) {
|
||||
return res.json({ files: [], count: 0 });
|
||||
}
|
||||
|
||||
const items = fs.readdirSync(logsDirectory);
|
||||
const files = items
|
||||
.filter(item => {
|
||||
const fullPath = path.join(logsDirectory, item);
|
||||
return fs.statSync(fullPath).isFile();
|
||||
})
|
||||
.map(file => {
|
||||
const fullPath = path.join(logsDirectory, file);
|
||||
const stats = fs.statSync(fullPath);
|
||||
return {
|
||||
name: file,
|
||||
size: stats.size,
|
||||
created: stats.birthtime,
|
||||
modified: stats.mtime
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
||||
|
||||
res.json({ files, count: files.length });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== GRAPHS API ====================
|
||||
const graphsCore = require('../datasetModels/graphsCore.js');
|
||||
|
||||
// Serve la pagina HTML dei grafici
|
||||
router.get("/graphs", (req, res) => {
|
||||
try {
|
||||
const path = require("path");
|
||||
const filePath = path.join(__dirname, "..", "public", "graphs.html");
|
||||
res.status(200).sendFile(filePath);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// API per ottenere dati grafici
|
||||
router.get("/api/graphs", (req, res) => {
|
||||
try {
|
||||
const hours = parseInt(req.query.hours) || 24;
|
||||
const data = graphsCore.getAllGraphsData(hours);
|
||||
|
||||
// Aggiungi valori attuali dalla cache condivisa
|
||||
const sharedData = graphsCore.getSharedWeatherData();
|
||||
data.current = {
|
||||
temperature: sharedData.forecast?.temperature,
|
||||
windSpeed: sharedData.forecast?.windSpeed,
|
||||
waveHeight: sharedData.waves?.waveHeight,
|
||||
humidity: sharedData.forecast?.humidity
|
||||
};
|
||||
|
||||
// Aggiungi unità di misura
|
||||
data.units = graphsCore.getUnits();
|
||||
|
||||
res.json(data);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// API per statistiche archivio
|
||||
router.get("/api/graphs/stats", (req, res) => {
|
||||
try {
|
||||
const stats = graphsCore.getArchiveStats();
|
||||
res.json(stats);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
function getOpenApiSpec() {
|
||||
return {
|
||||
openapi: "3.0.0",
|
||||
info: { title: "MebWeather API Portal", version: "1.0.0" },
|
||||
servers: [{ url: "/plugins/meb-weather" }],
|
||||
paths: {
|
||||
"/ping": {
|
||||
get: {
|
||||
summary: "Called /ping route",
|
||||
responses: {
|
||||
200: {
|
||||
description: "OK",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { message: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/meb/suggestion": {
|
||||
get: {
|
||||
summary: "Pagina di test MEB Suggestion",
|
||||
responses: {
|
||||
200: {
|
||||
description: "OK",
|
||||
content: {
|
||||
"text/html": {
|
||||
schema: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/dataset/start": {
|
||||
post: {
|
||||
summary: "Avvia la registrazione dataset",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Registrazione avviata",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
success: { type: "boolean" },
|
||||
message: { type: "string" }
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/dataset/stop": {
|
||||
post: {
|
||||
summary: "Ferma la registrazione dataset",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Registrazione fermata",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
success: { type: "boolean" },
|
||||
message: { type: "string" }
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/dataset/restart": {
|
||||
post: {
|
||||
summary: "Riavvia la registrazione dataset",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Registrazione riavviata",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
success: { type: "boolean" },
|
||||
message: { type: "string" }
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/dataset/status": {
|
||||
get: {
|
||||
summary: "Ottieni lo stato della registrazione dataset",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Stato corrente",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
isRecording: { type: "boolean" },
|
||||
recordCount: { type: "number" }
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/dataset/files": {
|
||||
get: {
|
||||
summary: "Ottieni la lista dei file log salvati",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Lista file log",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
files: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
size: { type: "number" },
|
||||
created: { type: "string" },
|
||||
modified: { type: "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
count: { type: "number" }
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { setupRoutes, getOpenApiSpec };
|
||||
@@ -1,78 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
|
||||
//Ottieni il percodi dal nome di una cartella, se questa non esiste, viene creata
|
||||
function getDirectory(directoryName) {
|
||||
const directoryPath = path.resolve(__dirname, directoryName);
|
||||
|
||||
if (!fs.existsSync(directoryPath)) {
|
||||
fs.mkdirSync(directoryPath, { recursive: true });
|
||||
} else {
|
||||
return directoryPath;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrivi un file con
|
||||
* @param {string} fileName - Il nome del file.
|
||||
* @param {string} extension - L'estensione
|
||||
* @param {string} content - Il contenuto del file.
|
||||
* @param {string} inDirectory - Il percorso in cui scrivere il file. Se non viene specificato, il file verrà aggiunto alla cartella principale del server.
|
||||
*
|
||||
* 🧠 Esempio d’uso
|
||||
* (async () => {
|
||||
* await writeFileToFolder("data", "prova.json", JSON.stringify({ name: "Giuseppe", age: 17 }, null, 2));
|
||||
* })();
|
||||
*
|
||||
*/
|
||||
async function write(fileName, extension, content, inDirectory) {
|
||||
try {
|
||||
const directoryPath = inDirectory ? getDirectory(inDirectory) : path.resolve(__dirname, '..');
|
||||
fs.mkdirSync(directoryPath, {recursive: true});
|
||||
|
||||
const filePath = path.join(directoryPath, `${fileName}.${extension}`);
|
||||
await fs.writeFileSync(filePath, content, 'utf-8');
|
||||
} catch (error) {
|
||||
console.error(`Error writing file ${fileName}.${extension}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
//Funzione per ottenere la data nel formato dd/mm/yyyy hh:mm
|
||||
function getDate(isoString) {
|
||||
const date = new Date(isoString);
|
||||
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const year = date.getFullYear();
|
||||
|
||||
const hours = String(date.getHours()).padStart(2, "0");
|
||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||
|
||||
return `${day}/${month}/${year} ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
// Funzione per ottenere il tempo relativo ("2 ore fa", "tra 4 ore")
|
||||
function relativeData(isoString) {
|
||||
const date = new Date(isoString);
|
||||
const now = new Date();
|
||||
const diffMs = date - now; // differenza in millisecondi
|
||||
const diffSec = Math.round(diffMs / 1000);
|
||||
const diffMin = Math.round(diffSec / 60);
|
||||
const diffHr = Math.round(diffMin / 60);
|
||||
const diffDay = Math.round(diffHr / 24);
|
||||
|
||||
const rtf = new Intl.RelativeTimeFormat("it", { numeric: "auto" });
|
||||
|
||||
if (Math.abs(diffSec) < 60) return rtf.format(diffSec, "second");
|
||||
if (Math.abs(diffMin) < 60) return rtf.format(diffMin, "minute");
|
||||
if (Math.abs(diffHr) < 24) return rtf.format(diffHr, "hour");
|
||||
return rtf.format(diffDay, "day");
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getDirectory,
|
||||
write,
|
||||
getDate,
|
||||
relativeData,
|
||||
}
|
||||
Reference in New Issue
Block a user