From 41f33ce1818261f7b0bcb3dfb226729b3b19611c Mon Sep 17 00:00:00 2001 From: Giuseppe Raffa <77052701+sesee3@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:25:03 +0100 Subject: [PATCH] Aggiunto collegamento al server --- docker-compose.yml | 27 + package-lock.json | 565 ++++++++++- package.json | 8 +- plugin/.DS_Store | Bin 6148 -> 10244 bytes plugin/api_models/aisstream.js | 32 - plugin/api_models/openmeteo.js | 110 +- plugin/bot/telegram.core.js | 947 ------------------ plugin/bot/telegram_users.json | 24 - plugin/config.js | 136 ++- plugin/datasetModels/datasetCore.js | 105 -- plugin/datasetModels/datasetUtils.js | 274 ----- plugin/datasetModels/graphsCore.js | 351 ------- plugin/index.cjs | 723 ++++--------- plugin/public/css/data_console.css | 1 + plugin/public/decrypt_tool.html | 785 --------------- plugin/public/graphs.html | 386 ------- plugin/{tools => }/public/map.html | 10 +- .../steering_helm_tip_builder.html | 1 + plugin/realtime/core.js | 450 +++++++++ plugin/routes/dataset.js | 56 ++ plugin/routes/forecasts.js | 63 ++ plugin/routes/helm.js | 30 + plugin/routes/index.js | 34 + plugin/routes/map.js | 43 + plugin/routes/telegram.js | 13 + plugin/sensors/sensors.references.json | 0 plugin/telegram/callbacks/dashboard.js | 152 +++ plugin/telegram/callbacks/data.js | 26 + plugin/telegram/callbacks/live.js | 141 +++ plugin/telegram/callbacks/logs.js | 51 + plugin/telegram/callbacks/settings.js | 80 ++ plugin/telegram/callbacks/status.js | 67 ++ plugin/telegram/callbacks/weather.js | 26 + plugin/telegram/commands/dashboard.js | 57 ++ plugin/telegram/commands/data.js | 58 ++ plugin/telegram/commands/live.js | 84 ++ plugin/telegram/commands/logs.js | 53 + plugin/telegram/commands/realtime.js | 24 + plugin/telegram/commands/settings.js | 20 + plugin/telegram/commands/status.js | 40 + plugin/telegram/commands/structure.js | 47 + plugin/telegram/commands/weather.js | 84 ++ plugin/telegram/telegram.core.js | 269 +++++ plugin/tools/crypt.js | 258 ----- plugin/tools/dataHub.js | 78 ++ plugin/tools/healthcheck.js | 50 + plugin/tools/logRecorder.js | 219 ---- plugin/tools/map.handler.js | 35 - plugin/tools/publisher.js | 10 +- plugin/tools/routes.js | 321 ------ plugin/tools/utils.js | 78 -- 51 files changed, 3088 insertions(+), 4414 deletions(-) create mode 100644 docker-compose.yml delete mode 100644 plugin/api_models/aisstream.js delete mode 100644 plugin/bot/telegram.core.js delete mode 100644 plugin/bot/telegram_users.json delete mode 100644 plugin/datasetModels/datasetCore.js delete mode 100644 plugin/datasetModels/datasetUtils.js delete mode 100644 plugin/datasetModels/graphsCore.js delete mode 100644 plugin/public/decrypt_tool.html delete mode 100644 plugin/public/graphs.html rename plugin/{tools => }/public/map.html (97%) create mode 100644 plugin/realtime/core.js create mode 100644 plugin/routes/dataset.js create mode 100644 plugin/routes/forecasts.js create mode 100644 plugin/routes/helm.js create mode 100644 plugin/routes/index.js create mode 100644 plugin/routes/map.js create mode 100644 plugin/routes/telegram.js create mode 100644 plugin/sensors/sensors.references.json create mode 100644 plugin/telegram/callbacks/dashboard.js create mode 100644 plugin/telegram/callbacks/data.js create mode 100644 plugin/telegram/callbacks/live.js create mode 100644 plugin/telegram/callbacks/logs.js create mode 100644 plugin/telegram/callbacks/settings.js create mode 100644 plugin/telegram/callbacks/status.js create mode 100644 plugin/telegram/callbacks/weather.js create mode 100644 plugin/telegram/commands/dashboard.js create mode 100644 plugin/telegram/commands/data.js create mode 100644 plugin/telegram/commands/live.js create mode 100644 plugin/telegram/commands/logs.js create mode 100644 plugin/telegram/commands/realtime.js create mode 100644 plugin/telegram/commands/settings.js create mode 100644 plugin/telegram/commands/status.js create mode 100644 plugin/telegram/commands/structure.js create mode 100644 plugin/telegram/commands/weather.js create mode 100644 plugin/telegram/telegram.core.js delete mode 100644 plugin/tools/crypt.js create mode 100644 plugin/tools/dataHub.js create mode 100644 plugin/tools/healthcheck.js delete mode 100644 plugin/tools/logRecorder.js delete mode 100644 plugin/tools/map.handler.js delete mode 100644 plugin/tools/routes.js delete mode 100644 plugin/tools/utils.js diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a91b8dc --- /dev/null +++ b/docker-compose.yml @@ -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 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index d879f21..04cd4b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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 + } + } } } } diff --git a/package.json b/package.json index 15e1a45..b5294c2 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/plugin/.DS_Store b/plugin/.DS_Store index 1240d43ab44c9f9ca547f9c68b5e84818e9cfda2..744f3e5e927e509aeb418c8ce32e6c41fe0c4f75 100644 GIT binary patch literal 10244 zcmeHMU2GIp6uxI#+Pidsp%f@=b(gOE1lq8zrCRW3+ir>cwsc#%{ekW7&e)F3&a6AL zyJ$hHi5MjqeG>n}lYhvIVldH&i6TaQF^EEp8XpWk7>ycy(dfBzXWMQ|6CVsgGdG!g z?mhRMJLi7$ote2agb--U$_<402_X~@CM6N8D-<@)ju}O8G!jZce?n5kATm*iM$8%3 zuEIsYMZiVCMZiVCMc`UM0H4{c&gz2w+^iAEda_= zRNDpHY&*bw{EiGca-2&X!2;YBC3gj9ivipn^?t`L;^~9v=v9YGc zmO6h^Q^UbJ|5!_75Zld72M>Da;=uY%-LZXIN;e*5VdG8Fxp|U#ygyMN#@4h|X=bjS zuc{H`%iNMQ^sbD;#`}_n zmQCqqOj?+XYsst@x0F^*i>UW1s9RtrGlr&h8K%mn>5p2W*WGxRCHO@M|$(BY>f^e&bp=EsVW(dXCXBA=<$?tu6o(>N^gz#`hpj?o%E$rfJUxONyan^Cq$yx!W?lnyd|dq%;Ut3~g+I&_HlmFi#1 z?(K2Y>P##8L{#A|ln$7q@r=@$M>ZBbc2H_xSJX3?4q3H~3!FW^hK z2tU9t@GJZVe+g9Z2y=xRVWqH2SS>UN8-*Q0r_d$r61s&!K^9U%Mi}EO6AD?km-h>` zZ~$8~v(CAf2(Mo`>wI@?*}84}e<$lM%x8JFsA}<&rT&#`*EijKYuf}BIe80wotg(W z^@|e5roPRM>72!k^wx;i`+6}ql>L*L$TPE$r&2k~0?O#>T_Fa1(j3el-d!cG@kwQv zIlQ~hSMQSqOjzCx`Wi*41k=%D`)=^nixN=YX%RPxQYj`T@7^YcM2WC_1@E`}I(!mK zvww~77sz+y5)%FoB>a3>1l6z_N!|=0B=}C~f?d!9y-4z5h(i)&NPz)q$ROnpze;l5Gr{P(64xWb>;bo-#ad-_$e+u4$x8WT)183nQ_!vI1>HiH}f*;}X zG(ztzP?<~keI}uEk&Lbx`UsA&G?+syd}tc%2SL#ToV>u^=ckNLeSELz&lg@RiYz7{ z-dDCLdUTU-@dR#h5pWT35pWT35pWSG;Sl)Q_y3OB|Npx{xd^xj{ErcU(r_f)h6^lg zRHv_%y_@^-7{-GYW;f2I?Sd6EVS4*>Ja*XU_%)8s*~;7FAxAgPrR|P>wmZtJ^fSQS Q|6jGQZtnj7+T8#D4U*9rIsgCw delta 397 zcmZn(XfcprU|?W$DortDU=RQ@Ie-{Mvv5r;6q~50$SATgU^g?P$m9e8iODJglk1ro zk{I$CN^;T-gOl@f3xG<2pdkTBKxK0CU0jlK@{@of9Kzab+*h_8cSM#=!6RFcfo$po zpf(0xh7^WGh7uqyW=LfyNn-E?TAu=B<}ef^JA#qp`V>s%$f}VYws86v>5UMFp;^G- zJ2_84%oLvjgaeS>%cv|cd9y&z<|>I9jEmVhI0TtN&Ig7jH;`}zh3Lk@@640=RXjoA S%fJK)Yf$hrY>wxd!wdi_b5@A} diff --git a/plugin/api_models/aisstream.js b/plugin/api_models/aisstream.js deleted file mode 100644 index 6310909..0000000 --- a/plugin/api_models/aisstream.js +++ /dev/null @@ -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 }; \ No newline at end of file diff --git a/plugin/api_models/openmeteo.js b/plugin/api_models/openmeteo.js index eb873cf..4eec566 100644 --- a/plugin/api_models/openmeteo.js +++ b/plugin/api_models/openmeteo.js @@ -83,15 +83,28 @@ function formatWithUnit(value, unitKey, category = 'forecast') { return `${value}${unit}`; } -async function getForecast(location) { +async function getForecast(location, options = { mode: 'both' }) { + + const mode = options.mode || 'both'; + const params = []; + + const currentParams = FORECAST_PARAMS.current.join(","); + const hourlyParams = FORECAST_PARAMS.hourly.join(","); + + if (mode === 'both' || mode === 'current') { + params.push('current=' + currentParams); + } + + if (mode === 'both' || mode === 'hourly') { + params.push('hourly=' + hourlyParams); + } + if (!location?.latitude || !location?.longitude) { console.warn('[OpenMeteo] Coordinate non valide per forecast'); return null; } - const currentParams = FORECAST_PARAMS.current.join(","); - const hourlyParams = FORECAST_PARAMS.hourly.join(","); - const api = `https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&hourly=${hourlyParams}¤t=${currentParams}`; + const api = `https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&${params.join('&')}`; try { const response = await axios.get(api, { @@ -102,11 +115,6 @@ async function getForecast(location) { const { data } = response; - if (!data?.current) { - console.warn('[OpenMeteo Forecast] Risposta senza dati current'); - return null; - } - // Aggiorna unità globali da API response if (data.current_units) { globalUnits.forecast = { @@ -122,23 +130,36 @@ async function getForecast(location) { } return { - temperature: data.current.temperature_2m ?? null, - humidity: data.current.relative_humidity_2m ?? null, - pressure: data.current.pressure_msl ?? null, - windSpeed: data.current.wind_speed_10m ?? null, - windDirection: data.current.wind_direction_10m ?? null, - windGusts: data.current.wind_gusts_10m ?? null, - rain: data.current.rain ?? null, - precipitation: data.current.precipitation ?? null, - // Unità di misura + timestamp: Date.now(), + temperature: data.current?.temperature_2m ?? null, + humidity: data.current?.relative_humidity_2m ?? null, + pressure: data.current?.pressure_msl ?? null, + + // Refactored to match sensorsReferences.json hierarchy + wind: { + speed: data.current?.wind_speed_10m ?? null, + direction: data.current?.wind_direction_10m ?? null, + gusts: data.current?.wind_gusts_10m ?? null, + }, + + rain: data.current?.rain ?? null, + precipitation: data.current?.precipitation ?? null, // Keeping simple properties flat + + // Unita di misura units: globalUnits.forecast, - // Dati orari per grafici - hourly: { + // Parametri orari + hourly: data.hourly ? { time: data.hourly?.time, temperature: data.hourly?.temperature_2m, + pressure: data.hourly?.pressure_msl, + precipitationProbability: data.hourly?.precipitation_probability, + precipitation: data.hourly?.precipitation, + rain: data.hourly?.rain, + cloudCover: data.hourly?.cloud_cover, + windDirection: data.hourly?.wind_direction_10m, humidity: data.hourly?.relative_humidity_2m, windSpeed: data.hourly?.wind_speed_10m - }, + } : null, hourlyUnits: data.hourly_units || null }; } catch (error) { @@ -147,15 +168,28 @@ async function getForecast(location) { } } -async function getSeaConditions(location) { +async function getSeaConditions(location, options = { mode: 'both' }) { + + const mode = options.mode || 'both'; + const params = []; + + const currentParams = MARINE_PARAMS.current.join(","); + const hourlyParams = MARINE_PARAMS.hourly.join(","); + + if (mode === 'both' || mode === 'current') { + params.push('current=' + currentParams); + } + + if (mode === 'both' || mode === 'hourly') { + params.push('hourly=' + hourlyParams); + } + if (!location?.latitude || !location?.longitude) { console.warn('[OpenMeteo] Coordinate non valide per onde'); return null; } - const currentParams = MARINE_PARAMS.current.join(","); - const hourlyParams = MARINE_PARAMS.hourly.join(","); - const api = `https://marine-api.open-meteo.com/v1/marine?latitude=${location.latitude}&longitude=${location.longitude}&hourly=${hourlyParams}¤t=${currentParams}&models=ecmwf_wam`; + const api = `https://marine-api.open-meteo.com/v1/marine?latitude=${location.latitude}&longitude=${location.longitude}&${params.join('&')}&models=ecmwf_wam`; try { const response = await axios.get(api, { @@ -166,11 +200,6 @@ async function getSeaConditions(location) { const { data } = response; - if (!data?.current) { - console.warn('[OpenMeteo Marine] Risposta senza dati current'); - return null; - } - // Aggiorna unità globali da API response if (data.current_units) { globalUnits.waves = { @@ -184,23 +213,28 @@ async function getSeaConditions(location) { } return { - waveHeight: data.current.wave_height ?? null, - wavePeriod: data.current.wave_period ?? null, - waveDirection: data.current.wave_direction ?? null, - wavePeakPeriod: data.current.wave_peak_period ?? null, - currentDirection: data.current.ocean_current_direction ?? null, - currentVelocity: data.current.ocean_current_velocity ?? null, + // Refactored to match sensorsReferences.json hierarchy + waves: { + height: data.current?.wave_height ?? null, + period: data.current?.wave_period ?? null, + direction: data.current?.wave_direction ?? null, + peakPeriod: data.current?.wave_peak_period ?? null + }, + + // Keeping these flat essentially + currentDirection: data.current?.ocean_current_direction ?? null, + currentVelocity: data.current?.ocean_current_velocity ?? null, // Unità di misura units: globalUnits.waves, // Dati orari per grafici - hourly: { + hourly: data.hourly ? { time: data.hourly?.time, waveHeight: data.hourly?.wave_height, wavePeriod: data.hourly?.wave_period, waveDirection: data.hourly?.wave_direction, currentDirection: data.hourly?.ocean_current_direction, currentVelocity: data.hourly?.ocean_current_velocity - }, + } : null, hourlyUnits: data.hourly_units || null }; } catch (error) { diff --git a/plugin/bot/telegram.core.js b/plugin/bot/telegram.core.js deleted file mode 100644 index ead4f21..0000000 --- a/plugin/bot/telegram.core.js +++ /dev/null @@ -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 "); - 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 "); - 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 "); - 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 "); - 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 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 \`\n` + - `\`/changei api \``, - { 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 "); - 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 -}; - - - diff --git a/plugin/bot/telegram_users.json b/plugin/bot/telegram_users.json deleted file mode 100644 index 9640e5d..0000000 --- a/plugin/bot/telegram_users.json +++ /dev/null @@ -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 - } -] \ No newline at end of file diff --git a/plugin/config.js b/plugin/config.js index af6fcf3..e98ba93 100644 --- a/plugin/config.js +++ b/plugin/config.js @@ -2,45 +2,137 @@ const dotenv = require("dotenv"); const path = require("path"); const fs = require("fs"); -// Carica il file .env dalla root del plugin dotenv.config({ path: path.resolve(__dirname, "..", ".env"), quiet: true }); -// Base path per tutti i file generati dal server -const SIGNALK_FILES = process.env.SIGNALK_FILES || path.resolve(__dirname, "..", "data"); +const SIGNALK_FILES = process.env.SIGNALK_FILES || path.resolve(__dirname); -// Crea le directory necessarie se non esistono -function ensureDir(dirPath) { - if (!fs.existsSync(dirPath)) { - fs.mkdirSync(dirPath, { recursive: true }); - console.log(`[Config] Creata directory: ${dirPath}`); +function checkFolder(dirPath) { + try { + fs.accessSync(dirPath, fs.constants.R_OK | fs.constants.W_OK); + } catch (err) { + if (err.code === 'ENOENT') { + fs.mkdirSync(dirPath, { + recursive: true, + mode: 0o777 + }); + } else { + throw new Error(`Permission denied for ${dirPath}`); + } } return dirPath; } -// Paths per i vari tipi di file const paths = { - // Base base: SIGNALK_FILES, - - // Logs: hourly_archive.json, logs_references.json, saved_datas/ - logs: ensureDir(path.join(SIGNALK_FILES, "logs")), + + logs: checkFolder(path.join(SIGNALK_FILES, "logs")), hourlyArchive: path.join(SIGNALK_FILES, "logs", "hourly_archive.json"), logsReferences: path.join(SIGNALK_FILES, "logs", "logs_references.json"), - savedDatas: ensureDir(path.join(SIGNALK_FILES, "logs", "saved_datas")), - - // Private: authorized_admins.txt, telegram_users.json - private: ensureDir(path.join(SIGNALK_FILES, "private")), + savedDatas: checkFolder(path.join(SIGNALK_FILES, "logs", "saved_datas")), + + private: checkFolder(path.join(SIGNALK_FILES, "private")), authorizedAdmins: path.join(SIGNALK_FILES, "private", "authorized_admins.txt"), telegramUsers: path.join(SIGNALK_FILES, "private", "telegram_users.json"), - - // Sensors: sensors.references.json - sensors: ensureDir(path.join(SIGNALK_FILES, "sensors")), - sensorsReferences: path.join(SIGNALK_FILES, "sensors", "sensors.references.json") + + sensorsReferences: path.join(__dirname, "sensors", "sensors.references.json") }; const config = { telegramBotToken: process.env.TELEGRAM_BOT_TOKEN, + mapboxKey: process.env.MAPBOX_KEY, + cloudUrl: process.env.CLOUD_URL || "https://realtime.mebcloud.it", + cloudApiKey: process.env.CLOUD_API_KEY, + realtimeUrl: process.env.REALTIME_URL || 'http://realtime:3002', paths }; -module.exports = { config, paths }; \ No newline at end of file +/** + * 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} { 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 }; \ No newline at end of file diff --git a/plugin/datasetModels/datasetCore.js b/plugin/datasetModels/datasetCore.js deleted file mode 100644 index c2249ca..0000000 --- a/plugin/datasetModels/datasetCore.js +++ /dev/null @@ -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 -}; \ No newline at end of file diff --git a/plugin/datasetModels/datasetUtils.js b/plugin/datasetModels/datasetUtils.js deleted file mode 100644 index 0577ba7..0000000 --- a/plugin/datasetModels/datasetUtils.js +++ /dev/null @@ -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 -}; diff --git a/plugin/datasetModels/graphsCore.js b/plugin/datasetModels/graphsCore.js deleted file mode 100644 index f6724ea..0000000 --- a/plugin/datasetModels/graphsCore.js +++ /dev/null @@ -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 -}; diff --git a/plugin/index.cjs b/plugin/index.cjs index db2e134..4833134 100644 --- a/plugin/index.cjs +++ b/plugin/index.cjs @@ -1,550 +1,193 @@ -const { config, paths } = require("./config.js"); -const { setupRoutes, getOpenApiSpec } = require("./tools/routes.js"); -const { aisStream } = require("./api_models/aisstream.js") -const mapHandler = require("./tools/map.handler.js"); -const { linkBot, send } = require("./bot/telegram.core.js"); -const dataset = require("./datasetModels/datasetCore.js"); -const dataUtils = require("./datasetModels/datasetUtils.js"); -const graphsCore = require("./datasetModels/graphsCore.js"); -const { generateToken, encryptLog, loadSecureFile, saveSecureFile } = require("./tools/crypt.js"); -const fs = require("fs"); -const path = require("path"); +const { config } = require("./config.js"); +const registerRoutes = require("./routes"); +const { linkBotToApp } = require("./telegram/telegram.core.js"); const { getForecast, getSeaConditions } = require("./api_models/openmeteo.js"); - const { publish } = require("./tools/publisher.js"); +const realtime = require("./realtime/core.js"); +const dataHub = require("./tools/dataHub.js"); -// CONFIG modificabile runtime (non più frozen per permettere modifiche admin) const CONFIG = { - log_interval: 2000, // Dataset entry ogni 2 secondi - openmeteo_interval: 300000, // OpenMeteo ogni 5 minuti - hourly_archive_interval: 3600000, // Archivio orario per grafici - number_value_fallback: 999999999999, - value_fallback: "Funzionalità da Sviluppare" + forecast_current_frequency: 300000, // 5 min default in ms + forecast_hourly_frequency: 3600000, // 1 hour default }; -// Funzione per aggiornare gli intervalli runtime -function updateInterval(type, newIntervalMs) { - if (type === 'api' || type === 'openmeteo') { - CONFIG.openmeteo_interval = newIntervalMs; - return { type: 'openmeteo_interval', value: newIntervalMs }; - } else if (type === 'log') { - CONFIG.log_interval = newIntervalMs; - return { type: 'log_interval', value: newIntervalMs }; - } - return null; -} - -// Getter per CONFIG (usato da altri moduli) -function getConfig() { - return { ...CONFIG }; -} - -const CSV_HEADERS = Object.freeze([ - 'timestamp', - 'wavesHeight', - 'wavesPeriod', - 'wavesDirection', - 'windSpeed', - 'windDirection', - 'temperature', - // 'currentSpeed', - // 'currentDirection', - 'speedOverGround', - 'courseOverGround', - 'headingTrue', - 'latitude', - 'longitude', - '1Voltage', - '1Current', - '1StateOfCharge', - '1Temperature', - '0Voltage', - '0Current', - '0CellsStateOfCharge', - '0AverageCellTemperature', - '0Power', - 'propultionShaftSpeed', - 'systemUptime' -]); - const state = { - logTimer: null, - logStreamer: null, - logsCount: 0, - isRecordingLogs: false, - currentLogFile: null, - currentLogKey: null, openMeteoTimer: null, - hourlyArchiveTimer: null, - unsubPos: null, app: null, - startTime: null -}; - -const logsDirectory = dataUtils.getDirectory(paths.savedDatas); -const logsReferencesFile = paths.logsReferences; -const lastCallRef = { current: null }; - - -const getSKValue = (path, fallback = CONFIG.value_fallback) => { - if (!state.app) { - console.warn(`[getSKValue] App not initialized, returning fallback for path: ${path}`); - return fallback; - } - - try { - const value = state.app.getSelfPath(path)?.value; - return (value !== undefined && value !== null) ? value : fallback; - } catch (error) { - console.error(`[getSKValue] Error reading path ${path}:`, error.message); - return fallback; - } -}; - - -const closeStream = (stream) => { - return new Promise((resolve) => { - if (!stream || stream.destroyed) { - resolve(); - return; - } - - stream.end(() => { - resolve(); - }); - - setTimeout(resolve, 1000); - }); + startTime: null, }; const clearIntervalSafe = (timerId) => { - if (timerId) { - clearInterval(timerId); - } + if (timerId) clearInterval(timerId); return null; }; -const collectSensorData = (settings = {}) => { - // Prendi la posizione dalla navigazione se disponibile - const position = state.app?.getSelfPath('navigation.position')?.value; - const lat = position?.latitude ?? settings.latitude ?? CONFIG.number_value_fallback; - const lon = position?.longitude ?? settings.longitude ?? CONFIG.number_value_fallback; - - return { - timestamp: new Date().toISOString(), - wavesHeight: getSKValue("meb.waves.height"), - wavesPeriod: getSKValue("meb.waves.period"), - wavesDirection: getSKValue("meb.waves.direction"), - windSpeed: getSKValue("meb.wind.speed"), - windDirection: getSKValue("meb.wind.direction"), - temperature: getSKValue("meb.temperature"), - // currentSpeed: getSKValue("meb.currents.speed"), - // currentDirection: getSKValue("meb.currents.direction"), - speedOverGround: getSKValue("navigation.speedOverGround"), - courseOverGround: getSKValue("navigation.courseOverGroundTrue"), - headingTrue: getSKValue("navigation.headingTrue"), - latitude: lat, - longitude: lon, - '1Voltage': getSKValue("electrical.batteries.service.Voltage"), - '1Current': getSKValue("electrical.batteries.service.current"), - '1StateOfCharge': getSKValue("electrical.batteries.service.stateOfCharge"), - '1Temperature': getSKValue("electrical.batteries.service.temperature"), - '0Voltage': getSKValue("electrical.batteries.traction.Voltage"), - '0Current': getSKValue("electrical.batteries.traction.current"), - '0CellsStateOfCharge': getSKValue("electrical.batteries.traction.stateOfCharge"), - '0AverageCellTemperature': getSKValue("electrical.batteries.traction.temperature"), - '0Power': getSKValue("electrical.batteries.traction.power"), - propultionShaftSpeed: getSKValue("propulsion.0.revolutions"), - systemUptime: process.uptime() ?? CONFIG.number_value_fallback - }; -}; - - -function createNewFiles() { - try { - const now = new Date(); - const dateStr = now.toLocaleString('it-IT', { - timeZone: 'Europe/Rome', - day: 'numeric', - month: 'long', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', - hour12: false - }).replace(/:/g, '-'); - const logFileName = `log_${dateStr}.csv`; - const logFile = path.join(logsDirectory, logFileName); - - // Close existing stream gracefully - if (state.logStreamer && !state.logStreamer.destroyed) { - state.logStreamer.end(); - } - - state.logStreamer = fs.createWriteStream(logFile, { flags: 'a' }); - - state.logStreamer.on('error', (err) => { - console.error('[log_file] Errore nello stream:', err); - }); - - dataset.datasetInit(CSV_HEADERS, state.logStreamer); - state.logsCount = 0; - - state.currentLogFile = logFileName; - state.currentLogKey = generateToken(); - - return true; - } catch (error) { - console.error('[log_file] Errore nella creazione di un nuovo file:', error); - return false; - } -} - -// ==================== RECORDING CONTROL ==================== - -/** - * Stops the data recording process - * @returns {boolean} True if stopped successfully, false if already stopped - */ -function stopRecording() { - if (!state.isRecordingLogs) { - return false; - } - - try { - state.logTimer = clearIntervalSafe(state.logTimer); - - if (state.logStreamer && !state.logStreamer.destroyed) { - state.logStreamer.end(); - } - - state.isRecordingLogs = false; - - // Usa la chiave generata all'inizio della sessione - if (state.currentLogFile && state.currentLogKey) { - const logFilePath = path.join(logsDirectory, state.currentLogFile); - - // Carica, aggiorna e salva references criptate - const logsData = loadSecureFile(logsReferencesFile, { references: [] }); - logsData.references.push({ - name: state.currentLogFile, - token: state.currentLogKey - }); - saveSecureFile(logsReferencesFile, logsData); - - // Cripta il file log con la stessa chiave - // encryptLog(logFilePath, state.currentLogKey); - - console.log(`[stopRecording] Log ${state.currentLogFile} criptato e salvato.`); - } - - state.logsCount = 0; - state.currentLogFile = null; - state.currentLogKey = null; - - return true; - } catch (error) { - console.error('[log_stop] Errore durante l\'arresto della registrazione:', error); - return false; - } -} - -/** - * Starts the data recording process - * @param {object} settings - Plugin settings - * @returns {boolean} True if started successfully, false if already running - */ -function startRecording(settings = {}) { - if (state.isRecordingLogs) { - return false; - } - - try { - state.isRecordingLogs = true; - state.startTime = Date.now(); - - if (!createNewFiles()) { - state.isRecordingLogs = false; - return false; - } - - state.logTimer = setInterval(() => { - try { - if (!state.logStreamer || state.logStreamer.destroyed) { - console.error('[log_dataset_error] Stream non disponibile'); - return; - } - const data = collectSensorData(settings); - const success = dataset.appendData(data, CSV_HEADERS, state.logStreamer); - if (success) { - state.logsCount++; - } - } catch (error) { - console.error('[log_dataset_error] Errore durante la raccolta dei dati:', error); - } - }, CONFIG.log_interval); - - return true; - } catch (error) { - console.error('[log_dataset_error] Errore nell\'avvio della registrazione', error); - state.isRecordingLogs = false; - return false; - } -} - -/** - * Restarts the recording process - * @param {object} settings - Plugin settings - * @returns {boolean} Success status - */ -function restartRecording(settings = {}) { - stopRecording(); - startRecording(settings); - return true; -} - -/** - * Gets current recording status with detailed metrics - * @returns {object} Status object - */ -function getRecordingStatus() { - return { - isRecording: state.isRecordingLogs, - recordCount: state.logsCount, - recordingInterval: CONFIG.log_interval, - uptime: state.startTime ? Date.now() - state.startTime : 0, - timestamp: new Date().toISOString() - }; -} module.exports = function (app) { state.app = app; + let lastHourlyUpdate = 0; + + const fetchAndPublishWeather = async (forceHourly = false) => { + try { + const pos = app.getSelfPath('navigation.position')?.value; + if (!pos?.latitude || !pos?.longitude) { + console.debug('[MEB] Posizione non disponibile per meteo'); + return; + } + + const now = Date.now(); + // Richiedi 'hourly' se forzato, o se e' passata piu' di 1 ora + const shouldFetchHourly = forceHourly || (now - lastHourlyUpdate > CONFIG.forecast_hourly_frequency); + const mode = shouldFetchHourly ? 'both' : 'current'; + + if (shouldFetchHourly) console.log('[MEB] Scaricamento previsioni complete (hourly + current)...'); + else console.debug('[MEB] Aggiornamento meteo (current)...'); + + const [forecast, sea] = await Promise.all([ + getForecast(pos, { mode }), + getSeaConditions(pos, { mode }) + ]); + + + + if (forecast) publish(app, forecast, {}); + if (sea) publish(app, sea, {}); + + if (shouldFetchHourly) { + lastHourlyUpdate = now; + } + + if (forecast || sea) { + // Aggiorna cache centralizzata per Telegram on-demand + dataHub.updateWeatherData(forecast, sea); + + // Invia al server SOLO quando è hourly (contiene previsioni 7gg) + // I dati current-only non vengono inviati — sono già disponibili localmente + if (shouldFetchHourly) { + realtime.sendWeatherPayload({ forecast, sea }); + } + } + + } catch (error) { + console.error('[MEB] Errore ciclo meteo:', error.message); + } + }; const plugin = { id: "meb", name: "MEB Plugin", start: async (settings) => { + + const randomVal = (min, max) => parseFloat((Math.random() * (max - min) + min).toFixed(2)); + + // Dati di test — i path SignalK DEVONO corrispondere alle sensor-references rules + // Le regole definiscono main_path e subPath, quindi i dati devono seguire esattamente quei path + publish(app, { + // engine (main_path: propulsion.0, subPath: revolutions) + "propulsion.0.revolutions": randomVal(1000, 5000), + // navigation (main_path: navigation) + "navigation.courseOverGroundTrue": randomVal(0, 360), + "navigation.speedOverGround": randomVal(0, 30), + "navigation.headingTrue": randomVal(0, 360), + // position (lat/lon sotto navigation.position come oggetto) + "navigation.position.latitude": randomVal(40, 45), + "navigation.position.longitude": randomVal(9, 14), + // service battery (main_path: electrical.batteries.service) — NB: spelling "electrical" + "electrical.batteries.service.current": randomVal(-50, 50), + "electrical.batteries.service.Voltage": randomVal(0, 500), + "electrical.batteries.service.stateOfCharge": randomVal(0.1, 1), + // traction battery (main_path: electrical.batteries.traction) + "electrical.batteries.traction.current": randomVal(-100, 100), + "electrical.batteries.traction.power": randomVal(0, 5000), + "electrical.batteries.traction.stateOfCharge": randomVal(0.1, 1), + "electrical.batteries.traction.temperature": randomVal(20, 45), + "electrical.batteries.traction.Voltage": randomVal(48, 58), + // temperatura (main_path: meb.temperature, single field) + "meb.temperature": randomVal(15, 35), + // waves (main_path: meb.waves) + "meb.waves.direction": randomVal(0, 360), + "meb.waves.height": randomVal(0, 4), + "meb.waves.period": randomVal(1, 12), + // wind (main_path: meb.wind) + "meb.wind.direction": randomVal(0, 360), + "meb.wind.speed": randomVal(0, 40), + // system uptime (main_path: system.uptime, single field) + "system.uptime": Math.floor(process.uptime()) + }) + try { - // ==================== WEB SOCKET AISSTREAM ==================== - try { - aisStream(); - } catch (error) { - console.error('[ERROR] Errore in AISStream:', error); + // Aggiorna CONFIG dai settings di SignalK + if (settings && settings.forecast_current_frequency) { + CONFIG.forecast_current_frequency = settings.forecast_current_frequency * 1000; + } + if (settings && settings.forecast_hourly_frequency) { + CONFIG.forecast_hourly_frequency = settings.forecast_hourly_frequency * 1000; } - // ==================== WEATHER UPDATES (OpenMeteo condiviso ogni 2 min) ==================== - - let location = { - latitude: app.getSelfPath('navigation.position')?.value?.latitude, - longitude: app.getSelfPath('navigation.position')?.value?.longitude, - }; + state.startTime = Date.now(); - const updateWeatherData = async () => { - const currentPos = app.getSelfPath('navigation.position')?.value; - if (currentPos?.latitude && currentPos?.longitude) { - location = { latitude: currentPos.latitude, longitude: currentPos.longitude }; - } else if (!location.latitude || !location.longitude) { - location = { - latitude: Number(settings?.latitude), - longitude: Number(settings?.longitude), - }; - } + // Inizializza realtime (async: carica sensor refs dal server) + await realtime.init(app, settings.sensor_code); - if (!location.latitude || !location.longitude) { - console.warn("[OpenMeteo] Posizione non disponibile"); - return; - } - - try { - const [forecastData, wavesData] = await Promise.all([ - getForecast(location), - getSeaConditions(location) - ]); - - // Log per debug - if (forecastData) { - console.log("[OpenMeteo] Forecast ricevuto:", { - temp: forecastData.temperature, - wind: forecastData.windSpeed, - humidity: forecastData.humidity - }); - } - - if (wavesData) { - console.log("[OpenMeteo] Marine ricevuto:", { - waveHeight: wavesData.waveHeight, - wavePeriod: wavesData.wavePeriod - }); - } - - // Aggiorna dati condivisi per grafici - graphsCore.updateSharedWeatherData(forecastData, wavesData); - - - // Pubblica su SignalK solo se abbiamo dati validi - const weatherPayload = { - temperature: forecastData?.temperature ?? null, - humidity: forecastData?.humidity ?? null, - pressure: forecastData?.pressure ?? null, - wind: { - speed: forecastData?.windSpeed ?? null, - direction: forecastData?.windDirection ?? null, - gusts: forecastData?.windGusts ?? null - }, - waves: { - height: wavesData?.waveHeight ?? null, - period: wavesData?.wavePeriod ?? null, - direction: wavesData?.waveDirection ?? null - }, - rain: forecastData?.rain ?? null, - precipitation: forecastData?.precipitation ?? null - }; - - publish(app, weatherPayload, settings); - console.log("[OpenMeteo] Dati pubblicati su SignalK"); - - } catch (error) { - console.error("[OpenMeteo] Errore aggiornamento:", error.message); - } - }; - - // Funzione per archiviare dati orari per grafici - const archiveHourlyData = () => { - const sharedData = graphsCore.getSharedWeatherData(); - if (sharedData.forecast || sharedData.waves) { - graphsCore.archiveHourlyData({ - temperature: sharedData.forecast?.temperature, - humidity: sharedData.forecast?.humidity, - pressure: sharedData.forecast?.pressure, - windSpeed: sharedData.forecast?.windSpeed, - windDirection: sharedData.forecast?.windDirection, - waveHeight: sharedData.waves?.waveHeight, - wavePeriod: sharedData.waves?.wavePeriod, - waveDirection: sharedData.waves?.waveDirection, - // currentSpeed: sharedData.waves?.currentVelocity, - // currentDirection: sharedData.waves?.currentDirection - }); - } - }; - - // Avvia aggiornamento meteo immediato + timer 2 minuti - updateWeatherData(); - state.openMeteoTimer = setInterval(updateWeatherData, CONFIG.openmeteo_interval); - - // Archivia dati ogni ora per i grafici - state.hourlyArchiveTimer = setInterval(archiveHourlyData, CONFIG.hourly_archive_interval); - - - // ==================== MAPPA INTERATTIVA ==================== - try { - mapHandler(app, settings); - } catch (error) { - console.error('[ERROR] Errore nell\'avvio della mappa:', error); - } - - // ==================== LOG DATI ==================== - try { - startRecording(settings); - } catch (error) { - console.error('[ERROR] Errore nell\'avvio dei log:', error); - } - - - app.datasetControl = { - start: () => startRecording(settings), - stop: stopRecording, - restart: () => restartRecording(settings), - getStatus: getRecordingStatus - }; - - // Esponi funzioni per modifica intervalli - app.intervalControl = { - updateInterval: (type, newIntervalMs) => { - const result = updateInterval(type, newIntervalMs); - if (!result) return null; - - // Riavvia il timer appropriato - if (result.type === 'openmeteo_interval') { - state.openMeteoTimer = clearIntervalSafe(state.openMeteoTimer); - updateWeatherData(); // Aggiorna subito - state.openMeteoTimer = setInterval(updateWeatherData, newIntervalMs); - console.log(`[IntervalControl] OpenMeteo interval aggiornato a ${newIntervalMs}ms`); - } else if (result.type === 'log_interval') { - // Riavvia recording con nuovo intervallo - const wasRecording = state.isRecordingLogs; - if (wasRecording) { - state.logTimer = clearIntervalSafe(state.logTimer); - state.logTimer = setInterval(() => { - try { - if (!state.logStreamer || state.logStreamer.destroyed) { - console.error('[log_dataset_error] Stream non disponibile'); - return; - } - const data = collectSensorData(settings); - const success = dataset.appendData(data, CSV_HEADERS, state.logStreamer); - if (success) { - state.logsCount++; - } - } catch (error) { - console.error('[log_dataset_error] Errore durante la raccolta dei dati:', error); - } - }, newIntervalMs); - } - console.log(`[IntervalControl] Log interval aggiornato a ${newIntervalMs}ms`); - } - - return result; - }, - getIntervals: () => ({ - log_interval: CONFIG.log_interval, - openmeteo_interval: CONFIG.openmeteo_interval, - hourly_archive_interval: CONFIG.hourly_archive_interval - }) - }; - - // ==================== BOT TELEGRAM (dopo intervalControl) ==================== + // Telegram Bot if (config.telegramBotToken) { try { - await linkBot(app); - let deviceName = process.env.HOST_NAME || 'Dispositivo Sconosciuto'; - await send(`Il bot è di nuovo disponibile. (Avviato da ${deviceName})`); - console.log('[MEB TELEGRAM] Bot avviato con app.intervalControl disponibile'); + await linkBotToApp(app); + console.log('[MEB] Telegram bot started'); } catch (error) { - console.error('[ERROR] Errore nell\'avvio del bot telegram', error); + console.error('[MEB] Error starting Telegram bot:', error); } } else { - console.warn('[MEB TELEGRAM] Bot disabilitato: TELEGRAM_BOT_TOKEN non configurato.'); + console.warn('[MEB] Telegram bot disabled: TELEGRAM_BOT_TOKEN not set'); } - // ===== Shutdown Hooks ===== + // Map & API routes + try { + registerRoutes(app, settings); + console.log('[MEB] Routes registered'); + } catch (error) { + console.error('[MEB] Error registering routes:', error); + } + + // Avvio ciclo meteo: Prima esecuzione immediata (con hourly) + fetchAndPublishWeather(true); + + // Timer ricorrente + state.openMeteoTimer = setInterval(() => { + fetchAndPublishWeather(false); + }, CONFIG.forecast_current_frequency); + console.log(`[MEB] Meteo polling avviato ogni ${CONFIG.forecast_current_frequency / 1000}s`); + + + // Shutdown hooks (register once) const shutdown = async (reason = 'signal') => { try { - console.log(`[shutdown] Received ${reason}. Stopping plugin...`); + console.log(`[MEB] Received ${reason}. Stopping plugin...`); await plugin.stop(); process.exit(0); } catch (err) { - console.error('[shutdown] Error during stop:', err); + console.error('[MEB] Error during shutdown:', err); process.exit(1); } }; - // Evita di registrare multipli handler if (!process.__meb_shutdown_hooks_installed) { process.__meb_shutdown_hooks_installed = true; process.on('SIGINT', () => shutdown('SIGINT')); process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('uncaughtException', (err) => { - console.error('[uncaughtException]', err); + console.error('[MEB] uncaughtException:', err); shutdown('uncaughtException'); }); process.on('unhandledRejection', (reason) => { - console.error('[unhandledRejection]', reason); + console.error('[MEB] unhandledRejection:', reason); shutdown('unhandledRejection'); }); } } catch (error) { - console.error('[Errore] Errore durante l\'avvio del plugin:', error); + console.error('[MEB] Error during plugin startup:', error); throw error; } }, @@ -552,46 +195,82 @@ module.exports = function (app) { stop: async () => { try { state.openMeteoTimer = clearIntervalSafe(state.openMeteoTimer); - state.hourlyArchiveTimer = clearIntervalSafe(state.hourlyArchiveTimer); - - if (typeof state.unsubPos === "function") { - try { - state.unsubPos(); - state.unsubPos = null; - } catch (error) { - console.error('[ERROR] Errore durante la cancellazione dell\'iscrizione alla posizione:', error); - } - } - - // stopRecording gestisce già criptazione e salvataggio reference - if (app.datasetControl) { - try { - app.datasetControl.stop(); - } catch (error) { - console.error('[ERROR] Errore durante l\'arresto del controllo del dataset:', error); - } - } - - await closeStream(state.logStreamer); - console.log('[stop] Plugin arrestato correttamente.'); - + realtime.stop(); + console.log('[MEB] Plugin stopped'); } catch (error) { - console.error('[ERROR] Errore durante l\'arresto del plugin:', error); + console.error('[MEB] Error during plugin stop:', error); } }, - schema: () => ({ - type: "object", - required: [], - properties: {}, - }), + schema: () => ({}), - registerWithRouter: (router) => { - setupRoutes(router, lastCallRef, app); + // Aggiorna la configurazione (da Telegram o API) + setConfig: (key, value) => { + if (key === 'forecast_current_frequency') { + const ms = value * 1000; + CONFIG.forecast_current_frequency = ms; + + // Riavvia il timer con la nuova frequenza + state.openMeteoTimer = clearIntervalSafe(state.openMeteoTimer); + state.openMeteoTimer = setInterval(() => { + fetchAndPublishWeather(false); + }, ms); + + console.log(`[MEB] Intervallo current aggiornato a ${value} s`); + return true; + } + if (key === 'forecast_hourly_frequency') { + CONFIG.forecast_hourly_frequency = value * 1000; + console.log(`[MEB] Intervallo Hourly aggiornato a ${value} s`); + return true; + } + return false; }, - getOpenApi: getOpenApiSpec, + // Gestione Polling Meteo (Start/Stop/Force) + startPolling: () => { + if (state.openMeteoTimer) { + console.log('[MEB] Polling già attivo.'); + return false; + } + + fetchAndPublishWeather(false); + + state.openMeteoTimer = setInterval(() => { + fetchAndPublishWeather(false); + }, CONFIG.forecast_current_frequency); + + console.log(`[MEB] Meteo AVVIATO (freq: ${CONFIG.forecast_current_frequency / 1000}s)`); + return true; + }, + + stopPolling: () => { + if (!state.openMeteoTimer) { + console.log('[MEB] Polling già fermo.'); + return false; + } + state.openMeteoTimer = clearIntervalSafe(state.openMeteoTimer); + console.log('[MEB] Meteo polling FERMATO.'); + return true; + }, + + isPollingActive: () => !!state.openMeteoTimer, + + forceUpdate: async () => { + console.log('[MEB] Aggiornamento Meteo Forzato da Utente.'); + await fetchAndPublishWeather(false); + return true; + }, + + getOpenApi: () => ({ + openapi: "3.0.0", + info: { title: "MEB Plugin API", version: "2.0.0" }, + servers: [{ url: "/plugins/meb" }], + paths: {} + }), }; + app.mebPlugin = plugin; + return plugin; -}; \ No newline at end of file +}; diff --git a/plugin/public/css/data_console.css b/plugin/public/css/data_console.css index 31beb7b..22f5ff5 100644 --- a/plugin/public/css/data_console.css +++ b/plugin/public/css/data_console.css @@ -5,6 +5,7 @@ border-radius: 25px; display: flex; align-items: center; + backdrop-filter: blur(10px); } #error-popup { diff --git a/plugin/public/decrypt_tool.html b/plugin/public/decrypt_tool.html deleted file mode 100644 index 0948500..0000000 --- a/plugin/public/decrypt_tool.html +++ /dev/null @@ -1,785 +0,0 @@ - - - - - - MEB - Decryption Tool - - - -
-
-

