Aggiunto collegamento al server

This commit is contained in:
Giuseppe Raffa
2026-03-11 15:25:03 +01:00
parent c37f30e4ea
commit 41f33ce181
51 changed files with 3088 additions and 4414 deletions

27
docker-compose.yml Normal file
View 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
View File

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

View File

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

Binary file not shown.

View File

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

View File

@@ -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}&current=${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}&current=${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) {

View File

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

View File

@@ -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
}
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@
border-radius: 25px;
display: flex;
align-items: center;
backdrop-filter: blur(10px);
}
#error-popup {

View File

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

View File

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

View File

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

View File

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

View 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
View 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
View 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
View 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
View 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." });
}
});
};

View File

View 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) { }
}
}
];

View 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);
}
}
}
}
];

View 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 */ }
}
}
];

View 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);
}
}
}
}
];

View 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.");
}
}
}
];

View 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);
}
}
}
}
];

View 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);
}
}
}
}
];

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

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

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

View 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}`);
}
}
};

View 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' });
}
};

View 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' }
]
]
}
});
}
};

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

View 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' });
}
};

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

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

View File

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

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

View File

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

View File

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

View File

@@ -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 }],

View File

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

View File

@@ -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 duso
* (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,
}