From bb8d267cd4d32d1e713dd4ed0f341e5e6f9df070 Mon Sep 17 00:00:00 2001 From: Giuseppe Raffa <77052701+sesee3@users.noreply.github.com> Date: Thu, 23 Apr 2026 16:19:11 +0200 Subject: [PATCH] Aggiunta stili CSS per Kiosk, struttura HTML per la Mappa e Riferimenti ai Sensori MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Creato un nuovo file CSS per gli stili del chiosco (kiosk) con variabili, stili per le schede (card) e animazioni. • Aggiunto un file HTML per l'interfaccia della mappa utilizzando Mapbox, inclusi gli stili e il JavaScript per le funzionalità della mappa. • Introdotto un file JSON per i riferimenti ai sensori, definendo percorsi ed elementi per i dati di temperatura, vento, onde, posizione, batteria, motore e sistema. Co-authored-by: Copilot --- .gitignore | 9 +- README.md | 46 - docker-compose.yml | 23 +- package-lock.json | 1444 +++++++---------- package.json | 19 +- plugin/api_models/openmeteo.js | 246 --- plugin/config.js | 138 -- plugin/config/configManager.js | 73 + plugin/config/skFlow.js | 171 ++ plugin/config/skSettings.js | 53 + .../aisstream.js} | 0 plugin/cores/logs.local.js | 250 +++ plugin/cores/openmeteo.js | 261 +++ plugin/cores/realtime/auth.js | 50 + plugin/cores/realtime/core.js | 106 ++ plugin/cores/realtime/socket.js | 114 ++ plugin/cores/weatherkit.js | 0 plugin/index.cjs | 276 ---- plugin/index.js | 118 ++ plugin/public/css/data_console.css | 59 - plugin/public/css/helm_suggestions.css | 177 -- .../helm_steering_destra.html | 336 ---- .../helm_steering_sinistra.html | 158 -- .../steering_helm_tip_builder.html | 589 ------- plugin/realtime/core.js | 450 ----- plugin/routes/collection/cloud.js | 27 + plugin/routes/collection/dashboard.js | 16 + plugin/routes/collection/data.js | 38 + plugin/routes/collection/kiosk.js | 34 + plugin/routes/collection/map.js | 9 + plugin/routes/collection/rec.js | 55 + plugin/routes/dataset.js | 56 - plugin/routes/forecasts.js | 63 - plugin/routes/helm.js | 30 - plugin/routes/index.js | 34 - plugin/routes/main.js | 18 + plugin/routes/map.js | 43 - plugin/routes/telegram.js | 13 - plugin/rules.js | 72 + plugin/telegram/callbacks/backupback.js | 29 + plugin/telegram/callbacks/backupdownload.js | 33 + plugin/telegram/callbacks/backupfile.js | 73 + plugin/telegram/callbacks/backupnoop.js | 6 + plugin/telegram/callbacks/backuppage.js | 29 + plugin/telegram/callbacks/close.js | 25 + plugin/telegram/callbacks/dashboard.js | 152 -- plugin/telegram/callbacks/data.js | 26 - plugin/telegram/callbacks/live.js | 241 ++- plugin/telegram/callbacks/livestop.js | 15 + plugin/telegram/callbacks/logbusy.js | 12 + plugin/telegram/callbacks/logfile.js | 69 + plugin/telegram/callbacks/logs.js | 51 - plugin/telegram/callbacks/settings.js | 80 - plugin/telegram/callbacks/status.js | 67 - plugin/telegram/callbacks/weather.js | 26 - plugin/telegram/commands/backuplogs.js | 110 ++ plugin/telegram/commands/dashboard.js | 57 - plugin/telegram/commands/data.js | 99 +- plugin/telegram/commands/live.js | 84 - plugin/telegram/commands/logs.js | 88 +- plugin/telegram/commands/marine.js | 30 + plugin/telegram/commands/realtime.js | 24 - plugin/telegram/commands/settings.js | 20 - plugin/telegram/commands/start.js | 17 + plugin/telegram/commands/status.js | 40 - plugin/telegram/commands/structure.js | 47 - plugin/telegram/commands/weather.js | 97 +- plugin/telegram/core.js | 121 ++ plugin/telegram/telegram.core.js | 269 --- plugin/telegram/utility/close.js | 17 + plugin/telegram/utility/live.js | 130 ++ plugin/tools/dataHub.js | 78 - plugin/tools/healthcheck.js | 50 - plugin/tools/kiosk/canvas.js | 336 ++++ plugin/tools/kiosk/control-socket.js | 90 + plugin/tools/kiosk/core.js | 100 ++ plugin/tools/kiosk/dashboard.html | 26 + plugin/tools/kiosk/fonts/atkinson-bold.ttf | Bin 0 -> 54444 bytes plugin/tools/kiosk/fonts/atkinson-regular.ttf | Bin 0 -> 53504 bytes plugin/tools/kiosk/kiosk.html | 24 + plugin/tools/kiosk/style.css | 472 ++++++ plugin/tools/kiosk/template-loader.js | 166 ++ plugin/{public => tools/map}/map.html | 0 plugin/tools/publisher.js | 72 - sensors.references.json | 104 ++ 85 files changed, 4293 insertions(+), 5083 deletions(-) delete mode 100644 README.md delete mode 100644 plugin/api_models/openmeteo.js delete mode 100644 plugin/config.js create mode 100644 plugin/config/configManager.js create mode 100644 plugin/config/skFlow.js create mode 100644 plugin/config/skSettings.js rename plugin/{sensors/sensors.references.json => cores/aisstream.js} (100%) create mode 100644 plugin/cores/logs.local.js create mode 100644 plugin/cores/openmeteo.js create mode 100644 plugin/cores/realtime/auth.js create mode 100644 plugin/cores/realtime/core.js create mode 100644 plugin/cores/realtime/socket.js create mode 100644 plugin/cores/weatherkit.js delete mode 100644 plugin/index.cjs create mode 100644 plugin/index.js delete mode 100644 plugin/public/css/data_console.css delete mode 100644 plugin/public/css/helm_suggestions.css delete mode 100644 plugin/public/steering_support/helm_steering_destra.html delete mode 100644 plugin/public/steering_support/helm_steering_sinistra.html delete mode 100644 plugin/public/steering_support/steering_helm_tip_builder.html delete mode 100644 plugin/realtime/core.js create mode 100644 plugin/routes/collection/cloud.js create mode 100644 plugin/routes/collection/dashboard.js create mode 100644 plugin/routes/collection/data.js create mode 100644 plugin/routes/collection/kiosk.js create mode 100644 plugin/routes/collection/map.js create mode 100644 plugin/routes/collection/rec.js delete mode 100644 plugin/routes/dataset.js delete mode 100644 plugin/routes/forecasts.js delete mode 100644 plugin/routes/helm.js delete mode 100644 plugin/routes/index.js create mode 100644 plugin/routes/main.js delete mode 100644 plugin/routes/map.js delete mode 100644 plugin/routes/telegram.js create mode 100644 plugin/rules.js create mode 100644 plugin/telegram/callbacks/backupback.js create mode 100644 plugin/telegram/callbacks/backupdownload.js create mode 100644 plugin/telegram/callbacks/backupfile.js create mode 100644 plugin/telegram/callbacks/backupnoop.js create mode 100644 plugin/telegram/callbacks/backuppage.js create mode 100644 plugin/telegram/callbacks/close.js delete mode 100644 plugin/telegram/callbacks/dashboard.js delete mode 100644 plugin/telegram/callbacks/data.js create mode 100644 plugin/telegram/callbacks/livestop.js create mode 100644 plugin/telegram/callbacks/logbusy.js create mode 100644 plugin/telegram/callbacks/logfile.js delete mode 100644 plugin/telegram/callbacks/logs.js delete mode 100644 plugin/telegram/callbacks/settings.js delete mode 100644 plugin/telegram/callbacks/status.js delete mode 100644 plugin/telegram/callbacks/weather.js create mode 100644 plugin/telegram/commands/backuplogs.js delete mode 100644 plugin/telegram/commands/dashboard.js delete mode 100644 plugin/telegram/commands/live.js create mode 100644 plugin/telegram/commands/marine.js delete mode 100644 plugin/telegram/commands/realtime.js delete mode 100644 plugin/telegram/commands/settings.js create mode 100644 plugin/telegram/commands/start.js delete mode 100644 plugin/telegram/commands/status.js delete mode 100644 plugin/telegram/commands/structure.js create mode 100644 plugin/telegram/core.js delete mode 100644 plugin/telegram/telegram.core.js create mode 100644 plugin/telegram/utility/close.js create mode 100644 plugin/telegram/utility/live.js delete mode 100644 plugin/tools/dataHub.js delete mode 100644 plugin/tools/healthcheck.js create mode 100644 plugin/tools/kiosk/canvas.js create mode 100644 plugin/tools/kiosk/control-socket.js create mode 100644 plugin/tools/kiosk/core.js create mode 100644 plugin/tools/kiosk/dashboard.html create mode 100644 plugin/tools/kiosk/fonts/atkinson-bold.ttf create mode 100644 plugin/tools/kiosk/fonts/atkinson-regular.ttf create mode 100644 plugin/tools/kiosk/kiosk.html create mode 100644 plugin/tools/kiosk/style.css create mode 100644 plugin/tools/kiosk/template-loader.js rename plugin/{public => tools/map}/map.html (100%) delete mode 100644 plugin/tools/publisher.js create mode 100644 sensors.references.json 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 0000000000000000000000000000000000000000..80da7d0a9a99f8857df5b251dc775f9d6e0fc4ad GIT binary patch literal 54444 zcmcG134GMmwg0`p-)u=H%VaXyCd({YW|GM|Gns58lSx8=EbNdVLIMe!Y(hX(R8(*Q zwJwP0qdsk`zFM^tTuNQ3`_k8nwpJ;Ewp0ZLEH1THX8zxEe>2Ijz4w3b|M_P)_uk)K z&pr3tbIx6UKNx3>8Q`c`N=IA!_##8$<&4QL#D3Q?W%Bgm?b`!nA@w4PiT&d%MGWUR{;}`y z@ciV)Z&2DKbCm6GIiU&A7lY83PeJru?O$9gkDQ>d>QA+1uO1t1$lqZTg?_VYpKj}ZH z6l39eV$TOJC39iE|-WSO#o-vQXhI`^>Gr}d6-eoJ4F z|60d%Y~kt=rVTv7O3gAWq2RA7RTf>UR*qMmJd#6`r4~=|foJu8onN&VNGBjQG4L$& ziyuAzn!3_9QowJR+8gxW&`ZyQZ6gkP-lJnm1Z(Rvj}%kX)4ENJtNb@CoFQ@|f&P9~ zE!i|_YB{N74-an%s%{>USli?3K@DqbBLZ=ZN>YSUPatdtZxDAFm=UgtWx&m1*>H1M zE?f(t;T<^=us678Zc}Ec=deE_2kwP239C&P(Bz@g}%_-U+vh zPhm2j#^=DjkoUtK-~)(Xz!xGsggGMfrF8Pcf_wdPl z3Tm3cXY$#+m-q2GsBs>j&-Ddy;*PGB@!_R0Bj zw|uXBSe38pQms>cp-xvft5>UktbRs)L}SqOX|`*=(3WX$);^~FSf|o?bp5*Pbx*`q z#N8KnG=5(EWAT4Y*qrc;UaP-Af4%;(#N@_ee<1yp zjKqw#jB7FuWyWRpW^T(2X60o0v#!h<$u?&H!hE;+m*)NE6FHr^hTNXq`*UB({U}eB zSCTg|Z*|^(p7GSwdM+SxeasWgnF%mA98aT9H$6L1kOzW0j$*?5ai8xz%@7|Frta>es42 zsQz2^-`%o1*==?gyWQ?)cc*){d!c)k`*Qd7?mOHMxJTU2x!-XA(fzUenERie1W$&? z?y2xJc)C4(o*~c0o@+gKdhYi;>iMYePpz`NLc zsdtO_F7MC1Pu8#+UCo4=DK)({{WVK!R@ZE(*;I2w%{?^_*6gl%zUJ+k12xBMzV~T- zm-ue<-RXPC_qgvB-#*`mz9YVGYI&`(HovyC)>k{Rc4qD3+O@S?YHzFkaqYkAv~|Y1 z!n&%uadi{xE~r~v_w%|F^}If@KEJ-SzPY}$es=xR`pfHYuK)M?U)2Aq{-yfg*MD4p ztp1yZoed8+JlpVk!yg(xYB<>tYK(78Z?rX*H`X@}Hg0QtzVWw>-;T>0=Nh+j+>7Hr zYf5XH-Zap(t?BM&RkOEwZu8a6&osZ${Hb5#PxC+Q|Gs5%%UvxGw2ZX;w&nen!-3Yo zRPZ;=5t58)V#biKk}t#aR)|#%i)V?L%N48+;${x(XP2=nAz6QpneaTxTz^IP%fmA60!+`BBA3u8-^=nLkSX z=(CT0d1%+6n+{!a@Mj0_K6vxNZ3k~SxaHuAgHsRIAN1{i7jiB7cTF@Pe0ti!izv)J zB1Sme#j7DxCqbT4${fI;2rYu#Tg`|029fQ5=d5NIvy0erwt}r>t6(SevOc~HTyp`N z%PwT|*nHNH*0!^~>^G45&#~v(3v35_ll_+cC;J`yJ^KUuBYThi3Hsmw`;Z-EhuBB# zW44Dq&i=yw$_}%?u}|0&>`wMFdxO2gUS+Sb*TIKxvA5Yi_6~cOz0W>i``Mqt4WF{l z*b#P=ea^mMzh=AHF?Jjl!k6st>{sk0dy%~aeWT)Pu7So$=NUYcxAXC!OAGk78GNnd zZQ!74?gkfmS&)r@#|wBZuj7T_uVkLWo@USRdT>}HAIGzJHt*mQ*jLaFlfd^L?qlCz zPB^%em#}ZyKlnswr%q_5U$BRvZMvX+RzTbIK>N&w#+eT;ods<(B*>n~enK-*6^Ew< z^43V0vl1D4PsB@1C-+9eGMgZ$M8YcOkq$({YBpZl5eaKpwKNzB>zGXnM8a|GGX8ob zoWKJ7x=2{h%6YW@L~dg>k@zHp7+b=I@mtN-Vis25x0WqrYuHl63?p0)SPC8}2M4TS zi;+H=jb~kK2Ffi(ngvg*z#qd1O-E=ELYH7BuVF)oQF1JYzYxzWk$;E{B7Pmhg9xud zco;QN-sx;2>b0Q$)go_f&0VL};6kohqBP+~ty@?XFeI?VV0L=DQEUca=MA9Ta<~iN zQrnl{neeb6j@oTuQ$=_fG_m|IDHNrvLRISHa{N}Ih1K{GZbXZ6P-YcoSTuKZ6fOZy zmmoC&8Y$QkrV|AXhXuClfbkG=M8~CF)KB!F@g#au%@ihjbtCU$)I_vuNBOm=o#;=! zrgWn4N{kDQXh2{@c`1cPdI&Lt0A*CC;CUF~Rmgo2yYvhkE$6_p1^F)$Ev!YUm23h^ z(I~G0&V<*}$VillwwI%=bB%8$WN!^?08Z4aMd%IDU={Eq+^NqFj4g3NEx-+1^~`oH z=WSW(HMLCTqb>D-8+$;nhO=t=?|VR0r278vacD(})u0T`zQuwgEUX&15Dxn=x2@XqwOu(q}9Ru5=$l_F9|^O8-& zS&F<#p1=?A9r$%YZWnPC^uTVm45M1fU@0IE<c@(REQ6Ji9_B=PSb#Ow4Urydfd|^)>rqpxs7(X+{56eXWMPR| z$DOZ58tE#GTMxA34Cu)|XwLc2nnTc-D+E3&Xy80(^(0`RwEcAiCl#I-VRTZkK2606 zH4Q7&bgWS`#TwNt)~IRip&X65{_7P%96VB(BwnIVrJI;4}&L-4Af*v46oGV&5Y@%6mO1^G_CH zVN}R5r=oLcR5pOG5@07MvZM%)o|`L{gA+OGQjg^yorC{Gi~j-!r^ece@c6lT=pyuw zNy)H{dVwdQ>Q_LziIwratU{UuTj@e(mDaEfX^=Uk4a_a&vt;RFRwPvcb~Ckf6>~}V zGrRN{{5F6E@OwNz#9aJMRwq>=&mD-%VlH_S{AU3_Abky<6XD;2=ZjdDv=sH;h48H` zUS7-MvELOh{SvT(StKXRlx6@=o$xcI8kWgdq28HzZe-QG0nc*yX~~ChJ*$@2BQN5m zaglO;NFT;?Iy3RhS)znI5+thhF2DqU^paF8kH5fWFczfMbAL!4q@g z&jh>$SOi!DIe8T0@GRg5<^&%(<%dA$?~pbLX#wy8jrG4!b}`ysi*Pr9c*cx87$a37 zv&%n69P#Y=fcWfuKs-+TM?4-2;6>ucC=ky_z$W}M?7NlG*oF@Pz8~d(ngg*wydMk1 z`%xaIzQqFZZ!8f163{#XkB`n9@U*N3zi(i9fFVF5pcY^O)BxOo0D$Ipx1bC4pXftA z^^^K`KA-RhUXgw#2p@A%$OEEPlx9)9sDFg_x%>vilWd?qM}3<6=!1{}T$XGZ9fDdg5FlqVSl`622&6Yr>eLft}8 zp6#Ta9d&jL=%I#O^_`hQ{1J_lCc}C?<4?$~d7v};Lok|5Rv!MsyCJX6@dp{nxWuv9 z!&T?B0mihxla;Vi=)^K;OWLQd0v2xAabBz+e5@Ar*Tbf7#IAK4to!k-1G;r0tm00r zgSr`6{Zp_4oQ5^j3^tR^QuaPZcRt={A7fX7Y=w`oH}Na%Up#?*i&xk+uqm#`p1`YO zC*)c7W9+N_4Et{T*|qErj@=1%GuN^k*h}mn*RgxC&-D$g)Nf<#mRxA!Cf3aSu;ByP zr&f(w+ox$r#SRiXFtG4{$~Lnt*nio9U6SkAEo>Wh9ByQ{vIp5N*gikP4$Ym|HMyIe z;Bh>jJ-}{<1*2y>c_O<9ryhr}%d#8x(M>!_?91HGZo~e{ee5V~>o2iegWVGB%shq_ z+CugydqeD&EW>JU1y(mJVZGA2@GaN}ufv|Y80(icSQY+0vIj)#v-MbMF2ZgO?dtpz z>zen(zR(iv70qF9(K*8`KAS8bvQ8o=4j1sbqo)o7;@n{ncI_f@ z`mhwcl{m$qQwVVIpRg-;F<*nd)M37kujiLwAM;Xv8NZxwRQ1g4>XKTgY1ge>T3Ojr zsrZ5Fi0>A@JJ1?=t|Py?y2=~z#dD>*r7mu0?eNkS1H(guN{Kp;$Wc}0*0!t|Sh!}@ zO6@?zziepDs`BAQnpG=@$Q!ev;z^pJ<{rs;>WiSt{NU#xNvCYaQx6{P}?5K zJ*4=W_JspLSu-TO#K}w6tz0~?X5EVA1M7wpSH(VQCr0W==S43kMrvBB`0=fyjl?e; z4Qi$jEL=A{q**IG?aWBob&9W^xn}9g#p-qBCeDm)Epc7!lXhmLt#uKsdz4yNDqfG$ zhn0#qM~S>lc=2;aQHj57G^m=eU|@}E37q(j(a8A4qd_sURn-blRJjAnv%6I@Luq2U z;&mzBu<(>T9>Fh_Rh}BnGzAYV+KB+ul)P&cuMHF&7}m5YPlGWPxN9Pf`65Ut3|d`P zt3204pIhQY#S2%hSdcUtDyRJdk9~;@4phUKoV|yWzp*1E$wbHD+vO1Dj z9h<1&=B})bB-X|xR{A25J`t&EU$0eY9lw5YJ-C7ouU6#Semj3?0h?LeE7%7xngHr2^4VIwZjzH_c zU{Ez>!){5EP)*P}lw+mX-AN`M$gv=359~H_Bhu_aHl=r{Z+AMUoMl0kD=3!+O|8Au zUNF72HIl(>8MFlVP6?`t=InOx#MbtO?Lkd@uQeza_D#E>7n#jldo95!QxN6vGh2eS z6sqm(vy3RYfsq4Ikta*AlF}A< z)h$ceR6|xfBV9q zy2OgaR?Ak)R$Ue;`z%5K^j@Tyi7pXT%7R)~Fs`+9Hx})J z>N-5y19q^BJuncI7Ay+#g+L^zEh!7exh#Zj61t^g3y=l)`uqDROMjcdHr};6E{V0a z2TH7?jFaFxosslPExZ&cw4&?%miDdo0UA_6Y-Xkb30llRGKwXJ);`dtG?93o;=uxx zLgCS_#TH5;{GFQArKE5TKg?q`z=8O$W|6ib#>0@9Z?Oo^%VpYhwK~5f<{;8 z)ZWhNN~GC}c%z6c0wE`o_Ik;4eXtcKAK9) zf*Gz6PJSlnO@5YZL?%DmHKHQl>>5#%pW_!a&sMsE6{W!n%*QHBg${7+IVYrjpw>=;{lClsb}S24M~8SMNz+ao7py87 zQFBv!FN7kEqC18>=gZ@9S-b*sFKScjX+K*`K>C~$TZ(7tPl#n)Ta&$Z#KTS0*Ba0R z9XMy1eoU5u+OnX}Ri4pU7Oeg6vVyS}B6}T%h@}@=$}Jrv5I-V@foN_^ zu%Bd#e`@dJDvR1;eq2?g&h874q>YErM*G0PKE6Mw!Dyd8T`{wGsiKust^I@cpc+aQ z!cNsXU`DvV5Axy6G6O&j^4~swpw?_h3**sGc%l{5caBDY2#I73ga`&h4TggTIRg{a zI6&HpkdWah_L35bN=Jtxs42wLL?n&cF3KRD&3XOy$Y??Gmj#=ntqJN!hO3_poHM%| z9b!M^M>$b(d@!T6cZwN0)za8kK2pikFmGEa(dr7;m&Rs@ z7K%35<_b2HZUy@hk8Q>LqNn#r#u}4<}z--s!kcO=Y^&$k_ zj!>WLaqJ^dXbwV3+y#`6Q`}t2CqfreJ`tKn`9x?wwb_nr{nRD}2B=L6ETA?iu#oah zKwyyaP+*AiP+$?|p}=B+R~tf01YQ(cD)6GvGJzL`E)sZAXt}_PLMsGb6j~|pqR=YP zxN&p+f|UI-)H6=0@^U3e88#|G%5VkpHjUPIrFatcU8Mvm`z9qw*{?>)CZ)b>lptle zRtZvu&B)t4THhA&B>zH( zj^hp3``n0~zSTI}9>j@ZFZNEm;y+{KnIAi?9&u**Yk4Y4{#wEeWLxA5g=>MkL+*zA zJNa62r9Z)4DX)XOM0y`?hrEzn>|=BGkO)5{!mr>|gtJ$q_sEr3i16ERZ!=wgNWC_-hcxUHSM$fPMh((#6jJ^Z+IR0s!1Sj;{oi015zk zXzPD=4>?LpOmUn}JK*MlmJ&dN@JieygGhK}ixamrC1-d+4rY;p>y$H?ZJ zwAHI=LjAQw8BMitUBWFAu7&!d$rf&!aFc|qBf4oMa@9Ww_gmq9sXnSctUf5*_l5h8 zaDPiwRliD9Rqqw<)53j1xQ_|GQ)gGdfYPSeKBEk<+?@$|hr@Bvs z?-Akcs#_3q1I-21HN>l`jUv2W@NKyYrL=!hEhD~F4I*`(;D5ox%1nvy>U)%5b5ezy z1*&N@UsPSh&#Lhv?5D6oRaHIB!N?pruJRz9t}4eI%v4e9HbH?LF^?WmK`Lo}r%FMd zM^w1;q`gn2in?-$XsG!e&Ydw|^$lvu{OAn+!R{og{<#8j{Bd zL!u)Lxy#p*jN)tY77gcmk;fpDf~ciF$HGTNR=$v!GIrz^X#z>=W2NCb0dPi1&${ zy`p}-C|fCFRH9vns5xJR^F@F2N%Qblif2ENrs7p1{wjfOm#C+UbQE7JVv2=(rHH?h zG$FrIq&^_x`-HnfXvq7i74%DJHqmaf(7ksF`G2L*il0(F=rz@!L}l5FqJ|uSVUEDf zFL3*(z^y{GTOsn#5;^0AYZQDkB-|F!R;~=4%yUIOxuQq80+a7WJ-LEbeS(IYL=7I{ z_Q^RY)hAl%6TRybZ9$76Uf315pABA>gdM?iMPE(`_jxfo;{G4kQimoOIgFxlhmm(g5LJ5#A%hU5YDW=v_F@y&^nOgeyh3LxkNF zmVyG4D@DweA{AB!O05uKaWj%9ig2@tpCrQcUL42md2&A$F*8K!3=y6u!fPljL8_oO zn{X>c>TD727U3Zg9uherCy}#HaSjCl3*Mlq@Z661G2KWo$0N_2q+vxS1G2xdHejY$00LjcA0YJCFHf69*8SGOA z3zfk}m0+b3Ft!n}9sv0di!Hi1=GY_*M}fFT!mI-nQ}jF&@JE# zS*my|E{#r+ak~O<3@}Mo4epWT6x(R**f}zt78O1m-XD#K<__-F6oa z_lG|Wzf8D@*k35AB22VSjlqRVAy@cIN{#&+Yc)3Z9AVE`9F62zl+q&|Hl6YZ{?XG@ z6a$6V!uN%L8qIMoAH51&|U&39V=$lG!KnlGz`mR&-ou%kIPh;;pjol;uH0Hgf zleqaqw~GHE?iDNVF422RxS>P0b8y2rDRRSDd7mj8_gyU9hPRf+?zzy77N5AyLN|<+ zn=JI+6Ws&vge+YSIWbj8aTO?Hz>^6-y1kwYHy=L7P^TDofoVNT_t%|F7PO*w zd@C>(RrqN@vl>VpAATIvtApEspBA^M8leZq;itykr6$Ov7X0EN`2)C3yc<6bYCefN zpTduWs!yZcXYiwUgKK_ ze1=;>9JKrrE&iRIL|b3urv*K~L7s2%O9oy4!Q!BEWZaC$4|m4YT#c{>x3FZ;8MmK7 zX|gT!xC;d<3b&t>-3<+H8>K-Gn{ba)!rNr&h|k~|2xsz4gtK`z)8YnF4&-wl?yIXY zE*7{pZi8FE3z!aLR0P+-9l)W4m%uIKWvHQ?m*bXq1+PFD_mU9ya1U<5`S6BW0!Fe9 zrJ8v&T4}+}Sx)znkaIjAkMIOO0qxS=ak^zc01W93X)SIREeHK2+)g8Xt&DU6v~enE zYQ#^1HqHPYv++w8V=rS)SddO*pMtqz2L*`(3=s|}#puUju9Shw<@lMzcxS|ncT&uF zCyMb-6yvSO9lA+qvlBlZW>h!AJ^1M`v!=nl7C#+k*cP}u@RKmpuoEcwgA4wU&%qzW z83}?j;xONSk5USM`~mM4D*PeCLiihcL0n>p;f%x>&M?Gqh8`Blci;nsKNQZ;axG}B za7G-*+Zhr5NZ_Dugi8{6BEkx<7`OprfSV2xUP%zV5+`^iLGVgkgjdeyl45ZeoA^VI z_XS`<2`*8%AqBJA%hCi-B;lr7J#>E~Z-m4d-eC~H|y8y=O&y?IFRs8!eBxmp)vlGxC3!7CLD-s z)Sc9Qp?eiOCwZy^sy9_HtDaW{RS&CnA+$rjRnCV0vUH!cL%K=2Mp`Y^!}UluDJS6_ zxVE?#RY9s%71Ym-{ZXmdAKDc^)GdAqjml3-N9o9qT1V~I=)RD4sNPICpr5OHSwC0O zQ|+e#%2M4TANjB1ipou@mlF=C9!@wQNlFU!3Ms%Fd{hkn(Sj;*mEDEpLEff8&JwRRfsQSJzXMJJBvynx<)ER2R&#L6mG(U|iN1)>!y7v}%!xOx%ONwW z&}JjXgz9KU9zSwXT~8wYCBVyoR{*a9UIV-icmwc0%6$OX5BM`6(l%Fxzv3D|DrQ<5 zz{gVf0Q}`>BNroS#5kt2D!koY3#=xEKZX3tV%_k2;75vwz<$7=!+(SJNr%=+=cx#%0R|A?0_<{_0X;VXasl~(QoM!k0+a!& zV3+&g*8v(4-VOgLz|+Y04E$$N{yD(&fENIJ0lxvf2zV2D-a?+Y0s8>&0Nw?>k9s}; z><9cA@FDUY1RMf<1o#;68S)(g90hz1_#*sQjA{->HHYgEjtA%gNr2Sw*XYsL9DU&F zc+P;I3BL?}1;B^2W<0k5+VI>C7?1D(;+La+4lZ;-PZfjnJ^_3K;NS!!;tF8*5uXy* zP(A~t;912oVcQlUPi^?O%#YM5pyOfC@i6Fk7<4=gJ|bEk2Q80-md6=z0X;tlJ&%K) z$JsdKX+m5JU^kwhg#Q%$rxE`Q{Fm_jGT;@!tAN)4uLIryyoo$-Afg^z>g z$3gSsp!spo{BzL!bI|-SXnq_tKMa~5=Q;5602Y7^PzESR`3m@z@T&mL$kzgBLtHyx zJi-IOwTC5x@=}0I1qWqT$o{jRL4RVDjN8DTta3ZY~J$C`h02Kf) z#<~XJL%0smfbfp+Pe9Fmpyqy1b6NJU;~vpCSDS;3(j8z!!k85&sP{f~wDfs{29J z=Rno{pz1zQbswm@4^-V3;mdsyzTC&NK}|?_j(Hs6$$b%?+y@Hp1BHut9nzW+ZUKz( z<~}|F@sp6=30R8u<1qJk!d^9ivPD8;khbt*XQ37{p&qa!e65fZ$DqAQ!jfz+1{DJE z|0{|43G#dc_y_ecBJCUqm!O;#WBeFeeh#weuR;?#fD6qA8ZYAdXOaFK;Ca9cfW3g< z0A2+ASDgL{$_u)}{}z3f!;eKc_A$Y+L_4AZ^<4u!h`ETq76Q*Agh4TA#vJs`fRG80 z3&;nQh7Wv_C?o{So{_yt5CyatOR~ z2)wcna%>;u*dfTV*I^|jV>F!M6Bv~f7?r~qmBSd7!x)vr7?r~qmBSd7!x)tlm@y|X zV@_bkoWP7Zff;iGGv)+F;V?$w1UURKM&ShbsT?vAU)sPZ6rgO3w&EV*P(UZr@C_}D zOlJ5aSdAIL;wZTB7GQD|nCu58`+>=RV6q>W><1?MfysVgauk>x1tv#<$x&c(6qpok&W5DJlusI2AP6C^gz~&^dISFh|LPHSf0osg%MVHKK{O)9ZLVTP~tCb~A`}C5`q{Uc6ad|eAWjHlX4R253)4 zI&Kr>W#px$;@BQbO^q(SRBbCN@_K6A)#;`*jmB8x^%U7{T4S|O)@V#=>Ads8O)akoANXr4P}<@>ioA3 zvQvX7X#*kc=wl|U@|WuqB)mo5#c|9d&M1^VqFx!)jn<2A@TC_Pi+a6HlIV{{TUk71P1%*q{ zdKqi;w`6BYvZ{znvRWmoMgOs#g%nIap|D8%M0KR3f`ip)tH^p;j{b(N#s=`1rWt8^6?7nE1K{dsAnEoC)R)AXLE{P7o*gSRt4 z#VHsM`Z`y&zakL?#E3{zml_?x#~mb$4$g79HC&~#t1vqH1mul37*Y*dU3RI}YxQ!s z!ELgexZBI;hMwW=JLk>~y?)=#tN4APORl?_8(+kbLjrs)(TP+(Yw>-k zhA?R)t`@|S=VLzD&LCD+Mw&6zkVF?s@_BxQSYxyTPZk@kCaZRI95uXn_Vt#{NzH}c zwvLKP1=pAG7eX&2XLQ;dFKX?)T;DyZvZ}DDzNx;Yn$OyJg(20mxTk9g4$ZJm1(pGf zDt(Ki!{4R{H4-t9My{5{sH#=6dMT(AnL}C-Wpr5c^RP;==Q;D87~JG|9n0mpy3u(= zqnMtd#n9R`rZH5DqvK37s-^!DpQU|ePuHUC8{$i{y{%d8RSk=q1H)ZawYAli4GsD! zx6Yez-LfXHtIA-UIx8dFH`Ly~yy@Z=uQ%YYslmP-~% zc8O*fS^Q{ zIv8WZysqbp3H7zfhN{_hb+aq2wG9(C_T&^7<>ok@IqpEf?Fj_*)eAbt_2g&z^1Zzt zPp`MJDt~g*_yyJcQD;uB!-1@!eI7?id3lM`jWI3)g;df*SS{OFrN2xAql+XmP1aG- zDCm@lxsjKXVav23Z+ekkOZ0*;vX?`4j1HkzqtOEUVr690(jU(CW+yZ+Y4R^`39M`} zq*{8M9#KDNK#&g~q77>9cJdkn zIi^26OWJj6@RCdZ5h9)7LfuPIcMA3rY^=^-6Ng!c+LiHyK9cd-4Qhf2F<*nbo}w9> zB_}&8!)QRcWUE#u6jMZADnn0(0bX_+qOB%${gEn$0rxv zw@bTnJI9xg&q$dtu5%8)?@*X;v4@s8?RL74W(Uu01JC7BZ=TlFQaZW0prCnjX^XVW+I{u>{!QK1ElJKPfxuKJ zCN3APmZQ}KR^fL63mNw>N&b$Fj2a`OR@>DyixXG^hTi}|Y$kKTWHpf-wwhd$6x#K{ z2YgW4wQYFY_1mH~$D`&r=JXexU9&nW-jN}W>eN8ED|Mn?gNq*w?dAT^UQuJ{IMqm_ z^$U!aja}CnG)?LCCqN0SB`asL)MXxh(%`92$&e5Jh5%KOzK={bF{B!;B&-m#-+qo9 z8jZbMWYa3@v(Inh%#xp%n`4f!0M0+g1g)5ur?Wtf*K7A$4fbLU?_Y3B_pG~zdT(~z zkkaa@=qc~-DQt7?;GQMve*MfJFI#rcjPY%?*+rG}JCRIA?Lc68}Ge0bWGY64xbw2sR{-0tpR?Us1suX zbC65(K^d}yU`d{DoT)~LY)m#&-Mo3cckbMc{AU|ChURVrR%(oi3N@xMhrf_!kC@=7 zl6YtV^f?)OoF(cpaZ-4S3KJ*8R#fcEgLbO%8r+67O?c@>(`}lp()?=gy<7B)W_okH zw&p2(S7`8xA4TYJqrkbyZ;y}DfJ24dMT9_uQ3V4+q^3qpIJd#*#&nUjCfB`x|4ryS zZu-^BZPKpLKX_c|FQIFAO6M!F9I9sdemapANK5@=N=wbu5v2u0!R7aIKjhnqZ3E;UB;iSRufTkeJZ!xq$MC!m2 zROpvs62eYjWYv%=G*}fqpt0LcB>ELx>@Ln{-#B^ljk6anDQvLY8w!_5y9(y@&YoZJ z8NVp0$ZjvhNfxN^hMWj0&<>SYpJb$`VjdY`UXV>VN)$2?*#Ou9jB84>=BE{#ii>P& zw68AK*p($plpxxS!fIc!FXMEoNKdbrb=fFkaL_`9iyKD?R6pn( znvs9Q>`RT#j5Dcp1*n9t|1h_|GM-DSQO&#JxpLb5FaGMujwQ97owcBsZO-(f&L%_Xs~>*gn{vx5a>3(S;F}qs7``du zFVD>(>sba0;O+sCEV9Iitmm*k$`2uaKF3Z{G@lLPBr8~tkodXJLnbn_1l9MY|>Dc;A zF=wLdK9X^t{7LHklVsSo?G){WxIlr6P=k?XBFRgL2yn-O(N3o%C&a1og%)m%bXtfs zv8swlH{SK*YwtDI`g}g)y_@wr?%^9kmsd@6I3`x{EukCk*@0GYb4|Jqt!M~ONT1Q^ zJ|>A^KBp??vrJM%{^Bn`yZfF8{)&li;-6!2s?;KrwWUZTjSSO;cR?PLT!Z}_RTz*+ z1_(+akwjfoon4lHasRy+F8JBP3-7)E=ZkPnE>y*TPXHnEUZ^BezchmS6Bx;1EiM|2 zAsQo>gd9#4BsI$J44+#D_i5w5`SiIPZhY?3Z=T37~nDfw*&=081g4*cDUmV#p zSpH(nAV6skBr^CIzt|Nz#3%%`vO-r@CRUS$PYpdUC5LwN3oqq6w_O@qvJLVh6TG0~ zA?$8A*ha;?NVF#>>0rC&(6SX4$k;QKRMZBcdlX{Al!_%S8<5pe<0EG*3A4%`Nyi$B zWDl$kOFmZa4vWK_Z8YfP@l~@tT8u(}(l$wrkG4mm+X*Jjr}PXM92y_=s#l2Tmtbwo z+}>}i%CFFE%33#b*&3BjeRFEgOu4Laa=$}g&{8&TLV?!n%FQdU4$QlFg=JD8y|LK3 zq_Rz~%`MKPn={qubtCARihf_C^g3Qgi_=J#BM)GPXn#?P)W2FS51><}-O7@cYoeXD zo{}+K8q6@fixaW9inU~kjIM@ACn*~jw4fbu%+54! z*_k=p;a^x=yKuFurNvd&(xNYNwo1FcbUK^XPMvy*e|>j*+l1Dx?f~&M#+BoLK7ek7 zl*=(E>dA`1B1JJ6OBG9Sbi!w47?PnMu`h7O8XzjyXc3^S0wCDZr(N9OU)xzab7IC- z`ifk0b-ty_Y;h%S%$hV^AGoBaXPrN@uBzOUZEo_`G@G;1%gVgz5niKPvnFO8rFsHx z^?)jZ(c+bd7%m&7x=`&=s>^3nJ(4c??X=wlP@OjAa?R=13~Q=ESZuf#L{yJ*94&QV z4MK898BMStdi<3jx8FG{bEh``wzT}|x+vxSmrR|yw#n)IGE`K!$k$q0us+b;)jFZA z9gu88*m&UrE#+6X$6Yjk)9hvcy!|f|4~DBCI&*_h+9k1{jBIBS!<09 zfkq>{#WI;Yrd-}W@zM#?)-|d_x2f7o?CzvXi`PBz7w&hJw(6VLPn$gK@4gFl*X%ir(tqkf5%Q;eqp6c+I2j~bJ0-f9j>$2+uPg1G+t;jSNay( z(7-SZaiDLGU<}%!Dj_RDY%LZFnT~Btvp<_*0)vCF?{j7%M7>^*8z=@n?a?M`)wtr6 zKum44jctg_#8ZZs3ccRKB9CY7ynjdm5h^P5o_Zeq6vA`>4 zrIjc)TFeIXf4LGJ)B5@C8zxQK(B4LFTlu(VS6OqDzII*ToV7mR+BtpeYS#yPCO0=v z?g?P#R)^x8 zpO%K}=Zg7)W>SmnPzqV48PTy8$6FAL#U^8n?NYrtEo!#-VZ|(tDA4<*+uEE?|GKGD z*EN+mKjR4##uAVQOKwP@N6S7#rg%6%12~#Of_J zGNLAVMQZ;OH1f7K3Jgx-zh%c3&i+&#y;9 z5=*K!Mt7IM`5C6DqS#>0m{O-tP4G4FFE41Uly^$p)9^hoFu|_;7skfHJpL+tK!eYVvDqInZj_Bim|U>d1S4_X?5lFGPG~MI zth1H&O_MYl8qWe zN>fvP4-P1>MF(f0v>dY>yCbl{n8~G9s}!BUF+`S4{?d)d^Zu2zsDK+gQSWRRq1FsmOdi z80gI3=`1XB-o4qB-lDIX*UC#mpSP7YwsUnTWqe^7FvOpf1PsqunHP4`gc&{9I&Eb> zGJ-wbAMfU0h92Y7NGB_`yo*}SUYQHo``@k1H*Va~>A%(AdDq4VC*Y;4P>@d%kirK; zyQ1}*Q2*I0^RxA+Sn5_8i)m#p6zQIyKR9dVgFoN%;NTFKk6n7{vCyd>{YcabIjKRd zVrMrF3km3F(r6e8Xhm#+2!V(&Hl0$_a2x>%N0va1Pg$YkOrj;vW;5!Z|LMT|pFW>i z)zxzUN=-%XXa#rh{Uu_(E=l4%owjPksGo1OUPznLYO5)a z^RM_*VaAg#4PAq+t+Zak%9++j0{u~y4*buh(Ib6`$~7U&V%PF_6f_p#Um>}L zX=ZbpF(*gLC>U2z*icZ=P>8^+9HSDCmJ7qvs9DI83kaKtDCrC)!Z61>gy^NT2Egz+ zTk1B(3*9#Rvy>!Ura3bWCJ%NH6LngWZ_)9LEQEa^I3zHbopjgZHBH&>+?#FV@=}s> zGznFY3N1x4KDp12jl9w3GS1W7?Kh6L_6oQNRN-$3s^rq?b7q{7GHMB& zj@C%T+gMLZGR(pBQr*)1vQq^T0y;Z{j&=7 zPNO7YG9Q=_CCzjuk&I~wVyf{!zY#g{K=e#Xf+b*1f}R!~)tp6F_j$T~?YZe4OM0S4 zcqM;B(B90to{FHLkUyC);PVxH#0Z!OK@34mf*6I=b0B&8Xq*qp&BS63XRW3?c9dcV zjQ9hS--!`RP)>CtXNSsI(Yoi{C&i;yf#Ksldv4vc2g}t*9-)nom>P}D=l5ti*WrR2 z-Oy60=nfDqmFePGVRt-_i?hcOPvcE5IxrT03A+4Jx3D@!+Yr_V+7T`7+4JbyxzyO) zwU1J3Ue?R6=0AhnqBFm<<)E_SplRqk{isLt=km_7vd(hh_Lg;(DUS%?{EF@xeUFh# z!0jeG!yONCX@U*tR9XoZ5^d*ZWzteQKR45!WyfwV{)J17w>goF_Q{#K`ORTA`(iY<-f z+C-iZuTA*xO8V>1Stznk%m={8j_zcPvOUtUh8o7LKd0DjYUu82@mEz^EvB>-EDfhm z?V8;^yQAIT+0t3(t8A`nb{1PItQF?0w0u*3iZKP3x0GQtDU#we6(e&ukG=cD&lqN5 z#*SUK(Gn`WJDtCKRme49EiboP%F896lH}|2di(hLn1@h~rJ}+D z;Qxty7Ubu7s6%9=LZQFV^%=fhRDD35Ix%0f!fn(+J{NJC$?-PA=WrjOVs*e{}nuj(6UD_Z=}CsgB?N-`C-TfunWq zxbv|)?r`jT_g#9YB^fee05U>mSz=XX>n5fWcY6>g8>nqJh9Q~%;mS}Mb(t&*FW<&z zNsnVK>tF$Yb22O;Tapf|Rx^hbrdva_Dxh0nZn3Gx2FT?1vL0G<<~i~m*;(S01nW(0 zTvS6qC&ZqTXldO<#y=SvUaipL_j!Ddwi3GmGg6+N+~M*2Iakeyi!arfatqDI6uzy# zsGuN4tx8LdPs-2F^ENhS*XLUd857de46>A*kU(!6d+~p|ED^L%5T~_tK1TFcr1b35 zrL+bnfhIwdfD2VL7NkcC#aLK{%lF)V`!Da@5gJfN=G}K_Y~G=>GB5rI5HEiarNnt8 zPOh&LGE5jXxJ$+Hzd4BURjw@*;;y9Cz@H3ll34ADt3tQqvpjf%;A`m!ZllpTcn##i zQenN#tKu3R@6zMWodzOH+Jw63yce5-xc8-sTZ;2JjasK!oIqxl)CUhs=y+kBIvghK z+Bc7@^Eql9*xYqjmpb!v>D)Xaj*dnXF%8MuQ`@4az;v%PNh*%sSh6MIIZrCanZ7W2 zv=}41No*_9fi=ISMAYm8mg;XNdCgXyDB?fcF#r2hb*4l z;^JJ79+@YM7DC~6k$aGG57tp>{Ewl=s`C8Cq&v55doMXNGnuF>)$@MAw?%>`_-e}8 zlPAi+!hFRF#LG*i`UMM!gB^SUUk=LB?$-YwoA^!FBp3dbO)4#&RF+>-lAl*v$``mM zm6c6&xh9sCO>)gC%|jeO@FWAboPbtJX7zp_EE%vQCe328<4-q#u)xA)!ub&7TD!JK z4Xtj7*CiYOPo;&IW-7qh5WqsQOu-Mb0rw|gb_#Ny>kmMmZV#3tevFTaB?kPXm) z(0|J9XtCu&vWFymwX!Kr>qfV@bd6WFO}HE#msM&!^KvxZJ$9SCU9$?ZQq#?<3sSmk z8>h~icrU+Ws;9LwJvBAcXf1JhYJJnIAlsF-r2YKhX8x2Ir zrlZ3~NDp!1m1am5*L|_~5|JIzBMjm{Fru>87`eJl*eQE2Z~3}YFTgGC?99S~RJYO!~X?LyqkTN;y% zEIK6i`CnRYX2<9uvC#cnIcro5%J(s}pR#FWY`m-Q#oZJ~cms;(K2 zYgdNS-0VDjbcD`Z=kXKLzv0{jchG3Li!WfIyV0vZbVh@73ABdKIN;AgHx)7rksGjd zMT341I$8?dvphVzV*cEp@HY}XT`tcock3yQ^!+KZ-tw}$@U@9PF!H(Wys`Bz?7UHf z>#m4tbr;#ewN3|p`2pV-;}+b>w6KfC2pKbowSYLSA?2=glO#`4)|okC{ks}VN@)uU zMwZxgS%kA`X1`f%s+~Kv&yPh;cBWuL3%5kq_*ieoFrrPX!9JkRm!Ty-5DVt}Gs}!A zI(uqrcA7z-j5j{iDvvWSDb46G7uG+c)l{qEFr$;yxC3d>&d92i_2!IRyV#Y&d}YVw z1m)fZp^N^|J;`eEUW775>DmR}ix5+k-VneE-iTI%=36*g3Lxa%NHq~yq?Co@Tg{1v3<2>6uE)nd1ICI17A&cVoV zJo)5Mef{G-(!ZS=;ssyzCgz833>}!%bLhR9_oRz7oL>O)!Hm{ zB?(I}8D|9)p&5&aoD;avD_IRoaTvCn0MMsd14K1HJ_W4;x-ycpj?z!!A5TcH@8qh|^QV~r}9I(%8B zg6rbdpq+UviN}%Pjf`y)ZlMAS7DrKF2!nF8wUgOEF4%|Tm*$U?PMJK zm%3aI2WPcDSB*M%iHvSSorQO>o*7q(+?Z!9*mWiZj`}* zhU90stSaH0d^W_T*q)H&$uy-^T2*uci@hP*pe7Sh!hh?DLguTkdgkh@Ne9MafJ5bK z?40A&Ty1jjcD&DYOn=LU^|!pcP}r2f)r0jro$xjK>yndTc4J=(IN*j1I03bhafQp6 zDwPbIMwXp2Rzx_)6)y&%Ty9|ry+`7Mjpd78y@-7WO`}PcVpQPMlDXDZQC?T+YEQf| z=az+m;fWKyWtF&liY*7yieKp~8%s^;=BzA>t*CnZq{7aIz{D(5fjvGf_9w6rfzHyM z)7+Op$yr@z{_p>*>hG>z zt81_7?%KNc>Z%YK05p=R-lGFcCQi%J<=@DysVTVBr97dX*Wl^Y`HAm}h@EFisHwjf<^lZBjT zLm!hi4)slJnCQx;nj;~KWHp;$yMT(O5_5T--NId%upmnvnHm<%gGYoZ!@ZgWGi11* zkf$%65C+Mqv5m)fWpwky{_ZOK25+E0oLy*b?5juIVMH1I@L2cWsaSk^U-w9VZm%O= zW;R;GPMWSGX(iyg#$Ms+d#`oqQ@$_xr(vEcH{z3 z3}S*wD=W_{eRsb9;!#@Fz&+S!J^=UdLDn(L>aU-|D-!U*kk`4)d?k06MM?%%_BHM< z@-a-ll@%sEe^j2m^S%EL(Cc3$5(_U2=AL7QFG9}xu&$-t?Ear+2?+YKP0^oB_{s@*Kz}UMRq4dm{L;)|Dz%wtS*;XrBQ}f zBpsk&BL8%4m0VE~sDSB1sYYnyb~znfL8LuR{2{bE8_0^kZE70;yf&Eb^f!v>>8L&>o;yR3~y|2seM#dow4Eef#nXv6#Ml@bUv3eXjuKG&KGd| zD+mjN^iy6fLBM;YzZU3sNUfB0sldA9MVvdEN%yq$TnpQ-L7zjx%9~K!8vGz^ zyVS_KPW(EXN30t&3jnG+Iu^1W3q_!sFl=Dn>w)S5IE|U*So^vBCHXbUFD1b@_2}e9 zGlYLlTWf>O3ROw=0+6D}`O*@^$CI*56(Nlg@C-x(iWZ!;|juEovlBW2zOU?FSU0Z3Ph*; zZBy~o=5%~1c1z<}d}1Z`SSH|MuUT4b?qGw<+`js($#lWg>hiad6ow>{zQ`pJaCCx% z#Ky$^x_KB9ksNA=CUdAw0K3T~+EhuU*gHIikmf*>Y$SQ@eAZ}AZ2O-NZt8Bo(4E~m z@ca#LoILoo+4%Ep($p}OsR1Bxgb@W`wbZr`Ve)0P!Zh3uv_G<@EzHtMt z)e8UWX*^f8RG)Kd!9z%tgABxB#E`7gjE&*Z%wbBWg5&_mVOPV?j`rN>zHxr3ez|9~ zhb^~l^=$L(+lT*bZQH{4JjmA!k9FEe6p2osu& za04iF*yAXtn?i?`-M|(7l=vZ5UYMRK)MPc+)e;qn1jDcpr%zTp5l-MKI}m%zL#{#j zp<3Y>AZ5DE${KoRllD}~o}BGz^LhO4n9~_^`#rwr-J=7`iS~Qi;x`VAxbLd1zbDq} zQWb-%HGZb#S*CTTmi-O+9N)hFF zy@22E^||5>N8IJ}`rCVVB<)H3e@E~0c#V_sR+m9hU9GWu@EY!sfg9s(_p~RL2S#af z0NH+=z0K}GM#x64Q*~3W1YG4zo~mQ=1S0ds3BXejHH6y2PsZUlg`#^9zirej64E*H z$YKy%2fE!J7Kt>#al@3hejw1*-caY159xbHLp`kylS?_Q@BZV)#>TeL^g?5!rzJRz zs1MT0mZ6oY(D6*9FF!tVKe~EOdwNJBum3x4{`N$H7ast$*4uO7UAS}Ixb^;z)Czp6oPQ>VFI!P#O>YzM* z)fzT&f&T8{JF+(oj%W8ZoGx#y3!6`P_O_4L2SamH6%XG!cI&X(pHq7_G}~SJ)gP7n zn>#zy>Azu>RsCH(IrKM0GR^}n>XI%^UaY{zWwBbI<)xTG0!d4`Aq^mq;&AOH#i{}D zkV?PkX}hCVK?>1j;8=wSBc&G84u(C#9(bNf*=~Sr(XzZFJ^@W%l?>|2H5(Y>ISvYo9x%ZiriB*3g8<-R4aAr&|I;13ufv z$~zl!2(caa3uj-6ccp5rt+^XnjCGZm7a3_%_Q)B@D6$zk&qchkWAG<$(wNFs1Ph+q!%iO$cSL_0)@7cB`mWoejn`qgg=pNrc7UK zLSm2PxxHA(cI-#|?XW4~t-_RKheeKsZLu9{A2Qo)=Arf>&tOFJlvKoN3 zA~*(m=mGd?U^7%OqcK3oHlaG64@Ls;di<%xa1o@&Lt{idCdP+H#<21;XU^RI4kb^% zbH}~+-a9$bKfz8Seo|V!5C25)rC8m6$|a~uaapCtR*9{CHCqT`RUy#|OAu$mcdY)+ z`@dq@)vx24*0pVL?EHz8m4+om`grg<{ViULPQlv|gkG5Mw5gFGLFhRHj8*SKP&7!R zuN4$sqP*Ev&|mN|(SoBjfKf9y!rb;?)-6Zu_m(&Ne8EuD(V>ZSsLtB6rz^5~$a1ez z`|6L6ZE8ta?cMvTEVj7+)~Mh8(nK!h_q%QOCR3+(YiDS{ZqGEf4hJkP@f4GAT!#5KFHVKJHMRT+L2mx-cathHKbeWQ|6uZJ3rXrKYwsX*Y;F2 zlFCC(p-ii#04SXMq(s~Pr5POLJ0F^p6{}l-10#t@nkUb<#ngvwWjq0Q{9BG2K`0=uQ8j=<xL9&tcz5NVn&V*lD<5lCm6#9mRvUAfZ9ZnE@Q? z1}$S>UqXIzWeo)I3I8|+`-?PWS@ObavLcOA}W z;?2p})(x?o$-1?F{KgcG7DDNiH{r#qL0S!BgrNDrdyI0Wj96>52$d294{T`5f`)ALC)9CL= zI@ci1c%S?o;5mpifLw%=hjcJZ7XU^2LlDyd4GD%vXa%imtQjH5>Vf9iusMa z45%w^=fsvTNk}$@sHNa?1q!>YKsy9$mJ>3z zm9^>(TCxNmKdVKh4L_-5LV~&h!OqyxGwoRjQcyS8s^y1mDG&4696rTViQCD{-O}IW zY;Is)yThwAu&bj3koko94u_+D0Yidtd==mH zH2d|2E&d??U!cs;2t%`=_?9RtdB4bd_2sdHYVS~}x7AVOU!V*~6pycyzW5u0I_{zwE3#Nf(Pbhjb86$D$iLkL60 zpxX{25*8ymhYy(jr07fJ2CTuljuu;#Kk%Cyz#R|Pj{E82A5azQULCXub7^R;frE`1 zV2T+Z0gYAwji9|t4UPX{^lw?H=c(SoPefi{{lV~!XSz@C*m1i1%#D2P=ncucW+YA^ zxGZ5AKvSK->#VnNRTW1l8XXF_C*l1f4;+K3D@Vy$w)$C+FyQm-iBl)eboH$M0?0DW zi{IheS>aowYe#X>&kAqc*)u&|XHJ}2eGeC=E3bV@?vVc$B2$&hzF+{ig$%!7xvbBI zRhC};75w&w)qgv2;^ZRx82jkzkuT$qSaC}-NP%DnL8k$Wap zSBQ=#nhcW#j&|6NW(AMtGb?z$x8IiSs~>&m9d_h}73O7)D=Q!@dIL{LGv^ibYv%{x z1?Wq{3!<|03diDM>B}d$CTGvB$KkmpOthcyNFWmPsa3c@1d~WqdPaH+nF${dI|ErE z_Hp4KLY@K-OMV7GLpDo51IeQ$`4hN%8IOsE6e4Mj=ooZ^q+Bg@Q42Uhx9G{vu!Ea~ z1#f-iN1I=~aOs7$kGw9Qe(yE(KoqdY>x^*qu+%1P%WWn;gcgsi(>nneDwL-L2oNXD z95Ncp6&*aY3;Z8C;D%-7I)xU~0M-t8``V&y0bhN%AxvCDh)!`ccZGN}+Q7MrQrOH$ z`T_p~j0&YwD4Kdlh>GLIT_CT<@?Y727o88@Lu@bZyzz80JOpg*u@3@kVb5?TLb6% zrkb}L?&~|erFnA0xz%@LgQ4V1DzzyY9*Q1ygzuZiFUcfoOy3uF9Q|iQdOU`gP0pla z<7vY`^Y!zt+=f+e7dxZJg|R@4C$<{een8-eu|U5=Ly=+I;)xfa@pGVv0~8H#HPqE| zJ_npXqhbIwGFSo1`rt{>WU_)6!h@?UHTL0AxJ$>5jjWz_=9&`IuHnG#U-@PGXj9Hv zIYK{-jvgC(%;pGro$dW>bW_rS6i-^|KbI5ePY?DM;8>VBd{*Ki2{i%6gRT4!JTj2V z3*?wluubZeEk18J=#Bg0Zl5)*8eH+L7Lf)OHYux_)g&UbRHaZZzyl=N1Lzf*gO4&?kCIH{Npp6RcmkOm5z0kn%gX9o z959o={(hDu5;8{Roj_kL65_t}xCVmE@kBhpP`9ii4OwR;0#}L~wDnhVAn+rh2x$OMes{Nq`2X(pr?|hm6BRe5J>^& zQyrDy$OiE-f@P`*rO=VMA=`nTmiZCgM^igcR86#p2Ei;MTx`{~4rQSn$y;HcrP^$) zt25huZE{zw5xf!EUW>KDh)`2=5R5~R7O%3uzg~1X%3gex?-ppK_^!K^{7Y(u6~&sB zQ=%t&t@7})p@LTn*-61JAK+@;mL-l|-{ddN2u~ za7N8!wQYb9T*@EF%OPmSv<0w+^rp0KU?4r)HN6noIfX>)NNtyGcIVcJ-CWtYv8$^2 zaOy9&?QNbOO>UXZjwgb3RST8`JF{W7y{QQ!MXRU>y;ns@3nAsSDoNo(?gpT1QhaYj z_=_ykjp4(wS~-s{JsN@#vR2jVv+mlw`Pt{5W4Sle$5U+J2fL3if00-UVK1?MT-zcg zb8&8YpexF_(Zm?8jWvsF7m~V-;29UmjsO^t%w4<#$K>DW8}oK_JazNU?oiCy+r8jB z?q&V|bKrRHAwLqj$=CP4-SbN6XrPLK*{$ox^cq)y6!L zX>YPK>GpsRA~foWb%q=K6L-D%jKkC1e@n7Yr~5-!rNx{{XS{QrpocJ-(&=XJ&Yqoj zcK+R8)Z}Kow++r%yw!gNRY~D7O#cMy+KD~5x%fWu*l&8D%XrR%O-yQP4mMxk`vh5j zQ^p$+*2-))cB1=WthKW>(men4{;s{z=FUW-W#JPv>*KxYEiF}_0;^#C>U;K%%EtQc zbWh^=_`#=V-L6c!z4_!MNErWx#eFTyqyChAd}DC)z92tPc6W{Yu<|2t(CEkT2t6{N(&BaWT%$?`k40#-?5J(}$Xt z=VzA!E3C@s15qCUPMRz4Dtn+WB%r_bu?7)~YNgO>Q!O;aY!fC-EC?oiz~++|?GU|= z^*;ug(GUX^N&#erpUQc=;!g`&^lB4uQ%Mis%>hEP@*GJBzc3g;b5Q|NK*3|;KB=qL z1$-jy7B#0U0lJnn%XJl6U{dEV4%oi%&ug}Y)#b~y<7BG7OkXRaDWhS(x3R(HK&*5n zl7kW~LAf&k380HYg|}lY=}dZHV1J}DolbPQv&WWOdV13>NSW+9I68c=*JWSKyaVMChQoe)7 zH|+X@`P&CNI&y>U;5OH|Vjw?>D+YKI9jqw0VuWa>d`T|Ct|WoE76VKPaUB^G>lt7Q zymjjcV3a-(*X53F*>dNGSmcKwfY~@aJuo#r%y)Sk``_$)u+$8n*HIiP92am|2Uk3R|`g z_?1o<)y+FB=wv@>57t2JK}n^fVS#U}8yl$AjO|DhYj5o%iZIGh3fY@Bd!Q`FroVZ5yV`_iJKzWlomT_ zfD}`gC)oC7Xp)o9DZfD!xOex@fGYbF04(>e`e*j9E-(BKald zs2sw~hnd56(3#nV@-3pAsnTbpd!*;2vX^Cc@wtnccuAd`3UwZrK8)6=BVYX3`Z~J` zbw;JH;`~rvr*D0o4;7waN%~sJ^^4`x3?w0iXPYiua}j5Jzbu~Z<@_~C`j!D}31Obm zd+m`PuKj!76r6d*V6Z8ejmn2Z$ z;5wI=PfI^4DW~zJ`UbY+Fg^8S;QJ(~5>Z@jcuU*n(ysapw zcM;`NID0Yw?m?OQkjl}A6u_hPNf6QR`SQ6BiSjPo`7!=3c3yvJ`_;--HZ48Ruj5Dk z&+xe_>hC}SU0+;>-Ua3SUG!JH3!n~GL+=7%PS3Ur1^n4i-b~}7l~3*B;P(DWQqLt5~fUZORe?g)3QjZ$6ULNJ^g_W!q&O6TE7j(>g?0&-AUS2*8 zRFkeL%4zIG{T;`*1DPa|xD zza!?u&Ak6b{T8r!J`~PoGp#Zk-PAL z^5Wdf^7^}Q#bPaY3K+aXEIyxOmb3)BO4o z?fTPKT%U7N@#_y3uMh2nEOjt6fbhykXg}h2;3I|IN-VYcjURX+yfvEDPh2sgEMb@S{zL1|mwA#w}DipirjPx+Smws(4orWXYLPG3kFY|H-%EA4Oa#4RD z!zb<~b|KoC#jdg_?d3Z&X6w40ImoX`y9Djb50tc}*(%zO;N)+-f?QsaoJ7*DbcVMTdoGQ%owr@YzDGJ(=}2R~ zGR)M%UbzPabl)y`<#FnYXp86Bf2%0RXc31n%3Dxikj4~IPS1Is?;R!-Kp$WpejU6Q zA|Od7$9egb^xq5bbzY&Fg!-8ABN)i5%p|m>nIzgSNdJwu74nw!E1$TR^kZHwWGvl_ zWULA>@~?Th*!!p-#NM}v{<*v##9lz}OM5|47Zq)vEwmj4W6~+=L;2#VeE;*dT%Qze z^ZKM{OJgVQ`yzC28auQtj2*Rg@&2K<2DW?}A2esMDsYN}Xp86BPkSf?UMpYKKLu6^ z3pf%s0h++k&tQV3fGoss03Aid4=guYANt*p^38gLq?%kXU0--M=X{C4dr`*-hkktEV9U9jo6wT`e6`L zYU|(vpCiz}_#DBWhjO7`(72I)LF4we=mUs{3w+-b`r5E`-xcddw54?;bd$}ui2n1U z?KHm^_Ds5$^fP`?D$4L7&h;Xn2 zu!*V&{3W5Q?UaBWfar$E{Yb-ng?tUe|_{~d8Pu_D+30txppBWgKIk}!^ z9OFr{;4^$Tv+}jQ5i*N*GsvBi-3;9Ed^bz+ahQ>w;^QOQ(m05=BdqF*ad50rTN($^ zc0qcRx8>!?vEk*2513`2lPkW05EWR&ugDdAxr()=C)Rb;bMbr6N@sa{|C*k7xKZAY zas!+7;{)$Q z7#)?(N?$6>95CCXyiF^Af|mzSAE(!$9IKJn|BhBpyFJYiv3m~#dAf>5gWa1XGwt4( zA=~hQXK(^d!3j+$7rXa1>3{HYD>!SwJ&kg)dvB9Isnyr7t3NBf&dY1?Y{-8>eOjjU zE*Gy|4hbpA8>DYu7i*CT+a5V85K9P5^qQna{F;}f?#b>XwmJ1Ff3Eq1TXQlb~GV4BiEoNQxza(E`9%UQW^+ue> zPI6h&h8x<%XRw{Pp|3^_E#@xz@hCJDMCM$BYq~C=QypN(;tG6e()NE|Pk~NWF$fyuCe@ z4L9sh1?PU<>}{}m)rh&(oy=h};{Ste=070+VDy9Y5BN;`2R5VJgqimE+E=+2B5BAg zt&~CO;oq5`I)DFS-UCTS2_yV4zu(B(o8o?&T@5vk>?0b&0jG{jNE zI$$@jm&r|RWUPWf6N7>4dvNa{%4j#dU}y&`pvh0*4rS<*vzFpEDAJY8Wpde4$O6*z zGx#g^G?C^&iTVbE9sjkFrN9#&9=h{M$DT;b&PcE8<40##{AbL=!Nh3Ym!i+X3OOAa zuNzMW#>^{C_g9{|W1?^3ww3LpsgS=bGf^I`n85>8iXAbhS&;CCGblYUM2JrZ9v+w3(bUV>si{ zjF9<825$MhdlkhXgeWP_m6l+Rb4M%jsw2IQj*B5{S+|Gs(L|nN*pN?A42TNA9rM3K z_0;WNUp40|sRctjKp{_j$whhEBQ6KODeMM3xxXn?XKvz0Yzh8BT1ET_A6iW!Jt0g< z3SmmUZGF<{^@Q4=OsG|Aox7%Q(q!t-I;?sXXR56JI?xm79Y%cN=fkq@giiK{JQ;(% z**Yj>@K(~K5I6PzolX9c>tU0Z*2ME!rM+K3JRxV*p%JN*U>h?q5CXZNxGo2eY9Z=J z6MkX9c}so(H?Jte0-}5n-b9wRqPIbPkF#4;N*LB5oK#cJvu#9f#03|+T6<7)vu8c; zy=(yurbc{EYtzokmH)_d0zPv6=LGN-@&|aHXW`)){TX3TShWT0$qJjbRBN6kq!#-w zW$KTx>(10qB2yoCs>0Q9o(gnoSf6A8AI8VE%DE2F1>TweEb*pt__v5R<^B2{b_bG) Q;0c60B+oe{kMNxTU+8d%v;Y7A literal 0 HcmV?d00001 diff --git a/plugin/tools/kiosk/fonts/atkinson-regular.ttf b/plugin/tools/kiosk/fonts/atkinson-regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..f0edd19047f7272b1abac2169850cb28bec503cd GIT binary patch literal 53504 zcmb@v2YegHu|K|h2jBn@EFcJgBv=4~onQk9f}LPhVUASPn4&=)*N>gI&1wC)5#=S^X6sjP zp4_miVtOZIF5FKtO@_8^Mn3B1S%~$M>%XzO{wGhK!&pDszwFI56QS{J^H)>2zXtbf z*C0dxElmxsJ8_-AX2Z4%o=a^07Gr66Ui;|!O=F?5%=y%re6)FRL+FCd(o4yyD35FA z+!)$0v7_wfUomF98D;L;ylLyUDfiu9GM4ch;9R&I%6-uGE4@M3EhpOr4BM)hfloS8QH;Nq2zxCv8cduTV z-1G%YmOrEt?{0Y2FXHf@Zk&F8TBkM27vNf_k_;)0WaBq5EpXZ`J%;OR)tJA}s<|L5 zJxza?F!m{9&K_}rpnsshi#^2>3tm=nlON=HP9RG6`DfA|L{9aJa?bdd&csi;hn`|= zdc|ytEyHg1dN*iCZuhsrH}XX0X~SD zhWKK>ln?U}z6>?4;4Aqk)y60J8u2uq<%{?dJliqzyN>N+f5j8m@eiafX|41d*(;CA z56Tfuy=GE#gO+PO+Qr(dwD)UY*D+nOZnJK$qEmX5pD3>?)A~I9Lj5=Ocj;d@v>Wa= zd}udulV4AfQamXuQ?5z5 zKjnbgY;H7PV*aK1@2Sqzp41ysU$@vSt(NmFw_83*dnxTN)cpVnvgW&28ee%~D5BHv2i zI^X%eZ~1QV-R`^B_cPyq-+R8pzAtK&8cR)XO<7H#rlV${W_itI&DNT4)?8EbZ#Dm3 z^FYndYu>2&Q_WF-lfTn{h5uUr&Hg?9AN%+E@Ap6Mf7buD{}298{9o6aYSU{=Yin!U zYhSDVL+vL47BB^J0;Pf4KzpD+uq?1Xa6#aTzzu;r0zU~n75GKqK;TH=Z*>!O=hp44 z`*z(A>h7+)ukN|J{q>gmW%cXpFR0&Le{21J)<0PPbp4z4AJl)@U~I58sV37n5vPRa+Cb?L?S^f|Carr4{nlsaxPG) zVsWNBvz$(WOZ_Rhn1M^i3@+n@%k&r1e`HIi--1^76|+yj$V}7Y(?@W%7g)0Czd|-b zB~Jf&`cJ^tM0x7p^wjiEaUVME_|D@SV!61o{PQ3Gyz`%j4}R}p^}(uxl?Ocs-3J{9 zQxAT4@Tc$ZdH>q?&p+_v1K&Gv-GOTkTz%lm0~-!3IM8q)@Xi~MYq7t}VhIt`)vdgc z(i~Pp?2mi67czA|SoyBV7K=aBkOuqWBi*sbhk_6zn)_AB-(dy~Dz-e$jsK6sbC#}2Uf z*+KSu_AtAT{ek_F9b$iCAF%t`E$mtL5_^t4&t70Jf)8I|ud)5?b@m4P4f`#7hy4!R z@MrcRJIs!-zp#(kBaqr3vrk|le8!HlhuAUpG@nT|4r}7gJcDQQIeaerEBhRjUB_#9 zfPH~EQOrwtDf=7yJD&&b)C0}*U+h1jZF-@7HbC3-LzgUt##sq2T>@=0Aqbzy?x2~d zG2m*I97}Un3hPMChbETKiKb=NFK0#58rC5FA)3~*ZrCPjc^#{h#-nLH%ahupX#?BA zUyP;`SUcYpO`BK+AC9IIc^<2crjw9jYz^Cn-)6QIv#=7st!#{KVQZ1I4e1KNTJS&x zIA9B##Qg!*&3a)yu0j4*$dYc{*@&`Ewg{=!NS%+FyoF65M=h})@ffZ*qWlCKNB(xC z$C2KG^fuH)Wf!q|sMm@5H;c0IHTRxSg9oLSh^GlRYTe1Ifgyo24zm;6jbRgF=K(+J zQ;4lXNNt~wYr?~cJZiU%EfDE#po#PUNTC>ARjN`S*W^3XgrCYR5PWCUVSJ#iJFL3U3h*g zYA5+B*vEbX$*a&^#GQVSOGoG)GI@E#n*6{LLG;Z(yBUw{PCcTDDQTPSisy63$3ca@YnGCWugN1+Bxnk!S^p zJEW0mJ-}Ite2F}lzsqmMuNU&XkZYhH_OWv?&Q%N+1jX*YdEAw5T3z*iCDxy@ zQnWJCK;NwvK)p@I8mI&-9i3=1A9R2uW;Fn;$VGj$B9TxkAnwq*>BKw9c%IhRTCwUP z>Z$m6kZVJI)C%chHV+H?rd zT+=K@PL_!E-I-dXk*>zL^+Rh8L4S@wqppNDoq#6YAn?&ZGv`3tCjkSs?a!k)X>h$7 zqmzPlYbsW*X;``1uy#!sYgdO@yXLT5=439GhZSr-D`16K#TLU(`7yg2mcV_mKpvg3 zUdWPp`ZN|yy!|q3yZn`X0c+q(*dAKg7zUoe6Jbl3VMSYEgJi&#&gQwoo-Tm(Py(B} z99Fd#_Czggj|SKpEj-BEVViVel;&ZSdS-eU;~wg<>5SA7BLXg$(DMqEgpUOwt#l{U z(ZVc`jTY6EaGIKk%H;Q{jy^%Ng!sP{)XD)RiEDLYPD<=JI8B1x_fLc?*gp_Xu&;4H z#(VvE<|`Iq)2NVRPQ~WXjBEg3CBS}8WJysTJv~>f7ha}(%$Aw*Gjs4OwD=ETaD29% zD370>ht{EgOiE^6X&87Cs_807SFSSg%}zcna`q+#f-F6!AHThgh*xfx7UF z)W?kST%I%BQSbo(G!{ z`BJg^oD@X)A(Z50K{L*{z7ZfMv`t=OF)Lz$l*Sh^D`Z*o6C;!0n%y ze`trh**vg~pYLS#^sL+f@Bqe9t_07bE)nZlv222-`z4+O@J7)48)&nUxzKMvzm^%P zZk~bsay<8K&|)E;`G1Iij%RKI-X7)!xcL<8q!NS64S)f_5Wojm0$2bT z0n8O?q6_su7E?c|Z)b`Lf8ZthY@7b48iQA2w2H+syr_SK_nBgn4bO2|m zn0%tTPyd&8`s@>PrSD=+?SYOx=}*Y7XddQS_er;zkXvVg&X8RMGxy2L!{78ic&$$J z$2UQ)NgPff*nOw70cN#+3A9=nbYeNQCHb$bAz6H|rQ z#Z~YXJkQ8O@;Lh;{B1vmKkglNIlGCx7VJ96->ca(>;TuZAHo0i1+3J+#n~;{(8MjQ zl?7qLx5Iy}fo6E_OY;2A+s-u^ZT3Y!7Uo zZ^LtQ3%n`cW1qryF|s?^jj&)$?7KXXeV_dR-kRUShQ5zo%aeq^=63cSNTA!`h4}=w z*u&&Sfyd@2Y!y}z_p+CS7v&tR@-|@Ywh`7VtruT`eefdesdKS@*@D&Lt5M$&t=P_E z=fie;LwI|BignG~!e6uoQt|?LCBFe*;7)cC`z9>*OW39G`#dTo8RC&_=Wr;P5Ep| zoR~=}UD47LYOL!T3jt-_goqLc)@y#;+J}v5v`o$1AtK|l8B~lrW|Eq@t3q2eYY-Uc%w!rTXOd!MtGy~uRQuZ1YhQED%LP zWl(Q*oqAmxyKXaxipMr>Sd}!Bsvv)XDCn!IH4&#Hj?e5!P%}F!;9iIp=!h%eRh#uy zd80SI@i$f6d{uSPn{{zFs{+x?fXLKzZQrtqF!5FeY7CQGLg!5gRA*B0kG4l2ZI92a ziDuT!W>&SUpe_1D+w2qGYIWjPwRJ}6rw*#Oy4|pC?fP*QshN~`kU=PW zoMEHid+~5sDhR0?)?@Bf4woy;M#8Ma-MJ6z zxQ@~G@-X*=oujME!;;7Ca=XgIvd1}opPXi8?HyrDhjVnaeM(B}XrC&OJHk@O&;`!0 z$&ExuXgsVLykMUsNvJ05n#gid?!F`|Z_jcf>2BX=;TGI;huPrp#K=Ayr;_DijVCOZ zg{>XK)Lz)u(Ge}+aE?2}PYs4Oh0FF8^Tdv>v97SLYuFW*3q}?$A4Xxv?qO$ma1dF+ z5r;EeN2$7z5$BXz8WMZ>t~Ai6|RDGw{2u%V-D z9~SL`>Uvzd+udLncY7!-ty&%CV?ZRVl$M7L9w%X&gl=irDii^}!O;;aGTJGyHG1|L zl2}Jqd#P)NaS}WyGLlKHg_i+^4s?Cg*|pmpqCpkJW)2#Vu+sq~V_0Hn-JwpkiNrHJ z9?r*8czC93@ed^t|GV4oOEfXL3q#~^yGBaE#L1p1N$LuZhdRr{DIRpe=?o`#%%`3q zfe{L)(A7d*rHJt|qoNc+6enmf2E@bWj#20CQD@i;vXzHZJv~FiQ=0M4k^FGtg!_W> zu*K7}V7O7YyQ1 z2?hw{YTu0!MzblUE;k;FrNIw$pB}+Xrn@8P$sFK62e~H>))|Hn$B-;;5V<4FTK2(w z5&U5Fz@U@5hK9qb?sjKaI0@{R=myKRJ4YW%PgjqIw70ht1=4Vjho;i>W#Mbe9C=`G z8~SK1D-YW}Q=H;-(3|26&y-AYre{h+vBNW^r8vtorK338Go?_Rn~P+aB# zHB|bCb)%rP+gTCjXVYvc4}0Qn+GcK^tKKY+yIDAMbBlV@>0#mIvNQG*oq9m+D)lqI ze=hXT3B2;qKZ@PxAI16TAH@aeAH{{}AH_xJAH~J!AH^l;AH}8UAH@|OXOoynl^*A4 zIDOO!p~pvwCqjtB6~wbuo^WMZxDxZR8dG5oIQFy?(jBUElVJasCBTm5VQ*}Rr;>DC z#Bt&3(kU&sb`3))(kS}k$aAJLH6Ev5VD3k4YCT=2iU~-c(_%~c%yz$6#&x#1>!xbB zmHJu>dY}WReP$GsB~(`)4tOf;P37Ub|E?$)dkn?vF+|K(;H+@YA&~&m&fC3vj(ZM7 z&@dDqq%Jg49S$j_fx-=tUAC|t#WWD71)|uLiM59lI?5(?SGb+drroHs@x)@z3bnql z&fOj>?F^5SObIR+zE9)SIvw|E3bmOd?IdZ95ZY)T7`VGf!#a%iiPIG`ijTXAmPeh^>)OVUjfC!0X9fSx5Lkot3 z1~~~6)EFXdMM%g9qmp#CEig+{j21X(5!Mz)Y2j`HnVM&Ag^dsQ=LsC6;o9_4fD3`gu4qJgD2c=KjCid+4%ZSexkz@ zZYYZ{5PK-rV5cYCShgGNM?AJ0GyP1sVL&UwRY0*zbeY(=P^Ekb23ot?bz)~XW?BVi zp4y>q&y*3mhPYpF{{N1p=bVng{x4ii^d>phh};shxUCFXCD_i3y}~xi>S;T+!~@Xlvqqy+3CgBFYn-8L99rj&0V%qF8!E?yrFEnBQ^)~nq zZ-&3|YIr^`f}d|QcDBc{Uw9iEhVQi3_%7>aL3mngfE#Cb%8MzK{sZ9#xm$z=gu7)r zQ~Z5-GlkN#2$#yE2y5ll6iSaGyiFdUPd)w9erg5nf`;cY$6Q0xDI zXYSGW>*wm*^-X$UrmxZiH+{Z7N1v`w)#KcvUPnhMl&_V~l~0rpl|LZ;uJSg**OZqL zKC3*bJgn?faOzRHOSui<_m!K~r8czR6ar2pwu9&P_PrAf-d$9;F5aAX<$rhrdcD)E!i`qtss@i2DJw)TE?H4(7we3hZ5hb;?f=Ysm)KS(} z5lytEL;D1CqF*L~n~vy%%?i**-X#iX-lkq?UK8n;MfzEhev;C}$td$MQCPE&sHE8|(sxl>H%PtH+$MS_ zI9Bt0QS;3-3pCe@@M_{!&1E8ek>J~2&3WLBcQu<4R%i&rapM0d4~vw@3#mtrb+&G|(K3&XEu4jUS~!Q-L{^s=>U_BJE8Itto?l8*pC`!B1AIj8rZm-n zG;{(_73D3WwvcGcFLHJYJa>tV+A>6o8RDrb@l=MWvr3dy#NB#<&jk!} zm2ZsVOueWQ`K3bJd?rekQVx5UG!Jl7LuejePMEOI1U_jZKaDgSPb0m=YsKBK#9gPz z?-sotp_ z9};&VB~a!wk#nv{zaYvVrM~ktQ9e!N2)l(lMfy&Wf2VlrJL0MBcn6GQ$Bt_ILe9do zW)Y_2Og~vQG@CYvr!qu3Q=~IRnIw_6hQ)lg6a(f}n(McOaY zm6XOQ9m0IQ$XPG$21NQCk**f$B#~|q=?0NDQyTlKA{03z;_iq@kBRi8NPjKT(86e? zO5`jM={X_|ZHW93rE%_w(zMr(5IGV~)zRBwc)Jq&>+0KK8gar-5hv^njNT4QVDxra zvUoczmC^3H6?^LeoW`TK!?GCdTsyJ*{UuIh(%WGxv3KiYo3Tq<#&(Lg!*+3cJM6pS zU9caE_rLBG?|Y54Kz6>=rp&M0$%z?-1$r zB3&!ewIY3+NZ%&X%R~)gd|;R-WRlFgWMmxvie4d_3dM~&~l2dRp0J>H~=k}mQroTXoC;d(Tb^6%!3)4iUGyY9K zIeou+bNb^m7K+}OJ~aJKtW2!*^gGl0)$}yY3(GW(oA{gl4c?$(sQ+KL|LF8@qva1p zADoG%Psy3NHIqR5FV0+?l9>L>DOo3F0Q+eF>BhfmG1lXgDqz!xPtG~HIIf!%AoS>Eus+^vq9-kWqBnhtB#eWS8UlTUi)q zcpjwpgHAdn{0z?b#NJwZ2~y~lnfIPz?=QvPf0}*wY4+^!pX1(PI)>9ebdvb*;w-WH z9uvK@gi|?mA_u36lcJ}J)pwgRamK~T^Y8}K?AaDN#S*~#OzNp(_4Eq814U=Qdmu~K zL!K-UQd|Rym~myrk4~&-Bh1B50&VheGPel7B(V~uGwUTx7PO-GeJioPtj13Vn$_YY zI$o9pjqCB_pkO1;syE@60BPTh^GPlE89~uDgzfklAd%s00yQ5%oe$#2LDfgm?qm4T zd&5sddOd@8!DLYLW#qqtA7`)Oj1b4WV827V@8N`%gcCp?0K-4yr-w%P2zNhbpP|L0 z>=@em96ts0`~qeEhF>!1`gdl4&XI8%9zUED!>heW>$nbSJ;#Y>P@1%_@M3`4Nl2&i zR7l`7*b?+s8EAz!%kUlqPIlXoPRA)?jx$G@Ou?z6EXd~^oKe?eT$~8=cpk!hp3n3c zqe6tmycjr?@=}E5IH@Y(3{nM7c31LBq;VDr=^9?c5;2MaoYSs{{?+3YQY%_%!wFMP zXOK{`o0By>m(NAJbY7fJ)`x&0y)CWaG|_s{U&4tr(%0%pC*Wj!DrjoKPl7hKgN~W_ z*~Hk(m=jLir?F4L+;D?}!~y0g2b5v-4VWwBO{&1pD#qI$H{MBc`khkP3TAkIh-oMFIxdlgTq z{P8BNMwLHgSO|YYFNjObah#DD#~J20&M?6u`4W7f@`uV93RggDl`{++Z)8OIBY}gu zQ7%d3iAbxwV&-Oy0ZunWc_l&cib3#7g5VWHlvhsWk|JJ&^OD3LCcGm63rcW_$_*)) z)qb3YB%VmZ+-`vGZ{ke|Tktl$Ecn9;t0{;yaY-89B{$GHRS1ktlwq$2rKOem9TQX&Z#-3 zY0^|^iZz*r~1lQX4XATE}+NNc1OQm)5SD1siQ*^q^~P=$M5$Uv7k zFIosm*M|A>P5eCU3jE4(-f|aa3-NU&tk8S$1aY@l=n^0J`Xy+dSHQDfLZ&SQul^aQ zNsmHmjR<~R0e(z^jZB=iPH@(G!BZQ+O_^+?kS*s5*|G(4!_T$~iLo7g(ZkV-_4(^wHS?c<1_06Yo!8Q>|v z&jC*ZKEVAiray)(O@j=@yXEM63VNRc+*2TbQ_x-^%8;axKrt)J$CV9K&SE9gN0D!V zj!3~%nK;jy&Vne>hV%ot_YB}!z;l4-0WSbv1iS<|iZaJ=##}Rflp>|VBPREKnQvDpg|ThBV`3- z19AanXx{@U2Lu51fTros**?S%0v<(~#}Ge`XP*E(3HTY{DZtMGPXk^?nO9KeHNbwr z>wq@^?;-yH;C;YB!0!PcBL6Vp2;eV(kI-M4Wr5yVT#vL7U;-oofG5t|ALVw$>4?h_ zR{{dlpYv8+w*fkF-391IdOh&w;KOwAdJ*{G1HcymjuEmTFCP|G0jwt)L*yYPvvk;o z`M4WIUOA|92-GKp=f4q;S3W@wQ$Bi(|y4e$WqQCvTR_*uYnfad`(0A2*V1b7+u z-vb-~ybm}C_&wlbz$buD0iOYmqU%$o5!|2f=jP>E@SRZDO zqrN8qPXc}hcna`yz|(+dQ1)5CbAaaoF92QyyaZ7B;4t{$F!e-JD}oz!5fbuejM#Q0eBMdGr&`Tp97u-JcF{&0-ggr4|oCaBH$$e z@y;uF{x!gU!0UiF0H@=p4}j;Nfy0Nme;9BC@E5>GfX|Wt1+##vPk^fLfT~Y`s_%fR z`$5(Hpz3~5b-&;_8)D+Sbi|pUWb|;97`lPz*XD3;M{6logN- z$OSax>l`ikI!7y@4e%)PUIx4eH~@Gba1ii&z{e>23E)$}XMm$9e{A|!7)Khz{g54R zL3X?a+3^-+$6FXf8b2DR{g4;?VLc`bDjWh84uJ}XK!rn~!XZ%M5U6knJ)=2z2xC)$ z9^fks=utkd<76tYp^*mk;2yrOgdV0(AA}~dgVskdve$#wM?mX$(8@b#Jg7lKL31B58dLHo050{{%*OKdS+)TBqN{2-DYT z43KFi)SHMHA6P;;x`GsAi$JHdaD5SC1MX#^?0)F$e3adfw{LRcwaaB!z;@XSxEBC# z8J<({JlN zYqwcs(Yhq@5KQ&rJ}sAJ7SeEr#dyCpjpGw>)~wVNMGHeeL8rHsY4ZyGHMKsk&6=hQ z)cR`*-Fb?|8{j&fHOd*8mh@44^grp1?E zTO84s^9yr(J(ph(`oKdYJj4d(4mxoS0^t1|Ny_Wf02Q~~FRW#Q#cbBl{a5ed<|OjZ>vHzi1Tmzc|vM5K*&4%NyaYphxj%{@zR{AOcK(AS(k zZ*fU~UBjlelfFLc3+O8Wg9_FeY|F`(WKA)bWUWR**bDmOy)kJm(`d9IrqveX3C=<~ zmDE7r>Bz98nv)F*!)jeoNnKbJu-SZG(78fJ&riitrx30>9Z%_4S!Y_5FfZ7?*y*kJ zrDU}=7WDa>=NcELZa=GGRa2e6$KScp(guz2?;POwM1}|M{G9tivs%#1fOg!h zE!dL5C2g8TtI=>8H!TRJ6$F!4Vn*bhM6hf}y4_|K1aouuNdzO#1Z!eEEiS7|i47_U zmDRDLE^(o0PFrzjNqJX(O?$$^68_D|bN0->lE$&-j`e~@ftJF#zPfH#bw)ZLTJTtA zdhP1@y=yRwBr%IJG0It3g<+h*X|j~cHCkYd4^wDl?OO0zl;ac-XJ(9ZvN_Ao%P!6- zwxy*cVVvR1(Z{A#iv+f_D=N~n=oZn~Q%7iaRqi~`(L6qH{#mxg$qmK*i)8zfj+Rhe z!`iv+0|V_H0|TaezCY)RwJj~>8QHGuw-wbktnO%8+d5}ySJzSkOaw?C#@_|Kbu2&V z(&98t56zF2Vkj}Zl3St~2MK95YxL=5K8}I3Vm}}+as&TmV8L@4{A%8T*EHMc9PD7z>*kvX`-5M_7> zmp~zjnCLW&F}NH@2uoa=`?@x_cLp+Zs~38G3#)T8>pI#ucY9h}Jp^+XFP=MR@nTcu zNU&|WEu*v8yRaGsi#s!H%iDq@mHejG3QtRmr=m6TP-jD9cXwk$rx;@dnbO^mC8Z>( zK_{8_>R{ZEl%^>>BQ6ESvK$N}>nP1C#S@u@g$hxwsznOu+g~4XW)e zM_H9MBdcvgTW~{r*ZP)JZGNDuFtx0^w6wddw7aCFyRcm#Dq-Bc zJaOLgviv-6b@i%wKYPUIvzO;R@=}2}Gs9Q-qO_-~r>wX?J9DI}YPgPH94ySN%8f{Q z{_Jd@i#Xg1&O8U4na!$$6^=|(g62dON65xB9B1h^F%`3Uwsw{SiT9#&QH&8#3)mq$ zLMyMH+js4Xp{w#1+D6(MSJu^rTG~eJ3-hlvjqF*y<_Amq=jD{wH=Vz9=z?HfMb7*_ z8W*D7CeY4=UCg3jejKeJCP>Sxw92q!BA8f$ODpK)^X5pTgxp0wA@(aIx4ZYj!7EqQ z)~>p8aCk{gsIR=dFI2Nc+T-fIbmi!!eXaqsZ*uYC2_Lp;xoFjlRufod&;u-FoZlmX zJv&ZXjFVRD*3x`WU=Z}PeVP(Fox4wrwW6xnk)Rb?*5%C2$&L@LFCkB(k7M~uCDkvjJNl`XFBhWuxER&9oJ)2R%QQq);vTM)T>$X6a6 zkJVy4hzFoQ@dEjz1k9j8wNlI^IdL4w*n`D`uy+Mtv0_C88@UlUBaV;rN|kQyAl_M` zR*VEpM=q_5+Dx^z-K*|8hR?N`^0IA8Jft#lZra zIbxc_923?G6h9e;oF$s{I7`S=G?+B@JeVmuGE-{(z&A~IaK%RLQfa=|QR`l@*)%bp z(K|0a*u-Cm)NR-r%yShXwYG#A*#TeAO@O@DIpLeH8pDX!G!S1+$}3skfMD1 zG|$@l;W(%f=7W*Lkqdcn0&Qt@R{xZ21s_x=H5N~_U&2m^Ru5ueFvnxYAxKuD>)}X8Ox%FrU}EBjBLgkxFI{qebMyI2mY&}tWE_`#f_8RR7A%IwAR}=S z+8`=#U^J@qvs;CjGZngYP?7;iebs=40*KcD`xow&oB+J^!CC!YUp2Wf+N-^Dxvd&CTh5ls8=5**# zS_8#srdZtaiDY{dWXl@pe|76C<2$PdDk}%7cS?J*7Y&y8*Cs>`@{Guz`I@9^UriO6 zfvgx@#a-k7E=3gp0Q6=W(Zs=x|L zHQM5ZsNJrrFfZ`u3SFPy2P@$BEP+~sI6$8Z&{sZ z{@JFy?%K@!N?WGCxTMNrF%{2msUIwL1j;hp`SuF8tJrQxF_rcPE9O;aq8DWMcrNMB>6r(AY~qi+Op7RpaaQfGtEwtq7WOzFEd?FN;K-V*x}@m zM(Prnj$fB9a zKP#7DaHpyUw@flc{`60i4?H;e0jB-yQU#W`YAtdLYEk8rPKE~Ly^zl&7}UmH&U^Haf+k$OC+Nb7)fOXo7c05#z`q5 zl~V;VEwaxJqY+#v8^8GT=AZub+&_PD?h}vWdla2td=Ys(at~P!(f-dv&3fhzI#F?) zWSyk|K5L$uDwZ5(l?3L09G!gR;mOayli!Ox!8;<85qu(_W7mrbS%aP8U@4Z*PKZOr<}n!Sa|%#Pd~oU<>KVm`z3o zTa#9+P@6&$2Wn}37j-;Xu_UmQy&>eXKM6LVZGUBd#iD-7uLCOalq8kN2@ z+rrACZcp##W}hi9Sl-xKkkHy$lAm7R8d|fhWtqFPpsXXSqajysFUfP4p?}@r@)V3h zDt71AJqRVF(Z>3QV;Q93wOToZ4wUt&%T}(7bHLr>kGB#$Gbkw=oRc&L#pxxFoWFKs(4>dN127-NkL4c{UxJTOabx~#W z)`bhVHqTu(JQ50x46gzQ&`7-@M(T7Hewk|Z%}n0^m4%-;XH8w>`gt9r>3u0Jg$3=! zC2a*I%_)6ZYfK&6dggEMXs^i1$uC;a+Onv)AS!93i9JwX>JYe_gAWrco@ zQC2>cveEm3e@^fkfU^ln4xQWF_X2QPn~Y+-X8v&_YUfsoohK&*)!YAKcK{xUA8kiCiZ6x^qhF z%?ljk2Y9}_)l<-3X6o8LZ(wU{<-oYVWiE)2URKAe+`*Ep7Lo;|#&RUuD_-0aHe;!f z9n)T=aAo4ASYH)@SE1!~{f?jmorBDg*PiC?ND$Sbsx-63m2Wod^;yE-=o1})BMVL# zDv^vyvibxvMIUU_k0Yf({a zVe`PiQ}&t?dr{{6x$Q+2^_vC;H#Srj^+#II`vyI^W`s+0v?ju)>~Jxx;Gi9Ee6HX|YP3Dy3q(s&0O?+&L^LoozJ^9RgG71VZG7AbiyS^v&7i4DU=VxXX9N!0i zh>&H9|C3}Tds^U?aaz2@(2}L418Wq~u&a1MY>|Hn;~sYtcqIZaE8K>rjKqvH;8oh?fosV8|Tw=WJ$vowz2?X|PCLkwNjP1IKy6 znO9{2AB@l<#o9ge;>2TbPTq+l#gXmr|1$F4pIYxyxq%&&egK;VmJbF*HLOp;<^6Lj`?$MGNByTL(l_#?+zbxXU@FTI}XX5aWcZ^7AeDxy=TBHksD2`bnIM zFrF3Tn6kxYofoL@PRUtZvt<5Ia(P8JHdc$U5mPoB!i#4)~bPj$~rQwY^bZWv}>rWY;jjfN!Q}C z{7#RjGvD3i@pPGTT32;+tZL1P#q*061cM8T)DW|}dwM0#b$uypl{F8VW3~#7bv7-L zqNE~4Ddc7ernf8?(U7}TqRptNQ_GA5)6#GxS+!NrL~5ZM!ak$S9vgkJFvg;=$Qp<< zRJxn(F&m{GCd)NZO?tcZoql1YEZEjuS^Uv^Gj!v&sWYxw7W0w;)w=VNE{5zk()#HJRl&X%!4>64P>aDd^0}u*1H=v07RRk_8sk zv}Do7t%9SAU^3^$5?H7Ev}{qEc|k%?Nsd3S4LeUQg>_4-8rRwe65HH`E&igm;<*(K zXPF#<(i~4wYM!km&yb|gt}QQbc2?J9=Va$Oo!R}! z;gwoQI0+VyBnduYtB8SwoF%JF-ED@v#W7>D9#we@UFP=*vj*!~pY=VGyF){xqsjT@ zkiERRW9QC}$a|{%?F6M>=SN}Z4nAOkV=%USu*hf_O^P-1%&N#9&BgYhdW+PuSP+@< znP@0jlM%?klqGQ@Oj#lPW2xA|7)YS6JE?qvwKZ{(OXaTeX4#H3)bpD5v{Kk ztOu0`_KxkiaBS}bV^>^0eRS{MqtlN(BI=x8$?xT#s@_@yRuvc%(sLLL=*m?zWgrxh zh65=z4OKL1WfWeur$N<#e!SC(qW6l?jX$?EkN` zIh{6pZm!fXBD>8=+C5pSWQXz2WFmH+>P4;e4FpJ6TK)+1XVg6KPfmh`X-UdR&VYPF zA81-e`w;UOqow$z`^CB5oE&d%MRRkNEhonYkScS$**VoYZ5~fsLypy&LqIf~UWET; z2XFVVvjiT-6p#>NK&*-qpxo?m7)yNfEK$iul-NR&_9s$1dIL5P4&Rs*9l04|LkTGY zk`SpOcWKkks=KMYx~!njm!D-dSd4l1dY!pGgQdWdmZs6v(pMz>{0{aYEZ;=t3})-2 z+ycsr9dR;I(E^A;L>G&I04AjHSfA%8)V2gF?YV{gj-m`x;_W*$`RR@VF_!px^n>uL zCekhhW~1=akrn|3ifQ{5cM=O_?DJXFy>2mxiqb^2Wm3KT-U~JEbVp%frYZ3bLBAb% zOJO^ngNIVB5e;#i-zHQsQy85TnyJ=)hH7`k*D6upIla4?1fLgnjMEK5erP4|Flr_7 zjg!qO$(-43$=NB{dIj$V;Ohgi<z zW1R7gcwj;lb1>{5BxA@yG%fz$FCrT*h;B(qQ16Z;hb>KXRCfyfd}rwPdZMetQJ`%; zfv)kyARMN5V7?KJ9b^eaaUeuMXF`Fm6bOOTr@`bzlK=OZ#1BP0BC53gLafEa?y5Km z6&nv)%bb4Cc2ZpM8QOz8Rf<8nU@wX$RN#ce5jx%PDtgMxdn!aYT;5x*ULt}0BtLv?mt%D3eF-<6 z@Dhg^SV*r?B$yFoSY)KrA~`oZ-JRivqt%{hR|RI&qMZ?zuDHd#%DSo$l{zrt95}ua zNk@QrSojM^L4gCYNnFt_%8iTh!36UDX^r6BWKb=aRpL#99=u)gfa*2&rf?19YJ!HX zHE<=7Cm5B4bAZ0W#Fl2FGGDgfDeOyP6 z%Q{_&4&LU*zTUQAb(PBrZvpoI7A@#q+P8F0SFoq8r#?{CTHOktQKhTWk&%{b%}ueS z;LMpiB35-`$7H=&1j*dT<4@l3lST}rJfVF2%D@%3N_f}1sJeQQ*Gs2whWMMq5l^i+ zq7w{CX>qss%D9V2GZiUsZRJnK5=~TxTGOKH$g|kR80S~1Cw)Avkqzn*pz4J_(GDF4 zQq%KAHRaJ-5DPBF|JA^MiJjttU>;0nk~iR9eE8=?k%|rDRFc%#5QHW6(#uiOl|ux3dw?M)ML`@ ze*8b8Ez%}Y{VpeG&Qe!tmLn}S zF@eEbs2F0x9O656VlL{Zr=?4DC&KTiB~+U2yA}S*zH+nHmTAk;HYc=JI{hw1(_l)h zQ`}CQzr?|>E6;ZqTQpKij>T+A%_*?fR4lZY+KV#Md?h&vvXW{sV@(HSiurLt{RFWG zO1n)o0;;rKdZM(|>2M$|L6?9-OEelJ_Y1^mxJ1amchgPdx8550g*rBGyiTL@I=#o~ zXUn7l{I4BabzcvA!n=^m@a1%jGtMhgFhoUPIr)?%+qr`78w6I443 z5!SIrVJ8+BlRqWc(ok0%D5fK{zOs_s?6e|lk?LJZ#5^RsOPhDHhlP%R;v7&O?F!{c zMYH?B7$>)t?0RziyZN{0?p#@0yK?8;Sll?-@A33cHb&##&4IpBQ(@uyC96yQWrbCR zRVI|4GxuV8>f*U`@N~0hV6vfca-h6?V6w4ca=>#g-<(=qom`TiaLYBw>I-Rl-!d!7H{U*2^OvxtrilLa)BUDBlp$Z6fsj5mXdi?cs&ka2H z99BTTz39e)n{FDo@uC6xmYAR4#OKMWcw-IvEWrpCIq}4@S9M?0D$XZXaW-bN78Gg? z8D$o~*mjTGI84(iKCAz%wYizLLQOECv&KJX)$lX?rogV~Uc9QQf(q%zRJpo){q5F+FXwBy)q5}g1l;U!TK65DSe28?lH z-t1}W*nz*t{GEv{`n=rS(#paNYrdy2J&@9zIHxAJp)d#Qm*O^lov$#}?s8?hvuYhV zSsDJ+;&Q7e-{CQtyxE1m43+B|*{`K%=&b|6b(u!s7hN}!^rU4Y&KwF0TQFUVOzTGC z4F$)#{c?sgTQR1l*tB{j-z7_p8dqt7l4vz6iF&;g|D`s@rh9q?e-pkA8(R@HCZm2U zhc^LksZy*~vPeML$VG1@+Gs*BrL4jCK;}ycQ5FUU(Vg{P& zEFLXP>6A9mRgYr|k1=)^1VPA}E2eV+c60Qw1|5Z<-(#hnMZP69uoM@pxShXX@b{P3 zEb(<5*GXR=hwvpy`Vf1N*MZ4TZ_~}LOkvZ_7+Tw-2Gc_^lLjSu9ntCgjqUcrv;ghEoFc#0fUwfG5^So}Ku zX*X@-x$`N^&nYU($u275*E`$1-ZrNi)>f65R}uXGp__%3;}Rj)-Ezy=%{NDWq1rq% zx*2+v_Hu;gFu|hFWakKOSeeQ7+7#$kNxUzCS7RpO_29}<^gNMkVE{m*>J41S^}01o zQTWOvqe;eg=|Ey4Jmw5b0GR!l==3H3o|pH+pB1dBJVD#{pfK0l5gLL4p1+ozejuF zj@brw^dSkWEU7mgJB{?;>`7`zU=k?0bI_FpwJZ4O0@p8PdOcrhG8pOW5YcWVx)a@b zuB=R}<)khop1upG)diw2o(`48GE~K&4JLF6#XzSN$MIr!tW&k~*9a|PDXFfhtlQWK zEuoH4CA4!1eB*Wa3d3t+jMC%vg+XfMMpn)BMlEC`9o7v%(uvc$FhH4vZMwBsY$^uW zTv!Le0$Q8MjTl44s9(bj24!W6DOpww%77!-0{UpQI?W|8$>_N6|FVLSU|B^4b~RZ; zeMMbGov^IDRrsQLX}PBiCkL>@5z|@bsEGxSPMka-y`|a>r|K~VT2k`AFD7JJbDK>w-BQq-_wYb7so}X1_N)U=I@*WMz z40AyigSR+r5gZM>4@bScWv&8$ffWY@c%+E`7O!J$eyjVfWk326New^y1}xpnKyii9 zd2dDnmEi!Ugk9vN5JX~$FUbQmNi>QkHYJ1)1qKQkDSkM8Q}}1xH!$!h%o9kScnt6| zffsIof&#zRTFf`2`QM`LWp8z(b(|yQZ-Y;qIGfcJtWQZMOQx4xjClJ1!-={=P?I=N zsL{x4kM%tE3bou+*U_y_SG|M!BneE5dDxYmIGG6WtYMQS0>E4bSQ?k-EvOIjEL z3cZ*_Zv?3QG8lvnfXyOB5q3W23=Dx1S`KKj960?`ab-t>x$SMxj&s`j+xwduyj3Md zblx&86>EYZ51PQRC$0xnGvUO;9jen4i-6fhg1TWx%D^vPEuvK%zrKCvSOcvO>bH+I zSI#NOYcQra=emQ%btB%KW^iV1iM6z6RXy4Lm4mATbA!&#)@;AgZpx_2EirkQU(!!& zgpvi@d-~2Uab{*`7tHhb4i_XC{$R5~(fF2k7km098-r_mOY*ytl8y#yCnYJZK2TNy zYQtZ`?#5mpd2+DlOgc@FQJmY7uvV3%5~-hDIq^=MS+}EV$jNc6u=*s&3_m|+a71>- zJT^4Wh0_;CTh1VhJ5g*jG6~KvNgkWUgTbuFA&?AHMp~+B*qqY%3692C^Fy=l+gSuO z;31|t)eCzq6BUNhZ7y$8 zR^Pw!8n_^`xFzz>rSJxj+ki6{&ZK-A*S4g-EP(l`?%$|eMR9G5HwAD`e&^0#-9BIX zkNN+62DQ+)pZ|ksoj=5+QmR zr>7vdNSFQ(SCDpyDkHyOKwUve>Pi5fcGa4sDzA*K0pMj%C;y4S0UNc#R|WR5+rMKe zO)@IB6n%pIVT_dP5m!3_jJ5Cn*QB{mWy0PX|{E)qA96c^F5B#RQM-If*Cc50<@Qpa{Z zcAF|=IdbBR<+ROWxyMeLrJc5Jt-D1=rn25=EQF5*kHf^-UkpA zDcf* zfTZ2;^9ZA&UeuF?LN#~8oDaYho7qWJhdT5K2-3e$J6rpw_+`)k4Sr~3V|Y_Ug&h{5 z-MO*aN)QKC$dh)i=Z+ofo6(I_s>WOx&J zTcAJ*I#dq6EzicGfCOu%Oq;Ogwj{z;yW6@W*f!akYJp^9p0cR1(n`Rs3jmyu_mN!o zKz9}u8LYXDu%c&?MsJyZbS-;u+8aK$Co-F!8F;X!L0Q(g?2q0xAHBoZ-UlVC+Y@l7 zZ}NVjZ#+0q-MKG2z1AF@z(}Sk))}W z4-?hNYW<_pv21p-ePC$n^6JZXtiQN#?CW=&c^Q$0;9@u=z5aIh67vx*aY)2MKgsc zZU+JYTY9TLbSW{g*09#S*04U97-aW(m)u#;$&;R}d&#>*wWZx#fLr?z@wI8|<5s?% z{cN-aQ1LPrroDP~_A39KC2m-*UA$W#ZM>vAe5hPuz+I)Q;NJ5<6gqQnX1C zL0<*Pf@&v|6vYWsyf6Vc4 zm>IYfvW-){po2+IpoCksCo#&4GlR@>Q*+R`xX$CnydVbe0AZ~v8Qjujp8z2pHmRuL zVGJ462@)TIL#qw(nA(8kH#U0Wh}nXXkDEkMk1SRz)JGUe)p}S6SdL}N=S3Y_1O6!L z5JkOyG{R+g! z!d^*+C?8>x` z4>VhS+6lw17wmSMtufJMcQn%r>DSb*|5MgOvo}i9V!veFRI7zCn>@`Nr)hzgiVCH| zu+{XHoT-SwuB*L)@|}h{X?a-H`Ntd54eb7+QuXFvU171p|Arr$wEfSpZ$Uq_Lp(r_ zYjHGN8!^i2D#4)+D=C%}Y*6JR=)WkJZ_t8_%>*>EriFR5q)CK~5bJ0Oy(MJiBK(K_ z2tHrwPYvz2PP@na>AGp#p?+H2mZvL5ZyU{@9;+P7%R@0ocSYgD8o$jE@7GQ~&ep4! zXZ8THLDppvc{dOEU$n6K)5rLdT$IS61lYQ6l9N;|#lK zy}sfwtVWTFnf~5@H*9PS1dX;TC~VfyfP%plf{-e${p-Z8wMUbxk?w`L;g=nK1yU;jc?MK)q|);LocFBEWFV4yiL&=;@| zSLb!jt-V2W($?V}4Gm0qCUdsVNJA{-AMtfGh8t27O?quZgQ0eJeXR*Z0O*%APD`(o z6X=zcG@09ld0zp*iOr;!5ya+ofOZeI;3*@(C=-w#B;FA(rko%}kjX1}k!fj{*__`O zYxSl4DYr95G4l}pq`!#BP15qQ2#DHZDG=LbzB|4?9Qw{;?Lk zX|h_I@X|8o_m4Us2$hWjVC;6 zjnY7_7s?s1>M(qO9Rup^aoGI;z<~aD3|<0g=LQDjG9CAV>{tn*eZx|WpXzexg+S~P z@`8SUcJboHN3KzT+_gs@ee}_#j-?JnwNaSdop`&X&PE~S^w;lWijWUX#GQ5-5Pb?4 ze{*weon;Dt{^q~ZU)`d1ofmM`S0`;#uGS%X?Z9tx0hW7#mJWrwCeL6~zU zKHVwor8tfiI748i2MI_sJW1dl<=T9?MJ^8bJdmPC4QeB_{cuDaVF$YjIkGLu887GKAb!P^^j-`kXQC&JH+ z^f!fKUQaOHn6%Gzr$+0|{qb0LOLu#d+u?9Jl9nFFRA=XOV`C=Rp7*juZM)YQH;(F? z{Y}1BctbI=f-z(djxarVbW|t}7oQY-;^c$}kHcgO&y5!C#xAs7pKb!ltj~>H`xCy_lJ(tf9n{7?r z1gg@$G0$G&D^g6l@Ws{$plvi#$&%+%7w~%t9KkmWFhC)kV6_;KGx=-?*$`01*ZF&) z!Jr96bk1g`oOg%sWm&UQyp=|t2iBg4?*#qLw(3|-Oe|}p zopYEs=r(|sZvv*Y+E58iBgSHU}&o#qtVcKdun%M z9l%dFM`&h38Y_PSX

