From 3e191a4f606abfb46293a41076882f7dcbc93d40 Mon Sep 17 00:00:00 2001 From: Jean Jacques Avril Date: Mon, 6 Oct 2025 18:27:50 +0200 Subject: [PATCH] init --- .gitignore | 5 + .vscode/extensions.json | 10 + README.md | 304 + data/web/index.html.gz | Bin 0 -> 147214 bytes include/README | 37 + lib/README | 46 + lib/data/hardware_config.cpp | 131 + lib/data/hardware_config.hpp | 46 + lib/data/library.json | 6 + lib/data/rfid.cpp | 282 + lib/data/rfid.hpp | 68 + lib/data/session.cpp | 108 + lib/data/session.hpp | 33 + lib/data/settings.cpp | 161 + lib/data/settings.hpp | 56 + lib/data/users.cpp | 392 ++ lib/data/users.hpp | 38 + lib/hardware/hardware_rtc.cpp | 168 + lib/hardware/hardware_rtc.hpp | 53 + lib/hardware/hardware_serial.cpp | 222 + lib/hardware/hardware_serial.hpp | 171 + lib/log/logger.cpp | 149 + lib/log/logger.hpp | 68 + lib/ota/ota_manager.cpp | 94 + lib/ota/ota_manager.hpp | 26 + lib/rpc/library.json | 6 + lib/rpc/rpc.cpp | 445 ++ lib/rpc/rpc.hpp | 155 + lib/tft/tft.cpp | 3 + lib/tft/tft.hpp | 48 + partitions_custom.csv | 7 + partitions_custom_ota.csv | 6 + partitions_prod.csv | 6 + platformio.ini | 94 + proto/control_communication.options | 9 + proto/control_communication.proto | 16 + proto/device.options | 15 + proto/device.proto | 46 + proto/hardware.options | 12 + proto/hardware.proto | 104 + proto/settings.options | 12 + proto/settings.proto | 57 + proto/webui.options | 36 + proto/webui.proto | 83 + src/device/device_manager.cpp | 255 + src/device/device_manager.hpp | 166 + src/device/device_status.cpp | 86 + src/device/device_status.hpp | 31 + src/main.cpp | 147 + src/webui/auth.cpp | 49 + src/webui/auth.hpp | 31 + src/webui/webui.cpp | 183 + src/webui/webui.hpp | 64 + src/webui/webui_auth.cpp | 127 + src/webui/webui_hardware.cpp | 98 + src/webui/webui_ota.cpp | 169 + src/webui/webui_protobuf.cpp | 34 + src/webui/webui_rfid.cpp | 81 + src/webui/webui_rpc.cpp | 22 + src/webui/webui_settings.cpp | 77 + src/webui/webui_status.cpp | 49 + src/webui/webui_strings.hpp | 24 + src/webui/webui_users.cpp | 329 + src/wifi/wifi_manager.cpp | 277 + src/wifi/wifi_manager.hpp | 123 + test/README | 11 + ui/.gitignore | 23 + ui/.npmrc | 1 + ui/.prettierignore | 9 + ui/.prettierrc | 16 + ui/README.md | 38 + ui/components.json | 16 + ui/eslint.config.js | 41 + ui/package-lock.json | 5398 +++++++++++++++++ ui/package.json | 58 + ui/scripts/copy-web-files.js | 93 + ui/src/app.css | 121 + ui/src/app.d.ts | 13 + ui/src/app.html | 11 + ui/src/hooks.server.ts | 278 + ui/src/lib/api/constants.ts | 1 + ui/src/lib/api/fetch.ts | 26 + ui/src/lib/api/webui.ts | 402 ++ ui/src/lib/assets/favicon.svg | 1 + .../components/dashboard/DeviceInfo.svelte | 88 + .../components/settings/HardwareConfig.svelte | 369 ++ .../settings/LedConfigDialog.svelte | 271 + .../settings/SyncServerConfig.svelte | 83 + .../components/settings/WifiSettings.svelte | 129 + .../alert-dialog/alert-dialog-action.svelte | 18 + .../alert-dialog/alert-dialog-cancel.svelte | 18 + .../alert-dialog/alert-dialog-content.svelte | 27 + .../alert-dialog-description.svelte | 17 + .../alert-dialog/alert-dialog-footer.svelte | 20 + .../alert-dialog/alert-dialog-header.svelte | 20 + .../alert-dialog/alert-dialog-overlay.svelte | 20 + .../ui/alert-dialog/alert-dialog-title.svelte | 17 + .../alert-dialog/alert-dialog-trigger.svelte | 7 + .../lib/components/ui/alert-dialog/index.ts | 39 + .../ui/alert/alert-description.svelte | 23 + .../components/ui/alert/alert-title.svelte | 20 + ui/src/lib/components/ui/alert/alert.svelte | 44 + ui/src/lib/components/ui/alert/index.ts | 14 + ui/src/lib/components/ui/button/button.svelte | 80 + ui/src/lib/components/ui/button/index.ts | 17 + .../lib/components/ui/card/card-action.svelte | 20 + .../components/ui/card/card-content.svelte | 15 + .../ui/card/card-description.svelte | 20 + .../lib/components/ui/card/card-footer.svelte | 20 + .../lib/components/ui/card/card-header.svelte | 23 + .../lib/components/ui/card/card-title.svelte | 20 + ui/src/lib/components/ui/card/card.svelte | 23 + ui/src/lib/components/ui/card/index.ts | 25 + .../components/ui/checkbox/checkbox.svelte | 36 + ui/src/lib/components/ui/checkbox/index.ts | 6 + .../lib/components/ui/color-picker-2d.svelte | 205 + .../components/ui/color-picker-sliders.svelte | 110 + ui/src/lib/components/ui/color-picker.svelte | 421 ++ ui/src/lib/components/ui/input/index.ts | 7 + ui/src/lib/components/ui/input/input.svelte | 51 + ui/src/lib/components/ui/label/index.ts | 7 + ui/src/lib/components/ui/label/label.svelte | 20 + ui/src/lib/components/ui/popover/index.ts | 17 + .../ui/popover/popover-content.svelte | 29 + .../ui/popover/popover-trigger.svelte | 17 + ui/src/lib/components/ui/select/index.ts | 37 + .../ui/select/select-content.svelte | 40 + .../ui/select/select-group-heading.svelte | 21 + .../components/ui/select/select-group.svelte | 7 + .../components/ui/select/select-item.svelte | 38 + .../components/ui/select/select-label.svelte | 20 + .../select/select-scroll-down-button.svelte | 20 + .../ui/select/select-scroll-up-button.svelte | 20 + .../ui/select/select-separator.svelte | 18 + .../ui/select/select-trigger.svelte | 29 + ui/src/lib/components/ui/separator/index.ts | 7 + .../components/ui/separator/separator.svelte | 20 + ui/src/lib/components/ui/sheet/index.ts | 36 + .../components/ui/sheet/sheet-close.svelte | 7 + .../components/ui/sheet/sheet-content.svelte | 58 + .../ui/sheet/sheet-description.svelte | 17 + .../components/ui/sheet/sheet-footer.svelte | 20 + .../components/ui/sheet/sheet-header.svelte | 20 + .../components/ui/sheet/sheet-overlay.svelte | 20 + .../components/ui/sheet/sheet-title.svelte | 17 + .../components/ui/sheet/sheet-trigger.svelte | 7 + ui/src/lib/components/ui/sidebar/constants.ts | 6 + .../components/ui/sidebar/context.svelte.ts | 81 + ui/src/lib/components/ui/sidebar/index.ts | 75 + .../ui/sidebar/sidebar-content.svelte | 24 + .../ui/sidebar/sidebar-footer.svelte | 21 + .../ui/sidebar/sidebar-group-action.svelte | 36 + .../ui/sidebar/sidebar-group-content.svelte | 21 + .../ui/sidebar/sidebar-group-label.svelte | 34 + .../ui/sidebar/sidebar-group.svelte | 21 + .../ui/sidebar/sidebar-header.svelte | 21 + .../ui/sidebar/sidebar-input.svelte | 21 + .../ui/sidebar/sidebar-inset.svelte | 24 + .../ui/sidebar/sidebar-menu-action.svelte | 43 + .../ui/sidebar/sidebar-menu-badge.svelte | 29 + .../ui/sidebar/sidebar-menu-button.svelte | 103 + .../ui/sidebar/sidebar-menu-item.svelte | 21 + .../ui/sidebar/sidebar-menu-skeleton.svelte | 36 + .../ui/sidebar/sidebar-menu-sub-button.svelte | 43 + .../ui/sidebar/sidebar-menu-sub-item.svelte | 21 + .../ui/sidebar/sidebar-menu-sub.svelte | 25 + .../components/ui/sidebar/sidebar-menu.svelte | 21 + .../ui/sidebar/sidebar-provider.svelte | 53 + .../components/ui/sidebar/sidebar-rail.svelte | 36 + .../ui/sidebar/sidebar-separator.svelte | 19 + .../ui/sidebar/sidebar-trigger.svelte | 35 + .../lib/components/ui/sidebar/sidebar.svelte | 104 + ui/src/lib/components/ui/skeleton/index.ts | 7 + .../components/ui/skeleton/skeleton.svelte | 17 + ui/src/lib/components/ui/slider.svelte | 78 + ui/src/lib/components/ui/sonner/index.ts | 1 + ui/src/lib/components/ui/sonner/sonner.svelte | 13 + ui/src/lib/components/ui/switch/index.ts | 7 + ui/src/lib/components/ui/switch/switch.svelte | 29 + ui/src/lib/components/ui/tabs/index.ts | 16 + .../components/ui/tabs/tabs-content.svelte | 17 + .../lib/components/ui/tabs/tabs-list.svelte | 20 + .../components/ui/tabs/tabs-trigger.svelte | 20 + ui/src/lib/components/ui/tabs/tabs.svelte | 19 + ui/src/lib/components/ui/tooltip/index.ts | 21 + .../ui/tooltip/tooltip-content.svelte | 47 + .../ui/tooltip/tooltip-trigger.svelte | 7 + ui/src/lib/gen/control_communication.ts | 290 + ui/src/lib/gen/device.ts | 395 ++ ui/src/lib/gen/device_communication.ts | 323 + ui/src/lib/gen/hardware.ts | 1126 ++++ ui/src/lib/gen/settings.ts | 503 ++ ui/src/lib/gen/webui.ts | 885 +++ ui/src/lib/hooks/is-mobile.svelte.ts | 9 + ui/src/lib/index.ts | 1 + ui/src/lib/stores/auth.ts | 49 + ui/src/lib/utils.ts | 13 + ui/src/routes/(dashboard)/+layout.svelte | 118 + ui/src/routes/(dashboard)/+layout.ts | 27 + ui/src/routes/(dashboard)/+page.svelte | 282 + ui/src/routes/(dashboard)/rfid/+page.svelte | 237 + .../routes/(dashboard)/settings/+page.svelte | 140 + .../settings/hardware/+page.svelte | 347 ++ ui/src/routes/(dashboard)/users/+page.svelte | 276 + ui/src/routes/+error.svelte | 44 + ui/src/routes/+layout.svelte | 13 + ui/src/routes/+layout.ts | 9 + ui/src/routes/login/+layout.ts | 11 + ui/src/routes/login/+page.svelte | 88 + ui/static/robots.txt | 3 + ui/svelte.config.js | 24 + ui/tsconfig.json | 19 + ui/vite.config.ts | 11 + 213 files changed, 22261 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/extensions.json create mode 100644 README.md create mode 100644 data/web/index.html.gz create mode 100644 include/README create mode 100644 lib/README create mode 100644 lib/data/hardware_config.cpp create mode 100644 lib/data/hardware_config.hpp create mode 100644 lib/data/library.json create mode 100644 lib/data/rfid.cpp create mode 100644 lib/data/rfid.hpp create mode 100644 lib/data/session.cpp create mode 100644 lib/data/session.hpp create mode 100644 lib/data/settings.cpp create mode 100644 lib/data/settings.hpp create mode 100644 lib/data/users.cpp create mode 100644 lib/data/users.hpp create mode 100644 lib/hardware/hardware_rtc.cpp create mode 100644 lib/hardware/hardware_rtc.hpp create mode 100644 lib/hardware/hardware_serial.cpp create mode 100644 lib/hardware/hardware_serial.hpp create mode 100644 lib/log/logger.cpp create mode 100644 lib/log/logger.hpp create mode 100644 lib/ota/ota_manager.cpp create mode 100644 lib/ota/ota_manager.hpp create mode 100644 lib/rpc/library.json create mode 100644 lib/rpc/rpc.cpp create mode 100644 lib/rpc/rpc.hpp create mode 100644 lib/tft/tft.cpp create mode 100644 lib/tft/tft.hpp create mode 100644 partitions_custom.csv create mode 100644 partitions_custom_ota.csv create mode 100644 partitions_prod.csv create mode 100644 platformio.ini create mode 100644 proto/control_communication.options create mode 100644 proto/control_communication.proto create mode 100644 proto/device.options create mode 100644 proto/device.proto create mode 100644 proto/hardware.options create mode 100644 proto/hardware.proto create mode 100644 proto/settings.options create mode 100644 proto/settings.proto create mode 100644 proto/webui.options create mode 100644 proto/webui.proto create mode 100644 src/device/device_manager.cpp create mode 100644 src/device/device_manager.hpp create mode 100644 src/device/device_status.cpp create mode 100644 src/device/device_status.hpp create mode 100644 src/main.cpp create mode 100644 src/webui/auth.cpp create mode 100644 src/webui/auth.hpp create mode 100644 src/webui/webui.cpp create mode 100644 src/webui/webui.hpp create mode 100644 src/webui/webui_auth.cpp create mode 100644 src/webui/webui_hardware.cpp create mode 100644 src/webui/webui_ota.cpp create mode 100644 src/webui/webui_protobuf.cpp create mode 100644 src/webui/webui_rfid.cpp create mode 100644 src/webui/webui_rpc.cpp create mode 100644 src/webui/webui_settings.cpp create mode 100644 src/webui/webui_status.cpp create mode 100644 src/webui/webui_strings.hpp create mode 100644 src/webui/webui_users.cpp create mode 100644 src/wifi/wifi_manager.cpp create mode 100644 src/wifi/wifi_manager.hpp create mode 100644 test/README create mode 100644 ui/.gitignore create mode 100644 ui/.npmrc create mode 100644 ui/.prettierignore create mode 100644 ui/.prettierrc create mode 100644 ui/README.md create mode 100644 ui/components.json create mode 100644 ui/eslint.config.js create mode 100644 ui/package-lock.json create mode 100644 ui/package.json create mode 100644 ui/scripts/copy-web-files.js create mode 100644 ui/src/app.css create mode 100644 ui/src/app.d.ts create mode 100644 ui/src/app.html create mode 100644 ui/src/hooks.server.ts create mode 100644 ui/src/lib/api/constants.ts create mode 100644 ui/src/lib/api/fetch.ts create mode 100644 ui/src/lib/api/webui.ts create mode 100644 ui/src/lib/assets/favicon.svg create mode 100644 ui/src/lib/components/dashboard/DeviceInfo.svelte create mode 100644 ui/src/lib/components/settings/HardwareConfig.svelte create mode 100644 ui/src/lib/components/settings/LedConfigDialog.svelte create mode 100644 ui/src/lib/components/settings/SyncServerConfig.svelte create mode 100644 ui/src/lib/components/settings/WifiSettings.svelte create mode 100644 ui/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte create mode 100644 ui/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte create mode 100644 ui/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte create mode 100644 ui/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte create mode 100644 ui/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte create mode 100644 ui/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte create mode 100644 ui/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte create mode 100644 ui/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte create mode 100644 ui/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte create mode 100644 ui/src/lib/components/ui/alert-dialog/index.ts create mode 100644 ui/src/lib/components/ui/alert/alert-description.svelte create mode 100644 ui/src/lib/components/ui/alert/alert-title.svelte create mode 100644 ui/src/lib/components/ui/alert/alert.svelte create mode 100644 ui/src/lib/components/ui/alert/index.ts create mode 100644 ui/src/lib/components/ui/button/button.svelte create mode 100644 ui/src/lib/components/ui/button/index.ts create mode 100644 ui/src/lib/components/ui/card/card-action.svelte create mode 100644 ui/src/lib/components/ui/card/card-content.svelte create mode 100644 ui/src/lib/components/ui/card/card-description.svelte create mode 100644 ui/src/lib/components/ui/card/card-footer.svelte create mode 100644 ui/src/lib/components/ui/card/card-header.svelte create mode 100644 ui/src/lib/components/ui/card/card-title.svelte create mode 100644 ui/src/lib/components/ui/card/card.svelte create mode 100644 ui/src/lib/components/ui/card/index.ts create mode 100644 ui/src/lib/components/ui/checkbox/checkbox.svelte create mode 100644 ui/src/lib/components/ui/checkbox/index.ts create mode 100644 ui/src/lib/components/ui/color-picker-2d.svelte create mode 100644 ui/src/lib/components/ui/color-picker-sliders.svelte create mode 100644 ui/src/lib/components/ui/color-picker.svelte create mode 100644 ui/src/lib/components/ui/input/index.ts create mode 100644 ui/src/lib/components/ui/input/input.svelte create mode 100644 ui/src/lib/components/ui/label/index.ts create mode 100644 ui/src/lib/components/ui/label/label.svelte create mode 100644 ui/src/lib/components/ui/popover/index.ts create mode 100644 ui/src/lib/components/ui/popover/popover-content.svelte create mode 100644 ui/src/lib/components/ui/popover/popover-trigger.svelte create mode 100644 ui/src/lib/components/ui/select/index.ts create mode 100644 ui/src/lib/components/ui/select/select-content.svelte create mode 100644 ui/src/lib/components/ui/select/select-group-heading.svelte create mode 100644 ui/src/lib/components/ui/select/select-group.svelte create mode 100644 ui/src/lib/components/ui/select/select-item.svelte create mode 100644 ui/src/lib/components/ui/select/select-label.svelte create mode 100644 ui/src/lib/components/ui/select/select-scroll-down-button.svelte create mode 100644 ui/src/lib/components/ui/select/select-scroll-up-button.svelte create mode 100644 ui/src/lib/components/ui/select/select-separator.svelte create mode 100644 ui/src/lib/components/ui/select/select-trigger.svelte create mode 100644 ui/src/lib/components/ui/separator/index.ts create mode 100644 ui/src/lib/components/ui/separator/separator.svelte create mode 100644 ui/src/lib/components/ui/sheet/index.ts create mode 100644 ui/src/lib/components/ui/sheet/sheet-close.svelte create mode 100644 ui/src/lib/components/ui/sheet/sheet-content.svelte create mode 100644 ui/src/lib/components/ui/sheet/sheet-description.svelte create mode 100644 ui/src/lib/components/ui/sheet/sheet-footer.svelte create mode 100644 ui/src/lib/components/ui/sheet/sheet-header.svelte create mode 100644 ui/src/lib/components/ui/sheet/sheet-overlay.svelte create mode 100644 ui/src/lib/components/ui/sheet/sheet-title.svelte create mode 100644 ui/src/lib/components/ui/sheet/sheet-trigger.svelte create mode 100644 ui/src/lib/components/ui/sidebar/constants.ts create mode 100644 ui/src/lib/components/ui/sidebar/context.svelte.ts create mode 100644 ui/src/lib/components/ui/sidebar/index.ts create mode 100644 ui/src/lib/components/ui/sidebar/sidebar-content.svelte create mode 100644 ui/src/lib/components/ui/sidebar/sidebar-footer.svelte create mode 100644 ui/src/lib/components/ui/sidebar/sidebar-group-action.svelte create mode 100644 ui/src/lib/components/ui/sidebar/sidebar-group-content.svelte create mode 100644 ui/src/lib/components/ui/sidebar/sidebar-group-label.svelte create mode 100644 ui/src/lib/components/ui/sidebar/sidebar-group.svelte create mode 100644 ui/src/lib/components/ui/sidebar/sidebar-header.svelte create mode 100644 ui/src/lib/components/ui/sidebar/sidebar-input.svelte create mode 100644 ui/src/lib/components/ui/sidebar/sidebar-inset.svelte create mode 100644 ui/src/lib/components/ui/sidebar/sidebar-menu-action.svelte create mode 100644 ui/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte create mode 100644 ui/src/lib/components/ui/sidebar/sidebar-menu-button.svelte create mode 100644 ui/src/lib/components/ui/sidebar/sidebar-menu-item.svelte create mode 100644 ui/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte create mode 100644 ui/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte create mode 100644 ui/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte create mode 100644 ui/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte create mode 100644 ui/src/lib/components/ui/sidebar/sidebar-menu.svelte create mode 100644 ui/src/lib/components/ui/sidebar/sidebar-provider.svelte create mode 100644 ui/src/lib/components/ui/sidebar/sidebar-rail.svelte create mode 100644 ui/src/lib/components/ui/sidebar/sidebar-separator.svelte create mode 100644 ui/src/lib/components/ui/sidebar/sidebar-trigger.svelte create mode 100644 ui/src/lib/components/ui/sidebar/sidebar.svelte create mode 100644 ui/src/lib/components/ui/skeleton/index.ts create mode 100644 ui/src/lib/components/ui/skeleton/skeleton.svelte create mode 100644 ui/src/lib/components/ui/slider.svelte create mode 100644 ui/src/lib/components/ui/sonner/index.ts create mode 100644 ui/src/lib/components/ui/sonner/sonner.svelte create mode 100644 ui/src/lib/components/ui/switch/index.ts create mode 100644 ui/src/lib/components/ui/switch/switch.svelte create mode 100644 ui/src/lib/components/ui/tabs/index.ts create mode 100644 ui/src/lib/components/ui/tabs/tabs-content.svelte create mode 100644 ui/src/lib/components/ui/tabs/tabs-list.svelte create mode 100644 ui/src/lib/components/ui/tabs/tabs-trigger.svelte create mode 100644 ui/src/lib/components/ui/tabs/tabs.svelte create mode 100644 ui/src/lib/components/ui/tooltip/index.ts create mode 100644 ui/src/lib/components/ui/tooltip/tooltip-content.svelte create mode 100644 ui/src/lib/components/ui/tooltip/tooltip-trigger.svelte create mode 100644 ui/src/lib/gen/control_communication.ts create mode 100644 ui/src/lib/gen/device.ts create mode 100644 ui/src/lib/gen/device_communication.ts create mode 100644 ui/src/lib/gen/hardware.ts create mode 100644 ui/src/lib/gen/settings.ts create mode 100644 ui/src/lib/gen/webui.ts create mode 100644 ui/src/lib/hooks/is-mobile.svelte.ts create mode 100644 ui/src/lib/index.ts create mode 100644 ui/src/lib/stores/auth.ts create mode 100644 ui/src/lib/utils.ts create mode 100644 ui/src/routes/(dashboard)/+layout.svelte create mode 100644 ui/src/routes/(dashboard)/+layout.ts create mode 100644 ui/src/routes/(dashboard)/+page.svelte create mode 100644 ui/src/routes/(dashboard)/rfid/+page.svelte create mode 100644 ui/src/routes/(dashboard)/settings/+page.svelte create mode 100644 ui/src/routes/(dashboard)/settings/hardware/+page.svelte create mode 100644 ui/src/routes/(dashboard)/users/+page.svelte create mode 100644 ui/src/routes/+error.svelte create mode 100644 ui/src/routes/+layout.svelte create mode 100644 ui/src/routes/+layout.ts create mode 100644 ui/src/routes/login/+layout.ts create mode 100644 ui/src/routes/login/+page.svelte create mode 100644 ui/static/robots.txt create mode 100644 ui/svelte.config.js create mode 100644 ui/tsconfig.json create mode 100644 ui/vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..89cc49c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.pio +.vscode/.browse.c_cpp.db* +.vscode/c_cpp_properties.json +.vscode/launch.json +.vscode/ipch diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..080e70d --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,10 @@ +{ + // See http://go.microsoft.com/fwlink/?LinkId=827846 + // for the documentation about the extensions.json format + "recommendations": [ + "platformio.platformio-ide" + ], + "unwantedRecommendations": [ + "ms-vscode.cpptools-extension-pack" + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..07d045c --- /dev/null +++ b/README.md @@ -0,0 +1,304 @@ +# RFID-Master-Client + +A comprehensive RFID access control system based on ESP32, providing a web UI for management, OTA updates, and synchronization with an external server. + +## Overview + +The RFID-Master-Client project is an embedded system for access control using RFID cards. It combines hardware control with a modern web interface, enabling management of users, settings, and logs through a user-friendly interface. + +## Features + +- **RFID Access Control**: Management of RFID cards and access logging +- **Web UI**: Modern SvelteKit-based user interface for configuration and monitoring +- **WiFi Management**: Support for Station and Access Point modes +- **OTA Updates**: Over-the-Air firmware updates for easy maintenance +- **Server Synchronization**: Automatic data synchronization with an external server via RPC +- **Hardware Configuration**: Customizable hardware settings (relays, sensors, pins) +- **Logging and Debugging**: Comprehensive logging system with various levels +- **TFT Display**: Local display for status and interaction +- **Protobuf Communication**: Efficient serial communication between components + +## Architecture + +The system consists of several modules: + +### Backend (C++ on ESP32) + +- **Core Modules**: Logger, Settings, Hardware Configuration +- **RFID Module**: Database and processing of RFID cards +- **WebUI Module**: HTTP server for the web interface +- **WiFi Manager**: Network management +- **OTA Manager**: Firmware updates +- **RPC Module**: Communication with external server +- **Device Manager**: Hardware control (relays, LEDs) + +### Frontend (SvelteKit) + +- **Dashboard**: Overview of system status +- **User Management**: RFID cards and permissions +- **Settings**: System and hardware configuration +- **Logs**: Display of system logs + +### Communication + +- **Protobuf**: Binary serial communication between ESP32 and connected sensors +- **HTTP/REST**: Web API for the user interface +- **WebSocket**: Real-time updates in the web UI + +## Prerequisites + +### Hardware + +- ESP32 development board (e.g., AZ-Delivery-DevKit-V4) +- RFID reader (MFRC522 or compatible) +- TFT display (ST7735/ST7789) +- Relay module for access control +- RTC module for timestamps + +### Software + +- PlatformIO (for ESP32 development) +- Node.js 18+ (for web UI) +- protoc (Protocol Buffers Compiler) + +## Installation + +1. **Clone the repository**: + + ```bash + git clone + cd RFID-Master-Client + ``` + +2. **Install PlatformIO dependencies**: + + ```bash + pio pkg install + ``` + +3. **Install web UI dependencies**: + + ```bash + cd ui + npm install + cd .. + ``` + +4. **Generate protobuf files**: + ```bash + cd ui + npm run proto + cd .. + ``` + +## Build and Flash + +### Development Environment + +```bash +pio run -e dev +pio run -e dev -t upload +pio run -e dev -t monitor +``` + +### Production Environment + +```bash +pio run -e prod +pio run -e prod -t upload +``` + +### Build Web UI + +```bash +cd ui +npm run build +npm run copy +cd .. +``` + +## Configuration + +### Hardware Configuration + +Edit `lib/data/hardware_config.hpp` for pin assignments and hardware parameters. + +### System Settings + +Configure via the web UI: + +- WiFi settings +- Server synchronization +- Log level +- Hardware parameters + +### Partitioning + +- `partitions_custom.csv`: For development (more memory for debugging) +- `partitions_prod.csv`: For production (optimized for size) + +## Usage + +1. **Initial Configuration**: + + - Start device in AP mode + - Connect to WiFi "RFID-Master" + - Open web UI at `http://192.168.4.1` + - Configure WiFi and server settings + +2. **Add RFID Cards**: + + - Navigate to "Users" in web UI + - Scan new RFID card or enter manually + - Assign permissions + +3. **Monitoring**: + - Dashboard for system status + - Logs for debugging + - View access logs + +## API Reference + +### REST API Endpoints + +- `GET /api/status`: Retrieve device status +- `GET /api/users`: Retrieve user list +- `POST /api/users`: Add new user +- `GET /api/logs`: Retrieve system logs +- `POST /api/settings`: Update settings + +### Protobuf Messages + +- `device.proto`: Device information and status +- `webui.proto`: Web UI communication +- `control_communication.proto`: Control messages +- `settings.proto`: Configuration settings +- `hardware.proto`: Hardware configuration + +## Development + +### Code Structure + +``` +src/ +├── main.cpp # Main entry point +├── webui/ # Web server and API +├── wifi/ # WiFi management +├── device/ # Device management +lib/ +├── data/ # Data models and configuration +├── hardware/ # Hardware abstractions +├── log/ # Logging system +├── ota/ # OTA updates +├── rpc/ # Server communication +├── tft/ # Display control +ui/ # Web UI (SvelteKit) +proto/ # Protocol Buffer definitions +``` + +### Debugging + +- Use `pio run -e dev -t monitor` for serial logs +- Adjust log level via web UI +- Core dumps are enabled in dev environment + +### Tests + +```bash +# Unit tests (if implemented) +pio test + +# Web UI tests +cd ui +npm run check +``` + +## Deployment + +1. **Build firmware**: + + ```bash + pio run -e prod + ``` + +2. **Build and copy web UI**: + + ```bash + cd ui + npm run build + npm run copy + cd .. + ``` + +3. **Flash**: + + ```bash + pio run -e prod -t upload + ``` + +4. **Flash filesystem** (for web UI): + ```bash + pio run -e prod -t uploadfs + ``` + +## Troubleshooting + +### Common Issues + +- **WiFi connection failed**: Check SSID/password, verify signal strength +- **RFID not recognized**: Check hardware connections, test MFRC522 module +- **Web UI unreachable**: Check firewall settings, verify IP address +- **OTA update failed**: Check network connection, verify firmware size + +### Analyze Logs + +- Set log level to DEBUG for detailed output +- Use serial monitor for real-time logs +- Web UI logs for HTTP requests + +### Hardware Debugging + +- Verify pin assignments in `hardware_config.hpp` +- Ensure proper power supply to modules +- Test I2C/serial connections + +## Contributing + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/AmazingFeature`) +3. Commit your changes (`git commit -m 'Add some AmazingFeature'`) +4. Push to the branch (`git push origin feature/AmazingFeature`) +5. Open a Pull Request + +### Code Standards + +- Use C++17 for ESP32 code +- TypeScript for web UI +- Protobuf for serial communication +- Document new features and API changes + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Support + +For questions or issues: + +- Open an issue in the GitHub repository +- Check logs for error indications +- Ensure all prerequisites are met + +## Changelog + +### [Unreleased] + +- Initial release of the RFID-Master-Client system +- Complete RFID access control +- Web UI with SvelteKit +- OTA update functionality +- Server synchronization + +--- + +For detailed information about specific modules, see the corresponding README files in the subdirectories. diff --git a/data/web/index.html.gz b/data/web/index.html.gz new file mode 100644 index 0000000000000000000000000000000000000000..64abdf2539f98d6e51dbd6e172fff674ff41402f GIT binary patch literal 147214 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 THIS FILE +| +|- platformio.ini +|--src + |- main.c + +Example contents of `src/main.c` using Foo and Bar: +``` +#include +#include + +int main (void) +{ + ... +} + +``` + +The PlatformIO Library Dependency Finder will find automatically dependent +libraries by scanning project source files. + +More information about PlatformIO Library Dependency Finder +- https://docs.platformio.org/page/librarymanager/ldf.html diff --git a/lib/data/hardware_config.cpp b/lib/data/hardware_config.cpp new file mode 100644 index 0000000..d33357c --- /dev/null +++ b/lib/data/hardware_config.cpp @@ -0,0 +1,131 @@ +#include "hardware_config.hpp" +#include +#include +#include +#include + +HardwareConfigRepo::HardwareConfigRepo() { + setDefaults(); +} + +HardwareConfigRepo::~HardwareConfigRepo() { + // Cleanup if needed +} + +void HardwareConfigRepo::setChangeCallback(std::function callback) { + changeCallback_ = callback; +} + +void HardwareConfigRepo::setDefaults() { + data_.hold_duration_ms = DEFAULT_HOLD_DURATION_MS; + data_.override = DEFAULT_OVERRIDE; + data_.relay_pin = DEFAULT_RELAY_PIN; + data_.sensor_rx_pin = DEFAULT_SENSOR_RX_PIN; + data_.sensor_tx_pin = DEFAULT_SENSOR_TX_PIN; + + // Initialize LedConfig to defaults (empty) + // Nanopb initializes to zero, so ok +} + +bool HardwareConfigRepo::fileExists() { + return LittleFS.exists(filename_); +} + +bool HardwareConfigRepo::load() { + if (!LittleFS.begin()) { + LOG_ERROR("HardwareConfigRepo: LittleFS mount failed"); + setDefaults(); + return false; + } + + if (!fileExists()) { + // File doesn't exist, use defaults + setDefaults(); + runCallback(); + return true; + } + + File file = LittleFS.open(filename_, "r"); + if (!file) { + LOG_ERROR("Failed to open hardware config file for reading"); + setDefaults(); + return false; + } + + // Read file into buffer + size_t fileSize = file.size(); + uint8_t buffer[fileSize]; + file.read(buffer, fileSize); + file.close(); + + // Decode protobuf + pb_istream_t stream = pb_istream_from_buffer(buffer, fileSize); + bool status = pb_decode(&stream, hardware_HardwareConfig_fields, &data_); + + if (!status) { + LOG_ERROR("Failed to decode hardware config protobuf"); + setDefaults(); + return false; + } + + runCallback(); + + return true; +} + +bool HardwareConfigRepo::save() { + if (!LittleFS.begin()) { + LOG_ERROR("HardwareConfigRepo: LittleFS mount failed"); + return false; + } + + // Encode to buffer first to get size + size_t bufferSize = 0; + pb_get_encoded_size(&bufferSize, hardware_HardwareConfig_fields, &data_); + + uint8_t buffer[bufferSize]; + pb_ostream_t stream = pb_ostream_from_buffer(buffer, bufferSize); + + bool status = pb_encode(&stream, hardware_HardwareConfig_fields, &data_); + if (!status) { + LOG_ERROR("Failed to encode hardware config protobuf"); + return false; + } + + // Write to file + File file = LittleFS.open(filename_, "w"); + if (!file) { + LOG_ERROR("Failed to open hardware config file for writing"); + return false; + } + + size_t bytesWritten = file.write(buffer, bufferSize); + file.close(); + + if (bytesWritten != bufferSize) { + LOG_ERROR("Failed to write complete hardware config file"); + return false; + } + + runCallback(); + + return true; +} + +void HardwareConfigRepo::setData(const hardware_HardwareConfig& newData) { + data_ = newData; + save(); // Persist immediately +} + +void HardwareConfigRepo::resetToDefaults() { + setDefaults(); + save(); +} + +void HardwareConfigRepo::runCallback() { + if (changeCallback_) { + changeCallback_(data_); + } +} + +HardwareConfigRepo hardwareConfigRepo; \ No newline at end of file diff --git a/lib/data/hardware_config.hpp b/lib/data/hardware_config.hpp new file mode 100644 index 0000000..de3e5bd --- /dev/null +++ b/lib/data/hardware_config.hpp @@ -0,0 +1,46 @@ +#pragma once +#include +#include + +class HardwareConfigRepo { +public: + HardwareConfigRepo(); + ~HardwareConfigRepo(); + + // Set callback that gets called when config changes (load/save) + void setChangeCallback(std::function callback); + + // Load config from LittleFS, use defaults if file doesn't exist + bool load(); + + + // Get reference to config data + hardware_HardwareConfig& getData() { return data_; } + + // Set config data + void setData(const hardware_HardwareConfig& newData); + + // Reset to default values + void resetToDefaults(); + +private: + // Save current config to LittleFS + bool save(); + hardware_HardwareConfig data_; + const char* filename_ = "/hardware.pb"; + std::function changeCallback_ = nullptr; + + // Default values + static constexpr uint32_t DEFAULT_HOLD_DURATION_MS = 5000; + static constexpr bool DEFAULT_OVERRIDE = false; + static constexpr uint32_t DEFAULT_RELAY_PIN = 2; + static constexpr uint32_t DEFAULT_SENSOR_RX_PIN = 16; + static constexpr uint32_t DEFAULT_SENSOR_TX_PIN = 17; + + // Helper methods + void setDefaults(); + bool fileExists(); + void runCallback(); +}; + +extern HardwareConfigRepo hardwareConfigRepo; \ No newline at end of file diff --git a/lib/data/library.json b/lib/data/library.json new file mode 100644 index 0000000..8789ba7 --- /dev/null +++ b/lib/data/library.json @@ -0,0 +1,6 @@ +{ + "name": "hardware", + "dependencies": { + "nanopb": "*" + } +} diff --git a/lib/data/rfid.cpp b/lib/data/rfid.cpp new file mode 100644 index 0000000..674aa78 --- /dev/null +++ b/lib/data/rfid.cpp @@ -0,0 +1,282 @@ +#include "rfid.hpp" +#include +RfidDB rfidDB; + +RfidDB::RfidDB(const String& filename) + : filename_(filename), tmpFilename_(filename + ".tmp"), initialized_(false) {} + +RfidDB::~RfidDB() {} +void printDatabaseContents() { + LOG_DEBUG("--- RFID Database Contents (Stored Values) ---"); + rfidDB.iterate([](uint32_t stored_id) { + // 'stored_id' ist der Wert, wie er in der Datei steht (Byte-geswappt) + Serial.printf("Stored HEX: 0x%08X\n", stored_id); + }); + LOG_DEBUG("-------------------------------------------"); +} + +bool RfidDB::begin() { + if (initialized_) { + return true; + } + if (!LittleFS.begin()) { + LOG_ERROR("RfidDB: LittleFS mount failed"); + return false; + } + + // Ensure the file exists, creating it if necessary. + if (!LittleFS.exists(filename_)) { + File f = LittleFS.open(filename_, "w"); + if (!f) { + return false; // Could not create the file + } + f.close(); + } + initialized_ = true; + printDatabaseContents(); + return true; +} + +uint32_t RfidDB::count() { + if (!initialized_) return 0; + + File f = LittleFS.open(filename_, "r"); + if (!f) return 0; + + uint32_t n = fileEntryCount(f); + f.close(); + return n; +} + +bool RfidDB::contains(uint32_t raw_id) { + if (!initialized_) { + LOG_DEBUG("RfidDB: contains not initialized"); + return false; + } + + File f = LittleFS.open(filename_, "r"); + if (!f) { + LOG_DEBUG("RfidDB: contains failed to open file"); + return false; + } + + uint32_t id = byteSwap(raw_id); + uint32_t idx; + bool found = false; + bool ok = binarySearch(f, idx, id, found); + + f.close(); + LOG_DEBUG("RfidDB: contains id=%08X, ok=%d, found=%d", raw_id, ok, found); + return ok && found; +} + +void RfidDB::iterate(std::function callback) { + if (!initialized_ || !callback) return; + + File f = LittleFS.open(filename_, "r"); + if (!f) return; + + uint32_t n = fileEntryCount(f); + for (uint32_t i = 0; i < n; ++i) { + uint32_t v; + if (readEntryAt(f, i, v)) { + callback(v); + } + } + f.close(); +} + +uint32_t RfidDB::fileEntryCount(File &f) { + return f.size() / ENTRY_SIZE; +} + +// 🚀 More efficient read/write methods +bool RfidDB::readEntryAt(File &f, uint32_t index, uint32_t &out) { + if (!f.seek(index * ENTRY_SIZE, SeekSet)) return false; + return f.read(reinterpret_cast(&out), ENTRY_SIZE) == ENTRY_SIZE; +} + +bool RfidDB::writeEntryAt(File &f, uint32_t index, uint32_t value) { + if (!f.seek(index * ENTRY_SIZE, SeekSet)) return false; + return f.write(reinterpret_cast(&value), ENTRY_SIZE) == ENTRY_SIZE; +} + + +bool RfidDB::binarySearch(File &f, uint32_t &outIndex, uint32_t key, bool &found) { + // Get the number of entries in the file (each entry is 4 bytes) + uint32_t n = fileEntryCount(f); + + // If the file is empty, return with outIndex = 0 and found = false + if (n == 0) { + outIndex = 0; + found = false; + return true; + } + + // Initialize search boundaries for binary search + uint32_t left = 0; + uint32_t right = n - 1; + + // Perform binary search on the sorted database + // Note: The database must contain values in ascending order of their *swapped* (Little-Endian) representation + // to ensure correct search results. The 'key' parameter is the byte-swapped (Little-Endian) value of the + // raw RFID ID (e.g., raw ID 0x635C426D is swapped to 0x6D425C63 for comparison). + while (left <= right) { + // Calculate the middle index + uint32_t mid = left + (right - left) / 2; + uint32_t v; + + // Read the entry at index 'mid' from the file + // The file stores IDs as Big-Endian (e.g., bytes 63 5C 42 6D for original ID 0x635C426D). + // On this Little-Endian platform (e.g., ESP32), reading 4 bytes into 'v' interprets them as + // Little-Endian, so bytes 63 5C 42 6D become v = 0x6D425C63 (swapped). + if (!readEntryAt(f, mid, v)) { + return false; // Failed to read entry + } + + // Compare the read value 'v' (Little-Endian, swapped) with the search key (also Little-Endian, swapped) + if (v == key) { + outIndex = mid; + found = true; + return true; // Found the ID at index 'mid' + } + + // Since the database is sorted by the swapped (Little-Endian) values, + // adjust the search boundaries based on the comparison + if (v < key) { + left = mid + 1; // Search in the right half + } else { + if (mid == 0) break; // Prevent underflow when right = mid - 1 + right = mid - 1; // Search in the left half + } + } + + // If not found, set outIndex to the insertion point where the key would be added + // to maintain the sorted order of swapped (Little-Endian) values + outIndex = left; + found = false; + return true; +} + +uint32_t RfidDB::byteSwap(uint32_t x) const +{ + return ((x & 0xFF000000) >> 24) | + ((x & 0x00FF0000) >> 8) | + ((x & 0x0000FF00) << 8) | + ((x & 0x000000FF) << 24); +} + +bool RfidDB::add(uint32_t raw_id) { + if (!initialized_) return false; + + File src = LittleFS.open(filename_, "r"); + if (!src) return false; + uint32_t id = byteSwap(raw_id); + uint32_t idx; + bool found; + if (!binarySearch(src, idx, id, found)) { + src.close(); + return false; + } + if (found) { + src.close(); + return true; // Already present, we consider this a success + } + + File dst = LittleFS.open(tmpFilename_, "w"); + if (!dst) { + src.close(); + return false; + } + + bool success = true; + uint32_t n = fileEntryCount(src); + uint32_t v; + + // Copy entries before the insertion point + for (uint32_t i = 0; i < idx; ++i) { + if (!readEntryAt(src, i, v) || !writeEntryAt(dst, i, v)) { + success = false; + break; + } + } + // Insert the new entry + if (success && !writeEntryAt(dst, idx, id)) { + success = false; + } + // Copy the remaining entries + if (success) { + for (uint32_t i = idx; i < n; ++i) { + if (!readEntryAt(src, i, v) || !writeEntryAt(dst, i + 1, v)) { + success = false; + break; + } + } + } + + src.close(); + dst.close(); + + if (!success) { + LittleFS.remove(tmpFilename_); + return false; + } + + // Atomic replace + if (!LittleFS.rename(tmpFilename_, filename_)) { + // Fallback in case rename fails + LittleFS.remove(tmpFilename_); + return false; + } + + return true; +} + +bool RfidDB::remove(uint32_t raw_id) { + if (!initialized_) return false; + + File src = LittleFS.open(filename_, "r"); + if (!src) return false; + uint32_t id = byteSwap(raw_id); + uint32_t idx; + bool found; + if (!binarySearch(src, idx, id, found) || !found) { + src.close(); + return false; // Not found, so nothing to remove + } + + File dst = LittleFS.open(tmpFilename_, "w"); + if (!dst) { + src.close(); + return false; + } + + bool success = true; + uint32_t n = fileEntryCount(src); + uint32_t written = 0; + uint32_t v; + + for (uint32_t i = 0; i < n; ++i) { + if (i == idx) continue; // Skip the entry to be deleted + if (!readEntryAt(src, i, v) || !writeEntryAt(dst, written, v)) { + success = false; + break; + } + written++; + } + + src.close(); + dst.close(); + + if (!success) { + LittleFS.remove(tmpFilename_); + return false; + } + + if (!LittleFS.rename(tmpFilename_, filename_)) { + LittleFS.remove(tmpFilename_); + return false; + } + + return true; +} \ No newline at end of file diff --git a/lib/data/rfid.hpp b/lib/data/rfid.hpp new file mode 100644 index 0000000..a8717a6 --- /dev/null +++ b/lib/data/rfid.hpp @@ -0,0 +1,68 @@ +#pragma once + +#include +#include +#include // For std::function + +class RfidDB { +public: + // The default constructor uses "/rfid.db" + RfidDB(const String& filename = "/rfid.db"); + ~RfidDB(); + + /** + * @brief Initializes the filesystem and the database file. + * Must be called once in setup(). + * @return true on success, otherwise false. + */ + bool begin(); + + /** + * @brief Returns the number of stored IDs. + */ + uint32_t count(); + + /** + * @brief Checks if an ID exists in the database. + * @param id The ID to check. + * @return true if the ID exists, otherwise false. + */ + bool contains(uint32_t id); + + /** + * @brief Adds a new ID. The ID is only added if it does not already exist. + * @param id The ID to add. + * @return true if the ID was successfully added, otherwise false. + */ + bool add(uint32_t id); + + /** + * @brief Removes an ID from the database. + * @param id The ID to remove. + * @return true if the ID was found and removed, otherwise false. + */ + bool remove(uint32_t id); + + /** + * @brief Iterates over all stored IDs and calls a callback function for each. + * @param callback The function to be called for each ID (e.g., a lambda: `[](uint32_t id){ Serial.println(id); }`). + */ + void iterate(std::function callback); + +private: + String filename_; + String tmpFilename_; + bool initialized_ = false; + + static constexpr size_t ENTRY_SIZE = sizeof(uint32_t); + + // Helper methods + bool readEntryAt(File &f, uint32_t index, uint32_t &out); + bool writeEntryAt(File &f, uint32_t index, uint32_t value); + bool binarySearch(File &f, uint32_t &outIndex, uint32_t key, bool &found); + uint32_t byteSwap(uint32_t x) const; + uint32_t fileEntryCount(File &f); +}; + +// Global instance, kept as requested +extern RfidDB rfidDB; \ No newline at end of file diff --git a/lib/data/session.cpp b/lib/data/session.cpp new file mode 100644 index 0000000..7dd8adc --- /dev/null +++ b/lib/data/session.cpp @@ -0,0 +1,108 @@ +#include "session.hpp" +#include // Required for PROGMEM + +// Definition of the global instance +SessionManager sessionManager; + +// The character set is now stored in flash memory to save RAM. +const char charset[] PROGMEM = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + +SessionManager::SessionManager() { + session_mutex_ = xSemaphoreCreateMutex(); + for (size_t i = 0; i < MAX_SESSIONS; ++i) { + sessions_[i].username[0] = '\0'; + } +} + +SessionManager::~SessionManager() { + vSemaphoreDelete(session_mutex_); +} + +const char* SessionManager::createSession(const char* username) { + const char* result_token = nullptr; + // Lock the mutex to ensure exclusive access to the sessions array. + if (xSemaphoreTake(session_mutex_, portMAX_DELAY) == pdTRUE) { + for (size_t i = 0; i < MAX_SESSIONS; ++i) { + if (sessions_[i].username[0] == '\0') { + strncpy(sessions_[i].username, username, sizeof(sessions_[i].username) - 1); + sessions_[i].username[sizeof(sessions_[i].username) - 1] = '\0'; + + generateToken(sessions_[i].token, sizeof(sessions_[i].token)); + + sessions_[i].expiry_time = millis() + (15 * 60 * 1000); + + result_token = sessions_[i].token; + break; // Exit loop once a slot is found + } + } + xSemaphoreGive(session_mutex_); // Release the mutex + } + return result_token; +} + +const char* SessionManager::validateSession(const char* token) { + if (token == nullptr) { + return nullptr; + } + + // We must copy the username to a static buffer, because the session array + // could be modified by another task after we release the mutex. + static char valid_username[33]; + valid_username[0] = '\0'; + + if (xSemaphoreTake(session_mutex_, portMAX_DELAY) == pdTRUE) { + for (size_t i = 0; i < MAX_SESSIONS; ++i) { + if (sessions_[i].username[0] != '\0' && strcmp(sessions_[i].token, token) == 0) { + if (millis() < sessions_[i].expiry_time) { + strcpy(valid_username, sessions_[i].username); + } else { + sessions_[i].username[0] = '\0'; // Expired, clear it + } + break; + } + } + xSemaphoreGive(session_mutex_); + } + + return (valid_username[0] != '\0') ? valid_username : nullptr; +} + +bool SessionManager::endSession(const char* token) { + if (token == nullptr) return false; + + bool found = false; + if (xSemaphoreTake(session_mutex_, portMAX_DELAY) == pdTRUE) { + for (size_t i = 0; i < MAX_SESSIONS; ++i) { + if (sessions_[i].username[0] != '\0' && strcmp(sessions_[i].token, token) == 0) { + sessions_[i].username[0] = '\0'; + found = true; + break; + } + } + xSemaphoreGive(session_mutex_); + } + return found; +} + +void SessionManager::cleanupExpiredSessions() { + unsigned long current_time = millis(); + if (xSemaphoreTake(session_mutex_, portMAX_DELAY) == pdTRUE) { + for (size_t i = 0; i < MAX_SESSIONS; ++i) { + if (sessions_[i].username[0] != '\0') { + if (current_time >= sessions_[i].expiry_time) { + sessions_[i].username[0] = '\0'; + } + } + } + xSemaphoreGive(session_mutex_); + } +} + +void SessionManager::generateToken(char* buffer, size_t buffer_size) { + const size_t charset_size = sizeof(charset) - 1; + for (size_t i = 0; i < buffer_size - 1; ++i) { + // Read the character directly from flash memory (PROGMEM). + buffer[i] = pgm_read_byte(&charset[esp_random() % charset_size]); + } + buffer[buffer_size - 1] = '\0'; +} \ No newline at end of file diff --git a/lib/data/session.hpp b/lib/data/session.hpp new file mode 100644 index 0000000..df2365e --- /dev/null +++ b/lib/data/session.hpp @@ -0,0 +1,33 @@ +#pragma once +#include +#include +#include // Required for mutex + +// Represents an active session. +// An empty username (username[0] == '\0') indicates the slot is free. +struct Session { + char username[33]; + char token[17]; // 16 chars + null terminator + unsigned long expiry_time; // Expiration timestamp from millis() +}; + +class SessionManager { +public: + SessionManager(); + ~SessionManager(); // Destructor to clean up the mutex + + const char* createSession(const char* username); + const char* validateSession(const char* token); + bool endSession(const char* token); + void cleanupExpiredSessions(); + +private: + static constexpr size_t MAX_SESSIONS = 3; + Session sessions_[MAX_SESSIONS]; + SemaphoreHandle_t session_mutex_; // Mutex to protect access to sessions_ + + void generateToken(char* buffer, size_t buffer_size); +}; + +// Central, global instance. +extern SessionManager sessionManager; \ No newline at end of file diff --git a/lib/data/settings.cpp b/lib/data/settings.cpp new file mode 100644 index 0000000..d653cbf --- /dev/null +++ b/lib/data/settings.cpp @@ -0,0 +1,161 @@ +#include "settings.hpp" +#include +#include +#include +#include +Settings::Settings() { + setDefaults(); +} + +Settings::~Settings() { + // Cleanup if needed +} + +void Settings::setDefaults() { + strcpy(data_.sync_server_url, "https://ssvc.ldev/api/"); + strcpy(data_.device_api_key, "c62a6b5c-914e-49d4-9871-1896a627e0df"); + strcpy(data_.sync_server_url, DEFAULT_SYNC_SERVER_URL); + strcpy(data_.device_api_key, DEFAULT_DEVICE_API_KEY); + data_.sync_interval_seconds = DEFAULT_SYNC_INTERVAL; + data_.auto_sync = DEFAULT_AUTO_SYNC; + data_.wifi_mode = DEFAULT_WIFI_MODE; + strcpy(data_.station_ssid, DEFAULT_STATION_SSID); + strcpy(data_.station_password, DEFAULT_STATION_PASSWORD); + strcpy(data_.ap_ssid, DEFAULT_AP_SSID); + strcpy(data_.ap_password, DEFAULT_AP_PASSWORD); + data_.ap_channel = DEFAULT_AP_CHANNEL; + data_.enable_fallback_ap = DEFAULT_ENABLE_FALLBACK_AP; + data_.log_level = DEFAULT_LOG_LEVEL; + data_.updated_at = millis(); // Use current uptime as timestamp + data_.version = DEFAULT_VERSION; +} + +bool Settings::fileExists() { + return LittleFS.exists(filename_); +} + +bool Settings::load() { + if (!fileExists()) { + // File doesn't exist, use defaults + setDefaults(); + }else{ + File file = LittleFS.open(filename_, "r"); + if (!file) { + LOG_ERROR("Failed to open settings file for reading"); + setDefaults(); + return false; + } + + // Read file into buffer + size_t fileSize = file.size(); + uint8_t buffer[fileSize]; + file.read(buffer, fileSize); + file.close(); + + // Decode protobuf + pb_istream_t stream = pb_istream_from_buffer(buffer, fileSize); + bool status = pb_decode(&stream, settings_SettingsData_fields, &data_); + + if (!status) { + LOG_ERROR("Failed to decode settings protobuf"); + setDefaults(); + return false; + } + } + + // callback if set + runCallback(); + + return true; +} + +bool Settings::save() { + // Update timestamp and version + data_.updated_at = millis(); + data_.version++; + + // Encode to buffer first to get size + size_t bufferSize = 0; + pb_get_encoded_size(&bufferSize, settings_SettingsData_fields, &data_); + + uint8_t buffer[bufferSize]; + pb_ostream_t stream = pb_ostream_from_buffer(buffer, bufferSize); + + bool status = pb_encode(&stream, settings_SettingsData_fields, &data_); + if (!status) { + LOG_ERROR("Failed to encode settings protobuf"); + return false; + } + + // Write to file + File file = LittleFS.open(filename_, "w"); + if (!file) { + LOG_ERROR("Failed to open settings file for writing"); + return false; + } + + size_t bytesWritten = file.write(buffer, bufferSize); + file.close(); + + if (bytesWritten != bufferSize) { + LOG_ERROR("Failed to write complete settings file"); + return false; + } + + return true; +} + +void Settings::setData(const settings_SettingsData& newData) { + data_ = newData; + data_.updated_at = millis(); + data_.version++; + + // Notify callback if set + runCallback(); +} + +void Settings::resetToDefaults() { + setDefaults(); +} + +struct SettingsCallbackParams { + settings_SettingsData data; + std::function callback; +}; + +static void settingsChangeTask(void* pvParameters) { + SettingsCallbackParams* params = static_cast(pvParameters); + if (params->callback) { + vTaskDelay(100 / portTICK_PERIOD_MS); // Small delay to ensure system stability + LOG_DEBUG("Settings (Task): Executing change callback..."); + params->callback(params->data); + LOG_DEBUG("Settings (Task): Callback finished."); + } + delete params; + vTaskDelete(NULL); +} + + +void Settings::runCallback() +{ + if (changeCallback_) { + SettingsCallbackParams* params = new SettingsCallbackParams(); + params->data = data_; // Create a safe copy for the task + params->callback = changeCallback_; + + xTaskCreate( + settingsChangeTask, + "SettingsChangeTask", + 4096, // Stack size + params, + 1, // Priority + NULL + ); + } +} + +void Settings::setChangeCallback(std::function callback) { + changeCallback_ = callback; +} + +Settings settings; \ No newline at end of file diff --git a/lib/data/settings.hpp b/lib/data/settings.hpp new file mode 100644 index 0000000..2a0bf4d --- /dev/null +++ b/lib/data/settings.hpp @@ -0,0 +1,56 @@ +#pragma once +#include +#include + +class Settings { +public: + Settings(); + ~Settings(); + + // Set callback that gets called when settings change + void setChangeCallback(std::function callback); + + // Load settings from LittleFS, use defaults if file doesn't exist + bool load(); + + // Save current settings to LittleFS + bool save(); + + // Get reference to settings data + settings_SettingsData& getData() { return data_; } + + // Set settings data and update timestamp/version + void setData(const settings_SettingsData& newData); + + // Reset to default values + void resetToDefaults(); + +private: + void runCallback(); + settings_SettingsData data_; + const char* filename_ = "/settings.pb"; + std::function changeCallback_ = nullptr; + SemaphoreHandle_t dataMutex_ = nullptr; + + + // Default values + static constexpr const char* DEFAULT_SYNC_SERVER_URL = "https://sync.example.com/api"; + static constexpr const char* DEFAULT_DEVICE_API_KEY = ""; + static constexpr uint32_t DEFAULT_SYNC_INTERVAL = 1800; + static constexpr bool DEFAULT_AUTO_SYNC = true; + static constexpr settings_WifiMode DEFAULT_WIFI_MODE = settings_WifiMode_WIFI_MODE_AP_STATION; + static constexpr const char* DEFAULT_STATION_SSID = "HOME"; + static constexpr const char* DEFAULT_STATION_PASSWORD = "redacted"; + static constexpr const char* DEFAULT_AP_SSID = "RFID-Master-AP"; + static constexpr const char* DEFAULT_AP_PASSWORD = "rfid12345"; + static constexpr uint32_t DEFAULT_AP_CHANNEL = 6; + static constexpr bool DEFAULT_ENABLE_FALLBACK_AP = true; + static constexpr settings_LogLevel DEFAULT_LOG_LEVEL = settings_LogLevel_LOG_LEVEL_INFO; + static constexpr uint32_t DEFAULT_VERSION = 1; + + // Helper methods + void setDefaults(); + bool fileExists(); +}; + +extern Settings settings; \ No newline at end of file diff --git a/lib/data/users.cpp b/lib/data/users.cpp new file mode 100644 index 0000000..d96b6c4 --- /dev/null +++ b/lib/data/users.cpp @@ -0,0 +1,392 @@ +#include "users.hpp" +#include +#include +#include +#include +#include +#include +#include + +// Definition of the global instance. +Users users; + +// ================================================================= +// == 1. NANOPB STREAM HELPERS for Arduino File Objects +// ================================================================= + +// A state structure to pass the Arduino File object to Nanopb's stream callbacks. +struct FileStreamState { + File* file; +}; + +// Nanopb callback for writing data to a File. +static bool writeFileCallback(pb_ostream_t *stream, const uint8_t *buf, size_t count) { + FileStreamState* state = (FileStreamState*)stream->state; + return state->file->write(buf, count) == count; +} + +// Nanopb callback for reading from a File. +static bool readFileCallback(pb_istream_t *stream, uint8_t *buf, size_t count) { + FileStreamState* state = (FileStreamState*)stream->state; + if (count == 0) return true; + size_t len = state->file->read(buf, count); + if (len < count) { + stream->bytes_left = 0; // Signal End-Of-File + } + return len > 0 || count == 0; +} + +// Helper to create a Nanopb output stream from an Arduino File object. +static pb_ostream_t pb_ostream_from_file(File &file) { + FileStreamState* state = new FileStreamState{&file}; + pb_ostream_t stream = {&writeFileCallback, state, SIZE_MAX, 0}; + return stream; +} + +// Helper to create a Nanopb input stream from an Arduino File object. +static pb_istream_t pb_istream_from_file(File &file) { + FileStreamState* state = new FileStreamState{&file}; + pb_istream_t stream = {&readFileCallback, state, file.size()}; + return stream; +} + +// This structure is used to pass data to the nanopb decode callbacks +// when decoding into a single, stack-allocated UserData object. +struct DecodeTarget { + UserData* user_buffer; +}; + +// Nanopb callback to decode a username string into our temporary buffer. +static bool decodeUsernameCallback(pb_istream_t *stream, const pb_field_t *field, void **arg) { + DecodeTarget* target = (DecodeTarget*)*arg; + + // 1. Store the length of the incoming string BEFORE reading from the stream. + size_t len = stream->bytes_left; + + if (len >= sizeof(target->user_buffer->username)) { + return false; // Input string is too long for our buffer. + } + + // 2. Read 'len' bytes. This will modify stream->bytes_left. + if (!pb_read(stream, (uint8_t*)target->user_buffer->username, len)) { + return false; + } + + // 3. Use the stored length 'len' to place the null terminator correctly. + target->user_buffer->username[len] = '\0'; + + return true; +} + +// Nanopb callback to decode a password hash (bytes) into our temporary buffer. +static bool decodePasswordCallback(pb_istream_t *stream, const pb_field_t *field, void **arg) { + DecodeTarget* target = (DecodeTarget*)*arg; + if (stream->bytes_left != sizeof(target->user_buffer->password_hash)) { + return false; // A SHA-256 hash MUST be 32 bytes. + } + return pb_read(stream, target->user_buffer->password_hash, stream->bytes_left); +} + +// ================================================================= +// == 2. USERS CLASS IMPLEMENTATION +// ================================================================= + +Users::Users() {} +Users::~Users() {} + +void Users::hashPassword(const char* password, uint8_t* hash_out) const { + SHA256 sha256; + sha256.update(password, strlen(password)); + sha256.finalize(hash_out, 32); +} + +// --- PRIVATE STATIC HELPERS (Moved inside the class scope) --- + +// Generic helper function to iterate over all users in the file +// and execute a callback for each one. The callback can stop the iteration by returning false. +bool Users::_iterateUsers(const char* filename, const std::function& user_callback) { + // check if file exists + if(!LittleFS.exists(filename)){ + return true; // No file is not an error, just means no users to iterate. + } + + File file = LittleFS.open(filename, "r"); + if (!file || file.size() == 0) { + return true; // No file is not an error, just means no users to iterate. + } + + pb_istream_t istream = pb_istream_from_file(file); + + UserData user_buffer; // Single user buffer on the stack + DecodeTarget target = { &user_buffer }; + webui_WebUiUser user_msg = webui_WebUiUser_init_zero; + user_msg.password_hash.funcs.decode = &decodePasswordCallback; + user_msg.password_hash.arg = ⌖ + + bool continue_iterating = true; + while (istream.bytes_left > 0) { + memset(&user_buffer, 0, sizeof(user_buffer)); + + if (pb_decode_delimited(&istream, webui_WebUiUser_fields, &user_msg)) { + strcpy(user_buffer.username, user_msg.username); + if (!user_callback(user_buffer)) { + continue_iterating = false; + break; + } + } else { + // Decoding failed, stop iterating. + break; + } + } + + + delete (FileStreamState*)istream.state; + file.close(); + return continue_iterating; +} + +// Generic helper for streaming transformations (delete, update). +// The callback modifies a user buffer and returns true to keep it, false to delete it. +bool Users::_transformUsers(const char* src_filename, const std::function& transform_callback) { + const char* tmp_filename = "/users.pb.tmp"; + File src_file = LittleFS.open(src_filename, "r"); + if (!src_file) return true; + + File dst_file = LittleFS.open(tmp_filename, "w"); + if (!dst_file) { + src_file.close(); + return false; + } + + pb_istream_t istream = pb_istream_from_file(src_file); + pb_ostream_t ostream = pb_ostream_from_file(dst_file); + + bool success = true; + UserData user_buffer; // Our 1-user buffer + + // --- CORRECTION START --- + + // 1. A structure ONLY for decoding (reading) + DecodeTarget decode_target = { &user_buffer }; + webui_WebUiUser decode_msg = webui_WebUiUser_init_zero; + decode_msg.password_hash.funcs.decode = &decodePasswordCallback; + decode_msg.password_hash.arg = &decode_target; + + // 2. A structure ONLY for encoding (writing) + webui_WebUiUser encode_msg = webui_WebUiUser_init_zero; + encode_msg.password_hash.funcs.encode = [](pb_ostream_t *stream, const pb_field_t *field, void * const *arg) { + UserData* u = (UserData*)*arg; + return pb_encode_tag_for_field(stream, field) && pb_encode_string(stream, u->password_hash, sizeof(u->password_hash)); + }; + + // 3. Main loop: Decode with decode_msg, transform user_buffer, encode with encode_msg + + while (istream.bytes_left > 0 && pb_decode_delimited(&istream, webui_WebUiUser_fields, &decode_msg)) { + strcpy(user_buffer.username, decode_msg.username); + if (transform_callback(user_buffer)) { + // Keep: Encode the content from the buffer with the encode_msg structure + strcpy(encode_msg.username, user_buffer.username); + encode_msg.password_hash.arg = &user_buffer; + if (!pb_encode_delimited(&ostream, webui_WebUiUser_fields, &encode_msg)) { + success = false; + break; + } + } + } + + // --- Rest of the function remains the same --- + delete (FileStreamState*)istream.state; + delete (FileStreamState*)ostream.state; + src_file.close(); + dst_file.close(); + + if (!success) { + LittleFS.remove(tmp_filename); + return false; + } + + if (LittleFS.exists(src_filename)) { + LittleFS.remove(src_filename); + } + return LittleFS.rename(tmp_filename, src_filename); +} + +// --- PUBLIC READ-ONLY METHODS --- + +size_t Users::getUserCount() { + if(initialized_) { + return userCount_; + } + + size_t count = 0; + _iterateUsers(filename_, [&](UserData& user) { + count++; + return true; // Continue iterating + }); + userCount_ = count; + initialized_ = true; + return count; +} + +bool Users::userExists(const char* username) const { + bool found = false; + _iterateUsers(filename_, [&](UserData& user) { + if (strcmp(user.username, username) == 0) { + found = true; + return false; // Stop iterating + } + return true; // Continue iterating + }); + return found; +} + +bool Users::checkPassword(const char* username, const char* password) const { + bool success = false; + _iterateUsers(filename_, [&](UserData& user) { + if (strcmp(user.username, username) == 0) { + uint8_t provided_hash[32]; + hashPassword(password, provided_hash); + if (memcmp(user.password_hash, provided_hash, 32) == 0) { + success = true; + } + return false; // Stop iterating, user found. + } + return true; // Continue iterating + }); + return success; +} + +bool Users::loadUsernamesCallback(const std::function &callback) const +{ + if (!callback) return false; + return _iterateUsers(filename_, [&](UserData &user) { + auto res = callback(user.username); + if (!res) return false; // Stop iterating + return true; // Continue iterating + }); +} + +bool Users::addUser(const char* username, const char* password) { + // --- Phase 1: Checking preconditions --- + bool exists = userExists(username); + size_t count = getUserCount(); + + if (exists || count >= MAX_USERS) { + if (exists) { + LOG_ERROR("addUser: User already exists. Aborting."); + } else { + LOG_ERROR("addUser: User limit reached. Aborting."); + } + return false; + } + + // --- Phase 2: Preparing the files --- + const char* tmp_filename = "/users.pb.tmp"; + File dst_file = LittleFS.open(tmp_filename, "w"); + if (!dst_file) { + LOG_ERROR("addUser: Failed to open temporary file. Check filesystem space and permissions."); + return false; + } + + File src_file = LittleFS.open(filename_, "r"); + if (src_file && src_file.size() > 0) { + uint8_t buffer[128]; + while (src_file.available()) { + size_t len = src_file.read(buffer, sizeof(buffer)); + dst_file.write(buffer, len); + } + src_file.close(); + } else { + if (src_file) src_file.close(); + } + + // --- Phase 3: Appending the new user --- + pb_ostream_t ostream = pb_ostream_from_file(dst_file); + webui_WebUiUser new_user_msg = webui_WebUiUser_init_zero; + + // Prepare username for the encoder + strcpy(new_user_msg.username, username); + + // Prepare password hash for the encoder + uint8_t password_hash[32]; + hashPassword(password, password_hash); // Important: Hash is created here, not in the lambda + new_user_msg.password_hash.arg = password_hash; + new_user_msg.password_hash.funcs.encode = [](pb_ostream_t *stream, const pb_field_t *field, void * const *arg) { + return pb_encode_tag_for_field(stream, field) && pb_encode_string(stream, (uint8_t*)*arg, 32); + }; + + bool status = pb_encode_delimited(&ostream, webui_WebUiUser_fields, &new_user_msg); + + // Important: Always release resources (stream state and file) + delete (FileStreamState*)ostream.state; + dst_file.close(); + + if (!status) { + LOG_ERROR("addUser: Protobuf encoding (pb_encode_delimited) failed!"); + LOG_DEBUG("addUser: Removing failed temporary file: %s\n", tmp_filename); + LittleFS.remove(tmp_filename); + return false; + } + + // --- Phase 4: Atomic replacement of the old file --- + if (LittleFS.exists(filename_)) { + if (!LittleFS.remove(filename_)) { + LOG_ERROR("addUser: Could not remove old file '%s'. Aborting.\n", filename_); + LittleFS.remove(tmp_filename); // Cleanup temp file + return false; + } + } + + bool rename_ok = LittleFS.rename(tmp_filename, filename_); + + if (rename_ok) { + } else { + LOG_ERROR("addUser: Final rename failed! Filesystem might be in an inconsistent state."); + LittleFS.remove(tmp_filename); // Attempt to clean up + } + + if (rename_ok) { + userCount_++; + } + + return rename_ok; +} + +bool Users::deleteUser(const char* username) { + bool user_was_found = false; + + bool success = _transformUsers(filename_, [&](UserData& user) { + if (strcmp(user.username, username) == 0) { + user_was_found = true; + return false; // Delete this user + } + return true; // Keep this user + }); + + bool user_deleted = success && user_was_found; + if (user_deleted) { + userCount_--; + } + + return user_deleted; +} + +bool Users::updatePassword(const char* username, const char* newPassword) { + bool user_was_found = false; + bool success = _transformUsers(filename_, [&](UserData& user) { + if (strcmp(user.username, username) == 0) { + user_was_found = true; + hashPassword(newPassword, user.password_hash); + } + return true; // Always keep the user, just maybe modified. + }); + return success && user_was_found; +} + +void Users::reset() { + if (LittleFS.exists(filename_)) { + LittleFS.remove(filename_); + } + userCount_ = 0; + initialized_ = false; +} \ No newline at end of file diff --git a/lib/data/users.hpp b/lib/data/users.hpp new file mode 100644 index 0000000..8db47e2 --- /dev/null +++ b/lib/data/users.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include +#include // Ensure this is the version with 'bytes password_hash' +#include // Required for std::function + +// This struct definition is correct and necessary. +struct UserData { + char username[33]; // 32 chars + null terminator + uint8_t password_hash[32]; // 32-byte raw SHA-256 hash +}; + +class Users { +public: + Users(); + ~Users(); + + bool addUser(const char* username, const char* password); + bool deleteUser(const char* username); + bool updatePassword(const char* username, const char* newPassword); + bool userExists(const char* username) const; + bool checkPassword(const char* username, const char* password) const; + bool loadUsernamesCallback(const std::function& callback) const; + size_t getUserCount(); + void reset(); + +private: + const char* filename_ = "/users.pb"; + static constexpr size_t MAX_USERS = 5; + mutable size_t userCount_ = 0; + mutable bool initialized_ = false; + + void hashPassword(const char* password, uint8_t* hash_out) const; static bool _iterateUsers(const char* filename, const std::function& user_callback); + static bool _transformUsers(const char* src_filename, const std::function& transform_callback); +}; + + +extern Users users; \ No newline at end of file diff --git a/lib/hardware/hardware_rtc.cpp b/lib/hardware/hardware_rtc.cpp new file mode 100644 index 0000000..8f958be --- /dev/null +++ b/lib/hardware/hardware_rtc.cpp @@ -0,0 +1,168 @@ +#include "hardware_rtc.hpp" +#include + + +HardwareRTC::HardwareRTC(TwoWire& wire): wire(wire), initialized(false) {} + +HardwareRTC::~HardwareRTC() {} + +void HardwareRTC::begin() { + if(wire.getClock() == 0) { + wire.begin(); + wire.setClock(100000); // Set to 100kHz + } + initialized = true; + LOG_DEBUG("RTC initialized"); + setSystemTime(0); +} + +bool HardwareRTC::setTime(time_t unixTimestamp) { + if (!initialized) { + LOG_WARN("RTC not initialized"); + return false; + } + + // Use time_t, which on ESP32 is 64-bit and safe for this conversion + time_t t = unixTimestamp; + struct tm timeStruct; + gmtime_r(&t, &timeStruct); + + // Check if the year is within the DS1307's valid range (2000-2099) + if (timeStruct.tm_year < 100 || timeStruct.tm_year > 199) { + // Year is out of the 2000-2099 range that the DS1307 can store. + LOG_ERROR("RTC setTime: Year %d is out of range (2000-2099)", timeStruct.tm_year + 1900); + return false; + } + + uint8_t buffer[7]; + buffer[0] = decToBcd(timeStruct.tm_sec); + buffer[1] = decToBcd(timeStruct.tm_min); + buffer[2] = decToBcd(timeStruct.tm_hour); + buffer[3] = decToBcd(timeStruct.tm_wday + 1); + buffer[4] = decToBcd(timeStruct.tm_mday); + buffer[5] = decToBcd(timeStruct.tm_mon + 1); + // The DS1307 only stores years 00-99. We handle this by taking the year since 2000. + buffer[6] = decToBcd(timeStruct.tm_year - 100); + + writeRegisters(buffer, 7); + + // set system time to match RTC + setSystemTime(0); + + LOG_DEBUG("RTC setTime: %s", toDateString(unixTimestamp).c_str()); + return true; +} + +time_t HardwareRTC::getTime() { + if (!initialized) return 0; + + uint8_t buffer[7]; + readRegisters(buffer, 7); + + struct tm timeStruct = {0}; // Important: Initialize the struct to zero + timeStruct.tm_sec = bcdToDec(buffer[0] & 0x7F); + timeStruct.tm_min = bcdToDec(buffer[1]); + timeStruct.tm_hour = bcdToDec(buffer[2] & 0x3F); + timeStruct.tm_wday = bcdToDec(buffer[3]) - 1; + timeStruct.tm_mday = bcdToDec(buffer[4]); + timeStruct.tm_mon = bcdToDec(buffer[5]) - 1; + // Assume all 2-digit years from the RTC are in the 21st century (2000-2099) + timeStruct.tm_year = bcdToDec(buffer[6]) + 100; // Years since 1900 + + // mktime converts a local time struct to a time_t. + // Since the RTC stores time without timezone info, we treat it as UTC. + // timegm is the correct function for this, but mktime is often used + // on embedded systems with the timezone set to UTC. + time_t t = mktime(&timeStruct); + + LOG_DEBUG("RTC getTime: %s", toDateString(t).c_str()); + + // Cast the 64-bit time_t to uint64_t for the return type + return static_cast(t); +} + +void HardwareRTC::setSystemTime(int timezoneOffsetHours) { + if (!initialized) { + LOG_WARN("RTC not initialized"); + return; + } + + + uint64_t unixTime = getTime(); + if (unixTime == 0) return; + + unixTime += timezoneOffsetHours * 3600; + + LOG_DEBUG("RTC setSystemTime: %s", toDateString(unixTime).c_str()); + + struct timeval tv; + // The tv_sec field is of type time_t, which is 64-bit on ESP32 + tv.tv_sec = unixTime; + tv.tv_usec = 0; + settimeofday(&tv, NULL); +} + + +bool HardwareRTC::isRunning() { + if (!initialized) return false; + + uint8_t buffer[1]; + readRegisters(buffer, 1); + return !(buffer[0] & 0x80); // CH bit is 0 when running +} + +void HardwareRTC::setTimezone(const char* timezone) { + setenv("TZ", timezone, 1); + tzset(); +} + +void HardwareRTC::update() +{ + static uint32_t nextSync = 0; + time_t now = millis(); + if (now >= nextSync) { + if(!isRunning()) { + LOG_WARN("RTC is not running, skipping update"); + return; + } + setSystemTime(0); + nextSync = now + (15UL * 60UL * 1000UL); // every 15 minutes + LOG_DEBUG("RTC update: System time synchronized with RTC"); + } +} + +String HardwareRTC::toDateString(time_t timestamp) { + struct tm* timeinfo = gmtime(×tamp); + char buffer[40]; + strftime(buffer, sizeof(buffer), "%Y-%m-%d %H:%M:%S", timeinfo); + return String(buffer); +} + +uint8_t HardwareRTC::bcdToDec(uint8_t bcd) { + return ((bcd >> 4) * 10) + (bcd & 0x0F); +} + +uint8_t HardwareRTC::decToBcd(uint8_t dec) { + return ((dec / 10) << 4) | (dec % 10); +} + +void HardwareRTC::readRegisters(uint8_t* buffer, uint8_t length) { + Wire.beginTransmission(DS1307_ADDRESS); + Wire.write(0x00); // start at register 0 + Wire.endTransmission(); + + Wire.requestFrom(DS1307_ADDRESS, length); + for (uint8_t i = 0; i < length; i++) { + buffer[i] = Wire.read(); + } +} + +void HardwareRTC::writeRegisters(uint8_t* buffer, uint8_t length) { + Wire.beginTransmission(DS1307_ADDRESS); + Wire.write(0x00); // start at register 0 + for (uint8_t i = 0; i < length; i++) { + Wire.write(buffer[i]); + } + Wire.endTransmission(); +} + diff --git a/lib/hardware/hardware_rtc.hpp b/lib/hardware/hardware_rtc.hpp new file mode 100644 index 0000000..b6be1cb --- /dev/null +++ b/lib/hardware/hardware_rtc.hpp @@ -0,0 +1,53 @@ +#pragma once + +#include +#include +#include +#include + +#define DS1307_ADDRESS 0x68 +#define EEPROM_ADDRESS 0x50 + +class HardwareRTC { +public: + HardwareRTC(TwoWire& wire = Wire); + ~HardwareRTC(); + + void begin(); + + // Set RTC time from a 64-bit Unix timestamp + bool setTime(time_t unixTimestamp); + + // Get current time as a 64-bit Unix timestamp + time_t getTime(); + + static time_t getSystemTime() { + struct timeval tv; + if (gettimeofday(&tv, nullptr) != 0) { + LOG_ERROR("Failed to get system time for log entry"); + return 0; // Fallback-Wert + } else { + return tv.tv_sec; // Unix-Timestamp in Sekunden + } + gettimeofday(&tv, NULL); + return tv.tv_sec; + } + // Helper method to convert time_t to date string + static String toDateString(time_t timestamp); + + + void setSystemTime(int timezoneOffsetHours = 0); + bool isRunning(); + void setTimezone(const char* timezone); + void update(); // Placeholder for future use + +private: + bool initialized; + TwoWire& wire; + + uint8_t bcdToDec(uint8_t bcd); + uint8_t decToBcd(uint8_t dec); + + void readRegisters(uint8_t* buffer, uint8_t length); + void writeRegisters(uint8_t* buffer, uint8_t length); +}; \ No newline at end of file diff --git a/lib/hardware/hardware_serial.cpp b/lib/hardware/hardware_serial.cpp new file mode 100644 index 0000000..8b963b4 --- /dev/null +++ b/lib/hardware/hardware_serial.cpp @@ -0,0 +1,222 @@ +#include "hardware_serial.hpp" +#include +#include + +//#define DEBUG_PROTO +#ifdef DEBUG_PROTO +#define LOG(msg) LOG_DEBUG(msg) +#else +#define LOG(msg) +#endif + +ProtoSerial::ProtoSerial() + : serial(nullptr), initialized(false), lastError{0}, callback(nullptr), + currentState(WAITING_FOR_SYNC_START), payloadLength(0), messageAvailable(false), + lastUpdate(0), lastByteTime(0) { + receivedMessage = INCOMING_MESSAGE_INIT; +} + +ProtoSerial::~ProtoSerial() { + // No delete needed; serial is caller-managed +} + +void ProtoSerial::begin(SerialType& serialPort) { + serial = &serialPort; + initialized = true; + currentState = WAITING_FOR_SYNC_START; + payloadLength = 0; + messageAvailable = false; + lastError[0] = '\0'; + lastUpdate = 0; + lastByteTime = 0; + snprintf(lastError, sizeof(lastError), "Initialized ProtoSerial"); + LOG(lastError); +} + +bool ProtoSerial::sendMessage(const OutgoingMessage& message) { + if (!initialized || !serial || !serial->availableForWrite()) { + snprintf(lastError, sizeof(lastError), "Serial not initialized or not ready"); + LOG(lastError); + return false; + } + + uint8_t buffer[OUTGOING_MESSAGE_SIZE]; + pb_ostream_t stream = pb_ostream_from_buffer(buffer, sizeof(buffer)); + + // Encode the message + if (!pb_encode(&stream, OUTGOING_MESSAGE_FIELDS, &message)) { + snprintf(lastError, sizeof(lastError), "Failed to encode message"); + LOG(lastError); + return false; + } + + uint16_t messageLength = stream.bytes_written; + if (messageLength > sizeof(buffer)) { + snprintf(lastError, sizeof(lastError), "Encoded message too large: %u", messageLength); + LOG(lastError); + return false; + } + + // Send framing: sync start, length, payload, CRC, sync end + serial->write(SYNC_START); + serial->write((uint8_t*)&messageLength, sizeof(messageLength)); + serial->write(buffer, messageLength); + uint8_t crc = calculate_crc8(buffer, messageLength); + serial->write(crc); + serial->write(SYNC_END); + serial->flush(); + + #ifdef DEBUG_PROTO + dumpHex(buffer, messageLength, "Sent"); + snprintf(lastError, sizeof(lastError), "Sent message, length: %u, CRC: 0x%02X", messageLength, crc); + LOG(lastError); + #endif + return true; +} + +void ProtoSerial::update() { + if (!initialized || !serial) { + snprintf(lastError, sizeof(lastError), "Serial not initialized"); + LOG(lastError); + return; + } + + // Throttle updates to ~100Hz + unsigned long now = millis(); + if (now - lastUpdate < 10) return; + lastUpdate = now; + + // Timeout if no data received for 1 second + if (currentState != WAITING_FOR_SYNC_START && now - lastByteTime > 1000) { + snprintf(lastError, sizeof(lastError), "Receive timeout"); + LOG(lastError); + currentState = WAITING_FOR_SYNC_START; + payloadLength = 0; + } + + while (serial->available()) { + lastByteTime = now; + switch (currentState) { + case WAITING_FOR_SYNC_START: { + uint8_t byte = serial->read(); + if (byte == SYNC_START) { + currentState = WAITING_FOR_LENGTH; + LOG("Received SYNC_START"); + } + break; + } + case WAITING_FOR_LENGTH: { + if (serial->available() >= sizeof(payloadLength)) { + serial->readBytes((uint8_t*)&payloadLength, sizeof(payloadLength)); + if (payloadLength > sizeof(payloadBuffer) || payloadLength == 0) { + snprintf(lastError, sizeof(lastError), "Invalid payload length: %u", payloadLength); + LOG(lastError); + currentState = WAITING_FOR_SYNC_START; + payloadLength = 0; + } else { + currentState = READING_PAYLOAD; + snprintf(lastError, sizeof(lastError), "Received length: %u", payloadLength); + LOG(lastError); + } + } + break; + } + case READING_PAYLOAD: { + if (serial->available() >= payloadLength) { + serial->readBytes(payloadBuffer, payloadLength); + currentState = READING_CRC; + } + break; + } + case READING_CRC: { + if (serial->available() >= 1) { + uint8_t received_crc = serial->read(); + uint8_t expected_crc = calculate_crc8(payloadBuffer, payloadLength); + if (received_crc != expected_crc) { + snprintf(lastError, sizeof(lastError), "CRC mismatch: received 0x%02X, expected 0x%02X", received_crc, expected_crc); + LOG(lastError); + } else if (serial->available() >= 1 && serial->read() != SYNC_END) { + snprintf(lastError, sizeof(lastError), "Missing SYNC_END"); + LOG(lastError); + } else { + dumpHex(payloadBuffer, payloadLength, "Received"); + processReceivedMessage(payloadBuffer, payloadLength); + snprintf(lastError, sizeof(lastError), "Received valid message, length: %u", payloadLength); + LOG(lastError); + } + currentState = WAITING_FOR_SYNC_START; + payloadLength = 0; + } + break; + } + } + } +} + +bool ProtoSerial::hasMessage() const { + return messageAvailable; +} + +const IncomingMessage& ProtoSerial::getMessage() const { + return receivedMessage; +} + +void ProtoSerial::clearMessage() { + messageAvailable = false; + receivedMessage = INCOMING_MESSAGE_INIT; + snprintf(lastError, sizeof(lastError), "Message cleared"); + LOG(lastError); +} + +void ProtoSerial::setCallback(Callback cb) { + callback = cb; + snprintf(lastError, sizeof(lastError), "Callback set"); + LOG(lastError); +} + +void ProtoSerial::processReceivedMessage(uint8_t* buffer, uint16_t length) { + receivedMessage = INCOMING_MESSAGE_INIT; + pb_istream_t stream = pb_istream_from_buffer(buffer, length); + if (pb_decode(&stream, INCOMING_MESSAGE_FIELDS, &receivedMessage)) { + if (receivedMessage.which_payload == 0) { + snprintf(lastError, sizeof(lastError), "Invalid message: which_payload not set"); + LOG(lastError); + return; + } + messageAvailable = true; + if (callback) { + callback(receivedMessage); + } + snprintf(lastError, sizeof(lastError), "Message decoded successfully"); + LOG(lastError); + } else { + snprintf(lastError, sizeof(lastError), "Failed to decode message"); + LOG(lastError); + } +} + +uint8_t ProtoSerial::calculate_crc8(const uint8_t* data, uint16_t len) { + uint8_t crc = 0x00; + for (uint16_t i = 0; i < len; i++) { + crc ^= data[i]; + for (uint8_t j = 0; j < 8; j++) { + if (crc & 0x80) crc = (crc << 1) ^ 0x31; // CRC-8 polynomial + else crc <<= 1; + } + } + return crc; +} + +void ProtoSerial::dumpHex(const uint8_t* data, uint16_t len, const char* label) { +#ifdef DEBUG_PROTO + Serial.print(label); + Serial.print(": "); + for (uint16_t i = 0; i < len; i++) { + if (i % 16 == 0) Serial.println(); + Serial.printf("%02X ", data[i]); + } + Serial.println(); +#endif +} + +ProtoSerial pserial; \ No newline at end of file diff --git a/lib/hardware/hardware_serial.hpp b/lib/hardware/hardware_serial.hpp new file mode 100644 index 0000000..2347eed --- /dev/null +++ b/lib/hardware/hardware_serial.hpp @@ -0,0 +1,171 @@ +#pragma once + +#ifdef ESP32 +#include +typedef HardwareSerial SerialType; +#define SERIAL_TYPE HardwareSerial +#elif defined(ESP8266) +#include +typedef HardwareSerial SerialType; +#define SERIAL_TYPE HardwareSerial +#else +#error "Unsupported platform. Only ESP32 and ESP8266 are supported." +#endif + +#include +#include +#include "hardware.pb.h" + +// Define the role of this device +// Uncomment one of the following defines to set the role +#define HARDWARE_SERIAL_ROLE_CONTROL +// #define HARDWARE_SERIAL_ROLE_SENSOR + +// Based on role, define the message types +#if defined(HARDWARE_SERIAL_ROLE_CONTROL) +typedef hardware_SensorToControlMessage IncomingMessage; +typedef hardware_ControlToSensorMessage OutgoingMessage; +#define INCOMING_MESSAGE_SIZE hardware_SensorToControlMessage_size +#define OUTGOING_MESSAGE_SIZE hardware_ControlToSensorMessage_size +#define INCOMING_MESSAGE_FIELDS hardware_SensorToControlMessage_fields +#define OUTGOING_MESSAGE_FIELDS hardware_ControlToSensorMessage_fields +#define INCOMING_MESSAGE_INIT hardware_SensorToControlMessage_init_zero +#define OUTGOING_MESSAGE_INIT hardware_ControlToSensorMessage_init_zero +#elif defined(HARDWARE_SERIAL_ROLE_SENSOR) +typedef hardware_ControlToSensorMessage IncomingMessage; +typedef hardware_SensorToControlMessage OutgoingMessage; +#define INCOMING_MESSAGE_SIZE hardware_ControlToSensorMessage_size +#define OUTGOING_MESSAGE_SIZE hardware_SensorToControlMessage_size +#define INCOMING_MESSAGE_FIELDS hardware_ControlToSensorMessage_fields +#define OUTGOING_MESSAGE_FIELDS hardware_SensorToControlMessage_fields +#define INCOMING_MESSAGE_INIT hardware_ControlToSensorMessage_init_zero +#define OUTGOING_MESSAGE_INIT hardware_ControlToSensorMessage_init_zero +#else +#error "Must define either HARDWARE_SERIAL_ROLE_CONTROL or HARDWARE_SERIAL_ROLE_SENSOR" +#endif + +/** + * @class ProtoSerial + * @brief Manages serial communication with Protocol Buffers for ESP32/ESP8266. + * Supports length-prefixed messages with CRC-8 checksum and sync bytes. + * Configured for SENSOR or CONTROL role via HARDWARE_SERIAL_ROLE_* macros. + * On ESP8266, uses UART0 (Serial, RX=GPIO3, TX=GPIO1). On ESP32, uses caller-provided UART. + */ +class ProtoSerial { +public: + using Callback = void (*)(const IncomingMessage&); ///< Callback for received messages. + + /** + * @brief Constructor. + */ + ProtoSerial(); + + /** + * @brief Destructor. No-op (serial is managed by caller). + */ + ~ProtoSerial(); + + /** + * @brief Initializes serial communication with a caller-provided serial port. + * @param serialPort Reference to a configured SerialType (e.g., Serial for ESP8266, HardwareSerial for ESP32). + */ + void begin(SerialType& serialPort); + + /** + * @brief Sends a Protocol Buffers message with sync bytes and CRC-8. + * @param message The message to send (OutgoingMessage type). + * @return True if sent successfully, false otherwise. + */ + bool sendMessage(const OutgoingMessage& message); + + /** + * @brief Checks for incoming messages (non-blocking). + */ + void update(); + + /** + * @brief Checks if a message is available. + * @return True if a message is ready to be read. + */ + bool hasMessage() const; + + /** + * @brief Gets the received message. + * @return Reference to the received message (valid only if hasMessage() is true). + */ + const IncomingMessage& getMessage() const; + + /** + * @brief Clears the received message. + */ + void clearMessage(); + + /** + * @brief Sets the callback for received messages. + * @param cb Callback function to handle incoming messages. + */ + void setCallback(Callback cb); + + /** + * @brief Checks if the serial interface is initialized. + * @return True if initialized. + */ + bool isInitialized() const { return initialized; } + + /** + * @brief Gets the last error message (for debugging). + * @return Last error string or empty if none. + */ + const char* getLastError() const { return lastError; } + +private: + SerialType* serial; ///< Serial interface (pointer to caller-provided SerialType). + bool initialized; ///< Tracks initialization state. + char lastError[64]; ///< Last error message (fixed-size to avoid heap issues). + Callback callback; ///< Callback for received messages. + + // Framing constants + static const uint8_t SYNC_START = 0xAA; ///< Start byte for message framing. + static const uint8_t SYNC_END = 0xBB; ///< End byte for message framing. + + // Receive state machine + enum RxState { + WAITING_FOR_SYNC_START, ///< Waiting for start byte (0xAA). + WAITING_FOR_LENGTH, ///< Waiting for 2-byte length. + READING_PAYLOAD, ///< Reading payload bytes. + READING_CRC ///< Reading CRC-8 byte. + }; + RxState currentState; ///< Current state of receive state machine. + uint16_t payloadLength; ///< Length of incoming payload. + uint8_t payloadBuffer[INCOMING_MESSAGE_SIZE + 10]; ///< Buffer for payload (plus overhead). + IncomingMessage receivedMessage; ///< Decoded incoming message. + bool messageAvailable; ///< True if a message is ready. + unsigned long lastUpdate; ///< Timestamp of last update (for throttling). + unsigned long lastByteTime; ///< Timestamp of last byte received (for timeout). + + // Helper methods + /** + * @brief Processes a received payload and decodes it. + * @param buffer Payload buffer. + * @param length Payload length. + */ + void processReceivedMessage(uint8_t* buffer, uint16_t length); + + /** + * @brief Calculates CRC-8 for a buffer. + * @param data Buffer to compute CRC over. + * @param len Length of buffer. + * @return CRC-8 value. + */ + uint8_t calculate_crc8(const uint8_t* data, uint16_t len); + + /** + * @brief Logs a hex dump of a buffer (debug only). + * @param data Buffer to dump. + * @param len Length of buffer. + * @param label Label for the dump. + */ + void dumpHex(const uint8_t* data, uint16_t len, const char* label); +}; + +extern ProtoSerial pserial; \ No newline at end of file diff --git a/lib/log/logger.cpp b/lib/log/logger.cpp new file mode 100644 index 0000000..4ef3fc6 --- /dev/null +++ b/lib/log/logger.cpp @@ -0,0 +1,149 @@ +#include "logger.hpp" +#include +#include +#include // For va_list, vsnprintf +#include + +Logger::Logger() + : logCount(0), logIndex(0), currentLogLevel(LOG_INFO), + logCallback(nullptr), logQueue(nullptr), logTaskHandle(nullptr) {} + +Logger::~Logger() { + // Clean up FreeRTOS objects + if (logTaskHandle) vTaskDelete(logTaskHandle); + if (logQueue) vQueueDelete(logQueue); +} + +void Logger::begin(UBaseType_t taskPriority, uint32_t taskStackSize) { + // Create a queue that can hold up to 10 LogEntry items. + logQueue = xQueueCreate(10, sizeof(LogEntry)); + + if (logQueue == nullptr) { + Serial.println("FATAL: Failed to create logger queue!"); + return; + } + + // Create the background task. It passes 'this' so the static function + // can access the instance's members. + xTaskCreate( + logProcessingTask, + "LoggerTask", + taskStackSize, + this, // Pass the current object instance to the task + taskPriority, + &logTaskHandle + ); + + if (logTaskHandle == nullptr) { + Serial.println("FATAL: Failed to create logger task!"); + } +} + +void Logger::addLog(LogLevel level, const char* format, va_list args) { + if (level < currentLogLevel) { + return; + } + + LogEntry entry; + entry.level = level; + + // Verwende Systemzeit statt millis() + struct timeval tv; + if (gettimeofday(&tv, nullptr) != 0) { + LOG_ERROR("Failed to get system time for log entry"); + entry.timestamp = 0; // Fallback-Wert + } else { + entry.timestamp = tv.tv_sec; // Unix-Timestamp in Sekunden + } + + vsnprintf(entry.message, sizeof(entry.message), format, args); + + // Add to internal circular buffer for history + logs[logIndex] = entry; + logIndex = (logIndex + 1) % MAX_LOGS; + if (logCount < MAX_LOGS) { + logCount++; + } + + if (logQueue != nullptr) { + xQueueSend(logQueue, &entry, pdMS_TO_TICKS(10)); + } +} + +// This is the function that runs in its own task. +void Logger::logProcessingTask(void* instance) { + // The instance pointer is the 'this' we passed in xTaskCreate. + Logger* loggerInstance = static_cast(instance); + LogEntry entryToProcess; + + while (true) { + if (xQueueReceive(loggerInstance->logQueue, &entryToProcess, portMAX_DELAY) == pdPASS) { + if (loggerInstance->logCallback) { + loggerInstance->logCallback(entryToProcess); + } + } + } +} + +void Logger::setLogLevel(LogLevel level) { + currentLogLevel = level; +} + +LogLevel Logger::getLogLevel() const { + return currentLogLevel; +} + +void Logger::setLogCallback(std::function callback) { + logCallback = callback; +} + +// --- NEW: Public methods now handle the variable arguments --- +void Logger::debug(const char* format, ...) { + va_list args; + va_start(args, format); + addLog(LOG_DEBUG, format, args); + va_end(args); +} + +void Logger::info(const char* format, ...) { + va_list args; + va_start(args, format); + addLog(LOG_INFO, format, args); + va_end(args); +} + +void Logger::warn(const char* format, ...) { + va_list args; + va_start(args, format); + addLog(LOG_WARN, format, args); + va_end(args); +} + +void Logger::error(const char* format, ...) { + va_list args; + va_start(args, format); + addLog(LOG_ERROR, format, args); + va_end(args); +} + +const LogEntry* Logger::getLogs(int& count) const { + count = logCount; + return logs; +} + +void Logger::clear() { + logCount = 0; + logIndex = 0; +} + +const char* Logger::levelToString(LogLevel level) const { + switch (level) { + case LOG_DEBUG: return "D"; + case LOG_INFO: return "I"; + case LOG_WARN: return "W"; + case LOG_ERROR: return "E"; + default: return "U"; + } +} + +Logger logger; \ No newline at end of file diff --git a/lib/log/logger.hpp b/lib/log/logger.hpp new file mode 100644 index 0000000..95f4889 --- /dev/null +++ b/lib/log/logger.hpp @@ -0,0 +1,68 @@ +#pragma once +#include +#include +#include // For std::function +#include +#include "freertos/FreeRTOS.h" +#include "freertos/queue.h" +#include "freertos/task.h" + + +enum LogLevel { + LOG_DEBUG, + LOG_INFO, + LOG_WARN, + LOG_ERROR +}; + +struct LogEntry { + LogLevel level; + time_t timestamp; + char message[128]; +}; + +class Logger { +public: + Logger(); + ~Logger(); + + void begin(UBaseType_t taskPriority = 1, uint32_t taskStackSize = 2048); + + void setLogLevel(LogLevel level); + LogLevel getLogLevel() const; + + // Use the more flexible std::function for callbacks + void setLogCallback(std::function callback); + + // --- NEW: Variadic functions for printf-style formatting --- + void debug(const char* format, ...); + void info(const char* format, ...); + void warn(const char* format, ...); + void error(const char* format, ...); + + const LogEntry* getLogs(int& count) const; + void clear(); + const char* levelToString(LogLevel level) const; + +private: + static const int MAX_LOGS = 20; + LogEntry logs[MAX_LOGS]; + int logCount; + int logIndex; + LogLevel currentLogLevel; + + std::function logCallback; + + QueueHandle_t logQueue; + TaskHandle_t logTaskHandle; + static void logProcessingTask(void* instance); + void addLog(LogLevel level, const char* format, va_list args); +}; + +extern Logger logger; + +// --- OPTIONAL: Performance-enhancing macros --- +#define LOG_DEBUG(format, ...) if(logger.getLogLevel() <= LOG_DEBUG) { logger.debug(format, ##__VA_ARGS__); } +#define LOG_INFO(format, ...) if(logger.getLogLevel() <= LOG_INFO) { logger.info(format, ##__VA_ARGS__); } +#define LOG_WARN(format, ...) if(logger.getLogLevel() <= LOG_WARN) { logger.warn(format, ##__VA_ARGS__); } +#define LOG_ERROR(format, ...) if(logger.getLogLevel() <= LOG_ERROR) { logger.error(format, ##__VA_ARGS__); } \ No newline at end of file diff --git a/lib/ota/ota_manager.cpp b/lib/ota/ota_manager.cpp new file mode 100644 index 0000000..6a21fea --- /dev/null +++ b/lib/ota/ota_manager.cpp @@ -0,0 +1,94 @@ +#include "ota_manager.hpp" + +OTAManager::OTAManager() : enabled(false), status("Disabled") {} + +OTAManager::~OTAManager() {} + +void OTAManager::begin() { + ArduinoOTA.setHostname("RFID-Master-Client"); + + ArduinoOTA.onStart([this]() { + this->onStart(); + }); + + ArduinoOTA.onEnd([this]() { + this->onEnd(); + }); + + ArduinoOTA.onProgress([this](unsigned int progress, unsigned int total) { + this->onProgress(progress, total); + }); + + ArduinoOTA.onError([this](ota_error_t error) { + this->onError(error); + }); + + LOG_INFO("OTA Manager initialized"); +} + +void OTAManager::enable() { + if (!enabled) { + ArduinoOTA.begin(); + enabled = true; + status = "Enabled"; + LOG_INFO("OTA enabled"); + } +} + +void OTAManager::disable() { + if (enabled) { + ArduinoOTA.end(); + enabled = false; + status = "Disabled"; + LOG_INFO("OTA disabled"); + } +} + +bool OTAManager::isEnabled() const { + return enabled; +} + +String OTAManager::getStatus() const { + return status; +} + +void OTAManager::onStart() { + status = "Starting OTA update..."; + LOG_INFO("OTA update started"); +} + +void OTAManager::onEnd() { + status = "OTA update completed"; + LOG_INFO("OTA update completed"); +} + +void OTAManager::onProgress(unsigned int progress, unsigned int total) { + status = "OTA progress: " + String(progress / (total / 100)) + "%"; + LOG_DEBUG("OTA progress: %u/%u", progress, total); +} + +void OTAManager::onError(ota_error_t error) { + String errorMsg; + switch (error) { + case OTA_AUTH_ERROR: + errorMsg = "Auth Failed"; + break; + case OTA_BEGIN_ERROR: + errorMsg = "Begin Failed"; + break; + case OTA_CONNECT_ERROR: + errorMsg = "Connect Failed"; + break; + case OTA_RECEIVE_ERROR: + errorMsg = "Receive Failed"; + break; + case OTA_END_ERROR: + errorMsg = "End Failed"; + break; + default: + errorMsg = "Unknown Error"; + break; + } + status = "OTA Error: " + errorMsg; + LOG_ERROR("OTA error: %s", errorMsg.c_str()); +} \ No newline at end of file diff --git a/lib/ota/ota_manager.hpp b/lib/ota/ota_manager.hpp new file mode 100644 index 0000000..1184d29 --- /dev/null +++ b/lib/ota/ota_manager.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include +#include +#include + +class OTAManager { +public: + OTAManager(); + ~OTAManager(); + + void begin(); + void enable(); + void disable(); + bool isEnabled() const; + String getStatus() const; + +private: + bool enabled; + String status; + + void onStart(); + void onEnd(); + void onProgress(unsigned int progress, unsigned int total); + void onError(ota_error_t error); +}; \ No newline at end of file diff --git a/lib/rpc/library.json b/lib/rpc/library.json new file mode 100644 index 0000000..bd07111 --- /dev/null +++ b/lib/rpc/library.json @@ -0,0 +1,6 @@ +{ + "name": "ota", + "dependencies": { + "hardware": "*" + } +} diff --git a/lib/rpc/rpc.cpp b/lib/rpc/rpc.cpp new file mode 100644 index 0000000..ba5809b --- /dev/null +++ b/lib/rpc/rpc.cpp @@ -0,0 +1,445 @@ +#include "rpc.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define LAST_SYNC_TIME_FILE "/last_sync.time" + +// ---- Constructor / Destructor ---- + +RPC::RPC() { + // NOTE: Initialization is done here. Ensure this global object is created + // AFTER core systems like LittleFS are initialized in setup(). + apiKey_[0] = '\0'; + baseUrl_[0] = '\0'; + + if (!accessLogQueue) { + accessLogQueue = xQueueCreate(100, sizeof(RpcAccessLogEntry)); + if (!accessLogQueue) { + LOG_ERROR("RPC: FATAL: Failed to create access log queue!"); + } + } + + syncTriggerSemaphore_ = xSemaphoreCreateBinary(); + if (!syncTriggerSemaphore_) { + LOG_ERROR("RPC: FATAL: Failed to create sync trigger semaphore!"); + } + + // Load the last sync time from persistent storage on startup. + this->lastSyncTime = loadLastSyncTime(); + LOG_INFO("RPC: Instance created. Loaded last sync timestamp: %s", HardwareRTC::toDateString(this->lastSyncTime).c_str()); +} + +RPC::~RPC() { + stopAutoSync(); + if (accessLogQueue) { + vQueueDelete(accessLogQueue); + accessLogQueue = nullptr; + } + if (syncTriggerSemaphore_) { + vSemaphoreDelete(syncTriggerSemaphore_); + syncTriggerSemaphore_ = nullptr; + } +} + +// ---- Public Methods ---- + +bool RPC::sync(bool forceDownload) { + if (isSyncing_) { + logger.warn("RPC: Sync aborted, another sync is already in progress."); + return false; + } + if (baseUrl_[0] == '\0') { + LOG_ERROR("RPC: Sync aborted (Base URL not set). Call startAutoSync first."); + return false; + } + if (WiFi.status() != WL_CONNECTED) { + LOG_ERROR("RPC: Sync aborted (no WiFi connection)."); + return false; + } + + isSyncing_ = true; + LOG_INFO("RPC: Starting synchronization process..."); + + bool pendingChanges = false; + bool syncSuccess = false; + + if (!syncLogsAndCheckForUpdates(&pendingChanges)) { + LOG_ERROR("RPC: Log synchronization failed."); + syncSuccess = false; + } else { + // Log sync was successful, now check if a DB download is needed. + if (pendingChanges || forceDownload) { + const char* dbPath = "/rfid.db"; + LOG_DEBUG("RPC: Pending changes: %d, forceDownload: %d", pendingChanges, forceDownload); + if (!downloadDatabaseToFile(dbPath)) { + LOG_ERROR("RPC: Failed to download database."); + syncSuccess = false; + } else { + syncSuccess = true; + } + } else { + LOG_DEBUG("RPC: No pending changes reported by server, skipping database download."); + syncSuccess = true; + } + } + + isSyncing_ = false; + if(syncSuccess) { + LOG_INFO("RPC: Synchronization completed successfully."); + } else { + LOG_ERROR("RPC: Synchronization failed."); + } + return syncSuccess; +} + +void RPC::addAccessLog(uint32_t rfidId) { + if (!accessLogQueue) { + return; // Queue not created, do nothing. + } + struct timeval tv; + RpcAccessLogEntry entry; + + entry.timestamp = HardwareRTC::getSystemTime(); + entry.rfidId = rfidId; + + if (xQueueSend(accessLogQueue, &entry, 0) != pdPASS) { + LOG_WARN("RPC: Access log queue is full, dropping entry."); + return; + } + + // --- TRIGGER LOGIC --- + // If the queue has reached the threshold and we can sync, trigger it. + if (uxQueueMessagesWaiting(accessLogQueue) >= LOG_SYNC_THRESHOLD && baseUrl_[0] != '\0') { + LOG_INFO("RPC: Queue size: %d. Triggering immediate sync.", uxQueueMessagesWaiting(accessLogQueue)); + xSemaphoreGive(syncTriggerSemaphore_); + } +} + +// ---- Auto-Sync Task Management ---- + +void RPC::startAutoSync(uint32_t intervalMs) { + stopAutoSync(); // Always stop any existing task to ensure a clean restart. + + if (baseUrl_[0] == '\0') { + LOG_ERROR("RPC: Cannot start auto-sync. Base URL is not configured."); + return; + } + + LOG_INFO("RPC: Starting auto-sync"); + syncIntervalMs = intervalMs; + autoSyncRunning = true; + + BaseType_t result = xTaskCreate( + autoSyncTask, "AutoSyncTask", 8192, this, 1, &autoSyncTaskHandle + ); + + if (result != pdPASS) { + LOG_ERROR("RPC: Failed to create auto-sync task."); + autoSyncRunning = false; + autoSyncTaskHandle = nullptr; + } else { + LOG_INFO("RPC: Auto-sync task started successfully."); + } +} + +void RPC::stopAutoSync() { + if (!autoSyncTaskHandle) { + return; + } + + LOG_INFO("RPC: Attempting to stop auto-sync task..."); + autoSyncRunning = false; // Signal the task to exit its loop + + // Wake the task if it's waiting on the semaphore + if (syncTriggerSemaphore_) { + xSemaphoreGive(syncTriggerSemaphore_); + } + + // Wait for the task to terminate itself (indicated by handle becoming nullptr) + uint32_t startTime = millis(); + while (autoSyncTaskHandle != nullptr && millis() - startTime < 1000) { + vTaskDelay(pdMS_TO_TICKS(50)); + } + + // If the task did not stop gracefully, force its deletion + if (autoSyncTaskHandle != nullptr) { + LOG_ERROR("RPC: Task did not stop gracefully. Forcing deletion."); + vTaskDelete(autoSyncTaskHandle); + autoSyncTaskHandle = nullptr; + } else { + LOG_INFO("RPC: Auto-sync task stopped gracefully."); + } +} + +void RPC::setApiKeyAndBaseUrl(const char* apiKey, const char* baseUrl) { + if (apiKey) { + strncpy(apiKey_, apiKey, sizeof(apiKey_) - 1); + apiKey_[sizeof(apiKey_) - 1] = '\0'; // Ensure null-termination + } else { + apiKey_[0] = '\0'; // Set to empty string + } + + if (baseUrl) { + strncpy(baseUrl_, baseUrl, sizeof(baseUrl_) - 1); + baseUrl_[sizeof(baseUrl_) - 1] = '\0'; + } else { + baseUrl_[0] = '\0'; + } +} + +// ---- Private Helper Methods ---- + +HttpResponse RPC::sendRequest(const String& url, const String& method, const uint8_t* payload, size_t payloadLen, const String& contentType) { + HttpResponse response; // Default httpCode is 0 + if (WiFi.status() != WL_CONNECTED) { + response.httpCode = -1; // Custom code for no WiFi + return response; + } + + HTTPClient http; + http.begin(url); + http.setTimeout(10000); + + if (apiKey_[0] != '\0') { + http.addHeader("x-device-key", apiKey_); + } + + if (method.equalsIgnoreCase("POST")) { + http.addHeader("Content-Type", contentType); + response.httpCode = http.POST(const_cast(payload), payloadLen); + } else { + response.httpCode = http.GET(); + } + + if (response.httpCode > 0) { + int len = http.getSize(); + if (len > 0) { + response.payload.resize(len); + WiFiClient* stream = http.getStreamPtr(); + stream->readBytes(response.payload.data(), len); + } + } else { + LOG_ERROR(("RPC: HTTP " + method + " " + url + " failed: " + http.errorToString(response.httpCode)).c_str()); + } + + http.end(); // Encapsulated resource management + return response; +} + + +bool RPC::syncLogsAndCheckForUpdates(bool* pendingChanges) { + // NOTE: This implementation assumes the logs should be sent with the sync request. + // The previous code collected logs but did not send them. This version does. + std::vector logBatch; + RpcAccessLogEntry entry; + + // Drain the queue to send all pending logs. + // To be more memory efficient on larger queues, this could be done in batches. + while (xQueueReceive(accessLogQueue, &entry, 0) == pdPASS) { + logBatch.push_back(entry); + } + + control_communication_SyncRequest request = control_communication_SyncRequest_init_zero; + request.has_lastSync = (lastSyncTime > 0); + request.lastSync = lastSyncTime; + + request.accessLogs.arg = &logBatch; + request.accessLogs.funcs.encode = [](pb_ostream_t* stream, const pb_field_t* field, void* const* arg) -> bool { + auto logs = static_cast*>(*arg); + if (!logs || logs->empty()) { + return true; // No logs to encode, return success + } + for (const auto& log : *logs) { + control_communication_SyncRequest_AccessLogsEntry entry = { log.timestamp, log.rfidId }; + if (!pb_encode_tag_for_field(stream, field)) { + return false; + } + if (!pb_encode_submessage(stream, control_communication_SyncRequest_AccessLogsEntry_fields, &entry)) { + return false; + } + } + return true; + }; + + uint8_t requestBuffer[512]; + pb_ostream_t ostream = pb_ostream_from_buffer(requestBuffer, sizeof(requestBuffer)); + if (!pb_encode(&ostream, control_communication_SyncRequest_fields, &request)) { + LOG_ERROR("RPC: Failed to encode sync request."); + return false; + } + + + String url = String(baseUrl_) + "device-communication/sync-binary"; + HttpResponse httpResponse = sendRequest(url, "POST", requestBuffer, ostream.bytes_written, "application/octet-stream"); + + if (httpResponse.httpCode != HTTP_CODE_OK) { + LOG_ERROR("RPC: Sync logs request failed with HTTP code %d", httpResponse.httpCode) + return false; + } + + // A successful connection was made. + lastSyncConnection = millis(); + + control_communication_SyncResponse response = control_communication_SyncResponse_init_zero; + pb_istream_t istream = pb_istream_from_buffer(httpResponse.payload.data(), httpResponse.payload.size()); + + if (!pb_decode(&istream, control_communication_SyncResponse_fields, &response)) { + LOG_ERROR("RPC: Failed to decode sync response."); + return false; + } + + // As per your design, only update lastSyncTime if the server reports pending changes. + if (response.pendingChanges) { + this->lastSyncTime = response.currentTime; + saveLastSyncTime(this->lastSyncTime); + } + + // Pass pendingChanges flag back to the caller. + if (pendingChanges) { + *pendingChanges = response.pendingChanges; + } + + if(newTimeCallback_){ + newTimeCallback_(response.currentTime); + } + return true; +} + +bool RPC::downloadDatabaseToFile(const char* filePath) { + String url = String(baseUrl_) + "device-communication/db"; + + if (WiFi.status() != WL_CONNECTED) { + return false; + } + + HTTPClient http; + http.begin(url); + http.setTimeout(10000); + if (apiKey_[0] != '\0') { + http.addHeader("x-device-key", apiKey_); + } + + int httpCode = http.GET(); + if (httpCode != HTTP_CODE_OK) { + LOG_ERROR(("RPC: DB download failed with HTTP code " + String(httpCode)).c_str()); + http.end(); + return false; + } + + lastSyncConnection = millis(); + + int len = http.getSize(); + if (len <= 0) { // Removed the multiple of 4 check, as it might be too restrictive. + LOG_ERROR(("RPC: Invalid database size: " + String(len) + " bytes.").c_str()); + http.end(); + return false; + } + + LOG_INFO(("RPC: Downloading database (" + String(len) + " bytes) to file '" + String(filePath) + "'...").c_str()); + + File dbFile = LittleFS.open(filePath, "w"); + if (!dbFile) { + LOG_ERROR(("RPC: Failed to open " + String(filePath) + " for writing.").c_str()); + http.end(); + return false; + } + + // Write the file content by streaming it from the WiFi client. + WiFiClient* stream = http.getStreamPtr(); + const size_t bufferSize = 1024; + uint8_t buffer[bufferSize]; + size_t written = 0; + + // Read from the stream in chunks until all bytes are received + while (http.connected() && (written < (size_t)len)) { + // Get available data size + size_t available = stream->available(); + if (available) { + // Read up to bufferSize bytes + int bytesRead = stream->read(buffer, std::min(bufferSize, available)); + // Write the chunk to the file + dbFile.write(buffer, bytesRead); + written += bytesRead; + } + } + + dbFile.close(); + http.end(); + + if (written != (size_t)len) { + LOG_ERROR("RPC: File download incomplete. Wrote %d of %d bytes.", (unsigned long)written, len); + LittleFS.remove(filePath); // Delete partial file + return false; + } + + LOG_INFO("RPC: Database downloaded successfully."); + return true; +} + +// ---- Persistence Methods ---- + +void RPC::saveLastSyncTime(time_t timestamp) { + File file = LittleFS.open(LAST_SYNC_TIME_FILE, "w"); + if (!file) { + LOG_ERROR("RPC: Failed to open last_sync.time for writing."); + return; + } + file.write(reinterpret_cast(×tamp), sizeof(timestamp)); + file.close(); +} + +time_t RPC::loadLastSyncTime() { + if (!LittleFS.exists(LAST_SYNC_TIME_FILE)) { + return 0; + } + File file = LittleFS.open(LAST_SYNC_TIME_FILE, "r"); + if (!file || file.size() != sizeof(time_t)) { + LOG_ERROR("RPC: Corrupt or unreadable timestamp file. Resetting to 0."); + if(file) file.close(); + LittleFS.remove(LAST_SYNC_TIME_FILE); + return 0; + } + + time_t timestamp = 0; + file.read(reinterpret_cast(×tamp), sizeof(timestamp)); + file.close(); + return timestamp; +} + +// ---- FreeRTOS Task ---- + +void RPC::autoSyncTask(void* pvParameters) { + RPC* rpc = static_cast(pvParameters); + + while (rpc->autoSyncRunning) { + // Wait for either the timer to expire OR the trigger semaphore to be given. + if (xSemaphoreTake(rpc->syncTriggerSemaphore_, pdMS_TO_TICKS(rpc->syncIntervalMs)) == pdTRUE) { + LOG_INFO("RPC: Sync triggered by log queue."); + } else { + LOG_INFO("RPC: Sync triggered by timer."); + } + + if (!rpc->autoSyncRunning) { + break; // Exit immediately if stop was called while waiting + } + + rpc->sync(); // Call sync without forcing a download + } + + // Safer handshake to signal graceful shutdown + rpc->autoSyncTaskHandle = nullptr; + + vTaskDelete(NULL); // Task deletes itself +} + +RPC rpc; \ No newline at end of file diff --git a/lib/rpc/rpc.hpp b/lib/rpc/rpc.hpp new file mode 100644 index 0000000..c25bbac --- /dev/null +++ b/lib/rpc/rpc.hpp @@ -0,0 +1,155 @@ +#pragma once + +#include +#include +#include +#include +#include // Required for semaphores +#include + + +/** + * @struct RpcAccessLogEntry + * @brief Holds a single access log record to be queued for synchronization. + */ +struct RpcAccessLogEntry { + time_t timestamp; + uint32_t rfidId; +}; + +/** + * @struct HttpResponse + * @brief Encapsulates the result of an HTTP request. + */ +struct HttpResponse { + int httpCode = 0; + std::vector payload; +}; + +/** + * @class RPC + * @brief Manages remote procedure calls (RPC) for device synchronization. + * * This class handles queuing access logs, synchronizing them with a server, + * and downloading updated data. It uses a background FreeRTOS task for + * automatic, periodic synchronization. + */ +class RPC { +public: + RPC(); + ~RPC(); + + /** + * @brief Performs a full, one-time synchronization cycle. + * @param forceDownload If true, the database will be downloaded even if the + * server reports no pending changes. + * @return true on success, false on failure. + */ + bool sync(bool forceDownload = false); + + /** + * @brief Adds an access log entry to the queue for the next sync cycle. + * If the queue reaches a threshold, a sync may be triggered. + * @param rfidId The RFID ID that was accessed. + */ + void addAccessLog(uint32_t rfidId); + + /** + * @brief Starts or restarts the automatic background synchronization task. + * @param intervalMs The sync interval in milliseconds. + */ + void startAutoSync(uint32_t intervalMs); + + /** + * @brief Stops the automatic synchronization task gracefully. + */ + void stopAutoSync(); + + /** + * @brief Sets the API key and base URL for server communication. + */ + void setApiKeyAndBaseUrl(const char* apiKey, const char* baseUrl); + + /** + * @brief Gets the timestamp (from millis()) of the last successful communication. + * @return The last connection timestamp, or 0 if never connected. + */ + time_t getLastConnectTime() const { return lastSyncConnection; } + + /** + * @brief Checks if a sync operation is currently in progress. + * @return true if syncing, false otherwise. + */ + bool isSyncing() const { return isSyncing_; } + + /** + * @brief Sets a callback function to get the current time in seconds since epoch. + * This is used to set the lastSync time during sync operations. + * @param callback A function that returns the current time as time_t. + */ + void setNewTimeCallback(std::function callback) { + newTimeCallback_ = callback; + } + +private: + // --- Member Variables --- + + // Configuration + char apiKey_[65]; // 64 chars + 1 null-terminator + char baseUrl_[257]; // 256 chars + 1 null-terminator + + // State + time_t lastSyncTime = 0; + time_t lastSyncConnection = 0; + bool isSyncing_ = false; + + // FreeRTOS components + QueueHandle_t accessLogQueue = nullptr; + TaskHandle_t autoSyncTaskHandle = nullptr; + SemaphoreHandle_t syncTriggerSemaphore_ = nullptr; + uint32_t syncIntervalMs = 300000; // Default: 5 minutes + bool autoSyncRunning = false; + + // Constants + static constexpr uint8_t LOG_SYNC_THRESHOLD = 20; + + // --- Private Helper Methods --- + + /** + * @brief Sends an HTTP request and returns the full response. + * This method is self-contained and manages the HTTPClient lifecycle. + */ + HttpResponse sendRequest(const String& url, const String& method = "GET", + const uint8_t* payload = nullptr, size_t payloadLen = 0, + const String& contentType = ""); + + /** + * @brief Streams an HTTP GET response directly to a file on LittleFS. + * This is memory-efficient for large files like a database. + */ + bool downloadDatabaseToFile(const char* filePath); + + /** + * @brief Sends queued logs to the server and checks if updates are pending. + * This is the first step in the sync process. + */ + bool syncLogsAndCheckForUpdates(bool* pendingChanges); + + /** + * @brief Saves the last successful database download timestamp to flash. + */ + void saveLastSyncTime(time_t timestamp); + + /** + * @brief Loads the last successful database download timestamp from flash. + */ + time_t loadLastSyncTime(); + + /** + * @brief The static function that runs as the FreeRTOS background task. + */ + static void autoSyncTask(void* pvParameters); + + std::function newTimeCallback_; +}; + +extern RPC rpc; \ No newline at end of file diff --git a/lib/tft/tft.cpp b/lib/tft/tft.cpp new file mode 100644 index 0000000..94117e3 --- /dev/null +++ b/lib/tft/tft.cpp @@ -0,0 +1,3 @@ +#include "tft.hpp" + +TFT tft; \ No newline at end of file diff --git a/lib/tft/tft.hpp b/lib/tft/tft.hpp new file mode 100644 index 0000000..f4d5a7a --- /dev/null +++ b/lib/tft/tft.hpp @@ -0,0 +1,48 @@ +#include +#include // Wichtig, um von der Print-Klasse erben zu können + +#define TFT_CS 5 +#define TFT_RST 4 +#define TFT_DC 19 +#define TFT_MOSI 23 +#define TFT_SCLK 18 + +class TFT : public Print { +public: + void setup() { + // Initialize the TFT display + // Setup SPI and display parameters + tft.initR(INITR_144GREENTAB); // Initialize a ST7735S chip, green tab + tft.fillScreen(ST77XX_BLACK); + tft.setRotation(1); // Landscape orientation + tft.setTextColor(ST77XX_WHITE); + tft.setTextSize(1); + //tft.setScrollDefinition(0, tft.height(), 0); + tft.setCursor(0, 0); + } + + + virtual size_t write(uint8_t c) override { + return tft.write(c); + } + + Adafruit_ST7735& getGfx() { + return tft; + } + + void checkCursor() { + if (tft.getCursorY() >= tft.height()) { + tft.setCursor(0, 0); + tft.fillScreen(ST77XX_BLACK); + } + } + + void resetCursor() { + tft.fillScreen(ST77XX_BLACK); + tft.setCursor(0, 0); + } +private: + Adafruit_ST7735 tft = Adafruit_ST7735(TFT_CS, TFT_DC, TFT_MOSI, TFT_SCLK, TFT_RST); +}; + +extern TFT tft; // Deine globale Instanz \ No newline at end of file diff --git a/partitions_custom.csv b/partitions_custom.csv new file mode 100644 index 0000000..9e7bafe --- /dev/null +++ b/partitions_custom.csv @@ -0,0 +1,7 @@ +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x5000, +otadata, data, ota, 0xe000, 0x2000, +ota_0, app, ota_0, 0x10000, 0x160000, +ota_1, app, ota_1, 0x170000, 0x160000, +spiffs, data, spiffs, 0x2D0000, 0x120000, +coredump, data, coredump, 0x3F0000, 0x10000, \ No newline at end of file diff --git a/partitions_custom_ota.csv b/partitions_custom_ota.csv new file mode 100644 index 0000000..b83ac14 --- /dev/null +++ b/partitions_custom_ota.csv @@ -0,0 +1,6 @@ +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x5000, +otadata, data, ota, 0xe000, 0x2000, +ota_0, app, ota_0, 0x10000, 0x270000, +spiffs, data, spiffs, 0x280000, 0x170000, +coredump, data, coredump,0x3F0000, 0x10000, diff --git a/partitions_prod.csv b/partitions_prod.csv new file mode 100644 index 0000000..9e956fa --- /dev/null +++ b/partitions_prod.csv @@ -0,0 +1,6 @@ +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x5000, +otadata, data, ota, 0xe000, 0x2000, +ota_0, app, ota_0, 0x10000, 0x160000, +ota_1, app, ota_1, 0x170000, 0x160000, +spiffs, data, spiffs, 0x2D0000, 0x130000, \ No newline at end of file diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..0555768 --- /dev/null +++ b/platformio.ini @@ -0,0 +1,94 @@ +; PlatformIO Project Configuration File +; +; Build options: build flags, source filter +; Upload options: custom upload port, speed and extra flags +; Library options: dependencies, extra library storages +; Advanced options: extra scripting +; +; Please visit documentation for the other options and examples +; https://docs.platformio.org/page/projectconf.html + +[platformio] +default_envs = dev + +[env] +platform = espressif32 +board = az-delivery-devkit-v4 +framework = arduino +board_build.tasks_stack_size = 8192 +lib_deps = + adafruit/Adafruit ST7735 and ST7789 Library@^1.11.0 + https://github.com/avrilogic/nanopb.git#fix/esp32-pio-include-path + ESP32Async/AsyncTCP@^3.4.8 + ESP32Async/ESPAsyncWebServer@^3.8.1 + rweather/Crypto@^0.4.0 + adafruit/Adafruit NeoPixel@^1.15.1 + ArduinoOTA +monitor_speed = 115200 +monitor_filters = esp32_exception_decoder +board_build.filesystem = littlefs +#lib_extra_dirs = .pio/libdeps/dev/Nanopb +build_unflags = -std=gnu++11 -fno-rtti +build_flags = + -std=gnu++17 + -ffunction-sections + -fdata-sections + -Wl,--gc-sections + -D CONFIG_BT_ENABLED=n + -D CONFIG_BLUEDROID_ENABLED=n +custom_nanopb_protos = + + + + + + + + + + + custom_nanopb_options = + --error-on-unmatched + +[env:dev] +build_type = debug +board_build.partitions = partitions_custom.csv +build_flags = + ${env.build_flags} + -Os + -D DEBUG_MODE + -D CORE_DEBUG_LEVEL=4 + -D LOG_LOCAL_LEVEL=4 + -fstack-protector-strong + + -D CONFIG_ESP32_ENABLE_COREDUMP_TO_FLASH=y + -D CONFIG_ESP32_COREDUMP_DATA_FORMAT_ELF=y + -D CONFIG_FREERTOS_WATCHPOINT_END_OF_STACK=y + + -D CONFIG_ESP_TASK_WDT=y + -D CONFIG_ESP_TASK_WDT_TIMEOUT_S=15 + -D CONFIG_ESP_TASK_WDT_PANIC=y + + +[env:prod] +build_type = release +board_build.partitions = partitions_prod.csv +build_flags = + ${env.build_flags} + + ; --- CORE OPTIMIZATIONS --- + -Os ; <-- 1. Optimize for SIZE, not speed + -fno-exceptions ; <-- 2. Disable C++ exceptions + + ; --- DISABLE BLUETOOTH --- + -D CONFIG_BT_ENABLED=n ; <-- 2. Disable Bluetooth stack + -D CONFIG_BLUEDROID_ENABLED=n ; <-- 2. Disable classic Bluetooth + + ; --- STRIP DEBUG SYMBOLS (already correct) --- + -D NDEBUG + -D CORE_DEBUG_LEVEL=0 + -D LOG_LOCAL_LEVEL=0 + + ; --- Security vs. Size Trade-off (optional) --- + -fstack-protector ; Keep this for security. Removing it saves a tiny amount of space. + + ; --- OTHER FLAGS (already correct) --- + -D CONFIG_ESP32_ENABLE_COREDUMP=n + -D CONFIG_ESP_TASK_WDT=y + -D CONFIG_ESP_TASK_WDT_TIMEOUT_S=60 + -D CONFIG_ESP_TASK_WDT_PANIC=n \ No newline at end of file diff --git a/proto/control_communication.options b/proto/control_communication.options new file mode 100644 index 0000000..9daa492 --- /dev/null +++ b/proto/control_communication.options @@ -0,0 +1,9 @@ +# Nanopb options for control_communication.proto +# Adjust these limits as needed for memory/performance. + +# RfidId +control_communication.RfidId.value max_length:8 + +# DeviceCommunicationSyncRequest +# Limit number of log entries and their key/value sizes. +control_communication.SyncRequest.logs max_count:50 \ No newline at end of file diff --git a/proto/control_communication.proto b/proto/control_communication.proto new file mode 100644 index 0000000..7bd4c0c --- /dev/null +++ b/proto/control_communication.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; +package control_communication; + +message RfidId { + uint32 value = 1; +} + +message SyncResponse { + int64 currentTime = 1; + bool pendingChanges = 2; +} + +message SyncRequest { + optional int64 lastSync = 1; + map accessLogs = 2; +} \ No newline at end of file diff --git a/proto/device.options b/proto/device.options new file mode 100644 index 0000000..4e293e8 --- /dev/null +++ b/proto/device.options @@ -0,0 +1,15 @@ +# Nanopb options for device.proto + +# DeviceInfo +device.DeviceInfo.device_id max_length:18 +device.DeviceInfo.firmware_version max_length:8 +device.DeviceInfo.hardware_version max_length:8 +device.DeviceInfo.sta_ip max_length:16 +device.DeviceInfo.sta_gateway max_length:16 +device.DeviceInfo.ap_ip max_length:16 + +# DeviceLogEntry +device.DeviceLogEntry.message max_length:128 + +# DeviceStatus +device.DeviceStatus.logs max_length:20 \ No newline at end of file diff --git a/proto/device.proto b/proto/device.proto new file mode 100644 index 0000000..e0eb209 --- /dev/null +++ b/proto/device.proto @@ -0,0 +1,46 @@ +syntax = "proto3"; + +package device; + +// WiFi connection state enumeration +enum WifiConnectionState { + WIFI_DISCONNECTED = 0; + WIFI_CONNECTED = 1; + WIFI_CONNECTING = 2; + WIFI_FAILED = 3; +} + +// Device information +message DeviceInfo { + string device_id = 1; + string firmware_version = 2; + string hardware_version = 3; + uint32 uptime_seconds = 4; + // STA mode info + WifiConnectionState sta_connection_state = 5; + string sta_ip = 6; + string sta_gateway = 7; + int32 sta_signal_strength = 8; // Signal strength in dBm + // AP mode info + WifiConnectionState ap_connection_state = 9; + string ap_ip = 10; + uint32 ap_client_count = 11; +} + +enum DeviceLogLevel { + LOG_LEVEL_DEBUG = 0; + LOG_LEVEL_INFO = 1; + LOG_LEVEL_WARN = 2; + LOG_LEVEL_ERROR = 3; +} + +message DeviceLogEntry { + int64 timestamp = 1; // Unix timestamp in milliseconds + DeviceLogLevel level = 2; + string message = 3; +} + +message DeviceStatus { + DeviceInfo info = 1; + repeated DeviceLogEntry logs = 2; +} \ No newline at end of file diff --git a/proto/hardware.options b/proto/hardware.options new file mode 100644 index 0000000..ce35ac4 --- /dev/null +++ b/proto/hardware.options @@ -0,0 +1,12 @@ +# Nanopb options for hardware.proto + +# LED configuration +hardware.FadeParams.colors max_count:5 + +hardware.SensorOTAEnable.ssid max_length:32 +hardware.SensorOTAEnable.password max_length:64 +hardware.SensorOTAEnable.static_ip max_length:16 +hardware.SensorOTAEnable.netmask max_length:16 +hardware.SensorOTAEnable.gateway max_length:16 +hardware.SensorOTAEnableResponse.ip_address max_length:16 +hardware.SensorOTAEnableResponse.error_message max_length:64 \ No newline at end of file diff --git a/proto/hardware.proto b/proto/hardware.proto new file mode 100644 index 0000000..7c9854a --- /dev/null +++ b/proto/hardware.proto @@ -0,0 +1,104 @@ +syntax = "proto3"; + +package hardware; + +// LED animation types +enum LedAnimation { + LED_ANIMATION_STATIC = 0; + LED_ANIMATION_PULSE = 1; + LED_ANIMATION_FADE = 2; + LED_ANIMATION_FLICKER = 3; +} + +// Hardware configuration +message HardwareConfig { + uint32 hold_duration_ms = 1; + bool override = 2; + uint32 relay_pin = 3; + uint32 sensor_rx_pin = 4; + uint32 sensor_tx_pin = 5; + LedConfig on_open_led = 6; + LedConfig default_led = 7; + LedConfig on_invalid_led = 8; + bool enable_serial_sensor = 9; + // repeated char sensor_api_key = 10; + // bool enable_ws_sensor = 11; + LedConfig on_override_open_led = 12; + LedConfig on_interception_led = 13; +} + +// LED configuration +message LedConfig { + // General properties that apply to all animations + uint32 brightness = 1; // 0-255 + uint32 duration_ms = 2; // 0 for indefinite + + oneof animation_params { + StaticParams static_params = 3; + PulseParams pulse_params = 4; + FadeParams fade_params = 5; + FlickerParams flicker_params = 6; + } +} + +// Define the specific parameters for each animation type +message StaticParams { + uint32 color = 1; +} +message PulseParams { + uint32 color = 1; + uint32 speed_ms = 2; +} +message FadeParams { + repeated uint32 colors = 1; // Fade between these colors + uint32 speed_ms = 2; +} +message FlickerParams { + uint32 color = 1; + uint32 intensity = 2; // e.g., 0-100 +} + +message RfidReading { + uint32 card_id = 1; +} + +message SensorToControlMessage { + uint32 sensor_id = 1; + oneof payload { + RfidReading rfid_reading = 2; + SensorOTAEnableResponse ota_response = 3; + // Add other sensor message types as needed + } +} + +message SensorOTAEnable { + string ssid = 1; + string password = 2; + uint32 timeout_seconds = 3; + bool as_station_mode = 4; + bool use_static_ip = 5; + string static_ip = 6; + string netmask = 7; + string gateway = 8; +} + +message SensorRestart { +} + + +message SensorOTAEnableResponse { + bool success = 1; + string ip_address = 2; + string error_message = 3; +} + + +message ControlToSensorMessage { + uint32 control_id = 1; + oneof payload { + LedConfig led_config = 2; + SensorOTAEnable ota_enable = 3; + SensorRestart restart = 4; + // Add other control message types as needed + } +} \ No newline at end of file diff --git a/proto/settings.options b/proto/settings.options new file mode 100644 index 0000000..070df47 --- /dev/null +++ b/proto/settings.options @@ -0,0 +1,12 @@ +# Nanopb options for settings.proto + +# SettingsData +settings.SettingsData.sync_server_url max_length:256 +settings.SettingsData.device_api_key max_length:64 +settings.SettingsData.station_ssid max_length:32 +settings.SettingsData.station_password max_length:64 +settings.SettingsData.ap_ssid max_length:32 +settings.SettingsData.ap_password max_length:64 + +# UpdateSettingsResponse +settings.UpdateSettingsResponse.error max_length:96 \ No newline at end of file diff --git a/proto/settings.proto b/proto/settings.proto new file mode 100644 index 0000000..564c602 --- /dev/null +++ b/proto/settings.proto @@ -0,0 +1,57 @@ +syntax = "proto3"; + +package settings; + +// WiFi mode enumeration +enum WifiMode { + WIFI_MODE_UNSPECIFIED = 0; + WIFI_MODE_STATION = 1; + WIFI_MODE_AP = 2; + WIFI_MODE_AP_STATION = 3; +} + +// Log level enumeration +enum LogLevel { + LOG_LEVEL_DEBUG = 0; + LOG_LEVEL_INFO = 1; + LOG_LEVEL_WARN = 2; + LOG_LEVEL_ERROR = 3; +} + +// Settings data structure +message SettingsData { + string sync_server_url = 1; + string device_api_key = 2; + uint32 sync_interval_seconds = 3; + bool auto_sync = 4; + WifiMode wifi_mode = 5; + string station_ssid = 6; + string station_password = 7; + string ap_ssid = 8; + string ap_password = 9; + uint32 ap_channel = 10; + bool enable_fallback_ap = 11; + int64 updated_at = 12; // Unix timestamp in milliseconds + uint32 version = 13; // Settings version for change tracking + LogLevel log_level = 14; +} + +// Request to get current settings +message GetSettingsRequest { +} + +// Response with current settings +message GetSettingsResponse { + SettingsData settings = 1; +} + +// Request to update settings +message UpdateSettingsRequest { + SettingsData settings = 1; +} + +// Response to settings update +message UpdateSettingsResponse { + bool success = 1; + optional string error = 2; +} \ No newline at end of file diff --git a/proto/webui.options b/proto/webui.options new file mode 100644 index 0000000..859026d --- /dev/null +++ b/proto/webui.options @@ -0,0 +1,36 @@ +# Nanopb options for webui.proto + +# Use callback-based strings for dynamic memory allocation +# No max_length specified to enable callbacks +webui.WebUiLoginRequest.username max_length:32 +webui.WebUiLoginRequest.password max_length:64 + +# WebUiLoginResponse +webui.WebUiLoginResponse.token max_length:128 +webui.WebUiLoginResponse.error max_length:96 + +# WebUiUser +webui.WebUiUser.username max_length:32 +webui.WebUiUser.password_hash max_length:32 + +# GetUsersResponse +webui.GetUsersResponse.usernames max_length:32 + +# AddUserRequest +webui.AddUserRequest.username max_length:32 +webui.AddUserRequest.password max_length:64 + +# DeleteUserRequest +webui.DeleteUserRequest.username max_length:32 + +# UpdateUserPasswordRequest +webui.UpdateUserPasswordRequest.username max_length:32 +webui.UpdateUserPasswordRequest.new_password max_length:64 + +# Response errors +webui.AddUserResponse.error max_length:96 +webui.DeleteUserResponse.error max_length:96 +webui.UpdateUserPasswordResponse.error max_length:96 + +# WebUiAuthCheckResponse +webui.WebUiAuthCheckResponse.username max_length:32 \ No newline at end of file diff --git a/proto/webui.proto b/proto/webui.proto new file mode 100644 index 0000000..e7ec45b --- /dev/null +++ b/proto/webui.proto @@ -0,0 +1,83 @@ +syntax = "proto3"; + +package webui; + +// Login request from Web UI +message WebUiLoginRequest { + string username = 1; // 1-32 chars + string password = 2; // 1-64 chars (plain or hashed) +} + +// Login response +message WebUiLoginResponse { + bool success = 1; + optional string token = 2; // session/JWT when success + optional string error = 3; // error message when failed +} + +// Logout response +message WebUiLogoutResponse { + bool success = 1; + string error = 2; +} + +// User data structure +message WebUiUser { + string username = 1; // 1-32 chars + bytes password_hash = 2; // 32 bytes (SHA-256) +} + +// User list (max 5 users) +message WebUiUserList { + repeated WebUiUser users = 1; +} + +// Request to get users +message GetUsersRequest { +} + +// Response with users +message GetUsersResponse { + repeated string usernames = 1; // List of usernames +} + +// Request to add user +message AddUserRequest { + string username = 1; + string password = 2; +} + +// Response to add user +message AddUserResponse { + bool success = 1; + optional string error = 2; // e.g., "table full", "user exists" +} + +// Request to delete user +message DeleteUserRequest { + string username = 1; +} + +// Response to delete user +message DeleteUserResponse { + bool success = 1; + optional string error = 2; // e.g., "user not found" +} + +// Request to update user password +message UpdateUserPasswordRequest { + string username = 1; + string new_password = 2; +} + +// Response to update user password +message UpdateUserPasswordResponse { + bool success = 1; + optional string error = 2; // e.g., "user not found" +} + +// Auth check response +message WebUiAuthCheckResponse { + bool authenticated = 1; + optional string username = 2; +} \ No newline at end of file diff --git a/src/device/device_manager.cpp b/src/device/device_manager.cpp new file mode 100644 index 0000000..bec916d --- /dev/null +++ b/src/device/device_manager.cpp @@ -0,0 +1,255 @@ +#include "device_manager.hpp" +#include "rfid.hpp" // For rfidDB +#include +#include + +DeviceManager::DeviceManager() { + // Constructor +} + +DeviceManager::~DeviceManager() { + if (taskHandle != nullptr) { + vTaskDelete(taskHandle); + taskHandle = nullptr; + } + if (mutex != nullptr) { + vSemaphoreDelete(mutex); + mutex = nullptr; + } +} + +void DeviceManager::begin() { + if (taskHandle == nullptr) { + scans = 0; // Reset scan count on start + // Initialize relay pin + setRelayPin(relayPin); + // Create mutex + mutex = xSemaphoreCreateMutex(); + // Create the FreeRTOS task + xTaskCreate( + taskFunction, // Task function + "DeviceManagerTask", // Name + 4096, // Stack size + this, // Parameter + 1, // Priority + &taskHandle // Task handle + ); + } +} + +void DeviceManager::end() { + if (taskHandle != nullptr) { + vTaskDelete(taskHandle); + taskHandle = nullptr; + } + // Reset state + xSemaphoreTake(mutex, portMAX_DELAY); + isOpen = false; + isOverridden = false; + openTillTime = 0; + xSemaphoreGive(mutex); +} + +void DeviceManager::setOpenDuration(uint32_t ms) { + openDurationMs = ms; +} + +void DeviceManager::processRfid(uint32_t cardId) { + if(isOverridden) { + LOG_DEBUG("DeviceManager: RFID card scanned while overridden, ignoring: %08X", cardId); + return; // Ignore scans while overridden + } + scans++; // Increment scan count + if (rfidDB.contains(cardId)) { + LOG_DEBUG("DeviceManager: Valid RFID card scanned: %08X", cardId); + // Valid card + xSemaphoreTake(mutex, portMAX_DELAY); + if (!isOverridden) { + isOpen = true; + openTillTime = millis() + openDurationMs; + if (onDoorOpenCb) { + onDoorOpenCb(); + } + } + setLEDState(LEDState::ON_OPEN); + xSemaphoreGive(mutex); + // If overridden, do nothing or perhaps log + } else { + LOG_DEBUG("DeviceManager: Invalid RFID card scanned: %08X", cardId); + setLEDState(LEDState::ON_INVALID); + // Invalid card + if (onInvalidCardCb) { + onInvalidCardCb(); + } + } +} + +void DeviceManager::overrideOpen(bool open) { + LOG_DEBUG("DeviceManager::overrideOpen called with open=%d, current isOverridden=%d, isOpen=%d", open, isOverridden, isOpen); + xSemaphoreTake(mutex, portMAX_DELAY); + if (open) { + if (!isOverridden) { + LOG_DEBUG("DeviceManager: Enabling override mode"); + setLEDState(LEDState::ON_OVERRIDE_OPEN); + openTillTime = 0; // Override keeps it open indefinitely + isOverridden = true; + isOpen = true; + if (onDoorOpenCb) { + onDoorOpenCb(); + } + } else { + LOG_DEBUG("DeviceManager: Override already enabled"); + } + } else { + if (isOverridden) { + LOG_DEBUG("DeviceManager: Disabling override mode"); + setLEDState(LEDState::ON_DEFAULT); + isOpen = false; + isOverridden = false; + if (onDoorCloseCb) { + onDoorCloseCb(); + } + } else { + LOG_DEBUG("DeviceManager: Override already disabled"); + } + } + LOG_DEBUG("DeviceManager::overrideOpen finished, isOverridden=%d, isOpen=%d", isOverridden, isOpen); + xSemaphoreGive(mutex); +} + +DeviceManager::State DeviceManager::getState() const { + xSemaphoreTake(mutex, portMAX_DELAY); + State state = {isOpen, isOverridden}; + xSemaphoreGive(mutex); + return state; +} + +uint32_t DeviceManager::getScanCount() const { + return scans; +} + +void DeviceManager::onDoorOpen(std::function callback) { + onDoorOpenCb = callback; +} + +void DeviceManager::onDoorClose(std::function callback) { + onDoorCloseCb = callback; +} + +void DeviceManager::onInvalidCard(std::function callback) { + onInvalidCardCb = callback; +} + +void DeviceManager::onLEDStateChange(std::function callback) +{ + onLEDStateChangeCb = callback; +} + +void DeviceManager::setRelayPin(int pin) { + if(pin == relayPin && relayInitialized) { + return; // No change + } + relayPin = pin; + pinMode(relayPin, OUTPUT); + digitalWrite(relayPin, isOpen ? HIGH : LOW); + relayInitialized = true; +} + +void DeviceManager::setRelay(bool state) { + if (relayInitialized) { + digitalWrite(relayPin, state ? HIGH : LOW); + } +} + +void DeviceManager::setLEDState(LEDState state) { + if(ledState == state) { + return; // No change + } + resetToDefaultLEDStateTimer = 0; + + ledState = state; + ledStateChanged = true; +} + +void DeviceManager::applyLEDState() { + bool apply =false; + unsigned long now = millis(); + if(ledStateChanged) { + apply = true; + ledStateChanged = false; + }else if(resetToDefaultLEDStateTimer > 0 && now >= resetToDefaultLEDStateTimer) { + apply = true; + resetToDefaultLEDStateTimer = 0; + ledState = LEDState::ON_DEFAULT; + } + if (apply && onLEDStateChangeCb) { + hardware_HardwareConfig currentConfig = hardwareConfigRepo.getData(); + hardware_ControlToSensorMessage message = hardware_ControlToSensorMessage_init_zero; + message.which_payload = hardware_ControlToSensorMessage_led_config_tag; + switch (ledState) + { + case LEDState::ON_DEFAULT: + // Set to default LED config + message.payload.led_config = currentConfig.default_led; + break; + case LEDState::ON_OPEN: + // Set to open LED config + resetToDefaultLEDStateTimer = millis() + openDurationMs; + message.payload.led_config = currentConfig.on_open_led; + break; + case LEDState::ON_OVERRIDE_OPEN: + // Set to override open LED config + message.payload.led_config = currentConfig.on_override_open_led; + break; + case LEDState::ON_INVALID: + // Set to invalid LED config + resetToDefaultLEDStateTimer = millis() + 2000; // 3 seconds + message.payload.led_config = currentConfig.on_invalid_led; + break; + case LEDState::ON_INTERCEPT: + // Set to intercept LED config + message.payload.led_config = currentConfig.on_interception_led; + break; + default: + break; + } + onLEDStateChangeCb(message); + } +} + + +void DeviceManager::taskFunction(void* param) { + DeviceManager* self = static_cast(param); + self->runTask(); +} + +void DeviceManager::runTask() { + while (true) { + xSemaphoreTake(mutex, portMAX_DELAY); + unsigned long now = millis(); + // Check if it's time to close the door + if (!isOverridden && openTillTime > 0 && now >= openTillTime) { + LOG_DEBUG("DeviceManager: Closing door due to timeout, openTillTime=%lu, now=%lu", openTillTime, now); + isOpen = false; + openTillTime = 0; + if (onDoorCloseCb) { + onDoorCloseCb(); + } + } + + // Control relay based on door state + if (isOpen != previousIsOpen) { + LOG_DEBUG("DeviceManager: Setting relay to %d (isOpen=%d, isOverridden=%d)", isOpen, isOpen, isOverridden); + setRelay(isOpen); + previousIsOpen = isOpen; + } + + // Apply LED state changes if needed + applyLEDState(); + + xSemaphoreGive(mutex); + + // Delay for 100ms + vTaskDelay(pdMS_TO_TICKS(100)); + } +} \ No newline at end of file diff --git a/src/device/device_manager.hpp b/src/device/device_manager.hpp new file mode 100644 index 0000000..3ecef16 --- /dev/null +++ b/src/device/device_manager.hpp @@ -0,0 +1,166 @@ +#pragma once + +#define RELAY_PIN 2 + +#include +#include +#include "rfid.hpp" +#include "hardware.pb.h" + +/** + * @enum DoorState + * @brief Internal states for the door locking mechanism. + */ +enum class DoorState { + CLOSED, + OPENING, + OPEN, + CLOSING, + OVERRIDDEN_OPEN +}; + +/** + * @enum LEDState + * @brief States for LED indication. + */ +enum class LEDState { + ON_DEFAULT, + ON_OPEN, + ON_INVALID, + ON_OVERRIDE_OPEN, + ON_INTERCEPT +}; + +/** + * @class DeviceManager + * @brief Manages the door locking mechanism with RFID processing, overrides, and timing. + * + * This class handles the state of a door lock, processes RFID cards, manages open durations, + * and provides callbacks for various events. It runs its own FreeRTOS task for timing. + */ +class DeviceManager { +public: + /** + * @struct State + * @brief Represents the current state of the device. + */ + struct State { + bool currentlyOpen; + bool currentlyOverridden; + }; + + DeviceManager(); + ~DeviceManager(); + + /** + * @brief Initializes the device manager and starts the background task. + */ + void begin(); + + /** + * @brief Stops the device manager and its background task gracefully. + */ + void end(); + + /** + * @brief Sets the duration the door stays open after a successful RFID card. + * @param ms Duration in milliseconds. + */ + void setOpenDuration(uint32_t ms); + + /** + * @brief Processes an RFID card read event. + * @param cardId The RFID card ID read. + */ + void processRfid(uint32_t cardId); + + /** + * @brief Overrides the door state. + * @param open True to override open, false to override close. + */ + void overrideOpen(bool open); + + /** + * @brief Gets the current state of the device. + * @return State struct with currentlyOpen and currentlyOverridden. + */ + State getState() const; + + /** + * @brief Gets the number of RFID scans since the device started. + * @return The scan count. + */ + uint32_t getScanCount() const; + + /** + * @brief Sets the callback for door open event. + */ + void onDoorOpen(std::function callback); + + /** + * @brief Sets the callback for door close event. + */ + void onDoorClose(std::function callback); + + /** + * @brief Sets the callback for invalid card event. + */ + void onInvalidCard(std::function callback); + + /** + * @brief Sets the callback for LED state change event. + * @param callback Function to call with the new LED state message. + */ + void onLEDStateChange(std::function callback); + + /** + * @brief Sets the relay pin number. + * @param pin The GPIO pin number for the relay. + */ + void setRelayPin(int pin); + + /** + * @brief Controls the relay state. + * @param state True to activate relay (HIGH), false to deactivate (LOW). + */ + void setRelay(bool state); + + /** + * @brief Sets the LED state and triggers the LED state change callback. + * @param state The new LEDState to set. + */ + void setLEDState(LEDState state); + +private: + // Internal state + volatile bool isOpen = false; + volatile bool isOverridden = false; + uint32_t openDurationMs = 5000; // Default 5 seconds + uint32_t openTillTime = 0; + uint32_t scans = 0; + + // Relay control + volatile int relayPin = RELAY_PIN; + volatile bool relayInitialized = false; + volatile bool previousIsOpen = false; + + // Mutex for thread safety + SemaphoreHandle_t mutex; + + // Callbacks + std::function onDoorOpenCb = nullptr; + std::function onDoorCloseCb = nullptr; + std::function onInvalidCardCb = nullptr; + std::function onLEDStateChangeCb = nullptr; + + // Task + TaskHandle_t taskHandle = nullptr; + static void taskFunction(void* param); + void runTask(); + + // LED State + LEDState ledState = LEDState::ON_DEFAULT; + uint32_t resetToDefaultLEDStateTimer = 0; + void applyLEDState(); + volatile bool ledStateChanged = false; +}; \ No newline at end of file diff --git a/src/device/device_status.cpp b/src/device/device_status.cpp new file mode 100644 index 0000000..1cfbeb4 --- /dev/null +++ b/src/device/device_status.cpp @@ -0,0 +1,86 @@ +#include "device_status.hpp" +#include +#include +#include + +extern WiFiManager wifiManager; + +// Version constants +const char* FIRMWARE_VERSION = "1.0.0"; +const char* HARDWARE_VERSION = "1.0"; + +DeviceStatus::DeviceStatus() { + status = device_DeviceStatus_init_zero; +} + +DeviceStatus::~DeviceStatus() { + // Clean up if needed +} + +void DeviceStatus::setInfo(const device_DeviceInfo& info) { + status.has_info = true; + status.info = info; +} + +bool DeviceStatus::writeStreamCallback(pb_ostream_t *stream, const uint8_t *buf, size_t count) { + StreamState* state = (StreamState*)stream->state; + return state->stream->write(buf, count) == count; +} + +const device_DeviceStatus& DeviceStatus::getStatus() { + populate(); + return status; +} + +void DeviceStatus::populate() { + // Populate device info from wifi manager + status.has_info = true; + wifiManager.getState(status.info); + + // Set device ID to MAC address + String mac = WiFi.macAddress(); + strncpy(status.info.device_id, mac.c_str(), sizeof(status.info.device_id) - 1); + status.info.device_id[sizeof(status.info.device_id) - 1] = '\0'; + + // Set firmware version + strncpy(status.info.firmware_version, FIRMWARE_VERSION, sizeof(status.info.firmware_version) - 1); + status.info.firmware_version[sizeof(status.info.firmware_version) - 1] = '\0'; + + // Set hardware version + strncpy(status.info.hardware_version, HARDWARE_VERSION, sizeof(status.info.hardware_version) - 1); + status.info.hardware_version[sizeof(status.info.hardware_version) - 1] = '\0'; + + // Set uptime in seconds + status.info.uptime_seconds = millis() / 1000; + + // Set up the logs callback to use the logger's logs + status.logs.funcs.encode = [](pb_ostream_t *pb_stream, const pb_field_t *field, void * const *arg) -> bool { + int count; + const LogEntry* log_entries = logger.getLogs(count); + for (int i = 0; i < count; ++i) { + device_DeviceLogEntry log; + log.timestamp = (int64_t)log_entries[i].timestamp * 1000; // Convert to milliseconds + log.level = (device_DeviceLogLevel)log_entries[i].level; + strncpy(log.message, log_entries[i].message, sizeof(log.message) - 1); + log.message[sizeof(log.message) - 1] = '\0'; + + if (!pb_encode_tag_for_field(pb_stream, field) || + !pb_encode_submessage(pb_stream, device_DeviceLogEntry_fields, &log)) { + return false; + } + } + return true; + }; + status.logs.arg = nullptr; // Not used +} + +bool DeviceStatus::encode(Stream& stream) { + populate(); + + // Create pb_ostream_t from Stream + StreamState state = {&stream}; + pb_ostream_t pb_stream = {&writeStreamCallback, &state, SIZE_MAX, 0}; + + // Encode the message + return pb_encode(&pb_stream, device_DeviceStatus_fields, &status); +} \ No newline at end of file diff --git a/src/device/device_status.hpp b/src/device/device_status.hpp new file mode 100644 index 0000000..bc41bff --- /dev/null +++ b/src/device/device_status.hpp @@ -0,0 +1,31 @@ +#ifndef DEVICE_STATUS_HPP +#define DEVICE_STATUS_HPP + +#include +#include +#include +#include "wifi/wifi_manager.hpp" + +class DeviceStatus { +private: + device_DeviceStatus status; + + // Stream callback + struct StreamState { + Stream* stream; + }; + + static bool writeStreamCallback(pb_ostream_t *stream, const uint8_t *buf, size_t count); + +public: + DeviceStatus(); + ~DeviceStatus(); + + void setInfo(const device_DeviceInfo& info); + + void populate(); + const device_DeviceStatus& getStatus(); + bool encode(Stream& stream); +}; + +#endif // DEVICE_STATUS_HPP \ No newline at end of file diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..bbaaa10 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,147 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include "webui/webui.hpp" +#include +#include +#include +#include "wifi/wifi_manager.hpp" +#include +#include +#include +#include "device/device_manager.hpp" +#include "hardware.pb.h" +#include +#include "ota_manager.hpp" + + +#define RXD2 16 // GPIO16 (RX2) +#define TXD2 17 // GPIO17 (TX2) + +WebUI webui; +WiFiManager wifiManager; +HardwareRTC rtc(Wire); +DeviceManager deviceManager; +OTAManager otaManager; +void setup() { + // **Hardware Initialization** + // Init I2C + Wire.begin(22, 21); // SDA=21, SCL=22 for ESP32 + Wire.setClock(100000); + Serial.begin(115200); + + // **System Initialization** + // Init logger + logger.setLogCallback([](const LogEntry& entry) { + Serial.print("["); + Serial.print(logger.levelToString(entry.level)); + Serial.print("] "); + Serial.println(entry.message); + }); + logger.begin(); + rtc.begin(); + otaManager.begin(); + logger.setLogLevel(LOG_DEBUG); + + tft.setup(); + delay(100); + rpc.setNewTimeCallback([](time_t timestamp) { + rtc.setTime(timestamp); + }); + + tft.println("Starting..."); + // Init filesystem + if (!LittleFS.begin(true)) { + LOG_ERROR("Failed to mount LittleFS"); + return; + } + rfidDB.begin(); + deviceManager.begin(); + + wifiManager.onStaConnected([](IPAddress ip) { + webui.begin(); + }); + wifiManager.onApStarted([](IPAddress ip) { + webui.begin(); + }); + wifiManager.onPreShutdown([]() { + webui.end(); + }); + + webui.setOTAManager(&otaManager); + + settings.setChangeCallback([](const settings_SettingsData& newData) { + wifiManager.begin(newData); + rpc.setApiKeyAndBaseUrl(newData.device_api_key, newData.sync_server_url); + // Set log level + logger.setLogLevel(static_cast(newData.log_level)); + // React to specific changes if needed + if (newData.auto_sync) { + rpc.startAutoSync(newData.sync_interval_seconds * 1000); + } else { + rpc.stopAutoSync(); + } + }); + + // Load settings initially + if (!settings.load()) { + LOG_ERROR("Failed to load settings"); + } else { + LOG_INFO("Settings loaded"); + } + + pserial.setCallback([](const IncomingMessage& msg) { + LOG_DEBUG("Received message via pserial"); + if (msg.which_payload == hardware_SensorToControlMessage_rfid_reading_tag) { + uint32_t cardId = msg.payload.rfid_reading.card_id; + deviceManager.processRfid(cardId); + rpc.addAccessLog(cardId); + } else if (msg.which_payload == hardware_SensorToControlMessage_ota_response_tag) { + if (msg.payload.ota_response.success) { + LOG_INFO("Sensor OTA update enabled successfully"); + LOG_INFO("Sensor IP: %s", msg.payload.ota_response.ip_address); + } else { + LOG_WARN("Sensor OTA update enable failed"); + } + } else { + LOG_WARN("Received unknown message type via serial"); + } + }); + + hardwareConfigRepo.setChangeCallback([](const hardware_HardwareConfig& newData) { + // Apply hardware config changes if needed + if (newData.enable_serial_sensor) { + Serial2.begin(9600, SERIAL_8N1, newData.sensor_rx_pin, newData.sensor_tx_pin); + vTaskDelay(80 / portTICK_PERIOD_MS); // Give some time for Serial2 to initialize + pserial.begin(Serial2); + } else { + Serial2.end(); + } + + deviceManager.setRelayPin(newData.relay_pin); + deviceManager.setOpenDuration(newData.hold_duration_ms); + deviceManager.overrideOpen(newData.override); + }); + hardwareConfigRepo.load(); + + deviceManager.onLEDStateChange([](hardware_ControlToSensorMessage message) { + pserial.sendMessage(message); + }); + + webui.onSendToSensor([](hardware_ControlToSensorMessage message) { + pserial.sendMessage(message); + }); +} + +void loop() { + rtc.update(); + pserial.update(); + wifiManager.update(); + ArduinoOTA.handle(); +} + diff --git a/src/webui/auth.cpp b/src/webui/auth.cpp new file mode 100644 index 0000000..db9198f --- /dev/null +++ b/src/webui/auth.cpp @@ -0,0 +1,49 @@ +#include "auth.hpp" +#include + +Auth::Auth(Users& users, SessionManager& sessionManager) + : users_(users), sessionManager_(sessionManager) {} + +const char* Auth::login(const char* username, const char* password) { + if (users_.checkPassword(username, password)) { + return sessionManager_.createSession(username); + } + return nullptr; +} + +bool Auth::logout(const char* token) { + return sessionManager_.endSession(token); +} + +const char* Auth::checkAuth(const char* token) { + // check if users exist + if (!isAuthRequired()) { + return "no-auth"; + } + return sessionManager_.validateSession(token); +} + +ArMiddlewareCallback Auth::middleware() { + return [this](AsyncWebServerRequest* request, ArMiddlewareNext next) { + if (!isAuthRequired()) { + // No users exist, authentication not required + next(); + return; + } + // Check Authorization header for Bearer token + if (request->hasHeader("Authorization")) { + String authHeader = request->getHeader("Authorization")->value(); + if (authHeader.startsWith("Bearer ")) { + String token = authHeader.substring(7); // Remove "Bearer " + const char* username = checkAuth(token.c_str()); + if (username) { + request->setAttribute("username", username); + next(); + return; + } + } + } + // If no valid auth, respond with 401 Unauthorized + request->send(401, CONTENT_TYPE_TEXT, CONTENT_TYPE_TEXT); + }; +} diff --git a/src/webui/auth.hpp b/src/webui/auth.hpp new file mode 100644 index 0000000..e5c1aed --- /dev/null +++ b/src/webui/auth.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include +#include +#include +#include "webui_strings.hpp" + +class Auth { +public: + Auth(Users& users, SessionManager& sessionManager); + + // Login: returns token on success, nullptr on failure + const char* login(const char* username, const char* password); + + // Logout: ends session for token + bool logout(const char* token); + + // Check auth: returns username if token valid, nullptr otherwise + const char* checkAuth(const char* token); + + bool isAuthRequired() const { + return users_.getUserCount() > 0; + } + + // Middleware function + ArMiddlewareCallback middleware(); + +private: + Users& users_; + SessionManager& sessionManager_; +}; \ No newline at end of file diff --git a/src/webui/webui.cpp b/src/webui/webui.cpp new file mode 100644 index 0000000..545e23c --- /dev/null +++ b/src/webui/webui.cpp @@ -0,0 +1,183 @@ +#include "webui.hpp" +#include +#include +#include + +WebUI::WebUI(uint16_t port) : server(port), auth_(users, sessionManager) {} + +WebUI::~WebUI() { + end(); +} + +void WebUI::begin(bool restartIfRunning) { + + if (isRunning_ && !restartIfRunning) { + LOG_INFO(WEBUI_SERVER_ALREADY_RUNNING); + return; + } + if (!LittleFS.begin(true)) { + LOG_ERROR(LITTLEFS_MOUNT_FAILED); + return; + } + end(); + setupRoutes(); + server.begin(); + isRunning_ = true; + LOG_INFO(WEBUI_SERVER_STARTED); +} + +void WebUI::end() { + if (!isRunning_) return; + server.end(); + isRunning_ = false; + vTaskDelay(pdMS_TO_TICKS(50)); +} + +void WebUI::setupRoutes() { + // API endpoint for login + server.on("/api/login", HTTP_POST, [](AsyncWebServerRequest *request){}, NULL, [this](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) { + handleLogin(request, data, len, index, total); + }); + + // API endpoint for logout + server.on("/api/logout", HTTP_POST, [this](AsyncWebServerRequest *request) { + handleLogout(request); + }); + + // API endpoint for auth check + server.on("/api/authchk", HTTP_GET, [this](AsyncWebServerRequest *request) { + handleAuthChk(request); + }); + + // API endpoints for settings + server.on("/api/settings", HTTP_GET, [this](AsyncWebServerRequest *request) { + handleGetSettings(request); + }).addMiddleware(auth_.middleware()); + + server.on("/api/settings", HTTP_POST, [](AsyncWebServerRequest *request){}, NULL, [this](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) { + handleUpdateSettings(request, data, len, index, total); + }).addMiddleware(auth_.middleware()); + + // API endpoint for status + server.on("/api/status", HTTP_GET, [this](AsyncWebServerRequest *request) { + handleGetStatus(request); + }).addMiddleware(auth_.middleware()); + + // API endpoints for hardware + server.on("/api/hardware", HTTP_GET, [this](AsyncWebServerRequest *request) { + handleGetHardware(request); + }).addMiddleware(auth_.middleware()); + + server.on("/api/hardware", HTTP_POST, [](AsyncWebServerRequest *request){}, NULL, [this](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) { + handleUpdateHardware(request, data, len, index, total); + }).addMiddleware(auth_.middleware()); + + // API endpoints for users + + server.on("/api/users/password", HTTP_POST, [](AsyncWebServerRequest *request){}, NULL, [this](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) { + handleUpdateUserPassword(request, data, len, index, total); + }).addMiddleware(auth_.middleware()); + + server.on("/api/users", HTTP_GET, [this](AsyncWebServerRequest *request) { + handleGetUsers(request); + }).addMiddleware(auth_.middleware()); + server.on("/api/users", HTTP_POST, [](AsyncWebServerRequest *request){}, NULL, [this](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) { + handleAddUser(request, data, len, index, total); + }).addMiddleware(auth_.middleware()); + server.on("/api/users", HTTP_DELETE, [](AsyncWebServerRequest *request){}, NULL, [this](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) { + handleDeleteUser(request, data, len, index, total); + }).addMiddleware(auth_.middleware()); + + // API endpoints for RFID database + server.on("/api/db", HTTP_GET, [this](AsyncWebServerRequest *request) { + handleGetRfidDatabase(request); + }).addMiddleware(auth_.middleware()); + + server.on("/api/db", HTTP_POST, [](AsyncWebServerRequest *request){}, NULL, [this](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) { + handleUpdateRfidDatabase(request, data, len, index, total); + }).addMiddleware(auth_.middleware()); + + // API endpoints for RPC sync + server.on("/api/sync/full", HTTP_POST, [this](AsyncWebServerRequest *request) { + handleFullSync(request); + }).addMiddleware(auth_.middleware()); + server.on("/api/sync", HTTP_POST, [this](AsyncWebServerRequest *request) { + handleSync(request); + }).addMiddleware(auth_.middleware()); + + // API endpoint for OTA enable + server.on("/api/ota", HTTP_POST, [](AsyncWebServerRequest *request){}, NULL, [this](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) { + handleEnableOTA(request, data, len, index, total); + }).addMiddleware(auth_.middleware()); + + // API endpoint for sensor restart + server.on("/api/restart-sensor", HTTP_POST, [this](AsyncWebServerRequest *request) { + handleRestartSensor(request); + }).addMiddleware(auth_.middleware()); + + // API endpoints for ESP OTA + server.on("/api/esp-ota-enable", HTTP_POST, [this](AsyncWebServerRequest *request) { + handleEnableESPOTA(request); + }).addMiddleware(auth_.middleware()); + + server.on("/api/esp-ota-disable", HTTP_POST, [this](AsyncWebServerRequest *request) { + handleDisableESPOTA(request); + }).addMiddleware(auth_.middleware()); + + server.on("/api/esp-ota-status", HTTP_GET, [this](AsyncWebServerRequest *request) { + handleGetESPOTAStatus(request); + }).addMiddleware(auth_.middleware()); + + server.on("/api/esp-ota-upload", HTTP_POST, [](AsyncWebServerRequest *request) { + }, [this](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) { + handleESPOTAUpload(request, filename, index, data, len, final); + }).addMiddleware(auth_.middleware()); + + server.onNotFound([this](AsyncWebServerRequest *request) { + handleRoot(request); + }); +} + +void WebUI::sendProtobufResponse(AsyncWebServerRequest *request, void *proto_msg, const pb_msgdesc_t *fields) { + size_t resp_size = 0; + if (!pb_get_encoded_size(&resp_size, fields, proto_msg)) { + LOG_ERROR("PROTO-API: failed to get encoded size"); + request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)ENCODING_FAILED); + return; + } + + uint8_t *resp_buffer = new uint8_t[resp_size]; + if (!resp_buffer) { + request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)MEMORY_ALLOCATION_FAILED); + return; + } + + pb_ostream_t resp_stream = pb_ostream_from_buffer(resp_buffer, resp_size); + if (!pb_encode(&resp_stream, fields, proto_msg)) { + LOG_ERROR("PROTO-API: response encode failed"); + delete[] resp_buffer; + request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)ENCODING_FAILED); + return; + } + + size_t actual_size = resp_stream.bytes_written; + LOG_DEBUG("PROTO-API: encoded size=%d", actual_size); + + auto send_lambda = [resp_buffer, actual_size](uint8_t *buf, size_t maxLen, size_t index) -> size_t { + size_t copy_len = std::min(maxLen, actual_size - index); + std::memcpy(buf, resp_buffer + index, copy_len); + if (index + copy_len >= actual_size) { + delete[] resp_buffer; + } + return copy_len; + }; + + AsyncWebServerResponse *response = request->beginResponse((__FlashStringHelper*)CONTENT_TYPE_OCTET, actual_size, send_lambda); + request->send(response); +} + +void WebUI::handleRoot(AsyncWebServerRequest *request) { + AsyncWebServerResponse *response = request->beginResponse(LittleFS, "/web/index.html.gz", "text/html", false); + response->addHeader("Content-Encoding", "gzip"); + request->send(response); +} diff --git a/src/webui/webui.hpp b/src/webui/webui.hpp new file mode 100644 index 0000000..6c63d62 --- /dev/null +++ b/src/webui/webui.hpp @@ -0,0 +1,64 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "webui_strings.hpp" +#include "auth.hpp" +#include "ota_manager.hpp" + +class WebUI { +public: + WebUI(uint16_t port = 80); + ~WebUI(); + + void begin(bool restartIfRunning=false); + void end(); + + void onSendToSensor(std::function callback); + void setOTAManager(OTAManager* otaManager); + +private: + AsyncWebServer server; + Auth auth_; + bool isRunning_ = false; + OTAManager* otaManager_ = nullptr; + void setupRoutes(); + void handleLogin(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total); + void handleLogout(AsyncWebServerRequest *request); + void handleAuthChk(AsyncWebServerRequest *request); + void handleGetSettings(AsyncWebServerRequest *request); + void handleUpdateSettings(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total); + void handleGetStatus(AsyncWebServerRequest *request); + void handleGetHardware(AsyncWebServerRequest *request); + void handleUpdateHardware(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total); + void handleGetUsers(AsyncWebServerRequest *request); + void handleAddUser(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total); + void handleDeleteUser(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total); + void handleUpdateUserPassword(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total); + void handleGetRfidDatabase(AsyncWebServerRequest *request); + void handleUpdateRfidDatabase(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total); + void handleSync(AsyncWebServerRequest *request); + void handleFullSync(AsyncWebServerRequest *request); + void handleEnableOTA(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total); + void handleRestartSensor(AsyncWebServerRequest *request); + void handleEnableESPOTA(AsyncWebServerRequest *request); + void handleDisableESPOTA(AsyncWebServerRequest *request); + void handleGetESPOTAStatus(AsyncWebServerRequest *request); + void handleESPOTAUpload(AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final); + void handleRoot(AsyncWebServerRequest *request); + // Protobuf callback functions + static bool decode_string(pb_istream_t *stream, const pb_field_t *field, void **arg); + static bool encode_string(pb_ostream_t *stream, const pb_field_t *field, void * const *arg); + static bool encode_string_array(pb_ostream_t *stream, const pb_field_t *field, void * const *arg); + void sendProtobufResponse(AsyncWebServerRequest *request, void *proto_msg, const pb_msgdesc_t *fields); + + // Callback for sending messages to sensor + std::function sendToSensorCallback = nullptr; +}; \ No newline at end of file diff --git a/src/webui/webui_auth.cpp b/src/webui/webui_auth.cpp new file mode 100644 index 0000000..45ab79c --- /dev/null +++ b/src/webui/webui_auth.cpp @@ -0,0 +1,127 @@ +#include "webui.hpp" +#include +#include +#include +#include // Für sichere Buffer-Handling (optional, fallback zu new) + + + +void WebUI::handleLogin(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) { + static std::vector buffer; // Non-static, aber static für chunked callback (per-request, assume no overlap) + // Alternative ohne STL: static uint8_t* buffer = nullptr; static size_t buffer_size = 0; (wie original, aber mit Warnung) + + if (index == 0) { + if (strcmp_P(request->contentType().c_str(), CONTENT_TYPE_OCTET) != 0) { + request->send(400, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)INVALID_CONTENT_TYPE); + return; + } + + if (total == 0) { + request->send(400, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)EMPTY_REQUEST); + return; + } + + buffer.resize(total); // Oder: buffer = new uint8_t[total]; buffer_size = total; + } + + std::memcpy(buffer.data() + index, data, len); // Oder buffer + index + + if (index + len >= total) { + pb_istream_t stream = pb_istream_from_buffer(buffer.data(), buffer.size()); + webui_WebUiLoginRequest login_req = webui_WebUiLoginRequest_init_zero; + bool decode_ok = pb_decode(&stream, webui_WebUiLoginRequest_fields, &login_req); + + buffer.clear(); // Oder delete[] buffer; buffer = nullptr; + + if (!decode_ok || stream.bytes_left != 0) { + LOG_ERROR("/api/login: protobuf decode failed"); + request->send(400, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)INVALID_PROTOBUF); + return; + } + + LOG_DEBUG("/api/login: decoded username='%s' (password length=%d)", login_req.username, strlen(login_req.password)); // Password maskiert + + const char* token = auth_.login(login_req.username, login_req.password); + bool success = (token != nullptr); + LOG_DEBUG("/api/login: auth result success=%d", success); + + webui_WebUiLoginResponse login_resp = webui_WebUiLoginResponse_init_zero; + login_resp.success = success; + if (success) { + login_resp.has_token = true; + strcpy(login_resp.token, token); + } else { + char error_buf[30]; + strcpy_P(error_buf, INVALID_CREDENTIALS); + login_resp.has_error = true; + strcpy(login_resp.error, error_buf); + } + + sendProtobufResponse(request, &login_resp, webui_WebUiLoginResponse_fields); + } +} + +void WebUI::handleLogout(AsyncWebServerRequest *request) { + const char* auth_value = request->getHeader("Authorization")->value().c_str(); + if (!auth_value || strncmp(auth_value, "Bearer ", 7) != 0) { + request->send(401, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)UNAUTHORIZED); + return; + } + + const char* token = auth_value + 7; // Skip "Bearer " + bool success = auth_.logout(token); + + webui_WebUiLogoutResponse logout_resp = webui_WebUiLogoutResponse_init_zero; // Angenommen, proto definiert + logout_resp.success = success; + + LOG_DEBUG("/api/logout: success=%d", success); + sendProtobufResponse(request, &logout_resp, webui_WebUiLogoutResponse_fields); +} + +void WebUI::handleAuthChk(AsyncWebServerRequest *request) { + // Manuell formatierte Client-IP für Logging + char ip_buf[16]; + snprintf(ip_buf, sizeof(ip_buf), "%d.%d.%d.%d", + request->client()->remoteIP()[0], + request->client()->remoteIP()[1], + request->client()->remoteIP()[2], + request->client()->remoteIP()[3]); + + LOG_DEBUG("/api/authchk: processing request, client=%s", ip_buf); + + // Prepare response + webui_WebUiAuthCheckResponse auth_resp = webui_WebUiAuthCheckResponse_init_zero; + auth_resp.authenticated = !auth_.isAuthRequired(); + + if (auth_.isAuthRequired()) { + // Prüfe Authorization-Header + if (!request->hasHeader("Authorization")) { + LOG_DEBUG("/api/authchk: no Authorization header, client=%s", ip_buf); + } else { + const AsyncWebHeader *auth_header = request->getHeader("Authorization"); + String auth_value = auth_header->value(); + if (!auth_value.startsWith("Bearer ")) { + LOG_DEBUG("/api/authchk: invalid Authorization header, client=%s", ip_buf); + } else { + String token = auth_value.substring(7); // Entferne "Bearer " + LOG_DEBUG("/api/authchk: checking token (masked), client=%s", ip_buf); + const char* username = auth_.checkAuth(token.c_str()); + if (username) { + auth_resp.authenticated = true; + auth_resp.has_username = true; + // Verwende strncpy für Sicherheit (angenommen, username hat max_size in .proto) + strncpy(auth_resp.username, username, sizeof(auth_resp.username) - 1); + auth_resp.username[sizeof(auth_resp.username) - 1] = '\0'; // Null-Terminierung + LOG_DEBUG("/api/authchk: authenticated as '%s', client=%s", auth_resp.username, ip_buf); + } else { + LOG_DEBUG("/api/authchk: invalid token, client=%s", ip_buf); + } + } + } + } else { + LOG_DEBUG("/api/authchk: no auth required, client=%s", ip_buf); + } + + // Entferne & vor _fields (Makro ist bereits Pointer) + sendProtobufResponse(request, &auth_resp, webui_WebUiAuthCheckResponse_fields); +} \ No newline at end of file diff --git a/src/webui/webui_hardware.cpp b/src/webui/webui_hardware.cpp new file mode 100644 index 0000000..31feee9 --- /dev/null +++ b/src/webui/webui_hardware.cpp @@ -0,0 +1,98 @@ +#include "webui.hpp" +#include +#include +#include +#include + +void WebUI::handleGetHardware(AsyncWebServerRequest *request) { + // Get hardware config + hardware_HardwareConfig& config = hardwareConfigRepo.getData(); + + // Encode response + size_t resp_size = 0; + pb_get_encoded_size(&resp_size, hardware_HardwareConfig_fields, &config); + + if (resp_size == 0) { + LOG_ERROR("WebUI: /api/hardware GET: Response size is 0 - protobuf encoding failed!"); + request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)"Response size is 0"); + return; + } + + uint8_t *resp_buffer = new uint8_t[resp_size]; + if (!resp_buffer) { + request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)MEMORY_ALLOCATION_FAILED); + return; + } + + pb_ostream_t resp_stream = pb_ostream_from_buffer(resp_buffer, resp_size); + bool encode_ok = pb_encode(&resp_stream, hardware_HardwareConfig_fields, &config); + + if (!encode_ok) { + delete[] resp_buffer; + request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)ENCODING_FAILED); + return; + } + + // Send response + AsyncWebServerResponse *response = request->beginResponse(CONTENT_TYPE_OCTET, resp_size, [resp_buffer, resp_size](uint8_t *buf, size_t maxLen, size_t index) -> size_t { + size_t len = min(maxLen, resp_size - index); + memcpy(buf, resp_buffer + index, len); + if (index + len >= resp_size) { + delete[] resp_buffer; + } + return len; + }); + request->send(response); +} + +void WebUI::handleUpdateHardware(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) { + static uint8_t *buffer = nullptr; + static size_t buffer_size = 0; + + if (index == 0) { + // First chunk + if (strcmp_P(request->contentType().c_str(), CONTENT_TYPE_OCTET) != 0) { + request->send(400, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)INVALID_CONTENT_TYPE); + return; + } + + buffer_size = total; + if (buffer_size == 0) { + request->send(400, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)EMPTY_REQUEST); + return; + } + + buffer = new uint8_t[buffer_size]; + if (!buffer) { + request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)MEMORY_ALLOCATION_FAILED); + return; + } + LOG_INFO("/api/hardware POST: receiving payload total=%d", buffer_size); + } + + memcpy(buffer + index, data, len); + + if (index + len >= total) { + // Last chunk, process + hardware_HardwareConfig update_config = hardware_HardwareConfig_init_zero; + pb_istream_t stream = pb_istream_from_buffer(buffer, buffer_size); + + bool decode_ok = pb_decode(&stream, hardware_HardwareConfig_fields, &update_config); + + delete[] buffer; + buffer = nullptr; + + if (!decode_ok) { + LOG_ERROR("/api/hardware POST: protobuf decode failed"); + request->send(400, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)INVALID_PROTOBUF); + return; + } + + // Update hardware config + hardwareConfigRepo.setData(update_config); + + // Prepare response (simple success) + AsyncWebServerResponse *response = request->beginResponse(200, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)"OK"); + request->send(response); + } +} \ No newline at end of file diff --git a/src/webui/webui_ota.cpp b/src/webui/webui_ota.cpp new file mode 100644 index 0000000..8161603 --- /dev/null +++ b/src/webui/webui_ota.cpp @@ -0,0 +1,169 @@ +#include "webui.hpp" +#include +#include +#include +#include + +void WebUI::handleEnableOTA(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) { + static uint8_t *buffer = nullptr; + static size_t buffer_size = 0; + + if (index == 0) { + // First chunk + if (strcmp_P(request->contentType().c_str(), CONTENT_TYPE_OCTET) != 0) { + request->send(400, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)INVALID_CONTENT_TYPE); + return; + } + + buffer_size = total; + if (buffer_size == 0) { + request->send(400, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)EMPTY_REQUEST); + return; + } + + buffer = new uint8_t[buffer_size]; + if (!buffer) { + request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)MEMORY_ALLOCATION_FAILED); + return; + } + LOG_INFO("/api/ota POST: receiving payload total=%d", buffer_size); + } + + memcpy(buffer + index, data, len); + + if (index + len >= total) { + // Last chunk, process + hardware_SensorOTAEnable ota_enable = hardware_SensorOTAEnable_init_zero; + pb_istream_t stream = pb_istream_from_buffer(buffer, buffer_size); + + bool decode_ok = pb_decode(&stream, hardware_SensorOTAEnable_fields, &ota_enable); + + delete[] buffer; + buffer = nullptr; + + if (!decode_ok) { + LOG_ERROR("/api/ota POST: protobuf decode failed"); + request->send(400, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)INVALID_PROTOBUF); + return; + } + + // Create ControlToSensorMessage with OTA enable + hardware_ControlToSensorMessage msg = hardware_ControlToSensorMessage_init_zero; + msg.control_id = 0; // Assuming control_id is 0 for now + msg.which_payload = hardware_ControlToSensorMessage_ota_enable_tag; + msg.payload.ota_enable = ota_enable; + + // Send via callback + if (sendToSensorCallback) { + sendToSensorCallback(msg); + request->send(200, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)"OTA enable sent"); + } else { + LOG_ERROR("No sendToSensorCallback set"); + request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)"Internal error"); + } + } +} + +void WebUI::handleRestartSensor(AsyncWebServerRequest *request) { + // Create ControlToSensorMessage with restart payload + hardware_ControlToSensorMessage msg = hardware_ControlToSensorMessage_init_zero; + msg.control_id = 0; // Assuming control_id is 0 for now + msg.which_payload = hardware_ControlToSensorMessage_restart_tag; + msg.payload.restart = hardware_SensorRestart_init_zero; + + // Send via callback + if (sendToSensorCallback) { + sendToSensorCallback(msg); + request->send(200, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)"Restart command sent"); + } else { + LOG_ERROR("No sendToSensorCallback set"); + request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)"Internal error"); + } +} + +void WebUI::onSendToSensor(std::function callback) { + sendToSensorCallback = callback; +} + +void WebUI::setOTAManager(OTAManager* otaManager) { + otaManager_ = otaManager; +} + +void WebUI::handleEnableESPOTA(AsyncWebServerRequest *request) { + if (!otaManager_) { + request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)"OTA manager not initialized"); + return; + } + otaManager_->enable(); + request->send(200, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)"ESP OTA enabled"); +} + +void WebUI::handleDisableESPOTA(AsyncWebServerRequest *request) { + if (!otaManager_) { + request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)"OTA manager not initialized"); + return; + } + otaManager_->disable(); + request->send(200, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)"ESP OTA disabled"); +} + +void WebUI::handleGetESPOTAStatus(AsyncWebServerRequest *request) { + if (!otaManager_) { + request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)"OTA manager not initialized"); + return; + } + String status = "{ \"enabled\": " + String(otaManager_->isEnabled() ? "true" : "false") + ", \"status\": \"" + otaManager_->getStatus() + "\" }"; + request->send(200, "application/json", status); +} + +void WebUI::handleESPOTAUpload(AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) { + if (!index) { + LOG_INFO("OTA Update Start: %s (total expected: %u bytes)", filename.c_str(), request->contentLength()); + + // Prüfe freien Speicher (Debug-Info) + Serial.printf("Free sketch space: %u bytes\n", ESP.getFreeSketchSpace()); + + if (!Update.begin(UPDATE_SIZE_UNKNOWN, U_FLASH)) { // Explizit U_FLASH für Klarheit + Update.printError(Serial); + request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)"OTA update failed to begin"); + return; + } + } + + // Debug: Fortschritt loggen (alle 100 KB oder so, um nicht zu spammen) + static size_t lastLogged = 0; + size_t currentPos = index + len; + if (currentPos - lastLogged > 100000) { // Alle 100 KB + LOG_INFO("Free Heap: %u bytes", ESP.getFreeHeap()); + LOG_INFO("OTA Progress: %u / %u bytes (%d%%)", currentPos, request->contentLength(), + (request->contentLength() > 0 ? (currentPos * 100 / request->contentLength()) : 0)); + lastLogged = currentPos; + } + + // Write firmware + size_t written = Update.write(data, len); + if (written != len) { + Update.printError(Serial); + LOG_ERROR("Write failed: wrote %u of %u bytes", written, len); + request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)"OTA update failed during write"); + return; + } + + if (final) { + if (Update.end(true)) { + LOG_INFO("OTA Update Success: %u bytes total", index + len); + request->send(200, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)"OTA update successful. Rebooting..."); + + // Start restart task with delay + xTaskCreate([](void* pvParameters) { + vTaskDelay(2000 / portTICK_PERIOD_MS); // 2 second delay + ESP.restart(); + vTaskDelete(NULL); + }, "RestartTask", 2048, NULL, 1, NULL); + } else { + Update.printError(Serial); + LOG_ERROR("End failed: error %u", Update.getError()); + request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)"OTA update failed to end"); + } + } +} \ No newline at end of file diff --git a/src/webui/webui_protobuf.cpp b/src/webui/webui_protobuf.cpp new file mode 100644 index 0000000..fd97346 --- /dev/null +++ b/src/webui/webui_protobuf.cpp @@ -0,0 +1,34 @@ +#include "webui.hpp" +#include +#include +#include + +// Protobuf callback functions +bool WebUI::decode_string(pb_istream_t *stream, const pb_field_t *field, void **arg) { + size_t len = stream->bytes_left; + char *str = (char*)malloc(len + 1); + if (!str) return false; + if (!pb_read(stream, (uint8_t*)str, len)) { + free(str); + return false; + } + str[len] = '\0'; + *arg = str; + return true; +} + +bool WebUI::encode_string(pb_ostream_t *stream, const pb_field_t *field, void * const *arg) { + char *str = (char*)*arg; + return pb_encode_tag_for_field(stream, field) && pb_encode_string(stream, (uint8_t*)str, strlen(str)); +} + +bool WebUI::encode_string_array(pb_ostream_t *stream, const pb_field_t *field, void * const *arg) { + std::vector *usernames = (std::vector*)*arg; + for (const auto& username : *usernames) { + if (!pb_encode_tag_for_field(stream, field) || + !pb_encode_string(stream, (uint8_t*)username.c_str(), username.length())) { + return false; + } + } + return true; +} \ No newline at end of file diff --git a/src/webui/webui_rfid.cpp b/src/webui/webui_rfid.cpp new file mode 100644 index 0000000..946d7f2 --- /dev/null +++ b/src/webui/webui_rfid.cpp @@ -0,0 +1,81 @@ +#include "webui.hpp" +#include +#include + +void WebUI::handleGetRfidDatabase(AsyncWebServerRequest *request) { + // Open the RFID database file directly from LittleFS + File file = LittleFS.open("/rfid.db", "r"); + + if (!file) { + // File doesn't exist or can't be opened + request->send(404, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)"RFID database file not found"); + return; + } + + size_t fileSize = file.size(); + + if (fileSize == 0) { + // Empty file + file.close(); + AsyncWebServerResponse *response = request->beginResponse(CONTENT_TYPE_OCTET, 0, [](uint8_t *buf, size_t maxLen, size_t index) -> size_t { + return 0; + }); + request->send(response); + return; + } + + // Send file content directly without modification + AsyncWebServerResponse *response = request->beginResponse(CONTENT_TYPE_OCTET, fileSize, [file](uint8_t *buf, size_t maxLen, size_t reqIndex) mutable -> size_t { + if (reqIndex >= file.size()) { + file.close(); + return 0; + } + + size_t bytesToRead = min(maxLen, (size_t)(file.size() - reqIndex)); + size_t bytesRead = file.read(buf, bytesToRead); + + // Close file when done + if (reqIndex + bytesRead >= file.size()) { + file.close(); + } + + return bytesRead; + }); + + request->send(response); +} + +void WebUI::handleUpdateRfidDatabase(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) { + static File uploadFile; + + if (index == 0) { + // First chunk - open file for writing + uploadFile = LittleFS.open("/rfid.db", "w"); + if (!uploadFile) { + request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)ACTION_FAILED); + return; + } + LOG_INFO("WebUI: Starting RFID database upload, total size: %d bytes\n", total); + } + + // Write data chunk to file + if (uploadFile) { + size_t bytesWritten = uploadFile.write(data, len); + if (bytesWritten != len) { + uploadFile.close(); + request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)ACTION_FAILED); + return; + } + } + + if (index + len >= total) { + // Last chunk - close file and send response + if (uploadFile) { + uploadFile.close(); + LOG_INFO("WebUI: RFID database upload completed, %d bytes written\n", total); + } + + // Send success response + request->send(200, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)ACTION_SUCCESS); + } +} \ No newline at end of file diff --git a/src/webui/webui_rpc.cpp b/src/webui/webui_rpc.cpp new file mode 100644 index 0000000..35c2951 --- /dev/null +++ b/src/webui/webui_rpc.cpp @@ -0,0 +1,22 @@ +#include "webui.hpp" +#include + +void WebUI::handleSync(AsyncWebServerRequest *request) { + if (rpc.sync(false)) { + request->send(200, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)ACTION_SUCCESS); + LOG_INFO("WebUI: Normal sync completed successfully"); + } else { + request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)ACTION_FAILED); + LOG_ERROR("WebUI: Normal sync failed"); + } +} + +void WebUI::handleFullSync(AsyncWebServerRequest *request) { + if (rpc.sync(true)) { + request->send(200, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)ACTION_SUCCESS); + LOG_INFO("WebUI: Full sync completed successfully"); + } else { + request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)ACTION_FAILED); + LOG_ERROR("WebUI: Full sync failed"); + } +} \ No newline at end of file diff --git a/src/webui/webui_settings.cpp b/src/webui/webui_settings.cpp new file mode 100644 index 0000000..635efd0 --- /dev/null +++ b/src/webui/webui_settings.cpp @@ -0,0 +1,77 @@ +#include "webui.hpp" +#include +#include +#include +#include +#include +#include // Für std::min + +void WebUI::handleGetSettings(AsyncWebServerRequest *request) { + // Prepare response + settings_GetSettingsResponse resp = settings_GetSettingsResponse_init_zero; + resp.settings = settings.getData(); + resp.has_settings = true; + + String client_ip = request->client()->remoteIP().toString(); + LOG_DEBUG("/api/settings GET: sending settings, client=%s", client_ip.c_str()); + + sendProtobufResponse(request, &resp, settings_GetSettingsResponse_fields); +} + +void WebUI::handleUpdateSettings(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) { + static std::vector buffer; // Sicherer Buffer pro Request + + String client_ip = request->client()->remoteIP().toString(); + + if (index == 0) { + // First chunk + if (strcmp_P(request->contentType().c_str(), CONTENT_TYPE_OCTET) != 0) { + LOG_ERROR("/api/settings POST: invalid content type, client=%s", client_ip.c_str()); + request->send(400, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)INVALID_CONTENT_TYPE); + return; + } + + if (total == 0) { + LOG_ERROR("/api/settings POST: empty request, client=%s", client_ip.c_str()); + request->send(400, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)EMPTY_REQUEST); + return; + } + + buffer.resize(total); + LOG_INFO("/api/settings POST: receiving payload total=%d, client=%s", total, client_ip.c_str()); + } + + std::memcpy(buffer.data() + index, data, len); + + if (index + len >= total) { + // Last chunk, process + settings_UpdateSettingsRequest update_req = settings_UpdateSettingsRequest_init_zero; + pb_istream_t stream = pb_istream_from_buffer(buffer.data(), buffer.size()); + + bool decode_ok = pb_decode(&stream, settings_UpdateSettingsRequest_fields, &update_req); + buffer.clear(); + + if (!decode_ok || stream.bytes_left != 0) { + LOG_ERROR("/api/settings POST: protobuf decode failed, client=%s", client_ip.c_str()); + request->send(400, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)INVALID_PROTOBUF); + return; + } + + // Update settings + settings.setData(update_req.settings); + + // Save settings + if (!settings.save()) { + LOG_ERROR("/api/settings POST: failed to save settings, client=%s", client_ip.c_str()); + request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)FAILED_TO_SAVE_SETTINGS); + return; + } + + // Prepare response + settings_UpdateSettingsResponse update_resp = settings_UpdateSettingsResponse_init_zero; + update_resp.success = true; + + LOG_DEBUG("/api/settings POST: settings updated successfully, client=%s", client_ip.c_str()); + sendProtobufResponse(request, &update_resp, settings_UpdateSettingsResponse_fields); + } +} \ No newline at end of file diff --git a/src/webui/webui_status.cpp b/src/webui/webui_status.cpp new file mode 100644 index 0000000..86162e8 --- /dev/null +++ b/src/webui/webui_status.cpp @@ -0,0 +1,49 @@ +#include "webui.hpp" +#include +#include +#include +#include "device/device_status.hpp" + +void WebUI::handleGetStatus(AsyncWebServerRequest *request) { + // Create DeviceStatus instance + DeviceStatus deviceStatus; + + // Get the populated status + const device_DeviceStatus& status = deviceStatus.getStatus(); + + // Get encoded size + size_t resp_size = 0; + pb_get_encoded_size(&resp_size, device_DeviceStatus_fields, &status); + + if (resp_size == 0) { + LOG_ERROR("WebUI: /api/status GET: Response size is 0 - protobuf encoding failed!"); + request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)"Response size is 0"); + return; + } + + uint8_t *resp_buffer = new uint8_t[resp_size]; + if (!resp_buffer) { + request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)MEMORY_ALLOCATION_FAILED); + return; + } + + pb_ostream_t resp_stream = pb_ostream_from_buffer(resp_buffer, resp_size); + bool encode_ok = pb_encode(&resp_stream, device_DeviceStatus_fields, &status); + + if (!encode_ok) { + delete[] resp_buffer; + request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)ENCODING_FAILED); + return; + } + + // Send response + AsyncWebServerResponse *response = request->beginResponse(CONTENT_TYPE_OCTET, resp_size, [resp_buffer, resp_size](uint8_t *buf, size_t maxLen, size_t index) -> size_t { + size_t len = min(maxLen, resp_size - index); + memcpy(buf, resp_buffer + index, len); + if (index + len >= resp_size) { + delete[] resp_buffer; + } + return len; + }); + request->send(response); +} \ No newline at end of file diff --git a/src/webui/webui_strings.hpp b/src/webui/webui_strings.hpp new file mode 100644 index 0000000..c0a939f --- /dev/null +++ b/src/webui/webui_strings.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include + +// Frequently used strings stored in PROGMEM +const char CONTENT_TYPE_OCTET[] PROGMEM = "application/octet-stream"; +const char CONTENT_TYPE_TEXT[] PROGMEM = "text/plain"; +const char INVALID_CONTENT_TYPE[] PROGMEM = "Invalid content type"; +const char EMPTY_REQUEST[] PROGMEM = "Empty request"; +const char MEMORY_ALLOCATION_FAILED[] PROGMEM = "Memory allocation failed"; +const char INVALID_PROTOBUF[] PROGMEM = "Invalid protobuf"; +const char ENCODING_FAILED[] PROGMEM = "Encoding failed"; +const char FAILED_TO_LOAD_SETTINGS[] PROGMEM = "Failed to load settings"; +const char FAILED_TO_SAVE_SETTINGS[] PROGMEM = "Failed to save settings"; +const char LITTLEFS_MOUNT_FAILED[] PROGMEM = "LittleFS Mount Failed"; +const char WEBUI_SERVER_STARTED[] PROGMEM = "WebUI server started"; +const char WEBUI_SERVER_ALREADY_RUNNING[] PROGMEM = "WebUI server already running"; +const char DUMMY_TOKEN[] PROGMEM = "dummy_token"; +const char INVALID_CREDENTIALS[] PROGMEM = "Invalid credentials"; +const char UNAUTHORIZED[] PROGMEM = "Unauthorized"; + +// Generic action response messages +const char ACTION_SUCCESS[] PROGMEM = "Action completed successfully"; +const char ACTION_FAILED[] PROGMEM = "Action failed"; \ No newline at end of file diff --git a/src/webui/webui_users.cpp b/src/webui/webui_users.cpp new file mode 100644 index 0000000..78e706a --- /dev/null +++ b/src/webui/webui_users.cpp @@ -0,0 +1,329 @@ +#include "webui.hpp" +#include +#include +#include +#include + +void WebUI::handleGetUsers(AsyncWebServerRequest *request) { + // CHANGE: Use a temporary in-memory list to collect usernames. + // We use std::vector because it manages memory for us + // and ensures the strings are valid throughout the function's scope. + std::vector usernames; + + // Populate the list using the new callback method from the Users class. + // The lambda function captures our 'usernames' vector by reference ([&]) + // and adds each username it receives to the list. + users.loadUsernamesCallback([&](const char* username) { + usernames.push_back(username); + return true; // Return true to continue iterating. + }); + + + // Prepare the Protobuf response structure. + webui_GetUsersResponse resp = webui_GetUsersResponse_init_zero; + + // Wire up our new encoder callback and pass it a pointer to our temporary list. + resp.usernames.funcs.encode = &WebUI::encode_string_array; + resp.usernames.arg = &usernames; // Pass the address of our vector. + + // --- The rest of the logic remains mostly the same --- + + // Encode response to a buffer. + size_t resp_size = 0; + // Note: pb_get_encoded_size will fail here because the callback needs to run. + // We must encode directly and handle a potentially oversized buffer. + // A safe upper limit: (32 chars username + 2 bytes overhead) * 5 users + overhead + const size_t max_resp_size = (32 + 2) * 5 + 4; + uint8_t *resp_buffer = new uint8_t[max_resp_size]; + if (!resp_buffer) { + request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)MEMORY_ALLOCATION_FAILED); + return; + } + + pb_ostream_t resp_stream = pb_ostream_from_buffer(resp_buffer, max_resp_size); + bool encode_ok = pb_encode(&resp_stream, webui_GetUsersResponse_fields, &resp); + + if (!encode_ok) { + delete[] resp_buffer; + request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)ENCODING_FAILED); + return; + } + + // Use the actual encoded size from the stream. + size_t final_resp_size = resp_stream.bytes_written; + + // Send response. + AsyncWebServerResponse *response = request->beginResponse(CONTENT_TYPE_OCTET, final_resp_size, [resp_buffer, final_resp_size](uint8_t *buf, size_t maxLen, size_t index) -> size_t { + size_t len = std::min(maxLen, final_resp_size - index); + if (len > 0) { + memcpy(buf, resp_buffer + index, len); + } + // Delete the buffer only when the transfer is complete. + if (index + len >= final_resp_size) { + delete[] resp_buffer; + } + return len; + }); + request->send(response); +} + +void WebUI::handleAddUser(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) { + static uint8_t *buffer = nullptr; + static size_t buffer_size = 0; + + if (index == 0) { + // First chunk + if (strcmp_P(request->contentType().c_str(), CONTENT_TYPE_OCTET) != 0) { + request->send(400, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)INVALID_CONTENT_TYPE); + return; + } + + buffer_size = total; + if (buffer_size == 0) { + request->send(400, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)EMPTY_REQUEST); + return; + } + + buffer = new uint8_t[buffer_size]; + if (!buffer) { + request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)MEMORY_ALLOCATION_FAILED); + return; + } + } + + memcpy(buffer + index, data, len); + + if (index + len >= total) { + // Last chunk, process + webui_AddUserRequest add_req = webui_AddUserRequest_init_zero; + pb_istream_t stream = pb_istream_from_buffer(buffer, buffer_size); + + bool decode_ok = pb_decode(&stream, webui_AddUserRequest_fields, &add_req); + + delete[] buffer; + buffer = nullptr; + + if (!decode_ok) { + LOG_ERROR("/api/users POST: protobuf decode failed"); + request->send(400, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)INVALID_PROTOBUF); + return; + } + + bool success = users.addUser(add_req.username, add_req.password); + + // Prepare response + webui_AddUserResponse add_resp = webui_AddUserResponse_init_zero; + add_resp.success = success; + + if (!success) { + add_resp.has_error = true; + strcpy(add_resp.error, "table full or user exists"); + }else{ + add_resp.has_error = false; + } + + // Encode response + size_t resp_size = 0; + pb_get_encoded_size(&resp_size, webui_AddUserResponse_fields, &add_resp); + + uint8_t *resp_buffer = new uint8_t[resp_size]; + if (!resp_buffer) { + request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)MEMORY_ALLOCATION_FAILED); + return; + } + + pb_ostream_t resp_stream = pb_ostream_from_buffer(resp_buffer, resp_size); + bool encode_ok = pb_encode(&resp_stream, webui_AddUserResponse_fields, &add_resp); + + + if (!encode_ok) { + LOG_ERROR("/api/users POST: protobuf encode failed"); + delete[] resp_buffer; + request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)ENCODING_FAILED); + return; + } + + // Send response + AsyncWebServerResponse *response = request->beginResponse(CONTENT_TYPE_OCTET, resp_size, [resp_buffer, resp_size](uint8_t *buf, size_t maxLen, size_t index) -> size_t { + size_t len = min(maxLen, resp_size - index); + memcpy(buf, resp_buffer + index, len); + if (index + len >= resp_size) { + delete[] resp_buffer; + } + return len; + }); + request->send(response); + } +} + +void WebUI::handleDeleteUser(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) { + static uint8_t *buffer = nullptr; + static size_t buffer_size = 0; + + if (index == 0) { + // First chunk + if (request->contentType() != "application/octet-stream") { + request->send(400, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)INVALID_CONTENT_TYPE); + return; + } + + buffer_size = total; + if (buffer_size == 0) { + request->send(400, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)EMPTY_REQUEST); + return; + } + + buffer = new uint8_t[buffer_size]; + if (!buffer) { + request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)MEMORY_ALLOCATION_FAILED); + return; + } + } + + memcpy(buffer + index, data, len); + + if (index + len >= total) { + // Last chunk, process + webui_DeleteUserRequest delete_req = webui_DeleteUserRequest_init_zero; + pb_istream_t stream = pb_istream_from_buffer(buffer, buffer_size); + + bool decode_ok = pb_decode(&stream, webui_DeleteUserRequest_fields, &delete_req); + + delete[] buffer; + buffer = nullptr; + + if (!decode_ok) { + request->send(400, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)INVALID_PROTOBUF); + return; + } + + // Delete user + bool success = users.deleteUser(delete_req.username); + + // Prepare response + webui_DeleteUserResponse delete_resp = webui_DeleteUserResponse_init_zero; + delete_resp.success = success; + + if (!success) { + delete_resp.has_error = true; + strcpy(delete_resp.error, "User not found"); + } + + // Encode response + size_t resp_size = 0; + pb_get_encoded_size(&resp_size, webui_DeleteUserResponse_fields, &delete_resp); + + uint8_t *resp_buffer = new uint8_t[resp_size]; + if (!resp_buffer) { + request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)MEMORY_ALLOCATION_FAILED); + return; + } + + pb_ostream_t resp_stream = pb_ostream_from_buffer(resp_buffer, resp_size); + bool encode_ok = pb_encode(&resp_stream, webui_DeleteUserResponse_fields, &delete_resp); + + + if (!encode_ok) { + delete[] resp_buffer; + request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)ENCODING_FAILED); + return; + } + + // Send response + AsyncWebServerResponse *response = request->beginResponse("application/octet-stream", resp_size, [resp_buffer, resp_size](uint8_t *buf, size_t maxLen, size_t index) -> size_t { + size_t len = min(maxLen, resp_size - index); + memcpy(buf, resp_buffer + index, len); + if (index + len >= resp_size) { + delete[] resp_buffer; + } + return len; + }); + request->send(response); + } +} + +void WebUI::handleUpdateUserPassword(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) { + static uint8_t *buffer = nullptr; + static size_t buffer_size = 0; + + if (index == 0) { + // First chunk + if (request->contentType() != "application/octet-stream") { + request->send(400, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)INVALID_CONTENT_TYPE); + return; + } + + buffer_size = total; + if (buffer_size == 0) { + request->send(400, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)EMPTY_REQUEST); + return; + } + + buffer = new uint8_t[buffer_size]; + if (!buffer) { + request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)MEMORY_ALLOCATION_FAILED); + return; + } + } + + memcpy(buffer + index, data, len); + + if (index + len >= total) { + // Last chunk, process + webui_UpdateUserPasswordRequest update_req = webui_UpdateUserPasswordRequest_init_zero; + pb_istream_t stream = pb_istream_from_buffer(buffer, buffer_size); + + bool decode_ok = pb_decode(&stream, webui_UpdateUserPasswordRequest_fields, &update_req); + + delete[] buffer; + buffer = nullptr; + + if (!decode_ok) { + request->send(400, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)INVALID_PROTOBUF); + return; + } + + // Update password + bool success = users.updatePassword(update_req.username, update_req.new_password); + + // Prepare response + webui_UpdateUserPasswordResponse update_resp = webui_UpdateUserPasswordResponse_init_zero; + update_resp.success = success; + + if (!success) { + update_resp.has_error = true; + strcpy(update_resp.error, "User not found"); + } + + // Encode response + size_t resp_size = 0; + pb_get_encoded_size(&resp_size, webui_UpdateUserPasswordResponse_fields, &update_resp); + + uint8_t *resp_buffer = new uint8_t[resp_size]; + if (!resp_buffer) { + request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)MEMORY_ALLOCATION_FAILED); + return; + } + + pb_ostream_t resp_stream = pb_ostream_from_buffer(resp_buffer, resp_size); + bool encode_ok = pb_encode(&resp_stream, webui_UpdateUserPasswordResponse_fields, &update_resp); + + + if (!encode_ok) { + delete[] resp_buffer; + request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)ENCODING_FAILED); + return; + } + + // Send response + AsyncWebServerResponse *response = request->beginResponse("application/octet-stream", resp_size, [resp_buffer, resp_size](uint8_t *buf, size_t maxLen, size_t index) -> size_t { + size_t len = min(maxLen, resp_size - index); + memcpy(buf, resp_buffer + index, len); + if (index + len >= resp_size) { + delete[] resp_buffer; + } + return len; + }); + request->send(response); + } +} \ No newline at end of file diff --git a/src/wifi/wifi_manager.cpp b/src/wifi/wifi_manager.cpp new file mode 100644 index 0000000..b757277 --- /dev/null +++ b/src/wifi/wifi_manager.cpp @@ -0,0 +1,277 @@ +#include "wifi_manager.hpp" +#include +#include + +WiFiManager::WiFiManager() { + registerEvents(); +} + +WiFiManager::~WiFiManager() { + stop(); + unregisterEvents(); +} + +void WiFiManager::registerEvents() { + WiFi.onEvent(std::bind(&WiFiManager::onWiFiStaGotIP, this, std::placeholders::_1, std::placeholders::_2), WiFiEvent_t::ARDUINO_EVENT_WIFI_STA_GOT_IP); + WiFi.onEvent(std::bind(&WiFiManager::onWiFiStaDisconnected, this, std::placeholders::_1, std::placeholders::_2), WiFiEvent_t::ARDUINO_EVENT_WIFI_STA_DISCONNECTED); +} + +void WiFiManager::unregisterEvents() { + WiFi.removeEvent(ARDUINO_EVENT_WIFI_STA_GOT_IP); + WiFi.removeEvent(ARDUINO_EVENT_WIFI_STA_DISCONNECTED); +} + +void WiFiManager::begin(const settings_SettingsData& settings) { + // Safely copy the new configuration + wifi_mode_ = settings.wifi_mode; + strncpy(station_ssid_, settings.station_ssid, sizeof(station_ssid_) - 1); + station_ssid_[sizeof(station_ssid_) - 1] = '\0'; + strncpy(station_password_, settings.station_password, sizeof(station_password_) - 1); + station_password_[sizeof(station_password_) - 1] = '\0'; + strncpy(ap_ssid_, settings.ap_ssid, sizeof(ap_ssid_) - 1); + ap_ssid_[sizeof(ap_ssid_) - 1] = '\0'; + strncpy(ap_password_, settings.ap_password, sizeof(ap_password_) - 1); + ap_password_[sizeof(ap_password_) - 1] = '\0'; + ap_channel_ = settings.ap_channel; + + stop(); // Start with a clean slate + + bool success = false; + switch (wifi_mode_) { + case settings_WifiMode_WIFI_MODE_STATION: + success = applyStationMode(); + break; + case settings_WifiMode_WIFI_MODE_AP: + success = applyAccessPointMode(); + break; + case settings_WifiMode_WIFI_MODE_AP_STATION: + success = applyStationAndAPMode(); + break; + default: + LOG_ERROR("WiFi: Unknown or unspecified WiFi mode. Stopping WiFi."); + state_ = WiFiState::STOPPED; + break; + } + + if (!success) { + state_ = WiFiState::CONNECTION_FAILED; + } +} + + +void WiFiManager::update() { + static uint32_t last_check_time = 0; + uint32_t now = millis(); + if (now - last_check_time < 1000) { + return; // Check every second + }else last_check_time = now; + switch (state_) { + case WiFiState::CONNECTING_STATION: + if (millis() - connection_start_time_ > CONNECTION_TIMEOUT_MS) { + LOG_WARN("WiFi: Station connection timed out."); + WiFi.disconnect(); + state_ = WiFiState::CONNECTION_FAILED; + last_reconnect_attempt_ = millis(); + } + break; + + case WiFiState::CONNECTION_FAILED: + if (millis() - last_reconnect_attempt_ > reconnect_delay_ms_) { + LOG_INFO("WiFi: Attempting to reconnect..."); + applyStationMode(); + } + break; + + default: + break; + } +} + +void WiFiManager::stop() { + if (state_ == WiFiState::STOPPED) { + return; // Already stopped + } + LOG_INFO("WiFi: Stopping all WiFi activity..."); + if (onPreShutdownCallback_) { + onPreShutdownCallback_(); + } + // Give the network stack a moment to release the web server's port. + // 100ms is usually more than enough. + vTaskDelay(pdMS_TO_TICKS(100)); + WiFi.disconnect(true); + WiFi.mode(WIFI_OFF); + state_ = WiFiState::STOPPED; +} + +bool WiFiManager::isConnected() const { + return (state_ == WiFiState::CONNECTED_STATION || state_ == WiFiState::RUNNING_AP_STA) && (WiFi.status() == WL_CONNECTED); +} + +void WiFiManager::getState(device_DeviceInfo& deviceInfo) const { + // Set STA connection state + switch (state_) { + case WiFiState::CONNECTED_STATION: + case WiFiState::RUNNING_AP_STA: + deviceInfo.sta_connection_state = device_WifiConnectionState_WIFI_CONNECTED; + break; + case WiFiState::CONNECTING_STATION: + deviceInfo.sta_connection_state = device_WifiConnectionState_WIFI_CONNECTING; + break; + case WiFiState::CONNECTION_FAILED: + deviceInfo.sta_connection_state = device_WifiConnectionState_WIFI_FAILED; + break; + default: + deviceInfo.sta_connection_state = device_WifiConnectionState_WIFI_DISCONNECTED; + break; + } + + // Set AP connection state + if (state_ == WiFiState::RUNNING_AP || state_ == WiFiState::RUNNING_AP_STA) { + deviceInfo.ap_connection_state = device_WifiConnectionState_WIFI_CONNECTED; + } else { + deviceInfo.ap_connection_state = device_WifiConnectionState_WIFI_DISCONNECTED; + } + + // Populate STA details if connected + if (deviceInfo.sta_connection_state == device_WifiConnectionState_WIFI_CONNECTED) { + strncpy(deviceInfo.sta_ip, WiFi.localIP().toString().c_str(), sizeof(deviceInfo.sta_ip) - 1); + deviceInfo.sta_ip[sizeof(deviceInfo.sta_ip) - 1] = '\0'; + strncpy(deviceInfo.sta_gateway, WiFi.gatewayIP().toString().c_str(), sizeof(deviceInfo.sta_gateway) - 1); + deviceInfo.sta_gateway[sizeof(deviceInfo.sta_gateway) - 1] = '\0'; + deviceInfo.sta_signal_strength = WiFi.RSSI(); + } else { + memset(deviceInfo.sta_ip, 0, sizeof(deviceInfo.sta_ip)); + memset(deviceInfo.sta_gateway, 0, sizeof(deviceInfo.sta_gateway)); + deviceInfo.sta_signal_strength = 0; + } + + // Populate AP details if running + if (deviceInfo.ap_connection_state == device_WifiConnectionState_WIFI_CONNECTED) { + strncpy(deviceInfo.ap_ip, WiFi.softAPIP().toString().c_str(), sizeof(deviceInfo.ap_ip) - 1); + deviceInfo.ap_ip[sizeof(deviceInfo.ap_ip) - 1] = '\0'; + deviceInfo.ap_client_count = WiFi.softAPgetStationNum(); + } else { + memset(deviceInfo.ap_ip, 0, sizeof(deviceInfo.ap_ip)); + deviceInfo.ap_client_count = 0; + } +} + +// --- Event Handlers --- + +void WiFiManager::onWiFiStaGotIP(WiFiEvent_t event, WiFiEventInfo_t info) { + IPAddress ip = WiFi.localIP(); + LOG_INFO("WiFi: Station connected! IP: %s", ip.toString().c_str()); + + if (wifi_mode_ == settings_WifiMode_WIFI_MODE_AP_STATION) { + state_ = WiFiState::RUNNING_AP_STA; + } else { + state_ = WiFiState::CONNECTED_STATION; + } + + if (onStaConnectedCallback_) { + onStaConnectedCallback_(ip); + } +} + +void WiFiManager::onWiFiStaDisconnected(WiFiEvent_t event, WiFiEventInfo_t info) { + LOG_WARN("WiFi: Station disconnected. Reason: %d", info.wifi_sta_disconnected.reason); + state_ = WiFiState::CONNECTION_FAILED; + last_reconnect_attempt_ = millis(); // Set timestamp to start reconnect delay + + if (onDisconnectedCallback_) { + onDisconnectedCallback_(); + } +} + + + +// --- Private Helper Methods --- + +bool WiFiManager::applyStationMode() { + if (!validateStationConfig()) { + LOG_ERROR("WiFi: Invalid station configuration."); + return false; + } + LOG_INFO("WiFi: Connecting to station '%s'...", station_ssid_); + WiFi.mode(WIFI_STA); + WiFi.begin(station_ssid_, station_password_); + state_ = WiFiState::CONNECTING_STATION; + connection_start_time_ = millis(); + return true; +} + +bool WiFiManager::applyAccessPointMode() { + if (!validateAPConfig()) { + LOG_ERROR("WiFi: Invalid AP configuration."); + return false; + } + LOG_INFO("WiFi: Starting AP '%s'...", ap_ssid_); + WiFi.mode(WIFI_AP); + if (WiFi.softAP(ap_ssid_, ap_password_, ap_channel_)) { + IPAddress ip = WiFi.softAPIP(); + LOG_INFO("WiFi: AP started, IP: %s", ip.toString().c_str()); + state_ = WiFiState::RUNNING_AP; + + if (onApStartedCallback_) { + onApStartedCallback_(ip); + } + return true; + + } else { + LOG_ERROR("WiFi: Failed to start AP."); + return false; + } +} + +bool WiFiManager::applyStationAndAPMode() { + if (!validateStationConfig() || !validateAPConfig()) { + LOG_ERROR("WiFi: Invalid station or AP configuration."); + return false; + } + LOG_INFO("WiFi: Starting AP + Station mode..."); + WiFi.mode(WIFI_AP_STA); + + // Start AP + if (!WiFi.softAP(ap_ssid_, ap_password_, ap_channel_)) { + LOG_ERROR("WiFi: Failed to start AP in AP+STA mode."); + return false; + } + + IPAddress ip = WiFi.softAPIP(); + LOG_INFO("WiFi: AP started, IP: %s", ip.toString().c_str()); + + if (onApStartedCallback_) { + onApStartedCallback_(ip); + } + + // Begin connecting to station + WiFi.begin(station_ssid_, station_password_); + state_ = WiFiState::CONNECTING_STATION; + connection_start_time_ = millis(); + return true; +} + + +bool WiFiManager::validateStationConfig() const { + return strlen(station_ssid_) > 0; +} + +bool WiFiManager::validateAPConfig() const { + return strlen(ap_ssid_) > 0 && strlen(ap_password_) >= 8 && ap_channel_ >= 1 && ap_channel_ <= 13; +} + +void WiFiManager::onStaConnected(std::function callback) { + onStaConnectedCallback_ = callback; +} + +void WiFiManager::onApStarted(std::function callback) { + onApStartedCallback_ = callback; +} + +void WiFiManager::onDisconnected(std::function callback) { + onDisconnectedCallback_ = callback; +} + +void WiFiManager::onPreShutdown(std::function callback) { + onPreShutdownCallback_ = callback; +} \ No newline at end of file diff --git a/src/wifi/wifi_manager.hpp b/src/wifi/wifi_manager.hpp new file mode 100644 index 0000000..242b27b --- /dev/null +++ b/src/wifi/wifi_manager.hpp @@ -0,0 +1,123 @@ +#pragma once + +#include +#include +#include "settings.pb.h" // Your nanopb settings header +#include "device.pb.h" // Your nanopb device header + +/** + * @enum WiFiState + * @brief Defines the possible operational states of the WiFiManager. + */ +enum class WiFiState { + STOPPED, + CONNECTING_STATION, + CONNECTED_STATION, + CONNECTION_FAILED, + RUNNING_AP, + RUNNING_AP_STA +}; + +/** + * @class WiFiManager + * @brief A non-blocking, event-driven manager for ESP32 WiFi connectivity. + * + * This class handles WiFi configuration and state management asynchronously, + * using the ESP32's event system instead of blocking delay loops. + */ +class WiFiManager { +public: + WiFiManager(); + ~WiFiManager(); + + /** + * @brief Applies a new WiFi configuration and initiates the connection process. + * @param settings The settings_SettingsData struct containing the new configuration. + */ + void begin(const settings_SettingsData& settings); + + /** + * @brief The main update loop for the manager. Must be called repeatedly from the main loop(). + * This method handles state transitions like connection timeouts and reconnect attempts. + */ + void update(); + + /** + * @brief Stops all WiFi activity and disconnects. + */ + void stop(); + + /** + * @brief Checks if the device is currently connected as a WiFi station. + * @return true if connected to an access point, false otherwise. + */ + bool isConnected() const; + + /** + * @brief Gets the current operational state of the WiFiManager. + * @return The current WiFiState. + */ + WiFiState getState() const { return state_; } + + /** + * @brief Populates the provided DeviceInfo with current WiFi status information. + * @param deviceInfo Reference to the DeviceInfo message to populate. + */ + void getState(device_DeviceInfo& deviceInfo) const; + + /** + * @brief Registers a callback for when the Station interface connects and gets an IP. + */ + void onStaConnected(std::function callback); + + /** + * @brief Registers a callback for when the Access Point interface has started. + */ + void onApStarted(std::function callback); + + /** + * @brief Registers a callback function to be called upon station disconnection. + */ + void onDisconnected(std::function callback); + + /** + * @brief Registers a callback to be called just before WiFi is disconnected or stopped. + * This allows other modules to shut down gracefully. + */ + void onPreShutdown(std::function callback); +private: + // --- Configuration --- + settings_WifiMode wifi_mode_; + char station_ssid_[33]; + char station_password_[65]; + char ap_ssid_[33]; + char ap_password_[65]; + uint8_t ap_channel_; + + // --- State Machine --- + WiFiState state_ = WiFiState::STOPPED; + uint32_t connection_start_time_ = 0; + uint32_t reconnect_delay_ms_ = 10000; // Wait 10s before reconnecting + uint32_t last_reconnect_attempt_ = 0; + static constexpr uint32_t CONNECTION_TIMEOUT_MS = 30000; // 30-second timeout + + // --- WiFi Event Handlers --- + void registerEvents(); + void unregisterEvents(); + void onWiFiStaGotIP(WiFiEvent_t event, WiFiEventInfo_t info); + void onWiFiStaDisconnected(WiFiEvent_t event, WiFiEventInfo_t info); + + // --- Private Helper Methods --- + bool applyStationMode(); + bool applyAccessPointMode(); + bool applyStationAndAPMode(); + bool validateStationConfig() const; + bool validateAPConfig() const; + + + std::function onStaConnectedCallback_ = nullptr; + std::function onApStartedCallback_ = nullptr; + std::function onDisconnectedCallback_ = nullptr; + std::function onPreShutdownCallback_ = nullptr; + +}; \ No newline at end of file diff --git a/test/README b/test/README new file mode 100644 index 0000000..9b1e87b --- /dev/null +++ b/test/README @@ -0,0 +1,11 @@ + +This directory is intended for PlatformIO Test Runner and project tests. + +Unit Testing is a software testing method by which individual units of +source code, sets of one or more MCU program modules together with associated +control data, usage procedures, and operating procedures, are tested to +determine whether they are fit for use. Unit testing finds problems early +in the development cycle. + +More information about PlatformIO Unit Testing: +- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 0000000..3b462cb --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,23 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/ui/.npmrc b/ui/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/ui/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/ui/.prettierignore b/ui/.prettierignore new file mode 100644 index 0000000..7d74fe2 --- /dev/null +++ b/ui/.prettierignore @@ -0,0 +1,9 @@ +# Package Managers +package-lock.json +pnpm-lock.yaml +yarn.lock +bun.lock +bun.lockb + +# Miscellaneous +/static/ diff --git a/ui/.prettierrc b/ui/.prettierrc new file mode 100644 index 0000000..8103a0b --- /dev/null +++ b/ui/.prettierrc @@ -0,0 +1,16 @@ +{ + "useTabs": true, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100, + "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], + "overrides": [ + { + "files": "*.svelte", + "options": { + "parser": "svelte" + } + } + ], + "tailwindStylesheet": "./src/app.css" +} diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 0000000..75842c4 --- /dev/null +++ b/ui/README.md @@ -0,0 +1,38 @@ +# sv + +Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). + +## Creating a project + +If you're seeing this, you've probably already done this step. Congrats! + +```sh +# create a new project in the current directory +npx sv create + +# create a new project in my-app +npx sv create my-app +``` + +## Developing + +Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: + +```sh +npm run dev + +# or start the server and open the app in a new browser tab +npm run dev -- --open +``` + +## Building + +To create a production version of your app: + +```sh +npm run build +``` + +You can preview the production build with `npm run preview`. + +> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. diff --git a/ui/components.json b/ui/components.json new file mode 100644 index 0000000..65fd565 --- /dev/null +++ b/ui/components.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://shadcn-svelte.com/schema.json", + "tailwind": { + "css": "src\\app.css", + "baseColor": "zinc" + }, + "aliases": { + "components": "$lib/components", + "utils": "$lib/utils", + "ui": "$lib/components/ui", + "hooks": "$lib/hooks", + "lib": "$lib" + }, + "typescript": true, + "registry": "https://shadcn-svelte.com/registry" +} diff --git a/ui/eslint.config.js b/ui/eslint.config.js new file mode 100644 index 0000000..2c49fa6 --- /dev/null +++ b/ui/eslint.config.js @@ -0,0 +1,41 @@ +import prettier from 'eslint-config-prettier'; +import { fileURLToPath } from 'node:url'; +import { includeIgnoreFile } from '@eslint/compat'; +import js from '@eslint/js'; +import svelte from 'eslint-plugin-svelte'; +import { defineConfig } from 'eslint/config'; +import globals from 'globals'; +import ts from 'typescript-eslint'; +import svelteConfig from './svelte.config.js'; + +const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url)); + +export default defineConfig( + includeIgnoreFile(gitignorePath), + js.configs.recommended, + ...ts.configs.recommended, + ...svelte.configs.recommended, + prettier, + ...svelte.configs.prettier, + { + languageOptions: { + globals: { ...globals.browser, ...globals.node } + }, + rules: { + // typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects. + // see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors + 'no-undef': 'off' + } + }, + { + files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'], + languageOptions: { + parserOptions: { + projectService: true, + extraFileExtensions: ['.svelte'], + parser: ts.parser, + svelteConfig + } + } + } +); diff --git a/ui/package-lock.json b/ui/package-lock.json new file mode 100644 index 0000000..dadf139 --- /dev/null +++ b/ui/package-lock.json @@ -0,0 +1,5398 @@ +{ + "name": "ui", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ui", + "version": "0.0.1", + "dependencies": { + "svelte-i18n": "^4.0.1", + "ts-proto": "^2.7.7" + }, + "devDependencies": { + "@eslint/compat": "^1.2.5", + "@eslint/js": "^9.22.0", + "@internationalized/date": "^3.9.0", + "@lucide/svelte": "^0.544.0", + "@sveltejs/adapter-static": "^3.0.8", + "@sveltejs/kit": "^2.22.0", + "@sveltejs/vite-plugin-svelte": "^6.0.0", + "@tailwindcss/forms": "^0.5.9", + "@tailwindcss/typography": "^0.5.15", + "@tailwindcss/vite": "^4.0.0", + "@types/node": "^22", + "bits-ui": "^2.11.4", + "clsx": "^2.1.1", + "eslint": "^9.22.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-svelte": "^3.0.0", + "globals": "^16.0.0", + "mode-watcher": "^1.1.0", + "prettier": "^3.4.2", + "prettier-plugin-svelte": "^3.3.3", + "prettier-plugin-tailwindcss": "^0.6.11", + "singlefile": "^0.0.0", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "svelte-sonner": "^1.0.5", + "tailwind-merge": "^3.3.1", + "tailwind-variants": "^3.1.1", + "tailwindcss": "^4.0.0", + "tw-animate-css": "^1.4.0", + "typescript": "^5.0.0", + "typescript-eslint": "^8.20.0", + "vite": "^7.0.4", + "vite-plugin-compression": "^0.5.1", + "vite-plugin-singlefile": "^2.3.0" + } + }, + "node_modules/@bufbuild/protobuf": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.9.0.tgz", + "integrity": "sha512-rnJenoStJ8nvmt9Gzye8nkYd6V22xUAnu4086ER7h1zJ508vStko4pMvDeQ446ilDTFpV5wnoc5YS7XvMwwMqA==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", + "integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.10.tgz", + "integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz", + "integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.10.tgz", + "integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz", + "integrity": "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz", + "integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz", + "integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz", + "integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz", + "integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz", + "integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz", + "integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz", + "integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz", + "integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz", + "integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz", + "integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz", + "integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz", + "integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz", + "integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz", + "integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz", + "integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz", + "integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz", + "integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz", + "integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz", + "integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz", + "integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz", + "integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/compat": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-1.4.0.tgz", + "integrity": "sha512-DEzm5dKeDBPm3r08Ixli/0cmxr8LkRdwxMRUIJBlSCpAwSrvFEJpVBzV+66JhDxiaqKxnRzCXhtiMiczF7Hglg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.16.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": "^8.40 || 9" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", + "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz", + "integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.36.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.36.0.tgz", + "integrity": "sha512-uhCbYtYynH30iZErszX78U+nR3pJU3RHGQ57NXy5QupD4SBVwDeU8TNBy+MjMngc1UyIW9noKqsRqfjQTBU2dw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.15.2", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@formatjs/ecma402-abstract": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.4.tgz", + "integrity": "sha512-qrycXDeaORzIqNhBOx0btnhpD1c+/qFIHAN9znofuMJX6QBwtbrmlpWfD4oiUUD2vJUOIYFA/gYtg2KAMGG7sA==", + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "2.2.7", + "@formatjs/intl-localematcher": "0.6.1", + "decimal.js": "^10.4.3", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/fast-memoize": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz", + "integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/icu-messageformat-parser": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.2.tgz", + "integrity": "sha512-AfiMi5NOSo2TQImsYAg8UYddsNJ/vUEv/HaNqiFjnI3ZFfWihUtD5QtuX6kHl8+H+d3qvnE/3HZrfzgdWpsLNA==", + "license": "MIT", + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.4", + "@formatjs/icu-skeleton-parser": "1.8.14", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/icu-skeleton-parser": { + "version": "1.8.14", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.14.tgz", + "integrity": "sha512-i4q4V4qslThK4Ig8SxyD76cp3+QJ3sAqr7f6q9VVfeGtxG9OhiAk3y9XF6Q41OymsKzsGQ6OQQoJNY4/lI8TcQ==", + "license": "MIT", + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.4", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/intl-localematcher": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.1.tgz", + "integrity": "sha512-ePEgLgVCqi2BBFnTMWPfIghu6FkbZnnBVhO2sSxvLfrdFw7wCHAHiDoM2h4NRgjbaY7+B7HgOLZGkK187pZTZg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@internationalized/date": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.9.0.tgz", + "integrity": "sha512-yaN3brAnHRD+4KyyOsJyk49XUvj2wtbNACSqg0bz3u8t2VuzhC8Q5dfRnrSxjnnbDb+ienBnkn1TzQfE154vyg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lucide/svelte": { + "version": "0.544.0", + "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-0.544.0.tgz", + "integrity": "sha512-9f9O6uxng2pLB01sxNySHduJN3HTl5p0HDu4H26VR51vhZfiMzyOMe9Mhof3XAk4l813eTtl+/DYRvGyoRR+yw==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "svelte": "^5" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.3.tgz", + "integrity": "sha512-h6cqHGZ6VdnwliFG1NXvMPTy/9PS3h8oLh7ImwR+kl+oYnQizgjxsONmmPSb2C66RksfkfIxEVtDSEcJiO0tqw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.3.tgz", + "integrity": "sha512-wd+u7SLT/u6knklV/ifG7gr5Qy4GUbH2hMWcDauPFJzmCZUAJ8L2bTkVXC2niOIxp8lk3iH/QX8kSrUxVZrOVw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.3.tgz", + "integrity": "sha512-lj9ViATR1SsqycwFkJCtYfQTheBdvlWJqzqxwc9f2qrcVrQaF/gCuBRTiTolkRWS6KvNxSk4KHZWG7tDktLgjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.3.tgz", + "integrity": "sha512-+Dyo7O1KUmIsbzx1l+4V4tvEVnVQqMOIYtrxK7ncLSknl1xnMHLgn7gddJVrYPNZfEB8CIi3hK8gq8bDhb3h5A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.3.tgz", + "integrity": "sha512-u9Xg2FavYbD30g3DSfNhxgNrxhi6xVG4Y6i9Ur1C7xUuGDW3banRbXj+qgnIrwRN4KeJ396jchwy9bCIzbyBEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.3.tgz", + "integrity": "sha512-5M8kyi/OX96wtD5qJR89a/3x5x8x5inXBZO04JWhkQb2JWavOWfjgkdvUqibGJeNNaz1/Z1PPza5/tAPXICI6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.3.tgz", + "integrity": "sha512-IoerZJ4l1wRMopEHRKOO16e04iXRDyZFZnNZKrWeNquh5d6bucjezgd+OxG03mOMTnS1x7hilzb3uURPkJ0OfA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.3.tgz", + "integrity": "sha512-ZYdtqgHTDfvrJHSh3W22TvjWxwOgc3ThK/XjgcNGP2DIwFIPeAPNsQxrJO5XqleSlgDux2VAoWQ5iJrtaC1TbA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.3.tgz", + "integrity": "sha512-NcViG7A0YtuFDA6xWSgmFb6iPFzHlf5vcqb2p0lGEbT+gjrEEz8nC/EeDHvx6mnGXnGCC1SeVV+8u+smj0CeGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.3.tgz", + "integrity": "sha512-d3pY7LWno6SYNXRm6Ebsq0DJGoiLXTb83AIPCXl9fmtIQs/rXoS8SJxxUNtFbJ5MiOvs+7y34np77+9l4nfFMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.3.tgz", + "integrity": "sha512-3y5GA0JkBuirLqmjwAKwB0keDlI6JfGYduMlJD/Rl7fvb4Ni8iKdQs1eiunMZJhwDWdCvrcqXRY++VEBbvk6Eg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.3.tgz", + "integrity": "sha512-AUUH65a0p3Q0Yfm5oD2KVgzTKgwPyp9DSXc3UA7DtxhEb/WSPfbG4wqXeSN62OG5gSo18em4xv6dbfcUGXcagw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.3.tgz", + "integrity": "sha512-1makPhFFVBqZE+XFg3Dkq+IkQ7JvmUrwwqaYBL2CE+ZpxPaqkGaiWFEWVGyvTwZace6WLJHwjVh/+CXbKDGPmg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.3.tgz", + "integrity": "sha512-OOFJa28dxfl8kLOPMUOQBCO6z3X2SAfzIE276fwT52uXDWUS178KWq0pL7d6p1kz7pkzA0yQwtqL0dEPoVcRWg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.3.tgz", + "integrity": "sha512-jMdsML2VI5l+V7cKfZx3ak+SLlJ8fKvLJ0Eoa4b9/vCUrzXKgoKxvHqvJ/mkWhFiyp88nCkM5S2v6nIwRtPcgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.3.tgz", + "integrity": "sha512-tPgGd6bY2M2LJTA1uGq8fkSPK8ZLYjDjY+ZLK9WHncCnfIz29LIXIqUgzCR0hIefzy6Hpbe8Th5WOSwTM8E7LA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.3.tgz", + "integrity": "sha512-BCFkJjgk+WFzP+tcSMXq77ymAPIxsX9lFJWs+2JzuZTLtksJ2o5hvgTdIcZ5+oKzUDMwI0PfWzRBYAydAHF2Mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.3.tgz", + "integrity": "sha512-KTD/EqjZF3yvRaWUJdD1cW+IQBk4fbQaHYJUmP8N4XoKFZilVL8cobFSTDnjTtxWJQ3JYaMgF4nObY/+nYkumA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.3.tgz", + "integrity": "sha512-+zteHZdoUYLkyYKObGHieibUFLbttX2r+58l27XZauq0tcWYYuKUwY2wjeCN9oK1Um2YgH2ibd6cnX/wFD7DuA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.3.tgz", + "integrity": "sha512-of1iHkTQSo3kr6dTIRX6t81uj/c/b15HXVsPcEElN5sS859qHrOepM5p9G41Hah+CTqSh2r8Bm56dL2z9UQQ7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.3.tgz", + "integrity": "sha512-s0hybmlHb56mWVZQj8ra9048/WZTPLILKxcvcq+8awSZmyiSUZjjem1AhU3Tf4ZKpYhK4mg36HtHDOe8QJS5PQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.3.tgz", + "integrity": "sha512-zGIbEVVXVtauFgl3MRwGWEN36P5ZGenHRMgNw88X5wEhEBpq0XrMEZwOn07+ICrwM17XO5xfMZqh0OldCH5VTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.6.tgz", + "integrity": "sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-static": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.9.tgz", + "integrity": "sha512-aytHXcMi7lb9ljsWUzXYQ0p5X1z9oWud2olu/EpmH7aCu4m84h7QLvb5Wp+CFirKcwoNnYvYWhyP/L8Vh1ztdw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.43.5", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.43.5.tgz", + "integrity": "sha512-44Mm5csR4mesKx2Eyhtk8UVrLJ4c04BT2wMTfYGKJMOkUqpHP5KLL2DPV0hXUA4t4+T3ZYe0aBygd42lVYv2cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/cookie": "^0.6.0", + "acorn": "^8.14.1", + "cookie": "^0.6.0", + "devalue": "^5.3.2", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "sade": "^1.8.1", + "set-cookie-parser": "^2.6.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.1.tgz", + "integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", + "debug": "^4.4.1", + "deepmerge": "^4.3.1", + "magic-string": "^0.30.17", + "vitefu": "^1.1.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.1.tgz", + "integrity": "sha512-ubWshlMk4bc8mkwWbg6vNvCeT7lGQojE3ijDh3QTR6Zr/R+GXxsGbyH4PExEPpiFmqPhYiVSVmHBjUcVc1JIrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", + "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/forms": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.10.tgz", + "integrity": "sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mini-svg-data-uri": "^1.2.3" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.13.tgz", + "integrity": "sha512-eq3ouolC1oEFOAvOMOBAmfCIqZBJuvWvvYWh5h5iOYfe1HFC6+GZ6EIL0JdM3/niGRJmnrOc+8gl9/HGUaaptw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.5.1", + "lightningcss": "1.30.1", + "magic-string": "^0.30.18", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.13" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.13.tgz", + "integrity": "sha512-CPgsM1IpGRa880sMbYmG1s4xhAy3xEt1QULgTJGQmZUeNgXFR7s1YxYygmJyBGtou4SyEosGAGEeYqY7R53bIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.4", + "tar": "^7.4.3" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.13", + "@tailwindcss/oxide-darwin-arm64": "4.1.13", + "@tailwindcss/oxide-darwin-x64": "4.1.13", + "@tailwindcss/oxide-freebsd-x64": "4.1.13", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.13", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.13", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.13", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.13", + "@tailwindcss/oxide-linux-x64-musl": "4.1.13", + "@tailwindcss/oxide-wasm32-wasi": "4.1.13", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.13", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.13" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.13.tgz", + "integrity": "sha512-BrpTrVYyejbgGo57yc8ieE+D6VT9GOgnNdmh5Sac6+t0m+v+sKQevpFVpwX3pBrM2qKrQwJ0c5eDbtjouY/+ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.13.tgz", + "integrity": "sha512-YP+Jksc4U0KHcu76UhRDHq9bx4qtBftp9ShK/7UGfq0wpaP96YVnnjFnj3ZFrUAjc5iECzODl/Ts0AN7ZPOANQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.13.tgz", + "integrity": "sha512-aAJ3bbwrn/PQHDxCto9sxwQfT30PzyYJFG0u/BWZGeVXi5Hx6uuUOQEI2Fa43qvmUjTRQNZnGqe9t0Zntexeuw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.13.tgz", + "integrity": "sha512-Wt8KvASHwSXhKE/dJLCCWcTSVmBj3xhVhp/aF3RpAhGeZ3sVo7+NTfgiN8Vey/Fi8prRClDs6/f0KXPDTZE6nQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.13.tgz", + "integrity": "sha512-mbVbcAsW3Gkm2MGwA93eLtWrwajz91aXZCNSkGTx/R5eb6KpKD5q8Ueckkh9YNboU8RH7jiv+ol/I7ZyQ9H7Bw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.13.tgz", + "integrity": "sha512-wdtfkmpXiwej/yoAkrCP2DNzRXCALq9NVLgLELgLim1QpSfhQM5+ZxQQF8fkOiEpuNoKLp4nKZ6RC4kmeFH0HQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.13.tgz", + "integrity": "sha512-hZQrmtLdhyqzXHB7mkXfq0IYbxegaqTmfa1p9MBj72WPoDD3oNOh1Lnxf6xZLY9C3OV6qiCYkO1i/LrzEdW2mg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.13.tgz", + "integrity": "sha512-uaZTYWxSXyMWDJZNY1Ul7XkJTCBRFZ5Fo6wtjrgBKzZLoJNrG+WderJwAjPzuNZOnmdrVg260DKwXCFtJ/hWRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.13.tgz", + "integrity": "sha512-oXiPj5mi4Hdn50v5RdnuuIms0PVPI/EG4fxAfFiIKQh5TgQgX7oSuDWntHW7WNIi/yVLAiS+CRGW4RkoGSSgVQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.13.tgz", + "integrity": "sha512-+LC2nNtPovtrDwBc/nqnIKYh/W2+R69FA0hgoeOn64BdCX522u19ryLh3Vf3F8W49XBcMIxSe665kwy21FkhvA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.5", + "@emnapi/runtime": "^1.4.5", + "@emnapi/wasi-threads": "^1.0.4", + "@napi-rs/wasm-runtime": "^0.2.12", + "@tybys/wasm-util": "^0.10.0", + "tslib": "^2.8.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.13.tgz", + "integrity": "sha512-dziTNeQXtoQ2KBXmrjCxsuPk3F3CQ/yb7ZNZNA+UkNTeiTGgfeh+gH5Pi7mRncVgcPD2xgHvkFCh/MhZWSgyQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.13.tgz", + "integrity": "sha512-3+LKesjXydTkHk5zXX01b5KMzLV1xl2mcktBJkje7rhFUpUlYJy7IMOLqjIRQncLTa1WZZiFY/foAeB5nmaiTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", + "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.13.tgz", + "integrity": "sha512-0PmqLQ010N58SbMTJ7BVJ4I2xopiQn/5i6nlb4JmxzQf8zcS5+m2Cv6tqh+sfDwtIdjoEnOvwsGQ1hkUi8QEHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.13", + "@tailwindcss/oxide": "4.1.13", + "tailwindcss": "4.1.13" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.18.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.6.tgz", + "integrity": "sha512-r8uszLPpeIWbNKtvWRt/DbVi5zbqZyj1PTmhRMqBMvDnaz1QpmSKujUtJLrqGZeoM8v72MfYggDceY4K1itzWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.44.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.1.tgz", + "integrity": "sha512-molgphGqOBT7t4YKCSkbasmu1tb1MgrZ2szGzHbclF7PNmOkSTQVHy+2jXOSnxvR3+Xe1yySHFZoqMpz3TfQsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.44.1", + "@typescript-eslint/type-utils": "8.44.1", + "@typescript-eslint/utils": "8.44.1", + "@typescript-eslint/visitor-keys": "8.44.1", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.44.1", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.44.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.44.1.tgz", + "integrity": "sha512-EHrrEsyhOhxYt8MTg4zTF+DJMuNBzWwgvvOYNj/zm1vnaD/IC5zCXFehZv94Piqa2cRFfXrTFxIvO95L7Qc/cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.44.1", + "@typescript-eslint/types": "8.44.1", + "@typescript-eslint/typescript-estree": "8.44.1", + "@typescript-eslint/visitor-keys": "8.44.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.44.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.44.1.tgz", + "integrity": "sha512-ycSa60eGg8GWAkVsKV4E6Nz33h+HjTXbsDT4FILyL8Obk5/mx4tbvCNsLf9zret3ipSumAOG89UcCs/KRaKYrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.44.1", + "@typescript-eslint/types": "^8.44.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.44.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.44.1.tgz", + "integrity": "sha512-NdhWHgmynpSvyhchGLXh+w12OMT308Gm25JoRIyTZqEbApiBiQHD/8xgb6LqCWCFcxFtWwaVdFsLPQI3jvhywg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.44.1", + "@typescript-eslint/visitor-keys": "8.44.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.44.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.44.1.tgz", + "integrity": "sha512-B5OyACouEjuIvof3o86lRMvyDsFwZm+4fBOqFHccIctYgBjqR3qT39FBYGN87khcgf0ExpdCBeGKpKRhSFTjKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.44.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.44.1.tgz", + "integrity": "sha512-KdEerZqHWXsRNKjF9NYswNISnFzXfXNDfPxoTh7tqohU/PRIbwTmsjGK6V9/RTYWau7NZvfo52lgVk+sJh0K3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.44.1", + "@typescript-eslint/typescript-estree": "8.44.1", + "@typescript-eslint/utils": "8.44.1", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.44.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.44.1.tgz", + "integrity": "sha512-Lk7uj7y9uQUOEguiDIDLYLJOrYHQa7oBiURYVFqIpGxclAFQ78f6VUOM8lI2XEuNOKNB7XuvM2+2cMXAoq4ALQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.44.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.44.1.tgz", + "integrity": "sha512-qnQJ+mVa7szevdEyvfItbO5Vo+GfZ4/GZWWDRRLjrxYPkhM+6zYB2vRYwCsoJLzqFCdZT4mEqyJoyzkunsZ96A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.44.1", + "@typescript-eslint/tsconfig-utils": "8.44.1", + "@typescript-eslint/types": "8.44.1", + "@typescript-eslint/visitor-keys": "8.44.1", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.44.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.44.1.tgz", + "integrity": "sha512-DpX5Fp6edTlocMCwA+mHY8Mra+pPjRZ0TfHkXI8QFelIKcbADQz1LUPNtzOFUriBB2UYqw4Pi9+xV4w9ZczHFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.44.1", + "@typescript-eslint/types": "8.44.1", + "@typescript-eslint/typescript-estree": "8.44.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.44.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.44.1.tgz", + "integrity": "sha512-576+u0QD+Jp3tZzvfRfxon0EA2lzcDt3lhUbsC6Lgzy9x2VR4E+JUiNyGHi5T8vk0TV+fpJ5GLG1JsJuWCaKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.44.1", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/bits-ui": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.11.4.tgz", + "integrity": "sha512-OlVBJhNUMDHbIAf8oDAyPchIrU8b1S5NAMm6enMZSKx5HKcf/QPI485/BL1r4EPlv4O3m45e59hBRCETtYFdxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.1", + "@floating-ui/dom": "^1.7.1", + "esm-env": "^1.1.2", + "runed": "^0.31.1", + "svelte-toolbelt": "^0.10.4", + "tabbable": "^6.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/huntabyte" + }, + "peerDependencies": { + "@internationalized/date": "^3.8.1", + "svelte": "^5.33.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/case-anything": { + "version": "2.1.13", + "resolved": "https://registry.npmjs.org/case-anything/-/case-anything-2.1.13.tgz", + "integrity": "sha512-zlOQ80VrQ2Ue+ymH5OuM/DlDq64mEm+B9UTdHULv5osUMD6HalNTblf2b1u/m6QecjsnOkBpqVZ+XPwIVsy7Ng==", + "license": "MIT", + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/cli-color": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/cli-color/-/cli-color-2.0.4.tgz", + "integrity": "sha512-zlnpg0jNcibNrO7GG9IeHH7maWFeCz+Ja1wx/7tZNU5ASSSSZ+/qZciM0/LHCYxSdqv5h2sdbQ/PXYdOuetXvA==", + "license": "ISC", + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.64", + "es6-iterator": "^2.0.3", + "memoizee": "^0.4.15", + "timers-ext": "^0.1.7" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/d": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", + "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", + "license": "ISC", + "dependencies": { + "es5-ext": "^0.10.64", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.1.tgz", + "integrity": "sha512-ecqj/sy1jcK1uWrwpR67UhYrIFQ+5WlGxth34WquCbamhFA6hkkwiu37o6J5xCHdo1oixJRfVRw+ywV+Hq/0Aw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.3.2.tgz", + "integrity": "sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw==", + "dev": true, + "license": "MIT" + }, + "node_modules/dprint-node": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/dprint-node/-/dprint-node-1.0.8.tgz", + "integrity": "sha512-iVKnUtYfGrYcW1ZAlfR/F59cUVL8QIhWoBJoSjkkdua/dkWIgjZfiLMeTjiB06X0ZLkQ0M2C1VbUj/CxkIf1zg==", + "license": "MIT", + "dependencies": { + "detect-libc": "^1.0.3" + } + }, + "node_modules/dprint-node/node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "license": "Apache-2.0", + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es5-ext": { + "version": "0.10.64", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", + "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.3", + "esniff": "^2.0.1", + "next-tick": "^1.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/es6-symbol": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", + "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.2", + "ext": "^1.7.0" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/es6-weak-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.3.tgz", + "integrity": "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==", + "license": "ISC", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.46", + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/esbuild": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", + "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.10", + "@esbuild/android-arm": "0.25.10", + "@esbuild/android-arm64": "0.25.10", + "@esbuild/android-x64": "0.25.10", + "@esbuild/darwin-arm64": "0.25.10", + "@esbuild/darwin-x64": "0.25.10", + "@esbuild/freebsd-arm64": "0.25.10", + "@esbuild/freebsd-x64": "0.25.10", + "@esbuild/linux-arm": "0.25.10", + "@esbuild/linux-arm64": "0.25.10", + "@esbuild/linux-ia32": "0.25.10", + "@esbuild/linux-loong64": "0.25.10", + "@esbuild/linux-mips64el": "0.25.10", + "@esbuild/linux-ppc64": "0.25.10", + "@esbuild/linux-riscv64": "0.25.10", + "@esbuild/linux-s390x": "0.25.10", + "@esbuild/linux-x64": "0.25.10", + "@esbuild/netbsd-arm64": "0.25.10", + "@esbuild/netbsd-x64": "0.25.10", + "@esbuild/openbsd-arm64": "0.25.10", + "@esbuild/openbsd-x64": "0.25.10", + "@esbuild/openharmony-arm64": "0.25.10", + "@esbuild/sunos-x64": "0.25.10", + "@esbuild/win32-arm64": "0.25.10", + "@esbuild/win32-ia32": "0.25.10", + "@esbuild/win32-x64": "0.25.10" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.36.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.36.0.tgz", + "integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.1", + "@eslint/core": "^0.15.2", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.36.0", + "@eslint/plugin-kit": "^0.3.5", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-svelte": { + "version": "3.12.4", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.12.4.tgz", + "integrity": "sha512-hD7wPe+vrPgx3U2X2b/wyTMtWobm660PygMGKrWWYTc9lvtY8DpNFDaU2CJQn1szLjGbn/aJ3g8WiXuKakrEkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.6.1", + "@jridgewell/sourcemap-codec": "^1.5.0", + "esutils": "^2.0.3", + "globals": "^16.0.0", + "known-css-properties": "^0.37.0", + "postcss": "^8.4.49", + "postcss-load-config": "^3.1.4", + "postcss-safe-parser": "^7.0.0", + "semver": "^7.6.3", + "svelte-eslint-parser": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + }, + "peerDependencies": { + "eslint": "^8.57.1 || ^9.0.0", + "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/@eslint/core": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT" + }, + "node_modules/esniff": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", + "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.62", + "event-emitter": "^0.3.5", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrap": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.0.tgz", + "integrity": "sha512-yzmPNpl7TBbMRC5Lj2JlJZNPml0tzqoqP5B1JXycNUwtqma9AKCO0M2wHrdgsHcy1WRW7S9rJknAMtByg3usgA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, + "node_modules/ext": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", + "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", + "license": "ISC", + "dependencies": { + "type": "^2.7.2" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", + "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalyzer": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.0.tgz", + "integrity": "sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==", + "license": "MIT" + }, + "node_modules/globrex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", + "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", + "license": "MIT" + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz", + "integrity": "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/intl-messageformat": { + "version": "10.7.16", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.16.tgz", + "integrity": "sha512-UmdmHUmp5CIKKjSoE10la5yfU+AYJAaiYLsodbjL4lji83JNvgOQUjGaGhGrpFCb0Uh7sl7qfP1IyILa8Z40ug==", + "license": "BSD-3-Clause", + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.4", + "@formatjs/fast-memoize": "2.2.7", + "@formatjs/icu-messageformat-parser": "2.11.2", + "tslib": "^2.8.0" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", + "license": "MIT" + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.0.tgz", + "integrity": "sha512-VXe6RjJkBPj0ohtqaO8vSWP3ZhAKo66fKrFNCll4BTcwljPLz03pCbaNKfzGP5MbrCYcbJ7v0nOYYwUzTEIdXQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/known-css-properties": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz", + "integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", + "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-darwin-arm64": "1.30.1", + "lightningcss-darwin-x64": "1.30.1", + "lightningcss-freebsd-x64": "1.30.1", + "lightningcss-linux-arm-gnueabihf": "1.30.1", + "lightningcss-linux-arm64-gnu": "1.30.1", + "lightningcss-linux-arm64-musl": "1.30.1", + "lightningcss-linux-x64-gnu": "1.30.1", + "lightningcss-linux-x64-musl": "1.30.1", + "lightningcss-win32-arm64-msvc": "1.30.1", + "lightningcss-win32-x64-msvc": "1.30.1" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", + "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", + "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", + "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", + "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", + "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", + "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", + "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", + "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", + "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", + "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/lru-queue/-/lru-queue-0.1.0.tgz", + "integrity": "sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ==", + "license": "MIT", + "dependencies": { + "es5-ext": "~0.10.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.19", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", + "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/memoizee": { + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.17.tgz", + "integrity": "sha512-DGqD7Hjpi/1or4F/aYAspXKNm5Yili0QDAFAY4QYvpqpgiY6+1jOfqpmByzjxbWd/T9mChbCArXAbDAsTm5oXA==", + "license": "ISC", + "dependencies": { + "d": "^1.0.2", + "es5-ext": "^0.10.64", + "es6-weak-map": "^2.0.3", + "event-emitter": "^0.3.5", + "is-promise": "^2.2.2", + "lru-queue": "^0.1.0", + "next-tick": "^1.1.0", + "timers-ext": "^0.1.7" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "dev": true, + "license": "MIT", + "bin": { + "mini-svg-data-uri": "cli.js" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mode-watcher": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/mode-watcher/-/mode-watcher-1.1.0.tgz", + "integrity": "sha512-mUT9RRGPDYenk59qJauN1rhsIMKBmWA3xMF+uRwE8MW/tjhaDSCCARqkSuDTq8vr4/2KcAxIGVjACxTjdk5C3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "runed": "^0.25.0", + "svelte-toolbelt": "^0.7.1" + }, + "peerDependencies": { + "svelte": "^5.27.0" + } + }, + "node_modules/mode-watcher/node_modules/runed": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.25.0.tgz", + "integrity": "sha512-7+ma4AG9FT2sWQEA0Egf6mb7PBT2vHyuHail1ie8ropfSjvZGtEAx8YTmUjv/APCsdRRxEVvArNjALk9zFSOrg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "dependencies": { + "esm-env": "^1.0.0" + }, + "peerDependencies": { + "svelte": "^5.7.0" + } + }, + "node_modules/mode-watcher/node_modules/svelte-toolbelt": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.7.1.tgz", + "integrity": "sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/huntabyte" + ], + "dependencies": { + "clsx": "^2.1.1", + "runed": "^0.23.2", + "style-to-object": "^1.0.8" + }, + "engines": { + "node": ">=18", + "pnpm": ">=8.7.0" + }, + "peerDependencies": { + "svelte": "^5.0.0" + } + }, + "node_modules/mode-watcher/node_modules/svelte-toolbelt/node_modules/runed": { + "version": "0.23.4", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.23.4.tgz", + "integrity": "sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "dependencies": { + "esm-env": "^1.0.0" + }, + "peerDependencies": { + "svelte": "^5.7.0" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/next-tick": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", + "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", + "license": "ISC" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-load-config": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz", + "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lilconfig": "^2.0.5", + "yaml": "^1.10.2" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-load-config/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss-safe-parser": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", + "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-scss": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.9.tgz", + "integrity": "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-scss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.4.29" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-svelte": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.4.0.tgz", + "integrity": "sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "prettier": "^3.0.0", + "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" + } + }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.6.14", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz", + "integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.21.3" + }, + "peerDependencies": { + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-hermes": "*", + "@prettier/plugin-oxc": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig": "*", + "prettier": "^3.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-import-sort": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", + "prettier-plugin-multiline-arrays": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", + "prettier-plugin-style-order": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-hermes": { + "optional": true + }, + "@prettier/plugin-oxc": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-import-sort": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-multiline-arrays": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-style-order": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "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/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.3.tgz", + "integrity": "sha512-RIDh866U8agLgiIcdpB+COKnlCreHJLfIhWC3LVflku5YHfpnsIKigRZeFfMfCc4dVcqNVfQQ5gO/afOck064A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.52.3", + "@rollup/rollup-android-arm64": "4.52.3", + "@rollup/rollup-darwin-arm64": "4.52.3", + "@rollup/rollup-darwin-x64": "4.52.3", + "@rollup/rollup-freebsd-arm64": "4.52.3", + "@rollup/rollup-freebsd-x64": "4.52.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.3", + "@rollup/rollup-linux-arm-musleabihf": "4.52.3", + "@rollup/rollup-linux-arm64-gnu": "4.52.3", + "@rollup/rollup-linux-arm64-musl": "4.52.3", + "@rollup/rollup-linux-loong64-gnu": "4.52.3", + "@rollup/rollup-linux-ppc64-gnu": "4.52.3", + "@rollup/rollup-linux-riscv64-gnu": "4.52.3", + "@rollup/rollup-linux-riscv64-musl": "4.52.3", + "@rollup/rollup-linux-s390x-gnu": "4.52.3", + "@rollup/rollup-linux-x64-gnu": "4.52.3", + "@rollup/rollup-linux-x64-musl": "4.52.3", + "@rollup/rollup-openharmony-arm64": "4.52.3", + "@rollup/rollup-win32-arm64-msvc": "4.52.3", + "@rollup/rollup-win32-ia32-msvc": "4.52.3", + "@rollup/rollup-win32-x64-gnu": "4.52.3", + "@rollup/rollup-win32-x64-msvc": "4.52.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/runed": { + "version": "0.31.1", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.31.1.tgz", + "integrity": "sha512-v3czcTnO+EJjiPvD4dwIqfTdHLZ8oH0zJheKqAHh9QMViY7Qb29UlAMRpX7ZtHh7AFqV60KmfxaJ9QMy+L1igQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "license": "MIT", + "dependencies": { + "esm-env": "^1.0.0" + }, + "peerDependencies": { + "svelte": "^5.7.0" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/singlefile": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/singlefile/-/singlefile-0.0.0.tgz", + "integrity": "sha512-OBf0xxNWd07ZDM0dcpK1jq4AvLmS1dUWDSgUomMBLPr6qU/aut4rCTDZVEi2itqHWfAYQepzkxRI5DUyWPAnkw==", + "dev": true, + "license": "MIT" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/style-to-object": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.9.tgz", + "integrity": "sha512-G4qppLgKu/k6FwRpHiGiKPaPTFcG3g4wNVX/Qsfu+RqQM30E7Tyu/TEgxcL9PNLF5pdRLwQdE3YKKf+KF2Dzlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.4" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/svelte": { + "version": "5.39.6", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.39.6.tgz", + "integrity": "sha512-bOJXmuwLNaoqPCTWO8mPu/fwxI5peGE5Efe7oo6Cakpz/G60vsnVF6mxbGODaxMUFUKEnjm6XOwHEqOht6cbvw==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "acorn": "^8.12.1", + "aria-query": "^5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "esm-env": "^1.2.1", + "esrap": "^2.1.0", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.3.2.tgz", + "integrity": "sha512-71udP5w2kaSTcX8iV0hn3o2FWlabQHhJTJLIQrCqMsrcOeDUO2VhCQKKCA8AMVHSPwdxLEWkUWh9OKxns5PD9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/svelte-eslint-parser": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.3.3.tgz", + "integrity": "sha512-oTrDR8Z7Wnguut7QH3YKh7JR19xv1seB/bz4dxU5J/86eJtZOU4eh0/jZq4dy6tAlz/KROxnkRQspv5ZEt7t+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-scope": "^8.2.0", + "eslint-visitor-keys": "^4.0.0", + "espree": "^10.0.0", + "postcss": "^8.4.49", + "postcss-scss": "^4.0.9", + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + }, + "peerDependencies": { + "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + } + } + }, + "node_modules/svelte-eslint-parser/node_modules/postcss-selector-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", + "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/svelte-i18n": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/svelte-i18n/-/svelte-i18n-4.0.1.tgz", + "integrity": "sha512-jaykGlGT5PUaaq04JWbJREvivlCnALtT+m87Kbm0fxyYHynkQaxQMnIKHLm2WeIuBRoljzwgyvz0Z6/CMwfdmQ==", + "license": "MIT", + "dependencies": { + "cli-color": "^2.0.3", + "deepmerge": "^4.2.2", + "esbuild": "^0.19.2", + "estree-walker": "^2", + "intl-messageformat": "^10.5.3", + "sade": "^1.8.1", + "tiny-glob": "^0.2.9" + }, + "bin": { + "svelte-i18n": "dist/cli.js" + }, + "engines": { + "node": ">= 16" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5" + } + }, + "node_modules/svelte-i18n/node_modules/@esbuild/aix-ppc64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", + "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/svelte-i18n/node_modules/@esbuild/android-arm": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", + "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/svelte-i18n/node_modules/@esbuild/android-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", + "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/svelte-i18n/node_modules/@esbuild/android-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", + "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/svelte-i18n/node_modules/@esbuild/darwin-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", + "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/svelte-i18n/node_modules/@esbuild/darwin-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", + "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/svelte-i18n/node_modules/@esbuild/freebsd-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", + "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/svelte-i18n/node_modules/@esbuild/freebsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", + "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/svelte-i18n/node_modules/@esbuild/linux-arm": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", + "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/svelte-i18n/node_modules/@esbuild/linux-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", + "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/svelte-i18n/node_modules/@esbuild/linux-ia32": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", + "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/svelte-i18n/node_modules/@esbuild/linux-loong64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", + "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/svelte-i18n/node_modules/@esbuild/linux-mips64el": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", + "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/svelte-i18n/node_modules/@esbuild/linux-ppc64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", + "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/svelte-i18n/node_modules/@esbuild/linux-riscv64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", + "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/svelte-i18n/node_modules/@esbuild/linux-s390x": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", + "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/svelte-i18n/node_modules/@esbuild/linux-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", + "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/svelte-i18n/node_modules/@esbuild/netbsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", + "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/svelte-i18n/node_modules/@esbuild/openbsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", + "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/svelte-i18n/node_modules/@esbuild/sunos-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", + "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/svelte-i18n/node_modules/@esbuild/win32-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", + "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/svelte-i18n/node_modules/@esbuild/win32-ia32": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", + "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/svelte-i18n/node_modules/@esbuild/win32-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", + "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/svelte-i18n/node_modules/esbuild": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", + "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.19.12", + "@esbuild/android-arm": "0.19.12", + "@esbuild/android-arm64": "0.19.12", + "@esbuild/android-x64": "0.19.12", + "@esbuild/darwin-arm64": "0.19.12", + "@esbuild/darwin-x64": "0.19.12", + "@esbuild/freebsd-arm64": "0.19.12", + "@esbuild/freebsd-x64": "0.19.12", + "@esbuild/linux-arm": "0.19.12", + "@esbuild/linux-arm64": "0.19.12", + "@esbuild/linux-ia32": "0.19.12", + "@esbuild/linux-loong64": "0.19.12", + "@esbuild/linux-mips64el": "0.19.12", + "@esbuild/linux-ppc64": "0.19.12", + "@esbuild/linux-riscv64": "0.19.12", + "@esbuild/linux-s390x": "0.19.12", + "@esbuild/linux-x64": "0.19.12", + "@esbuild/netbsd-x64": "0.19.12", + "@esbuild/openbsd-x64": "0.19.12", + "@esbuild/sunos-x64": "0.19.12", + "@esbuild/win32-arm64": "0.19.12", + "@esbuild/win32-ia32": "0.19.12", + "@esbuild/win32-x64": "0.19.12" + } + }, + "node_modules/svelte-sonner": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/svelte-sonner/-/svelte-sonner-1.0.5.tgz", + "integrity": "sha512-9dpGPFqKb/QWudYqGnEz93vuY+NgCEvyNvxoCLMVGw6sDN/3oVeKV1xiEirW2E1N3vJEyj5imSBNOGltQHA7mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "runed": "^0.28.0" + }, + "peerDependencies": { + "svelte": "^5.0.0" + } + }, + "node_modules/svelte-sonner/node_modules/runed": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.28.0.tgz", + "integrity": "sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "license": "MIT", + "dependencies": { + "esm-env": "^1.0.0" + }, + "peerDependencies": { + "svelte": "^5.7.0" + } + }, + "node_modules/svelte-toolbelt": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.5.tgz", + "integrity": "sha512-8e+eWTgxw1aiLxhDE8Rb1X6AoLitqpJz+WhAul2W7W58C8KoLoJQf1TgQdFPBiCPJ0Jg5y0Zi1uyua9em4VS0w==", + "dev": true, + "funding": [ + "https://github.com/sponsors/huntabyte" + ], + "dependencies": { + "clsx": "^2.1.1", + "runed": "^0.29.0", + "style-to-object": "^1.0.8" + }, + "engines": { + "node": ">=18", + "pnpm": ">=8.7.0" + }, + "peerDependencies": { + "svelte": "^5.30.2" + } + }, + "node_modules/svelte-toolbelt/node_modules/runed": { + "version": "0.29.2", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.29.2.tgz", + "integrity": "sha512-0cq6cA6sYGZwl/FvVqjx9YN+1xEBu9sDDyuWdDW1yWX7JF2wmvmVKfH+hVCZs+csW+P3ARH92MjI3H9QTagOQA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "license": "MIT", + "dependencies": { + "esm-env": "^1.0.0" + }, + "peerDependencies": { + "svelte": "^5.7.0" + } + }, + "node_modules/tabbable": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwind-merge": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz", + "integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwind-variants": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.1.1.tgz", + "integrity": "sha512-ftLXe3krnqkMHsuBTEmaVUXYovXtPyTK7ckEfDRXS8PBZx0bAUas+A0jYxuKA5b8qg++wvQ3d2MQ7l/xeZxbZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.x", + "pnpm": ">=7.x" + }, + "peerDependencies": { + "tailwind-merge": ">=3.0.0", + "tailwindcss": "*" + }, + "peerDependenciesMeta": { + "tailwind-merge": { + "optional": true + } + } + }, + "node_modules/tailwindcss": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.13.tgz", + "integrity": "sha512-i+zidfmTqtwquj4hMEwdjshYYgMbOrPzb9a0M3ZgNa0JMoZeFC6bxZvO8yr8ozS6ix2SDz0+mvryPeBs2TFE+w==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.3.tgz", + "integrity": "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tar": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.1.tgz", + "integrity": "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==", + "dev": true, + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/timers-ext": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/timers-ext/-/timers-ext-0.1.8.tgz", + "integrity": "sha512-wFH7+SEAcKfJpfLPkrgMPvvwnEtj8W4IurvEyrKsDleXnKLCDw71w8jltvfLa8Rm4qQxxT4jmDBYbJG/z7qoww==", + "license": "ISC", + "dependencies": { + "es5-ext": "^0.10.64", + "next-tick": "^1.1.0" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/tiny-glob": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz", + "integrity": "sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==", + "license": "MIT", + "dependencies": { + "globalyzer": "0.1.0", + "globrex": "^0.1.2" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-poet": { + "version": "6.12.0", + "resolved": "https://registry.npmjs.org/ts-poet/-/ts-poet-6.12.0.tgz", + "integrity": "sha512-xo+iRNMWqyvXpFTaOAvLPA5QAWO6TZrSUs5s4Odaya3epqofBu/fMLHEWl8jPmjhA0s9sgj9sNvF1BmaQlmQkA==", + "license": "Apache-2.0", + "dependencies": { + "dprint-node": "^1.0.8" + } + }, + "node_modules/ts-proto": { + "version": "2.7.7", + "resolved": "https://registry.npmjs.org/ts-proto/-/ts-proto-2.7.7.tgz", + "integrity": "sha512-/OfN9/Yriji2bbpOysZ/Jzc96isOKz+eBTJEcKaIZ0PR6x1TNgVm4Lz0zfbo+J0jwFO7fJjJyssefBPQ0o1V9A==", + "license": "ISC", + "dependencies": { + "@bufbuild/protobuf": "^2.0.0", + "case-anything": "^2.1.13", + "ts-poet": "^6.12.0", + "ts-proto-descriptors": "2.0.0" + }, + "bin": { + "protoc-gen-ts_proto": "protoc-gen-ts_proto" + } + }, + "node_modules/ts-proto-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-proto-descriptors/-/ts-proto-descriptors-2.0.0.tgz", + "integrity": "sha512-wHcTH3xIv11jxgkX5OyCSFfw27agpInAd6yh89hKG6zqIXnjW9SYqSER2CVQxdPj4czeOhGagNvZBEbJPy7qkw==", + "license": "ISC", + "dependencies": { + "@bufbuild/protobuf": "^2.0.0" + } + }, + "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/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, + "node_modules/type": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", + "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==", + "license": "ISC" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.44.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.44.1.tgz", + "integrity": "sha512-0ws8uWGrUVTjEeN2OM4K1pLKHK/4NiNP/vz6ns+LjT/6sqpaYzIVFajZb1fj/IDwpsrrHb3Jy0Qm5u9CPcKaeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.44.1", + "@typescript-eslint/parser": "8.44.1", + "@typescript-eslint/typescript-estree": "8.44.1", + "@typescript-eslint/utils": "8.44.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.1.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.7.tgz", + "integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-compression": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/vite-plugin-compression/-/vite-plugin-compression-0.5.1.tgz", + "integrity": "sha512-5QJKBDc+gNYVqL/skgFAP81Yuzo9R+EAf19d+EtsMF/i8kFUpNi3J/H01QD3Oo8zBQn+NzoCIFkpPLynoOzaJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "debug": "^4.3.3", + "fs-extra": "^10.0.0" + }, + "peerDependencies": { + "vite": ">=2.0.0" + } + }, + "node_modules/vite-plugin-singlefile": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/vite-plugin-singlefile/-/vite-plugin-singlefile-2.3.0.tgz", + "integrity": "sha512-DAcHzYypM0CasNLSz/WG0VdKOCxGHErfrjOoyIPiNxTPTGmO6rRD/te93n1YL/s+miXq66ipF1brMBikf99c6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">18.0.0" + }, + "peerDependencies": { + "rollup": "^4.44.1", + "vite": "^5.4.11 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/vitefu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", + "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "license": "MIT" + } + } +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..d4be74c --- /dev/null +++ b/ui/package.json @@ -0,0 +1,58 @@ +{ + "name": "ui", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "format": "prettier --write .", + "lint": "prettier --check . && eslint .", + "copy": "node scripts/copy-web-files.js", + "proto": "protoc --plugin=./node_modules/.bin/protoc-gen-ts_proto.cmd --ts_proto_out=./src/lib/gen --ts_proto_opt=useOptionals=messages,outputEncodeMethods=true,outputJsonMethods=false,outputClientImpl=true,addGrpcMetadata=false --proto_path=../proto ../proto/*.proto" + }, + "devDependencies": { + "@eslint/compat": "^1.2.5", + "@eslint/js": "^9.22.0", + "@internationalized/date": "^3.9.0", + "@lucide/svelte": "^0.544.0", + "@sveltejs/adapter-static": "^3.0.8", + "@sveltejs/kit": "^2.22.0", + "@sveltejs/vite-plugin-svelte": "^6.0.0", + "@tailwindcss/forms": "^0.5.9", + "@tailwindcss/typography": "^0.5.15", + "@tailwindcss/vite": "^4.0.0", + "@types/node": "^22", + "bits-ui": "^2.11.4", + "clsx": "^2.1.1", + "eslint": "^9.22.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-svelte": "^3.0.0", + "globals": "^16.0.0", + "mode-watcher": "^1.1.0", + "prettier": "^3.4.2", + "prettier-plugin-svelte": "^3.3.3", + "prettier-plugin-tailwindcss": "^0.6.11", + "singlefile": "^0.0.0", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "svelte-sonner": "^1.0.5", + "tailwind-merge": "^3.3.1", + "tailwind-variants": "^3.1.1", + "tailwindcss": "^4.0.0", + "tw-animate-css": "^1.4.0", + "typescript": "^5.0.0", + "typescript-eslint": "^8.20.0", + "vite": "^7.0.4", + "vite-plugin-compression": "^0.5.1", + "vite-plugin-singlefile": "^2.3.0" + }, + "dependencies": { + "svelte-i18n": "^4.0.1", + "ts-proto": "^2.7.7" + } +} diff --git a/ui/scripts/copy-web-files.js b/ui/scripts/copy-web-files.js new file mode 100644 index 0000000..ba37937 --- /dev/null +++ b/ui/scripts/copy-web-files.js @@ -0,0 +1,93 @@ +#!/usr/bin/env node + +import fs from 'fs'; +import path from 'path'; +import zlib from 'zlib'; +import { fileURLToPath } from 'url'; +import { pipeline } from 'stream/promises'; + +// --- Konfiguration --- +const GZIP_OPTIONS = { level: zlib.constants.Z_BEST_COMPRESSION }; +// -------------------- + +/** + * Kopiert und komprimiert nur die index.html Datei von sourceDir nach destDir. + * Die Datei wird nur als .gz gespeichert. + * + * @param {string} sourceDir - Quellordner + * @param {string} destDir - Zielordner + */ +async function processIndexHtml(sourceDir, destDir) { + console.log(`Processing index.html from '${sourceDir}' to '${destDir}'`); + + // Erstelle Zielverzeichnis, wenn es nicht existiert + if (!fs.existsSync(destDir)) { + fs.mkdirSync(destDir, { recursive: true }); + } + + const sourcePath = path.join(sourceDir, 'index.html'); + const destPathGz = path.join(destDir, 'index.html.gz'); + + // Prüfe ob index.html existiert + if (!fs.existsSync(sourcePath)) { + throw new Error(`index.html not found in '${sourceDir}'`); + } + + // Komprimiere index.html + console.log(`Compressing ${sourcePath} -> ${destPathGz}`); + try { + const readStream = fs.createReadStream(sourcePath); + const gzipStream = zlib.createGzip(GZIP_OPTIONS); + const writeStream = fs.createWriteStream(destPathGz); + await pipeline(readStream, gzipStream, writeStream); + } catch (err) { + console.error(`Error compressing ${sourcePath}:`, err); + throw err; + } +} + +/** + * Lösche den Ordner und seinen gesamten Inhalt + * + * @param {string} dir - Der zu löschende Ordner + */ +function removeDir(dir) { + if (fs.existsSync(dir)) { + console.log(`Removing existing directory '${dir}'`); + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +// Hauptfunktion +async function main() { + // Projekt-Root-Verzeichnis ermitteln + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + + const scriptDir = __dirname; + const projectRoot = path.resolve(scriptDir, '..'); + const rootDir = path.resolve(projectRoot, '..'); + + // Quell- und Zielverzeichnisse + const sourceDir = path.join(projectRoot, 'build'); + const destDir = path.join(rootDir, 'data', 'web'); + + if (!fs.existsSync(sourceDir) || !fs.statSync(sourceDir).isDirectory()) { + console.error(`Error: Source directory '${sourceDir}' doesn't exist or is not a directory!`); + console.error("Make sure to build the Svelte app first with 'npm run build'"); + process.exit(1); + } + + removeDir(destDir); + + try { + await processIndexHtml(sourceDir, destDir); + console.log("index.html processed successfully!"); + } catch(err) { + console.error("Error during file processing:", err); + process.exit(1); + } +} + +// Ausführen des Skripts +main(); \ No newline at end of file diff --git a/ui/src/app.css b/ui/src/app.css new file mode 100644 index 0000000..ee2d8d5 --- /dev/null +++ b/ui/src/app.css @@ -0,0 +1,121 @@ +@import "tailwindcss"; + +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.141 0.005 285.823); + --card: oklch(1 0 0); + --card-foreground: oklch(0.141 0.005 285.823); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.21 0.006 285.885); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.705 0.015 286.067); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.141 0.005 285.823); + --sidebar-primary: oklch(0.21 0.006 285.885); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.967 0.001 286.375); + --sidebar-accent-foreground: oklch(0.21 0.006 285.885); + --sidebar-border: oklch(0.92 0.004 286.32); + --sidebar-ring: oklch(0.705 0.015 286.067); +} + +.dark { + --background: oklch(0.141 0.005 285.823); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.92 0.004 286.32); + --primary-foreground: oklch(0.21 0.006 285.885); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.552 0.016 285.938); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.21 0.006 285.885); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.552 0.016 285.938); +} + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} \ No newline at end of file diff --git a/ui/src/app.d.ts b/ui/src/app.d.ts new file mode 100644 index 0000000..da08e6d --- /dev/null +++ b/ui/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/ui/src/app.html b/ui/src/app.html new file mode 100644 index 0000000..f273cc5 --- /dev/null +++ b/ui/src/app.html @@ -0,0 +1,11 @@ + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/ui/src/hooks.server.ts b/ui/src/hooks.server.ts new file mode 100644 index 0000000..8ce0a5d --- /dev/null +++ b/ui/src/hooks.server.ts @@ -0,0 +1,278 @@ +import { + GetSettingsResponse, + SettingsData, + UpdateSettingsRequest, + UpdateSettingsResponse, + WifiMode +} from '$lib/gen/settings'; +import { DeviceInfo } from '$lib/gen/device'; +import { + AddUserRequest, + AddUserResponse, + DeleteUserRequest, + DeleteUserResponse, + GetUsersResponse, + UpdateUserPasswordRequest, + UpdateUserPasswordResponse, + WebUiLoginRequest, + WebUiLoginResponse +} from '$lib/gen/webui'; +import type { Handle } from '@sveltejs/kit'; + +// ESP32 proxy URL - comment out this line to use mock API instead +const ESP32_URL = 'http://192.168.20.38'; + +// Mock settings storage (in-memory for development) +let mockSettings = SettingsData.create({ + syncServerUrl: 'http://mock-server.com', + syncIntervalSeconds: 1800, // 30 minutes in seconds + autoSync: true, + wifiMode: WifiMode.WIFI_MODE_STATION, + stationSsid: 'MockWiFi', + stationPassword: 'password123', + apSsid: 'ESP32-AP', + apPassword: 'ap123456', + apChannel: 6, + enableFallbackAp: true, + updatedAt: Date.now(), + version: 1 +}); + +// Mock users storage (in-memory for development, max 5 users) +let mockUsers: WebUiLoginRequest[] = [ + WebUiLoginRequest.create({ username: 'admin', password: 'password' }), + WebUiLoginRequest.create({ username: 'user1', password: 'pass123' }) +]; + +export const handle: Handle = async ({ event, resolve }) => { + // If ESP32_URL is defined, proxy to ESP32, otherwise use mock + if (ESP32_URL && event.url.pathname.startsWith('/api/')) { + const esp32Url = ESP32_URL + event.url.pathname; + + try { + let body: ArrayBuffer | undefined; + if (event.request.method !== 'GET') { + body = await event.request.arrayBuffer(); + } + + const headers: Record = { + 'Content-Type': 'application/octet-stream' + }; + + // Forward Authorization header if present + const authHeader = event.request.headers.get('Authorization'); + if (authHeader) { + headers['Authorization'] = authHeader; + console.log('Authorization:', authHeader); + } + + const response = await fetch(esp32Url, { + method: event.request.method, + headers, + body: body + }); + + if (!response.ok) { + return new Response(response.statusText, { status: response.status }); + } + + const responseData = await response.arrayBuffer(); + + return new Response(responseData, { + headers: { + 'Content-Type': 'application/octet-stream' + } + }); + } catch (error) { + console.error('ESP32 proxy error:', error); + return new Response('ESP32 connection failed', { status: 502 }); + } + } + + // Mock the ESP32 API endpoints (when ESP32_URL is not set) + if (event.url.pathname === '/api/login' && event.request.method === 'POST') { + // Get the binary data + const buffer = await event.request.arrayBuffer(); + const uint8Array = new Uint8Array(buffer); + + // Decode the protobuf request + const loginReq = WebUiLoginRequest.decode(uint8Array); + + // Simple authentication (same as ESP32) + const success = loginReq.username === 'admin' && loginReq.password === 'password'; + + // Create response + const loginResp = WebUiLoginResponse.create({ + success, + token: success ? 'mock_token' : undefined, + error: success ? undefined : 'Invalid credentials' + }); + + // Encode response + const responseData = WebUiLoginResponse.encode(loginResp).finish(); + + return new Response(responseData, { + headers: { + 'Content-Type': 'application/octet-stream' + } + }); + } + + // Mock settings endpoints + if (event.url.pathname === '/api/settings') { + if (event.request.method === 'GET') { + // Mock get settings using global mockSettings + const mockDeviceInfo = DeviceInfo.create({ + deviceId: 'ESP32-001', + firmwareVersion: '1.0.0-mock', + hardwareVersion: 'ESP32-v1', + uptimeSeconds: 123 + }); + + const getResp = GetSettingsResponse.create({ + settings: mockSettings + }); + + const responseData = GetSettingsResponse.encode(getResp).finish(); + + return new Response(responseData, { + headers: { + 'Content-Type': 'application/octet-stream' + } + }); + } else if (event.request.method === 'POST') { + // Mock update settings + const buffer = await event.request.arrayBuffer(); + const uint8Array = new Uint8Array(buffer); + + // Decode the protobuf request + const updateReq = UpdateSettingsRequest.decode(uint8Array); + + // Update the global mock settings + mockSettings = SettingsData.create({ + ...mockSettings, + ...updateReq.settings, + updatedAt: Date.now(), + version: mockSettings.version + 1 + }); + + console.log('Mock updated settings:', mockSettings); + + // Create success response + const updateResp = UpdateSettingsResponse.create({ + success: true + }); + + const responseData = UpdateSettingsResponse.encode(updateResp).finish(); + + return new Response(responseData, { + headers: { + 'Content-Type': 'application/octet-stream' + } + }); + } + } + + // Mock user endpoints + if (event.url.pathname === '/api/users') { + if (event.request.method === 'GET') { + // Get users + const usernames = mockUsers.map((u) => u.username); + const getResp = GetUsersResponse.create({ usernames }); + const responseData = GetUsersResponse.encode(getResp).finish(); + + return new Response(responseData, { + headers: { + 'Content-Type': 'application/octet-stream' + } + }); + } else if (event.request.method === 'POST') { + // Add user + const buffer = await event.request.arrayBuffer(); + const uint8Array = new Uint8Array(buffer); + const addReq = AddUserRequest.decode(uint8Array); + + // Check if user already exists + const existingUser = mockUsers.find((u) => u.username === addReq.username); + if (existingUser) { + const addResp = AddUserResponse.create({ success: false, error: 'User already exists' }); + const responseData = AddUserResponse.encode(addResp).finish(); + return new Response(responseData, { + headers: { 'Content-Type': 'application/octet-stream' } + }); + } + + // Check if table is full (max 5 users) + if (mockUsers.length >= 5) { + const addResp = AddUserResponse.create({ success: false, error: 'User table full' }); + const responseData = AddUserResponse.encode(addResp).finish(); + return new Response(responseData, { + headers: { 'Content-Type': 'application/octet-stream' } + }); + } + + // Add user + mockUsers.push( + WebUiLoginRequest.create({ username: addReq.username, password: addReq.password }) + ); + const addResp = AddUserResponse.create({ success: true }); + const responseData = AddUserResponse.encode(addResp).finish(); + + return new Response(responseData, { + headers: { 'Content-Type': 'application/octet-stream' } + }); + } else if (event.request.method === 'DELETE') { + // Delete user + const buffer = await event.request.arrayBuffer(); + const uint8Array = new Uint8Array(buffer); + const deleteReq = DeleteUserRequest.decode(uint8Array); + + const userIndex = mockUsers.findIndex((u) => u.username === deleteReq.username); + if (userIndex === -1) { + const deleteResp = DeleteUserResponse.create({ success: false, error: 'User not found' }); + const responseData = DeleteUserResponse.encode(deleteResp).finish(); + return new Response(responseData, { + headers: { 'Content-Type': 'application/octet-stream' } + }); + } + + mockUsers.splice(userIndex, 1); + const deleteResp = DeleteUserResponse.create({ success: true }); + const responseData = DeleteUserResponse.encode(deleteResp).finish(); + + return new Response(responseData, { + headers: { 'Content-Type': 'application/octet-stream' } + }); + } + } + + // Mock user password update endpoint + if (event.url.pathname === '/api/users/password' && event.request.method === 'POST') { + const buffer = await event.request.arrayBuffer(); + const uint8Array = new Uint8Array(buffer); + const updateReq = UpdateUserPasswordRequest.decode(uint8Array); + + const user = mockUsers.find((u) => u.username === updateReq.username); + if (!user) { + const updateResp = UpdateUserPasswordResponse.create({ + success: false, + error: 'User not found' + }); + const responseData = UpdateUserPasswordResponse.encode(updateResp).finish(); + return new Response(responseData, { + headers: { 'Content-Type': 'application/octet-stream' } + }); + } + + user.password = updateReq.newPassword; + const updateResp = UpdateUserPasswordResponse.create({ success: true }); + const responseData = UpdateUserPasswordResponse.encode(updateResp).finish(); + + return new Response(responseData, { + headers: { 'Content-Type': 'application/octet-stream' } + }); + } + + // For other requests, continue normally + return resolve(event); +}; diff --git a/ui/src/lib/api/constants.ts b/ui/src/lib/api/constants.ts new file mode 100644 index 0000000..058e26b --- /dev/null +++ b/ui/src/lib/api/constants.ts @@ -0,0 +1 @@ +export const BASE_URL = window.location.origin; diff --git a/ui/src/lib/api/fetch.ts b/ui/src/lib/api/fetch.ts new file mode 100644 index 0000000..55b2171 --- /dev/null +++ b/ui/src/lib/api/fetch.ts @@ -0,0 +1,26 @@ +import { goto } from '$app/navigation'; +import { get } from 'svelte/store'; +import { authStore } from '../stores/auth'; + +export async function authenticatedFetch( + input: RequestInfo | URL, + init?: RequestInit +): Promise { + const authState = get(authStore); + const headers = new Headers(init?.headers); + + if (authState.token && !headers.has('Authorization')) { + headers.set('Authorization', `Bearer ${authState.token}`); + } + + const updatedInit = { ...init, headers }; + + const response = await fetch(input, updatedInit); + + if (response.status === 401) { + authStore.logout(); + goto('/login'); + } + + return response; +} diff --git a/ui/src/lib/api/webui.ts b/ui/src/lib/api/webui.ts new file mode 100644 index 0000000..fb042c0 --- /dev/null +++ b/ui/src/lib/api/webui.ts @@ -0,0 +1,402 @@ +import { + GetSettingsResponse, + SettingsData, + UpdateSettingsRequest, + UpdateSettingsResponse +} from '../gen/settings'; +import { HardwareConfig, SensorOTAEnable } from '../gen/hardware'; +import { + AddUserRequest, + AddUserResponse, + DeleteUserRequest, + DeleteUserResponse, + GetUsersResponse, + UpdateUserPasswordRequest, + UpdateUserPasswordResponse, + WebUiAuthCheckResponse, + WebUiLoginRequest, + WebUiLoginResponse +} from '../gen/webui'; +import { DeviceStatus } from '../gen/device'; +import { BASE_URL } from './constants'; +import { authenticatedFetch } from './fetch'; + +export class WebUIApi { + private baseUrl: string; + private username: string | null = null; + + constructor(baseUrl: string = BASE_URL) { + this.baseUrl = baseUrl; + } + + async login(username: string, password: string): Promise { + const request = WebUiLoginRequest.create({ username, password }); + const requestData = WebUiLoginRequest.encode(request).finish(); + + const response = await authenticatedFetch(`${this.baseUrl}/api/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/octet-stream' + }, + body: requestData + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const responseData = await response.arrayBuffer(); + const loginResponse = WebUiLoginResponse.decode(new Uint8Array(responseData)); + + if (loginResponse.token) { + this.username = username; + } + + return loginResponse; + } + + async authchk(): Promise { + const response = await authenticatedFetch(`${this.baseUrl}/api/authchk`, { + method: 'GET' + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const responseData = await response.arrayBuffer(); + const authResponse = WebUiAuthCheckResponse.decode(new Uint8Array(responseData)); + + if (authResponse.authenticated) { + this.username = authResponse.username || null; + } + + return authResponse; + } + + async getStatus(): Promise { + const response = await authenticatedFetch(`${this.baseUrl}/api/status`, { + method: 'GET' + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const responseData = await response.arrayBuffer(); + const statusResponse = DeviceStatus.decode(new Uint8Array(responseData)); + return statusResponse; + } + + async getSettings(): Promise { + const response = await authenticatedFetch(`${this.baseUrl}/api/settings`, { + method: 'GET' + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const responseData = await response.arrayBuffer(); + const settingsResponse = GetSettingsResponse.decode(new Uint8Array(responseData)); + return settingsResponse; + } + + async updateSettings(settings: SettingsData): Promise { + const request = UpdateSettingsRequest.create({ settings }); + const requestData = UpdateSettingsRequest.encode(request).finish(); + + const response = await authenticatedFetch(`${this.baseUrl}/api/settings`, { + method: 'POST', + headers: { + 'Content-Type': 'application/octet-stream' + }, + body: requestData + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const responseData = await response.arrayBuffer(); + const updateResponse = UpdateSettingsResponse.decode(new Uint8Array(responseData)); + + return updateResponse; + } + + async getHardware(): Promise { + const response = await authenticatedFetch(`${this.baseUrl}/api/hardware`, { + method: 'GET' + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const responseData = await response.arrayBuffer(); + const hardwareConfig = HardwareConfig.decode(new Uint8Array(responseData)); + return hardwareConfig; + } + + async updateHardware(config: HardwareConfig): Promise { + const requestData = HardwareConfig.encode(config).finish(); + + const response = await authenticatedFetch(`${this.baseUrl}/api/hardware`, { + method: 'POST', + headers: { + 'Content-Type': 'application/octet-stream' + }, + body: requestData + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + } + + async getUsers(): Promise { + const response = await authenticatedFetch(`${this.baseUrl}/api/users`, { + method: 'GET', + headers: { + 'Content-Type': 'application/octet-stream' + } + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const responseData = await response.arrayBuffer(); + const usersResponse = GetUsersResponse.decode(new Uint8Array(responseData)); + + return usersResponse; + } + + async addUser(username: string, password: string): Promise { + const request = AddUserRequest.create({ username, password }); + const requestData = AddUserRequest.encode(request).finish(); + + const response = await authenticatedFetch(`${this.baseUrl}/api/users`, { + method: 'POST', + headers: { + 'Content-Type': 'application/octet-stream' + }, + body: requestData + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const responseData = await response.arrayBuffer(); + const addResponse = AddUserResponse.decode(new Uint8Array(responseData)); + + return addResponse; + } + + async deleteUser(username: string): Promise { + const request = DeleteUserRequest.create({ username }); + const requestData = DeleteUserRequest.encode(request).finish(); + + const response = await authenticatedFetch(`${this.baseUrl}/api/users`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/octet-stream' + }, + body: requestData + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const responseData = await response.arrayBuffer(); + const deleteResponse = DeleteUserResponse.decode(new Uint8Array(responseData)); + + return deleteResponse; + } + + async updateUserPassword( + username: string, + newPassword: string + ): Promise { + const request = UpdateUserPasswordRequest.create({ username, newPassword }); + const requestData = UpdateUserPasswordRequest.encode(request).finish(); + + const response = await authenticatedFetch(`${this.baseUrl}/api/users/password`, { + method: 'POST', + headers: { + 'Content-Type': 'application/octet-stream' + }, + body: requestData + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const responseData = await response.arrayBuffer(); + const updateResponse = UpdateUserPasswordResponse.decode(new Uint8Array(responseData)); + + return updateResponse; + } + + get getUsername(): string | null { + return this.username; + } + + async getRfidDatabase(): Promise { + const response = await authenticatedFetch(`${this.baseUrl}/api/db`, { + method: 'GET' + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const binaryData = await response.arrayBuffer(); + const dataView = new DataView(binaryData); + + // Read uint32 values as big endian + const rfidIds = new Uint32Array(binaryData.byteLength / 4); + for (let i = 0; i < rfidIds.length; i++) { + rfidIds[i] = dataView.getUint32(i * 4, false); // false = big endian + } + + return rfidIds; + } + + async updateRfidDatabase(rfidIds: Uint32Array): Promise { + // Sort the RFID IDs + const sortedIds = new Uint32Array(rfidIds); + sortedIds.sort((a, b) => a - b); + + // Create a buffer and write values in big endian format + const buffer = new ArrayBuffer(sortedIds.length * 4); + const dataView = new DataView(buffer); + + for (let i = 0; i < sortedIds.length; i++) { + dataView.setUint32(i * 4, sortedIds[i], false); // false = big endian + } + + const response = await authenticatedFetch(`${this.baseUrl}/api/db`, { + method: 'POST', + headers: { + 'Content-Type': 'application/octet-stream' + }, + body: buffer + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + } + + async sync(): Promise { + const response = await authenticatedFetch(`${this.baseUrl}/api/sync`, { + method: 'POST' + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.text(); + } + + async fullSync(): Promise { + const response = await authenticatedFetch(`${this.baseUrl}/api/sync/full`, { + method: 'POST' + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.text(); + } + + async enableOTA(otaConfig: SensorOTAEnable): Promise { + const requestData = SensorOTAEnable.encode(otaConfig).finish(); + + const response = await authenticatedFetch(`${this.baseUrl}/api/ota`, { + method: 'POST', + headers: { + 'Content-Type': 'application/octet-stream' + }, + body: requestData + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.text(); + } + + async restartSensor(): Promise { + const response = await authenticatedFetch(`${this.baseUrl}/api/restart-sensor`, { + method: 'POST' + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.text(); + } + + async enableESPOTA(): Promise { + const response = await authenticatedFetch(`${this.baseUrl}/api/esp-ota-enable`, { + method: 'POST' + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.text(); + } + + async disableESPOTA(): Promise { + const response = await authenticatedFetch(`${this.baseUrl}/api/esp-ota-disable`, { + method: 'POST' + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.text(); + } + + async getESPOTAStatus(): Promise<{ enabled: boolean; status: string }> { + const response = await authenticatedFetch(`${this.baseUrl}/api/esp-ota-status`, { + method: 'GET' + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); + } + + async uploadESPOTAFirmware(file: File): Promise { + const formData = new FormData(); + formData.append('firmware', file); + + const response = await authenticatedFetch(`${this.baseUrl}/api/esp-ota-upload`, { + method: 'POST', + body: formData + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.text(); + } +} + +export const webUIApi = new WebUIApi(); diff --git a/ui/src/lib/assets/favicon.svg b/ui/src/lib/assets/favicon.svg new file mode 100644 index 0000000..cc5dc66 --- /dev/null +++ b/ui/src/lib/assets/favicon.svg @@ -0,0 +1 @@ +svelte-logo \ No newline at end of file diff --git a/ui/src/lib/components/dashboard/DeviceInfo.svelte b/ui/src/lib/components/dashboard/DeviceInfo.svelte new file mode 100644 index 0000000..b423a3a --- /dev/null +++ b/ui/src/lib/components/dashboard/DeviceInfo.svelte @@ -0,0 +1,88 @@ + + + + + Device Information + Read-only information about this device + + + {#if deviceInfo} +
+
+ +

{deviceInfo.deviceId || 'N/A'}

+
+
+ +

{deviceInfo.firmwareVersion || 'N/A'}

+
+
+ +

{deviceInfo.hardwareVersion || 'N/A'}

+
+
+ +

{formatUptime(deviceInfo.uptimeSeconds)}

+
+
+ +
+

WiFi Status

+
+
+ +

{getWifiStatus(deviceInfo.staConnectionState)}

+ {#if deviceInfo.staConnectionState === 1} +

IP: {deviceInfo.staIp}

+

Signal: {deviceInfo.staSignalStrength} dBm

+ {/if} +
+
+ +

{getWifiStatus(deviceInfo.apConnectionState)}

+ {#if deviceInfo.apConnectionState === 1} +

IP: {deviceInfo.apIp}

+

Clients: {deviceInfo.apClientCount}

+ {/if} +
+
+
+ {:else} +

Loading device information...

+ {/if} +
+
diff --git a/ui/src/lib/components/settings/HardwareConfig.svelte b/ui/src/lib/components/settings/HardwareConfig.svelte new file mode 100644 index 0000000..121e2a5 --- /dev/null +++ b/ui/src/lib/components/settings/HardwareConfig.svelte @@ -0,0 +1,369 @@ + + +
+ + + + + 🔒 + Door Relay + + + Configure the door lock mechanism. The relay controls the door opening duration and which + GPIO pin is used. + + + +
+
+ + +

+ How long the door stays unlocked after valid RFID scan +

+
+
+ + +

GPIO pin connected to the door relay

+
+
+
+
+ + + + + + 📡 + Sensor Unit (RFID Reader) + + + The RFID reader unit with integrated RGB LED. Communication happens via serial connection + (UART). The reader scans RFID cards and communicates card data to the main controller. + + + +
+ + +
+

Enable or disable the serial sensor unit.

+ {#if enableSerialSensor} +
+
+ + +

+ UART RX pin for receiving data from RFID reader +

+
+
+ + +

+ UART TX pin for sending commands to RFID reader +

+
+
+ {/if} +
+
+ + + + + + 🔓 + Permanent Unlock + + + Override mode allows keeping the door permanently unlocked. Use with caution - this bypasses + all access control. + + + +
+ + +
+

+ When enabled, the door relay stays activated permanently, ignoring RFID access control. +

+
+
+ + + + + + 💡 + LED Configuration + + + Configure RGB LED animations for different access states. Each LED can have custom colors, + brightness, and animation patterns. + + + + +
+
+

On Open LED

+

+ {onOpenLed + ? `Animation: ${getAnimationName(getCurrentAnimation(onOpenLed))}` + : 'Not configured'} +

+

Shows when door is unlocked after valid RFID scan

+
+
+ + {#if onOpenLed} + + {/if} +
+
+ + +
+
+

Default LED

+

+ {defaultLed + ? `Animation: ${getAnimationName(getCurrentAnimation(defaultLed))}` + : 'Not configured'} +

+

Default idle state when system is ready

+
+
+ + {#if defaultLed} + + {/if} +
+
+ + +
+
+

On Invalid LED

+

+ {onInvalidLed + ? `Animation: ${getAnimationName(getCurrentAnimation(onInvalidLed))}` + : 'Not configured'} +

+

Shows when RFID scan is rejected

+
+
+ + {#if onInvalidLed} + + {/if} +
+
+ + +
+
+

On Override Open LED

+

+ {onOverrideOpenLed + ? `Animation: ${getAnimationName(getCurrentAnimation(onOverrideOpenLed))}` + : 'Not configured'} +

+

Shows when door is in permanent unlock mode

+
+
+ + {#if onOverrideOpenLed} + + {/if} +
+
+ + +
+
+

On Interception LED

+

+ {onInterceptionLed + ? `Animation: ${getAnimationName(getCurrentAnimation(onInterceptionLed))}` + : 'Not configured'} +

+

Shows when RFID card is read to the ui

+
+
+ + {#if onInterceptionLed} + + {/if} +
+
+
+
+
+ + + (dialogOpen = false)} +/> diff --git a/ui/src/lib/components/settings/LedConfigDialog.svelte b/ui/src/lib/components/settings/LedConfigDialog.svelte new file mode 100644 index 0000000..64b3683 --- /dev/null +++ b/ui/src/lib/components/settings/LedConfigDialog.svelte @@ -0,0 +1,271 @@ + + + + + + Configure {ledType} LED + Set the LED animation and parameters. + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + + {#if tempConfig.staticParams} +
+ + { + staticColor = color; + updateStaticColor(); + }} + /> +
+ {:else if tempConfig.pulseParams} +
+
+ + { + pulseColor = color; + updatePulseColor(); + }} + /> +
+
+ + +
+
+ {:else if tempConfig.fadeParams} +
+
+ + {#each fadeColors as color, i} +
+ { + fadeColors[i] = newColor; + updateFadeColor(i); + }} + class="flex-1" + /> + +
+ {/each} + +
+
+ + +
+
+ {:else if tempConfig.flickerParams} +
+
+ + { + flickerColor = color; + updateFlickerColor(); + }} + /> +
+
+ + +
+
+ {/if} +
+ +
+ + +
+
+
diff --git a/ui/src/lib/components/settings/SyncServerConfig.svelte b/ui/src/lib/components/settings/SyncServerConfig.svelte new file mode 100644 index 0000000..837ba74 --- /dev/null +++ b/ui/src/lib/components/settings/SyncServerConfig.svelte @@ -0,0 +1,83 @@ + + + + + Sync Server Configuration + Configure the server URL and synchronization settings + + +
+ + +

The URL of the synchronization server for data exchange

+
+ +
+ + +

API key for device authentication with the sync server

+
+ + + +
+
+ + +
+ + {#if autoSync} +
+ + +

+ How often to synchronize data with the server (5-86400 seconds) +

+
+ {/if} +
+
+
diff --git a/ui/src/lib/components/settings/WifiSettings.svelte b/ui/src/lib/components/settings/WifiSettings.svelte new file mode 100644 index 0000000..51fe3bb --- /dev/null +++ b/ui/src/lib/components/settings/WifiSettings.svelte @@ -0,0 +1,129 @@ + + + + + WiFi Settings + Configure WiFi connection modes and access point settings + + +
+
+ + +

+ Choose how the device connects to and provides WiFi networks +

+
+ + {#if [WifiMode.WIFI_MODE_STATION, WifiMode.WIFI_MODE_AP_STATION].includes(wifiMode)} +
+

Station Settings (Connect to WiFi)

+
+
+ + +
+
+ + +
+
+
+ {/if} + + {#if [WifiMode.WIFI_MODE_STATION, WifiMode.WIFI_MODE_AP, WifiMode.WIFI_MODE_AP_STATION].includes(wifiMode)} +
+ {#if wifiMode === WifiMode.WIFI_MODE_STATION} +
+
+ + +
+

+ If station connection fails, create AP for configuration +

+
+ {/if} +

Access Point Settings (Create WiFi Network)

+ {#if [WifiMode.WIFI_MODE_STATION, WifiMode.WIFI_MODE_AP_STATION].includes(wifiMode) || (wifiMode === WifiMode.WIFI_MODE_STATION && enableFallbackAp)} +
+
+ + +
+
+ + +

Password must be 8-63 characters long

+
+
+ + +
+
+ {/if} +
+ {/if} +
+
+
diff --git a/ui/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte b/ui/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte new file mode 100644 index 0000000..a005691 --- /dev/null +++ b/ui/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte @@ -0,0 +1,18 @@ + + + diff --git a/ui/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte b/ui/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte new file mode 100644 index 0000000..a7b0cf7 --- /dev/null +++ b/ui/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte @@ -0,0 +1,18 @@ + + + diff --git a/ui/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte b/ui/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte new file mode 100644 index 0000000..6c3c604 --- /dev/null +++ b/ui/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte @@ -0,0 +1,27 @@ + + + + + + diff --git a/ui/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte b/ui/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte new file mode 100644 index 0000000..2ec67dc --- /dev/null +++ b/ui/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte @@ -0,0 +1,17 @@ + + + diff --git a/ui/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte b/ui/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte new file mode 100644 index 0000000..f78b97a --- /dev/null +++ b/ui/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/ui/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte b/ui/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte new file mode 100644 index 0000000..c8fa762 --- /dev/null +++ b/ui/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/ui/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte b/ui/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte new file mode 100644 index 0000000..a64ee76 --- /dev/null +++ b/ui/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte @@ -0,0 +1,20 @@ + + + diff --git a/ui/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte b/ui/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte new file mode 100644 index 0000000..7ef2b5f --- /dev/null +++ b/ui/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte @@ -0,0 +1,17 @@ + + + diff --git a/ui/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte b/ui/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte new file mode 100644 index 0000000..b22d1d5 --- /dev/null +++ b/ui/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/ui/src/lib/components/ui/alert-dialog/index.ts b/ui/src/lib/components/ui/alert-dialog/index.ts new file mode 100644 index 0000000..cc281c5 --- /dev/null +++ b/ui/src/lib/components/ui/alert-dialog/index.ts @@ -0,0 +1,39 @@ +import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; +import Trigger from "./alert-dialog-trigger.svelte"; +import Title from "./alert-dialog-title.svelte"; +import Action from "./alert-dialog-action.svelte"; +import Cancel from "./alert-dialog-cancel.svelte"; +import Footer from "./alert-dialog-footer.svelte"; +import Header from "./alert-dialog-header.svelte"; +import Overlay from "./alert-dialog-overlay.svelte"; +import Content from "./alert-dialog-content.svelte"; +import Description from "./alert-dialog-description.svelte"; + +const Root = AlertDialogPrimitive.Root; +const Portal = AlertDialogPrimitive.Portal; + +export { + Root, + Title, + Action, + Cancel, + Portal, + Footer, + Header, + Trigger, + Overlay, + Content, + Description, + // + Root as AlertDialog, + Title as AlertDialogTitle, + Action as AlertDialogAction, + Cancel as AlertDialogCancel, + Portal as AlertDialogPortal, + Footer as AlertDialogFooter, + Header as AlertDialogHeader, + Trigger as AlertDialogTrigger, + Overlay as AlertDialogOverlay, + Content as AlertDialogContent, + Description as AlertDialogDescription, +}; diff --git a/ui/src/lib/components/ui/alert/alert-description.svelte b/ui/src/lib/components/ui/alert/alert-description.svelte new file mode 100644 index 0000000..8b56aed --- /dev/null +++ b/ui/src/lib/components/ui/alert/alert-description.svelte @@ -0,0 +1,23 @@ + + +
+ {@render children?.()} +
diff --git a/ui/src/lib/components/ui/alert/alert-title.svelte b/ui/src/lib/components/ui/alert/alert-title.svelte new file mode 100644 index 0000000..77e45ad --- /dev/null +++ b/ui/src/lib/components/ui/alert/alert-title.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/ui/src/lib/components/ui/alert/alert.svelte b/ui/src/lib/components/ui/alert/alert.svelte new file mode 100644 index 0000000..2b2eff9 --- /dev/null +++ b/ui/src/lib/components/ui/alert/alert.svelte @@ -0,0 +1,44 @@ + + + + + diff --git a/ui/src/lib/components/ui/alert/index.ts b/ui/src/lib/components/ui/alert/index.ts new file mode 100644 index 0000000..97e21b4 --- /dev/null +++ b/ui/src/lib/components/ui/alert/index.ts @@ -0,0 +1,14 @@ +import Root from "./alert.svelte"; +import Description from "./alert-description.svelte"; +import Title from "./alert-title.svelte"; +export { alertVariants, type AlertVariant } from "./alert.svelte"; + +export { + Root, + Description, + Title, + // + Root as Alert, + Description as AlertDescription, + Title as AlertTitle, +}; diff --git a/ui/src/lib/components/ui/button/button.svelte b/ui/src/lib/components/ui/button/button.svelte new file mode 100644 index 0000000..4daf453 --- /dev/null +++ b/ui/src/lib/components/ui/button/button.svelte @@ -0,0 +1,80 @@ + + + + +{#if href} + + {@render children?.()} + +{:else} + +{/if} diff --git a/ui/src/lib/components/ui/button/index.ts b/ui/src/lib/components/ui/button/index.ts new file mode 100644 index 0000000..fb585d7 --- /dev/null +++ b/ui/src/lib/components/ui/button/index.ts @@ -0,0 +1,17 @@ +import Root, { + type ButtonProps, + type ButtonSize, + type ButtonVariant, + buttonVariants, +} from "./button.svelte"; + +export { + Root, + type ButtonProps as Props, + // + Root as Button, + buttonVariants, + type ButtonProps, + type ButtonSize, + type ButtonVariant, +}; diff --git a/ui/src/lib/components/ui/card/card-action.svelte b/ui/src/lib/components/ui/card/card-action.svelte new file mode 100644 index 0000000..cc36c56 --- /dev/null +++ b/ui/src/lib/components/ui/card/card-action.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/ui/src/lib/components/ui/card/card-content.svelte b/ui/src/lib/components/ui/card/card-content.svelte new file mode 100644 index 0000000..bc90b83 --- /dev/null +++ b/ui/src/lib/components/ui/card/card-content.svelte @@ -0,0 +1,15 @@ + + +
+ {@render children?.()} +
diff --git a/ui/src/lib/components/ui/card/card-description.svelte b/ui/src/lib/components/ui/card/card-description.svelte new file mode 100644 index 0000000..9b20ac7 --- /dev/null +++ b/ui/src/lib/components/ui/card/card-description.svelte @@ -0,0 +1,20 @@ + + +

+ {@render children?.()} +

diff --git a/ui/src/lib/components/ui/card/card-footer.svelte b/ui/src/lib/components/ui/card/card-footer.svelte new file mode 100644 index 0000000..cf43353 --- /dev/null +++ b/ui/src/lib/components/ui/card/card-footer.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/ui/src/lib/components/ui/card/card-header.svelte b/ui/src/lib/components/ui/card/card-header.svelte new file mode 100644 index 0000000..8a91abb --- /dev/null +++ b/ui/src/lib/components/ui/card/card-header.svelte @@ -0,0 +1,23 @@ + + +
+ {@render children?.()} +
diff --git a/ui/src/lib/components/ui/card/card-title.svelte b/ui/src/lib/components/ui/card/card-title.svelte new file mode 100644 index 0000000..22586e6 --- /dev/null +++ b/ui/src/lib/components/ui/card/card-title.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/ui/src/lib/components/ui/card/card.svelte b/ui/src/lib/components/ui/card/card.svelte new file mode 100644 index 0000000..99448cc --- /dev/null +++ b/ui/src/lib/components/ui/card/card.svelte @@ -0,0 +1,23 @@ + + +
+ {@render children?.()} +
diff --git a/ui/src/lib/components/ui/card/index.ts b/ui/src/lib/components/ui/card/index.ts new file mode 100644 index 0000000..4d3fce4 --- /dev/null +++ b/ui/src/lib/components/ui/card/index.ts @@ -0,0 +1,25 @@ +import Root from "./card.svelte"; +import Content from "./card-content.svelte"; +import Description from "./card-description.svelte"; +import Footer from "./card-footer.svelte"; +import Header from "./card-header.svelte"; +import Title from "./card-title.svelte"; +import Action from "./card-action.svelte"; + +export { + Root, + Content, + Description, + Footer, + Header, + Title, + Action, + // + Root as Card, + Content as CardContent, + Description as CardDescription, + Footer as CardFooter, + Header as CardHeader, + Title as CardTitle, + Action as CardAction, +}; diff --git a/ui/src/lib/components/ui/checkbox/checkbox.svelte b/ui/src/lib/components/ui/checkbox/checkbox.svelte new file mode 100644 index 0000000..1622e05 --- /dev/null +++ b/ui/src/lib/components/ui/checkbox/checkbox.svelte @@ -0,0 +1,36 @@ + + + + {#snippet children({ checked, indeterminate })} +
+ {#if checked} + + {:else if indeterminate} + + {/if} +
+ {/snippet} +
diff --git a/ui/src/lib/components/ui/checkbox/index.ts b/ui/src/lib/components/ui/checkbox/index.ts new file mode 100644 index 0000000..6d92d94 --- /dev/null +++ b/ui/src/lib/components/ui/checkbox/index.ts @@ -0,0 +1,6 @@ +import Root from "./checkbox.svelte"; +export { + Root, + // + Root as Checkbox, +}; diff --git a/ui/src/lib/components/ui/color-picker-2d.svelte b/ui/src/lib/components/ui/color-picker-2d.svelte new file mode 100644 index 0000000..21450c7 --- /dev/null +++ b/ui/src/lib/components/ui/color-picker-2d.svelte @@ -0,0 +1,205 @@ + + +
+
+ +
+
+
{value.toUpperCase()}
+
+
+
diff --git a/ui/src/lib/components/ui/color-picker-sliders.svelte b/ui/src/lib/components/ui/color-picker-sliders.svelte new file mode 100644 index 0000000..3c097ae --- /dev/null +++ b/ui/src/lib/components/ui/color-picker-sliders.svelte @@ -0,0 +1,110 @@ + + +
+
+
+
{value.toUpperCase()}
+
+ +
+
+
+ Red + {rgb.r} +
+ (rgb = { ...rgb, r: newValue })} + class="w-full" + /> +
+ +
+
+ Green + {rgb.g} +
+ (rgb = { ...rgb, g: newValue })} + class="w-full" + /> +
+ +
+
+ Blue + {rgb.b} +
+ (rgb = { ...rgb, b: newValue })} + class="w-full" + /> +
+
+
diff --git a/ui/src/lib/components/ui/color-picker.svelte b/ui/src/lib/components/ui/color-picker.svelte new file mode 100644 index 0000000..2447e43 --- /dev/null +++ b/ui/src/lib/components/ui/color-picker.svelte @@ -0,0 +1,421 @@ + + +
+ + + + + + + + Color Picker - {value.toUpperCase()} + + +
+ +
+ Color Wheel + +
+ + +
+ RGB Sliders + +
+
+ Red + {rgb.r} +
+ (rgb = { ...rgb, r: newValue })} + class="w-full" + /> +
+ +
+
+ Green + {rgb.g} +
+ (rgb = { ...rgb, g: newValue })} + class="w-full" + /> +
+ +
+
+ Blue + {rgb.b} +
+ (rgb = { ...rgb, b: newValue })} + class="w-full" + /> +
+
+
+ + +
+
+
+ + + Close + +
+
+
diff --git a/ui/src/lib/components/ui/input/index.ts b/ui/src/lib/components/ui/input/index.ts new file mode 100644 index 0000000..f47b6d3 --- /dev/null +++ b/ui/src/lib/components/ui/input/index.ts @@ -0,0 +1,7 @@ +import Root from "./input.svelte"; + +export { + Root, + // + Root as Input, +}; diff --git a/ui/src/lib/components/ui/input/input.svelte b/ui/src/lib/components/ui/input/input.svelte new file mode 100644 index 0000000..19c6dae --- /dev/null +++ b/ui/src/lib/components/ui/input/input.svelte @@ -0,0 +1,51 @@ + + +{#if type === "file"} + +{:else} + +{/if} diff --git a/ui/src/lib/components/ui/label/index.ts b/ui/src/lib/components/ui/label/index.ts new file mode 100644 index 0000000..8bfca0b --- /dev/null +++ b/ui/src/lib/components/ui/label/index.ts @@ -0,0 +1,7 @@ +import Root from "./label.svelte"; + +export { + Root, + // + Root as Label, +}; diff --git a/ui/src/lib/components/ui/label/label.svelte b/ui/src/lib/components/ui/label/label.svelte new file mode 100644 index 0000000..d0afda3 --- /dev/null +++ b/ui/src/lib/components/ui/label/label.svelte @@ -0,0 +1,20 @@ + + + diff --git a/ui/src/lib/components/ui/popover/index.ts b/ui/src/lib/components/ui/popover/index.ts new file mode 100644 index 0000000..9f30922 --- /dev/null +++ b/ui/src/lib/components/ui/popover/index.ts @@ -0,0 +1,17 @@ +import { Popover as PopoverPrimitive } from "bits-ui"; +import Content from "./popover-content.svelte"; +import Trigger from "./popover-trigger.svelte"; +const Root = PopoverPrimitive.Root; +const Close = PopoverPrimitive.Close; + +export { + Root, + Content, + Trigger, + Close, + // + Root as Popover, + Content as PopoverContent, + Trigger as PopoverTrigger, + Close as PopoverClose, +}; diff --git a/ui/src/lib/components/ui/popover/popover-content.svelte b/ui/src/lib/components/ui/popover/popover-content.svelte new file mode 100644 index 0000000..9bced7a --- /dev/null +++ b/ui/src/lib/components/ui/popover/popover-content.svelte @@ -0,0 +1,29 @@ + + + + + diff --git a/ui/src/lib/components/ui/popover/popover-trigger.svelte b/ui/src/lib/components/ui/popover/popover-trigger.svelte new file mode 100644 index 0000000..586323c --- /dev/null +++ b/ui/src/lib/components/ui/popover/popover-trigger.svelte @@ -0,0 +1,17 @@ + + + diff --git a/ui/src/lib/components/ui/select/index.ts b/ui/src/lib/components/ui/select/index.ts new file mode 100644 index 0000000..9e8d3e9 --- /dev/null +++ b/ui/src/lib/components/ui/select/index.ts @@ -0,0 +1,37 @@ +import { Select as SelectPrimitive } from "bits-ui"; + +import Group from "./select-group.svelte"; +import Label from "./select-label.svelte"; +import Item from "./select-item.svelte"; +import Content from "./select-content.svelte"; +import Trigger from "./select-trigger.svelte"; +import Separator from "./select-separator.svelte"; +import ScrollDownButton from "./select-scroll-down-button.svelte"; +import ScrollUpButton from "./select-scroll-up-button.svelte"; +import GroupHeading from "./select-group-heading.svelte"; + +const Root = SelectPrimitive.Root; + +export { + Root, + Group, + Label, + Item, + Content, + Trigger, + Separator, + ScrollDownButton, + ScrollUpButton, + GroupHeading, + // + Root as Select, + Group as SelectGroup, + Label as SelectLabel, + Item as SelectItem, + Content as SelectContent, + Trigger as SelectTrigger, + Separator as SelectSeparator, + ScrollDownButton as SelectScrollDownButton, + ScrollUpButton as SelectScrollUpButton, + GroupHeading as SelectGroupHeading, +}; diff --git a/ui/src/lib/components/ui/select/select-content.svelte b/ui/src/lib/components/ui/select/select-content.svelte new file mode 100644 index 0000000..dc16d65 --- /dev/null +++ b/ui/src/lib/components/ui/select/select-content.svelte @@ -0,0 +1,40 @@ + + + + + + + {@render children?.()} + + + + diff --git a/ui/src/lib/components/ui/select/select-group-heading.svelte b/ui/src/lib/components/ui/select/select-group-heading.svelte new file mode 100644 index 0000000..1fab5f0 --- /dev/null +++ b/ui/src/lib/components/ui/select/select-group-heading.svelte @@ -0,0 +1,21 @@ + + + + {@render children?.()} + diff --git a/ui/src/lib/components/ui/select/select-group.svelte b/ui/src/lib/components/ui/select/select-group.svelte new file mode 100644 index 0000000..5454fdb --- /dev/null +++ b/ui/src/lib/components/ui/select/select-group.svelte @@ -0,0 +1,7 @@ + + + diff --git a/ui/src/lib/components/ui/select/select-item.svelte b/ui/src/lib/components/ui/select/select-item.svelte new file mode 100644 index 0000000..49dbbd7 --- /dev/null +++ b/ui/src/lib/components/ui/select/select-item.svelte @@ -0,0 +1,38 @@ + + + + {#snippet children({ selected, highlighted })} + + {#if selected} + + {/if} + + {#if childrenProp} + {@render childrenProp({ selected, highlighted })} + {:else} + {label || value} + {/if} + {/snippet} + diff --git a/ui/src/lib/components/ui/select/select-label.svelte b/ui/src/lib/components/ui/select/select-label.svelte new file mode 100644 index 0000000..4696025 --- /dev/null +++ b/ui/src/lib/components/ui/select/select-label.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/ui/src/lib/components/ui/select/select-scroll-down-button.svelte b/ui/src/lib/components/ui/select/select-scroll-down-button.svelte new file mode 100644 index 0000000..3629205 --- /dev/null +++ b/ui/src/lib/components/ui/select/select-scroll-down-button.svelte @@ -0,0 +1,20 @@ + + + + + diff --git a/ui/src/lib/components/ui/select/select-scroll-up-button.svelte b/ui/src/lib/components/ui/select/select-scroll-up-button.svelte new file mode 100644 index 0000000..1aa2300 --- /dev/null +++ b/ui/src/lib/components/ui/select/select-scroll-up-button.svelte @@ -0,0 +1,20 @@ + + + + + diff --git a/ui/src/lib/components/ui/select/select-separator.svelte b/ui/src/lib/components/ui/select/select-separator.svelte new file mode 100644 index 0000000..0eac3eb --- /dev/null +++ b/ui/src/lib/components/ui/select/select-separator.svelte @@ -0,0 +1,18 @@ + + + diff --git a/ui/src/lib/components/ui/select/select-trigger.svelte b/ui/src/lib/components/ui/select/select-trigger.svelte new file mode 100644 index 0000000..d405187 --- /dev/null +++ b/ui/src/lib/components/ui/select/select-trigger.svelte @@ -0,0 +1,29 @@ + + + + {@render children?.()} + + diff --git a/ui/src/lib/components/ui/separator/index.ts b/ui/src/lib/components/ui/separator/index.ts new file mode 100644 index 0000000..82442d2 --- /dev/null +++ b/ui/src/lib/components/ui/separator/index.ts @@ -0,0 +1,7 @@ +import Root from "./separator.svelte"; + +export { + Root, + // + Root as Separator, +}; diff --git a/ui/src/lib/components/ui/separator/separator.svelte b/ui/src/lib/components/ui/separator/separator.svelte new file mode 100644 index 0000000..09d88f4 --- /dev/null +++ b/ui/src/lib/components/ui/separator/separator.svelte @@ -0,0 +1,20 @@ + + + diff --git a/ui/src/lib/components/ui/sheet/index.ts b/ui/src/lib/components/ui/sheet/index.ts new file mode 100644 index 0000000..01d40c8 --- /dev/null +++ b/ui/src/lib/components/ui/sheet/index.ts @@ -0,0 +1,36 @@ +import { Dialog as SheetPrimitive } from "bits-ui"; +import Trigger from "./sheet-trigger.svelte"; +import Close from "./sheet-close.svelte"; +import Overlay from "./sheet-overlay.svelte"; +import Content from "./sheet-content.svelte"; +import Header from "./sheet-header.svelte"; +import Footer from "./sheet-footer.svelte"; +import Title from "./sheet-title.svelte"; +import Description from "./sheet-description.svelte"; + +const Root = SheetPrimitive.Root; +const Portal = SheetPrimitive.Portal; + +export { + Root, + Close, + Trigger, + Portal, + Overlay, + Content, + Header, + Footer, + Title, + Description, + // + Root as Sheet, + Close as SheetClose, + Trigger as SheetTrigger, + Portal as SheetPortal, + Overlay as SheetOverlay, + Content as SheetContent, + Header as SheetHeader, + Footer as SheetFooter, + Title as SheetTitle, + Description as SheetDescription, +}; diff --git a/ui/src/lib/components/ui/sheet/sheet-close.svelte b/ui/src/lib/components/ui/sheet/sheet-close.svelte new file mode 100644 index 0000000..ae382c1 --- /dev/null +++ b/ui/src/lib/components/ui/sheet/sheet-close.svelte @@ -0,0 +1,7 @@ + + + diff --git a/ui/src/lib/components/ui/sheet/sheet-content.svelte b/ui/src/lib/components/ui/sheet/sheet-content.svelte new file mode 100644 index 0000000..856922e --- /dev/null +++ b/ui/src/lib/components/ui/sheet/sheet-content.svelte @@ -0,0 +1,58 @@ + + + + + + + + {@render children?.()} + + + Close + + + diff --git a/ui/src/lib/components/ui/sheet/sheet-description.svelte b/ui/src/lib/components/ui/sheet/sheet-description.svelte new file mode 100644 index 0000000..333b17a --- /dev/null +++ b/ui/src/lib/components/ui/sheet/sheet-description.svelte @@ -0,0 +1,17 @@ + + + diff --git a/ui/src/lib/components/ui/sheet/sheet-footer.svelte b/ui/src/lib/components/ui/sheet/sheet-footer.svelte new file mode 100644 index 0000000..dd9ed84 --- /dev/null +++ b/ui/src/lib/components/ui/sheet/sheet-footer.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/ui/src/lib/components/ui/sheet/sheet-header.svelte b/ui/src/lib/components/ui/sheet/sheet-header.svelte new file mode 100644 index 0000000..757a6a5 --- /dev/null +++ b/ui/src/lib/components/ui/sheet/sheet-header.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/ui/src/lib/components/ui/sheet/sheet-overlay.svelte b/ui/src/lib/components/ui/sheet/sheet-overlay.svelte new file mode 100644 index 0000000..345e197 --- /dev/null +++ b/ui/src/lib/components/ui/sheet/sheet-overlay.svelte @@ -0,0 +1,20 @@ + + + diff --git a/ui/src/lib/components/ui/sheet/sheet-title.svelte b/ui/src/lib/components/ui/sheet/sheet-title.svelte new file mode 100644 index 0000000..9fda327 --- /dev/null +++ b/ui/src/lib/components/ui/sheet/sheet-title.svelte @@ -0,0 +1,17 @@ + + + diff --git a/ui/src/lib/components/ui/sheet/sheet-trigger.svelte b/ui/src/lib/components/ui/sheet/sheet-trigger.svelte new file mode 100644 index 0000000..e266975 --- /dev/null +++ b/ui/src/lib/components/ui/sheet/sheet-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/ui/src/lib/components/ui/sidebar/constants.ts b/ui/src/lib/components/ui/sidebar/constants.ts new file mode 100644 index 0000000..4de4435 --- /dev/null +++ b/ui/src/lib/components/ui/sidebar/constants.ts @@ -0,0 +1,6 @@ +export const SIDEBAR_COOKIE_NAME = "sidebar:state"; +export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; +export const SIDEBAR_WIDTH = "16rem"; +export const SIDEBAR_WIDTH_MOBILE = "18rem"; +export const SIDEBAR_WIDTH_ICON = "3rem"; +export const SIDEBAR_KEYBOARD_SHORTCUT = "b"; diff --git a/ui/src/lib/components/ui/sidebar/context.svelte.ts b/ui/src/lib/components/ui/sidebar/context.svelte.ts new file mode 100644 index 0000000..15248ad --- /dev/null +++ b/ui/src/lib/components/ui/sidebar/context.svelte.ts @@ -0,0 +1,81 @@ +import { IsMobile } from "$lib/hooks/is-mobile.svelte.js"; +import { getContext, setContext } from "svelte"; +import { SIDEBAR_KEYBOARD_SHORTCUT } from "./constants.js"; + +type Getter = () => T; + +export type SidebarStateProps = { + /** + * A getter function that returns the current open state of the sidebar. + * We use a getter function here to support `bind:open` on the `Sidebar.Provider` + * component. + */ + open: Getter; + + /** + * A function that sets the open state of the sidebar. To support `bind:open`, we need + * a source of truth for changing the open state to ensure it will be synced throughout + * the sub-components and any `bind:` references. + */ + setOpen: (open: boolean) => void; +}; + +class SidebarState { + readonly props: SidebarStateProps; + open = $derived.by(() => this.props.open()); + openMobile = $state(false); + setOpen: SidebarStateProps["setOpen"]; + #isMobile: IsMobile; + state = $derived.by(() => (this.open ? "expanded" : "collapsed")); + + constructor(props: SidebarStateProps) { + this.setOpen = props.setOpen; + this.#isMobile = new IsMobile(); + this.props = props; + } + + // Convenience getter for checking if the sidebar is mobile + // without this, we would need to use `sidebar.isMobile.current` everywhere + get isMobile() { + return this.#isMobile.current; + } + + // Event handler to apply to the `` + handleShortcutKeydown = (e: KeyboardEvent) => { + if (e.key === SIDEBAR_KEYBOARD_SHORTCUT && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + this.toggle(); + } + }; + + setOpenMobile = (value: boolean) => { + this.openMobile = value; + }; + + toggle = () => { + return this.#isMobile.current + ? (this.openMobile = !this.openMobile) + : this.setOpen(!this.open); + }; +} + +const SYMBOL_KEY = "scn-sidebar"; + +/** + * Instantiates a new `SidebarState` instance and sets it in the context. + * + * @param props The constructor props for the `SidebarState` class. + * @returns The `SidebarState` instance. + */ +export function setSidebar(props: SidebarStateProps): SidebarState { + return setContext(Symbol.for(SYMBOL_KEY), new SidebarState(props)); +} + +/** + * Retrieves the `SidebarState` instance from the context. This is a class instance, + * so you cannot destructure it. + * @returns The `SidebarState` instance. + */ +export function useSidebar(): SidebarState { + return getContext(Symbol.for(SYMBOL_KEY)); +} diff --git a/ui/src/lib/components/ui/sidebar/index.ts b/ui/src/lib/components/ui/sidebar/index.ts new file mode 100644 index 0000000..318a341 --- /dev/null +++ b/ui/src/lib/components/ui/sidebar/index.ts @@ -0,0 +1,75 @@ +import { useSidebar } from "./context.svelte.js"; +import Content from "./sidebar-content.svelte"; +import Footer from "./sidebar-footer.svelte"; +import GroupAction from "./sidebar-group-action.svelte"; +import GroupContent from "./sidebar-group-content.svelte"; +import GroupLabel from "./sidebar-group-label.svelte"; +import Group from "./sidebar-group.svelte"; +import Header from "./sidebar-header.svelte"; +import Input from "./sidebar-input.svelte"; +import Inset from "./sidebar-inset.svelte"; +import MenuAction from "./sidebar-menu-action.svelte"; +import MenuBadge from "./sidebar-menu-badge.svelte"; +import MenuButton from "./sidebar-menu-button.svelte"; +import MenuItem from "./sidebar-menu-item.svelte"; +import MenuSkeleton from "./sidebar-menu-skeleton.svelte"; +import MenuSubButton from "./sidebar-menu-sub-button.svelte"; +import MenuSubItem from "./sidebar-menu-sub-item.svelte"; +import MenuSub from "./sidebar-menu-sub.svelte"; +import Menu from "./sidebar-menu.svelte"; +import Provider from "./sidebar-provider.svelte"; +import Rail from "./sidebar-rail.svelte"; +import Separator from "./sidebar-separator.svelte"; +import Trigger from "./sidebar-trigger.svelte"; +import Root from "./sidebar.svelte"; + +export { + Content, + Footer, + Group, + GroupAction, + GroupContent, + GroupLabel, + Header, + Input, + Inset, + Menu, + MenuAction, + MenuBadge, + MenuButton, + MenuItem, + MenuSkeleton, + MenuSub, + MenuSubButton, + MenuSubItem, + Provider, + Rail, + Root, + Separator, + // + Root as Sidebar, + Content as SidebarContent, + Footer as SidebarFooter, + Group as SidebarGroup, + GroupAction as SidebarGroupAction, + GroupContent as SidebarGroupContent, + GroupLabel as SidebarGroupLabel, + Header as SidebarHeader, + Input as SidebarInput, + Inset as SidebarInset, + Menu as SidebarMenu, + MenuAction as SidebarMenuAction, + MenuBadge as SidebarMenuBadge, + MenuButton as SidebarMenuButton, + MenuItem as SidebarMenuItem, + MenuSkeleton as SidebarMenuSkeleton, + MenuSub as SidebarMenuSub, + MenuSubButton as SidebarMenuSubButton, + MenuSubItem as SidebarMenuSubItem, + Provider as SidebarProvider, + Rail as SidebarRail, + Separator as SidebarSeparator, + Trigger as SidebarTrigger, + Trigger, + useSidebar, +}; diff --git a/ui/src/lib/components/ui/sidebar/sidebar-content.svelte b/ui/src/lib/components/ui/sidebar/sidebar-content.svelte new file mode 100644 index 0000000..f121800 --- /dev/null +++ b/ui/src/lib/components/ui/sidebar/sidebar-content.svelte @@ -0,0 +1,24 @@ + + +
+ {@render children?.()} +
diff --git a/ui/src/lib/components/ui/sidebar/sidebar-footer.svelte b/ui/src/lib/components/ui/sidebar/sidebar-footer.svelte new file mode 100644 index 0000000..6259cb9 --- /dev/null +++ b/ui/src/lib/components/ui/sidebar/sidebar-footer.svelte @@ -0,0 +1,21 @@ + + +
+ {@render children?.()} +
diff --git a/ui/src/lib/components/ui/sidebar/sidebar-group-action.svelte b/ui/src/lib/components/ui/sidebar/sidebar-group-action.svelte new file mode 100644 index 0000000..fb84e4a --- /dev/null +++ b/ui/src/lib/components/ui/sidebar/sidebar-group-action.svelte @@ -0,0 +1,36 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/ui/src/lib/components/ui/sidebar/sidebar-group-content.svelte b/ui/src/lib/components/ui/sidebar/sidebar-group-content.svelte new file mode 100644 index 0000000..415255f --- /dev/null +++ b/ui/src/lib/components/ui/sidebar/sidebar-group-content.svelte @@ -0,0 +1,21 @@ + + +
+ {@render children?.()} +
diff --git a/ui/src/lib/components/ui/sidebar/sidebar-group-label.svelte b/ui/src/lib/components/ui/sidebar/sidebar-group-label.svelte new file mode 100644 index 0000000..e292945 --- /dev/null +++ b/ui/src/lib/components/ui/sidebar/sidebar-group-label.svelte @@ -0,0 +1,34 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/ui/src/lib/components/ui/sidebar/sidebar-group.svelte b/ui/src/lib/components/ui/sidebar/sidebar-group.svelte new file mode 100644 index 0000000..ec18a69 --- /dev/null +++ b/ui/src/lib/components/ui/sidebar/sidebar-group.svelte @@ -0,0 +1,21 @@ + + +
+ {@render children?.()} +
diff --git a/ui/src/lib/components/ui/sidebar/sidebar-header.svelte b/ui/src/lib/components/ui/sidebar/sidebar-header.svelte new file mode 100644 index 0000000..a1b2db1 --- /dev/null +++ b/ui/src/lib/components/ui/sidebar/sidebar-header.svelte @@ -0,0 +1,21 @@ + + +
+ {@render children?.()} +
diff --git a/ui/src/lib/components/ui/sidebar/sidebar-input.svelte b/ui/src/lib/components/ui/sidebar/sidebar-input.svelte new file mode 100644 index 0000000..19b3666 --- /dev/null +++ b/ui/src/lib/components/ui/sidebar/sidebar-input.svelte @@ -0,0 +1,21 @@ + + + diff --git a/ui/src/lib/components/ui/sidebar/sidebar-inset.svelte b/ui/src/lib/components/ui/sidebar/sidebar-inset.svelte new file mode 100644 index 0000000..d862761 --- /dev/null +++ b/ui/src/lib/components/ui/sidebar/sidebar-inset.svelte @@ -0,0 +1,24 @@ + + +
+ {@render children?.()} +
diff --git a/ui/src/lib/components/ui/sidebar/sidebar-menu-action.svelte b/ui/src/lib/components/ui/sidebar/sidebar-menu-action.svelte new file mode 100644 index 0000000..fa3fb0c --- /dev/null +++ b/ui/src/lib/components/ui/sidebar/sidebar-menu-action.svelte @@ -0,0 +1,43 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/ui/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte b/ui/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte new file mode 100644 index 0000000..69e5a3c --- /dev/null +++ b/ui/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte @@ -0,0 +1,29 @@ + + +
+ {@render children?.()} +
diff --git a/ui/src/lib/components/ui/sidebar/sidebar-menu-button.svelte b/ui/src/lib/components/ui/sidebar/sidebar-menu-button.svelte new file mode 100644 index 0000000..4bef683 --- /dev/null +++ b/ui/src/lib/components/ui/sidebar/sidebar-menu-button.svelte @@ -0,0 +1,103 @@ + + + + +{#snippet Button({ props }: { props?: Record })} + {@const mergedProps = mergeProps(buttonProps, props)} + {#if child} + {@render child({ props: mergedProps })} + {:else} + + {/if} +{/snippet} + +{#if !tooltipContent} + {@render Button({})} +{:else} + + + {#snippet child({ props })} + {@render Button({ props })} + {/snippet} + + + +{/if} diff --git a/ui/src/lib/components/ui/sidebar/sidebar-menu-item.svelte b/ui/src/lib/components/ui/sidebar/sidebar-menu-item.svelte new file mode 100644 index 0000000..4db4453 --- /dev/null +++ b/ui/src/lib/components/ui/sidebar/sidebar-menu-item.svelte @@ -0,0 +1,21 @@ + + +
  • + {@render children?.()} +
  • diff --git a/ui/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte b/ui/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte new file mode 100644 index 0000000..cc63b04 --- /dev/null +++ b/ui/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte @@ -0,0 +1,36 @@ + + +
    + {#if showIcon} + + {/if} + + {@render children?.()} +
    diff --git a/ui/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte b/ui/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte new file mode 100644 index 0000000..987f104 --- /dev/null +++ b/ui/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte @@ -0,0 +1,43 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + + {@render children?.()} + +{/if} diff --git a/ui/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte b/ui/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte new file mode 100644 index 0000000..681d0f1 --- /dev/null +++ b/ui/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte @@ -0,0 +1,21 @@ + + +
  • + {@render children?.()} +
  • diff --git a/ui/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte b/ui/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte new file mode 100644 index 0000000..8ab1111 --- /dev/null +++ b/ui/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte @@ -0,0 +1,25 @@ + + +
      + {@render children?.()} +
    diff --git a/ui/src/lib/components/ui/sidebar/sidebar-menu.svelte b/ui/src/lib/components/ui/sidebar/sidebar-menu.svelte new file mode 100644 index 0000000..946ccce --- /dev/null +++ b/ui/src/lib/components/ui/sidebar/sidebar-menu.svelte @@ -0,0 +1,21 @@ + + +
      + {@render children?.()} +
    diff --git a/ui/src/lib/components/ui/sidebar/sidebar-provider.svelte b/ui/src/lib/components/ui/sidebar/sidebar-provider.svelte new file mode 100644 index 0000000..5b0d0aa --- /dev/null +++ b/ui/src/lib/components/ui/sidebar/sidebar-provider.svelte @@ -0,0 +1,53 @@ + + + + + +
    + {@render children?.()} +
    +
    diff --git a/ui/src/lib/components/ui/sidebar/sidebar-rail.svelte b/ui/src/lib/components/ui/sidebar/sidebar-rail.svelte new file mode 100644 index 0000000..c180cf5 --- /dev/null +++ b/ui/src/lib/components/ui/sidebar/sidebar-rail.svelte @@ -0,0 +1,36 @@ + + + diff --git a/ui/src/lib/components/ui/sidebar/sidebar-separator.svelte b/ui/src/lib/components/ui/sidebar/sidebar-separator.svelte new file mode 100644 index 0000000..5a7deda --- /dev/null +++ b/ui/src/lib/components/ui/sidebar/sidebar-separator.svelte @@ -0,0 +1,19 @@ + + + diff --git a/ui/src/lib/components/ui/sidebar/sidebar-trigger.svelte b/ui/src/lib/components/ui/sidebar/sidebar-trigger.svelte new file mode 100644 index 0000000..1825182 --- /dev/null +++ b/ui/src/lib/components/ui/sidebar/sidebar-trigger.svelte @@ -0,0 +1,35 @@ + + + diff --git a/ui/src/lib/components/ui/sidebar/sidebar.svelte b/ui/src/lib/components/ui/sidebar/sidebar.svelte new file mode 100644 index 0000000..3e9eba9 --- /dev/null +++ b/ui/src/lib/components/ui/sidebar/sidebar.svelte @@ -0,0 +1,104 @@ + + +{#if collapsible === "none"} +
    + {@render children?.()} +
    +{:else if sidebar.isMobile} + sidebar.openMobile, (v) => sidebar.setOpenMobile(v)} + {...restProps} + > + + + Sidebar + Displays the mobile sidebar. + +
    + {@render children?.()} +
    +
    +
    +{:else} + +{/if} diff --git a/ui/src/lib/components/ui/skeleton/index.ts b/ui/src/lib/components/ui/skeleton/index.ts new file mode 100644 index 0000000..186db21 --- /dev/null +++ b/ui/src/lib/components/ui/skeleton/index.ts @@ -0,0 +1,7 @@ +import Root from "./skeleton.svelte"; + +export { + Root, + // + Root as Skeleton, +}; diff --git a/ui/src/lib/components/ui/skeleton/skeleton.svelte b/ui/src/lib/components/ui/skeleton/skeleton.svelte new file mode 100644 index 0000000..c7e3d26 --- /dev/null +++ b/ui/src/lib/components/ui/skeleton/skeleton.svelte @@ -0,0 +1,17 @@ + + +
    diff --git a/ui/src/lib/components/ui/slider.svelte b/ui/src/lib/components/ui/slider.svelte new file mode 100644 index 0000000..23e1ac3 --- /dev/null +++ b/ui/src/lib/components/ui/slider.svelte @@ -0,0 +1,78 @@ + + +
    + {#if label} +
    {label}
    + {/if} +
    + +
    + {min} + {value} + {max} +
    +
    +
    + + diff --git a/ui/src/lib/components/ui/sonner/index.ts b/ui/src/lib/components/ui/sonner/index.ts new file mode 100644 index 0000000..1ad9f4a --- /dev/null +++ b/ui/src/lib/components/ui/sonner/index.ts @@ -0,0 +1 @@ +export { default as Toaster } from "./sonner.svelte"; diff --git a/ui/src/lib/components/ui/sonner/sonner.svelte b/ui/src/lib/components/ui/sonner/sonner.svelte new file mode 100644 index 0000000..1f50e1e --- /dev/null +++ b/ui/src/lib/components/ui/sonner/sonner.svelte @@ -0,0 +1,13 @@ + + + diff --git a/ui/src/lib/components/ui/switch/index.ts b/ui/src/lib/components/ui/switch/index.ts new file mode 100644 index 0000000..f5533db --- /dev/null +++ b/ui/src/lib/components/ui/switch/index.ts @@ -0,0 +1,7 @@ +import Root from "./switch.svelte"; + +export { + Root, + // + Root as Switch, +}; diff --git a/ui/src/lib/components/ui/switch/switch.svelte b/ui/src/lib/components/ui/switch/switch.svelte new file mode 100644 index 0000000..78477dd --- /dev/null +++ b/ui/src/lib/components/ui/switch/switch.svelte @@ -0,0 +1,29 @@ + + + + + diff --git a/ui/src/lib/components/ui/tabs/index.ts b/ui/src/lib/components/ui/tabs/index.ts new file mode 100644 index 0000000..12d4327 --- /dev/null +++ b/ui/src/lib/components/ui/tabs/index.ts @@ -0,0 +1,16 @@ +import Root from "./tabs.svelte"; +import Content from "./tabs-content.svelte"; +import List from "./tabs-list.svelte"; +import Trigger from "./tabs-trigger.svelte"; + +export { + Root, + Content, + List, + Trigger, + // + Root as Tabs, + Content as TabsContent, + List as TabsList, + Trigger as TabsTrigger, +}; diff --git a/ui/src/lib/components/ui/tabs/tabs-content.svelte b/ui/src/lib/components/ui/tabs/tabs-content.svelte new file mode 100644 index 0000000..340d65c --- /dev/null +++ b/ui/src/lib/components/ui/tabs/tabs-content.svelte @@ -0,0 +1,17 @@ + + + diff --git a/ui/src/lib/components/ui/tabs/tabs-list.svelte b/ui/src/lib/components/ui/tabs/tabs-list.svelte new file mode 100644 index 0000000..08932b6 --- /dev/null +++ b/ui/src/lib/components/ui/tabs/tabs-list.svelte @@ -0,0 +1,20 @@ + + + diff --git a/ui/src/lib/components/ui/tabs/tabs-trigger.svelte b/ui/src/lib/components/ui/tabs/tabs-trigger.svelte new file mode 100644 index 0000000..dced992 --- /dev/null +++ b/ui/src/lib/components/ui/tabs/tabs-trigger.svelte @@ -0,0 +1,20 @@ + + + diff --git a/ui/src/lib/components/ui/tabs/tabs.svelte b/ui/src/lib/components/ui/tabs/tabs.svelte new file mode 100644 index 0000000..ef6cada --- /dev/null +++ b/ui/src/lib/components/ui/tabs/tabs.svelte @@ -0,0 +1,19 @@ + + + diff --git a/ui/src/lib/components/ui/tooltip/index.ts b/ui/src/lib/components/ui/tooltip/index.ts new file mode 100644 index 0000000..313a7f0 --- /dev/null +++ b/ui/src/lib/components/ui/tooltip/index.ts @@ -0,0 +1,21 @@ +import { Tooltip as TooltipPrimitive } from "bits-ui"; +import Trigger from "./tooltip-trigger.svelte"; +import Content from "./tooltip-content.svelte"; + +const Root = TooltipPrimitive.Root; +const Provider = TooltipPrimitive.Provider; +const Portal = TooltipPrimitive.Portal; + +export { + Root, + Trigger, + Content, + Provider, + Portal, + // + Root as Tooltip, + Content as TooltipContent, + Trigger as TooltipTrigger, + Provider as TooltipProvider, + Portal as TooltipPortal, +}; diff --git a/ui/src/lib/components/ui/tooltip/tooltip-content.svelte b/ui/src/lib/components/ui/tooltip/tooltip-content.svelte new file mode 100644 index 0000000..e495efe --- /dev/null +++ b/ui/src/lib/components/ui/tooltip/tooltip-content.svelte @@ -0,0 +1,47 @@ + + + + + {@render children?.()} + + {#snippet child({ props })} +
    + {/snippet} +
    +
    +
    diff --git a/ui/src/lib/components/ui/tooltip/tooltip-trigger.svelte b/ui/src/lib/components/ui/tooltip/tooltip-trigger.svelte new file mode 100644 index 0000000..1acdaa4 --- /dev/null +++ b/ui/src/lib/components/ui/tooltip/tooltip-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/ui/src/lib/gen/control_communication.ts b/ui/src/lib/gen/control_communication.ts new file mode 100644 index 0000000..986f9e3 --- /dev/null +++ b/ui/src/lib/gen/control_communication.ts @@ -0,0 +1,290 @@ +// Code generated by protoc-gen-ts_proto. DO NOT EDIT. +// versions: +// protoc-gen-ts_proto v2.7.7 +// protoc v6.32.1 +// source: control_communication.proto + +/* eslint-disable */ +import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire"; + +export const protobufPackage = "control_communication"; + +export interface RfidId { + value: number; +} + +export interface SyncResponse { + currentTime: number; + pendingChanges: boolean; +} + +export interface SyncRequest { + lastSync?: number | undefined; + accessLogs: { [key: number]: number }; +} + +export interface SyncRequest_AccessLogsEntry { + key: number; + value: number; +} + +function createBaseRfidId(): RfidId { + return { value: 0 }; +} + +export const RfidId: MessageFns = { + encode(message: RfidId, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.value !== 0) { + writer.uint32(8).uint32(message.value); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): RfidId { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseRfidId(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.value = reader.uint32(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create, I>>(base?: I): RfidId { + return RfidId.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): RfidId { + const message = createBaseRfidId(); + message.value = object.value ?? 0; + return message; + }, +}; + +function createBaseSyncResponse(): SyncResponse { + return { currentTime: 0, pendingChanges: false }; +} + +export const SyncResponse: MessageFns = { + encode(message: SyncResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.currentTime !== 0) { + writer.uint32(8).int64(message.currentTime); + } + if (message.pendingChanges !== false) { + writer.uint32(16).bool(message.pendingChanges); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): SyncResponse { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseSyncResponse(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.currentTime = longToNumber(reader.int64()); + continue; + } + case 2: { + if (tag !== 16) { + break; + } + + message.pendingChanges = reader.bool(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create, I>>(base?: I): SyncResponse { + return SyncResponse.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): SyncResponse { + const message = createBaseSyncResponse(); + message.currentTime = object.currentTime ?? 0; + message.pendingChanges = object.pendingChanges ?? false; + return message; + }, +}; + +function createBaseSyncRequest(): SyncRequest { + return { lastSync: undefined, accessLogs: {} }; +} + +export const SyncRequest: MessageFns = { + encode(message: SyncRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.lastSync !== undefined) { + writer.uint32(8).int64(message.lastSync); + } + Object.entries(message.accessLogs).forEach(([key, value]) => { + SyncRequest_AccessLogsEntry.encode({ key: key as any, value }, writer.uint32(18).fork()).join(); + }); + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): SyncRequest { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseSyncRequest(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.lastSync = longToNumber(reader.int64()); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + const entry2 = SyncRequest_AccessLogsEntry.decode(reader, reader.uint32()); + if (entry2.value !== undefined) { + message.accessLogs[entry2.key] = entry2.value; + } + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create, I>>(base?: I): SyncRequest { + return SyncRequest.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): SyncRequest { + const message = createBaseSyncRequest(); + message.lastSync = object.lastSync ?? undefined; + message.accessLogs = Object.entries(object.accessLogs ?? {}).reduce<{ [key: number]: number }>( + (acc, [key, value]) => { + if (value !== undefined) { + acc[globalThis.Number(key)] = globalThis.Number(value); + } + return acc; + }, + {}, + ); + return message; + }, +}; + +function createBaseSyncRequest_AccessLogsEntry(): SyncRequest_AccessLogsEntry { + return { key: 0, value: 0 }; +} + +export const SyncRequest_AccessLogsEntry: MessageFns = { + encode(message: SyncRequest_AccessLogsEntry, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.key !== 0) { + writer.uint32(8).uint64(message.key); + } + if (message.value !== 0) { + writer.uint32(16).uint32(message.value); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): SyncRequest_AccessLogsEntry { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseSyncRequest_AccessLogsEntry(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.key = longToNumber(reader.uint64()); + continue; + } + case 2: { + if (tag !== 16) { + break; + } + + message.value = reader.uint32(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create, I>>(base?: I): SyncRequest_AccessLogsEntry { + return SyncRequest_AccessLogsEntry.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): SyncRequest_AccessLogsEntry { + const message = createBaseSyncRequest_AccessLogsEntry(); + message.key = object.key ?? 0; + message.value = object.value ?? 0; + return message; + }, +}; + +type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined; + +export type DeepPartial = T extends Builtin ? T + : T extends globalThis.Array ? globalThis.Array> + : T extends ReadonlyArray ? ReadonlyArray> + : T extends {} ? { [K in keyof T]?: DeepPartial } + : Partial; + +type KeysOfUnion = T extends T ? keyof T : never; +export type Exact = P extends Builtin ? P + : P & { [K in keyof P]: Exact } & { [K in Exclude>]: never }; + +function longToNumber(int64: { toString(): string }): number { + const num = globalThis.Number(int64.toString()); + if (num > globalThis.Number.MAX_SAFE_INTEGER) { + throw new globalThis.Error("Value is larger than Number.MAX_SAFE_INTEGER"); + } + if (num < globalThis.Number.MIN_SAFE_INTEGER) { + throw new globalThis.Error("Value is smaller than Number.MIN_SAFE_INTEGER"); + } + return num; +} + +export interface MessageFns { + encode(message: T, writer?: BinaryWriter): BinaryWriter; + decode(input: BinaryReader | Uint8Array, length?: number): T; + create, I>>(base?: I): T; + fromPartial, I>>(object: I): T; +} diff --git a/ui/src/lib/gen/device.ts b/ui/src/lib/gen/device.ts new file mode 100644 index 0000000..4067c4c --- /dev/null +++ b/ui/src/lib/gen/device.ts @@ -0,0 +1,395 @@ +// Code generated by protoc-gen-ts_proto. DO NOT EDIT. +// versions: +// protoc-gen-ts_proto v2.7.7 +// protoc v6.32.1 +// source: device.proto + +/* eslint-disable */ +import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire"; + +export const protobufPackage = "device"; + +/** WiFi connection state enumeration */ +export enum WifiConnectionState { + WIFI_DISCONNECTED = 0, + WIFI_CONNECTED = 1, + WIFI_CONNECTING = 2, + WIFI_FAILED = 3, + UNRECOGNIZED = -1, +} + +export enum DeviceLogLevel { + LOG_LEVEL_DEBUG = 0, + LOG_LEVEL_INFO = 1, + LOG_LEVEL_WARN = 2, + LOG_LEVEL_ERROR = 3, + UNRECOGNIZED = -1, +} + +/** Device information */ +export interface DeviceInfo { + deviceId: string; + firmwareVersion: string; + hardwareVersion: string; + uptimeSeconds: number; + /** STA mode info */ + staConnectionState: WifiConnectionState; + staIp: string; + staGateway: string; + /** Signal strength in dBm */ + staSignalStrength: number; + /** AP mode info */ + apConnectionState: WifiConnectionState; + apIp: string; + apClientCount: number; +} + +export interface DeviceLogEntry { + /** Unix timestamp in milliseconds */ + timestamp: number; + level: DeviceLogLevel; + message: string; +} + +export interface DeviceStatus { + info?: DeviceInfo | undefined; + logs: DeviceLogEntry[]; +} + +function createBaseDeviceInfo(): DeviceInfo { + return { + deviceId: "", + firmwareVersion: "", + hardwareVersion: "", + uptimeSeconds: 0, + staConnectionState: 0, + staIp: "", + staGateway: "", + staSignalStrength: 0, + apConnectionState: 0, + apIp: "", + apClientCount: 0, + }; +} + +export const DeviceInfo: MessageFns = { + encode(message: DeviceInfo, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.deviceId !== "") { + writer.uint32(10).string(message.deviceId); + } + if (message.firmwareVersion !== "") { + writer.uint32(18).string(message.firmwareVersion); + } + if (message.hardwareVersion !== "") { + writer.uint32(26).string(message.hardwareVersion); + } + if (message.uptimeSeconds !== 0) { + writer.uint32(32).uint32(message.uptimeSeconds); + } + if (message.staConnectionState !== 0) { + writer.uint32(40).int32(message.staConnectionState); + } + if (message.staIp !== "") { + writer.uint32(50).string(message.staIp); + } + if (message.staGateway !== "") { + writer.uint32(58).string(message.staGateway); + } + if (message.staSignalStrength !== 0) { + writer.uint32(64).int32(message.staSignalStrength); + } + if (message.apConnectionState !== 0) { + writer.uint32(72).int32(message.apConnectionState); + } + if (message.apIp !== "") { + writer.uint32(82).string(message.apIp); + } + if (message.apClientCount !== 0) { + writer.uint32(88).uint32(message.apClientCount); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): DeviceInfo { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseDeviceInfo(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.deviceId = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.firmwareVersion = reader.string(); + continue; + } + case 3: { + if (tag !== 26) { + break; + } + + message.hardwareVersion = reader.string(); + continue; + } + case 4: { + if (tag !== 32) { + break; + } + + message.uptimeSeconds = reader.uint32(); + continue; + } + case 5: { + if (tag !== 40) { + break; + } + + message.staConnectionState = reader.int32() as any; + continue; + } + case 6: { + if (tag !== 50) { + break; + } + + message.staIp = reader.string(); + continue; + } + case 7: { + if (tag !== 58) { + break; + } + + message.staGateway = reader.string(); + continue; + } + case 8: { + if (tag !== 64) { + break; + } + + message.staSignalStrength = reader.int32(); + continue; + } + case 9: { + if (tag !== 72) { + break; + } + + message.apConnectionState = reader.int32() as any; + continue; + } + case 10: { + if (tag !== 82) { + break; + } + + message.apIp = reader.string(); + continue; + } + case 11: { + if (tag !== 88) { + break; + } + + message.apClientCount = reader.uint32(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create, I>>(base?: I): DeviceInfo { + return DeviceInfo.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): DeviceInfo { + const message = createBaseDeviceInfo(); + message.deviceId = object.deviceId ?? ""; + message.firmwareVersion = object.firmwareVersion ?? ""; + message.hardwareVersion = object.hardwareVersion ?? ""; + message.uptimeSeconds = object.uptimeSeconds ?? 0; + message.staConnectionState = object.staConnectionState ?? 0; + message.staIp = object.staIp ?? ""; + message.staGateway = object.staGateway ?? ""; + message.staSignalStrength = object.staSignalStrength ?? 0; + message.apConnectionState = object.apConnectionState ?? 0; + message.apIp = object.apIp ?? ""; + message.apClientCount = object.apClientCount ?? 0; + return message; + }, +}; + +function createBaseDeviceLogEntry(): DeviceLogEntry { + return { timestamp: 0, level: 0, message: "" }; +} + +export const DeviceLogEntry: MessageFns = { + encode(message: DeviceLogEntry, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.timestamp !== 0) { + writer.uint32(8).int64(message.timestamp); + } + if (message.level !== 0) { + writer.uint32(16).int32(message.level); + } + if (message.message !== "") { + writer.uint32(26).string(message.message); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): DeviceLogEntry { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseDeviceLogEntry(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.timestamp = longToNumber(reader.int64()); + continue; + } + case 2: { + if (tag !== 16) { + break; + } + + message.level = reader.int32() as any; + continue; + } + case 3: { + if (tag !== 26) { + break; + } + + message.message = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create, I>>(base?: I): DeviceLogEntry { + return DeviceLogEntry.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): DeviceLogEntry { + const message = createBaseDeviceLogEntry(); + message.timestamp = object.timestamp ?? 0; + message.level = object.level ?? 0; + message.message = object.message ?? ""; + return message; + }, +}; + +function createBaseDeviceStatus(): DeviceStatus { + return { info: undefined, logs: [] }; +} + +export const DeviceStatus: MessageFns = { + encode(message: DeviceStatus, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.info !== undefined) { + DeviceInfo.encode(message.info, writer.uint32(10).fork()).join(); + } + for (const v of message.logs) { + DeviceLogEntry.encode(v!, writer.uint32(18).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): DeviceStatus { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseDeviceStatus(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.info = DeviceInfo.decode(reader, reader.uint32()); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.logs.push(DeviceLogEntry.decode(reader, reader.uint32())); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create, I>>(base?: I): DeviceStatus { + return DeviceStatus.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): DeviceStatus { + const message = createBaseDeviceStatus(); + message.info = (object.info !== undefined && object.info !== null) + ? DeviceInfo.fromPartial(object.info) + : undefined; + message.logs = object.logs?.map((e) => DeviceLogEntry.fromPartial(e)) || []; + return message; + }, +}; + +type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined; + +export type DeepPartial = T extends Builtin ? T + : T extends globalThis.Array ? globalThis.Array> + : T extends ReadonlyArray ? ReadonlyArray> + : T extends {} ? { [K in keyof T]?: DeepPartial } + : Partial; + +type KeysOfUnion = T extends T ? keyof T : never; +export type Exact = P extends Builtin ? P + : P & { [K in keyof P]: Exact } & { [K in Exclude>]: never }; + +function longToNumber(int64: { toString(): string }): number { + const num = globalThis.Number(int64.toString()); + if (num > globalThis.Number.MAX_SAFE_INTEGER) { + throw new globalThis.Error("Value is larger than Number.MAX_SAFE_INTEGER"); + } + if (num < globalThis.Number.MIN_SAFE_INTEGER) { + throw new globalThis.Error("Value is smaller than Number.MIN_SAFE_INTEGER"); + } + return num; +} + +export interface MessageFns { + encode(message: T, writer?: BinaryWriter): BinaryWriter; + decode(input: BinaryReader | Uint8Array, length?: number): T; + create, I>>(base?: I): T; + fromPartial, I>>(object: I): T; +} diff --git a/ui/src/lib/gen/device_communication.ts b/ui/src/lib/gen/device_communication.ts new file mode 100644 index 0000000..941a39a --- /dev/null +++ b/ui/src/lib/gen/device_communication.ts @@ -0,0 +1,323 @@ +// Code generated by protoc-gen-ts_proto. DO NOT EDIT. +// versions: +// protoc-gen-ts_proto v2.7.7 +// protoc v6.32.1 +// source: control_communication.proto + +/* eslint-disable */ +import { BinaryReader, BinaryWriter } from '@bufbuild/protobuf/wire'; + +export const protobufPackage = 'control_communication'; + +export interface RfidId { + value: number; +} + +export interface DeviceCommunicationSyncResponse { + currentTime: number; + pendingChanges: boolean; +} + +export interface DeviceCommunicationSyncRequest { + lastSync?: number | undefined; + logs: { [key: string]: string }; +} + +export interface DeviceCommunicationSyncRequest_LogsEntry { + key: string; + value: string; +} + +function createBaseRfidId(): RfidId { + return { value: 0 }; +} + +export const RfidId: MessageFns = { + encode(message: RfidId, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.value !== 0) { + writer.uint32(8).uint32(message.value); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): RfidId { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseRfidId(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.value = reader.uint32(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create, I>>(base?: I): RfidId { + return RfidId.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): RfidId { + const message = createBaseRfidId(); + message.value = object.value ?? 0; + return message; + } +}; + +function createBaseDeviceCommunicationSyncResponse(): DeviceCommunicationSyncResponse { + return { currentTime: 0, pendingChanges: false }; +} + +export const DeviceCommunicationSyncResponse: MessageFns = { + encode( + message: DeviceCommunicationSyncResponse, + writer: BinaryWriter = new BinaryWriter() + ): BinaryWriter { + if (message.currentTime !== 0) { + writer.uint32(8).int64(message.currentTime); + } + if (message.pendingChanges !== false) { + writer.uint32(16).bool(message.pendingChanges); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): DeviceCommunicationSyncResponse { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseDeviceCommunicationSyncResponse(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.currentTime = longToNumber(reader.int64()); + continue; + } + case 2: { + if (tag !== 16) { + break; + } + + message.pendingChanges = reader.bool(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create, I>>( + base?: I + ): DeviceCommunicationSyncResponse { + return DeviceCommunicationSyncResponse.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>( + object: I + ): DeviceCommunicationSyncResponse { + const message = createBaseDeviceCommunicationSyncResponse(); + message.currentTime = object.currentTime ?? 0; + message.pendingChanges = object.pendingChanges ?? false; + return message; + } +}; + +function createBaseDeviceCommunicationSyncRequest(): DeviceCommunicationSyncRequest { + return { lastSync: undefined, logs: {} }; +} + +export const DeviceCommunicationSyncRequest: MessageFns = { + encode( + message: DeviceCommunicationSyncRequest, + writer: BinaryWriter = new BinaryWriter() + ): BinaryWriter { + if (message.lastSync !== undefined) { + writer.uint32(8).int64(message.lastSync); + } + Object.entries(message.logs).forEach(([key, value]) => { + DeviceCommunicationSyncRequest_LogsEntry.encode( + { key: key as any, value }, + writer.uint32(18).fork() + ).join(); + }); + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): DeviceCommunicationSyncRequest { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseDeviceCommunicationSyncRequest(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.lastSync = longToNumber(reader.int64()); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + const entry2 = DeviceCommunicationSyncRequest_LogsEntry.decode(reader, reader.uint32()); + if (entry2.value !== undefined) { + message.logs[entry2.key] = entry2.value; + } + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create, I>>( + base?: I + ): DeviceCommunicationSyncRequest { + return DeviceCommunicationSyncRequest.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>( + object: I + ): DeviceCommunicationSyncRequest { + const message = createBaseDeviceCommunicationSyncRequest(); + message.lastSync = object.lastSync ?? undefined; + message.logs = Object.entries(object.logs ?? {}).reduce<{ [key: string]: string }>( + (acc, [key, value]) => { + if (value !== undefined) { + acc[key] = globalThis.String(value); + } + return acc; + }, + {} + ); + return message; + } +}; + +function createBaseDeviceCommunicationSyncRequest_LogsEntry(): DeviceCommunicationSyncRequest_LogsEntry { + return { key: '', value: '' }; +} + +export const DeviceCommunicationSyncRequest_LogsEntry: MessageFns = + { + encode( + message: DeviceCommunicationSyncRequest_LogsEntry, + writer: BinaryWriter = new BinaryWriter() + ): BinaryWriter { + if (message.key !== '') { + writer.uint32(10).string(message.key); + } + if (message.value !== '') { + writer.uint32(18).string(message.value); + } + return writer; + }, + + decode( + input: BinaryReader | Uint8Array, + length?: number + ): DeviceCommunicationSyncRequest_LogsEntry { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseDeviceCommunicationSyncRequest_LogsEntry(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.key = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.value = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create, I>>( + base?: I + ): DeviceCommunicationSyncRequest_LogsEntry { + return DeviceCommunicationSyncRequest_LogsEntry.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>( + object: I + ): DeviceCommunicationSyncRequest_LogsEntry { + const message = createBaseDeviceCommunicationSyncRequest_LogsEntry(); + message.key = object.key ?? ''; + message.value = object.value ?? ''; + return message; + } + }; + +type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined; + +export type DeepPartial = T extends Builtin + ? T + : T extends globalThis.Array + ? globalThis.Array> + : T extends ReadonlyArray + ? ReadonlyArray> + : T extends {} + ? { [K in keyof T]?: DeepPartial } + : Partial; + +type KeysOfUnion = T extends T ? keyof T : never; +export type Exact = P extends Builtin + ? P + : P & { [K in keyof P]: Exact } & { [K in Exclude>]: never }; + +function longToNumber(int64: { toString(): string }): number { + const num = globalThis.Number(int64.toString()); + if (num > globalThis.Number.MAX_SAFE_INTEGER) { + throw new globalThis.Error('Value is larger than Number.MAX_SAFE_INTEGER'); + } + if (num < globalThis.Number.MIN_SAFE_INTEGER) { + throw new globalThis.Error('Value is smaller than Number.MIN_SAFE_INTEGER'); + } + return num; +} + +export interface MessageFns { + encode(message: T, writer?: BinaryWriter): BinaryWriter; + decode(input: BinaryReader | Uint8Array, length?: number): T; + create, I>>(base?: I): T; + fromPartial, I>>(object: I): T; +} diff --git a/ui/src/lib/gen/hardware.ts b/ui/src/lib/gen/hardware.ts new file mode 100644 index 0000000..c5db5a5 --- /dev/null +++ b/ui/src/lib/gen/hardware.ts @@ -0,0 +1,1126 @@ +// Code generated by protoc-gen-ts_proto. DO NOT EDIT. +// versions: +// protoc-gen-ts_proto v2.7.7 +// protoc v6.32.1 +// source: hardware.proto + +/* eslint-disable */ +import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire"; + +export const protobufPackage = "hardware"; + +/** LED animation types */ +export enum LedAnimation { + LED_ANIMATION_STATIC = 0, + LED_ANIMATION_PULSE = 1, + LED_ANIMATION_FADE = 2, + LED_ANIMATION_FLICKER = 3, + UNRECOGNIZED = -1, +} + +/** Hardware configuration */ +export interface HardwareConfig { + holdDurationMs: number; + override: boolean; + relayPin: number; + sensorRxPin: number; + sensorTxPin: number; + onOpenLed?: LedConfig | undefined; + defaultLed?: LedConfig | undefined; + onInvalidLed?: LedConfig | undefined; + enableSerialSensor: boolean; + /** + * repeated char sensor_api_key = 10; + * bool enable_ws_sensor = 11; + */ + onOverrideOpenLed?: LedConfig | undefined; + onInterceptionLed?: LedConfig | undefined; +} + +/** LED configuration */ +export interface LedConfig { + /** General properties that apply to all animations */ + brightness: number; + /** 0 for indefinite */ + durationMs: number; + staticParams?: StaticParams | undefined; + pulseParams?: PulseParams | undefined; + fadeParams?: FadeParams | undefined; + flickerParams?: FlickerParams | undefined; +} + +/** Define the specific parameters for each animation type */ +export interface StaticParams { + color: number; +} + +export interface PulseParams { + color: number; + speedMs: number; +} + +export interface FadeParams { + /** Fade between these colors */ + colors: number[]; + speedMs: number; +} + +export interface FlickerParams { + color: number; + /** e.g., 0-100 */ + intensity: number; +} + +export interface RfidReading { + cardId: number; +} + +export interface SensorToControlMessage { + sensorId: number; + rfidReading?: + | RfidReading + | undefined; + /** Add other sensor message types as needed */ + otaResponse?: SensorOTAEnableResponse | undefined; +} + +export interface SensorOTAEnable { + ssid: string; + password: string; + timeoutSeconds: number; + asStationMode: boolean; + useStaticIp: boolean; + staticIp: string; + netmask: string; + gateway: string; +} + +export interface SensorRestart { +} + +export interface SensorOTAEnableResponse { + success: boolean; + ipAddress: string; + errorMessage: string; +} + +export interface ControlToSensorMessage { + controlId: number; + ledConfig?: LedConfig | undefined; + otaEnable?: + | SensorOTAEnable + | undefined; + /** Add other control message types as needed */ + restart?: SensorRestart | undefined; +} + +function createBaseHardwareConfig(): HardwareConfig { + return { + holdDurationMs: 0, + override: false, + relayPin: 0, + sensorRxPin: 0, + sensorTxPin: 0, + onOpenLed: undefined, + defaultLed: undefined, + onInvalidLed: undefined, + enableSerialSensor: false, + onOverrideOpenLed: undefined, + onInterceptionLed: undefined, + }; +} + +export const HardwareConfig: MessageFns = { + encode(message: HardwareConfig, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.holdDurationMs !== 0) { + writer.uint32(8).uint32(message.holdDurationMs); + } + if (message.override !== false) { + writer.uint32(16).bool(message.override); + } + if (message.relayPin !== 0) { + writer.uint32(24).uint32(message.relayPin); + } + if (message.sensorRxPin !== 0) { + writer.uint32(32).uint32(message.sensorRxPin); + } + if (message.sensorTxPin !== 0) { + writer.uint32(40).uint32(message.sensorTxPin); + } + if (message.onOpenLed !== undefined) { + LedConfig.encode(message.onOpenLed, writer.uint32(50).fork()).join(); + } + if (message.defaultLed !== undefined) { + LedConfig.encode(message.defaultLed, writer.uint32(58).fork()).join(); + } + if (message.onInvalidLed !== undefined) { + LedConfig.encode(message.onInvalidLed, writer.uint32(66).fork()).join(); + } + if (message.enableSerialSensor !== false) { + writer.uint32(72).bool(message.enableSerialSensor); + } + if (message.onOverrideOpenLed !== undefined) { + LedConfig.encode(message.onOverrideOpenLed, writer.uint32(98).fork()).join(); + } + if (message.onInterceptionLed !== undefined) { + LedConfig.encode(message.onInterceptionLed, writer.uint32(106).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): HardwareConfig { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseHardwareConfig(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.holdDurationMs = reader.uint32(); + continue; + } + case 2: { + if (tag !== 16) { + break; + } + + message.override = reader.bool(); + continue; + } + case 3: { + if (tag !== 24) { + break; + } + + message.relayPin = reader.uint32(); + continue; + } + case 4: { + if (tag !== 32) { + break; + } + + message.sensorRxPin = reader.uint32(); + continue; + } + case 5: { + if (tag !== 40) { + break; + } + + message.sensorTxPin = reader.uint32(); + continue; + } + case 6: { + if (tag !== 50) { + break; + } + + message.onOpenLed = LedConfig.decode(reader, reader.uint32()); + continue; + } + case 7: { + if (tag !== 58) { + break; + } + + message.defaultLed = LedConfig.decode(reader, reader.uint32()); + continue; + } + case 8: { + if (tag !== 66) { + break; + } + + message.onInvalidLed = LedConfig.decode(reader, reader.uint32()); + continue; + } + case 9: { + if (tag !== 72) { + break; + } + + message.enableSerialSensor = reader.bool(); + continue; + } + case 12: { + if (tag !== 98) { + break; + } + + message.onOverrideOpenLed = LedConfig.decode(reader, reader.uint32()); + continue; + } + case 13: { + if (tag !== 106) { + break; + } + + message.onInterceptionLed = LedConfig.decode(reader, reader.uint32()); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create, I>>(base?: I): HardwareConfig { + return HardwareConfig.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): HardwareConfig { + const message = createBaseHardwareConfig(); + message.holdDurationMs = object.holdDurationMs ?? 0; + message.override = object.override ?? false; + message.relayPin = object.relayPin ?? 0; + message.sensorRxPin = object.sensorRxPin ?? 0; + message.sensorTxPin = object.sensorTxPin ?? 0; + message.onOpenLed = (object.onOpenLed !== undefined && object.onOpenLed !== null) + ? LedConfig.fromPartial(object.onOpenLed) + : undefined; + message.defaultLed = (object.defaultLed !== undefined && object.defaultLed !== null) + ? LedConfig.fromPartial(object.defaultLed) + : undefined; + message.onInvalidLed = (object.onInvalidLed !== undefined && object.onInvalidLed !== null) + ? LedConfig.fromPartial(object.onInvalidLed) + : undefined; + message.enableSerialSensor = object.enableSerialSensor ?? false; + message.onOverrideOpenLed = (object.onOverrideOpenLed !== undefined && object.onOverrideOpenLed !== null) + ? LedConfig.fromPartial(object.onOverrideOpenLed) + : undefined; + message.onInterceptionLed = (object.onInterceptionLed !== undefined && object.onInterceptionLed !== null) + ? LedConfig.fromPartial(object.onInterceptionLed) + : undefined; + return message; + }, +}; + +function createBaseLedConfig(): LedConfig { + return { + brightness: 0, + durationMs: 0, + staticParams: undefined, + pulseParams: undefined, + fadeParams: undefined, + flickerParams: undefined, + }; +} + +export const LedConfig: MessageFns = { + encode(message: LedConfig, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.brightness !== 0) { + writer.uint32(8).uint32(message.brightness); + } + if (message.durationMs !== 0) { + writer.uint32(16).uint32(message.durationMs); + } + if (message.staticParams !== undefined) { + StaticParams.encode(message.staticParams, writer.uint32(26).fork()).join(); + } + if (message.pulseParams !== undefined) { + PulseParams.encode(message.pulseParams, writer.uint32(34).fork()).join(); + } + if (message.fadeParams !== undefined) { + FadeParams.encode(message.fadeParams, writer.uint32(42).fork()).join(); + } + if (message.flickerParams !== undefined) { + FlickerParams.encode(message.flickerParams, writer.uint32(50).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): LedConfig { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseLedConfig(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.brightness = reader.uint32(); + continue; + } + case 2: { + if (tag !== 16) { + break; + } + + message.durationMs = reader.uint32(); + continue; + } + case 3: { + if (tag !== 26) { + break; + } + + message.staticParams = StaticParams.decode(reader, reader.uint32()); + continue; + } + case 4: { + if (tag !== 34) { + break; + } + + message.pulseParams = PulseParams.decode(reader, reader.uint32()); + continue; + } + case 5: { + if (tag !== 42) { + break; + } + + message.fadeParams = FadeParams.decode(reader, reader.uint32()); + continue; + } + case 6: { + if (tag !== 50) { + break; + } + + message.flickerParams = FlickerParams.decode(reader, reader.uint32()); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create, I>>(base?: I): LedConfig { + return LedConfig.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): LedConfig { + const message = createBaseLedConfig(); + message.brightness = object.brightness ?? 0; + message.durationMs = object.durationMs ?? 0; + message.staticParams = (object.staticParams !== undefined && object.staticParams !== null) + ? StaticParams.fromPartial(object.staticParams) + : undefined; + message.pulseParams = (object.pulseParams !== undefined && object.pulseParams !== null) + ? PulseParams.fromPartial(object.pulseParams) + : undefined; + message.fadeParams = (object.fadeParams !== undefined && object.fadeParams !== null) + ? FadeParams.fromPartial(object.fadeParams) + : undefined; + message.flickerParams = (object.flickerParams !== undefined && object.flickerParams !== null) + ? FlickerParams.fromPartial(object.flickerParams) + : undefined; + return message; + }, +}; + +function createBaseStaticParams(): StaticParams { + return { color: 0 }; +} + +export const StaticParams: MessageFns = { + encode(message: StaticParams, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.color !== 0) { + writer.uint32(8).uint32(message.color); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): StaticParams { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseStaticParams(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.color = reader.uint32(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create, I>>(base?: I): StaticParams { + return StaticParams.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): StaticParams { + const message = createBaseStaticParams(); + message.color = object.color ?? 0; + return message; + }, +}; + +function createBasePulseParams(): PulseParams { + return { color: 0, speedMs: 0 }; +} + +export const PulseParams: MessageFns = { + encode(message: PulseParams, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.color !== 0) { + writer.uint32(8).uint32(message.color); + } + if (message.speedMs !== 0) { + writer.uint32(16).uint32(message.speedMs); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): PulseParams { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBasePulseParams(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.color = reader.uint32(); + continue; + } + case 2: { + if (tag !== 16) { + break; + } + + message.speedMs = reader.uint32(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create, I>>(base?: I): PulseParams { + return PulseParams.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): PulseParams { + const message = createBasePulseParams(); + message.color = object.color ?? 0; + message.speedMs = object.speedMs ?? 0; + return message; + }, +}; + +function createBaseFadeParams(): FadeParams { + return { colors: [], speedMs: 0 }; +} + +export const FadeParams: MessageFns = { + encode(message: FadeParams, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + writer.uint32(10).fork(); + for (const v of message.colors) { + writer.uint32(v); + } + writer.join(); + if (message.speedMs !== 0) { + writer.uint32(16).uint32(message.speedMs); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): FadeParams { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseFadeParams(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag === 8) { + message.colors.push(reader.uint32()); + + continue; + } + + if (tag === 10) { + const end2 = reader.uint32() + reader.pos; + while (reader.pos < end2) { + message.colors.push(reader.uint32()); + } + + continue; + } + + break; + } + case 2: { + if (tag !== 16) { + break; + } + + message.speedMs = reader.uint32(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create, I>>(base?: I): FadeParams { + return FadeParams.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): FadeParams { + const message = createBaseFadeParams(); + message.colors = object.colors?.map((e) => e) || []; + message.speedMs = object.speedMs ?? 0; + return message; + }, +}; + +function createBaseFlickerParams(): FlickerParams { + return { color: 0, intensity: 0 }; +} + +export const FlickerParams: MessageFns = { + encode(message: FlickerParams, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.color !== 0) { + writer.uint32(8).uint32(message.color); + } + if (message.intensity !== 0) { + writer.uint32(16).uint32(message.intensity); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): FlickerParams { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseFlickerParams(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.color = reader.uint32(); + continue; + } + case 2: { + if (tag !== 16) { + break; + } + + message.intensity = reader.uint32(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create, I>>(base?: I): FlickerParams { + return FlickerParams.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): FlickerParams { + const message = createBaseFlickerParams(); + message.color = object.color ?? 0; + message.intensity = object.intensity ?? 0; + return message; + }, +}; + +function createBaseRfidReading(): RfidReading { + return { cardId: 0 }; +} + +export const RfidReading: MessageFns = { + encode(message: RfidReading, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.cardId !== 0) { + writer.uint32(8).uint32(message.cardId); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): RfidReading { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseRfidReading(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.cardId = reader.uint32(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create, I>>(base?: I): RfidReading { + return RfidReading.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): RfidReading { + const message = createBaseRfidReading(); + message.cardId = object.cardId ?? 0; + return message; + }, +}; + +function createBaseSensorToControlMessage(): SensorToControlMessage { + return { sensorId: 0, rfidReading: undefined, otaResponse: undefined }; +} + +export const SensorToControlMessage: MessageFns = { + encode(message: SensorToControlMessage, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.sensorId !== 0) { + writer.uint32(8).uint32(message.sensorId); + } + if (message.rfidReading !== undefined) { + RfidReading.encode(message.rfidReading, writer.uint32(18).fork()).join(); + } + if (message.otaResponse !== undefined) { + SensorOTAEnableResponse.encode(message.otaResponse, writer.uint32(26).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): SensorToControlMessage { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseSensorToControlMessage(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.sensorId = reader.uint32(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.rfidReading = RfidReading.decode(reader, reader.uint32()); + continue; + } + case 3: { + if (tag !== 26) { + break; + } + + message.otaResponse = SensorOTAEnableResponse.decode(reader, reader.uint32()); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create, I>>(base?: I): SensorToControlMessage { + return SensorToControlMessage.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): SensorToControlMessage { + const message = createBaseSensorToControlMessage(); + message.sensorId = object.sensorId ?? 0; + message.rfidReading = (object.rfidReading !== undefined && object.rfidReading !== null) + ? RfidReading.fromPartial(object.rfidReading) + : undefined; + message.otaResponse = (object.otaResponse !== undefined && object.otaResponse !== null) + ? SensorOTAEnableResponse.fromPartial(object.otaResponse) + : undefined; + return message; + }, +}; + +function createBaseSensorOTAEnable(): SensorOTAEnable { + return { + ssid: "", + password: "", + timeoutSeconds: 0, + asStationMode: false, + useStaticIp: false, + staticIp: "", + netmask: "", + gateway: "", + }; +} + +export const SensorOTAEnable: MessageFns = { + encode(message: SensorOTAEnable, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.ssid !== "") { + writer.uint32(10).string(message.ssid); + } + if (message.password !== "") { + writer.uint32(18).string(message.password); + } + if (message.timeoutSeconds !== 0) { + writer.uint32(24).uint32(message.timeoutSeconds); + } + if (message.asStationMode !== false) { + writer.uint32(32).bool(message.asStationMode); + } + if (message.useStaticIp !== false) { + writer.uint32(40).bool(message.useStaticIp); + } + if (message.staticIp !== "") { + writer.uint32(50).string(message.staticIp); + } + if (message.netmask !== "") { + writer.uint32(58).string(message.netmask); + } + if (message.gateway !== "") { + writer.uint32(66).string(message.gateway); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): SensorOTAEnable { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseSensorOTAEnable(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.ssid = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.password = reader.string(); + continue; + } + case 3: { + if (tag !== 24) { + break; + } + + message.timeoutSeconds = reader.uint32(); + continue; + } + case 4: { + if (tag !== 32) { + break; + } + + message.asStationMode = reader.bool(); + continue; + } + case 5: { + if (tag !== 40) { + break; + } + + message.useStaticIp = reader.bool(); + continue; + } + case 6: { + if (tag !== 50) { + break; + } + + message.staticIp = reader.string(); + continue; + } + case 7: { + if (tag !== 58) { + break; + } + + message.netmask = reader.string(); + continue; + } + case 8: { + if (tag !== 66) { + break; + } + + message.gateway = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create, I>>(base?: I): SensorOTAEnable { + return SensorOTAEnable.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): SensorOTAEnable { + const message = createBaseSensorOTAEnable(); + message.ssid = object.ssid ?? ""; + message.password = object.password ?? ""; + message.timeoutSeconds = object.timeoutSeconds ?? 0; + message.asStationMode = object.asStationMode ?? false; + message.useStaticIp = object.useStaticIp ?? false; + message.staticIp = object.staticIp ?? ""; + message.netmask = object.netmask ?? ""; + message.gateway = object.gateway ?? ""; + return message; + }, +}; + +function createBaseSensorRestart(): SensorRestart { + return {}; +} + +export const SensorRestart: MessageFns = { + encode(_: SensorRestart, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): SensorRestart { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseSensorRestart(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create, I>>(base?: I): SensorRestart { + return SensorRestart.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(_: I): SensorRestart { + const message = createBaseSensorRestart(); + return message; + }, +}; + +function createBaseSensorOTAEnableResponse(): SensorOTAEnableResponse { + return { success: false, ipAddress: "", errorMessage: "" }; +} + +export const SensorOTAEnableResponse: MessageFns = { + encode(message: SensorOTAEnableResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.success !== false) { + writer.uint32(8).bool(message.success); + } + if (message.ipAddress !== "") { + writer.uint32(18).string(message.ipAddress); + } + if (message.errorMessage !== "") { + writer.uint32(26).string(message.errorMessage); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): SensorOTAEnableResponse { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseSensorOTAEnableResponse(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.success = reader.bool(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.ipAddress = reader.string(); + continue; + } + case 3: { + if (tag !== 26) { + break; + } + + message.errorMessage = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create, I>>(base?: I): SensorOTAEnableResponse { + return SensorOTAEnableResponse.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): SensorOTAEnableResponse { + const message = createBaseSensorOTAEnableResponse(); + message.success = object.success ?? false; + message.ipAddress = object.ipAddress ?? ""; + message.errorMessage = object.errorMessage ?? ""; + return message; + }, +}; + +function createBaseControlToSensorMessage(): ControlToSensorMessage { + return { controlId: 0, ledConfig: undefined, otaEnable: undefined, restart: undefined }; +} + +export const ControlToSensorMessage: MessageFns = { + encode(message: ControlToSensorMessage, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.controlId !== 0) { + writer.uint32(8).uint32(message.controlId); + } + if (message.ledConfig !== undefined) { + LedConfig.encode(message.ledConfig, writer.uint32(18).fork()).join(); + } + if (message.otaEnable !== undefined) { + SensorOTAEnable.encode(message.otaEnable, writer.uint32(26).fork()).join(); + } + if (message.restart !== undefined) { + SensorRestart.encode(message.restart, writer.uint32(34).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): ControlToSensorMessage { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseControlToSensorMessage(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.controlId = reader.uint32(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.ledConfig = LedConfig.decode(reader, reader.uint32()); + continue; + } + case 3: { + if (tag !== 26) { + break; + } + + message.otaEnable = SensorOTAEnable.decode(reader, reader.uint32()); + continue; + } + case 4: { + if (tag !== 34) { + break; + } + + message.restart = SensorRestart.decode(reader, reader.uint32()); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create, I>>(base?: I): ControlToSensorMessage { + return ControlToSensorMessage.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): ControlToSensorMessage { + const message = createBaseControlToSensorMessage(); + message.controlId = object.controlId ?? 0; + message.ledConfig = (object.ledConfig !== undefined && object.ledConfig !== null) + ? LedConfig.fromPartial(object.ledConfig) + : undefined; + message.otaEnable = (object.otaEnable !== undefined && object.otaEnable !== null) + ? SensorOTAEnable.fromPartial(object.otaEnable) + : undefined; + message.restart = (object.restart !== undefined && object.restart !== null) + ? SensorRestart.fromPartial(object.restart) + : undefined; + return message; + }, +}; + +type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined; + +export type DeepPartial = T extends Builtin ? T + : T extends globalThis.Array ? globalThis.Array> + : T extends ReadonlyArray ? ReadonlyArray> + : T extends {} ? { [K in keyof T]?: DeepPartial } + : Partial; + +type KeysOfUnion = T extends T ? keyof T : never; +export type Exact = P extends Builtin ? P + : P & { [K in keyof P]: Exact } & { [K in Exclude>]: never }; + +export interface MessageFns { + encode(message: T, writer?: BinaryWriter): BinaryWriter; + decode(input: BinaryReader | Uint8Array, length?: number): T; + create, I>>(base?: I): T; + fromPartial, I>>(object: I): T; +} diff --git a/ui/src/lib/gen/settings.ts b/ui/src/lib/gen/settings.ts new file mode 100644 index 0000000..411858a --- /dev/null +++ b/ui/src/lib/gen/settings.ts @@ -0,0 +1,503 @@ +// Code generated by protoc-gen-ts_proto. DO NOT EDIT. +// versions: +// protoc-gen-ts_proto v2.7.7 +// protoc v6.32.1 +// source: settings.proto + +/* eslint-disable */ +import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire"; + +export const protobufPackage = "settings"; + +/** WiFi mode enumeration */ +export enum WifiMode { + WIFI_MODE_UNSPECIFIED = 0, + WIFI_MODE_STATION = 1, + WIFI_MODE_AP = 2, + WIFI_MODE_AP_STATION = 3, + UNRECOGNIZED = -1, +} + +/** Log level enumeration */ +export enum LogLevel { + LOG_LEVEL_DEBUG = 0, + LOG_LEVEL_INFO = 1, + LOG_LEVEL_WARN = 2, + LOG_LEVEL_ERROR = 3, + UNRECOGNIZED = -1, +} + +/** Settings data structure */ +export interface SettingsData { + syncServerUrl: string; + deviceApiKey: string; + syncIntervalSeconds: number; + autoSync: boolean; + wifiMode: WifiMode; + stationSsid: string; + stationPassword: string; + apSsid: string; + apPassword: string; + apChannel: number; + enableFallbackAp: boolean; + /** Unix timestamp in milliseconds */ + updatedAt: number; + /** Settings version for change tracking */ + version: number; + logLevel: LogLevel; +} + +/** Request to get current settings */ +export interface GetSettingsRequest { +} + +/** Response with current settings */ +export interface GetSettingsResponse { + settings?: SettingsData | undefined; +} + +/** Request to update settings */ +export interface UpdateSettingsRequest { + settings?: SettingsData | undefined; +} + +/** Response to settings update */ +export interface UpdateSettingsResponse { + success: boolean; + error?: string | undefined; +} + +function createBaseSettingsData(): SettingsData { + return { + syncServerUrl: "", + deviceApiKey: "", + syncIntervalSeconds: 0, + autoSync: false, + wifiMode: 0, + stationSsid: "", + stationPassword: "", + apSsid: "", + apPassword: "", + apChannel: 0, + enableFallbackAp: false, + updatedAt: 0, + version: 0, + logLevel: 0, + }; +} + +export const SettingsData: MessageFns = { + encode(message: SettingsData, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.syncServerUrl !== "") { + writer.uint32(10).string(message.syncServerUrl); + } + if (message.deviceApiKey !== "") { + writer.uint32(18).string(message.deviceApiKey); + } + if (message.syncIntervalSeconds !== 0) { + writer.uint32(24).uint32(message.syncIntervalSeconds); + } + if (message.autoSync !== false) { + writer.uint32(32).bool(message.autoSync); + } + if (message.wifiMode !== 0) { + writer.uint32(40).int32(message.wifiMode); + } + if (message.stationSsid !== "") { + writer.uint32(50).string(message.stationSsid); + } + if (message.stationPassword !== "") { + writer.uint32(58).string(message.stationPassword); + } + if (message.apSsid !== "") { + writer.uint32(66).string(message.apSsid); + } + if (message.apPassword !== "") { + writer.uint32(74).string(message.apPassword); + } + if (message.apChannel !== 0) { + writer.uint32(80).uint32(message.apChannel); + } + if (message.enableFallbackAp !== false) { + writer.uint32(88).bool(message.enableFallbackAp); + } + if (message.updatedAt !== 0) { + writer.uint32(96).int64(message.updatedAt); + } + if (message.version !== 0) { + writer.uint32(104).uint32(message.version); + } + if (message.logLevel !== 0) { + writer.uint32(112).int32(message.logLevel); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): SettingsData { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseSettingsData(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.syncServerUrl = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.deviceApiKey = reader.string(); + continue; + } + case 3: { + if (tag !== 24) { + break; + } + + message.syncIntervalSeconds = reader.uint32(); + continue; + } + case 4: { + if (tag !== 32) { + break; + } + + message.autoSync = reader.bool(); + continue; + } + case 5: { + if (tag !== 40) { + break; + } + + message.wifiMode = reader.int32() as any; + continue; + } + case 6: { + if (tag !== 50) { + break; + } + + message.stationSsid = reader.string(); + continue; + } + case 7: { + if (tag !== 58) { + break; + } + + message.stationPassword = reader.string(); + continue; + } + case 8: { + if (tag !== 66) { + break; + } + + message.apSsid = reader.string(); + continue; + } + case 9: { + if (tag !== 74) { + break; + } + + message.apPassword = reader.string(); + continue; + } + case 10: { + if (tag !== 80) { + break; + } + + message.apChannel = reader.uint32(); + continue; + } + case 11: { + if (tag !== 88) { + break; + } + + message.enableFallbackAp = reader.bool(); + continue; + } + case 12: { + if (tag !== 96) { + break; + } + + message.updatedAt = longToNumber(reader.int64()); + continue; + } + case 13: { + if (tag !== 104) { + break; + } + + message.version = reader.uint32(); + continue; + } + case 14: { + if (tag !== 112) { + break; + } + + message.logLevel = reader.int32() as any; + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create, I>>(base?: I): SettingsData { + return SettingsData.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): SettingsData { + const message = createBaseSettingsData(); + message.syncServerUrl = object.syncServerUrl ?? ""; + message.deviceApiKey = object.deviceApiKey ?? ""; + message.syncIntervalSeconds = object.syncIntervalSeconds ?? 0; + message.autoSync = object.autoSync ?? false; + message.wifiMode = object.wifiMode ?? 0; + message.stationSsid = object.stationSsid ?? ""; + message.stationPassword = object.stationPassword ?? ""; + message.apSsid = object.apSsid ?? ""; + message.apPassword = object.apPassword ?? ""; + message.apChannel = object.apChannel ?? 0; + message.enableFallbackAp = object.enableFallbackAp ?? false; + message.updatedAt = object.updatedAt ?? 0; + message.version = object.version ?? 0; + message.logLevel = object.logLevel ?? 0; + return message; + }, +}; + +function createBaseGetSettingsRequest(): GetSettingsRequest { + return {}; +} + +export const GetSettingsRequest: MessageFns = { + encode(_: GetSettingsRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): GetSettingsRequest { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseGetSettingsRequest(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create, I>>(base?: I): GetSettingsRequest { + return GetSettingsRequest.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(_: I): GetSettingsRequest { + const message = createBaseGetSettingsRequest(); + return message; + }, +}; + +function createBaseGetSettingsResponse(): GetSettingsResponse { + return { settings: undefined }; +} + +export const GetSettingsResponse: MessageFns = { + encode(message: GetSettingsResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.settings !== undefined) { + SettingsData.encode(message.settings, writer.uint32(10).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): GetSettingsResponse { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseGetSettingsResponse(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.settings = SettingsData.decode(reader, reader.uint32()); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create, I>>(base?: I): GetSettingsResponse { + return GetSettingsResponse.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): GetSettingsResponse { + const message = createBaseGetSettingsResponse(); + message.settings = (object.settings !== undefined && object.settings !== null) + ? SettingsData.fromPartial(object.settings) + : undefined; + return message; + }, +}; + +function createBaseUpdateSettingsRequest(): UpdateSettingsRequest { + return { settings: undefined }; +} + +export const UpdateSettingsRequest: MessageFns = { + encode(message: UpdateSettingsRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.settings !== undefined) { + SettingsData.encode(message.settings, writer.uint32(10).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): UpdateSettingsRequest { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseUpdateSettingsRequest(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.settings = SettingsData.decode(reader, reader.uint32()); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create, I>>(base?: I): UpdateSettingsRequest { + return UpdateSettingsRequest.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): UpdateSettingsRequest { + const message = createBaseUpdateSettingsRequest(); + message.settings = (object.settings !== undefined && object.settings !== null) + ? SettingsData.fromPartial(object.settings) + : undefined; + return message; + }, +}; + +function createBaseUpdateSettingsResponse(): UpdateSettingsResponse { + return { success: false, error: undefined }; +} + +export const UpdateSettingsResponse: MessageFns = { + encode(message: UpdateSettingsResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.success !== false) { + writer.uint32(8).bool(message.success); + } + if (message.error !== undefined) { + writer.uint32(18).string(message.error); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): UpdateSettingsResponse { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseUpdateSettingsResponse(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.success = reader.bool(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.error = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create, I>>(base?: I): UpdateSettingsResponse { + return UpdateSettingsResponse.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): UpdateSettingsResponse { + const message = createBaseUpdateSettingsResponse(); + message.success = object.success ?? false; + message.error = object.error ?? undefined; + return message; + }, +}; + +type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined; + +export type DeepPartial = T extends Builtin ? T + : T extends globalThis.Array ? globalThis.Array> + : T extends ReadonlyArray ? ReadonlyArray> + : T extends {} ? { [K in keyof T]?: DeepPartial } + : Partial; + +type KeysOfUnion = T extends T ? keyof T : never; +export type Exact = P extends Builtin ? P + : P & { [K in keyof P]: Exact } & { [K in Exclude>]: never }; + +function longToNumber(int64: { toString(): string }): number { + const num = globalThis.Number(int64.toString()); + if (num > globalThis.Number.MAX_SAFE_INTEGER) { + throw new globalThis.Error("Value is larger than Number.MAX_SAFE_INTEGER"); + } + if (num < globalThis.Number.MIN_SAFE_INTEGER) { + throw new globalThis.Error("Value is smaller than Number.MIN_SAFE_INTEGER"); + } + return num; +} + +export interface MessageFns { + encode(message: T, writer?: BinaryWriter): BinaryWriter; + decode(input: BinaryReader | Uint8Array, length?: number): T; + create, I>>(base?: I): T; + fromPartial, I>>(object: I): T; +} diff --git a/ui/src/lib/gen/webui.ts b/ui/src/lib/gen/webui.ts new file mode 100644 index 0000000..163d3aa --- /dev/null +++ b/ui/src/lib/gen/webui.ts @@ -0,0 +1,885 @@ +// Code generated by protoc-gen-ts_proto. DO NOT EDIT. +// versions: +// protoc-gen-ts_proto v2.7.7 +// protoc v6.32.1 +// source: webui.proto + +/* eslint-disable */ +import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire"; + +export const protobufPackage = "webui"; + +/** Login request from Web UI */ +export interface WebUiLoginRequest { + /** 1-32 chars */ + username: string; + /** 1-64 chars (plain or hashed) */ + password: string; +} + +/** Login response */ +export interface WebUiLoginResponse { + success: boolean; + /** session/JWT when success */ + token?: + | string + | undefined; + /** error message when failed */ + error?: string | undefined; +} + +/** Logout response */ +export interface WebUiLogoutResponse { + success: boolean; + error: string; +} + +/** User data structure */ +export interface WebUiUser { + /** 1-32 chars */ + username: string; + /** 32 bytes (SHA-256) */ + passwordHash: Uint8Array; +} + +/** User list (max 5 users) */ +export interface WebUiUserList { + users: WebUiUser[]; +} + +/** Request to get users */ +export interface GetUsersRequest { +} + +/** Response with users */ +export interface GetUsersResponse { + /** List of usernames */ + usernames: string[]; +} + +/** Request to add user */ +export interface AddUserRequest { + username: string; + password: string; +} + +/** Response to add user */ +export interface AddUserResponse { + success: boolean; + /** e.g., "table full", "user exists" */ + error?: string | undefined; +} + +/** Request to delete user */ +export interface DeleteUserRequest { + username: string; +} + +/** Response to delete user */ +export interface DeleteUserResponse { + success: boolean; + /** e.g., "user not found" */ + error?: string | undefined; +} + +/** Request to update user password */ +export interface UpdateUserPasswordRequest { + username: string; + newPassword: string; +} + +/** Response to update user password */ +export interface UpdateUserPasswordResponse { + success: boolean; + /** e.g., "user not found" */ + error?: string | undefined; +} + +/** Auth check response */ +export interface WebUiAuthCheckResponse { + authenticated: boolean; + username?: string | undefined; +} + +function createBaseWebUiLoginRequest(): WebUiLoginRequest { + return { username: "", password: "" }; +} + +export const WebUiLoginRequest: MessageFns = { + encode(message: WebUiLoginRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.username !== "") { + writer.uint32(10).string(message.username); + } + if (message.password !== "") { + writer.uint32(18).string(message.password); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): WebUiLoginRequest { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseWebUiLoginRequest(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.username = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.password = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create, I>>(base?: I): WebUiLoginRequest { + return WebUiLoginRequest.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): WebUiLoginRequest { + const message = createBaseWebUiLoginRequest(); + message.username = object.username ?? ""; + message.password = object.password ?? ""; + return message; + }, +}; + +function createBaseWebUiLoginResponse(): WebUiLoginResponse { + return { success: false, token: undefined, error: undefined }; +} + +export const WebUiLoginResponse: MessageFns = { + encode(message: WebUiLoginResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.success !== false) { + writer.uint32(8).bool(message.success); + } + if (message.token !== undefined) { + writer.uint32(18).string(message.token); + } + if (message.error !== undefined) { + writer.uint32(26).string(message.error); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): WebUiLoginResponse { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseWebUiLoginResponse(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.success = reader.bool(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.token = reader.string(); + continue; + } + case 3: { + if (tag !== 26) { + break; + } + + message.error = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create, I>>(base?: I): WebUiLoginResponse { + return WebUiLoginResponse.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): WebUiLoginResponse { + const message = createBaseWebUiLoginResponse(); + message.success = object.success ?? false; + message.token = object.token ?? undefined; + message.error = object.error ?? undefined; + return message; + }, +}; + +function createBaseWebUiLogoutResponse(): WebUiLogoutResponse { + return { success: false, error: "" }; +} + +export const WebUiLogoutResponse: MessageFns = { + encode(message: WebUiLogoutResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.success !== false) { + writer.uint32(8).bool(message.success); + } + if (message.error !== "") { + writer.uint32(18).string(message.error); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): WebUiLogoutResponse { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseWebUiLogoutResponse(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.success = reader.bool(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.error = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create, I>>(base?: I): WebUiLogoutResponse { + return WebUiLogoutResponse.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): WebUiLogoutResponse { + const message = createBaseWebUiLogoutResponse(); + message.success = object.success ?? false; + message.error = object.error ?? ""; + return message; + }, +}; + +function createBaseWebUiUser(): WebUiUser { + return { username: "", passwordHash: new Uint8Array(0) }; +} + +export const WebUiUser: MessageFns = { + encode(message: WebUiUser, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.username !== "") { + writer.uint32(10).string(message.username); + } + if (message.passwordHash.length !== 0) { + writer.uint32(18).bytes(message.passwordHash); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): WebUiUser { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseWebUiUser(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.username = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.passwordHash = reader.bytes(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create, I>>(base?: I): WebUiUser { + return WebUiUser.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): WebUiUser { + const message = createBaseWebUiUser(); + message.username = object.username ?? ""; + message.passwordHash = object.passwordHash ?? new Uint8Array(0); + return message; + }, +}; + +function createBaseWebUiUserList(): WebUiUserList { + return { users: [] }; +} + +export const WebUiUserList: MessageFns = { + encode(message: WebUiUserList, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + for (const v of message.users) { + WebUiUser.encode(v!, writer.uint32(10).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): WebUiUserList { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseWebUiUserList(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.users.push(WebUiUser.decode(reader, reader.uint32())); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create, I>>(base?: I): WebUiUserList { + return WebUiUserList.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): WebUiUserList { + const message = createBaseWebUiUserList(); + message.users = object.users?.map((e) => WebUiUser.fromPartial(e)) || []; + return message; + }, +}; + +function createBaseGetUsersRequest(): GetUsersRequest { + return {}; +} + +export const GetUsersRequest: MessageFns = { + encode(_: GetUsersRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): GetUsersRequest { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseGetUsersRequest(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create, I>>(base?: I): GetUsersRequest { + return GetUsersRequest.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(_: I): GetUsersRequest { + const message = createBaseGetUsersRequest(); + return message; + }, +}; + +function createBaseGetUsersResponse(): GetUsersResponse { + return { usernames: [] }; +} + +export const GetUsersResponse: MessageFns = { + encode(message: GetUsersResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + for (const v of message.usernames) { + writer.uint32(10).string(v!); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): GetUsersResponse { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseGetUsersResponse(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.usernames.push(reader.string()); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create, I>>(base?: I): GetUsersResponse { + return GetUsersResponse.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): GetUsersResponse { + const message = createBaseGetUsersResponse(); + message.usernames = object.usernames?.map((e) => e) || []; + return message; + }, +}; + +function createBaseAddUserRequest(): AddUserRequest { + return { username: "", password: "" }; +} + +export const AddUserRequest: MessageFns = { + encode(message: AddUserRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.username !== "") { + writer.uint32(10).string(message.username); + } + if (message.password !== "") { + writer.uint32(18).string(message.password); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): AddUserRequest { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseAddUserRequest(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.username = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.password = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create, I>>(base?: I): AddUserRequest { + return AddUserRequest.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): AddUserRequest { + const message = createBaseAddUserRequest(); + message.username = object.username ?? ""; + message.password = object.password ?? ""; + return message; + }, +}; + +function createBaseAddUserResponse(): AddUserResponse { + return { success: false, error: undefined }; +} + +export const AddUserResponse: MessageFns = { + encode(message: AddUserResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.success !== false) { + writer.uint32(8).bool(message.success); + } + if (message.error !== undefined) { + writer.uint32(18).string(message.error); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): AddUserResponse { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseAddUserResponse(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.success = reader.bool(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.error = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create, I>>(base?: I): AddUserResponse { + return AddUserResponse.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): AddUserResponse { + const message = createBaseAddUserResponse(); + message.success = object.success ?? false; + message.error = object.error ?? undefined; + return message; + }, +}; + +function createBaseDeleteUserRequest(): DeleteUserRequest { + return { username: "" }; +} + +export const DeleteUserRequest: MessageFns = { + encode(message: DeleteUserRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.username !== "") { + writer.uint32(10).string(message.username); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): DeleteUserRequest { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseDeleteUserRequest(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.username = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create, I>>(base?: I): DeleteUserRequest { + return DeleteUserRequest.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): DeleteUserRequest { + const message = createBaseDeleteUserRequest(); + message.username = object.username ?? ""; + return message; + }, +}; + +function createBaseDeleteUserResponse(): DeleteUserResponse { + return { success: false, error: undefined }; +} + +export const DeleteUserResponse: MessageFns = { + encode(message: DeleteUserResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.success !== false) { + writer.uint32(8).bool(message.success); + } + if (message.error !== undefined) { + writer.uint32(18).string(message.error); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): DeleteUserResponse { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseDeleteUserResponse(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.success = reader.bool(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.error = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create, I>>(base?: I): DeleteUserResponse { + return DeleteUserResponse.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): DeleteUserResponse { + const message = createBaseDeleteUserResponse(); + message.success = object.success ?? false; + message.error = object.error ?? undefined; + return message; + }, +}; + +function createBaseUpdateUserPasswordRequest(): UpdateUserPasswordRequest { + return { username: "", newPassword: "" }; +} + +export const UpdateUserPasswordRequest: MessageFns = { + encode(message: UpdateUserPasswordRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.username !== "") { + writer.uint32(10).string(message.username); + } + if (message.newPassword !== "") { + writer.uint32(18).string(message.newPassword); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): UpdateUserPasswordRequest { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseUpdateUserPasswordRequest(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.username = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.newPassword = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create, I>>(base?: I): UpdateUserPasswordRequest { + return UpdateUserPasswordRequest.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): UpdateUserPasswordRequest { + const message = createBaseUpdateUserPasswordRequest(); + message.username = object.username ?? ""; + message.newPassword = object.newPassword ?? ""; + return message; + }, +}; + +function createBaseUpdateUserPasswordResponse(): UpdateUserPasswordResponse { + return { success: false, error: undefined }; +} + +export const UpdateUserPasswordResponse: MessageFns = { + encode(message: UpdateUserPasswordResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.success !== false) { + writer.uint32(8).bool(message.success); + } + if (message.error !== undefined) { + writer.uint32(18).string(message.error); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): UpdateUserPasswordResponse { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseUpdateUserPasswordResponse(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.success = reader.bool(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.error = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create, I>>(base?: I): UpdateUserPasswordResponse { + return UpdateUserPasswordResponse.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): UpdateUserPasswordResponse { + const message = createBaseUpdateUserPasswordResponse(); + message.success = object.success ?? false; + message.error = object.error ?? undefined; + return message; + }, +}; + +function createBaseWebUiAuthCheckResponse(): WebUiAuthCheckResponse { + return { authenticated: false, username: undefined }; +} + +export const WebUiAuthCheckResponse: MessageFns = { + encode(message: WebUiAuthCheckResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.authenticated !== false) { + writer.uint32(8).bool(message.authenticated); + } + if (message.username !== undefined) { + writer.uint32(18).string(message.username); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): WebUiAuthCheckResponse { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseWebUiAuthCheckResponse(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.authenticated = reader.bool(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.username = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create, I>>(base?: I): WebUiAuthCheckResponse { + return WebUiAuthCheckResponse.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): WebUiAuthCheckResponse { + const message = createBaseWebUiAuthCheckResponse(); + message.authenticated = object.authenticated ?? false; + message.username = object.username ?? undefined; + return message; + }, +}; + +type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined; + +export type DeepPartial = T extends Builtin ? T + : T extends globalThis.Array ? globalThis.Array> + : T extends ReadonlyArray ? ReadonlyArray> + : T extends {} ? { [K in keyof T]?: DeepPartial } + : Partial; + +type KeysOfUnion = T extends T ? keyof T : never; +export type Exact = P extends Builtin ? P + : P & { [K in keyof P]: Exact } & { [K in Exclude>]: never }; + +export interface MessageFns { + encode(message: T, writer?: BinaryWriter): BinaryWriter; + decode(input: BinaryReader | Uint8Array, length?: number): T; + create, I>>(base?: I): T; + fromPartial, I>>(object: I): T; +} diff --git a/ui/src/lib/hooks/is-mobile.svelte.ts b/ui/src/lib/hooks/is-mobile.svelte.ts new file mode 100644 index 0000000..4829c00 --- /dev/null +++ b/ui/src/lib/hooks/is-mobile.svelte.ts @@ -0,0 +1,9 @@ +import { MediaQuery } from "svelte/reactivity"; + +const DEFAULT_MOBILE_BREAKPOINT = 768; + +export class IsMobile extends MediaQuery { + constructor(breakpoint: number = DEFAULT_MOBILE_BREAKPOINT) { + super(`max-width: ${breakpoint - 1}px`); + } +} diff --git a/ui/src/lib/index.ts b/ui/src/lib/index.ts new file mode 100644 index 0000000..856f2b6 --- /dev/null +++ b/ui/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/ui/src/lib/stores/auth.ts b/ui/src/lib/stores/auth.ts new file mode 100644 index 0000000..58b3640 --- /dev/null +++ b/ui/src/lib/stores/auth.ts @@ -0,0 +1,49 @@ +import { writable } from 'svelte/store'; +import { browser } from '$app/environment'; + +export interface AuthState { + authenticated: boolean; + username: string | null; + token: string | null; +} + +function createAuthStore() { + const initialState: AuthState = { + authenticated: false, + username: null, + token: localStorage.getItem('token') + }; + + const { subscribe, set, update } = writable(initialState); + + return { + subscribe, + set, + update, + login: (username: string, token: string) => { + const newState: AuthState = { + authenticated: true, + username, + token + }; + set(newState); + localStorage.setItem('token', token); + }, + logout: () => { + set(initialState); + if (browser) { + localStorage.removeItem('token'); + } + }, + setFromLocalStorage: () => { + if (browser) { + const token = localStorage.getItem('token'); + if (token) { + update((state) => ({ ...state, token })); + } + } + } + }; +} + +export const authStore = createAuthStore(); diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts new file mode 100644 index 0000000..55b3a91 --- /dev/null +++ b/ui/src/lib/utils.ts @@ -0,0 +1,13 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type WithoutChild = T extends { child?: any } ? Omit : T; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type WithoutChildren = T extends { children?: any } ? Omit : T; +export type WithoutChildrenOrChild = WithoutChildren>; +export type WithElementRef = T & { ref?: U | null }; diff --git a/ui/src/routes/(dashboard)/+layout.svelte b/ui/src/routes/(dashboard)/+layout.svelte new file mode 100644 index 0000000..cc89c6f --- /dev/null +++ b/ui/src/routes/(dashboard)/+layout.svelte @@ -0,0 +1,118 @@ + + + + + +

    RFID Master

    +
    + + + Navigation + + + + goto('/')}> + Dashboard + + + + goto('/users')}> + Users + + + + goto('/rfid')}> + RFID Database + + + + goto('/settings')} + > + Settings + + + + goto('/settings/hardware')} + > + Hardware Config + + + + + + + + {#if username} +

    Logged in as {username}

    + {/if} + {#if showLogout} + + {:else} +

    Auth disabled

    + {/if} +
    +
    +
    +
    +
    +
    + +

    + {#if currentPath === '/'} + Dashboard + {:else if currentPath === '/users'} + User Management + {:else if currentPath === '/rfid'} + RFID Database + {:else if currentPath === '/settings'} + Settings + {:else if currentPath === '/settings/hardware'} + Hardware Configuration + {:else} + Dashboard + {/if} +

    +
    +
    +
    +
    + {@render children()} +
    +
    +
    diff --git a/ui/src/routes/(dashboard)/+layout.ts b/ui/src/routes/(dashboard)/+layout.ts new file mode 100644 index 0000000..5981f22 --- /dev/null +++ b/ui/src/routes/(dashboard)/+layout.ts @@ -0,0 +1,27 @@ +import { redirect } from '@sveltejs/kit'; +import { webUIApi } from '$lib/api/webui'; +import { authStore } from '$lib/stores/auth'; + +export async function load() { + try { + const response = await webUIApi.authchk(); + if (response.authenticated) { + if (response && response.username) { + authStore.update((state) => ({ + ...state, + username: response.username || null, + authenticated: true + })); + } + + return { + username: response.username + }; + } else { + throw Error('Not authenticated'); + } + } catch { + authStore.logout(); + throw redirect(302, '/login'); + } +} diff --git a/ui/src/routes/(dashboard)/+page.svelte b/ui/src/routes/(dashboard)/+page.svelte new file mode 100644 index 0000000..3fe1255 --- /dev/null +++ b/ui/src/routes/(dashboard)/+page.svelte @@ -0,0 +1,282 @@ + + +
    + +
    +
    + +
    + + +
    +
    +
    +
    +
    +

    System Status

    +
    +
    + WiFi Station + + {deviceStatus?.info?.staConnectionState === 1 + ? 'Connected' + : deviceStatus?.info?.staConnectionState === 2 + ? 'Connecting' + : 'Disconnected'} + +
    +
    + WiFi AP + + {deviceStatus?.info?.apConnectionState === 1 ? 'Active' : 'Inactive'} + +
    +
    +
    + +
    +

    Sync Operations

    +
    + + +
    +
    +
    + +
    +

    Recent Activity

    +
    + {#if deviceStatus?.logs && deviceStatus.logs.length > 0} + {#each deviceStatus.logs.slice(-10) as log} + +
    +
    +
    +
    +
    +

    {log.message}

    +

    {new Date(log.timestamp).toLocaleString()}

    +
    +
    + {/each} + {:else} +

    No recent activity

    + {/if} +
    +
    + + +
    diff --git a/ui/src/routes/(dashboard)/rfid/+page.svelte b/ui/src/routes/(dashboard)/rfid/+page.svelte new file mode 100644 index 0000000..0fd2f97 --- /dev/null +++ b/ui/src/routes/(dashboard)/rfid/+page.svelte @@ -0,0 +1,237 @@ + + +
    +
    +

    RFID Database

    +

    View and manage RFID card IDs in the database

    +
    + +
    + + +
    +
    + RFID IDs ({rfidHexValues.length}) + Hexadecimal representation of stored RFID card IDs +
    +
    + + +
    + + +
    +
    +
    +
    + + +
    +
    + + { + if (e.key === 'Enter') { + addRfidId(); + } + }} + /> +
    +
    + +
    +
    + + +
    + {#if rfidHexValues.length === 0} +

    No RFID IDs in database

    + {:else} +
    + {#each rfidHexValues as hexValue (hexValue)} +
    +
    + {formatHexDisplay(hexValue)} + ({hexValue}) +
    + +
    + {/each} +
    + {/if} +
    +
    +
    +
    +
    diff --git a/ui/src/routes/(dashboard)/settings/+page.svelte b/ui/src/routes/(dashboard)/settings/+page.svelte new file mode 100644 index 0000000..c3e0c16 --- /dev/null +++ b/ui/src/routes/(dashboard)/settings/+page.svelte @@ -0,0 +1,140 @@ + + +
    +
    +

    Settings

    +

    Configure system settings and synchronization options

    +
    + +
    + + + + + Logging + Configure the logging level for system messages + + +
    + + +

    + Choose the minimum log level to display. Lower levels include higher ones. +

    +
    +
    +
    +
    +
    + For hardware configuration (pins, timing, LEDs), go to{' '} + +
    + +
    +
    +
    diff --git a/ui/src/routes/(dashboard)/settings/hardware/+page.svelte b/ui/src/routes/(dashboard)/settings/hardware/+page.svelte new file mode 100644 index 0000000..a80a701 --- /dev/null +++ b/ui/src/routes/(dashboard)/settings/hardware/+page.svelte @@ -0,0 +1,347 @@ + + +
    +
    +

    Hardware Configuration

    +

    Configure hardware pins, timing, and LED settings

    +
    + + {#if loading} +
    +
    +
    + {:else} +
    + +
    + +
    + + + Sensor OTA Update + + Enable Over-The-Air updates on the connected sensor. Configure the WiFi settings that + the sensor will use. + + + +
    +
    + + +
    +
    + + +
    +
    + +
    +
    + + +
    +
    + + +
    +
    + +
    + + +
    + + {#if otaConfig.useStaticIp} +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + {/if} + +
    + + +
    +
    +
    + + + + ESP32 OTA Update + + Enable Over-The-Air firmware updates for the ESP32 controller itself. Use Arduino IDE or + PlatformIO to upload firmware when enabled. + + + + {#if espOTAStatus} +
    +

    + Status: + {espOTAStatus.status} + +

    +
    + {/if} + +
    + + { + const target = e.target as HTMLInputElement; + selectedFirmwareFile = target.files?.[0] || null; + }} + class="block w-full text-sm text-gray-500 file:mr-4 file:rounded-full file:border-0 file:bg-blue-50 file:px-4 file:py-2 file:text-sm file:font-semibold file:text-blue-700 hover:file:bg-blue-100" + /> +
    + +
    + + + +
    +
    +
    + + {#if error} +
    +

    {error}

    +
    + {/if} +
    + {/if} +
    diff --git a/ui/src/routes/(dashboard)/users/+page.svelte b/ui/src/routes/(dashboard)/users/+page.svelte new file mode 100644 index 0000000..080c7de --- /dev/null +++ b/ui/src/routes/(dashboard)/users/+page.svelte @@ -0,0 +1,276 @@ + + +
    +
    +
    +

    WebUI User Management

    +

    Manage webui users and their passwords

    +
    +
    + + + + + + + + + Add New User + Create a new webui user account. + +
    +
    + + +
    +
    + + +
    +
    + + Cancel + Add User + +
    +
    +
    + + + + + Change Password + Enter a new password for the user. + +
    +
    + + +
    +
    + + Cancel + Change Password + +
    +
    + + + + + Delete User + + {#if users.length === 1} + This is the last user. Deleting it will disable web UI authentication. Are you sure? + {:else} + Are you sure you want to delete this user? + {/if} + + + + Cancel + Delete + + + +
    + {#if users.length === 0 && !loading} + + + Warning: No Users Configured + + Authentication is disabled. Add at least one user to enable web UI authentication. + + + {:else if loading} +

    Loading users...

    + {:else} + + + Users ({users.length}) + Current registered webui users + + +
    + + + + + + + + + {#each users as user} + + + + + {/each} + +
    UsernameActions
    {user} +
    + + +
    +
    +
    +
    +
    + {/if} +
    diff --git a/ui/src/routes/+error.svelte b/ui/src/routes/+error.svelte new file mode 100644 index 0000000..2d2ae3d --- /dev/null +++ b/ui/src/routes/+error.svelte @@ -0,0 +1,44 @@ + + +
    + + +
    + {status || '404'} +
    + + {#if status === 404} + Page Not Found + {:else} + Something went wrong + {/if} + + + {#if status === 404} + The page you're looking for doesn't exist. + {:else} + {error?.message || 'An unexpected error occurred.'} + {/if} + +
    + + + + +
    +
    diff --git a/ui/src/routes/+layout.svelte b/ui/src/routes/+layout.svelte new file mode 100644 index 0000000..4e660d6 --- /dev/null +++ b/ui/src/routes/+layout.svelte @@ -0,0 +1,13 @@ + + + + + + + +{@render children?.()} diff --git a/ui/src/routes/+layout.ts b/ui/src/routes/+layout.ts new file mode 100644 index 0000000..e4c61e4 --- /dev/null +++ b/ui/src/routes/+layout.ts @@ -0,0 +1,9 @@ +import { authStore } from '$lib/stores/auth'; + +export const ssr = false; +export const prerender = true; + +export const load = async () => { + authStore.setFromLocalStorage(); + return {}; +}; diff --git a/ui/src/routes/login/+layout.ts b/ui/src/routes/login/+layout.ts new file mode 100644 index 0000000..8c0f113 --- /dev/null +++ b/ui/src/routes/login/+layout.ts @@ -0,0 +1,11 @@ +import { redirect } from '@sveltejs/kit'; +import { webUIApi } from '$lib/api/webui'; + +export async function load() { + const response = await webUIApi.authchk(); + if (response.authenticated) { + throw redirect(302, '/'); + } else { + localStorage.removeItem('token'); + } +} diff --git a/ui/src/routes/login/+page.svelte b/ui/src/routes/login/+page.svelte new file mode 100644 index 0000000..6aefd1c --- /dev/null +++ b/ui/src/routes/login/+page.svelte @@ -0,0 +1,88 @@ + + +
    + + + Login + Enter your credentials to access the system + +
    + +
    + + +
    +
    + + +
    + {#if error} +

    {error}

    + {/if} + +
    +
    +
    +
    diff --git a/ui/static/robots.txt b/ui/static/robots.txt new file mode 100644 index 0000000..b6dd667 --- /dev/null +++ b/ui/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/ui/svelte.config.js b/ui/svelte.config.js new file mode 100644 index 0000000..13c04c9 --- /dev/null +++ b/ui/svelte.config.js @@ -0,0 +1,24 @@ +import adapter from '@sveltejs/adapter-static'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter(), + csp: { + //mode: 'hash', + + directives: { + 'script-src': ['self','unsafe-inline','*'], + 'style-src': ['self','unsafe-inline','*'], + } + }, + output: { + bundleStrategy: 'inline', + } + }, + +}; + +export default config; diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 0000000..a5567ee --- /dev/null +++ b/ui/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // To make changes to top-level options such as include and exclude, we recommend extending + // the generated config; see https://svelte.dev/docs/kit/configuration#typescript +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts new file mode 100644 index 0000000..510c896 --- /dev/null +++ b/ui/vite.config.ts @@ -0,0 +1,11 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import tailwindcss from '@tailwindcss/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + server: { + port: 3001 + }, + + plugins: [tailwindcss(), sveltekit()] +});