🔐 MEB Decryption Tool

-

Decripta i file CSV criptati con AES-256-GCM

-
- - -
-

📁 Importa File Criptato

- -
- ⬆️ -

Trascina qui il file criptato o clicca per selezionarlo

- Formati supportati: .csv (criptati) -
- - -
-
-
-
-
- - -
-

🔑 Chiave di Decriptazione

- -
-
- - -
-
- -
- ℹ️ Formato chiave supportato:
- • Token esadecimale (48 caratteri): 217af80a15d54289...
- • Qualsiasi stringa (verrà hashata con SHA-256)
- • Algoritmo: AES-256-GCM con IV (12 byte) + Auth Tag (16 byte) -
-
- - -
- - -
- - -
-
-

📄 Anteprima Contenuto

- -
-
- -
- - - -
-
-
-
- - - - diff --git a/plugin/public/graphs.html b/plugin/public/graphs.html deleted file mode 100644 index f03d00d..0000000 --- a/plugin/public/graphs.html +++ /dev/null @@ -1,386 +0,0 @@ - - - - - - Previsioni - 7 giorni - - - - -
-

📊 MEB Grafici Meteo

-
-
- Aggiornamento... -
-
- -
- - - - -
- -
-
-
🌡️ Temperatura Attuale
-
--
-
°C
-
-
-
🌬️ Vento
-
--
-
km/h
-
-
-
🌊 Altezza Onde
-
--
-
m
-
-
-
💧 Umidità
-
--
-
%
-
-
- -
-
-

