diff --git a/.gitignore b/.gitignore index 04e9058..a38ea6f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,10 +5,13 @@ plugin/telegram_users.json .env -plugin/datasetModels/saved_datas +plugin/datasetModels/saved_data plugin/datasetModels/hourly_archive.json plugin/datasetModels/logs_references.json -.DS_Store +*.DS_Store .vscode/ -.idea/ \ No newline at end of file +.idea/ + +data/ +docker-compose.yml \ No newline at end of file diff --git a/README.md b/README.md deleted file mode 100644 index 792c074..0000000 --- a/README.md +++ /dev/null @@ -1,46 +0,0 @@ -In questa repository è presente il plugin MEB per SignalK. -Ulteriori informazioni verranno aggiunte nelle prossime versioni - - -# Variabili d'Ambiente -Prima di avviare il plugin sul SignalK, è necessario configurare il file di variabili d'ambiente. - -**Ricorda**: -Inserisci i valori di chiavi, percorsi di file o altro subito dopo il simbolo di uguale, senza parentesi, virgolette o altro - -_Una volta creato il file env, aggiungi:_ - -### Chiave di Criptazione -Utilizzata per criptare i dati come i riferimenti dei log, i file di log registrati o gli utenti admin. - - -Aggiungi nel file env una riga come questa: - - - CRYPTOKEY= - - -### Nome dell'Host -Usato per identificare meglio, sopratutto nei casi di test temporanei, il dispositivo in cui il server SignalK è attivo. - -Aggiungi nel file env una riga come questa: - - HOST_NAME= - - -### Token Telegram -Il plugin anima un Bot Telegram. Per ragioni di sicurezza, il token che è l'identificativo del bot deve essere protetto e salvato all'interno delle variabili d'ambiente. - - -Aggiungi nel file env una riga come questa: - - TELEGRAM_BOT_TOKEN= - - -### Percorso dei file -Il plugin genera file all'interno del server SignalK, come i log o file criptati. Specifica il percorso globale all'interno del tuo dispositivo nel quale vuoi che il plugin inserisca e modifichi i dati generati. - - -Aggiungi nel file env una riga come questa: - - SIGNALK_FILES= \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index a91b8dc..5d7e7fe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,26 +2,19 @@ services: signalk: image: signalk/signalk-server:latest container_name: signalk + env_file: + - .env + environment: + - NODE_ENV=development # <--- Aggiunto per attivare l'hot-reload del nostro plugin 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 + volumes: + - /Users/sese/Local/dev/MEB/signalk/data:/home/node/.signalk:rw + - /Users/sese/Local/dev/MEB/meb-plugin:/home/node/.signalk/node_modules/meb:rw + - /Users/sese/Local/dev/MEB/meb-plugin/data:/home/node/.signalk/node_modules/meb/data:rw \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 04cd4b7..3b5d51d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,33 +8,17 @@ "name": "meb", "version": "1.5.0", "dependencies": { + "@msgpack/msgpack": "^3.1.3", "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", - "mqtt": "^5.14.1", - "msgpack-lite": "^0.1.26", + "express": "^5.2.1", "node-telegram-bot-api": "^0.66.0", - "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": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.9.tgz", - "integrity": "sha512-I3l7FdGRXluAS44/0NguwWlO83J18p0vlr2FYHrJkWdNYhgVoiYo61IXPqaOsL+vNxU1ZqMACzItGK3/KKDsdw==", + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.10.tgz", + "integrity": "sha512-hauBrOdvu08vOsagkZ/Aju5XuiZx6ldsLfByg1htFeldhex+PeMrYauANzFsMJeAA0+dyPLbDoX2OYuvVoLDkQ==", "license": "Apache-2.0", "dependencies": { "aws-sign2": "~0.7.0", @@ -50,7 +34,7 @@ "json-stringify-safe": "~5.0.1", "mime-types": "~2.1.19", "performance-now": "^2.1.0", - "qs": "6.14.0", + "qs": "~6.14.1", "safe-buffer": "^5.1.2", "tough-cookie": "^5.0.0", "tunnel-agent": "^0.6.0", @@ -93,73 +77,68 @@ "node": ">=6" } }, - "node_modules/@mongodb-js/saslprep": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.4.tgz", - "integrity": "sha512-p7X/ytJDIdwUfFL/CLOhKgdfJe1Fa8uw9seJYvdOmnP9JBWGWHW69HkOixXS6Wy9yvGf1MbhcS6lVmrhy4jm2g==", + "node_modules/@cypress/request/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "license": "MIT", - "dependencies": { - "sparse-bitfield": "^3.0.3" + "engines": { + "node": ">= 0.6" } }, - "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==", + "node_modules/@cypress/request/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "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", - "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", - "license": "MIT" - }, - "node_modules/@types/whatwg-url": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz", - "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==", - "license": "MIT", - "dependencies": { - "@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" + "mime-db": "1.52.0" }, "engines": { - "node": ">=6.5" + "node": ">= 0.6" + } + }, + "node_modules/@cypress/request/node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@msgpack/msgpack": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.3.tgz", + "integrity": "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==", + "license": "ISC", + "engines": { + "node": ">= 18" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "license": "MIT", "peer": true, "dependencies": { @@ -291,36 +270,16 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", - "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "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", @@ -346,63 +305,39 @@ "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==", + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "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", - "integrity": "sha512-Kwc6Wh4lQ5OmkqqKhYGKIuELXl+EPYSCObVE6bWsp1T/cGkOCBN0I8wF/T44BiuhHyNi1mmKVPXk60d41xZ7kw==", - "license": "Apache-2.0", + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, "engines": { - "node": ">=20.19.0" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "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" - } - ], + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" + "engines": { + "node": ">= 0.8" } }, - "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", @@ -468,39 +403,44 @@ "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" - ], + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.0.2", - "typedarray": "^0.0.6" + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "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==", + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, "engines": { - "node": ">= 6" + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" } }, "node_modules/core-util-is": { @@ -573,12 +513,20 @@ } }, "node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "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.1" + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, "node_modules/define-data-property": { @@ -624,16 +572,13 @@ "node": ">=0.4.0" } }, - "node_modules/dotenv": { - "version": "17.2.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", - "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", - "license": "BSD-2-Clause", + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" + "node": ">= 0.8" } }, "node_modules/dunder-proto": { @@ -660,13 +605,19 @@ "safer-buffer": "^2.1.0" } }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" } }, "node_modules/end-of-stream": { @@ -679,9 +630,9 @@ } }, "node_modules/es-abstract": { - "version": "1.24.0", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", - "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", "license": "MIT", "dependencies": { "array-buffer-byte-length": "^1.0.2", @@ -820,19 +771,19 @@ "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==", + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "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==", + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", "license": "MIT", "engines": { - "node": ">=6" + "node": ">= 0.6" } }, "node_modules/eventemitter3": { @@ -841,13 +792,47 @@ "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==", + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, "engines": { - "node": ">=0.8.x" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/extend": { @@ -879,19 +864,6 @@ "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", @@ -901,6 +873,27 @@ "node": ">=0.10.0" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/follow-redirects": { "version": "1.15.11", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", @@ -961,11 +954,44 @@ "node": ">= 6" } }, - "node_modules/fs": { - "version": "0.0.1-security", - "resolved": "https://registry.npmjs.org/fs/-/fs-0.0.1-security.tgz", - "integrity": "sha512-3XY9e1pP0CVEUCdj5BmfIZxRBTSDycnbqhIOGec9QYtmVH2fbLpj86CFWkrNOkt/Fvty4KZG5lTglL9j/gJ87w==", - "license": "ISC" + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } }, "node_modules/function-bind": { "version": "1.1.2", @@ -1208,11 +1234,25 @@ "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-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } }, "node_modules/http-signature": { "version": "1.4.0", @@ -1228,46 +1268,28 @@ "node": ">=0.10" } }, - "node_modules/i": { - "version": "0.3.7", - "resolved": "https://registry.npmjs.org/i/-/i-0.3.7.tgz", - "integrity": "sha512-FYz4wlXgkQwIPqhzC5TdNMLSE5+GS1IIDJZY/1ZiEPCT2S3COUVZeT5OW4BmW4r5LHLQuOosSwsvnroG9GR59Q==", + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, "engines": { - "node": ">=0.4" + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "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==", + "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/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", @@ -1282,13 +1304,13 @@ "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==", + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", "license": "MIT", "engines": { - "node": ">= 12" + "node": ">= 0.10" } }, "node_modules/is-array-buffer": { @@ -1477,6 +1499,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -1631,16 +1659,6 @@ "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", @@ -1666,28 +1684,6 @@ "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", "license": "ISC" }, - "node_modules/jsonwebtoken": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", - "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", - "license": "MIT", - "dependencies": { - "jws": "^3.2.2", - "lodash.includes": "^4.3.0", - "lodash.isboolean": "^3.0.3", - "lodash.isinteger": "^4.0.4", - "lodash.isnumber": "^3.0.3", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "lodash.once": "^4.0.0", - "ms": "^2.1.1", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=12", - "npm": ">=6" - } - }, "node_modules/jsprim": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz", @@ -1703,90 +1699,12 @@ "verror": "1.10.0" } }, - "node_modules/jwa": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", - "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jws": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", - "license": "MIT", - "dependencies": { - "jwa": "^1.4.1", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/kareem": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/kareem/-/kareem-3.0.0.tgz", - "integrity": "sha512-RKhaOBSPN8L7y4yAgNhDT2602G5FD6QbOIISbjN9D6mjHPeqeg7K+EB5IGSU5o81/X2Gzm3ICnAvQW3x3OP8HA==", - "license": "Apache-2.0", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, - "node_modules/lodash.includes": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", - "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", - "license": "MIT" - }, - "node_modules/lodash.isboolean": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", - "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", - "license": "MIT" - }, - "node_modules/lodash.isinteger": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", - "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", - "license": "MIT" - }, - "node_modules/lodash.isnumber": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", - "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", - "license": "MIT" - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "license": "MIT" - }, - "node_modules/lodash.isstring": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", - "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", - "license": "MIT" - }, - "node_modules/lodash.once": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "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", @@ -1796,11 +1714,26 @@ "node": ">= 0.4" } }, - "node_modules/memory-pager": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", - "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", - "license": "MIT" + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/mime": { "version": "1.6.0", @@ -1815,276 +1748,28 @@ } }, "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "license": "MIT", "dependencies": { - "mime-db": "1.52.0" + "mime-db": "^1.54.0" }, "engines": { - "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", - "integrity": "sha512-vG/A5cQrvGGvZm2mTnCSz1LUcbOPl83hfB6bxULKQ8oFZauyox/2xbZOoGNl+64m8VBrETkdGCDBdOsCr3F3jg==", - "license": "Apache-2.0", - "dependencies": { - "@mongodb-js/saslprep": "^1.3.0", - "bson": "^7.0.0", - "mongodb-connection-string-url": "^7.0.0" - }, - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@aws-sdk/credential-providers": "^3.806.0", - "@mongodb-js/zstd": "^7.0.0", - "gcp-metadata": "^7.0.1", - "kerberos": "^7.0.0", - "mongodb-client-encryption": ">=7.0.0 <7.1.0", - "snappy": "^7.3.2", - "socks": "^2.8.6" - }, - "peerDependenciesMeta": { - "@aws-sdk/credential-providers": { - "optional": true - }, - "@mongodb-js/zstd": { - "optional": true - }, - "gcp-metadata": { - "optional": true - }, - "kerberos": { - "optional": true - }, - "mongodb-client-encryption": { - "optional": true - }, - "snappy": { - "optional": true - }, - "socks": { - "optional": true - } - } - }, - "node_modules/mongodb-connection-string-url": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.0.tgz", - "integrity": "sha512-irhhjRVLE20hbkRl4zpAYLnDMM+zIZnp0IDB9akAFFUZp/3XdOfwwddc7y6cNvF2WCEtfTYRwYbIfYa2kVY0og==", - "license": "Apache-2.0", - "dependencies": { - "@types/whatwg-url": "^13.0.0", - "whatwg-url": "^14.1.0" - }, - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/mongoose": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-9.1.2.tgz", - "integrity": "sha512-EdxZ/l+wLIPymy/ODxulg13CaUPpt8RenGmuoNAuRhA5Dgk/QERkUm3U0wOEbAi6ULG08bGDo9sy2tKX0KwxIg==", - "license": "MIT", - "dependencies": { - "kareem": "3.0.0", - "mongodb": "~7.0", - "mpath": "0.9.0", - "mquery": "6.0.0", - "ms": "2.1.3", - "sift": "17.1.3" - }, - "engines": { - "node": ">=20.19.0" + "node": ">=18" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/mongoose" - } - }, - "node_modules/mpath": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", - "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", - "license": "MIT", - "engines": { - "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", - "integrity": "sha512-b2KQNsmgtkscfeDgkYMcWGn9vZI9YoXh802VDEwE6qc50zxBFQ0Oo8ROkawbPAsXCY1/Z1yp0MagqsZStPWJjw==", - "license": "MIT", - "engines": { - "node": ">=20.19.0" + "url": "https://opencollective.com/express" } }, "node_modules/ms": { @@ -2093,19 +1778,13 @@ "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==", + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "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" + "engines": { + "node": ">= 0.6" } }, "node_modules/node-telegram-bot-api": { @@ -2128,31 +1807,13 @@ "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==", + "node_modules/node-telegram-bot-api/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "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 - } + "ms": "^2.1.1" } }, "node_modules/oauth-sign": { @@ -2206,6 +1867,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -2232,14 +1905,23 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/path": { - "version": "0.12.7", - "resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz", - "integrity": "sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==", + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", "license": "MIT", - "dependencies": { - "process": "^0.11.1", - "util": "^0.10.3" + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/performance-now": { @@ -2257,21 +1939,25 @@ "node": ">= 0.4" } }, - "node_modules/process": { - "version": "0.11.10", - "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", - "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - } - }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -2310,9 +1996,9 @@ } }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -2330,6 +2016,30 @@ "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", "license": "MIT" }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", @@ -2488,10 +2198,33 @@ "node": ">=0.6.0" } }, + "node_modules/request/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/request/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "peer": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/request/node_modules/qs": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", - "integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==", + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.5.tgz", + "integrity": "sha512-mzR4sElr1bfCaPJe7m8ilJ6ZXdDaGoObcYR0ZHSsktM/Lt21MVHj5De30GQH2eiZ1qGRTO7LCAzQsUeXTNexWQ==", "license": "BSD-3-Clause", "peer": true, "engines": { @@ -2529,11 +2262,21 @@ "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/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } }, "node_modules/safe-array-concat": { "version": "1.1.3", @@ -2625,16 +2368,49 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, - "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" }, "engines": { - "node": ">=10" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/set-function-length": { @@ -2683,6 +2459,12 @@ "node": ">= 0.4" } }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -2755,54 +2537,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/sift": { - "version": "17.1.3", - "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz", - "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", - "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", - "license": "MIT", - "dependencies": { - "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", @@ -2828,6 +2562,15 @@ "node": ">=0.10.0" } }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/stealthy-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz", @@ -2939,6 +2682,15 @@ "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", "license": "MIT" }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tough-cookie": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", @@ -2951,24 +2703,6 @@ "node": ">=16" } }, - "node_modules/tr46": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", - "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "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", @@ -2987,6 +2721,20 @@ "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", "license": "Unlicense" }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -3061,12 +2809,6 @@ "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", @@ -3085,12 +2827,6 @@ "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", @@ -3100,6 +2836,15 @@ "node": ">= 4.0.0" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -3120,15 +2865,6 @@ "requires-port": "^1.0.0" } }, - "node_modules/util": { - "version": "0.10.4", - "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", - "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==", - "license": "MIT", - "dependencies": { - "inherits": "2.0.3" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -3144,6 +2880,15 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/verror": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", @@ -3164,28 +2909,6 @@ "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", "license": "MIT" }, - "node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-url": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", - "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", - "license": "MIT", - "dependencies": { - "tr46": "^5.1.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/which-boxed-primitive": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", @@ -3257,9 +2980,9 @@ } }, "node_modules/which-typed-array": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", - "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", @@ -3277,53 +3000,6 @@ "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", diff --git a/package.json b/package.json index b5294c2..820b115 100644 --- a/package.json +++ b/package.json @@ -2,30 +2,17 @@ "name": "meb", "version": "1.5.0", "description": "Il plugin personalizzato realizzato dal MEB per tener traccia dei log della barca, implementare previsioni meteo e molto altro.", - "main": "plugin/index.cjs", + "main": "plugin/index.js", "keywords": [ "signalk-node-server-plugin", "signalk-category-utility", "signalk-plugin" ], - "schema": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "title": "Abilita plugin", - "default": true - } - } - }, "signalk-plugin-enabled-by-default": true, - "signalk": { - "displayName": "MEB" - }, "dependencies": { + "@msgpack/msgpack": "^3.1.3", "axios": "^1.12.2", - "dotenv": "^17.2.3", - "msgpack-lite": "^0.1.26", + "express": "^5.2.1", "node-telegram-bot-api": "^0.66.0", "ws": "^8.19.0" } diff --git a/plugin/api_models/openmeteo.js b/plugin/api_models/openmeteo.js deleted file mode 100644 index 4eec566..0000000 --- a/plugin/api_models/openmeteo.js +++ /dev/null @@ -1,246 +0,0 @@ -const axios = require('axios'); - -const TIMEOUT = 10000; -const HEADERS = { Accept: "application/json, text/plain;q=0.9,*/*;q=0.8" }; - -// Parametri API -const FORECAST_PARAMS = { - current: [ - 'temperature_2m', - 'wind_speed_10m', - 'wind_direction_10m', - 'wind_gusts_10m', - 'precipitation', - 'rain', - 'relative_humidity_2m', - 'pressure_msl' - ], - hourly: [ - 'temperature_2m', - 'precipitation_probability', - 'precipitation', - 'rain', - 'wind_speed_10m', - 'cloud_cover', - 'wind_direction_10m', - 'relative_humidity_2m', - 'pressure_msl' - ] -}; - -const MARINE_PARAMS = { - current: [ - 'wave_height', - 'wave_direction', - 'wave_period', - 'wave_peak_period', - 'ocean_current_velocity', - 'ocean_current_direction' - ], - hourly: [ - 'wave_height', - 'wave_direction', - 'wave_period', - 'wave_peak_period', - 'ocean_current_velocity', - 'ocean_current_direction' - ] -}; - -// Unità di misura globali (aggiornate da OpenMeteo) -let globalUnits = { - forecast: { - temperature: '°C', - humidity: '%', - pressure: 'hPa', - windSpeed: 'km/h', - windDirection: '°', - windGusts: 'km/h', - rain: 'mm', - precipitation: 'mm' - }, - waves: { - waveHeight: 'm', - wavePeriod: 's', - waveDirection: '°', - wavePeakPeriod: 's' - } -}; - -/** - * Ottiene le unità di misura globali - */ -function getUnits() { - return globalUnits; -} - -/** - * Formatta un valore con la sua unità - */ -function formatWithUnit(value, unitKey, category = 'forecast') { - if (value === null || value === undefined) return 'n/d'; - const unit = globalUnits[category]?.[unitKey] || ''; - return `${value}${unit}`; -} - -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 api = `https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&${params.join('&')}`; - - try { - const response = await axios.get(api, { - headers: HEADERS, - timeout: TIMEOUT, - validateStatus: (status) => status === 200 - }); - - const { data } = response; - - // Aggiorna unità globali da API response - if (data.current_units) { - globalUnits.forecast = { - temperature: data.current_units.temperature_2m || '°C', - humidity: data.current_units.relative_humidity_2m || '%', - pressure: data.current_units.pressure_msl || 'hPa', - windSpeed: data.current_units.wind_speed_10m || 'km/h', - windDirection: data.current_units.wind_direction_10m || '°', - windGusts: data.current_units.wind_gusts_10m || 'km/h', - rain: data.current_units.rain || 'mm', - precipitation: data.current_units.precipitation || 'mm' - }; - } - - return { - 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, - // 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) { - console.error(`[OpenMeteo Forecast] Errore: ${error.message}`); - return null; - } -} - -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 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, { - headers: HEADERS, - timeout: TIMEOUT, - validateStatus: (status) => status === 200 - }); - - const { data } = response; - - // Aggiorna unità globali da API response - if (data.current_units) { - globalUnits.waves = { - waveHeight: data.current_units.wave_height || 'm', - wavePeriod: data.current_units.wave_period || 's', - waveDirection: data.current_units.wave_direction || '°', - wavePeakPeriod: data.current_units.wave_peak_period || 's', - currentVelocity: data.current_units.ocean_current_velocity || 'm/s', - currentDirection: data.current_units.ocean_current_direction || '°' - }; - } - - return { - // 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: 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) { - console.error(`[OpenMeteo Marine] Errore: ${error.message}`); - return null; - } -} - -module.exports = { getSeaConditions, getForecast, getUnits, formatWithUnit }; \ No newline at end of file diff --git a/plugin/config.js b/plugin/config.js deleted file mode 100644 index e98ba93..0000000 --- a/plugin/config.js +++ /dev/null @@ -1,138 +0,0 @@ -const dotenv = require("dotenv"); -const path = require("path"); -const fs = require("fs"); - -dotenv.config({ path: path.resolve(__dirname, "..", ".env"), quiet: true }); - -const SIGNALK_FILES = process.env.SIGNALK_FILES || path.resolve(__dirname); - -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; -} - -const paths = { - base: SIGNALK_FILES, - - 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: 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"), - - 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 -}; - -/** - * 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/config/configManager.js b/plugin/config/configManager.js new file mode 100644 index 0000000..5da7242 --- /dev/null +++ b/plugin/config/configManager.js @@ -0,0 +1,73 @@ +/** + * Gestore centralizzato della configurazione del plugin. + * Questa soluzione permette di leggere i valori di configurazione dinamicamente, + * quindi cambiano in tempo reale quando l'utente li modifica dalle impostazioni del plugin. + */ + +let pluginOptions = {}; + +/** + * Inizializza il ConfigManager con le opzioni del plugin. + * Deve essere chiamato all'avvio del plugin. + * @param {Object} options - Le opzioni passate da Signal K al plugin + */ +function init(options) { + pluginOptions = options || {}; + console.log('[CONFIG] ConfigManager inizializzato'); +} + +/** + * Ottiene il Telegram Bot Token + */ +function getTelegramToken() { + return process.env.TELEGRAM_BOT_TOKEN || ''; +} + +/** + * Ottiene il codice sensore + */ +function getSensorCode() { + return pluginOptions.sensor_code || ''; +} + +/** + * Ottiene il nome sensore + */ +function getSensorName() { + return pluginOptions.sensor_name || ''; +} + +/** + * Ottiene l'intervallo di invio dati (in millisecondi, converte da secondi) + */ +function getSendInterval() { + const seconds = pluginOptions.sensor_interval || process.env.SEND_INTERVAL; + return (typeof seconds === 'number' ? seconds : parseInt(seconds)) * 1000 || 60000; +} + +/** + * Ottiene il ritardo di riconnessione (in millisecondi, converte da secondi) + */ +function getReconnectDelay() { + const seconds = pluginOptions.reconnect_delay || process.env.RECONNECT_DELAY; + return (typeof seconds === 'number' ? seconds : parseInt(seconds)) * 1000 || 5000; +} + +/** + * Ottiene un valore di configurazione generico + * @param {string} key - La chiave della configurazione + * @param {*} defaultValue - Il valore di default + */ +function get(key, defaultValue = null) { + return pluginOptions[key] !== undefined ? pluginOptions[key] : defaultValue; +} + +module.exports = { + init, + getTelegramToken, + getSensorCode, + getSensorName, + getSendInterval, + getReconnectDelay, + get +}; diff --git a/plugin/config/skFlow.js b/plugin/config/skFlow.js new file mode 100644 index 0000000..8da64a9 --- /dev/null +++ b/plugin/config/skFlow.js @@ -0,0 +1,171 @@ +let skApp = null; + +/** + * Inizializza il modulo con l'istanza dell'app Signal K. + * Da chiamare una sola volta nel plugin.start() + * @param {Object} app - l'istanza dell'applicazione Signal K + */ +function init(app) { + skApp = app; +} + +/** + * Pubblica un set di dati nel data browser di Signal K tramite i delta. + * @param {Object} data - Oggetto JSON dove le chiavi sono i percorsi e i valori sono i dati da pubblicare + */ +function publish(data) { + + //TODO: Controlla se serve aggiungere typeof skApp.handleMessage !== 'function' (controlla che esista la funzione handleMessage, ma in teoria esiste sempre) + if (!skApp) { + console.error('[SKFLOW] skApp non inizializzato') + return; + } + + if (!data || typeof data !== 'object') { + console.error('[SKFLOW] Dati non validi') + return; + } + + const values = Object.entries(data).map(([path, value]) => { + return { + path: path, + value: value + }; + }); + + //La funzione non continua se non ci sono dati + //TODO: Controllare se serve davvero, non dovrebbe interrompersi già al check di data? + if (values.length === 0) return; + + // Viene creato un "Delta Update" con l'ID del plugin 'meb.plugin' e l'array di valori. + skApp.handleMessage('meb.plugin', { + updates: [ + { + values: values + } + ] + }); +} + +/** + * Ottieni i dati dal Data-Browser di Signal K + * @param {String} path - Il path Signal K + * @returns {*} Il dato + */ +function get(path) { + if (!skApp) { + return null; + } + + const valObj = skApp.getSelfPath(path); + return valObj ? valObj.value : null; +} + +/** + * Ottieni tutti i dati nel databrowser di Signal K che corrispondono ad un ID o ad una sorgente specifica. + * @param {String} source - Il parametro da confrontare con il sorgente ($source o source) o ID del dato. + * @returns {Object} Un oggetto contenente path e valori trovati. + */ +function getBySource(source) { + if (!skApp) return {}; + + const results = {}; + const self = skApp.signalk?.self || skApp.signalk?.retrieve()?.vessels?.[skApp.selfId] || {}; + + if (!self || Object.keys(self).length === 0) { + console.log('[SKFLOW] Nessun dato trovato nel databrowser'); + return results; + } + + const traverse = (obj, path = '') => { + if (!obj || typeof obj !== 'object') return; + + // Se l'oggetto ha una proprietà 'value', verifichiamo la sorgente + if (Object.prototype.hasOwnProperty.call(obj, 'value')) { + const hasSource = obj.$source === source || obj.source === source || obj.id === source; + if (hasSource) { + results[path] = obj.value; + } + } + + // Esplora i sotto-oggetti escludendo chiavi di sistema che non sono percorsi SK + const skip = ['value', 'timestamp', '$source', 'source', 'meta', 'sentence', 'talker']; + for (const key in obj) { + if (skip.includes(key)) continue; + const subPath = path ? `${path}.${key}` : key; + traverse(obj[key], subPath); + } + }; + + traverse(self); + return results; +} + +/** + * Ottieni tutti i dati nel databrowser di Signal K il cui path inizia con la stringa specificata. + * @param {String} filterPath - La stringa con cui deve iniziare il path (es. "custom.plugin"). + * @returns {Object} Un oggetto contenente path e valori trovati. + */ +function getWithFilter(filterPath) { + if (!skApp) return {}; + + const results = {}; + const self = skApp.signalk?.self || skApp.signalk?.retrieve()?.vessels?.[skApp.selfId] || {}; + + if (!self || Object.keys(self).length === 0) { + return results; + } + + const traverse = (obj, path = '') => { + if (!obj || typeof obj !== 'object') return; + + // Se l'oggetto ha una proprietà 'value', verifichiamo se il path corrisponde al filtro + if (Object.prototype.hasOwnProperty.call(obj, 'value')) { + if (path.startsWith(filterPath)) { + results[path] = obj.value; + } + } + + // Esplora i sotto-oggetti escludendo chiavi di sistema + const skip = ['value', 'timestamp', '$source', 'source', 'meta', 'sentence', 'talker']; + for (const key in obj) { + if (skip.includes(key)) continue; + const subPath = path ? `${path}.${key}` : key; + + // Ottimizzazione: se `subPath` non inizia con `filterPath` E `filterPath` non inizia con `subPath`, + // possiamo evitare di scendere in rami completamente irrilevanti. + // (es. filter = "environment." e subPath = "navigation." -> skippa) + if (!subPath.startsWith(filterPath) && !filterPath.startsWith(subPath)) continue; + + traverse(obj[key], subPath); + } + }; + + traverse(self); + return results; +} + +/** + * Ottieni un oggetto con path e valori per una lista specifica di path. + * @param {Array} data - Un array contenente i path di Signal K da recuperare. + * @returns {Object} Un oggetto JSON con elementi path: value. + */ +function getFrom(data) { + if (!Array.isArray(data)) return {}; + + const results = {}; + for (const path of data) { + results[path] = get(path); + } + + return results; +} + +module.exports = { + init, + publish, + get, + getBySource, + getWithFilter, + getFrom +}; diff --git a/plugin/config/skSettings.js b/plugin/config/skSettings.js new file mode 100644 index 0000000..5a7107a --- /dev/null +++ b/plugin/config/skSettings.js @@ -0,0 +1,53 @@ +module.exports = { + type: 'object', + properties: { // impostazioni + wthr_update_interval: { + type: 'number', + title: 'Aggiornamento meteo (in secondi)', + description: 'Imposta ogni quanto verranno aggiornati i dati meteo attuali', + default: 60, //1m + }, + wthr_longterm_interval: { + type: 'number', + title: 'Aggiornamenti Previsioni (in minuti)', + description: 'Imposta ogni quanti minuti verranno aggiornate le previsioni meteo fino a 7 ore', + default: 60, //1h + }, + telemetry_log_interval: { + type: 'number', + title: 'Registrazione dei dati (in secondi)', + description: 'Imposta ogni quanto la telemetria della barca verrà registrata', + default: 10, //10sec + }, + telegam_token: { + type: 'string', + title: 'Telegram Bot Token', + description: 'Inserisci il token del tuo bot Telegram per ricevere notifiche e interagire con la tua barca da remoto', + default: '', + }, + sensor_code: { + type: 'string', + title: 'Sensore', + description: 'Inserisci un codice identificativo per inviare i dati al server', + default: '', + }, + sensor_name: { + type: 'string', + title: 'Nome Sensore', + description: 'Inserisci un nome per il tuo sensore, che verrà visualizzato nel server', + default: '', + }, + sensor_interval: { + type: 'number', + title: 'Aggiornamento dati (in secondi)', + description: 'Imposta ogni quanto i dati del sensore verranno inviati al server', + default: 60, //1m + }, + reconnect_delay: { + type: 'number', + title: 'Ritardo di riconnessione (in secondi)', + description: 'Imposta il ritardo prima di tentare una nuova connessione al server', + default: 10, //10sec + } + } +} \ No newline at end of file diff --git a/plugin/sensors/sensors.references.json b/plugin/cores/aisstream.js similarity index 100% rename from plugin/sensors/sensors.references.json rename to plugin/cores/aisstream.js diff --git a/plugin/cores/logs.local.js b/plugin/cores/logs.local.js new file mode 100644 index 0000000..26cc599 --- /dev/null +++ b/plugin/cores/logs.local.js @@ -0,0 +1,250 @@ +const fs = require('fs').promises; +const fsSync = require('fs'); +const pth = require('path'); +const os = require('os'); +const skFlow = require('../config/skFlow'); +const realtime = require('./realtime/core'); + +const logsDirectory = pth.join(__dirname, '../../data/logs/'); + +// Intervallo di scrittura fisso: 1 secondo +const WRITE_INTERVAL = 1000; + +// Stato della sessione attiva +let session = null; +let writeInterval = null; + +// Paths da registrare (impostati da init) +let logPaths = []; + +/** + * Risolutori speciali per path che non sono direttamente accessibili via skFlow.get(). + * Per ogni path speciale, definisce una funzione che restituisce il valore. + */ +const SPECIAL_RESOLVERS = { + 'navigation.position.latitude': () => { + const pos = skFlow.get('navigation.position'); + return pos?.latitude ?? null; + }, + 'navigation.position.longitude': () => { + const pos = skFlow.get('navigation.position'); + return pos?.longitude ?? null; + }, + 'system.uptime': () => Math.floor(process.uptime()) +}; + +/** + * Risolve il valore di un path, gestendo i casi speciali. + * @param {String} path - il path da risolvere + * @returns {*} il valore + */ +function resolveValue(path) { + // Controlla se c'e un risolutore speciale + if (SPECIAL_RESOLVERS[path]) { + return SPECIAL_RESOLVERS[path](); + } + // Path standard: leggi dal databrowser Signal K + return skFlow.get(path); +} + +/** + * Inizializza i path da registrare. + * @param {Array} paths - array di path da rules.js LOG_PATHS + */ +function init(paths) { + logPaths = paths; + console.log(`[LOGS] Inizializzati ${paths.length} path`); +} + +/** + * Assicura che la cartella logs esista + */ +async function ensureDir() { + try { + await fs.mkdir(logsDirectory, { recursive: true }); + } catch (e) {} +} + +/** + * Avvia la registrazione: crea un nuovo file CSV e inizia a scrivere ogni secondo. + * @param {String} name - nome del file (opzionale, default: data/ora corrente) + */ +async function startRecording(name) { + // Se c'e gia una sessione attiva, fermala prima + if (session) { + await stopRecording(); + } + + if (!name) { + const now = new Date(); + name = now.toISOString().replace(/[:.]/g, '-'); + } + + if (logPaths.length === 0) { + console.warn('[LOGS] Nessun path configurato, la registrazione non catturera dati'); + } + + await ensureDir(); + + // Header CSV: timestamp + tutti i path + const header = ['timestamp', ...logPaths].join(',') + '\n'; + const filePath = pth.join(logsDirectory, `${name}.csv`); + await fs.writeFile(filePath, header); + + session = { + name: name, + paths: logPaths, + startTime: new Date(), + elements: 0, + filePath: filePath + }; + + // Scrivi ogni secondo + writeInterval = setInterval(() => { + writeLog(); + }, WRITE_INTERVAL); + + console.log(`[LOGS] Registrazione avviata: ${name} (${logPaths.length} colonne, intervallo ${WRITE_INTERVAL}ms)`); + return session; +} + +/** + * Scrive una riga nel CSV e invia i dati al server via WebSocket. + */ +async function writeLog() { + if (!session) return; + + try { + const timestamp = new Date().toISOString(); + + // Risolvi tutti i valori + const fields = {}; + const csvValues = []; + + for (const path of session.paths) { + const val = resolveValue(path); + fields[path] = val; + + // Formatta per CSV + if (val === null || val === undefined) { + csvValues.push(''); + } else if (typeof val === 'object') { + csvValues.push(JSON.stringify(val).replace(/,/g, ';')); + } else { + csvValues.push(val); + } + } + + // Scrivi riga CSV nel file locale + const row = [timestamp, ...csvValues].join(',') + '\n'; + await fs.appendFile(session.filePath, row); + session.elements++; + + // Invia al server via WebSocket (se connesso) + if (realtime.isConnected()) { + realtime.send([Date.now(), 'logs', fields]); + } + + } catch (error) { + console.error('[LOGS] Errore scrittura:', error.message); + } +} + +/** + * Interrompe la registrazione e chiude il file. + */ +async function stopRecording() { + if (writeInterval) { + clearInterval(writeInterval); + writeInterval = null; + } + + if (session) { + console.log(`[LOGS] Registrazione fermata: ${session.name} (${session.elements} righe)`); + session = null; + } +} + +/** + * Ottieni i dati del file come stringa CSV. + * @param {String} name - il nome del file (senza estensione) + * @returns {String|null} + */ +async function getLog(name) { + try { + const filePath = pth.join(logsDirectory, `${name}.csv`); + const content = await fs.readFile(filePath, 'utf-8'); + return content; + } catch (error) { + console.error('[LOGS] Errore lettura log:', error.message); + return null; + } +} + +/** + * Ottieni il percorso del file CSV. + * @param {String} name - il nome del file (senza estensione) + * @returns {String|null} + */ +function getLogFile(name) { + const filePath = pth.join(logsDirectory, `${name}.csv`); + if (fsSync.existsSync(filePath)) { + return filePath; + } + return null; +} + +/** + * Ottieni la lista di tutti i file di log disponibili. + * @returns {Array} + */ +async function listLogs() { + await ensureDir(); + try { + const files = await fs.readdir(logsDirectory); + const csvFiles = files.filter(f => f.endsWith('.csv')); + + const result = []; + for (const file of csvFiles) { + const filePath = pth.join(logsDirectory, file); + const stat = await fs.stat(filePath); + result.push({ + name: file.replace('.csv', ''), + filename: file, + size: (stat.size / (1024 * 1024)).toFixed(2), + created: stat.birthtime, + modified: stat.mtime + }); + } + + return result.sort((a, b) => b.modified - a.modified); + } catch (error) { + console.error('[LOGS] Errore lista log:', error.message); + return []; + } +} + +/** + * Ottieni informazioni sulla sessione di registrazione attiva. + * @returns {Object|null} + */ +function getSession() { + if (!session) return null; + return { + name: session.name, + paths: session.paths, + startTime: session.startTime, + elements: session.elements, + delay: WRITE_INTERVAL + }; +} + +module.exports = { + init, + startRecording, + stopRecording, + getLog, + getLogFile, + getSession, + listLogs +}; diff --git a/plugin/cores/openmeteo.js b/plugin/cores/openmeteo.js new file mode 100644 index 0000000..79c5626 --- /dev/null +++ b/plugin/cores/openmeteo.js @@ -0,0 +1,261 @@ +const skFlow = require('../config/skFlow'); +const realtimeCore = require('./realtime/core'); +const { + FORECAST_CURRENT, + FORECAST_HOURLY, + MARINE_CURRENT, + MARINE_HOURLY +} = require('../rules'); + +const FETCH_TIMEOUT = 10000; +const FORECAST_API = 'https://api.open-meteo.com/v1/forecast'; +const MARINE_API = 'https://marine-api.open-meteo.com/v1/marine'; + +/** + * Mapping da parametri API Open-Meteo a path Signal K. + * Questi path vengono pubblicati sul databrowser e letti dai log. + */ +const FORECAST_PATH_MAP = { + 'temperature_2m': 'meb.forecasts.temperature', + 'wind_speed_10m': 'meb.forecast.wind.speed', + 'wind_direction_10m': 'meb.forecast.wind.direction', + 'wind_gusts_10m': 'meb.forecast.wind.gusts', + 'precipitation': 'meb.forecast.precipitation', + 'rain': 'meb.forecast.rain', + 'relative_humidity_2m': 'meb.forecast.humidity', + 'pressure_msl': 'meb.forecast.pressure', + 'precipitation_probability':'meb.forecast.precipitationProbability', + 'cloud_cover': 'meb.forecast.cloudCover', +}; + +const MARINE_PATH_MAP = { + 'wave_height': 'meb.waves.height', + 'wave_direction': 'meb.waves.direction', + 'wave_period': 'meb.waves.period', + 'wave_peak_period': 'meb.waves.peakPeriod', + 'ocean_current_velocity': 'meb.waves.currentVelocity', + 'ocean_current_direction': 'meb.waves.currentDirection', +}; + +/** + * Fetch JSON con timeout + */ +async function fetchJSON(url) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT); + try { + const res = await fetch(url, { signal: controller.signal }); + clearTimeout(timeoutId); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return await res.json(); + } catch (err) { + clearTimeout(timeoutId); + throw err; + } +} + +/** + * Pubblica i dati current su Signal K usando i path mappati. + */ +function publishCurrentToSignalK(forecastData, marineData) { + const skData = {}; + + if (forecastData?.current) { + for (const [key, value] of Object.entries(forecastData.current)) { + if (key === 'time' || key === 'interval') continue; + const skPath = FORECAST_PATH_MAP[key]; + if (skPath && value != null) skData[skPath] = value; + } + } + + if (marineData?.current) { + for (const [key, value] of Object.entries(marineData.current)) { + if (key === 'time' || key === 'interval') continue; + const skPath = MARINE_PATH_MAP[key]; + if (skPath && value != null) skData[skPath] = value; + } + } + + if (Object.keys(skData).length > 0) { + skData['meb.weather.timestamp'] = Date.now(); + skFlow.publish(skData); + console.log(`[OPENMETEO] Pubblicati ${Object.keys(skData).length} valori su Signal K`); + } +} + +/** + * Invia i dati current weather al server realtime (measurement: weather). + * Usa i path mappati come field keys per InfluxDB. + */ +function sendCurrentToRealtime(forecastData, marineData) { + const fields = {}; + + if (forecastData?.current) { + for (const [key, value] of Object.entries(forecastData.current)) { + if (key === 'time' || key === 'interval') continue; + const skPath = FORECAST_PATH_MAP[key]; + if (skPath && value != null) fields[skPath] = value; + } + } + + if (marineData?.current) { + for (const [key, value] of Object.entries(marineData.current)) { + if (key === 'time' || key === 'interval') continue; + const skPath = MARINE_PATH_MAP[key]; + if (skPath && value != null) fields[skPath] = value; + } + } + + if (Object.keys(fields).length > 0) { + realtimeCore.send([Date.now(), 'weather', fields]); + } +} + +/** + * Invia i dati hourly forecast come batch al server realtime (measurement: weather_forecast). + * Ogni punto usa i path mappati come field keys. + */ +function sendForecastBatchToRealtime(forecastData, marineData) { + const forecastHourly = forecastData?.hourly; + const marineHourly = marineData?.hourly; + + if (!forecastHourly?.time && !marineHourly?.time) return; + + const times = forecastHourly?.time || marineHourly?.time; + const points = []; + + for (let i = 0; i < times.length; i++) { + const ts = new Date(times[i]).getTime(); + const fields = {}; + + if (forecastHourly) { + for (const [key, values] of Object.entries(forecastHourly)) { + if (key === 'time') continue; + const skPath = FORECAST_PATH_MAP[key]; + if (skPath && values?.[i] != null) fields[skPath] = values[i]; + } + } + + if (marineHourly) { + for (const [key, values] of Object.entries(marineHourly)) { + if (key === 'time') continue; + const skPath = MARINE_PATH_MAP[key]; + if (skPath && values?.[i] != null) fields[skPath] = values[i]; + } + } + + if (Object.keys(fields).length > 0) { + points.push([ts, fields]); + } + } + + if (points.length > 0) { + realtimeCore.sendRaw({ ts: 0, _m: 'forecast_batch', points }); + console.log(`[OPENMETEO] Batch forecast inviato: ${points.length} punti orari`); + } +} + +// ========== FUNZIONI PRINCIPALI ========== + +/** + * Fetch dati meteo current (ogni 5 minuti). + */ +async function fetchCurrentWeather(location) { + if (!location?.latitude || !location?.longitude) { + console.warn('[OPENMETEO] Coordinate non valide'); + return; + } + + if (FORECAST_CURRENT.length === 0 && MARINE_CURRENT.length === 0) { + console.warn('[OPENMETEO] Nessun parametro current configurato'); + return; + } + + console.log(`[OPENMETEO] Fetch current — forecast: ${FORECAST_CURRENT.length} params, marine: ${MARINE_CURRENT.length} params`); + + let forecastData = null, marineData = null; + + try { + const promises = []; + + if (FORECAST_CURRENT.length > 0) { + const url = `${FORECAST_API}?latitude=${location.latitude}&longitude=${location.longitude}¤t=${FORECAST_CURRENT.join(',')}`; + promises.push(fetchJSON(url).then(d => { forecastData = d; }).catch(e => { + console.error(`[OPENMETEO] Errore forecast current: ${e.message}`); + })); + } + + if (MARINE_CURRENT.length > 0) { + const url = `${MARINE_API}?latitude=${location.latitude}&longitude=${location.longitude}¤t=${MARINE_CURRENT.join(',')}&models=ecmwf_wam`; + promises.push(fetchJSON(url).then(d => { marineData = d; }).catch(e => { + console.error(`[OPENMETEO] Errore marine current: ${e.message}`); + })); + } + + await Promise.all(promises); + + publishCurrentToSignalK(forecastData, marineData); + sendCurrentToRealtime(forecastData, marineData); + + } catch (err) { + console.error(`[OPENMETEO] Errore fetch current: ${err.message}`); + } +} + +/** + * Fetch previsioni orarie 7 giorni (ogni 1 ora). + */ +async function fetchHourlyForecasts(location) { + if (!location?.latitude || !location?.longitude) { + console.warn('[OPENMETEO] Coordinate non valide per forecast'); + return; + } + + if (FORECAST_HOURLY.length === 0 && MARINE_HOURLY.length === 0) { + console.warn('[OPENMETEO] Nessun parametro hourly configurato'); + return; + } + + console.log(`[OPENMETEO] Fetch hourly 7gg — forecast: ${FORECAST_HOURLY.length} params, marine: ${MARINE_HOURLY.length} params`); + + let forecastData = null, marineData = null; + + try { + const promises = []; + + if (FORECAST_HOURLY.length > 0) { + const url = `${FORECAST_API}?latitude=${location.latitude}&longitude=${location.longitude}&hourly=${FORECAST_HOURLY.join(',')}&forecast_days=7`; + promises.push(fetchJSON(url).then(d => { forecastData = d; }).catch(e => { + console.error(`[OPENMETEO] Errore forecast hourly: ${e.message}`); + })); + } + + if (MARINE_HOURLY.length > 0) { + const url = `${MARINE_API}?latitude=${location.latitude}&longitude=${location.longitude}&hourly=${MARINE_HOURLY.join(',')}&forecast_days=7&models=ecmwf_wam`; + promises.push(fetchJSON(url).then(d => { marineData = d; }).catch(e => { + console.error(`[OPENMETEO] Errore marine hourly: ${e.message}`); + })); + } + + await Promise.all(promises); + + sendForecastBatchToRealtime(forecastData, marineData); + + } catch (err) { + console.error(`[OPENMETEO] Errore fetch hourly: ${err.message}`); + } +} + +/** + * Fetch completo: current + hourly. Chiamato all'avvio. + */ +async function fetchAll(location) { + await fetchCurrentWeather(location); + await fetchHourlyForecasts(location); +} + +module.exports = { + fetchCurrentWeather, + fetchHourlyForecasts, + fetchAll +}; diff --git a/plugin/cores/realtime/auth.js b/plugin/cores/realtime/auth.js new file mode 100644 index 0000000..56e2f5e --- /dev/null +++ b/plugin/cores/realtime/auth.js @@ -0,0 +1,50 @@ +const configManager = require('../../config/configManager.js'); +const REALTIME_URL = process.env.REALTIME_URL; + +/** + * Autentica il sensore per connetterlo al server. + * Il token viene inviato al server, che restituisce un token temporaneo per la connessione websocket. + * @returns {Promise<{socketToken: string, sensorId: string, expiresIn: number}|null>} + */ +async function authenticate() { + const SENSOR_CODE = configManager.getSensorCode(); + const SENSOR_NAME = configManager.getSensorName(); + + if (!REALTIME_URL || !SENSOR_CODE || !SENSOR_NAME) { + console.error('[REALTIME|AUTH] REALTIME_URL, SENSOR_CODE o SENSOR_NAME non configurati'); + return null; + } + + try { + const response = await fetch(`${REALTIME_URL}/connect`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: SENSOR_NAME, + code: SENSOR_CODE + }) + }); + + if (!response.ok) { + const err = await response.json().catch(() => ({})); + console.error(`[REALTIME|AUTH] auth error (${response.status}):`, err.error || 'unknown'); + return null; + } + + const data = await response.json(); + // Server risponde { s: 'ok', t: token } + if (data.s !== 'ok' || !data.t) { + console.error('[REALTIME|AUTH] Risposta inattesa:', data); + return null; + } + + console.log(`[REALTIME|AUTH] Autenticato: ${SENSOR_NAME}, token valido 5s`); + return { socketToken: data.t }; + + } catch (error) { + console.error(`[REALTIME|AUTH] error: ${error.message}`); + return null; + } +} + +module.exports = { authenticate }; diff --git a/plugin/cores/realtime/core.js b/plugin/cores/realtime/core.js new file mode 100644 index 0000000..587edfc --- /dev/null +++ b/plugin/cores/realtime/core.js @@ -0,0 +1,106 @@ +const os = require('os'); +const auth = require('./auth'); +const socket = require('./socket'); +const configManager = require('../../config/configManager.js'); + +let reconnectTimer = null; +let isShuttingDown = false; + +/** + * Inizializza la connessione al server realtime. + * Autentica il sensore e apre la connessione WebSocket. + * In caso di disconnessione, tenta di riconnettersi. + */ +async function init() { + isShuttingDown = false; + await connectToServer(); +} + +/** + * Esegue il flusso di connessione: auth → websocket + */ +async function connectToServer() { + if (isShuttingDown) return; + + console.log('CONNECTING......') + + const result = await auth.authenticate(); + console.log('AUTH RESULT:', result); + + if (!result) { + scheduleReconnect(); + return; + } + + const connected = await socket.connect(result.socketToken, () => { + if (!isShuttingDown) { + scheduleReconnect(); + } + }); + + if (!connected) { + scheduleReconnect(); + } +} + +/** + * Pianifica un tentativo di riconnessione dopo il ritardo configurato. + */ +function scheduleReconnect() { + if (reconnectTimer || isShuttingDown) return; + + const RECONNECT_DELAY = configManager.getReconnectDelay(); + console.log(`[REALTIME] riconnessione in ${RECONNECT_DELAY / 1000}s...`); + reconnectTimer = setTimeout(async () => { + reconnectTimer = null; + await connectToServer(); + }, RECONNECT_DELAY); +} + +/** + * Invia dati al server se la connessione è attiva. + * @param {Array} data - Array nel formato [timestamp, measurement, fields] + */ +function send(data) { + if (socket.isConnected()) { + socket.send(data); + } +} + +/** + * Invia un oggetto raw al server (senza trasformazione [ts, _m, fields]). + * Usato per forecast_batch e altri messaggi con struttura custom. + */ +function sendRaw(obj) { + if (socket.isConnected()) { + socket.sendRaw(obj); + } +} + +/** + * @returns {boolean} true se la connessione WebSocket è attiva + */ +function isConnected() { + return socket.isConnected(); +} + +/** + * Ferma la connessione e i tentativi di riconnessione. + */ +function stop() { + isShuttingDown = true; + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + socket.close(); +} + +module.exports = { + init, + send, + sendIfConnected: send, + sendRaw, + isConnected, + stop +}; diff --git a/plugin/cores/realtime/socket.js b/plugin/cores/realtime/socket.js new file mode 100644 index 0000000..207dbeb --- /dev/null +++ b/plugin/cores/realtime/socket.js @@ -0,0 +1,114 @@ +const WebSocket = require('ws'); +const os = require('os'); +const { encode } = require('@msgpack/msgpack'); + +const SOCKET_URL = process.env.REALTIME_SOCKET_URL; + +let ws = null; +let onDisconnect = null; + +/** + * Apre una connessione WebSocket al server realtime usando il token temporaneo. + * @param {string} socketToken - Token temporaneo ottenuto da auth.authenticate() + * @param {Function} onClose - Callback chiamata quando la connessione si chiude + * @returns {Promise} true se la connessione è riuscita + */ +function connect(socketToken, onClose) { + return new Promise((resolve) => { + if (!SOCKET_URL) { + console.error('[REALTIME|WS] REALTIME_SOCKET_URL non configurato nel .env'); + return resolve(false); + } + + onDisconnect = onClose; + + try { + const wsUrl = `${SOCKET_URL}/?token=${encodeURIComponent(socketToken)}`; + ws = new WebSocket(wsUrl); + } catch (err) { + console.error('[REALTIME|WS] Errore creazione:', err.message); + return resolve(false); + } + + ws.on('open', () => { + console.log('[REALTIME|WS] Connesso'); + // Invia init con system uptime + const initPayload = { + _t: 'init', + uptime: Math.floor(os.uptime()) + }; + ws.send(encode(initPayload)); + console.log('[REALTIME|WS] Init inviato:', initPayload); + resolve(true); + }); + + ws.on('message', () => { + // Il server non invia messaggi ai sensori per ora + }); + + ws.on('ping', () => { + // ws risponde automaticamente con pong + }); + + ws.on('error', (err) => { + console.error(`[REALTIME|WS] Errore: ${err.message}`); + resolve(false); + }); + + ws.on('close', (code) => { + console.log(`[REALTIME|WS] Disconnesso (code: ${code})`); + ws = null; + if (onDisconnect) onDisconnect(); + }); + }); +} + +/** + * Invia dati al server tramite WebSocket, codificati in msgpack. + * @param {Array} data - Array nel formato [timestamp, measurement, fields] + */ +function send(data) { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + + try { + const [timestamp, measurement, fields] = data; + const packet = { ts: timestamp, _m: measurement, ...fields }; + ws.send(encode(packet)); + } catch (err) { + console.error('[REALTIME|WS] Errore invio:', err.message); + } +} + +/** + * Invia un oggetto raw al server, codificato in msgpack. + * A differenza di send(), non fa transform [ts, measurement, fields]. + * @param {Object} obj - Oggetto da inviare direttamente + */ +function sendRaw(obj) { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + try { + ws.send(encode(obj)); + } catch (err) { + console.error('[REALTIME|WS] Errore invio raw:', err.message); + } +} + +/** + * @returns {boolean} true se la connessione è attiva + */ +function isConnected() { + return ws !== null && ws.readyState === WebSocket.OPEN; +} + +/** + * Chiude la connessione WebSocket. + */ +function close() { + onDisconnect = null; + if (ws) { + ws.close(); + ws = null; + } +} + +module.exports = { connect, send, sendRaw, isConnected, close }; diff --git a/plugin/cores/weatherkit.js b/plugin/cores/weatherkit.js new file mode 100644 index 0000000..e69de29 diff --git a/plugin/index.cjs b/plugin/index.cjs deleted file mode 100644 index 4833134..0000000 --- a/plugin/index.cjs +++ /dev/null @@ -1,276 +0,0 @@ -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"); - -const CONFIG = { - forecast_current_frequency: 300000, // 5 min default in ms - forecast_hourly_frequency: 3600000, // 1 hour default -}; - -const state = { - openMeteoTimer: null, - app: null, - startTime: null, -}; - -const clearIntervalSafe = (timerId) => { - if (timerId) clearInterval(timerId); - return null; -}; - - -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 { - // 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; - } - - state.startTime = Date.now(); - - // Inizializza realtime (async: carica sensor refs dal server) - await realtime.init(app, settings.sensor_code); - - // Telegram Bot - if (config.telegramBotToken) { - try { - await linkBotToApp(app); - console.log('[MEB] Telegram bot started'); - } catch (error) { - console.error('[MEB] Error starting Telegram bot:', error); - } - } else { - console.warn('[MEB] Telegram bot disabled: TELEGRAM_BOT_TOKEN not set'); - } - - // 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(`[MEB] Received ${reason}. Stopping plugin...`); - await plugin.stop(); - process.exit(0); - } catch (err) { - console.error('[MEB] Error during shutdown:', err); - process.exit(1); - } - }; - - 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('[MEB] uncaughtException:', err); - shutdown('uncaughtException'); - }); - process.on('unhandledRejection', (reason) => { - console.error('[MEB] unhandledRejection:', reason); - shutdown('unhandledRejection'); - }); - } - - } catch (error) { - console.error('[MEB] Error during plugin startup:', error); - throw error; - } - }, - - stop: async () => { - try { - state.openMeteoTimer = clearIntervalSafe(state.openMeteoTimer); - realtime.stop(); - console.log('[MEB] Plugin stopped'); - } catch (error) { - console.error('[MEB] Error during plugin stop:', error); - } - }, - - schema: () => ({}), - - // 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; - }, - - // 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; -}; diff --git a/plugin/index.js b/plugin/index.js new file mode 100644 index 0000000..f43dd1e --- /dev/null +++ b/plugin/index.js @@ -0,0 +1,118 @@ +const settingsSchema = require('./config/skSettings.js') +const configManager = require('./config/configManager.js') +const routes = require('./routes/main.js') +const openmeteo = require('./cores/openmeteo.js') +const skFlow = require('./config/skFlow.js') +const telegram = require('./telegram/core.js') +const recorder = require('./cores/logs.local.js') +const realtime = require('./cores/realtime/core.js') +const { LOG_PATHS } = require('./rules') + +module.exports = function(app) { + + const plugin = {}; + + plugin.id = 'meb.plugin'; + plugin.name = 'MEB Plugin'; + plugin.description = 'MEB custom plugin'; + plugin.version = '1.5.0'; + + plugin.start = async function(options) { + + // Inizializza il gestore della configurazione con le opzioni del plugin + configManager.init(options); + + // Setup routing + if (process.env.NODE_ENV === 'development') { + app.debug('Running in DEVELOPMENT mode: routes will be reloaded on every request'); + app.use('/meb', (req, res, next) => { + const path = require('path'); + const routesPath = path.resolve(__dirname, 'routes'); + Object.keys(require.cache).forEach(key => { + if (key.startsWith(routesPath)) { + delete require.cache[key]; + } + }); + require('./routes/main.js')(req, res, next); + }); + } else { + app.debug('Running in PRODUCTION mode: routes are cached'); + app.use('/meb', routes); + } + + // Inizializza il modulo per la pubblicazione dei dati Signal K + skFlow.init(app); + + // Inizializza il bot Telegram + telegram.init(); + + // Avvia la connessione realtime al server + realtime.init(); + + // Inizializza e avvia subito la registrazione log (1 riga/secondo) + recorder.init(LOG_PATHS); + try { + await recorder.startRecording(); + } catch (err) { + console.warn('[INDEX] Errore avvio recording, proseguo:', err.message); + } + + // ===== Weather & Forecast ===== + + const fetchWeather = async () => { + try { + const position = skFlow.get('navigation.position'); + if (position) { + await openmeteo.fetchCurrentWeather(position); + } + } catch (err) { + console.error('[INDEX] Errore fetchCurrentWeather:', err.message); + } + }; + + const fetchForecasts = async () => { + try { + const position = skFlow.get('navigation.position'); + if (position) { + await openmeteo.fetchHourlyForecasts(position); + } + } catch (err) { + console.error('[INDEX] Errore fetchHourlyForecasts:', err.message); + } + }; + + // Intervalli: current ogni 5 min, hourly ogni 1 ora + const startWeatherIntervals = () => { + setInterval(fetchWeather, 5 * 60 * 1000); + setInterval(fetchForecasts, 60 * 60 * 1000); + }; + + // Aspetta la posizione GPS, poi avvia il fetch meteo + const position = skFlow.get('navigation.position'); + if (position) { + await openmeteo.fetchAll(position); + startWeatherIntervals(); + } else { + const waitForPosition = setInterval(async () => { + const pos = skFlow.get('navigation.position'); + if (pos) { + clearInterval(waitForPosition); + await openmeteo.fetchAll(pos); + startWeatherIntervals(); + } + }, 2000); + } + + } + + plugin.stop = function() { + recorder.stopRecording(); + realtime.stop(); + app.debug('Plugin stopped'); + } + + plugin.schema = settingsSchema + + return plugin; + +} diff --git a/plugin/public/css/data_console.css b/plugin/public/css/data_console.css deleted file mode 100644 index 22f5ff5..0000000 --- a/plugin/public/css/data_console.css +++ /dev/null @@ -1,59 +0,0 @@ -.data-console-container { - font-family: sans-serif; - background-color: #f4f4f4; - padding: 15px; - border-radius: 25px; - display: flex; - align-items: center; - backdrop-filter: blur(10px); -} - -#error-popup { - position: fixed; - z-index: 9999; - inset: 0; - background: rgba(0,0,0,0.4); - display: none; - justify-content: center; - align-items: center; -} - -#error-popup-content { - background: #fff; - padding: 20px 25px; - border-radius: 8px; - max-width: 400px; - width: 90%; - box-shadow: 0 2px 10px rgba(0,0,0,0.2); - position: relative; - font-family: sans-serif; -} - -#error-popup-close { - position: absolute; - right: 10px; - top: 5px; - cursor: pointer; - font-size: 20px; -} - -table { - width: 100%; - border-collapse: collapse; - margin-top: 30px; - border: 0px solid #f9f9f9; - font-family: sans-serif; -} - -table thead { - background-color: #e5effa; - font-size: 13px; - color: rgb(0, 0, 0); - -} - -table.th, table td { - padding: 10px 10px; - text-align: left; - border-bottom: 1px solid #ddd; -} \ No newline at end of file diff --git a/plugin/public/css/helm_suggestions.css b/plugin/public/css/helm_suggestions.css deleted file mode 100644 index 92a68a9..0000000 --- a/plugin/public/css/helm_suggestions.css +++ /dev/null @@ -1,177 +0,0 @@ -body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - display: flex; - justify-content: center; - align-items: center; - flex-direction: column; - min-height: 100vh; - background-color: #f4f4f4; - color: #333; - margin: 0; - padding: 20px; -} - -.widget-container { - width: 100%; - max-width: 600px; - padding: 30px; - background: #ffffff; - border-radius: 10px; - box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); - text-align: center; -} - -.visualization-container { - position: relative; - width: 100%; - height: 300px; - margin: 20px auto; - display: flex; - justify-content: center; - align-items: center; - background: #fafafa; - border-radius: 8px; - border: 1px solid #eee; -} - -.arrow-svg { - width: 300px; - height: 300px; - overflow: visible; -} - -.arrow-head { - fill: none; - stroke: #007aff; - stroke-width: 12; - stroke-linecap: round; - stroke-linejoin: round; - filter: drop-shadow(0 2px 4px rgba(0, 122, 255, 0.3)); - transition: transform 0.1s linear; -} - -.arrow-stem { - fill: none; - stroke: #007aff; - stroke-width: 12; - stroke-linecap: round; - transition: d 0.1s linear; - filter: drop-shadow(0 2px 4px rgba(0, 122, 255, 0.3)); - transform: rotate(-40deg); -} - -.controls-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 15px; - margin: 30px 0 20px; - text-align: left; -} - -.control-group { - background: #f8f9fa; - padding: 15px; - border-radius: 8px; -} - -.control-group label { - display: block; - font-size: 12px; - font-weight: 600; - color: #666; - margin-bottom: 8px; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.control-group input[type="number"] { - width: 100%; - padding: 10px; - border: 2px solid #e0e0e0; - border-radius: 6px; - font-size: 16px; - font-weight: 600; - color: #333; - transition: border-color 0.3s ease; - box-sizing: border-box; -} - -.control-group input[type="number"]:focus { - outline: none; - border-color: #007aff; -} - -.slider-container { - width: 100%; - margin-top: 30px; - position: relative; - padding: 0 10px; - box-sizing: border-box; -} - -input[type=range] { - width: 100%; - cursor: pointer; - height: 8px; - border-radius: 5px; - outline: none; - -webkit-appearance: none; - appearance: none; - background: #e0e0e0; - transition: background 0.3s ease; -} - -input[type=range]::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 24px; - height: 24px; - border-radius: 50%; - background: #007aff; - cursor: pointer; - box-shadow: 0 2px 8px rgba(0, 122, 255, 0.4); - transition: transform 0.2s ease; - margin-top: -8px; -} - -input[type=range]::-webkit-slider-thumb:hover { - transform: scale(1.1); -} - -.stats { - display: flex; - justify-content: space-around; - margin-top: 30px; - padding-top: 20px; - border-top: 1px solid #e0e0e0; -} - -.stat-item { - text-align: center; -} - -.stat-value { - font-size: 24px; - font-weight: bold; - color: #007aff; - transition: all 0.3s ease; -} - -.stat-label { - font-size: 12px; - color: #888; - margin-top: 5px; - text-transform: uppercase; -} - -.percentage-display { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - font-size: 40px; - font-weight: 800; - color: rgba(0, 0, 0, 0.1); - pointer-events: none; - z-index: 0; -} \ No newline at end of file diff --git a/plugin/public/steering_support/helm_steering_destra.html b/plugin/public/steering_support/helm_steering_destra.html deleted file mode 100644 index b3d3f74..0000000 --- a/plugin/public/steering_support/helm_steering_destra.html +++ /dev/null @@ -1,336 +0,0 @@ - - - - - - - Helm Steering UI - - - - - - - -
-