cDr^o7w3d)os#Fjw3a}a)U^U9Dap34WR0(j0phq!^@Ov(j zja3vU#EJwdcm)Ioexxe)eRj3*QC3xWjU6xa6ka+0B;2x`GUYda^Z&6NtqC?r&oH7D z-LNuOk%b<^iTji(auFonT5d#c#TJwVG}+04HXy-)Y00Iikg;wINZ5+BCvKPzqgKr# zFr6oQFJ}EtW>R3_ym)7H(_gA03ML zn0&_WGdo>oI6vh2aPa)UoF83E)Z2ZQ7MBS018gdKRRL9MZX;>H29x^#vU~&Y3+w|g@-=6pp3r!!HT6<#hTH(8k@7;51_RN{t zQ+vo}OFQ{m#PH}a)4^Ksu@ucU0 zEyFIv4PRLr{uF{K-JF2u`jsK*r&42Sa(SC|Zq!lpxr! zVS#-Wny=8EN?2O#X#R41<>raZyxTpOnYejnJiFlaE_AxPy!LD$(4J&>`IGGdf7b5H z9AC^2EoOYa%;Hdfu^UFkDKBFE!d_QA?(&B5){8bvp~v0_O&}tra>;O$302^9G{Syr z1{xKT?hvP_vM97eiAa&Ku_?^4OE86zJ);x`iXAd0tRT}YZL+ME)MNRD_}t0<{!_Dw zy(5qPFgelMceKCnXkY6@a;`o2z6wo0R#CjEfF+0EO44b;NweK(Icu5!Dk*NjZ*=}-~19QU((I-A!dJN|PX6JWT&&=a z9KwobmJlrhyFcUz1?+H0p60M#Cj*a1oTSKLHhaL4WUSB&HV1;daoZtTJ<_=U@deqm zQ0Mn_AoB4=_KErV!asoLfMOrLg;m~@avwV(e-$f>r5FKb)S>1Att@>=xb5yV7$9aX z2BNnD4XT%r6?{)uLLS0}rp|A@K79+p4>kI^DZHt(;*H#QPfcUJzE(GWPmQ&XUb$>U zgxs${cXE`_vCxw=D_DThpb9srLRUfDA4U#gV~uq+fJsBPfLfF`7*qmmGO&*ZO|NfL z{U}Nr9c!s~o9pa#0qsy_ci0j3S?Y{>9m-*Bj>&@nPt``S8ELFG7%EKFbzZVJMqrWt z5~EGRTdfRakip6jVZVNruMD_K{+D-rmHZ953bTm$6+6aEH98Wrh_kXm=vKi*2K1O# zz8{JUnUa_36^wTf#gY~vD)?s2H3R|!jRP*`jBN&8a}=yf!YkiN!!;XdyXT!b_R3aWD46dXn z;LjmOP>c+kccj>n0t<^ZwAx!>29-sSEd1pbi=bda*ApMI6R3|Q?Y!S#{IMae5eP`e7gwPFl+$Glv{76)r4Z2Tr;RV9TUm#7}7 z{^~RMg#3fH4pTb4Fg8~2Z!o7j9(9em*+}6ZTgO|!j0p*uPF@XYJU@d)Hwf?XST1ie z;&U?5V%8xV2s$5@d1b1BK{_G*Flt9c2DA{6847#SaEM8v_Het$B}iup*MY7G>0vr7 zZY+vsLr7M!{Bp~w5V9c!-aB)6kGnM(vpR^>Ah6372}bN5ynp)tm=adVR>MOOv$ZvU zZRx1C+1ov7*Fq)_ssoEP>1lVZ^wRrJykV>Z*MT#29DjXXjT@l$K>doWezeNPXuMQUX(d#4M{>8NEdUpjc89M$3sB{3JvT)#3vDeF&21C_-)7(CMy^#M9EWA zB_r*;3FK!5je&v2%*y$vd$*M;pZ8ARae z!f9N!V#AH0hu%>Q(_%l!b-^v1NkSXM>yKx5pL>8ahxLuQ?AA;e)M32?v%%JK zrlm|G+O4uJ75;91cReVZiA$E|p%zk*bm7_DG$?+t%^*>Wov;B+*vgcRDFjmzdKon; zCT19R4OP#f77h4|N-_EuTX4z8d}V%hl8aU#JNVRu7m%CtOH?ChE1?)OBh;F%)q&w< zt0w5)?H{&}wt{&v;Sc*0xa80{Sh4e9$F3ecF_2&HNry)dxsa>2dfIG5CigApdi!p_ zsdJMeTZV52+#`^g#FK{DDwTXh$ji526}b_mNEz)Jb>UV@krM0K7Df?e;Af$>{`c$! zqexqbGm5OP3g)x46aq)xH?UXIeO^O|On5us@fPmxTe}~Boc>?B)cts<=hEXnkK_NP zp2sm0(o!vUb1&mM!j)f`eAb7N?*=ZJVHs6`5yE0Y@)GtYvO*DKH#ps~_ZKG6Vm?WW zZ?u}V&HQBA_el&1GWX=EH$=~CU=C65rPa(~t8X{flWR`|@Q9!pC@MHsfXq(imQVtt zmZPXxoGhOR#Vp{+-k+-P*(#6BMDm5XxvvxrpAa@AMU~euI*Ggcia^@{aI92f6Q+}= z>^b!V4!E8IVH81@y9+c*CGk^~1{mvE&1)732MbYS#V-)Ko+9FDAL30=Zue>eV+i)f$k1CBv!$7Rs zZN$irT}zM!w_;WWrP5nVv@O0lW-jhyvM0Un_5QvxinuOFv?F69YfkUU{|<@f9gGKt zqOWe>-Q=8zc}gc|H@V|7iH>&G=7&3Ypl}U~?+}p=4S{+{+Sni~v3y)PA_uu1iNou1FQn$?VxH&!(egdG?g@T#%09JT~N!pS`#>&sZtXI8N~D7kT=( z=DDR*hJ!fWYqJcS>C@6iS$cozp8KU=mM)93T`t}e@|yj%{Bh)gF&s|i)%W2A^%dza zsOfrkK>Ao&`j<-i7uL%2f2NdvX0<&1_e<%^$I8-`%F_Lh(YeSwJaU9lK38xj<%+BoNPzwq+1vjY7-E3ApIaX5_U?T#tDp@>D0d>|012w zT)GeS9T9%$r3*Nfiu#U~^&QteWooN9f7St=2 z(wE;W(ueWnn|WQVJpaMPoznGeT6&b<2UoMRe5{K6i*!5OKZ*2P6{-u;sV*C@Z>tN; z^3sb`7sz}n+W|_zwR8)d7hg)BTIT6E8%JUx;O>g~7m$Gb8{bEIKt^5E`z#}Y?nC{u zrqFzWDTL3L5A*rL%H|8#b$NYTa1r(0kNLINr%y9jsBlk_PJJixFXC*@&2)Me()qJ4 zY@3_nT51!K=K|*S^>dSR0Q2(r)bhL}>H=M7y)1nidstpao=)Wv`4EB;od`ISMsj-9y&FG$}UV_Ba6fb{7d)2F0Y%F=1< zQGSfQbLID;`-}85x0R>Um=x*DcZhV(_r%A1KN3hj{5O98>(i&C@06v}b16Tbd!qb4 zbbpb)d`hHCKvg}%??1%vKc&tokxuz3UB{ODM1Ia!tsFqQhqvtrPG=%HTt2Qio0t3? z^7Qg+XUPhLHWY16{X*~zn&XSx`Xz>b>EQQAufq=_u&ZZd7(qWs`yCje5!Ef!-13tj z$gKLFLx*-c|KZ_>S#p-OY&!(v8?&f&7e?tW=t4S4`vwXwok1xZP|6xo`{ZkjxAq`I93=8j072{I9Aw#c}yJz)pc0TeC~5?^EjXB z$!Yd9KcVS4ELa;9Z!fp;^62CAc>(1k26CNL(Mq2?@clH(2h39qeO|)%5&pT#O`qrR znf%r|nA+$wz*KUOe_uU8pLgN=EdRcyn&-#&Nu1-NlWKW>8U@FB55snW9v+o$-zF`@ zsz@V9td=wCY^Va8AI?`nIT>%8 zy;DoP-B|On8&U_Snj6B_CJQk?bp#p%wd&Tk)K?@~L|^SR^F@2pxS+nm%)bjSxK^w# zXOZ5*$JA%lzJlgg(hkM7Uqv30un1d~p5oVvRiDO)m0!Cb^5eQOvOyCUyxpZ$b`=Tq z++mD6oc$rLMS0fMaq0!v78r4n-cIF|cKyRA9H04TGaK@P+Uu6P+Yqr{SCiXNNRc(BsJ<=T;YUthK9kVvmR#1Kjrzw z>Pzh)R?Yp;6t8OsNvt|lUs^?Iu6&^E+Ph2Fj-w2>3GMB+q6W;b<(jCt_LzjQNz_q1 zm--Pp4YLg@5FPd@5Hr-q)Y0xd+F2waV?)`wI~;`>gX3@NeoJ-awb1~B|alu z8-O+9m@90H)tTlXNEo);u#u9RN)oecwK%(Mw8hkD*RRh-GYUNGP6rsD1%V=VS|z|V z%&D7H0^27NoQj-Lu{ItDvp|s(4U?ulW-hTSekV8_5+nm9nzWQVkg6B~*CMqTB1{b$iiJU`7WS)wUeGP(yG{4x0gF0d5mQ%#Z`i2#nCu6hFfUe+y4B z`u*wjGJd8D6?B#lyZ3I49MU0(N8UjtFkmOnxg4A`Jwey%fdl$B@5SH-?g_ILPY3VO63|12KLJn$ z`|U5w20o?5+|{Y;9Bp6pYzZfvQ+jqoCxLSs*LU(VEs@AYI`y!h*T<`l#N4(r(MXKo zWF3AjG`KnGIb2zZHT%mO8}wv+;_JHnDdfR(DG&3Y@A$gTit+|M$rj}AAWxsjBYg*B zo9D?C^Gt2%WSqBwJYps-sj_oyL&yG>>l0!oElHo^<)`OPDKs10yuQ;oO_<6P1SksU|_oR^sZC2Lde0aR&JCI?FOgbCm zfiKz(GB_9@$jEpW+C&2ZF9`b=+0eM=AnafIlG!YJ!<;*s%jndMfKQy8!3iCT<}#e0 zLAE6z)i%v#gjEw0u8g&+Xe8b|1>uvQT$rD~!AS@oq=`^??E2FX)E>W$kDLazCC_ea zNm}7QApbU`KBN|n3b_(j@-e$Pa?s<5(nOEg(5f_I|4#HEdK~QQNQeH*(=YS%6!PO# zG^Aq|Q97$s({aKbFOOK6N2G_gjbyPhlT4+R89jSDUQnViY%I{Yk?!I5+$(*Pr@O$* z0v2SX)4qvpERU%9bzAZ;NmqG#70QNFcaR^miI@40Zk&$!0hTlAsh@+h$GDZV=WQu_ z=n2=PE6OdwRLrt`ig*Ck*_Jvpj%sQsWa8+!AXNo z#2vd?@IQq4^&>Z8enpQ+@+&N;9KZ}6-S`XMkCJL95hci@1n-W|_Ng8DY|Y>czFYns z>|*>mkAX1}ySK4)|Ob#G;h%j~Oau1_{4$)_tJ&SCHu5a*ClS&ZKz&Vi4x@3BE;59Z4S zj4-m}NvfTe+HOWV{jOP*i_e!gS(N$nUYAZQ>v-PSwh@if66JHSH^ilza?56utZ1XH zGEQ`xGGGaq)G=T?frN zW-N34!$(d3>Uf2*FFG!mkBs)sH%@y$Q2o%kjtZY&-*Lx-dv-hGUHcY0^nPDO$Nc_m z+=0VOiJ{;lY#qE-bP6=^fpiKq03ATO00V|Mz^oE#KE-J!JFYtfy0RH8b>-wr-&OgP z*hk7(Zi?%|&&%L&^9>msXcSzNp212`vu!2dj1X7}ObIze$^$u>t%ntNpt9al2QrA0 z@-^Aiq_1tLLr~-?+z9{sSm(P&fAJYg(@9^B{(1-CFFx+Bu}OAq54rmI|0ZpE= zT+km*8TCSI#07jkgW4rl$8Fj@{41lzwMnd(ev^936$h+rjy41 znXE)uJ4#j}7xeIQ9e7E47`-lH@H5PV8S@Shk3m+C7<@d*jJx4{4rs{O1408|I)IlO prE_g_5F_v3hxH^vvm0VPul<}*Pu^NT07K~_X8Y4c86 + + + + +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