🌡️ Temperatura

-
- -
-
- -
-

🌬️ Velocità Vento

-
- -
-
- -
-

🌊 Altezza Onde

-
- -
-
- -
-

💧 Umidità

-
- -
-
-
- - - - diff --git a/plugin/tools/public/map.html b/plugin/public/map.html similarity index 97% rename from plugin/tools/public/map.html rename to plugin/public/map.html index 17427d1..587118e 100644 --- a/plugin/tools/public/map.html +++ b/plugin/public/map.html @@ -2,7 +2,7 @@ -Mappa Meteo SignalK +Mappa SignalK @@ -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); } diff --git a/plugin/public/steering_support/steering_helm_tip_builder.html b/plugin/public/steering_support/steering_helm_tip_builder.html index 732785a..3ff6813 100644 --- a/plugin/public/steering_support/steering_helm_tip_builder.html +++ b/plugin/public/steering_support/steering_helm_tip_builder.html @@ -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; } diff --git a/plugin/realtime/core.js b/plugin/realtime/core.js new file mode 100644 index 0000000..a89d363 --- /dev/null +++ b/plugin/realtime/core.js @@ -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 +}; diff --git a/plugin/routes/dataset.js b/plugin/routes/dataset.js new file mode 100644 index 0000000..8e5f213 --- /dev/null +++ b/plugin/routes/dataset.js @@ -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; diff --git a/plugin/routes/forecasts.js b/plugin/routes/forecasts.js new file mode 100644 index 0000000..5885ac3 --- /dev/null +++ b/plugin/routes/forecasts.js @@ -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; diff --git a/plugin/routes/helm.js b/plugin/routes/helm.js new file mode 100644 index 0000000..edd7d7b --- /dev/null +++ b/plugin/routes/helm.js @@ -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; diff --git a/plugin/routes/index.js b/plugin/routes/index.js new file mode 100644 index 0000000..aad601e --- /dev/null +++ b/plugin/routes/index.js @@ -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); +}; diff --git a/plugin/routes/map.js b/plugin/routes/map.js new file mode 100644 index 0000000..2b26160 --- /dev/null +++ b/plugin/routes/map.js @@ -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; diff --git a/plugin/routes/telegram.js b/plugin/routes/telegram.js new file mode 100644 index 0000000..2618896 --- /dev/null +++ b/plugin/routes/telegram.js @@ -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." }); + } + }); +}; diff --git a/plugin/sensors/sensors.references.json b/plugin/sensors/sensors.references.json new file mode 100644 index 0000000..e69de29 diff --git a/plugin/telegram/callbacks/dashboard.js b/plugin/telegram/callbacks/dashboard.js new file mode 100644 index 0000000..ff2fcd0 --- /dev/null +++ b/plugin/telegram/callbacks/dashboard.js @@ -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) { } + } + } +]; diff --git a/plugin/telegram/callbacks/data.js b/plugin/telegram/callbacks/data.js new file mode 100644 index 0000000..7033629 --- /dev/null +++ b/plugin/telegram/callbacks/data.js @@ -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); + } + } + } + } +]; diff --git a/plugin/telegram/callbacks/live.js b/plugin/telegram/callbacks/live.js new file mode 100644 index 0000000..6ee0e31 --- /dev/null +++ b/plugin/telegram/callbacks/live.js @@ -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 */ } + } + } +]; diff --git a/plugin/telegram/callbacks/logs.js b/plugin/telegram/callbacks/logs.js new file mode 100644 index 0000000..ad2f8a2 --- /dev/null +++ b/plugin/telegram/callbacks/logs.js @@ -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); + } + } + } + } +]; diff --git a/plugin/telegram/callbacks/settings.js b/plugin/telegram/callbacks/settings.js new file mode 100644 index 0000000..77fc287 --- /dev/null +++ b/plugin/telegram/callbacks/settings.js @@ -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."); + } + } + } +]; diff --git a/plugin/telegram/callbacks/status.js b/plugin/telegram/callbacks/status.js new file mode 100644 index 0000000..5323b24 --- /dev/null +++ b/plugin/telegram/callbacks/status.js @@ -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); + } + } + } + } +]; diff --git a/plugin/telegram/callbacks/weather.js b/plugin/telegram/callbacks/weather.js new file mode 100644 index 0000000..473328c --- /dev/null +++ b/plugin/telegram/callbacks/weather.js @@ -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); + } + } + } + } +]; diff --git a/plugin/telegram/commands/dashboard.js b/plugin/telegram/commands/dashboard.js new file mode 100644 index 0000000..7c1ba4b --- /dev/null +++ b/plugin/telegram/commands/dashboard.js @@ -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 +}; diff --git a/plugin/telegram/commands/data.js b/plugin/telegram/commands/data.js new file mode 100644 index 0000000..7b56d57 --- /dev/null +++ b/plugin/telegram/commands/data.js @@ -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 +}; diff --git a/plugin/telegram/commands/live.js b/plugin/telegram/commands/live.js new file mode 100644 index 0000000..b83ada4 --- /dev/null +++ b/plugin/telegram/commands/live.js @@ -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 +}; diff --git a/plugin/telegram/commands/logs.js b/plugin/telegram/commands/logs.js new file mode 100644 index 0000000..9204912 --- /dev/null +++ b/plugin/telegram/commands/logs.js @@ -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}`); + } + } +}; diff --git a/plugin/telegram/commands/realtime.js b/plugin/telegram/commands/realtime.js new file mode 100644 index 0000000..f0ae7af --- /dev/null +++ b/plugin/telegram/commands/realtime.js @@ -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' }); + } +}; diff --git a/plugin/telegram/commands/settings.js b/plugin/telegram/commands/settings.js new file mode 100644 index 0000000..6eecc2a --- /dev/null +++ b/plugin/telegram/commands/settings.js @@ -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' } + ] + ] + } + }); + } +}; diff --git a/plugin/telegram/commands/status.js b/plugin/telegram/commands/status.js new file mode 100644 index 0000000..3c3f7f4 --- /dev/null +++ b/plugin/telegram/commands/status.js @@ -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 +}; diff --git a/plugin/telegram/commands/structure.js b/plugin/telegram/commands/structure.js new file mode 100644 index 0000000..0bfb2bd --- /dev/null +++ b/plugin/telegram/commands/structure.js @@ -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' }); + } +}; diff --git a/plugin/telegram/commands/weather.js b/plugin/telegram/commands/weather.js new file mode 100644 index 0000000..dae1807 --- /dev/null +++ b/plugin/telegram/commands/weather.js @@ -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 +}; diff --git a/plugin/telegram/telegram.core.js b/plugin/telegram/telegram.core.js new file mode 100644 index 0000000..ebcd310 --- /dev/null +++ b/plugin/telegram/telegram.core.js @@ -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} 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 +}; diff --git a/plugin/tools/crypt.js b/plugin/tools/crypt.js deleted file mode 100644 index c817a7c..0000000 --- a/plugin/tools/crypt.js +++ /dev/null @@ -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 -}; \ No newline at end of file diff --git a/plugin/tools/dataHub.js b/plugin/tools/dataHub.js new file mode 100644 index 0000000..06dce23 --- /dev/null +++ b/plugin/tools/dataHub.js @@ -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 +}; diff --git a/plugin/tools/healthcheck.js b/plugin/tools/healthcheck.js new file mode 100644 index 0000000..a02e30e --- /dev/null +++ b/plugin/tools/healthcheck.js @@ -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} 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 +}; diff --git a/plugin/tools/logRecorder.js b/plugin/tools/logRecorder.js deleted file mode 100644 index 2fa60c7..0000000 --- a/plugin/tools/logRecorder.js +++ /dev/null @@ -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 -}; diff --git a/plugin/tools/map.handler.js b/plugin/tools/map.handler.js deleted file mode 100644 index fc86339..0000000 --- a/plugin/tools/map.handler.js +++ /dev/null @@ -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); - }); -} diff --git a/plugin/tools/publisher.js b/plugin/tools/publisher.js index d4f6718..cd1b8bb 100644 --- a/plugin/tools/publisher.js +++ b/plugin/tools/publisher.js @@ -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 }], diff --git a/plugin/tools/routes.js b/plugin/tools/routes.js deleted file mode 100644 index 41f63c0..0000000 --- a/plugin/tools/routes.js +++ /dev/null @@ -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 }; \ No newline at end of file diff --git a/plugin/tools/utils.js b/plugin/tools/utils.js deleted file mode 100644 index 5f8e339..0000000 --- a/plugin/tools/utils.js +++ /dev/null @@ -1,78 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - - -//Ottieni il percodi dal nome di una cartella, se questa non esiste, viene creata -function getDirectory(directoryName) { - const directoryPath = path.resolve(__dirname, directoryName); - - if (!fs.existsSync(directoryPath)) { - fs.mkdirSync(directoryPath, { recursive: true }); - } else { - return directoryPath; - } -} - -/** - * Scrivi un file con - * @param {string} fileName - Il nome del file. - * @param {string} extension - L'estensione - * @param {string} content - Il contenuto del file. - * @param {string} inDirectory - Il percorso in cui scrivere il file. Se non viene specificato, il file verrà aggiunto alla cartella principale del server. - * - * 🧠 Esempio d’uso - * (async () => { - * await writeFileToFolder("data", "prova.json", JSON.stringify({ name: "Giuseppe", age: 17 }, null, 2)); - * })(); - * - */ -async function write(fileName, extension, content, inDirectory) { - try { - const directoryPath = inDirectory ? getDirectory(inDirectory) : path.resolve(__dirname, '..'); - fs.mkdirSync(directoryPath, {recursive: true}); - - const filePath = path.join(directoryPath, `${fileName}.${extension}`); - await fs.writeFileSync(filePath, content, 'utf-8'); - } catch (error) { - console.error(`Error writing file ${fileName}.${extension}:`, error); - } -} - -//Funzione per ottenere la data nel formato dd/mm/yyyy hh:mm -function getDate(isoString) { - const date = new Date(isoString); - - const day = String(date.getDate()).padStart(2, "0"); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const year = date.getFullYear(); - - const hours = String(date.getHours()).padStart(2, "0"); - const minutes = String(date.getMinutes()).padStart(2, "0"); - - return `${day}/${month}/${year} ${hours}:${minutes}`; -} - -// Funzione per ottenere il tempo relativo ("2 ore fa", "tra 4 ore") -function relativeData(isoString) { - const date = new Date(isoString); - const now = new Date(); - const diffMs = date - now; // differenza in millisecondi - const diffSec = Math.round(diffMs / 1000); - const diffMin = Math.round(diffSec / 60); - const diffHr = Math.round(diffMin / 60); - const diffDay = Math.round(diffHr / 24); - - const rtf = new Intl.RelativeTimeFormat("it", { numeric: "auto" }); - - if (Math.abs(diffSec) < 60) return rtf.format(diffSec, "second"); - if (Math.abs(diffMin) < 60) return rtf.format(diffMin, "minute"); - if (Math.abs(diffHr) < 24) return rtf.format(diffHr, "hour"); - return rtf.format(diffDay, "day"); -} - -module.exports = { - getDirectory, - write, - getDate, - relativeData, -} \ No newline at end of file