Helm Steering Control - Destra

- -
-
0%
- - - - - - - - - - - - - - - -
- -
-
- - -
- -
- - -
-
- -
- -
- -
-
-
0
-
Valore Attuale
-
-
-
0%
-
Progresso
-
-
- - - -
- - - - - \ No newline at end of file diff --git a/plugin/public/steering_support/helm_steering_sinistra.html b/plugin/public/steering_support/helm_steering_sinistra.html deleted file mode 100644 index d16707f..0000000 --- a/plugin/public/steering_support/helm_steering_sinistra.html +++ /dev/null @@ -1,158 +0,0 @@ - - - - - - - Helm Steering UI - - - - - - - -
-

Helm Steering Control - Sinistra

- -
-
0%
- - - - - - - - - - - - - - - -
- -
-
- - -
- -
- - -
-
- -
- -
- -
-
-
0
-
Valore Attuale
-
-
-
0%
-
Progresso
-
-
- - - -
- - - - - \ No newline at end of file diff --git a/plugin/public/steering_support/steering_helm_tip_builder.html b/plugin/public/steering_support/steering_helm_tip_builder.html deleted file mode 100644 index 3ff6813..0000000 --- a/plugin/public/steering_support/steering_helm_tip_builder.html +++ /dev/null @@ -1,589 +0,0 @@ - - - - - - - Steering Suggestions Widget - - - - - - -
-

Progress Circle

- -
- - - - - - - - - - - -
0%
- -
- - - -
-
- - -
Punto di partenza
-
- -
- - -
Arco massimo
-
-
- - -
- Direzione -
- - Oraria -
- -
- -
- -
- 0% - 50% - 100% -
-
- -
-
-
-
Progresso
-
-
-
-
Arco attuale
-
-
-
100%
-
Rimanente
-
-
- -
-

Controllo Trasformazioni Freccia

-
-
- - -
70°
-
- -
- - -
0
-
- -
- - -
0
-
- -
- - -
1x
-
- -
- - -
80
-
- -
- - -
-150
-
- -
- - -
-
-
- -
- - - - - - \ No newline at end of file diff --git a/plugin/realtime/core.js b/plugin/realtime/core.js deleted file mode 100644 index a89d363..0000000 --- a/plugin/realtime/core.js +++ /dev/null @@ -1,450 +0,0 @@ -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/collection/cloud.js b/plugin/routes/collection/cloud.js new file mode 100644 index 0000000..ebff007 --- /dev/null +++ b/plugin/routes/collection/cloud.js @@ -0,0 +1,27 @@ +const router = require('express').Router(); +const { + FORECAST_CURRENT, + FORECAST_HOURLY, + MARINE_CURRENT, + MARINE_HOURLY, + LOG_PATHS +} = require('../../rules'); + +const api_url = process.env.API_URL || 'http://api-services:3003'; + +router.get('/status', (req, res) => { + res.json({ cloud: 'active', api: api_url, version: '2.0' }); +}); + +// Ritorna la configurazione statica corrente +router.get('/config', (req, res) => { + res.json({ + forecast_current: FORECAST_CURRENT, + forecast_hourly: FORECAST_HOURLY, + marine_current: MARINE_CURRENT, + marine_hourly: MARINE_HOURLY, + log_paths: LOG_PATHS + }); +}); + +module.exports = router; diff --git a/plugin/routes/collection/dashboard.js b/plugin/routes/collection/dashboard.js new file mode 100644 index 0000000..5d8fcd7 --- /dev/null +++ b/plugin/routes/collection/dashboard.js @@ -0,0 +1,16 @@ +const router = require('express').Router(); +const express = require('express'); +const path = require('path') + + +const kioskPath = path.join(__dirname, '../../tools/kiosk'); + +router.use('/', express.static(kioskPath)); +router.get('/', (req, res) => { + res.sendFile(path.join(kioskPath, 'dashboard.html')); +}); + + +router.get('/api/', (req, res) => {}); + +module.exports = router; \ No newline at end of file diff --git a/plugin/routes/collection/data.js b/plugin/routes/collection/data.js new file mode 100644 index 0000000..503bfd9 --- /dev/null +++ b/plugin/routes/collection/data.js @@ -0,0 +1,38 @@ +const router = require('express').Router(); +const db = require('../../config/skFlow') + +const config = require('../../config/configManager.js') + +router.get('/', (req, res) => { + const { path } = req.query; + const data = db.get(path); + res.json(data); +}); + +router.get('/', (req, res) => { + const { source } = req.query; + const data = db.getBySource(source); + res.json(data); +}); + +router.get('/info', (req, res) => { + const info = { + + telegram: config.getTelegramToken(), + + sensor: { + name: config.getSensorName(), + code: config.getSensorCode() + }, + + other: { + api_url: process.env.API_URL, + realtime_url: process.env.REALTIME_URL, + realtime_socket_url: process.env.REALTIME_SOCKET_URL, + reconnect_delay: config.getReconnectDelay() + } + } + res.json(info); +}); + +module.exports = router; \ No newline at end of file diff --git a/plugin/routes/collection/kiosk.js b/plugin/routes/collection/kiosk.js new file mode 100644 index 0000000..d7c95a3 --- /dev/null +++ b/plugin/routes/collection/kiosk.js @@ -0,0 +1,34 @@ +const router = require('express').Router(); +const express = require('express'); +const path = require('path'); +const fs = require('fs'); +const configManager = require('../../config/configManager.js'); + +const kioskPath = path.join(__dirname, '../../tools/kiosk'); +const htmlFile = path.join(kioskPath, 'kiosk.html'); + +router.use('/', express.static(kioskPath)); + +router.get('/', (req, res) => { + const apiUrl = process.env.API_URL || 'https://api.mebboat.it'; + const realtimeUrl = process.env.REALTIME_URL || 'https://realtime.mebboat.it'; + const realtimeWsUrl = process.env.REALTIME_SOCKET_URL || 'wss://realtime.mebboat.it'; + const sensorCode = configManager.getSensorCode(); + const sensorName = configManager.getSensorName(); + + const esc = (s) => String(s || '').replace(/"/g, '"'); + const metas = ` + + + + + +`; + let html; + try { html = fs.readFileSync(htmlFile, 'utf8'); } + catch (e) { return res.status(500).send('kiosk.html not found'); } + html = html.replace('', metas + ''); + res.set('Content-Type', 'text/html').send(html); +}); + +module.exports = router; diff --git a/plugin/routes/collection/map.js b/plugin/routes/collection/map.js new file mode 100644 index 0000000..513f78d --- /dev/null +++ b/plugin/routes/collection/map.js @@ -0,0 +1,9 @@ +const router = require('express').Router(); +const path = require('path') +//Endpoints per controllare lo stato di un servizio di mappe da implementare poi.. + +router.get('/', (req, res) => { + res.sendFile(path.join(__dirname, '../tools/map/map.html')); +}); + +module.exports = router; \ No newline at end of file diff --git a/plugin/routes/collection/rec.js b/plugin/routes/collection/rec.js new file mode 100644 index 0000000..7a33f85 --- /dev/null +++ b/plugin/routes/collection/rec.js @@ -0,0 +1,55 @@ +const core = require('../../cores/logs.local') +const router = require('express').Router(); + + +router.post('/start', async (req, res) => { + const { name } = req.body; + const session = await core.startRecording(name); + res.status(200).send({ + status: 'Started', + session: session + }); +}); + +router.get('/', (req, res) => { + const session = core.getSession(); + if (session) { + res.status(200).send(session); + } else { + res.status(404).send({ session: 'Nessuna sessione attiva' }); + } +}); + +router.get('/list', async (req, res) => { + const logs = await core.listLogs(); + res.status(200).send(logs); +}); + +router.get('/:log', async (req, res) => { + const { log } = req.params + const data = await core.getLog(log); + if (data) { + res.status(200).send(data); + } else { + res.status(404).send({ error: 'Log non trovato' }); + } +}); + +router.get('/download/:name', (req, res) => { + const name = req.params.name; + const filePath = core.getLogFile(name); + if (filePath) { + res.download(filePath); + } else { + res.status(404).send({ error: 'File non trovato' }); + } +}); + +router.post('/stop', async (req, res) => { + await core.stopRecording(); + res.status(200).send({ + status: 'Stopped' + }); +}); + +module.exports = router; diff --git a/plugin/routes/dataset.js b/plugin/routes/dataset.js deleted file mode 100644 index 8e5f213..0000000 --- a/plugin/routes/dataset.js +++ /dev/null @@ -1,56 +0,0 @@ -/** - * 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 deleted file mode 100644 index 5885ac3..0000000 --- a/plugin/routes/forecasts.js +++ /dev/null @@ -1,63 +0,0 @@ -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 deleted file mode 100644 index edd7d7b..0000000 --- a/plugin/routes/helm.js +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index aad601e..0000000 --- a/plugin/routes/index.js +++ /dev/null @@ -1,34 +0,0 @@ -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/main.js b/plugin/routes/main.js new file mode 100644 index 0000000..cb6ed19 --- /dev/null +++ b/plugin/routes/main.js @@ -0,0 +1,18 @@ +// Il file generale che raggruppa le api +const router = require('express').Router(); +const cloudRoutes = require('./collection/cloud') +const mapRoutes = require('./collection/map') +const dataRoutes = require('./collection/data') +const recRoutes = require('./collection/rec') +const dashboard = require('./collection/dashboard') +const kiosk = require('./collection/kiosk') + +router.use('/cloud', cloudRoutes) +router.use('/map', mapRoutes) +router.use('/data', dataRoutes) +router.use('/rec', recRoutes) + +router.use('/dashboard', dashboard) +router.use('/kiosk', kiosk) + +module.exports = router \ No newline at end of file diff --git a/plugin/routes/map.js b/plugin/routes/map.js deleted file mode 100644 index 2b26160..0000000 --- a/plugin/routes/map.js +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index 2618896..0000000 --- a/plugin/routes/telegram.js +++ /dev/null @@ -1,13 +0,0 @@ -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/rules.js b/plugin/rules.js new file mode 100644 index 0000000..3a24da0 --- /dev/null +++ b/plugin/rules.js @@ -0,0 +1,72 @@ +const FORECAST_CURRENT = [ + 'temperature_2m', + 'wind_speed_10m', + 'wind_direction_10m', + 'wind_gusts_10m', + 'precipitation', + 'rain', + 'relative_humidity_2m', + 'pressure_msl' +]; + +const FORECAST_HOURLY = [ + 'temperature_2m', + 'precipitation_probability', + 'precipitation', + 'rain', + 'wind_speed_10m', + 'cloud_cover', + 'wind_direction_10m', + 'relative_humidity_2m', + 'pressure_msl' +]; + +const MARINE_CURRENT = [ + 'wave_height', + 'wave_direction', + 'wave_period', + 'wave_peak_period', + 'ocean_current_velocity', + 'ocean_current_direction' +]; + +const MARINE_HOURLY = [ + 'wave_height', + 'wave_direction', + 'wave_period', + 'wave_peak_period', + 'ocean_current_velocity', + 'ocean_current_direction' +]; + +const LOG_PATHS = [ + 'meb.forecasts.temperature', + 'meb.forecast.wind.direction', + 'meb.forecast.wind.speed', + 'meb.waves.direction', + 'meb.waves.height', + 'meb.waves.period', + 'navigation.position.latitude', + 'navigation.position.longitude', + 'navigation.headingTrue', + 'navigation.speedOverGround', + 'navigation.courseOverGroundTrue', + 'electrical.batteries.service.Voltage', + 'electrical.batteries.service.current', + 'electrical.batteries.service.stateOfCharge', + 'electrical.batteries.traction.Voltage', + 'electrical.batteries.traction.current', + 'electrical.batteries.traction.stateOfCharge', + 'electrical.batteries.traction.temperature', + 'electrical.batteries.traction.power', + 'propulsion.0.revolutions', + 'system.uptime' +]; + +module.exports = { + FORECAST_CURRENT, + FORECAST_HOURLY, + MARINE_CURRENT, + MARINE_HOURLY, + LOG_PATHS +}; diff --git a/plugin/telegram/callbacks/backupback.js b/plugin/telegram/callbacks/backupback.js new file mode 100644 index 0000000..843b955 --- /dev/null +++ b/plugin/telegram/callbacks/backupback.js @@ -0,0 +1,29 @@ +const { listDataFiles, buildPage } = require('../commands/backuplogs'); + +module.exports = { + prefix: 'bkback:', + handler: async (bot, query) => { + const chatId = query.message.chat.id; + const botMessageId = query.message.message_id; + + const parts = query.data.split(':'); + const page = parseInt(parts[1]); + const userMessageId = parts[2]; + + const files = await listDataFiles(); + const keyboard = buildPage(files, page, userMessageId); + + const text = `*Backup & Logs*\n\n${files.length} file disponibili nella cartella data.\n_Seleziona un file per info e download._`; + + try { + await bot.editMessageText(text, { + chat_id: chatId, + message_id: botMessageId, + parse_mode: 'Markdown', + reply_markup: { inline_keyboard: keyboard } + }); + } catch (e) {} + + bot.answerCallbackQuery(query.id); + } +}; diff --git a/plugin/telegram/callbacks/backupdownload.js b/plugin/telegram/callbacks/backupdownload.js new file mode 100644 index 0000000..9287c07 --- /dev/null +++ b/plugin/telegram/callbacks/backupdownload.js @@ -0,0 +1,33 @@ +const fs = require('fs'); +const { listDataFiles } = require('../commands/backuplogs'); + +module.exports = { + prefix: 'bkdl:', + handler: async (bot, query) => { + const chatId = query.message.chat.id; + + const parts = query.data.split(':'); + const fileIdx = parseInt(parts[1]); + const userMessageId = parts[2]; + + const files = await listDataFiles(); + const file = files[fileIdx]; + + if (!file || !fs.existsSync(file.path)) { + bot.answerCallbackQuery(query.id, { text: 'File non trovato', show_alert: true }); + return; + } + + try { + await bot.sendDocument(chatId, file.path, { + caption: `\`${file.name}\``, + parse_mode: 'Markdown' + }); + } catch (e) { + bot.answerCallbackQuery(query.id, { text: 'Errore invio file', show_alert: true }); + return; + } + + bot.answerCallbackQuery(query.id, { text: 'File inviato' }); + } +}; diff --git a/plugin/telegram/callbacks/backupfile.js b/plugin/telegram/callbacks/backupfile.js new file mode 100644 index 0000000..654ac88 --- /dev/null +++ b/plugin/telegram/callbacks/backupfile.js @@ -0,0 +1,73 @@ +const fs = require('fs'); +const fsPromises = require('fs').promises; +const readline = require('readline'); +const { listDataFiles, formatSize, buildPage } = require('../commands/backuplogs'); + +/** + * Conta le righe di un file in modo efficiente (stream) + */ +function countLines(filePath) { + return new Promise((resolve) => { + let count = 0; + const stream = fs.createReadStream(filePath); + const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); + rl.on('line', () => count++); + rl.on('close', () => resolve(count)); + rl.on('error', () => resolve(-1)); + }); +} + +module.exports = { + prefix: 'bkfile:', + handler: async (bot, query) => { + const chatId = query.message.chat.id; + const botMessageId = query.message.message_id; + + const parts = query.data.split(':'); + const fileIdx = parseInt(parts[1]); + const userMessageId = parts[2]; + + const files = await listDataFiles(); + const file = files[fileIdx]; + + if (!file || !fs.existsSync(file.path)) { + bot.answerCallbackQuery(query.id, { text: 'File non trovato', show_alert: true }); + return; + } + + // Conta righe + let lineCount = '—'; + try { + const ext = file.name.split('.').pop().toLowerCase(); + if (['csv', 'txt', 'log', 'json'].includes(ext)) { + lineCount = await countLines(file.path); + } + } catch (e) {} + + const modified = new Date(file.modified).toLocaleDateString('it-IT', { + day: '2-digit', month: 'long', year: 'numeric', + hour: '2-digit', minute: '2-digit' + }); + + const text = `*File:* \`${file.name}\`\n\n` + + `*Dimensione:* ${formatSize(file.size)}\n` + + `*Ultima modifica:* ${modified}\n` + + `*Righe:* ${lineCount}\n`; + + const keyboard = [ + [{ text: 'Scarica file', callback_data: `bkdl:${fileIdx}:${userMessageId}` }], + [{ text: '<- Torna alla lista', callback_data: `bkback:0:${userMessageId}` }] + ]; + + try { + await bot.editMessageText(text, { + chat_id: chatId, + message_id: botMessageId, + parse_mode: 'Markdown', + reply_markup: { inline_keyboard: keyboard } + }); + } catch (e) {} + + bot.answerCallbackQuery(query.id); + } +}; diff --git a/plugin/telegram/callbacks/backupnoop.js b/plugin/telegram/callbacks/backupnoop.js new file mode 100644 index 0000000..89a3914 --- /dev/null +++ b/plugin/telegram/callbacks/backupnoop.js @@ -0,0 +1,6 @@ +module.exports = { + prefix: 'bknoop:', + handler: async (bot, query) => { + bot.answerCallbackQuery(query.id); + } +}; diff --git a/plugin/telegram/callbacks/backuppage.js b/plugin/telegram/callbacks/backuppage.js new file mode 100644 index 0000000..9242064 --- /dev/null +++ b/plugin/telegram/callbacks/backuppage.js @@ -0,0 +1,29 @@ +const { listDataFiles, buildPage } = require('../commands/backuplogs'); + +module.exports = { + prefix: 'bkpage:', + handler: async (bot, query) => { + const chatId = query.message.chat.id; + const botMessageId = query.message.message_id; + + const parts = query.data.split(':'); + const page = parseInt(parts[1]); + const userMessageId = parts[2]; + + const files = await listDataFiles(); + const keyboard = buildPage(files, page, userMessageId); + + const text = `*Backup & Logs*\n\n${files.length} file disponibili nella cartella data.\n_Seleziona un file per info e download._`; + + try { + await bot.editMessageText(text, { + chat_id: chatId, + message_id: botMessageId, + parse_mode: 'Markdown', + reply_markup: { inline_keyboard: keyboard } + }); + } catch (e) {} + + bot.answerCallbackQuery(query.id); + } +}; diff --git a/plugin/telegram/callbacks/close.js b/plugin/telegram/callbacks/close.js new file mode 100644 index 0000000..694ebdc --- /dev/null +++ b/plugin/telegram/callbacks/close.js @@ -0,0 +1,25 @@ +module.exports = { + prefix: 'close', + handler: async (bot, query) => { + const chatId = query.message.chat.id; + const botMessageId = query.message.message_id; + + // L'ID del messaggio dell'utente è passato nel callback_data (close:) + const userMessageId = query.data.split(':')[1]; + + try { + // Elimina il messaggio del bot + await bot.deleteMessage(chatId, botMessageId); + + // Elimina il messaggio dell'utente (il comando /data) + if (userMessageId) { + await bot.deleteMessage(chatId, parseInt(userMessageId)); + } + } catch (error) { + console.error('[TELEGRAM] Errore eliminazione messaggi:', error.message); + } + + // Rispondi alla callback per togliere il "loading" dal bottone + bot.answerCallbackQuery(query.id); + } +}; diff --git a/plugin/telegram/callbacks/dashboard.js b/plugin/telegram/callbacks/dashboard.js deleted file mode 100644 index ff2fcd0..0000000 --- a/plugin/telegram/callbacks/dashboard.js +++ /dev/null @@ -1,152 +0,0 @@ -// 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 deleted file mode 100644 index 7033629..0000000 --- a/plugin/telegram/callbacks/data.js +++ /dev/null @@ -1,26 +0,0 @@ -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 index 6ee0e31..04a91fe 100644 --- a/plugin/telegram/callbacks/live.js +++ b/plugin/telegram/callbacks/live.js @@ -1,141 +1,104 @@ -// Mappa globale per salvare gli interval id anche dopo un "hot-reload" -if (!global.__meb_live_trackers) { - global.__meb_live_trackers = new Map(); -} +const skFlow = require('../../config/skFlow'); +const { startSession } = require('../utility/live'); -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 */ } - } - } +const logsPaths = [ + "navigation.position", + "navigation.headingTrue", + "navigation.speedOverGround", + "propulsion.p1.temperature" ]; + +// Funzioni per generare il testo aggiornato per ogni tipo di dato +const textGenerators = { + logs: () => { + const data = skFlow.getFrom(logsPaths); + if (!data || Object.keys(data).length === 0) return 'Nessun log disponibile.'; + let text = '*Telemetria di Bordo*\n\n'; + for (const [path, value] of Object.entries(data)) { + const displayValue = typeof value === 'object' ? JSON.stringify(value) : value; + text += `*${path}*: ${displayValue}\n`; + } + return text; + }, + weather: () => { + const data = skFlow.getWithFilter('meb.forecast'); + if (!data || Object.keys(data).length === 0) return 'Nessun dato meteo disponibile.'; + let text = '*Dati Meteo*\n\n'; + for (const [path, value] of Object.entries(data)) { + const displayValue = typeof value === 'object' ? JSON.stringify(value) : value; + text += `*${path}*: ${displayValue}\n`; + } + return text; + }, + marine: () => { + const data = skFlow.getWithFilter('meb.marine'); + if (!data || Object.keys(data).length === 0) return 'Nessun dato sul mare disponibile.'; + let text = '*Dati Meteo del mare*\n\n'; + for (const [path, value] of Object.entries(data)) { + const displayValue = typeof value === 'object' ? JSON.stringify(value) : value; + text += `*${path}*: ${displayValue}\n`; + } + return text; + }, + data: () => { + let text = ''; + + const logs = skFlow.getFrom(logsPaths); + text += '*Telemetria di Bordo*\n\n'; + if (logs && Object.keys(logs).length > 0) { + for (const [path, value] of Object.entries(logs)) { + const displayValue = typeof value === 'object' ? JSON.stringify(value) : value; + text += `*${path}*: ${displayValue}\n`; + } + } else { + text += 'Nessun dato disponibile.\n'; + } + + const weather = skFlow.getWithFilter('meb.forecast'); + text += '\n*Dati Meteo*\n\n'; + if (weather && Object.keys(weather).length > 0) { + for (const [path, value] of Object.entries(weather)) { + const displayValue = typeof value === 'object' ? JSON.stringify(value) : value; + text += `*${path}*: ${displayValue}\n`; + } + } else { + text += 'Nessun dato disponibile.\n'; + } + + const marine = skFlow.getWithFilter('meb.marine'); + text += '\n*Dati Meteo del mare*\n\n'; + if (marine && Object.keys(marine).length > 0) { + for (const [path, value] of Object.entries(marine)) { + const displayValue = typeof value === 'object' ? JSON.stringify(value) : value; + text += `*${path}*: ${displayValue}\n`; + } + } else { + text += 'Nessun dato disponibile.\n'; + } + + return text; + } +}; + +module.exports = { + prefix: 'live:', + handler: async (bot, query) => { + const chatId = query.message.chat.id; + const botMessageId = query.message.message_id; + + // callback_data = live:: + const parts = query.data.split(':'); + const dataType = parts[1]; + const userMessageId = parts[2]; + + const getTextFn = textGenerators[dataType]; + if (!getTextFn) { + bot.answerCallbackQuery(query.id, { text: 'Tipo non supportato' }); + return; + } + + startSession(bot, chatId, botMessageId, userMessageId, getTextFn); + bot.answerCallbackQuery(query.id, { text: 'Live avviato' }); + }, + textGenerators +}; diff --git a/plugin/telegram/callbacks/livestop.js b/plugin/telegram/callbacks/livestop.js new file mode 100644 index 0000000..050aa56 --- /dev/null +++ b/plugin/telegram/callbacks/livestop.js @@ -0,0 +1,15 @@ +const { stopSession, getSession } = require('../utility/live'); + +module.exports = { + prefix: 'livestop', + handler: async (bot, query) => { + const chatId = query.message.chat.id; + const botMessageId = query.message.message_id; + + // callback_data = livestop: + const userMessageId = query.data.split(':')[1]; + + await stopSession(bot, chatId, botMessageId, userMessageId); + bot.answerCallbackQuery(query.id); + } +}; diff --git a/plugin/telegram/callbacks/logbusy.js b/plugin/telegram/callbacks/logbusy.js new file mode 100644 index 0000000..e504392 --- /dev/null +++ b/plugin/telegram/callbacks/logbusy.js @@ -0,0 +1,12 @@ +module.exports = { + prefix: 'logbusy:', + handler: async (bot, query) => { + const logName = query.data.split(':')[1]; + + // Mostra un alert all'utente che il file non si puo scaricare + bot.answerCallbackQuery(query.id, { + text: `La registrazione "${logName}" è in corso. Fermala per scaricare il file.`, + show_alert: true + }); + } +}; diff --git a/plugin/telegram/callbacks/logfile.js b/plugin/telegram/callbacks/logfile.js new file mode 100644 index 0000000..c01e8a3 --- /dev/null +++ b/plugin/telegram/callbacks/logfile.js @@ -0,0 +1,69 @@ +const recorder = require('../../cores/logs.local'); + +module.exports = { + prefix: 'logfile:', + handler: async (bot, query) => { + const chatId = query.message.chat.id; + const listMessageId = query.message.message_id; + + // callback_data = logfile:: + const parts = query.data.split(':'); + const logName = parts[1]; + const userMessageId = parts[2]; + + // Elimina il messaggio con la lista dei file + try { + await bot.deleteMessage(chatId, listMessageId); + } catch (e) {} + + // Ottieni il file e le sue informazioni + const filePath = recorder.getLogFile(logName); + if (!filePath) { + bot.answerCallbackQuery(query.id, { text: 'File non trovato' }); + return; + } + + // Controllo aggiuntivo: se il file è quello in registrazione attiva + const session = recorder.getSession(); + if (session && session.name === logName) { + bot.answerCallbackQuery(query.id, { + text: `Il file "${logName}" è attualmente in uso per la registrazione attiva. Fermala per scaricarlo.`, + show_alert: true + }); + return; + } + + // Ottieni info del file + const fs = require('fs'); + const stat = fs.statSync(filePath); + const sizeMB = (stat.size / (1024 * 1024)).toFixed(2); + const created = new Date(stat.birthtime).toLocaleDateString('it-IT', { + day: '2-digit', month: '2-digit', year: 'numeric', + hour: '2-digit', minute: '2-digit' + }); + + const caption = `*CSV\nCreato: ${created}\ ${sizeMB} MB`; + + // Invia il file + const docMessage = await bot.sendDocument(chatId, filePath, { + caption: caption, + parse_mode: 'Markdown' + }); + + // Elimina il messaggio dell'utente (il comando /logs) + try { + if (userMessageId) { + await bot.deleteMessage(chatId, parseInt(userMessageId)); + } + } catch (e) {} + + // Dopo 5 secondi, elimina il messaggio con il documento + setTimeout(async () => { + try { + await bot.deleteMessage(chatId, docMessage.message_id); + } catch (e) {} + }, 10000); //dopo 10 secondi + + bot.answerCallbackQuery(query.id); + } +}; diff --git a/plugin/telegram/callbacks/logs.js b/plugin/telegram/callbacks/logs.js deleted file mode 100644 index ad2f8a2..0000000 --- a/plugin/telegram/callbacks/logs.js +++ /dev/null @@ -1,51 +0,0 @@ -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 deleted file mode 100644 index 77fc287..0000000 --- a/plugin/telegram/callbacks/settings.js +++ /dev/null @@ -1,80 +0,0 @@ -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 deleted file mode 100644 index 5323b24..0000000 --- a/plugin/telegram/callbacks/status.js +++ /dev/null @@ -1,67 +0,0 @@ -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 deleted file mode 100644 index 473328c..0000000 --- a/plugin/telegram/callbacks/weather.js +++ /dev/null @@ -1,26 +0,0 @@ -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/backuplogs.js b/plugin/telegram/commands/backuplogs.js new file mode 100644 index 0000000..ce28b50 --- /dev/null +++ b/plugin/telegram/commands/backuplogs.js @@ -0,0 +1,110 @@ +const fs = require('fs').promises; +const fsSync = require('fs'); +const pth = require('path'); +const { closeButton } = require('../utility/close'); + +const dataDir = pth.join(__dirname, '../../../data/'); +const PAGE_SIZE = 5; + +/** + * Raccoglie ricorsivamente tutti i file nella cartella data/ + */ +async function listDataFiles() { + const results = []; + + async function scan(dir, prefix) { + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = pth.join(dir, entry.name); + if (entry.isDirectory()) { + await scan(fullPath, prefix ? `${prefix}/${entry.name}` : entry.name); + } else { + const stat = await fs.stat(fullPath); + results.push({ + name: prefix ? `${prefix}/${entry.name}` : entry.name, + path: fullPath, + size: stat.size, + modified: stat.mtime, + lines: null // calcolato on-demand + }); + } + } + } + + try { + await scan(dataDir, ''); + } catch (e) { + console.error('[BACKUP] Errore scan:', e.message); + } + + return results.sort((a, b) => b.modified - a.modified); +} + +function formatSize(bytes) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; +} + +function buildPage(files, page, userMessageId) { + const totalPages = Math.ceil(files.length / PAGE_SIZE); + const start = page * PAGE_SIZE; + const pageFiles = files.slice(start, start + PAGE_SIZE); + + const keyboard = pageFiles.map(f => { + const date = new Date(f.modified).toLocaleDateString('it-IT', { + day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' + }); + const label = `${f.name} (${formatSize(f.size)}, ${date})`; + // Encode file name as base64-safe identifier (index in full list) + const fileIdx = files.indexOf(f); + return [{ text: label, callback_data: `bkfile:${fileIdx}:${userMessageId}` }]; + }); + + // Navigation row + const navRow = []; + if (page > 0) { + navRow.push({ text: '<< Prec', callback_data: `bkpage:${page - 1}:${userMessageId}` }); + } + navRow.push({ text: `${page + 1}/${totalPages}`, callback_data: `bknoop:0` }); + if (page < totalPages - 1) { + navRow.push({ text: 'Succ >>', callback_data: `bkpage:${page + 1}:${userMessageId}` }); + } + keyboard.push(navRow); + + // Close button + keyboard.push([{ text: '<- Chiudi', callback_data: `close:${userMessageId}` }]); + + return keyboard; +} + +module.exports = { + command: 'backup', + handler: async (bot, msg) => { + const chatId = msg.chat.id; + const files = await listDataFiles(); + + if (files.length === 0) { + bot.sendMessage(chatId, 'Nessun file nella cartella data.', { + reply_to_message_id: msg.message_id, + reply_markup: closeButton(msg.message_id) + }); + return; + } + + const text = `*Backup & Logs*\n\n${files.length} file disponibili nella cartella data.\n_Seleziona un file per info e download._`; + const keyboard = buildPage(files, 0, msg.message_id); + + bot.sendMessage(chatId, text, { + parse_mode: 'Markdown', + reply_to_message_id: msg.message_id, + reply_markup: { inline_keyboard: keyboard } + }); + }, + + // Export utilities for callbacks + listDataFiles, + formatSize, + buildPage, + PAGE_SIZE +}; diff --git a/plugin/telegram/commands/dashboard.js b/plugin/telegram/commands/dashboard.js deleted file mode 100644 index 7c1ba4b..0000000 --- a/plugin/telegram/commands/dashboard.js +++ /dev/null @@ -1,57 +0,0 @@ -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 index 7b56d57..be9e80c 100644 --- a/plugin/telegram/commands/data.js +++ b/plugin/telegram/commands/data.js @@ -1,58 +1,59 @@ -const dataHub = require('../../tools/dataHub'); +const skFlow = require('../../config/skFlow'); +const { liveMarkup } = require('../utility/live'); -/** - * 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; -} +const logsPaths = [ + "navigation.position", + "navigation.headingTrue", + "navigation.speedOverGround", + "propulsion.p1.temperature" +]; module.exports = { command: 'data', - description: 'Mostra i dati sensori attuali', - pattern: /\/data/, - execute: async (bot, msg) => { + handler: (bot, msg) => { const chatId = msg.chat.id; - const text = formatSensorData(); + let text = ''; - await bot.sendMessage(chatId, text, { - parse_mode: 'Markdown', - reply_markup: { - inline_keyboard: [ - [{ text: 'Aggiorna', callback_data: 'data-refresh' }] - ] + // Telemetria + const logs = skFlow.getFrom(logsPaths); + text += '*Telemetria di Bordo*\n\n'; + if (logs && Object.keys(logs).length > 0) { + for (const [path, value] of Object.entries(logs)) { + const displayValue = typeof value === 'object' ? JSON.stringify(value) : value; + text += `*${path}*: ${displayValue}\n`; } + } else { + text += 'Nessun dato disponibile.\n'; + } + + // Meteo + const weather = skFlow.getWithFilter('meb.forecast'); + text += '\n*Dati Meteo*\n\n'; + if (weather && Object.keys(weather).length > 0) { + for (const [path, value] of Object.entries(weather)) { + const displayValue = typeof value === 'object' ? JSON.stringify(value) : value; + text += `*${path}*: ${displayValue}\n`; + } + } else { + text += 'Nessun dato disponibile.\n'; + } + + // Mare + const marine = skFlow.getWithFilter('meb.marine'); + text += '\n*Dati Meteo del mare*\n\n'; + if (marine && Object.keys(marine).length > 0) { + for (const [path, value] of Object.entries(marine)) { + const displayValue = typeof value === 'object' ? JSON.stringify(value) : value; + text += `*${path}*: ${displayValue}\n`; + } + } else { + text += 'Nessun dato disponibile.\n'; + } + + bot.sendMessage(chatId, text, { + parse_mode: 'Markdown', + reply_to_message_id: msg.message_id, + reply_markup: liveMarkup(msg.message_id, 'data') }); - }, - formatSensorData + } }; diff --git a/plugin/telegram/commands/live.js b/plugin/telegram/commands/live.js deleted file mode 100644 index b83ada4..0000000 --- a/plugin/telegram/commands/live.js +++ /dev/null @@ -1,84 +0,0 @@ -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 index 9204912..f0c4a9e 100644 --- a/plugin/telegram/commands/logs.js +++ b/plugin/telegram/commands/logs.js @@ -1,53 +1,53 @@ -const realtime = require('../../realtime/core.js'); -const { config } = require('../../config.js'); +const recorder = require('../../cores/logs.local'); +const { closeButton } = require('../utility/close'); module.exports = { command: 'logs', - description: 'Mostra lo stato della registrazione dati in tempo reale', - pattern: /\/logs/, - execute: async (bot, msg, { app }) => { + handler: async (bot, msg) => { const chatId = msg.chat.id; - try { - const stats = realtime.getStats(); - const consoleUrl = config.cloudUrl || 'https://console.mebboat.it'; + const logs = await recorder.listLogs(); - 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' }] - ] - } + if (!logs || logs.length === 0) { + bot.sendMessage(chatId, 'Nessun file di log disponibile.', { + reply_to_message_id: msg.message_id, + reply_markup: closeButton(msg.message_id) }); - } catch (error) { - console.error("[Telegram] Errore comando /logs:", error); - bot.sendMessage(chatId, `❌ Errore: ${error.message}`); + return; } + + + const session = recorder.getSession(); + let text = '*Registrazioni dei Log*\n\n'; + + if (session) { + text += `in corso: *${session.name}*\n`; + text += `${session.elements} dati raccolti ogni ${session.delay}s\n\n`; + } + + text += `${logs.length} file disponibili:\n`; + text += '_Selezionane uno per scaricarlo_'; + + // Bottoni per ogni file + const keyboard = logs.map(log => { + const date = new Date(log.created).toLocaleDateString('it-IT', { + day: '2-digit', month: '2-digit', year: '2-digit', + hour: '2-digit', minute: '2-digit' + }); + + const isActive = session && session.name === log.name; + const label = isActive ? `🔴 ${log.name} *[IN CORSO, NON DISPONIBILE]*` : `${date})`; + const callback = isActive ? `logbusy:${log.name}` : `logfile:${log.name}:${msg.message_id}`; + + return [{ text: label, callback_data: callback }]; + }); + + // Aggiungi il bottone chiudi + keyboard.push([{ text: '<- Chiudi', callback_data: `close:${msg.message_id}` }]); + + bot.sendMessage(chatId, text, { + parse_mode: 'Markdown', + reply_to_message_id: msg.message_id, + reply_markup: { inline_keyboard: keyboard } + }); } }; diff --git a/plugin/telegram/commands/marine.js b/plugin/telegram/commands/marine.js new file mode 100644 index 0000000..6c4f290 --- /dev/null +++ b/plugin/telegram/commands/marine.js @@ -0,0 +1,30 @@ +const skFlow = require('../../config/skFlow'); +const { liveMarkup } = require('../utility/live'); + +module.exports = { + command: 'marine', + handler: (bot, msg) => { + const chatId = msg.chat.id; + const data = skFlow.getWithFilter('meb.marine'); + + let text = ''; + + if (!data || Object.keys(data).length === 0) { + text = 'Nessun dato sul mare disponibile.'; + } else { + text = '*Dati Meteo del mare*\n\n'; + for (const [path, value] of Object.entries(data)) { + const displayValue = typeof value === 'object' ? JSON.stringify(value) : value; + //TODO: ADD units + //TODO: Formattare meglio i path + text += `*${path}*: ${displayValue}\n`; + } + } + + bot.sendMessage(chatId, text, { + parse_mode: 'Markdown', + reply_to_message_id: msg.message_id, + reply_markup: liveMarkup(msg.message_id, 'marine') + }); + } +}; diff --git a/plugin/telegram/commands/realtime.js b/plugin/telegram/commands/realtime.js deleted file mode 100644 index f0ae7af..0000000 --- a/plugin/telegram/commands/realtime.js +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 6eecc2a..0000000 --- a/plugin/telegram/commands/settings.js +++ /dev/null @@ -1,20 +0,0 @@ -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/start.js b/plugin/telegram/commands/start.js new file mode 100644 index 0000000..3b2cc88 --- /dev/null +++ b/plugin/telegram/commands/start.js @@ -0,0 +1,17 @@ +module.exports = { + command: 'start', + handler: (bot, msg) => { + const chatId = msg.chat.id; + + bot.setMyCommands([ + { command: 'data', description: 'Mostra tutti i dati' }, + { command: 'logs', description: 'Registrazioni logs' }, + { command: 'weather', description: 'Mostra i dati meteo' }, + { command: 'marine', description: 'Mostra i dati del mare' }, + { command: 'backup', description: 'Backup logs - lista file nella cartella data' }, + // { command: 'start', description: 'Avvia il bot e configura i comandi' } + ]); + + bot.sendMessage(chatId, 'Benvenuto nel bot MEB!'); + } +}; diff --git a/plugin/telegram/commands/status.js b/plugin/telegram/commands/status.js deleted file mode 100644 index 3c3f7f4..0000000 --- a/plugin/telegram/commands/status.js +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index 0bfb2bd..0000000 --- a/plugin/telegram/commands/structure.js +++ /dev/null @@ -1,47 +0,0 @@ -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 index dae1807..d2f8153 100644 --- a/plugin/telegram/commands/weather.js +++ b/plugin/telegram/commands/weather.js @@ -1,84 +1,29 @@ -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; -} +const skFlow = require('../../config/skFlow'); +const { liveMarkup } = require('../utility/live'); module.exports = { command: 'weather', - description: 'Mostra i dati meteo attuali', - pattern: /\/weather/, - execute: async (bot, msg) => { + handler: (bot, msg) => { const chatId = msg.chat.id; - const text = formatWeatherData(); + const data = skFlow.getWithFilter('meb.forecast'); - await bot.sendMessage(chatId, text, { - parse_mode: 'Markdown', - reply_markup: { - inline_keyboard: [ - [{ text: 'Aggiorna', callback_data: 'weather-refresh' }] - ] + let text = ''; + + if (!data || Object.keys(data).length === 0) { + text = 'Nessun dato meteo disponibile.'; + } else { + text = '*Dati Meteo*\n\n'; + for (const [path, value] of Object.entries(data)) { + const displayValue = typeof value === 'object' ? JSON.stringify(value) : value; + //TODO: ADD units + text += `*${path}*: ${displayValue}\n`; } + } + + bot.sendMessage(chatId, text, { + parse_mode: 'Markdown', + reply_to_message_id: msg.message_id, + reply_markup: liveMarkup(msg.message_id, 'weather') }); - }, - formatWeatherData + } }; diff --git a/plugin/telegram/core.js b/plugin/telegram/core.js new file mode 100644 index 0000000..94606fb --- /dev/null +++ b/plugin/telegram/core.js @@ -0,0 +1,121 @@ +const TelegramBot = require('node-telegram-bot-api'); +const fs = require('fs'); +const path = require('path'); +const configManager = require('../config/configManager.js'); + +let bot = null; + +/** + * Inizializza il bot Telegram con il token dalle configurazioni del plugin. + * Carica automaticamente i comandi dalla cartella commands/ e i callback dalla cartella callbacks/. + * @returns {TelegramBot|null} L'istanza del bot o null se il token non è disponibile. + */ +function init() { + const token = configManager.getTelegramToken(); + + if (!token) { + console.error('[TELEGRAM] TELEGRAM_BOT_TOKEN non trovato nelle configurazioni del plugin'); + return null; + } + + bot = new TelegramBot(token, { polling: true }); + loadCommands(); + loadCallbacks(); + + bot.on('polling_error', (error) => { + console.error('[TELEGRAM] Errore polling:', error.message); + }); + + return bot; +} + +/** + * Carica tutti i file dalla cartella commands/ e li registra come handler per i comandi. + * Ogni file deve esportare un oggetto con { command: string, handler: function(bot, msg, match) } + */ +function loadCommands() { + const commandsDir = path.join(__dirname, 'commands'); + + if (!fs.existsSync(commandsDir)) return; + + const files = fs.readdirSync(commandsDir).filter(f => f.endsWith('.js')); + + for (const file of files) { + try { + const cmd = require(path.join(commandsDir, file)); + if (cmd.command && cmd.handler) { + bot.onText(new RegExp(`^/${cmd.command}`), (msg, match) => { + cmd.handler(bot, msg, match); + }); + } + } catch (error) { + console.error(`[TELEGRAM] Errore caricamento comando ${file}:`, error.message); + } + } +} + +/** + * Carica tutti i file dalla cartella callbacks/ e li registra come handler per le callback query. + * Ogni file deve esportare un oggetto con { prefix: string, handler: function(bot, query) } + */ +function loadCallbacks() { + const callbacksDir = path.join(__dirname, 'callbacks'); + + if (!fs.existsSync(callbacksDir)) return; + + const files = fs.readdirSync(callbacksDir).filter(f => f.endsWith('.js')); + const handlers = []; + + for (const file of files) { + try { + const cb = require(path.join(callbacksDir, file)); + if (cb.prefix && cb.handler) { + handlers.push(cb); + } + } catch (error) { + console.error(`[TELEGRAM] Errore caricamento callback ${file}:`, error.message); + } + } + + if (handlers.length > 0) { + bot.on('callback_query', (query) => { + const matched = handlers.find(h => query.data.startsWith(h.prefix)); + if (matched) { + matched.handler(bot, query); + } + }); + } +} + +/** + * Restituisce l'istanza del bot (se inizializzato) + * @returns {TelegramBot|null} + */ +function getBot() { + return bot; +} + +/** + * Invia un messaggio ad un chatId specifico + * @param {Number|String} chatId + * @param {String} text + * @param {Object} options - opzioni aggiuntive (parse_mode, reply_markup, ecc.) + */ +async function send(chatId, text, options = {}) { + if (!bot) { + console.error('[TELEGRAM] Bot non inizializzato'); + return; + } + try { + await bot.sendMessage(chatId, text, options); + } catch (error) { + console.error(`[TELEGRAM] Errore invio messaggio: ${error.message}`); + } +} + + +module.exports = { + init, + getBot, + send +}; diff --git a/plugin/telegram/telegram.core.js b/plugin/telegram/telegram.core.js deleted file mode 100644 index ebcd310..0000000 --- a/plugin/telegram/telegram.core.js +++ /dev/null @@ -1,269 +0,0 @@ -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/telegram/utility/close.js b/plugin/telegram/utility/close.js new file mode 100644 index 0000000..cbc7df4 --- /dev/null +++ b/plugin/telegram/utility/close.js @@ -0,0 +1,17 @@ +/** + * Restituisce l'oggetto reply_markup per il bottone "Chiudi", che elimina + * il messaggio del bot e il messaggio originale dell'utente. + * @param {Number} userMessageId - L'ID del messaggio originale dell'utente a cui si sta rispondendo. + * @returns {Object} Oggetto compatibile con reply_markup. + */ +function addCloseAction(userMessageId) { + return { + inline_keyboard: [ + [{ text: '<- Chiudi', callback_data: `close:${userMessageId}` }] + ] + }; +} + +module.exports = { + closeButton: addCloseAction +} \ No newline at end of file diff --git a/plugin/telegram/utility/live.js b/plugin/telegram/utility/live.js new file mode 100644 index 0000000..173b59e --- /dev/null +++ b/plugin/telegram/utility/live.js @@ -0,0 +1,130 @@ +const MAX_LIVE_DURATION = 2 * 60 * 1000; // 2 minuti +const UPDATE_INTERVAL = 2000; // 2 secondi + +// Mappa delle sessioni live attive: chiave = `chatId:botMessageId` +const activeSessions = new Map(); + +/** + * Restituisce il markup con i bottoni "Live" e "Chiudi" + * @param {Number} userMessageId + * @param {String} dataType - tipo di dati (logs, marine, weather, data) + */ +function liveMarkup(userMessageId, dataType) { + return { + inline_keyboard: [ + [ + { text: 'Live', callback_data: `live:${dataType}:${userMessageId}` }, + { text: '<- Chiudi', callback_data: `close:${userMessageId}` } + ] + ] + }; +} + +/** + * Restituisce il markup con il bottone "Stop" + * @param {Number} userMessageId + */ +function stopMarkup(userMessageId) { + return { + inline_keyboard: [ + [{ text: 'Stop', callback_data: `livestop:${userMessageId}` }] + ] + }; +} + +/** + * Formatta il tempo rimanente in MM:SS + */ +function formatTime(ms) { + const totalSec = Math.ceil(ms / 1000); + const min = Math.floor(totalSec / 60); + const sec = totalSec % 60; + return `${min}:${sec.toString().padStart(2, '0')}`; +} + +/** + * Avvia una sessione live + * @param {Object} bot - istanza del bot + * @param {Number} chatId + * @param {Number} botMessageId - ID del messaggio del bot da aggiornare + * @param {Number} userMessageId - ID del messaggio dell'utente + * @param {Function} getTextFn - funzione che restituisce il testo aggiornato (senza timer) + */ +function startSession(bot, chatId, botMessageId, userMessageId, getTextFn) { + const key = `${chatId}:${botMessageId}`; + + // Se esiste già una sessione, non avviarne una nuova + if (activeSessions.has(key)) return; + + const startTime = Date.now(); + + // Pinna il messaggio + bot.pinChatMessage(chatId, botMessageId, { disable_notification: true }).catch(() => {}); + + const interval = setInterval(async () => { + const elapsed = Date.now() - startTime; + const remaining = MAX_LIVE_DURATION - elapsed; + + if (remaining <= 0) { + stopSession(bot, chatId, botMessageId, userMessageId); + return; + } + + try { + const freshText = getTextFn(); + const textWithTimer = freshText + `\n_Live: ${formatTime(remaining)} rimanenti_`; + + await bot.editMessageText(textWithTimer, { + chat_id: chatId, + message_id: botMessageId, + parse_mode: 'Markdown', + reply_markup: stopMarkup(userMessageId) + }); + } catch (error) { + // Ignora errori di "message not modified" + if (!error.message?.includes('message is not modified')) { + console.error('[LIVE] Errore aggiornamento:', error.message); + } + } + }, UPDATE_INTERVAL); + + activeSessions.set(key, { interval, userMessageId }); +} + +/** + * Ferma una sessione live, toglie il pin, elimina i messaggi + */ +async function stopSession(bot, chatId, botMessageId, userMessageId) { + const key = `${chatId}:${botMessageId}`; + const session = activeSessions.get(key); + + if (session) { + clearInterval(session.interval); + activeSessions.delete(key); + } + + try { + await bot.unpinChatMessage(chatId, { message_id: botMessageId }).catch(() => {}); + await bot.deleteMessage(chatId, botMessageId); + if (userMessageId) { + await bot.deleteMessage(chatId, parseInt(userMessageId)); + } + } catch (error) { + console.error('[LIVE] Errore chiusura sessione:', error.message); + } +} + +/** + * Cerca una sessione attiva dal botMessageId + */ +function getSession(chatId, botMessageId) { + return activeSessions.get(`${chatId}:${botMessageId}`); +} + +module.exports = { + liveMarkup, + stopMarkup, + startSession, + stopSession, + getSession +}; diff --git a/plugin/tools/dataHub.js b/plugin/tools/dataHub.js deleted file mode 100644 index 06dce23..0000000 --- a/plugin/tools/dataHub.js +++ /dev/null @@ -1,78 +0,0 @@ -/** - * 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 deleted file mode 100644 index a02e30e..0000000 --- a/plugin/tools/healthcheck.js +++ /dev/null @@ -1,50 +0,0 @@ -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/kiosk/canvas.js b/plugin/tools/kiosk/canvas.js new file mode 100644 index 0000000..e78f3b6 --- /dev/null +++ b/plugin/tools/kiosk/canvas.js @@ -0,0 +1,336 @@ +const COLS = 24, ROWS = 18; +const SNAP = 0.5; +const SNAP_MAG = 0.3; +const MIN_GW = 2, MIN_GH = 1.5; +const MAX_GW = 20, MAX_GH = 16; +const DEF_GW = 6, DEF_GH = 5; + +const canvasEl = document.getElementById('canvas'); +const tooltipEl = document.getElementById('tooltip'); +const emptyState = document.getElementById('emptyState'); +const cardCountEl = document.getElementById('cardCount'); +const unitBadge = document.getElementById('unitBadge'); +const modalOvl = document.getElementById('modalOverlay'); +const modalTitle = document.getElementById('modalTitle'); +const importArea = document.getElementById('importArea'); +const modalApply = document.getElementById('modalApply'); +const toastEl = document.getElementById('toast'); + +let cards = [], cardIdCounter = 0, selectedCard = null, zCounter = 1; +let snapGuidesH = [], snapGuidesV = []; +let editMode = false; + +const uw = () => canvasEl.clientWidth / COLS; +const uh = () => canvasEl.clientHeight / ROWS; +const gSnap = v => Math.round(v / SNAP) * SNAP; +const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); + +function screenToGrid(cx, cy) { + const r = canvasEl.getBoundingClientRect(); + return { gx: (cx - r.left) / uw(), gy: (cy - r.top) / uh() }; +} + +function renderCard(c) { + const u = uw(), h = uh(); + c.el.style.left = (c.gx * u) + 'px'; + c.el.style.top = (c.gy * h) + 'px'; + c.el.style.width = (c.gw * u) + 'px'; + c.el.style.height = (c.gh * h) + 'px'; + + if (editMode) c.el.classList.add('editable'); + else c.el.classList.remove('editable', 'selected'); +} + +function renderAll() { + cards.forEach(renderCard); + unitBadge.textContent = `1u = ${Math.round(uw())}px`; +} + +function updateCount() { + const n = cards.length; + cardCountEl.textContent = `${n} card${n !== 1 ? 's' : ''}`; + emptyState.classList.toggle('hidden', n > 0); +} + +// Responsive re-render +let rafId = null; +window.addEventListener('resize', () => { + if (rafId) cancelAnimationFrame(rafId); + rafId = requestAnimationFrame(renderAll); +}); + +// Toast +let toastT = null; +function toast(msg) { + toastEl.textContent = msg; + toastEl.classList.add('show'); + clearTimeout(toastT); + toastT = setTimeout(() => toastEl.classList.remove('show'), 2200); +} + +// Guides +function ensureGuides() { + if (snapGuidesH.length) return; + for (let i = 0; i < 2; i++) { + let g = document.createElement('div'); g.className = 'guide snap-guide horizontal'; canvasEl.appendChild(g); snapGuidesH.push(g); + g = document.createElement('div'); g.className = 'guide snap-guide vertical'; canvasEl.appendChild(g); snapGuidesV.push(g); + } +} +function hideGuides() { + snapGuidesH.forEach(g => g.classList.remove('visible')); snapGuidesV.forEach(g => g.classList.remove('visible')); +} +function showGuide(type, gp, idx = 0) { + if (type === 'h' && idx < snapGuidesH.length) { snapGuidesH[idx].style.top = (gp * uh()) + 'px'; snapGuidesH[idx].classList.add('visible'); } + else if (type === 'v' && idx < snapGuidesV.length) { snapGuidesV[idx].style.left = (gp * uw()) + 'px'; snapGuidesV[idx].classList.add('visible'); } +} + +// Magnetic snap +function magSnap(el, gx, gy, gw, gh) { + let sx = gx, sy = gy, gH = [], gV = []; + const others = cards.filter(c => c.el !== el); + + let bH = SNAP_MAG + 1; + for (const o of others) + for (const [f, t] of [[gy, o.gy], [gy, o.gy + o.gh], [gy + gh, o.gy], [gy + gh, o.gy + o.gh]]) { + const d = Math.abs(f - t); + if (d < bH) { bH = d; sy = gy + (t - f); gH = [t]; } + } + + let bV = SNAP_MAG + 1; + for (const o of others) + for (const [f, t] of [[gx, o.gx], [gx, o.gx + o.gw], [gx + gw, o.gx], [gx + gw, o.gx + o.gw]]) { + const d = Math.abs(f - t); + if (d < bV) { bV = d; sx = gx + (t - f); gV = [t]; } + } + + if (bH > SNAP_MAG) sy = gSnap(gy); + if (bV > SNAP_MAG) sx = gSnap(gx); + return { gx: sx, gy: sy, guidesH: gH, guidesV: gV }; +} + +// Signal K data handling +function updateData(path, value) { + cards.filter(c => c.path === path).forEach(c => { + const body = c.el.querySelector('.card-body'); + if (body) { + let displayVal = value; + if (path.includes('speed')) displayVal = (value * 1.94384).toFixed(1) + ' kn'; + else if (path.includes('depth')) displayVal = value.toFixed(1) + ' m'; + body.textContent = displayVal; + } + }); +} +window.updateKioskData = updateData; + +// Create card +function createCard(gx, gy, gw = DEF_GW, gh = DEF_GH, forceId = null, gz = null, type = 'widget', path = null) { + const id = forceId || (++cardIdCounter); + if (forceId && forceId >= cardIdCounter) cardIdCounter = forceId; + if (!forceId && id > cardIdCounter) cardIdCounter = id; + + const skPaths = window.skPaths || []; + const finalPath = path || (type === 'widget' && skPaths.length ? skPaths[(id - 1) % skPaths.length] : null); + + const el = document.createElement('div'); + el.className = 'card spawning' + (editMode ? ' editable' : ''); + el.dataset.id = id; + el.dataset.type = type; + const z = gz || (++zCounter); + el.style.zIndex = z; + if (gz && gz >= zCounter) zCounter = gz; + + let headerHtml = `${type === 'map' ? 'Mappa' : (finalPath ? finalPath.split('.').pop() : 'Widget')}`; + + // Suggerimento menu path se widget + if (type === 'widget') { + let menuHtml = `
`; + skPaths.forEach(p => { + menuHtml += `
${p.split('.').pop()}
`; + }); + menuHtml += `
`; + headerHtml = `
${headerHtml}${menuHtml}
`; + } + + el.innerHTML = ` +
+ ${headerHtml} + +
+
+
+
+
+
`; + + canvasEl.appendChild(el); + const c = { id, el, gx, gy, gw, gh, type, path: finalPath }; + cards.push(c); + renderCard(c); + + if (type === 'map') { + const mapDiv = document.createElement('div'); + mapDiv.id = `map-container-${id}`; + mapDiv.className = 'card-map-canvas'; + el.querySelector('.card-body').appendChild(mapDiv); + if (window.initMapInstance) window.initMapInstance(mapDiv.id); + } else { + updateBody(c); + // Listener per il cambio path + el.querySelectorAll('.path-option').forEach(opt => { + opt.addEventListener('click', (e) => { + e.stopPropagation(); + c.path = opt.dataset.path; + el.querySelector('.card-label').textContent = c.path.split('.').pop(); + toast(`Path aggiornato: ${c.path}`); + }); + }); + } + + el.addEventListener('animationend', () => el.classList.remove('spawning'), { once: true }); + el.querySelector('.card-close').addEventListener('click', ev => { ev.stopPropagation(); removeCard(c); }); + el.addEventListener('mousedown', () => { if (editMode) selectCard(c); }); + + setupDrag(c); + setupResize(c); + updateCount(); + return c; +} + +function removeCard(c) { + c.el.classList.add('removing'); + c.el.addEventListener('animationend', () => { + c.el.remove(); + cards = cards.filter(x => x.id !== c.id); + if (selectedCard === c) selectedCard = null; + updateCount(); + }, { once: true }); +} + +function selectCard(c) { + if (selectedCard?.el) selectedCard.el.classList.remove('selected'); + selectedCard = c; c.el.classList.add('selected'); c.el.style.zIndex = ++zCounter; +} + +function updateBody(c) { + if (c.type === 'map') { + if (window.resizeMapInstance) window.resizeMapInstance(); + } else { + const b = c.el.querySelector('.card-body'); + if (b && !b.textContent.trim()) { + b.textContent = `${c.gw.toFixed(1)} × ${c.gh.toFixed(1)} u`; + } + } +} + +// Drag +function setupDrag(c) { + c.el.addEventListener('mousedown', e => { + if (!editMode) return; + if (e.target.classList.contains('rh') || e.target.classList.contains('card-close')) return; + e.preventDefault(); ensureGuides(); c.el.classList.add('dragging'); + const start = screenToGrid(e.clientX, e.clientY); + const oGx = c.gx, oGy = c.gy; + + const onMove = ev => { + const now = screenToGrid(ev.clientX, ev.clientY); + let nx = oGx + (now.gx - start.gx), ny = oGy + (now.gy - start.gy); + nx = clamp(nx, 0, COLS - c.gw); ny = clamp(ny, 0, ROWS - c.gh); + const s = magSnap(c.el, nx, ny, c.gw, c.gh); + c.gx = clamp(s.gx, 0, COLS - c.gw); c.gy = clamp(s.gy, 0, ROWS - c.gh); + hideGuides(); + s.guidesH.forEach((p, i) => showGuide('h', p, i)); + s.guidesV.forEach((p, i) => showGuide('v', p, i)); + renderCard(c); + tooltipEl.textContent = `${c.gx.toFixed(1)}, ${c.gy.toFixed(1)}`; + tooltipEl.style.left = (ev.clientX + 14) + 'px'; tooltipEl.style.top = (ev.clientY + 14) + 'px'; + tooltipEl.classList.add('visible'); + }; + + const onUp = () => { + c.el.classList.remove('dragging'); hideGuides(); tooltipEl.classList.remove('visible'); + document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); + if (c.type === 'map' && window.resizeMapInstance) window.resizeMapInstance(); + }; + document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); + }); +} + +// Resize +function setupResize(c) { + c.el.querySelectorAll('.rh').forEach(h => { + h.addEventListener('mousedown', e => { + if (!editMode) return; + e.preventDefault(); e.stopPropagation(); ensureGuides(); + c.el.classList.add('resizing'); selectCard(c); + const start = screenToGrid(e.clientX, e.clientY); + const oGx = c.gx, oGy = c.gy, oGw = c.gw, oGh = c.gh; + const isN = h.classList.contains('nw') || h.classList.contains('ne') || h.classList.contains('n'); + const isS = h.classList.contains('sw') || h.classList.contains('se') || h.classList.contains('s'); + const isW = h.classList.contains('nw') || h.classList.contains('sw') || h.classList.contains('w'); + const isE = h.classList.contains('ne') || h.classList.contains('se') || h.classList.contains('e'); + + const onMove = ev => { + const now = screenToGrid(ev.clientX, ev.clientY); + const dx = now.gx - start.gx, dy = now.gy - start.gy; + let nw = oGw, nh = oGh, nx = oGx, ny = oGy; + if (isE) nw = oGw + dx; if (isS) nh = oGh + dy; + if (isW) { nw = oGw - dx; nx = oGx + dx; } + if (isN) { nh = oGh - dy; ny = oGy + dy; } + nw = gSnap(clamp(nw, MIN_GW, MAX_GW)); nh = gSnap(clamp(nh, MIN_GH, MAX_GH)); + if (isW) nx = oGx + oGw - nw; if (isN) ny = oGy + oGh - nh; + nx = gSnap(clamp(nx, 0, COLS - nw)); ny = gSnap(clamp(ny, 0, ROWS - nh)); + c.gx = nx; c.gy = ny; c.gw = nw; c.gh = nh; + renderCard(c); updateBody(c); + tooltipEl.textContent = `${nw.toFixed(1)} × ${nh.toFixed(1)} u`; + tooltipEl.style.left = (ev.clientX + 14) + 'px'; tooltipEl.style.top = (ev.clientY + 14) + 'px'; + tooltipEl.classList.add('visible'); + }; + + const onUp = () => { + c.el.classList.remove('resizing'); tooltipEl.classList.remove('visible'); hideGuides(); + document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); + }; + document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); + }); + }); +} + +// ═══════════════════════════════════════════════════════ +// EXPORT / IMPORT +// ═══════════════════════════════════════════════════════ +function exportConfig() { + return JSON.stringify({ + canvas: { cols: COLS, rows: ROWS }, + cards: cards.map(c => ({ + id: c.id, type: c.type, + dimensions: { + x: Math.round(c.gx * 100) / 100, y: Math.round(c.gy * 100) / 100, + z: parseInt(c.el.style.zIndex) || 1, + width: Math.round(c.gw * 100) / 100, height: Math.round(c.gh * 100) / 100 + } + })) + }, null, 2); +} + +function importConfig(json) { + let data; + try { data = JSON.parse(json); } catch { toast('JSON non valido'); return false; } + if (!data.cards || !Array.isArray(data.cards)) { toast('Formato non valido: serve "cards" array'); return false; } + + cards.forEach(c => c.el.remove()); + cards = []; selectedCard = null; cardIdCounter = 0; zCounter = 1; + + for (const entry of data.cards) { + const d = entry.dimensions || {}; + createCard( + clamp(d.x ?? 0, 0, COLS - MIN_GW), clamp(d.y ?? 0, 0, ROWS - MIN_GH), + clamp(d.width ?? DEF_GW, MIN_GW, MAX_GW), clamp(d.height ?? DEF_GH, MIN_GH, MAX_GH), + entry.id ?? null, d.z ?? 1, entry.type ?? 'widget' + ); + } + updateCount(); renderAll(); + toast(`Importate ${data.cards.length} card`); + return true; +} + +updateCount(); renderAll(); \ No newline at end of file diff --git a/plugin/tools/kiosk/control-socket.js b/plugin/tools/kiosk/control-socket.js new file mode 100644 index 0000000..f8d660a --- /dev/null +++ b/plugin/tools/kiosk/control-socket.js @@ -0,0 +1,90 @@ +/** + * Kiosk plugin page bootstrap: + * 1) legge config dal iniettato dal server del plugin + * 2) carica template attivo via API + * 3) apre WS locale SignalK per i valori live + * 4) apre WS "leggero" al realtime server per comandi dalla console + */ +(async function () { + function cfg(name, fallback) { + const m = document.querySelector(`meta[name="${name}"]`); + return (m && m.content) || fallback; + } + + const apiUrl = cfg('api-url', 'https://api.mebboat.it'); + const realtimeUrl = cfg('realtime-url', 'https://realtime.mebboat.it'); + const realtimeWsUrl = cfg('realtime-ws-url', 'wss://realtime.mebboat.it'); + const sensorCode = cfg('sensor-code', ''); + const sensorName = cfg('sensor-name', ''); + + window.kiosk.init({ apiUrl, sensorCode, sensorName }); + + // 1) template iniziale + await window.kiosk.loadTemplate(); + + // 2) WS locale SignalK + try { + const skWs = new WebSocket(`ws://${location.host}/signalk/v1/stream?subscribe=all`); + skWs.onmessage = (ev) => { + let msg; try { msg = JSON.parse(ev.data); } catch { return; } + for (const u of msg.updates || []) for (const v of u.values || []) { + window.kiosk.updateValue(v.path, v.value); + } + }; + skWs.onclose = () => setTimeout(() => location.reload(), 5000); + } catch (e) { console.error('[kiosk] signalk ws error', e); } + + // 3) WS controllo verso realtime server + if (!sensorCode || !sensorName) { window.kiosk.setStatus('no sensor config', true); return; } + + let controlWs = null; + let reconnectTm = null; + + async function fetchSocketToken() { + const r = await fetch(`${realtimeUrl}/connect`, { + method: 'POST', headers: { 'Content-Type':'application/json' }, + body: JSON.stringify({ name: sensorName, code: sensorCode }) + }); + if (!r.ok) return null; + const j = await r.json(); + return j.s === 'ok' ? j.t : null; + } + + async function connectControl() { + clearTimeout(reconnectTm); + const token = await fetchSocketToken(); + if (!token) { reconnectTm = setTimeout(connectControl, 5000); return; } + const url = `${realtimeWsUrl}/kiosk?role=device&sensor=${encodeURIComponent(sensorName)}&token=${encodeURIComponent(token)}`; + controlWs = new WebSocket(url); + controlWs.onopen = () => { + controlWs.send(JSON.stringify({ t:'hello', templateId: window.kiosk.currentTemplateId() })); + }; + controlWs.onmessage = async (ev) => { + let m; try { m = JSON.parse(ev.data); } catch { return; } + let ok = true, err = null; + try { + switch (m.t) { + case 'patch_box': ok = window.kiosk.patchBox(m.boxId, m.patch || {}); break; + case 'add_box': ok = window.kiosk.addBox(m.box); break; + case 'remove_box': ok = window.kiosk.removeBox(m.boxId); break; + case 'load_template': { + const tpl = await window.kiosk.loadTemplate(m.templateId); + ok = !!tpl; + break; + } + case 'apply_inline': ok = window.kiosk.applyInline(m.content); break; + case 'persist': ok = true; break; // no-op locale, la persistenza è server-side + case 'reload': location.reload(); return; + default: ok = false; err = 'unknown cmd'; + } + } catch (e) { ok = false; err = e.message; } + if (m.cmdId) controlWs.send(JSON.stringify({ t:'ack', cmdId: m.cmdId, ok, err })); + }; + controlWs.onclose = () => { reconnectTm = setTimeout(connectControl, 5000); }; + controlWs.onerror = () => { try { controlWs.close(); } catch {} }; + + setInterval(() => { if (controlWs && controlWs.readyState === 1) controlWs.send(JSON.stringify({ t:'heartbeat' })); }, 25000); + } + + connectControl(); +})(); diff --git a/plugin/tools/kiosk/core.js b/plugin/tools/kiosk/core.js new file mode 100644 index 0000000..9250dc7 --- /dev/null +++ b/plugin/tools/kiosk/core.js @@ -0,0 +1,100 @@ +const paths = [ + "navigation.speedOverGround", + "environment.depth.belowTransducer", +] +window.skPaths = paths; + +mapboxgl.accessToken = 'pk.eyJ1Ijoic2VzZWUzIiwiYSI6ImNtZ2dydndkMDBsNjUya3NjeW91dW41MzcifQ.M2qxj0wL1W7plRzIataojQ'; + +let map = null; +let boatMark = null; +let followBoat = true; + +window.initMapInstance = (containerId) => { + map = new mapboxgl.Map({ + container: containerId, + style: { + "version": 8, + "sources": { + "osm": { "type": "raster", "tiles": ["https://a.tile.openstreetmap.org/{z}/{x}/{y}.png"], "tileSize": 256 }, + "openseamap": { "type": "raster", "tiles": ["https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png"], "tileSize": 256 } + }, + "layers": [ + { "id": "osm-layer", "type": "raster", "source": "osm", "minzoom": 0, "maxzoom": 22 }, + { "id": "openseamap-layer", "type": "raster", "source": "openseamap", "minzoom": 0, "maxzoom": 18 } + ] + } + }); + + map.on('dragstart', () => { + followBoat = false; + }); + + boatMark = new mapboxgl.Marker({ color: 'red' }) + .setLngLat([9, 9]) + .addTo(map); + + map.on('load', () => { + // Area Protetta mock + map.addSource('area-protetta', { + 'type': 'geojson', + 'data': { + 'type': 'Feature', + 'geometry': { + 'type': 'Polygon', + 'coordinates': [[[9.05, 45.05], [9.15, 45.05], [9.15, 45.15], [9.05, 45.15], [9.05, 45.05]]] + } + } + }); + map.addLayer({ + 'id': 'area-layer', + 'type': 'fill', + 'source': 'area-protetta', + 'paint': { 'fill-color': '#0080ff', 'fill-opacity': 0.3 } + }); + }); +}; + +window.resizeMapInstance = () => { + if (map) map.resize(); +}; + +function movePosition(lng, lat) { + if (!followBoat || !map) return; + map.flyTo({ center: [lng, lat], zoom: 14, speed: 1.2 }); +} + +const host = window.location.host; +const connection = `ws://${host}/signalk/v1/stream?subscribe=all`; +const ws = new WebSocket(connection); + +ws.onmessage = (event) => { + const msg = JSON.parse(event.data); + if (msg.updates) { + msg.updates.forEach(update => { + if (update.values) { + update.values.forEach(v => { + // Aggiorna le card nel dashboard tramite canvas.js + if (window.updateKioskData) { + window.updateKioskData(v.path, v.value); + } + + if (v.path === "navigation.position" && boatMark) { + const lng = v.value.longitude; + const lat = v.value.latitude; + boatMark.setLngLat([lng, lat]); + movePosition(lng, lat); + } + }); + } + }); + } +}; + +ws.onerror = (err) => console.error("Errore WebSocket:", err); +ws.onclose = () => { + console.log("WebSocket chiuso. Riconnessione tra 5s..."); + setTimeout(() => location.reload(), 5000); +}; + + // style: 'mapbox://styles/sesee3/cmn9767jg003l01qsbpmace1t/draft' \ No newline at end of file diff --git a/plugin/tools/kiosk/dashboard.html b/plugin/tools/kiosk/dashboard.html new file mode 100644 index 0000000..de6a541 --- /dev/null +++ b/plugin/tools/kiosk/dashboard.html @@ -0,0 +1,26 @@ + + + + + + Kiosk Dashboard + + + + + + + + +
+
Caricamento dei tile in corso
+
+ + +
+
+ + + + + \ No newline at end of file diff --git a/plugin/tools/kiosk/fonts/atkinson-bold.ttf b/plugin/tools/kiosk/fonts/atkinson-bold.ttf new file mode 100644 index 0000000..80da7d0 Binary files /dev/null and b/plugin/tools/kiosk/fonts/atkinson-bold.ttf differ diff --git a/plugin/tools/kiosk/fonts/atkinson-regular.ttf b/plugin/tools/kiosk/fonts/atkinson-regular.ttf new file mode 100644 index 0000000..f0edd19 Binary files /dev/null and b/plugin/tools/kiosk/fonts/atkinson-regular.ttf differ diff --git a/plugin/tools/kiosk/kiosk.html b/plugin/tools/kiosk/kiosk.html new file mode 100644 index 0000000..856459b --- /dev/null +++ b/plugin/tools/kiosk/kiosk.html @@ -0,0 +1,24 @@ + + + + + +Kiosk + + + +
+
boot…
+ + + + diff --git a/plugin/tools/kiosk/style.css b/plugin/tools/kiosk/style.css new file mode 100644 index 0000000..658fe31 --- /dev/null +++ b/plugin/tools/kiosk/style.css @@ -0,0 +1,472 @@ +:root { + --card-bg: #101415; + --card-border: #2a2d2e; + --card-border-active: #3a9bff; + --danger: #ff4d4d; + --success: #34d399; + --grid-dot: rgba(255, 255, 255, 0.04); + --snap-line: rgba(50, 152, 255, 0.25); + --cols: 24; + --rows: 18 +} + +@font-face { + font-family: 'hyperlegible'; + src: url('./fonts/atkinson-regular.ttf'); + font-weight: normal; + font-style: normal; +} + +@font-face { + font-family: 'hyperlegible'; + src: url('./fonts/atkinson-bold.ttf'); + font-weight: bold; + font-style: normal; +} + +body { + font-family: 'hyperlegible', sans-serif; + background-color: black; + color: white +} + +.data-card { + border: 1px solid #ccc; + padding: 15px; + margin-bottom: 10px; + border-radius: 8px; + width: 300px; +} + +.value { + font-size: 24px; + font-weight: bold; + color: #007bff; +} + +/** CAMVAS!!! */ + +.canvas { + width: 100%; + height: 100%; + position: absolute; + background: + radial-gradient(circle 1px, #ffffff2c 0.8px, transparent 0.4px); + background-size: calc(100% / var(--cols)) calc(100% / var(--rows)); +} + +/* CARDS */ + +.card { + position: absolute; + background-color: #101415; + border: 2px dashed var(--card-border); + border-radius: 15px; + cursor: grab; + transition: box-shadow 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94), border-color 0.25s ease; + will-change: left, top, width, height; + overflow: visible; + /* Necessario per vedere i manigli di resize fuori bordo se necessario */ +} + +/* Stili Header Card */ +.card-header { + height: 32px; + padding: 0 12px; + display: flex; + align-items: center; + justify-content: space-between; + font-size: 11px; + user-select: none; +} + +.card-label { + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* ── Path Picker Menu ────────────────────────────── */ +.label-wrapper { + position: relative; + height: 100%; + display: flex; + align-items: center; +} + +.path-menu { + display: none; + position: absolute; + top: 28px; + left: -8px; + background: rgba(26, 30, 31, 0.95); + backdrop-filter: blur(10px); + border: 1px solid var(--card-border-active); + border-radius: 8px; + z-index: 2000; + min-width: 180px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + padding: 5px 0; + overflow: hidden; +} + +.card.editable .label-wrapper:hover .path-menu { + display: block; + animation: spawnIn 0.2s ease-out; +} + +.path-option { + padding: 10px 15px; + color: #aaa; + cursor: pointer; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + transition: all 0.2s; + border-bottom: 1px solid rgba(255, 255, 255, 0.03); +} + +.path-option:last-child { + border-bottom: none; +} + +.path-option:hover { + background: var(--card-border-active); + color: white; +} + +.card-close { + background: none; + border: none; + color: rgba(255, 0, 0, 0.404); + font-size: 10px; + font-weight: 700; + cursor: pointer; + line-height: 1; + padding: 0 4px; + transition: all 0.2s; +} + +.card-close:hover { + color: var(--danger); + text-decoration: underline; +} + +.card:not(.editable) .card-close { + display: none; +} + +.card-body { + padding: 12px; + font-size: 70px; + font-weight: bold; + color: #ffffff; + height: calc(100% - 33px); + display: flex; + align-items: center; + justify-content: center; +} + + +@keyframes cardSpawn { + 0% { + opacity: 0; + transform: scale(0.92); + } + + 100% { + opacity: 1; + transform: scale(1); + } +} + +.card.spawning { + animation: cardSpawn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; +} + +@keyframes cardRemove { + 0% { + opacity: 1; + transform: scale(1); + } + + 100% { + opacity: 0; + transform: scale(0.88); + } +} + +.card.removing { + animation: cardRemove 0.2s ease forwards; + pointer-events: none; +} + +/* Stili per le classi dinamiche delle card */ +.card.selected { + border-color: var(--card-border-active); + box-shadow: 0 0 15px rgba(50, 152, 255, 0.5); +} + +.card.dragging, +.card.resizing { + cursor: grabbing; + opacity: 0.8; +} + +/* Stili per gli elementi aggiunti da canvas.js */ +.tooltip { + position: fixed; + background-color: rgba(0, 0, 0, 0.7); + color: white; + padding: 5px 10px; + border-radius: 5px; + font-size: 12px; + pointer-events: none; + opacity: 0; + transition: opacity 0.1s ease; + z-index: 2000; +} + +.tooltip.visible { + opacity: 1; +} + +.empty-state { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: #aaa; + font-size: 1.2em; + text-align: center; + pointer-events: none; + z-index: 1; +} + +.empty-state.hidden { + display: none; +} + +.unit-badge { + position: fixed; + top: 10px; + right: 10px; + background-color: rgba(0, 0, 0, 0.7); + color: white; + padding: 5px 10px; + border-radius: 5px; + font-size: 12px; + z-index: 1000; +} + +.toast { + position: fixed; + bottom: 60px; + /* Regola in base all'altezza della toolbar */ + left: 50%; + transform: translateX(-50%); + background-color: rgba(0, 0, 0, 0.8); + color: white; + padding: 10px 20px; + border-radius: 8px; + opacity: 0; + visibility: hidden; + transition: opacity 0.3s ease, visibility 0.3s ease; + z-index: 2000; +} + +.toast.show { + opacity: 1; + visibility: visible; +} + +.modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.7); + display: flex; + justify-content: center; + align-items: center; + z-index: 3000; + opacity: 0; + visibility: hidden; + transition: opacity 0.3s ease, visibility 0.3s ease; +} + +.modal-overlay.open { + opacity: 1; + visibility: visible; +} + +.modal-content { + background-color: var(--card-bg); + border: 1px solid var(--card-border); + border-radius: 15px; + padding: 20px; + width: 90%; + max-width: 600px; + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3); +} + +.modal-content h2 { + margin-top: 0; + color: white; +} + +.modal-content textarea { + width: calc(100% - 20px); + min-height: 200px; + background-color: #2a2d2e; + border: 1px solid #3a3d3e; + color: white; + padding: 10px; + border-radius: 8px; + margin-bottom: 15px; + resize: vertical; +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 10px; +} + +.modal-actions button { + height: 32px; + padding: 0 13px; + border: none; + border-radius: 7px; + font-family: var(--font); + font-size: 12px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + gap: 5px; + transition: all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94); + white-space: nowrap; + background-color: rgba(255, 255, 255, 0.103); + color: white; +} + +.modal-actions button.primary { + background-color: #4da8ff; +} + +.modal-actions button:hover { + transform: translateY(-1px); + box-shadow: 0 4px 16px rgba(50, 152, 255, 0.3); +} + +/* ── Edit Mode & Animations ──────────────────────── */ +@keyframes cardSpawn { + 0% { opacity: 0; transform: scale(0.92); } + 100% { opacity: 1; transform: scale(1); } +} + +@keyframes cardRemove { + 0% { opacity: 1; transform: scale(1); } + 100% { opacity: 0; transform: scale(0.88); } +} + +.card.spawning { + animation: cardSpawn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; +} + +.card.removing { + animation: cardRemove 0.2s ease forwards; + pointer-events: none; +} + +/* Canvas state during editing */ +.canvas.edit-active { + outline: 2px dashed rgba(58, 155, 255, 0.3); + outline-offset: -10px; + background-color: rgba(58, 155, 255, 0.02); +} + +.card.editable { + border-style: dashed; +} + +.card.editable:not(.selected) { + border-color: rgba(255, 255, 255, 0.1); +} + +/* Hide handlers when not editing */ +.card:not(.editable) .rh { + display: none !important; +} + +.card:not(.editable) { + cursor: default; + border-style: solid; +} + +/* Rimuovi padding se la card contiene la mappa per farla aderire ai bordi */ +.card[data-type="map"] .card-body { + padding: 0; + overflow: hidden; +} + +.card-map-canvas { + width: 100%; + height: 100%; + border-radius: 0 0 15px 15px; + overflow: hidden; +} + +/* Fix per Mapbox canvas che a volte non prende il 100% se il container è flex */ +.card-map-canvas .mapboxgl-canvas-container, +.card-map-canvas .mapboxgl-canvas { + width: 100% !important; + height: 100% !important; +} + +.hidden { + display: none !important; +} + +.toolbar button.primary { + background-color: var(--card-border-active) !important; + color: white; +} + +/* ── Global Edit Mode Overrides ──────────────────── */ +body.edit-mode { + background-color: #0a0e0f; + transition: background-color 0.4s ease; +} + +body.edit-mode .toolbar { + background: rgba(58, 155, 255, 0.15) !important; + backdrop-filter: blur(25px); + border: 2px dashed var(--card-border-active) !important; + box-shadow: 0 0 20px rgba(58, 155, 255, 0.2); +} + +body.edit-mode .toolbar p#cardCount { + color: var(--card-border-active); +} + +@keyframes editPulse { + 0% { opacity: 0.6; } + 50% { opacity: 1; } + 100% { opacity: 0.6; } +} + +body.edit-mode .canvas.edit-active::after { + content: "DASHBOARD EDITING"; + position: fixed; + top: 20px; + right: 20px; + font-size: 10px; + font-weight: 800; + color: var(--card-border-active); + letter-spacing: 2px; + animation: editPulse 2s infinite ease-in-out; + pointer-events: none; +} \ No newline at end of file diff --git a/plugin/tools/kiosk/template-loader.js b/plugin/tools/kiosk/template-loader.js new file mode 100644 index 0000000..5b15b82 --- /dev/null +++ b/plugin/tools/kiosk/template-loader.js @@ -0,0 +1,166 @@ +/** + * Kiosk template loader + renderer (display-only). + * Espone window.kiosk con: loadTemplate, applyInline, patchBox, addBox, removeBox, updateValue. + */ +(function () { + const COLS = 24, ROWS = 18; + const canvasEl = document.getElementById('canvas'); + const chip = document.getElementById('statusChip'); + + const state = { + template: null, + boxes: [], // array con .el attaccato + byPath: new Map(), // path → Set + sensorCode: null, + sensorName: null, + apiUrl: null, + }; + + function setStatus(msg, err) { + chip.textContent = msg; + chip.classList.toggle('err', !!err); + } + + function indexPaths() { + state.byPath.clear(); + for (const b of state.boxes) { + if (!b.path) continue; + if (!state.byPath.has(b.path)) state.byPath.set(b.path, new Set()); + state.byPath.get(b.path).add(b); + } + } + + function renderBox(b) { + const uw = canvasEl.clientWidth / COLS; + const uh = canvasEl.clientHeight / ROWS; + b.el.style.left = (b.x * uw) + 'px'; + b.el.style.top = (b.y * uh) + 'px'; + b.el.style.width = (b.w * uw) + 'px'; + b.el.style.height = (b.h * uh) + 'px'; + b.el.style.background = b.color || '#1e293b'; + b.el.style.color = b.textColor || '#fff'; + const titleEl = b.el.querySelector('.title'); + const valEl = b.el.querySelector('.val'); + titleEl.textContent = b.title || (b.path ? b.path.split('.').pop() : ''); + // adatta font-size al box + valEl.style.fontSize = Math.min(b.w * uw, b.h * uh) * 0.35 + 'px'; + if (b._lastVal !== undefined) renderValue(b, b._lastVal); + else valEl.innerHTML = ''; + } + + function renderValue(b, value) { + const valEl = b.el.querySelector('.val'); + if (value == null || (typeof value === 'object' && !('longitude' in value))) { + valEl.innerHTML = ''; + return; + } + let v = value; + if (typeof v === 'number') { + const mul = typeof b.multiplier === 'number' ? b.multiplier : 1; + const dec = typeof b.decimals === 'number' ? b.decimals : 1; + v = (v * mul).toFixed(dec); + } else if (v && typeof v === 'object' && 'longitude' in v) { + v = v.latitude.toFixed(3) + ', ' + v.longitude.toFixed(3); + } + valEl.innerHTML = String(v) + (b.unit ? `${b.unit}` : ''); + } + + function createBoxEl() { + const el = document.createElement('div'); + el.className = 'box'; + el.innerHTML = '
'; + canvasEl.appendChild(el); + return el; + } + + function clearAll() { + for (const b of state.boxes) b.el.remove(); + state.boxes = []; + state.byPath.clear(); + } + + function applyContent(content) { + clearAll(); + if (content?.background) document.body.style.background = content.background; + for (const raw of content?.boxes || []) { + const b = { ...raw, el: createBoxEl() }; + state.boxes.push(b); + renderBox(b); + } + indexPaths(); + } + + async function loadTemplate(templateId) { + try { + const url = templateId + ? `${state.apiUrl}/kiosk/templates/${templateId}` + : `${state.apiUrl}/kiosk/template/active`; + const r = await fetch(url); + if (!r.ok) { setStatus('no template (' + r.status + ')', true); return null; } + const tpl = await r.json(); + state.template = tpl; + applyContent(tpl.content); + setStatus('template ' + tpl.name); + return tpl; + } catch (e) { + setStatus('fetch error', true); + return null; + } + } + + function patchBox(boxId, patch) { + const b = state.boxes.find(x => x.id === boxId); + if (!b) return false; + Object.assign(b, patch); + indexPaths(); + renderBox(b); + return true; + } + + function addBox(boxDef) { + const b = { ...boxDef, el: createBoxEl() }; + state.boxes.push(b); + renderBox(b); + indexPaths(); + return true; + } + + function removeBox(boxId) { + const i = state.boxes.findIndex(b => b.id === boxId); + if (i < 0) return false; + state.boxes[i].el.remove(); + state.boxes.splice(i, 1); + indexPaths(); + return true; + } + + function applyInline(content) { + applyContent(content); + return true; + } + + function updateValue(path, value) { + const set = state.byPath.get(path); + if (!set) return; + for (const b of set) { + b._lastVal = value; + renderValue(b, value); + } + } + + function init({ apiUrl, sensorCode, sensorName }) { + state.apiUrl = apiUrl; + state.sensorCode = sensorCode; + state.sensorName = sensorName; + } + + function currentTemplateId() { return state.template?.id || null; } + + let resizeRaf; + window.addEventListener('resize', () => { + cancelAnimationFrame(resizeRaf); + resizeRaf = requestAnimationFrame(() => state.boxes.forEach(renderBox)); + }); + + window.kiosk = { init, loadTemplate, patchBox, addBox, removeBox, applyInline, updateValue, currentTemplateId, setStatus }; +})(); diff --git a/plugin/public/map.html b/plugin/tools/map/map.html similarity index 100% rename from plugin/public/map.html rename to plugin/tools/map/map.html diff --git a/plugin/tools/publisher.js b/plugin/tools/publisher.js deleted file mode 100644 index cd1b8bb..0000000 --- a/plugin/tools/publisher.js +++ /dev/null @@ -1,72 +0,0 @@ -/** - * publisher.js - Pubblica dati su SignalK - */ - -/** - * Genera valori SignalK da un oggetto dati - * @param {Object} data - Dati da convertire - * @param {string} prefix - Prefisso per i path SignalK - * @returns {Array} Array di valori SignalK - */ -function generateValues(data, prefix = "") { - if (!data || typeof data !== 'object') { - return []; - } - - const values = []; - - function traverse(obj, pathParts) { - for (const key in obj) { - if (!Object.prototype.hasOwnProperty.call(obj, key)) continue; - - const val = obj[key]; - if (val === undefined || val === null) continue; - - const newPath = pathParts.length > 0 ? [...pathParts, key] : [key]; - - if (typeof val === "object" && !Array.isArray(val)) { - traverse(val, newPath); - } else if (!Array.isArray(val)) { - // Ignora array, pubblica solo valori primitivi - values.push({ - path: newPath.join("."), - value: val, - meta: { displayName: key }, - }); - } - } - } - - const initialPath = prefix ? [prefix] : []; - traverse(data, initialPath); - return values; -} - -/** - * Pubblica dati meteo su SignalK - * @param {Object} app - Istanza app SignalK - * @param {Object} weatherData - Dati meteo da pubblicare - * @param {Object} settings - Impostazioni plugin - */ -function publishWeatherData(app, weatherData, settings) { - if (!app || !weatherData) { - console.warn('[Publisher] App o dati non disponibili'); - return; - } - - const values = generateValues(weatherData); - - if (values.length === 0) { - return; - } - - try { - app.handleMessage("meb", { - updates: [{ values }], - }); - } catch (error) { - console.error('[Publisher] Errore pubblicazione:', error.message); - } -} - -module.exports = { publish: publishWeatherData }; \ No newline at end of file diff --git a/sensors.references.json b/sensors.references.json new file mode 100644 index 0000000..2bd5231 --- /dev/null +++ b/sensors.references.json @@ -0,0 +1,104 @@ +[ + { + "collection": "temperature", + "path": "meb.temperature", + "elements": null + }, + { + "collection": "wind", + "path": "meb.wind", + "elements": [ + { + "direction": "direction" + }, + { + "speed": "speed" + } + ] + }, + { + "collection": "waves", + "path": "meb.waves", + "elements": [ + { + "direction": "direction" + }, + { + "height": "height" + }, + { + "period": "period" + } + ] + }, + { + "collection": "position", + "path": "navigation", + "elements": [ + { + "latitude": "position.latitude" + }, + { + "longitude": "position.longitude" + }, + { + "headingTrue": "headingTrue" + }, + { + "speedOverGround": "speedOverGround" + }, + { + "courseOverGround": "courseOverGroundTrue" + } + ] + }, + { + "collection": "service_battery", + "path": "electrical.batteries.service", + "elements": [ + { + "voltage": "Voltage" + }, + { + "current": "current" + }, + { + "stateOfCharge": "stateOfCharge" + } + ] + }, + { + "collection": "traction_battery", + "path": "electrical.batteries.traction", + "elements": [ + { + "voltage": "Voltage" + }, + { + "current": "current" + }, + { + "stateOfCharge": "stateOfCharge" + }, + { + "temperature": "temperature" + }, + { + "power": "power" + } + ] + }, + { + "collection": "engine", + "path": "propulsion.0", + "elements": [ + { + "proipultionShaftSpeed": "revolutions" + } + ] + }, + { + "collection": "system", + "path": "system.uptime" + } +] \ No newline at end of file